feat: support relative notification links sent via email processor
Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-notifications-backend-module-email': patch
|
||||
---
|
||||
|
||||
Support relative links in notifications sent via email
|
||||
+151
-74
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+32
-6
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user