Skip to content

Email Connector — Provider-Neutral Transport Facade

Documents the @cinatra-ai/email-connector subsystem introduced in Phase 302 and completed in Phase 303. Read alongside mcp-patterns.md, llm-orchestration.md, and docs/developer/extensions.md (the kind:"connector" extension kind).

Before Phase 302, email transport was Gmail-hardcoded: src/lib/email-system.ts + connector-gmail each implemented the Phase 259 dev-mode recipient override separately, the orphan packages/asset-email/ carried a dead MCP spec, and adding a second provider (SMTP / SES / Outlook) would have meant rewriting every caller. The facade makes email transport provider-neutral: callers send through one chokepoint, providers register against one interface, and the routing chain picks the destination connector.

caller (trigger-email-send-use-cases.ts / email_send MCP primitive / chat)
└─ sendEmailThroughSystem(msg, opts) @cinatra-ai/email-connector/facade
├─ resolveConnectorId(opts) src/lib/register-email-providers.ts
│ 1. explicit connectorId
│ 2. explicit senderIdentityId → objects_get → identity.connectorId
│ 3. userId → first @cinatra-ai/email:sender-identity owned by user
│ 4. orgId → first @cinatra-ai/email:sender-identity owned by org
│ 5. first registered connector
├─ applyDevModeOverride(msg) (centralized — was duplicated pre-302)
├─ connector.send(msg, { userId }) e.g. gmailEmailConnector → Gmail API
└─ saveSentEmailObject(...) best-effort → @cinatra-ai/email:sent-email object
FileRole
extensions/cinatra-ai/email-connector/src/contract.tsEmailConnector interface + EmailSystemMessage / EmailSendReceipt / EmailReplyMatch / EmailConnectorId (types-only; providers import type it)
extensions/cinatra-ai/email-connector/src/registry.tsIn-memory emailConnectorRegistry + registerEmailConnector + listInstalledEmailConnectors
extensions/cinatra-ai/email-connector/src/facade.tssendEmailThroughSystem / findReplyThroughSystem chokepoint + EmailSystemDeps (host-injected routing + dev-mode override + best-effort sent-email writer)
extensions/cinatra-ai/email-connector/src/mcp/module.tsemail_send MCP primitive (createEmailModule) — one-shot transactional send for chat + ad-hoc agents
extensions/cinatra-ai/gmail-connector/src/email-connector.tsgmailEmailConnector — the first EmailConnector impl, wraps the existing gmail functions
src/lib/register-email-providers.tsBoot wiring: configureEmailSystem(deps) + registerEmailConnector(gmailEmailConnector). The routing chain + dev-mode override + saveSentEmailObject live here (host knows the DB; the facade does not)
src/lib/email-system-persistence.tsThe email_send_events ledger read/write helpers (moved here from the deleted packages/asset-email/send-events.ts in Phase 302)

A provider package implements EmailConnector and exports a singleton:

import type { EmailConnector } from "@cinatra-ai/email-connector"; // import TYPE only
export const myEmailConnector: EmailConnector = {
definition, // EmailConnectorDefinition (id/name/slug/settingsHref/caps)
send(msg, opts), // → EmailSendReceipt
findReply(opts), // → EmailReplyMatch | null
getStatus(opts), // → { status: "connected"|"incomplete"|"not_connected", ... }
listFromAddresses?(), // optional — aliases / verified identities
};

Then register it at boot in src/lib/register-email-providers.ts:

registerEmailConnector(myEmailConnector);

The import type discipline is enforced by an ESLint consistent-type-imports rule on extensions/cinatra-ai/*-connector/ + a regression test (email-connector/src/__tests__/import-boundary.test.ts) — a runtime import { EmailConnector } would pull the facade registry into the provider bundle and defeat pluggability.

Four provider-neutral object types registered statically in packages/objects/src/integration/register-types.ts (registerEmailObjectTypes):

TypeIdentity keyWritten by
@cinatra-ai/email:sender-identity<connectorId>:<fromEmail>user / agent (routing input — the chain reads this)
@cinatra-ai/email:sent-emailidempotencyKeyfacade saveSentEmailObject after every successful send
@cinatra-ai/email:received-replyinternetMessageId (fallback <connectorId>:<providerMessageId>)reply-watcher (future)
@cinatra-ai/email:thread<connectorId>:<providerThreadId>groups sends + replies

sent-email references the email_send_events audit row by auditId. The object write is best-effort — it runs after connector.send() has already succeeded, so a failure is logged but never thrown back into the send path (the email was already delivered). Idempotency key email-send:<providerId>:<providerMessageId> makes repeated facade calls dedupe at the objects layer.

Routing chain semantics (Phase 303, codex-reviewed)

Section titled “Routing chain semantics (Phase 303, codex-reviewed)”
  • Step 2 (explicit senderIdentityId): a genuine not-found falls through to step 3 (the id was stale); a real lookup error (permission / backend / schema) throws — the facade refuses to silently mis-route an explicitly-chosen identity to a fallback connector.
  • Steps 3–4 (auto-resolve user/org): any objects-layer failure → console.warn + fall through (the caller did not pick these; best-effort is correct, but not silent).
  • Step 5: first registered connector (matches the pre-303 v1 behaviour).
  • The sender-identity list uses a 200-row page budget with client-side ownerLevel/ownerId filtering (objects_list has no data.<field> server filter; small per-org record counts make this safe; a structured-filter promotion is a future-scale TODO).

email_send is the one-shot transactional primitive (chat-callable, agent-callable) — distinct from email_outreach_send_initial_start which is the orchestrated batch path. Input: to[], subject, textBody, cc?[], bcc?[], replyTo?, fromName?, fromEmail?, connectorId?, senderIdentityId?. connectorId and senderIdentityId are not mutually-exclusive-enforced: the facade checks explicitConnectorId first, so connectorId wins if both are passed (documented precedence, friendlier than a hard reject). Reads actor.userId/actor.orgId (empty/whitespace treated as absent) so routing steps 3–4 resolve transparently.

  1. extensions/cinatra-ai/smtp-connector/ with cinatra.kind: "connector" + a register*Connector(deps) factory if it needs host-internal deps.
  2. Implement EmailConnector; export smtpEmailConnector.
  3. registerEmailConnector(smtpEmailConnector) in src/lib/register-email-providers.ts.
  4. Persist a @cinatra-ai/email:sender-identity object (connectorId: "smtp", fromEmail, ownerLevel/ownerId) so the routing chain picks it for that user/org.

Dev-mode recipient override + the sent-email object write are inherited for free — they live in the facade, not per-provider.