From 5295dc2da4d8fd1cb5687cc53bcfc4abc7a7f817 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sun, 17 May 2026 04:50:34 -0700 Subject: [PATCH] feat(notifications): P2b email transport seam (Resend via fetch) lib/email/send.ts: single sendEmail() over Resend's HTTP API (no SDK dependency). Returns a retryable flag so the dispatcher distinguishes transient (429 / 5xx / network) from permanent (config / validation) failures. Unset RESEND_API_KEY/EMAIL_FROM is a clean non-retryable no-op so a fresh clone still runs. Idempotency-Key wired for retry-safe sends. Env documented in .env.example. 6 tests (fetch mocked). --- .env.example | 10 ++++ __tests__/email-send.test.ts | 77 +++++++++++++++++++++++++++++++ lib/email/send.ts | 88 ++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 __tests__/email-send.test.ts create mode 100644 lib/email/send.ts diff --git a/.env.example b/.env.example index 0e3f822..35b4099 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,16 @@ NEXT_PUBLIC_OAUTH_GOOGLE= # Node env (optional, defaults to "development" locally) NODE_ENV=development +# Transactional email (optional; required for dues-lifecycle notifications). +# Resend HTTP API key (https://resend.com, starts with "re_"). Unset = the +# notification outbox still fills but the dispatcher records each row as +# failed with "transport not configured" instead of sending. +RESEND_API_KEY= +# From address for all platform email. Must be on a domain you have verified +# in Resend (SPF + DKIM) for production delivery. Friendly name optional. +# Example: HeatSync Labs +EMAIL_FROM= + # Docker build flag (only when building the standalone image) # Set to "1" in the Dockerfile so next.config.mjs emits standalone output. # DOCKER_BUILD=1 diff --git a/__tests__/email-send.test.ts b/__tests__/email-send.test.ts new file mode 100644 index 0000000..340e254 --- /dev/null +++ b/__tests__/email-send.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { sendEmail } from '@/lib/email/send' + +const OLD = { ...process.env } + +function mockFetch(status: number, body: unknown) { + return vi.fn(async () => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }), + ) +} + +beforeEach(() => { + process.env.RESEND_API_KEY = 're_test' + process.env.EMAIL_FROM = 'Acme ' +}) +afterEach(() => { + process.env = { ...OLD } + vi.restoreAllMocks() +}) + +const msg = { to: 'a@b.test', subject: 's', html: '

h

', text: 't' } + +describe('sendEmail', () => { + it('non-retryable when transport is not configured', async () => { + delete process.env.RESEND_API_KEY + const r = await sendEmail(msg) + expect(r).toEqual({ ok: false, error: expect.stringContaining('not configured'), retryable: false }) + }) + + it('non-retryable on an invalid recipient (never calls fetch)', async () => { + const f = vi.fn() + vi.stubGlobal('fetch', f) + const r = await sendEmail({ ...msg, to: 'nope' }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.retryable).toBe(false) + expect(f).not.toHaveBeenCalled() + }) + + it('ok with the returned id on 200', async () => { + vi.stubGlobal('fetch', mockFetch(200, { id: 'e_123' })) + const r = await sendEmail(msg) + expect(r).toEqual({ ok: true, id: 'e_123' }) + }) + + it('passes the Idempotency-Key header when given', async () => { + const f = mockFetch(200, { id: 'e_1' }) + vi.stubGlobal('fetch', f) + await sendEmail({ ...msg, idempotencyKey: 'row-42' }) + const headers = (f.mock.calls[0][1] as RequestInit).headers as Record + expect(headers['Idempotency-Key']).toBe('row-42') + }) + + it('429 is retryable; 422 is not', async () => { + vi.stubGlobal('fetch', mockFetch(429, { name: 'rate_limit_exceeded', message: 'slow down' })) + const a = await sendEmail(msg) + expect(a.ok).toBe(false) + if (!a.ok) expect(a.retryable).toBe(true) + + vi.stubGlobal('fetch', mockFetch(422, { name: 'validation_error', message: 'bad from' })) + const b = await sendEmail(msg) + expect(b.ok).toBe(false) + if (!b.ok) { + expect(b.retryable).toBe(false) + expect(b.error).toContain('422') + } + }) + + it('network throw is retryable', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNRESET') })) + const r = await sendEmail(msg) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.retryable).toBe(true) + }) +}) diff --git a/lib/email/send.ts b/lib/email/send.ts new file mode 100644 index 0000000..e6ad72e --- /dev/null +++ b/lib/email/send.ts @@ -0,0 +1,88 @@ +// Server-only email transport seam. One function, sendEmail. Today it talks +// to Resend's HTTP API (no SDK dependency, just fetch); a self-hosted deploy +// can replace the body of this module with SMTP without touching any caller. +// When RESEND_API_KEY / EMAIL_FROM are unset the call is a non-retryable +// no-op with a clear reason, so a fresh clone or dev box without mail config +// still runs (the dispatcher records 'failed' rather than crashing). +// +// SendResult.retryable tells the dispatcher whether to leave the row pending +// for another minute (transient: 429 / 5xx / network) or stop (config or +// validation error that a retry will not fix). + +const RESEND_ENDPOINT = 'https://api.resend.com/emails' + +export type SendInput = { + to: string + subject: string + html: string + text: string + // Passed as Resend's Idempotency-Key so a dispatcher retry of the same row + // cannot double-send (Resend dedupes for 24h on key + payload). + idempotencyKey?: string +} + +export type SendResult = + | { ok: true; id: string } + | { ok: false; error: string; retryable: boolean } + +export async function sendEmail(input: SendInput): Promise { + const apiKey = process.env.RESEND_API_KEY + const from = process.env.EMAIL_FROM + + if (!apiKey || !from) { + return { + ok: false, + error: 'Email transport not configured (RESEND_API_KEY / EMAIL_FROM unset).', + retryable: false, + } + } + if (!input.to || !input.to.includes('@')) { + return { ok: false, error: `Invalid recipient: ${input.to || '(empty)'}`, retryable: false } + } + + let res: Response + try { + res = await fetch(RESEND_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + ...(input.idempotencyKey ? { 'Idempotency-Key': input.idempotencyKey } : {}), + }, + body: JSON.stringify({ + from, + to: input.to, + subject: input.subject, + html: input.html, + text: input.text, + }), + }) + } catch (e) { + // Network / DNS / timeout: transient, worth another attempt. + return { ok: false, error: e instanceof Error ? e.message : 'Network error', retryable: true } + } + + if (res.ok) { + try { + const body = (await res.json()) as { id?: string } + return { ok: true, id: body.id ?? 'sent' } + } catch { + return { ok: true, id: 'sent' } + } + } + + let detail = '' + try { + const body = (await res.json()) as { name?: string; message?: string } + detail = [body.name, body.message].filter(Boolean).join(': ') + } catch { + detail = await res.text().catch(() => '') + } + // 429 (rate limit) and 5xx are transient; 4xx config/validation is not. + const retryable = res.status === 429 || res.status >= 500 + return { + ok: false, + error: `Resend ${res.status}${detail ? ` ${detail}` : ''}`, + retryable, + } +}