Email (Inbound)
Declare inbound mail handlers with defineEmail. The same execute runs for Cloudflare Email Routing and Amazon SES — adapters normalize to one message shape.
defineEmail is the inbound-mail mirror of defineSchedule. Drop a file in services/email/ and the compiler emits a shared deliverInboundEmail() dispatcher. Execute is provider-agnostic — you parse RFC 822 bytes and write the store. Ingress is an adapter:
| Provider | Ingress | How it reaches execute |
|---|---|---|
Cloudflare (email.provider: 'cloudflare', default) | Email Routing → Worker email() | Compiler emits email on the Worker export. Buffers message.raw once, then calls deliverInboundEmail. |
Amazon SES (email.provider: 'aws-ses') | Receipt rule → S3 + SNS | Build the same InboundEmailMessage (provider: 'ses', raw = the .eml bytes) and call deliverInboundEmail(message, env). |
Outbound sending is a separate surface (email.provider already switches Cloudflare send_email vs AWS SES) — see Email Configuration.
Define a handler
// services/email/inbound.ts
import { defineEmail } from "quickback";
import { ingestMessage } from "../../features/mail/lib/ingest";
export default defineEmail({
name: "inbound", // identifier + audit actor (system:email-<name>)
to: "paul@kardoe.com", // optional; omit to match every recipient
description: "Ingest inbound mail",
execute: async ({ message, db, env, services }) => {
// message.raw is already-buffered RFC 822 bytes (not a stream).
await ingestMessage(db, env, services, message.raw);
},
});One file per handler under services/email/*.ts. Files prefixed with _ are ignored. name must be a valid camelCase identifier (it's used as a generated symbol and the audit actor). The same file is what runs on Cloudflare and on SES — do not import ForwardableEmailMessage or SES receipt types in execute.
Envelope matching
to | Behavior |
|---|---|
| omitted | Handler runs for every inbound message |
"user@example.com" | Exact, case-insensitive match against message.to |
Multiple handlers can match the same message (a catch-all plus an address-specific handler). Failures are isolated so one throwing handler does not abort the others.
If no handler matches, the runner logs a warning and returns. It does not call Cloudflare setReject() — Email Routing can still fall through, and SES has already accepted the message.
Importing feature code
Email files live at quickback/services/email/<name>.ts, so imports are relative to that location. To reach a feature table, a feature lib/ helper, or db/, climb up to the quickback/ root first (../../):
// quickback/services/email/inbound.ts
import { messages } from "../../features/mail/messages";
import { ingestMessage } from "../../features/mail/lib/ingest";
import { parseRfc822 } from "../../lib/rfc822";Two ../ — up from services/email/ to quickback/, then down into features/ (or lib/, db/). The compiler relocates the handler into the generated src/lib/email.ts and rewrites these paths to resolve from there automatically — you always write them relative to your own source file, exactly as your editor resolves them.
A single ../features/… is the common mistake — from services/email/ that resolves to services/features/…, which doesn't exist. Use ../../features/….
What the compiler emits
deliverInboundEmail(message, env)insrc/lib/email.ts— the shared dispatcher.email(cfMessage, env, ctx)on the Worker default export — Cloudflare adapter. Regenerated on everyquickback build.- No wrangler inbound binding. Cloudflare: configure Email Routing in the dashboard. SES: Receipt rule + S3 + SNS; call
deliverInboundEmailwith the fetched.eml. - The existing outbound
[[send_email]]binding (Cloudflare) / SES plugin (aws-ses) is unchanged.
Execute context
| Field | Type | Notes |
|---|---|---|
message | InboundEmailMessage | { provider, from, to, headers, raw }. raw is ArrayBuffer. |
message.provider | 'cloudflare' | 'ses' | Ingress tag for logs. Parse raw, don't branch on this. |
db | Drizzle instance | The same DB the queue/schedule handlers get |
env | CloudflareBindings | All worker bindings |
services | Services | The services layer (createServices(env)) |
withInternalContext | helper | Opt into the INTERNAL trust zone for roles: ['INTERNAL']-gated rows |
Cloudflare-only methods (setReject, forward, reply) are not on InboundEmailMessage. SES has already accepted the mail; Cloudflare fall-through is the adapter's job (it does not reject unmatched recipients).
Amazon SES inbound
SES cannot invoke a Worker email() export — receipt is S3 + SNS. After you verify the SNS signature and fetch the object:
import { deliverInboundEmail } from "../lib/email";
await deliverInboundEmail({
provider: "ses",
from: receipt.mail.source,
to: receipt.receipt.recipients[0],
headers: new Headers(/* from the .eml or commonHeaders */),
raw: await s3.getObject(bucket, key).arrayBuffer(),
}, env);execute does not change. MX, Receipt rules, the S3 bucket, and the SNS subscription stay in AWS — the compiler does not emit those.
Audit & trust
Inbound email runs are server-internal: there's no authenticated user, no request, no org scope. A delivered message gets a plain db (unscoped Drizzle) plus a withInternalContext helper:
execute: async ({ db, withInternalContext, message }) => {
// `db` here is unscoped — no firewall WHERE, no audit actor.
await withInternalContext(async ({ ctx, db, services }) => {
// ctx.internal = true, ctx.userId = "system:email-<name>"
// audited writes are attributable to e.g. system:email-inbound
});
},withInternalContext builds a context with internal: true and userId: "system:email-<name>", so audited writes made through it are attributable to the handler and can reach roles: ['INTERNAL']-gated rows.