mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(core): Send email notification when a user invited to a project (#16687)
This commit is contained in:
committed by
GitHub
parent
719a17427e
commit
7e376e087e
@@ -64,6 +64,10 @@ export class TemplateConfig {
|
|||||||
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
|
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
|
||||||
@Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED')
|
@Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED')
|
||||||
'credentials-shared': string = '';
|
'credentials-shared': string = '';
|
||||||
|
|
||||||
|
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
|
||||||
|
@Env('N8N_UM_EMAIL_TEMPLATES_PROJECT_SHARED')
|
||||||
|
'project-shared': string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailModeSchema = z.enum(['', 'smtp']);
|
const emailModeSchema = z.enum(['', 'smtp']);
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ describe('GlobalConfig', () => {
|
|||||||
'user-invited': '',
|
'user-invited': '',
|
||||||
'password-reset-requested': '',
|
'password-reset-requested': '',
|
||||||
'workflow-shared': '',
|
'workflow-shared': '',
|
||||||
|
'project-shared': '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class UserRepository extends Repository<User> {
|
|||||||
*/
|
*/
|
||||||
async getEmailsByIds(userIds: string[]) {
|
async getEmailsByIds(userIds: string[]) {
|
||||||
return await this.find({
|
return await this.find({
|
||||||
select: ['email'],
|
select: ['id', 'email'],
|
||||||
where: { id: In(userIds), password: Not(IsNull()) },
|
where: { id: In(userIds), password: Not(IsNull()) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
TeamProjectOverQuotaError,
|
TeamProjectOverQuotaError,
|
||||||
UnlicensedProjectRoleError,
|
UnlicensedProjectRoleError,
|
||||||
} from '@/services/project.service.ee';
|
} from '@/services/project.service.ee';
|
||||||
|
import { UserManagementMailer } from '@/user-management/email';
|
||||||
|
|
||||||
@RestController('/projects')
|
@RestController('/projects')
|
||||||
export class ProjectController {
|
export class ProjectController {
|
||||||
@@ -36,6 +37,7 @@ export class ProjectController {
|
|||||||
private readonly projectsService: ProjectService,
|
private readonly projectsService: ProjectService,
|
||||||
private readonly projectRepository: ProjectRepository,
|
private readonly projectRepository: ProjectRepository,
|
||||||
private readonly eventService: EventService,
|
private readonly eventService: EventService,
|
||||||
|
private readonly userManagementMailer: UserManagementMailer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('/')
|
@Get('/')
|
||||||
@@ -208,7 +210,17 @@ export class ProjectController {
|
|||||||
}
|
}
|
||||||
if (relations) {
|
if (relations) {
|
||||||
try {
|
try {
|
||||||
await this.projectsService.syncProjectRelations(projectId, relations);
|
const { project, newRelations } = await this.projectsService.syncProjectRelations(
|
||||||
|
projectId,
|
||||||
|
relations,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send email notifications to new sharees
|
||||||
|
await this.userManagementMailer.notifyProjectShared({
|
||||||
|
sharer: req.user,
|
||||||
|
newSharees: newRelations,
|
||||||
|
project: { id: project.id, name: project.name },
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof UnlicensedProjectRoleError) {
|
if (e instanceof UnlicensedProjectRoleError) {
|
||||||
throw new BadRequestError(e.message);
|
throw new BadRequestError(e.message);
|
||||||
|
|||||||
@@ -238,7 +238,8 @@ export type RelayEventMap = {
|
|||||||
| 'New user invite'
|
| 'New user invite'
|
||||||
| 'Resend invite'
|
| 'Resend invite'
|
||||||
| 'Workflow shared'
|
| 'Workflow shared'
|
||||||
| 'Credentials shared';
|
| 'Credentials shared'
|
||||||
|
| 'Project shared';
|
||||||
publicApi: boolean;
|
publicApi: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,7 +275,8 @@ export type RelayEventMap = {
|
|||||||
| 'New user invite'
|
| 'New user invite'
|
||||||
| 'Resend invite'
|
| 'Resend invite'
|
||||||
| 'Workflow shared'
|
| 'Workflow shared'
|
||||||
| 'Credentials shared';
|
| 'Credentials shared'
|
||||||
|
| 'Project shared';
|
||||||
publicApi: boolean;
|
publicApi: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export class ProjectService {
|
|||||||
async syncProjectRelations(
|
async syncProjectRelations(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
relations: Required<UpdateProjectDto>['relations'],
|
relations: Required<UpdateProjectDto>['relations'],
|
||||||
) {
|
): Promise<{ project: Project; newRelations: Required<UpdateProjectDto>['relations'] }> {
|
||||||
const project = await this.getTeamProjectWithRelations(projectId);
|
const project = await this.getTeamProjectWithRelations(projectId);
|
||||||
this.checkRolesLicensed(project, relations);
|
this.checkRolesLicensed(project, relations);
|
||||||
|
|
||||||
@@ -277,7 +277,13 @@ export class ProjectService {
|
|||||||
await this.pruneRelations(em, project);
|
await this.pruneRelations(em, project);
|
||||||
await this.addManyRelations(em, project, relations);
|
await this.addManyRelations(em, project, relations);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const newRelations = relations.filter(
|
||||||
|
(relation) => !project.projectRelations.some((r) => r.userId === relation.userId),
|
||||||
|
);
|
||||||
await this.clearCredentialCanUseExternalSecretsCache(projectId);
|
await this.clearCredentialCanUseExternalSecretsCache(projectId);
|
||||||
|
|
||||||
|
return { project, newRelations };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance } from '@n8n/backend-test-utils';
|
||||||
import type { GlobalConfig } from '@n8n/config';
|
import type { GlobalConfig } from '@n8n/config';
|
||||||
|
import type { ProjectRole, User, UserRepository } from '@n8n/db';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { IWorkflowBase } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { UrlService } from '@/services/url.service';
|
import type { UrlService } from '@/services/url.service';
|
||||||
import type { InviteEmailData, PasswordResetData } from '@/user-management/email/interfaces';
|
import type { InviteEmailData, PasswordResetData } from '@/user-management/email/interfaces';
|
||||||
@@ -58,10 +60,11 @@ describe('UserManagementMailer', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const urlService = mock<UrlService>();
|
const urlService = mock<UrlService>();
|
||||||
|
const userRepository = mock<UserRepository>();
|
||||||
const userManagementMailer = new UserManagementMailer(
|
const userManagementMailer = new UserManagementMailer(
|
||||||
config,
|
config,
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
userRepository,
|
||||||
urlService,
|
urlService,
|
||||||
mock(),
|
mock(),
|
||||||
);
|
);
|
||||||
@@ -94,5 +97,100 @@ describe('UserManagementMailer', () => {
|
|||||||
subject: 'n8n password reset',
|
subject: 'n8n password reset',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should send workflow share notifications', async () => {
|
||||||
|
const sharer = mock<User>({ firstName: 'Sharer', email: 'sharer@user.com' });
|
||||||
|
const newShareeIds = ['recipient1', 'recipient2'];
|
||||||
|
const workflow = mock<IWorkflowBase>({ id: 'workflow1', name: 'Test Workflow' });
|
||||||
|
userRepository.getEmailsByIds.mockResolvedValue([
|
||||||
|
{ id: 'recipient1', email: 'recipient1@user.com' },
|
||||||
|
{ id: 'recipient2', email: 'recipient2@user.com' },
|
||||||
|
] as User[]);
|
||||||
|
const result = await userManagementMailer.notifyWorkflowShared({
|
||||||
|
sharer,
|
||||||
|
newShareeIds,
|
||||||
|
workflow,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.emailSent).toBe(true);
|
||||||
|
expect(nodeMailer.sendMail).toHaveBeenCalledTimes(2);
|
||||||
|
newShareeIds.forEach((id, index) => {
|
||||||
|
expect(nodeMailer.sendMail).toHaveBeenNthCalledWith(index + 1, {
|
||||||
|
body: expect.stringContaining(`href="https://n8n.url/workflow/${workflow.id}"`),
|
||||||
|
emailRecipients: `${id}@user.com`,
|
||||||
|
subject: 'Sharer has shared an n8n workflow with you',
|
||||||
|
});
|
||||||
|
|
||||||
|
const callBody = nodeMailer.sendMail.mock.calls[index][0].body;
|
||||||
|
expect(callBody).toContain('Test Workflow');
|
||||||
|
expect(callBody).toContain('A workflow has been shared with you');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send credentials share notifications', async () => {
|
||||||
|
const sharer = mock<User>({ firstName: 'Sharer', email: 'sharer@user.com' });
|
||||||
|
const newShareeIds = ['recipient1', 'recipient2'];
|
||||||
|
userRepository.getEmailsByIds.mockResolvedValue([
|
||||||
|
{ id: 'recipient1', email: 'recipient1@user.com' },
|
||||||
|
{ id: 'recipient2', email: 'recipient2@user.com' },
|
||||||
|
] as User[]);
|
||||||
|
const result = await userManagementMailer.notifyCredentialsShared({
|
||||||
|
sharer,
|
||||||
|
newShareeIds,
|
||||||
|
credentialsName: 'Test Credentials',
|
||||||
|
});
|
||||||
|
expect(result.emailSent).toBe(true);
|
||||||
|
expect(nodeMailer.sendMail).toHaveBeenCalledTimes(2);
|
||||||
|
newShareeIds.forEach((id, index) => {
|
||||||
|
expect(nodeMailer.sendMail).toHaveBeenNthCalledWith(index + 1, {
|
||||||
|
body: expect.stringContaining('href="https://n8n.url/home/credentials"'),
|
||||||
|
emailRecipients: `${id}@user.com`,
|
||||||
|
subject: 'Sharer has shared an n8n credential with you',
|
||||||
|
});
|
||||||
|
|
||||||
|
const callBody = nodeMailer.sendMail.mock.calls[index][0].body;
|
||||||
|
expect(callBody).toContain('Test Credentials');
|
||||||
|
expect(callBody).toContain('A credential has been shared with you');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send project share notifications', async () => {
|
||||||
|
const sharer = mock<User>({ firstName: 'Sharer', email: 'sharer@user.com' });
|
||||||
|
const newSharees = [
|
||||||
|
{ userId: 'recipient1', role: 'project:editor' as ProjectRole },
|
||||||
|
{ userId: 'recipient2', role: 'project:viewer' as ProjectRole },
|
||||||
|
];
|
||||||
|
const project = { id: 'project1', name: 'Test Project' };
|
||||||
|
userRepository.getEmailsByIds.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'recipient1',
|
||||||
|
email: 'recipient1@user.com',
|
||||||
|
} as User,
|
||||||
|
{
|
||||||
|
id: 'recipient2',
|
||||||
|
email: 'recipient2@user.com',
|
||||||
|
} as User,
|
||||||
|
]);
|
||||||
|
const result = await userManagementMailer.notifyProjectShared({
|
||||||
|
sharer,
|
||||||
|
newSharees,
|
||||||
|
project,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.emailSent).toBe(true);
|
||||||
|
expect(nodeMailer.sendMail).toHaveBeenCalledTimes(2);
|
||||||
|
newSharees.forEach((sharee, index) => {
|
||||||
|
expect(nodeMailer.sendMail).toHaveBeenCalledWith({
|
||||||
|
body: expect.stringContaining(`href="https://n8n.url/projects/${project.id}"`),
|
||||||
|
emailRecipients: `recipient${index + 1}@user.com`,
|
||||||
|
subject: 'Sharer has invited you to a project',
|
||||||
|
});
|
||||||
|
|
||||||
|
const callBody = nodeMailer.sendMail.mock.calls[index][0].body;
|
||||||
|
expect(callBody).toContain(
|
||||||
|
`You have been added as a ${sharee.role.replace('project:', '')} to the ${project.name} project`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type PasswordResetData = {
|
|||||||
|
|
||||||
export type SendEmailResult = {
|
export type SendEmailResult = {
|
||||||
emailSent: boolean;
|
emailSent: boolean;
|
||||||
|
errors?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailData = {
|
export type MailData = {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<mjml>
|
||||||
|
<mj-include path="./_common.mjml" />
|
||||||
|
<mj-body>
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-text font-size="24px" color="#ff6f5c"
|
||||||
|
>You have been added as a {{ role }} to the {{ projectName }} project</mj-text
|
||||||
|
>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
<mj-section background-color="#FFFFFF" border="1px solid #ddd">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text
|
||||||
|
>This gives you access to all the workflows and credentials in that project</mj-text
|
||||||
|
>
|
||||||
|
<mj-button href="{{projectUrl}}">View project</mj-button>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
<mj-include path="./_logo.mjml" />
|
||||||
|
</mj-body>
|
||||||
|
</mjml>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { inTest, Logger } from '@n8n/backend-common';
|
import { inTest, Logger } from '@n8n/backend-common';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import type { User } from '@n8n/db';
|
import type { ProjectRole, User } from '@n8n/db';
|
||||||
import { UserRepository } from '@n8n/db';
|
import { UserRepository } from '@n8n/db';
|
||||||
import { Container, Service } from '@n8n/di';
|
import { Container, Service } from '@n8n/di';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
@@ -11,6 +11,7 @@ import { join as pathJoin } from 'path';
|
|||||||
|
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
|
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
||||||
import { UrlService } from '@/services/url.service';
|
import { UrlService } from '@/services/url.service';
|
||||||
import { toError } from '@/utils';
|
import { toError } from '@/utils';
|
||||||
|
|
||||||
@@ -22,7 +23,8 @@ type TemplateName =
|
|||||||
| 'user-invited'
|
| 'user-invited'
|
||||||
| 'password-reset-requested'
|
| 'password-reset-requested'
|
||||||
| 'workflow-shared'
|
| 'workflow-shared'
|
||||||
| 'credentials-shared';
|
| 'credentials-shared'
|
||||||
|
| 'project-shared';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class UserManagementMailer {
|
export class UserManagementMailer {
|
||||||
@@ -73,6 +75,68 @@ export class UserManagementMailer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sendNotificationEmails<T extends { email: string }>({
|
||||||
|
mailerTemplate,
|
||||||
|
recipients,
|
||||||
|
sharer,
|
||||||
|
getTemplateData,
|
||||||
|
subjectBuilder,
|
||||||
|
messageType,
|
||||||
|
}: {
|
||||||
|
mailerTemplate: TemplateName;
|
||||||
|
recipients: T[];
|
||||||
|
sharer: User;
|
||||||
|
getTemplateData: (recipient: T) => Record<string, any>;
|
||||||
|
subjectBuilder: () => string;
|
||||||
|
messageType: RelayEventMap['user-transactional-email-sent']['messageType'];
|
||||||
|
}): Promise<SendEmailResult> {
|
||||||
|
if (!this.mailer) return { emailSent: false };
|
||||||
|
if (recipients.length === 0) return { emailSent: false };
|
||||||
|
|
||||||
|
const populateTemplate = await this.getTemplate(mailerTemplate);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises = recipients.map(async (recipient) => {
|
||||||
|
const templateData = getTemplateData(recipient);
|
||||||
|
return await this.mailer!.sendMail({
|
||||||
|
emailRecipients: recipient.email,
|
||||||
|
subject: subjectBuilder(),
|
||||||
|
body: populateTemplate(templateData),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises);
|
||||||
|
const errors = results.filter((result) => result.status === 'rejected');
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`Sent ${messageType} email ${errors.length ? 'with errors' : 'successfully'}`,
|
||||||
|
{
|
||||||
|
sharerId: sharer.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.eventService.emit('user-transactional-email-sent', {
|
||||||
|
userId: sharer.id,
|
||||||
|
messageType,
|
||||||
|
publicApi: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
emailSent: true,
|
||||||
|
errors: errors.map((e) => e.reason as string),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.eventService.emit('email-failed', {
|
||||||
|
user: sharer,
|
||||||
|
messageType,
|
||||||
|
publicApi: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = toError(e);
|
||||||
|
throw new InternalServerError(`Please contact your administrator: ${error.message}`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async notifyWorkflowShared({
|
async notifyWorkflowShared({
|
||||||
sharer,
|
sharer,
|
||||||
newShareeIds,
|
newShareeIds,
|
||||||
@@ -82,50 +146,20 @@ export class UserManagementMailer {
|
|||||||
newShareeIds: string[];
|
newShareeIds: string[];
|
||||||
workflow: IWorkflowBase;
|
workflow: IWorkflowBase;
|
||||||
}): Promise<SendEmailResult> {
|
}): Promise<SendEmailResult> {
|
||||||
if (!this.mailer) return { emailSent: false };
|
|
||||||
|
|
||||||
const recipients = await this.userRepository.getEmailsByIds(newShareeIds);
|
const recipients = await this.userRepository.getEmailsByIds(newShareeIds);
|
||||||
|
|
||||||
if (recipients.length === 0) return { emailSent: false };
|
|
||||||
|
|
||||||
const emailRecipients = recipients.map(({ email }) => email);
|
|
||||||
|
|
||||||
const populateTemplate = await this.getTemplate('workflow-shared');
|
|
||||||
|
|
||||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||||
|
|
||||||
try {
|
return await this.sendNotificationEmails({
|
||||||
const result = await this.mailer.sendMail({
|
mailerTemplate: 'workflow-shared',
|
||||||
emailRecipients,
|
recipients,
|
||||||
subject: `${sharer.firstName} has shared an n8n workflow with you`,
|
sharer,
|
||||||
body: populateTemplate({
|
getTemplateData: () => ({
|
||||||
workflowName: workflow.name,
|
workflowName: workflow.name,
|
||||||
workflowUrl: `${baseUrl}/workflow/${workflow.id}`,
|
workflowUrl: `${baseUrl}/workflow/${workflow.id}`,
|
||||||
}),
|
}),
|
||||||
});
|
subjectBuilder: () => `${sharer.firstName} has shared an n8n workflow with you`,
|
||||||
|
messageType: 'Workflow shared',
|
||||||
if (!result) return { emailSent: false };
|
});
|
||||||
|
|
||||||
this.logger.info('Sent workflow shared email successfully', { sharerId: sharer.id });
|
|
||||||
|
|
||||||
this.eventService.emit('user-transactional-email-sent', {
|
|
||||||
userId: sharer.id,
|
|
||||||
messageType: 'Workflow shared',
|
|
||||||
publicApi: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
this.eventService.emit('email-failed', {
|
|
||||||
user: sharer,
|
|
||||||
messageType: 'Workflow shared',
|
|
||||||
publicApi: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const error = toError(e);
|
|
||||||
|
|
||||||
throw new InternalServerError(`Please contact your administrator: ${error.message}`, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async notifyCredentialsShared({
|
async notifyCredentialsShared({
|
||||||
@@ -137,50 +171,58 @@ export class UserManagementMailer {
|
|||||||
newShareeIds: string[];
|
newShareeIds: string[];
|
||||||
credentialsName: string;
|
credentialsName: string;
|
||||||
}): Promise<SendEmailResult> {
|
}): Promise<SendEmailResult> {
|
||||||
if (!this.mailer) return { emailSent: false };
|
|
||||||
|
|
||||||
const recipients = await this.userRepository.getEmailsByIds(newShareeIds);
|
const recipients = await this.userRepository.getEmailsByIds(newShareeIds);
|
||||||
|
|
||||||
if (recipients.length === 0) return { emailSent: false };
|
|
||||||
|
|
||||||
const emailRecipients = recipients.map(({ email }) => email);
|
|
||||||
|
|
||||||
const populateTemplate = await this.getTemplate('credentials-shared');
|
|
||||||
|
|
||||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||||
|
|
||||||
try {
|
return await this.sendNotificationEmails({
|
||||||
const result = await this.mailer.sendMail({
|
mailerTemplate: 'credentials-shared',
|
||||||
emailRecipients,
|
recipients,
|
||||||
subject: `${sharer.firstName} has shared an n8n credential with you`,
|
sharer,
|
||||||
body: populateTemplate({
|
getTemplateData: () => ({
|
||||||
credentialsName,
|
credentialsName,
|
||||||
credentialsListUrl: `${baseUrl}/home/credentials`,
|
credentialsListUrl: `${baseUrl}/home/credentials`,
|
||||||
}),
|
}),
|
||||||
});
|
subjectBuilder: () => `${sharer.firstName} has shared an n8n credential with you`,
|
||||||
|
messageType: 'Credentials shared',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!result) return { emailSent: false };
|
async notifyProjectShared({
|
||||||
|
sharer,
|
||||||
|
newSharees,
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
sharer: User;
|
||||||
|
newSharees: Array<{ userId: string; role: ProjectRole }>;
|
||||||
|
project: { id: string; name: string };
|
||||||
|
}): Promise<SendEmailResult> {
|
||||||
|
const recipients = await this.userRepository.getEmailsByIds(newSharees.map((s) => s.userId));
|
||||||
|
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||||
|
|
||||||
this.logger.info('Sent credentials shared email successfully', { sharerId: sharer.id });
|
// Merge recipient data with role
|
||||||
|
const recipientsData = newSharees
|
||||||
|
.map((sharee) => {
|
||||||
|
const recipient = recipients.find((r) => r.id === sharee.userId);
|
||||||
|
if (!recipient) return null;
|
||||||
|
return {
|
||||||
|
email: recipient.email,
|
||||||
|
role: sharee.role.split('project:')?.[1] ?? sharee.role,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ email: string; role: string }>;
|
||||||
|
|
||||||
this.eventService.emit('user-transactional-email-sent', {
|
return await this.sendNotificationEmails({
|
||||||
userId: sharer.id,
|
mailerTemplate: 'project-shared',
|
||||||
messageType: 'Credentials shared',
|
recipients: recipientsData,
|
||||||
publicApi: false,
|
sharer,
|
||||||
});
|
getTemplateData: (recipient) => ({
|
||||||
|
role: recipient.role,
|
||||||
return result;
|
projectName: project.name,
|
||||||
} catch (e) {
|
projectUrl: `${baseUrl}/projects/${project.id}`,
|
||||||
this.eventService.emit('email-failed', {
|
}),
|
||||||
user: sharer,
|
subjectBuilder: () => `${sharer.firstName} has invited you to a project`,
|
||||||
messageType: 'Credentials shared',
|
messageType: 'Project shared',
|
||||||
publicApi: false,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const error = toError(e);
|
|
||||||
|
|
||||||
throw new InternalServerError(`Please contact your administrator: ${error.message}`, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplate(templateName: TemplateName): Promise<Template> {
|
async getTemplate(templateName: TemplateName): Promise<Template> {
|
||||||
|
|||||||
Reference in New Issue
Block a user