# Email (Inbound)

`defineEmail` is the inbound-mail mirror of [`defineSchedule`](/platform/schedules). 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](/configure#email-configuration).

## Define a handler

```typescript
// 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 (`../../`):

```typescript
// 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 compile`.
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

| 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:

```typescript
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:

```typescript
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.
