Skip to main content
suchi’s ingest pipeline branches by MIME type sniffed server-side from the first 512 bytes of the upload. Every branch degrades gracefully: if the binary a branch needs isn’t installed, the doc is still stored and searchable, documents.content just lands empty until the tool shows up.

Format matrix

Anything else lands as a doc with empty content — the original bytes are safe in the CAS, dedup + metadata still work, but there’s no text index. Add a converter (or wait for a future ingester) to fill in documents.content later.

PDF pipeline in detail

The PDF path is the most involved because it fans out based on content:
Each step degrades independently. Missing pdftotext sends every PDF to the OCR path (slower but correct). Missing OCR engine leaves the content empty for scanned PDFs. Missing qpdf keeps the original bytes as-is (encrypted PDFs will then fail downstream).

OCR engine dispatch

Two engines are supported and picked at boot via OCR_ENGINE: tesseract is the smaller path (~4× less Docker image size vs ocrmypdf’s Python stack) but does not produce a searchable-PDF archive — documents.archive_blob stays NULL for scanned docs. FTS + full-text search still work over documents.content. Pick ocrmypdf when you want the archive PDF (e.g. long-term retention where the searchable PDF matters more than image size), or set to auto and let the image you deployed decide.

Password-protected PDFs

Encrypted PDFs go through a candidate-passwords loop before being parked as pending-decryption. The loop tries, in order:
  1. Empty password — the “owner restrictions only” case (very common for bank statements, invoices). qpdf --decrypt= with no password.
  2. Passwords file (optional) — INGEST_PASSWORDS_FILE points at a newline-separated list of candidates; blank lines and # comments are skipped. Chmod 600 recommended.
  3. Learned passwords — every password an operator supplies via POST /api/documents/{id}/decrypt with remember=true gets sealed with AES-256-GCM (.decrypt-key file) and stored owner-scoped in the decryption_passwords table. Hot passwords hit first.
If every candidate fails, the doc lands in encryption_state='encrypted' with the pipeline halted. Surface it via GET /api/documents/pending-decryption. The operator supplies a password:
On success suchi writes a decrypted CAS blob (originals are always preserved verbatim in original_blob), flips the state, and re-enqueues post-ingest — the doc flows through the rest of the pipeline (OCR, ZUGFeRD, rules, render) as if it had arrived unencrypted. Batch decrypt — for monthly statements where multiple accounts share a password, POST /api/documents/decrypt-batch accepts a doc-id array and one password; remember=true fires once per batch on first hit. qpdf warnings-are-fine gotcha. Some bank PDFs ship spec-nonconformant /Perms and produce qpdf exit code 3 (warnings). suchi treats exit 3 with non-empty output as success — the pragmatic convention for pre-consume decrypt.

Email (message/rfc822)

.eml files land as documents with the email body text as documents.content and headers projected onto standard fields:
  • title ← Subject (RFC 2047 encoded-word decoded)
  • created_at ← Date header (unix epoch)
  • email_message_id ← Message-Id (for future dedup on re-syncs)
  • Sender (From: Name <email>) upserts into the correspondents table and attaches to the doc under role=sender.
Attachments fan out into sibling docs. Each attachment gets its own documents row with email_parent_id pointing back at the .eml, its own CAS blob, and its own post-ingest job — so an attached PDF flows through the entire pipeline (qpdf, OCR, ZUGFeRD, rules, render) as if it had arrived directly. multipart/related inline images (referenced from an HTML body) are NOT ingested as separate docs — they’re kept inside the parent .eml’s CAS blob only. Attachment children inherit the parent’s correspondents (so the From: sender shows up on every child PDF), owner, and JD category. They do NOT inherit tags or document type — the pipeline (rules, LLM classifier) reclassifies each child independently. How to feed it in. Any of the three canonical ingest paths works; suchi doesn’t care what put the .eml on the disk:
  • INGEST_FS_DIR — a fs-watch on a Maildir or archive folder. Pair with mbsync/isync against your IMAP server (or a Bridge like Proton’s) to keep the folder fresh. Recommended for setups that already have a mail sync toolchain.
  • INGEST_IMAP_URL — direct IMAP polling. No external mail sync needed; suchi holds the credentials and pulls new messages on an interval.
  • Upload API — POST a .eml file to /api/documents/ for one-off imports.
The parser is pure Go stdlib (net/mail + mime/multipart + mime/quotedprintable) — it works in both slim and full images with no external tools. RFC 2047 encoded-word subjects, quoted-printable + base64 bodies, and nested multipart trees all decode correctly.

Multi-doc splitting on QR separator sheets

Feeder-scanning a stack of unrelated documents produces one large PDF that should really be N docs. Opt in with SCAN_SPLIT_ENABLED=on and suchi will detect separator sheets — pages carrying a QR code with a specific payload — and fan out one document per segment. Setup:
  1. Generate a separator sheet:
    Print several copies. Any QR encoder works; the default token is SUCHI-SPLIT (override via SCAN_SPLIT_TOKEN).
  2. Enable in the environment:
  3. Insert a separator between each document in your feeder stack and scan the whole pile as one PDF. Upload as usual.
What happens on ingest:
  • docsplit rasterizes each page at 150 DPI and scans for QR codes.
  • Separator pages are dropped; the ranges between them become segments.
  • Each segment becomes a fresh document (with its own original_blob and post-ingest job), linked via split_parent_id.
  • The parent doc is soft-deleted so the workspace only shows the children. Undelete brings the parent (original combined scan) back if the split was wrong.
Why QR-only: blank-page splitting sounds ergonomic but silently breaks legit multi-page docs that happen to contain a mostly-empty page. QR is explicit — the user prints separator sheets on purpose. Config knobs: see config for SCAN_SPLIT_ENABLED, SCAN_SPLIT_TOKEN, SCAN_SPLIT_DPI.

ZUGFeRD / Factur-X / XRechnung

PDF/A-3 documents with an embedded Cross-Industry Invoice XML get their structured data extracted and written to custom fields: The custom field rows are created lazily on the first successful extraction — a stock install without any e-invoices doesn’t clutter the fields table. Recognised attachment names (case-insensitive): factur-x.xml, zugferd-invoice.xml, xrechnung.xml.

Images (barcodes)

Every image upload runs QR / DataMatrix / Aztec decoding via gozxing (pure Go, in-process, no external binary). Any decoded barcode value lands in documents.content as barcode:<value> tokens so the FTS5 index picks them up — search for barcode:INV-2026-0042 and the doc surfaces. The image path skips qpdf / pdf-inspector / ocrmypdf entirely because those tools would fail on non-PDF input. There is no OCR of image uploads today — the design assumes you shot a barcode or a photo of a receipt whose barcode carries the useful key.

EPUB

Pure-Go zip walker under core/pipeline/epub/. Reads META-INF/container.xml → OPF manifest → spine, then concatenates the visible text from each XHTML in reading order into documents.content. Naive tag stripping:
  • <script> and <style> bodies are dropped.
  • All other tags become whitespace so words don’t get glued together.
  • Common named entities (&amp;, &nbsp;, &lt; etc.) are decoded.
Title (dc:title) and authors (dc:creator) are parsed and logged today; wiring them into custom fields is a follow-up. The output cap is configurable via EPUB_MAX_CONTENT_BYTES — hitting it truncates silently and logs at Warn (epub.truncated).

DjVu

djvutxt from djvulibre-bin extracts the OCR text layer that archive.org and library-scan DjVus normally carry. If the DjVu has no text layer (unusual), the extraction returns empty content — suchi does not OCR DjVu itself; convert to PDF upstream if needed. Output cap: DJVU_MAX_CONTENT_BYTES (default 32 MiB). Same silent-truncate
  • Warn behaviour as EPUB.

Office documents

anydoc from Firecrawl (MIT, Rust) extracts text from Word, PowerPoint, Excel, OpenDocument, RTF, and CSV files into GitHub-flavored Markdown. The Markdown lands as documents.content and FTS5 indexes it just like OCR’d PDF text. Supported MIMEs (routed by core/pipeline/anydoc.Recognized): PDF and EPUB are deliberately NOT routed through anydoc — the PDF path (qpdf → pdf-inspector → ocrmypdf) and the pure-Go EPUB extractor own those MIMEs. anydoc’s own coverage of them isn’t used. Missing anydoc binary = documents in these formats still land, they just don’t get their content indexed. Same posture as missing ocrmypdf. Both Docker images (slim and full) bundle anydoc as of Phase 3.5; bare-metal installs need to place an anydoc executable on PATH themselves — see source caveats below.

anydoc source caveats

Two things worth knowing about how suchi consumes anydoc: Upstream doesn’t ship a standalone CLI binary. anydoc is distributed as a Rust library (crates.io), Node napi binding (npm), and Python wheel (PyPI). Firecrawl’s CLI story is npx @firecrawl/anydoc — a Node wrapper around the napi binding. suchi doesn’t want Node in the runtime image, so the Dockerfile compiles examples/convert.rs from the anydoc repo into a static musl binary and installs it as /usr/local/bin/anydoc. Cost: ~10 MB, no runtime dependency. examples/convert.rs is not upstream-guaranteed as a stable CLI. It’s an example — the argv shape could shift between anydoc releases. As of v0.1.3 it’s convert <file> [-f <fmt>] [-o <out>] [--assets <dir>]. suchi shells out with just anydoc <file>, reading stdout for Markdown. If a future upstream tag changes this shape, suchi’s extractor at core/pipeline/anydoc/anydoc.go needs a matching update; symptoms would be silent Skipped=true with non-zero exit and “unrecognized argument” in res.StderrTail. The bump procedure — enforced by hack/pin-bumper.sh and the Dockerfile comment — is:
  1. Run hack/pin-bumper.sh from repo root. It reports when the pinned version is more than 2 releases behind and suggests a bump to n-1 (never latest — soak time matters).
  2. Read the compare URL it prints. Scan examples/convert.rs for argv changes.
  3. Edit ARG ANYDOC_TAG= in Dockerfile. Rebuild slim, run docker run --rm suchi:slim doctor to confirm anydoc is on PATH, and smoke-test a docx ingest.
  4. Commit as a standalone “bump” commit so it’s easy to roll back.
Alternative if the CLI drift becomes painful: vendor the convert example into suchi’s tree, stripping the language wrappers, and pin the anydoc library directly. Not done today because examples/convert.rs has been stable in shape since the crate’s first release and the maintenance burden isn’t worth it yet.

Not yet supported

  • .zip / .tar bundles — no auto-explode. Ingest the individual documents.
Add support by dropping a new package under core/pipeline/<format>/ with an Extract(...) that returns (*Result, error), then wire it into core/pipeline/postingest/postingest.go behind a MIME check. Every existing extractor is a template.

Per-format tuning

All accept K/M/G suffixes or raw byte counts. Truncation is silent + logged at Warn — the doc still lands, FTS still indexes what fit.

JSON sidecar spec

Any producer dropping a file into the fs-watch staging dir (or a mail attachment carrying a Correspondent) can hand suchi structured metadata via a sibling <name>.json (or <path>.json) file. The sidecar shape below is the wire contract — additive fields don’t need a version bump; a required-field change bumps suchi_sidecar.
Source of truth: core/ingest/sidecar/sidecar.go. Parser accepts the native shape and a flat-JSON compat shape (no suchi_sidecar key, top-level title/created/correspondent/tags). Bad JSON or an explicit-but-mismatched suchi_sidecar version fails ingest; the file moves to <dir>/errors/ with a .err companion.

Docker image matrix

The slim image is the recommended default — it covers PDF end-to-end including OCR of scanned pages, plus office document text extraction via anydoc. Pick full when you need text-selectable scanned-PDF archives (ocrmypdf writes the OCR layer inside the PDF), DjVu ingest, or Outlook .msg parsing.