diff --git a/README.md b/README.md index 1c9c31a..b92f32b 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,13 @@ Members database, removes active member bans, and filters each candidate through the shared forums access policy using the effective challenge, role, and conflict state for the created content. IP bans are not evaluated for notifications because delivery is not bound to a trusted request client IP. The -publisher emits one `external.action.email` event with the final deduped email -list and `SENDGRID_NOTIFICATION_TEMPLATE`. Missing template or missing -recipient email skips notification delivery and is logged without rolling back -the content write. +publisher emits one `external.action.email` event with +`SENDGRID_NOTIFICATION_TEMPLATE`, a single non-member `To` address +(`NOTIFICATION_TO_EMAIL`, default `no-reply@topcoder.com`) in `recipients`, and +the final deduped member email list in `bcc`. Member addresses are never placed +in `recipients` or `cc`, because SendGrid shows those headers to every +recipient. Missing template or missing recipient email skips notification +delivery and is logged without rolling back the content write. For challenge-scoped notifications, the publisher fetches the effective challenge from `GET /v6/challenges/:challengeId` using the configured M2M credentials and @@ -144,6 +147,7 @@ DATABASE_URL="postgresql://user:password@localhost:5432/forums" AUTH_SECRET="replace-with-a-secure-secret" VALID_ISSUERS='["https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/","https://api.topcoder.com","https://api.topcoder-dev.com"]' SENDGRID_NOTIFICATION_TEMPLATE="sendgrid-template-id" +NOTIFICATION_TO_EMAIL="no-reply@topcoder.com" BUSAPI_URL="https://api.topcoder-dev.com/v6" BUS_API_URL="https://api.topcoder-dev.com/v6/bus/events" TOPCODER_API_URL_BASE="https://api.topcoder-dev.com" @@ -168,6 +172,7 @@ PORT=3000 `VANILLA_DB_URL` is used only by the standalone Vanilla import CLI for legacy MySQL reads. The runtime HTTP service does not connect to Vanilla. `AUTH_SECRET` is required; the service fails during startup when it is omitted. `SENDGRID_NOTIFICATION_TEMPLATE` enables forum watch notification emails. When omitted, notification publishing is skipped and content writes still succeed. +`NOTIFICATION_TO_EMAIL` optionally sets the only address shown in the `To` header of forum watch notification emails; all watching members receive the email via BCC so their addresses are not exposed to each other. When omitted or blank, it defaults to `no-reply@topcoder.com`. Use a non-member address. `CHALLENGE_API_URL` optionally configures the challenges collection endpoint for notification titles. When omitted, it defaults to `${TOPCODER_API_URL_BASE}/v6/challenges`. The lookup uses the same Auth0 configuration and `M2M_CLIENT_ID` / `M2M_CLIENT_SECRET` as outbound bus publishing; the M2M client must have challenge read access. `TOPCODER_URL` optionally configures the public web origin used by challenge links. When omitted, the service derives it from `TOPCODER_API_URL_BASE` by replacing the conventional `api.` hostname prefix with `www.`. `BUSAPI_URL` configures the shared Bus API v6 base for `external.action.email`; the backwards-compatible `BUS_API_URL` alias may contain either that base or the complete `/v6/bus/events` endpoint. Both values are normalized to the `/v6` base because `tc-bus-api-wrapper` appends `/bus/events`, and conflicting aliases or legacy `/eventBus` and `/v5` values are rejected. When neither alias is set, the service derives the v6 base from `TOPCODER_API_URL_BASE`. `KAFKA_ERROR_TOPIC`, `AUTH0_URL`, `AUTH0_AUDIENCE`, `TOKEN_CACHE_TIME`, `M2M_CLIENT_ID`, `M2M_CLIENT_SECRET`, and `AUTH0_PROXY_SERVER_URL` are passed to the standard bus wrapper for outbound authenticated publishing. `TRUST_FORWARDED_CLIENT_IP=true` enables forwarded client-IP moderation using the first exact IPv4/IPv6 host from trusted forwarding headers. When disabled, or when the forwarded value is missing, malformed, CIDR, wildcard, or otherwise non-exact, no client IP is resolved and IP-ban enforcement is skipped for that request. Do not enable this unless the service is behind infrastructure that strips or controls inbound forwarding headers. diff --git a/src/config/notifications.config.ts b/src/config/notifications.config.ts index 0d73e12..bd4426c 100644 --- a/src/config/notifications.config.ts +++ b/src/config/notifications.config.ts @@ -5,12 +5,13 @@ * notification publisher. Missing template configuration disables notification * publishing without affecting forum writes. * - * @returns SendGrid template, Bus, Challenge API, and public web bases, Kafka error topic, and Auth0 M2M values. + * @returns SendGrid template, visible `To` address, Bus, Challenge API, and public web bases, Kafka error topic, and Auth0 M2M values. * @throws Does not throw; consumers decide whether optional values are required. */ export default () => ({ notifications: { sendgridNotificationTemplate: process.env.SENDGRID_NOTIFICATION_TEMPLATE, + notificationToEmail: process.env.NOTIFICATION_TO_EMAIL, busApiUrl: process.env.BUSAPI_URL, busApiUrlAlias: process.env.BUS_API_URL, topcoderApiUrlBase: process.env.TOPCODER_API_URL_BASE, diff --git a/src/forums/forums-notification-read.integration.spec.ts b/src/forums/forums-notification-read.integration.spec.ts index 4e8e280..762e3de 100644 --- a/src/forums/forums-notification-read.integration.spec.ts +++ b/src/forums/forums-notification-read.integration.spec.ts @@ -944,7 +944,7 @@ describe('forums notification/read integration', () => { }) .expect(201); expect(publishedEvents).toHaveLength(1); - expect(publishedEvents[0].payload.recipients).toEqual(['six@example.com']); + expect(publishedEvents[0].payload.bcc).toEqual(['six@example.com']); await request(app.getHttpServer()) .put('/challenges/challenge-1/watch') @@ -962,7 +962,7 @@ describe('forums notification/read integration', () => { }) .expect(201); expect(publishedEvents).toHaveLength(2); - expect(publishedEvents[1].payload.recipients).toEqual([ + expect(publishedEvents[1].payload.bcc).toEqual([ 'two@example.com', 'six@example.com', ]); @@ -1053,7 +1053,7 @@ describe('forums notification/read integration', () => { .send({ content: 'Notification check' }) .expect(201); expect( - publishedEvents.flatMap((event) => event.payload.recipients), + publishedEvents.flatMap((event) => event.payload.bcc), ).toEqual(expectedWatching ? [emailsByMemberId.get(memberId)] : []); } } @@ -1106,7 +1106,8 @@ describe('forums notification/read integration', () => { expect(publishedEvents[0]).toEqual({ topic: 'external.action.email', payload: expect.objectContaining({ - recipients: ['two@example.com'], + recipients: ['no-reply@topcoder.com'], + bcc: ['two@example.com'], sendgrid_template_id: 'template-id', version: 'v3', }), @@ -1165,7 +1166,7 @@ describe('forums notification/read integration', () => { expect.objectContaining({ topic: 'external.action.email' }), ); - const notifiedMemberIds = publishedEvents[0].payload.recipients.map( + const notifiedMemberIds = publishedEvents[0].payload.bcc.map( (email: string) => Array.from(emailsByMemberId.entries()).find( ([, memberEmail]) => memberEmail === email, @@ -1174,7 +1175,7 @@ describe('forums notification/read integration', () => { expect(visibleWatchedMemberIds).toEqual(['2', '4', '5', '6']); expect(notifiedMemberIds).toEqual(visibleWatchedMemberIds); - expect(publishedEvents[0].payload.recipients).not.toContain( + expect(publishedEvents[0].payload.bcc).not.toContain( 'author@example.com', ); }); @@ -1238,12 +1239,12 @@ describe('forums notification/read integration', () => { .expect(201); expect(publishedEvents).toHaveLength(1); - expect(publishedEvents[0].payload.recipients).toEqual([ + expect(publishedEvents[0].payload.bcc).toEqual([ 'two@example.com', 'five@example.com', 'six@example.com', ]); - expect(publishedEvents[0].payload.recipients).not.toContain( + expect(publishedEvents[0].payload.bcc).not.toContain( 'four@example.com', ); }); diff --git a/src/forums/forums-watch-notification.service.spec.ts b/src/forums/forums-watch-notification.service.spec.ts index 781e22a..113dbbe 100644 --- a/src/forums/forums-watch-notification.service.spec.ts +++ b/src/forums/forums-watch-notification.service.spec.ts @@ -191,7 +191,8 @@ describe('ForumsWatchNotificationService', () => { expect(eventBusService.postEvent).toHaveBeenCalledWith( 'external.action.email', expect.objectContaining({ - recipients: ['two@example.com', 'three@example.com'], + recipients: ['no-reply@topcoder.com'], + bcc: ['two@example.com', 'three@example.com'], }), ); }); @@ -238,7 +239,8 @@ describe('ForumsWatchNotificationService', () => { expect(eventBusService.postEvent).toHaveBeenCalledWith( 'external.action.email', expect.objectContaining({ - recipients: ['2@example.com'], + recipients: ['no-reply@topcoder.com'], + bcc: ['2@example.com'], }), ); }); @@ -301,7 +303,8 @@ describe('ForumsWatchNotificationService', () => { expect(eventBusService.postEvent).toHaveBeenCalledWith( 'external.action.email', expect.objectContaining({ - recipients: ['two@example.com'], + recipients: ['no-reply@topcoder.com'], + bcc: ['two@example.com'], sendgrid_template_id: 'template-id', version: 'v3', }), @@ -421,7 +424,8 @@ describe('ForumsWatchNotificationService', () => { expect(eventBusService.postEvent).toHaveBeenCalledWith( 'external.action.email', expect.objectContaining({ - recipients: ['two@example.com'], + recipients: ['no-reply@topcoder.com'], + bcc: ['two@example.com'], }), ); }); @@ -473,7 +477,8 @@ describe('ForumsWatchNotificationService', () => { expect(eventBusService.postEvent).toHaveBeenCalledWith( 'external.action.email', expect.objectContaining({ - recipients: ['two@example.com', 'four@example.com'], + recipients: ['no-reply@topcoder.com'], + bcc: ['two@example.com', 'four@example.com'], }), ); }); @@ -671,7 +676,8 @@ describe('ForumsWatchNotificationService', () => { authorHandle: 'author', createdAt: createdAt.toISOString(), }, - recipients: ['two@example.com', 'three@example.com'], + recipients: ['no-reply@topcoder.com'], + bcc: ['two@example.com', 'three@example.com'], sendgrid_template_id: 'template-id', version: 'v3', }, @@ -736,4 +742,76 @@ describe('ForumsWatchNotificationService', () => { expect.stringContaining('challenge challenge-1 URL resolution failed'), ); }); + + it('never exposes member addresses in the To or CC headers', async () => { + const { db, eventBusService, memberDirectoryService, service } = + createService(); + db.topicWatch.findMany.mockResolvedValue([ + { memberId: '2', watching: true }, + { memberId: '3', watching: true }, + ]); + memberDirectoryService.getMembersByIds.mockResolvedValue([ + { memberId: '2', email: 'two@example.com', handle: 'two' }, + { memberId: '3', email: 'three@example.com', handle: 'three' }, + ]); + + await service.publishPostNotification({ + topic: makeTopic(), + post: makePost(), + restrictions: { + challengeId: null, + roleName: null, + hasRestrictionConflict: false, + }, + operationName: 'createPost', + }); + + const payload = eventBusService.postEvent.mock.calls[0][1]; + expect(payload.recipients).toEqual(['no-reply@topcoder.com']); + expect(payload).not.toHaveProperty('cc'); + expect(payload.bcc).toEqual(['two@example.com', 'three@example.com']); + }); + + it('uses NOTIFICATION_TO_EMAIL as the visible recipient and keeps it out of BCC', async () => { + const { + configService, + db, + eventBusService, + memberDirectoryService, + service, + } = createService(); + configService.get.mockImplementation((key: string) => { + if (key === 'notifications.sendgridNotificationTemplate') { + return 'template-id'; + } + + return key === 'notifications.notificationToEmail' + ? ' Forums@Example.com ' + : undefined; + }); + db.topicWatch.findMany.mockResolvedValue([ + { memberId: '2', watching: true }, + { memberId: '3', watching: true }, + ]); + memberDirectoryService.getMembersByIds.mockResolvedValue([ + { memberId: '2', email: 'two@example.com', handle: 'two' }, + { memberId: '3', email: 'forums@example.com', handle: 'three' }, + ]); + + const result = await service.publishPostNotification({ + topic: makeTopic(), + post: makePost(), + restrictions: { + challengeId: null, + roleName: null, + hasRestrictionConflict: false, + }, + operationName: 'createPost', + }); + + expect(result).toEqual({ attemptedRecipientCount: 2, published: true }); + const payload = eventBusService.postEvent.mock.calls[0][1]; + expect(payload.recipients).toEqual(['Forums@Example.com']); + expect(payload.bcc).toEqual(['two@example.com']); + }); }); diff --git a/src/forums/forums-watch-notification.service.ts b/src/forums/forums-watch-notification.service.ts index 1fd4810..95a3d6c 100644 --- a/src/forums/forums-watch-notification.service.ts +++ b/src/forums/forums-watch-notification.service.ts @@ -19,10 +19,19 @@ import { IdentityAccessService } from './identity-access.service'; const EMAIL_EVENT_TOPIC = 'external.action.email'; const RECIPIENT_AUTHORIZATION_CONCURRENCY = 10; +const DEFAULT_NOTIFICATION_TO_EMAIL = 'no-reply@topcoder.com'; +/** + * Bus payload for `external.action.email`. + * + * `recipients` maps to the SendGrid `To` header and is shared by every copy of + * the message, so it only ever carries the non-member visible address. Watcher + * addresses are delivered through `bcc` so members never see each other. + */ interface ForumsWatchNotificationEmailPayload { data: Record; recipients: string[]; + bcc: string[]; sendgrid_template_id: string; version: 'v3'; } @@ -81,8 +90,10 @@ export class ForumsNotificationPublishError extends Error { * excludes the persisted author member id, * filters active member bans and remaining recipients through the shared forums * access policy, resolves the challenge title when applicable, and publishes one - * SendGrid email event for the final recipient list. IP-ban checks are intentionally - * excluded because notification delivery is not bound to a trusted request client IP. + * SendGrid email event addressed to a single non-member `To` address with the + * final recipient list in BCC, so watcher email addresses are never exposed to + * other recipients. IP-ban checks are intentionally excluded because + * notification delivery is not bound to a trusted request client IP. */ @Injectable() export class ForumsWatchNotificationService { @@ -174,6 +185,7 @@ export class ForumsWatchNotificationService { const payload = this.buildEmailPayload( params, templateId, + this.resolveVisibleRecipient(), recipientEmails, challengeContext, ); @@ -181,7 +193,7 @@ export class ForumsWatchNotificationService { try { await this.eventBusService.postEvent(EMAIL_EVENT_TOPIC, payload); this.logger.log( - `Published '${EMAIL_EVENT_TOPIC}' (${params.operationName}) for topic ${params.topic.id}, post ${params.post.id} to ${recipientEmails.length} recipients.`, + `Published '${EMAIL_EVENT_TOPIC}' (${params.operationName}) for topic ${params.topic.id}, post ${params.post.id} to ${recipientEmails.length} BCC recipients.`, ); return { attemptedRecipientCount: recipientEmails.length, @@ -443,12 +455,37 @@ export class ForumsWatchNotificationService { return context; } + /** + * Resolves the single address shown in the notification `To` header. + * + * SendGrid shows every `To` and `CC` address to all recipients of a message, + * so member addresses must never be placed there. This non-member address + * fills the required `To` slot while members receive the email through BCC. + * + * @returns Trimmed `NOTIFICATION_TO_EMAIL`, or `no-reply@topcoder.com` when unset or blank. + * @throws Does not throw. + */ + private resolveVisibleRecipient(): string { + return ( + this.configService + .get('notifications.notificationToEmail') + ?.trim() || DEFAULT_NOTIFICATION_TO_EMAIL + ); + } + /** * Builds the SendGrid event payload from persisted topic and post rows. * + * Member addresses are placed only in `bcc`; `recipients` (the SendGrid `To` + * header) carries just the visible non-member address. A member address that + * matches the visible address case-insensitively is dropped from BCC because + * it already receives the message through `To` and SendGrid rejects addresses + * repeated across `To` and BCC. + * * @param params Current notification publish parameters. * @param templateId Configured SendGrid template id. - * @param recipients Final recipient email list. + * @param visibleRecipient Non-member address used as the only `To` recipient. + * @param bccRecipients Final authorized member email list, delivered via BCC. * @param challengeContext Challenge API name and public Opportunities URL when available. * @returns Event-bus email payload. * @throws Does not throw. @@ -456,9 +493,12 @@ export class ForumsWatchNotificationService { private buildEmailPayload( params: PublishForumsPostNotificationParams, templateId: string, - recipients: string[], + visibleRecipient: string, + bccRecipients: string[], challengeContext: ForumsNotificationChallengeContext, ): ForumsWatchNotificationEmailPayload { + const normalizedVisibleRecipient = visibleRecipient.toLowerCase(); + return { data: { ...(params.restrictions.challengeId @@ -482,7 +522,10 @@ export class ForumsWatchNotificationService { authorHandle: params.post.authorHandle, createdAt: params.post.createdAt.toISOString(), }, - recipients, + recipients: [visibleRecipient], + bcc: bccRecipients.filter( + (email) => email.toLowerCase() !== normalizedVisibleRecipient, + ), sendgrid_template_id: templateId, version: 'v3', };