feat(integration-ever-works, inbox): G-P4-6 part 1 — a Gauzy mailbox routed to an Ever Works agent hands its mail to the agent

Merged
EV
10d ago
+1258-6

Plan: Gauzy ↔ Ever Works integration, G-P4-6 EW agent bridge, part 1 (plan 04 §6 "Gauzy mailbox → EW agent"; 04 §5.3 step 7 "P4c: if the mailbox has everWorksAgentId, hand off to the integration"); contract CR-11 (POST /email/inbound, ever-works #2653). Needs G-P4-1 mailboxes (#10821, merged). Owned by 7dae55c0/gw-gauzy-followups — other lanes please don't push to this branch.

What

A Gauzy mailbox whose settings name an Ever Works agent hands every message it receives to that agent, where Ever Works dispatches it through the agent's inbound assignment (task-spawn or conversation); the spawned task syncs back like any task.

Seam (contracts + @gauzy/plugin-inbox, additive): INBOX_EMAIL_HANDOFFS (IInboxEmailHandoffs): plugins register a listener and are told (ids + the mailbox's free settings) about every delivered message — after PROCESSED, and after a quarantine release; a listener never fails or delays the delivery (errors logged). read(tenantId, emailId) answers a delivered inbound message of the tenant (addresses, threading headers, non-inline attachment metadata; nothing for another tenant, an outbound, quarantined, failed or deleted one). The Inbox depends on no listener; InboxEmailProcessor takes the seam as an optional dependency.

Bridge (@gauzy/plugin-integration-ever-works): EverWorksAgentEmailService

  • the listener only enqueues a durable outbound change (AGENT_EMAIL, the message id) for a mailbox with settings.everWorksAgentId (the agent's Ever Works id, or its agent employee's Gauzy id);
  • the pusher handler reads the message, resolves the agent through its AGENT link (still synced), checks the opt-in email:write scope, builds InboundEmail within the contract limits (subject 998, bodies 2 MB, ≤100 recipients / references, ≤20 attachments as metadata with url: null — Ever Works never fetches them), and POST /email/inbound with one Idempotency-Key per message; the answer is linked (AGENT_EMAIL: message ↔ EW email message, snapshot with the dispatch) and logged; a second push of the same message sends nothing;
  • refusals no retry changes — no email:write, an agent not paired here, no sender, the mailbox no longer routed — are skipped + logged (agent_email in the sync log); Ever Works' 422 domain_rule (no inbound assignment, D9 opt-in) reaches the pusher as a terminal refusal (dead letter + notice); where the Inbox email features are not loaded the change is deferred for a process that hosts them.
  • Flags: everything stays behind EVER_WORKS_ENABLED + GAUZY_INBOX_ENABLED + GAUZY_INBOX_EMAIL_ENABLED (off by default); no migration (the link type is a new value of an existing varchar column).

Part 2 (next PR): the optional read-only mirror of EW agent mail (email.message.* → EMAIL items, source = EVER_WORKS).

Tests

email/ever-works-agent-email.service.spec.ts (mock Ever Works + new testing/mock-ever-works.email.ts route): listener registration, only routed mailboxes queue, the hand-off once + link + log, agent named by its employee, the refusals, the terminal 422, deferral without the seam, the body limits. email/handoff/inbox-email-handoffs.spec.ts: listeners best-effort, read mapping and its refusals. Per the run rules not run locally (end-of-run full pass).

Checked locally: tsc --noEmit of both plugins with their strict config and with the API build's loose settings (strict: false), 0 errors in them; prettier on the new files; cspell; egress static-hostnames; no Ever hostnames in the diff.

🤖 Generated with Claude Code


Summary by cubic

Implements the first half of the Gauzy ↔ Ever Works agent email bridge (G-P4-6): a Gauzy mailbox whose settings name an Ever Works agent (everWorksAgentId, the agent's Ever Works id or the Gauzy id of its agent employee) hands every message it receives to that agent through POST /email/inbound; Ever Works dispatches it via the agent's inbound assignment, and the spawned task syncs back like any task. The hand-off is additive — the Inbox email delivery pipeline is unchanged, listeners can never fail or delay a delivery, and nothing runs where the Inbox features are off.

Hand-off seam (@gauzy/plugin-inbox)

  • New INBOX_EMAIL_HANDOFFS token: plugins register a best-effort listener notified of every delivered message (processed or quarantine-released) with the ids and the mailbox's free settings, and read the stored message themselves via read(tenantId, emailId), which answers only a delivered inbound message of the tenant — addresses, threading headers, non-inline attachment metadata — and null for anything else.
  • The processor takes the seam as an optional dependency; a listener error is logged and never blocks delivery.

Bridge (@gauzy/plugin-integration-ever-works)

  • EverWorksAgentEmailService enqueues a durable AGENT_EMAIL change per delivered message of a routed mailbox; the pusher handler resolves the paired agent, checks the opt-in email:write scope, and calls POST /email/inbound once per message (one Idempotency-Key), linking the message and logging the dispatch; bodies stay within the contract limits and attachments are sent as metadata with url: null.
  • A recovery sweep runs every 10 minutes (per connection lease) and queues messages delivered to routed mailboxes in the last 24 h that have neither a link nor a queued change yet, covering a restart between delivery and the queue write.
  • Refusals — no email:write, an agent not paired here, no sender, a mailbox no longer routed — are skipped and logged; Ever Works' 422 domain_rule (no inbound assignment) is terminal; where the Inbox features aren't loaded the change is deferred.
  • All behind EVER_WORKS_ENABLED + GAUZY_INBOX_ENABLED + GAUZY_INBOX_EMAIL_ENABLED (off by default); no migration (AGENT_EMAIL is a new value of an existing varchar column).