Skip to content

Inbound Mail

Three features that turn a mailbox into an event-sourced inbound channel: inbound-mail-foundation owns the pipeline, the inbound-provider-* features are swappable mailbox backends. Your app attaches business processes (ticketing, AI triage, document intake) as consumers — it never talks IMAP.

Status: ✅ Stable

What: The inbound pipeline: mail accounts (shared team inboxes and personal mailboxes), exactly-once ingest with dedup, thread rollup, and PII-encrypted storage. Every inbound mail becomes a domain event (inbound-message-received) with a full replayable history — a consumer you build next year can process this year’s mail.

How it works:

  1. A tenant connects a mailbox — via credentials form (IMAP) or the built-in OAuth connect/callback routes (createInboundMailConnectRoutes).
  2. The watch-supervisor (createInboundMailSupervisor) keeps a live push connection per account (IMAP IDLE — mail arrives in seconds) and runs a reconciliation poll as the correctness anchor.
  3. Each mail is ingested exactly once: deterministic aggregate ids + a dedup anchor make provider replays no-ops.
  4. Sender, recipients, subject and snippet are envelope-encrypted per tenant before anything touches storage — tenant destroy crypto-shreds the key, making event log and projections unreadable.

Read models: read_mail_accounts, read_inbound_messages, read_mail_threads — list queries decrypt on read and enforce mailbox scoping (personal mailboxes are visible to their owner and tenant admins only).

Status: ✅ Stable

What: IMAP backend using password / app-password auth — covers classic mail hosts without any OAuth setup. Live push via IMAP IDLE, incremental sync via a UIDVALIDITY:lastUid cursor (a mailbox reset triggers a clean full resync inside the backfill window).

Connection data lives as a JSON document in the per-account secret slot (host, port, user, password) — one tenant can connect mailboxes on different hosts side by side.

Status: ✅ Stable

What: Scriptable in-process provider for tests, demos and sample apps. Seed messages, inject errors, simulate a dying IDLE connection — your consumer code runs against the same contract as production.

Consumers hook onto the ingest write-handler; the job receives the plaintext normalized mail:

import { InboundMailFoundationHandlers } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
r.job(
"triage-inbound",
{ trigger: { on: InboundMailFoundationHandlers.ingestMessage }, runIn: "worker" },
async (payload) => {
// create a ticket, run AI classification, extract an invoice …
// key side-effects by payload.providerMessageId — the trigger also
// fires on idempotent replays.
},
);

Recipe: samples/recipes/inbound-mail-triage shows the full flow — connect, ingest, worker-lane triage job, replay idempotency — verified end-to-end.

Microsoft 365 (Graph delta queries) and Gmail (history API) ship as enterprise providers against the same plugin contract — the oauth block and provider-opaque cursors are already part of it. Consumer code does not change when a tenant switches providers.