Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/config/notifications.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 9 additions & 8 deletions src/forums/forums-notification-read.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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',
]);
Expand Down Expand Up @@ -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)] : []);
}
}
Expand Down Expand Up @@ -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',
}),
Expand Down Expand Up @@ -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,
Expand All @@ -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',
);
});
Expand Down Expand Up @@ -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',
);
});
Expand Down
90 changes: 84 additions & 6 deletions src/forums/forums-watch-notification.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}),
);
});
Expand Down Expand Up @@ -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'],
}),
);
});
Expand Down Expand Up @@ -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',
}),
Expand Down Expand Up @@ -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'],
}),
);
});
Expand Down Expand Up @@ -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'],
}),
);
});
Expand Down Expand Up @@ -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',
},
Expand Down Expand Up @@ -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']);
});
});
55 changes: 49 additions & 6 deletions src/forums/forums-watch-notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
recipients: string[];
bcc: string[];
sendgrid_template_id: string;
version: 'v3';
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -174,14 +185,15 @@ export class ForumsWatchNotificationService {
const payload = this.buildEmailPayload(
params,
templateId,
this.resolveVisibleRecipient(),
recipientEmails,
challengeContext,
);

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,
Expand Down Expand Up @@ -443,22 +455,50 @@ 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<string>('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.
*/
private buildEmailPayload(
params: PublishForumsPostNotificationParams,
templateId: string,
recipients: string[],
visibleRecipient: string,
bccRecipients: string[],
challengeContext: ForumsNotificationChallengeContext,
): ForumsWatchNotificationEmailPayload {
const normalizedVisibleRecipient = visibleRecipient.toLowerCase();

return {
data: {
...(params.restrictions.challengeId
Expand All @@ -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',
};
Expand Down
Loading