From dbf269686a5e68f4637289c7b285dfa69b2b76de Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Wed, 17 Apr 2024 13:51:53 +0300 Subject: [PATCH 1/8] feat: add email notification processor Signed-off-by: Heikki Hellgren --- .changeset/green-boxes-rescue.md | 5 + .../.eslintrc.js | 1 + .../README.md | 26 ++ .../api-report.md | 11 + .../catalog-info.yaml | 12 + .../config.d.ts | 120 +++++++ .../package.json | 54 ++++ .../src/index.ts | 23 ++ .../src/module.ts | 57 ++++ .../NotificationsEmailProcessor.test.ts | 295 ++++++++++++++++++ .../processor/NotificationsEmailProcessor.ts | 229 ++++++++++++++ .../src/processor/index.ts | 16 + .../src/processor/transports/index.ts | 18 ++ .../src/processor/transports/sendmail.ts | 25 ++ .../src/processor/transports/ses.ts | 39 +++ .../src/processor/transports/smtp.ts | 30 ++ .../src/types.ts | 80 +++++ yarn.lock | 87 ++++++ 18 files changed, 1128 insertions(+) create mode 100644 .changeset/green-boxes-rescue.md create mode 100644 plugins/notifications-backend-module-email/.eslintrc.js create mode 100644 plugins/notifications-backend-module-email/README.md create mode 100644 plugins/notifications-backend-module-email/api-report.md create mode 100644 plugins/notifications-backend-module-email/catalog-info.yaml create mode 100644 plugins/notifications-backend-module-email/config.d.ts create mode 100644 plugins/notifications-backend-module-email/package.json create mode 100644 plugins/notifications-backend-module-email/src/index.ts create mode 100644 plugins/notifications-backend-module-email/src/module.ts create mode 100644 plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts create mode 100644 plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts create mode 100644 plugins/notifications-backend-module-email/src/processor/index.ts create mode 100644 plugins/notifications-backend-module-email/src/processor/transports/index.ts create mode 100644 plugins/notifications-backend-module-email/src/processor/transports/sendmail.ts create mode 100644 plugins/notifications-backend-module-email/src/processor/transports/ses.ts create mode 100644 plugins/notifications-backend-module-email/src/processor/transports/smtp.ts create mode 100644 plugins/notifications-backend-module-email/src/types.ts diff --git a/.changeset/green-boxes-rescue.md b/.changeset/green-boxes-rescue.md new file mode 100644 index 0000000000..146695a320 --- /dev/null +++ b/.changeset/green-boxes-rescue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend-module-email': patch +--- + +Allow sending notifications by email with the new notifications module diff --git a/plugins/notifications-backend-module-email/.eslintrc.js b/plugins/notifications-backend-module-email/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/notifications-backend-module-email/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/notifications-backend-module-email/README.md b/plugins/notifications-backend-module-email/README.md new file mode 100644 index 0000000000..f87ce8c18e --- /dev/null +++ b/plugins/notifications-backend-module-email/README.md @@ -0,0 +1,26 @@ +# @backstage/plugin-notifications-backend-module-email + +Adds support for sending Backstage notifications as emails to users. + +Supports sending emails using SMTP, SES, or sendmail. + +## Example configuration: + +```yaml +notifications: + processors: + email: + transportConfig: + transport: 'smtp' + hostname: 'my-smtp-server' + port: 587 + secure: false + username: 'my-username' + password: 'my-password' + sender: 'sender@mycompany.com' + replyTo: 'no-reply@mycompany.com' + broadcastConfig: + receiver: 'user' +``` + +See `config.d.ts` for more options for configuration. diff --git a/plugins/notifications-backend-module-email/api-report.md b/plugins/notifications-backend-module-email/api-report.md new file mode 100644 index 0000000000..3f7c9246f5 --- /dev/null +++ b/plugins/notifications-backend-module-email/api-report.md @@ -0,0 +1,11 @@ +## API Report File for "@backstage/plugin-notifications-backend-module-email" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +const notificationsModuleEmail: () => BackendFeature; +export default notificationsModuleEmail; +``` diff --git a/plugins/notifications-backend-module-email/catalog-info.yaml b/plugins/notifications-backend-module-email/catalog-info.yaml new file mode 100644 index 0000000000..6e9c0a044f --- /dev/null +++ b/plugins/notifications-backend-module-email/catalog-info.yaml @@ -0,0 +1,12 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-notifications-backend-module-email + title: '@backstage/plugin-notifications-backend-module-email' + description: >- + The email-notification-processor backend module for the notifications + plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts new file mode 100644 index 0000000000..503d381161 --- /dev/null +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + /** + * Configuration options for notifications-backend-module-email */ + notifications: { + processors: { + email: { + /** + * Transport to use for sending emails */ + transportConfig: + | { + transport: 'smtp'; + /** + * SMTP server hostname + */ + hostname: string; + /** + * SMTP server port + */ + port: number; + /** + * Use secure connection for SMTP, defaults to false + */ + secure?: boolean; + /** + * Require TLS for SMTP connection, defaults to false + */ + requireTls?: boolean; + /** + * SMTP username + */ + username?: string; + /** + * SMTP password + * @visibility secret + */ + password?: string; + } + | { + transport: 'ses'; + /** + * SES ApiVersion to use, defaults to 2010-12-01 + */ + apiVersion?: string; + /** + * SES region to use + */ + region?: string; + /** + * AWS access key id + */ + accessKeyId?: string; + /** + * AWS secret access key + * @visibility secret + */ + secretAccessKey?: string; + } + | { + transport: 'sendmail'; + /** + * Sendmail binary path, defaults to /usr/sbin/sendmail + */ + path?: string; + /** + * Newline style, defaults to 'unix' + */ + newline?: 'unix' | 'windows'; + }; + /** + * Sender email address + */ + sender: string; + /** + * Email format, defaults to HTML + */ + format?: 'html' | 'text'; + /** + * Optional reply-to address + */ + replyTo?: string; + /** + * Configuration for broadcast notifications + */ + broadcastConfig?: { + /** + * Receiver of the broadcast notifications: + * none - skips sending + * users - sends to all users in backstage, might have performance impact + * config - sends to the emails specified in the config + */ + receiver: 'none' | 'users' | 'config'; + /** + * Broadcast notification receivers when receiver is set to config + */ + receiverEmails?: string[]; + }; + /** + * Email cache TTL, defaults to 1 hour + */ + cacheTtl?: number; + }; + }; + }; +} diff --git a/plugins/notifications-backend-module-email/package.json b/plugins/notifications-backend-module-email/package.json new file mode 100644 index 0000000000..0a5bf4ffa2 --- /dev/null +++ b/plugins/notifications-backend-module-email/package.json @@ -0,0 +1,54 @@ +{ + "name": "@backstage/plugin-notifications-backend-module-email", + "version": "0.0.0", + "description": "The email backend module for the notifications plugin.", + "backstage": { + "role": "backend-plugin-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/notifications-backend-module-email" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "config.d.ts" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@aws-sdk/client-ses": "^3.556.0", + "@aws-sdk/credential-provider-node": "^3.350.0", + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-notifications-common": "workspace:^", + "@backstage/plugin-notifications-node": "workspace:^", + "@backstage/types": "workspace:^", + "lodash": "^4.17.21", + "nodemailer": "^6.9.13" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@types/nodemailer": "^6.4.14" + }, + "configSchema": "config.d.ts" +} diff --git a/plugins/notifications-backend-module-email/src/index.ts b/plugins/notifications-backend-module-email/src/index.ts new file mode 100644 index 0000000000..816d5d3579 --- /dev/null +++ b/plugins/notifications-backend-module-email/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The email backend module for the notifications plugin. + * + * @packageDocumentation + */ + +export { notificationsModuleEmail as default } from './module'; diff --git a/plugins/notifications-backend-module-email/src/module.ts b/plugins/notifications-backend-module-email/src/module.ts new file mode 100644 index 0000000000..9c8dfabd8c --- /dev/null +++ b/plugins/notifications-backend-module-email/src/module.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { CatalogClient } from '@backstage/catalog-client'; +import { notificationsProcessingExtensionPoint } from '@backstage/plugin-notifications-node'; +import { NotificationsEmailProcessor } from './processor'; + +/** + * @public + */ +export const notificationsModuleEmail = createBackendModule({ + pluginId: 'notifications', + moduleId: 'email', + register(reg) { + reg.registerInit({ + deps: { + config: coreServices.rootConfig, + notifications: notificationsProcessingExtensionPoint, + discovery: coreServices.discovery, + logger: coreServices.logger, + auth: coreServices.auth, + cache: coreServices.cache, + }, + async init({ config, notifications, discovery, logger, auth, cache }) { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + + notifications.addProcessor( + new NotificationsEmailProcessor( + logger, + config, + catalogClient, + auth, + cache, + ), + ); + }, + }); + }, +}); diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts new file mode 100644 index 0000000000..13e1e912d8 --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts @@ -0,0 +1,295 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { NotificationsEmailProcessor } from './NotificationsEmailProcessor'; +import { ConfigReader } from '@backstage/config'; +import { JsonArray } from '@backstage/types'; +import { CatalogClient } from '@backstage/catalog-client'; +import { createTransport } from 'nodemailer'; + +const sendmailMock = jest.fn(); +const mockTransport = { + sendMail: sendmailMock, +}; +jest.mock('nodemailer', () => ({ + ...jest.requireActual('nodemailer'), + createTransport: jest.fn(), +})); + +describe('NotificationsEmailProcessor', () => { + const logger = mockServices.logger.mock(); + const auth = mockServices.auth(); + + const getEntityRefMock = jest.fn(); + const getEntitiesMock = jest.fn(); + const mockCatalogClient: Partial = { + getEntityByRef: getEntityRefMock, + getEntities: getEntitiesMock, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should create smtp transport', () => { + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + email: { + transport: { + transport: 'smtp', + hostname: 'localhost', + port: 465, + secure: true, + requireTls: false, + }, + sender: 'backstage@backstage.io', + }, + }, + }), + mockCatalogClient as unknown as CatalogClient, + auth, + ); + + expect(processor).toBeInstanceOf(NotificationsEmailProcessor); + expect(createTransport as jest.Mock).toHaveBeenCalledWith({ + host: 'localhost', + port: 465, + requireTLS: false, + secure: true, + }); + }); + + it('should create ses transport', () => { + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + email: { + transport: { + transport: 'ses', + region: 'us-west-2', + }, + sender: 'backstage@backstage.io', + }, + }, + }), + mockCatalogClient as unknown as CatalogClient, + auth, + ); + + expect(processor).toBeInstanceOf(NotificationsEmailProcessor); + expect(createTransport as jest.Mock).toHaveBeenCalledWith({ + SES: expect.anything(), + }); + }); + + it('should create sendmail transport', () => { + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + email: { + transport: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', + }, + }, + }), + mockCatalogClient as unknown as CatalogClient, + auth, + ); + + expect(processor).toBeInstanceOf(NotificationsEmailProcessor); + expect(createTransport as jest.Mock).toHaveBeenCalledWith({ + sendmail: true, + path: '/usr/local/bin/sendmail', + newline: 'unix', + }); + }); + + it('should send user email', async () => { + (createTransport as jest.Mock).mockReturnValue(mockTransport); + getEntityRefMock.mockResolvedValue({ + kind: 'User', + spec: { + profile: { + email: 'mock@backstage.io', + }, + }, + }); + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + email: { + transport: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', + }, + }, + }), + mockCatalogClient as unknown as CatalogClient, + auth, + ); + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { title: 'notification' }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + + expect(sendmailMock).toHaveBeenCalledWith({ + from: 'backstage@backstage.io', + html: '

', + replyTo: undefined, + subject: 'notification', + text: undefined, + 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', + }, + }, + }, + ], + }); + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + email: { + transport: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', + broadcastConfig: { + receiver: 'users', + }, + }, + }, + }), + mockCatalogClient as unknown as CatalogClient, + auth, + ); + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: null, + created: new Date(), + payload: { title: 'notification' }, + }, + { + recipients: { type: 'broadcast' }, + payload: { title: 'notification' }, + }, + ); + + expect(sendmailMock).toHaveBeenCalledWith({ + from: 'backstage@backstage.io', + html: '

', + replyTo: undefined, + subject: 'notification', + text: undefined, + 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', + }, + }, + }, + ], + }); + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + email: { + transport: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', + broadcastConfig: { + receiver: 'config', + receiverEmails: ['broadcast@backstage.io'] as JsonArray, + }, + }, + }, + }), + mockCatalogClient as unknown as CatalogClient, + auth, + ); + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: null, + created: new Date(), + payload: { title: 'notification' }, + }, + { + recipients: { type: 'broadcast' }, + payload: { title: 'notification' }, + }, + ); + + expect(sendmailMock).toHaveBeenCalledWith({ + from: 'backstage@backstage.io', + html: '

', + replyTo: undefined, + subject: 'notification', + text: undefined, + to: 'broadcast@backstage.io', + }); + }); +}); diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts new file mode 100644 index 0000000000..8cf28917d5 --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -0,0 +1,229 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + NotificationProcessor, + NotificationSendOptions, +} from '@backstage/plugin-notifications-node'; +import { + AuthService, + CacheService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; +import { JsonArray } from '@backstage/types'; +import { + CATALOG_FILTER_EXISTS, + CatalogClient, +} from '@backstage/catalog-client'; +import { Notification } from '@backstage/plugin-notifications-common'; +import { createSendmailTransport, createSmtpTransport } from './transports'; +import { createSesTransport } from './transports/ses'; +import { UserEntity } from '@backstage/catalog-model'; +import { compact } from 'lodash'; + +export class NotificationsEmailProcessor implements NotificationProcessor { + private readonly transportter: any; + private readonly broadcastConfig?: Config; + private readonly sender: string; + private readonly format: string; + private readonly replyTo?: string; + private readonly cacheTtl: number; + + constructor( + private readonly logger: LoggerService, + config: Config, + private readonly catalog: CatalogClient, + private readonly auth: AuthService, + private readonly cache?: CacheService, + ) { + const transportConfig = config.getConfig('notifications.email.transport'); + const transport = transportConfig.getString('transport'); + if (transport === 'smtp') { + this.transportter = createSmtpTransport({ + transport: 'smtp', + hostname: transportConfig.getString('hostname'), + port: transportConfig.getNumber('port'), + secure: transportConfig.getOptionalBoolean('secure'), + requireTls: transportConfig.getOptionalBoolean('requireTls'), + username: transportConfig.getOptionalString('username'), + password: transportConfig.getOptionalString('password'), + }); + } else if (transport === 'ses') { + this.transportter = createSesTransport({ + transport: 'ses', + apiVersion: transportConfig.getOptionalString('apiVersion'), + region: transportConfig.getOptionalString('region'), + accessKeyId: transportConfig.getOptionalString('accessKeyId'), + secretAccessKey: transportConfig.getOptionalString('secretAccessKey'), + }); + } else if (transport === 'sendmail') { + this.transportter = createSendmailTransport({ + transport: 'sendmail', + path: transportConfig.getOptionalString('path'), + newline: transportConfig.getOptionalString('newline'), + }); + } else { + throw new Error(`Unsupported transport: ${transport}`); + } + + this.broadcastConfig = config.getOptionalConfig( + 'notifications.email.broadcastConfig', + ); + this.sender = config.getString('notifications.email.sender'); + this.format = + config.getOptionalString('notifications.email.format') ?? 'html'; + this.replyTo = config.getOptionalString('notifications.email.replyTo'); + this.cacheTtl = + config.getOptionalNumber('notifications.email.cacheTtl') ?? 3_600_000; + } + + getName(): string { + return 'Email'; + } + + private async getBroadcastEmails(): Promise { + if (!this.broadcastConfig) { + return []; + } + + const receiver = this.broadcastConfig.getString('receiver'); + if (receiver === 'none') { + return []; + } + + if (receiver === 'config') { + return ( + this.broadcastConfig.getOptionalStringArray('receiverEmails') ?? [] + ); + } + + if (receiver === 'users') { + const cached = await this.cache?.get('user-emails:all'); + if (cached) { + return cached as string[]; + } + + const credentials = await this.auth.getOwnServiceCredentials(); + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); + const entities = await this.catalog.getEntities( + { + filter: [ + { kind: 'user', 'spec.profile.email': CATALOG_FILTER_EXISTS }, + ], + fields: ['spec.profile.email'], + }, + { token }, + ); + const ret = compact([ + ...new Set( + entities.items.map(entity => { + return (entity as UserEntity)?.spec.profile?.email; + }), + ), + ]); + await this.cache?.set('user-emails:all', ret as JsonArray, { + ttl: this.cacheTtl, + }); + return ret; + } + + throw new Error(`Unsupported broadcast receiver: ${receiver}`); + } + + private async getUserEmail(entityRef: string): Promise { + const cached = await this.cache?.get(`user-emails:${entityRef}`); + if (cached) { + return [cached]; + } + + const credentials = await this.auth.getOwnServiceCredentials(); + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: 'catalog', + }); + const entity = await this.catalog.getEntityByRef(entityRef, { token }); + if (!entity) { + return []; + } + + const userEntity = entity as UserEntity; + if (!userEntity.spec.profile?.email) { + return []; + } + + await this.cache?.set( + `user-emails:${entityRef}`, + userEntity.spec.profile.email, + { ttl: this.cacheTtl }, + ); + + return [userEntity.spec.profile.email]; + } + + private async getRecipientEmails( + notification: Notification, + options: NotificationSendOptions, + ) { + if (options.recipients.type === 'broadcast' || notification.user === null) { + return await this.getBroadcastEmails(); + } + return await this.getUserEmail(notification.user); + } + + async postProcess( + notification: Notification, + options: NotificationSendOptions, + ): Promise { + let emails: string[] = []; + try { + emails = await this.getRecipientEmails(notification, options); + } catch (e) { + this.logger.error(`Failed to resolve recipient emails: ${e}`); + return; + } + + // TODO: add template support for content either HTML or text + const contentParts: string[] = []; + if (notification.payload.description) { + contentParts.push(`${notification.payload.description}`); + } + if (notification.payload.link) { + contentParts.push(`${notification.payload.link}`); + } + + const mailOptions = { + from: this.sender, + subject: notification.payload.title, + html: + this.format === 'html' + ? `

${contentParts.join('
')}

` + : undefined, + text: this.format === 'text' ? contentParts.join('\n\n') : undefined, + replyTo: this.replyTo, + }; + + for (const email of emails) { + try { + await this.transportter.sendMail({ ...mailOptions, to: email }); + } catch (e) { + this.logger.error(`Failed to send email to ${email}: ${e}`); + } + } + } +} diff --git a/plugins/notifications-backend-module-email/src/processor/index.ts b/plugins/notifications-backend-module-email/src/processor/index.ts new file mode 100644 index 0000000000..cc5d6a1dc8 --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { NotificationsEmailProcessor } from './NotificationsEmailProcessor'; diff --git a/plugins/notifications-backend-module-email/src/processor/transports/index.ts b/plugins/notifications-backend-module-email/src/processor/transports/index.ts new file mode 100644 index 0000000000..ccea9f77cc --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/transports/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { createSmtpTransport } from './smtp'; +export { createSesTransport } from './ses'; +export { createSendmailTransport } from './sendmail'; diff --git a/plugins/notifications-backend-module-email/src/processor/transports/sendmail.ts b/plugins/notifications-backend-module-email/src/processor/transports/sendmail.ts new file mode 100644 index 0000000000..8f093f562d --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/transports/sendmail.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createTransport } from 'nodemailer'; +import { SendmailTransportConfig } from '../../types'; + +export const createSendmailTransport = (config: SendmailTransportConfig) => { + return createTransport({ + sendmail: true, + newline: config.newline ?? 'unix', + path: config.path ?? '/usr/sbin/sendmail', + }); +}; diff --git a/plugins/notifications-backend-module-email/src/processor/transports/ses.ts b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts new file mode 100644 index 0000000000..739a3167ab --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { SesTransportConfig } from '../../types'; +import { createTransport } from 'nodemailer'; +import * as aws from '@aws-sdk/client-ses'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; + +export const createSesTransport = (config: SesTransportConfig) => { + const ses = new aws.SES([ + { + region: config.region, + apiVersion: config.apiVersion ?? '2010-12-01', + defaultProvider, + credentials: + config.accessKeyId && config.secretAccessKey + ? { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + } + : undefined, + }, + ]); + return createTransport({ + SES: { ses, aws }, + }); +}; diff --git a/plugins/notifications-backend-module-email/src/processor/transports/smtp.ts b/plugins/notifications-backend-module-email/src/processor/transports/smtp.ts new file mode 100644 index 0000000000..e473ea5ba7 --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/transports/smtp.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { SmtpTransportConfig } from '../../types'; +import { createTransport } from 'nodemailer'; + +export const createSmtpTransport = (config: SmtpTransportConfig) => { + return createTransport({ + host: config.hostname, + port: config.port, + secure: config.secure ?? false, + requireTLS: config.requireTls ?? false, + auth: + config.username && config.password + ? { user: config.username, pass: config.password } + : undefined, + }); +}; diff --git a/plugins/notifications-backend-module-email/src/types.ts b/plugins/notifications-backend-module-email/src/types.ts new file mode 100644 index 0000000000..6dd6ae9d76 --- /dev/null +++ b/plugins/notifications-backend-module-email/src/types.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface TransportConfig { + transport: 'smtp' | 'ses' | 'sendmail'; +} + +export interface SmtpTransportConfig extends TransportConfig { + transport: 'smtp'; + /** + * SMTP server hostname + */ + hostname: string; + /** + * SMTP server port + */ + port: number; + /** + * Use secure connection for SMTP, defaults to false + */ + secure?: boolean; + /** + * Require TLS for SMTP connection, defaults to false + */ + requireTls?: boolean; + /** + * SMTP username + */ + username?: string; + /** + * SMTP password + * @visibility secret + */ + password?: string; +} + +export interface SesTransportConfig extends TransportConfig { + transport: 'ses'; + /** + * SES ApiVersion to use, defaults to 2010-12-01 + */ + apiVersion?: string; + /** + * SES region to use + */ + region?: string; + /** + * AWS access key id + */ + accessKeyId?: string; + /** + * AWS secret access key + * @visibility secret + */ + secretAccessKey?: string; +} + +export interface SendmailTransportConfig extends TransportConfig { + transport: 'sendmail'; + /** + * Sendmail binary path, defaults to /usr/sbin/sendmail + */ + path?: string; + /** + * Newline style, defaults to 'unix' + */ + newline?: string; +} diff --git a/yarn.lock b/yarn.lock index a07e9249f7..a0f16fa1ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -599,6 +599,55 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-ses@npm:^3.556.0": + version: 3.556.0 + resolution: "@aws-sdk/client-ses@npm:3.556.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/client-sts": 3.556.0 + "@aws-sdk/core": 3.556.0 + "@aws-sdk/credential-provider-node": 3.556.0 + "@aws-sdk/middleware-host-header": 3.535.0 + "@aws-sdk/middleware-logger": 3.535.0 + "@aws-sdk/middleware-recursion-detection": 3.535.0 + "@aws-sdk/middleware-user-agent": 3.540.0 + "@aws-sdk/region-config-resolver": 3.535.0 + "@aws-sdk/types": 3.535.0 + "@aws-sdk/util-endpoints": 3.540.0 + "@aws-sdk/util-user-agent-browser": 3.535.0 + "@aws-sdk/util-user-agent-node": 3.535.0 + "@smithy/config-resolver": ^2.2.0 + "@smithy/core": ^1.4.2 + "@smithy/fetch-http-handler": ^2.5.0 + "@smithy/hash-node": ^2.2.0 + "@smithy/invalid-dependency": ^2.2.0 + "@smithy/middleware-content-length": ^2.2.0 + "@smithy/middleware-endpoint": ^2.5.1 + "@smithy/middleware-retry": ^2.3.1 + "@smithy/middleware-serde": ^2.3.0 + "@smithy/middleware-stack": ^2.2.0 + "@smithy/node-config-provider": ^2.3.0 + "@smithy/node-http-handler": ^2.5.0 + "@smithy/protocol-http": ^3.3.0 + "@smithy/smithy-client": ^2.5.1 + "@smithy/types": ^2.12.0 + "@smithy/url-parser": ^2.2.0 + "@smithy/util-base64": ^2.3.0 + "@smithy/util-body-length-browser": ^2.2.0 + "@smithy/util-body-length-node": ^2.3.0 + "@smithy/util-defaults-mode-browser": ^2.2.1 + "@smithy/util-defaults-mode-node": ^2.3.1 + "@smithy/util-endpoints": ^1.2.0 + "@smithy/util-middleware": ^2.2.0 + "@smithy/util-retry": ^2.2.0 + "@smithy/util-utf8": ^2.3.0 + "@smithy/util-waiter": ^2.2.0 + tslib: ^2.6.2 + checksum: 913108e79061185faae51711b121df99da624f988f530af23c3e4270299be60771be83f507dc8ed4af4051765077ea038edab19e4d0d32e159c4e67faf5fc9f8 + languageName: node + linkType: hard + "@aws-sdk/client-sqs@npm:^3.350.0": version: 3.556.0 resolution: "@aws-sdk/client-sqs@npm:3.556.0" @@ -5982,6 +6031,28 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-notifications-backend-module-email@workspace:plugins/notifications-backend-module-email": + version: 0.0.0-use.local + resolution: "@backstage/plugin-notifications-backend-module-email@workspace:plugins/notifications-backend-module-email" + dependencies: + "@aws-sdk/client-ses": ^3.556.0 + "@aws-sdk/credential-provider-node": ^3.350.0 + "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-notifications-common": "workspace:^" + "@backstage/plugin-notifications-node": "workspace:^" + "@backstage/types": "workspace:^" + "@types/nodemailer": ^6.4.14 + lodash: ^4.17.21 + nodemailer: ^6.9.13 + languageName: unknown + linkType: soft + "@backstage/plugin-notifications-backend@workspace:^, @backstage/plugin-notifications-backend@workspace:plugins/notifications-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-notifications-backend@workspace:plugins/notifications-backend" @@ -16064,6 +16135,15 @@ __metadata: languageName: node linkType: hard +"@types/nodemailer@npm:^6.4.14": + version: 6.4.14 + resolution: "@types/nodemailer@npm:6.4.14" + dependencies: + "@types/node": "*" + checksum: 5f61f01dd736b17f431d1e8b320322f86460604b45df947fc4bc8999d7c7719405e349f7abba86e4fb100a464a30b52615d00dac03d9cb37562ff04487ebd310 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -32254,6 +32334,13 @@ __metadata: languageName: node linkType: hard +"nodemailer@npm:^6.9.13": + version: 6.9.13 + resolution: "nodemailer@npm:6.9.13" + checksum: 1b591ef480be2ff69480127cbff819e6593b1ef263b6f920e1a4e83e40280582daf7a14a809ef92f9828e2a70bdb3ce22b11924e209f2afe4975f9ff37e08e9d + languageName: node + linkType: hard + "nodemon@npm:^3.0.1": version: 3.1.0 resolution: "nodemon@npm:3.1.0" From 1c1718857e3083e1fbeff1450c46cecba542dc65 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Wed, 17 Apr 2024 18:16:47 +0300 Subject: [PATCH 2/8] fix: use credentials manager instead config Signed-off-by: Heikki Hellgren --- .../config.d.ts | 13 +- .../package.json | 5 +- .../NotificationsEmailProcessor.test.ts | 48 ++- .../processor/NotificationsEmailProcessor.ts | 86 +++--- .../src/processor/transports/ses.ts | 24 +- .../src/types.ts | 17 +- yarn.lock | 273 +++++++++++++++++- 7 files changed, 381 insertions(+), 85 deletions(-) diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts index 503d381161..fff78f735f 100644 --- a/plugins/notifications-backend-module-email/config.d.ts +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -58,18 +58,9 @@ export interface Config { */ apiVersion?: string; /** - * SES region to use + * AWS account ID to use */ - region?: string; - /** - * AWS access key id - */ - accessKeyId?: string; - /** - * AWS secret access key - * @visibility secret - */ - secretAccessKey?: string; + accountId?: string; } | { transport: 'sendmail'; diff --git a/plugins/notifications-backend-module-email/package.json b/plugins/notifications-backend-module-email/package.json index 0a5bf4ffa2..345ba162b6 100644 --- a/plugins/notifications-backend-module-email/package.json +++ b/plugins/notifications-backend-module-email/package.json @@ -32,13 +32,14 @@ "test": "backstage-cli package test" }, "dependencies": { - "@aws-sdk/client-ses": "^3.556.0", - "@aws-sdk/credential-provider-node": "^3.350.0", + "@aws-sdk/client-ses": "3.554.0", + "@aws-sdk/types": "^3.347.0", "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/integration-aws-node": "workspace:^", "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", "@backstage/types": "workspace:^", 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 13e1e912d8..72d1113eb1 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts @@ -45,7 +45,7 @@ describe('NotificationsEmailProcessor', () => { jest.resetAllMocks(); }); - it('should create smtp transport', () => { + it('should create smtp transport', async () => { const processor = new NotificationsEmailProcessor( logger, new ConfigReader({ @@ -66,6 +66,20 @@ describe('NotificationsEmailProcessor', () => { auth, ); + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { title: 'notification' }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + expect(processor).toBeInstanceOf(NotificationsEmailProcessor); expect(createTransport as jest.Mock).toHaveBeenCalledWith({ host: 'localhost', @@ -75,7 +89,7 @@ describe('NotificationsEmailProcessor', () => { }); }); - it('should create ses transport', () => { + it('should create ses transport', async () => { const processor = new NotificationsEmailProcessor( logger, new ConfigReader({ @@ -93,13 +107,27 @@ describe('NotificationsEmailProcessor', () => { auth, ); + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { title: 'notification' }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + expect(processor).toBeInstanceOf(NotificationsEmailProcessor); expect(createTransport as jest.Mock).toHaveBeenCalledWith({ SES: expect.anything(), }); }); - it('should create sendmail transport', () => { + it('should create sendmail transport', async () => { const processor = new NotificationsEmailProcessor( logger, new ConfigReader({ @@ -117,6 +145,20 @@ describe('NotificationsEmailProcessor', () => { auth, ); + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { title: 'notification' }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + expect(processor).toBeInstanceOf(NotificationsEmailProcessor); expect(createTransport as jest.Mock).toHaveBeenCalledWith({ sendmail: true, diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts index 8cf28917d5..8150aeebe6 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -33,9 +33,10 @@ import { createSendmailTransport, createSmtpTransport } from './transports'; import { createSesTransport } from './transports/ses'; import { UserEntity } from '@backstage/catalog-model'; import { compact } from 'lodash'; +import { DefaultAwsCredentialsManager } from '@backstage/integration-aws-node'; export class NotificationsEmailProcessor implements NotificationProcessor { - private readonly transportter: any; + private transporter: any; private readonly broadcastConfig?: Config; private readonly sender: string; private readonly format: string; @@ -44,41 +45,11 @@ export class NotificationsEmailProcessor implements NotificationProcessor { constructor( private readonly logger: LoggerService, - config: Config, + private readonly config: Config, private readonly catalog: CatalogClient, private readonly auth: AuthService, private readonly cache?: CacheService, ) { - const transportConfig = config.getConfig('notifications.email.transport'); - const transport = transportConfig.getString('transport'); - if (transport === 'smtp') { - this.transportter = createSmtpTransport({ - transport: 'smtp', - hostname: transportConfig.getString('hostname'), - port: transportConfig.getNumber('port'), - secure: transportConfig.getOptionalBoolean('secure'), - requireTls: transportConfig.getOptionalBoolean('requireTls'), - username: transportConfig.getOptionalString('username'), - password: transportConfig.getOptionalString('password'), - }); - } else if (transport === 'ses') { - this.transportter = createSesTransport({ - transport: 'ses', - apiVersion: transportConfig.getOptionalString('apiVersion'), - region: transportConfig.getOptionalString('region'), - accessKeyId: transportConfig.getOptionalString('accessKeyId'), - secretAccessKey: transportConfig.getOptionalString('secretAccessKey'), - }); - } else if (transport === 'sendmail') { - this.transportter = createSendmailTransport({ - transport: 'sendmail', - path: transportConfig.getOptionalString('path'), - newline: transportConfig.getOptionalString('newline'), - }); - } else { - throw new Error(`Unsupported transport: ${transport}`); - } - this.broadcastConfig = config.getOptionalConfig( 'notifications.email.broadcastConfig', ); @@ -90,6 +61,46 @@ export class NotificationsEmailProcessor implements NotificationProcessor { config.getOptionalNumber('notifications.email.cacheTtl') ?? 3_600_000; } + private async getTransporter() { + if (this.transporter) { + return this.transporter; + } + const transportConfig = this.config.getConfig( + 'notifications.email.transport', + ); + const transport = transportConfig.getString('transport'); + if (transport === 'smtp') { + this.transporter = createSmtpTransport({ + transport: 'smtp', + hostname: transportConfig.getString('hostname'), + port: transportConfig.getNumber('port'), + secure: transportConfig.getOptionalBoolean('secure'), + requireTls: transportConfig.getOptionalBoolean('requireTls'), + username: transportConfig.getOptionalString('username'), + password: transportConfig.getOptionalString('password'), + }); + } else if (transport === 'ses') { + const awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig( + this.config, + ); + this.transporter = await createSesTransport({ + transport: 'ses', + credentialsManager: awsCredentialsManager, + apiVersion: transportConfig.getOptionalString('apiVersion'), + accountId: transportConfig.getOptionalString('accountId'), + }); + } else if (transport === 'sendmail') { + this.transporter = createSendmailTransport({ + transport: 'sendmail', + path: transportConfig.getOptionalString('path'), + newline: transportConfig.getOptionalString('newline'), + }); + } else { + throw new Error(`Unsupported transport: ${transport}`); + } + return this.transporter; + } + getName(): string { return 'Email'; } @@ -190,6 +201,8 @@ export class NotificationsEmailProcessor implements NotificationProcessor { notification: Notification, options: NotificationSendOptions, ): Promise { + this.transporter = await this.getTransporter(); + let emails: string[] = []; try { emails = await this.getRecipientEmails(notification, options); @@ -198,6 +211,13 @@ export class NotificationsEmailProcessor implements NotificationProcessor { return; } + if (emails.length === 0) { + this.logger.info( + `No email recipients found for notification: ${notification.id}, skipping`, + ); + return; + } + // TODO: add template support for content either HTML or text const contentParts: string[] = []; if (notification.payload.description) { @@ -220,7 +240,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { for (const email of emails) { try { - await this.transportter.sendMail({ ...mailOptions, to: email }); + await this.transporter.sendMail({ ...mailOptions, to: email }); } catch (e) { this.logger.error(`Failed to send email to ${email}: ${e}`); } diff --git a/plugins/notifications-backend-module-email/src/processor/transports/ses.ts b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts index 739a3167ab..7073cc08b2 100644 --- a/plugins/notifications-backend-module-email/src/processor/transports/ses.ts +++ b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts @@ -15,25 +15,21 @@ */ import { SesTransportConfig } from '../../types'; import { createTransport } from 'nodemailer'; -import * as aws from '@aws-sdk/client-ses'; -import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { SendRawEmailCommand, SES } from '@aws-sdk/client-ses'; -export const createSesTransport = (config: SesTransportConfig) => { - const ses = new aws.SES([ +export const createSesTransport = async (config: SesTransportConfig) => { + const credentials = await config.credentialsManager.getCredentialProvider({ + accountId: config.accountId, + }); + const ses = new SES([ { - region: config.region, apiVersion: config.apiVersion ?? '2010-12-01', - defaultProvider, - credentials: - config.accessKeyId && config.secretAccessKey - ? { - accessKeyId: config.accessKeyId, - secretAccessKey: config.secretAccessKey, - } - : undefined, + credentials: credentials.sdkCredentialProvider, + region: credentials.stsRegion, + accountId: credentials.accountId, }, ]); return createTransport({ - SES: { ses, aws }, + SES: { ses, aws: { SendRawEmailCommand } }, }); }; diff --git a/plugins/notifications-backend-module-email/src/types.ts b/plugins/notifications-backend-module-email/src/types.ts index 6dd6ae9d76..67c7a1740d 100644 --- a/plugins/notifications-backend-module-email/src/types.ts +++ b/plugins/notifications-backend-module-email/src/types.ts @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { AwsCredentialsManager } from '@backstage/integration-aws-node'; + export interface TransportConfig { transport: 'smtp' | 'ses' | 'sendmail'; } @@ -52,19 +54,8 @@ export interface SesTransportConfig extends TransportConfig { * SES ApiVersion to use, defaults to 2010-12-01 */ apiVersion?: string; - /** - * SES region to use - */ - region?: string; - /** - * AWS access key id - */ - accessKeyId?: string; - /** - * AWS secret access key - * @visibility secret - */ - secretAccessKey?: string; + accountId?: string; + credentialsManager: AwsCredentialsManager; } export interface SendmailTransportConfig extends TransportConfig { diff --git a/yarn.lock b/yarn.lock index a0f16fa1ab..94ba875fae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -599,15 +599,15 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-ses@npm:^3.556.0": - version: 3.556.0 - resolution: "@aws-sdk/client-ses@npm:3.556.0" +"@aws-sdk/client-ses@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/client-ses@npm:3.554.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.556.0 - "@aws-sdk/core": 3.556.0 - "@aws-sdk/credential-provider-node": 3.556.0 + "@aws-sdk/client-sts": 3.554.0 + "@aws-sdk/core": 3.554.0 + "@aws-sdk/credential-provider-node": 3.554.0 "@aws-sdk/middleware-host-header": 3.535.0 "@aws-sdk/middleware-logger": 3.535.0 "@aws-sdk/middleware-recursion-detection": 3.535.0 @@ -644,7 +644,7 @@ __metadata: "@smithy/util-utf8": ^2.3.0 "@smithy/util-waiter": ^2.2.0 tslib: ^2.6.2 - checksum: 913108e79061185faae51711b121df99da624f988f530af23c3e4270299be60771be83f507dc8ed4af4051765077ea038edab19e4d0d32e159c4e67faf5fc9f8 + checksum: 14512aac8bd84a69066535580bd739509a5698a64be78b111fd9656f4a01f4921c016b0c22c8b103261370d9e75ba3c6301cc652b47e396e421b8b02f93334ce languageName: node linkType: hard @@ -698,6 +698,55 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso-oidc@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/client-sso-oidc@npm:3.554.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/client-sts": 3.554.0 + "@aws-sdk/core": 3.554.0 + "@aws-sdk/middleware-host-header": 3.535.0 + "@aws-sdk/middleware-logger": 3.535.0 + "@aws-sdk/middleware-recursion-detection": 3.535.0 + "@aws-sdk/middleware-user-agent": 3.540.0 + "@aws-sdk/region-config-resolver": 3.535.0 + "@aws-sdk/types": 3.535.0 + "@aws-sdk/util-endpoints": 3.540.0 + "@aws-sdk/util-user-agent-browser": 3.535.0 + "@aws-sdk/util-user-agent-node": 3.535.0 + "@smithy/config-resolver": ^2.2.0 + "@smithy/core": ^1.4.2 + "@smithy/fetch-http-handler": ^2.5.0 + "@smithy/hash-node": ^2.2.0 + "@smithy/invalid-dependency": ^2.2.0 + "@smithy/middleware-content-length": ^2.2.0 + "@smithy/middleware-endpoint": ^2.5.1 + "@smithy/middleware-retry": ^2.3.1 + "@smithy/middleware-serde": ^2.3.0 + "@smithy/middleware-stack": ^2.2.0 + "@smithy/node-config-provider": ^2.3.0 + "@smithy/node-http-handler": ^2.5.0 + "@smithy/protocol-http": ^3.3.0 + "@smithy/smithy-client": ^2.5.1 + "@smithy/types": ^2.12.0 + "@smithy/url-parser": ^2.2.0 + "@smithy/util-base64": ^2.3.0 + "@smithy/util-body-length-browser": ^2.2.0 + "@smithy/util-body-length-node": ^2.3.0 + "@smithy/util-defaults-mode-browser": ^2.2.1 + "@smithy/util-defaults-mode-node": ^2.3.1 + "@smithy/util-endpoints": ^1.2.0 + "@smithy/util-middleware": ^2.2.0 + "@smithy/util-retry": ^2.2.0 + "@smithy/util-utf8": ^2.3.0 + tslib: ^2.6.2 + peerDependencies: + "@aws-sdk/credential-provider-node": ^3.554.0 + checksum: 91d0f6b8c5c787e2b705803d615f9400430ea0bca9d12171a5e7abe5c53cd83f711300e5c0a106dc4f1f1259adf08f87b728f9820679aa5ca73cd66a09bf8dd4 + languageName: node + linkType: hard + "@aws-sdk/client-sso-oidc@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/client-sso-oidc@npm:3.556.0" @@ -747,6 +796,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/client-sso@npm:3.554.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/core": 3.554.0 + "@aws-sdk/middleware-host-header": 3.535.0 + "@aws-sdk/middleware-logger": 3.535.0 + "@aws-sdk/middleware-recursion-detection": 3.535.0 + "@aws-sdk/middleware-user-agent": 3.540.0 + "@aws-sdk/region-config-resolver": 3.535.0 + "@aws-sdk/types": 3.535.0 + "@aws-sdk/util-endpoints": 3.540.0 + "@aws-sdk/util-user-agent-browser": 3.535.0 + "@aws-sdk/util-user-agent-node": 3.535.0 + "@smithy/config-resolver": ^2.2.0 + "@smithy/core": ^1.4.2 + "@smithy/fetch-http-handler": ^2.5.0 + "@smithy/hash-node": ^2.2.0 + "@smithy/invalid-dependency": ^2.2.0 + "@smithy/middleware-content-length": ^2.2.0 + "@smithy/middleware-endpoint": ^2.5.1 + "@smithy/middleware-retry": ^2.3.1 + "@smithy/middleware-serde": ^2.3.0 + "@smithy/middleware-stack": ^2.2.0 + "@smithy/node-config-provider": ^2.3.0 + "@smithy/node-http-handler": ^2.5.0 + "@smithy/protocol-http": ^3.3.0 + "@smithy/smithy-client": ^2.5.1 + "@smithy/types": ^2.12.0 + "@smithy/url-parser": ^2.2.0 + "@smithy/util-base64": ^2.3.0 + "@smithy/util-body-length-browser": ^2.2.0 + "@smithy/util-body-length-node": ^2.3.0 + "@smithy/util-defaults-mode-browser": ^2.2.1 + "@smithy/util-defaults-mode-node": ^2.3.1 + "@smithy/util-endpoints": ^1.2.0 + "@smithy/util-middleware": ^2.2.0 + "@smithy/util-retry": ^2.2.0 + "@smithy/util-utf8": ^2.3.0 + tslib: ^2.6.2 + checksum: 08dbaca87bb25d7662a2e856dca16f92bc461b9cf50e048c679969df93189f653c2bc74c8be1ac3e82e720d2742fc81af554a04f0ebf83353c1fa6e30fdbbecf + languageName: node + linkType: hard + "@aws-sdk/client-sso@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/client-sso@npm:3.556.0" @@ -793,6 +888,54 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sts@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/client-sts@npm:3.554.0" + dependencies: + "@aws-crypto/sha256-browser": 3.0.0 + "@aws-crypto/sha256-js": 3.0.0 + "@aws-sdk/core": 3.554.0 + "@aws-sdk/middleware-host-header": 3.535.0 + "@aws-sdk/middleware-logger": 3.535.0 + "@aws-sdk/middleware-recursion-detection": 3.535.0 + "@aws-sdk/middleware-user-agent": 3.540.0 + "@aws-sdk/region-config-resolver": 3.535.0 + "@aws-sdk/types": 3.535.0 + "@aws-sdk/util-endpoints": 3.540.0 + "@aws-sdk/util-user-agent-browser": 3.535.0 + "@aws-sdk/util-user-agent-node": 3.535.0 + "@smithy/config-resolver": ^2.2.0 + "@smithy/core": ^1.4.2 + "@smithy/fetch-http-handler": ^2.5.0 + "@smithy/hash-node": ^2.2.0 + "@smithy/invalid-dependency": ^2.2.0 + "@smithy/middleware-content-length": ^2.2.0 + "@smithy/middleware-endpoint": ^2.5.1 + "@smithy/middleware-retry": ^2.3.1 + "@smithy/middleware-serde": ^2.3.0 + "@smithy/middleware-stack": ^2.2.0 + "@smithy/node-config-provider": ^2.3.0 + "@smithy/node-http-handler": ^2.5.0 + "@smithy/protocol-http": ^3.3.0 + "@smithy/smithy-client": ^2.5.1 + "@smithy/types": ^2.12.0 + "@smithy/url-parser": ^2.2.0 + "@smithy/util-base64": ^2.3.0 + "@smithy/util-body-length-browser": ^2.2.0 + "@smithy/util-body-length-node": ^2.3.0 + "@smithy/util-defaults-mode-browser": ^2.2.1 + "@smithy/util-defaults-mode-node": ^2.3.1 + "@smithy/util-endpoints": ^1.2.0 + "@smithy/util-middleware": ^2.2.0 + "@smithy/util-retry": ^2.2.0 + "@smithy/util-utf8": ^2.3.0 + tslib: ^2.6.2 + peerDependencies: + "@aws-sdk/credential-provider-node": ^3.554.0 + checksum: dee932b55e89f9b68275d21861f534ae1cdf006410841fe84bab38df12253ebafd4928e440d6243391c3234f2509b943628c5368d2bcd0489ea240464d65edc6 + languageName: node + linkType: hard + "@aws-sdk/client-sts@npm:3.556.0, @aws-sdk/client-sts@npm:^3.350.0": version: 3.556.0 resolution: "@aws-sdk/client-sts@npm:3.556.0" @@ -841,6 +984,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/core@npm:3.554.0" + dependencies: + "@smithy/core": ^1.4.2 + "@smithy/protocol-http": ^3.3.0 + "@smithy/signature-v4": ^2.2.1 + "@smithy/smithy-client": ^2.5.1 + "@smithy/types": ^2.12.0 + fast-xml-parser: 4.2.5 + tslib: ^2.6.2 + checksum: 61e48deee6146aa92fca14f787dfcfe1ef91e0b9feda897d375174643071119aa5b0d51185f9a23843a8aaa82372187af4bb6aa9489e20b1fc651c1734592b40 + languageName: node + linkType: hard + "@aws-sdk/core@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/core@npm:3.556.0" @@ -898,6 +1056,25 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.554.0" + dependencies: + "@aws-sdk/client-sts": 3.554.0 + "@aws-sdk/credential-provider-env": 3.535.0 + "@aws-sdk/credential-provider-process": 3.535.0 + "@aws-sdk/credential-provider-sso": 3.554.0 + "@aws-sdk/credential-provider-web-identity": 3.554.0 + "@aws-sdk/types": 3.535.0 + "@smithy/credential-provider-imds": ^2.3.0 + "@smithy/property-provider": ^2.2.0 + "@smithy/shared-ini-file-loader": ^2.4.0 + "@smithy/types": ^2.12.0 + tslib: ^2.6.2 + checksum: 5b93ba3d3cf98cd0738b871db1036303ab0b05538171a650168aefc0071a1e51e4bbd0b88d06047b6170fbf20417069beb29d8f71cae13d87c4e17c736a5a252 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/credential-provider-ini@npm:3.556.0" @@ -917,6 +1094,26 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.554.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.535.0 + "@aws-sdk/credential-provider-http": 3.552.0 + "@aws-sdk/credential-provider-ini": 3.554.0 + "@aws-sdk/credential-provider-process": 3.535.0 + "@aws-sdk/credential-provider-sso": 3.554.0 + "@aws-sdk/credential-provider-web-identity": 3.554.0 + "@aws-sdk/types": 3.535.0 + "@smithy/credential-provider-imds": ^2.3.0 + "@smithy/property-provider": ^2.2.0 + "@smithy/shared-ini-file-loader": ^2.4.0 + "@smithy/types": ^2.12.0 + tslib: ^2.6.2 + checksum: b8dfbbace2c279792934057de6879d47fb95b5c068aed0938f5c104b79034982b04e34739dba78274c9fabaf97f6079a9938ff3b01765fc3042b15abbf273190 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:3.556.0, @aws-sdk/credential-provider-node@npm:^3.350.0": version: 3.556.0 resolution: "@aws-sdk/credential-provider-node@npm:3.556.0" @@ -950,6 +1147,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.554.0" + dependencies: + "@aws-sdk/client-sso": 3.554.0 + "@aws-sdk/token-providers": 3.554.0 + "@aws-sdk/types": 3.535.0 + "@smithy/property-provider": ^2.2.0 + "@smithy/shared-ini-file-loader": ^2.4.0 + "@smithy/types": ^2.12.0 + tslib: ^2.6.2 + checksum: 0665748fce2ef6c4572570be35af31a55cee3fa3be49ef7a15be9e25ac54d3528c5a0b82c9672b7e147a9114525aeef7d9bb2a9270ef3d3e09de91d940da3779 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/credential-provider-sso@npm:3.556.0" @@ -965,6 +1177,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.554.0" + dependencies: + "@aws-sdk/client-sts": 3.554.0 + "@aws-sdk/types": 3.535.0 + "@smithy/property-provider": ^2.2.0 + "@smithy/types": ^2.12.0 + tslib: ^2.6.2 + checksum: e47772ca693bb2a0b02a1bf1b7ce288fd141510e944145d3188f532feffa3f7511f037ed5203c581764ab9d397c358c63baf28f3cf96bbce98c303ed7b9f1a2a + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.556.0" @@ -1310,6 +1535,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/token-providers@npm:3.554.0": + version: 3.554.0 + resolution: "@aws-sdk/token-providers@npm:3.554.0" + dependencies: + "@aws-sdk/client-sso-oidc": 3.554.0 + "@aws-sdk/types": 3.535.0 + "@smithy/property-provider": ^2.2.0 + "@smithy/shared-ini-file-loader": ^2.4.0 + "@smithy/types": ^2.12.0 + tslib: ^2.6.2 + checksum: ff6e13ab91ac9d5e0f7de8673a16b541b41dc445c7ec3dae66d85e47514c808ed059af4702156b437c4d2a510d378a83662ee988a853cdf2625cde886fe962cb + languageName: node + linkType: hard + "@aws-sdk/token-providers@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/token-providers@npm:3.556.0" @@ -6035,8 +6274,8 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-notifications-backend-module-email@workspace:plugins/notifications-backend-module-email" dependencies: - "@aws-sdk/client-ses": ^3.556.0 - "@aws-sdk/credential-provider-node": ^3.350.0 + "@aws-sdk/client-ses": 3.554.0 + "@aws-sdk/types": ^3.347.0 "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" @@ -6044,6 +6283,7 @@ __metadata: "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/integration-aws-node": "workspace:^" "@backstage/plugin-notifications-common": "workspace:^" "@backstage/plugin-notifications-node": "workspace:^" "@backstage/types": "workspace:^" @@ -13553,6 +13793,21 @@ __metadata: languageName: node linkType: hard +"@smithy/signature-v4@npm:^2.2.1": + version: 2.2.1 + resolution: "@smithy/signature-v4@npm:2.2.1" + dependencies: + "@smithy/is-array-buffer": ^2.2.0 + "@smithy/types": ^2.12.0 + "@smithy/util-hex-encoding": ^2.2.0 + "@smithy/util-middleware": ^2.2.0 + "@smithy/util-uri-escape": ^2.2.0 + "@smithy/util-utf8": ^2.3.0 + tslib: ^2.6.2 + checksum: 8e44f2acfff8dd690396d93a86655291b344f35ed9f82755433982cf045c69513298935e54aa3a25354d33a167ba4fdf27067486003261a1ba9edcf58f6f9bef + languageName: node + linkType: hard + "@smithy/signature-v4@npm:^2.3.0": version: 2.3.0 resolution: "@smithy/signature-v4@npm:2.3.0" From 29e16abc8592927ce93812680599152ced43dc62 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Wed, 17 Apr 2024 18:59:13 +0300 Subject: [PATCH 3/8] fix: add missing region config for ses Signed-off-by: Heikki Hellgren --- plugins/notifications-backend-module-email/config.d.ts | 4 ++++ .../src/processor/NotificationsEmailProcessor.ts | 1 + .../src/processor/transports/ses.ts | 3 +-- plugins/notifications-backend-module-email/src/types.ts | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts index fff78f735f..3d85022193 100644 --- a/plugins/notifications-backend-module-email/config.d.ts +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -61,6 +61,10 @@ export interface Config { * AWS account ID to use */ accountId?: string; + /** + * AWS region to use + */ + region?: string; } | { transport: 'sendmail'; diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts index 8150aeebe6..4a190a8bb7 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -88,6 +88,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { credentialsManager: awsCredentialsManager, apiVersion: transportConfig.getOptionalString('apiVersion'), accountId: transportConfig.getOptionalString('accountId'), + region: transportConfig.getOptionalString('region'), }); } else if (transport === 'sendmail') { this.transporter = createSendmailTransport({ diff --git a/plugins/notifications-backend-module-email/src/processor/transports/ses.ts b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts index 7073cc08b2..d208001bad 100644 --- a/plugins/notifications-backend-module-email/src/processor/transports/ses.ts +++ b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts @@ -25,8 +25,7 @@ export const createSesTransport = async (config: SesTransportConfig) => { { apiVersion: config.apiVersion ?? '2010-12-01', credentials: credentials.sdkCredentialProvider, - region: credentials.stsRegion, - accountId: credentials.accountId, + region: config.region, }, ]); return createTransport({ diff --git a/plugins/notifications-backend-module-email/src/types.ts b/plugins/notifications-backend-module-email/src/types.ts index 67c7a1740d..57c4b05234 100644 --- a/plugins/notifications-backend-module-email/src/types.ts +++ b/plugins/notifications-backend-module-email/src/types.ts @@ -55,6 +55,7 @@ export interface SesTransportConfig extends TransportConfig { */ apiVersion?: string; accountId?: string; + region?: string; credentialsManager: AwsCredentialsManager; } From 9f9dbdbdeeae2d1a6d501b273b96e077de9b778e Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 18 Apr 2024 08:21:32 +0300 Subject: [PATCH 4/8] chore: make cache config follow slack notification config Signed-off-by: Heikki Hellgren --- plugins/notifications-backend-module-email/README.md | 4 +++- plugins/notifications-backend-module-email/config.d.ts | 10 ++++++---- .../src/processor/NotificationsEmailProcessor.ts | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/notifications-backend-module-email/README.md b/plugins/notifications-backend-module-email/README.md index f87ce8c18e..1bdfd59c01 100644 --- a/plugins/notifications-backend-module-email/README.md +++ b/plugins/notifications-backend-module-email/README.md @@ -20,7 +20,9 @@ notifications: sender: 'sender@mycompany.com' replyTo: 'no-reply@mycompany.com' broadcastConfig: - receiver: 'user' + receiver: 'users' + cache: + ttl: 60000 ``` See `config.d.ts` for more options for configuration. diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts index 3d85022193..ec92bb8813 100644 --- a/plugins/notifications-backend-module-email/config.d.ts +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -105,10 +105,12 @@ export interface Config { */ receiverEmails?: string[]; }; - /** - * Email cache TTL, defaults to 1 hour - */ - cacheTtl?: number; + cache?: { + /** + * Email cache TTL, defaults to 1 hour + */ + ttl?: number; + }; }; }; }; diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts index 4a190a8bb7..f9e9ac7bd3 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -58,7 +58,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { config.getOptionalString('notifications.email.format') ?? 'html'; this.replyTo = config.getOptionalString('notifications.email.replyTo'); this.cacheTtl = - config.getOptionalNumber('notifications.email.cacheTtl') ?? 3_600_000; + config.getOptionalNumber('notifications.email.cache.ttl') ?? 3_600_000; } private async getTransporter() { From c137035b83205e8fc8a59ec125458db0093e2522 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 18 Apr 2024 08:49:50 +0300 Subject: [PATCH 5/8] feat: add support for templating the email content Signed-off-by: Heikki Hellgren --- .../README.md | 36 +++ .../api-report.md | 21 ++ .../config.d.ts | 4 - .../package.json | 2 +- .../src/extensions.ts | 41 +++ .../src/index.ts | 1 + .../src/module.ts | 15 + .../NotificationsEmailProcessor.test.ts | 6 +- .../processor/NotificationsEmailProcessor.ts | 83 ++++-- yarn.lock | 270 +----------------- 10 files changed, 181 insertions(+), 298 deletions(-) create mode 100644 plugins/notifications-backend-module-email/src/extensions.ts diff --git a/plugins/notifications-backend-module-email/README.md b/plugins/notifications-backend-module-email/README.md index 1bdfd59c01..008174e5d3 100644 --- a/plugins/notifications-backend-module-email/README.md +++ b/plugins/notifications-backend-module-email/README.md @@ -4,6 +4,42 @@ Adds support for sending Backstage notifications as emails to users. Supports sending emails using SMTP, SES, or sendmail. +## Customizing email content + +The email content can be customized with the `notificationsEmailTemplateExtensionPoint`. When you create +this extension, you can set the custom `NotificationTemplateRenderer` to the module. To modify the contents, +override the `getSubject`, `getHtml` and `getText` methods. + +```ts +import { notificationsEmailTemplateExtensionPoint } from '@backstage/plugin-notifications-backend-module-email'; +import { Notification } from '@backstage/plugin-notifications-common'; + +export const notificationsModuleEmailDecorator = createBackendModule({ + pluginId: 'notifications', + moduleId: 'email.templates', + register(reg) { + reg.registerInit({ + deps: { + emailTemplates: notificationsEmailTemplateExtensionPoint, + }, + async init({ emailTemplates }) { + emailTemplates.setTemplateRenderer({ + getSubject(notification) { + return `New notification from ${notification.source}`; + }, + getText(notification) { + return notification.content; + }, + getHtml(notification) { + return `

${notification.content}

`; + }, + }); + }, + }); + }, +}); +``` + ## Example configuration: ```yaml diff --git a/plugins/notifications-backend-module-email/api-report.md b/plugins/notifications-backend-module-email/api-report.md index 3f7c9246f5..26563bde85 100644 --- a/plugins/notifications-backend-module-email/api-report.md +++ b/plugins/notifications-backend-module-email/api-report.md @@ -4,8 +4,29 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { Notification as Notification_2 } from '@backstage/plugin-notifications-common'; + +// @public (undocumented) +export interface NotificationsEmailTemplateExtensionPoint { + // (undocumented) + setTemplateRenderer(renderer: NotificationTemplateRenderer): void; +} + +// @public (undocumented) +export const notificationsEmailTemplateExtensionPoint: ExtensionPoint; // @public (undocumented) const notificationsModuleEmail: () => BackendFeature; export default notificationsModuleEmail; + +// @public (undocumented) +export interface NotificationTemplateRenderer { + // (undocumented) + getHtml?(notification: Notification_2): string; + // (undocumented) + getSubject?(notification: Notification_2): string; + // (undocumented) + getText?(notification: Notification_2): string; +} ``` diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts index ec92bb8813..54de2f6ab4 100644 --- a/plugins/notifications-backend-module-email/config.d.ts +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -81,10 +81,6 @@ export interface Config { * Sender email address */ sender: string; - /** - * Email format, defaults to HTML - */ - format?: 'html' | 'text'; /** * Optional reply-to address */ diff --git a/plugins/notifications-backend-module-email/package.json b/plugins/notifications-backend-module-email/package.json index 345ba162b6..29cc9d7c24 100644 --- a/plugins/notifications-backend-module-email/package.json +++ b/plugins/notifications-backend-module-email/package.json @@ -32,7 +32,7 @@ "test": "backstage-cli package test" }, "dependencies": { - "@aws-sdk/client-ses": "3.554.0", + "@aws-sdk/client-ses": "^3.550.0", "@aws-sdk/types": "^3.347.0", "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", diff --git a/plugins/notifications-backend-module-email/src/extensions.ts b/plugins/notifications-backend-module-email/src/extensions.ts new file mode 100644 index 0000000000..454fe9b384 --- /dev/null +++ b/plugins/notifications-backend-module-email/src/extensions.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createExtensionPoint } from '@backstage/backend-plugin-api'; +import { Notification } from '@backstage/plugin-notifications-common'; + +/** + * @public + */ +export interface NotificationTemplateRenderer { + getSubject?(notification: Notification): string; + getText?(notification: Notification): string; + getHtml?(notification: Notification): string; +} + +/** + * @public + */ +export interface NotificationsEmailTemplateExtensionPoint { + setTemplateRenderer(renderer: NotificationTemplateRenderer): void; +} + +/** + * @public + */ +export const notificationsEmailTemplateExtensionPoint = + createExtensionPoint({ + id: 'notifications.email.templates', + }); diff --git a/plugins/notifications-backend-module-email/src/index.ts b/plugins/notifications-backend-module-email/src/index.ts index 816d5d3579..547a69360c 100644 --- a/plugins/notifications-backend-module-email/src/index.ts +++ b/plugins/notifications-backend-module-email/src/index.ts @@ -21,3 +21,4 @@ */ export { notificationsModuleEmail as default } from './module'; +export * from './extensions'; diff --git a/plugins/notifications-backend-module-email/src/module.ts b/plugins/notifications-backend-module-email/src/module.ts index 9c8dfabd8c..6b74bf70e7 100644 --- a/plugins/notifications-backend-module-email/src/module.ts +++ b/plugins/notifications-backend-module-email/src/module.ts @@ -20,6 +20,10 @@ import { import { CatalogClient } from '@backstage/catalog-client'; import { notificationsProcessingExtensionPoint } from '@backstage/plugin-notifications-node'; import { NotificationsEmailProcessor } from './processor'; +import { + notificationsEmailTemplateExtensionPoint, + NotificationTemplateRenderer, +} from './extensions'; /** * @public @@ -28,6 +32,16 @@ export const notificationsModuleEmail = createBackendModule({ pluginId: 'notifications', moduleId: 'email', register(reg) { + let templateRenderer: NotificationTemplateRenderer | undefined; + reg.registerExtensionPoint(notificationsEmailTemplateExtensionPoint, { + setTemplateRenderer(renderer) { + if (templateRenderer) { + throw new Error(`Email template renderer was already registered`); + } + templateRenderer = renderer; + }, + }); + reg.registerInit({ deps: { config: coreServices.rootConfig, @@ -49,6 +63,7 @@ export const notificationsModuleEmail = createBackendModule({ catalogClient, auth, cache, + templateRenderer, ), ); }, 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 72d1113eb1..f4db00869e 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts @@ -213,7 +213,7 @@ describe('NotificationsEmailProcessor', () => { html: '

', replyTo: undefined, subject: 'notification', - text: undefined, + text: '', to: 'mock@backstage.io', }); }); @@ -271,7 +271,7 @@ describe('NotificationsEmailProcessor', () => { html: '

', replyTo: undefined, subject: 'notification', - text: undefined, + text: '', to: 'mock@backstage.io', }); }); @@ -330,7 +330,7 @@ describe('NotificationsEmailProcessor', () => { html: '

', replyTo: undefined, subject: 'notification', - text: undefined, + text: '', to: 'broadcast@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 f9e9ac7bd3..296cf83657 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -34,12 +34,12 @@ import { createSesTransport } from './transports/ses'; import { UserEntity } from '@backstage/catalog-model'; import { compact } from 'lodash'; import { DefaultAwsCredentialsManager } from '@backstage/integration-aws-node'; +import { NotificationTemplateRenderer } from '../extensions'; export class NotificationsEmailProcessor implements NotificationProcessor { private transporter: any; private readonly broadcastConfig?: Config; private readonly sender: string; - private readonly format: string; private readonly replyTo?: string; private readonly cacheTtl: number; @@ -49,13 +49,12 @@ export class NotificationsEmailProcessor implements NotificationProcessor { private readonly catalog: CatalogClient, private readonly auth: AuthService, private readonly cache?: CacheService, + private readonly templateRenderer?: NotificationTemplateRenderer, ) { this.broadcastConfig = config.getOptionalConfig( 'notifications.email.broadcastConfig', ); this.sender = config.getString('notifications.email.sender'); - this.format = - config.getOptionalString('notifications.email.format') ?? 'html'; this.replyTo = config.getOptionalString('notifications.email.replyTo'); this.cacheTtl = config.getOptionalNumber('notifications.email.cache.ttl') ?? 3_600_000; @@ -198,6 +197,55 @@ export class NotificationsEmailProcessor implements NotificationProcessor { return await this.getUserEmail(notification.user); } + private async sendPlainEmail(notification: Notification, emails: string[]) { + const contentParts: string[] = []; + if (notification.payload.description) { + contentParts.push(`${notification.payload.description}`); + } + if (notification.payload.link) { + contentParts.push(`${notification.payload.link}`); + } + + const mailOptions = { + from: this.sender, + subject: notification.payload.title, + html: `

${contentParts.join('
')}

`, + text: contentParts.join('\n\n'), + replyTo: this.replyTo, + }; + + for (const email of emails) { + try { + await this.transporter.sendMail({ ...mailOptions, to: email }); + } catch (e) { + this.logger.error(`Failed to send email to ${email}: ${e}`); + } + } + } + + private async sendTemplateEmail( + notification: Notification, + emails: string[], + ) { + const mailOptions = { + from: this.sender, + subject: + this.templateRenderer?.getSubject?.(notification) ?? + notification.payload.title, + html: this.templateRenderer?.getHtml?.(notification), + text: this.templateRenderer?.getText?.(notification), + replyTo: this.replyTo, + }; + + for (const email of emails) { + try { + await this.transporter.sendMail({ ...mailOptions, to: email }); + } catch (e) { + this.logger.error(`Failed to send email to ${email}: ${e}`); + } + } + } + async postProcess( notification: Notification, options: NotificationSendOptions, @@ -219,32 +267,11 @@ export class NotificationsEmailProcessor implements NotificationProcessor { return; } - // TODO: add template support for content either HTML or text - const contentParts: string[] = []; - if (notification.payload.description) { - contentParts.push(`${notification.payload.description}`); - } - if (notification.payload.link) { - contentParts.push(`${notification.payload.link}`); + if (!this.templateRenderer) { + await this.sendPlainEmail(notification, emails); + return; } - const mailOptions = { - from: this.sender, - subject: notification.payload.title, - html: - this.format === 'html' - ? `

${contentParts.join('
')}

` - : undefined, - text: this.format === 'text' ? contentParts.join('\n\n') : undefined, - replyTo: this.replyTo, - }; - - for (const email of emails) { - try { - await this.transporter.sendMail({ ...mailOptions, to: email }); - } catch (e) { - this.logger.error(`Failed to send email to ${email}: ${e}`); - } - } + await this.sendTemplateEmail(notification, emails); } } diff --git a/yarn.lock b/yarn.lock index 94ba875fae..40554ed539 100644 --- a/yarn.lock +++ b/yarn.lock @@ -599,15 +599,15 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-ses@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/client-ses@npm:3.554.0" +"@aws-sdk/client-ses@npm:^3.550.0": + version: 3.556.0 + resolution: "@aws-sdk/client-ses@npm:3.556.0" dependencies: "@aws-crypto/sha256-browser": 3.0.0 "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.554.0 - "@aws-sdk/core": 3.554.0 - "@aws-sdk/credential-provider-node": 3.554.0 + "@aws-sdk/client-sts": 3.556.0 + "@aws-sdk/core": 3.556.0 + "@aws-sdk/credential-provider-node": 3.556.0 "@aws-sdk/middleware-host-header": 3.535.0 "@aws-sdk/middleware-logger": 3.535.0 "@aws-sdk/middleware-recursion-detection": 3.535.0 @@ -644,7 +644,7 @@ __metadata: "@smithy/util-utf8": ^2.3.0 "@smithy/util-waiter": ^2.2.0 tslib: ^2.6.2 - checksum: 14512aac8bd84a69066535580bd739509a5698a64be78b111fd9656f4a01f4921c016b0c22c8b103261370d9e75ba3c6301cc652b47e396e421b8b02f93334ce + checksum: 913108e79061185faae51711b121df99da624f988f530af23c3e4270299be60771be83f507dc8ed4af4051765077ea038edab19e4d0d32e159c4e67faf5fc9f8 languageName: node linkType: hard @@ -698,55 +698,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-sso-oidc@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/client-sso-oidc@npm:3.554.0" - dependencies: - "@aws-crypto/sha256-browser": 3.0.0 - "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/client-sts": 3.554.0 - "@aws-sdk/core": 3.554.0 - "@aws-sdk/middleware-host-header": 3.535.0 - "@aws-sdk/middleware-logger": 3.535.0 - "@aws-sdk/middleware-recursion-detection": 3.535.0 - "@aws-sdk/middleware-user-agent": 3.540.0 - "@aws-sdk/region-config-resolver": 3.535.0 - "@aws-sdk/types": 3.535.0 - "@aws-sdk/util-endpoints": 3.540.0 - "@aws-sdk/util-user-agent-browser": 3.535.0 - "@aws-sdk/util-user-agent-node": 3.535.0 - "@smithy/config-resolver": ^2.2.0 - "@smithy/core": ^1.4.2 - "@smithy/fetch-http-handler": ^2.5.0 - "@smithy/hash-node": ^2.2.0 - "@smithy/invalid-dependency": ^2.2.0 - "@smithy/middleware-content-length": ^2.2.0 - "@smithy/middleware-endpoint": ^2.5.1 - "@smithy/middleware-retry": ^2.3.1 - "@smithy/middleware-serde": ^2.3.0 - "@smithy/middleware-stack": ^2.2.0 - "@smithy/node-config-provider": ^2.3.0 - "@smithy/node-http-handler": ^2.5.0 - "@smithy/protocol-http": ^3.3.0 - "@smithy/smithy-client": ^2.5.1 - "@smithy/types": ^2.12.0 - "@smithy/url-parser": ^2.2.0 - "@smithy/util-base64": ^2.3.0 - "@smithy/util-body-length-browser": ^2.2.0 - "@smithy/util-body-length-node": ^2.3.0 - "@smithy/util-defaults-mode-browser": ^2.2.1 - "@smithy/util-defaults-mode-node": ^2.3.1 - "@smithy/util-endpoints": ^1.2.0 - "@smithy/util-middleware": ^2.2.0 - "@smithy/util-retry": ^2.2.0 - "@smithy/util-utf8": ^2.3.0 - tslib: ^2.6.2 - peerDependencies: - "@aws-sdk/credential-provider-node": ^3.554.0 - checksum: 91d0f6b8c5c787e2b705803d615f9400430ea0bca9d12171a5e7abe5c53cd83f711300e5c0a106dc4f1f1259adf08f87b728f9820679aa5ca73cd66a09bf8dd4 - languageName: node - linkType: hard - "@aws-sdk/client-sso-oidc@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/client-sso-oidc@npm:3.556.0" @@ -796,52 +747,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-sso@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/client-sso@npm:3.554.0" - dependencies: - "@aws-crypto/sha256-browser": 3.0.0 - "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/core": 3.554.0 - "@aws-sdk/middleware-host-header": 3.535.0 - "@aws-sdk/middleware-logger": 3.535.0 - "@aws-sdk/middleware-recursion-detection": 3.535.0 - "@aws-sdk/middleware-user-agent": 3.540.0 - "@aws-sdk/region-config-resolver": 3.535.0 - "@aws-sdk/types": 3.535.0 - "@aws-sdk/util-endpoints": 3.540.0 - "@aws-sdk/util-user-agent-browser": 3.535.0 - "@aws-sdk/util-user-agent-node": 3.535.0 - "@smithy/config-resolver": ^2.2.0 - "@smithy/core": ^1.4.2 - "@smithy/fetch-http-handler": ^2.5.0 - "@smithy/hash-node": ^2.2.0 - "@smithy/invalid-dependency": ^2.2.0 - "@smithy/middleware-content-length": ^2.2.0 - "@smithy/middleware-endpoint": ^2.5.1 - "@smithy/middleware-retry": ^2.3.1 - "@smithy/middleware-serde": ^2.3.0 - "@smithy/middleware-stack": ^2.2.0 - "@smithy/node-config-provider": ^2.3.0 - "@smithy/node-http-handler": ^2.5.0 - "@smithy/protocol-http": ^3.3.0 - "@smithy/smithy-client": ^2.5.1 - "@smithy/types": ^2.12.0 - "@smithy/url-parser": ^2.2.0 - "@smithy/util-base64": ^2.3.0 - "@smithy/util-body-length-browser": ^2.2.0 - "@smithy/util-body-length-node": ^2.3.0 - "@smithy/util-defaults-mode-browser": ^2.2.1 - "@smithy/util-defaults-mode-node": ^2.3.1 - "@smithy/util-endpoints": ^1.2.0 - "@smithy/util-middleware": ^2.2.0 - "@smithy/util-retry": ^2.2.0 - "@smithy/util-utf8": ^2.3.0 - tslib: ^2.6.2 - checksum: 08dbaca87bb25d7662a2e856dca16f92bc461b9cf50e048c679969df93189f653c2bc74c8be1ac3e82e720d2742fc81af554a04f0ebf83353c1fa6e30fdbbecf - languageName: node - linkType: hard - "@aws-sdk/client-sso@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/client-sso@npm:3.556.0" @@ -888,54 +793,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-sts@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/client-sts@npm:3.554.0" - dependencies: - "@aws-crypto/sha256-browser": 3.0.0 - "@aws-crypto/sha256-js": 3.0.0 - "@aws-sdk/core": 3.554.0 - "@aws-sdk/middleware-host-header": 3.535.0 - "@aws-sdk/middleware-logger": 3.535.0 - "@aws-sdk/middleware-recursion-detection": 3.535.0 - "@aws-sdk/middleware-user-agent": 3.540.0 - "@aws-sdk/region-config-resolver": 3.535.0 - "@aws-sdk/types": 3.535.0 - "@aws-sdk/util-endpoints": 3.540.0 - "@aws-sdk/util-user-agent-browser": 3.535.0 - "@aws-sdk/util-user-agent-node": 3.535.0 - "@smithy/config-resolver": ^2.2.0 - "@smithy/core": ^1.4.2 - "@smithy/fetch-http-handler": ^2.5.0 - "@smithy/hash-node": ^2.2.0 - "@smithy/invalid-dependency": ^2.2.0 - "@smithy/middleware-content-length": ^2.2.0 - "@smithy/middleware-endpoint": ^2.5.1 - "@smithy/middleware-retry": ^2.3.1 - "@smithy/middleware-serde": ^2.3.0 - "@smithy/middleware-stack": ^2.2.0 - "@smithy/node-config-provider": ^2.3.0 - "@smithy/node-http-handler": ^2.5.0 - "@smithy/protocol-http": ^3.3.0 - "@smithy/smithy-client": ^2.5.1 - "@smithy/types": ^2.12.0 - "@smithy/url-parser": ^2.2.0 - "@smithy/util-base64": ^2.3.0 - "@smithy/util-body-length-browser": ^2.2.0 - "@smithy/util-body-length-node": ^2.3.0 - "@smithy/util-defaults-mode-browser": ^2.2.1 - "@smithy/util-defaults-mode-node": ^2.3.1 - "@smithy/util-endpoints": ^1.2.0 - "@smithy/util-middleware": ^2.2.0 - "@smithy/util-retry": ^2.2.0 - "@smithy/util-utf8": ^2.3.0 - tslib: ^2.6.2 - peerDependencies: - "@aws-sdk/credential-provider-node": ^3.554.0 - checksum: dee932b55e89f9b68275d21861f534ae1cdf006410841fe84bab38df12253ebafd4928e440d6243391c3234f2509b943628c5368d2bcd0489ea240464d65edc6 - languageName: node - linkType: hard - "@aws-sdk/client-sts@npm:3.556.0, @aws-sdk/client-sts@npm:^3.350.0": version: 3.556.0 resolution: "@aws-sdk/client-sts@npm:3.556.0" @@ -984,21 +841,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/core@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/core@npm:3.554.0" - dependencies: - "@smithy/core": ^1.4.2 - "@smithy/protocol-http": ^3.3.0 - "@smithy/signature-v4": ^2.2.1 - "@smithy/smithy-client": ^2.5.1 - "@smithy/types": ^2.12.0 - fast-xml-parser: 4.2.5 - tslib: ^2.6.2 - checksum: 61e48deee6146aa92fca14f787dfcfe1ef91e0b9feda897d375174643071119aa5b0d51185f9a23843a8aaa82372187af4bb6aa9489e20b1fc651c1734592b40 - languageName: node - linkType: hard - "@aws-sdk/core@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/core@npm:3.556.0" @@ -1056,25 +898,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-ini@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/credential-provider-ini@npm:3.554.0" - dependencies: - "@aws-sdk/client-sts": 3.554.0 - "@aws-sdk/credential-provider-env": 3.535.0 - "@aws-sdk/credential-provider-process": 3.535.0 - "@aws-sdk/credential-provider-sso": 3.554.0 - "@aws-sdk/credential-provider-web-identity": 3.554.0 - "@aws-sdk/types": 3.535.0 - "@smithy/credential-provider-imds": ^2.3.0 - "@smithy/property-provider": ^2.2.0 - "@smithy/shared-ini-file-loader": ^2.4.0 - "@smithy/types": ^2.12.0 - tslib: ^2.6.2 - checksum: 5b93ba3d3cf98cd0738b871db1036303ab0b05538171a650168aefc0071a1e51e4bbd0b88d06047b6170fbf20417069beb29d8f71cae13d87c4e17c736a5a252 - languageName: node - linkType: hard - "@aws-sdk/credential-provider-ini@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/credential-provider-ini@npm:3.556.0" @@ -1094,26 +917,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/credential-provider-node@npm:3.554.0" - dependencies: - "@aws-sdk/credential-provider-env": 3.535.0 - "@aws-sdk/credential-provider-http": 3.552.0 - "@aws-sdk/credential-provider-ini": 3.554.0 - "@aws-sdk/credential-provider-process": 3.535.0 - "@aws-sdk/credential-provider-sso": 3.554.0 - "@aws-sdk/credential-provider-web-identity": 3.554.0 - "@aws-sdk/types": 3.535.0 - "@smithy/credential-provider-imds": ^2.3.0 - "@smithy/property-provider": ^2.2.0 - "@smithy/shared-ini-file-loader": ^2.4.0 - "@smithy/types": ^2.12.0 - tslib: ^2.6.2 - checksum: b8dfbbace2c279792934057de6879d47fb95b5c068aed0938f5c104b79034982b04e34739dba78274c9fabaf97f6079a9938ff3b01765fc3042b15abbf273190 - languageName: node - linkType: hard - "@aws-sdk/credential-provider-node@npm:3.556.0, @aws-sdk/credential-provider-node@npm:^3.350.0": version: 3.556.0 resolution: "@aws-sdk/credential-provider-node@npm:3.556.0" @@ -1147,21 +950,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-sso@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/credential-provider-sso@npm:3.554.0" - dependencies: - "@aws-sdk/client-sso": 3.554.0 - "@aws-sdk/token-providers": 3.554.0 - "@aws-sdk/types": 3.535.0 - "@smithy/property-provider": ^2.2.0 - "@smithy/shared-ini-file-loader": ^2.4.0 - "@smithy/types": ^2.12.0 - tslib: ^2.6.2 - checksum: 0665748fce2ef6c4572570be35af31a55cee3fa3be49ef7a15be9e25ac54d3528c5a0b82c9672b7e147a9114525aeef7d9bb2a9270ef3d3e09de91d940da3779 - languageName: node - linkType: hard - "@aws-sdk/credential-provider-sso@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/credential-provider-sso@npm:3.556.0" @@ -1177,19 +965,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-web-identity@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/credential-provider-web-identity@npm:3.554.0" - dependencies: - "@aws-sdk/client-sts": 3.554.0 - "@aws-sdk/types": 3.535.0 - "@smithy/property-provider": ^2.2.0 - "@smithy/types": ^2.12.0 - tslib: ^2.6.2 - checksum: e47772ca693bb2a0b02a1bf1b7ce288fd141510e944145d3188f532feffa3f7511f037ed5203c581764ab9d397c358c63baf28f3cf96bbce98c303ed7b9f1a2a - languageName: node - linkType: hard - "@aws-sdk/credential-provider-web-identity@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.556.0" @@ -1535,20 +1310,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/token-providers@npm:3.554.0": - version: 3.554.0 - resolution: "@aws-sdk/token-providers@npm:3.554.0" - dependencies: - "@aws-sdk/client-sso-oidc": 3.554.0 - "@aws-sdk/types": 3.535.0 - "@smithy/property-provider": ^2.2.0 - "@smithy/shared-ini-file-loader": ^2.4.0 - "@smithy/types": ^2.12.0 - tslib: ^2.6.2 - checksum: ff6e13ab91ac9d5e0f7de8673a16b541b41dc445c7ec3dae66d85e47514c808ed059af4702156b437c4d2a510d378a83662ee988a853cdf2625cde886fe962cb - languageName: node - linkType: hard - "@aws-sdk/token-providers@npm:3.556.0": version: 3.556.0 resolution: "@aws-sdk/token-providers@npm:3.556.0" @@ -6274,7 +6035,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-notifications-backend-module-email@workspace:plugins/notifications-backend-module-email" dependencies: - "@aws-sdk/client-ses": 3.554.0 + "@aws-sdk/client-ses": ^3.550.0 "@aws-sdk/types": ^3.347.0 "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" @@ -13793,21 +13554,6 @@ __metadata: languageName: node linkType: hard -"@smithy/signature-v4@npm:^2.2.1": - version: 2.2.1 - resolution: "@smithy/signature-v4@npm:2.2.1" - dependencies: - "@smithy/is-array-buffer": ^2.2.0 - "@smithy/types": ^2.12.0 - "@smithy/util-hex-encoding": ^2.2.0 - "@smithy/util-middleware": ^2.2.0 - "@smithy/util-uri-escape": ^2.2.0 - "@smithy/util-utf8": ^2.3.0 - tslib: ^2.6.2 - checksum: 8e44f2acfff8dd690396d93a86655291b344f35ed9f82755433982cf045c69513298935e54aa3a25354d33a167ba4fdf27067486003261a1ba9edcf58f6f9bef - languageName: node - linkType: hard - "@smithy/signature-v4@npm:^2.3.0": version: 2.3.0 resolution: "@smithy/signature-v4@npm:2.3.0" From 4e4e5607e2d544b3f951cef5095028fc719302bf Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Wed, 24 Apr 2024 09:18:35 +0300 Subject: [PATCH 6/8] fix: review findings for email notification processor Signed-off-by: Heikki Hellgren --- .../README.md | 12 +- .../config.d.ts | 8 +- .../package.json | 3 +- .../NotificationsEmailProcessor.test.ts | 92 ++++++------ .../processor/NotificationsEmailProcessor.ts | 133 +++++++++--------- .../src/processor/transports/sendmail.ts | 8 +- .../src/processor/transports/ses.ts | 16 ++- .../src/processor/transports/smtp.ts | 20 +-- .../src/types.ts | 72 ---------- yarn.lock | 1 + 10 files changed, 161 insertions(+), 204 deletions(-) delete mode 100644 plugins/notifications-backend-module-email/src/types.ts diff --git a/plugins/notifications-backend-module-email/README.md b/plugins/notifications-backend-module-email/README.md index 008174e5d3..7bbac05b62 100644 --- a/plugins/notifications-backend-module-email/README.md +++ b/plugins/notifications-backend-module-email/README.md @@ -2,7 +2,7 @@ Adds support for sending Backstage notifications as emails to users. -Supports sending emails using SMTP, SES, or sendmail. +Supports sending emails using `SMTP`, `SES`, or `sendmail`. ## Customizing email content @@ -46,6 +46,7 @@ export const notificationsModuleEmailDecorator = createBackendModule({ notifications: processors: email: + # Transport config, see options at `config.d.ts` transportConfig: transport: 'smtp' hostname: 'my-smtp-server' @@ -53,12 +54,19 @@ notifications: secure: false username: 'my-username' password: 'my-password' + # The email sender address sender: 'sender@mycompany.com' replyTo: 'no-reply@mycompany.com' + # Who to get email for broadcast notifications broadcastConfig: receiver: 'users' + # How many emails to send concurrently, defaults to 2 + concurrencyLimit: 10 + # Cache configuration for email addresses + # This is to prevent unnecessary calls to the catalog cache: - ttl: 60000 + ttl: + days: 1 ``` See `config.d.ts` for more options for configuration. diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts index 54de2f6ab4..90fb43e046 100644 --- a/plugins/notifications-backend-module-email/config.d.ts +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { HumanDuration } from '@backstage/types'; + export interface Config { /** * Configuration options for notifications-backend-module-email */ @@ -85,6 +87,10 @@ export interface Config { * Optional reply-to address */ replyTo?: string; + /** + * Concurrency limit for email sending, defaults to 2 + */ + concurrencyLimit?: number; /** * Configuration for broadcast notifications */ @@ -105,7 +111,7 @@ export interface Config { /** * Email cache TTL, defaults to 1 hour */ - ttl?: number; + ttl?: HumanDuration; }; }; }; diff --git a/plugins/notifications-backend-module-email/package.json b/plugins/notifications-backend-module-email/package.json index 29cc9d7c24..3d44db6a45 100644 --- a/plugins/notifications-backend-module-email/package.json +++ b/plugins/notifications-backend-module-email/package.json @@ -44,7 +44,8 @@ "@backstage/plugin-notifications-node": "workspace:^", "@backstage/types": "workspace:^", "lodash": "^4.17.21", - "nodemailer": "^6.9.13" + "nodemailer": "^6.9.13", + "p-limit": "^3.1.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", 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 f4db00869e..3af1e498ca 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts @@ -50,15 +50,17 @@ describe('NotificationsEmailProcessor', () => { logger, new ConfigReader({ notifications: { - email: { - transport: { - transport: 'smtp', - hostname: 'localhost', - port: 465, - secure: true, - requireTls: false, + processors: { + email: { + transport: { + transport: 'smtp', + hostname: 'localhost', + port: 465, + secure: true, + requireTls: false, + }, + sender: 'backstage@backstage.io', }, - sender: 'backstage@backstage.io', }, }, }), @@ -94,12 +96,14 @@ describe('NotificationsEmailProcessor', () => { logger, new ConfigReader({ notifications: { - email: { - transport: { - transport: 'ses', - region: 'us-west-2', + processors: { + email: { + transport: { + transport: 'ses', + region: 'us-west-2', + }, + sender: 'backstage@backstage.io', }, - sender: 'backstage@backstage.io', }, }, }), @@ -132,12 +136,14 @@ describe('NotificationsEmailProcessor', () => { logger, new ConfigReader({ notifications: { - email: { - transport: { - transport: 'sendmail', - path: '/usr/local/bin/sendmail', + processors: { + email: { + transport: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', }, - sender: 'backstage@backstage.io', }, }, }), @@ -181,12 +187,14 @@ describe('NotificationsEmailProcessor', () => { logger, new ConfigReader({ notifications: { - email: { - transport: { - transport: 'sendmail', - path: '/usr/local/bin/sendmail', + processors: { + email: { + transport: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', }, - sender: 'backstage@backstage.io', }, }, }), @@ -236,14 +244,16 @@ describe('NotificationsEmailProcessor', () => { logger, new ConfigReader({ notifications: { - email: { - transport: { - transport: 'sendmail', - path: '/usr/local/bin/sendmail', - }, - sender: 'backstage@backstage.io', - broadcastConfig: { - receiver: 'users', + processors: { + email: { + transport: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', + broadcastConfig: { + receiver: 'users', + }, }, }, }, @@ -294,15 +304,17 @@ describe('NotificationsEmailProcessor', () => { logger, new ConfigReader({ notifications: { - email: { - transport: { - transport: 'sendmail', - path: '/usr/local/bin/sendmail', - }, - sender: 'backstage@backstage.io', - broadcastConfig: { - receiver: 'config', - receiverEmails: ['broadcast@backstage.io'] as JsonArray, + processors: { + email: { + transport: { + transport: 'sendmail', + path: '/usr/local/bin/sendmail', + }, + sender: 'backstage@backstage.io', + broadcastConfig: { + receiver: 'config', + receiverEmails: ['broadcast@backstage.io'] as JsonArray, + }, }, }, }, diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts index 296cf83657..a52495a8ea 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -22,26 +22,33 @@ import { CacheService, LoggerService, } from '@backstage/backend-plugin-api'; -import { Config } from '@backstage/config'; -import { JsonArray } from '@backstage/types'; +import { Config, readDurationFromConfig } from '@backstage/config'; +import { durationToMilliseconds, JsonArray } from '@backstage/types'; import { CATALOG_FILTER_EXISTS, CatalogClient, } from '@backstage/catalog-client'; import { Notification } from '@backstage/plugin-notifications-common'; -import { createSendmailTransport, createSmtpTransport } from './transports'; -import { createSesTransport } from './transports/ses'; +import { + createSendmailTransport, + createSesTransport, + createSmtpTransport, +} from './transports'; import { UserEntity } from '@backstage/catalog-model'; import { compact } from 'lodash'; import { DefaultAwsCredentialsManager } from '@backstage/integration-aws-node'; import { NotificationTemplateRenderer } from '../extensions'; +import Mail from 'nodemailer/lib/mailer'; +import pLimit from 'p-limit'; export class NotificationsEmailProcessor implements NotificationProcessor { private transporter: any; private readonly broadcastConfig?: Config; + private readonly transportConfig: Config; private readonly sender: string; private readonly replyTo?: string; private readonly cacheTtl: number; + private readonly concurrencyLimit: number; constructor( private readonly logger: LoggerService, @@ -51,50 +58,39 @@ export class NotificationsEmailProcessor implements NotificationProcessor { private readonly cache?: CacheService, private readonly templateRenderer?: NotificationTemplateRenderer, ) { - this.broadcastConfig = config.getOptionalConfig( - 'notifications.email.broadcastConfig', + const emailProcessorConfig = config.getConfig( + 'notifications.processors.email', ); - this.sender = config.getString('notifications.email.sender'); - this.replyTo = config.getOptionalString('notifications.email.replyTo'); - this.cacheTtl = - config.getOptionalNumber('notifications.email.cache.ttl') ?? 3_600_000; + this.transportConfig = emailProcessorConfig.getConfig('transport'); + this.broadcastConfig = + emailProcessorConfig.getOptionalConfig('broadcastConfig'); + this.sender = emailProcessorConfig.getString('sender'); + this.replyTo = emailProcessorConfig.getOptionalString('replyTo'); + this.concurrencyLimit = + emailProcessorConfig.getOptionalNumber('concurrencyLimit') ?? 2; + const cacheConfig = emailProcessorConfig.getOptionalConfig('cache.ttl'); + this.cacheTtl = cacheConfig + ? durationToMilliseconds(readDurationFromConfig(cacheConfig)) + : 3_600_000; } private async getTransporter() { if (this.transporter) { return this.transporter; } - const transportConfig = this.config.getConfig( - 'notifications.email.transport', - ); - const transport = transportConfig.getString('transport'); + const transport = this.transportConfig.getString('transport'); if (transport === 'smtp') { - this.transporter = createSmtpTransport({ - transport: 'smtp', - hostname: transportConfig.getString('hostname'), - port: transportConfig.getNumber('port'), - secure: transportConfig.getOptionalBoolean('secure'), - requireTls: transportConfig.getOptionalBoolean('requireTls'), - username: transportConfig.getOptionalString('username'), - password: transportConfig.getOptionalString('password'), - }); + this.transporter = createSmtpTransport(this.transportConfig); } else if (transport === 'ses') { const awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig( this.config, ); - this.transporter = await createSesTransport({ - transport: 'ses', - credentialsManager: awsCredentialsManager, - apiVersion: transportConfig.getOptionalString('apiVersion'), - accountId: transportConfig.getOptionalString('accountId'), - region: transportConfig.getOptionalString('region'), - }); + this.transporter = await createSesTransport( + this.transportConfig, + awsCredentialsManager, + ); } else if (transport === 'sendmail') { - this.transporter = createSendmailTransport({ - transport: 'sendmail', - path: transportConfig.getOptionalString('path'), - newline: transportConfig.getOptionalString('newline'), - }); + this.transporter = createSendmailTransport(this.transportConfig); } else { throw new Error(`Unsupported transport: ${transport}`); } @@ -122,14 +118,13 @@ export class NotificationsEmailProcessor implements NotificationProcessor { } if (receiver === 'users') { - const cached = await this.cache?.get('user-emails:all'); + const cached = await this.cache?.get('user-emails:all'); if (cached) { - return cached as string[]; + return cached; } - const credentials = await this.auth.getOwnServiceCredentials(); const { token } = await this.auth.getPluginRequestToken({ - onBehalfOf: credentials, + onBehalfOf: await this.auth.getOwnServiceCredentials(), targetPluginId: 'catalog', }); const entities = await this.catalog.getEntities( @@ -148,6 +143,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { }), ), ]); + await this.cache?.set('user-emails:all', ret as JsonArray, { ttl: this.cacheTtl, }); @@ -163,28 +159,24 @@ export class NotificationsEmailProcessor implements NotificationProcessor { return [cached]; } - const credentials = await this.auth.getOwnServiceCredentials(); const { token } = await this.auth.getPluginRequestToken({ - onBehalfOf: credentials, + onBehalfOf: await this.auth.getOwnServiceCredentials(), targetPluginId: 'catalog', }); const entity = await this.catalog.getEntityByRef(entityRef, { token }); - if (!entity) { - return []; + const ret: string[] = []; + if (entity) { + const userEntity = entity as UserEntity; + if (userEntity.spec.profile?.email) { + ret.push(userEntity.spec.profile.email); + } } - const userEntity = entity as UserEntity; - if (!userEntity.spec.profile?.email) { - return []; - } + await this.cache?.set(`user-emails:${entityRef}`, ret[0], { + ttl: this.cacheTtl, + }); - await this.cache?.set( - `user-emails:${entityRef}`, - userEntity.spec.profile.email, - { ttl: this.cacheTtl }, - ); - - return [userEntity.spec.profile.email]; + return ret; } private async getRecipientEmails( @@ -197,6 +189,23 @@ export class NotificationsEmailProcessor implements NotificationProcessor { return await this.getUserEmail(notification.user); } + private async sendMail(options: Mail.Options) { + try { + await this.transporter.sendMail(options); + } catch (e) { + this.logger.error(`Failed to send email to ${options.to}: ${e}`); + } + } + + private async sendMails(options: Mail.Options, emails: string[]) { + const limit = pLimit(this.concurrencyLimit); + await Promise.all( + emails.map(email => + limit(() => this.sendMail({ ...options, to: email })), + ), + ); + } + private async sendPlainEmail(notification: Notification, emails: string[]) { const contentParts: string[] = []; if (notification.payload.description) { @@ -214,13 +223,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { replyTo: this.replyTo, }; - for (const email of emails) { - try { - await this.transporter.sendMail({ ...mailOptions, to: email }); - } catch (e) { - this.logger.error(`Failed to send email to ${email}: ${e}`); - } - } + await this.sendMails(mailOptions, emails); } private async sendTemplateEmail( @@ -237,13 +240,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { replyTo: this.replyTo, }; - for (const email of emails) { - try { - await this.transporter.sendMail({ ...mailOptions, to: email }); - } catch (e) { - this.logger.error(`Failed to send email to ${email}: ${e}`); - } - } + await this.sendMails(mailOptions, emails); } async postProcess( diff --git a/plugins/notifications-backend-module-email/src/processor/transports/sendmail.ts b/plugins/notifications-backend-module-email/src/processor/transports/sendmail.ts index 8f093f562d..e16ea37ac9 100644 --- a/plugins/notifications-backend-module-email/src/processor/transports/sendmail.ts +++ b/plugins/notifications-backend-module-email/src/processor/transports/sendmail.ts @@ -14,12 +14,12 @@ * limitations under the License. */ import { createTransport } from 'nodemailer'; -import { SendmailTransportConfig } from '../../types'; +import { Config } from '@backstage/config'; -export const createSendmailTransport = (config: SendmailTransportConfig) => { +export const createSendmailTransport = (config: Config) => { return createTransport({ sendmail: true, - newline: config.newline ?? 'unix', - path: config.path ?? '/usr/sbin/sendmail', + newline: config.getOptionalString('newline') ?? 'unix', + path: config.getOptionalString('path') ?? '/usr/sbin/sendmail', }); }; diff --git a/plugins/notifications-backend-module-email/src/processor/transports/ses.ts b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts index d208001bad..0be23078ab 100644 --- a/plugins/notifications-backend-module-email/src/processor/transports/ses.ts +++ b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts @@ -13,19 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SesTransportConfig } from '../../types'; import { createTransport } from 'nodemailer'; import { SendRawEmailCommand, SES } from '@aws-sdk/client-ses'; +import { Config } from '@backstage/config'; +import { AwsCredentialsManager } from '@backstage/integration-aws-node'; -export const createSesTransport = async (config: SesTransportConfig) => { - const credentials = await config.credentialsManager.getCredentialProvider({ - accountId: config.accountId, +export const createSesTransport = async ( + config: Config, + credentialsManager: AwsCredentialsManager, +) => { + const credentials = await credentialsManager.getCredentialProvider({ + accountId: config.getOptionalString('accountId'), }); const ses = new SES([ { - apiVersion: config.apiVersion ?? '2010-12-01', + apiVersion: config.getOptionalString('apiVersion') ?? '2010-12-01', credentials: credentials.sdkCredentialProvider, - region: config.region, + region: config.getOptionalString('region'), }, ]); return createTransport({ diff --git a/plugins/notifications-backend-module-email/src/processor/transports/smtp.ts b/plugins/notifications-backend-module-email/src/processor/transports/smtp.ts index e473ea5ba7..feea417c27 100644 --- a/plugins/notifications-backend-module-email/src/processor/transports/smtp.ts +++ b/plugins/notifications-backend-module-email/src/processor/transports/smtp.ts @@ -13,18 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SmtpTransportConfig } from '../../types'; import { createTransport } from 'nodemailer'; +import { Config } from '@backstage/config'; + +export const createSmtpTransport = (config: Config) => { + const username = config.getOptionalString('username'); + const password = config.getOptionalString('password'); -export const createSmtpTransport = (config: SmtpTransportConfig) => { return createTransport({ - host: config.hostname, - port: config.port, - secure: config.secure ?? false, - requireTLS: config.requireTls ?? false, - auth: - config.username && config.password - ? { user: config.username, pass: config.password } - : undefined, + host: config.getString('hostname'), + port: config.getNumber('port'), + secure: config.getOptionalBoolean('secure') ?? false, + requireTLS: config.getOptionalBoolean('requireTls') ?? false, + auth: username && password ? { user: username, pass: password } : undefined, }); }; diff --git a/plugins/notifications-backend-module-email/src/types.ts b/plugins/notifications-backend-module-email/src/types.ts deleted file mode 100644 index 57c4b05234..0000000000 --- a/plugins/notifications-backend-module-email/src/types.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { AwsCredentialsManager } from '@backstage/integration-aws-node'; - -export interface TransportConfig { - transport: 'smtp' | 'ses' | 'sendmail'; -} - -export interface SmtpTransportConfig extends TransportConfig { - transport: 'smtp'; - /** - * SMTP server hostname - */ - hostname: string; - /** - * SMTP server port - */ - port: number; - /** - * Use secure connection for SMTP, defaults to false - */ - secure?: boolean; - /** - * Require TLS for SMTP connection, defaults to false - */ - requireTls?: boolean; - /** - * SMTP username - */ - username?: string; - /** - * SMTP password - * @visibility secret - */ - password?: string; -} - -export interface SesTransportConfig extends TransportConfig { - transport: 'ses'; - /** - * SES ApiVersion to use, defaults to 2010-12-01 - */ - apiVersion?: string; - accountId?: string; - region?: string; - credentialsManager: AwsCredentialsManager; -} - -export interface SendmailTransportConfig extends TransportConfig { - transport: 'sendmail'; - /** - * Sendmail binary path, defaults to /usr/sbin/sendmail - */ - path?: string; - /** - * Newline style, defaults to 'unix' - */ - newline?: string; -} diff --git a/yarn.lock b/yarn.lock index 40554ed539..400095afee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6051,6 +6051,7 @@ __metadata: "@types/nodemailer": ^6.4.14 lodash: ^4.17.21 nodemailer: ^6.9.13 + p-limit: ^3.1.0 languageName: unknown linkType: soft From 49295af2c16e50b259ec39e9c0c68ed8c0d11d13 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Wed, 24 Apr 2024 10:30:28 +0300 Subject: [PATCH 7/8] feat: allow throttling email sending Signed-off-by: Heikki Hellgren --- .../config.d.ts | 4 ++++ .../package.json | 2 +- .../processor/NotificationsEmailProcessor.ts | 19 ++++++++++++++----- yarn.lock | 9 ++++++++- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts index 90fb43e046..db49d0016a 100644 --- a/plugins/notifications-backend-module-email/config.d.ts +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -91,6 +91,10 @@ export interface Config { * Concurrency limit for email sending, defaults to 2 */ concurrencyLimit?: number; + /** + * Throttle duration between email sending, defaults to 100ms + */ + throttleInterval?: HumanDuration; /** * Configuration for broadcast notifications */ diff --git a/plugins/notifications-backend-module-email/package.json b/plugins/notifications-backend-module-email/package.json index 3d44db6a45..dfff036a13 100644 --- a/plugins/notifications-backend-module-email/package.json +++ b/plugins/notifications-backend-module-email/package.json @@ -45,7 +45,7 @@ "@backstage/types": "workspace:^", "lodash": "^4.17.21", "nodemailer": "^6.9.13", - "p-limit": "^3.1.0" + "p-throttle": "^6.1.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts index a52495a8ea..d9f6b73e0a 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -39,7 +39,7 @@ import { compact } from 'lodash'; import { DefaultAwsCredentialsManager } from '@backstage/integration-aws-node'; import { NotificationTemplateRenderer } from '../extensions'; import Mail from 'nodemailer/lib/mailer'; -import pLimit from 'p-limit'; +import pThrottle from 'p-throttle'; export class NotificationsEmailProcessor implements NotificationProcessor { private transporter: any; @@ -49,6 +49,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { private readonly replyTo?: string; private readonly cacheTtl: number; private readonly concurrencyLimit: number; + private readonly throttleInterval: number; constructor( private readonly logger: LoggerService, @@ -68,6 +69,11 @@ export class NotificationsEmailProcessor implements NotificationProcessor { this.replyTo = emailProcessorConfig.getOptionalString('replyTo'); this.concurrencyLimit = emailProcessorConfig.getOptionalNumber('concurrencyLimit') ?? 2; + const throttleConfig = + emailProcessorConfig.getOptionalConfig('throttleInterval'); + this.throttleInterval = throttleConfig + ? durationToMilliseconds(readDurationFromConfig(throttleConfig)) + : 100; const cacheConfig = emailProcessorConfig.getOptionalConfig('cache.ttl'); this.cacheTtl = cacheConfig ? durationToMilliseconds(readDurationFromConfig(cacheConfig)) @@ -198,11 +204,14 @@ export class NotificationsEmailProcessor implements NotificationProcessor { } private async sendMails(options: Mail.Options, emails: string[]) { - const limit = pLimit(this.concurrencyLimit); + const throttle = pThrottle({ + limit: this.concurrencyLimit, + interval: this.throttleInterval, + }); + + const throttled = throttle((opts: Mail.Options) => this.sendMail(opts)); await Promise.all( - emails.map(email => - limit(() => this.sendMail({ ...options, to: email })), - ), + emails.map(email => throttled({ ...options, to: email })), ); } diff --git a/yarn.lock b/yarn.lock index 400095afee..846721560c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6051,7 +6051,7 @@ __metadata: "@types/nodemailer": ^6.4.14 lodash: ^4.17.21 nodemailer: ^6.9.13 - p-limit: ^3.1.0 + p-throttle: ^6.1.0 languageName: unknown linkType: soft @@ -33255,6 +33255,13 @@ __metadata: languageName: node linkType: hard +"p-throttle@npm:^6.1.0": + version: 6.1.0 + resolution: "p-throttle@npm:6.1.0" + checksum: c1947cca8844564c3d86f8c09067add5e7398b87898cb1f1aea5c48d2c211590d039a316d28a4b0d95364ffa0213c01dff6bf71175c468eab0e381f77715dbdb + languageName: node + linkType: hard + "p-timeout@npm:^3.2.0": version: 3.2.0 resolution: "p-timeout@npm:3.2.0" From 743832d39f64c6091b581c621d4bff99cf651fd8 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Wed, 24 Apr 2024 12:09:54 +0300 Subject: [PATCH 8/8] fix: use string array for cache service Signed-off-by: Heikki Hellgren --- .../src/processor/NotificationsEmailProcessor.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts index d9f6b73e0a..5387d09c3d 100644 --- a/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -23,7 +23,7 @@ import { LoggerService, } from '@backstage/backend-plugin-api'; import { Config, readDurationFromConfig } from '@backstage/config'; -import { durationToMilliseconds, JsonArray } from '@backstage/types'; +import { durationToMilliseconds } from '@backstage/types'; import { CATALOG_FILTER_EXISTS, CatalogClient, @@ -150,7 +150,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { ), ]); - await this.cache?.set('user-emails:all', ret as JsonArray, { + await this.cache?.set('user-emails:all', ret, { ttl: this.cacheTtl, }); return ret; @@ -160,9 +160,9 @@ export class NotificationsEmailProcessor implements NotificationProcessor { } private async getUserEmail(entityRef: string): Promise { - const cached = await this.cache?.get(`user-emails:${entityRef}`); + const cached = await this.cache?.get(`user-emails:${entityRef}`); if (cached) { - return [cached]; + return cached; } const { token } = await this.auth.getPluginRequestToken({ @@ -178,7 +178,7 @@ export class NotificationsEmailProcessor implements NotificationProcessor { } } - await this.cache?.set(`user-emails:${entityRef}`, ret[0], { + await this.cache?.set(`user-emails:${entityRef}`, ret, { ttl: this.cacheTtl, });