feat(inbox): mailboxes — allocation, custom domains, forwarding, members, provisioning (G-P4-1)

Merged
EV
10d ago
+2329-17

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. Without GAUZY_INBOX_DOMAIN platform mailboxes are refused with a clear message.
  • Custom domains: x@acme.com through an inbound provider account of the organization (or tenant-wide), PENDING until POST /:id/verify finds the DNS TXT _gauzy-inbox.<domain> = gauzy-inbox-verify=<token> (5 s timeout); a verified domain whose record disappears goes FAILED (the pipeline only delivers to VERIFIED custom-domain mailboxes). GAUZY_INBOX_DOMAIN and the Docs capture domain are reserved.
  • Forwarding mailboxes (platform address + the forwardFrom source for the instructions); GET /mailboxes/mine answers 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 /provision creates the missing employee mailboxes (agent employees — employee.kind = 'agent' — never) and the default inbox.<orgSlug> mailbox, idempotently; PUT /settings stores 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 checks document_inbound_address before 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 in packages/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_DOMAIN encode 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 without GAUZY_INBOX_DOMAIN platform 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 PENDING until its _gauzy-inbox.<domain> TXT record matches, a lost record flips it to FAILED, 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 /provision creates missing employee mailboxes and the default inbox.<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 FAILED instead of leaving a QUEUED orphan; 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.