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"