diff --git a/.changeset/giant-donkeys-talk.md b/.changeset/giant-donkeys-talk.md new file mode 100644 index 0000000000..62af2eb7e8 --- /dev/null +++ b/.changeset/giant-donkeys-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend-module-email': patch +--- + +Support relative links in notifications sent via email diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts index 11eef9b999..79d1110968 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts @@ -30,6 +30,36 @@ jest.mock('nodemailer', () => ({ createTransport: jest.fn(), })); +const DEFAULT_ENTITIES_RESPONSE = { + items: [ + { + kind: 'User', + spec: { + profile: { + email: 'mock@backstage.io', + }, + }, + }, + ], +}; + +const DEFAULT_SENDMAIL_CONFIG = { + app: { + baseUrl: 'https://example.org', + }, + notifications: { + processors: { + email: { + transportConfig: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', + }, + }, + }, +}; + describe('NotificationsEmailProcessor', () => { const logger = mockServices.logger.mock(); const auth = mockServices.auth(); @@ -49,6 +79,10 @@ describe('NotificationsEmailProcessor', () => { const processor = new NotificationsEmailProcessor( logger, new ConfigReader({ + app: { + baseUrl: 'http://localhost:3000', + externalBaseUrl: 'https://example.org', + }, notifications: { processors: { email: { @@ -95,6 +129,10 @@ describe('NotificationsEmailProcessor', () => { const processor = new NotificationsEmailProcessor( logger, new ConfigReader({ + app: { + baseUrl: 'http://localhost:3000', + externalBaseUrl: 'https://example.org', + }, notifications: { processors: { email: { @@ -135,6 +173,10 @@ describe('NotificationsEmailProcessor', () => { const processor = new NotificationsEmailProcessor( logger, new ConfigReader({ + app: { + baseUrl: 'http://localhost:3000', + externalBaseUrl: 'https://example.org', + }, notifications: { processors: { email: { @@ -175,29 +217,10 @@ describe('NotificationsEmailProcessor', () => { it('should send user email', async () => { (createTransport as jest.Mock).mockReturnValue(mockTransport); - getEntityRefMock.mockResolvedValue({ - kind: 'User', - spec: { - profile: { - email: 'mock@backstage.io', - }, - }, - }); + getEntityRefMock.mockResolvedValue(DEFAULT_ENTITIES_RESPONSE.items[0]); const processor = new NotificationsEmailProcessor( logger, - new ConfigReader({ - notifications: { - processors: { - email: { - transportConfig: { - transport: 'sendmail', - path: '/usr/local/bin/sendmail', - }, - sender: 'backstage@backstage.io', - }, - }, - }, - }), + mockServices.rootConfig({ data: DEFAULT_SENDMAIL_CONFIG }), mockCatalogClient as unknown as CatalogClient, auth, ); @@ -218,41 +241,29 @@ describe('NotificationsEmailProcessor', () => { expect(sendmailMock).toHaveBeenCalledWith({ from: 'backstage@backstage.io', - html: '

', + html: '

https://example.org/notifications

', replyTo: undefined, subject: 'notification', - text: '', + text: 'https://example.org/notifications', to: 'mock@backstage.io', }); }); it('should send email to all', async () => { (createTransport as jest.Mock).mockReturnValue(mockTransport); - getEntitiesMock.mockResolvedValue({ - items: [ - { - kind: 'User', - spec: { - profile: { - email: 'mock@backstage.io', - }, - }, - }, - ], - }); + getEntitiesMock.mockResolvedValue(DEFAULT_ENTITIES_RESPONSE); const processor = new NotificationsEmailProcessor( logger, - new ConfigReader({ - notifications: { - processors: { - email: { - transportConfig: { - transport: 'sendmail', - path: '/usr/local/bin/sendmail', - }, - sender: 'backstage@backstage.io', - broadcastConfig: { - receiver: 'users', + mockServices.rootConfig({ + data: { + ...DEFAULT_SENDMAIL_CONFIG, + notifications: { + processors: { + email: { + ...DEFAULT_SENDMAIL_CONFIG.notifications.processors.email, + broadcastConfig: { + receiver: 'users', + }, }, }, }, @@ -278,42 +289,30 @@ describe('NotificationsEmailProcessor', () => { expect(sendmailMock).toHaveBeenCalledWith({ from: 'backstage@backstage.io', - html: '

', + html: '

https://example.org/notifications

', replyTo: undefined, subject: 'notification', - text: '', + text: 'https://example.org/notifications', to: 'mock@backstage.io', }); }); it('should send email to configured addresses', async () => { (createTransport as jest.Mock).mockReturnValue(mockTransport); - getEntitiesMock.mockResolvedValue({ - items: [ - { - kind: 'User', - spec: { - profile: { - email: 'mock@backstage.io', - }, - }, - }, - ], - }); + getEntitiesMock.mockResolvedValue(DEFAULT_ENTITIES_RESPONSE); const processor = new NotificationsEmailProcessor( logger, - new ConfigReader({ - notifications: { - processors: { - email: { - transportConfig: { - transport: 'sendmail', - path: '/usr/local/bin/sendmail', - }, - sender: 'backstage@backstage.io', - broadcastConfig: { - receiver: 'config', - receiverEmails: ['broadcast@backstage.io'] as JsonArray, + mockServices.rootConfig({ + data: { + ...DEFAULT_SENDMAIL_CONFIG, + notifications: { + processors: { + email: { + ...DEFAULT_SENDMAIL_CONFIG.notifications.processors.email, + broadcastConfig: { + receiver: 'config', + receiverEmails: ['broadcast@backstage.io'] as JsonArray, + }, }, }, }, @@ -339,11 +338,115 @@ describe('NotificationsEmailProcessor', () => { expect(sendmailMock).toHaveBeenCalledWith({ from: 'backstage@backstage.io', - html: '

', + html: '

https://example.org/notifications

', replyTo: undefined, subject: 'notification', - text: '', + text: 'https://example.org/notifications', to: 'broadcast@backstage.io', }); }); + + it('should send email with relative link to given address', async () => { + (createTransport as jest.Mock).mockReturnValue(mockTransport); + getEntityRefMock.mockResolvedValue(DEFAULT_ENTITIES_RESPONSE.items[0]); + const processor = new NotificationsEmailProcessor( + logger, + mockServices.rootConfig({ + data: DEFAULT_SENDMAIL_CONFIG, + }), + mockCatalogClient as unknown as CatalogClient, + auth, + ); + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + link: 'catalog/user/default/john.doe', + }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + + expect(sendmailMock).toHaveBeenCalledWith({ + from: 'backstage@backstage.io', + html: '

https://example.org/catalog/user/default/john.doe

', + replyTo: undefined, + subject: 'notification', + text: 'https://example.org/catalog/user/default/john.doe', + to: 'mock@backstage.io', + }); + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + + expect(sendmailMock).toHaveBeenCalledWith({ + from: 'backstage@backstage.io', + html: '

https://example.org/catalog/user/default/jane.doe

', + replyTo: undefined, + subject: 'notification', + text: 'https://example.org/catalog/user/default/jane.doe', + to: 'mock@backstage.io', + }); + }); + + it('should send email with absolute link to given address', async () => { + (createTransport as jest.Mock).mockReturnValue(mockTransport); + getEntityRefMock.mockResolvedValue(DEFAULT_ENTITIES_RESPONSE.items[0]); + const processor = new NotificationsEmailProcessor( + logger, + mockServices.rootConfig({ + data: DEFAULT_SENDMAIL_CONFIG, + }), + mockCatalogClient as unknown as CatalogClient, + auth, + ); + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + link: 'https://backstage.io', + }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + + expect(sendmailMock).toHaveBeenCalledWith({ + from: 'backstage@backstage.io', + html: '

https://backstage.io/

', + replyTo: undefined, + subject: 'notification', + text: 'https://backstage.io/', + to: 'mock@backstage.io', + }); + }); }); diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts index ce944d9592..c06d28f903 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -50,6 +50,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { private readonly cacheTtl: number; private readonly concurrencyLimit: number; private readonly throttleInterval: number; + private readonly frontendBaseUrl: string; constructor( private readonly logger: LoggerService, @@ -78,6 +79,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { this.cacheTtl = cacheConfig ? durationToMilliseconds(readDurationFromConfig(cacheConfig)) : 3_600_000; + this.frontendBaseUrl = config.getString('app.baseUrl'); } private async getTransporter() { @@ -215,20 +217,50 @@ export class NotificationsEmailProcessor implements NotificationProcessor { ); } - private async sendPlainEmail(notification: Notification, emails: string[]) { + private getNotificationLink(notification: Notification) { + if (notification.payload.link) { + const stripLeadingSlash = (s: string) => s.replace(/^\//, ''); + const ensureTrailingSlash = (s: string) => s.replace(/\/?$/, '/'); + + try { + const url = new URL( + stripLeadingSlash(notification.payload.link), + ensureTrailingSlash(this.frontendBaseUrl), + ); + return url.toString(); + } catch (_e) { + // noop: fallback to relative URL + } + return notification.payload.link; + } + return `${this.frontendBaseUrl}/notifications`; + } + + private getHtmlContent(notification: Notification) { const contentParts: string[] = []; if (notification.payload.description) { contentParts.push(`${notification.payload.description}`); } - if (notification.payload.link) { - contentParts.push(`${notification.payload.link}`); - } + const link = this.getNotificationLink(notification); + contentParts.push(`${link}`); + return `

${contentParts.join('
')}

`; + } + private getTextContent(notification: Notification) { + const contentParts: string[] = []; + if (notification.payload.description) { + contentParts.push(notification.payload.description); + } + contentParts.push(this.getNotificationLink(notification)); + return contentParts.join('\n\n'); + } + + private async sendPlainEmail(notification: Notification, emails: string[]) { const mailOptions = { from: this.sender, subject: notification.payload.title, - html: `

${contentParts.join('
')}

`, - text: contentParts.join('\n\n'), + html: this.getHtmlContent(notification), + text: this.getTextContent(notification), replyTo: this.replyTo, };