Skip to content

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_URLhttps://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

cd dockerfiles/heezy-mailbot && python3 -m pytest tests/ --cov=app --cov-fail-under=90

No network and no database — the Gmail, Ollama and Postgres clients are all injected.