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..7bbac05b62 --- /dev/null +++ b/plugins/notifications-backend-module-email/README.md @@ -0,0 +1,72 @@ +# @backstage/plugin-notifications-backend-module-email + +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 +notifications: + processors: + email: + # Transport config, see options at `config.d.ts` + transportConfig: + transport: 'smtp' + hostname: 'my-smtp-server' + port: 587 + 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: + days: 1 +``` + +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..26563bde85 --- /dev/null +++ b/plugins/notifications-backend-module-email/api-report.md @@ -0,0 +1,32 @@ +## 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'; +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/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..db49d0016a --- /dev/null +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -0,0 +1,123 @@ +/* + * 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 { HumanDuration } from '@backstage/types'; + +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; + /** + * AWS account ID to use + */ + accountId?: string; + /** + * AWS region to use + */ + region?: 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; + /** + * Optional reply-to address + */ + replyTo?: string; + /** + * Concurrency limit for email sending, defaults to 2 + */ + concurrencyLimit?: number; + /** + * Throttle duration between email sending, defaults to 100ms + */ + throttleInterval?: HumanDuration; + /** + * 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[]; + }; + cache?: { + /** + * Email cache TTL, defaults to 1 hour + */ + ttl?: HumanDuration; + }; + }; + }; + }; +} diff --git a/plugins/notifications-backend-module-email/package.json b/plugins/notifications-backend-module-email/package.json new file mode 100644 index 0000000000..dfff036a13 --- /dev/null +++ b/plugins/notifications-backend-module-email/package.json @@ -0,0 +1,56 @@ +{ + "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.550.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:^", + "lodash": "^4.17.21", + "nodemailer": "^6.9.13", + "p-throttle": "^6.1.0" + }, + "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/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 new file mode 100644 index 0000000000..547a69360c --- /dev/null +++ b/plugins/notifications-backend-module-email/src/index.ts @@ -0,0 +1,24 @@ +/* + * 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'; +export * from './extensions'; 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..6b74bf70e7 --- /dev/null +++ b/plugins/notifications-backend-module-email/src/module.ts @@ -0,0 +1,72 @@ +/* + * 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'; +import { + notificationsEmailTemplateExtensionPoint, + NotificationTemplateRenderer, +} from './extensions'; + +/** + * @public + */ +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, + 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, + 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 new file mode 100644 index 0000000000..3af1e498ca --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.test.ts @@ -0,0 +1,349 @@ +/* + * 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', async () => { + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + processors: { + email: { + transport: { + transport: 'smtp', + hostname: 'localhost', + port: 465, + secure: true, + requireTls: false, + }, + 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(processor).toBeInstanceOf(NotificationsEmailProcessor); + expect(createTransport as jest.Mock).toHaveBeenCalledWith({ + host: 'localhost', + port: 465, + requireTLS: false, + secure: true, + }); + }); + + it('should create ses transport', async () => { + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + processors: { + email: { + transport: { + transport: 'ses', + region: 'us-west-2', + }, + 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(processor).toBeInstanceOf(NotificationsEmailProcessor); + expect(createTransport as jest.Mock).toHaveBeenCalledWith({ + SES: expect.anything(), + }); + }); + + it('should create sendmail transport', async () => { + const processor = new NotificationsEmailProcessor( + logger, + new ConfigReader({ + notifications: { + processors: { + 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(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: { + processors: { + 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: '', + 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: { + processors: { + 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: '', + 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: { + processors: { + 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: '', + 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..5387d09c3d --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/NotificationsEmailProcessor.ts @@ -0,0 +1,283 @@ +/* + * 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, readDurationFromConfig } from '@backstage/config'; +import { durationToMilliseconds } from '@backstage/types'; +import { + CATALOG_FILTER_EXISTS, + CatalogClient, +} from '@backstage/catalog-client'; +import { Notification } from '@backstage/plugin-notifications-common'; +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 pThrottle from 'p-throttle'; + +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; + private readonly throttleInterval: number; + + constructor( + private readonly logger: LoggerService, + private readonly config: Config, + private readonly catalog: CatalogClient, + private readonly auth: AuthService, + private readonly cache?: CacheService, + private readonly templateRenderer?: NotificationTemplateRenderer, + ) { + const emailProcessorConfig = config.getConfig( + 'notifications.processors.email', + ); + 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 throttleConfig = + emailProcessorConfig.getOptionalConfig('throttleInterval'); + this.throttleInterval = throttleConfig + ? durationToMilliseconds(readDurationFromConfig(throttleConfig)) + : 100; + 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 transport = this.transportConfig.getString('transport'); + if (transport === 'smtp') { + this.transporter = createSmtpTransport(this.transportConfig); + } else if (transport === 'ses') { + const awsCredentialsManager = DefaultAwsCredentialsManager.fromConfig( + this.config, + ); + this.transporter = await createSesTransport( + this.transportConfig, + awsCredentialsManager, + ); + } else if (transport === 'sendmail') { + this.transporter = createSendmailTransport(this.transportConfig); + } else { + throw new Error(`Unsupported transport: ${transport}`); + } + return this.transporter; + } + + 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; + } + + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + 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, { + 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 { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'catalog', + }); + const entity = await this.catalog.getEntityByRef(entityRef, { token }); + const ret: string[] = []; + if (entity) { + const userEntity = entity as UserEntity; + if (userEntity.spec.profile?.email) { + ret.push(userEntity.spec.profile.email); + } + } + + await this.cache?.set(`user-emails:${entityRef}`, ret, { + ttl: this.cacheTtl, + }); + + return ret; + } + + 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); + } + + 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 throttle = pThrottle({ + limit: this.concurrencyLimit, + interval: this.throttleInterval, + }); + + const throttled = throttle((opts: Mail.Options) => this.sendMail(opts)); + await Promise.all( + emails.map(email => throttled({ ...options, to: email })), + ); + } + + 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, + }; + + await this.sendMails(mailOptions, emails); + } + + 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, + }; + + await this.sendMails(mailOptions, emails); + } + + async postProcess( + notification: Notification, + options: NotificationSendOptions, + ): Promise { + this.transporter = await this.getTransporter(); + + let emails: string[] = []; + try { + emails = await this.getRecipientEmails(notification, options); + } catch (e) { + this.logger.error(`Failed to resolve recipient emails: ${e}`); + return; + } + + if (emails.length === 0) { + this.logger.info( + `No email recipients found for notification: ${notification.id}, skipping`, + ); + return; + } + + if (!this.templateRenderer) { + await this.sendPlainEmail(notification, emails); + return; + } + + await this.sendTemplateEmail(notification, emails); + } +} 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..e16ea37ac9 --- /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 { Config } from '@backstage/config'; + +export const createSendmailTransport = (config: Config) => { + return createTransport({ + sendmail: true, + 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 new file mode 100644 index 0000000000..0be23078ab --- /dev/null +++ b/plugins/notifications-backend-module-email/src/processor/transports/ses.ts @@ -0,0 +1,38 @@ +/* + * 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 { SendRawEmailCommand, SES } from '@aws-sdk/client-ses'; +import { Config } from '@backstage/config'; +import { AwsCredentialsManager } from '@backstage/integration-aws-node'; + +export const createSesTransport = async ( + config: Config, + credentialsManager: AwsCredentialsManager, +) => { + const credentials = await credentialsManager.getCredentialProvider({ + accountId: config.getOptionalString('accountId'), + }); + const ses = new SES([ + { + apiVersion: config.getOptionalString('apiVersion') ?? '2010-12-01', + credentials: credentials.sdkCredentialProvider, + region: config.getOptionalString('region'), + }, + ]); + return createTransport({ + SES: { ses, aws: { SendRawEmailCommand } }, + }); +}; 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..feea417c27 --- /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 { createTransport } from 'nodemailer'; +import { Config } from '@backstage/config'; + +export const createSmtpTransport = (config: Config) => { + const username = config.getOptionalString('username'); + const password = config.getOptionalString('password'); + + return createTransport({ + 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/yarn.lock b/yarn.lock index 9a940a725a..63a8e2504b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -599,6 +599,55 @@ __metadata: languageName: node linkType: hard +"@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.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" @@ -6124,6 +6173,30 @@ __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.550.0 + "@aws-sdk/types": ^3.347.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/integration-aws-node": "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 + p-throttle: ^6.1.0 + 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" @@ -16224,6 +16297,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" @@ -32423,6 +32505,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" @@ -33348,6 +33437,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"