Mailbot¶
k8s: heezy namespace, three CronJobs. No web UI, no Service, no hostname.
Source: heezy-containers/dockerfiles/heezy-mailbot/
Manifests: heezy-k8s/apps/heezy-mailbot/
Last Updated: 2026-08-14
What It Does¶
Scheduled Gmail jobs. Replaces the n8n workflows that ran on big-boi and the OpenClaw gmail-*
skills, so mail automation now lives in the same build, test and deploy pipeline as everything else.
| CronJob | Module | Schedule | Replaces |
|---|---|---|---|
heezy-mailbot-amazon-forward |
app.jobs.amazon_forward |
*/5 * * * * |
n8n "Amazon Order Forwarding" |
heezy-mailbot-receipt-ingest |
app.jobs.receipt_ingest |
*/15 * * * * |
n8n "Email Receipt Ingestion" |
heezy-mailbot-triage |
app.jobs.triage |
0 * * * * |
OpenClaw gmail-triage skill |
All three run --live. They landed in shadow mode on 2026-08-10 and were cut over after the dry runs
matched.
Every job requires exactly one of --dry-run or --live; there is no default. A dry run classifies
and prints, touches no labels, sends nothing, and writes no ledger rows. Output is a single JSON
object on stdout, exit 0 on success and 1 with error populated otherwise, so a failure shows up in
kubectl get jobs -n heezy.
kubectl create job -n heezy --from=cronjob/heezy-mailbot-triage triage-manual
kubectl logs -n heezy job/triage-manual
Idempotency¶
mailbot_processed (job, message_id) in the shared heezy database is written before the
mark-read call, not after. A replay of the same message is a skip.
This is the whole reason the service exists in this shape. n8n leaned on is:unread plus a
mark-as-read call, so a crash between the side effect and the modify call repeated the side effect on
the next run. receipts.heezy.info/ingest/email has no dedupe of its own, which meant duplicate
receipts. The table is created on connect; there is no migration step.
Amazon forwarding¶
Re-addresses the original message rather than rebuilding its body. Amazon sends
multipart/alternative whose text/plain part is a dump of navigation links, tracking-stage words
and a Grand Total: 0.0 USD. n8n forwarded that part, which is why the forwarded mail was
unreadable. Keeping the original MIME tree byte for byte means the recipient sees the same HTML
receipt that arrived.
Only Subject, MIME-Version and Content-* carry over. From is dropped rather than rewritten so
Gmail fills in the sending account, which keeps the message from being a spoof of the original
sender. Date is dropped so the forward is stamped when it was sent, which matters after a backlog.
Two guards protect the recipient:
--max-age-days(default 3) keeps an outage from replaying weeks of mail--max-send(default 5) caps forwards per run, so a backlog trickles out over several runs
An Ollama classification decides forward or skip: order confirmations forward, shipping updates, delivery notices, returns and newsletters do not. An unparseable model response defaults to forwarding, matching the n8n behaviour — the failure mode is a redundant forward, not a missed one.
The query deliberately omits label:INBOX
Triage classifies these same order confirmations as transactional and archives them, which
strips INBOX. Triage runs hourly and this job every five minutes, so it normally wins the race,
but any outage longer than an hour used to mean triage archived the mail and it was never
forwarded. is:unread plus the ledger is enough.
Receipt ingestion¶
Matches order confirmations from a fixed merchant list (Newegg, Best Buy, B&H, Walmart, Costco,
Sam's Club, Target, Apple, Adorama), extracts merchant, order number, total and line items with
Ollama, and POSTs to RECEIPTS_INGEST_URL — https://receipts.heezy.info/ingest/email.
Those rows land in the receipts database with a sentinel filename email_<order>.txt and no image
behind it, which is why the receipts UI labels them "Manually entered" rather than showing a broken
image. See Receipts Scanner.
Inbox triage¶
Classifies inbox mail and acts on it.
| Category | Action |
|---|---|
marketing, social, spam, transactional |
Archived |
trash |
Trashed |
| everything else | Left alone |
transactional is archived, not kept — receipts and shipping notices have already been handled by
the other two jobs by the time triage sees them. --hours (default 24) bounds the window.
Trashing is a side effect the ledger cannot undo
A misclassification into trash moves real mail. Run --dry-run --hours N after any prompt or
category change and read the classifications before letting the CronJob resume.
Configuration¶
| Env var | Source / default | Notes |
|---|---|---|
GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN |
ExternalSecret heezy-mailbot-gmail |
OpenBao production/heezy/gmail/oauth |
DB_PASSWORD |
ExternalSecret heezy-mailbot-postgres-credentials |
Required for --live |
DB_HOST / DB_PORT / DB_USER / DB_NAME |
bigboi.heezy.local / 5432 / heezy_app / heezy |
Ledger table |
OLLAMA_URL |
http://bigboi.heezy.local:11434 |
GPU host |
OLLAMA_MODEL |
llama3.2:3b |
Matches what the n8n nodes used |
RECEIPTS_INGEST_URL |
https://receipts.heezy.info/ingest/email |
Receipt job only |
FORWARD_TO |
required by amazon_forward |
Destination address |
The refresh token is the thing that expires. Rotating it means updating the OpenBao secret; the ExternalSecret refreshes on its own interval.
Tests¶
No network and no database — the Gmail, Ollama and Postgres clients are all injected.
Related¶
- Receipts Scanner — where the receipt job posts
- Data Sources — how ingested receipts reach finance