Repository navigation
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.
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
The arrows show the main direction of calls. They are a guide, not a full list.
| 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/.
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
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.jsruns 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()incore/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.jschecks every API call against the budget.core/retry.jsretries temporary errors twice, after 2 s and 4 s. Errors that would repeat for every message,authandbad_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.
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"]
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.
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
| 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.
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.
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.
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'.
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.
Preen is licensed under the PolyForm Shield License 1.0.0. It is source-available, not open source. | Photos: hjburatynski, edmondlafoto, monika1607, u_4kqnhxzlh0 and yuri_b via Pixabay.