Skip to content

Document Filing

A scan-to-file pipeline. PDFs that arrive in silicon-zack's dedicated Fastmail inbox β€” sent by a scanner or forwarded from bio-zack's personal email β€” are classified by an LLM, renamed to a sortable standard format, uploaded to the right Google Drive folder, and announced over Telegram with enough context that a reply can refile them. It runs in near-real-time, triggered by the Sentinel framework on new mail rather than on a poll loop.

The pipeline lives almost entirely in one file, scripts/documents/scan_filer.py, with mail I/O in scripts/mail/ and Drive I/O in scripts/integrations/google_api.py.

Architecture

EMAIL SOURCES
+------------------------+     +---------------------------+
|     Scanner device     |     |  Forwarded documents      |
|  (scans β†’ PDF email)   |     |  from bio-zack's email    |
+----------+-------------+     +-------------+-------------+
           |                                 |
           +----------------+----------------+
                            |
                            v
            +-------------------------------+
            |   FASTMAIL INBOX (vita inbox) |   <- silicon-zack's address
            +---------------+---------------+
                            |  IMAP IDLE: new-mail push
                            v
            +-------------------------------+
            |   SENTINEL: fastmail-inbox    |   data/sentinel/sentinels.json
            |   detect (IMAP IDLE) -> respond
            +---------------+---------------+
                            |  python scripts/mail/fastmail_sentinel.py respond
                            v
            +-------------------------------+
            |   fastmail_sentinel.respond() |
            |   1. email_processor (VDB)    |   <- other handler, see vdb.md
            |   2. scan_filer.run()  -------+---+
            +-------------------------------+   |
                                                v
            +-----------------------------------------------+
            |              SCAN FILER (run())               |
            |  find_pdf_emails  -> known-sender + dedup     |
            |  download_pdf     -> JMAP blob download       |
            |  classify_pdf     -> GEMINI FLASH via OpenRouter
            |  ensure_folder    -> create Drive hierarchy   |
            |  upload_to_drive  -> drive_upload_file()      |
            |  notify           -> Telegram message + context
            +-----------------------------------------------+
                            |
                            v
            +-------------------------------+
            |   GOOGLE DRIVE (personal acct)|
            +-------------------------------+
                            |
                            v
            +-------------------------------+
            |   TELEGRAM NOTIFICATION       |   reply -> refile
            +-------------------------------+

Why this shape

  1. Dedicated filing inbox. Documents go to silicon-zack's own address, not a personal inbox, so filing never clutters human mail. The scanner emails there directly; humans forward anything worth keeping.
  2. Known-sender filtering. Only mail from a small KNOWN_SENDERS allowlist is processed. Everything else is silently ignored β€” no spam or phishing PDF ever reaches Drive.
  3. LLM classification. The classifier reads the PDF itself: it extracts the document's own date (not the email's), writes a descriptive filename, and chooses a folder by matching against the live Drive folder list. This beats sender/subject rules, which break the moment a new document type arrives.
  4. Date-prefixed filenames. YYYY-MM-DD_descriptive-name.pdf sorts chronologically and is searchable by date.
  5. Folder cache with auto-creation. The Drive folder tree is cached locally to avoid repeated API calls, and any folder the classifier names but that doesn't exist is created along the path.
  6. Reply-enabled notifications. The Telegram notice carries structured context (file id, current folder, available folders) so a human reply like "move to Health/MRI" can be acted on.

Trigger

The pipeline is event-driven, not scheduled. A Sentinel watches the inbox over IMAP IDLE and fires the moment new mail lands.

The sentinel definition lives in data/sentinel/sentinels.json:

{
  "id": "fastmail-inbox",
  "name": "Fastmail inbox monitor",
  "enabled": true,
  "detection": {
    "mode": "imap_idle",
    "config": {
      "host": "imap.fastmail.com",
      "port": 993,
      "username": "<vita-inbox-address>",
      "credentials_file": "fastmail.json",
      "credentials_key": "imap_password",
      "mailbox": "INBOX",
      "idle_timeout": 300,
      "reconnect_delay": 30
    }
  },
  "response": {
    "mode": "run_script",
    "config": { "command": "python scripts/mail/fastmail_sentinel.py respond" }
  },
  "schedule": { "cooldown_seconds": 60 }
}
Field Meaning
mode: imap_idle Holds an IMAP IDLE connection; wakes on server push
idle_timeout: 300 Re-issue the IDLE command every 300s to keep the connection live
reconnect_delay: 30 Wait 30s before reconnecting after a dropped connection
cooldown_seconds: 60 Minimum gap between two respond runs
credentials_key: imap_password The IMAP password lives in ~/.config/vita/fastmail.json under this key

Note: The earlier design of this pipeline used a 30-minute scheduler loop (_scan_filer_loop in the API). That no longer exists. Filing is now real-time via the Sentinel. See Sentinel for the detector/responder model.

fastmail_sentinel.respond() runs two handlers in sequence:

def respond():
    # 1. VDB replies + general email routing
    from mail.email_processor import process_emails
    process_emails()

    # 2. PDF attachments -> classify -> Drive
    from documents.scan_filer import run as run_scan_filer
    run_scan_filer()

Both share the inbox, so the sentinel de-dupes across both state files. Its detect() step merges processed-id sets from data/email/processor_state.json (raw JMAP ids) and data/documents/state.json (ids prefixed fastmail:, normalized by stripping the prefix), and triggers only if a truly new message exists.

Manual entry points (no sentinel needed):

python scripts/documents/scan_filer.py            # normal run
python scripts/documents/scan_filer.py --dry-run   # list what would be processed
python scripts/documents/scan_filer.py --reindex   # refresh the Drive folder cache only

The pipeline, step by step

1. Find candidate emails β€” find_pdf_emails(state)

Calls get_emails_with_attachments(hours=168) from scripts/mail/fastmail.py to pull the last 7 days of mail with attachments over JMAP. (The function's own default is hours=24; the scan filer passes 168 explicitly.) Each email is then:

  • skipped if its id (fastmail:<jmap_id>) is already in state["processed_email_ids"];
  • skipped unless its from address contains a known sender (case-insensitive substring match against KNOWN_SENDERS);
  • reduced to its attachment list (filename, blobId, size).

Only emails that survive all three checks and still have attachments become candidates.

KNOWN_SENDERS = [
    "scanner@example.com",   # scanner device  (illustrative placeholder)
    "user@example.com",      # forwarded docs  (illustrative placeholder)
]

Note: Matching is substring, not exact β€” KNOWN_SENDERS entries match anywhere inside the From header. Editing the list is a code change.

2. Download β€” download_pdf(email_id, attachment)

Pulls the attachment bytes via download_blob(blob_id, filename) (JMAP blob download). If blobId is missing or the download throws, it logs and returns None, which the caller counts as an error and does not mark the email processed β€” so it retries next run.

MAX_PDF_SIZE = 10 * 1024 * 1024 (10 MB). Oversized PDFs are warned about but still returned β€” they fall through to classification, which may degrade or fall back.

3. Classify β€” classify_pdf(...)

This is the heart of the pipeline, and the part most worth understanding for a reimplementation.

  • Provider: OpenRouter chat-completions endpoint (https://openrouter.ai/api/v1/chat/completions), called directly with httpx.
  • Model: google/gemini-3-flash-preview (a Gemini Flash model). This is a deliberate choice: the PDF is sent as inline base64 file data straight to the model, avoiding the Claude daemon because that daemon is unavailable inside the Docker container this runs in. (For how vita routes other LLM work, see Model Guidance.)
  • Auth: API key from OPENROUTER_API_KEY env var, falling back to ~/.config/vita/openrouter.json ({"api_key": "..."}). No key β†’ fallback classification, no network call.

The request shape:

httpx.post(
    "https://openrouter.ai/api/v1/chat/completions",
    headers={
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "HTTP-Referer": "https://vita.example.xyz",  # OpenRouter attribution
        "X-Title": "Vita Scan Filer",
    },
    json={
        "model": "google/gemini-3-flash-preview",
        "messages": [{
            "role": "user",
            "content": [
                {"type": "file", "file": {
                    "filename": original_filename or "document.pdf",
                    "file_data": f"data:application/pdf;base64,{pdf_b64}",
                }},
                {"type": "text", "text": prompt},
            ],
        }],
        "temperature": 0.1,
        "max_tokens": 500,
    },
    timeout=120.0,
)

The prompt lists every cached Drive folder as - {path} (id: {id}) and asks for a single JSON object only:

{
    "filename": "YYYY-MM-DD_descriptive-name.pdf",
    "folder_path": "Best matching folder path, or a new path if none fit",
    "folder_id": "ID from the list if matched, or null if suggesting a new folder",
    "summary": "One sentence describing the document",
    "category": "medical|financial|legal|insurance|vehicle|home|personal|other"
}

Filename rules baked into the prompt: start with the document's own date (from content, not today's, unless unclear); lowercase kebab-case after the date; descriptive but concise; include the source organization when identifiable; keep the .pdf extension.

Folder rules: prefer existing folders; only propose new ones for genuinely new categories; use slash-separated paths for nesting.

Response parsing is defensive β€” the model output is treated as untrusted text:

  1. Non-200 status β†’ fallback.
  2. Strip a leading ``` markdown fence (and trailing fence) if present.
  3. Slice from the first { to the last } to drop any commentary.
  4. json.loads the result; a JSONDecodeError (or any other exception) β†’ fallback.

Fallback β€” _fallback_classification(subject, filename)

Used whenever classification can't produce valid JSON (no key, non-200, timeout, parse failure). It never fails:

{
    "filename": f"{today}_{clean_name}",   # today's date + sanitized original name
    "folder_path": "Unsorted",
    "folder_id": None,
    "summary": f"Document from email: {email_subject}",
    "category": "other",
}

clean_name lowercases the original filename and replaces spaces and underscores with hyphens, ensuring a .pdf suffix. The document still gets filed β€” to Unsorted β€” rather than dropped.

4. Ensure the folder β€” ensure_folder(classification, drive_folders)

If the classification returned a folder_id, it's used as-is. Otherwise the folder_path is split on / and walked left to right from root: each segment is matched against the cached folder list by partial path, and any missing segment is created via drive_create_folder(name, parent_id). Newly created folders are appended to the in-memory drive_folders list (and persisted to the cache at end of run), so a Health/Labs/2026 path creates whichever of the three levels don't yet exist and returns the deepest folder's id. A failed drive_create_folder stops the walk and returns the last good parent.

5. Upload β€” upload_to_drive β†’ drive_upload_file

drive_upload_file(pdf_bytes, filename, "application/pdf", folder_id)
# -> {"id": ..., "name": ..., "webViewLink": ...}  or None on error

Uploads via MediaInMemoryUpload to the personal Drive account, requesting id, name, webViewLink back. None means the upload failed.

6. Notify β€” notify(classification, drive_result, folders, folder_id)

Queues a Telegram message through the proactive queue (enqueue_message in scripts/proactive/queue.py) with trigger "scan_filer".

On success:

Filed document to Drive

*2026-01-15_example-document.pdf*
Folder: [Health/Labs](https://drive.google.com/drive/folders/<folder_id>)
One-sentence summary of the document.

[Open file](https://drive.google.com/file/d/.../view)

On upload failure it instead sends a "Failed to upload document" notice pointing at the logs.

Crucially, every notification carries a structured context dict so a reply can refile the document:

context = {
    "type": "scan_filer_document",
    "filename": ...,
    "folder_path": ...,
    "folder_id": ...,
    "summary": ...,
    "category": ...,
    "drive_file_id": ...,         # added when upload succeeded
    "drive_link": ...,
    "folder_link": ...,
    "available_folders": [f["path"] for f in folders],   # paths only, IDs omitted
}

A dedup_key of scan-filer-<filename>-<YYYY-MM-DDTHH:MM> prevents duplicate notices within the same minute.

7. Refile by reply (agent-mediated)

When a human replies to a filing notice in Telegram, scripts/telegram_bot.py looks up the stored context by message id. For type == "scan_filer_document" it injects the document's filename, current folder, Drive file id, category, link, and the available-folder list into the agent prompt, then appends:

[To refile: use drive_move_file(file_id, folder_id).
 To create folder: use drive_create_folder(name, parent_id).]

So refiling is handled by a Claude agent acting on natural language ("move it to Health/MRI"), using drive_move_file / drive_create_folder β€” there is no hardcoded "move to X" parser. The notification provides the context; the agent does the work.

State β€” data/documents/state.json

One JSON file, written atomically (atomic_write) and read with safe_read_json.

{
  "processed_email_ids": ["fastmail:<jmap_id>", "..."],
  "folder_cache": [
    {"id": "<id>", "name": "Health", "path": "Health"},
    {"id": "<id>", "name": "Labs",   "path": "Health/Labs"}
  ],
  "folder_cache_updated": "2026-02-01T20:29:18",
  "last_run": "2026-02-02T11:29:24",
  "stats": { "total_filed": 0, "last_filed": null }
}
Field Purpose
processed_email_ids Already-handled emails, capped at the most recent 500
folder_cache Cached Drive folder tree (id, name, path)
folder_cache_updated Cache freshness timestamp (ISO)
last_run Last pipeline execution (set even when nothing was found)
stats.total_filed Lifetime count of successfully uploaded documents
stats.last_filed Timestamp of the most recent successful upload

Folder cache freshness (get_folders / refresh_folder_cache): the cache is reused if it exists and is under 24h old; otherwise drive_list_folders(max_depth=3) rebuilds it. (drive_list_folders defaults to max_depth=2; the scan filer asks for 3.) --reindex forces a rebuild and prints the folder count and paths.

Note: An email is marked processed (and so never retried) as soon as its attachments have been attempted β€” even if upload failed and an error notice was sent. A failed download is the exception: it does not mark the email, so it retries next run.

Categories

The classifier tags each document with one category. Categories are stored but not used for routing β€” folder paths handle placement. They're available for future analytics or archival policy.

Category Examples
medical Lab/health documents, reports, visit notes
financial Statements, investment reports, pay stubs
legal Contracts, agreements, court documents
insurance Policies, claims, benefit statements
vehicle Registration, titles, service records
home Property docs, HOA, utilities
personal IDs, certifications, miscellaneous
other Fallback for unclassifiable documents

Configuration

Fastmail (~/.config/vita/fastmail.json)

JMAP keys drive sending and attachment download; the IMAP password drives the IDLE sentinel. Both live in one file.

{
  "jmap_token": "<token>",
  "account_id": "<account-id>",
  "from_email": "<vita-inbox-address>",
  "to_email": "<recipient-address>",
  "imap_password": "<app-specific-password>"
}

load_config() requires jmap_token, account_id, from_email, to_email. The sentinel additionally reads imap_password (via credentials_key).

OpenRouter (~/.config/vita/openrouter.json or env)

{ "api_key": "<openrouter-api-key>" }

Or set OPENROUTER_API_KEY in the environment, which takes precedence.

Google Drive (personal account)

Drive operations use the personal Google account credentials in scripts/integrations/google_api.py:

File Purpose
~/.config/vita/google_credentials_personal.json OAuth client (desktop app)
~/.config/vita/google_oauth_personal.json Stored OAuth token

Required scope: https://www.googleapis.com/auth/drive (full Drive β€” read folders + upload). Run the auth flow with:

python scripts/integrations/google_api.py reauth personal

(reauth also accepts work and photos.)

CLI

# normal run β€” prints a JSON stats summary
python scripts/documents/scan_filer.py
{ "found": 2, "processed": 2, "errors": 0, "skipped": 0 }

found counts attachments (not emails). --dry-run downloads and logs what it would process, counts them as skipped, and writes nothing. --reindex only refreshes the folder cache:

Cached 142 folders
  Health
  Health/Labs
  ...

Error handling

Failure Behavior
No OpenRouter key / non-200 / parse error / timeout (120s) Fallback classification β†’ file to Unsorted
PDF download fails Counted as error; email not marked processed β†’ retried next run
PDF over 10 MB Warned, still attempted (may degrade or fall back)
Drive upload fails "Failed to upload" Telegram notice; email is marked processed (no infinite retry)
Fastmail unreachable find_pdf_emails logs and returns []; sentinel detect() exits 0 (no trigger)

Monitoring

# recent activity
jq '{last_run, total_filed: .stats.total_filed, last_filed: .stats.last_filed}' \
   data/documents/state.json

# folder cache age
jq '.folder_cache_updated' data/documents/state.json

Known limitations

  1. Single inbox. One filing address only.
  2. PDF only. Image scans (JPG/PNG) are ignored; configure the scanner for PDF.
  3. No OCR fallback. Classification quality depends on the model reading the PDF; there is no separate OCR stage.
  4. Manual sender updates. KNOWN_SENDERS is hardcoded β€” adding one is a code change.
  5. Refile is agent-mediated, not parsed. Replies are handled by an LLM agent with the right context and tools, not by a deterministic command parser.

File locations

File Purpose
scripts/documents/scan_filer.py Main pipeline (~541 lines)
scripts/mail/fastmail_sentinel.py Sentinel responder: routes PDFs + VDB replies (~106 lines)
scripts/mail/fastmail.py JMAP client: fetch, attachments, blob download (~834 lines)
scripts/integrations/google_api.py Drive operations + OAuth (~2355 lines)
data/sentinel/sentinels.json fastmail-inbox IMAP-IDLE sentinel definition
data/documents/state.json Pipeline state (processed ids, folder cache, stats)
~/.config/vita/fastmail.json JMAP creds + IMAP password
~/.config/vita/openrouter.json Classifier API key

Where to go next

  • Sentinel β€” the event-driven detector/responder framework that triggers this pipeline
  • VDB β€” the other handler in fastmail_sentinel.respond() (email-routed inbox)
  • Proactive β€” the Telegram message queue used for filing notices
  • Model Guidance β€” how vita routes LLM work across providers
  • Photos β€” the parallel Google-account media pipeline