mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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) */
|
||||
@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']);
|
||||
|
||||
@@ -121,6 +121,7 @@ describe('GlobalConfig', () => {
|
||||
'user-invited': '',
|
||||
'password-reset-requested': '',
|
||||
'workflow-shared': '',
|
||||
'project-shared': '',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export type PasswordResetData = {
|
||||
|
||||
export type SendEmailResult = {
|
||||
emailSent: boolean;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
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 { 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> {
|
||||
|
||||
Reference in New Issue
Block a user