diff --git a/jsr.json b/jsr.json index 1c34f24..525a4a3 100644 --- a/jsr.json +++ b/jsr.json @@ -39,11 +39,18 @@ "./webhooks/mailgun": "./src/webhooks/mailgun.ts", "./webhooks/sendgrid": "./src/webhooks/sendgrid.ts", "./webhooks/ses": "./src/webhooks/ses.ts", + "./webhooks/standard": "./src/webhooks/standard.ts", "./verify": "./src/verify/index.ts", "./queue": "./src/queue/index.ts", "./queue/memory": "./src/queue/memory.ts", "./queue/unstorage": "./src/queue/unstorage.ts", - "./queue/worker": "./src/queue/worker.ts" + "./queue/worker": "./src/queue/worker.ts", + "./suppression": "./src/suppression/index.ts", + "./compliance": "./src/compliance/index.ts", + "./result": "./src/result/index.ts", + "./ics": "./src/ics/index.ts", + "./inbound/reply": "./src/inbound/reply.ts", + "./inbound/thread": "./src/inbound/thread.ts" }, "publish": { "include": ["src/**/*.ts", "README.md", "LICENSE"], diff --git a/package.json b/package.json index 5d4493f..9e59e68 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,14 @@ "types": "./dist/inbound/mailgun.d.mts", "default": "./dist/inbound/mailgun.mjs" }, + "./inbound/reply": { + "types": "./dist/inbound/reply.d.mts", + "default": "./dist/inbound/reply.mjs" + }, + "./inbound/thread": { + "types": "./dist/inbound/thread.d.mts", + "default": "./dist/inbound/thread.mjs" + }, "./webhooks": { "types": "./dist/webhooks/index.d.mts", "default": "./dist/webhooks/index.mjs" @@ -204,6 +212,10 @@ "types": "./dist/webhooks/ses.d.mts", "default": "./dist/webhooks/ses.mjs" }, + "./webhooks/standard": { + "types": "./dist/webhooks/standard.d.mts", + "default": "./dist/webhooks/standard.mjs" + }, "./verify": { "types": "./dist/verify/index.d.mts", "default": "./dist/verify/index.mjs" @@ -223,6 +235,22 @@ "./queue/worker": { "types": "./dist/queue/worker.d.mts", "default": "./dist/queue/worker.mjs" + }, + "./suppression": { + "types": "./dist/suppression/index.d.mts", + "default": "./dist/suppression/index.mjs" + }, + "./compliance": { + "types": "./dist/compliance/index.d.mts", + "default": "./dist/compliance/index.mjs" + }, + "./result": { + "types": "./dist/result/index.d.mts", + "default": "./dist/result/index.mjs" + }, + "./ics": { + "types": "./dist/ics/index.d.mts", + "default": "./dist/ics/index.mjs" } }, "scripts": { diff --git a/src/compliance/index.ts b/src/compliance/index.ts new file mode 100644 index 0000000..fb1053b --- /dev/null +++ b/src/compliance/index.ts @@ -0,0 +1,130 @@ +/** + * Deliverability and compliance helpers. Currently ships primitives for + * RFC 2369 / RFC 8058 List-Unsubscribe: signing one-click tokens and a + * framework-agnostic HTTP handler that verifies + dispatches to a + * suppression store. + * + * @module + */ + +import type { SuppressionStore } from "../suppression/index.ts" + +const encoder = /* @__PURE__ */ new TextEncoder() + +/** Base64url encode a byte array without padding. */ +function b64url(bytes: Uint8Array): string { + let s = "" + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function b64urlDecode(s: string): Uint8Array { + const pad = s.length % 4 === 2 ? "==" : s.length % 4 === 3 ? "=" : "" + const std = s.replace(/-/g, "+").replace(/_/g, "/") + pad + const bin = atob(std) + const buf = new ArrayBuffer(bin.length) + const out = new Uint8Array(buf) + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out +} + +async function hmacKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ) +} + +/** Opaque, tamper-proof token encoding the unsubscribe subject. */ +export interface UnsubscribeTokenPayload { + recipient: string + campaign?: string + /** Expiry as epoch seconds. Omit for non-expiring tokens. */ + exp?: number +} + +/** Sign a one-click unsubscribe token with HMAC-SHA256. */ +export async function signUnsubscribeToken( + payload: UnsubscribeTokenPayload, + secret: string, +): Promise { + const body = b64url(encoder.encode(JSON.stringify(payload))) + const key = await hmacKey(secret) + const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(body))) + return `${body}.${b64url(sig)}` +} + +/** Verify a token. Returns the payload on success, `null` on tamper / + * expiry. Constant-time via Web Crypto `verify`. */ +export async function verifyUnsubscribeToken( + token: string, + secret: string, + now: () => number = Date.now, +): Promise { + const dot = token.indexOf(".") + if (dot < 0) return null + const body = token.slice(0, dot) + const sig = token.slice(dot + 1) + const key = await hmacKey(secret) + const sigBytes = b64urlDecode(sig) + const bodyBytes = encoder.encode(body) + let ok: boolean + try { + ok = await crypto.subtle.verify( + "HMAC", + key, + sigBytes as BufferSource, + bodyBytes as BufferSource, + ) + } catch { + return null + } + if (!ok) return null + let payload: UnsubscribeTokenPayload + try { + payload = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as UnsubscribeTokenPayload + } catch { + return null + } + if (payload.exp !== undefined && now() / 1000 > payload.exp) return null + return payload +} + +/** Options for `defineUnsubscribeHandler`. */ +export interface UnsubscribeHandlerOptions { + secret: string + /** Query-string key that carries the token. Default: `t`. */ + tokenParam?: string + /** Suppression store receiving the opt-out. */ + store?: SuppressionStore + /** Optional hook fired after a successful unsubscribe. */ + onUnsubscribe?: (payload: UnsubscribeTokenPayload) => void | Promise + /** Custom clock for testing. */ + now?: () => number +} + +/** Framework-agnostic handler — give it a `Request`, it returns a + * `Response`. RFC 8058 requires 200 OK on POST with no user + * confirmation; we honor that. GET also works for mail-client URL + * rendering. */ +export function defineUnsubscribeHandler( + opts: UnsubscribeHandlerOptions, +): (request: Request) => Promise { + const param = opts.tokenParam ?? "t" + return async (request: Request) => { + const url = new URL(request.url) + const token = url.searchParams.get(param) + if (!token) return new Response("missing token", { status: 400 }) + const payload = await verifyUnsubscribeToken(token, opts.secret, opts.now) + if (!payload) return new Response("invalid token", { status: 400 }) + await opts.store?.add(payload.recipient, "unsubscribed", payload.campaign) + await opts.onUnsubscribe?.(payload) + return new Response("unsubscribed", { + status: 200, + headers: { "content-type": "text/plain; charset=utf-8" }, + }) + } +} diff --git a/src/drivers/_smtp/dkim.ts b/src/drivers/_smtp/dkim.ts new file mode 100644 index 0000000..3628ad4 --- /dev/null +++ b/src/drivers/_smtp/dkim.ts @@ -0,0 +1,216 @@ +/** + * RFC 6376 (RSA-SHA256) + RFC 8463 (Ed25519-SHA256) DKIM signer. + * + * Ships relaxed/relaxed canonicalization because it's what gmail, + * yahoo, outlook and every popular mail server prefer in 2026. Uses + * Web Crypto throughout — no node:crypto — so the SMTP driver can run + * on Cloudflare Workers if you bring your own socket transport. + * + * @module + */ + +export interface DkimSignerOptions { + /** DNS selector — the TXT record at `._domainkey.`. */ + selector: string + /** Signing domain. */ + domain: string + /** Private key as PEM (RSA: PKCS8 or PKCS1; Ed25519: PKCS8). Also + * accepts a pre-imported CryptoKey. */ + privateKey: string | CryptoKey + /** Signing algorithm. Default: `rsa-sha256`. */ + algorithm?: "rsa-sha256" | "ed25519-sha256" + /** Headers to include in the signature. Default set is the canonical + * minimal list recommended by RFC 6376. */ + headers?: ReadonlyArray +} + +/** Per-message signer. Takes a built RFC 5322 message (headers + CRLF + + * body) and returns a new message with a leading `DKIM-Signature:` + * header. */ +export async function signDkim(message: string, options: DkimSignerOptions): Promise { + const algorithm = options.algorithm ?? "rsa-sha256" + const headerNames = normalizeHeaderList( + options.headers ?? ["From", "To", "Subject", "Date", "MIME-Version", "Content-Type"], + ) + + const sep = findHeaderBodySeparator(message) + if (sep < 0) throw new Error("[unemail/dkim] message must contain CRLF CRLF separator") + + const headersBlock = message.slice(0, sep) + const body = message.slice(sep + 4) + + const parsedHeaders = parseHeaders(headersBlock) + const canonBody = canonicalizeBodyRelaxed(body) + const bodyHash = await sha256Base64(canonBody) + + const signedHeaderList = headerNames + .filter((n) => parsedHeaders.find((h) => h.name.toLowerCase() === n.toLowerCase())) + .join(":") + + const dkimHeaderFields: Record = { + v: "1", + a: algorithm, + c: "relaxed/relaxed", + d: options.domain, + s: options.selector, + t: Math.floor(Date.now() / 1000).toString(), + bh: bodyHash, + h: signedHeaderList, + b: "", + } + const dkimHeaderNoSig = buildDkimHeader(dkimHeaderFields) + + const toSign = [ + ...headerNames + .map((n) => parsedHeaders.find((h) => h.name.toLowerCase() === n.toLowerCase())) + .filter((h): h is ParsedHeader => h !== undefined) + .map((h) => canonicalizeHeaderRelaxed(h.name, h.value)), + canonicalizeHeaderRelaxed("DKIM-Signature", stripHeaderName(dkimHeaderNoSig)).replace( + /\r\n$/, + "", + ), + ].join("") + + const key = await importKey(options.privateKey, algorithm) + const signature = await signBytes(key, algorithm, new TextEncoder().encode(toSign)) + dkimHeaderFields.b = bytesToBase64(signature) + + const finalHeader = buildDkimHeader(dkimHeaderFields) + return finalHeader + "\r\n" + message +} + +interface ParsedHeader { + name: string + value: string +} + +function parseHeaders(block: string): ParsedHeader[] { + const out: ParsedHeader[] = [] + const lines = block.split(/\r\n/) + let current: ParsedHeader | null = null + for (const line of lines) { + if (!line) continue + if (/^[ \t]/.test(line)) { + if (current) current.value += "\r\n" + line + continue + } + if (current) out.push(current) + const colon = line.indexOf(":") + if (colon < 0) { + current = null + continue + } + current = { name: line.slice(0, colon), value: line.slice(colon + 1) } + } + if (current) out.push(current) + return out +} + +function findHeaderBodySeparator(message: string): number { + // Accept either CRLF CRLF or LF LF (we normalize to CRLF before calling). + const idx = message.indexOf("\r\n\r\n") + if (idx >= 0) return idx + return -1 +} + +function canonicalizeHeaderRelaxed(name: string, value: string): string { + // Lowercase header name, unfold + collapse WSP runs in the value. + const canonValue = value + .replace(/\r\n/g, "") + .replace(/[ \t]+/g, " ") + .replace(/[ \t]+$/g, "") + .replace(/^[ \t]+/g, "") + return `${name.toLowerCase().trim()}:${canonValue}\r\n` +} + +function canonicalizeBodyRelaxed(body: string): string { + // Reduce WSP runs within lines; strip trailing WSP; strip trailing + // empty lines. If the body is empty, return CRLF per RFC 6376. + const lines = body.split(/\r\n/) + // strip trailing empty lines + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop() + if (lines.length === 0) return "\r\n" + return lines.map((l) => l.replace(/[ \t]+/g, " ").replace(/[ \t]+$/g, "")).join("\r\n") + "\r\n" +} + +function buildDkimHeader(fields: Record): string { + const order = ["v", "a", "c", "d", "s", "t", "bh", "h", "b"] + const parts: string[] = [] + for (const k of order) { + if (fields[k] === undefined) continue + parts.push(`${k}=${fields[k]}`) + } + // Fold: keep it simple — one line. Lines >72 chars are tolerated by + // every verifier we care about. + return `DKIM-Signature: ${parts.join("; ")}` +} + +function stripHeaderName(header: string): string { + return header.slice(header.indexOf(":") + 1) +} + +function normalizeHeaderList(names: ReadonlyArray): string[] { + return Array.from(new Set(names.map((n) => n.trim()))) +} + +async function sha256Base64(value: string): Promise { + const bytes = new TextEncoder().encode(value) + const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource) + return bytesToBase64(new Uint8Array(digest)) +} + +async function importKey( + key: string | CryptoKey, + algorithm: "rsa-sha256" | "ed25519-sha256", +): Promise { + if (typeof key !== "string") return key + const pem = key.trim() + const b64 = pem + .replace(/-----BEGIN [A-Z ]+-----/g, "") + .replace(/-----END [A-Z ]+-----/g, "") + .replace(/\s+/g, "") + const der = base64ToBytes(b64) + if (algorithm === "ed25519-sha256") { + return crypto.subtle.importKey( + "pkcs8", + der as BufferSource, + { name: "Ed25519" } as unknown as AlgorithmIdentifier, + false, + ["sign"], + ) + } + return crypto.subtle.importKey( + "pkcs8", + der as BufferSource, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ) +} + +async function signBytes( + key: CryptoKey, + algorithm: "rsa-sha256" | "ed25519-sha256", + data: Uint8Array, +): Promise { + const algo: AlgorithmIdentifier = + algorithm === "ed25519-sha256" + ? ({ name: "Ed25519" } as unknown as AlgorithmIdentifier) + : { name: "RSASSA-PKCS1-v1_5" } + const sig = await crypto.subtle.sign(algo, key, data as BufferSource) + return new Uint8Array(sig) +} + +function bytesToBase64(bytes: Uint8Array): string { + let s = "" + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s) +} + +function base64ToBytes(s: string): Uint8Array { + const bin = atob(s) + const buf = new ArrayBuffer(bin.length) + const out = new Uint8Array(buf) + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out +} diff --git a/src/drivers/brevo.ts b/src/drivers/brevo.ts index eacee79..baf56e1 100644 --- a/src/drivers/brevo.ts +++ b/src/drivers/brevo.ts @@ -95,6 +95,11 @@ function buildBrevoPayload(msg: EmailMessage): Record { payload.scheduledAt = d.toISOString() } if (msg.attachments?.length) payload.attachment = msg.attachments.map(toBrevoAttachment) + if (msg.template) { + if (msg.template.id) + payload.templateId = Number.parseInt(msg.template.id, 10) || msg.template.id + if (msg.template.variables) payload.params = { ...msg.template.variables } + } return payload } diff --git a/src/drivers/loops.ts b/src/drivers/loops.ts index 9df21f5..881c8a7 100644 --- a/src/drivers/loops.ts +++ b/src/drivers/loops.ts @@ -40,14 +40,15 @@ const loops: DriverFactory = defineDriver = defineDriver [t.name, t.value])) + const dataVariables: Record = { + ...(msg.template?.variables ?? {}), + } + for (const t of msg.tags ?? []) dataVariables[t.name] = t.value const res = await httpJson({ fetch: fetchImpl, diff --git a/src/drivers/mailersend.ts b/src/drivers/mailersend.ts index 2545477..6c77e6d 100644 --- a/src/drivers/mailersend.ts +++ b/src/drivers/mailersend.ts @@ -119,6 +119,17 @@ function buildMailerSendPayload(msg: EmailMessage): Record { payload.send_at = Math.floor(d.getTime() / 1000) } if (msg.attachments?.length) payload.attachments = msg.attachments.map(toMsAttachment) + if (msg.template) { + if (msg.template.id) payload.template_id = msg.template.id + if (msg.template.variables) { + payload.personalization = [ + { + email: normalizeAddresses(msg.to)[0]?.email, + data: { ...msg.template.variables }, + }, + ] + } + } return payload } diff --git a/src/drivers/mailgun.ts b/src/drivers/mailgun.ts index 7afc8b1..11aab20 100644 --- a/src/drivers/mailgun.ts +++ b/src/drivers/mailgun.ts @@ -87,6 +87,23 @@ function buildMailgunForm(msg: EmailMessage): FormData { form.append("attachment", blob, a.filename) } } + if (msg.template) { + if (msg.template.id) form.append("template", msg.template.id) + if (msg.template.variables) + form.append("h:X-Mailgun-Variables", JSON.stringify(msg.template.variables)) + } + if (msg.sandbox) form.append("o:testmode", "yes") + if (msg.tracking) { + if (msg.tracking.opens !== undefined) + form.append("o:tracking-opens", msg.tracking.opens ? "yes" : "no") + if (msg.tracking.clicks !== undefined) + form.append("o:tracking-clicks", msg.tracking.clicks ? "yes" : "no") + if (msg.tracking.opens !== undefined || msg.tracking.clicks !== undefined) + form.append("o:tracking", "yes") + } + if (msg.metadata) { + for (const [k, v] of Object.entries(msg.metadata)) form.append(`v:${k}`, v) + } return form } diff --git a/src/drivers/postmark.ts b/src/drivers/postmark.ts index a9c2a8b..02d6c51 100644 --- a/src/drivers/postmark.ts +++ b/src/drivers/postmark.ts @@ -56,7 +56,8 @@ const postmark: DriverFactory = defineDriver const body = res.data as PostmarkSendResponse const result: EmailResult = { @@ -71,17 +72,13 @@ const postmark: DriverFactory = defineDriver buildPayload(m, options.messageStream)) - const res = await request( - fetchImpl, - endpoint, - "/email/batch", - "POST", - options.token, - payload, - ) + const anyTemplate = msgs.some((m) => m.template) + const path = anyTemplate ? "/email/batchWithTemplates" : "/email/batch" + const requestBody = anyTemplate ? { Messages: payload } : payload + const res = await request(fetchImpl, endpoint, path, "POST", options.token, requestBody) if (res.error) return res as never - const body = res.data as PostmarkSendResponse[] - const failures = body.filter((entry) => (entry.ErrorCode ?? 0) !== 0) + const responses = res.data as PostmarkSendResponse[] + const failures = responses.filter((entry) => (entry.ErrorCode ?? 0) !== 0) if (failures.length > 0) { const first = failures[0]! return { @@ -89,16 +86,16 @@ const postmark: DriverFactory = defineDriver ({ + const results: EmailResult[] = responses.map((entry, i) => ({ id: entry.MessageID, driver: DRIVER, stream: msgs[i]?.stream ?? options.messageStream, @@ -137,8 +134,20 @@ function buildPayload(msg: EmailMessage, defaultStream?: string): Record ({ Name, Value })) - if (msg.tags?.length) body.Metadata = Object.fromEntries(msg.tags.map((t) => [t.name, t.value])) + // Postmark treats Metadata as the metadata bag. Prefer msg.metadata; fall back to tags. + if (msg.metadata) body.Metadata = { ...msg.metadata } + else if (msg.tags?.length) + body.Metadata = Object.fromEntries(msg.tags.map((t) => [t.name, t.value])) + if (msg.tags?.length) body.Tag = msg.tags[0]!.name + if (msg.tracking?.opens !== undefined) body.TrackOpens = msg.tracking.opens + if (msg.tracking?.clicks !== undefined) + body.TrackLinks = msg.tracking.clicks ? "HtmlAndText" : "None" if (msg.attachments?.length) body.Attachments = msg.attachments.map(toPostmarkAttachment) + if (msg.template) { + if (msg.template.id) body.TemplateId = Number.parseInt(msg.template.id, 10) || msg.template.id + if (msg.template.alias) body.TemplateAlias = msg.template.alias + if (msg.template.variables) body.TemplateModel = { ...msg.template.variables } + } const stream = msg.stream ?? defaultStream if (stream) body.MessageStream = stream return body diff --git a/src/drivers/resend.ts b/src/drivers/resend.ts index 4f123d8..bdac3d2 100644 --- a/src/drivers/resend.ts +++ b/src/drivers/resend.ts @@ -6,6 +6,8 @@ import type { EmailResult, EmailTag, Result, + SendStatus, + SendStatusState, } from "../types.ts" import { defineDriver } from "../_define.ts" import { formatAddress, normalizeAddresses } from "../_normalize.ts" @@ -59,6 +61,8 @@ const resend: DriverFactory = defineDriver = defineDriver + return { data: undefined, error: null } + }, + + async retrieve(id) { + const res = await request(fetchImpl, endpoint, `/emails/${id}`, "GET", options.apiKey, null) + if (res.error) return res as Result + const body = (res.data ?? {}) as { + id?: string + last_event?: string + created_at?: string + } + return { + data: { + id: body.id ?? id, + driver: DRIVER, + state: mapResendStatus(body.last_event), + at: body.created_at ? new Date(body.created_at) : undefined, + provider: body, + }, + error: null, + } + }, + async sendBatch(msgs) { const payload = msgs.map((m) => buildPayload(m)) const res = await request( @@ -127,6 +164,11 @@ function buildPayload(msg: EmailMessage): Record { if (msg.html) body.html = msg.html if (msg.headers) body.headers = msg.headers if (msg.tags) body.tags = msg.tags.map((t: EmailTag) => ({ name: t.name, value: t.value })) + if (msg.metadata) { + const headers = (body.headers as Record) ?? {} + for (const [k, v] of Object.entries(msg.metadata)) headers[`X-Metadata-${k}`] = v + body.headers = headers + } if (msg.attachments?.length) body.attachments = msg.attachments.map(toResendAttachment) if (msg.scheduledAt) { body.scheduled_at = @@ -151,6 +193,30 @@ function toResendAttachment(a: Attachment): Record { return out } +function mapResendStatus(event?: string): SendStatusState { + switch (event) { + case "sent": + return "sent" + case "delivered": + return "delivered" + case "bounced": + case "delivery_delayed": + return "bounced" + case "complained": + return "complained" + case "opened": + return "opened" + case "clicked": + return "clicked" + case "scheduled": + return "scheduled" + case "cancelled": + return "cancelled" + default: + return "unknown" + } +} + function bytesToBase64(bytes: Uint8Array): string { const g = globalThis as { Buffer?: { from: (b: Uint8Array) => { toString: (enc: string) => string } } @@ -179,7 +245,9 @@ async function request( let res: Response try { - res = await fetchImpl(`${endpoint}${path}`, { method, headers, body: JSON.stringify(body) }) + const init: RequestInit = { method, headers } + if (body !== null && method !== "GET") init.body = JSON.stringify(body) + res = await fetchImpl(`${endpoint}${path}`, init) } catch (err) { return { data: null, error: toEmailError(DRIVER, err) } } diff --git a/src/drivers/sendgrid.ts b/src/drivers/sendgrid.ts index eb37886..57f51eb 100644 --- a/src/drivers/sendgrid.ts +++ b/src/drivers/sendgrid.ts @@ -107,8 +107,20 @@ function buildSendGridPayload( if (msg.attachments?.length) payload.attachments = msg.attachments.map(toSgAttachment) if (msg.headers) payload.headers = msg.headers if (msg.tags?.length) payload.categories = msg.tags.map((t) => t.name) - if (options.templateId) payload.template_id = options.templateId + const templateId = msg.template?.id ?? options.templateId + if (templateId) payload.template_id = templateId + if (msg.template?.variables) personalization.dynamic_template_data = { ...msg.template.variables } if (options.ipPoolName) payload.ip_pool_name = options.ipPoolName + if (msg.metadata) personalization.custom_args = { ...msg.metadata } + if (msg.tracking) { + const t: Record = {} + if (msg.tracking.opens !== undefined) t.open_tracking = { enable: msg.tracking.opens } + if (msg.tracking.clicks !== undefined) t.click_tracking = { enable: msg.tracking.clicks } + if (msg.tracking.unsubscribes !== undefined) + t.subscription_tracking = { enable: msg.tracking.unsubscribes } + if (Object.keys(t).length) payload.tracking_settings = t + } + if (msg.sandbox) payload.mail_settings = { sandbox_mode: { enable: true } } return payload } diff --git a/src/drivers/smtp.ts b/src/drivers/smtp.ts index 7c8b371..e719d60 100644 --- a/src/drivers/smtp.ts +++ b/src/drivers/smtp.ts @@ -1,4 +1,4 @@ -import type { DriverFactory, EmailResult } from "../types.ts" +import type { DriverFactory, EmailMessage, EmailResult } from "../types.ts" import type { ConnectionOptions } from "./_smtp/connection.ts" import type { PoolOptions } from "./_smtp/pool.ts" import type { AuthMethod } from "./_smtp/auth.ts" @@ -7,6 +7,9 @@ import { EmailError } from "../errors.ts" import { createError, createRequiredError, toEmailError } from "../errors.ts" import { buildMime, normalizeMimeInput } from "./_smtp/mime.ts" import { createPool, type ConnectionPool } from "./_smtp/pool.ts" +import { signDkim, type DkimSignerOptions } from "./_smtp/dkim.ts" + +export type { DkimSignerOptions } /** User-visible options. See `docs/drivers/smtp.md` (lands with #54) for * the full matrix. Defaults favor security: `rejectUnauthorized: true`, @@ -30,6 +33,10 @@ export interface SmtpDriverOptions { connectionTimeoutMs?: number commandTimeoutMs?: number disposeGraceMs?: number + /** Sign outbound messages with DKIM (RFC 6376 / RFC 8463). Accepts a + * single signer config or a per-message resolver for multi-tenant + * sending. */ + dkim?: DkimSignerOptions | ((msg: EmailMessage) => DkimSignerOptions | null) } const DRIVER = "smtp" @@ -92,10 +99,12 @@ const smtp: DriverFactory = defineDriver(( const mime = buildMime(normalizeMimeInput(msg, messageId)) if (mime.envelope.rcpt.length === 0) throw createError(DRIVER, "INVALID_OPTIONS", "at least one recipient is required") + const dkimConfig = typeof opts.dkim === "function" ? opts.dkim(msg) : opts.dkim + const body = dkimConfig ? await signDkim(mime.body, dkimConfig) : mime.body const conn = await getPool().acquire() let failed = false try { - await conn.sendMessage(mime.envelope, mime.body) + await conn.sendMessage(mime.envelope, body) const result: EmailResult = { id: messageId, driver: DRIVER, diff --git a/src/drivers/zeptomail.ts b/src/drivers/zeptomail.ts index 5d22104..6505478 100644 --- a/src/drivers/zeptomail.ts +++ b/src/drivers/zeptomail.ts @@ -98,6 +98,11 @@ function buildPayload(msg: EmailMessage, options: ZeptomailDriverOptions): Recor if (msg.attachments?.length) payload.attachments = msg.attachments.map(toZeptoAttachment) if (options.trackClicks) payload.track_clicks = true if (options.trackOpens) payload.track_opens = true + if (msg.template) { + if (msg.template.id) payload.template_key = msg.template.id + if (msg.template.alias) payload.template_alias = msg.template.alias + if (msg.template.variables) payload.merge_info = { ...msg.template.variables } + } return payload } diff --git a/src/email.ts b/src/email.ts index 256ea95..b6b0921 100644 --- a/src/email.ts +++ b/src/email.ts @@ -7,9 +7,14 @@ import type { Middleware, Result, SendContext, + SendStatus, } from "./types.ts" import { memoryIdempotencyStore } from "./_idempotency.ts" -import { toEmailError } from "./errors.ts" +import { createError, toEmailError } from "./errors.ts" + +function createUnsupported(driver: string, op: string) { + return createError(driver, "UNSUPPORTED", `${op}() not supported by "${driver}"`) +} /** Options accepted by `createEmail()`. Only `driver` is required; the rest * have sensible, zero-dependency defaults. */ @@ -34,6 +39,17 @@ export interface Email { isAvailable: (stream?: string) => Promise send: (msg: EmailMessage) => Promise> sendBatch: (msgs: ReadonlyArray) => Promise>> + /** Stream the results of `sendBatch` one at a time — useful for + * large (5k+) fan-outs where you don't want every `EmailResult` in + * memory. Unlike `sendBatch` it never short-circuits on the first + * error; each message yields its own Result. */ + sendBatchStream: (msgs: ReadonlyArray) => AsyncIterable> + /** Cancel a scheduled send on the active (or mounted) driver. Routes + * to `UNSUPPORTED` when the driver's `flags.cancelable` is unset. */ + cancel: (id: string, options?: { stream?: string }) => Promise> + /** Retrieve the state of a previously-sent message. Routes to + * `UNSUPPORTED` when the driver's `flags.retrievable` is unset. */ + retrieve: (id: string, options?: { stream?: string }) => Promise> dispose: () => Promise } @@ -111,6 +127,7 @@ export function createEmail(options: CreateEmailOptions): Email { } try { + msg = applyUnsubscribeHeaders(msg) await runHook("beforeSend", (mw) => mw.beforeSend?.(msg, ctx)) let result = await driver.send(msg, ctx) @@ -156,6 +173,38 @@ export function createEmail(options: CreateEmailOptions): Email { return { data: results, error: null } }, + async cancel(id, opts = {}) { + const driver = api.getMount(opts.stream) + if (!driver.cancel) { + return { + data: null, + error: createUnsupported(driver.name, "cancel"), + } + } + return driver.cancel(id) + }, + + async retrieve(id, opts = {}) { + const driver = api.getMount(opts.stream) + if (!driver.retrieve) { + return { + data: null, + error: createUnsupported(driver.name, "retrieve"), + } + } + return driver.retrieve(id) + }, + + sendBatchStream(msgs) { + const api2 = api + return { + async *[Symbol.asyncIterator]() { + await ensureInitialized() + for (const msg of msgs) yield await api2.send(msg) + }, + } + }, + async dispose() { await options.driver.dispose?.() for (const driver of mounts.values()) await driver.dispose?.() @@ -192,6 +241,33 @@ export function createEmail(options: CreateEmailOptions): Email { return api } +function applyUnsubscribeHeaders(msg: EmailMessage): EmailMessage { + if (!msg.unsubscribe) return msg + const { url, mailto, oneClick } = msg.unsubscribe + if (!url && !mailto) return msg + const parts: string[] = [] + if (url) parts.push(`<${url}>`) + if (mailto) parts.push(``) + const existing = msg.headers ?? {} + const headers: Record = { ...existing } + if (!hasHeader(existing, "list-unsubscribe")) { + headers["List-Unsubscribe"] = parts.join(", ") + } + const wantsOneClick = oneClick ?? Boolean(url) + if (wantsOneClick && url && !hasHeader(existing, "list-unsubscribe-post")) { + headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" + } + return { ...msg, headers } +} + +function hasHeader(headers: Record, name: string): boolean { + const lower = name.toLowerCase() + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lower) return true + } + return false +} + function resolveIdempotency( input: CreateEmailOptions["idempotency"], ): { store: IdempotencyStore; ttlSeconds?: number } | null { diff --git a/src/ics/index.ts b/src/ics/index.ts new file mode 100644 index 0000000..c3ad565 --- /dev/null +++ b/src/ics/index.ts @@ -0,0 +1,159 @@ +/** + * Minimal, dependency-free builder for iCalendar (RFC 5545) event + * invites. Produces an `Attachment` you can hand to `email.send()`. + * + * Scope: single VEVENT with optional organizer + attendees + alarms. + * That covers the meeting-invite use case, which is the 95% that + * nodemailer's `icalEvent` shipped and every user needs. + * + * @module + */ + +import type { Attachment } from "../types.ts" + +export type IcsMethod = "REQUEST" | "PUBLISH" | "CANCEL" | "REPLY" +export type IcsStatus = "CONFIRMED" | "TENTATIVE" | "CANCELLED" +export type IcsRole = "REQ-PARTICIPANT" | "OPT-PARTICIPANT" | "CHAIR" | "NON-PARTICIPANT" +export type IcsPartStat = "ACCEPTED" | "DECLINED" | "TENTATIVE" | "NEEDS-ACTION" + +export interface IcsAttendee { + email: string + name?: string + role?: IcsRole + partstat?: IcsPartStat + rsvp?: boolean +} + +export interface IcsAlarm { + /** Minutes before the event start (positive). */ + triggerMinutesBefore: number + description?: string +} + +export interface IcsEvent { + /** Stable unique id — required by RFC 5545. */ + uid: string + /** Local or UTC Date. */ + start: Date + /** Local or UTC Date. */ + end: Date + summary: string + description?: string + location?: string + url?: string + status?: IcsStatus + organizer?: { email: string; name?: string } + attendees?: ReadonlyArray + alarms?: ReadonlyArray + /** 0 for new invites, incremented on updates. Default 0. */ + sequence?: number +} + +export interface IcsOptions { + method?: IcsMethod + /** PRODID identifier. Default: `-//unemail//ics//EN`. */ + prodId?: string + /** Filename for the attachment. Default: `invite.ics`. */ + filename?: string +} + +/** Build an iCalendar `VEVENT` attachment for an email. Content-Type is + * set with the canonical `method=` parameter so Outlook / Gmail render + * the invite inline. */ +export function icalEvent(event: IcsEvent, options: IcsOptions = {}): Attachment { + const method = options.method ?? "REQUEST" + const prodId = options.prodId ?? "-//unemail//ics//EN" + const filename = options.filename ?? "invite.ics" + const content = buildIcs(event, method, prodId) + return { + filename, + content, + contentType: `text/calendar; charset=UTF-8; method=${method}`, + disposition: "attachment", + } +} + +function buildIcs(event: IcsEvent, method: IcsMethod, prodId: string): string { + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + `PRODID:${prodId}`, + `METHOD:${method}`, + "CALSCALE:GREGORIAN", + "BEGIN:VEVENT", + `UID:${event.uid}`, + `DTSTAMP:${formatUtc(new Date())}`, + `DTSTART:${formatUtc(event.start)}`, + `DTEND:${formatUtc(event.end)}`, + `SUMMARY:${escapeText(event.summary)}`, + `SEQUENCE:${event.sequence ?? 0}`, + `STATUS:${event.status ?? "CONFIRMED"}`, + ] + if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`) + if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`) + if (event.url) lines.push(`URL:${event.url}`) + if (event.organizer) { + const cn = event.organizer.name ? `CN=${escapeText(event.organizer.name)}:` : "" + lines.push(`ORGANIZER;${cn}mailto:${event.organizer.email}`) + } + for (const a of event.attendees ?? []) lines.push(formatAttendee(a)) + for (const alarm of event.alarms ?? []) { + lines.push( + "BEGIN:VALARM", + "ACTION:DISPLAY", + `TRIGGER:-PT${Math.round(alarm.triggerMinutesBefore)}M`, + `DESCRIPTION:${escapeText(alarm.description ?? event.summary)}`, + "END:VALARM", + ) + } + lines.push("END:VEVENT", "END:VCALENDAR") + return lines.map(foldLine).join("\r\n") + "\r\n" +} + +function formatUtc(d: Date): string { + const pad = (n: number) => String(n).padStart(2, "0") + return ( + d.getUTCFullYear().toString() + + pad(d.getUTCMonth() + 1) + + pad(d.getUTCDate()) + + "T" + + pad(d.getUTCHours()) + + pad(d.getUTCMinutes()) + + pad(d.getUTCSeconds()) + + "Z" + ) +} + +function escapeText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") +} + +function formatAttendee(a: IcsAttendee): string { + const parts: string[] = [] + if (a.name) parts.push(`CN=${escapeText(a.name)}`) + if (a.role) parts.push(`ROLE=${a.role}`) + if (a.partstat) parts.push(`PARTSTAT=${a.partstat}`) + if (a.rsvp !== undefined) parts.push(`RSVP=${a.rsvp ? "TRUE" : "FALSE"}`) + const suffix = parts.length ? `;${parts.join(";")}` : "" + return `ATTENDEE${suffix}:mailto:${a.email}` +} + +/** RFC 5545 requires lines ≤ 75 octets, continuation lines start with a + * single space. We approximate by chars since all our content is ASCII + * after escaping — if you need UTF-8 display names this still works + * because the folded continuation is decoded identically. */ +function foldLine(line: string): string { + if (line.length <= 75) return line + const parts: string[] = [] + let start = 0 + while (start < line.length) { + const chunk = line.slice(start, start + 75) + parts.push(start === 0 ? chunk : ` ${chunk}`) + start += 75 + } + return parts.join("\r\n") +} diff --git a/src/inbound/reply.ts b/src/inbound/reply.ts new file mode 100644 index 0000000..94eaed1 --- /dev/null +++ b/src/inbound/reply.ts @@ -0,0 +1,74 @@ +/** + * Extract the "new content" from a reply email — strip quoted previous + * messages and trailing signatures. + * + * The world moved on from the Ruby `email_reply_parser` heuristics + * around 2018 so we ship the same approach: look for canonical + * header-bodied replies ("On ... wrote:") and signature dashes. + * Covers English, Turkish, German, French, Spanish. + * + * @module + */ + +export interface ReplyParseResult { + /** Just the new content the author wrote. */ + text: string + /** Everything after the new content (quoted previous message + sig). */ + quoted: string +} + +const HEADER_PATTERNS: ReadonlyArray = [ + // English: "On Mon, Jan 1, 2026 at 3:00 PM Name wrote:" + /^[ \t]*On\b.*\bwrote:\s*$/im, + // Turkish: "1 Ocak 2026 Pazartesi tarihinde Name şunları yazdı:" + /^[ \t]*.*tarihinde\b.*(yazd\u0131|\u015funlar\u0131 yazd\u0131):\s*$/im, + // German: "Am 1. Januar 2026 um 15:00 schrieb Name :" + /^[ \t]*Am\b.*\bschrieb\b.*:\s*$/im, + // French: "Le 1 janvier 2026 à 15:00, Name a écrit :" + /^[ \t]*Le\b.*\ba [eé]crit\b.*:\s*$/im, + // Spanish: "El 1 de enero de 2026, Name escribió:" + /^[ \t]*El\b.*\bescribi[oó]\b.*:\s*$/im, + // Outlook-style forwarded header block + /^[ \t]*-----\s*Original Message\s*-----\s*$/im, + /^[ \t]*From:\s/m, +] + +/** Strip quoted history and signature; keep just the new content. */ +export function stripReply(rawText: string): ReplyParseResult { + const normalized = rawText.replace(/\r\n/g, "\n") + + // 1. Cut at the earliest header-bodied quote marker. + let cutIndex = normalized.length + for (const pattern of HEADER_PATTERNS) { + const match = pattern.exec(normalized) + if (match && match.index < cutIndex) cutIndex = match.index + } + + // 2. Cut at the first line starting with one or more ">" chars after + // content, preceded by a blank line. + const quoteMatch = /\n\s*\n(?:>[^\n]*\n?)+/g.exec(normalized) + if (quoteMatch && quoteMatch.index < cutIndex) cutIndex = quoteMatch.index + + let text = normalized.slice(0, cutIndex).replace(/[\s\n]+$/g, "") + const quoted = normalized.slice(cutIndex).replace(/^[\s\n]+/, "") + + // 3. Strip trailing signature block introduced by "-- \n" or common + // sign-offs on their own line. + text = stripSignature(text) + + return { text, quoted } +} + +const SIGN_OFF_PATTERNS: ReadonlyArray = [ + /\n--\s*\n[\s\S]*$/, // canonical RFC 3676 + /\n[ \t]*(Thanks|Regards|Cheers|Best|Sincerely|Yours|Sent from my [A-Za-z]+)[^\n]*\n[\s\S]*$/i, + /\n[ \t]*(Te\u015fekk\u00fcrler|Sayg\u0131lar\u0131mla|Selamlar)[^\n]*\n[\s\S]*$/i, +] + +function stripSignature(text: string): string { + for (const pattern of SIGN_OFF_PATTERNS) { + const match = pattern.exec(text) + if (match) return text.slice(0, match.index).replace(/[\s\n]+$/g, "") + } + return text +} diff --git a/src/inbound/thread.ts b/src/inbound/thread.ts new file mode 100644 index 0000000..28d0567 --- /dev/null +++ b/src/inbound/thread.ts @@ -0,0 +1,46 @@ +/** + * Thread-key derivation from RFC 5322 `Message-ID`, `In-Reply-To`, + * and `References` headers. Given a parsed email we return a stable + * identifier that groups messages in the same conversation. + * + * @module + */ + +import type { ParsedEmail } from "../parse/index.ts" + +export interface ThreadKeyInput { + messageId?: string + inReplyTo?: string + references?: ReadonlyArray +} + +/** Pick the canonical root Message-ID for a parsed email. */ +export function threadKey(input: ThreadKeyInput | ParsedEmail): string { + const msg = input as ThreadKeyInput + const refs = msg.references ?? [] + const candidates: string[] = [] + if (refs[0]) candidates.push(refs[0]) + if (msg.inReplyTo) candidates.push(msg.inReplyTo) + if (msg.messageId) candidates.push(msg.messageId) + const first = candidates.find(Boolean) + if (!first) return "__no_thread__" + return normalizeMessageId(first) +} + +/** Build a deterministic adjacency list `{ root -> [member-ids] }` from + * a batch of parsed messages. Useful for UI grouping. */ +export function buildThreads(messages: ReadonlyArray): Map { + const out = new Map() + for (const m of messages) { + const key = threadKey(m) + const id = m.messageId ? normalizeMessageId(m.messageId) : key + const list = out.get(key) ?? [] + list.push(id) + out.set(key, list) + } + return out +} + +function normalizeMessageId(id: string): string { + return id.trim().replace(/^<|>$/g, "") +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts index cb9144c..046127c 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -6,6 +6,7 @@ export { export { type LogEntry, type LoggerOptions, withLogger } from "./logger.ts" export { withRateLimit, type RateLimitOptions } from "./rate-limit.ts" export { withRetry, type RetryOptions } from "./retry.ts" +export { type SuppressionOptions, type SuppressionPolicy, withSuppression } from "./suppression.ts" export { type OtelSpan, type OtelTracer, diff --git a/src/middleware/retry.ts b/src/middleware/retry.ts index 6323396..1c5d875 100644 --- a/src/middleware/retry.ts +++ b/src/middleware/retry.ts @@ -1,6 +1,19 @@ import type { EmailDriver, EmailResult, Result } from "../types.ts" import { toEmailError } from "../errors.ts" +/** Backoff strategies. + * - `exponential` — `initialDelay * 2^attempt` (default). + * - `constant` — `initialDelay` every time. + * - `exponential-jitter` — exponential with ±50% uniform noise. + * - `full-jitter` — random in `[0, exponential]` (AWS recommendation). + * - `decorrelated-jitter` — `random(initialDelay, prev * 3)`. */ +export type RetryBackoff = + | "exponential" + | "constant" + | "exponential-jitter" + | "full-jitter" + | "decorrelated-jitter" + /** Options for `withRetry`. All numeric values are milliseconds unless * noted. `respectRetryAfter` honors `error.status === 429` with the * matching `Retry-After` surfaced via `error.cause`. */ @@ -11,14 +24,19 @@ export interface RetryOptions { initialDelay?: number /** Maximum backoff delay between attempts. Default: 10_000ms. */ maxDelay?: number - /** Backoff strategy. `exponential` doubles; `constant` keeps `initialDelay`. */ - backoff?: "exponential" | "constant" + /** Backoff strategy. See `RetryBackoff`. */ + backoff?: RetryBackoff /** Honor a `Retry-After` seconds value when present on 429. Default: true. */ respectRetryAfter?: boolean /** Override default retryability — by default only `error.retryable === true`. */ shouldRetry?: (error: NonNullable["error"]>, attempt: number) => boolean + /** Route exhausted sends to this driver (dead-letter). Original error + * is preserved on `ctx.meta.deadLetterReason`. */ + deadLetter?: EmailDriver /** Injected for tests. Default: `setTimeout`. */ sleep?: (ms: number, signal?: AbortSignal) => Promise + /** Injected for deterministic jitter in tests. Default: `Math.random`. */ + random?: () => number } /** Wrap a driver so every send is retried on transient failures. Returns a @@ -36,12 +54,15 @@ export function withRetry(driver: EmailDriver, options: RetryOptions = {}): Emai const respectRetryAfter = options.respectRetryAfter ?? true const sleep = options.sleep ?? defaultSleep const shouldRetry = options.shouldRetry ?? ((err) => err.retryable) + const random = options.random ?? Math.random + const deadLetter = options.deadLetter return { ...driver, name: driver.name, async send(msg, ctx) { let lastError: NonNullable["error"]> | null = null + let lastDelay = initialDelay for (let attempt = 0; attempt <= retries; attempt++) { ctx.attempt = attempt + 1 if (ctx.signal?.aborted) { @@ -58,31 +79,49 @@ export function withRetry(driver: EmailDriver, options: RetryOptions = {}): Emai } if (result.data) return result lastError = result.error - if (attempt === retries || !shouldRetry(result.error, attempt + 1)) return result - await sleep( - computeDelay({ - attempt, - initialDelay, - maxDelay, - backoff, - respectRetryAfter, - error: result.error, - }), - ctx.signal, - ) + if (attempt === retries || !shouldRetry(result.error, attempt + 1)) { + return deadLetter ? routeToDeadLetter(deadLetter, msg, ctx, result.error) : result + } + const delay = computeDelay({ + attempt, + initialDelay, + maxDelay, + backoff, + respectRetryAfter, + error: result.error, + random, + previousDelay: lastDelay, + }) + lastDelay = delay + await sleep(delay, ctx.signal) } - return { data: null, error: lastError! } + return deadLetter && lastError + ? routeToDeadLetter(deadLetter, msg, ctx, lastError) + : { data: null, error: lastError! } }, } } +async function routeToDeadLetter( + dlq: EmailDriver, + msg: Parameters[0], + ctx: Parameters[1], + error: NonNullable["error"]>, +): Promise> { + ctx.meta.deadLetterReason = error.message + ctx.meta.deadLetterCode = error.code + return dlq.send(msg, ctx) +} + interface DelayInput { attempt: number initialDelay: number maxDelay: number - backoff: "exponential" | "constant" + backoff: RetryBackoff respectRetryAfter: boolean error: NonNullable["error"]> + random: () => number + previousDelay: number } function computeDelay(input: DelayInput): number { @@ -90,9 +129,24 @@ function computeDelay(input: DelayInput): number { const retryAfter = extractRetryAfter(input.error.cause) if (retryAfter != null) return Math.min(retryAfter * 1000, input.maxDelay) } - const base = - input.backoff === "exponential" ? input.initialDelay * 2 ** input.attempt : input.initialDelay - return Math.min(base, input.maxDelay) + const exp = input.initialDelay * 2 ** input.attempt + switch (input.backoff) { + case "constant": + return Math.min(input.initialDelay, input.maxDelay) + case "exponential": + return Math.min(exp, input.maxDelay) + case "exponential-jitter": { + const jitter = 0.5 + input.random() + return Math.min(Math.floor(exp * jitter), input.maxDelay) + } + case "full-jitter": + return Math.min(Math.floor(input.random() * exp), input.maxDelay) + case "decorrelated-jitter": { + const high = Math.max(input.previousDelay * 3, input.initialDelay) + const value = input.initialDelay + input.random() * (high - input.initialDelay) + return Math.min(Math.floor(value), input.maxDelay) + } + } } function extractRetryAfter(cause: unknown): number | null { diff --git a/src/middleware/suppression.ts b/src/middleware/suppression.ts new file mode 100644 index 0000000..ecf76e1 --- /dev/null +++ b/src/middleware/suppression.ts @@ -0,0 +1,69 @@ +import type { EmailDriver, EmailMessage } from "../types.ts" +import type { SuppressionStore } from "../suppression/index.ts" +import { createError } from "../errors.ts" +import { normalizeAddresses } from "../_normalize.ts" + +/** Behavior when a recipient is suppressed. + * - `"error"` — return an `EmailError` with code `PROVIDER` and + * suppression metadata. + * - `"drop"` — strip the suppressed recipients and continue. If + * every recipient is suppressed, behaves like `"error"`. */ +export type SuppressionPolicy = "error" | "drop" + +export interface SuppressionOptions { + store: SuppressionStore + policy?: SuppressionPolicy + /** Hook fired for each suppressed recipient. */ + onBlocked?: (recipient: string, reason: string) => void +} + +/** Wrap a driver so `send()` checks the suppression store before the + * request leaves the process. */ +export function withSuppression(driver: EmailDriver, options: SuppressionOptions): EmailDriver { + const policy = options.policy ?? "error" + return { + ...driver, + async send(msg, ctx) { + const all = [ + ...normalizeAddresses(msg.to), + ...normalizeAddresses(msg.cc), + ...normalizeAddresses(msg.bcc), + ] + const blocked: Array<{ recipient: string; reason: string }> = [] + const allowed = new Set() + for (const addr of all) { + const rec = await options.store.has(addr.email) + if (rec) { + blocked.push({ recipient: addr.email, reason: String(rec.reason) }) + options.onBlocked?.(addr.email, String(rec.reason)) + } else { + allowed.add(addr.email.toLowerCase()) + } + } + if (blocked.length === 0) return driver.send(msg, ctx) + + if (policy === "error" || allowed.size === 0) { + return { + data: null, + error: createError( + driver.name, + "PROVIDER", + `recipient suppressed: ${blocked.map((b) => b.recipient).join(", ")}`, + { retryable: false }, + ), + } + } + + return driver.send(filterRecipients(msg, allowed), ctx) + }, + } +} + +function filterRecipients(msg: EmailMessage, allowed: Set): EmailMessage { + const keep = (input: EmailMessage["to"] | undefined) => { + if (!input) return undefined + const list = normalizeAddresses(input).filter((a) => allowed.has(a.email.toLowerCase())) + return list.length ? list : undefined + } + return { ...msg, to: keep(msg.to) ?? msg.to, cc: keep(msg.cc), bcc: keep(msg.bcc) } +} diff --git a/src/queue/memory.ts b/src/queue/memory.ts index 27a03b6..5416036 100644 --- a/src/queue/memory.ts +++ b/src/queue/memory.ts @@ -23,11 +23,13 @@ export function memoryQueue(options: MemoryQueueOptions = {}): EmailQueue { if (items.length >= maxSize) throw new Error(`[unemail/queue/memory] max size ${maxSize} reached`) const stamp = now() + const scheduled = msg.scheduledAt ? new Date(msg.scheduledAt).getTime() : 0 + const visible = Math.max(stamp + (opts.delayMs ?? 0), scheduled) const item: QueueItem = { id: opts.id ?? `mq_${++counter}_${stamp.toString(36)}`, msg, attempts: 0, - nextAttemptAt: stamp + (opts.delayMs ?? 0), + nextAttemptAt: visible, createdAt: stamp, } items.push(item) diff --git a/src/queue/unstorage.ts b/src/queue/unstorage.ts index df55266..bf37890 100644 --- a/src/queue/unstorage.ts +++ b/src/queue/unstorage.ts @@ -28,12 +28,15 @@ export function unstorageQueue(options: UnstorageQueueOptions): EmailQueue { return { name: "unstorage", async enqueue(msg: EmailMessage, opts: QueueEnqueueOptions = {}) { + const stamp = Date.now() + const scheduled = msg.scheduledAt ? new Date(msg.scheduledAt).getTime() : 0 + const visible = Math.max(stamp + (opts.delayMs ?? 0), scheduled) const item: QueueItem = { - id: opts.id ?? `uq_${++counter}_${Date.now().toString(36)}`, + id: opts.id ?? `uq_${++counter}_${stamp.toString(36)}`, msg, attempts: 0, - nextAttemptAt: Date.now() + (opts.delayMs ?? 0), - createdAt: Date.now(), + nextAttemptAt: visible, + createdAt: stamp, } await options.storage.setItem(key(item.id), item) return item diff --git a/src/result/index.ts b/src/result/index.ts new file mode 100644 index 0000000..8565acc --- /dev/null +++ b/src/result/index.ts @@ -0,0 +1,54 @@ +/** + * Narrow, dependency-free helpers for the `Result` discriminated + * union. Import when you prefer fluent semantics over discriminating + * `{ data, error }` by hand. + * + * @module + */ + +import type { EmailError, Result } from "../types.ts" + +export function isOk(r: Result): r is { data: T; error: null } { + return r.error === null +} + +export function isErr(r: Result): r is { data: null; error: EmailError } { + return r.error !== null +} + +/** Return `data` or throw the `error`. Intentionally dramatic — only + * use when you're sure the caller wants a throw. */ +export function unwrap(r: Result): T { + if (r.error) throw r.error + return r.data +} + +/** Return `data` if Ok, `fallback` otherwise. */ +export function unwrapOr(r: Result, fallback: T): T { + return r.error ? fallback : r.data +} + +/** Apply `f` to `data` if Ok; pass through the Err unchanged. */ +export function mapOk(r: Result, f: (t: T) => U): Result { + if (r.error) return r as unknown as Result + return { data: f(r.data), error: null } +} + +/** Transform the `error` while preserving Ok. */ +export function mapErr(r: Result, f: (e: EmailError) => EmailError): Result { + if (!r.error) return r + return { data: null, error: f(r.error) } +} + +/** Run `f` and capture any thrown `EmailError` into a `Result`. Any + * non-EmailError exception re-throws. */ +export async function tryAsync( + f: () => Promise, + wrap: (err: unknown) => EmailError, +): Promise> { + try { + return { data: await f(), error: null } + } catch (err) { + return { data: null, error: wrap(err) } + } +} diff --git a/src/suppression/index.ts b/src/suppression/index.ts new file mode 100644 index 0000000..df8689d --- /dev/null +++ b/src/suppression/index.ts @@ -0,0 +1,107 @@ +/** + * Suppression store — persistent record of recipients who must not + * receive mail. Unifies bounces, complaints, and opt-outs across every + * driver we ship. + * + * @module + */ + +import type { MaybePromise } from "../types.ts" + +export type SuppressionReason = + | "bounce" + | "complaint" + | "unsubscribed" + | "manual" + | "invalid" + | string + +export interface SuppressionRecord { + recipient: string + reason: SuppressionReason + source?: string + at: Date +} + +export interface SuppressionStore { + has: (recipient: string) => MaybePromise + add: (recipient: string, reason: SuppressionReason, source?: string) => MaybePromise + remove: (recipient: string) => MaybePromise + list?: () => MaybePromise> +} + +/** Options for `memorySuppressionStore`. */ +export interface MemorySuppressionStoreOptions { + /** Injectable clock for deterministic tests. */ + now?: () => number +} + +/** In-memory store. Recipients are normalized to lowercase. */ +export function memorySuppressionStore(opts: MemorySuppressionStoreOptions = {}): SuppressionStore { + const now = opts.now ?? Date.now + const map = new Map() + const key = (r: string) => r.toLowerCase().trim() + return { + has(recipient) { + return map.get(key(recipient)) ?? null + }, + add(recipient, reason, source) { + map.set(key(recipient), { recipient, reason, source, at: new Date(now()) }) + }, + remove(recipient) { + map.delete(key(recipient)) + }, + list() { + return Array.from(map.values()) + }, + } +} + +/** Minimal unstorage-like contract (decoupled so we don't bind the + * dependency). Mirrors the shape already used by `src/queue`. */ +interface UnstorageLike { + getItem: (key: string) => MaybePromise + setItem: (key: string, value: unknown) => MaybePromise + removeItem: (key: string) => MaybePromise + getKeys?: (base?: string) => MaybePromise> +} + +/** Persist suppressions to any `unstorage` driver (KV, Redis, + * filesystem, …). Keys are prefixed with `suppression:`. */ +export function unstorageSuppressionStore(storage: UnstorageLike): SuppressionStore { + const prefix = "suppression:" + const key = (r: string) => prefix + r.toLowerCase().trim() + return { + async has(recipient) { + const value = await storage.getItem(key(recipient)) + if (!value) return null + return deserialize(value) + }, + async add(recipient, reason, source) { + const rec: SuppressionRecord = { recipient, reason, source, at: new Date() } + await storage.setItem(key(recipient), serialize(rec)) + }, + async remove(recipient) { + await storage.removeItem(key(recipient)) + }, + async list() { + if (!storage.getKeys) return [] + const keys = await storage.getKeys(prefix) + const out: SuppressionRecord[] = [] + for (const k of keys) { + const value = await storage.getItem(k) + if (value) out.push(deserialize(value)) + } + return out + }, + } +} + +function serialize(rec: SuppressionRecord): unknown { + return { ...rec, at: rec.at.toISOString() } +} + +function deserialize(value: unknown): SuppressionRecord { + const v = value as { recipient: string; reason: string; source?: string; at: string } + return { recipient: v.recipient, reason: v.reason, source: v.source, at: new Date(v.at) } +} diff --git a/src/test/index.ts b/src/test/index.ts index 6b3f648..917059e 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -1,2 +1,2 @@ export { createTestEmail, type CreateTestEmailOptions, type TestEmail } from "./inbox.ts" -export { emailMatchers, matchesEmail, type EmailMatch } from "./matchers.ts" +export { emailMatchers, matchesEmail, toEmailSnapshot, type EmailMatch } from "./matchers.ts" diff --git a/src/test/matchers.ts b/src/test/matchers.ts index 5c537c9..626bf19 100644 --- a/src/test/matchers.ts +++ b/src/test/matchers.ts @@ -53,31 +53,41 @@ function formatExpected(value: unknown): string { return JSON.stringify(value) } -/** Vitest-compatible matcher: `expect(email).toHaveSent(match)`. Register - * from a test setup file: +function inboxOf(received: unknown): readonly EmailMessage[] | null { + const v = received as { inbox?: unknown } | null | undefined + if (v && Array.isArray(v.inbox)) return v.inbox as readonly EmailMessage[] + return null +} + +/** Vitest-compatible matchers: register from a test setup file. * * ```ts * import { expect } from "vitest" * import { emailMatchers } from "unemail/test" * expect.extend(emailMatchers) * ``` + * + * Adds: + * - `toHaveSent(match)` — any sent email matches the partial. + * - `toHaveSentTo(address)` — any sent email contains this recipient. + * - `toHaveSentWithSubject(pattern)` — subject matches exact or regex. + * - `toHaveSentWithAttachment(filename | predicate)`. + * - `toHaveSentMatching(predicate)` — fully custom. */ export const emailMatchers = { toHaveSent( received: { inbox: readonly EmailMessage[] }, match: EmailMatch, - ): { - pass: boolean - message: () => string - } { - if (!received || !Array.isArray(received.inbox)) + ): { pass: boolean; message: () => string } { + const inbox = inboxOf(received) + if (!inbox) return { pass: false, message: () => `toHaveSent: received value does not expose an inbox; pass a TestEmail instance`, } const hits: string[] = [] - for (const msg of received.inbox) { + for (const msg of inbox) { const { pass, diff } = matchesEmail(msg, match) if (pass) return { pass: true, message: () => `expected no email to match ${JSON.stringify(match)}` } @@ -86,7 +96,139 @@ export const emailMatchers = { return { pass: false, message: () => - `expected an email to match ${JSON.stringify(match)}; checked ${received.inbox.length} message(s):\n - ${hits.join("\n - ")}`, + `expected an email to match ${JSON.stringify(match)}; checked ${inbox.length} message(s):\n - ${hits.join("\n - ")}`, + } + }, + + toHaveSentTo( + received: { inbox: readonly EmailMessage[] }, + recipient: string, + ): { pass: boolean; message: () => string } { + const inbox = inboxOf(received) + if (!inbox) + return { + pass: false, + message: () => + `toHaveSentTo: received value does not expose an inbox; pass a TestEmail instance`, + } + for (const msg of inbox) { + const emails = [ + ...normalizeAddresses(msg.to), + ...normalizeAddresses(msg.cc), + ...normalizeAddresses(msg.bcc), + ].map((a) => a.email.toLowerCase()) + if (emails.includes(recipient.toLowerCase())) + return { pass: true, message: () => `expected no email to be sent to ${recipient}` } + } + return { + pass: false, + message: () => + `expected an email to ${recipient}; ${inbox.length} message(s) checked but none matched`, + } + }, + + toHaveSentWithSubject( + received: { inbox: readonly EmailMessage[] }, + pattern: string | RegExp, + ): { pass: boolean; message: () => string } { + const inbox = inboxOf(received) + if (!inbox) + return { + pass: false, + message: () => + `toHaveSentWithSubject: received value does not expose an inbox; pass a TestEmail instance`, + } + for (const msg of inbox) { + const ok = pattern instanceof RegExp ? pattern.test(msg.subject) : msg.subject === pattern + if (ok) + return { + pass: true, + message: () => `expected no email with subject ${formatExpected(pattern)}`, + } + } + return { + pass: false, + message: () => + `expected an email with subject ${formatExpected(pattern)}; got ${inbox.map((m) => JSON.stringify(m.subject)).join(", ")}`, + } + }, + + toHaveSentWithAttachment( + received: { inbox: readonly EmailMessage[] }, + match: string | ((a: NonNullable[number]) => boolean), + ): { pass: boolean; message: () => string } { + const inbox = inboxOf(received) + if (!inbox) + return { + pass: false, + message: () => + `toHaveSentWithAttachment: received value does not expose an inbox; pass a TestEmail instance`, + } + const predicate = + typeof match === "string" ? (a: { filename: string }) => a.filename === match : match + for (const msg of inbox) { + if ((msg.attachments ?? []).some(predicate)) + return { pass: true, message: () => `expected no email with a matching attachment` } + } + return { + pass: false, + message: () => + `expected an email with an attachment matching ${typeof match === "string" ? match : ""}; checked ${inbox.length} message(s)`, + } + }, + + toHaveSentMatching( + received: { inbox: readonly EmailMessage[] }, + predicate: (msg: EmailMessage) => boolean, + ): { pass: boolean; message: () => string } { + const inbox = inboxOf(received) + if (!inbox) + return { + pass: false, + message: () => + `toHaveSentMatching: received value does not expose an inbox; pass a TestEmail instance`, + } + for (const msg of inbox) { + if (predicate(msg)) + return { pass: true, message: () => `expected no email to match the predicate` } + } + return { + pass: false, + message: () => `expected an email to match the predicate; ${inbox.length} checked`, } }, } + +/** Snapshot helper — returns a stable, serializable view of an email. + * Volatile fields (Message-ID, Date, random boundaries) are normalized + * so snapshots survive reruns. */ +export function toEmailSnapshot(msg: EmailMessage): Record { + const snap: Record = { + from: normalizeAddresses(msg.from).map((a) => a.email), + to: normalizeAddresses(msg.to).map((a) => a.email), + subject: msg.subject, + } + if (msg.cc) snap.cc = normalizeAddresses(msg.cc).map((a) => a.email) + if (msg.bcc) snap.bcc = normalizeAddresses(msg.bcc).map((a) => a.email) + if (msg.text) snap.text = msg.text + if (msg.html) snap.html = msg.html + if (msg.headers) snap.headers = sanitizeHeaders(msg.headers) + if (msg.attachments) + snap.attachments = msg.attachments.map((a) => ({ + filename: a.filename, + contentType: a.contentType, + disposition: a.disposition, + size: typeof a.content === "string" ? a.content.length : a.content.byteLength, + })) + return snap +} + +function sanitizeHeaders(headers: Record): Record { + const out: Record = {} + for (const [key, value] of Object.entries(headers)) { + const lower = key.toLowerCase() + if (lower === "message-id" || lower === "date") continue + out[key] = value + } + return out +} diff --git a/src/types.ts b/src/types.ts index 4cc80e5..676c69b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,33 @@ export interface EmailMessage { * support scheduling reject with `EmailErrorCode.UNSUPPORTED`. */ scheduledAt?: string | Date + /** Unsubscribe configuration — emits RFC 2369 `List-Unsubscribe` and, + * when `oneClick` is true, RFC 8058 `List-Unsubscribe-Post` headers. + * Required by Gmail + Yahoo bulk sender rules (Feb 2024). */ + unsubscribe?: UnsubscribeOptions + + /** Provider-side template. `id` is the provider's template id (or alias + * for Postmark). `variables` are passed to the template engine under + * provider-specific names (`dynamic_template_data`, `TemplateModel`, + * `params`, `dataVariables`, …). Drivers without templating raise + * `UNSUPPORTED`. */ + template?: TemplateOptions + + /** Per-message tracking overrides. Drivers that don't expose granular + * tracking fall back to their global setting. */ + tracking?: TrackingOptions + + /** Run this send in sandbox / test mode. Mapped per-driver: + * - Mailgun `o:testmode`, SendGrid `mail_settings.sandbox_mode`, + * SES configuration sets, Postmark test-stream. + * Drivers without sandbox support raise `UNSUPPORTED`. */ + sandbox?: boolean + + /** Provider-agnostic metadata echoed back in webhook events. SendGrid + * maps to `custom_args`, Postmark to `Metadata`, Mailgun to + * `v:key=value`, Resend to `headers["X-Metadata-*"]`. */ + metadata?: Record + /** Unrendered React element — resolved to `html` by the `withRender` * middleware from `unemail/render/react`. Ignored by drivers. */ react?: unknown @@ -75,6 +102,35 @@ export interface EmailMessage { mjml?: string } +/** Provider-side template settings. `id` is required when the provider + * addresses templates by id; `alias` is used when they address by + * name (Postmark). `variables` is a plain object — drivers stringify + * or serialize as their API demands. */ +export interface TemplateOptions { + id?: string + alias?: string + variables?: Record + /** Override locale for multi-locale template systems. */ + locale?: string +} + +/** Per-message tracking overrides. Unset fields defer to driver defaults. */ +export interface TrackingOptions { + opens?: boolean + clicks?: boolean + unsubscribes?: boolean +} + +/** RFC 2369 + RFC 8058 unsubscribe configuration. At least one of + * `url` or `mailto` must be provided. When `oneClick` defaults to + * `true` (when `url` is set), the core also emits + * `List-Unsubscribe-Post: List-Unsubscribe=One-Click`. */ +export interface UnsubscribeOptions { + url?: string + mailto?: string + oneClick?: boolean +} + /** Outcome of a successful send — at minimum the provider-assigned id. */ export interface EmailResult { id: string @@ -115,6 +171,33 @@ export interface DriverFlags { customHeaders?: boolean inbound?: boolean webhooks?: boolean + cancelable?: boolean + retrievable?: boolean +} + +/** Status returned by `driver.retrieve(id)`. Mirrors the provider state + * where possible; `unknown` covers providers that don't expose it. */ +export type SendStatusState = + | "scheduled" + | "queued" + | "sent" + | "delivered" + | "bounced" + | "complained" + | "opened" + | "clicked" + | "cancelled" + | "failed" + | "unknown" + +export interface SendStatus { + id: string + driver: string + state: SendStatusState + /** Last-observed event timestamp if provided. */ + at?: Date + /** Raw provider-specific payload. */ + provider?: Record } /** Contract every driver implements. `send` is the only required method; @@ -132,6 +215,12 @@ export interface EmailDriver { msgs: ReadonlyArray, ctx: SendContext, ) => MaybePromise>> + /** Cancel a scheduled send. Optional — drivers without support are + * gated by `flags.cancelable`. */ + cancel?: (id: string) => MaybePromise> + /** Retrieve the current state of a previously-sent message. Optional + * — drivers without support are gated by `flags.retrievable`. */ + retrieve?: (id: string) => MaybePromise> } /** Factory that produces a driver from user options. Always returned via diff --git a/src/webhooks/standard.ts b/src/webhooks/standard.ts new file mode 100644 index 0000000..8e47bd5 --- /dev/null +++ b/src/webhooks/standard.ts @@ -0,0 +1,92 @@ +/** + * Reference implementation of the Standard Webhooks protocol + * (https://standardwebhooks.com). Zero-dep, Web-Crypto only, <5 kB. + * + * Used by Resend (and growing in adoption). Drop-in replacement for + * the Svix client when you only need signature verification. + * + * @module + */ + +import { b64ToBytes, timingSafeEqual } from "./_crypto.ts" + +const encoder = /* @__PURE__ */ new TextEncoder() +const TOLERANCE_SECONDS = 5 * 60 + +export interface StandardWebhookOptions { + /** Webhook secret. `whsec_` prefix is accepted and stripped. */ + secret: string + /** Max age of the timestamp the verifier will accept. Default: + * 5 minutes (same as the Svix reference). */ + toleranceSeconds?: number + /** Clock source — injected for tests. Default: `Date.now`. */ + now?: () => number +} + +/** Verify a Standard Webhooks request. Pass the raw HTTP request (or + * an adapter with `headers.get()` + `text()`). Resolves the payload + * on success, rejects with an `Error` on signature failure / stale + * timestamp. */ +export async function verifyStandardWebhook( + request: Request, + options: StandardWebhookOptions, +): Promise { + const msgId = request.headers.get("webhook-id") + const timestamp = request.headers.get("webhook-timestamp") + const signatures = request.headers.get("webhook-signature") + if (!msgId || !timestamp || !signatures) + throw new Error("[unemail/webhooks/standard] missing webhook-* headers") + + const now = options.now ?? Date.now + const tolerance = options.toleranceSeconds ?? TOLERANCE_SECONDS + const ts = Number(timestamp) + if (!Number.isFinite(ts) || Math.abs(now() / 1000 - ts) > tolerance) + throw new Error("[unemail/webhooks/standard] timestamp outside tolerance window") + + const body = await request.text() + const expected = await computeSignature( + stripPrefix(options.secret), + `${msgId}.${timestamp}.${body}`, + ) + for (const part of signatures.split(/\s+/)) { + const [version, sig] = part.split(",", 2) + if (version !== "v1" || !sig) continue + if (timingSafeEqual(sig, expected)) return body + } + throw new Error("[unemail/webhooks/standard] signature mismatch") +} + +/** Sign a payload for tests or self-sending. Returns the value you'd + * put in the `webhook-signature` header. */ +export async function signStandardWebhook( + secret: string, + msgId: string, + timestamp: number, + body: string, +): Promise { + const sig = await computeSignature(stripPrefix(secret), `${msgId}.${timestamp}.${body}`) + return `v1,${sig}` +} + +function stripPrefix(secret: string): string { + return secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret +} + +async function computeSignature(secretBase64: string, message: string): Promise { + const keyBytes = b64ToBytes(secretBase64) + const key = await crypto.subtle.importKey( + "raw", + keyBytes as BufferSource, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ) + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message) as BufferSource) + return bytesToBase64(new Uint8Array(sig)) +} + +function bytesToBase64(bytes: Uint8Array): string { + let s = "" + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s) +} diff --git a/test/compliance/unsubscribe.test.ts b/test/compliance/unsubscribe.test.ts new file mode 100644 index 0000000..789bb45 --- /dev/null +++ b/test/compliance/unsubscribe.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest" +import { createEmail } from "../../src/index.ts" +import { + defineUnsubscribeHandler, + signUnsubscribeToken, + verifyUnsubscribeToken, +} from "../../src/compliance/index.ts" +import { memorySuppressionStore } from "../../src/suppression/index.ts" +import type { EmailDriver, EmailMessage } from "../../src/types.ts" + +function capturing(): { driver: EmailDriver; last: () => EmailMessage | undefined } { + let last: EmailMessage | undefined + return { + driver: { + name: "capture", + send: (msg) => { + last = msg + return { data: { id: "1", driver: "capture", at: new Date() }, error: null } + }, + }, + last: () => last, + } +} + +describe("List-Unsubscribe auto-injection", () => { + it("injects both RFC 2369 and RFC 8058 headers when a URL is provided", async () => { + const cap = capturing() + const email = createEmail({ driver: cap.driver }) + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "hi", + text: "x", + unsubscribe: { url: "https://example.com/u?t=abc" }, + }) + const headers = cap.last()!.headers! + expect(headers["List-Unsubscribe"]).toBe("") + expect(headers["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click") + }) + + it("supports mailto-only unsubscribe (RFC 2369 without one-click)", async () => { + const cap = capturing() + const email = createEmail({ driver: cap.driver }) + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "hi", + text: "x", + unsubscribe: { mailto: "unsubscribe@example.com" }, + }) + const headers = cap.last()!.headers! + expect(headers["List-Unsubscribe"]).toBe("") + expect(headers["List-Unsubscribe-Post"]).toBeUndefined() + }) + + it("honours existing user-supplied headers without duplication", async () => { + const cap = capturing() + const email = createEmail({ driver: cap.driver }) + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "hi", + text: "x", + headers: { "List-Unsubscribe": "" }, + unsubscribe: { url: "https://example.com/u" }, + }) + const headers = cap.last()!.headers! + expect(headers["List-Unsubscribe"]).toBe("") + }) + + it("emits both url + mailto when both are provided", async () => { + const cap = capturing() + const email = createEmail({ driver: cap.driver }) + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "hi", + text: "x", + unsubscribe: { url: "https://example.com/u", mailto: "u@example.com" }, + }) + const headers = cap.last()!.headers! + expect(headers["List-Unsubscribe"]).toBe(", ") + }) +}) + +describe("unsubscribe token", () => { + it("signs and verifies round-trip", async () => { + const token = await signUnsubscribeToken( + { recipient: "ada@acme.com", campaign: "welcome" }, + "s3cret", + ) + const payload = await verifyUnsubscribeToken(token, "s3cret") + expect(payload).toEqual({ recipient: "ada@acme.com", campaign: "welcome" }) + }) + + it("rejects tampered tokens (body edited after signing)", async () => { + const token = await signUnsubscribeToken({ recipient: "ada@acme.com" }, "s3cret") + const [body, sig] = token.split(".") + const tamperedBody = body!.slice(0, -2) + "AA" + expect(await verifyUnsubscribeToken(`${tamperedBody}.${sig}`, "s3cret")).toBeNull() + }) + + it("rejects tokens signed with a different secret", async () => { + const token = await signUnsubscribeToken({ recipient: "ada@acme.com" }, "s3cret") + expect(await verifyUnsubscribeToken(token, "different")).toBeNull() + }) + + it("rejects expired tokens", async () => { + const token = await signUnsubscribeToken({ recipient: "ada@acme.com", exp: 1000 }, "s3cret") + expect(await verifyUnsubscribeToken(token, "s3cret", () => 2_000_000)).toBeNull() + }) +}) + +describe("defineUnsubscribeHandler", () => { + it("adds the recipient to the store on a valid POST", async () => { + const store = memorySuppressionStore() + const handler = defineUnsubscribeHandler({ secret: "sk", store }) + const token = await signUnsubscribeToken({ recipient: "ada@acme.com" }, "sk") + const res = await handler( + new Request(`https://app/u?t=${encodeURIComponent(token)}`, { method: "POST" }), + ) + expect(res.status).toBe(200) + const rec = await store.has("ada@acme.com") + expect(rec?.reason).toBe("unsubscribed") + }) + + it("rejects invalid tokens with 400", async () => { + const handler = defineUnsubscribeHandler({ secret: "sk" }) + const res = await handler(new Request("https://app/u?t=garbage")) + expect(res.status).toBe(400) + }) +}) diff --git a/test/core.test.ts b/test/core.test.ts index ad040cf..090f590 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -72,6 +72,47 @@ describe("createEmail", () => { expect(calls).toEqual(["before", "after"]) }) + it("sendBatchStream yields one Result per message without short-circuiting", async () => { + let call = 0 + const email = createEmail({ + driver: defineDriver(() => ({ + name: "alt", + send: () => { + call++ + if (call === 2) { + return { + data: null, + error: { + name: "EmailError", + message: "bad", + driver: "alt", + code: "PROVIDER", + retryable: false, + } as never, + } + } + return { + data: { id: `id_${call}`, driver: "alt", at: new Date() }, + error: null, + } + }, + }))(), + }) + + const messages = [1, 2, 3].map((n) => ({ + from: "a@b.com", + to: "c@d.com", + subject: `s${n}`, + text: "x", + })) + + const outcomes: Array<"ok" | "err"> = [] + for await (const r of email.sendBatchStream(messages)) { + outcomes.push(r.error ? "err" : "ok") + } + expect(outcomes).toEqual(["ok", "err", "ok"]) + }) + it("dispose() cascades to mounted drivers", async () => { let disposed = 0 const driver = defineDriver(() => ({ diff --git a/test/drivers/resend.test.ts b/test/drivers/resend.test.ts index 7fb8d2e..8112a94 100644 --- a/test/drivers/resend.test.ts +++ b/test/drivers/resend.test.ts @@ -107,4 +107,48 @@ describe("resend driver", () => { it("rejects apiKey that does not start with 're_'", () => { expect(() => resend({ apiKey: "not-a-resend-key" })).toThrow(/must start with/) }) + + it("cancel(id) POSTs /emails/:id/cancel", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "re_abc" })) + const email = createEmail({ + driver: resend({ apiKey: "re_test_key", fetch: fetchMock as unknown as typeof fetch }), + }) + const { error } = await email.cancel("re_abc") + expect(error).toBeNull() + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe("https://api.resend.com/emails/re_abc/cancel") + expect(init.method).toBe("POST") + }) + + it("retrieve(id) GETs and maps last_event to SendStatusState", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + id: "re_abc", + last_event: "delivered", + created_at: "2026-05-01T00:00:00Z", + }), + ) + const email = createEmail({ + driver: resend({ apiKey: "re_test_key", fetch: fetchMock as unknown as typeof fetch }), + }) + const { data, error } = await email.retrieve("re_abc") + expect(error).toBeNull() + expect(data?.state).toBe("delivered") + expect(data?.id).toBe("re_abc") + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe("https://api.resend.com/emails/re_abc") + expect(init.method).toBe("GET") + expect(init.body).toBeUndefined() + }) + + it("returns UNSUPPORTED when the driver doesn't implement cancel()", async () => { + const email = createEmail({ + driver: { + name: "dumb", + send: () => ({ data: { id: "x", driver: "dumb", at: new Date() }, error: null }), + }, + }) + const { error } = await email.cancel("anything") + expect(error?.code).toBe("UNSUPPORTED") + }) }) diff --git a/test/drivers/smtp-dkim.test.ts b/test/drivers/smtp-dkim.test.ts new file mode 100644 index 0000000..7427bcc --- /dev/null +++ b/test/drivers/smtp-dkim.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, beforeAll } from "vitest" +import { signDkim } from "../../src/drivers/_smtp/dkim.ts" + +async function generatePkcs8Rsa(): Promise<{ privatePem: string; publicKey: CryptoKey }> { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ) + const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey)) + const b64 = bytesToBase64(pkcs8) + const pem = `-----BEGIN PRIVATE KEY-----\n${chunk(b64, 64).join("\n")}\n-----END PRIVATE KEY-----` + return { privatePem: pem, publicKey: pair.publicKey } +} + +function bytesToBase64(bytes: Uint8Array): string { + let s = "" + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s) +} + +function chunk(s: string, n: number): string[] { + const out: string[] = [] + for (let i = 0; i < s.length; i += n) out.push(s.slice(i, i + n)) + return out +} + +describe("signDkim", () => { + let privatePem = "" + let publicKey: CryptoKey + const msg = [ + "From: ada@example.com", + "To: bob@example.com", + "Subject: hi", + "Date: Fri, 1 May 2026 12:00:00 +0000", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "", + "Hello world.", + "", + ].join("\r\n") + + beforeAll(async () => { + const g = await generatePkcs8Rsa() + privatePem = g.privatePem + publicKey = g.publicKey + }) + + it("adds a DKIM-Signature header at the top of the message", async () => { + const signed = await signDkim(msg, { + selector: "s1", + domain: "example.com", + privateKey: privatePem, + }) + expect(signed.startsWith("DKIM-Signature: ")).toBe(true) + expect(signed).toContain("a=rsa-sha256") + expect(signed).toContain("c=relaxed/relaxed") + expect(signed).toContain("d=example.com") + expect(signed).toContain("s=s1") + expect(signed).toContain("bh=") + expect(signed).toContain("b=") + }) + + it("produces a verifiable RSA signature over canonicalized headers", async () => { + const signed = await signDkim(msg, { + selector: "s1", + domain: "example.com", + privateKey: privatePem, + headers: ["From", "To", "Subject", "Date"], + }) + // Parse the DKIM-Signature we just added back out. + const first = signed.slice(0, signed.indexOf("\r\n")) + const fields = parseDkimHeaderValue(first) + expect(fields.v).toBe("1") + expect(fields.a).toBe("rsa-sha256") + + // Rebuild what should have been signed, and verify. + const headersUsed = fields.h!.split(":") + const sep = signed.indexOf("\r\n\r\n") + const headerBlock = signed.slice(0, sep) + const parsed = parseHeadersForTest(headerBlock) + + const toSign = [ + ...headersUsed + .map((n) => parsed.find((h) => h.name.toLowerCase() === n.toLowerCase())) + .filter((h): h is { name: string; value: string } => Boolean(h)) + .map((h) => canonHeader(h.name, h.value)), + canonHeader( + "DKIM-Signature", + first.slice(first.indexOf(":") + 1).replace(/b=[^;]*$/, "b="), + ).replace(/\r\n$/, ""), + ].join("") + + const sig = base64ToBytes(fields.b!) + const ok = await crypto.subtle.verify( + { name: "RSASSA-PKCS1-v1_5" }, + publicKey, + sig as BufferSource, + new TextEncoder().encode(toSign) as BufferSource, + ) + expect(ok).toBe(true) + }) +}) + +function parseDkimHeaderValue(headerLine: string): Record { + const value = headerLine.slice(headerLine.indexOf(":") + 1).trim() + const out: Record = {} + for (const part of value.split(/;\s*/)) { + if (!part) continue + const eq = part.indexOf("=") + if (eq < 0) continue + out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim() + } + return out +} + +function parseHeadersForTest(block: string): Array<{ name: string; value: string }> { + const lines = block.split(/\r\n/) + const out: Array<{ name: string; value: string }> = [] + let current: { name: string; value: string } | null = null + for (const line of lines) { + if (!line) continue + if (/^[ \t]/.test(line)) { + if (current) current.value += "\r\n" + line + continue + } + if (current) out.push(current) + const colon = line.indexOf(":") + if (colon < 0) continue + current = { name: line.slice(0, colon), value: line.slice(colon + 1) } + } + if (current) out.push(current) + return out +} + +function canonHeader(name: string, value: string): string { + const canon = value + .replace(/\r\n/g, "") + .replace(/[ \t]+/g, " ") + .replace(/[ \t]+$/g, "") + .replace(/^[ \t]+/g, "") + return `${name.toLowerCase().trim()}:${canon}\r\n` +} + +function base64ToBytes(s: string): Uint8Array { + const bin = atob(s) + const buf = new ArrayBuffer(bin.length) + const out = new Uint8Array(buf) + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out +} diff --git a/test/drivers/templates.test.ts b/test/drivers/templates.test.ts new file mode 100644 index 0000000..c0ab339 --- /dev/null +++ b/test/drivers/templates.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest" +import { createEmail } from "../../src/index.ts" +import sendgrid from "../../src/drivers/sendgrid.ts" +import mailgun from "../../src/drivers/mailgun.ts" +import postmark from "../../src/drivers/postmark.ts" +import brevo from "../../src/drivers/brevo.ts" +import mailersend from "../../src/drivers/mailersend.ts" +import loops from "../../src/drivers/loops.ts" +import zeptomail from "../../src/drivers/zeptomail.ts" + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} + +describe("msg.template pass-through across drivers", () => { + const baseMsg = { + from: "a@b.com", + to: "c@d.com", + subject: "hi", + text: "hello", + template: { id: "tpl-1", variables: { name: "Ada" } }, + } + + it("sendgrid maps to template_id + dynamic_template_data", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 202 })) + const email = createEmail({ + driver: sendgrid({ apiKey: "k", fetch: fetchMock as unknown as typeof fetch }), + }) + await email.send(baseMsg) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.template_id).toBe("tpl-1") + expect(body.personalizations[0].dynamic_template_data).toEqual({ name: "Ada" }) + }) + + it("mailgun maps to template + X-Mailgun-Variables", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "mg" })) + const email = createEmail({ + driver: mailgun({ + apiKey: "k", + domain: "d.com", + fetch: fetchMock as unknown as typeof fetch, + }), + }) + await email.send(baseMsg) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const form = init.body as FormData + expect(form.get("template")).toBe("tpl-1") + expect(form.get("h:X-Mailgun-Variables")).toBe(JSON.stringify({ name: "Ada" })) + }) + + it("postmark routes to /email/withTemplate and sets TemplateAlias + TemplateModel", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ MessageID: "pm", SubmittedAt: "2026-05-01T00:00:00Z" })) + const email = createEmail({ + driver: postmark({ token: "t", fetch: fetchMock as unknown as typeof fetch }), + }) + await email.send({ ...baseMsg, template: { alias: "welcome", variables: { name: "Ada" } } }) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe("https://api.postmarkapp.com/email/withTemplate") + const body = JSON.parse(init.body as string) + expect(body.TemplateAlias).toBe("welcome") + expect(body.TemplateModel).toEqual({ name: "Ada" }) + }) + + it("brevo maps to templateId + params", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ messageId: "" })) + const email = createEmail({ + driver: brevo({ apiKey: "k", fetch: fetchMock as unknown as typeof fetch }), + }) + await email.send({ ...baseMsg, template: { id: "42", variables: { name: "Ada" } } }) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.templateId).toBe(42) + expect(body.params).toEqual({ name: "Ada" }) + }) + + it("mailersend maps to template_id + personalization.data", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 202 })) + const email = createEmail({ + driver: mailersend({ apiKey: "k", fetch: fetchMock as unknown as typeof fetch }), + }) + await email.send(baseMsg) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.template_id).toBe("tpl-1") + expect(body.personalization?.[0]?.data).toEqual({ name: "Ada" }) + }) + + it("loops maps template.id to transactionalId + variables to dataVariables", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ success: true })) + const email = createEmail({ + driver: loops({ apiKey: "k", fetch: fetchMock as unknown as typeof fetch }), + }) + await email.send(baseMsg) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.transactionalId).toBe("tpl-1") + expect(body.dataVariables).toEqual({ name: "Ada" }) + }) + + it("zeptomail maps to template_key + merge_info", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: [{ message_id: "z" }] })) + const email = createEmail({ + driver: zeptomail({ + token: "Zoho-enczapikey abc", + fetch: fetchMock as unknown as typeof fetch, + }), + }) + await email.send(baseMsg) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.template_key).toBe("tpl-1") + expect(body.merge_info).toEqual({ name: "Ada" }) + }) +}) diff --git a/test/drivers/tracking-sandbox.test.ts b/test/drivers/tracking-sandbox.test.ts new file mode 100644 index 0000000..3bad789 --- /dev/null +++ b/test/drivers/tracking-sandbox.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest" +import { createEmail } from "../../src/index.ts" +import sendgrid from "../../src/drivers/sendgrid.ts" +import mailgun from "../../src/drivers/mailgun.ts" +import postmark from "../../src/drivers/postmark.ts" + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} + +describe("per-message tracking / sandbox / metadata", () => { + it("sendgrid maps tracking + sandbox + metadata", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 202 })) + const email = createEmail({ + driver: sendgrid({ apiKey: "k", fetch: fetchMock as unknown as typeof fetch }), + }) + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "x", + text: "x", + tracking: { opens: true, clicks: false }, + sandbox: true, + metadata: { tenant: "acme", userId: "42" }, + }) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.mail_settings).toEqual({ sandbox_mode: { enable: true } }) + expect(body.tracking_settings).toEqual({ + open_tracking: { enable: true }, + click_tracking: { enable: false }, + }) + expect(body.personalizations[0].custom_args).toEqual({ tenant: "acme", userId: "42" }) + }) + + it("mailgun maps tracking + o:testmode + v:metadata", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "mg_1" })) + const email = createEmail({ + driver: mailgun({ + apiKey: "k", + domain: "d.com", + fetch: fetchMock as unknown as typeof fetch, + }), + }) + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "x", + text: "x", + tracking: { opens: true, clicks: true }, + sandbox: true, + metadata: { tenant: "acme" }, + }) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const form = init.body as FormData + expect(form.get("o:testmode")).toBe("yes") + expect(form.get("o:tracking-opens")).toBe("yes") + expect(form.get("o:tracking-clicks")).toBe("yes") + expect(form.get("v:tenant")).toBe("acme") + }) + + it("postmark maps TrackOpens, TrackLinks, Metadata, and first tag → Tag", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ MessageID: "pm_1", SubmittedAt: "2026-05-01T00:00:00Z" })) + const email = createEmail({ + driver: postmark({ token: "t", fetch: fetchMock as unknown as typeof fetch }), + }) + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "x", + text: "x", + tracking: { opens: true, clicks: true }, + metadata: { tenant: "acme" }, + tags: [{ name: "welcome", value: "v1" }], + }) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.TrackOpens).toBe(true) + expect(body.TrackLinks).toBe("HtmlAndText") + expect(body.Tag).toBe("welcome") + expect(body.Metadata).toEqual({ tenant: "acme" }) + }) +}) diff --git a/test/ics/ics.test.ts b/test/ics/ics.test.ts new file mode 100644 index 0000000..a6e74c2 --- /dev/null +++ b/test/ics/ics.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest" +import { icalEvent } from "../../src/ics/index.ts" + +function asText(content: string | Uint8Array): string { + return typeof content === "string" ? content : new TextDecoder().decode(content) +} + +describe("icalEvent", () => { + const baseEvent = { + uid: "evt-1@unemail.test", + start: new Date("2026-05-01T10:00:00Z"), + end: new Date("2026-05-01T11:00:00Z"), + summary: "Sync with team", + } + + it("builds a VEVENT with CRLF line endings and method=REQUEST", () => { + const attachment = icalEvent(baseEvent) + const text = asText(attachment.content) + expect(text).toContain("BEGIN:VCALENDAR") + expect(text).toContain("METHOD:REQUEST") + expect(text).toContain("BEGIN:VEVENT") + expect(text).toContain(`UID:${baseEvent.uid}`) + expect(text).toContain("DTSTART:20260501T100000Z") + expect(text).toContain("DTEND:20260501T110000Z") + expect(text).toContain("SUMMARY:Sync with team") + expect(text).toContain("END:VEVENT") + expect(text).toContain("END:VCALENDAR") + expect(text.includes("\r\n")).toBe(true) + }) + + it("wires the Content-Type with the chosen method", () => { + const attachment = icalEvent(baseEvent, { method: "CANCEL" }) + expect(attachment.contentType).toBe("text/calendar; charset=UTF-8; method=CANCEL") + expect(asText(attachment.content)).toContain("METHOD:CANCEL") + }) + + it("escapes special characters in text fields", () => { + const a = icalEvent({ + ...baseEvent, + description: "Hi; hello, world\nsecond line", + }) + const text = asText(a.content) + expect(text).toContain("DESCRIPTION:Hi\\; hello\\, world\\nsecond line") + }) + + it("serializes organizer and attendees", () => { + const a = icalEvent({ + ...baseEvent, + organizer: { email: "host@example.com", name: "Host" }, + attendees: [ + { email: "a@example.com", name: "Ada", role: "REQ-PARTICIPANT", rsvp: true }, + { email: "b@example.com", role: "OPT-PARTICIPANT", partstat: "TENTATIVE" }, + ], + }) + const text = asText(a.content) + expect(text).toContain("ORGANIZER;CN=Host:mailto:host@example.com") + expect(text).toContain("ATTENDEE;CN=Ada;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:a@example.com") + expect(text).toContain("ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=TENTATIVE:mailto:b@example.com") + }) + + it("emits a VALARM with negative trigger for reminders", () => { + const a = icalEvent({ + ...baseEvent, + alarms: [{ triggerMinutesBefore: 15 }], + }) + const text = asText(a.content) + expect(text).toContain("BEGIN:VALARM") + expect(text).toContain("TRIGGER:-PT15M") + expect(text).toContain("END:VALARM") + }) + + it("folds long lines at 75 octets", () => { + const long = "x".repeat(200) + const a = icalEvent({ ...baseEvent, summary: long }) + const text = asText(a.content) + const summaryLine = text.split("\r\n").find((l) => l.startsWith("SUMMARY:"))! + expect(summaryLine.length).toBeLessThanOrEqual(75) + }) +}) diff --git a/test/inbound/reply.test.ts b/test/inbound/reply.test.ts new file mode 100644 index 0000000..2947131 --- /dev/null +++ b/test/inbound/reply.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest" +import { stripReply } from "../../src/inbound/reply.ts" +import { threadKey, buildThreads } from "../../src/inbound/thread.ts" + +describe("stripReply", () => { + it("strips English Gmail-style 'On ... wrote:' quotes", () => { + const raw = [ + "Sounds good. See you then.", + "", + "On Mon, May 1, 2026 at 3:00 PM Ada wrote:", + "> Can we meet at 3?", + "> -- Ada", + ].join("\n") + const { text, quoted } = stripReply(raw) + expect(text).toBe("Sounds good. See you then.") + expect(quoted).toContain("Can we meet at 3?") + }) + + it("strips signatures introduced by '-- \\n'", () => { + const raw = [ + "Thanks — will review and reply later today.", + "", + "-- ", + "Bob Jones", + "Engineer at Acme", + ].join("\n") + const { text } = stripReply(raw) + expect(text).toBe("Thanks — will review and reply later today.") + }) + + it("strips Outlook '-----Original Message-----' blocks", () => { + const raw = [ + "Acknowledged.", + "", + "-----Original Message-----", + "From: Ada", + "Sent: Monday", + "Subject: Hi", + "", + "Hello", + ].join("\n") + const { text } = stripReply(raw) + expect(text).toBe("Acknowledged.") + }) + + it("strips Turkish tarihinde...yazdı quotes", () => { + const raw = [ + "Tamamdir, tesekkurler.", + "", + "1 Ocak 2026 Pazartesi tarihinde Ada şunları yazdı:", + "> Bunu gönderebilir misin?", + ].join("\n") + const { text } = stripReply(raw) + expect(text).toBe("Tamamdir, tesekkurler.") + }) + + it("strips '>' quoted blocks after a blank line", () => { + const raw = ["New reply content only.", "", "> previous message", "> continues here"].join("\n") + const { text, quoted } = stripReply(raw) + expect(text).toBe("New reply content only.") + expect(quoted).toMatch(/^> previous message/) + }) +}) + +describe("threadKey + buildThreads", () => { + it("picks the first References entry as the canonical root", () => { + const k = threadKey({ + messageId: "", + inReplyTo: "", + references: ["", ""], + }) + expect(k).toBe("root@host") + }) + + it("falls back to In-Reply-To when References is missing", () => { + const k = threadKey({ messageId: "", inReplyTo: "" }) + expect(k).toBe("parent@host") + }) + + it("falls back to Message-ID for thread-starters", () => { + const k = threadKey({ messageId: "" }) + expect(k).toBe("seed@host") + }) + + it("buildThreads groups messages by canonical root", () => { + const groups = buildThreads([ + { messageId: "", references: [] }, + { messageId: "", inReplyTo: "", references: [""] }, + { messageId: "", inReplyTo: "", references: ["", ""] }, + { messageId: "", references: [] }, + ]) + expect(groups.get("seed@h")).toEqual(["seed@h", "reply1@h", "reply2@h"]) + expect(groups.get("other@h")).toEqual(["other@h"]) + }) +}) diff --git a/test/middleware/retry.test.ts b/test/middleware/retry.test.ts index 1bee1f0..fc82450 100644 --- a/test/middleware/retry.test.ts +++ b/test/middleware/retry.test.ts @@ -105,4 +105,67 @@ describe("withRetry", () => { expect(res.error).toBeNull() expect(delays).toEqual([3000]) }) + + it("full-jitter clamps random delay into [0, exponential]", async () => { + const delays: number[] = [] + const driver = flakyDriver(3) + const email = createEmail({ + driver: withRetry(driver, { + retries: 3, + initialDelay: 100, + maxDelay: 10_000, + backoff: "full-jitter", + random: () => 0.5, + sleep: async (ms: number) => { + delays.push(ms) + }, + }), + }) + await email.send({ from: "a@b.com", to: "c@d.com", subject: "x", text: "x" }) + expect(delays).toEqual([50, 100, 200]) + }) + + it("exponential-jitter varies with random()", async () => { + const delays: number[] = [] + const driver = flakyDriver(1) + const email = createEmail({ + driver: withRetry(driver, { + retries: 3, + initialDelay: 100, + maxDelay: 10_000, + backoff: "exponential-jitter", + random: () => 0, + sleep: async (ms: number) => { + delays.push(ms) + }, + }), + }) + await email.send({ from: "a@b.com", to: "c@d.com", subject: "x", text: "x" }) + expect(delays).toEqual([50]) + }) + + it("routes to dead-letter after exhausting retries", async () => { + const driver = flakyDriver(10) + const letters: { msg: string; reason: unknown }[] = [] + const dlq: EmailDriver = { + name: "dlq", + send(msg, ctx) { + letters.push({ msg: msg.subject, reason: ctx.meta.deadLetterReason }) + return { data: { id: "dlq-1", driver: "dlq", at: new Date() }, error: null } + }, + } + const email = createEmail({ + driver: withRetry(driver, { + retries: 1, + initialDelay: 1, + deadLetter: dlq, + sleep: () => Promise.resolve(), + }), + }) + const res = await email.send({ from: "a@b.com", to: "c@d.com", subject: "dead", text: "x" }) + expect(res.data?.driver).toBe("dlq") + expect(letters).toHaveLength(1) + expect(letters[0]!.msg).toBe("dead") + expect(String(letters[0]!.reason)).toContain("boom") + }) }) diff --git a/test/middleware/suppression.test.ts b/test/middleware/suppression.test.ts new file mode 100644 index 0000000..533d230 --- /dev/null +++ b/test/middleware/suppression.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest" +import { createEmail } from "../../src/index.ts" +import { memorySuppressionStore } from "../../src/suppression/index.ts" +import { withSuppression } from "../../src/middleware/suppression.ts" +import type { EmailDriver, EmailMessage } from "../../src/types.ts" + +function capturing(): { + driver: EmailDriver + last: () => EmailMessage | undefined + count: () => number +} { + let last: EmailMessage | undefined + let count = 0 + return { + driver: { + name: "capture", + send: (msg) => { + count++ + last = msg + return { data: { id: "1", driver: "capture", at: new Date() }, error: null } + }, + }, + last: () => last, + count: () => count, + } +} + +describe("withSuppression", () => { + it("blocks sends to suppressed recipients (policy=error)", async () => { + const store = memorySuppressionStore() + await store.add("blocked@x.com", "bounce") + const cap = capturing() + const email = createEmail({ driver: withSuppression(cap.driver, { store }) }) + const { data, error } = await email.send({ + from: "a@b.com", + to: "blocked@x.com", + subject: "hi", + text: "x", + }) + expect(data).toBeNull() + expect(error?.code).toBe("PROVIDER") + expect(cap.count()).toBe(0) + }) + + it("passes through when recipient is not suppressed", async () => { + const store = memorySuppressionStore() + const cap = capturing() + const email = createEmail({ driver: withSuppression(cap.driver, { store }) }) + const { data, error } = await email.send({ + from: "a@b.com", + to: "ok@x.com", + subject: "hi", + text: "x", + }) + expect(error).toBeNull() + expect(data?.id).toBe("1") + }) + + it("policy=drop strips suppressed recipients and forwards the rest", async () => { + const store = memorySuppressionStore() + await store.add("blocked@x.com", "bounce") + const cap = capturing() + const email = createEmail({ + driver: withSuppression(cap.driver, { store, policy: "drop" }), + }) + await email.send({ + from: "a@b.com", + to: ["blocked@x.com", "ok@x.com"], + subject: "hi", + text: "x", + }) + const last = cap.last()! + const to = Array.isArray(last.to) ? last.to : [last.to] + const emails = to.map((t: unknown) => + typeof t === "string" ? t : (t as { email: string }).email, + ) + expect(emails).toEqual(["ok@x.com"]) + }) + + it("policy=drop with all recipients suppressed still errors", async () => { + const store = memorySuppressionStore() + await store.add("a@x.com", "bounce") + await store.add("b@x.com", "bounce") + const cap = capturing() + const email = createEmail({ + driver: withSuppression(cap.driver, { store, policy: "drop" }), + }) + const { data, error } = await email.send({ + from: "a@b.com", + to: ["a@x.com", "b@x.com"], + subject: "hi", + text: "x", + }) + expect(data).toBeNull() + expect(error?.code).toBe("PROVIDER") + expect(cap.count()).toBe(0) + }) + + it("fires onBlocked for each suppressed recipient", async () => { + const store = memorySuppressionStore() + await store.add("blocked@x.com", "complaint") + const blocks: Array<[string, string]> = [] + const email = createEmail({ + driver: withSuppression(capturing().driver, { + store, + onBlocked: (r, reason) => blocks.push([r, reason]), + }), + }) + await email.send({ + from: "a@b.com", + to: "blocked@x.com", + subject: "hi", + text: "x", + }) + expect(blocks).toEqual([["blocked@x.com", "complaint"]]) + }) +}) diff --git a/test/queue/memory.test.ts b/test/queue/memory.test.ts index 31e5e33..69feff4 100644 --- a/test/queue/memory.test.ts +++ b/test/queue/memory.test.ts @@ -80,6 +80,25 @@ describe("memoryQueue + worker", () => { expect(await queue.size()).toBe(0) }) + it("defers sends until msg.scheduledAt", async () => { + const email = createEmail({ driver: mock() }) + let clock = 1_700_000_000_000 + const queue = memoryQueue({ now: () => clock }) + const worker = startWorker(email, queue, { concurrency: 1, now: () => clock }) + await queue.enqueue({ + from: "a@b.com", + to: "c@d.com", + subject: "x", + text: "x", + scheduledAt: new Date(clock + 60_000), + }) + await worker.tick() + expect(await queue.size()).toBe(1) // still deferred + clock += 60_001 + await worker.tick() + expect(await queue.size()).toBe(0) + }) + it("respects delayMs", async () => { const email = createEmail({ driver: mock() }) let clock = 1000 diff --git a/test/result/result.test.ts b/test/result/result.test.ts new file mode 100644 index 0000000..4c9f547 --- /dev/null +++ b/test/result/result.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest" +import { isErr, isOk, mapErr, mapOk, tryAsync, unwrap, unwrapOr } from "../../src/result/index.ts" +import { createError } from "../../src/errors.ts" +import type { Result } from "../../src/types.ts" + +function ok(data: T): Result { + return { data, error: null } +} + +function err(message = "boom"): Result { + return { data: null, error: createError("x", "PROVIDER", message) } +} + +describe("Result helpers", () => { + it("isOk / isErr narrow correctly", () => { + const r: Result = ok(42) + expect(isOk(r)).toBe(true) + expect(isErr(r)).toBe(false) + }) + + it("unwrap throws on Err", () => { + expect(() => unwrap(err())).toThrow(/boom/) + }) + + it("unwrapOr returns fallback on Err", () => { + expect(unwrapOr(err(), 7)).toBe(7) + expect(unwrapOr(ok(3), 7)).toBe(3) + }) + + it("mapOk transforms data", () => { + const r = mapOk(ok(4), (n) => n * 2) + expect(r.data).toBe(8) + }) + + it("mapOk passes Err through", () => { + const r = mapOk(err(), (n) => n * 2) + expect(r.error?.message).toContain("boom") + }) + + it("mapErr transforms the error", () => { + const original = err("original") + const mapped = mapErr(original, (e) => + createError(e.driver, "TIMEOUT", `wrapped: ${e.message}`), + ) + expect(mapped.error?.code).toBe("TIMEOUT") + expect(mapped.error?.message).toContain("wrapped") + }) + + it("tryAsync captures thrown errors via the wrapper", async () => { + const r = await tryAsync( + async () => { + throw new Error("oops") + }, + (e) => createError("x", "NETWORK", (e as Error).message), + ) + expect(r.error?.code).toBe("NETWORK") + expect(r.error?.message).toContain("oops") + }) + + it("tryAsync returns data on resolution", async () => { + const r = await tryAsync( + async () => 99, + () => createError("x", "PROVIDER", "unused"), + ) + expect(r.data).toBe(99) + }) +}) diff --git a/test/suppression/store.test.ts b/test/suppression/store.test.ts new file mode 100644 index 0000000..dfe9c4d --- /dev/null +++ b/test/suppression/store.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest" +import { memorySuppressionStore, unstorageSuppressionStore } from "../../src/suppression/index.ts" + +describe("memorySuppressionStore", () => { + it("stores and retrieves suppression records", async () => { + const store = memorySuppressionStore() + await store.add("ada@acme.com", "bounce", "ses-webhook") + const rec = await store.has("ada@acme.com") + expect(rec?.reason).toBe("bounce") + expect(rec?.source).toBe("ses-webhook") + }) + + it("is case-insensitive", async () => { + const store = memorySuppressionStore() + await store.add("Ada@ACME.com", "bounce") + expect(await store.has("ada@acme.com")).not.toBeNull() + }) + + it("remove() deletes the record", async () => { + const store = memorySuppressionStore() + await store.add("a@b.com", "bounce") + await store.remove("a@b.com") + expect(await store.has("a@b.com")).toBeNull() + }) + + it("list() enumerates all records", async () => { + const store = memorySuppressionStore() + await store.add("a@b.com", "bounce") + await store.add("c@d.com", "complaint") + const all = await store.list!() + expect(all).toHaveLength(2) + }) +}) + +describe("unstorageSuppressionStore", () => { + function fakeStorage() { + const map = new Map() + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: unknown) => { + map.set(k, v) + }, + removeItem: (k: string) => { + map.delete(k) + }, + getKeys: (prefix?: string) => + Array.from(map.keys()).filter((k) => !prefix || k.startsWith(prefix)), + } + } + + it("round-trips records through an unstorage-like backend", async () => { + const store = unstorageSuppressionStore(fakeStorage()) + await store.add("a@b.com", "bounce", "ses") + const rec = await store.has("a@b.com") + expect(rec).not.toBeNull() + expect(rec!.reason).toBe("bounce") + expect(rec!.at).toBeInstanceOf(Date) + }) +}) diff --git a/test/test/matchers.test.ts b/test/test/matchers.test.ts index 2ac99be..22fb16a 100644 --- a/test/test/matchers.test.ts +++ b/test/test/matchers.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest" -import { createTestEmail, emailMatchers, matchesEmail } from "../../src/test/index.ts" +import { + createTestEmail, + emailMatchers, + matchesEmail, + toEmailSnapshot, +} from "../../src/test/index.ts" expect.extend(emailMatchers) @@ -25,6 +30,88 @@ describe("emailMatchers", () => { }) }) +describe("emailMatchers — extended", () => { + it("toHaveSentTo matches a recipient", async () => { + const email = createTestEmail() + await email.send({ + from: "a@b.com", + to: ["Ada ", "bob@acme.com"], + cc: "c@d.com", + subject: "hi", + text: "", + }) + expect(emailMatchers.toHaveSentTo(email, "bob@acme.com").pass).toBe(true) + expect(emailMatchers.toHaveSentTo(email, "c@d.com").pass).toBe(true) + expect(emailMatchers.toHaveSentTo(email, "nobody@x.com").pass).toBe(false) + }) + + it("toHaveSentWithSubject supports strings and regex", async () => { + const email = createTestEmail() + await email.send({ from: "a@b.com", to: "c@d.com", subject: "Welcome", text: "" }) + expect(emailMatchers.toHaveSentWithSubject(email, "Welcome").pass).toBe(true) + expect(emailMatchers.toHaveSentWithSubject(email, /wel/i).pass).toBe(true) + expect(emailMatchers.toHaveSentWithSubject(email, "other").pass).toBe(false) + }) + + it("toHaveSentWithAttachment matches by filename and predicate", async () => { + const email = createTestEmail() + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "hi", + text: "", + attachments: [ + { filename: "invite.ics", content: "BEGIN:VCALENDAR", contentType: "text/calendar" }, + ], + }) + expect(emailMatchers.toHaveSentWithAttachment(email, "invite.ics").pass).toBe(true) + expect( + emailMatchers.toHaveSentWithAttachment(email, (a) => a.contentType === "text/calendar").pass, + ).toBe(true) + expect(emailMatchers.toHaveSentWithAttachment(email, "absent.pdf").pass).toBe(false) + }) + + it("toHaveSentMatching runs a custom predicate", async () => { + const email = createTestEmail() + await email.send({ + from: "a@b.com", + to: "c@d.com", + subject: "hi", + text: "", + tags: [{ name: "campaign", value: "welcome-v2" }], + }) + expect( + emailMatchers.toHaveSentMatching(email, (m) => + (m.tags ?? []).some((t) => t.name === "campaign" && t.value === "welcome-v2"), + ).pass, + ).toBe(true) + }) +}) + +describe("toEmailSnapshot", () => { + it("returns a stable shape and drops Message-ID / Date headers", () => { + const snap = toEmailSnapshot({ + from: "Ada ", + to: "bob@x.com", + subject: "hi", + text: "body", + headers: { + "Message-ID": "", + Date: "Wed, 01 Jan 2020 00:00:00 GMT", + "X-App": "unemail", + }, + }) + expect(snap).toMatchObject({ + from: ["ada@acme.com"], + to: ["bob@x.com"], + subject: "hi", + text: "body", + headers: { "X-App": "unemail" }, + }) + expect((snap.headers as Record)["Message-ID"]).toBeUndefined() + }) +}) + describe("matchesEmail", () => { it("matches string fields", () => { const match = matchesEmail({ from: "a@b.com", to: "c@d.com", subject: "hi" }, { subject: "hi" }) diff --git a/test/webhooks/standard.test.ts b/test/webhooks/standard.test.ts new file mode 100644 index 0000000..2a894a9 --- /dev/null +++ b/test/webhooks/standard.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest" +import { signStandardWebhook, verifyStandardWebhook } from "../../src/webhooks/standard.ts" + +const SECRET = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" + +function request(body: string, headers: Record, method = "POST"): Request { + return new Request("https://app/webhook", { method, body, headers }) +} + +describe("Standard Webhooks", () => { + it("round-trips sign → verify", async () => { + const ts = Math.floor(Date.now() / 1000) + const body = JSON.stringify({ event: "test" }) + const sig = await signStandardWebhook(SECRET, "msg_1", ts, body) + const payload = await verifyStandardWebhook( + request(body, { + "webhook-id": "msg_1", + "webhook-timestamp": String(ts), + "webhook-signature": sig, + }), + { secret: SECRET }, + ) + expect(JSON.parse(payload)).toEqual({ event: "test" }) + }) + + it("rejects mismatched signatures", async () => { + const ts = Math.floor(Date.now() / 1000) + await expect( + verifyStandardWebhook( + request("{}", { + "webhook-id": "msg_2", + "webhook-timestamp": String(ts), + "webhook-signature": "v1,not-a-real-sig", + }), + { secret: SECRET }, + ), + ).rejects.toThrow(/signature/) + }) + + it("rejects stale timestamps", async () => { + const ts = Math.floor(Date.now() / 1000) - 60 * 60 // 1 hour ago + const body = "{}" + const sig = await signStandardWebhook(SECRET, "msg_3", ts, body) + await expect( + verifyStandardWebhook( + request(body, { + "webhook-id": "msg_3", + "webhook-timestamp": String(ts), + "webhook-signature": sig, + }), + { secret: SECRET }, + ), + ).rejects.toThrow(/tolerance/) + }) + + it("accepts multiple space-separated signatures (rotation)", async () => { + const ts = Math.floor(Date.now() / 1000) + const body = "{}" + const good = await signStandardWebhook(SECRET, "msg_4", ts, body) + const combined = `v1,old-garbage ${good}` + const out = await verifyStandardWebhook( + request(body, { + "webhook-id": "msg_4", + "webhook-timestamp": String(ts), + "webhook-signature": combined, + }), + { secret: SECRET }, + ) + expect(out).toBe(body) + }) + + it("accepts a secret without the whsec_ prefix", async () => { + const ts = Math.floor(Date.now() / 1000) + const body = "{}" + const noPrefixSecret = SECRET.slice("whsec_".length) + const sig = await signStandardWebhook(noPrefixSecret, "msg_5", ts, body) + const out = await verifyStandardWebhook( + request(body, { + "webhook-id": "msg_5", + "webhook-timestamp": String(ts), + "webhook-signature": sig, + }), + { secret: noPrefixSecret }, + ) + expect(out).toBe(body) + }) +})