Our Tech estate Context and architecture

How the mail system works

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

Components

Gmail, Worker cron, Worker mail store, engine, D1, the desk, the Mac app, pa.

2

Sequences

Poll, pitch declines, the Mail Queue, Gmail sign-in, and the Mail tab load.

3

Draft lifecycle

The statuses a queued draft moves through, as the code defines them.

5

Troubleshooting

No drafts appearing: a short checklist of what to check.

Scope: this page describes what the Profit Admin repository does. Anything that lives outside it is marked as such. Where the code does not settle a question, the page says so rather than guessing. No credentials or customer data appear here.

1. Components

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.

2a. The scheduled mail poll

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.

2b. Pitch declines: drafted, never auto-sent

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.

GuaranteeWhat it means
No Auto ruleA pitch rule cannot be saved as Auto, and a stored Auto value still behaves as Draft.
Queue onlyThe poll writes one queued decline per thread and sends nothing.
Person onlyOnly a signed-in person can edit or approve a pitch decline.
Send what was reviewedA 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.

2c. Human review in the Mail Queue

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.

2d. Gmail connect, reconnect and token refresh

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.

2e. Mail tab load, cached data and background refresh

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.

3. Mail draft lifecycle

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.

4. Rule modes and switches

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.

Never auto-sent

These kinds cannot run in Auto mode: 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.

5. Troubleshooting: no drafts appearing

Work down the list. The detailed runbook, with the tables and logs to check, is private. Ask a maintainer for it.

  1. Is the scheduled run happening? Production polls on a schedule. Staging only polls when it has been explicitly enabled. Also check the organisation has a usable subscription. Organisations without one are not polled.
  2. Is Gmail connected? The Mail tab shows a red banner when Gmail needs reconnecting. Reconnect from Mail settings.
  3. Is the label present? Open the thread in Gmail and look for 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.
  4. What is the rule mode? In Mail, Automations, the pitch rule should be Draft. Off skips the category. In Assist settings, a pitch action of Escalate gives no draft, and so does turning the cheap path off.
  5. Are the Assist switches on? Assist enabled and Cron poll enabled, both in Mail, Automations, must be on. Master auto-send is not one of them, because pitch declines are drafts either way.
  6. Was a draft already made? A pitch decline is created once per thread, in any status, and never again after that. Look in Mail, Queue, not in the saved replies.
  7. Still nothing? See the private runbook for the engine and Worker logs and the data to inspect.