feat(inbox): mailboxes — allocation, custom domains, forwarding, members, provisioning (G-P4-1)
Plan item G-P4-1 — Inbox mailboxes (Gauzy ↔ Ever Works integration plan, 04-inbox-and-email.md §3, §5.1 / 06-gauzy-implementation-plan.md Phase 4).
Owned by 7dae55c0/gw-gauzy-email — other lanes please don't push to this branch.
Stacked on #10815 (inbound pipeline: it created inbox_mailbox / inbox_mailbox_member). This PR's own work: email/mailboxes/inbox-mailbox.{service,controller,dto,provisioner}.ts, mailbox-address.ts, inbox-employee.subscriber.ts, inbox_organization_setting (+ migration), the contract additions, the Docs hook, EMAIL.md.
Inside InboxEmailPlugin (off unless GAUZY_INBOX_ENABLED=true and GAUZY_INBOX_EMAIL_ENABLED=true).
What
- Platform addresses on
GAUZY_INBOX_DOMAIN(owner decision D12: one domain for every tenant, each environment its own), organization encoded in the local part:<first>.<last>.<orgSlug>(employee),<name>.<orgSlug>(organization, several),<team>.<orgSlug>(team). Slugs fold accents; a collision gets a numeric suffix on the name (ada.lovelace2.acme); a chosen name that is taken is a 409. WithoutGAUZY_INBOX_DOMAINplatform mailboxes are refused with a clear message. - Custom domains:
x@acme.comthrough an inbound provider account of the organization (or tenant-wide),PENDINGuntilPOST /:id/verifyfinds the DNS TXT_gauzy-inbox.<domain>=gauzy-inbox-verify=<token>(5 s timeout); a verified domain whose record disappears goesFAILED(the pipeline only delivers to VERIFIED custom-domain mailboxes).GAUZY_INBOX_DOMAINand the Docs capture domain are reserved. - Forwarding mailboxes (platform address + the
forwardFromsource for the instructions);GET /mailboxes/mineanswers the caller's mailboxes with forwarding instructions. - Members of organization / team mailboxes (
PUT /:id/members, READER / MANAGER, users of the organization only; an employee mailbox has none). - Relay secret rotation (answered once, scrypt digest in
inboundSecretHash, never answered). - Provisioning:
POST /provisioncreates the missing employee mailboxes (agent employees —employee.kind = 'agent'— never) and the defaultinbox.<orgSlug>mailbox, idempotently;PUT /settingsstores the per-organization opt-in (inbox_organization_setting, migration ×3), applied on every new employee through an ORM subscriber (afterEntityCreate→Subject→ provisioner, best effort, never failing the employee's creation). - One address namespace:
INBOUND_ADDRESS_NAMESPACE(contracts) is provided by the Inbox; the Inbox checksdocument_inbound_addressbefore taking an address, and the Docs plugin now asks the namespace (optional injection) before it takes a custom capture address — a 13-line additive hook inpackages/plugins/docs/src/lib/capture/inbound-address.service.ts, no behaviour change when the Inbox email features are off.
Not in this PR: the Settings → Inbox → Mailboxes / Providers UI (now that inbox-ui is on develop, a follow-up in that package), "Save to Documents", P4c agent routing (settings.everWorksAgentId is stored as free settings already).
Tests
mailbox-address.spec.ts (slugs, allocation, suffixes, domains) and inbox-mailbox.db.spec.ts on better-sqlite3: allocation for employee / organization / team with collisions and a taken chosen name, the Docs namespace, owner checks, no domain configured, forwarding + mine, custom domain (account required, reserved domain, PENDING → VERIFIED → FAILED through a stubbed TXT lookup), members (organization users only, not on employee mailboxes), secret rotation (digest only), provisioning (humans only, default mailbox, idempotent), the opt-in + employee-created hook (agents skipped), organization isolation. Not run locally (owner rule; full suites on develop at the end). Strict tsc --noEmit of plugin-inbox (sources + specs) clean; prettier, cspell and the egress static scan clean.
🤖 Generated with Claude Code
Summary by cubic
Adds Inbox mailbox management (/api/inbox/mailboxes) and the inbound email pipeline (webhook ingestion, processing worker, message API) to InboxEmailPlugin. Both stay off unless GAUZY_INBOX_ENABLED=true and GAUZY_INBOX_EMAIL_ENABLED=true are both set.
Mailboxes
- Platform addresses on
GAUZY_INBOX_DOMAINencode the organization in the local part (<first>.<last>.<orgSlug>,<name>.<orgSlug>,<team>.<orgSlug>); a collision gets a numeric suffix, a taken chosen name is a 409, and withoutGAUZY_INBOX_DOMAINplatform mailboxes are refused. The Inbox and Docs plugins share one address namespace, each claiming an address before saving so both never take one; the Docs hook also refuses capture addresses on the platform domain, and an address is judged free only when the lookup itself succeeded. - Custom domains need an inbound provider account; a mailbox is
PENDINGuntil its_gauzy-inbox.<domain>TXT record matches, a lost record flips it toFAILED, and verify changes only the checked mailbox. - Organization and team mailboxes take members (
READER/MANAGER, organization users only); the relay secret is answered once and stored as a scrypt digest. POST /provisioncreates missing employee mailboxes and the defaultinbox.<orgSlug>, serialized per organization under a named lock so overlapping runs never create a mailbox twice; a failed run rolls its transaction back. A per-organization opt-in auto-provisions new employees (never agents) via an ORM subscriber.
Inbound
- The generic provider signs with HMAC-SHA256 over
t.<raw body>(5-minute tolerance); a delivery is single-use — the pipeline marks it before storing and releases the mark when storing fails, so provider retries succeed but replays are refused. An oversized batch is refused whole (413), and a raw message is stored under a stable key (message id + content digest) so overlapping deliveries share one file. - Only DMARC-authenticated senders pass the allowlist; an allowlisted sender without a DMARC pass is held in the quarantine rather than dropped unseen. The webhook rate limit keys on the Express client address (honouring
trust proxy). - A worker sanitizes HTML server-side, stores attachments with sha256, threads by
In-Reply-To/References(else subject + participant within 30 days), and quarantines spam. Thread counts match the thread view (deleted, quarantined and failed inbound work excluded), recounted once a message reaches a final status and on release; replies and forwards recount too. Attachments are uploaded before the claimed state and rows are written in one transaction; email files use the deployment's storage settings, never a caller's tenant overrides. - Only users still active in the mailbox's organization count as recipients, readers or managers; reply/forward need current access, a reply to a sent message follows up with its recipients, and a forward carries every stored attachment. A failed reply/forward send marks the kept message
FAILEDinstead of leaving aQUEUEDorphan; a failed upload is an error so no row points at a missing file. Sent mail keeps the transport's provider message id in the result.