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
29 changes: 29 additions & 0 deletions backend/__tests__/unit/services/emailService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p>Plain text</p>',
});

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: '<p>Plain text</p>',
})).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',
Expand Down
14 changes: 14 additions & 0 deletions backend/services/emailService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isReservedEmailDomain } from '../utils/reservedEmailDomain';
// eslint-disable-next-line global-require
const axios = require('axios');

Expand All @@ -24,6 +25,19 @@ export const sendEmail = async ({
textBody,
htmlBody,
}: SendEmailOptions): Promise<any> => {
// 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();

Expand Down
Loading