diff --git a/packages/@n8n/config/src/configs/user-management.config.ts b/packages/@n8n/config/src/configs/user-management.config.ts index 7acda93d75..2eece00a86 100644 --- a/packages/@n8n/config/src/configs/user-management.config.ts +++ b/packages/@n8n/config/src/configs/user-management.config.ts @@ -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']); diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index cbae18eaa3..35bd70dfca 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -121,6 +121,7 @@ describe('GlobalConfig', () => { 'user-invited': '', 'password-reset-requested': '', 'workflow-shared': '', + 'project-shared': '', }, }, }, diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index c2f4bb42d2..a1f8217877 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -80,7 +80,7 @@ export class UserRepository extends Repository { */ async getEmailsByIds(userIds: string[]) { return await this.find({ - select: ['email'], + select: ['id', 'email'], where: { id: In(userIds), password: Not(IsNull()) }, }); } diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index cbd6aa5299..939369acc1 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -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); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index d29254deb1..2d5fd0e2c7 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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; }; diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 7936e4ebcf..0053187711 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -269,7 +269,7 @@ export class ProjectService { async syncProjectRelations( projectId: string, relations: Required['relations'], - ) { + ): Promise<{ project: Project; newRelations: Required['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 }; } /** diff --git a/packages/cli/src/user-management/email/__tests__/user-management-mailer.test.ts b/packages/cli/src/user-management/email/__tests__/user-management-mailer.test.ts index d5f7e27260..a6ec99eb1f 100644 --- a/packages/cli/src/user-management/email/__tests__/user-management-mailer.test.ts +++ b/packages/cli/src/user-management/email/__tests__/user-management-mailer.test.ts @@ -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(); + const userRepository = mock(); 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({ firstName: 'Sharer', email: 'sharer@user.com' }); + const newShareeIds = ['recipient1', 'recipient2']; + const workflow = mock({ 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({ 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({ 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`, + ); + }); + }); }); }); diff --git a/packages/cli/src/user-management/email/interfaces.ts b/packages/cli/src/user-management/email/interfaces.ts index 65775079ad..305f3f1ab6 100644 --- a/packages/cli/src/user-management/email/interfaces.ts +++ b/packages/cli/src/user-management/email/interfaces.ts @@ -11,6 +11,7 @@ export type PasswordResetData = { export type SendEmailResult = { emailSent: boolean; + errors?: string[]; }; export type MailData = { diff --git a/packages/cli/src/user-management/email/templates/project-shared.mjml b/packages/cli/src/user-management/email/templates/project-shared.mjml new file mode 100644 index 0000000000..8e788d3f78 --- /dev/null +++ b/packages/cli/src/user-management/email/templates/project-shared.mjml @@ -0,0 +1,21 @@ + + + + + + You have been added as a {{ role }} to the {{ projectName }} project + + + + + This gives you access to all the workflows and credentials in that project + View project + + + + + diff --git a/packages/cli/src/user-management/email/user-management-mailer.ts b/packages/cli/src/user-management/email/user-management-mailer.ts index 8f8e177b51..45b0fd9de9 100644 --- a/packages/cli/src/user-management/email/user-management-mailer.ts +++ b/packages/cli/src/user-management/email/user-management-mailer.ts @@ -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({ + mailerTemplate, + recipients, + sharer, + getTemplateData, + subjectBuilder, + messageType, + }: { + mailerTemplate: TemplateName; + recipients: T[]; + sharer: User; + getTemplateData: (recipient: T) => Record; + subjectBuilder: () => string; + messageType: RelayEventMap['user-transactional-email-sent']['messageType']; + }): Promise { + 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 { - 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 { - 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 { + 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