From e9eb400f9cedf53438082b31b7d33632400ad1bb Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 29 Jan 2026 15:19:49 +0200 Subject: [PATCH] feat(notifications): add support for included topics this can be used to configure processors to only process notifications that contain specific topics. if notification does not have a topic and included topics are configured, the notification will not get processed by the specific processor. this is mainly for the email processor but can be used by other processors as well. closes #32497 Signed-off-by: Hellgren Heikki --- .changeset/seven-poets-shout.md | 7 ++ .../config.d.ts | 7 +- .../src/service/router.test.ts | 113 ++++++++++++++++++ .../src/service/router.ts | 27 +++-- plugins/notifications-common/report.api.md | 1 + plugins/notifications-common/src/filters.ts | 3 + plugins/notifications-common/src/types.ts | 1 + 7 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 .changeset/seven-poets-shout.md diff --git a/.changeset/seven-poets-shout.md b/.changeset/seven-poets-shout.md new file mode 100644 index 0000000000..0dd62b1a4f --- /dev/null +++ b/.changeset/seven-poets-shout.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-notifications-backend-module-email': patch +'@backstage/plugin-notifications-backend': patch +'@backstage/plugin-notifications-common': patch +--- + +Allow configuring included topics for email notifications. diff --git a/plugins/notifications-backend-module-email/config.d.ts b/plugins/notifications-backend-module-email/config.d.ts index 55a751f8fa..97c1bc814b 100644 --- a/plugins/notifications-backend-module-email/config.d.ts +++ b/plugins/notifications-backend-module-email/config.d.ts @@ -161,9 +161,14 @@ export interface Config { */ maxSeverity?: NotificationSeverity; /** - * A notification who's topic is in this array will not be emailed + * A notification with topic is in this array will not be emailed */ excludedTopics?: string[]; + /** + * A notification with topic in this array will be emailed. If not defined, only + * excludedTopics takes effect. + */ + includedTopics?: string[]; }; /** * White list of addresses to send email to diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index 62a37acbbb..b15f753e1b 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -26,6 +26,7 @@ import { TestDatabases, } from '@backstage/backend-test-utils'; import { + NotificationProcessor, NotificationRecipientResolver, NotificationSendOptions, } from '@backstage/plugin-notifications-node'; @@ -827,6 +828,118 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { }); }); + describe('POST /notifications with custom processor', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.service(), + }); + + const customProcessor: NotificationProcessor = { + getName: () => 'customProcessor', + processOptions: jest.fn(), + preProcess: jest.fn(), + postProcess: jest.fn(), + getNotificationFilters: jest.fn(), + }; + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('notification').del(); + await client('broadcast').del(); + await client('user_settings').del(); + + (customProcessor.processOptions as jest.Mock).mockImplementation( + opts => opts, + ); + (customProcessor.preProcess as jest.Mock).mockImplementation( + (notification, _options) => notification, + ); + + const router = await createRouter({ + logger: mockServices.logger.mock(), + store, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + processors: [customProcessor], + }); + app = express().use(router).use(mockErrorHandler()); + }); + + const sendNotification = async (data: NotificationSendOptions) => + request(app) + .post('/notifications') + .send(data) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + it('should not call processor preProcess if topic is excluded', async () => { + (customProcessor.getNotificationFilters as jest.Mock).mockReturnValue({ + excludedTopics: ['topic1'], + }); + // Should be processed + await sendNotification({ + recipients: { + type: 'broadcast', + }, + payload: { + title: 'test notification', + topic: 'topic2', + }, + }); + // Excluded, should not be processed + await sendNotification({ + recipients: { + type: 'broadcast', + }, + payload: { + title: 'test notification', + topic: 'topic1', + }, + }); + expect(customProcessor.preProcess).toHaveBeenCalledTimes(1); + }); + + it('should not call processor preProcess if topic is not included', async () => { + (customProcessor.getNotificationFilters as jest.Mock).mockReturnValue({ + includedTopics: ['topic1'], + }); + // Should not be processed, not included topic + await sendNotification({ + recipients: { + type: 'broadcast', + }, + payload: { + title: 'test notification', + topic: 'topic2', + }, + }); + // Should not be processed, no topic + await sendNotification({ + recipients: { + type: 'broadcast', + }, + payload: { + title: 'test notification', + }, + }); + // Included, should be processed + await sendNotification({ + recipients: { + type: 'broadcast', + }, + payload: { + title: 'test notification', + topic: 'topic1', + }, + }); + expect(customProcessor.preProcess).toHaveBeenCalledTimes(1); + }); + }); + describe('GET /', () => { const httpAuth = mockServices.httpAuth({ defaultCredentials: mockCredentials.user(), diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 9e410cb964..3bdd4d4c6a 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -365,6 +365,15 @@ export async function createRouter( continue; } } + + if (filters.includedTopics) { + if ( + !payload.topic || + !filters.includedTopics.includes(payload.topic) + ) { + continue; + } + } } result.push(processor); } @@ -418,14 +427,16 @@ export async function createRouter( ) => { const filtered = await filterProcessors(notification); for (const processor of filtered) { - if (processor.postProcess) { - try { - await processor.postProcess(notification, opts); - } catch (e) { - logger.error( - `Error while post processing notification with ${processor.getName()}: ${e}`, - ); - } + if (!processor.postProcess) { + continue; + } + + try { + await processor.postProcess(notification, opts); + } catch (e) { + logger.error( + `Error while post processing notification with ${processor.getName()}: ${e}`, + ); } } }; diff --git a/plugins/notifications-common/report.api.md b/plugins/notifications-common/report.api.md index ecc0ec8ef0..4ec181a940 100644 --- a/plugins/notifications-common/report.api.md +++ b/plugins/notifications-common/report.api.md @@ -64,6 +64,7 @@ export type NotificationProcessorFilters = { minSeverity?: NotificationSeverity; maxSeverity?: NotificationSeverity; excludedTopics?: string[]; + includedTopics?: string[]; }; // @public (undocumented) diff --git a/plugins/notifications-common/src/filters.ts b/plugins/notifications-common/src/filters.ts index 725cfcf714..401618cc39 100644 --- a/plugins/notifications-common/src/filters.ts +++ b/plugins/notifications-common/src/filters.ts @@ -43,5 +43,8 @@ export const getProcessorFiltersFromConfig = (config: Config) => { filter.excludedTopics = config.getOptionalStringArray( 'filter.excludedTopics', ); + filter.includedTopics = config.getOptionalStringArray( + 'filter.includedTopics', + ); return filter; }; diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts index 41b335a4c2..ddb854b602 100644 --- a/plugins/notifications-common/src/types.ts +++ b/plugins/notifications-common/src/types.ts @@ -130,6 +130,7 @@ export type NotificationProcessorFilters = { minSeverity?: NotificationSeverity; maxSeverity?: NotificationSeverity; excludedTopics?: string[]; + includedTopics?: string[]; }; /**