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: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 viaOCR_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 aspending-decryption. The loop tries, in order:
- Empty password — the “owner restrictions only” case (very common
for bank statements, invoices).
qpdf --decrypt=with no password. - Passwords file (optional) —
INGEST_PASSWORDS_FILEpoints at a newline-separated list of candidates; blank lines and#comments are skipped. Chmod 600 recommended. - Learned passwords — every password an operator supplies via
POST /api/documents/{id}/decryptwithremember=truegets sealed with AES-256-GCM (.decrypt-keyfile) and stored owner-scoped in thedecryption_passwordstable. Hot passwords hit first.
encryption_state='encrypted' with the pipeline halted. Surface it via
GET /api/documents/pending-decryption. The operator supplies a
password:
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 thecorrespondentstable and attaches to the doc underrole=sender.
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
.emlfile to/api/documents/for one-off imports.
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 withSCAN_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:
- Generate a separator sheet:
Print several copies. Any QR encoder works; the default token is
SUCHI-SPLIT(override viaSCAN_SPLIT_TOKEN). - Enable in the environment:
- Insert a separator between each document in your feeder stack and scan the whole pile as one PDF. Upload as usual.
docsplitrasterizes 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_blobandpost-ingestjob), linked viasplit_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.
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 indocuments.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 undercore/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 (
&, ,<etc.) are decoded.
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 isnpx @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:
- Run
hack/pin-bumper.shfrom 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). - Read the compare URL it prints. Scan
examples/convert.rsfor argv changes. - Edit
ARG ANYDOC_TAG=inDockerfile. Rebuild slim, rundocker run --rm suchi:slim doctorto confirm anydoc is on PATH, and smoke-test a docx ingest. - Commit as a standalone “bump” commit so it’s easy to roll back.
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/.tarbundles — no auto-explode. Ingest the individual documents.
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.