# InboxOS — Operations Runbook **Audience:** Any operator (or the same operator after a 6-month gap) recovering from common failure modes without re-deriving the architecture. **Status:** Phase 5 deliverable for [INB-7](/INB/issues/INB-7) — issued under [INB-35](/INB/issues/INB-35). **Scope:** Single self-hosted pipeline. A Gmail inbox → IMAP poll → SeaweedFS archive + LLM-extracted rows in NocoDB + curated attachments in Papra. Everything is self-hosted; no third-party SaaS touches PII. **Acceptance test for this runbook:** *"Given a workflow is stuck in `in_progress` for 1 hour, what do you do?"* — answer in under 5 minutes using §5.1 below. --- ## 1. System overview InboxOS is a self-hosted, air-gapped personal ERP/CRM pipeline. It polls a single Gmail inbox over IMAP, backs up the raw `.eml` to SeaweedFS, classifies each email with a local LLM (Ollama), runs one or more `inboxos:extract-` prompts against the same email, and UPSERTs the resulting structured records into NocoDB (one table per module: `finance`, `crm`, `intel`, `promotions`). Qualifying attachments are also uploaded to Papra for long-term searchability. The historical pipeline performs the same flow against a Google Takeout `.mbox` for backfill, with byte-offset checkpoints for safe resume. ### 1.1 Topology ``` ┌──────────────┐ IMAP/993 ┌─────────────────┐ │ Gmail │ ◀───────────── │ gitea-runner-1 │ │ inbox │ (App Password)│ gitea-runner-2 │ (self-hosted, parallel) └──────────────┘ └────────┬────────┘ │ run jobs (Gitea Actions) │ polls IMAP, calls Ollama ▼ ┌──────────────────┐ raw .eml ┌─────────────────┐ │ Papra │ attachment │ SeaweedFS │ S3-compatible │ (long-term doc)│ ◀───────────│ (S3 API) │ .eml archive + │ intake │ uploads │ │ state checkpoints └──────────────────┘ └────────┬────────┘ │ UPSERT by Message-ID ▼ ┌─────────────────┐ direct HTTP │ NocoDB │ ── /v1/chat │ (DWH) │ /completions └─────────────────┘ │ ▲ ▼ ┌─────────────────┐ │ Ollama │ local LLM │ (FRP) │ gemma4:e4b │ /v1/chat │ │ /completions │ └─────────────────┘ ``` - **Gmail** is the source. The runner authenticates with an App Password (2FA must be on). - **Gitea** is the version-controlled home for prompts, scripts, and CI. - **Gitea Actions runners** (`gitea-runner-1` and `gitea-runner-2`, both self-hosted) execute the live and historical pipelines. Two runners so the 5-minute live cron and a long historical run can execute in parallel. - **SeaweedFS** is the system of record for raw `.eml` (in `s3://$SEAWEEDFS_S3_BUCKET/eml/...`) and pipeline state (`state/live-state.json`, `state/historical-state.json`). - **NocoDB** is the structured warehouse. Every module table is keyed on `Message_ID` (UNIQUE). All writes are UPSERTs. - **Papra** holds curated attachments and (selectively) full email bodies for long-term search. The first ship goes out in **dry-run** mode. - **Ollama** serves the local LLM behind a FRP tunnel at `https://ollama-frp.cloudinit.dev/v1`. No external API calls for PII-bearing data. --- ## 2. Service inventory All services run in the `inboxos-net` Docker network defined in `docker/docker-compose.yml`. | # | Service | Container / DNS name | Public URL | Role in pipeline | Restart | Log location | |---|------------------|---------------------------|-----------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------------------------------|-----------------------------------------------------------------------| | 1 | **NocoDB** | `nocodb` | `https://noco.cloudinit.dev` | ERP/CRM data warehouse. Four module tables (`finance`, `crm`, `intel`, `promotions`). | `docker compose -f docker/docker-compose.yml restart nocodb` | `docker logs nocodb` | | 2 | **Gitea** | `gitea` | `https://git.cloudinit.dev` | Version control for prompts, scripts, and CI. Hosts Actions runner registration & secrets. | `docker compose -f docker/docker-compose.yml restart gitea` | `docker logs gitea` | | 3 | **Gitea Actions Runners** | `gitea-runner`, `gitea-runner-2` | n/a (self-hosted) | Execute the live and historical workflows. Two so live cron + historical run can run in parallel. | `docker compose -f docker/docker-compose.yml restart gitea-runner [gitea-runner-2]` | `docker logs gitea-runner [gitea-runner-2]` + per-job artifact under `pipelines/_artifacts/*.log` | | 4 | **Papra** | `papra` | `https://papra.cloudinit.dev` | Curated document archive (attachments + selected bodies). First ship is dry-run. | `docker compose -f docker/docker-compose.yml restart papra` | `docker logs papra` | | 5 | **SeaweedFS** | `seaweedfs-master`, `-volume`, `-filer` | `https://storage.cloudinit.dev` (via reverse proxy) on `:8888` S3 endpoint in-network | Object store for `.eml` archive and pipeline state checkpoints. | `docker compose -f docker/docker-compose.yml restart seaweedfs-{master,volume,filer}` | `docker logs seaweedfs-{master,volume,filer}` | | 6 | **Ollama** | `ollama` | `https://ollama-frp.cloudinit.dev/v1` | Local LLM inference for `inboxos:classify` and `inboxos:extract-*`. Default model `gemma4:e4b`. | `docker compose -f docker/docker-compose.yml restart ollama` | `docker logs ollama` | The **OpenCode CLI** is not invoked by either pipeline; both pipelines call Ollama's OpenAI-compatible `/v1/chat/completions` endpoint directly via `pipelines.common.llm_run_command` (default backend: `ollama`). The `opencode` binary install step in `historical-pipeline.yml` is retained for any human-driven `opencode` invocation from the repo worktree; the pipeline itself does not shell out to it. See `docs/extraction-contract.md §Legacy: opencode CLI backend` for the rationale. The **reverse proxy** (Caddy in the commented-out `caddy` service in `docker/docker-compose.yml`) terminates TLS for `*.cloudinit.dev` and routes to each service. Operators without a reverse proxy can reach services via the `inboxos-net` Docker DNS names (e.g. `http://gitea:3000`, `http://seaweedfs-filer:8888`). --- ## 3. Workflow operations All workflows live in `.gitea/workflows/`. They are declarative, version-controlled in the same repo as the scripts they invoke, and have no separate runtime state to back up. This is the board decision recorded in [INB-8](/INB/issues/INB-8) — n8n was ruled out as unreliable. **Quickstart:** for a 15-minute green-field spin-up, see `docs/quickstart.md` and `scripts/quickstart.sh` (REQ-INB-40). The script runs the 8 steps below in sequence and prints the runbook §X.Y cross-ref on any failure. **Daily digest:** see `scripts/daily_digest.py` and `.gitea/workflows/daily-digest.yml` (REQ-INB-47). The workflow runs on `cron 0 6 * * *` and emails a one-line per-module summary. **Container hardening:** all services run with `cap_drop: [ALL]`, `security_opt: no-new-privileges:true`, and either `read_only: true` (no write access needed) or `read_only: false` with a `tmpfs: [/tmp]` mount (for services that need a writable scratch dir). See `docker/docker-compose.yml` for the full stanza and the per-service `cap_add` list (REQ-INB-55). **DPIA:** see `docs/security/dpia.md` (REQ-INB-58). The Data Protection Impact Assessment per GDPR Art. 35 names the data flows, the lawful basis, the data-subject rights, and the residual risks. **Threat model:** see `docs/security/threat-model.md` (REQ-INB-59). The STRIDE-per-component decomposition covers the 10 components in the inboxos architecture. ### 3.1 Live pipeline — `live-pipeline.yml` | Aspect | Value | |----------------|---------------------------------------------------------------------------------------------| | Schedule | `cron(*/5 * * * *)` — every 5 minutes | | Manual trigger | `workflow_dispatch` with optional inputs `since_uid` (override high-water mark) and `max_emails` (default 50) | | Concurrency | `group: live-pipeline, cancel-in-progress: false` (overlapping runs must not trample state) | | Timeout | 15 minutes | | Script | `pipelines/live_pipeline.py` | | Run log | `pipelines/_artifacts/live-run-.log` (uploaded as `live-pipeline-log` artifact) | **Flow** (verifiable in `pipelines/live_pipeline.py`): 1. Load the IMAP UID checkpoint from `s3://$SEAWEEDFS_S3_BUCKET/state/live-state.json`. 2. Poll IMAP for `UID > last_uid` (bounded by `MAX_EMAILS_OVERRIDE`, default 50). 3. For each message, in UID-ascending order: - Back up the raw `.eml` to `s3://$SEAWEEDFS_S3_BUCKET/eml/live///
/.eml` (skip if exists). - Call `inboxos:classify` to pick which modules apply. - For each applicable module, call `inboxos:extract-` and UPSERT the result. - Flush the checkpoint (`state/live-state.json`) **per message** so a crash never replays a success. **What success looks like in the run log:** - One `live_run_start` event at the top. - One or more `process_start` → `eml_backed_up` → `classified` → `process_done` event groups, in UID-ascending order. - Every `process_done` is followed by a checkpoint write (visible as the `state/live-state.json` PUT in S3, or as the next call to `imap_fetched`). **What failure looks like:** - A `classify_error` or `extract_permanent_failure` event means the pipeline stops at that UID. The next run retries it. - A `dead_letter_unclassified` event means the email had no module match — the raw `.eml` is preserved at `eml/dead-letter/.eml` and the cursor advances. - A `missing_message_id` event means the email lacked a `Message-ID:` header. The raw `.eml` is preserved at `eml/dead-letter/uid-.eml`. - A non-zero job exit code with no `live_run_done` in the log means the runner OOM'd or the IMAP connection was killed mid-fetch — see §5.1. ### 3.2 Historical pipeline — `historical-pipeline.yml` | Aspect | Value | |----------------|---------------------------------------------------------------------------------------------| | Schedule | **None** — manual only (`workflow_dispatch`). Heavy operation; full archives run for hours. | | Required input | `mbox_object_key` — the SeaweedFS object key of the `.mbox` (e.g. `eml/historical/all-mail.mbox`) | | Optional inputs| `start_offset` (resume from a byte offset; leave blank to use persisted checkpoint), `batch_size` (default 100), `limit` (0 = no limit) | | Concurrency | `group: historical-pipeline, cancel-in-progress: false` | | Timeout | 12 hours | | Script | `pipelines/historical_pipeline.py` (canonical reference for the extraction contract) | | Streamer | `pipelines/mbox_stream.py` — byte-offset streaming parser for `.mbox` | | Run log | `pipelines/_artifacts/historical-run-.log` (uploaded as `historical-pipeline-log` artifact) | **Idempotency contract:** - **Message-ID UPSERT** is the only write path into NocoDB (`pipelines/common.py::nocodb_upsert`). It does a GET by `Message_ID` and PATCHes if present, POSTs otherwise. Re-running the same `.mbox` produces zero duplicates. - **Byte-offset checkpoint** is persisted to `s3://$SEAWEEDFS_S3_BUCKET/state/historical-state.json` every `BATCH_SIZE` messages (default 100) and on shutdown. A crashed run resumes from the last checkpoint on the next dispatch. - **Object keys** are deterministic by `Message-ID` and date, so re-uploading the same `.eml` overwrites the same S3 object. **Expected runtime per 10K messages:** ~5–10 minutes on a clean `c5.large` runner when the LLM is the bottleneck (depends on the model — `gemma4:e4b` is fast; `gemma4:12b` adds ~2×). The I/O-bound parts (S3 GET of the `.mbox`, S3 PUTs of `.eml`, Papra uploads) are sub-second per message. **How to resume after a crash:** 1. Re-dispatch `historical-pipeline.yml` with the **same** `mbox_object_key` and **no** `start_offset` input. The script loads `state/historical-state.json` and resumes from the persisted byte offset. 2. Do **not** dispatch with `start_offset=0` unless you have deleted the state file — that re-processes everything from the top (safe but wasteful, and will take hours). **How to dead-letter a message manually:** - A message that was already dead-lettered by the pipeline lives at `s3://$SEAWEEDFS_S3_BUCKET/eml/dead-letter/historical-.eml` (or `.eml`). To permanently skip a poison message, find its `Message-ID` in the run log, then write a marker object to `eml/dead-letter/poison/.json` containing the reason. A future enhancement will let the script consult this list; for now, mark it in your run log and move on. ### 3.3 PR CI — `ci-pr.yml` | Aspect | Value | |----------------|---------------------------------------------------------------------------------------------| | Triggers | `pull_request` opened / reopened / synchronized against `main`; `workflow_dispatch` | | Concurrency | `group: pr-ci-${{ pull_request.number }}`, cancel-in-progress | | Jobs | `fast-tests` (~30s), `e2e-100mb` (20 min), `live-llm-smoke` (15 min, real Ollama) | | Required secrets | `NOCODB_BASE_URL`, `NOCODB_API_TOKEN`, `NOCODB_PROJECT_ID`; `OLLAMA_BASE_URL` is optional | - `fast-tests` exercises `gitea_request`, `mbox_stream`, the offline parts of `live_pipeline`, and the fast subset of `historical_pipeline` (config, parsing, dead-letter paths). - `e2e-100mb` builds a 100 MB synthetic `.mbox` and runs the historical pipeline end-to-end with S3 / NocoDB / Papra stubbed. Verifies UPSERT idempotency, crash-and-resume offset stability, and dead-letter semantics. The merge gate. - `live-llm-smoke` calls the real Ollama endpoint with the Substack input that previously dead-lettered in live run #674 (UID 4017, 86 KB body) and asserts the extract step returns parseable JSON. Skipped when `OLLAMA_BASE_URL` is not set; surfaces a clear `skipped` notice rather than failing the PR. --- ## 4. Observability ### 4.1 Reading a workflow run log 1. Open the Gitea UI → repo → **Actions** → click the run. 2. The job's `Run process-new-emails` / `Run process-mbox` step shows stdout in the Gitea Actions UI. Each line is a JSON object emitted by `log_event`. 3. Expand the **Upload run log as artifact** step. The per-run log file is downloaded as `live-pipeline-log` or `historical-pipeline-log`. This is identical to what was streamed to stdout, and is the canonical artifact to attach to a post-mortem. 4. The artifact also lives in the project workspace at `pipelines/_artifacts/-run-.log`. ### 4.2 Structured event vocabulary Every step in both pipelines calls `pipelines.common.log_event` to emit a JSON line. The canonical event names (verified in source) are: | Event | Where | What it tells you | |--------------------------------|--------------------------------------------|--------------------------------------------------------------------------------| | `process_start` | `live_pipeline.py:428`, `historical_pipeline.py:282` | A new message is about to be processed. Carries `uid` (live) or `offset` (historical) and `message_id`. | | `eml_backed_up` | both pipelines | The raw `.eml` was PUT to SeaweedFS. Carries `key` and `bytes`. | | `classified` | both pipelines | `inboxos:classify` returned a non-empty module list. Carries `modules`. | | `llm_call` | `common.py:591` | The Ollama call is about to be made. Carries `backend`, `modelID`, `command`, `url`. | | `process_done` | both pipelines | The message was processed end-to-end. Carries `upserts` (NocoDB action list). | | `module_table_missing` | both pipelines | A module came back from classify but has no entry in `NOCODB_MODULE_TABLES`. Carries `module`. | | `extract_permanent_failure` | both pipelines | `inboxos:extract-` failed irrecoverably. The run stops without advancing. | | `dead_letter_unclassified` | both pipelines | Classify returned `[]`. The `.eml` is preserved at `eml/dead-letter/...` and the cursor advances. | | `historical_run_done` | `historical_pipeline.py:545` | The historical run finished cleanly. Carries totals. | | `live_run_start` | `live_pipeline.py:533` | The live run began. Carries `started_at`. | | `papra_decision` | `common.py:997` | The Papra write-through decision was made. Carries `would_write`, `reason`, `module`, `dry_run`. | | `papra_write_ok` / `papra_write_failed` / `papra_write_dry_run` | `common.py:1018`-`1055` | Per-target outcome of a Papra write. | | `state_recovered_from_corrupt` | `live_pipeline.py:550` | The persisted IMAP checkpoint was malformed; the run reset it. | The `live_pipeline.py` source also emits `imap_search_result`, `imap_fetch_missing_body`, `imap_fetched`, `classify_error`, `extract_invalid_response`, `missing_message_id`, `config_error`, `since_uid_override`, `stopped_on_failure_at_uid`, and others (search for `log_event` in `pipelines/live_pipeline.py` for the full list). ### 4.3 NocoDB table inventory The four module tables (verified against `pipelines/_tests/test_upsert_synthetic.py` and `docs/phase-1.1-verification/inb-28-report.md`): | Module | Table name in NocoDB | Table ID | Notes | |--------------|----------------------|---------------------|-------| | `finance` | `pl_finance` | `m0thluc3jyz2iok` | Receipts, invoices, P&L items. 14 user columns. | | `crm` | `personal_crm` | `m2wsbpp01v08pm5` | People, organisations, intent. 15 user columns. | | `intel` | `daily_news` | `msi6dj1cany1b5r` | Newsletters, briefings. 13 user columns. | | `promotions` | `promotions` | `mf7catfwwyh3jj5` | Sales, promo codes, launches. 14 user columns. | Per-table row counts and column schemas are listed in `docs/phase-1.1-verification/inb-28-report.md` and `docs/schemas.md`. Every module table has `Message_ID` (SingleLineText, UNIQUE, required) as the natural key. ### 4.4 SeaweedFS bucket layout `$SEAWEEDFS_S3_BUCKET` is `inboxos` (default in `.env.example`). The layout is: ``` s3://inboxos/ ├── eml/ │ ├── live/ │ │ └── //
/.eml │ ├── historical/ │ │ └── //
/.eml │ └── dead-letter/ │ ├── .eml (live, unclassified) │ ├── uid-.eml (live, missing Message-ID) │ └── historical-.eml (historical) └── state/ ├── live-state.json {"last_uid": , "last_run_at": , "messages_seen": } └── historical-state.json {"mbox_key": , "offset": , "messages_seen": , "last_run_at": } ``` > State-file field names come from the actual code: live > (`pipelines/live_pipeline.py:600-603`) and historical > (`pipelines/historical_pipeline.py:516-521`). The > `_corrupt_state_recovered` field is an additional transient > key on first load when the persisted JSON is unparseable. The state files are tiny JSON blobs written via `s3_put_bytes` after every successful message (live) or every `BATCH_SIZE` messages (historical). They are the **only** persistent state between workflow runs. ### 4.5 Metrics surface (REQ-INB-23) Each pipeline run writes **one** JSONL line to `pipelines/_artifacts/-metrics-.jsonl` (one file per run; never overwritten). The line shape and counter / gauge vocabulary are documented in `pipelines/_metrics/emit.py` (a copy-paste of the docstring is in this runbook for operator reference). | Counter key | Type | What it tells you | |----------------------------|------|------------------------------------------------------------| | `messages_processed` | int | Total messages processed in this run. | | `messages_dead_lettered` | int | Subset routed to the dead-letter prefix. | | `noco_upsert_ok` | int | NocoDB upserts that returned 2xx. | | `noco_upsert_4xx` | int | 4xx (drift; permanent failure for the affected message). | | `noco_upsert_5xx` | int | 5xx (transient; retried by tenacity). | | `papra_would_write` | int | Papra decisions that resolved `would_write=true`. | | `papra_write_ok` | int | Papra HTTP POSTs that returned 2xx. | | `papra_write_failed` | int | Papra HTTP POSTs that returned >= 400. | | `papra_index_upsert_ok` | int | `papra_index` audit-trail rows written. | | `papra_index_upsert_failed`| int | `papra_index` upserts that raised. | | `llm_call_seconds_sum` | int | Total wall-clock ms spent in `requests.post` to Ollama. | | `llm_call_count` | int | Number of LLM HTTP POSTs in the run. | | Gauge key | Type | What it tells you | |--------------------------|----------------|-------------------------------------------------| | `last_run_at` | ISO 8601 str | The run's `now_iso()` at metrics emission. | | `dead_letter_total` | int | Cumulative dead-letter prefix size (best-effort). | | `papra_dry_run` | "true"/"false" | The effective `PAPRA_DRY_RUN` after precedence. | | `papra_dry_run_override` | "true"/"false"/"none" | The parsed override (or "none" if unset). | **Tail-and-summarize procedure.** To compute a daily total of `messages_processed` across all live runs: ```bash cat pipelines/_artifacts/live-metrics-*.jsonl \ | jq -s '[.[].counters.messages_processed] | add' ``` To inspect the last 20 metrics lines in time order: ```bash cat pipelines/_artifacts/live-metrics-*.jsonl \ | tail -20 \ | jq -c '{ts, run_id, counters}' ``` **Forward-pointer.** Alerting is **out of scope** for v1.4. v1.2 closed on a 0/1 ideation disposition (no candidates accepted) per `.ciagent/inboxos/CI-LOG.md`, and v1.3 was a doc-hygiene milestone; v1.4 added the operator audit cursor and the pollable healthcheck but no alert layer. The metrics file is the substrate; the alert layer is the next v1.x candidate cycle to re-evaluate. In the meantime the runbook §4 observability surface is logs-only, with `pipelines/_metrics/emit.py` emitting a per-run `metrics.jsonl` (REQ-INB-23) for offline postmortem and `pipelines/_metrics/healthcheck.json` (REQ-INB-52) as the canonical uptime-monitor pollable endpoint. **Opt-out.** Set `LLM_METRICS_ENABLED=false` in the pipeline env to skip the `time.monotonic()` wrapping around the LLM HTTP POST. The counters default to enabled; opt-out is for canary runs that want to measure pure LLM latency without the wrapper overhead. --- ## 5. Common failure modes and recovery ### ERROR-NN catalog A one-page lookup table for the on-call operator. If you are reading this from a pager, the table below tells you which §5.X subsection to jump to for the full recovery procedure. | ERROR-NN | Symptom | See §X.Y | |-----------|------------------------------------------------------|----------| | ERROR-01 | Runner stuck / OOM (> 60 min in `in_progress`) | §5.1 | | ERROR-02 | Live cron emitted no new MIDs for > 30 min | §5.2 | | ERROR-03 | NocoDB 4xx on upsert (wrong table_id) | §5.3 | | ERROR-04 | NocoDB 4xx "field is required" (schema drift) | §5.3 | | ERROR-05 | IMAP connection refused / auth failure | §5.4 | | ERROR-06 | Ollama connection refused / model OOM | §5.5 | | ERROR-07 | SeaweedFS connection refused / auth failure | §5.6 | | ERROR-08 | Papra `dry_run` unexpectedly flipped to `false` | §5.7 | | ERROR-09 | State file corrupted / offset regression | §5.8 | | ERROR-10 | Backup tar fails (NocoDB / SeaweedFS / Gitea) | §5.9 | ### 5.1 Runner stuck / OOM (> 60 minutes in `in_progress`) A single stuck job can block the live cron (live jobs have `cancel-in-progress: false`). Diagnosis and recovery: 1. **Confirm it is stuck.** Open Gitea Actions → run → check whether the `Run process-new-emails` step has emitted *any* new log lines in the last 10 minutes. A runner that is downloading a large `.mbox` is making progress (watch for S3 GET bytes) — a runner that is silent is OOM'd or wedged. 2. **Check the host's container health.** ```bash docker ps --filter "name=gitea-runner" --format "table {{.Names}}\t{{.Status}}\t{{.CPUPerc}}\t{{.MemUsage}}" ``` Memory at 100% with no progress for > 10 minutes = OOM. CPU at 0% with no progress = wedged. 3. **Drain the stuck job.** From Gitea UI → run → **Cancel job**. The live workflow's `cancel-in-progress: false` will *not* preempt the next cron tick — the next run will queue until the cancelled one reports back. This is the correct behaviour: do not change the concurrency setting to fix a stuck job. 4. **Restart the runner.** ```bash docker compose -f docker/docker-compose.yml restart gitea-runner ``` If `gitea-runner-2` is also stuck, restart it too. The other runner will pick up new jobs within ~30 s. 5. **Verify recovery.** ```bash curl -u "$GITEA_USER:$GITEA_PASS" \ https://git.cloudinit.dev/api/v1/repos/jc-capital/inboxos/actions/runners ``` Expect 2 entries with `status: "online"`. Then dispatch `live-pipeline.yml` manually with a small `max_emails` (e.g. `5`) to confirm the end-to-end loop. 6. **If recurring:** the runner is under-provisioned. Increase its memory limit in `docker/docker-compose.yml` (`mem_limit: 4g` or similar) or move to a host with more RAM. ### 5.2 SeaweedFS `NoSuchKey` on a Message-ID This means a NocoDB row references a `Message-ID` whose raw `.eml` was never uploaded to (or was deleted from) SeaweedFS. Two common causes: 1. **The `.eml` write failed** but the NocoDB upsert still succeeded (e.g. SeaweedFS was down briefly; the retry on the S3 PUT hit a 5-attempt limit; the pipeline then proceeded to NocoDB). 2. **The Message-ID is malformed** — e.g. a stray `<` or `>` made it into the NocoDB row but the file was written with the correct key. **How to find the offending record in NocoDB:** ```bash # For a finance row, replace m0thluc3jyz2iok with the module's table ID from §4.3 curl -G \ -H "xc-token: $NOCODB_API_TOKEN" \ --data-urlencode "where=(Message_ID,eq,)" \ --data-urlencode "limit=1" \ "$NOCODB_BASE_URL/api/v2/tables/m0thluc3jyz2iok/records" ``` **How to dead-letter the row:** 1. Verify the `.eml` truly is missing in SeaweedFS (use the `s3_get_text_optional` helper in `pipelines/common.py` against `s3://$SEAWEEDFS_S3_BUCKET/eml/live/...`). 2. If it is missing, re-fetch the message from Gmail via IMAP using the `Message-ID` (Gmail's IMAP supports `UID FETCH` by `Message-ID` only via header search; the easier path is `since_uid`). 3. Re-dispatch `live-pipeline.yml` with `since_uid` set to the lowest affected UID. The pipeline will re-archive the `.eml` (overwriting the same key, or PUTing a new one) and re-UPSERT the NocoDB row. 4. If the IMAP mailbox no longer has the email (it was deleted from Gmail), the row is orphaned. Mark it manually in NocoDB (set a `Notes` field with a date) and move on — do not bulk-delete rows, the audit trail matters. ### 5.3 NocoDB 4xx on a UPSERT Read the response body (NocoDB returns JSON like `{"msg":"..."}`). Common causes and recovery: | Cause | Symptom | Recovery | |----------------------------------------------------|--------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------| | **Wrong table_id** | 404 from `/api/v2/tables//records` | Verify the table ID in `NOCODB_MODULE_TABLES` matches the live table ID (see §4.3). Re-bootstrap with `python scripts/apply_schemas.py` to print the current mapping. | | **Missing required field** | 400 with `" is required"` | Re-run `python scripts/apply_schemas.py` to add the column. UPSERT will retry on the next dispatch. | | **Schema drift** (column renamed, type changed) | 400 with `" validation failed"` | Edit `docs/schemas.md` first, then `scripts/apply_schemas.py`. Re-run the workflow. | | **`Message_ID` UNIQUE violation on a POST** | This should not happen — `nocodb_upsert` does GET-then-PATCH/POST. If it does, race. | Re-run the workflow; the second pass will hit the GET and switch to PATCH. | | **SingleSelect option not in the allowed list** | 400 with `"