feat: support relative notification links sent via email processor

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-04-30 09:50:26 +03:00
parent 9910c6babc
commit e538b10043
3 changed files with 188 additions and 80 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend-module-email': patch
---
Support relative links in notifications sent via email
@@ -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: '<p></p>',
html: '<p><a href="https://example.org/notifications">https://example.org/notifications</a></p>',
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: '<p></p>',
html: '<p><a href="https://example.org/notifications">https://example.org/notifications</a></p>',
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,89 @@ describe('NotificationsEmailProcessor', () => {
expect(sendmailMock).toHaveBeenCalledWith({
from: 'backstage@backstage.io',
html: '<p></p>',
html: '<p><a href="https://example.org/notifications">https://example.org/notifications</a></p>',
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: '<p><a href="https://example.org/catalog/user/default/john.doe">https://example.org/catalog/user/default/john.doe</a></p>',
replyTo: undefined,
subject: 'notification',
text: 'https://example.org/catalog/user/default/john.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: '<p><a href="https://backstage.io/">https://backstage.io/</a></p>',
replyTo: undefined,
subject: 'notification',
text: 'https://backstage.io/',
to: 'mock@backstage.io',
});
});
});
@@ -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,44 @@ export class NotificationsEmailProcessor implements NotificationProcessor {
);
}
private async sendPlainEmail(notification: Notification, emails: string[]) {
private getNotificationLink(notification: Notification) {
if (notification.payload.link) {
try {
const url = new URL(notification.payload.link, 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(`<a href="${link}">${link}</a>`);
return `<p>${contentParts.join('<br/>')}</p>`;
}
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: `<p>${contentParts.join('<br/>')}</p>`,
text: contentParts.join('\n\n'),
html: this.getHtmlContent(notification),
text: this.getTextContent(notification),
replyTo: this.replyTo,
};