feat(inbox): email provider registry and provider accounts (G-P4-2, part 1)
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),EmailProviderRegistrybehind theINBOX_EMAIL_PROVIDER_REGISTRYtoken (contracts) so provider plugins inject it optionally. inbox_provider_account(migration ×3 dialects, idempotent, advisory lock on Postgres,downdrops): public config JSON + secrets sealed byInboxSecretBox(AES-256-GCM, AAD bound to purpose + row id,ENCRYPTION_KEYor a non-defaultJWT_SECRET-derived key, never a per-process random key — theEverWorksSecretBoxscheme with its own derivation).configEncryptedis@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'sconfigSchema(unknown fields refused, types/enums/formats/https-only URIs); secrets are write-only (left out or empty = kept,null= cleared);generatedsecrets (Postmark's Basic-auth credential) are created server-side and answered once. Tenant-wide accounts needALL_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_ENABLEDin.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.tson 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, exacttrue, one log line for a bad value, loaded afterInboxPlugin).- Not run locally (owner rule for this run: review + merge, full suites once on
developat the end).tsc --noEmitof the inbox plugin (sources + specs, strict, with the workspace path map) is clean;prettier --check+cspellclean 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) andEmailProviderRegistrybehind theINBOX_EMAIL_PROVIDER_REGISTRYtoken for provider plugins to inject. - Adds the
inbox_provider_accounttable (Postgres, MySQL, SQLite migrations): public config JSON plus secrets sealed with AES-256-GCM (InboxSecretBox,ENCRYPTION_KEYor a derived non-defaultJWT_SECRETkey). Secrets are write-only: never answered by the API, kept when omitted, rotated onnull, generated values shown once. - Adds
GET /api/inbox/providersand CRUD + verify routes for provider accounts, guarded byINBOX_MAILBOX_MANAGEand active org membership; tenant-wide accounts needALL_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
InboxEmailTransportResolverwith the coreEmailTransportRegistryso 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, andcspellare clean on touched files.