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();