Skip to main content
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.

Flutter Mail

The mobile Apps hub exposes Mail to exact @tuturuuu.com accounts and tagged reviewer accounts on the routed @tutur3u.com domain. Discovery from mobile session metadata is only a hint: the Mail API checks the live administrator reviewer tag, active account, workspace membership, and personal workspace on every request. Reviewers cannot access shared or staff mailboxes. The review domain has an active Mail domain row for personal mailbox bootstrap; the exact reviewer address is forwarded to the verified QA inbox for login codes. The Mail UI defaults to the reviewer’s own personal workspace. Its API client routes workspace Mail requests directly to mail.tuturuuu.com (port 7820 in development), with an optional MAIL_API_BASE_URL build override. Bearer API requests skip browser-cookie refresh in the Mail proxy but still pass the API guard and route-level workspace and mailbox authorization. The native client supports mailbox switching, paginated conversations, search, standard folders, labels and custom folders, read/star/archive/trash actions, bounded bulk actions, and folder-wide mark-read. Drafts retain their server ID, recipients, reply headers and attachments. Closing saves pending edits; a failed save keeps the editor open. Composer attachments use bounded authenticated uploads, and forwarding copies attachments through the authorized draft API. Rich-text composition uses Quill and exports email HTML through the same draft and send contracts as the web client. Untouched saved HTML is preserved. AI results require review before replacing the editor content and never send mail automatically. Owners and admins can manage mailbox settings, forwarding, smart-label configuration, custom folders/labels and group membership. Account or workspace changes dispose the entire Mail navigation stack. Attachment downloads reject redirects and oversized responses. Original HTML renders in an isolated, script-disabled web view; remote images require an explicit action. Downloaded attachments use the device’s native share sheet. Run ttr resources run -- bun check:mobile after native changes, and the Mail proxy auth tests plus the actual Mail build after auth-routing changes. Unit and widget checks do not prove real-provider sending, receiving, or store delivery; verify those separately with the intended authenticated account.

Read state and archive actions

Opening a loaded conversation optimistically clears its unread indicators and updates cached Inbox counts. Failed writes restore that conversation’s prior state. The reader’s compact read/unread control closes the conversation when marking it unread, preventing automatic viewing from immediately clearing it. Archive actions persist both archived_at and read_at for the current user; users can explicitly mark an archived conversation unread afterward. Inbox and Archive each offer a folder-wide Mark all as read action. This covers the selected mailbox’s entire folder, including unloaded messages, and ignores the current search and selection. The app-local POST endpoint /api/v1/workspaces/:wsId/mail/mailboxes/:mailboxId/read-all scans 250 inbound messages per request. Clients continue with the returned nextCursor and before until the cursor is null. The timestamp excludes later arrivals; mailbox access and group-message visibility are checked for every batch. Batches persist independently. If a request fails, already completed batches remain read and the client refreshes the mailbox; retrying completes remaining unread messages. Thread and selected-thread actions also paginate message IDs and write bounded batches instead of silently truncating long conversations.

Similar deliveries and failed recipients

The copy addressed to the active mailbox stays separate. Alternate-recipient copies with the same sender and normalized subject within ten minutes share a compact expandable summary. All original copies remain inside that group with their own selection, content, read state, and actions; personalized previews do not split otherwise matching deliveries. Recognized delivery failures offer a blacklist shortcut in the list and reader. The reason defaults to Inactive/Abandoned and supports additional Infrastructure quick reasons. The action writes to the existing global email_blacklist, blocking platform outbound email to the chosen recipient. It requires Mail mailbox access, a root-workspace linked user, and view_infrastructure. The server derives allowed recipients from the authorized failure message, validates the submitted address, and records the current actor. Existing blacklist entries retain their reason and actor. Ordinary messages and delay notifications do not produce automatic blacklist suggestions.

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. Authenticated root and personal workspace entry requests redirect directly to /personal/inbox in the proxy, preserving query state and locale. Mailbox discovery uses bootstrap ?view=mailboxes; unread badges load separately through ?view=counts and stay unknown until available. Both paths retain session and membership checks. The default bootstrap response still includes counts for other clients. Thread scans fetch previews rather than full bodies; missing legacy snippets are loaded only for the visible page. State scans exclude read-only rows unless the requested search needs read state.

Reading and attachment previews

Settings → Reading stores the archive navigation and email appearance preferences in the current browser. Archiving an open inbox conversation selects the next visible conversation by default, falling back to the previous one at the end of the loaded list. Users can choose to return to the list instead. Bulk archive skips all selected conversations when choosing the next message. Loaded unread conversations are marked read even when opened through restored URLs or archive navigation. Read updates wait for pending archive/trash operations to settle. Archive controls wait for read-state writes to settle before another mutation. Its header remains available from the inbox summary while the message body loads. Failed operations restore only their own conversations and counters; mailbox reconciliation waits for overlapping operations to finish. Catch-all headers fall back to the observed delivery recipient, then the envelope recipient when parsed To recipients are absent. Sender and recipient summaries clamp long addresses and expose full text in tooltips. Email frames remain hidden until the saved appearance is restored and contrast adjustments finish. Dark mode matches neutral paper surfaces to the reader’s background, including black and gray panels and neutral gradients, and softens bright neutral borders while preserving colored panels and images. Compact App theme/Dark/Original icon controls live on the floating action toolbar with tooltips. New browsers follow the selected app theme; saved explicit choices remain unchanged. Original mode retains sender backgrounds and images, while unreadable text is corrected to at least 4.5:1 contrast on resolved solid backgrounds, including calendar invitations. Dark mode keeps the same contrast floor. Regression coverage lives in mail-preview-contrast-dom.test.ts and mail-preview-appearance.test.ts. Neither mode enables scripts inside email HTML. Message headers use a single truncated sender-to-recipient row. The participant button opens full address details; the separate chevron collapses the message. The reader collapses recognized Gmail, Outlook, cite-style, and plain-text reply history into a native disclosure. Quote-only and unrecognized messages remain visible. Quote detection runs on the sanitized isolated document before it is revealed; it never modifies stored message content or outgoing reply headers. The protected attachment endpoint accepts ?preview=1 for a passive-format allowlist: raster images, supported video/audio types, and plain text (including .txt files stored as application/octet-stream). Preview access uses the same mailbox and message authorization as downloads and preserves byte-range support. HTML, SVG, and other unsupported types remain downloads. Responses are private, uncached, MIME-sniffing-disabled, and sandboxed. The reader exposes previews from both the conversation and Attachments tab, with a separate download action. Text previews use the internal API client, render escaped text in the reader theme, and fall back to download on authorization failures or files larger than 1 MiB.

Composing and draft recovery

Close saves the latest draft before dismissing the composer; offline or failed saves keep the editor open. Untouched empty composers close without creating a draft. Drafts exposes Continue editing and deletion, and reopening keeps the original draft identity, recipients, reply headers and attachments. Switching composers waits for the previous draft to save before starting a new editor session. The reader header omits its back button; narrow layouts retain a message-list action on the floating toolbar. The composer keeps recognized trailing Gmail/Outlook history outside the rich-text editor, collapsed by default, preserving its HTML when saving or sending. Bounded reply blocks are recognized; ambiguous Outlook tails and authored quotations stay editable. Inline quoted images retain their attachment metadata. Selecting text exposes Enhance selection. AI previews replace only that passage after review; a document change invalidates the captured selection. Plain-text reading caps runs of blank lines without changing stored text or meaningful indentation.

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

Mail settings and account settings

Mailbox settings use the mailSettings=open query parameter. The shared satellite account settings use settingsDialog=open and settingsTab; keep these separate so the guidance dialog cannot cover the mailbox delivery, membership, and sender controls. Closing either dialog must preserve the other dialog’s query state. Mail sanitizes message HTML and signatures with sanitize-html in both the server ingestion/draft path and browser previews. Its allowlist retains email tables, typography, and cid: image references while excluding active content and CSS resource URLs. Avoid reintroducing an externalized jsdom-based sanitizer: an incompatible CommonJS/ESM dependency can fail module initialization and make even the mailbox bootstrap route return HTTP 500. Validate sanitizer security fixtures and a real Mail build when changing this dependency boundary.

Full Google mail migration

Inventory active and suspended accounts, aliases, group membership, delegated mailbox access, and existing routing before importing data. Preserve suspended account access restrictions. Google groups can also authorize access to Google Cloud resources; migrating their email does not authorize deleting those groups or changing their IAM membership. For the Tuturuuu migration, suspended accounts stay suspended while incoming mail for their addresses follows the domain catch-all to phucvo@tuturuuu.com. An existing inactive mailbox must not be recreated by automatic provisioning. This delivery policy does not grant the suspended user mailbox access. Google Admin’s available organization export may include all services rather than Gmail alone. Review the actual scope before starting it, obtain approval for non-mail data, and use only the mail portion for this migration. A scheduled or in-progress export is not an imported archive. Keep the source service until the completed export has been downloaded, reconciled, and imported with original dates, folders/labels, read state, attachments, and mailbox ownership intact. The live Cloudflare webhook imports new inbound deliveries; it is not a historical Gmail importer and must not be used to silently flatten sent mail, drafts, or labels into the inbox. Use the resumable Google Takeout importer after extracting only each account’s Takeout/Mail directory into one directory per email address. Run it without --apply first and reconcile its report with an independent MBOX inventory:
The production operator can then repeat the command with --apply. The importer writes raw MIME and attachments to the configured Mail R2 bucket, uses deterministic IDs, and skips existing provider or Internet Message-ID records so an interrupted run can resume safely. It processes Deleted MBOX files first so duplicates retain their trash state. It preserves Gmail thread IDs, original timestamps, sent/draft/spam/trash/read/star/archive state, system labels, and custom labels. State rows may be recorded for a suspended user’s existing profile, but the importer does not activate a mailbox or create a mailbox membership. After the apply run, compare its report with counts from private.mail_messages, private.mail_raw_messages, private.mail_attachments, and private.mail_stored_objects. Re-run the importer with the same arguments to verify that every message is reported as already present. Keep the source export until database counts, R2 objects, and an authenticated Mail reader sample all agree. Onboard Cloudflare Email Sending separately from Email Routing. Sending uses its bounce subdomain and DKIM records and can be prepared while the apex MX remains on Google. Verify the existing DMARC policy and reporting addresses after onboarding. Check the account’s actual daily quota and the outbound message-size limit before switching the application provider; domain activation alone does not prove application sends or recipient delivery. For tuturuuu.com, the requested catch-all destination is phucvo@tuturuuu.com. Configure the logical Mail destination first, route the Cloudflare catch-all to the ingestion Worker, and retain explicit recipient routes. Prove delivery to a new random local part, the preserved original recipient, attachment storage, and duplicate handling before enabling the Google catch-all bridge or changing the apex MX. Never use SMTP forwarding back to the same domain as a substitute for internal mailbox ingestion.

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 offline 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 preview and production use .github/workflows/vercel-preview-mail.yaml and vercel-production-mail.yaml, registered in tuturuuu.ci.ts through ci-check.yml. Preview builds prebuilt Vercel CLI artifacts. Production uses concurrency to prevent stale deployment; manual dispatch requires refs/heads/production. Credentials belong to the corresponding vercel-preview-mail or vercel-production-mail GitHub Environment: VERCEL_TOKEN, VERCEL_ORG_ID, and VERCEL_MAIL_PROJECT_ID. Keep production Supabase/SES values in the Vercel project. Do not put those values, TURBO_TOKEN or TURBO_TEAM into workflow env; use vercel pull. apps/mail/vercel.json disables Vercel Git integration. The exact-head mail-profile-runtime-contract.yaml follows resource CLI changes and uses frozen normal setup through the shared queue for Lettin profile upload/save/reload and independent Chromium session transport tests. Its exclusive disposable VM owns complete-schema Supabase Auth/REST/Storage on safe local API port 8001, local D1 fixtures and Next apps. The profile browser and session cookies use registered http://localhost:7833; Web/API, storage and readiness retain their independent IPv4 bindings. This fixture origin matches the existing signed-upload CORS registry without widening capability or authentication policy. Storage uses an owned HTTPS proxy and one-day CA with normal verification. Chromium sandboxing stays enabled: hosted-only setup authenticates Google’s signed Release/Packages SHA256 chain, extracts only a fresh packaged helper without maintainer scripts, and records package/helper hashes. Root installs it exclusively at /usr/local/libexec/tuturuuu-mail/chrome-sandbox with protected parents and 4755 mode; CHROME_DEVEL_SANDBOX selects it using Chromium’s helper option. No existing /opt bytes, shared Linux permissions or security policy change; require actual runtime success. Initial profile navigation failures retain at most 20 navigation-response source/target route classes, fixed locale presence, same-path booleans and HTTP status codes, without URLs, query values, workspace IDs, headers or bodies. Terminal receipts retain actual statuses for the two named tests even on failure; stopped app records remain available to startup diagnostics. The disposable hosted fixture also enables default-off profile proxy provenance: fixed response-producer and request/Next pathname locale categories with a same-path boolean, without URL, tenant, query, cookie or token values. An absent marker is unclassified, not proof of a post-proxy cause; a marker identifies the proxy producer, not the redirect root cause. Lettin uses Next.js skipProxyUrlNormalize so locale canonicalization sees the original client URL instead of a synthesized locale prefix; explicit /en and /vi redirects, locale cookies, queries, shared-session and MFA checks remain enforced. Config changes require the real Lettin build and hosted profile runtime. Source checks and these observations do not turn a failed runtime gate into a pass. Missing/skipped/retried tests and failed cleanup fail the gate; fixtures delete only their synthetic IDs/media and stop recorded project/app/proxy processes. No provider credentials, raw traces or private CA keys are published. Broad E2E stays disabled; terminal evidence proves local profile/session behavior, not Worker/provider/production/customer delivery; pgTAP/schema types remain separate.

Mail client navigation and recovery

Mail owns one settings entry in its sidebar. Its shell disables the shared settings utility with showSettingsButton={false}; other satellite apps keep the default. Mail settings continue using the mailSettings URL state. Unread and attachment quick filters compose with the search query. The selection checkbox selects loaded conversations, and selection resets when the mailbox, folder, label, or search changes. Message loading failures show an explicit retry state instead of appearing as an empty mailbox. Sending, draft autosave, protected attachments, and delivery settings remain behind the existing Mail APIs. The conversation pane constrains message width and keeps message details and attachments available without expanding them by default. Small screens show one pane at a time, with a back action returning to the message list.

Distribution groups

A shared address can be provisioned as a distribution group by setting private.mail_mailboxes.metadata.mail_group to:
Provision this metadata when creating a new address. Settings cannot convert an existing archive into a group, or a group into an archive. Owners and admins can change the three permission scopes in Settings → Members, add active internal members by email, change their roles, and remove non-owner members. Routine member management cannot create, demote, or remove an owner. Posting and attachment scopes support managers, members, the organization, or anyone. Send-as supports managers or all members. Owners and admins are managers; viewer and sender roles are ordinary members. A sender role does not override a managers-only send-as policy. Personal mailbox status must remain active for group access, sending, membership additions, and delivery. Membership does not reactivate suspended users. Cloudflare and SES ingress distribute accepted messages into active members’ personal mailboxes in the group’s domain. No shared inbound message or thread is created. Drafts and sent copies made using the group address are visible only to their author. Removing a member stops future delivery and access to the group address; copies already delivered to that member remain personal. Delivery uses the existing Message-ID/provider deduplication when groups overlap or delivery is retried. An empty group accepts a permitted message with zero personal copies. Group ingress verifies DKIM against the stored raw bytes and requires the signing domain to exactly match the From domain. It does not trust an incoming Authentication-Results header. This intentionally requires DKIM even for an anyone posting policy; SPF-only senders are not accepted. Failed authentication or posting/attachment restrictions quarantine the delivery job. Temporary DNS errors fail processing so the existing transport retry can recover. R2 attachment objects may be shared by personal copies; download authorization follows the accessible message and its attachment record. This supports direct internal membership and per-message delivery. It does not replicate Google Groups join requests, external or nested membership, digest subscriptions, shared archives, custom roles, Google footers, or moderator notification/approval workflows. Quarantine records remain available to operators through the existing delivery controls. Google IAM groups and their access grants are independent of Mail group configuration and must remain in place when still used for Google Cloud authorization.

Internal forwarding and smart labels

Mailbox owners and administrators can use Settings → Automation to forward new incoming messages to another accessible, active mailbox in the same domain, or to that domain’s current enabled catch-all mailbox. Delivery retains the source copy and preserves original content and attachments. Forwarding is one internal hop; forwarded copies never trigger another forwarding rule. Self, inactive, cross-domain and distribution-group targets are rejected. Distribution groups continue to use member delivery. Disabling catch-all suspends a catch-all-based forward until an eligible target is configured again. Smart inbox uses gemini-3.5-flash-lite, Google’s latest stable Flash-Lite model verified on September 7, 2026. Enable it separately per mailbox. New incoming mail is classified against custom labels; only labels with both AI and automatic application enabled are applied. Gemini may suggest up to three new categories; owners and administrators can review recent suggestions under Settings → Labels. Suggested labels are never created automatically. Subjects, sender addresses and up to 6,000 text characters are processed by the configured Google AI service. Attachments are not sent to the classifier. The existing AI memory/usage wrapper continues to apply its configured policies. Classification is bounded to twelve seconds without provider retries. Failures are logged and never reject accepted mail. The existing manual classification and suggestion controls remain available for recovery and older messages. Completed classification events prevent repeat work on normal delivery retries. Concurrent duplicate deliveries can still produce duplicate AI requests; label assignment itself is idempotent. Forwarding errors remain retryable through the inbound delivery job. Configuration is stored in mailbox metadata; this feature requires no schema migration. The reader defaults to dark appearance, with original newsletter colors available from the floating toolbar or Reading settings. Stylesheets are retained only inside a sandboxed iframe with scripts, forms, remote stylesheets/fonts and network APIs blocked by CSP. The iframe grants same-origin access to the parent solely for height measurement and protected inline images; it never grants script execution. External links open separately with no referrer. Raster CID images resolve through authorized attachment endpoints. Signature sanitization still removes stylesheets. Wide messages scroll within the reader rather than expanding the app. Rendering aims for common Gmail-style newsletters; it is not a claim of pixel-identical Gmail compatibility for every email template. Thread lists always display both the message date and delivery time. The mobile reader uses constrained native scrolling to prevent wide tables, subjects or attachment names from widening the application viewport. The reader preserves hidden newsletter preheaders and isolated inline CSS. Dark appearance adapts neutral surfaces and borders while preserving branded colors. Message content uses the full pane width; reply actions float over the bottom edge with reserved scroll space so the last line remains reachable. The collapsed sidebar contains folder actions only. Expanded navigation groups shared mailboxes by repeated address prefixes (for example GCP), while personal mailboxes remain directly accessible.

Keyboard navigation

Use the keyboard button beside the mailbox filters, or ? while single-key shortcuts are enabled, to open the shortcut reference. Reading settings and the reference both let users disable single-key shortcuts; Tab, Shift+Tab and arrow navigation remain available. Arrow keys move focus between loaded conversations, Home/End reach the list boundaries, and Enter opens the focused row. j/k also switch an already-open reader. x selects a conversation, Shift+X toggles all loaded conversations, and e, #, and Shift+I apply archive, trash, and mark-read to the checked selection. u or Escape clears a checked selection; when no selection remains, it returns to the list. / focuses search, c composes, and r/a/f reply, reply all, or forward the loaded message. g followed by a folder key navigates while preserving the active mailbox. Composer shortcuts use Ctrl on Windows/Linux or Command on macOS: Enter sends through the normal review flow, S saves, and J opens AI assistance. Escape saves and closes. Focus moves to recipients for a new draft or the body for a reply, and returns to the opener after closing. Mailbox shortcuts pause during typing, IME composition, composing, and dialogs. Same-origin email-frame key listeners retain the script-disabled sandbox.

Address decoding and similar deliveries

Mail recovers damaged display names from the matching original RFC 2047 address header when available, including Latin-1 encoded words incorrectly labeled UTF-8. Already-lost replacement characters cannot be reconstructed; the reader displays the address instead. New inbound records use the same decoder. This requires no production backfill and does not alter stored raw headers. The loaded list groups single-message catch-all deliveries with distinct original recipients when sender, subject, preview, attachment presence, mailbox and a 10-minute time window match. These are labeled similar deliveries, since previews do not prove identical bodies. Groups are presentation-only: records, message counts, search, permissions and per-delivery actions remain independent. Expanding a group exposes recipient rows; keyboard navigation opens a collapsed group as focus reaches those rows. Filtering and loading additional pages may change group membership. No grouping occurs across mailbox access boundaries.

Mobile inbox restoration

Mail is a core mobile app for accounts whose email domain is exactly tuturuuu.com; other accounts do not see its launcher or direct-route content. API authorization still controls mailbox access. The mobile inbox restores its last mailbox, folder, search, labels, and message list from encrypted, account- and workspace-scoped storage. Snapshots expire after seven days. Opening Mail rechecks access and refreshes messages; cached send permissions are never trusted. A denied access response clears the cached view and prevents late requests from restoring it. Draft editing and attachment downloads continue to use authenticated network requests. Opening a mobile message keeps its request active while the inbox revalidates. Cached thread detail opens immediately; transient detail failures get one bounded retry. If opening still fails, the inbox shows a Retry action above the floating dock. The reader leaves enough bottom scroll space to reveal the end of long messages above that dock.

Mobile incoming-mail notifications

Incoming SES and Cloudflare messages create durable mail_received push notifications when their Inbox label is persisted. Each active mailbox member with an exact @tuturuuu.com account receives a separate personal notification, independent of the currently selected workspace. Shared and internally forwarded copies use the membership of the mailbox that actually contains the message. Google Takeout imports, outbound mail, quarantined messages, and initial deliveries older than five minutes do not enqueue pushes. No notification email is generated. The 20260923100000_mail_received_push.sql migration must be applied by the operator before delivery is available. It records a durable message/recipient receipt even when push preferences suppress sending, so replaying or restoring an Inbox label cannot send the same notification again. User-scoped mail_received push preferences, the general account push switch, and quiet hours apply. The dispatcher rechecks current mailbox membership, internal identity, message availability, and preferences before sending. Delivery uses the existing immediate notification queue and Firebase device registry. Mobile push batches can reach registered recipients in every workspace; the dispatcher still checks current workspace membership before sending. The root-workspace rollout restriction remains for notification email batches. Dedicated batches preserve each message’s mailbox/thread target. Stale immediate email and push claims recover with bounded retries. Before contacting a provider, the dispatcher persists a delivery-in-flight marker. A lost response or acknowledgement is flagged as delivery_outcome_unknown for operator reconciliation instead of being replayed automatically; confirm provider delivery evidence before any manual retry. Exhausted or expired batches terminate as failed. Status-write failures are reported, not counted as successful delivery. The native payload contains openTarget: mail, mailboxId, threadId, messageId, and userId; opening it requires the same signed-in recipient and fresh authorized mailbox access. To verify delivery, send a new external message to a test mailbox with a registered beta device. Check the private Mail receipt, notification delivery log, and immediate batch status, then tap the device notification and verify the correct mailbox and thread. Repeat a provider event to verify no duplicate, and remove mailbox membership before a queued delivery to verify suppression. A successful Firebase response alone does not prove device receipt or navigation. The five-minute recovery timer runs in Cloudflare Cron Control; the authenticated web cron endpoint remains available for manual recovery. Explicit immediate requests read at most 256 KiB in 4,096 chunks after authentication, with a ten-second total read deadline. Stalled/slow bodies return HTTP 408 before any database/provider work; chunks do not renew the deadline and cancellation cannot extend it. Exceeding byte/chunk bounds returns HTTP 413 before JSON parsing or database work. They accept at most 100 batch IDs and reject oversized or overlong IDs rather than silently truncating them. Unrequested batches remain pending; the empty-body automatic processor retains complete queue selection to preserve rollout filtering. This shared email/push rule preserves complete delivery logs, recipient consent and ambiguous-send rules. See the explicit selection budget and immediate-selection/request-budget regressions. The timer drains pending batches when a database webhook is missed, and recovers failed or abandoned Mail batches with at most three retries. A processing lease is considered abandoned after ten minutes. Every attempt repeats the same access and preference checks. A persisted delivery_in_flight marker makes ambiguous crash or provider outcomes terminal for manual reconciliation, with no automatic replay.

Snooze and mute

Snooze and mute are personal thread preferences. They do not change another member’s shared mailbox. Snooze hides a thread from your Inbox until its stored UTC deadline, including newly arriving replies. Inbox and Snoozed views refresh while open and on returning to the app; expired threads reappear without a cron write. Snoozing from Archive restores that user’s message state so the thread can return to Inbox. Muted threads remain in the Muted view until unmuted. Both states suppress incoming Mail notification creation and delivery for that recipient. Other mailbox members are unaffected. Unmuting does not replay old muted notifications. Apply 20260923110000_mail_thread_snooze_mute.sql after 20260923100000_mail_received_push.sql before enabling these actions in production. The native swipe settings include Snooze and Mute. Swipe and web actions offer Undo; reader and web menus support rescheduling, unmuting, and returning snoozed mail to Inbox.

Calendar invitations and message details

Mail reads scheduling requests from authorized calendar attachments, including Google multipart/alternative calendar parts and legacy SES raw MIME. Ordinary ICS exports (PUBLISH), cancellation notices and calendar replies are files, not RSVP requests. The responding address must exactly match an invited mailbox; viewers and group delivery identities cannot send replies. Unsupported delegated identities and multiple competing event attachments do not expose controls. Google’s duplicate alternative-body and ICS attachment copies are treated as one request only when their decoded contents match after newline normalization. Every copy must independently pass mailbox authorization; differing sequence, attendee, UID or other content remains ambiguous and hides RSVP controls. Reads are bounded to eight copies, 256 KiB per copy and 512 KiB combined. This applies to modern stored attachments and legacy SES raw MIME; regression coverage is in calendar.test.ts and calendar-sources.test.ts. The invitation card preserves the organizer, UID, sequence and occurrence when sending an iTIP REPLY. It displays the original location and time-zone label; Teams and Google Meet links have a separate join action. It creates no calendar event copy. A self-organized personal hold is not the organizer’s invitation. Replies use a persisted invitation claim and an atomic draft-to-sending claim. Retries keep the same request identity. A pending or uncertain provider result is checked rather than automatically sent again. Interrupted preparation can resume with its original request identity, exposed only to its original actor. For historical threads without indexed iCalendar identity, only the latest inbound message in the thread can be answered; newer mail conservatively hides older RSVP controls, including after updates or cancellation notices. Mobile message details expand to show each authorized From, To and Cc name and address with native text selection/copy. Bcc appears only when it exists in the already authorized message data. A resolved empty inbox remains visible during background refresh; exiting the reader waits for pending archive settlement before reconciliation.

Focused synthetic verification

Run the calendar parser, attachment worker, reply claim, permission and invitation card tests in apps/mail with one Vitest worker. Run the Mail empty-inbox, invitation-card and message-details Flutter tests with --no-pub after flutter gen-l10n. Include existing optimistic/cache/filter/reader tests. Never send RSVP, archive user messages or create/delete live events for verification. A timezone-specific recurrence reply includes the original referenced VTIMEZONE component. Mail does not infer UTC from custom or Windows timezone names. Missing, competing, malformed or oversized definitions suppress RSVP; supported definitions use bounded STANDARD/DAYLIGHT observances and common yearly timezone rules. Scheduling enum parameters are case-insensitive; only individual attendee identities are eligible, including when CUTYPE is explicitly supplied. A completed historical request acknowledges its receipt without replacing a newer reply claim or the current response displayed by either client. Fresh changed intentions use fresh request identities. Preparation recovery uses the original stored reply identity, including when reopened in another authorized workspace; mailbox permissions and the original actor are still required. List refresh remains available during reader mutations and overlays only the owning mailbox’s pending reader actions, so unrelated incoming messages remain visible. Web inbox list responses also replay state changes that were pending when the request began or started while it was in flight. This applies to pagination and background refresh in the owning workspace/mailbox. An archived final row stays hidden while the action is pending; a failed action restores it without removing new arrivals. The archive folder retains archived rows. The mounted React test uses the actual inbox, reader, Archive control and action hooks with synthetic API responses; it covers empty state, failure/retry and refresh after reader exit. Pending archive refreshes compare the captured conversation revision before hiding stale results. A newer inbound message in the same conversation remains visible because it falls outside the earlier archive action. Failure restores optimistic flags or missing old rows without replacing newer conversation data. On web and mobile, an actionable invitation can be associated with an existing Calendar event by pasting its Calendar event link, viewing both records, and confirming. The preview separates the original invitation’s organizer, invited identity, time, physical location and conference action from the selected event’s account, organizer, attendees and location. A self-organized private hold remains a separate event even when the title and time match. No event is copied, merged, deleted or reparented, and linking never sends an RSVP. Replies continue to use the original invitation’s validated scheduling protocol and identity. The association belongs to the actor and original Mail message. The server rechecks current invitation access, Calendar workspace/account/source access, provider UID and occurrence identity, and preview authority at confirmation. Unknown identity, superseded invitations, updates/cancellations and revoked permissions fail closed. A changed preview requires a new preview; an uncertain network retry reuses its receipt. Metadata compare-and-swap preserves reply claims and other actors’ associations. Remove link deletes only the actor’s association; it never deletes the Calendar event. Opening a linked event requires a fresh Calendar-authorized target. Ordinary ICS attachments do not expose these controls. On mobile, replacing the Mail repository clears the link preview and saved target even when workspace, mailbox and message stay the same. Delayed results from the previous repository are discarded; mail_calendar_link_test.dart covers this boundary. Mail-owned association endpoints use the existing message path followed by /calendar-link: GET reads the actor’s association and authorized target; POST /preview previews { calendarWorkspaceId, eventId }; PUT confirms that selection with receipt; DELETE removes the saved association with receipt. All responses are private and uncached. Calendar owns the read-only event authority endpoint; Mail accesses it using the configured Calendar origin and existing forwarded cookie/Bearer authentication, with no Calendar table access.

Native inbox refresh and settings

Opening a Mail notification prepares the inbox alongside the reader. An existing snapshot for the same inbox remains visible, and the inbox revalidates while its reader is open. Background refresh pauses during outstanding optimistic Mail mutations. Network, timeout, and server list failures retry once. Rate limits are not automatically retried and preserve the authorized snapshot; revoked authentication/access is not retried and clears cached content. A failed refresh preserves a previously resolved empty inbox and shows a retry action rather than replacing it with a full-screen error. Cold failures remain visible. These rules apply only within the same account/workspace/mailbox and retain the existing cache permission checks. mail_background_refresh_test.dart and existing inbox mutation tests cover this behavior; authenticated provider/device proof remains a release gate. A completed Mail mutation can invalidate a concurrent inbox or detail fetch. Within an unchanged actor/workspace cache scope, the native repository restarts that interrupted read once and coalesces concurrent consumers onto the new request. Repeated invalidations remain bounded and surface a failure for retry; authentication, access denial, logout and scope clears never trigger this restart. The reader awaits the API client’s transport timeout rather than starting a shorter timeout that rejoins the same unfinished request. Real API failures still use the existing transient-error policy. mail_invalidation_race_test.dart covers delayed mark-read success/failure, coalescing, bounded restarts, scoped mutations and denial/logout ABA fences with the actual encrypted cache and repository. mail_error_scope_test.dart covers awaited fallback and cache-unavailable publication boundaries, including late errors after a new same-ID session. Mail quick settings and central App settings use the same mailbox settings editor. Central settings resolves real mailbox membership before opening the editor; members keep personal device options and only owners/admins edit mailbox configuration. Mail settings uses the existing shell title and bottom Back/Save actions, without a second app bar. Unsaved mailbox changes require confirmation to discard; saves block exit until settled. Account or workspace transitions cancel the editor, including a switch away and back. mail_settings_navigation_test.dart covers the title/actions and edit guards. Retained sender image and gradient backgrounds in Original view keep their foreground colors, because a background-color alone does not establish the painted contrast. An opaque solid child resumes contrast correction. The immediate request reader checks monotonic elapsed time before and after each stream read, including end-of-stream. Immediately resolved chunks cannot bypass the ten-second deadline while timer callbacks await service; wall-clock changes do not extend it. The existing byte/chunk/selection limits and authenticated request ownership remain unchanged. Deadline regressions count actual stream reads at the exact boundary; this bounds request intake, not automatic draining or spend.

Personal Gmail and Outlook accounts

Mail also offers Connected accounts in a personal workspace. Any authenticated Tuturuuu user with access to that personal workspace may connect Gmail or Outlook; managed Tuturuuu mailboxes retain their existing staff/reviewer and mailbox-role checks. Connections belong to the authorizing user, not to workspace members or shared mailbox delegates. Account discovery and every provider operation bind both user and workspace before decrypting credentials. The connected view reads provider mail directly. Inbox, Sent, Drafts, Archive, Trash and Spam, provider search, pagination, read state, stars, archive, trash and restore therefore operate on Gmail/Outlook rather than a separate local mirror. Mailbox operations use immutable Outlook IDs. Refresh retrieves current provider state; this view does not implement push notifications, offline mail storage, custom-folder/label management, or Google Tasks/Microsoft To Do synchronization. Calendar account consent remains separate from Mail consent. Compose, Reply, Reply All and Forward send through the connected provider. Reply-To can contain multiple addresses; Reply All retains To/Cc roles, removes the user’s address and deduplicates case-insensitively. Replies to sent messages target the original recipients. Bcc is never copied into a reply. Forwarding copies selected original attachments from authorized source bytes and starts without recipients. MIME carries Unicode subjects, threading headers and calendar scheduling types. Gmail replies and reply drafts additionally retain the authorized provider thread ID when the subject is unchanged apart from reply prefixes; forwards and changed subjects start a new conversation. Attachments are limited to 10 MiB in the browser and outbound MIME to 20 MiB. Message preview and JSON request reads have bounded server-side limits. Aggregate attachment bytes are checked before MIME encoding, including forwarded copies. Drafts are saved in the provider mailbox and can be opened, edited, and sent from Drafts. Gmail edits update the existing draft. Graph does not support updating a draft with raw MIME, so an edit first creates a confirmed replacement and deletes the unchanged original with an ETag fence. A deletion conflict retains both drafts and reports that partial outcome. Untouched draft HTML, inline image content IDs, threading headers and attachment bytes are preserved. Original HTML is recovered from the authorized provider source for saving; previews still use sanitized content. Save edited drafts before using the native Drafts send action. HTML previews use a script-disabled sandbox and a restrictive CSP; remote images are blocked. Original attachments download as files with nosniff. Personal calendar invitations use the same bounded iTIP parser as managed Mail. Accept, Decline and Tentative send a single-attendee METHOD:REPLY to the stored organizer, retaining UID, sequence and recurrence identity. No calendar event is copied into Tuturuuu, and ambiguous/delegated/cancelled requests do not expose RSVP controls.

OAuth setup and delivery verification

Configure these server-only variables for the Mail deployment:
  • MAIL_CONNECTED_ACCOUNTS_KEY: a dedicated 32-byte encryption key encoded as 64 hexadecimal characters. Credentials and PKCE verifiers use AES-256-GCM with the authenticated user ID as associated data. Keep the key stable; changing it requires reconnecting accounts.
  • MAIL_GOOGLE_CLIENT_ID, MAIL_GOOGLE_CLIENT_SECRET, MAIL_GOOGLE_REDIRECT_URI.
  • MAIL_MICROSOFT_CLIENT_ID, MAIL_MICROSOFT_CLIENT_SECRET, MAIL_MICROSOFT_REDIRECT_URI.
Both registered callback URLs point to the Mail origin’s /api/v1/mail/connected/callback. Enable the Gmail API and grant https://www.googleapis.com/auth/gmail.modify. This is a restricted Google scope; complete the provider’s required consent verification before public rollout. Register the Microsoft app for organizational and personal Microsoft accounts when both are intended. Scopes: User.Read, Mail.ReadWrite, Mail.Send, and offline_access; Mail requests no Calendar scopes. OAuth uses PKCE, random hashed state, an HttpOnly SameSite browser cookie, a 10-minute expiry, atomic state consumption, and renewed personal-workspace access checks during the callback. Private connection, OAuth and send-claim tables have RLS and no anon/authenticated grants. Expired tokens refresh on the server; refresh writes compare revisions; only a newer scoped, valid winner can resolve a conflict. An unauthorized read can refresh and retry once; mutations are not automatically replayed. Disconnect removes local access and encrypted credentials without changing provider messages. Provider-side application consent can be revoked in Google/Microsoft account settings. Sends reserve an account/request claim; acceptance records a receipt. Proven pre-send failures or explicit provider rejections release only their matching sending claim for user retry. Completed requests acknowledge without resending; in-flight, unknown or post-acceptance failures require provider Sent checks. Provider acceptance is not a delivery receipt. Test only with disposable mail and meeting fixtures unless real-account actions have been explicitly authorized. Regression coverage: apps/mail/src/lib/mail/connected/*.test.ts, connected composer/reply tests, managed mail-reply-actions.test.ts, and the private-table checks in apps/database/supabase/tests/connected-mail-accounts.sql. The DB gate compares the three generated private Mail table contracts against committed types, ignoring only presentation; it does not certify unrelated schema drift. Require the Mail and affected workspace CI type-check, lint, tests and real app builds for the exact authored commit. Synthetic tests do not prove provider consent, delivery or RSVP; verify those with configured test accounts after deployment.

Complete-schema database gate

The Connected Mail database contract workflow runs on normal pull-request updates that change migrations, its fixture or verifier. It checks out the exact PR head, applies every tracked Supabase migration in an isolated hosted database, and requires all 10 assertions in connected-mail-accounts.sql to pass without skips. Its enabled-gate check fails if the workflow is disabled. After TAP succeeds, it generates actual public, private and storage schema types and uploads connected-mail-accounts-types-<head> for three days. Inspect the exact run’s reset, fixture-labelled TAP, type-generation and owned-project stop results, then compare the downloaded types with the authored private Mail tables before merging. A generated artifact or staging wrapper success alone does not prove the entire contract. This disposable gate does not apply production migrations or establish provider consent, mailbox delivery or RSVP outcomes.