Quickback Docs

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:

ProviderIngressHow 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 + SNSBuild 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

toBehavior
omittedHandler 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

  1. deliverInboundEmail(message, env) in src/lib/email.ts — the shared dispatcher.
  2. email(cfMessage, env, ctx) on the Worker default export — Cloudflare adapter. Regenerated on every quickback build.
  3. No wrangler inbound binding. Cloudflare: configure Email Routing in the dashboard. SES: Receipt rule + S3 + SNS; call deliverInboundEmail with the fetched .eml.
  4. The existing outbound [[send_email]] binding (Cloudflare) / SES plugin (aws-ses) is unchanged.

Execute context

FieldTypeNotes
messageInboundEmailMessage{ provider, from, to, headers, raw }. raw is ArrayBuffer.
message.provider'cloudflare' | 'ses'Ingress tag for logs. Parse raw, don't branch on this.
dbDrizzle instanceThe same DB the queue/schedule handlers get
envCloudflareBindingsAll worker bindings
servicesServicesThe services layer (createServices(env))
withInternalContexthelperOpt 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.

On this page