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: 37ZscHy7SyDYx1CC — viska-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]
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.
| # | 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. |
The transform writes an OKF v0.2 source-document with:
source_meta.origin as the email-lane dedup identity;links array;source_meta.extraction_contract: okf-parcel-n8n-substack/4 and matching producer stamp process:okf-parcel-n8n-substack/4.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:
open.substack.com/chat/ links;substack.com/redirect/<uuid> targets rather than fetching or resolving them;[unresolved Substack citation omitted] when an opaque redirect is omitted.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.
parcels/substack/<source>-<published>-<hash>.md.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.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).
Use scripts/recover-substack-email.js only for a confirmed missed publication, listener downtime, or a known absent execution.
37ZscHy7SyDYx1CC is active and the IMAP mailbox and binding above remain unchanged. N8N_ALLOW_MUTATIONS=1 node scripts/recover-substack-email.js
Substack mailbox with the same two publication headers and fetches raw MIME without marking it read.fetchRawMessages() waits for the IMAP message and every body stream to end before parsing. This prevents partial, non-deterministic MIME bodies.Build OKF Parcel transform twice per fetched email and stops if the parcel Markdown differs. It prints the resulting parcel identities, not credentials.Build OKF Parcel, then removes both webhook nodes and the credential in finally.already_delivered; the latter means the object already exists and is an idempotent outcome. Public n8n execution history can omit temporary-webhook runs.Never expose credential values, use a synthetic source-email fixture, invoke an unauthenticated webhook, or use the Fastmail MCP token as a JMAP bearer.
npm run debug -- <executionId> and inspect the named node.npm run sync before editing, npm run validate after, then deploy and activate only the changed workflow.source_meta.extraction_contract, deploy, and recover the affected source emails twice to demonstrate deterministic output.| 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). |
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.