From e336d76af68e111098d38d11a379a7adfe6c43d1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:18:14 -0700 Subject: [PATCH] fix(email): sendEmail never sends to a reserved domain, for every caller Sprint-review's third note on #2298: the reserved-domain check sat at one of sendEmail's five callers. The other four (registration and resend verification, password reset, the admin waitlist invite, onboarding alerts) had none, and every QA walk registers an @ops.commonly.invalid account whose verification email bounced against the same SMTP2GO reputation real signups' verification emails depend on. sendEmail now returns { skipped: 'reserved_domain' } for such a recipient, before the configuration check, and logs one line naming the email's subject and never the address. It returns rather than throws, so the background verification send does not enter its retry or log a delivery failure; `data` mirrors the reason for callers that log the provider's response. The digest's own check stays where it is: it runs before an unsubscribe token is minted and the send is claimed, which sendEmail cannot do. Ruled by Cofounder: its own PR, SECURITY-GATE from vera (it is the verification and password-reset path), pressed after the Wednesday 10-21 call so it ships with #2297. Mutations: the check never firing, or throwing instead of returning, red both new tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../unit/services/emailService.test.js | 29 +++++++++++++++++++ backend/services/emailService.ts | 14 +++++++++ 2 files changed, 43 insertions(+) diff --git a/backend/__tests__/unit/services/emailService.test.js b/backend/__tests__/unit/services/emailService.test.js index baef2c659f..107339b038 100644 --- a/backend/__tests__/unit/services/emailService.test.js +++ b/backend/__tests__/unit/services/emailService.test.js @@ -22,6 +22,35 @@ describe('emailService', () => { process.env = originalEnv; }); + // Every QA walk registers an @ops.commonly.invalid account, and its + // verification email bounced against the sender's reputation. + it('skips a reserved-domain recipient without throwing, sends nothing, and logs without the address', async () => { + process.env.SMTP2GO_API_KEY = 'smtp-key'; + const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); + + const result = await sendEmail({ + to: 'walk-123@ops.commonly.invalid', + subject: 'Verify Your Email - Commonly', + textBody: 'Plain text', + htmlBody: '

Plain text

', + }); + + expect(result).toEqual({ skipped: 'reserved_domain', data: { skipped: 'reserved_domain' } }); + expect(axios.post).not.toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(JSON.stringify(infoSpy.mock.calls[0])).not.toContain('commonly.invalid'); + infoSpy.mockRestore(); + }); + + it('skips a reserved-domain recipient even when email is not configured: it could never deliver', async () => { + const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {}); + await expect(sendEmail({ + to: 'a@x.test', subject: 'Hello', textBody: 'Plain text', htmlBody: '

Plain text

', + })).resolves.toEqual(expect.objectContaining({ skipped: 'reserved_domain' })); + expect(axios.post).not.toHaveBeenCalled(); + infoSpy.mockRestore(); + }); + it('throws a typed error without an SMTP2GO API key and makes no network call', async () => { await expect(sendEmail({ to: 'person@example.com', diff --git a/backend/services/emailService.ts b/backend/services/emailService.ts index 23e7a7b82f..696e15431d 100644 --- a/backend/services/emailService.ts +++ b/backend/services/emailService.ts @@ -1,3 +1,4 @@ +import { isReservedEmailDomain } from '../utils/reservedEmailDomain'; // eslint-disable-next-line global-require const axios = require('axios'); @@ -24,6 +25,19 @@ export const sendEmail = async ({ textBody, htmlBody, }: SendEmailOptions): Promise => { + // The backstop for every caller (sprint-review on #2298): an address on a + // reserved domain (.invalid, .test, .example, .localhost) can never receive + // mail, so a send buys only a bounce against the reputation every real + // person's email depends on. Every QA walk registers such an account and its + // verification email bounced. Returned, never thrown: a throw would send the + // background verification into its retry and log as a delivery failure. + // `data` mirrors the reason for callers that log the provider's response. + // Callers that can skip earlier still should (the digest skips before it + // mints a token); this only stops the send itself. + if (isReservedEmailDomain(to)) { + console.info('Email skipped: the recipient is on a reserved domain and cannot be delivered.', { subject }); + return { skipped: 'reserved_domain', data: { skipped: 'reserved_domain' } }; + } const apiKey = process.env.SMTP2GO_API_KEY; if (!apiKey) throw new EmailNotConfiguredError();