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).
Why it exists
Section titled “Why it exists”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.
Architecture overview
Section titled “Architecture overview”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 objectKey files
Section titled “Key files”| File | Role |
|---|---|
extensions/cinatra-ai/email-connector/src/contract.ts | EmailConnector interface + EmailSystemMessage / EmailSendReceipt / EmailReplyMatch / EmailConnectorId (types-only; providers import type it) |
extensions/cinatra-ai/email-connector/src/registry.ts | In-memory emailConnectorRegistry + registerEmailConnector + listInstalledEmailConnectors |
extensions/cinatra-ai/email-connector/src/facade.ts | sendEmailThroughSystem / findReplyThroughSystem chokepoint + EmailSystemDeps (host-injected routing + dev-mode override + best-effort sent-email writer) |
extensions/cinatra-ai/email-connector/src/mcp/module.ts | email_send MCP primitive (createEmailModule) — one-shot transactional send for chat + ad-hoc agents |
extensions/cinatra-ai/gmail-connector/src/email-connector.ts | gmailEmailConnector — the first EmailConnector impl, wraps the existing gmail functions |
src/lib/register-email-providers.ts | Boot 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.ts | The email_send_events ledger read/write helpers (moved here from the deleted packages/asset-email/send-events.ts in Phase 302) |
The contract
Section titled “The contract”A provider package implements EmailConnector and exports a singleton:
import type { EmailConnector } from "@cinatra-ai/email-connector"; // import TYPE onlyexport 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.
Email object types (Phase 303)
Section titled “Email object types (Phase 303)”Four provider-neutral object types registered statically in packages/objects/src/integration/register-types.ts (registerEmailObjectTypes):
| Type | Identity key | Written by |
|---|---|---|
@cinatra-ai/email:sender-identity | <connectorId>:<fromEmail> | user / agent (routing input — the chain reads this) |
@cinatra-ai/email:sent-email | idempotencyKey | facade saveSentEmailObject after every successful send |
@cinatra-ai/email:received-reply | internetMessageId (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/ownerIdfiltering (objects_listhas nodata.<field>server filter; small per-org record counts make this safe; a structured-filter promotion is a future-scale TODO).
email_send MCP primitive
Section titled “email_send MCP primitive”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.
Adding a new provider (e.g. SMTP)
Section titled “Adding a new provider (e.g. SMTP)”extensions/cinatra-ai/smtp-connector/withcinatra.kind: "connector"+ aregister*Connector(deps)factory if it needs host-internal deps.- Implement
EmailConnector; exportsmtpEmailConnector. registerEmailConnector(smtpEmailConnector)insrc/lib/register-email-providers.ts.- Persist a
@cinatra-ai/email:sender-identityobject (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.