Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ const { data, error } = await email.send({
html: "<p>Attached.</p>",
preheader: "Invoice #1042 · due in 14 days",
attachments: [{ filename: "invoice.pdf", content: bytes, contentType: "application/pdf" }],
// A string attachment is text unless you say `encoding: "base64"` —
// the library will not guess, because "test" is valid as both.
tags: [{ name: "campaign", value: "billing" }],
})
```
Expand Down Expand Up @@ -215,6 +217,10 @@ on a name:
if (email.driver.features?.scheduling) await email.send({ ...msg, scheduledAt })
```

The core reads the same declaration. Asking a driver for something it has
said it cannot do returns `UNSUPPORTED` instead of sending a message with
the important part missing.

### Failover

```ts
Expand Down
30 changes: 25 additions & 5 deletions docs/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ resend({ apiKey: process.env.RESEND_API_KEY!, endpoint?, fetch?, timeoutMs? })

Native batch (`/emails/batch`), scheduling, `cancel()`, `retrieve()`, and
provider-side idempotency via the `Idempotency-Key` header when the message
carries an `idempotencyKey`.
carries an `idempotencyKey`. A batch presents one key derived from its
messages' keys — stable for the same batch, so a retry is recognised as a
repeat rather than duplicating every message in it.

Batches are chunked at Resend's cap of 100 messages per request. Resend's
batch endpoint does not accept attachments, so a batch carrying one is sent
message by message instead; the caller sees no difference.

Resend has no metadata field, so `message.metadata` is sent as
`X-Metadata-*` headers — which is what comes back on its webhook events.
Expand All @@ -24,8 +30,11 @@ postmark({ token, messageStream?, endpoint?, fetch?, timeoutMs? })
Pass the per-server token, not the account token.

Postmark reports per-message failures inside a `200` batch response; those
become individual failed results rather than a failed batch. It accepts one
`Tag` per message, so extra tags carry as metadata instead of being dropped.
become individual failed results rather than a failed batch. Batches are
chunked at its cap of 500.

Its `Tag` is a single string with no value, so the first tag's name goes
there and every tag — the first included — is also carried as `Metadata`.

Templated and plain messages use different endpoints and cannot be mixed in
one batch — a mixed batch fails with `INVALID_OPTIONS` before any request
Expand Down Expand Up @@ -77,8 +86,13 @@ dependencies. `port` defaults to 465 when `secure`, 587 otherwise.
With `pool: true` connections are reused; one that fails mid-transaction is
discarded rather than returned in an unknown protocol state.

DKIM signs the assembled document. Pass a function to select a key per
message for multi-tenant sending:
`EHLO` announces the machine's hostname when it is fully qualified, and
`localhost.localdomain` otherwise. Override it with `localName`.

DKIM signs the assembled document, and accepts an RSA key in either PKCS8
(`BEGIN PRIVATE KEY`) or PKCS1 (`BEGIN RSA PRIVATE KEY`, what
`openssl genrsa` writes); Ed25519 must be PKCS8. Pass a function to select a
key per message for multi-tenant sending:

```ts
smtp({ host, dkim: (msg) => keyFor(msg.from.email.split("@")[1]!) })
Expand Down Expand Up @@ -145,3 +159,9 @@ Read it at runtime rather than hard-coding it:
```ts
if (email.driver.features?.scheduling) await email.send({ ...msg, scheduledAt })
```

The core reads it too. A message asking for something the driver has said it
cannot do — a `template` on a driver without templates, a `scheduledAt` on
one without scheduling — comes back `UNSUPPORTED` rather than being sent
without the part that mattered. Only that message fails; the rest of a batch
goes out. A driver that declares no `features` at all is not second-guessed.
71 changes: 57 additions & 14 deletions src/core/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type {
SendHandler,
} from "./types.ts"
import { err, ok } from "./result.ts"
import { toEmailError } from "./error.ts"
import type { EmailError } from "./error.ts"
import { createUnsupportedError, toEmailError } from "./error.ts"

/**
* Declare a driver. Purely a typing helper — it makes `TOpts` required
Expand Down Expand Up @@ -109,19 +110,10 @@ export function wrap<TInstance>(
* returns exactly one result per input.
*/
export function driverHandler(driver: EmailDriver): SendHandler {
return async (msgs, ctx) => {
if (msgs.length === 0) return []

// Before choosing a branch: the native-batch path never reaches the
// per-message check below, so an already-aborted send would otherwise
// go out anyway.
if (ctx.signal?.aborted) {
const cancelled = err<EmailResult>(
toEmailError(driver.name, ctx.signal.reason ?? new Error("aborted")),
)
return msgs.map(() => cancelled)
}

async function deliver(
msgs: readonly NormalizedMessage[],
ctx: SendContext,
): Promise<readonly Result<EmailResult>[]> {
if (msgs.length > 1 && driver.sendBatch) {
let results: readonly Result<EmailResult>[]
try {
Expand Down Expand Up @@ -158,6 +150,57 @@ export function driverHandler(driver: EmailDriver): SendHandler {
}
return out
}

return async (msgs, ctx) => {
if (msgs.length === 0) return []

// Before choosing a branch: the native-batch path never reaches the
// per-message check below, so an already-aborted send would otherwise
// go out anyway.
if (ctx.signal?.aborted) {
const cancelled = err<EmailResult>(
toEmailError(driver.name, ctx.signal.reason ?? new Error("aborted")),
)
return msgs.map(() => cancelled)
}

// A message asking for something the driver has said it cannot do is
// refused rather than quietly sent wrong: a template-only message on a
// driver without templates has no body at all, and `scheduledAt` on one
// without scheduling goes out immediately. Only that message fails —
// the rest of the batch is unaffected, as with any other failure.
const unsupported = msgs.map((msg) => unsupportedFor(driver, msg))
if (!unsupported.some(Boolean)) return deliver(msgs, ctx)

const sendable = msgs.filter((_, index) => !unsupported[index])
const produced = sendable.length > 0 ? await deliver(sendable, ctx) : []
let slot = 0
return msgs.map((_, index) => {
const refusal = unsupported[index]
return refusal ? err<EmailResult>(refusal) : produced[slot++]!
})
}
}

/** What this driver cannot do with this message, if anything. Only
* features whose absence changes what the recipient receives are checked;
* a driver that ignores `tags` still sends the right mail. */
function unsupportedFor(driver: EmailDriver, msg: NormalizedMessage): EmailError | null {
const features = driver.features
if (!features) return null
if (msg.template && !features.templates) {
return createUnsupportedError(driver.name, "`template`")
}
if (msg.scheduledAt && !features.scheduling) {
return createUnsupportedError(driver.name, "`scheduledAt`")
}
if (msg.sandbox && !features.sandbox) {
return createUnsupportedError(driver.name, "`sandbox`")
}
if (msg.attachments.length > 0 && features.attachments === false) {
return createUnsupportedError(driver.name, "`attachments`")
}
return null
}

/** A middleware that throws must not take the batch down with it — its
Expand Down
11 changes: 9 additions & 2 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@ export type AddressInput = string | EmailAddress | readonly (string | EmailAddre
// Message
// ---------------------------------------------------------------------------

/** A file part. `content` is either raw bytes or a base64 string; set
* `cid` to reference it from HTML as `<img src="cid:...">`. */
/** A file part. Set `cid` to reference it from HTML as
* `<img src="cid:...">`. */
export interface Attachment {
readonly filename: string
/** Raw bytes, or a string. A string is treated as text unless
* `encoding` says otherwise. */
readonly content: string | Uint8Array
/** How to read a string `content`. Default: `utf8`. Say `base64` when
* you already encoded it — the library will not guess, because `"test"`
* is both valid text and valid base64 and guessing wrong corrupts the
* file silently. Ignored when `content` is bytes. */
readonly encoding?: "utf8" | "base64"
readonly contentType?: string
readonly disposition?: "attachment" | "inline"
readonly cid?: string
Expand Down
25 changes: 14 additions & 11 deletions src/drivers/_base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @module
*/

import type { Attachment } from "../core/types.ts"

interface BufferGlobal {
Buffer?: {
from: (
Expand All @@ -32,15 +34,16 @@ export function stringToBase64(value: string): string {
return bytesToBase64(new TextEncoder().encode(value))
}

/** Whether a string is already base64, so an attachment handed to us
* pre-encoded is not encoded twice. */
export function isBase64(value: string): boolean {
const compact = value.replace(/[\r\n]/g, "")
return compact.length > 0 && compact.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(compact)
}

/** Encode attachment content for a provider that wants base64. */
export function attachmentToBase64(content: string | Uint8Array): string {
if (typeof content !== "string") return bytesToBase64(content)
return isBase64(content) ? content : stringToBase64(content)
/**
* Encode attachment content for a provider that wants base64.
*
* A string is treated as text unless the caller says otherwise. Guessing
* is not an option here: `"test"` is both valid text and valid base64, and
* a wrong guess is silent — the recipient's client decodes the text into
* three bytes of noise with no error anywhere. Set `encoding: "base64"` to
* pass content through already encoded.
*/
export function attachmentToBase64(attachment: Attachment): string {
if (typeof attachment.content !== "string") return bytesToBase64(attachment.content)
return attachment.encoding === "base64" ? attachment.content : stringToBase64(attachment.content)
}
31 changes: 31 additions & 0 deletions src/drivers/_chunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/** Split a list into runs of at most `size`. Providers cap how many
* messages one batch request may carry, and exceeding the cap fails the
* whole request — which the driver would then have to report against
* every message in it, including the ones that were fine.
*
* @module
*/
export function chunk<T>(items: readonly T[], size: number): T[][] {
if (items.length <= size) return [[...items]]
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}

/**
* One idempotency key for a whole batch request.
*
* The providers take a single key per request, while a message carries its
* own. Hashing the messages' keys gives a value that is stable for the same
* batch and different for any other, so a retried batch is recognised as a
* repeat instead of duplicating every message in it.
*/
export async function batchIdempotencyKey(
keys: readonly (string | undefined)[],
): Promise<string | undefined> {
if (!keys.some(Boolean)) return undefined
const joined = keys.map((key) => key ?? "\u0000").join("\n")
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(joined))
const hex = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
return `batch_${hex.slice(0, 32)}`
}
17 changes: 16 additions & 1 deletion src/drivers/_fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,22 @@ export async function httpJson(request: HttpRequest): Promise<Result<unknown>> {
return err(toEmailError(request.driver, error))
}

const text = await response.text()
// fetch() resolves once the headers arrive, so the body is still a live
// stream. A proxy timing out or a socket reset here used to throw past
// the driver boundary and be reported as a non-retryable PROVIDER error —
// exactly backwards for a transient failure.
let text: string
try {
text = await response.text()
} catch (error) {
return err(
createError(request.driver, "NETWORK", "connection closed while reading the response", {
status: response.status,
retryable: true,
cause: error,
}),
)
}
const parsed = text ? safeJson(text) : null

if (!response.ok) {
Expand Down
31 changes: 31 additions & 0 deletions src/drivers/_lazy-init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { EmailDriver } from "../core/types.ts"

/**
* Initialize each driver at most once.
*
* The composites reach their legs directly rather than through the core, so
* the core's memoized `ensureInitialized` never sees them and every send
* paid for an `initialize()` again. Same shape as the core's: the promise
* is stored before it is awaited, so concurrent sends share one
* initialization instead of racing past a half-open connection.
*
* @module
*/
export function createInitializer(): (driver: EmailDriver) => Promise<void> {
const pending = new Map<EmailDriver, Promise<void>>()
return (driver) => {
let started = pending.get(driver)
if (started) return started
started = (async () => {
try {
await driver.initialize?.()
} catch (error) {
// A failed initialization is not remembered, so the next send retries.
pending.delete(driver)
throw error
}
})()
pending.set(driver, started)
return started
}
}
Loading