import { RetryStrategy as RetryStrategy_dwxcm3 } from "@workkit/errors";
type ChannelName = string;
type DeliveryStatus = "queued" | "sent" | "delivered" | "read" | "failed" | "bounced" | "skipped" | "duplicate";
type DispatchMode = "live" | "test";
interface ChannelTemplate
{
/**
* The template body. Adapters interpret it differently:
* - `email`: `string` HTML, or a React Email element rendered via the
* optional `@react-email/render` peer.
* - `whatsapp`: typically a template id or full template object.
* - others: as the adapter documents.
*
* Typed as `unknown` so adapters can accept their own narrower shape
* without forcing every other adapter to deal with it.
*/
template?: unknown;
variables?: (payload: P) => Record;
props?: (payload: P) => unknown;
attachments?: (payload: P) => Array<{
filename?: string;
r2Key: string;
type?: string;
}>;
title?: (payload: P) => string;
body?: (payload: P) => string;
deepLink?: (payload: P) => string;
}
interface AdapterSendArgs {
userId: string;
notificationId: string;
channel: ChannelName;
address: string;
template: ChannelTemplate
;
payload: P;
deliveryId: string;
mode: DispatchMode;
}
interface AdapterSendResult {
providerId?: string;
status: Exclude;
error?: string;
/**
* Optional. Whether the failure should be retried. Adapters that catch
* a `WorkkitError` populate this from `WorkkitError.retryable`; other
* adapters can leave it undefined. See ADR-002.
*/
retryable?: boolean;
/**
* Optional. Recommended backoff strategy for the failure. Adapters that
* catch a `WorkkitError` populate this from `WorkkitError.retryStrategy`.
* Consumers / queue policy can opt into reading this field; today it is
* not yet acted on by `createNotifyConsumer` (see ADR-002 follow-ups).
*/
retryStrategy?: RetryStrategy_dwxcm3;
}
interface WebhookEvent {
channel: ChannelName;
providerId: string;
status: Extract;
at: number;
raw?: unknown;
}
interface Adapter {
send(args: AdapterSendArgs
): Promise;
parseWebhook?(req: Request): Promise;
verifySignature?(req: Request, secret: string): Promise;
}
interface AttachmentSpec {
filename: string;
r2Key: string;
type?: string;
}
interface AttachmentBlob {
filename: string;
contentType: string;
bytes: Uint8Array;
}
interface R2BucketLike {
get(key: string): Promise<{
arrayBuffer: () => Promise;
httpMetadata?: {
contentType?: string;
};
} | null>;
}
interface AttachmentLoadOptions {
maxTotalBytes?: number;
concurrency?: number;
}
declare function loadAttachments(bucket: R2BucketLike, specs: ReadonlyArray, options?: AttachmentLoadOptions): Promise;
/** A single attachment passed from the adapter to the provider — raw bytes. */
interface EmailAttachmentWire {
readonly filename: string;
readonly content: Uint8Array;
readonly contentType: string;
}
/** Arguments the adapter passes to `provider.send` after template rendering + attachment loading. */
interface EmailProviderSendArgs {
readonly to: string;
readonly subject: string;
readonly html: string;
readonly text: string;
readonly attachments?: readonly EmailAttachmentWire[];
readonly headers?: Readonly>;
readonly notificationId: string;
readonly deliveryId: string;
}
/**
* Pluggable email provider. `cloudflareEmailProvider` is the default;
* `resendEmailProvider` is the first-class alternative. Mirrors the
* `WaProvider` shape (`adapters/whatsapp/provider.ts`) minus
* `handleVerificationChallenge` (email has no such handshake).
*
* Contract: `send` MUST return `AdapterSendResult` and MUST NOT throw —
* providers that delegate to libraries which throw (e.g., `@workkit/mail`)
* must catch and convert.
*/
interface EmailProvider {
/**
* Provider identifier. Known names: `"cloudflare"`, `"resend"`. The
* `(string & {})` branch preserves autocomplete while letting community
* / follow-up providers (SES, Postmark, etc.) implement this interface
* without editing the core type.
*/
readonly name: "cloudflare" | "resend" | (string & {});
send(args: EmailProviderSendArgs): Promise;
parseWebhook?(req: Request): Promise;
verifySignature?(req: Request, secret: string): Promise;
}
interface EmailPayload {
[key: string]: unknown;
}
interface R2BucketLike2 {
get(key: string): Promise<{
arrayBuffer: () => Promise;
httpMetadata?: {
contentType?: string;
};
} | null>;
}
interface EmailAdapterOptions {
/** Pluggable provider — `cloudflareEmailProvider` (default) or `resendEmailProvider`. */
provider: EmailProvider;
/** R2 bucket for loading attachments referenced by templates. */
bucket?: R2BucketLike2;
attachments?: AttachmentLoadOptions;
/**
* Notification ids that should carry an explicit unsubscribe header
* (`List-Unsubscribe-Post: List-Unsubscribe=One-Click`). Resend does not
* expose a public flag to disable open/click tracking, so the option
* was renamed from `disableTrackingFor` to reflect what it actually
* does. Callers wanting full tracking suppression should configure it
* in the Resend dashboard. The CF provider forwards `X-*` headers
* reliably; `List-Unsubscribe*` survival depends on the MTA path.
*/
markUnsubscribable?: ReadonlyArray;
}
declare function emailAdapter(options: EmailAdapterOptions): Adapter;
interface CloudflareEmailProviderOptions {
/** The `SendEmail` binding from `[[send_email]]` in wrangler.toml */
binding: SendEmail;
from: string;
replyTo?: string | string[];
}
/**
* Default email provider — delegates to `@workkit/mail` which wraps
* Cloudflare's `send_email` binding (transactional email, beta).
*
* `parseWebhook` / `verifySignature` are intentionally omitted: the binding
* exposes no delivery-webhook surface. Bounce handling is a follow-up (see
* `createBounceRoute` in the roadmap — parses inbound DSN via Email Routing).
*
* Requires `@workkit/mail` as an optional peerDependency — imported lazily
* at first `send()` call.
*/
declare function cloudflareEmailProvider(options: CloudflareEmailProviderOptions): EmailProvider;
/**
* Auto opt-out hook — called when a webhook event indicates a hard bounce
* or a complaint. **Always invoked with `notificationId: null`** (global
* opt-out for the channel) because Resend's webhook payload does not carry
* the originating notification id. The hook receives the recipient email
* address as `userId` — your implementation must resolve it to your
* internal id (typically via your user table).
*/
type EmailOptOutHook = (emailAddress: string, channel: "email", notificationId: null, reason: "hard-bounce" | "complaint") => Promise;
interface ResendEmailProviderOptions {
apiKey: string;
from: string;
replyTo?: string | string[];
apiUrl?: string;
webhook?: {
maxAgeMs?: number;
};
autoOptOut?: {
enabled?: boolean;
hook: EmailOptOutHook;
};
}
declare function resendEmailProvider(options: ResendEmailProviderOptions): EmailProvider;
interface SesEmailProviderOptions {
/** AWS region, e.g. `"us-east-1"`. */
readonly region: string;
/** IAM access key id with `ses:SendRawEmail` (or `ses:SendEmail`) permission. */
readonly accessKeyId: string;
/** IAM secret access key paired with `accessKeyId`. */
readonly secretAccessKey: string;
/** Default `From:` address — must be a verified identity in SES. */
readonly from: string;
/** Optional. Override the API host (e.g. for VPC endpoints). */
readonly apiUrl?: string;
}
/**
* Stub for AWS SES. The provider interface is fixed (matches the existing
* `cloudflareEmailProvider` / `resendEmailProvider`), so a real SigV4 +
* `SendRawEmail` implementation can drop in without touching the adapter
* or any caller code.
*
* Community implementation welcome — see
* [#57](https://github.com/beeeku/workkit/issues/57).
*/
declare function sesEmailProvider(_options: SesEmailProviderOptions): EmailProvider;
interface PostmarkEmailProviderOptions {
/** Server token from a Postmark "Server" — narrower scope than the account token. */
readonly serverToken: string;
/** Default `From:` address — must be a verified sender signature in Postmark. */
readonly from: string;
/** Optional. Override the API host (rarely needed). */
readonly apiUrl?: string;
}
/**
* Stub for Postmark. The provider interface is fixed (matches the
* existing `cloudflareEmailProvider` / `resendEmailProvider`), so a real
* `POST /email` + webhook implementation can drop in without touching
* the adapter or any caller code.
*
* Community implementation welcome — see
* [#57](https://github.com/beeeku/workkit/issues/57).
*/
declare function postmarkEmailProvider(_options: PostmarkEmailProviderOptions): EmailProvider;
import { InboundEmail } from "@workkit/mail";
interface BounceRouteOptions {
/**
* Called when a hard bounce DSN is parsed. Same shape as the Resend
* provider's `autoOptOut.hook` so consumers can share one implementation
* across both transports. The reason is always `"hard-bounce"`; soft
* bounces don't fire the hook (auto-opting-out on a transient delivery
* failure would silently lose real subscribers).
*/
readonly optOutHook: EmailOptOutHook;
/**
* Optional. Called when an email arrives at the bounce route but isn't
* a DSN — typically a misrouted reply, an auto-responder, or a delayed
* notification. Default: no-op (silently drop). Use this to log or to
* route the message somewhere else.
*/
readonly onNonBounce?: (email: InboundEmail) => void | Promise;
}
/**
* Build an inbound-email handler that drives `autoOptOut` from RFC 3464
* delivery-status notifications. Restores bounce-driven opt-out parity for
* the Cloudflare `send_email` transport, which has no delivery-webhook
* surface (so the Resend-style provider webhook isn't available — see
* `cloudflareEmailProvider`).
*
* Wire it into `createEmailRouter()` from `@workkit/mail` against whichever
* inbound mailbox you've configured for bounces in Cloudflare Email
* Routing (commonly `bounces@yourdomain`).
*
* ```ts
* import { createEmailRouter } from "@workkit/mail";
* import { createBounceRoute } from "@workkit/notify/email";
* import { optOut } from "@workkit/notify";
*
* const bounces = createBounceRoute({
* optOutHook: async (address, channel, _nid, reason) => {
* const userId = await lookupUserIdByEmail(address);
* if (userId) await optOut(env.DB, userId, channel, null, reason);
* },
* });
*
* {
* email: createEmailRouter()
* .match((e) => e.to === "bounces@yourdomain.com", bounces)
* .default((e) => e.setReject("Unknown recipient"))
* .handle,
* };
* ```
*
* Errors from `optOutHook` propagate to the caller — Email Routing will
* see the rejection and the MTA will retry. If you'd rather swallow
* transient hook failures, wrap the hook yourself.
*/
declare function createBounceRoute(opts: BounceRouteOptions): (email: InboundEmail) => Promise;
/**
* Render an email body to HTML + plain text.
*
* - String template ⇒ treated as ready HTML.
* - React element ⇒ lazy-import `@react-email/render` (optional peer); throws
* if absent. Caller composes the React element themselves so we don't add
* a React peer dep here.
*
* Note: an earlier API surface accepted a `props` field; it was unused at
* render time and removed (callers should pass a fully composed React
* element).
*/
interface RenderArgs {
template: unknown;
text?: string;
}
interface Rendered {
html: string;
text: string;
}
declare function renderEmail(args: RenderArgs): Promise;
/**
* Strip script/style blocks, then tags, decode the most common HTML entities,
* collapse whitespace. Good enough for an automatic plain-text fallback.
* Pass `text` explicitly when fidelity matters.
*/
declare function htmlToText(html: string): string;
/**
* Verify a Resend (Svix-format) webhook signature.
*
* Header: `svix-signature: v1, [v1,...]` — Svix sends one or
* more `v1,` pairs. The official Svix spec separates them with a
* single space, but some forwarders or test harnesses serialize them as
* comma-separated `v1,,v1,`. We accept both — split on whitespace
* AND comma, then re-pair `v1,` tokens.
*
* Timestamp: `svix-timestamp` (unix seconds). Id: `svix-id`.
*
* Signed string: `${id}.${timestamp}.${rawBody}`. HMAC-SHA256 with the
* secret (base64-decoded, after stripping `whsec_` prefix).
*/
declare function verifyResendSignature(req: Request, secret: string, options?: {
maxAgeMs?: number;
}): Promise<{
rawBody: string;
timestampMs: number;
}>;
declare function parseResendEvents(rawBody: string): WebhookEvent[];
declare function isComplaint(rawEvent: unknown): boolean;
declare function isHardBounce(rawEvent: unknown): boolean;
import { ConfigError, ValidationError } from "@workkit/errors";
declare class FromDomainError extends ConfigError {
constructor(value: string);
}
declare class AttachmentTooLargeError extends ValidationError {
constructor(totalBytes: number, capBytes: number);
}
type EmailProviderName = "resend" | "cloudflare" | (string & {});
declare class WebhookSignatureError extends ValidationError {
constructor(provider: EmailProviderName, reason: string);
}
declare class ProviderMissingError extends ConfigError {
constructor();
}
export { verifyResendSignature, sesEmailProvider, resendEmailProvider, renderEmail, postmarkEmailProvider, parseResendEvents, loadAttachments, isHardBounce, isComplaint, htmlToText, emailAdapter, createBounceRoute, cloudflareEmailProvider, WebhookSignatureError, SesEmailProviderOptions, ResendEmailProviderOptions, ProviderMissingError, PostmarkEmailProviderOptions, FromDomainError, EmailProviderSendArgs, EmailProvider, EmailPayload, EmailOptOutHook, EmailAttachmentWire, EmailAdapterOptions, CloudflareEmailProviderOptions, BounceRouteOptions, AttachmentTooLargeError, AttachmentSpec, AttachmentLoadOptions, AttachmentBlob };