Engineer-facing reference for how email actually flows into memories. The end-user guides (Email Ingestion, Background Email Polling) describe the experience; this doc describes the machinery - the two pipelines, the safety layers, and the failure modes. Ground truth as of 2026-07-26; cites gateway/src/... unless noted.
Security reviewers: use
docs/audits/email-lethal-trifecta-capability-map.md before adding an email
ingest, draft/reply, briefing, or delivery lane. It records which lanes hold
private-data access, untrusted-content exposure, and external-send capability,
plus the required structural review checklist.
The single most important fact: there are two pipelines
A feature flag decides which one runs. Read this before debugging anything.
EMAIL_CONNECTOR_ENABLED !== 'true' -> legacy poller runs (email-poll.ts, cron */3)
EMAIL_CONNECTOR_ENABLED === 'true' -> connector pipeline runs (email-connector.ts, cron */1 discovery)
- Legacy poller (
email-poll.ts): the original background poll. Its three-minute cron runs only when the flag is off. It processes exactly one connector per invocation, round-robin by oldestlast_poll_at, with a cap of 10 messages per tick. - Connector pipeline (
email-connector.ts): the newer, flag-gated default. Its one-minute discovery cron scans for due connectors; email connectors have a seven-minute minimum poll interval and a cap of 10 messages per poll. - A third, unrelated thing shares the word "email":
daemon/ingest.tsis the autofix bug-report intake daemon on the gandalf home-lab box. It is not part of memory ingestion. Do not conflate it.
There is also a manual MCP path independent of both pollers: the ingest tool with action:"emails" (or legacy ingest_emails) calls handleIngestEmails, with a cap of 5 emails per call. This is the "Claude reads your inbox via an email MCP during a session" path.
So content enters through three doors: legacy poll, connector poll, or MCP-mediated manual read. The automatic pollers attribute messages before extraction. The manual path is already routed to a caller-selected mind, verifies write access to that mind, and then joins the extraction and storage stages.
End-to-end flow
(door 1) legacy three-minute cron --> fetch --> skip --> attribute --> legacy dedup --.
|
(door 2) connector discovery cron --> fetch --> skip --> attribute --> queue dedup ----+--> guardrails
| |
(door 3) MCP manual read --> caller-selected mind --> raw-document dedup --------------' v
extract + store
Stage-by-stage
1. Scheduling and concurrency safety
The legacy poller claims a mailbox with a compare-and-swap on last_poll_at and a 30-second staleness window. The connector pipeline scans due connectors in bounded batches, then relies on durable queue uniqueness and downstream idempotency to make overlapping discovery safe. Do not assume the two schedulers use the same claim mechanism.
2. Fetch (incremental)
Gmail only. getProvider() (email-provider.ts:38) has a single case - gmail -> GmailProvider - and default: throw 'Unsupported email provider'. Outlook/IMAP are not implemented (not even stubs) on the poller path. Fetch is incremental off the Gmail historyId cursor, capped at 10 messages per invocation.
3. Skip filters (rule-based, no ML)
email-prefilter.ts shouldSkipExtraction() runs, in order: noreply-sender checks -> word-count floor (MIN_WORD_COUNT=15) -> auto-reply body checks -> newsletter detection (needs 2+ signals, such as unsubscribe markers / List-Unsubscribe / Precedence: bulk). The filters are deliberately conservative to avoid dropping real correspondence. email-poll.ts adds skips for calendar invites and Armbrain's own domain.
4. Dedup (multi-layer, durable)
The paths use related but not identical durable guards:
- The connector path first deduplicates discovered work on
ingestion_queue(connector_id, source_ref). - Connector ingestion, the legacy extraction path, and manual MCP ingestion deduplicate email content on
raw_documents(mind_id, content_hash). The manual and legacy extraction paths use a pre-check plus an idempotentINSERT ... onConflict ignoreDuplicatesrace-closer. - The legacy poller also checks
ingest_logby source reference and skips messages already marked poison (see Failure Modes).
A past bug documented in mcp-tools-ingest.ts wrote a string message ID into a UUID column, so dedup writes silently failed and every poll re-ingested. Fixed; the comment is a landmine marker - don't reintroduce it.
5. Guardrails (detect + contain, never block)
Lives in guardrails.ts (note: not ingest-security.ts - see the naming trap below).
- Prompt-injection:
detectPromptInjection()flags suspicious content. High and medium severity are added to the pending-memory review queue; high severity is hidden from search, while medium remains searchable with a warning. Low severity logs only. A per-customer trusted-sender allowlist can reduce high severity to medium but never bypasses logging. - PII:
detectPII()logs recognized sensitive-data patterns for telemetry; it does not redact, quarantine, or persist a review tag. - Content fencing: untrusted email body is wrapped in DATA-ONLY delimiters before it reaches the model.
Naming trap: ingest-security.ts (564 lines) is not general ingest security - it's SSRF protection + zip-bomb pre-checks (IPv4-literal detection in all encodings, private/reserved/metadata-IP blocking, DNS-rebinding defense via DoH, per-hop redirect re-validation, 10 MB streaming cap). Robust, but scoped to URL/website-connector fetches, not inbound email content. The email-egress-*.ts files are a separate concern again: they govern outbound email the product sends (send gate, killswitch, authz, outbox) - not ingestion.
6. Attribution (the strongest subsystem)
email-attribution.ts (341 lines). Match priority:
- Exact
email_contactsmatch, case-insensitive. company_info.domain(singular).company_info.domains[](array).
Two hardening layers, each added after a real misfire:
- Generic-domain guard: ~20 free providers (gmail.com, outlook.com, icloud.com, ...) are never used for domain matching - only exact-contact match. This mirrors Attio's published entity-resolution rule and prevents every personal-Gmail sender collapsing into one fake "company."
- Internal-domain neutrality: the CMO's own-org teammates are excluded from routing signal (cites the "Jul 1-2 misfile").
Multi-mind fan-out returns human-readable "decision receipt" reasons per match. On no match, the connector path can escalate to a bounded LLM attribution pass; still nothing -> mind_id: null review queue (not dropped).
7. Extraction gate (a flag, not a human gate)
Despite the name, shouldExtractEmail() is a feature-flag resolver: per-customer extraction_enabled wins, else global EMAIL_EXTRACTION_ENABLED. Flag off -> one whole-body classify-only memory (legacy behavior). Flag on -> extractAndStoreConversational, with an empty-extraction fallback so trivial emails aren't silently dropped.
8. Store + advance
Memories are written client-isolated (mind ownership validated before every write - an email attributed to Client A cannot land in Client B). After durable work is recorded, the historyId cursor advances. Both automatic paths update connectors.last_poll_summary: the legacy poller writes {ingested, skipped, unattributed, errors, poisoned}, while connector discovery writes its generic found/queued/skipped/failed counters plus any provider-specific fields.
Failure modes (the best-engineered part)
- Legacy per-message poison / dead-letter:
email_message_attemptstracks attempts; afterPOISON_ATTEMPT_THRESHOLD=3a message flips topoisonand is skipped on later polls. Currently no replay path once poisoned - an operator gap. - Legacy cursor safety: cursor advancement is gated by
hadNonPoisonError- a fresh error holds the historyId (message retries next tick), but a poisoned message does not wedge the whole mailbox. Prior-attempt-lookup failure fails closed. - Connector durability: the connector path advances the Gmail checkpoint only after queue rows are durably inserted. A durability failure preserves both source checkpoints so the next discovery tick can safely retry.
- Auth errors: the legacy poller disables the connector after 3 consecutive failures and sends a transactional reconnect email. The connector pipeline uses its shared connector error policy.
- Infra errors: Slack alert, no error-count bump (so a Google outage doesn't disable a healthy connector).
- Transient 429/5xx: single bounded retry honoring
Retry-After(capped 60s) ingmail-client.ts, then throw and retry next cron tick. No true exponential backoff/jitter - a known gap.
Observability
Structured JSON log() calls cover the main branches. Per-poll summaries are stored in connectors.last_poll_summary. A cron heartbeat email_poll:last_run is written outside the flag gate, so it proves the trigger fired even when the legacy body is skipped. No StatsD/Prometheus metrics backend - logs + DB summary only. "Ingestion receipts" are a conversational, non-persisted concept. There is no published latency SLA (event -> searchable memory).
Token security
Refresh tokens are envelope-encrypted with an environment key-encryption-key and decrypted in memory per poll. The current v2 format also binds ciphertext to its storage context and customer ID, but the production diagnostic fallback still permits legacy v1 writes without that binding while issue #2124 is resolved. Gmail access uses a read-only scope. No provider credentials are stored in plaintext.
Known hazards for maintainers
- The two-pipeline flag split duplicates attribution/dedup logic across
email-poll.tsandemail-connector.ts- cross-path behavioral drift is possible. Changes to shared behavior must be checked on both paths. ingest-security.tsis misnamed (SSRF+zipbomb, not content security). The real content-security logic is inguardrails.ts+ theemail-egress-*files.email-prefs.tsis an additive layer over legacy KV flags - master opt-out correctness depends on every send path consulting the unified record.
Test coverage map
The main regression coverage lives in email-poll-integration.test.ts, email-connector.test.ts, email-attribution.test.ts, email-extraction-flag.test.ts, mcp-tools-ingest-consolidated.test.ts, connector-email-quarantine.test.ts, and the focused cursor/backfill suites.