`)
+ if (!hasHeader(headers, "list-unsubscribe")) headers["List-Unsubscribe"] = parts.join(", ")
+ const oneClick = unsubscribe.oneClick ?? Boolean(unsubscribe.url)
+ if (oneClick && unsubscribe.url && !hasHeader(headers, "list-unsubscribe-post")) {
+ headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
+ }
+ }
+
+ return Object.freeze(headers)
+}
+
+/** Hidden span most clients read as the preview line. The trailing
+ * zero-width joiners stop the client from spilling body text into the
+ * preview after the preheader ends. */
+function injectPreheader(html: string, preheader: string): string {
+ const block =
+ `` +
+ `${escapeHtml(preheader)}${" ".repeat(60)}
`
+ const bodyOpen = /]*>/i.exec(html)
+ if (bodyOpen) {
+ const at = bodyOpen.index + bodyOpen[0].length
+ return html.slice(0, at) + block + html.slice(at)
+ }
+ return block + html
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+}
+
+function parseDate(value: string | Date): Date {
+ const date = value instanceof Date ? value : new Date(value)
+ if (Number.isNaN(date.getTime())) throw invalid(`\`scheduledAt\` is not a valid date: ${value}`)
+ return date
+}
+
+function invalid(message: string) {
+ return createError(CORE, "INVALID_OPTIONS", message)
+}
diff --git a/src/core/result.ts b/src/core/result.ts
new file mode 100644
index 0000000..a29e640
--- /dev/null
+++ b/src/core/result.ts
@@ -0,0 +1,36 @@
+import type { BatchResult, EmailResult, Result } from "./types.ts"
+import type { EmailError } from "./error.ts"
+
+/** Wrap a value as a success. */
+export function ok(data: T): Result {
+ return { data, error: null }
+}
+
+/** Wrap an error as a failure. */
+export function err(error: EmailError): Result {
+ return { data: null, error }
+}
+
+/** Narrow a `Result` to its success branch. */
+export function isOk(result: Result): result is { data: T; error: null } {
+ return result.error === null
+}
+
+/** Throw on failure, return the value on success. For callers who prefer
+ * exceptions to branching — `const sent = unwrap(await email.send(msg))`. */
+export function unwrap(result: Result): T {
+ if (result.error) throw result.error
+ return result.data
+}
+
+/** Summarize positional per-message results. Never loses a partial
+ * success: `sent` holds what got through even when `ok` is false. */
+export function toBatchResult(results: readonly Result[]): BatchResult {
+ const sent: EmailResult[] = []
+ const failed: { index: number; error: EmailError }[] = []
+ for (const [index, result] of results.entries()) {
+ if (result.error) failed.push({ index, error: result.error })
+ else sent.push(result.data)
+ }
+ return { results, sent, failed, ok: failed.length === 0 }
+}
diff --git a/src/core/types.ts b/src/core/types.ts
new file mode 100644
index 0000000..43556c5
--- /dev/null
+++ b/src/core/types.ts
@@ -0,0 +1,305 @@
+/**
+ * Every type in the public surface. Runtime-free by construction — this
+ * module compiles to nothing, so importing it costs no bytes in a Worker
+ * bundle.
+ *
+ * @module
+ */
+
+import type { EmailError } from "./error.ts"
+
+/** A value that may be returned synchronously or as a promise. */
+export type MaybePromise = T | Promise
+
+// ---------------------------------------------------------------------------
+// Addresses
+// ---------------------------------------------------------------------------
+
+/** A contact: an address plus an optional display name. */
+export interface EmailAddress {
+ readonly email: string
+ readonly name?: string
+}
+
+/** Anything accepted where an address is expected — `"ada@acme.com"`,
+ * `"Ada "`, `{ email, name }`, or a list of those. */
+export type AddressInput = string | EmailAddress | readonly (string | EmailAddress)[]
+
+// ---------------------------------------------------------------------------
+// Message
+// ---------------------------------------------------------------------------
+
+/** A file part. `content` is either raw bytes or a base64 string; set
+ * `cid` to reference it from HTML as `
`. */
+export interface Attachment {
+ readonly filename: string
+ readonly content: string | Uint8Array
+ readonly contentType?: string
+ readonly disposition?: "attachment" | "inline"
+ readonly cid?: string
+}
+
+/** Key-value pair forwarded to provider analytics. */
+export interface EmailTag {
+ readonly name: string
+ readonly value: string
+}
+
+/** RFC 2369 + RFC 8058 unsubscribe configuration. Gmail and Yahoo require
+ * a one-click unsubscribe on bulk mail; setting `url` turns it on. */
+export interface UnsubscribeOptions {
+ readonly url?: string
+ readonly mailto?: string
+ /** Emit `List-Unsubscribe-Post`. Defaults to `true` when `url` is set. */
+ readonly oneClick?: boolean
+}
+
+/** A provider-hosted template. Use `id` where the provider addresses
+ * templates numerically, `alias` where it addresses them by name. */
+export interface TemplateOptions {
+ readonly id?: string
+ readonly alias?: string
+ readonly variables?: Readonly>
+}
+
+/** Per-message open/click tracking. Unset fields defer to the provider's
+ * account-level setting. */
+export interface TrackingOptions {
+ readonly opens?: boolean
+ readonly clicks?: boolean
+}
+
+/** Unrendered body handed to a `Renderer`. The core never inspects
+ * anything but `type`, which is how a renderer claims a message —
+ * so a new template language is a package, not a core change. */
+export interface MessageContent {
+ readonly type: string
+ readonly [key: string]: unknown
+}
+
+/** What you pass to `email.send()`. Every address field is loose; the core
+ * normalizes and validates before a driver sees it. */
+export interface EmailMessage {
+ /** Route to a driver registered with `mount(stream, driver)`. */
+ readonly stream?: string
+
+ readonly from?: AddressInput
+ readonly to: AddressInput
+ readonly cc?: AddressInput
+ readonly bcc?: AddressInput
+ readonly replyTo?: AddressInput
+
+ readonly subject: string
+ /** Preview line most clients show next to the subject. */
+ readonly preheader?: string
+ readonly text?: string
+ readonly html?: string
+ /** Unrendered body — a `Renderer` turns this into `html`. */
+ readonly content?: MessageContent
+
+ readonly headers?: Readonly>
+ readonly attachments?: readonly Attachment[]
+ readonly tags?: readonly EmailTag[]
+ /** Provider-agnostic metadata, echoed back on webhook events. */
+ readonly metadata?: Readonly>
+
+ readonly idempotencyKey?: string
+ readonly scheduledAt?: string | Date
+ readonly unsubscribe?: UnsubscribeOptions
+ readonly template?: TemplateOptions
+ readonly tracking?: TrackingOptions
+ /** Route to the provider's sandbox instead of real delivery. */
+ readonly sandbox?: boolean
+ /** A pre-composed RFC 5322 message. Bypasses the MIME builder; SMTP only. */
+ readonly raw?: string | Uint8Array
+}
+
+/** What a driver receives: validated, address-parsed, header-folded. Lists
+ * are always present (empty rather than `undefined`) so drivers never
+ * branch on nullish, and `from` is guaranteed. */
+export interface NormalizedMessage {
+ readonly stream?: string
+
+ readonly from: EmailAddress
+ readonly to: readonly EmailAddress[]
+ readonly cc: readonly EmailAddress[]
+ readonly bcc: readonly EmailAddress[]
+ readonly replyTo: readonly EmailAddress[]
+
+ readonly subject: string
+ readonly text?: string
+ readonly html?: string
+ readonly content?: MessageContent
+
+ /** Header names are as-cased by the caller; `hasHeader()` compares
+ * case-insensitively. Already includes derived `List-Unsubscribe`. */
+ readonly headers: Readonly>
+ readonly attachments: readonly Attachment[]
+ readonly tags: readonly EmailTag[]
+ readonly metadata: Readonly>
+
+ readonly idempotencyKey?: string
+ readonly scheduledAt?: Date
+ readonly template?: TemplateOptions
+ readonly tracking?: TrackingOptions
+ readonly sandbox?: boolean
+ readonly raw?: string | Uint8Array
+}
+
+// ---------------------------------------------------------------------------
+// Results
+// ---------------------------------------------------------------------------
+
+/** A delivery accepted by the provider. */
+export interface EmailResult {
+ readonly id: string
+ readonly driver: string
+ readonly stream?: string
+ readonly at: Date
+ /** The provider's own response, untouched. */
+ readonly provider?: Readonly>
+}
+
+/** Discriminated union — narrowing on `error` yields typed `data`. */
+export type Result = { data: T; error: null } | { data: null; error: EmailError }
+
+/** Outcome of `sendBatch()`. `results` is positional: `results[i]` is the
+ * outcome of `messages[i]`, always, even when some failed. */
+export interface BatchResult {
+ readonly results: readonly Result[]
+ readonly sent: readonly EmailResult[]
+ readonly failed: readonly { readonly index: number; readonly error: EmailError }[]
+ /** True when every message was accepted. */
+ readonly ok: boolean
+}
+
+/** Lifecycle state of a message, as far as the provider will tell us. */
+export type SendState =
+ | "scheduled"
+ | "queued"
+ | "sent"
+ | "delivered"
+ | "bounced"
+ | "complained"
+ | "opened"
+ | "clicked"
+ | "cancelled"
+ | "failed"
+ | "unknown"
+
+export interface SendStatus {
+ readonly id: string
+ readonly driver: string
+ readonly state: SendState
+ readonly at?: Date
+ readonly provider?: Readonly>
+}
+
+/** Machine-readable failure taxonomy. Stable across every driver. */
+export type EmailErrorCode =
+ | "INVALID_OPTIONS"
+ | "NETWORK"
+ | "AUTH"
+ | "RATE_LIMIT"
+ | "TIMEOUT"
+ | "PROVIDER"
+ | "UNSUPPORTED"
+ | "CANCELLED"
+
+// ---------------------------------------------------------------------------
+// Drivers
+// ---------------------------------------------------------------------------
+
+/** Capabilities a driver advertises. Callers gate on these instead of
+ * string-matching `driver.name`. */
+export interface DriverFeatures {
+ readonly attachments?: boolean
+ readonly html?: boolean
+ readonly text?: boolean
+ /** `sendBatch` reaches the provider in one request. */
+ readonly batch?: boolean
+ readonly scheduling?: boolean
+ /** The provider itself deduplicates on `idempotencyKey`. */
+ readonly idempotency?: boolean
+ readonly tracking?: boolean
+ readonly templates?: boolean
+ readonly tagging?: boolean
+ readonly replyTo?: boolean
+ readonly customHeaders?: boolean
+ readonly sandbox?: boolean
+ readonly cancelable?: boolean
+ readonly retrievable?: boolean
+}
+
+/** Everything a driver is asked to do. Only `name` and `send` are
+ * required; the rest is feature-gated and routed to `UNSUPPORTED`. */
+export interface EmailDriver {
+ readonly name: string
+ readonly features?: DriverFeatures
+ /** The underlying client (a pool, an inbox array, an SDK handle). */
+ readonly getInstance?: () => TInstance
+ readonly initialize?: () => MaybePromise
+ readonly dispose?: () => MaybePromise
+ readonly isAvailable?: () => MaybePromise
+
+ readonly send: (msg: NormalizedMessage, ctx: SendContext) => MaybePromise>
+ /** One request for many messages. Must return one result per input, in
+ * order — the core checks this and fails loudly if it does not. */
+ readonly sendBatch?: (
+ msgs: readonly NormalizedMessage[],
+ ctx: SendContext,
+ ) => MaybePromise[]>
+ readonly cancel?: (id: string) => MaybePromise>
+ readonly retrieve?: (id: string) => MaybePromise>
+}
+
+/** A driver that is guaranteed to expose its underlying client, so callers
+ * reach it without an optional-call guard. */
+export type DriverWithInstance = EmailDriver & {
+ readonly getInstance: () => TInstance
+}
+
+/** The driver shape implied by an instance type. Naming one obliges the
+ * driver to expose it and lets callers reach it unguarded; leaving it
+ * `unknown` keeps `getInstance` optional. */
+export type DriverOf = unknown extends TInstance
+ ? EmailDriver
+ : DriverWithInstance
+
+/** What `defineDriver` returns. Options are required when `TOpts` has a
+ * required field — `resend()` with no key is a type error, not a
+ * runtime surprise. */
+export type DriverFactory = (options: TOpts) => DriverOf
+
+// ---------------------------------------------------------------------------
+// Pipeline
+// ---------------------------------------------------------------------------
+
+/** Ambient state for one trip through the pipeline. `meta` is a shared
+ * mutable bag for middleware to leave notes in; everything else is
+ * replaced, not mutated, when a middleware derives a new context. */
+export interface SendContext {
+ readonly driver: string
+ readonly stream?: string
+ /** 1 on the first try. Retry middleware derives a context per attempt. */
+ readonly attempt: number
+ readonly signal?: AbortSignal
+ readonly meta: Record
+}
+
+/** The one unit of work in this library. Always a list, always one result
+ * per input, in order — `send()` is the single-element case. Making the
+ * list the primitive is what lets retry re-send only the failures even
+ * when the driver batches natively. */
+export type SendHandler = (
+ msgs: readonly NormalizedMessage[],
+ ctx: SendContext,
+) => Promise[]>
+
+/** The one composition primitive. Wrap the next handler, do work around
+ * it, return the results. Register with `email.use()` to cover a whole
+ * instance, or `wrap(driver, ...)` to cover one driver. */
+export interface Middleware {
+ readonly name: string
+ readonly handle: (next: SendHandler) => SendHandler
+}
diff --git a/src/dmarc/index.ts b/src/dmarc/index.ts
deleted file mode 100644
index 19b5e83..0000000
--- a/src/dmarc/index.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * Minimal DMARC aggregate (RUA) XML report parser. Zero-dep — we only
- * need the narrow RUA schema Google/Yahoo/Microsoft emit. Input can be
- * raw XML, gzipped bytes, or a fetch Response body.
- *
- * @module
- */
-
-export interface DmarcReport {
- orgName?: string
- email?: string
- reportId?: string
- domain?: string
- dateRange?: { begin: Date; end: Date }
- policy?: {
- p?: "none" | "quarantine" | "reject"
- sp?: "none" | "quarantine" | "reject"
- adkim?: "r" | "s"
- aspf?: "r" | "s"
- pct?: number
- }
- records: ReadonlyArray
-}
-
-export interface DmarcRecord {
- sourceIp?: string
- count: number
- disposition?: "none" | "quarantine" | "reject"
- dkim?: "pass" | "fail"
- spf?: "pass" | "fail"
- headerFrom?: string
-}
-
-/** Parse a DMARC aggregate report. Accepts a plain XML string or a
- * gzipped Uint8Array (detected via magic bytes). */
-export async function parseDmarcAggregate(input: string | Uint8Array): Promise {
- const xml = typeof input === "string" ? input : await gunzipOrUtf8(input)
- return parseReportXml(xml)
-}
-
-async function gunzipOrUtf8(bytes: Uint8Array): Promise {
- if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
- // Use DecompressionStream if available, else node:zlib.
- const g = globalThis as { DecompressionStream?: typeof DecompressionStream }
- if (g.DecompressionStream) {
- const stream = new Response(
- new Response(bytes as BufferSource).body!.pipeThrough(new g.DecompressionStream("gzip")),
- )
- return stream.text()
- }
- // Node.js fallback.
- const zlibModule = await import("node:zlib").catch(() => null)
- if (zlibModule) {
- return new Promise((resolve, reject) => {
- zlibModule.gunzip(Buffer.from(bytes), (err, out) => {
- if (err) reject(err)
- else resolve(out.toString("utf8"))
- })
- })
- }
- throw new Error("[unemail/dmarc] gzip input but no DecompressionStream / node:zlib available")
- }
- return new TextDecoder().decode(bytes)
-}
-
-function parseReportXml(xml: string): DmarcReport {
- const out: DmarcReport = { records: [] }
- const records: DmarcRecord[] = []
-
- out.orgName = takeTag(xml, "org_name")
- out.email = takeTag(xml, "email")
- out.reportId = takeTag(xml, "report_id")
- out.domain = takeTag(xml, "domain")
-
- const rangeBlock = takeSection(xml, "date_range")
- if (rangeBlock) {
- const begin = Number(takeTag(rangeBlock, "begin"))
- const end = Number(takeTag(rangeBlock, "end"))
- if (Number.isFinite(begin) && Number.isFinite(end))
- out.dateRange = { begin: new Date(begin * 1000), end: new Date(end * 1000) }
- }
-
- const policyBlock = takeSection(xml, "policy_published")
- if (policyBlock) {
- out.policy = {
- p: takeTag(policyBlock, "p") as DmarcReport["policy"] extends infer P
- ? P extends { p?: infer T }
- ? T
- : never
- : never,
- sp: takeTag(policyBlock, "sp") as "none" | "quarantine" | "reject" | undefined,
- adkim: takeTag(policyBlock, "adkim") as "r" | "s" | undefined,
- aspf: takeTag(policyBlock, "aspf") as "r" | "s" | undefined,
- pct: Number(takeTag(policyBlock, "pct")) || undefined,
- }
- }
-
- const recordRegex = /([\s\S]*?)<\/record>/g
- let m: RegExpExecArray | null
- while ((m = recordRegex.exec(xml)) !== null) {
- const block = m[1]!
- records.push({
- sourceIp: takeTag(block, "source_ip"),
- count: Number(takeTag(block, "count")) || 0,
- disposition: takeTag(block, "disposition") as DmarcRecord["disposition"],
- dkim: takeTag(block, "dkim") as DmarcRecord["dkim"],
- spf: takeTag(block, "spf") as DmarcRecord["spf"],
- headerFrom: takeTag(block, "header_from"),
- })
- }
- return { ...out, records }
-}
-
-function takeTag(block: string, tag: string): string | undefined {
- const re = new RegExp(`<${tag}>([^<]*)<\\/${tag}>`, "i")
- const m = re.exec(block)
- return m ? m[1]!.trim() : undefined
-}
-
-function takeSection(block: string, tag: string): string | undefined {
- const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i")
- const m = re.exec(block)
- return m ? m[1] : undefined
-}
diff --git a/src/driver/_http.ts b/src/driver/_http.ts
deleted file mode 100644
index 1f8587d..0000000
--- a/src/driver/_http.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { Result } from "../types.ts"
-import { createError, toEmailError } from "../errors.ts"
-
-/** Thin wrapper around `fetch` used by every HTTP-based driver (Resend,
- * Postmark, SendGrid, Mailgun, Mailtrap, Brevo, MailerSend, Loops, Zeptomail,
- * MailChannels, HTTP). Handles JSON encoding, response parsing, and
- * mapping HTTP status codes to our `EmailErrorCode` taxonomy.
- *
- * Drivers pass a tiny `classifyError()` callback when the provider
- * returns richer error codes than plain HTTP (Postmark's `ErrorCode 10`,
- * SendGrid's `errors[].field`, etc.).
- */
-export interface HttpRequestInit {
- fetch: typeof fetch
- driver: string
- url: string
- method?: string
- headers?: Record
- body?: unknown
- /** Return a custom EmailErrorCode classification from the parsed body. */
- classifyError?: (
- status: number,
- body: unknown,
- ) => {
- code: "AUTH" | "RATE_LIMIT" | "NETWORK" | "PROVIDER"
- retryable?: boolean
- message?: string
- } | null
-}
-
-/** Issue a JSON HTTP request and return a `Result` where the
- * data is the parsed response (or null for empty bodies). */
-export async function httpJson(init: HttpRequestInit): Promise> {
- const headers: Record = {
- accept: "application/json",
- "content-type": "application/json",
- ...init.headers,
- }
-
- let res: Response
- try {
- res = await init.fetch(init.url, {
- method: init.method ?? "POST",
- headers,
- body: init.body == null ? undefined : JSON.stringify(init.body),
- })
- } catch (err) {
- return { data: null, error: toEmailError(init.driver, err) }
- }
-
- const text = await res.text()
- const parsed = text ? safeJson(text) : null
-
- if (!res.ok) {
- const custom = init.classifyError?.(res.status, parsed)
- const code = custom?.code ?? defaultCodeForStatus(res.status)
- const message = custom?.message ?? extractMessage(parsed) ?? `HTTP ${res.status}`
- const retryable = custom?.retryable ?? (code === "RATE_LIMIT" || code === "NETWORK")
- return {
- data: null,
- error: createError(init.driver, code, message, {
- status: res.status,
- retryable,
- cause: { headers: res.headers, body: parsed ?? text },
- }),
- }
- }
-
- return { data: parsed, error: null }
-}
-
-function defaultCodeForStatus(status: number): "AUTH" | "RATE_LIMIT" | "NETWORK" | "PROVIDER" {
- if (status === 401 || status === 403) return "AUTH"
- if (status === 429) return "RATE_LIMIT"
- if (status >= 500) return "NETWORK"
- return "PROVIDER"
-}
-
-function extractMessage(body: unknown): string | null {
- if (!body || typeof body !== "object") return null
- const record = body as Record
- // Common shapes: { message }, { Message }, { error }, { errors: [{ message }] }
- const direct = record.message ?? record.Message ?? record.error ?? record.detail
- if (typeof direct === "string") return direct
- if (Array.isArray(record.errors) && record.errors[0] && typeof record.errors[0] === "object") {
- const first = record.errors[0] as Record
- if (typeof first.message === "string") return first.message
- }
- return null
-}
-
-function safeJson(text: string): unknown {
- try {
- return JSON.parse(text)
- } catch {
- return null
- }
-}
diff --git a/src/driver/_smtp/mime.ts b/src/driver/_smtp/mime.ts
deleted file mode 100644
index 99b7d0e..0000000
--- a/src/driver/_smtp/mime.ts
+++ /dev/null
@@ -1,320 +0,0 @@
-import type { Attachment, EmailAddress, EmailMessage } from "../../types.ts"
-import { formatAddress, normalizeAddresses } from "../../_normalize.ts"
-
-/** Inputs used to assemble the MIME document. Kept separate from
- * `EmailMessage` so the builder can be unit-tested in isolation. */
-export interface MimeInput {
- from: EmailAddress
- to: EmailAddress[]
- cc: EmailAddress[]
- bcc: EmailAddress[]
- replyTo: EmailAddress[]
- subject: string
- text?: string
- html?: string
- amp?: string
- headers?: Record
- attachments?: ReadonlyArray
- date?: Date
- messageId?: string
-}
-
-/** Output of `buildMime()` — the serialized RFC 5322 message plus the list
- * of envelope recipients (to/cc/bcc merged) for `RCPT TO`. */
-export interface MimeOutput {
- envelope: {
- from: string
- rcpt: string[]
- }
- body: string
- headers: Record
-}
-
-export function normalizeMimeInput(
- msg: EmailMessage,
- messageId: string,
- date: Date = new Date(),
-): MimeInput {
- const fromList = normalizeAddresses(msg.from)
- const from = fromList[0]
- if (!from) throw new Error("`from` is required")
- return {
- from,
- to: normalizeAddresses(msg.to),
- cc: normalizeAddresses(msg.cc),
- bcc: normalizeAddresses(msg.bcc),
- replyTo: normalizeAddresses(msg.replyTo),
- subject: msg.subject,
- text: msg.text,
- html: msg.html,
- amp: msg.amp,
- headers: msg.headers,
- attachments: msg.attachments,
- date,
- messageId,
- }
-}
-
-export function buildMime(input: MimeInput): MimeOutput {
- const boundary = `----unemail_${randomBoundary()}`
- const altBoundary = `----unemail_alt_${randomBoundary()}`
- const hasAttachments = (input.attachments?.length ?? 0) > 0
- const hasBothBodies = Boolean(input.text && input.html) || Boolean(input.amp)
-
- const headers: Record = {
- From: formatAddress(input.from),
- To: input.to.map(formatAddress).join(", "),
- Subject: encodeHeader(input.subject),
- "Message-ID": input.messageId ?? "",
- Date: (input.date ?? new Date()).toUTCString(),
- "MIME-Version": "1.0",
- }
- if (input.cc.length) headers.Cc = input.cc.map(formatAddress).join(", ")
- if (input.replyTo.length) headers["Reply-To"] = input.replyTo.map(formatAddress).join(", ")
- if (input.headers) {
- for (const [k, v] of Object.entries(input.headers)) headers[k] = v
- }
-
- const body = hasAttachments
- ? buildMultipartMixed(input, boundary, altBoundary, hasBothBodies, headers)
- : hasBothBodies
- ? buildMultipartAlternative(input, altBoundary, headers)
- : buildSinglePart(input, headers)
-
- const rendered = renderHeaders(headers) + "\r\n" + body
-
- return {
- envelope: {
- from: input.from.email,
- rcpt: dedupe([...input.to, ...input.cc, ...input.bcc].map((a) => a.email)),
- },
- headers,
- body: rendered,
- }
-}
-
-function renderHeaders(headers: Record): string {
- const lines: string[] = []
- for (const [name, value] of Object.entries(headers)) {
- if (value === "") continue
- lines.push(`${name}: ${foldHeader(value)}`)
- }
- return lines.join("\r\n") + "\r\n"
-}
-
-function buildSinglePart(input: MimeInput, headers: Record): string {
- if (input.html) {
- headers["Content-Type"] = "text/html; charset=utf-8"
- headers["Content-Transfer-Encoding"] = "quoted-printable"
- return encodeQuotedPrintable(input.html)
- }
- headers["Content-Type"] = "text/plain; charset=utf-8"
- headers["Content-Transfer-Encoding"] = "quoted-printable"
- return encodeQuotedPrintable(input.text ?? "")
-}
-
-function buildMultipartAlternative(
- input: MimeInput,
- boundary: string,
- headers: Record,
-): string {
- headers["Content-Type"] = `multipart/alternative; boundary="${boundary}"`
- const parts: string[] = []
- if (input.text) {
- parts.push(
- [
- `--${boundary}`,
- "Content-Type: text/plain; charset=utf-8",
- "Content-Transfer-Encoding: quoted-printable",
- "",
- encodeQuotedPrintable(input.text),
- ].join("\r\n"),
- )
- }
- if (input.amp) {
- parts.push(
- [
- `--${boundary}`,
- "Content-Type: text/x-amp-html; charset=utf-8",
- "Content-Transfer-Encoding: quoted-printable",
- "",
- encodeQuotedPrintable(input.amp),
- ].join("\r\n"),
- )
- }
- if (input.html) {
- parts.push(
- [
- `--${boundary}`,
- "Content-Type: text/html; charset=utf-8",
- "Content-Transfer-Encoding: quoted-printable",
- "",
- encodeQuotedPrintable(input.html),
- ].join("\r\n"),
- )
- }
- parts.push(`--${boundary}--`)
- return parts.join("\r\n")
-}
-
-function buildMultipartMixed(
- input: MimeInput,
- outerBoundary: string,
- altBoundary: string,
- hasBothBodies: boolean,
- headers: Record,
-): string {
- headers["Content-Type"] = `multipart/mixed; boundary="${outerBoundary}"`
- const parts: string[] = []
-
- const altHeaders: Record = {}
- const bodyPart = hasBothBodies
- ? buildMultipartAlternative(input, altBoundary, altHeaders)
- : buildSinglePart(input, altHeaders)
-
- parts.push(
- [
- `--${outerBoundary}`,
- `Content-Type: ${altHeaders["Content-Type"] ?? "text/plain; charset=utf-8"}`,
- ...(altHeaders["Content-Transfer-Encoding"]
- ? [`Content-Transfer-Encoding: ${altHeaders["Content-Transfer-Encoding"]}`]
- : []),
- "",
- bodyPart,
- ].join("\r\n"),
- )
-
- for (const a of input.attachments ?? []) {
- parts.push(renderAttachment(outerBoundary, a))
- }
- parts.push(`--${outerBoundary}--`)
- return parts.join("\r\n")
-}
-
-function renderAttachment(boundary: string, a: Attachment): string {
- const base64 =
- typeof a.content === "string"
- ? isLikelyBase64(a.content)
- ? a.content
- : toBase64FromString(a.content)
- : toBase64FromBytes(a.content)
- const folded = foldBase64(base64)
- const contentType = a.contentType ?? "application/octet-stream"
- const disposition = a.disposition ?? "attachment"
- const lines = [
- `--${boundary}`,
- `Content-Type: ${contentType}; name="${encodeHeader(a.filename)}"`,
- "Content-Transfer-Encoding: base64",
- `Content-Disposition: ${disposition}; filename="${encodeHeader(a.filename)}"`,
- ]
- if (a.cid) lines.push(`Content-ID: <${a.cid}>`)
- lines.push("", folded)
- return lines.join("\r\n")
-}
-
-/** Dot-stuff a body for DATA transmission per RFC 5321 §4.5.2. Lines that
- * begin with `.` get an extra `.` prepended so the sequence `\r\n.\r\n`
- * never appears inside the payload. Returns a single string with CRLF
- * line endings. */
-export function dotStuff(body: string): string {
- const crlfBody = body.replace(/\r?\n/g, "\r\n")
- return crlfBody.replace(/(^|\r\n)(\.)/g, "$1.$2")
-}
-
-function foldHeader(value: string, max = 76): string {
- if (value.length <= max) return value
- const words = value.split(" ")
- const lines: string[] = []
- let current = ""
- for (const word of words) {
- if (current.length + word.length + 1 > max) {
- lines.push(current)
- current = ` ${word}`
- } else {
- current = current ? `${current} ${word}` : word
- }
- }
- if (current) lines.push(current)
- return lines.join("\r\n")
-}
-
-function encodeHeader(value: string): string {
- if (/^[\x20-\x7E]*$/.test(value)) return value
- const b64 = toBase64FromString(value)
- return `=?utf-8?B?${b64}?=`
-}
-
-function encodeQuotedPrintable(input: string): string {
- const out: string[] = []
- for (const ch of input) {
- const code = ch.codePointAt(0)!
- if (ch === "\n") {
- out.push("\r\n")
- continue
- }
- if (ch === "\r") continue
- if (code === 0x20 || code === 0x09) {
- out.push(ch)
- continue
- }
- if (code >= 0x21 && code <= 0x7e && ch !== "=") {
- out.push(ch)
- continue
- }
- const bytes = new TextEncoder().encode(ch)
- for (const b of bytes) out.push(`=${b.toString(16).toUpperCase().padStart(2, "0")}`)
- }
- return softWrap(out.join(""), 76)
-}
-
-function softWrap(input: string, max: number): string {
- const lines = input.split(/\r\n/)
- return lines
- .map((line) => {
- if (line.length <= max) return line
- const out: string[] = []
- let rest = line
- while (rest.length > max - 1) {
- let cut = max - 1
- while (cut > 0 && (rest[cut - 1] === "=" || (cut >= 2 && rest[cut - 2] === "="))) cut--
- out.push(`${rest.slice(0, cut)}=`)
- rest = rest.slice(cut)
- }
- out.push(rest)
- return out.join("\r\n")
- })
- .join("\r\n")
-}
-
-function toBase64FromString(value: string): string {
- const bytes = new TextEncoder().encode(value)
- return toBase64FromBytes(bytes)
-}
-
-function toBase64FromBytes(bytes: Uint8Array): string {
- const g = globalThis as {
- Buffer?: { from: (b: Uint8Array) => { toString: (enc: string) => string } }
- }
- if (g.Buffer) return g.Buffer.from(bytes).toString("base64")
- let binary = ""
- for (const byte of bytes) binary += String.fromCharCode(byte)
- return btoa(binary)
-}
-
-function isLikelyBase64(value: string): boolean {
- return /^[A-Za-z0-9+/=\r\n]+$/.test(value) && value.length > 0 && value.length % 4 === 0
-}
-
-function foldBase64(b64: string, width = 76): string {
- const chunks: string[] = []
- for (let i = 0; i < b64.length; i += width) chunks.push(b64.slice(i, i + width))
- return chunks.join("\r\n")
-}
-
-function randomBoundary(): string {
- return Math.random().toString(36).slice(2, 12) + Date.now().toString(36)
-}
-
-function dedupe(values: string[]): string[] {
- return [...new Set(values)]
-}
diff --git a/src/driver/brevo.ts b/src/driver/brevo.ts
deleted file mode 100644
index baf56e1..0000000
--- a/src/driver/brevo.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import type {
- Attachment,
- DriverFactory,
- EmailAddress,
- EmailMessage,
- EmailResult,
- Result,
-} from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { normalizeAddresses } from "../_normalize.ts"
-import { createError, createRequiredError } from "../errors.ts"
-import { httpJson } from "./_http.ts"
-
-export interface BrevoDriverOptions {
- apiKey: string
- endpoint?: string
- fetch?: typeof fetch
-}
-
-const DRIVER = "brevo"
-
-const brevo: DriverFactory = defineDriver((options) => {
- if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey")
- const endpoint = options.endpoint ?? "https://api.brevo.com"
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- return {
- name: DRIVER,
- options,
- flags: {
- html: true,
- text: true,
- attachments: true,
- tagging: true,
- tracking: true,
- replyTo: true,
- customHeaders: true,
- scheduling: true,
- templates: true,
- },
-
- async isAvailable() {
- return Boolean(options.apiKey)
- },
-
- async send(msg) {
- const payload = buildBrevoPayload(msg)
- const res = await httpJson({
- fetch: fetchImpl,
- driver: DRIVER,
- url: `${endpoint}/v3/smtp/email`,
- headers: { "api-key": options.apiKey },
- body: payload,
- })
- if (res.error) return res as Result
- const body = (res.data ?? {}) as { messageId?: string }
- return {
- data: {
- id: body.messageId ?? `brevo_${Date.now().toString(36)}`,
- driver: DRIVER,
- at: new Date(),
- provider: body as Record,
- },
- error: null,
- }
- },
- }
-})
-
-export default brevo
-
-function buildBrevoPayload(msg: EmailMessage): Record {
- const from = normalizeAddresses(msg.from)[0]
- if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required")
-
- const payload: Record = {
- sender: toBrevoAddress(from),
- to: normalizeAddresses(msg.to).map(toBrevoAddress),
- subject: msg.subject,
- }
- if (msg.cc) payload.cc = normalizeAddresses(msg.cc).map(toBrevoAddress)
- if (msg.bcc) payload.bcc = normalizeAddresses(msg.bcc).map(toBrevoAddress)
- if (msg.replyTo) {
- const r = normalizeAddresses(msg.replyTo)[0]
- if (r) payload.replyTo = toBrevoAddress(r)
- }
- if (msg.text) payload.textContent = msg.text
- if (msg.html) payload.htmlContent = msg.html
- if (msg.headers) payload.headers = msg.headers
- if (msg.tags?.length) payload.tags = msg.tags.map((t) => t.name)
- if (msg.scheduledAt) {
- const d = msg.scheduledAt instanceof Date ? msg.scheduledAt : new Date(msg.scheduledAt)
- 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
-}
-
-function toBrevoAddress(a: EmailAddress): Record {
- return a.name ? { email: a.email, name: a.name } : { email: a.email }
-}
-
-function toBrevoAttachment(a: Attachment): Record {
- const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content)
- return { name: a.filename, content }
-}
-
-function bytesToBase64(bytes: Uint8Array): string {
- const g = globalThis as {
- Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } }
- }
- if (g.Buffer) return g.Buffer.from(bytes).toString("base64")
- let binary = ""
- for (const byte of bytes) binary += String.fromCharCode(byte)
- return btoa(binary)
-}
diff --git a/src/driver/cloudflare-email-service.ts b/src/driver/cloudflare-email-service.ts
deleted file mode 100644
index cebd548..0000000
--- a/src/driver/cloudflare-email-service.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import type {
- Attachment,
- DriverFactory,
- EmailAddress,
- EmailErrorCode,
- EmailResult,
- Result,
-} from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { createError, createRequiredError, toEmailError } from "../errors.ts"
-import { normalizeAddresses } from "../_normalize.ts"
-
-/** Cloudflare **Email Service** (Email Sending) outbound binding.
- *
- * Distinct from `unemail/driver/cloudflare-email`, which targets the older
- * **Email Routing** API: that one constructs `EmailMessage` from the virtual
- * `cloudflare:email` module and hands the binding raw RFC 5322 text. Email
- * Service takes structured fields on the same `send_email` binding, so this
- * driver needs no ambient global, no virtual module, and no MIME builder.
- * Both are kept — pick the one matching the API your binding speaks.
- *
- * ```ts
- * export default {
- * async fetch(req, env) {
- * const email = createEmail({ driver: cloudflareEmailService({ binding: env.EMAIL }) })
- * await email.send({ from, to, subject, html, text })
- * }
- * }
- * ```
- *
- * Requires a `send_email` binding in `wrangler.jsonc` and a sender domain
- * onboarded via `wrangler email sending enable `. */
-export interface CloudflareEmailServiceDriverOptions {
- binding: CloudflareEmailServiceBinding
-}
-
-export interface CloudflareEmailServiceBinding {
- send: (message: CloudflareEmailServiceMessage) => Promise
-}
-
-/** Structured payload accepted by the binding. Declared locally so the driver
- * depends on neither `@cloudflare/workers-types` nor `cloudflare:email` —
- * when those types are available, prefer them at the call site. */
-export interface CloudflareEmailServiceMessage {
- from: EmailAddress
- to: EmailAddress[]
- subject: string
- text?: string
- html?: string
- cc?: EmailAddress[]
- bcc?: EmailAddress[]
- replyTo?: EmailAddress
- headers?: Record
- attachments?: CloudflareEmailServiceAttachment[]
-}
-
-export interface CloudflareEmailServiceAttachment {
- content: string | Uint8Array
- filename: string
- type: string
- disposition: "attachment" | "inline"
- contentId?: string
-}
-
-export interface CloudflareEmailSendResult {
- messageId?: string
-}
-
-const DRIVER = "cloudflare-email-service"
-
-/** The binding throws `Error`s carrying an `E_*` `code`. Map them onto our
- * taxonomy so `retryable` is meaningful — otherwise the retry middleware
- * would keep re-sending messages that a validation fix, not a retry, cures. */
-const ERROR_CODES: Record = {
- E_VALIDATION_ERROR: "INVALID_OPTIONS",
- E_FIELD_MISSING: "INVALID_OPTIONS",
- E_TOO_MANY_RECIPIENTS: "INVALID_OPTIONS",
- E_TOO_MANY_ATTACHMENTS: "INVALID_OPTIONS",
- E_CONTENT_TOO_LARGE: "INVALID_OPTIONS",
- E_HEADER_NOT_ALLOWED: "INVALID_OPTIONS",
- E_HEADER_USE_API_FIELD: "INVALID_OPTIONS",
- E_HEADER_VALUE_INVALID: "INVALID_OPTIONS",
- E_HEADER_VALUE_TOO_LONG: "INVALID_OPTIONS",
- E_HEADER_NAME_INVALID: "INVALID_OPTIONS",
- E_HEADERS_TOO_LARGE: "INVALID_OPTIONS",
- E_HEADERS_TOO_MANY: "INVALID_OPTIONS",
- E_SENDER_NOT_VERIFIED: "AUTH",
- E_SENDER_DOMAIN_NOT_AVAILABLE: "AUTH",
- E_RECIPIENT_NOT_ALLOWED: "AUTH",
- E_RECIPIENT_SUPPRESSED: "PROVIDER",
- E_RATE_LIMIT_EXCEEDED: "RATE_LIMIT",
- E_DAILY_LIMIT_EXCEEDED: "RATE_LIMIT",
- E_DELIVERY_FAILED: "NETWORK",
- E_INTERNAL_SERVER_ERROR: "NETWORK",
-}
-
-function toDriverError(err: unknown) {
- const code = ERROR_CODES[String((err as { code?: unknown })?.code ?? "")]
- if (!code) return toEmailError(DRIVER, err)
- return createError(DRIVER, code, (err as Error).message, { cause: err })
-}
-
-/** The binding validates every key it receives, so `name` is left out
- * entirely rather than sent as `undefined`. */
-function toAddress(addr: EmailAddress): EmailAddress {
- return addr.name ? { email: addr.email, name: addr.name } : { email: addr.email }
-}
-
-/** Email Service takes structured attachments rather than MIME parts, so the
- * mapping is a rename: `contentType` → `type`, `cid` → `contentId`. */
-function toCloudflareAttachment(file: Attachment): CloudflareEmailServiceAttachment {
- const out: CloudflareEmailServiceAttachment = {
- content: file.content,
- filename: file.filename,
- type: file.contentType ?? "application/octet-stream",
- disposition: file.disposition ?? (file.cid ? "inline" : "attachment"),
- }
- if (file.cid) out.contentId = file.cid
- return out
-}
-
-const cloudflareEmailService: DriverFactory =
- defineDriver((options) => {
- if (!options?.binding) throw createRequiredError(DRIVER, "binding")
-
- return {
- name: DRIVER,
- options,
- flags: {
- html: true,
- text: true,
- attachments: true,
- customHeaders: true,
- replyTo: true,
- },
-
- async isAvailable() {
- return true
- },
-
- async send(msg): Promise> {
- try {
- const from = normalizeAddresses(msg.from)[0]
- const to = normalizeAddresses(msg.to).map(toAddress)
- if (!from || !to.length)
- return {
- data: null,
- error: createError(DRIVER, "INVALID_OPTIONS", "`from` and `to` are required"),
- }
-
- const cc = normalizeAddresses(msg.cc).map(toAddress)
- const bcc = normalizeAddresses(msg.bcc).map(toAddress)
- const replyTo = normalizeAddresses(msg.replyTo)[0]
-
- const result = await options.binding.send({
- from: toAddress(from),
- to,
- subject: msg.subject,
- text: msg.text,
- html: msg.html,
- cc: cc.length ? cc : undefined,
- bcc: bcc.length ? bcc : undefined,
- replyTo: replyTo ? toAddress(replyTo) : undefined,
- headers: msg.headers,
- attachments: msg.attachments?.length
- ? msg.attachments.map(toCloudflareAttachment)
- : undefined,
- })
-
- return {
- data: {
- id: result?.messageId ?? "",
- driver: DRIVER,
- at: new Date(),
- },
- error: null,
- }
- } catch (err) {
- return { data: null, error: toDriverError(err) }
- }
- },
- }
- })
-
-export default cloudflareEmailService
diff --git a/src/driver/cloudflare-email.ts b/src/driver/cloudflare-email.ts
deleted file mode 100644
index ddb2111..0000000
--- a/src/driver/cloudflare-email.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import type { DriverFactory, EmailResult, Result } from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { buildMime, normalizeMimeInput } from "./_smtp/mime.ts"
-import { createError, createRequiredError, toEmailError } from "../errors.ts"
-import { normalizeAddresses } from "../_normalize.ts"
-
-/** Cloudflare Email Workers outbound binding. Instantiate with the binding
- * object defined in your \`wrangler.toml\` (\`send_email\` rule):
- *
- * ```ts
- * export default {
- * async fetch(req, env) {
- * const email = createEmail({ driver: cloudflareEmail({ binding: env.SEND_EMAIL }) })
- * await email.send({ from, to, subject, text })
- * }
- * }
- * ```
- *
- * The binding accepts a constructed \`EmailMessage\` (see Cloudflare docs —
- * the SDK exposes \`new EmailMessage(from, to, raw)\` via the global
- * \`postalmime\` bindings); we build raw RFC 5322 text ourselves. */
-export interface CloudflareEmailDriverOptions {
- binding: CloudflareEmailBinding
- /** Optional factory for the \`EmailMessage\` class. Defaults to
- * \`globalThis.EmailMessage\`, which Workers injects at runtime. */
- EmailMessage?: CloudflareEmailMessageCtor
-}
-
-export interface CloudflareEmailBinding {
- send: (message: unknown) => Promise | void
-}
-
-export type CloudflareEmailMessageCtor = new (from: string, to: string, raw: string) => unknown
-
-const DRIVER = "cloudflare-email"
-
-const cloudflareEmail: DriverFactory =
- defineDriver((options) => {
- if (!options?.binding) throw createRequiredError(DRIVER, "binding")
- const Ctor =
- options.EmailMessage ??
- (globalThis as { EmailMessage?: CloudflareEmailMessageCtor }).EmailMessage
- if (!Ctor)
- throw createError(
- DRIVER,
- "INVALID_OPTIONS",
- "EmailMessage constructor is unavailable; pass it via options when not running on Cloudflare Workers",
- )
-
- return {
- name: DRIVER,
- options,
- flags: {
- html: true,
- text: true,
- attachments: true,
- customHeaders: true,
- replyTo: true,
- },
-
- async isAvailable() {
- return true
- },
-
- async send(msg): Promise> {
- try {
- const from = normalizeAddresses(msg.from)[0]
- const to = normalizeAddresses(msg.to)[0]
- if (!from || !to)
- return {
- data: null,
- error: createError(DRIVER, "INVALID_OPTIONS", "`from` and `to` are required"),
- }
- const messageId =
- msg.headers?.["Message-ID"] ??
- `<${Date.now().toString(36)}.${Math.random().toString(36).slice(2)}@cloudflare-email>`
- const mime = buildMime(normalizeMimeInput(msg, messageId))
- const message = new Ctor(from.email, to.email, mime.body)
- await options.binding.send(message)
- return {
- data: {
- id: messageId,
- driver: DRIVER,
- at: new Date(),
- },
- error: null,
- }
- } catch (err) {
- return { data: null, error: toEmailError(DRIVER, err) }
- }
- },
- }
- })
-
-export default cloudflareEmail
diff --git a/src/driver/fallback.ts b/src/driver/fallback.ts
deleted file mode 100644
index d5d1deb..0000000
--- a/src/driver/fallback.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import type { DriverFactory, EmailDriver } from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { createError, toEmailError } from "../errors.ts"
-
-/** Try each wrapped driver in order; move on to the next when the current
- * one returns a retryable error. Non-retryable errors short-circuit.
- *
- * ```ts
- * createEmail({ driver: fallback([resend({...}), ses({...})]) })
- * ```
- */
-export interface FallbackOptions {
- drivers: ReadonlyArray
- /** Override the "is this error worth moving on for" check. */
- shouldAdvance?: (error: NonNullable>["error"]>) => boolean
-}
-
-const fallback: DriverFactory = defineDriver((options) => {
- if (!options || options.drivers.length === 0)
- throw createError("fallback", "INVALID_OPTIONS", "at least one driver is required")
- const drivers = options.drivers
- const shouldAdvance = options.shouldAdvance ?? ((err) => err.retryable)
-
- return {
- name: "fallback",
- options,
- async send(msg, ctx) {
- let lastError: ReturnType | null = null
- for (const driver of drivers) {
- ctx.driver = driver.name
- try {
- const result = await driver.send(msg, ctx)
- if (result.data) return result
- lastError = result.error
- if (!shouldAdvance(result.error)) return result
- } catch (thrown) {
- lastError = toEmailError(driver.name, thrown)
- }
- }
- return {
- data: null,
- error: lastError ?? createError("fallback", "PROVIDER", "all drivers failed"),
- }
- },
- async initialize() {
- await Promise.all(drivers.map((d) => d.initialize?.()))
- },
- async dispose() {
- await Promise.all(drivers.map((d) => d.dispose?.()))
- },
- async isAvailable() {
- for (const d of drivers) {
- if (!d.isAvailable) return true
- if (await d.isAvailable()) return true
- }
- return false
- },
- }
-})
-
-export default fallback
diff --git a/src/driver/http.ts b/src/driver/http.ts
deleted file mode 100644
index 5ef7fbb..0000000
--- a/src/driver/http.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import type {
- Attachment,
- DriverFactory,
- EmailAddress,
- EmailMessage,
- EmailResult,
- Result,
-} from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { formatAddress, normalizeAddresses } from "../_normalize.ts"
-import { createError, createRequiredError } from "../errors.ts"
-import { httpJson } from "./_http.ts"
-
-/** Options for the generic `http` driver — useful for proxying through
- * your own endpoint (a Next.js route handler, a hosted worker, a test
- * harness, anything JSON-shaped). */
-export interface HttpDriverOptions {
- /** The POST target. */
- endpoint: string
- /** HTTP method. Defaults to POST. */
- method?: string
- /** Bearer token sent as `Authorization: Bearer ` when set. */
- apiKey?: string
- /** Extra headers merged on every request. */
- headers?: Record
- /** Transform the normalized message into the payload shape your API
- * expects. The default emits a sensible object similar to Resend's
- * public shape. */
- transform?: (msg: EmailMessage) => unknown
- /** Extract the provider-assigned id from the response body. Default:
- * looks at \`id\`, \`messageId\`, \`data.id\`, \`data.messageId\`. */
- extractId?: (body: unknown) => string | null
- /** Injected fetch — defaults to global \`fetch\`. */
- fetch?: typeof fetch
-}
-
-const DRIVER = "http"
-
-const http: DriverFactory = defineDriver((options) => {
- if (!options?.endpoint) throw createRequiredError(DRIVER, "endpoint")
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- const transform = options.transform ?? defaultTransform
- const extractId = options.extractId ?? defaultExtractId
-
- return {
- name: DRIVER,
- options,
- flags: {
- html: true,
- text: true,
- attachments: true,
- replyTo: true,
- customHeaders: true,
- },
-
- async isAvailable() {
- return true
- },
-
- async send(msg) {
- const payload = transform(msg)
- const headers: Record = { ...options.headers }
- if (options.apiKey) headers.authorization = `Bearer ${options.apiKey}`
- const res = await httpJson({
- fetch: fetchImpl,
- driver: DRIVER,
- url: options.endpoint,
- method: options.method ?? "POST",
- headers,
- body: payload,
- })
- if (res.error) return res as Result
- const id = extractId(res.data) ?? synthId()
- return {
- data: {
- id,
- driver: DRIVER,
- at: new Date(),
- provider: (res.data ?? null) as Record | undefined,
- },
- error: null,
- }
- },
- }
-})
-
-export default http
-
-function defaultTransform(msg: EmailMessage): Record {
- const from = normalizeAddresses(msg.from)[0]
- const out: Record = {
- from: from ? formatAddress(from) : undefined,
- to: normalizeAddresses(msg.to).map((a: EmailAddress) => formatAddress(a)),
- subject: msg.subject,
- }
- if (msg.cc) out.cc = normalizeAddresses(msg.cc).map(formatAddress)
- if (msg.bcc) out.bcc = normalizeAddresses(msg.bcc).map(formatAddress)
- if (msg.replyTo) out.replyTo = normalizeAddresses(msg.replyTo).map(formatAddress)
- if (msg.text) out.text = msg.text
- if (msg.html) out.html = msg.html
- if (msg.headers) out.headers = msg.headers
- if (msg.attachments?.length) out.attachments = msg.attachments.map(toAttachmentPayload)
- return out
-}
-
-function toAttachmentPayload(a: Attachment): Record {
- const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content)
- const out: Record = {
- filename: a.filename,
- content,
- }
- if (a.contentType) out.contentType = a.contentType
- if (a.disposition) out.disposition = a.disposition
- if (a.cid) out.cid = a.cid
- return out
-}
-
-function defaultExtractId(body: unknown): string | null {
- if (!body || typeof body !== "object") return null
- const r = body as Record
- if (typeof r.id === "string") return r.id
- if (typeof r.messageId === "string") return r.messageId
- if (r.data && typeof r.data === "object") {
- const inner = r.data as Record
- if (typeof inner.id === "string") return inner.id
- if (typeof inner.messageId === "string") return inner.messageId
- }
- return null
-}
-
-function synthId(): string {
- return `http_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
-}
-
-function bytesToBase64(bytes: Uint8Array): string {
- const g = globalThis as {
- Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } }
- }
- if (g.Buffer) return g.Buffer.from(bytes).toString("base64")
- let binary = ""
- for (const byte of bytes) binary += String.fromCharCode(byte)
- return btoa(binary)
-}
diff --git a/src/driver/loops.ts b/src/driver/loops.ts
deleted file mode 100644
index 127abaf..0000000
--- a/src/driver/loops.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import type { DriverFactory, EmailResult, Result } from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { normalizeAddresses } from "../_normalize.ts"
-import { createError, createRequiredError } from "../errors.ts"
-import { httpJson } from "./_http.ts"
-
-/** The Loops transactional API takes \`transactionalId\` + \`email\` + \`dataVariables\`.
- * Map from our \`EmailMessage\`: \`headers["x-loops-transactional-id"]\` or
- * the driver's default \`transactionalId\` option carries the id;
- * \`dataVariables\` come from \`msg.tags\` (repurposed as vars since Loops
- * doesn't have free-form tag support). */
-export interface LoopsDriverOptions {
- apiKey: string
- /** Default Loops transactionalId if the message doesn't specify one. */
- transactionalId?: string
- endpoint?: string
- fetch?: typeof fetch
-}
-
-const DRIVER = "loops"
-
-const loops: DriverFactory = defineDriver((options) => {
- if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey")
- const endpoint = options.endpoint ?? "https://app.loops.so"
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- return {
- name: DRIVER,
- options,
- flags: {
- templates: true,
- tracking: true,
- tagging: true,
- },
-
- async isAvailable() {
- return Boolean(options.apiKey)
- },
-
- async send(msg) {
- const transactionalId =
- msg.template?.id ?? msg.headers?.["x-loops-transactional-id"] ?? options.transactionalId
- if (!transactionalId) {
- return {
- data: null,
- error: createError(
- DRIVER,
- "INVALID_OPTIONS",
- "transactionalId is required: pass via msg.template.id, headers['x-loops-transactional-id'] or driver options",
- ),
- }
- }
- const to = normalizeAddresses(msg.to)[0]
- if (!to) {
- return {
- data: null,
- error: createError(DRIVER, "INVALID_OPTIONS", "`to` is required"),
- }
- }
- const dataVariables: Record = {
- ...msg.template?.variables,
- }
- for (const t of msg.tags ?? []) dataVariables[t.name] = t.value
-
- const res = await httpJson({
- fetch: fetchImpl,
- driver: DRIVER,
- url: `${endpoint}/api/v1/transactional`,
- headers: { authorization: `Bearer ${options.apiKey}` },
- body: {
- transactionalId,
- email: to.email,
- dataVariables,
- },
- })
- if (res.error) return res as Result
- const body = (res.data ?? {}) as { success?: boolean }
- return {
- data: {
- id: `loops_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
- driver: DRIVER,
- at: new Date(),
- provider: body as Record,
- },
- error: null,
- }
- },
- }
-})
-
-export default loops
diff --git a/src/driver/mailchannels.ts b/src/driver/mailchannels.ts
deleted file mode 100644
index 29630ae..0000000
--- a/src/driver/mailchannels.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import type {
- Attachment,
- DriverFactory,
- EmailAddress,
- EmailMessage,
- EmailResult,
- Result,
-} from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { normalizeAddresses } from "../_normalize.ts"
-import { createError } from "../errors.ts"
-import { httpJson } from "./_http.ts"
-
-/** MailChannels — free transactional send from Cloudflare Workers (no auth
- * needed when running inside a CF Worker; requires SPF/DKIM configured
- * for your sending domain). Outside Workers you need an API key. */
-export interface MailChannelsDriverOptions {
- /** Required when not running inside a Cloudflare Worker. */
- apiKey?: string
- /** DKIM signing for non-Worker usage. */
- dkim?: {
- domain: string
- selector: string
- privateKey: string
- }
- endpoint?: string
- fetch?: typeof fetch
-}
-
-const DRIVER = "mailchannels"
-
-const mailchannels: DriverFactory =
- defineDriver((options = {}) => {
- const endpoint = options.endpoint ?? "https://api.mailchannels.net"
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- return {
- name: DRIVER,
- options,
- flags: {
- html: true,
- text: true,
- attachments: true,
- replyTo: true,
- customHeaders: true,
- },
-
- async isAvailable() {
- return true
- },
-
- async send(msg) {
- const payload = buildMailChannelsPayload(msg, options)
- const headers: Record = {}
- if (options.apiKey) headers["x-api-key"] = options.apiKey
- const res = await httpJson({
- fetch: fetchImpl,
- driver: DRIVER,
- url: `${endpoint}/tx/v1/send`,
- headers,
- body: payload,
- })
- if (res.error) return res as Result
- return {
- data: {
- id: `mc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
- driver: DRIVER,
- at: new Date(),
- provider: (res.data as Record | null) ?? undefined,
- },
- error: null,
- }
- },
- }
- })
-
-export default mailchannels
-
-function buildMailChannelsPayload(
- msg: EmailMessage,
- options: MailChannelsDriverOptions,
-): Record {
- const from = normalizeAddresses(msg.from)[0]
- if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required")
-
- const personalization: Record = {
- to: normalizeAddresses(msg.to).map(toMcAddress),
- }
- if (msg.cc) personalization.cc = normalizeAddresses(msg.cc).map(toMcAddress)
- if (msg.bcc) personalization.bcc = normalizeAddresses(msg.bcc).map(toMcAddress)
- if (msg.headers) personalization.headers = msg.headers
- if (options.dkim) {
- personalization.dkim_domain = options.dkim.domain
- personalization.dkim_selector = options.dkim.selector
- personalization.dkim_private_key = options.dkim.privateKey
- }
-
- const content: Array> = []
- if (msg.text) content.push({ type: "text/plain", value: msg.text })
- if (msg.html) content.push({ type: "text/html", value: msg.html })
-
- const payload: Record = {
- personalizations: [personalization],
- from: toMcAddress(from),
- subject: msg.subject,
- content,
- }
- if (msg.replyTo) {
- const r = normalizeAddresses(msg.replyTo)[0]
- if (r) payload.reply_to = toMcAddress(r)
- }
- if (msg.attachments?.length) payload.attachments = msg.attachments.map(toMcAttachment)
- return payload
-}
-
-function toMcAddress(a: EmailAddress): Record {
- return a.name ? { email: a.email, name: a.name } : { email: a.email }
-}
-
-function toMcAttachment(a: Attachment): Record {
- const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content)
- return {
- filename: a.filename,
- content,
- type: a.contentType ?? "application/octet-stream",
- }
-}
-
-function bytesToBase64(bytes: Uint8Array): string {
- const g = globalThis as {
- Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } }
- }
- if (g.Buffer) return g.Buffer.from(bytes).toString("base64")
- let binary = ""
- for (const byte of bytes) binary += String.fromCharCode(byte)
- return btoa(binary)
-}
diff --git a/src/driver/mailcrab.ts b/src/driver/mailcrab.ts
deleted file mode 100644
index 60e0bd3..0000000
--- a/src/driver/mailcrab.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { DriverFactory } from "../types.ts"
-import type { SmtpDriverOptions } from "./smtp.ts"
-import { defineDriver } from "../_define.ts"
-import smtp from "./smtp.ts"
-
-/** MailCrab is a local SMTP sink (the \`unemail-mailcrab\` CLI spins it up
- * via docker). This driver is a thin wrapper over the SMTP driver with
- * MailCrab-friendly defaults so \`createEmail({ driver: mailcrab() })\`
- * Just Works in dev.
- *
- * The web UI lives at \`http://localhost:1080\` — a one-liner pointer is
- * printed on first use so new users find it fast. */
-export interface MailCrabDriverOptions extends Partial {
- /** Defaults to \`localhost\`. */
- host?: string
- /** Defaults to \`1025\` (MailCrab's SMTP port). */
- port?: number
- /** Web UI port, used only for the on-first-send help message. Default 1080. */
- uiPort?: number
- /** Silence the "see messages at …" hint. Default \`false\`. */
- quiet?: boolean
-}
-
-const mailcrab: DriverFactory = defineDriver(
- (options = {}) => {
- const host = options.host ?? "localhost"
- const port = options.port ?? 1025
- const uiPort = options.uiPort ?? 1080
- const delegate = smtp({
- ...options,
- host,
- port,
- secure: false,
- rejectUnauthorized: false,
- commandTimeoutMs: options.commandTimeoutMs ?? 5000,
- connectionTimeoutMs: options.connectionTimeoutMs ?? 5000,
- })
- let hinted = false
-
- return {
- ...delegate,
- name: "mailcrab",
- async send(msg, ctx) {
- if (!hinted && !options.quiet) {
- hinted = true
- console.info(`[unemail] [mailcrab] inspecting messages at http://${host}:${uiPort}`)
- }
- const result = await delegate.send(msg, ctx)
- if (result.data) return { data: { ...result.data, driver: "mailcrab" }, error: null }
- return result
- },
- }
- },
-)
-
-export default mailcrab
diff --git a/src/driver/mailersend.ts b/src/driver/mailersend.ts
deleted file mode 100644
index 6c77e6d..0000000
--- a/src/driver/mailersend.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import type {
- Attachment,
- DriverFactory,
- EmailAddress,
- EmailMessage,
- EmailResult,
- Result,
-} from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { normalizeAddresses } from "../_normalize.ts"
-import { createError, createRequiredError } from "../errors.ts"
-import { httpJson } from "./_http.ts"
-
-export interface MailerSendDriverOptions {
- apiKey: string
- endpoint?: string
- fetch?: typeof fetch
-}
-
-const DRIVER = "mailersend"
-
-const mailersend: DriverFactory = defineDriver(
- (options) => {
- if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey")
- const endpoint = options.endpoint ?? "https://api.mailersend.com"
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- return {
- name: DRIVER,
- options,
- flags: {
- html: true,
- text: true,
- attachments: true,
- tagging: true,
- tracking: true,
- replyTo: true,
- customHeaders: true,
- scheduling: true,
- batch: true,
- },
-
- async isAvailable() {
- return Boolean(options.apiKey)
- },
-
- async send(msg) {
- const payload = buildMailerSendPayload(msg)
- const res = await httpJson({
- fetch: fetchImpl,
- driver: DRIVER,
- url: `${endpoint}/v1/email`,
- headers: { authorization: `Bearer ${options.apiKey}` },
- body: payload,
- })
- if (res.error) return res as Result
- // MailerSend returns 202 with `X-Message-Id` header; body is empty.
- const id = `ms_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
- return {
- data: {
- id,
- driver: DRIVER,
- at: new Date(),
- provider: (res.data as Record | null) ?? undefined,
- },
- error: null,
- }
- },
-
- async sendBatch(msgs) {
- const payload = msgs.map(buildMailerSendPayload)
- const res = await httpJson({
- fetch: fetchImpl,
- driver: DRIVER,
- url: `${endpoint}/v1/bulk-email`,
- headers: { authorization: `Bearer ${options.apiKey}` },
- body: payload,
- })
- if (res.error) return res as never
- const body = (res.data ?? {}) as { bulk_email_id?: string }
- const results: EmailResult[] = msgs.map((_, i) => ({
- id: `${body.bulk_email_id ?? "ms_bulk"}_${i}`,
- driver: DRIVER,
- at: new Date(),
- provider: body as Record,
- }))
- return { data: results, error: null }
- },
- }
- },
-)
-
-export default mailersend
-
-function buildMailerSendPayload(msg: EmailMessage): Record {
- const from = normalizeAddresses(msg.from)[0]
- if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required")
-
- const payload: Record = {
- from: toMsAddress(from),
- to: normalizeAddresses(msg.to).map(toMsAddress),
- subject: msg.subject,
- }
- if (msg.cc) payload.cc = normalizeAddresses(msg.cc).map(toMsAddress)
- if (msg.bcc) payload.bcc = normalizeAddresses(msg.bcc).map(toMsAddress)
- if (msg.replyTo) {
- const r = normalizeAddresses(msg.replyTo)[0]
- if (r) payload.reply_to = toMsAddress(r)
- }
- if (msg.text) payload.text = msg.text
- if (msg.html) payload.html = msg.html
- if (msg.tags?.length) payload.tags = msg.tags.map((t) => t.name)
- if (msg.headers)
- payload.headers = Object.entries(msg.headers).map(([name, value]) => ({ name, value }))
- if (msg.scheduledAt) {
- const d = msg.scheduledAt instanceof Date ? msg.scheduledAt : new Date(msg.scheduledAt)
- 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
-}
-
-function toMsAddress(a: EmailAddress): Record {
- return a.name ? { email: a.email, name: a.name } : { email: a.email }
-}
-
-function toMsAttachment(a: Attachment): Record {
- const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content)
- const out: Record = {
- filename: a.filename,
- content,
- disposition: a.disposition ?? "attachment",
- }
- if (a.cid) out.id = a.cid
- return out
-}
-
-function bytesToBase64(bytes: Uint8Array): string {
- const g = globalThis as {
- Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } }
- }
- if (g.Buffer) return g.Buffer.from(bytes).toString("base64")
- let binary = ""
- for (const byte of bytes) binary += String.fromCharCode(byte)
- return btoa(binary)
-}
diff --git a/src/driver/mailgun.ts b/src/driver/mailgun.ts
deleted file mode 100644
index 683a955..0000000
--- a/src/driver/mailgun.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import type { DriverFactory, EmailMessage, EmailResult, Result } from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { formatAddress, normalizeAddresses } from "../_normalize.ts"
-import { createError, createRequiredError, toEmailError } from "../errors.ts"
-
-export interface MailgunDriverOptions {
- apiKey: string
- domain: string
- /** Region preset — `"us"` (default) or `"eu"`. Maps to the matching
- * Mailgun API endpoint. Overridden by `endpoint`. */
- region?: "us" | "eu"
- /** Explicit API endpoint override (highest precedence). */
- endpoint?: string
- fetch?: typeof fetch
-}
-
-const DRIVER = "mailgun"
-
-const mailgun: DriverFactory = defineDriver(
- (options) => {
- if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey")
- if (!options?.domain) throw createRequiredError(DRIVER, "domain")
- const endpoint =
- options.endpoint ??
- (options.region === "eu" ? "https://api.eu.mailgun.net" : "https://api.mailgun.net")
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- return {
- name: DRIVER,
- options,
- flags: {
- html: true,
- text: true,
- attachments: true,
- tagging: true,
- tracking: true,
- replyTo: true,
- customHeaders: true,
- scheduling: true,
- },
-
- async isAvailable() {
- return Boolean(options.apiKey && options.domain)
- },
-
- async send(msg) {
- const form = buildMailgunForm(msg)
- return mailgunRequest(
- fetchImpl,
- `${endpoint}/v3/${options.domain}/messages`,
- options.apiKey,
- form,
- )
- },
- }
- },
-)
-
-export default mailgun
-
-function buildMailgunForm(msg: EmailMessage): FormData {
- const from = normalizeAddresses(msg.from)[0]
- if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required")
-
- const form = new FormData()
- form.append("from", formatAddress(from))
- for (const t of normalizeAddresses(msg.to)) form.append("to", formatAddress(t))
- for (const c of normalizeAddresses(msg.cc)) form.append("cc", formatAddress(c))
- for (const b of normalizeAddresses(msg.bcc)) form.append("bcc", formatAddress(b))
- form.append("subject", msg.subject)
- if (msg.text) form.append("text", msg.text)
- if (msg.html) form.append("html", msg.html)
- for (const r of normalizeAddresses(msg.replyTo)) form.append("h:Reply-To", formatAddress(r))
- if (msg.headers) {
- for (const [k, v] of Object.entries(msg.headers)) form.append(`h:${k}`, v)
- }
- if (msg.tags?.length) {
- for (const t of msg.tags) form.append("o:tag", t.name)
- }
- if (msg.scheduledAt) {
- const d = msg.scheduledAt instanceof Date ? msg.scheduledAt : new Date(msg.scheduledAt)
- form.append("o:deliverytime", d.toUTCString())
- }
- if (msg.attachments?.length) {
- for (const a of msg.attachments) {
- const blob = new Blob(
- [typeof a.content === "string" ? a.content : (a.content as unknown as BlobPart)],
- { type: a.contentType ?? "application/octet-stream" },
- )
- 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
-}
-
-async function mailgunRequest(
- fetchImpl: typeof fetch,
- url: string,
- apiKey: string,
- form: FormData,
-): Promise> {
- const auth = `Basic ${basicAuth("api", apiKey)}`
- let res: Response
- try {
- res = await fetchImpl(url, {
- method: "POST",
- headers: { authorization: auth, accept: "application/json" },
- body: form,
- })
- } catch (err) {
- return { data: null, error: toEmailError(DRIVER, err) }
- }
- const text = await res.text()
- const parsed = text ? safeJson(text) : null
- if (!res.ok) {
- const body = (parsed ?? {}) as { message?: string }
- const code =
- res.status === 401 || res.status === 403
- ? "AUTH"
- : res.status === 429
- ? "RATE_LIMIT"
- : res.status >= 500
- ? "NETWORK"
- : "PROVIDER"
- return {
- data: null,
- error: createError(DRIVER, code, body.message ?? `HTTP ${res.status}`, {
- status: res.status,
- retryable: code === "RATE_LIMIT" || code === "NETWORK",
- cause: { headers: res.headers, body: parsed ?? text },
- }),
- }
- }
- const body = (parsed ?? {}) as { id?: string; message?: string }
- const id = body.id ?? `mg_${Date.now().toString(36)}`
- return {
- data: {
- id: id.replace(/^<|>$/g, ""),
- driver: DRIVER,
- at: new Date(),
- provider: body as Record,
- },
- error: null,
- }
-}
-
-function basicAuth(user: string, pass: string): string {
- const raw = `${user}:${pass}`
- const g = globalThis as { Buffer?: { from: (v: string) => { toString: (e: string) => string } } }
- if (g.Buffer) return g.Buffer.from(raw).toString("base64")
- return btoa(raw)
-}
-
-function safeJson(text: string): unknown {
- try {
- return JSON.parse(text)
- } catch {
- return null
- }
-}
diff --git a/src/driver/mailtrap.ts b/src/driver/mailtrap.ts
deleted file mode 100644
index caa1dd8..0000000
--- a/src/driver/mailtrap.ts
+++ /dev/null
@@ -1,391 +0,0 @@
-import type {
- Attachment,
- DriverFactory,
- EmailAddress,
- EmailMessage,
- EmailResult,
- EmailTag,
- Result,
-} from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { normalizeAddresses } from "../_normalize.ts"
-import { createError, createRequiredError } from "../errors.ts"
-import { httpJson } from "./_http.ts"
-
-/** Mailtrap driver — Email API sending and Email Sandbox testing
- * with the same API token. Mirrors the official SDK env pattern:
- * \`MAILTRAP_API_KEY\` → \`apiKey\`, \`MAILTRAP_USE_SANDBOX\` → \`sandbox\`,
- * \`MAILTRAP_INBOX_ID\` → \`inboxId\`. */
-export interface MailtrapDriverOptions {
- apiKey: string
- /** Email API base. Default \`https://send.api.mailtrap.io\`. */
- endpoint?: string
- fetch?: typeof fetch
- /** Used when no \`tags\` entry has \`name: "category"\`. */
- defaultCategory?: string
- /** Mailtrap edge protection may block requests without a User-Agent. */
- userAgent?: string
- /** Default sandbox mode when \`msg.sandbox\` is unset. */
- sandbox?: boolean
- /** Sandbox inbox ID (from mailtrap.io/sandboxes/{id}). Required for sandbox sends. */
- inboxId?: number | string
- /** Sandbox API base. Default \`https://sandbox.api.mailtrap.io\`. */
- sandboxEndpoint?: string
-}
-
-interface MailtrapSendSuccess {
- success?: boolean
- message_ids?: string[]
- errors?: string[]
-}
-
-interface MailtrapBatchItemResponse {
- success?: boolean
- message_ids?: string[]
- errors?: string[]
-}
-
-interface MailtrapBatchSuccess {
- success?: boolean
- responses?: MailtrapBatchItemResponse[]
- errors?: string[]
-}
-
-const DRIVER = "mailtrap"
-const DEFAULT_ENDPOINT = "https://send.api.mailtrap.io"
-const DEFAULT_SANDBOX_ENDPOINT = "https://sandbox.api.mailtrap.io"
-
-const mailtrap: DriverFactory = defineDriver(
- (options) => {
- if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey")
-
- const sendEndpoint = options.endpoint ?? DEFAULT_ENDPOINT
- const sandboxEndpoint = options.sandboxEndpoint ?? DEFAULT_SANDBOX_ENDPOINT
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- const defaultCategory = options.defaultCategory ?? "transactional"
- const userAgent = options.userAgent ?? "unemail/mailtrap"
-
- const mailtrapHeaders = (): Record => ({
- "api-token": options.apiKey,
- "user-agent": userAgent,
- })
-
- return {
- name: DRIVER,
- options,
- flags: {
- attachments: true,
- html: true,
- text: true,
- batch: true,
- templates: true,
- tagging: true,
- replyTo: true,
- customHeaders: true,
- },
-
- async isAvailable() {
- return Boolean(options.apiKey)
- },
-
- async send(msg) {
- const unsupported = rejectUnsupported(msg)
- if (unsupported) return unsupported
-
- const useSandbox = resolveSandboxMode(msg, options)
- const inboxErr = requireInboxIdForSandbox(useSandbox, options)
- if (inboxErr) return inboxErr as Result
-
- const payload = buildPayload(msg, defaultCategory)
- const res = await httpJson({
- fetch: fetchImpl,
- driver: DRIVER,
- url: resolveApiUrl(useSandbox, "send", options, sendEndpoint, sandboxEndpoint),
- headers: mailtrapHeaders(),
- body: payload,
- classifyError: classifyMailtrapError,
- })
- if (res.error) return res as Result
- return parseSendSuccess(res.data)
- },
-
- async sendBatch(msgs) {
- if (msgs.length === 0) return { data: [], error: null }
- for (const msg of msgs) {
- const unsupported = rejectUnsupported(msg)
- if (unsupported) return unsupported as Result>
- }
-
- const mixed = validateBatchSandboxModes(msgs, options)
- if (mixed) return mixed as Result>
-
- const useSandbox = resolveSandboxMode(msgs[0]!, options)
- const inboxErr = requireInboxIdForSandbox(useSandbox, options)
- if (inboxErr) return inboxErr as Result>
-
- const payload = {
- requests: msgs.map((m) => buildPayload(m, defaultCategory)),
- }
- const res = await httpJson({
- fetch: fetchImpl,
- driver: DRIVER,
- url: resolveApiUrl(useSandbox, "batch", options, sendEndpoint, sandboxEndpoint),
- headers: mailtrapHeaders(),
- body: payload,
- classifyError: classifyMailtrapError,
- })
- if (res.error) return res as never
-
- const body = (res.data ?? {}) as MailtrapBatchSuccess
- if (body.success === false) {
- return {
- data: null,
- error: createError(
- DRIVER,
- "PROVIDER",
- formatErrors(body.errors) ?? "batch request failed",
- { cause: body },
- ),
- }
- }
-
- const responses = body.responses ?? []
- for (let i = 0; i < responses.length; i++) {
- const item = responses[i]
- if (item?.success === false) {
- return {
- data: null,
- error: createError(
- DRIVER,
- "PROVIDER",
- formatErrors(item.errors) || `batch item ${i} failed`,
- { cause: item },
- ),
- }
- }
- }
-
- const results: EmailResult[] = responses.map((item, i) => ({
- id: item?.message_ids?.[0] ?? `mailtrap_${Date.now().toString(36)}_${i}`,
- driver: DRIVER,
- at: new Date(),
- provider: item as unknown as Record,
- }))
- return { data: results, error: null }
- },
- }
- },
-)
-
-export default mailtrap
-
-function resolveSandboxMode(msg: EmailMessage, options: MailtrapDriverOptions): boolean {
- return msg.sandbox ?? options.sandbox ?? false
-}
-
-function isValidInboxId(inboxId: number | string | undefined): boolean {
- if (inboxId === undefined || inboxId === null) return false
- if (typeof inboxId === "number") return true
- return String(inboxId).trim().length > 0
-}
-
-function requireInboxIdForSandbox(
- useSandbox: boolean,
- options: MailtrapDriverOptions,
-): Result | null {
- if (!useSandbox) return null
- if (!isValidInboxId(options.inboxId)) {
- return {
- data: null,
- error: createError(
- DRIVER,
- "INVALID_OPTIONS",
- "`inboxId` is required for Mailtrap Email Sandbox",
- { retryable: false },
- ),
- }
- }
- return null
-}
-
-function resolveApiUrl(
- useSandbox: boolean,
- kind: "send" | "batch",
- options: MailtrapDriverOptions,
- sendEndpoint: string,
- sandboxEndpoint: string,
-): string {
- const host = useSandbox ? sandboxEndpoint : sendEndpoint
- const suffix = useSandbox && isValidInboxId(options.inboxId) ? `/${options.inboxId}` : ""
- return `${host}/api/${kind}${suffix}`
-}
-
-function validateBatchSandboxModes(
- msgs: ReadonlyArray,
- options: MailtrapDriverOptions,
-): Result | null {
- if (msgs.length === 0) return null
- const first = resolveSandboxMode(msgs[0]!, options)
- for (let i = 1; i < msgs.length; i++) {
- if (resolveSandboxMode(msgs[i]!, options) !== first) {
- return {
- data: null,
- error: createError(
- DRIVER,
- "INVALID_OPTIONS",
- "mixed Email Sandbox and Email API messages in one batch",
- { retryable: false },
- ),
- }
- }
- }
- return null
-}
-
-function rejectUnsupported(msg: EmailMessage): Result | null {
- if (msg.scheduledAt) {
- return {
- data: null,
- error: createError(DRIVER, "UNSUPPORTED", "scheduling is not supported by Mailtrap", {
- retryable: false,
- }),
- }
- }
- return null
-}
-
-function parseSendSuccess(data: unknown): Result {
- const body = (data ?? {}) as MailtrapSendSuccess
- if (body.success === false) {
- return {
- data: null,
- error: createError(DRIVER, "PROVIDER", formatErrors(body.errors) ?? "send failed", {
- cause: body,
- }),
- }
- }
- const id = body.message_ids?.[0] ?? `mailtrap_${Date.now().toString(36)}`
- return {
- data: {
- id,
- driver: DRIVER,
- at: new Date(),
- provider: body as Record,
- },
- error: null,
- }
-}
-
-function classifyMailtrapError(
- status: number,
- body: unknown,
-): {
- code: "AUTH" | "RATE_LIMIT" | "NETWORK" | "PROVIDER"
- retryable?: boolean
- message?: string
-} | null {
- const record = body && typeof body === "object" ? (body as Record) : null
- const errors = record?.errors
- const message =
- formatErrors(Array.isArray(errors) ? (errors as string[]) : undefined) ??
- (typeof record?.message === "string" ? record.message : null)
-
- if (status === 401 || status === 403) {
- return { code: "AUTH", retryable: false, message: message ?? undefined }
- }
- if (status === 429) {
- return { code: "RATE_LIMIT", retryable: true, message: message ?? undefined }
- }
- if (status >= 500) {
- return { code: "NETWORK", retryable: true, message: message ?? undefined }
- }
- if (message) return { code: "PROVIDER", retryable: false, message }
- return null
-}
-
-function formatErrors(errors: string[] | undefined): string | null {
- if (!errors?.length) return null
- return errors.join("; ")
-}
-
-function buildPayload(msg: EmailMessage, defaultCategory: string): Record {
- const from = normalizeAddresses(msg.from)[0]
- if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required")
-
- const payload: Record = {
- from: toMailtrapAddress(from),
- to: normalizeAddresses(msg.to).map(toMailtrapAddress),
- subject: msg.subject,
- }
- if (msg.cc) payload.cc = normalizeAddresses(msg.cc).map(toMailtrapAddress)
- if (msg.bcc) payload.bcc = normalizeAddresses(msg.bcc).map(toMailtrapAddress)
- if (msg.replyTo) {
- const r = normalizeAddresses(msg.replyTo)[0]
- if (r) payload.reply_to = toMailtrapAddress(r)
- }
- if (msg.text) payload.text = msg.text
- if (msg.html) payload.html = msg.html
- if (msg.headers) payload.headers = msg.headers
- if (msg.attachments?.length) payload.attachments = msg.attachments.map(toMailtrapAttachment)
-
- const customVars: Record = {}
- if (msg.metadata) {
- for (const [k, v] of Object.entries(msg.metadata)) customVars[k] = String(v)
- }
- if (msg.tags?.length) {
- for (const tag of msg.tags) {
- if (tag.name === "category") continue
- customVars[`tag_${tag.name}`] = tag.value ?? ""
- }
- }
- if (Object.keys(customVars).length) payload.custom_variables = customVars
-
- payload.category = resolveCategory(msg.tags, defaultCategory)
-
- if (msg.template) {
- if (msg.template.id) payload.template_uuid = msg.template.id
- if (msg.template.variables) payload.template_variables = { ...msg.template.variables }
- }
-
- if (!msg.template && !msg.text && !msg.html) {
- throw createError(DRIVER, "INVALID_OPTIONS", "`text`, `html`, or `template` is required")
- }
-
- return payload
-}
-
-function resolveCategory(tags: ReadonlyArray | undefined, fallback: string): string {
- const cat = tags?.find((t) => t.name === "category")
- if (cat?.value) return cat.value
- if (cat?.name === "category" && !cat.value) return fallback
- return fallback
-}
-
-function toMailtrapAddress(a: EmailAddress): Record {
- return a.name ? { email: a.email, name: a.name } : { email: a.email }
-}
-
-function toMailtrapAttachment(a: Attachment): Record {
- const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content)
- const out: Record = {
- content,
- filename: a.filename,
- }
- if (a.contentType) out.type = a.contentType
- if (a.disposition) out.disposition = a.disposition
- if (a.cid) out.content_id = a.cid
- return out
-}
-
-function bytesToBase64(bytes: Uint8Array): string {
- const g = globalThis as {
- Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } }
- }
- if (g.Buffer) return g.Buffer.from(bytes).toString("base64")
- let binary = ""
- for (const byte of bytes) binary += String.fromCharCode(byte)
- return btoa(binary)
-}
diff --git a/src/driver/mock.ts b/src/driver/mock.ts
deleted file mode 100644
index 1f247a2..0000000
--- a/src/driver/mock.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import type { DriverFactory, EmailMessage, EmailResult } from "../types.ts"
-import { defineDriver } from "../_define.ts"
-
-/** Options for the `mock` driver — a drop-in replacement used in tests that
- * records every sent message instead of hitting the network. */
-export interface MockDriverOptions {
- /** When true, drivers simulate a rejection on every send — useful for
- * exercising `onError` middleware. */
- fail?: boolean
- /** Inspect or mutate the captured inbox. Exposed via `driver.inbox` too. */
- inbox?: EmailMessage[]
-}
-
-/** Driver with an injected `inbox` you can assert against. Also returned as
- * `driver.getInstance()`. */
-const mock: DriverFactory = defineDriver<
- MockDriverOptions,
- EmailMessage[]
->((options) => {
- const inbox: EmailMessage[] = options?.inbox ?? []
- let counter = 0
-
- return {
- name: "mock",
- options,
- flags: {
- attachments: true,
- html: true,
- text: true,
- batch: true,
- replyTo: true,
- customHeaders: true,
- tagging: true,
- idempotency: true,
- scheduling: true,
- },
- getInstance: () => inbox,
- async isAvailable() {
- return !options?.fail
- },
- send(msg, ctx) {
- if (options?.fail) {
- return {
- data: null,
- error: new (class extends Error {})(`[unemail] [mock] configured to fail`) as never,
- }
- }
- inbox.push(msg)
- const result: EmailResult = {
- id: `mock_${++counter}_${Date.now()}`,
- driver: "mock",
- stream: ctx.stream,
- at: new Date(),
- }
- return { data: result, error: null }
- },
- async sendBatch(msgs, ctx) {
- const out: EmailResult[] = []
- for (const msg of msgs) {
- const r = await this.send(msg, ctx)
- if (r.error) return r as never
- out.push(r.data!)
- }
- return { data: out, error: null }
- },
- }
-})
-
-export default mock
diff --git a/src/driver/postmark.ts b/src/driver/postmark.ts
deleted file mode 100644
index 02d6c51..0000000
--- a/src/driver/postmark.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import type {
- Attachment,
- DriverFactory,
- EmailAddress,
- EmailMessage,
- EmailResult,
- Result,
-} from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { formatAddress, normalizeAddresses } from "../_normalize.ts"
-import { createError, createRequiredError, toEmailError } from "../errors.ts"
-
-/** Options for the Postmark driver. Postmark is the only mainstream
- * provider with native transactional vs broadcast stream isolation —
- * route by `msg.stream`, or set `messageStream` as a driver-level default. */
-export interface PostmarkDriverOptions {
- /** Server API token (the per-server token, not the account token). */
- token: string
- /** Default \`MessageStream\` if the message doesn't specify \`stream\`. */
- messageStream?: string
- /** Override for self-hosted gateways or test stubs. */
- endpoint?: string
- /** Injected fetch — defaults to global \`fetch\`. */
- fetch?: typeof fetch
-}
-
-const DRIVER = "postmark"
-const DEFAULT_ENDPOINT = "https://api.postmarkapp.com"
-
-const postmark: DriverFactory = defineDriver(
- (options) => {
- if (!options?.token) throw createRequiredError(DRIVER, "token")
- const endpoint = options.endpoint ?? DEFAULT_ENDPOINT
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- return {
- name: DRIVER,
- options,
- flags: {
- attachments: true,
- html: true,
- text: true,
- batch: true,
- tracking: true,
- templates: true,
- tagging: true,
- replyTo: true,
- customHeaders: true,
- },
-
- async isAvailable() {
- return Boolean(options.token)
- },
-
- async send(msg) {
- const payload = buildPayload(msg, options.messageStream)
- const path = msg.template ? "/email/withTemplate" : "/email"
- const res = await request(fetchImpl, endpoint, path, "POST", options.token, payload)
- if (res.error) return res as Result
- const body = res.data as PostmarkSendResponse
- const result: EmailResult = {
- id: body.MessageID,
- driver: DRIVER,
- stream: msg.stream ?? options.messageStream,
- at: parsePostmarkDate(body.SubmittedAt) ?? new Date(),
- provider: body as unknown as Record,
- }
- return { data: result, error: null }
- },
-
- async sendBatch(msgs) {
- const payload = msgs.map((m) => buildPayload(m, options.messageStream))
- 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 responses = res.data as PostmarkSendResponse[]
- const failures = responses.filter((entry) => (entry.ErrorCode ?? 0) !== 0)
- if (failures.length > 0) {
- const first = failures[0]!
- return {
- data: null,
- error: createError(
- DRIVER,
- "PROVIDER",
- first.Message ?? `batch partial failure (${failures.length}/${responses.length})`,
- {
- status: first.ErrorCode,
- cause: responses,
- retryable: false,
- },
- ),
- }
- }
- const results: EmailResult[] = responses.map((entry, i) => ({
- id: entry.MessageID,
- driver: DRIVER,
- stream: msgs[i]?.stream ?? options.messageStream,
- at: parsePostmarkDate(entry.SubmittedAt) ?? new Date(),
- provider: entry as unknown as Record,
- }))
- return { data: results, error: null }
- },
- }
- },
-)
-
-export default postmark
-
-interface PostmarkSendResponse {
- MessageID: string
- SubmittedAt?: string
- To?: string
- ErrorCode?: number
- Message?: string
-}
-
-function buildPayload(msg: EmailMessage, defaultStream?: string): Record {
- const from = normalizeAddresses(msg.from)[0]
- if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required")
-
- const body: Record = {
- From: formatAddress(from),
- To: addressList(msg.to),
- Subject: msg.subject,
- }
- if (msg.cc) body.Cc = addressList(msg.cc)
- if (msg.bcc) body.Bcc = addressList(msg.bcc)
- if (msg.replyTo) body.ReplyTo = addressList(msg.replyTo)
- if (msg.text) body.TextBody = msg.text
- if (msg.html) body.HtmlBody = msg.html
- if (msg.headers)
- body.Headers = Object.entries(msg.headers).map(([Name, Value]) => ({ Name, 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
-}
-
-function addressList(input: EmailMessage["to"]): string {
- return normalizeAddresses(input)
- .map((a: EmailAddress) => formatAddress(a))
- .join(", ")
-}
-
-function toPostmarkAttachment(a: Attachment): Record {
- const content = typeof a.content === "string" ? a.content : bytesToBase64(a.content)
- const out: Record = {
- Name: a.filename,
- Content: content,
- ContentType: a.contentType ?? "application/octet-stream",
- }
- if (a.cid) out.ContentID = `cid:${a.cid}`
- return out
-}
-
-function bytesToBase64(bytes: Uint8Array): string {
- const g = globalThis as {
- Buffer?: { from: (b: Uint8Array) => { toString: (e: string) => string } }
- }
- if (g.Buffer) return g.Buffer.from(bytes).toString("base64")
- let binary = ""
- for (const byte of bytes) binary += String.fromCharCode(byte)
- return btoa(binary)
-}
-
-function parsePostmarkDate(value?: string): Date | null {
- if (!value) return null
- const d = new Date(value)
- return Number.isNaN(d.getTime()) ? null : d
-}
-
-async function request(
- fetchImpl: typeof fetch,
- endpoint: string,
- path: string,
- method: string,
- token: string,
- body: unknown,
-): Promise> {
- let res: Response
- try {
- res = await fetchImpl(`${endpoint}${path}`, {
- method,
- headers: {
- accept: "application/json",
- "content-type": "application/json",
- "x-postmark-server-token": token,
- },
- body: JSON.stringify(body),
- })
- } catch (err) {
- return { data: null, error: toEmailError(DRIVER, err) }
- }
-
- const text = await res.text()
- const parsed = text ? safeJson(text) : null
-
- if (!res.ok) {
- const apiError = (parsed ?? {}) as { Message?: string; ErrorCode?: number }
- const code =
- res.status === 401 || res.status === 403 || apiError.ErrorCode === 10
- ? "AUTH"
- : res.status === 429
- ? "RATE_LIMIT"
- : res.status >= 500
- ? "NETWORK"
- : "PROVIDER"
- return {
- data: null,
- error: createError(DRIVER, code, apiError.Message ?? `HTTP ${res.status}`, {
- status: res.status,
- cause: { headers: res.headers, body: parsed ?? text },
- retryable: code === "RATE_LIMIT" || code === "NETWORK",
- }),
- }
- }
-
- return { data: parsed, error: null }
-}
-
-function safeJson(text: string): unknown {
- try {
- return JSON.parse(text)
- } catch {
- return null
- }
-}
diff --git a/src/driver/resend.ts b/src/driver/resend.ts
deleted file mode 100644
index bdac3d2..0000000
--- a/src/driver/resend.ts
+++ /dev/null
@@ -1,287 +0,0 @@
-import type {
- Attachment,
- DriverFactory,
- EmailAddress,
- EmailMessage,
- EmailResult,
- EmailTag,
- Result,
- SendStatus,
- SendStatusState,
-} from "../types.ts"
-import { defineDriver } from "../_define.ts"
-import { formatAddress, normalizeAddresses } from "../_normalize.ts"
-import { createError, createRequiredError, toEmailError } from "../errors.ts"
-
-/** Options for the Resend driver. Keep the surface minimal — everything
- * Resend-specific (tags, scheduling, idempotency) is carried on the
- * `EmailMessage` itself. */
-export interface ResendDriverOptions {
- apiKey: string
- /** Override for self-hosted gateways or test stubs. */
- endpoint?: string
- /** Fetch impl — useful for tests. Defaults to the global `fetch`. */
- fetch?: typeof fetch
-}
-
-interface ResendApiSuccess {
- id: string
- [k: string]: unknown
-}
-
-interface ResendApiError {
- name?: string
- message?: string
- statusCode?: number
-}
-
-const DRIVER = "resend"
-const DEFAULT_ENDPOINT = "https://api.resend.com"
-
-const resend: DriverFactory = defineDriver((options) => {
- if (!options?.apiKey) throw createRequiredError(DRIVER, "apiKey")
- if (!options.apiKey.startsWith("re_"))
- throw createError(DRIVER, "INVALID_OPTIONS", "apiKey must start with 're_'")
-
- const endpoint = options.endpoint ?? DEFAULT_ENDPOINT
- const fetchImpl = options.fetch ?? globalThis.fetch
- if (typeof fetchImpl !== "function")
- throw createError(DRIVER, "INVALID_OPTIONS", "fetch is unavailable; pass `fetch` explicitly")
-
- return {
- name: DRIVER,
- options,
- flags: {
- attachments: true,
- html: true,
- text: true,
- batch: true,
- scheduling: true,
- idempotency: true,
- tagging: true,
- replyTo: true,
- customHeaders: true,
- cancelable: true,
- retrievable: true,
- },
-
- async isAvailable() {
- return Boolean(options.apiKey)
- },
-
- async send(msg) {
- const payload = buildPayload(msg)
- const res = await request(fetchImpl, endpoint, "/emails", "POST", options.apiKey, payload, {
- idempotencyKey: msg.idempotencyKey,
- })
- if (res.error) return res as Result
- const data = res.data as ResendApiSuccess
- return {
- data: {
- id: data.id,
- driver: DRIVER,
- at: new Date(),
- provider: data,
- },
- error: null,
- }
- },
-
- async cancel(id) {
- const res = await request(
- fetchImpl,
- endpoint,
- `/emails/${id}/cancel`,
- "POST",
- options.apiKey,
- {},
- )
- if (res.error) return res as Result
- 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(
- fetchImpl,
- endpoint,
- "/emails/batch",
- "POST",
- options.apiKey,
- payload,
- )
- if (res.error) return res as never
- const body = (res.data ?? {}) as { data?: Array<{ id: string }> }
- const items = body.data ?? []
- return {
- data: items.map((entry) => ({
- id: entry.id,
- driver: DRIVER,
- at: new Date(),
- provider: entry,
- })),
- error: null,
- }
- },
- }
-})
-
-export default resend
-
-function buildPayload(msg: EmailMessage): Record {
- const from = normalizeAddresses(msg.from)[0]
- if (!from) throw createError(DRIVER, "INVALID_OPTIONS", "`from` is required")
-
- const body: Record = {
- from: formatAddress(from),
- to: addressList(msg.to),
- subject: msg.subject,
- }
- if (msg.cc) body.cc = addressList(msg.cc)
- if (msg.bcc) body.bcc = addressList(msg.bcc)
- if (msg.replyTo) body.reply_to = addressList(msg.replyTo)
- if (msg.text) body.text = msg.text
- 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 =
- msg.scheduledAt instanceof Date ? msg.scheduledAt.toISOString() : msg.scheduledAt
- }
- return body
-}
-
-function addressList(input: EmailMessage["to"]): string[] {
- return normalizeAddresses(input).map((a: EmailAddress) => formatAddress(a))
-}
-
-function toResendAttachment(a: Attachment): Record