From 8b016d349c35eadcd8ccc60a523c1182801dc6b0 Mon Sep 17 00:00:00 2001 From: emmanard Date: Mon, 1 Jun 2026 11:05:41 +0100 Subject: [PATCH] feat: implement transactional email service with template support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sendEmail(to, template, data) generic sender - Add 6 HTML templates: verify_email, reset_password, market_resolved, winnings_available, dispute_filed, dispute_resolved - Support EMAIL_PROVIDER env var (smtp | sendgrid) for transport selection - Refactor sendPasswordResetEmail as a thin wrapper over sendEmail - Log and swallow transport errors — callers are never affected - Add 22 unit tests covering all templates, subjects, interpolation, graceful failure, and SendGrid provider branching - Update .env.example with email provider env vars --- backend/.env.example | 13 + backend/package-lock.json | 17 ++ .../src/email/templates/dispute_filed.html | 43 +++ .../src/email/templates/dispute_resolved.html | 45 +++ .../src/email/templates/market_resolved.html | 45 +++ .../src/email/templates/reset_password.html | 45 +++ backend/src/email/templates/verify_email.html | 45 +++ .../email/templates/winnings_available.html | 43 +++ backend/src/services/email.service.ts | 147 ++++++---- backend/tests/services/email.service.test.ts | 257 ++++++++++++++++++ 10 files changed, 645 insertions(+), 55 deletions(-) create mode 100644 backend/src/email/templates/dispute_filed.html create mode 100644 backend/src/email/templates/dispute_resolved.html create mode 100644 backend/src/email/templates/market_resolved.html create mode 100644 backend/src/email/templates/reset_password.html create mode 100644 backend/src/email/templates/verify_email.html create mode 100644 backend/src/email/templates/winnings_available.html create mode 100644 backend/tests/services/email.service.test.ts diff --git a/backend/.env.example b/backend/.env.example index 9eb8b163..879910bf 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -38,3 +38,16 @@ BOXING_API_URL=https://api.example-boxing-data.com/v1 # ── Sentry ──────────────────────────────────────────────────── SENTRY_DSN= + +# ── Email ───────────────────────────────────────────────────── +# Provider: smtp | sendgrid +EMAIL_PROVIDER=smtp +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=you@example.com +SMTP_PASS=your-smtp-password +SMTP_FROM=no-reply@boxmeout.app +# Required when EMAIL_PROVIDER=sendgrid +SENDGRID_API_KEY=SG.xxxx +APP_NAME=BoxMeOut +APP_BASE_URL=http://localhost:3001 diff --git a/backend/package-lock.json b/backend/package-lock.json index a2b67bd1..c3d3d848 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -134,6 +134,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2166,6 +2167,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2187,6 +2189,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" }, @@ -2223,6 +2226,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -2773,6 +2777,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3276,6 +3281,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3578,6 +3584,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -3756,6 +3763,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4259,6 +4267,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5163,6 +5172,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5493,6 +5503,7 @@ "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5573,6 +5584,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6605,6 +6617,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7074,6 +7087,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8704,6 +8718,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -10518,6 +10533,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10714,6 +10730,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/src/email/templates/dispute_filed.html b/backend/src/email/templates/dispute_filed.html new file mode 100644 index 00000000..eb0cb668 --- /dev/null +++ b/backend/src/email/templates/dispute_filed.html @@ -0,0 +1,43 @@ + + + + + + Dispute filed — {{appName}} + + + + + + +
+ + + + + + + + + + +
+ {{appName}} +
+

Dispute filed

+

+ A dispute has been filed for a market you participated in. Our team will review it shortly. +

+
+

{{marketTitle}}

+

Dispute ID: {{disputeId}}

+
+

+ You will receive another email once the dispute has been resolved. Log in to {{appName}} to track the status. +

+
+

— The {{appName}} team

+
+
+ + diff --git a/backend/src/email/templates/dispute_resolved.html b/backend/src/email/templates/dispute_resolved.html new file mode 100644 index 00000000..a87030cb --- /dev/null +++ b/backend/src/email/templates/dispute_resolved.html @@ -0,0 +1,45 @@ + + + + + + Dispute resolved — {{appName}} + + + + + + +
+ + + + + + + + + + +
+ {{appName}} +
+

Dispute resolved

+

+ The dispute for the following market has been resolved by our team. +

+
+

{{marketTitle}}

+

+ Resolution: {{resolution}} +

+
+

+ Log in to {{appName}} to view the full outcome and any updated balances. +

+
+

— The {{appName}} team

+
+
+ + diff --git a/backend/src/email/templates/market_resolved.html b/backend/src/email/templates/market_resolved.html new file mode 100644 index 00000000..a45622bf --- /dev/null +++ b/backend/src/email/templates/market_resolved.html @@ -0,0 +1,45 @@ + + + + + + Market resolved — {{appName}} + + + + + + +
+ + + + + + + + + + +
+ {{appName}} +
+

Market resolved

+

+ The following market you participated in has been resolved: +

+
+

{{marketTitle}}

+

+ Outcome: {{outcome}} +

+
+

+ Log in to {{appName}} to view your position and check for any winnings. +

+
+

— The {{appName}} team

+
+
+ + diff --git a/backend/src/email/templates/reset_password.html b/backend/src/email/templates/reset_password.html new file mode 100644 index 00000000..861ef5e1 --- /dev/null +++ b/backend/src/email/templates/reset_password.html @@ -0,0 +1,45 @@ + + + + + + Reset your password — {{appName}} + + + + + + +
+ + + + + + + + + + +
+ {{appName}} +
+

Reset your password

+

+ We received a request to reset your {{appName}} password. Click the button below to choose a new one. +

+ + Reset my password + +

+ This link expires in 15 minutes. If you did not request a password reset, you can safely ignore this email — your password will not change. +

+

+ Or copy this link: {{resetUrl}} +

+
+

— The {{appName}} team

+
+
+ + diff --git a/backend/src/email/templates/verify_email.html b/backend/src/email/templates/verify_email.html new file mode 100644 index 00000000..6ef33593 --- /dev/null +++ b/backend/src/email/templates/verify_email.html @@ -0,0 +1,45 @@ + + + + + + Verify your email — {{appName}} + + + + + + +
+ + + + + + + + + + +
+ {{appName}} +
+

Verify your email address

+

+ Thanks for signing up. Click the button below to confirm your email address and activate your account. +

+ + Verify email address + +

+ This link expires in 24 hours. If you did not create an account, you can safely ignore this email. +

+

+ Or copy this link: {{verifyUrl}} +

+
+

— The {{appName}} team

+
+
+ + diff --git a/backend/src/email/templates/winnings_available.html b/backend/src/email/templates/winnings_available.html new file mode 100644 index 00000000..6228f082 --- /dev/null +++ b/backend/src/email/templates/winnings_available.html @@ -0,0 +1,43 @@ + + + + + + Your winnings are available — {{appName}} + + + + + + +
+ + + + + + + + + + +
+ {{appName}} +
+

Your winnings are available

+

+ Congratulations! Your winnings from the market below are ready to claim. +

+
+

{{marketTitle}}

+

{{amount}}

+
+

+ Log in to {{appName}} to claim your winnings to your wallet. +

+
+

— The {{appName}} team

+
+
+ + diff --git a/backend/src/services/email.service.ts b/backend/src/services/email.service.ts index 206f93ce..a3e8bfc7 100644 --- a/backend/src/services/email.service.ts +++ b/backend/src/services/email.service.ts @@ -1,12 +1,61 @@ +import fs from 'fs'; +import path from 'path'; import nodemailer from 'nodemailer'; import { logger } from '../utils/logger'; // --------------------------------------------------------------------------- -// Transporter — configure via env vars. -// For production use SMTP_HOST/PORT/USER/PASS. -// Falls back to Ethereal (catch-all test account) when env vars are absent. +// Types // --------------------------------------------------------------------------- -function createTransporter() { + +export type EmailTemplate = + | 'verify_email' + | 'reset_password' + | 'market_resolved' + | 'winnings_available' + | 'dispute_filed' + | 'dispute_resolved'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const APP_NAME = process.env.APP_NAME ?? 'BoxMeOut'; +const APP_BASE_URL = process.env.APP_BASE_URL ?? 'http://localhost:3001'; +const FROM_ADDRESS = process.env.SMTP_FROM ?? 'no-reply@boxmeout.app'; + +const TEMPLATES_DIR = path.resolve(__dirname, '../email/templates'); + +const SUBJECTS: Record = { + verify_email: `Verify your ${APP_NAME} email address`, + reset_password: `Reset your ${APP_NAME} password`, + market_resolved: `[${APP_NAME}] Market resolved`, + winnings_available: `[${APP_NAME}] Your winnings are available`, + dispute_filed: `[${APP_NAME}] Dispute filed`, + dispute_resolved: `[${APP_NAME}] Dispute resolved`, +}; + +// --------------------------------------------------------------------------- +// Transporter — branches on EMAIL_PROVIDER env var (smtp | sendgrid) +// --------------------------------------------------------------------------- + +function createTransporter(): nodemailer.Transporter { + const provider = process.env.EMAIL_PROVIDER ?? 'smtp'; + + if (provider === 'sendgrid') { + const apiKey = process.env.SENDGRID_API_KEY; + if (!apiKey) { + logger.warn('SENDGRID_API_KEY not set; falling back to stub transport'); + return nodemailer.createTransport({ jsonTransport: true }); + } + return nodemailer.createTransport({ + host: 'smtp.sendgrid.net', + port: 587, + secure: false, + auth: { user: 'apikey', pass: apiKey }, + }); + } + + // Default: SMTP const host = process.env.SMTP_HOST; const port = parseInt(process.env.SMTP_PORT ?? '587', 10); const user = process.env.SMTP_USER; @@ -21,81 +70,69 @@ function createTransporter() { }); } - // Development fallback — logs preview URL to console - logger.warn('SMTP env vars not set; using nodemailer stub transport (emails will not be delivered)'); + logger.warn('SMTP env vars not set; using stub transport (emails will not be delivered)'); return nodemailer.createTransport({ jsonTransport: true }); } const transporter = createTransporter(); -const APP_NAME = process.env.APP_NAME ?? 'BoxMeOut'; -const APP_BASE_URL = process.env.APP_BASE_URL ?? 'http://localhost:3001'; -const FROM_ADDRESS = process.env.SMTP_FROM ?? `no-reply@boxmeout.app`; +// --------------------------------------------------------------------------- +// Template rendering +// --------------------------------------------------------------------------- + +function renderTemplate(template: EmailTemplate, data: Record): string { + const filePath = path.join(TEMPLATES_DIR, `${template}.html`); + let html = fs.readFileSync(filePath, 'utf-8'); + for (const [key, value] of Object.entries(data)) { + html = html.replaceAll(`{{${key}}}`, value); + } + return html; +} // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** - * Send a password-reset email containing a signed JWT link. - * Failures are caught and logged — never thrown — so the caller cannot - * distinguish "email sent" from "email failed" (prevents enumeration). + * Generic transactional email sender. + * Loads the matching HTML template, interpolates {{key}} placeholders, + * and dispatches via the configured transport. + * Errors are logged but never thrown — callers are not affected by delivery failures. */ -export async function sendPasswordResetEmail( - toEmail: string, - resetToken: string, +export async function sendEmail( + to: string, + template: EmailTemplate, + data: Record, ): Promise { - const resetUrl = `${APP_BASE_URL}/auth/reset-password?token=${resetToken}`; - - const html = ` -

Hi,

-

We received a request to reset your ${APP_NAME} password.

-

- Reset my password -

-

⚠ This link expires in 15 minutes.

-

If you did not request a password reset, you can safely ignore this email.

-

— The ${APP_NAME} team

- `; - - const text = [ - `Reset your ${APP_NAME} password`, - '', - `Visit the link below to reset your password (expires in 15 minutes):`, - resetUrl, - '', - 'If you did not request a password reset, ignore this email.', - ].join('\n'); - + const mergedData = { appName: APP_NAME, ...data }; try { + const html = renderTemplate(template, mergedData); const info = await transporter.sendMail({ from: `"${APP_NAME}" <${FROM_ADDRESS}>`, - to: toEmail, - subject: `Reset your ${APP_NAME} password`, - text, + to, + subject: SUBJECTS[template], html, }); - - // In dev the jsonTransport serialises the message — log it for inspection if (process.env.NODE_ENV !== 'production') { - logger.info({ msg: 'Password reset email (dev)', messageId: info.messageId }); + logger.info({ msg: `Email sent (dev)`, template, messageId: info.messageId }); } } catch (err) { - // Log but swallow — callers must not learn whether delivery succeeded - logger.error({ msg: 'Failed to send password reset email', error: err }); + logger.error({ msg: 'Failed to send email', template, to, error: err }); } } -/** - * Send an async export-ready email with the CSV as an attachment. - */ +// --------------------------------------------------------------------------- +// Convenience wrappers (preserve existing call sites) +// --------------------------------------------------------------------------- + +export async function sendPasswordResetEmail( + toEmail: string, + resetToken: string, +): Promise { + const resetUrl = `${APP_BASE_URL}/auth/reset-password?token=${resetToken}`; + await sendEmail(toEmail, 'reset_password', { resetUrl }); +} + export async function sendExportReadyEmail( toEmail: string, exportType: string, diff --git a/backend/tests/services/email.service.test.ts b/backend/tests/services/email.service.test.ts new file mode 100644 index 00000000..6cb76f1a --- /dev/null +++ b/backend/tests/services/email.service.test.ts @@ -0,0 +1,257 @@ +/** + * Unit tests — Email Service + * + * Covers: + * 1. sendEmail() dispatches to the correct recipient for all 6 templates + * 2. Each template resolves the expected subject line + * 3. Graceful failure — transport errors are logged and not re-thrown + * 4. Convenience wrappers (sendPasswordResetEmail, sendExportReadyEmail) use the transport + * 5. EMAIL_PROVIDER=sendgrid routes to smtp.sendgrid.net + */ + +// --------------------------------------------------------------------------- +// Mocks — must be declared before any imports +// --------------------------------------------------------------------------- + +const mockSendMail = jest.fn(); + +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(() => ({ sendMail: mockSendMail })), +})); + +jest.mock('fs', () => ({ + readFileSync: jest.fn(() => '{{appName}} {{verifyUrl}} {{resetUrl}} {{marketTitle}} {{outcome}} {{amount}} {{disputeId}} {{resolution}}'), +})); + +jest.mock('../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Imports — after mocks so jest intercepts module registration +// --------------------------------------------------------------------------- + +import nodemailer from 'nodemailer'; +import { logger } from '../../src/utils/logger'; +import { + sendEmail, + sendPasswordResetEmail, + sendExportReadyEmail, + type EmailTemplate, +} from '../../src/services/email.service'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const APP_NAME = process.env.APP_NAME ?? 'BoxMeOut'; + +function lastSendMailCall() { + const calls = mockSendMail.mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][0] as Record; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('sendEmail()', () => { + beforeEach(() => { + mockSendMail.mockClear(); + mockSendMail.mockResolvedValue({ messageId: 'test-id' }); + (logger.error as jest.Mock).mockClear(); + }); + + // ── Recipients ─────────────────────────────────────────────────────────── + describe('recipient routing', () => { + const templates: EmailTemplate[] = [ + 'verify_email', + 'reset_password', + 'market_resolved', + 'winnings_available', + 'dispute_filed', + 'dispute_resolved', + ]; + + it.each(templates)('sends to the correct recipient for template "%s"', async (template) => { + await sendEmail('user@example.com', template, { verifyUrl: 'http://x', resetUrl: 'http://x', marketTitle: 'Test', outcome: 'Yes', amount: '100', disputeId: 'd1', resolution: 'upheld' }); + expect(lastSendMailCall().to).toBe('user@example.com'); + }); + }); + + // ── Subject lines ───────────────────────────────────────────────────────── + describe('subject lines', () => { + it('verify_email uses correct subject', async () => { + await sendEmail('a@b.com', 'verify_email', { verifyUrl: 'http://x' }); + expect(lastSendMailCall().subject).toContain('Verify'); + }); + + it('reset_password uses correct subject', async () => { + await sendEmail('a@b.com', 'reset_password', { resetUrl: 'http://x' }); + expect(lastSendMailCall().subject).toContain('Reset'); + }); + + it('market_resolved uses correct subject', async () => { + await sendEmail('a@b.com', 'market_resolved', { marketTitle: 'Fight', outcome: 'Yes' }); + expect(lastSendMailCall().subject).toContain('resolved'); + }); + + it('winnings_available uses correct subject', async () => { + await sendEmail('a@b.com', 'winnings_available', { amount: '50 XLM', marketTitle: 'Fight' }); + expect(lastSendMailCall().subject).toContain('winnings'); + }); + + it('dispute_filed uses correct subject', async () => { + await sendEmail('a@b.com', 'dispute_filed', { marketTitle: 'Fight', disputeId: 'd1' }); + expect(lastSendMailCall().subject).toContain('Dispute filed'); + }); + + it('dispute_resolved uses correct subject', async () => { + await sendEmail('a@b.com', 'dispute_resolved', { marketTitle: 'Fight', resolution: 'upheld' }); + expect(lastSendMailCall().subject).toContain('Dispute resolved'); + }); + }); + + // ── Template rendering ──────────────────────────────────────────────────── + it('injects appName into the rendered HTML', async () => { + await sendEmail('a@b.com', 'verify_email', { verifyUrl: 'http://verify' }); + const mail = lastSendMailCall(); + expect(mail.html as string).toContain(APP_NAME); + }); + + it('interpolates custom data variables into the HTML', async () => { + await sendEmail('a@b.com', 'verify_email', { verifyUrl: 'http://custom-url' }); + const mail = lastSendMailCall(); + expect(mail.html as string).toContain('http://custom-url'); + }); + + // ── Graceful failure ────────────────────────────────────────────────────── + describe('graceful failure', () => { + it('does not throw when sendMail rejects', async () => { + mockSendMail.mockRejectedValueOnce(new Error('SMTP connection refused')); + await expect(sendEmail('a@b.com', 'verify_email', {})).resolves.toBeUndefined(); + }); + + it('logs an error when sendMail rejects', async () => { + mockSendMail.mockRejectedValueOnce(new Error('timeout')); + await sendEmail('a@b.com', 'market_resolved', { marketTitle: 'Fight', outcome: 'Yes' }); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ msg: 'Failed to send email', template: 'market_resolved' }), + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Convenience wrappers +// --------------------------------------------------------------------------- + +describe('sendPasswordResetEmail()', () => { + beforeEach(() => { + mockSendMail.mockClear(); + mockSendMail.mockResolvedValue({ messageId: 'pw-reset-id' }); + }); + + it('dispatches a mail to the given address', async () => { + await sendPasswordResetEmail('user@example.com', 'tok123'); + expect(mockSendMail).toHaveBeenCalledTimes(1); + expect(lastSendMailCall().to).toBe('user@example.com'); + }); + + it('does not throw on transport failure', async () => { + mockSendMail.mockRejectedValueOnce(new Error('fail')); + await expect(sendPasswordResetEmail('user@example.com', 'tok')).resolves.toBeUndefined(); + }); +}); + +describe('sendExportReadyEmail()', () => { + beforeEach(() => { + mockSendMail.mockClear(); + mockSendMail.mockResolvedValue({ messageId: 'export-id' }); + }); + + it('dispatches a mail with CSV attachment', async () => { + await sendExportReadyEmail('admin@example.com', 'bets', 'col1,col2\n1,2'); + expect(mockSendMail).toHaveBeenCalledTimes(1); + const mail = lastSendMailCall(); + expect(mail.to).toBe('admin@example.com'); + const attachments = mail.attachments as Array<{ filename: string }>; + expect(attachments[0].filename).toMatch(/bets-export-.+\.csv/); + }); + + it('does not throw on transport failure', async () => { + mockSendMail.mockRejectedValueOnce(new Error('fail')); + await expect(sendExportReadyEmail('a@b.com', 'bets', '')).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Provider — SendGrid +// --------------------------------------------------------------------------- + +describe('EMAIL_PROVIDER=sendgrid', () => { + const createTransportMock = nodemailer.createTransport as jest.Mock; + + beforeEach(() => { + createTransportMock.mockClear(); + }); + + it('calls createTransport with smtp.sendgrid.net when provider is sendgrid', () => { + jest.resetModules(); + + process.env.EMAIL_PROVIDER = 'sendgrid'; + process.env.SENDGRID_API_KEY = 'SG.test-key'; + + // Re-require the module so createTransporter() runs with the new env + jest.isolateModules(() => { + jest.mock('nodemailer', () => ({ + createTransport: jest.fn(() => ({ sendMail: jest.fn() })), + })); + jest.mock('fs', () => ({ readFileSync: jest.fn(() => '') })); + jest.mock('../../src/utils/logger', () => ({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, + })); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodemailerFresh = require('nodemailer') as typeof nodemailer; + require('../../src/services/email.service'); + + expect((nodemailerFresh.createTransport as jest.Mock)).toHaveBeenCalledWith( + expect.objectContaining({ host: 'smtp.sendgrid.net' }), + ); + }); + + delete process.env.EMAIL_PROVIDER; + delete process.env.SENDGRID_API_KEY; + }); + + it('falls back to stub transport when SENDGRID_API_KEY is missing', () => { + jest.isolateModules(() => { + jest.mock('nodemailer', () => ({ + createTransport: jest.fn(() => ({ sendMail: jest.fn() })), + })); + jest.mock('fs', () => ({ readFileSync: jest.fn(() => '') })); + jest.mock('../../src/utils/logger', () => ({ + logger: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, + })); + + process.env.EMAIL_PROVIDER = 'sendgrid'; + delete process.env.SENDGRID_API_KEY; + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodemailerFresh = require('nodemailer') as typeof nodemailer; + require('../../src/services/email.service'); + + expect((nodemailerFresh.createTransport as jest.Mock)).toHaveBeenCalledWith( + expect.objectContaining({ jsonTransport: true }), + ); + + delete process.env.EMAIL_PROVIDER; + }); + }); +});