> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tuturuuu Mail

> Multi-mailbox client with permanent SES and Cloudflare transports.

`apps/mail` is the standalone Tuturuuu mailbox app. It runs on
`https://mail.tuturuuu.com` in production and port `7820` locally, delegates
auth to `apps/web`, and preserves Tuturuuu workspace and mailbox-role checks.
Platform operators may configure additional managed domains; a mailbox address
must match its linked `private.mail_domains` row.

## Architecture

* `apps/mail` owns the mailbox UI and protected app-local APIs under
  `/api/v1/workspaces/:wsId/mail/*`.
* The mailbox mirror is stored in service-only `private.mail_*` tables. Browser
  code never reads those tables directly; routes verify workspace membership
  and mailbox roles before using an admin client.
* SES and Cloudflare Email Service are permanent outbound transports. The
  domain default comes from `mail_domains.outbound_provider`; an optional
  mailbox override wins. Shared abuse, rate-limit, and audit controls in
  `@tuturuuu/email-service` still run for both providers.
* Inbound transport is domain-wide. SES receipt/S3/SNS ingestion remains
  supported, while Cloudflare Email Routing invokes the Worker at
  `apps/mail/src/email-worker/index.ts`.
* Supabase is authoritative for domains, authorization, audit, threads, search,
  AI state, and MCP credentials. The private R2 bucket contains raw MIME, body
  objects, and attachment bytes; callers receive authorized short-lived access,
  never raw keys or R2 credentials.
* Thread resolution checks `Message-ID`, `In-Reply-To`, and `References` first.
  Normalized subject is only a recent-reply fallback and is not unique.

Mail consumes `/verify-token` in the proxy before centralized auth handling so
local Portless handoffs set cookies on `mail.tuturuuu.localhost`. The app keeps
token refresh same-origin through `/api/auth/refresh-app-session`; a valid
refresh cookie should rotate the Mail-local and Web-issued app-session cookies
without sending the browser back through `apps/web` login.

## SES Receiving Setup

Do not change DNS from code or migrations. The current public MX for `tuturuuu.com` is Google-routed, so real `@tuturuuu.com` receiving requires an explicit staged MX cutover or a pilot subdomain first.

1. Verify the domain or pilot subdomain in the SES receiving region.
2. Create an S3 bucket for raw MIME objects.
3. Create an SNS topic for receipt notifications and subscribe the web webhook:
   `POST /api/v1/webhooks/mail/ses`.
4. Create an SES receipt rule that stores raw MIME in S3 and publishes the SNS notification.
5. Configure `MAIL_SES_INBOUND_TOPIC_ARN`, `MAIL_SES_INBOUND_BUCKET`, `MAIL_SES_INBOUND_KEY_PREFIX`, and `MAIL_SES_REGION`.
6. Only after validation, stage the MX/DNS change outside the app repository.

For local SNS fixture tests, set `MAIL_SES_SNS_SIGNATURE_VERIFICATION=disabled`. Do not use that setting in production.

## Cloudflare onboarding

Cloudflare must already manage DNS for an onboarded domain. Arbitrary-recipient
sending also requires Email Sending to be enabled for the account. Configure a
staging domain before changing a production domain.

1. Create a private R2 bucket (the checked-in Worker configuration uses
   `tuturuuu-mail`) and bind it as `MAIL_R2_BUCKET` in
   `apps/mail/wrangler.email-routing.jsonc`.
2. Configure the Mail app server with `MAIL_R2_ACCOUNT_ID`,
   `MAIL_R2_ACCESS_KEY_ID`, `MAIL_R2_SECRET_ACCESS_KEY`, and
   `MAIL_R2_BUCKET_NAME`. The bucket name must match the Worker binding.
   `MAIL_R2_ENDPOINT` is only needed for an R2-compatible development or test
   endpoint. Object keys are private implementation details and must not be
   returned to clients. See `apps/mail/.env.example` for the complete contract.
3. Set the Worker secret with
   `bunx wrangler secret put MAIL_INGEST_SECRET --config apps/mail/wrangler.email-routing.jsonc`.
4. Configure the same value as `MAIL_CLOUDFLARE_INGEST_SECRET` in the Mail
   Vercel environment. Signed events include the request body and a timestamp;
   the API rejects invalid or older-than-five-minute signatures.
5. Set `MAIL_INGEST_URL` to the deployed Mail endpoint
   `/api/v1/webhooks/mail/cloudflare`, then deploy with
   `bunx wrangler deploy --config apps/mail/wrangler.email-routing.jsonc`.
6. In Cloudflare Email Routing, onboard the domain and route its intended
   address patterns to `tuturuuu-mail-email-routing`.
7. Configure the domain row through `GET/PUT /api/v1/mail/domains`. Only root
   workspace operators may use this endpoint. Move the domain from `verifying`
   to `active` only after DNS and routing checks pass.
8. For outbound Cloudflare sends, set `MAIL_CLOUDFLARE_API_TOKEN` with Email
   Sending permission and either store the managed account ID on the domain or
   set `MAIL_CLOUDFLARE_ACCOUNT_ID` as the fallback.

The Worker checks domain/provider status before reading and parsing the MIME
stream, uses `postal-mime`, stores deterministic R2 objects, and submits a signed
idempotent delivery event. Malformed or spam/virus-signaled deliveries are
recorded as quarantined. Transient API failures are thrown so Email Routing can
retry without creating duplicate messages.

Cloudflare controls the outbound `Message-ID` header and rejects clients that
set it. Mail therefore sends only `In-Reply-To` and `References` through the
Cloudflare API. SES raw MIME sends retain a deterministic `Message-ID`. Store
Cloudflare's returned provider identifier separately; do not substitute it for
an RFC message identifier unless the provider explicitly returns one in that
format.

## Mailbox API foundation

Mailbox routes require workspace membership and a mailbox role on every
request. The API provides chronological thread retrieval and thread-level state
changes, label and custom-folder CRUD, bulk message mutations, and private
attachment upload/download/delete routes. Attachment downloads stream through
an authorized route with byte-range support; clients never receive an R2 object
key.

Message listing accepts the structured search operators `from:`, `to:`,
`cc:`, `bcc:`, `subject:`, `is:`, `has:attachment`, `before:`, `after:`, and
`label:`. Quote values containing spaces. Structured filters are combined with
remaining free text and mailbox/folder state filters. Client applications
should call these routes through `packages/internal-api/src/mail.ts`.

Cloudflare currently permits 50 combined `to`/`cc`/`bcc` recipients and a
normal outbound size of 5 MiB including attachments. Email Routing accepts up
to 25 MiB inbound. The provider enforces outbound limits before making the API
request; the Worker rejects inbound events above the routing limit. Reconfirm
current quotas in the
[Cloudflare Email Service limits](https://developers.cloudflare.com/email-service/platform/limits/)
before changing these constants.

The managed staging baseline uses `tutur3u.com` for Email Routing and Email
Sending and the private `tuturuuu-mail` R2 bucket. Do not attach a routing rule
to the inbound Worker until the Mail deployment has the matching ingestion
secret and Supabase has an enabled `ingest.tutur3u.com` domain row linked to
the canonical `tuturuuu.com` row. Email Sending and R2 can be verified
independently before that inbound cutover.

## Google Workspace shadow-ingestion migration

Do not onboard `tuturuuu.com` into Cloudflare Email Routing while Google
Workspace still owns its apex MX records. Email Routing is enabled at the zone
level before Cloudflare allows routing subdomains, so onboarding the production
zone would replace and lock the Google MX records too early. Use the already
onboarded staging zone as the shadow bridge instead:

* Cloudflare Email Routing is enabled for `ingest.tutur3u.com`; its routing DNS
  records are managed and locked by Cloudflare.
* A temporary exact-address routing rule may forward a pilot shadow address to
  a verified test inbox. Replace this action with the deployed ingestion Worker
  before starting a parity run.
* Google Workspace has a recipient-address-map setting named
  `Cloudflare shadow ingestion pilot`. Keep it disabled between tests. It must
  map each selected `@tuturuuu.com` address to the same local part at
  `@ingest.tutur3u.com`, retain the original Gmail destination, and add
  `X-Gm-Original-To`.
* The public `tuturuuu.com` MX records remain Google-only throughout the shadow
  phase. Never publish Google and Cloudflare MX records together as a substitute
  for dual delivery.

### Activation gates

Complete all of these before changing the temporary Cloudflare forwarding rule
to the ingestion Worker or enabling the Google pilot:

1. Canonicalize a trusted shadow recipient such as
   `user@ingest.tutur3u.com` to `user@tuturuuu.com`. Preserve both addresses in
   the ingestion event and accept `X-Gm-Original-To` only on the configured
   shadow domain.
2. Configure the same HMAC secret as `MAIL_INGEST_SECRET` on the Worker and
   `MAIL_CLOUDFLARE_INGEST_SECRET` on the Mail deployment.
3. Deploy the Worker with the private `tuturuuu-mail` R2 binding and confirm a
   signed domain check and ingestion event reach the Mail webhook.
4. Ensure Supabase has enabled domain metadata for the shadow and canonical
   domains, including their explicit relationship. Do not infer an arbitrary
   production domain from an inbound subdomain.
5. Prove direct shadow delivery, raw MIME storage, body and attachment storage,
   quarantine behavior, and duplicate retry before enabling Google delivery.

The additive `add_mail_domain_canonical_relationship` migration installs this
exact staging relationship. Apply it through the normal database release path;
do not push it ad hoc from a workstation. The Worker derives the canonical
recipient only after a signed domain check, and the webhook independently
validates the ingress domain, canonical domain, observed recipient, and local
part. Duplicate transport deliveries with the same mailbox and RFC
`Message-ID` reuse the existing message instead of incrementing thread counts.

### Rollout plan

1. **Single-address pilot:** point one exact Cloudflare shadow rule at the
   Worker, enable only the matching Google address-map entry, and send external
   and internal test messages. The original Gmail delivery must remain enabled.
2. **Shadow parity:** expand the explicit map to the active user, group, and
   alias inventory. Run for at least three days and preferably seven. Compare
   Google Email Log Search with Supabase ingestion records by authoritative
   `Message-ID`, recipient, timestamp, raw MIME hash, attachment count, and
   quarantine result.
3. **Cutover readiness:** require no unexplained missing messages, idempotent
   duplicate handling, correct canonical recipients, attachment parity, and an
   alertable ingestion-latency baseline. Snapshot the Google MX records and
   lower or verify their DNS TTL at least 24 hours before the change.
4. **Apex cutover:** during a low-traffic window, onboard `tuturuuu.com` in
   Cloudflare and route its intended addresses to the same Worker. Keep Google
   Workspace and the shadow address map available for at least seven days so
   senders using cached Google MX records still feed the Cloudflare ingestion
   path.
5. **Stabilization:** remove the temporary test forwarding destination only
   after the Worker route is verified. Retire the Google shadow map after the
   cached-MX window and parity checks are complete; migrate outbound transport
   separately.

### Rollback

Restore the saved Google MX records first and wait for DNS confirmation. Keep
the Google shadow map and Cloudflare staging subdomain available during
rollback so messages delivered through either cached MX path still reach the
same idempotent ingestion boundary. Change the Supabase inbound-provider flag
only after DNS is serving the intended provider. A rollback must not delete R2
objects, mail metadata, Google accounts, or the disabled pilot configuration.

The initial transport smoke test used distinct subjects for Google outbound and
Google-to-Cloudflare shadow delivery. Google delivered the original inbound
message to the Workspace inbox, and Cloudflare recorded the mapped shadow copy
as forwarded. After the test, the Google pilot setting was returned to its
disabled state.

## Catch-all delivery

Catch-all routing is platform-operated because it affects an entire inbound
domain. The destination is relational metadata on `private.mail_domains`, not a
workspace secret: `catch_all_mailbox_id` must reference an active mailbox on the
canonical domain, and `catch_all_enabled` defaults to `false`. Automatic drafts
for catch-all deliveries have a separate opt-in and remain disabled unless a
platform operator explicitly enables them.

Activate the bridge in this order:

1. Apply the additive `mail_catch_all_delivery` migration through the normal
   database release process and deploy the matching Mail app and Email Routing
   Worker.
2. Open Mail settings as a root workspace operator, select
   `ingest.tutur3u.com`, choose the destination mailbox, and enable the logical
   catch-all route. The initial pilot destination is
   `phucvo@tuturuuu.com` when that mailbox exists.
3. In Cloudflare Email Routing, select the already-onboarded
   `ingest.tutur3u.com` subdomain and set its catch-all action to the
   `tuturuuu-mail-email-routing` Worker. Keep explicit rules enabled; they take
   precedence over catch-all.
4. Send a unique random local part directly to the ingress subdomain. Confirm
   the original recipient is visible in Mail, raw MIME and attachments are in
   R2, and a retry does not create another message.
5. In Google Admin, add a rule named `Tuturuuu Mail catch-all bridge` for
   inbound **Unrecognized/Catch-all** recipients only. Replace only the
   recipient domain with `ingest.tutur3u.com`, add `X-Gm-Original-To`, and leave
   Users and Groups unchecked. This keeps recognized Google Workspace delivery
   unchanged while preserving the unknown local part for Mail.

Do not enable Cloudflare Email Routing on the `tuturuuu.com` apex while Google
owns its MX records. To roll back, disable the Google unrecognized-recipient
rule first, disable the Cloudflare subdomain catch-all second, and disable the
logical Mail route last. Do not delete ingested metadata or R2 objects.

## Provider rollout and rollback

Provider selection is independent in each direction. Change only one direction
at a time on the staging domain, complete inbound delivery, outbound delivery,
attachment, threading, duplicate retry, and bounce/throttle smoke tests, then
enable the production domain. A mailbox override may be used for a narrow
outbound canary.

To roll back outbound delivery, clear the mailbox override and set the domain
outbound provider to `ses`. To roll back inbound delivery, restore the domain's
SES MX/receipt-rule configuration first, then set `inbound_provider` to `ses`.
Do not change the database flag before DNS is serving the intended provider.
Existing SES jobs, raw S3 metadata, and credentials remain supported throughout
the rollback.

## Operations

### Smart labels and AI-assisted drafting

Apply the `mail_smart_labels` migration before deploying the matching settings
UI. It adds a description, mailbox-scoped AI instructions, an enable flag, and
an auto-apply flag to each private custom label. The migration is additive and
defaults every AI option to disabled. Label CRUD remains limited to mailbox
owners and admins; senders may apply configured labels but cannot redefine the
taxonomy.

Mail exposes AI drafting and smart-label classification only through
mailbox-authorized app routes and `packages/internal-api`. Drafting supports new
messages, rewrites, and follow-ups with bounded thread context. Message content
is treated as untrusted reference material so instructions embedded in an
email cannot override the system prompt. Generated text is returned to the
composer as an editable draft; the AI route has no send, schedule, or transport
tool.

Smart-label suggestions help owners/admins create a taxonomy from recent
mailbox patterns. Classification accepts explicit thread IDs, validates every
thread and label against the active mailbox, and applies labels only after an
authorized user invokes the workflow. `ai_auto_apply` records whether a label
may be applied by an authorized automatic workflow; it does not grant AI any
additional mailbox role or send capability.

The `repair_mail_thread_subjects` migration backfills blank legacy thread
subjects from the newest meaningful message. Runtime thread hydration also
falls back to the newest message so sent mail remains accurate when application
deployment precedes the migration.

* Run `bun sb:up` locally after mail schema changes, then `bun sb:typegen`.
* Keep new mail route access checks in `apps/mail`; do not add direct client
  Supabase reads.
* Use `packages/internal-api/src/mail.ts` for client helpers and TanStack Query in the app UI.
* Unknown inbound recipients are retained as `quarantined` jobs for administrator review instead of being delivered to a user inbox.
* Keep generated public assets such as `/manifest.webmanifest`, `/sw.js`, and
  Serwist worker files out of the auth proxy matcher. Redirecting those files to
  central Web login breaks standalone Mail startup and PWA registration.
* Keep `apps/mail/src/proxy.ts` `config.matcher` entries as inline string
  literals. Next.js statically parses proxy matcher config during Vercel builds
  and rejects imported constants even when they resolve to strings.

## CI and deployment

Mail has dedicated Vercel workflows:

* `.github/workflows/vercel-preview-mail.yaml`
* `.github/workflows/vercel-production-mail.yaml`

Both workflows are registered in `tuturuuu.ts` and use the shared
`ci-check.yml` switchboard. They require environment-scoped Vercel credentials
plus `VERCEL_MAIL_PROJECT_ID`; production Supabase and SES values should live in
the Vercel project environment rather than GitHub Actions.

The preview workflow builds and deploys prebuilt artifacts through Vercel CLI.
The production workflow also uses workflow concurrency so stale production runs
are canceled instead of deploying after a newer production commit is pushed.

Deployment credentials must stay environment-scoped in GitHub Actions. The
preview job is bound to the `vercel-preview-mail` GitHub Environment, and the
production job is bound to `vercel-production-mail`. Store `VERCEL_TOKEN`,
`VERCEL_ORG_ID`, and `VERCEL_MAIL_PROJECT_ID` in those environments instead of
repository-wide or organization-wide secrets. Do not add `TURBO_TOKEN`,
`TURBO_TEAM`, production Supabase service keys, or SES credentials to
workflow-level `env`; Mail deploys should rely on Vercel project environment
variables pulled by `vercel pull`. Manual production dispatch is only valid from
`refs/heads/production`.

`apps/mail/vercel.json` disables Vercel Git deployments and GitHub integration
so preview and production deploys only happen through the CI workflows.
