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 };