feat(inbox): email provider registry and provider accounts (G-P4-2, part 1)

Merged
EV
10d ago
+2672-2

Plan item G-P4-2 (part 1 of 2) — Gauzy ↔ Ever Works integration plan, 04-inbox-and-email.md §2, §3, §5.2, §5.5 / 06-gauzy-implementation-plan.md Phase 4.

Owned by 7dae55c0/gw-gauzy-email — other lanes please don't push to this branch.
Stacked on #10798 (G-P4-5 core transport seam): its commit shows here until #10798 merges; this PR's own diff is everything under packages/plugins/inbox/src/lib/email/, packages/contracts/src/lib/inbox-email.model.ts, the plugin list/matrix spec and the env docs.

What

A second plugin in @gauzy/plugin-inbox, InboxEmailPlugin, listed by apps/api/src/plugins.ts only when GAUZY_INBOX_ENABLED=true and GAUZY_INBOX_EMAIL_ENABLED=true (exactly; anything else keeps it off, logged once). Off = not loaded: no route, no table, no transport.

  • Provider plugin contract + registry (plan 04 §5.2): IEmailProviderPlugin (id, displayName, capabilities, configSchema, inbound.verify/parse, createTransport, verifyAccount), ParsedInboundEmail (the docs shape + cc, replyTo, inReplyTo, references, html, text, headers, spamScore, dmarc, envelopeRecipients), EmailProviderRegistry behind the INBOX_EMAIL_PROVIDER_REGISTRY token (contracts) so provider plugins inject it optionally.
  • inbox_provider_account (migration ×3 dialects, idempotent, advisory lock on Postgres, down drops): public config JSON + secrets sealed by InboxSecretBox (AES-256-GCM, AAD bound to purpose + row id, ENCRYPTION_KEY or a non-default JWT_SECRET-derived key, never a per-process random key — the EverWorksSecretBox scheme with its own derivation). configEncrypted is @ExportRedacted({ blank: true }).
  • API (INBOX_MAILBOX_MANAGE + active org membership, Cache-Control: no-store): GET /api/inbox/providers, GET/POST /api/inbox/provider-accounts, GET/PATCH /api/inbox/provider-accounts/:id, POST /api/inbox/provider-accounts/:id/verify. Config is validated against the provider's configSchema (unknown fields refused, types/enums/formats/https-only URIs); secrets are write-only (left out or empty = kept, null = cleared); generated secrets (Postmark's Basic-auth credential) are created server-side and answered once. Tenant-wide accounts need ALL_ORG_EDIT. One default outbound account per scope.
  • Transport resolver registered with the core EmailTransportRegistry (#10798): the named account (an Inbox reply's mailbox account) → the organization's default outbound account → the tenant's → null (SMTP chain unchanged). Disabled accounts, other tenants' accounts and non-sending providers are never used.
  • Env docs: GAUZY_INBOX_EMAIL_ENABLED in .env.sample, .env.docker, .env.compose. Docs: packages/plugins/inbox/EMAIL.md (+ a pointer at the end of the README).

Next (part 2, own PR): mailbox + email tables, the /api/inbox/inbound/:providerId/:accountId webhook, the built-in generic signed-webhook provider (and the Cloudflare platform route), the worker (sanitize, threading, attachments, spam/quarantine), EMAIL inbox items, reply/forward through EmailSendService.sendMessage. The Settings → Inbox → Providers form (renders configSchema) goes into inbox-ui once that package lands (sibling lane gw-gauzy-inbox).

Touches the sibling lane's package only additively: new src/lib/email/**, one export line in src/index.ts, a README pointer. No change to InboxPlugin, InboxModule or InboxService.

Tests

  • provider-config.spec.ts, email-provider.registry.spec.ts, inbox-secret-box.spec.ts.
  • inbox-provider-account.db.spec.ts on better-sqlite3: migration up/up-again/down; secrets never answered nor stored in the clear; kept on update; required secret can't be cleared; org/tenant/other-tenant/non-member scoping; connection test recorded; one default outbound per scope; resolver order (named → org default → tenant default → none; other tenant never; disabled skipped).
  • apps/api/src/plugins.matrix.spec.ts: Inbox-email switch matrix (only on top of the Inbox, exact true, one log line for a bad value, loaded after InboxPlugin).
  • Not run locally (owner rule for this run: review + merge, full suites once on develop at the end). tsc --noEmit of the inbox plugin (sources + specs, strict, with the workspace path map) is clean; prettier --check + cspell clean on the touched files.

🤖 Generated with Claude Code


Summary by cubic

Adds the first half of the Inbox email feature (G-P4-2): an email provider plugin registry, provider accounts with encrypted secrets, a management API, and a transport resolver that routes outbound email through the new core transport seam.

InboxEmailPlugin loads only when both GAUZY_INBOX_ENABLED=true and GAUZY_INBOX_EMAIL_ENABLED=true (exactly). When off, nothing is registered: no route, no table, no transport, and all email goes out over the existing SMTP chain.

What changed

  • Introduces the provider plugin contract (IEmailProviderPlugin) and EmailProviderRegistry behind the INBOX_EMAIL_PROVIDER_REGISTRY token for provider plugins to inject.
  • Adds the inbox_provider_account table (Postgres, MySQL, SQLite migrations): public config JSON plus secrets sealed with AES-256-GCM (InboxSecretBox, ENCRYPTION_KEY or a derived non-default JWT_SECRET key). Secrets are write-only: never answered by the API, kept when omitted, rotated on null, generated values shown once.
  • Adds GET /api/inbox/providers and CRUD + verify routes for provider accounts, guarded by INBOX_MAILBOX_MANAGE and active org membership; tenant-wide accounts need ALL_ORG_EDIT. One default outbound account per scope is enforced with database locks and partial unique indexes, with a 409 on a lost race; verify never re-enables a disabled account; config merges under a row lock so overlapping edits of different fields both survive. When an edit sets the default account, the scope lock is taken before the row lock.
  • Registers InboxEmailTransportResolver with the core EmailTransportRegistry so outbound mail resolves: named account → organization default outbound → tenant default outbound → SMTP fallback. Disabled or cross-tenant accounts are never used, and one that cannot build a transport does not block the next.

Tests

  • New spec suites for the provider config split, registry, secret box, transport resolver, provider account DB behavior (scoping, defaults, secrets, races), and the plugin switch matrix in plugins.matrix.spec.ts.
  • Suites not run locally per the owner rule; tsc --noEmit, prettier, and cspell are clean on touched files.