`docs/papra-write-policy.md:192` and :202 both said "13 cases" but `pipelines/_tests/test_papra_decide_offline.py` defines 15 test functions. The v1.1 EXECUTE stage added 2 tests (test_papra_dry_run_override_wins and test_emit_papra_write_gate_event_logs_three_fields) but the doc count was not updated. Off-by-2. This commit updates both lines to "15 cases" to match the actual test inventory. The 7-bullet list above (attachment collection, four rule outcomes, HTML body stripping, classifier-order precedence, dry-run mode, env parsing, is_configured()) is unchanged — the list still represents the policy-level coverage, not the test-function count. ---ci--- project: inboxos phase: 1 milestone: v1.3 status: execute tasks_completed: [T2.3] candidates_closed: [REQ-INB-31] files_touched: - docs/papra-write-policy.md (2 lines, 192 and 202) ---/ci---
9.3 KiB
Papra write policy (INB-27)
This document describes how the InboxOS live and historical pipelines decide
which emails get written to Papra for long-term storage and searchability.
The implementation lives in pipelines/common.py (PapraConfig,
papra_decide, papra_write_attachment, papra_write_body) and is
exercised by pipelines/_tests/test_papra_decide_offline.py.
Why a policy, not a blanket copy
The earlier INTAKE_EMAILS_DRIVER=catch-all integration tried to copy
every email into Papra. That approach was cancelled by the CEO on
2026-06-08 (see INB-27 thread, comment 563d0801):
"Lets cancel this integration, I see little value in it. Instead, as a separate effort, I want to see for certain documents, as they are ingested by the live/historical pipelines, a certain set of relevant document are written directly to papra for long term storage and searcheability. certains emails where the metadate extracted is enough don't need the email replicated in papra."
The new policy makes the selection explicit, deterministic, and
operator-tunable. As a corollary, the docker/docker-compose.yml
papra service no longer advertises the INTAKE_EMAILS_* env vars;
Papra writes are pipeline-driven only.
The decision, in one diagram
┌────────────────────────┐
│ Email arrives in │
│ process_one_message │
└─────────────┬──────────┘
│
▼
┌────────────────────────┐
│ papra_decide() │
│ pure function │
└─────────────┬──────────┘
│
┌──────────────┬───────────┴────────────┬────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
attachment_present body_module metadata_only_module no_matching_module
│ │ │ │
write each write the body skip skip
attachment (capped 64 KB) (NocoDB only) (NocoDB only)
Every path emits a papra_decision structured log event so the audit
trail is complete.
Rule details
Rule 1 — attachments present (always write)
If the RFC-822 message contains one or more MIME parts with
Content-Disposition: attachment and a non-empty decoded payload, each
qualifying attachment is uploaded to Papra. The email body itself is
not written alongside — the attachment is the document of record.
Inline images and signature blobs are skipped (they do not have
Content-Disposition: attachment).
Each attachment is uploaded to POST /api/organizations/{org}/documents
with a tags field of ["inboxos", "message_id:<hash12>"]. The first
attachment's resulting document id is stored on the NocoDB row as
Papra_Link. Both pipelines now capture Papra_Link on the
NocoDB row whenever the attachment walk returns a papra_link —
see ARCHITECTURE.md §Document Store (curated) and the
papra_link_patched event in pipelines/live_pipeline.py.
Rule 2 — body is the artifact (module-driven)
If there is no attachment AND the classifier routed the email to a
module in PAPRA_BODY_WRITE_MODULES, the email body is uploaded to
Papra as a single document:
- HTML bodies are stripped to plain text (the same
_strip_html_to_textalgorithm used in the live pipeline prompt builder). - The body is capped at 64 KB to keep the upload bounded.
- The resulting document's filename is
<message-id>.txtand its content-type istext/plain; charset=utf-8.
The module used is the first one in classifier-output order that appears in the allow-list. The classifier is the source of truth for which module is primary; the env list is just a membership filter.
Rule 3 — metadata is enough (skip Papra, keep NocoDB)
If there is no attachment AND the classifier routed the email to a
module in PAPRA_METADATA_ONLY_MODULES, nothing is written to Papra.
The structured fields in NocoDB are sufficient; we don't need the
original document body in Papra.
Rule 4 — default (skip)
If none of the above match (no attachment, no module in the body-write list, no module in the metadata-only list), nothing is written to Papra.
Configuration (env vars)
| Var | Default | Meaning |
|---|---|---|
PAPRA_BASE_URL |
unset | The Papra server root. When unset, the helper silently skips. |
PAPRA_API_TOKEN |
unset | Bearer token for POST /api/.../documents. |
PAPRA_ORGANIZATION_ID |
unset | Org id appended to the upload URL. |
PAPRA_DRY_RUN |
true |
When true, no HTTP request is made; the helper logs papra_write_dry_run and returns a synthetic id. The default for the first ship. |
PAPRA_BODY_WRITE_MODULES |
crm,finance,legal |
Comma-separated module list that triggers a body write (Rule 2). |
PAPRA_METADATA_ONLY_MODULES |
intel,promotions |
Comma-separated module list that skips Papra (Rule 3). |
PAPRA_HTTP_TIMEOUT |
60 |
Seconds for one Papra POST. |
Flipping the dry-run switch
- Run with
PAPRA_DRY_RUN=true(default). Inspect thelive-pipeline-logandhistorical-pipeline-logartifacts; every message has apapra_decisionevent withwould_write,reason, and (for writes)target_countand the synthetic id. - Confirm the decisions match expectations (no surprise writes, no missed attachments).
- Set the repo var
PAPRA_DRY_RUN=false(in Gitea → repo → Settings → Actions → Variables, or as a secret). Re-run. - Verify the new
papra_write_okevents include realpapra_document_idvalues and that the documents are searchable in the Papra web UI.
Live-write gate (REQ-INB-20)
The Papra live-write switch is guarded by an engineering gate so the
first real write is observable end-to-end before the repo-wide
PAPRA_DRY_RUN flip is finalised.
Override precedence. When the live and historical pipelines boot, they parse the dry-run state in this order (highest precedence first):
PAPRA_DRY_RUN_OVERRIDE— a per-message kill switch. Truthy values (true/1/yes/on) force dry-run on; falsy values (false/0/no/off) force dry-run off. Empty string means "no override".PAPRA_DRY_RUN— the repo var (the existing ship-defaulttruelives here).- The hard-coded default
true(the first-ship posture).
The effective value is stored on PapraConfig.dry_run; the
override is preserved on PapraConfig.dry_run_override for the
papra_write_gate log event.
Canary procedure. To audit the gate before the real-write flip:
- Dispatch
live-pipeline.ymlwithmax_emails=1andPAPRA_DRY_RUN_OVERRIDE=false; inspect the per-run log for apapra_write_gateevent followed bypapra_write_ok/papra_write_failed. - Repeat for
max_emails=10andmax_emails=100. - Confirm the
papra_indexaudit table (REQ-INB-19) has one row per processed message; the row'soutcomeisokfor every successful write andfailedfor every error. - Set the repo var
PAPRA_DRY_RUN=false(in Gitea → repo → Settings → Actions → Variables) and re-run; thepapra_dry_run_envfield inpapra_write_gatewill befalse. - Inspect the Papra web UI for the new documents; verify the
papra_document_idis non-null on the matchingpapra_indexrows.
Test coverage. pipelines/_tests/test_papra_decide_offline.py
gains two cases (added in the same commit that landed this
subsection):
test_papra_dry_run_override_wins— asserts the precedence table; the override beats the repo var, and an empty override falls back to the repo var.test_emit_papra_write_gate_event_logs_three_fields— asserts the helper emits exactly onepapra_write_gateevent with the documenteddry_run,dry_run_override, andpapra_dry_run_envfields.
Failure handling
Papra failures are non-fatal:
- A transient HTTP error is retried by
tenacity(3 attempts, exponential backoff). - A permanent failure logs
papra_write_failedand the pipeline continues. The NocoDB row is not updated withPapra_Status=pendingin this child; that column arrives inINB-27.balongside thepapra_indexNocoDB table. - A Papra outage does not stall a multi-hour historical run; the
.emlis still in SeaweedFS and a re-run after the outage is over will retry the upload.
Tests
pipelines/_tests/test_papra_decide_offline.py covers 15 cases:
- attachment collection (inline images skipped, real PDFs found)
- the four rule outcomes (attachment, body, metadata-only, default)
- HTML body stripping
- classifier-order precedence in the body-write rule
- dry-run mode (no HTTP)
- env parsing for
PAPRA_DRY_RUNand the module lists is_configured()returnsFalsewhen any of base_url / token / org is empty
All 15 cases pass on the test environment.