Skip to main content

Feature Specification: Email Providers

Feature ID: email-providers Branch: feat/email-providers Status: Draft (v1.1) Jira Epic: EW-650 Created: 2026-05-26 Last updated: 2026-05-28 Version: v1.1 — added §11 React-Email/composer UI, §12 agent-to-agent communication, §13 sibling-spec cross-references. v1 sections 0–10 unchanged. Owner: Product (Ruslan) Related code today:

  • AI provider plugin pattern: packages/agent/src/facades/ai.facade.ts, packages/plugin/src/abstract/base-ai-provider.ts
  • Pluggable capability registry: packages/agent/src/plugins/services/plugin-registry.service.ts, packages/plugin/src/contracts/capabilities/
  • Tenant settings + secrets pipeline: apps/api/src/settings/, packages/agent/src/plugins/services/plugin-settings.service.ts
  • Agent entity (per-Agent committer email column): packages/agent/src/entities/agent.entity.ts
  • Existing one-off transactional mail surface: packages/agent/src/notifications/email/ (single hardcoded provider — Postmark in early phases, Resend later — not pluggable today)
  • AGENT_GIT_FACADE binding (consumes Agent.committerEmail): apps/api/src/agents/agents.module.ts

Scope of this document: product behavior — what users see and do, how Email addresses get registered at the tenant level, how an Agent gets bound to one or more inbound + outbound addresses, what the plugin contract looks like, and how it integrates with the agent-run path. Implementation details live in plan.md (TBW); architecture context references agents-skills-tasks.md (Email surface is additive to the same plugin registry).

Hard rule (additive only): existing one-off mail surfaces (password-reset emails, mission notifications, etc.) keep working unchanged. The new Email Providers surface adds a tenant-managed inbox layer on top of that, plus per-Agent assignment.


0. Why this exists​

Today the platform sends a handful of transactional emails (password resets, OAuth notifications, mission-failure alerts) through a single hardcoded provider. There is no concept of:

  1. Tenant-managed addresses. An operator can't say "register agents@example.com as an outbound address" — the from-address is whatever the platform's default provider sends as.
  2. Inbound email. Nothing in the platform receives email today. Replies bounce or get black-holed.
  3. Per-Agent email identity. When an Agent commits to a Work's git repo, the commit author email is a placeholder (<slug>@agents.ever.works) because there's nowhere to put a real address. The FU-13 follow-up (this PR) added a committerEmail column on the Agent — that column wants a managed inbox to back it.
  4. Multi-provider redundancy. The product wants choice + failover across Mailchimp, Mailgun, Postmark, Resend, Sendgrid (et al.). One hardcoded provider can't deliver that.

This spec defines the tenant-level Email Addresses surface, the plugin contract for sending + receiving, the Agent assignment model, and the agent-run integration that brings them all together.


1. Personas + use cases​

PersonaUse case
OperatorRegisters pm@acme.com as an outbound address backed by Postmark; assigns it to the "Project Manager" Agent so its standups land in inboxes that look human.
OperatorRegisters support@acme.com as an inbound address backed by Mailgun routes; assigns it to a "Support Triage" Agent so incoming emails become Tasks.
AgentSends a daily summary to a Task's watchers from its assigned outbound address. The commit author email on its git commits matches.
AgentReceives a reply on its inbound address, parses it as a chat message on the originating Task, posts back.
OperatorA provider has an outage. Drains traffic to a secondary provider on the same address without touching Agent configuration.

2. Surfaces — what the user sees​

2.1 Tenant Settings → Email Addresses​

A new sub-page under Settings → Integrations:

  • Outbound addresses list. Each row: address · provider · fromName? · verified? · defaultForReplies? · per-row spend rollup · "Edit" / "Disable" / "Remove".
  • Inbound addresses list. Same shape minus fromName + defaultForReplies; gains routingRule (regex over subject/from for routing into Mission/Idea/Work/Task scopes).
  • Add address wizard:
    • Step 1: pick direction (Outbound / Inbound / Both).
    • Step 2: pick provider (filtered by capability — see §3.1).
    • Step 3: address + provider-specific settings (domain, sending-domain DNS check, webhook URL the operator must register at the provider, etc.).
    • Step 4: verification — outbound sends a confirmation email; inbound asks the user to send a test email to the address.
  • Bulk-import from existing provider account (Postmark / Resend / Sendgrid API — list addresses already configured at the provider and offer to claim them).

2.2 Per-Agent assignment​

Agent detail page → "Identity" section gains an "Email addresses" panel:

  • Outbound (1 default + N additional). Default is what from: resolves to for Agent-originated emails. Operator can pick a different default per Agent.
  • Inbound (0..N). Incoming mail on these addresses dispatches a Task / Chat message via the agent-run path (§4).

Both are searchable dropdowns over the tenant's registered addresses (analogous to FU-8's skill-binding picker).

2.3 Plugins page​

The existing Plugins page (/settings/plugins) lists every plugin grouped by capability. Email providers join the grid under a new "Email Providers" group:

  • Mailchimp Transactional (Mandrill)
  • Mailgun
  • Postmark
  • Resend
  • Sendgrid
  • (any future provider plugin that declares the capability)

Each plugin is enabled / disabled / configured per-tenant the same way AI providers are today. Settings include API keys, sending domains, webhook secrets, default sender, etc.


3. Plugin contract​

3.1 Capability declaration​

Email Providers register against two new plugin capabilities:

  • PLUGIN_CAPABILITIES.EMAIL_OUTBOUND — provider can send mail.
  • PLUGIN_CAPABILITIES.EMAIL_INBOUND — provider exposes inbound webhooks / IMAP / API polling.

A provider may declare one or both. Mailchimp Transactional + Mailgun + Postmark + Sendgrid declare both; Resend currently declares outbound only.

Manifest everworks.plugin block:

{
"id": "postmark",
"name": "Postmark",
"category": "email-provider",
"capabilities": ["email-outbound", "email-inbound"],
"settings": {
"apiKey": { "type": "string", "x-secret": true, "x-envVar": "POSTMARK_API_KEY" },
"defaultSenderDomain": { "type": "string" },
"inboundStreamId": { "type": "string", "optional": true }
}
}

3.2 Interface — IEmailOutboundPlugin​

interface IEmailOutboundPlugin extends IPlugin {
sendEmail(input: EmailSendInput, options: EmailOptions): Promise<EmailSendResult>;
verifyAddress(address: string, options: EmailOptions): Promise<EmailVerification>;
listDeliveryEvents(filter: EmailEventFilter, options: EmailOptions): AsyncGenerator<EmailDeliveryEvent>;
}

interface EmailSendInput {
from: string; // canonical address (tenant-registered)
fromName?: string;
to: readonly string[];
cc?: readonly string[];
bcc?: readonly string[];
subject: string;
bodyText: string;
bodyHtml?: string;
replyTo?: string;
attachments?: readonly EmailAttachment[];
metadata?: Record<string, string>; // forwarded to provider tags
/** Idempotency key for retries — required when called from agent-run path. */
messageRef: string;
}

interface EmailSendResult {
provider: string;
providerMessageId: string;
accepted: readonly string[]; // RFC 5321 mailbox addresses the provider accepted
rejected: readonly { address: string; reason: string }[];
}

3.3 Interface — IEmailInboundPlugin​

interface IEmailInboundPlugin extends IPlugin {
/**
* Server-side webhook handler exposed at `/api/email/inbound/:pluginId`.
* The platform decodes provider-specific webhook payloads (Mailgun
* signed forms, Postmark JSON, Sendgrid Event Webhook, etc.) into a
* canonical `EmailInboundMessage` shape.
*/
parseInboundWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<EmailInboundMessage>;
/**
* Verify a webhook signature so a third party can't spoof inbound mail.
* MUST throw on signature mismatch.
*/
verifyWebhookSignature(rawBody: Buffer, headers: Record<string, string>): void;
}

interface EmailInboundMessage {
provider: string;
providerMessageId: string;
from: string;
to: readonly string[];
subject: string;
bodyText: string;
bodyHtml?: string;
attachments?: readonly EmailAttachment[];
receivedAt: Date;
/** Provider-specific metadata (spam score, DKIM result, etc.) */
metadata: Record<string, unknown>;
}

3.4 EmailFacade​

A new EmailFacadeService in packages/agent/src/facades/email.facade.ts follows the existing facade pattern (mirrors AiFacadeService):

  • Resolution priority: tenant address override → user default → first-enabled provider for the capability.
  • Settings resolution via the existing 4-level hierarchy (Work → User → Admin → Plugin defaults).
  • Per-call EmailFacadeOptions carries userId, workId?, agentId?, taskId? for attribution (same shape as FacadeOptions).
  • Records a PluginUsageEvent per send with capability EMAIL so the per-Agent + per-Task spend rollups work without additional plumbing.

4. Data model​

4.1 New tables​

tenant_email_addresses
id uuid PK
userId uuid FK users -- tenant-scoped for now (multi-tenant lands later)
address varchar(254) NOT NULL -- RFC 5321 mailbox max
direction varchar(16) NOT NULL -- 'outbound' | 'inbound' | 'both'
pluginId varchar(64) NOT NULL -- 'postmark' | 'mailgun' | ...
providerSettings jsonb NOT NULL -- per-plugin shape: from-name, routing-tag, webhook secret, etc.
verified boolean DEFAULT false
verificationToken varchar(64) NULL
defaultForReplies boolean DEFAULT false
disabledAt timestamp NULL
createdAt timestamp
updatedAt timestamp
UNIQUE(userId, address, direction)

agent_email_assignments
id uuid PK
agentId uuid FK agents ON DELETE CASCADE
emailAddressId uuid FK tenant_email_addresses ON DELETE CASCADE
direction varchar(16) NOT NULL -- 'outbound' | 'inbound'
priority int NOT NULL DEFAULT 100 -- lower = higher precedence; default outbound = lowest priority among outbound assignments
createdAt timestamp
UNIQUE(agentId, emailAddressId, direction)

email_messages
id uuid PK
userId uuid FK users
agentId uuid NULL FK agents -- attribution (Phase 15.6 mirror)
taskId uuid NULL FK tasks
emailAddressId uuid FK tenant_email_addresses
direction varchar(16) NOT NULL -- 'outbound' | 'inbound'
providerMessageId varchar(200)
from varchar(254)
toAddresses jsonb
subject varchar(998) -- RFC 5322 line max
bodyText text
bodyHtml text NULL
metadata jsonb
sentAt timestamp NULL -- outbound only
receivedAt timestamp NULL -- inbound only
deliveryStatus varchar(16) NULL -- 'accepted' | 'delivered' | 'bounced' | 'complained' | 'open' | 'click'
createdAt timestamp
INDEX (userId, agentId, createdAt)
INDEX (taskId, createdAt)

4.2 Reuses​

  • PluginUsageEvent gains no new columns — Email usage rows use the existing shape with capability='email'.
  • Attachments reuse the existing work_knowledge_upload table (same path as Task attachments, FU-5).

5. Agent-run integration​

5.1 Outbound​

A new Agent tool sendEmail registers when:

  • the Agent has at least one outbound agent_email_assignments row, AND
  • permissions.canCallExternalTools is true (mirrors searchWeb/screenshot/extractContent gate)

The descriptor's invoke(args) routes through EmailFacadeService.send with the Agent's default outbound address (or a specific assigned address if the model passed args.from). Sent messages persist to email_messages with agentId set; the existing Activity log gets a new EMAIL_SENT action.

5.2 Inbound → Task​

A new dispatcher contract AGENT_INBOUND_EMAIL_DISPATCHER (mirrors the existing AGENT_CHAT_REPLY_DISPATCHER) gets invoked when an inbound webhook lands.

Destination = the address the webhook was verified for. Before verifying, EmailFacadeService.parseInbound binds the webhook to the first recipient (from the plugin's extractInboundRecipients) that is a registered inbound address of the webhook's plugin, and verifies the signature with the secret at that address owner's scope. That is the owner's own secret when they saved one — it replaces the platform secret at their scope — or the admin/env secret otherwise. The dispatcher delivers to that address (payload.recipient) only, and refuses it unless it is still an active inbound address of that plugin and owner. It never re-derives a destination from the payload's to list: any tenant can sign a webhook with their own per-user key and list a victim's address beside their own. A webhook with no such recipient is acknowledged but not dispatched.

The default binding:

  1. Resolves the destination Agent via agent_email_assignments.direction='inbound' on that address.
  2. Resolves or creates a Task — either by parsing the subject for a Task slug ([ACME-123]) or by spawning a fresh Task with the email as its description.
  3. Persists the inbound row to email_messages.
  4. Enqueues agent-task-execute (existing Trigger.dev job) so the Agent processes the email like any other Task.

Spec gap: how do we attribute multi-recipient inbound mail (e.g. one email lands on triage@ + manager@)? Default v1: the first recipient that is a registered address of the webhook's plugin (the one the signature is verified for), then its lowest-priority assignment; future work covers fan-out, which would need each extra address's owner secret verified too.

5.3 Per-Agent commit identity (FU-13 closing loop)​

When the AGENT_GIT_FACADE binding looks up the Agent's committerEmail:

  • if set to a tenant-registered outbound email → use as-is.
  • if set to a free-form string → use as-is (operator opt-in to deliver to wherever they want).
  • if null → synthesize <slug>@agents.ever.works (today's behaviour from FU-13).

The Email Addresses dropdown on the Agent identity panel offers the registered addresses as the canonical pick so operators don't have to memorize the column-vs-tenant-address distinction.


6. Providers — initial list​

Each provider lands as its own plugin under packages/plugins/<name>/:

PluginCapabilitiesAuthNotes
mailchimp-transactionaloutbound + inboundAPI key + webhook secretInbound via Mandrill webhooks; outbound via Mandrill Send API
mailgunoutbound + inboundAPI key + signing-key (webhook signature) + sending domainInbound via "Routes" → HTTP POST to our webhook
postmarkoutbound + inboundserver token + inbound webhook secretInbound via Postmark Inbound Streams
resendoutbound only (v1)API keyResend's inbound is in private beta — add when GA
sendgridoutbound + inboundAPI key + Event Webhook signingInbound Parse Webhook

Plus a fallback local-smtp plugin for dev / self-host that wraps nodemailer with no inbound (no public webhook).

6.1 Why a separate plugin per provider​

  • Auth + webhook signature mechanics differ enough that a single "generic SMTP" abstraction would either be lowest-common-denominator (no event tracking) or full of branches.
  • Tenant-level enable/disable + per-tenant API keys is the existing plugin-system idiom; no new infrastructure.
  • Failover across providers is then a list-of-plugins-in-priority-order at the Email Address level, not a custom config.

7. Webhook surface​

apps/api/src/email/email.controller.ts (new):

POST /api/email/inbound/:pluginId
POST /api/email/events/:pluginId -- provider delivery events (bounces, opens, etc.)
GET /api/email/verify/:tokenId -- tenant address verification click-through

Each POST route dispatches to the plugin's verifyWebhookSignature + parseInboundWebhook / parseEventWebhook. Auth: webhook secret stored per plugin instance; rejection on signature mismatch returns 401 with a generic body (Invalid webhook signature) — don't leak which secrets are wrong: the plugin's own reason is only logged (EmailFacadeService.verifyWebhookSignature). A plugin that cannot load answers 503, so the provider retries; the webhook is never accepted unverified.

Rate-limited per plugin id (default 600/min — reduces blast radius if a provider mis-routes traffic).


8. Out of scope (v1)​

  • Reply-by-email threading. Inbound messages create or join Tasks via subject parsing; SMTP-level threading (In-Reply-To headers) lands in v2.
  • Templates. Send paths in v1 use plain text + HTML body inlined by the caller. Template management (Mailchimp templates, Postmark templates, etc.) lands in v2.
  • Calendar / contacts. Out of scope for this spec — see future calendar-providers spec.
  • Multi-tenant routing. Today addresses are scoped per userId. Multi-tenant work groups land later — see the platform's multi-tenancy track.
  • Encryption / S/MIME / PGP. Plain SMTP / API submission only in v1.

9. Acceptance criteria​

  • Operator can register an outbound address backed by Postmark and send a verification email that lands in their inbox within 60s.
  • Operator can register an inbound address backed by Mailgun and confirm a test email round-trips into an email_messages row within 5s of arrival.
  • Operator can assign an outbound address to an Agent; the Agent's git commits surface that address as the committer email.
  • Operator can switch the provider behind an outbound address without changing the Agent's configuration (failover validated).
  • An Agent with the sendEmail tool descriptor invokes it from a task run and the resulting PluginUsageEvent carries agentId + taskId for the spend rollup.
  • An inbound message dispatches a Task and the originating Agent picks it up via the existing agent-task-execute job.
  • All five v1 providers (Mailchimp, Mailgun, Postmark, Resend, Sendgrid) declare the EMAIL_OUTBOUND capability; four of them declare EMAIL_INBOUND (Resend deferred).

  • Calendar providers (separate spec) — same plugin pattern, different verbs (createEvent, listEvents).
  • Voice / SMS providers (separate spec) — same pattern, Twilio + MessageBird + Plivo + Vonage.
  • Multi-tenant ownership — tenant_email_addresses is single-tenant per userId in v1; promote to organization-owned when the multi-tenancy track lands.

11. React-Email + composer UI integration (v1.1 — additive)​

Why this section was added: the v1 spec defined the data model + plugin contract but left the rendering surface vague. Operators want a rich composer for one-off agent emails, and outbound templates should be authored as React components so designers can iterate without Handlebars. This section is additive — Handlebars templates from the existing mail-providers/ surface keep working unchanged; React-Email is an additional template medium that the EmailFacadeService can render.

11.1 React-Email components (server-side render)​

The platform adopts @react-email/components and @react-email/render for outbound email composition. Both packages live in apps/api so rendering happens on the server before handoff to the provider plugin's sendEmail.

  • Templates live under apps/api/src/email/templates/react/<slug>.tsx (TSX). Each exports a default React component that accepts a typed Props object.
  • The EmailFacadeService.send accepts an optional template: { kind: 'react', slug: string, props: T } argument. When provided, the facade calls render(<Template {...props} />, { pretty: false }) to produce the HTML body and render(<Template {...props} />, { plainText: true }) for the text fallback. The provider plugin still receives bodyHtml + bodyText in the canonical EmailSendInput shape — providers never see React.
  • Existing Handlebars templates under apps/api/src/templates/*.hbs continue to work via mail-providers. The two systems coexist; choice is per-call.

11.2 Composer UI (web app)​

A new in-app composer lives under agent-inbox-ui and is referenced here for completeness:

  • Rich-text + Markdown editor (operator-facing, not React-Email TSX). On submit, the composed content lands in email_messages.bodyText + bodyHtml.
  • Template picker: drops in any registered React-Email template, pre-fills the form with the template's Props shape (via a Zod schema each template co-locates).
  • Live preview pane: iframe srcDoc={render(<Template {...props} />)} renders the React-Email output as the operator types.

11.3 Novu in-app inbox widget (optional)​

For tenants that opt in via the novu plugin (see notification-channels), the web app can mount @novu/react as an alternative in-app notification surface alongside the platform's native notifications panel. The native panel from notifications v1 remains the default; Novu is additional, not a replacement.


12. Agent-to-agent communication via email (v1.1 — additive)​

Why this section was added: v1 framed inbound email as "external mail → Task". v1.1 elevates email to a first-class peer of Tasks as a communication medium between agents. Tasks remain the canonical work unit; email becomes a parallel addressable channel for inter-agent (and human↔agent) conversation.

12.1 Conceptual model​

Each Agent with at least one inbound assignment exposes a stable mailbox address. Any sender — another Agent, a human user, or an external system — can reach the agent at that address. The receiving Agent's run loop processes inbound mail with the same scheduling primitives as Task execution.

SenderReceiverMechanism
Agent A → Agent BAgent BsendEmail tool call with to: ["<agent-b-slug>@inbound.acme.com"]. Webhook fan-in → Agent B's inbound dispatcher
Human → AgentAgentStandard SMTP send to the agent's address; provider webhook → inbound dispatcher
External AI → AgentAgentSame as human — agent inbox is just an SMTP address
Agent → Human (reply)HumansendEmail from the agent's outbound assignment; threading via In-Reply-To (v2)

12.2 Dispatch semantics​

The inbound dispatcher gains two modes (set per agent_email_assignments row):

  • task-spawn (v1 default). Each inbound message creates or joins a Task. Existing behavior from §5.2.
  • conversation (v1.1 new). Inbound messages append to a per-Agent EmailConversation thread (keyed by (agentId, threadKey) where threadKey is derived from In-Reply-To or message subject). The agent's chat-reply path processes the message the same way it processes chat messages — no new Task is created. Use this when the goal is ongoing dialogue, not a discrete unit of work.

The mode is a property of the assignment, not the address — one address can serve multiple agents in different modes (rare but supported).

12.3 Data model deltas​

email_conversations
id uuid PK
agentId uuid FK agents ON DELETE CASCADE
threadKey varchar(200) -- derived from In-Reply-To header or normalized subject
participants jsonb -- [{address, displayName?}, …]
lastMessageAt timestamp
createdAt timestamp
UNIQUE(agentId, threadKey)

email_messages gains a nullable FK conversationId (NULL when the message is bound to a Task instead). One of taskId / conversationId is always set; the other is NULL.

12.4 New messageAgent tool descriptor​

In addition to sendEmail (raw outbound, §5.1), Agents with canCallExternalTools=true get a higher-level messageAgent tool:

interface MessageAgentInput {
targetAgentId: string; // resolved via agent registry
subject: string;
body: string; // plain text; React-Email template optional
attachReferences?: { workId?: string; taskId?: string; missionId?: string }[];
}

The descriptor resolves the target agent's primary inbound address and calls EmailFacadeService.send with appropriate routing metadata so the receiving end auto-routes into conversation mode. This gives agents a clean "send a message to peer agent" verb without making them assemble RFC 5321 addresses by hand.

12.5 Why not just use Tasks for inter-agent messaging?​

  • Tasks are units of work (have lifecycle: created → in-progress → done → archived). A conversational ping ("FYI, the deploy finished") doesn't fit that shape.
  • Email is the universal cross-system addressing scheme. Agents reachable by email are reachable by humans, by external AI agents, by webhooks, and by other Ever Works tenants — all without bespoke integration code.
  • The Agent's existing chat-reply path is the right execution model for conversational input; the conversation mode reuses it without inventing a new dispatcher.

13. Cross-references to sibling specs (v1.1)​

The notifications/communications track is now decomposed into four sibling specs. This spec (email-providers) owns email-specific concerns; the siblings pick up adjacent surfaces:

SpecOwnsJira Epic
email-providers (this doc)Tenant email addresses, per-Agent assignment, Email plugin contract, EmailFacadeService, React-Email templatesEW-650
notification-channelsMulti-channel notification plugin contract (Discord/Slack/Telegram/WhatsApp/Novu), channel router, fanout/failoverTBD
event-subscriptionsPer-user per-event delivery preferences extending notifications v1 (mute, quiet hours, channel selection)TBD
agent-inbox-uiPer-agent inbox views, composer with React-Email preview, address-management wizardTBD

All four share the same constitution gates and the same plugin-registry foundation. The dependency graph is:

agent-inbox-ui ──▶ email-providers ──┐
├─▶ event-subscriptions ──▶ notifications v1
notification-channels ────────────────┘

email-providers and notification-channels are independent (can ship in either order). event-subscriptions is the user-facing aggregator and depends on at least one channel surface (in-app via v1, plus any of email/Discord/Slack/etc.).