Substack email ingestion pipeline

Purpose and current boundary

This is the production path for Substack publication emails delivered to Fastmail. It converts a qualifying email into an OKF source-document parcel, writes that parcel to the parcels bucket, and relies on the L2 projector to make the current revision retrievable by Mímir.

Live n8n workflow: 37ZscHy7SyDYx1CCviska-fastmail-ingest-transform — active.

The older wig8zrxkyoLLeO9G workflow, Viska Substack Ingest (email/eml -> research_findings), is an Outlook-era research_findings lane. It is not the current Fastmail → OKF parcels → L2 retrieval path described here. Do not use it for recovery or infer current serving behaviour from it.

flowchart LR
  M[Fastmail / Substack mailbox] --> I[Email Trigger IMAP]
  I --> B[Build OKF Parcel]
  B --> A[Assert Contract]
  A --> S[Sign Parcel Upload]
  S --> H[Handle Sign Outcome]
  H --> P[PUT parcel markdown]
  P --> L1[parcels bucket / L1]
  L1 --> X[OKF bucket projector / L2]
  X --> D[(okf_parcels)]
  D --> R[Mímir retrieval: stable rows only]

Intake and scheduling

Email Trigger (IMAP) is event-driven, not cron-driven.

Setting Production behaviour
Mailbox Substack
IMAP credential binding mimir-viska.gg-ingestion
Message format resolved
Search predicate LIST-ID: substack.com and X-MAILGUN-TAG: post
Mail action leave unchanged (postProcessAction: nothing)
Reconnect force reconnect every 15 minutes
trackLastMessageId disabled

The trigger only searches when Fastmail emits its new-mail callback. Activation and a reconnect reopen the connection; neither backfills mail already sitting in the folder. A non-matching wake-marker may cause a search but is an unreliable diagnostic, not a recovery mechanism. JMAP polling is not part of this pipeline; historical reconciliation belongs to ViskaRes.

Workflow nodes

# Node Role Output / failure behaviour
1 Email Trigger (IMAP) Receives qualifying resolved messages from Fastmail. One email item per matching publication.
2 Build OKF Parcel Parses sender, subject, original Message-ID, body and media; strips forwarding headers; generates Markdown and OKF frontmatter. Runs once per item, never batch-first-item-only.
3 Assert Contract Prevents a malformed parcel from reaching storage. Requires parcel_id, object_key, bare origin, title, source id and parcel Markdown; rejects an origin with angle brackets, a non-Substack object prefix, or body under 50 characters.
4 Sign Parcel Upload Requests a signed URL for lane: parcels and the object key. Authenticated HTTP call; response errors are retained for the next node.
5 Handle Sign Outcome Separates a valid signed URL from idempotent object existence. Fails unknown signing errors; drops object already exists items so they are not PUT again.
6 PUT Parcel to Signed URL Writes the asserted parcel Markdown to the signed L1 location. The object write is the producer delivery point.

Parcel construction contract

The transform writes an OKF v0.2 source-document with:

parcel_id and object_key include the extraction-contract revision plus origin URL, title, and body. A corrected extraction contract therefore creates a new, deterministic object rather than colliding with the old result. Re-running the same email under the same contract produces the same parcel identity.

The structured links array is intentionally stricter than source prose:

This preserves evidence that a citation existed without persisting an unverifiable target. Paywall gates become explicit completeness metadata; footer notices are removed without declaring a complete article truncated.

Storage, projection, and serving

  1. L1 / parcels bucket stores the immutable Markdown parcel at parcels/substack/<source>-<published>-<hash>.md.
  2. L2 / okf_parcels is a re-runnable projection of bucket objects, not an authoring destination. The projector chooses the highest source_meta.extraction_contract for a shared origin. It retires an older row as status='deprecated', preserves its bytes and records superseded_by; it does not delete historical bucket objects.
  3. Serving must select status='stable'. Mímir applies that filter to body retrieval, structured parcel retrieval, and visibility probes so retired revisions never reappear in search results.

The production v4 audit measured 98 stable Substack bodies, 9 retained links (7 distinct), zero opaque redirects, non-HTTPS URLs, capability parameters, chrome/chat, suspicious/unrelated URLs, or unclassified URLs. It retained 86 explicit unresolved-citation markers. The deployed retrieval control selected Citrini v4 ...:967b6a58 (47,170 characters, five parts, 61 markers, no truncation).

Backfill and missed-email recovery

Use scripts/recover-substack-email.js only for a confirmed missed publication, listener downtime, or a known absent execution.

  1. Read the workflow descriptor and the failed execution first; diagnose the named node rather than guessing.
  2. Confirm workflow 37ZscHy7SyDYx1CC is active and the IMAP mailbox and binding above remain unchanged.
  3. Run with the injected bindings and explicit mutation approval:
   N8N_ALLOW_MUTATIONS=1 node scripts/recover-substack-email.js
  1. The script searches the Substack mailbox with the same two publication headers and fetches raw MIME without marking it read.
  2. fetchRawMessages() waits for the IMAP message and every body stream to end before parsing. This prevents partial, non-deterministic MIME bodies.
  3. It runs the live Build OKF Parcel transform twice per fetched email and stops if the parcel Markdown differs. It prints the resulting parcel identities, not credentials.
  4. It creates a temporary authenticated n8n webhook and temporary header credential, posts each parsed email into Build OKF Parcel, then removes both webhook nodes and the credential in finally.
  5. Success evidence is either a synchronous upload response or explicit already_delivered; the latter means the object already exists and is an idempotent outcome. Public n8n execution history can omit temporary-webhook runs.
  6. For corrected historical content, confirm the new bucket object and L2 projection before declaring recovery complete. Mímir's stable-only audit is the acceptance control for serving safety and relevance.

Never expose credential values, use a synthetic source-email fixture, invoke an unauthenticated webhook, or use the Fastmail MCP token as a JMAP bearer.

Operational runbook

Normal operation

Incident: publication is missing

  1. Confirm that it is a publication, not an account notice or other transactional Substack mail.
  2. Check whether the listener has an execution. Do not treat reconnect/re-activation as a backfill.
  3. Run the recovery procedure above.
  4. Verify the bucket object and L2 row are present and stable; request Mímir stable-only retrieval verification when the correction affects persisted corpus content.
  1. Audit stored values read-only; do not request or execute the persisted URL to judge it.
  2. Correct the producer's extractor and add a focused red case.
  3. Increase source_meta.extraction_contract, deploy, and recover the affected source emails twice to demonstrate deterministic output.
  4. Have L2 admit/project the newer contract, retire the old revision, and re-run the Mímir stable-only audit.

Deliverables and acceptance evidence

Deliverable Location / owner Current evidence
Active ingress and parcel producer workflows/active/viska-fastmail-ingest-transform.json / ViskaN8N Workflow 37ZscHy7SyDYx1CC active.
Workflow node map workflows/active/viska-fastmail-ingest-transform.descriptor.md / ViskaN8N Six-node production chain documented.
Recovery runbook and implementation .agent/skills/substack-email-recovery/SKILL.md, scripts/recover-substack-email.js / ViskaN8N Authenticated temporary ingress, deterministic double-transform, cleanup.
MIME-race regression control tests/ingestion/imap-raw-fetch.test.js / ViskaN8N Message-end-before-body-end case must retain full MIME body.
Link/extraction controls tests/ingestion/substack-link-extraction.test.js / ViskaN8N HTTPS/chrome/opaque redirect and deterministic revision controls.
L1 → L2 correction policy producers/okf_bucket_projector.py / ViskaDB Later extraction contract replaces serving revision; older row retired.
Stable-only data service Mímir retrieval / ViskaMimir Stable v4 final audit PASS: Mímir #122 comment 5279199735, PR #146 (main@10389a3).

Known separate concern

Four duplicate live Oilprice origins in the RSS lane predate this work and have no extraction-contract metadata. They are not generated by the Fastmail/Substack pipeline and need an R6 decision on which capture wins. Do not fold them into Substack email recovery.