---ci--- project: inboxos phase: 1 milestone: v1.4 status: execute wave: 2 task: T2 ---/ci---
14 KiB
InboxOS — NocoDB Schema Design
Status: Phase 2 deliverable for INB-4
Applies to: NocoDB project at https://noco.cloudinit.dev
Pipeline contract: pipelines/common.py::nocodb_upsert, pipelines/historical_pipeline.py (canonical), and the OpenCode prompts under .opencode/commands/inboxos:*.md
This document is the source of truth for the four module tables in NocoDB. The schemas are intentionally narrow: every column is either written by one of the pipelines (after extraction) or computed from the extraction JSON. No column is allowed to be filled by hand in NocoDB's spreadsheet UI in production — the pipelines are the only writer.
The live bootstrap script scripts/apply_schemas.py is idempotent
and creates these tables + columns + unique constraint on
Message_ID exactly as specified here. Re-running it on an
already-bootstrapped project is a no-op.
Cross-cutting invariants
| Invariant | Why | How it is enforced |
|---|---|---|
Message_ID is the natural primary key for every module table. |
The pipeline is retried; the same email may arrive multiple times. A stable natural key is the only way to make upserts idempotent. | UNIQUE constraint on Message_ID in every module table. nocodb_upsert in pipelines/common.py does a GET-then-PATCH/POST keyed on Message_ID. |
| Both pipelines write to the same tables. | The historical pipeline backfills the same data the live pipeline ingests, into the same tables. Joins across modules are performed at query time by Message_ID. |
The schema includes the union of columns written by either pipeline; all optional. |
The LLM extraction is strict JSON, gated by inboxos:classify. |
Routing first lets us skip irrelevant modules (a receipt email doesn't waste inference on CRM extraction) and isolates per-module failures. | .opencode/commands/inboxos:classify.md returns {"modules": [...]}; .opencode/commands/inboxos:extract-<module>.md returns a flat record per module. |
| Module tables never cross-reference by foreign key. | The same email can simultaneously be a Finance record, a CRM record, and an Intel record. Cross-keys via Message_ID only. |
All FK-style joins are done at query time by Message_ID. |
Common columns (every module table)
Every module table has the same set of "audit trail" columns that the pipelines write, in addition to the module-specific fields the LLM produces. All are nullable so the same table can be written by either pipeline.
| Column | Type | Written by | Notes |
|---|---|---|---|
Message_ID |
SingleLineText (UNIQUE, required) | both pipelines | Natural key, stripped of angle brackets. |
Date |
DateTime | live (email Date header) | The live pipeline sets this to the email Date: header. The historical pipeline currently does not set it — to keep both pipelines writing to the same table, the column is nullable. A future schema migration can backfill from the LLM-extracted per-module date if needed. |
Raw_S3_Link |
URL | live | s3://… of the raw .eml in SeaweedFS. |
Papra_Link |
URL | historical (when attachments exist) | URL of the first Papra document for the email, or null. |
Extracted_At |
DateTime | both | Set to now_iso() at upsert time. Used to detect stale rows. |
Module tables
There are exactly four module tables, one per category. The table
name in NocoDB equals the module_name the pipeline uses in
NOCODB_MODULE_TABLES. Keep them stable.
finance — Finance / P&L
Receipts, invoices, bank transactions, credit-card statements, subscription renewals, refunds, fees. Anything that affects the personal P&L.
Field contract mirrors .opencode/commands/inboxos:extract-finance.md.
| Column | Type | Source | Notes |
|---|---|---|---|
Message_ID |
SingleLineText (UNIQUE, required) | both pipelines | Natural key. |
Direction |
SingleSelect | LLM | income / expense / refund / transfer / unknown. |
Counterparty |
SingleLineText | LLM | Vendor / customer / payer / payee name. |
Amount |
Decimal | LLM | Numeric; positive. |
Currency |
SingleLineText | LLM | ISO-4217, uppercase. null unless explicitly priced. |
Transaction_Date |
Date | LLM | Date of the transaction (not the email date), YYYY-MM-DD. |
Category |
SingleLineText | LLM | Free-form but stable tag: e.g. software, payroll, travel, tax, marketing, cloud. |
Reference |
SingleLineText | LLM | Invoice / receipt / order / transaction ID. |
Notes |
LongText | LLM | One-sentence human summary, ≤ ~200 chars. |
crm — Personal CRM
Any person-to-person email worth remembering: networking, recruiters, friends, vendors, prospects, customer support threads. Captures the relationship, not just the message.
Field contract mirrors .opencode/commands/inboxos:extract-crm.md.
| Column | Type | Source | Notes |
|---|---|---|---|
Message_ID |
SingleLineText (UNIQUE, required) | both pipelines | Natural key. |
Contact_Name |
SingleLineText | LLM | Best guess at full name. |
Contact_Email |
LLM | Single email address (not a list). | |
Organization |
SingleLineText | LLM | Company / org. |
Role |
SingleLineText | LLM | Title or role. |
Channel |
SingleSelect | LLM | inbound / outbound / intro / reply / scheduling / other. |
Intent |
SingleSelect | LLM | sales / partnership / recruiting / support / social / intro / other. |
Last_Contacted |
Date | LLM | YYYY-MM-DD. |
Summary |
LongText | LLM | One-sentence human summary, ≤ ~200 chars. |
Action_Needed |
LongText | LLM | Short string describing the next action, or null. |
intel — Daily News / Intel
Newsletters, market briefings, security digests, product news, RSS digests. The point is to be able to search "what did I read about X last month" without trawling Gmail.
Field contract mirrors .opencode/commands/inboxos:extract-intel.md.
| Column | Type | Source | Notes |
|---|---|---|---|
Message_ID |
SingleLineText (UNIQUE, required) | both pipelines | Natural key. |
Source |
SingleLineText | LLM | Publication / newsletter name. |
Topic |
SingleSelect | LLM | markets / ai / security / industry / policy / tech / other. |
Headline |
LongText | LLM | Single most important headline. |
Summary |
LongText | LLM | 1–3 sentence digest, ≤ ~600 chars. |
Items |
LongText | LLM | JSON-encoded array of up to 5 short bullet strings. Stored as a JSON-encoded string in a LongText column — NocoDB's native array columns are not stable across versions; storing as text keeps the contract portable. |
Issued_Date |
Date | LLM | YYYY-MM-DD. |
Read_Priority |
SingleSelect | LLM | must / should / skim / archive. |
promotions — Promotions / Marketing
Sales, discount codes, free-shipping offers, product launches. The point is "do not pay full price if you don't have to".
Field contract mirrors .opencode/commands/inboxos:extract-promotions.md.
| Column | Type | Source | Notes |
|---|---|---|---|
Message_ID |
SingleLineText (UNIQUE, required) | both pipelines | Natural key. |
Brand |
SingleLineText | LLM | Sending brand / company / product. |
Offer_Type |
SingleSelect | LLM | discount / bogo / freebie / launch / sale / event / newsletter / other. |
Headline |
LongText | LLM | Main promotional claim. |
Discount_Pct |
Number | LLM | Numeric percent if explicit, else null. |
Discount_Code |
SingleLineText | LLM | Promo code string, or null. |
Valid_Until |
Date | LLM | YYYY-MM-DD if explicit deadline. |
Category |
SingleSelect | LLM | apparel / software / hardware / food / travel / services / other. |
Worth_Saving |
Checkbox | LLM | True only when there is a concrete, time-bound, generous offer. |
Notes |
LongText | LLM | One-sentence summary, ≤ ~200 chars. |
papra_index — Papra write audit trail (REQ-INB-19)
Per-message audit trail for every Papra write intent. One row per
Papra write decision; populated by the helper
pipelines.common.papra_index_upsert (and the test cases in
pipelines/_tests/test_papra_index_offline.py). The table is the
only column-count change in v1.1: the four module tables
(finance, crm, intel, promotions) are unchanged from the
Phase 1.1 freeze recorded in
docs/phase-1.1-verification/inb-28-report.md.
| Column | Type | Source | Notes |
|---|---|---|---|
Message_ID |
SingleLineText (UNIQUE, required) | pipeline | Natural key. One row per Papra write intent. |
papra_document_id |
SingleLineText (nullable) | pipeline | The returned Papra document id; null when dry_run=true or when the write failed. |
module |
SingleLineText (nullable) | pipeline | The module that triggered the write, or null for attachment writes. |
kind |
SingleSelect | pipeline | attachment / body. Which Papra write path produced the row. |
decision_at |
DateTime (required) | pipeline | now_iso() at decision time. |
outcome |
SingleSelect | pipeline | ok / failed / dry_run. The final outcome. |
error |
LongText (nullable) | pipeline | Error string when outcome=failed; null otherwise. |
The schema-change procedure in §"Schema changes (process)" applies:
scripts/apply_schemas.py is idempotent on the four module tables
(it leaves them untouched) and creates papra_index on first run
(or no-ops on subsequent runs). The --check mode verifies the
new table's existence and Message_ID UNIQUE constraint.
How the pipelines write these tables
The historical pipeline code that consumes this contract:
# pipelines/historical_pipeline.py::process_one_message
modules = classify(opencode_cfg, summary, logger) # inboxos:classify
for module in modules:
extracted = extract_for_module(opencode_cfg, module, summary, papra_link, logger)
# extracted is the LLM's JSON output, with:
# "Message_ID": <set by the pipeline from the email header>
# "Papra_Link": <set by the pipeline if attachments were uploaded>
upsert_result = nocodb_upsert(nocodb_cfg, table_id, extracted, message_id)
Important consequences of this contract:
- The LLM prompt never fills
Message_IDreliably. The pipeline overwritesMessage_IDfrom the email header (or the LLM's value if the prompt happens to include it). This is the idempotency key. Raw_S3_Linkis currently written only by the live pipeline. The historical pipeline could also set it, but currently does not — the column is nullable so both pipelines can write to the same table.- The LLM extractor is expected to fill the module-specific
fields exactly as listed above. If it returns extra fields, NocoDB
will reject the row. If it returns fewer, the missing fields are
stored as
null. - The
Itemscolumn inintelis a JSON-encoded string, not a NocoDB multi-select, on purpose — it keeps the contract portable and lets us change item boundaries without schema migrations.
Schema changes (process)
Schema changes are versioned here, not in n8n or the workflow files. The procedure is:
- Edit this document.
- Update
MODULESinscripts/apply_schemas.pyso a re-run is a no-op on the changed columns (add, not rename; if a rename is needed, file a separate migration issue). - Update the corresponding
.opencode/commands/inboxos:extract-*.mdfile so the LLM's output matches the new column. - Re-run
python scripts/apply_schemas.pyagainst NocoDB. It is idempotent. - Commit the change in the Gitea repo (the same change must propagate to the prompt file if it adds / removes / renames fields).
- Open a PR; reviewers should look for:
- Column additions that the LLM prompt does not yet produce (regressions).
- Column type changes that would orphan existing data.
Message_IDconstraint violations.- SingleSelect option list changes that would invalidate existing values.
Verification
The schema is considered applied and verified when:
python scripts/apply_schemas.py --checkexits 0 (the script verifies the tables and unique constraints exist and match).- A single test email flows through
pipelines/historical_pipeline.pyagainst NocoDB and produces one upserted row in at least one module table, retrievable by?where=(Message_ID,eq,…). - A second pass with the same
Message_IDproduces{"action": "patch"}on the same row, confirming the upsert is idempotent.
Curated views (REQ-INB-41)
The NocoDB base ships with 4 curated views, one per module. The views are seeded by scripts/apply_schemas.py --seed-views (v1.4 REQ-INB-41).
| Table | View title | Filter |
|---|---|---|
finance |
Open invoices 30d | (status,eq,open) AND (due_date,le,today+30d) |
crm |
New contacts 7d | (created_at,ge,today-7d) |
intel |
Intel watchlist alerts | (priority,eq,high) |
promotions |
Promotions opt-out queue | (opt_out,eq,true) |
The view seed is idempotent: re-running apply_schemas.py --seed-views updates the existing views; if a view with the same name exists but a different filter, the script updates the filter (does not create a duplicate).
After running the script, the views appear in the NocoDB left-nav under each module table.
Open alignment work
None. Both pipelines (pipelines/live_pipeline.py and
pipelines/historical_pipeline.py) read the same
NOCODB_MODULE_TABLES env var, call the same inboxos:classify
prompt, and then call the same inboxos:extract-<module> prompt
per matched module. The NocoDB schema here matches the field
contract that those prompts declare.