Skip to content

Architecture

claude edited this page Nov 10, 2026 · 1 revision

Architecture: How the Thunderbird add-on is put together, for developers.

Architecture

This page is for developers. It shows how the Thunderbird add-on is put together. It was last checked against the code at version 0.0.29.

The code is split in two. apps/thunderbird/ holds what is specific to Thunderbird: background/, mail/, storage/, ui/, the manifest and the icon. packages/shared/ holds core/ and providers/, which make no mail-app calls and are meant for every Preen app. The add-on links them in as apps/thunderbird/core and apps/thunderbird/providers, so the folder names below are the same in both places, and the build (npm run build:thunderbird) copies everything into dist/thunderbird/. Future apps go in apps/<name>/ in the same repository.

Module map

flowchart TB
  subgraph UI["ui/ (pages)"]
    options["options/<br/>settings page"]
    popup["popup/<br/>toolbar pop-up"]
    activity["activity/<br/>activity log"]
    confirm["confirm/<br/>big-run prompt"]
    shared["shared/<br/>helpers, theme, page guard"]
  end
  subgraph BG["background/"]
    main["main.js<br/>menus, events, alarms"]
    pipeline["pipeline.js<br/>classify messages"]
    scheduler["scheduler.js<br/>queue and runs"]
    corrections["corrections.js<br/>tag corrections"]
  end
  subgraph CORE["core/ (pure logic, no browser calls)"]
    rules["rules.js, questions.js<br/>score to tag"]
    schema["settings-schema.js<br/>defaults, validation"]
    other["budget, pace, depth, scope,<br/>consent, sensitive, text, undo, ..."]
  end
  subgraph MAIL["mail/ (Thunderbird APIs)"]
    messages["messages, folders,<br/>contacts, tags, actions, undo"]
  end
  subgraph PROV["providers/"]
    prov["index.js, systemone.js"]
  end
  subgraph STORE["storage/"]
    store["settings, state, processed,<br/>overrides, activity-log, history-db"]
  end

  UI -->|"runtime messages"| BG
  main --> pipeline
  main --> scheduler
  scheduler --> pipeline
  pipeline --> CORE
  pipeline --> MAIL
  pipeline --> PROV
  pipeline --> STORE
  UI --> CORE
  UI --> STORE
  STORE --> CORE
  PROV --> CORE
Loading

The arrows show the main direction of calls. They are a guide, not a full list.

What each folder does

Folder Role
background/ The add-on's background script. main.js registers the menus, event listeners and alarms at the top level so Thunderbird can wake the script. pipeline.js classifies messages. scheduler.js queues new mail and works through it in runs, within the limits. corrections.js records when the user changes a Preen tag.
core/ Pure logic. Settings schema and validation, the decision rules, request building, limits and pace, depth, scope, consent, sensitive-data patterns, text cleaning, undo planning, and more. No Thunderbird calls, so it is easy to test.
mail/ Everything that touches Thunderbird messages. It reads a message, finds folders, looks up address books, makes sure Preen's tags exist, applies a decision and undoes it.
providers/ The provider table (OpenRouter and TypeSafe) and the client for the System One endpoint. Both providers accept the same request body.
storage/ Settings, run state, the processed record, sender rules, and the activity history.
ui/ The four pages (settings, pop-up, activity, confirm) and shared helpers. Each page loads page-guard.js first.

The manifest, with its permissions, is apps/thunderbird/manifest.json. The icon is in apps/thunderbird/icons/.

The message pipeline

This is the flow in background/pipeline.js, for one message.

flowchart LR
  A["1. Message<br/>mail/messages.js reads metadata"] --> B["2. Local checks<br/>headers, contacts, sender rules,<br/>sensitive patterns, text cleaning"]
  B -->|"consent given"| C["3. Ask Jev<br/>providers/ POST to System One<br/>the only network call"]
  B -->|"no consent"| W["skipped: waiting for your OK"]
  C --> D["4. Decide<br/>core/rules.js decide()"]
  D --> E{"apply, or preview / dry run?"}
  E -->|"apply"| F["5a. mail/actions.js<br/>tags, flag, junk, move"]
  E -->|"preview or dry run"| G["5b. log only"]
  F --> H["activity log<br/>storage/activity-log.js"]
  G --> H
Loading

Notes:

  • Step 2 builds the facts sent with the question: header hints, whether the sender is in a local address book, whether DMARC failed. A sender rule can settle the Real person question, so that question is not asked. The request is still sent for the other questions. Consent is read fresh before every request.
  • Step 3 depends on the account's depth. core/depth.js runs one request: Surface without the body, Deep with it. If the body cannot be used because it is too large, has no readable text or is a PGP block, the request goes without it and a note is kept.
  • Step 4 is decide() in core/rules.js. It takes Jev's answers, your thresholds and anything already known, and returns the tags, a review flag, reasons and scores. It only returns tags that Preen knows about.
  • Before the request, core/budget.js checks every API call against the budget. core/retry.js retries temporary errors twice, after 2 s and 4 s. Errors that would repeat for every message, auth and bad_request, stop the batch.
  • Step 5 reads the message's current state right before changing it, and records the before and after so it can be undone.

Runs

flowchart TD
  T["Trigger"] --> R["classifyMessages(ids, {apply, source})"]
  T1["right-click or folder menu"] --> T
  T2["pop-up: Classify / Preview selected"] --> T
  T3["new mail event"] --> Q["scheduler: queue"]
  T4["alarm (schedule)"] --> Q
  T5["Run now"] --> Q
  Q --> R
  R --> P["pipeline per message<br/>concurrency and pause from core/pace.js"]
Loading

Right-click, folder and pop-up runs go straight to the pipeline. Automatic runs go through the scheduler's queue. A queued message that fails for a temporary reason is tried again in later runs, up to 3 times, set by MAX_ATTEMPTS in scheduler.js, then recorded as processed so it is not retried forever.

Storage layout

flowchart LR
  subgraph LOCAL["messenger.storage.local (keys)"]
    k1["settings"]
    k2["queue"]
    k3["usage"]
    k4["status"]
    k5["scanCursor"]
    k6["processed"]
    k7["dryRunSeen"]
    k8["senderOverrides"]
    k9["runProgress"]
  end
  subgraph IDB["IndexedDB: preen-history"]
    s1["entries<br/>activity log rows"]
    s2["monthly<br/>token summaries"]
    s3["corrections<br/>tag corrections"]
    s4["debug<br/>raw requests and answers"]
  end
Loading
Where Key or store What it holds Module
storage.local settings All settings, including the API key and consent storage/settings.js
storage.local queue Messages waiting for automatic processing storage/state.js
storage.local usage Day and month counters (messages, tokens, estimated cost) storage/state.js
storage.local status Latest status: blocked reason, last error, last run, last skipped storage/state.js
storage.local scanCursor Where the scheduler's folder scan continues storage/state.js
storage.local processed Local record of processed messages, keyed by Message-ID storage/processed.js
storage.local dryRunSeen Messages already seen in dry run. Cleared when dry run is switched off storage/processed.js
storage.local senderOverrides Your per-sender rules (person or service) storage/overrides.js
storage.local runProgress Progress for the activity page's bar core/protocol.js
IndexedDB preen-history entries Activity log rows: ids, decisions, scores, tokens, sender and subject. Never the message text storage/history-db.js, activity-log.js
IndexedDB monthly Monthly summaries. They survive when old entries are trimmed same
IndexedDB corrections Tag corrections. Untouched by history trimming and Clear history. Kept 730 days same
IndexedDB debug Full requests and responses. Only filled while debug mode is on. Trimmed with the history same

mail/own-updates.js also keeps a short list of the messages Preen itself is changing, in storage.session when Thunderbird has it, and in storage.local otherwise, so the tag-correction listener does not mistake Preen's changes for yours.

Reading rules: loadSettings() merges what is stored over DEFAULT_SETTINGS (mergeWithDefaults), so a new setting gets its default without a migration. Unknown stored keys are dropped.

Settings flow

The settings page reads the form into a settings object, validates it with validateSettings(), the same function the pipeline uses before a run, and saves it. The page shows unsaved changes as a list of old and new values. Imports from a file are validated and shown for review, and are only saved when the user clicks Save.

Where the key is used

The API key is sent in an Authorization header to the provider's /v1/systemone endpoint. Error text from the server is redacted of the key and capped at 300 characters. The debug log keeps the request body and response, never the headers. The build script refuses to pack anything that looks like a key.

Permissions

From apps/thunderbird/manifest.json: messagesRead, sensitiveDataUpload, messagesUpdate, messagesTags, messagesTagsList, accountsRead, addressBooks, storage, menus, alarms. Host permissions: the two System One URLs. Optional: messagesMove, requested only when "Also move it to the Junk folder" is ticked. The CSP is script-src 'self'; object-src 'self'.

Other mail apps

Preen is planned for more mail apps: Betterbird, SeaMonkey, Outlook, KMail, Claws Mail, Evolution, Gmail as a Google Workspace add-on, and Apple Mail on macOS. None of them is built yet. Each has a folder in apps/<name>/ that holds only a README.md with what is known about that app's add-on system.

The split above is what makes this possible: rules, settings, cost limits and the provider requests in packages/shared/ make no mail-app calls, so a new app only has to supply its own way to read a message, set tags, list folders and store settings. A small "mail host" interface for those four jobs, with Thunderbird as the first implementation, is planned and not built. Some apps cannot run JavaScript modules directly; their READMEs say what could be reused.

Clone this wiki locally