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"