feat(core): Send email notification when a user invited to a project (#16687)

This commit is contained in:
Guillaume Jacquart
2025-06-26 11:43:59 +02:00
committed by GitHub
parent 719a17427e
commit 7e376e087e
10 changed files with 274 additions and 87 deletions

View File

@@ -64,6 +64,10 @@ export class TemplateConfig {
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED')
'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']);

View File

@@ -121,6 +121,7 @@ describe('GlobalConfig', () => {
'user-invited': '',
'password-reset-requested': '',
'workflow-shared': '',
'project-shared': '',
},
},
},

View File

@@ -80,7 +80,7 @@ export class UserRepository extends Repository<User> {
*/
async getEmailsByIds(userIds: string[]) {
return await this.find({
select: ['email'],
select: ['id', 'email'],
where: { id: In(userIds), password: Not(IsNull()) },
});
}

View File

@@ -29,6 +29,7 @@ import {
TeamProjectOverQuotaError,
UnlicensedProjectRoleError,
} from '@/services/project.service.ee';
import { UserManagementMailer } from '@/user-management/email';
@RestController('/projects')
export class ProjectController {
@@ -36,6 +37,7 @@ export class ProjectController {
private readonly projectsService: ProjectService,
private readonly projectRepository: ProjectRepository,
private readonly eventService: EventService,
private readonly userManagementMailer: UserManagementMailer,
) {}
@Get('/')
@@ -208,7 +210,17 @@ export class ProjectController {
}
if (relations) {
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) {
if (e instanceof UnlicensedProjectRoleError) {
throw new BadRequestError(e.message);

View File

@@ -238,7 +238,8 @@ export type RelayEventMap = {
| 'New user invite'
| 'Resend invite'
| 'Workflow shared'
| 'Credentials shared';
| 'Credentials shared'
| 'Project shared';
publicApi: boolean;
};
@@ -274,7 +275,8 @@ export type RelayEventMap = {
| 'New user invite'
| 'Resend invite'
| 'Workflow shared'
| 'Credentials shared';
| 'Credentials shared'
| 'Project shared';
publicApi: boolean;
};

View File

@@ -269,7 +269,7 @@ export class ProjectService {
async syncProjectRelations(
projectId: string,
relations: Required<UpdateProjectDto>['relations'],
) {
): Promise<{ project: Project; newRelations: Required<UpdateProjectDto>['relations'] }> {
const project = await this.getTeamProjectWithRelations(projectId);
this.checkRolesLicensed(project, relations);
@@ -277,7 +277,13 @@ export class ProjectService {
await this.pruneRelations(em, project);
await this.addManyRelations(em, project, relations);
});
const newRelations = relations.filter(
(relation) => !project.projectRelations.some((r) => r.userId === relation.userId),
);
await this.clearCredentialCanUseExternalSecretsCache(projectId);
return { project, newRelations };
}
/**

View File

@@ -1,6 +1,8 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import type { ProjectRole, User, UserRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { IWorkflowBase } from 'n8n-workflow';
import type { UrlService } from '@/services/url.service';
import type { InviteEmailData, PasswordResetData } from '@/user-management/email/interfaces';
@@ -58,10 +60,11 @@ describe('UserManagementMailer', () => {
},
});
const urlService = mock<UrlService>();
const userRepository = mock<UserRepository>();
const userManagementMailer = new UserManagementMailer(
config,
mock(),
mock(),
userRepository,
urlService,
mock(),
);
@@ -94,5 +97,100 @@ describe('UserManagementMailer', () => {
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`,
);
});
});
});
});

View File

@@ -11,6 +11,7 @@ export type PasswordResetData = {
export type SendEmailResult = {
emailSent: boolean;
errors?: string[];
};
export type MailData = {

View File

@@ -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>

View File

@@ -1,6 +1,6 @@
import { inTest, Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import type { ProjectRole, User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { existsSync } from 'fs';
@@ -11,6 +11,7 @@ import { join as pathJoin } from 'path';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { UrlService } from '@/services/url.service';
import { toError } from '@/utils';
@@ -22,7 +23,8 @@ type TemplateName =
| 'user-invited'
| 'password-reset-requested'
| 'workflow-shared'
| 'credentials-shared';
| 'credentials-shared'
| 'project-shared';
@Service()
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({
sharer,
newShareeIds,
@@ -82,50 +146,20 @@ export class UserManagementMailer {
newShareeIds: string[];
workflow: IWorkflowBase;
}): Promise<SendEmailResult> {
if (!this.mailer) return { emailSent: false };
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();
try {
const result = await this.mailer.sendMail({
emailRecipients,
subject: `${sharer.firstName} has shared an n8n workflow with you`,
body: populateTemplate({
workflowName: workflow.name,
workflowUrl: `${baseUrl}/workflow/${workflow.id}`,
}),
});
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);
}
return await this.sendNotificationEmails({
mailerTemplate: 'workflow-shared',
recipients,
sharer,
getTemplateData: () => ({
workflowName: workflow.name,
workflowUrl: `${baseUrl}/workflow/${workflow.id}`,
}),
subjectBuilder: () => `${sharer.firstName} has shared an n8n workflow with you`,
messageType: 'Workflow shared',
});
}
async notifyCredentialsShared({
@@ -137,50 +171,58 @@ export class UserManagementMailer {
newShareeIds: string[];
credentialsName: string;
}): Promise<SendEmailResult> {
if (!this.mailer) return { emailSent: false };
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();
try {
const result = await this.mailer.sendMail({
emailRecipients,
subject: `${sharer.firstName} has shared an n8n credential with you`,
body: populateTemplate({
credentialsName,
credentialsListUrl: `${baseUrl}/home/credentials`,
}),
});
return await this.sendNotificationEmails({
mailerTemplate: 'credentials-shared',
recipients,
sharer,
getTemplateData: () => ({
credentialsName,
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', {
userId: sharer.id,
messageType: 'Credentials shared',
publicApi: false,
});
return result;
} catch (e) {
this.eventService.emit('email-failed', {
user: sharer,
messageType: 'Credentials shared',
publicApi: false,
});
const error = toError(e);
throw new InternalServerError(`Please contact your administrator: ${error.message}`, e);
}
return await this.sendNotificationEmails({
mailerTemplate: 'project-shared',
recipients: recipientsData,
sharer,
getTemplateData: (recipient) => ({
role: recipient.role,
projectName: project.name,
projectUrl: `${baseUrl}/projects/${project.id}`,
}),
subjectBuilder: () => `${sharer.firstName} has invited you to a project`,
messageType: 'Project shared',
});
}
async getTemplate(templateName: TemplateName): Promise<Template> {