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).
This commit is contained in:
@@ -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 protected]>
|
||||
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
|
||||
|
||||
@@ -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 <[email protected]>'
|
||||
})
|
||||
afterEach(() => {
|
||||
process.env = { ...OLD }
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const msg = { to: '[email protected]', subject: 's', html: '<p>h</p>', 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<string, string>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<SendResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user