Profit Admin reads the store Gmail mailbox, drafts replies for the mail it can safely answer, and puts them in front of a person. This page is the architecture level picture: the parts, five sequence diagrams, the draft lifecycle, the rule switches, and a checklist for “no drafts appearing”.
1
Gmail, Worker cron, Worker mail store, engine, D1, the desk, the Mac app, pa.
2
Poll, pitch declines, the Mail Queue, Gmail sign-in, and the Mail tab load.
3
The statuses a queued draft moves through, as the code defines them.
5
No drafts appearing: a short checklist of what to check.
flowchart LR
subgraph google ["Google"]
gmail["Gmail mailbox and labels"]
gauth["Google OAuth and token endpoint"]
end
subgraph clients ["Clients"]
tab["Desk Mail tab, React in web/"]
mac["Mac app, web view of the same desk"]
pa["pa CLI, status and saved drafts"]
end
subgraph worker ["Cloudflare Worker"]
cron["Scheduled cron, mail-automation-cron.js"]
routes["Mail routes and mail store, mail-store.js and mail-drafts.js"]
end
subgraph engine ["Python engine in the Container"]
app["app.py mail routes"]
triage["mail_triage.py poll and routing"]
rules["mail_rules.py rule modes"]
assist["mail_assist.py Assist settings"]
mstore["mail_store.py client of the Worker mail store"]
end
d1[("D1 mail tables")]
tab --> routes
mac -->|"web view"| tab
pa --> routes
cron --> app
routes --> app
app --> triage
triage --> rules
triage --> assist
triage --> gmail
app --> gmail
app --> gauth
triage --> mstore
mstore -->|"internal mail-store API"| routes
routes --> d1
The engine has no database of its own. It reads and writes mail state through the Worker, and Gmail itself is only ever called from the engine.
| Component | Where | Job |
|---|---|---|
| Gmail | Google (outside this repo) |
The mailbox. The desk reads threads and labels and sends replies through the Gmail API.
The inbox-bot/… labels are applied by the store’s Apps Script inbox bot,
which lives outside this repository. The desk only reads the label names.
|
| Worker cron | src/mail-automation-cron.js, runMailTriage in src/index.js |
One scheduled run: send due deferred shipping mail, then poll the inbox for pitch declines. Production runs every 15 minutes, staging hourly. |
| Worker mail routes and store | src/mail-store.js, src/mail-drafts.js |
Authenticates desk requests and proxies /api/mail/* to the engine.
Owns the D1 mail store (outbox, queue, rules, triage jobs, tickets), computes the
draft digest, and stores the Gmail connection and the Assist settings.
/api/v1/mail-drafts (saved replies) is served here directly.
|
| Python engine | Cloudflare Container: app.py, mail_triage.py, mail_rules.py, mail_store.py, mail_assist.py |
app.py exposes the mail routes. mail_triage.py polls, classifies, drafts
and queues. mail_rules.py resolves rule modes. mail_assist.py holds the
Assist settings and presets. mail_store.py is the client for the Worker mail store.
Also involved: mail_box.py (Gmail client, OAuth, templates, sending) and
mail_queue_ops.py (approve and send a queued draft).
|
| D1 mail tables | D1, rows scoped by environment and org_id |
Drafts (the queue and saved replies), triage jobs, automation rules, the send outbox, tickets, deferred shipping mail, returns, and mail settings such as sending hours. The Gmail connection and the Assist settings are stored in D1, not on the engine disk. |
| Desk Mail tab | web/src/pages/mail/ (MailQueue.tsx, MailAutomations.tsx, inbox, sent, templates, settings, returns) |
The human surface. Inbox, Queue (the Mail Queue), Sent, Templates, Automations, Settings and Returns. Reads go through an in-memory cache. Anything that sends or approves re-reads the server first. |
| Mac app | apps/macos |
Shows the hosted desk’s Mail screens in a WKWebView. It makes no native mail
API calls, so mail behaves exactly as it does in the browser. Gmail sign-in opens in the
default browser.
|
pa CLI |
apps/cli |
Touches mail in a small way: pa mail status, threads, send,
and drafts list | create | delete (saved replies). There is no approve command:
queued drafts are reviewed and approved in the desk. pa integrations list warns
when Gmail needs reconnecting.
|
sequenceDiagram
autonumber
participant Cron as Cloudflare cron
participant W as Worker
participant E as Engine
participant G as Gmail
participant D as D1
Cron->>W: scheduled tick, every 15 minutes on production
Note over W: Production runs several jobs and mail automation is one step. Staging runs mail automation only, hourly. Local does nothing.
W->>E: Step 1, send due deferred shipping mail
Note over W,E: Step 2 runs even if step 1 failed. Each failure is logged and isolated.
alt triage not enabled in this environment
W->>W: log the skip
else triage enabled
W->>D: find organisations to poll
loop each organisation
W->>D: load the Gmail row and Assist settings
W->>E: poll request with limit 12
E->>E: Assist enabled, cron_enabled, Gmail connected, mailbox address known?
E->>G: refresh the access token if the cached one is near expiry
E->>G: list recent threads for the mailbox
E->>G: read the label list once, label id to name
E->>G: read metadata for each candidate thread
Note over E,G: A thread tagged inbox-bot/pitch is classed as pitch. Other threads are classed by keyword rules.
loop each candidate thread, up to the poll limit
E->>E: skip if no inbound message, we spoke last, or this message was already settled
E->>G: read the full thread
E->>E: classify again, the inbox-bot/pitch label wins, other bot labels are ignored here
E->>E: route by rule mode and Assist action
alt pitch
E->>E: draft from the pitch-decline template
E->>D: insert-only queue row, through the Worker mail store
else FAQ or volume deal
E->>E: draft from a template, left as a draft unless the rule is Auto and the master switch is on
else tracking
E->>E: agent drafts from Shopify and tracking facts
else refund, return, damage or unclear
E->>E: escalate, no draft by default
end
E->>D: save the triage job
end
E-->>W: poll result with queued drafts and other desk drafts
W->>D: save non-pitch desk drafts as saved replies
W->>D: persist any refreshed Gmail credential
W->>W: log the poll result
end
end
The label read is one Gmail call per poll, turning label ids
into names such as inbox-bot/pitch. If that call fails the names come back empty
and classification falls back to keywords. A poll looks at a bounded number of recent
threads. The cron asks for 12. Pitch declines go to the Mail Queue, never to the
saved-replies list.
sequenceDiagram
participant P as Poll in the engine
participant R as Rule resolution
participant D as D1 mail tables
participant API as Desk API
participant K as Scripts and agents
participant H as Signed-in person
participant G as Gmail
P->>R: pitch thread found
Note over R: A pitch rule cannot be saved as Auto, and a stored Auto value still resolves to Draft.
R-->>P: action is draft
Note over P: The poll never auto-sends a pitch, whatever the rules and the master switch say.
P->>D: insert-only queue row, one per thread, status queued
alt a row for that thread exists in any state
D-->>P: nothing written, so a discarded or approved decline never comes back
end
K-xAPI: approving a pitch draft is refused, a signed-in person must do it
H->>API: approve with the digest of the draft reviewed
API->>D: claim queued to sending on that digest
API->>G: send in the sender's Gmail thread
Pitch declines are drafts only. Several independent checks in the engine, the Worker and the desk stop a pitch rule from being set to Auto, stop the poll from sending one, and make a signed-in person approve each decline. Changing one setting cannot turn auto-send on for pitches.
| Guarantee | What it means |
|---|---|
| No Auto rule | A pitch rule cannot be saved as Auto, and a stored Auto value still behaves as Draft. |
| Queue only | The poll writes one queued decline per thread and sends nothing. |
| Person only | Only a signed-in person can edit or approve a pitch decline. |
| Send what was reviewed | A decline is sent only as the reviewer last saw it. |
Not traced: this page has not confirmed whether the manual per-thread “Run triage” action also queues a decline. It cannot send one. The queue row described above is written by the poll.
sequenceDiagram
autonumber
actor H as Reviewer
participant UI as Mail Queue screen
participant E as Engine behind the Worker
participant S as Worker mail store and D1
participant G as Gmail
Note over UI,S: Browser requests reach the engine through the Worker. The engine reaches D1 only through the Worker mail store.
H->>UI: open Queue
UI->>E: GET /api/mail/queue/drafts, always revalidated
E->>S: list queued drafts
S-->>UI: drafts, each with a digest of the fields that decide what is sent
opt Edit
H->>UI: change subject, body or recipient
UI->>E: re-read the queue and compare with the screen
UI->>E: PUT the draft with the digest
E->>S: update only while still queued and unchanged
alt changed since review
S-->>UI: 409 draft_changed_since_review, edit not saved
else unchanged
S-->>UI: saved draft with a new digest
end
end
H->>UI: Approve and send
UI->>E: re-read the queue, refuse if the row differs from the screen
UI->>E: POST approve with the digest of the re-read row
E->>S: claim the draft on that digest
alt digest differs
S-->>E: changed
E-->>UI: 409 draft_changed_since_review, nothing sent
else not found or no longer queued
S-->>E: refused
E-->>UI: 404 or 409 draft_not_queued, nothing sent
else claimed
S-->>E: locked snapshot, status sending
E->>S: reserve an outbox row, refused unless the draft is claimed
E->>G: send the claimed snapshot, in the Gmail thread when there is one
G-->>E: accepted
E->>S: record the Gmail id and mark the outbox row sent
E->>S: finish the claim as approved
E-->>UI: approved and sent, row removed from the list
end
opt Approve all for one rule
H->>UI: confirm the dialog that lists the drafts
UI->>E: POST bulk-approve with the rule and an id plus digest for each listed draft, at most 200
loop each listed draft in order
E->>E: skip if wrong rule, person-only, changed or not queued
E->>S: claim, send and finish exactly as for a single approve
end
E-->>UI: approved, changed, not_found, skipped and errors lists
UI->>H: notice counting sent, changed, skipped and failed
end
alt Gmail or the token refresh refused before accepting
G-->>E: error
E->>S: mark the outbox row failed, release the claim
S-->>E: draft back to queued
E-->>UI: error shown on the queue, draft stays for another try
else timeout or transport error during the send call
E->>S: claim stays, draft remains sending
Note over S,UI: Whether Gmail received it is unknown. After 15 minutes the draft shows a Stuck badge. Check Sent in Gmail, then approve again or discard.
end
This is the approve digest change from PR #51: approving, bulk approving and editing all send
or save exactly what the reviewer saw, or nothing. Approve and bulk approve are disabled until the
queue has been re-read since the screen was shown. A rule in dry-run records a
dry_run outbox row, sends nothing, and puts the draft back to queued.
Discarding a stuck draft whose email did reach Gmail marks it approved instead, and says it was
already sent.
sequenceDiagram
autonumber
actor A as Admin or owner
participant UI as Mail settings
participant W as Worker
participant E as Engine
participant Gg as Google
participant D as D1
Note over A,D: Connect or Reconnect Gmail
A->>UI: Connect with Google
UI->>W: POST /api/integrations/gmail/oauth/start
W->>E: forward
E-->>UI: Google consent URL
UI->>Gg: browser opens the consent screen
Gg-->>W: redirect to the desk callback with a code and the state
W->>E: forward
alt the sign-in is valid for this person and organisation
E->>Gg: exchange the code for tokens
E-->>W: new Gmail connection fields
W->>D: save the connection
W-->>A: redirect to Mail, gmail connected
else otherwise
E-->>A: error page, nothing saved
end
Note over A,D: Every later mail request and every cron poll
W->>D: load the Gmail connection
W->>E: request with the connection attached
alt cached access token valid for another minute
E->>E: use it
else needs a refresh
E->>Gg: refresh token grant, retried a few times
alt refreshed
Gg-->>E: new access token, sometimes a rotated refresh token
E-->>W: save fields and clear any reconnect flag
W->>D: persist
else permanent refusal, invalid_grant, invalid_client and similar
E-->>W: save auth_state needs_reconnect, keep the refresh token
E-->>UI: 409, Gmail needs reconnecting
else transient, network error, 5xx or 429
E-->>W: save a transient marker
E-->>UI: 503, requests fail fast for a short while
end
end
E->>Gg: Gmail API call with the access token
alt Gmail answers 401
E->>Gg: drop the cached token and refresh once
end
The Gmail connection is stored in D1, not on the engine disk, so replacing the Container does not drop the mailbox. Only Disconnect deletes it.
| Situation | What happens |
|---|---|
| Access token expires | Normal. The engine refreshes it when the cached one has under a minute left. No one sees it. |
| Google network error, 5xx or 429 | Retried a few times. The saved connection is untouched. Further requests answer 503 immediately for a short while. The Mail status shows degraded until a refresh works. |
Refresh token expired or revoked (invalid_grant) |
The row is flagged needs_reconnect and keeps its token. A red Reconnect Gmail banner shows on Mail, Mail settings and Settings, including in the Mac app. Google is re-checked periodically. Polls and sends fail until someone reconnects. |
| The desk’s own OAuth client is wrong or disabled | Same flag, but the banner says to check the client settings, because reconnecting cannot fix it. |
| Gmail answers 401 to a cached access token | The cached token is dropped and refreshed once. That refresh is what notices a revoked grant. |
| Reconnecting | A successful reconnect clears the flag at once. If Google does not return a usable connection, the reconnect fails and nothing is saved. |
Not settled by the code: the poll route does not translate Gmail sign-in errors into a specific
status, so a poll against a dead Gmail connection shows up as a generic error in the
logs rather than as a named error. Google’s own reasons for
invalid_grant (an OAuth consent screen left in Testing, a password change, access
removed in the Google account) are outside this repository.
sequenceDiagram
autonumber
actor U as User
participant UI as Mail tab, in the browser or the Mac app web view
participant C as In-memory cache
participant W as Worker
participant E as Engine
participant G as Gmail
U->>UI: open Mail
UI->>C: read mail home, queue and thread list for this user and organisation
alt first visit this session
C-->>UI: nothing cached, loading state
UI->>W: GET /api/mail
W->>E: mailbox status and templates
E-->>UI: Gmail health and templates, no Google call
UI->>W: GET /api/mail/threads
W->>E: forward
E->>G: list threads, read label names and thread metadata
G-->>UI: threads
UI->>C: store the copies
else returning in the same session
C-->>UI: last copy shown at once
Note over UI: A thin sync bar shows while the screen revalidates.
par queue and outbox, always revalidated
UI->>W: GET /api/mail/queue/drafts
and thread list, older than 20 seconds
UI->>W: GET /api/mail/threads
and mail home, older than 60 seconds
UI->>W: GET /api/mail
end
W-->>UI: fresh data replaces the copy
end
Note over UI: Approve, discard, edit and bulk approve stay disabled until the queue has answered since the screen was shown.
U->>UI: approve a draft
UI->>W: POST approve
W-->>UI: result
UI->>C: invalidate the affected reads and refetch them
This is the stale-while-revalidate read cache from PR #41. It lives in browser memory only, is keyed by user and organisation, and is wiped when either changes. Mail sections you have visited stay mounted, so the open thread and an unsent reply survive a trip to another section. Entries nobody is looking at are dropped after 30 minutes.
stateDiagram-v2
[*] --> queued: queued by the poll, lifecycle mail, returns or the queue API
queued --> queued: edited with the digest
queued --> sending: approve claims it on the digest
queued --> discarded: discard
queued --> superseded: a sibling lifecycle mail was sent
sending --> approved: Gmail accepted or already sent
sending --> queued: released, failed before Gmail accepted, or dry run
sending --> sending: no answer for 15 minutes, shown as Stuck
sending --> approved: discard finds it was sent
sending --> discarded: stuck and not sent, discard
approved --> [*]
discarded --> [*]
superseded --> [*]
These are the draft statuses in code: queued,
sending, approved, discarded and
superseded. The status is free text in the schema, so the list comes from the
code, not a constraint. Rows with no status are saved replies for the Drafts list, not queue items.
A stuck draft (sending for over 15 minutes) can be claimed again or discarded.
Re-queueing. Pitch declines are written insert-only, so a thread that has a
row in any state is never queued again. Other writers use a plain upsert, which sets the row
back to queued unless it is currently sending.
Triage jobs have their own states:
pending, classifying, draft_ready, agent_running,
agent_draft, sent, escalate, failed,
skipped and dismissed. A job is the engine’s record of how it handled a
thread. A draft in the queue is what a person acts on.
Each automation rule (one per kind, per organisation) has a mode and a dry-run flag, stored per organisation. Assist settings add category actions and the switches below.
| Setting | Meaning |
|---|---|
Rule mode off |
Nothing is drafted or sent for that kind. Triage skips the category. For shipping mail the outbox records it as dropped. |
Rule mode draft |
A draft is left for a person. Pitch declines go to the Mail Queue. Other Assist drafts appear as saved replies. |
Rule mode auto |
The engine may send without a person, but only if the master auto-send switch is on, the kind is not on the never-auto list, and the poll budget is not used up. Without the master switch an Auto rule resolves to Draft. |
| Dry run | The send path runs but records a dry_run outbox row and does not call Gmail. |
Master auto-send (auto_send_enabled) |
Off by default. Required for any rule in Auto mode. It does not affect whether pitch drafts appear. |
max_auto_sends_per_poll |
The most automatic sends one poll may make. Default 3, range 0 to 40. Further candidates stay drafts and are marked as deferred by the poll budget. It counts as 0 while the master switch is off. |
Assist enabled (enabled) |
On by default. Off means polls do nothing. |
Cron poll enabled (cron_enabled) |
On by default. Off stops the scheduled poll but not manual runs. |
| Poll limit, cheap path, tracking agent | Poll limit defaults to 12 (maximum 40). Turning the cheap path off sends pitch, FAQ and deal to escalate. Turning the tracking agent off does the same for tracking. |
| Category actions and presets | Each category (pitch, FAQ, deal, tracking, unclear, refund, receipt) is Draft, Auto-send, Escalate or Skip. Presets set them together: safe drafts (default), auto cheap, human only, paused. |
Staging runs inbox triage only when it is explicitly enabled. Production always does. Shipping mail outside the organisation’s sending hours (London 08:00 to 20:00 unless changed in Mail settings) is parked and sent by the next eligible cron run.
refund, refund-return-damage, return,
return-approved, damage, delay-apology
(also spelled delay_apology) and pitch.
Saving one as Auto is refused by both the engine and the Worker, and an Auto row that
exists anyway resolves to Draft. In Assist settings a refund category set to auto-send is
reset to escalate.
Refund, return and damage mail is escalated with no draft by default. Return emails also carry a customer’s return QR or label, so they are only ever sent to the customer’s own address, and only a signed-in person can act on them.
Work down the list. The detailed runbook, with the tables and logs to check, is private. Ask a maintainer for it.
inbox-bot/pitch. The label is applied outside
this repository. Without it the thread is classed by keywords, and many pitches contain none,
so they are escalated with no draft. The thread must also be recent, the newest message must
not be from us, and it must not already be settled.