return-tracker
Daily Gmail scan that detects product-return deadlines from order emails,
classifies them with per-merchant regex parsers (Claude Haiku as fallback),
persists to a local SQLite DB, fires precision iMessage reminders exactly
reminder_lead_days before each deadline, and renders a static HTML
dashboard served via Tailscale Funnel. Launchd-scheduled at 08:00 local.
Status: v2. Implements the full plan in PLAN.md: SQLite state, regex
parsers with LLM fallback, per-merchant policy table, static dashboard,
subcommand CLI.
Scope
A "return" is any email that (a) references a refund/RMA/return-label
action the recipient still has to take, and (b) can be tied to a merchant
with a known return window (via policies.toml) or carries an explicit
deadline in the email body.
The pipeline is detect -> persist -> remind -> render:
- Detect. Each morning, list Gmail messages from the last
gmail_query_days(default 60) matching a return-keyword query, skip anything at or before the last-processed watermark, fetch the body. - Parse. Dispatch to a merchant-specific regex parser (keyed off
From:domain). If confidence is low, fall through to a generic regex parser. If still low, optionally retry via Claude Haiku. - Persist. Orders with
parse_confidence >= confidence_track(default 0.6) upsert into SQLite keyed bygmail_message_id(idempotent). Nordstrom and otherreturn_window_days = "unlimited"merchants are tracked but never get a deadline or reminder. - Remind. For each open order with a
deadline, fire exactly one reminder whentoday + reminder_lead_days == deadline(default 3). Also fires one urgent reminder on or after the deadline itself.UNIQUE(order_id, fire_at)caps at one reminder per calendar day. - Render. Write
~/www/returns.htmlgrouped into urgent / upcoming (<=30d) / later / unlimited / needs-review sections.
A daily digest summarizes reminders fired today plus deadlines in the next 14 days. On silent days (no reminders, no upcoming deadlines), nothing is sent.
Architecture
return_tracker.py CLI entry (uv-script shebang). Subcommands:
setup/run/scan/remind/dashboard/returned/healthcheck
return-tracker.sh Launchd wrapper. SOLE caller of bb-send.sh.
config.py TOML loader -> frozen Config dataclass
policies.py TOML loader -> PolicyTable (per-merchant windows)
db.py SQLite schema + DAO + transaction() ctx manager
gmail_client.py OAuth + Gmail API wrapper (search_ids, fetch_body)
fetch.py scan pipeline: Gmail -> parsers -> policies -> DB
deadlines.py resolver: email-explicit > policy-window > unlimited
reminders.py check_due, mark_expired — precision reminder logic
dashboard.py Jinja2 render -> atomic write ~/www/returns.html
parsers/base.py EmailPayload + ParseResult dataclasses
parsers/{amazon,costco,nordstrom,generic,llm}.py
parsers/dispatch.py specific -> generic -> LLM (gated on confidence)
templates/dashboard.html.j2 dark-mode, mobile viewport, no JS
migrate_json_to_sqlite.py one-shot legacy importer (returns.json -> orders)
examples/{config,policies}.toml ship as templates; copy to ~/.config/
tests/ pytest suite — 20 tests across parsers/db/policies
Interface
One shell entry point, one Python CLI, one launchd plist.
return-tracker.sh (launchd entry)
Invokes return_tracker.py run, parses REMINDER: / DIGEST: lines from
its stdout, and delivers each via bb-send.sh. It is the sole caller
of bb-send.sh in this project — the LLM fallback never messages: it drops
a classification event for the return-tracker-llm poll session, whose
instructions allow only writing the result JSON to a cache file.
~/bin/return-tracker.sh # normal run (sends iMessages)
~/bin/return-tracker.sh --dry-run # print what would be sent, skip delivery
Daily log at ~/Library/Logs/return-tracker/return-tracker-YYYYMMDD.log
(30-day retention). On non-zero exit from the Python subprocess, sends a
single warning iMessage with the log path.
return_tracker.py (Python CLI)
Runs standalone via its uv run --script shebang — PEP 723 inline deps
mean no venv management. All subcommands share -v/--verbose for debug
logging.
return_tracker.py setup
One-time OAuth bootstrap. Reads client_secret.json, opens browser
for consent, writes token.json (chmod 600).
return_tracker.py run [--dry-run] [--skip-gmail]
Full pipeline: Gmail scan + reminder check + dashboard render.
Emits REMINDER:<text> and DIGEST:<text> to stdout for the wrapper.
--skip-gmail tests reminder + dashboard logic against existing DB.
return_tracker.py scan [--dry-run]
Gmail scan only. No reminders, no dashboard.
return_tracker.py remind [--dry-run]
Reminder check only (vs current DB state). No Gmail call.
return_tracker.py dashboard
Re-render ~/www/returns.html from current DB state.
return_tracker.py returned <order_id>
Mark an order as returned. order_id may be the integer orders.id
or the merchant-provided string order_id. Idempotent.
return_tracker.py healthcheck
Print counts + watermark + last run summary.
Output format (stdout)
Each reminder is a single REMINDER: line; digest is one or more
DIGEST: lines. Example:
REMINDER:Return reminder: Amazon — Kindle (ref 111-2222222-3333333). Deadline 2026-04-22 (3 days).
DIGEST:Return Tracker — Apr 19
DIGEST:
DIGEST:1 reminder fired:
DIGEST: - Return reminder: Amazon — Kindle (ref 111-2222222-3333333). Deadline 2026-04-22 (3 days).
DIGEST:
DIGEST:3 upcoming deadlines (next 14d):
DIGEST: - Amazon — 2026-04-22 (3d) — ref 111-2222222-3333333
DIGEST: - Costco — 2026-04-28 (9d) — ref 8712345
DIGEST: - Apple — 2026-05-01 (12d) — ref W12345678
Dashboard
https://marks-mac-mini.tail20af9f.ts.net/returns.html
Sections (any empty section is hidden):
- Urgent — deadline within
dashboard_urgent_days(default 3). Red-tinted cards. - Upcoming — deadline within 30 days.
- Later — deadline > 30 days out.
- Unlimited return window — Nordstrom-class merchants; no countdown.
- Needs review — no deadline parsed (parser confidence was too low for an explicit date).
Cards show merchant, item (from first line item), countdown chip,
deadline, order ref, total, and deadline source (email / policy /
unlimited). Rendered fresh each run via atomic write.
State
SQLite DB at ~/Library/Application Support/return-tracker/return-tracker.db
(WAL mode; schema versioned via PRAGMA user_version).
Tables
- orders — one row per Gmail message.
UNIQUE(gmail_message_id)makes re-runs idempotent. Columns:merchant,order_id,order_date,deadline(nullable),deadline_source(policy/email/unlimited/unknown),status(open/returned/expired/dismissed),parse_source,parse_confidence,subject,from_header,total_cents,items_json,email_date_ms,received_at,resolved_at,raw_json. - reminders — fired reminders.
UNIQUE(order_id, fire_at)guarantees at-most-once per calendar day. Stores urgency (upcoming/urgent), sent_at, message, optionalbb_send_exit. - runs — observability. Per-invocation: emails_seen, orders_added, reminders_sent, errors_json.
- watermark — key/value.
last_internal_date(Gmail epoch ms) advances only at the end of a successful transaction.
Logs
~/Library/Logs/return-tracker/return-tracker-YYYYMMDD.log— daily run log (30-day retention, pruned by the wrapper).~/Library/Logs/return-tracker/launchd.log— launchd stdout/stderr.
Legacy JSON migration
Running migrate_json_to_sqlite.py once on a box with an old v1
returns.json copies each entry into orders with
parse_source='legacy-llm' and renames the original to .bak. The
watermark.txt file is also imported to the watermark table. Safe to
re-run; it no-ops if returns.json.bak already exists.
Config
Two TOML files under ~/.config/return-tracker/ plus the OAuth pair.
config.toml — pipeline tunables
Missing file -> all defaults. Templates in examples/config.toml.
tz = "America/New_York"
[pipeline]
reminder_lead_days = 3 # fire exactly N days before deadline
confidence_track = 0.6 # min parse confidence to persist
confidence_auto_remind = 0.8 # below this: tracked but no reminder
max_emails_per_run = 50
max_body_chars = 12000
prune_days = 90
gmail_query_days = 60
[llm]
fallback_enabled = true
confidence_floor = 0.7 # regex < floor -> LLM retry
model = "claude-haiku-4-5-20251001" # advisory hint in the event; poll-bringup pins the session to haiku
[dashboard]
enabled = true
path = "~/www/returns.html"
urgent_days = 3 # highlights deadlines within N days
policies.toml — per-merchant return windows
Missing file -> default 30d window for all merchants. Templates in
examples/policies.toml.
[_meta]
default_window_days = 30
[amazon]
return_window_days = 30
from_domains = ["amazon.com", "auto-confirm@amazon.com"]
[nordstrom]
return_window_days = "unlimited" # -> no deadline, no reminder
from_domains = ["nordstrom.com"]
[costco]
return_window_days = 90
from_domains = ["costco.com"]
# plus target, best_buy, apple — see examples/policies.toml
return_window_days = "unlimited" resolves to None and skips all
reminder + deadline logic for that merchant. Dashboard entries still
appear in the "Unlimited return window" section.
Matching is by From: header substring (lowercased on both sides).
Per-email explicit deadlines (e.g. "must return by YYYY-MM-DD" for final
sale items) override policy.
Environment variables
| Var | Effect |
|---|---|
RETURN_TRACKER_CONFIG_DIR |
Override ~/.config/return-tracker/ |
RETURN_TRACKER_CONFIG |
Override config.toml path |
RETURN_TRACKER_POLICIES |
Override policies.toml path |
OAuth files
| Path | Owner | Purpose |
|---|---|---|
~/.config/return-tracker/client_secret.json |
you (manual) | Google OAuth 2.0 Desktop-app client secret. Download from Google Cloud Console -> Credentials. |
~/.config/return-tracker/token.json |
setup subcommand |
OAuth refresh+access tokens. Chmod 600. |
Deployment
install.sh
This repo is self-contained. From the repo root ~/return-tracker/:
./install.sh
This (idempotent):
1. Symlinks the executable return_tracker.py and return-tracker.sh into
~/bin/ (non-executable modules like migrate_json_to_sqlite.py stay in
the repo and import via the script's own dir).
2. Ensures ~/.config/return-tracker/ exists. Config is not auto-seeded —
the code runs on built-in defaults when the TOML is absent. Copy
examples/*.toml there to customize (note: a policies.toml switches from
the blanket 30-day window to per-merchant windows).
3. Creates the state dir (~/Library/Application Support/return-tracker) and
log dir (~/Library/Logs/return-tracker).
4. Copies LaunchAgents/com.mark.return-tracker.plist into
~/Library/LaunchAgents/ (copy, not symlink — launchd distrusts symlinked
plists) and (re)loads the job.
Editing any *.py module is live immediately via the symlink (Python
re-imports each launchd fire). Verify a deploy with ./verify.sh.
launchd plist
~/Library/LaunchAgents/com.mark.return-tracker.plist:
Label:com.mark.return-trackerProgramArguments:/Users/mark/bin/return-tracker.shStartCalendarInterval: 08:00 local dailyRunAtLoad: falseKeepAlive: false- Logs:
~/Library/Logs/return-tracker/launchd.log
To (re)load after editing the plist:
launchctl bootout gui/$UID ~/Library/LaunchAgents/com.mark.return-tracker.plist
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.mark.return-tracker.plist
Fire once now to verify:
launchctl kickstart gui/$UID/com.mark.return-tracker
Tailscale Funnel
~/www/returns.html is served by the shared webpage-server (in
~/agents/) on port 8899 — it serves the file statically, with no
return-tracker-specific server code. That port must already be exposed via
Funnel (tailscale funnel --bg 8899) — no new setup here.
First-time setup
- Drop your Google OAuth client secret at
~/.config/return-tracker/client_secret.json(Desktop app type). ~/bin/return_tracker.py setup— opens a browser for consent, writestoken.json.- (Optional) Customize config:
cp ~/return-tracker/examples/*.toml ~/.config/return-tracker/and tune. Without this the code uses built-in defaults (blanket 30-day return window, no per-merchant policies). - (Optional) If migrating from v1: run
~/bin/migrate_json_to_sqlite.pyto import legacyreturns.json. - Dry-run sanity check:
~/bin/return-tracker.sh --dry-run. - Load the launchd job (see above).
Usage
# Daily — fully automatic via launchd at 08:00 local.
# Test without sending iMessages or writing state:
~/bin/return-tracker.sh --dry-run
# Test reminder + dashboard against existing DB (no Gmail call):
~/bin/return_tracker.py run --skip-gmail --dry-run
# Inspect state:
~/bin/return_tracker.py healthcheck
# Mark an order returned (stop reminders):
~/bin/return_tracker.py returned 111-2222222-3333333
~/bin/return_tracker.py returned 42 # by orders.id
# Re-render the dashboard only:
~/bin/return_tracker.py dashboard
# Tail today's log:
tail -f ~/Library/Logs/return-tracker/return-tracker-$(date +%Y%m%d).log
# Raw SQL access:
sqlite3 "$HOME/Library/Application Support/return-tracker/return-tracker.db" \
"SELECT merchant, deadline, order_id, status FROM orders ORDER BY deadline;"
Dependencies
Runtime binaries
uvon PATH (resolves thereturn_tracker.pyshebang and installs the PEP 723 inline deps into its cache).- A running
return-tracker-llmpoll session (started bypoll-bringup) — the LLM fallback drops events for it; no per-runclaudesubprocess. ~/bin/bb-send.shfrombb-tools/— sole iMessage sender.
Python (declared inline via PEP 723)
google-api-python-client>=2.110google-auth>=2.23google-auth-oauthlib>=1.2jinja2>=3.1tomli>=2.0on Python <3.11 (stdlibtomllibon 3.11+)pytestfor the test suite (python3 -m pytest tests/)
macOS
launchdviacom.mark.return-tracker.plist— scheduled at 08:00 local.- Write access to
~/Library/Application Support/,~/Library/Logs/, and~/www/.
External services
- Gmail API (scope:
gmail.readonly). - Claude, via the
return-tracker-llmpoll session, for LLM classification (fallback only; regex parsers handle the hot path).
Sibling projects
bb-tools/— suppliesbb-send.sh.webpage-server/— serves~/www/returns.htmlon the Tailscale Funnel.
Notes
- Messaging stays in the wrapper. The LLM fallback never messages — it
drops a classification event for the
return-tracker-llmpoll session, which is instruction-scoped to write JSON to a cache file (seeparsers/llm.py). Only the shell wrapper sends messages, and only viabb-send.sh. - Re-entrancy. All DB writes run inside explicit transactions; the
watermark only advances at the end of a successful scan, so a mid-run
crash is safe (the next run re-sees the same emails and skips them via
the
UNIQUE(gmail_message_id)constraint).--dry-runnever mutates state. Dashboard writes use tempfile +os.replace. - Reminder de-dup.
UNIQUE(order_id, fire_at)guarantees one reminder per calendar day per order even if launchd double-fires or the clock jumps. - Expiry. Orders whose deadline passed > 1 day ago auto-transition
from
opentoexpiredon each run; they stay in the DB but stop appearing on the dashboard. - Failure mode. If the Python script exits non-zero, the wrapper
sends a single warning iMessage (
"Return tracker failed...") with the log path, then exits 1.
Files
return_tracker.py— CLI entry (symlinked to~/bin/return_tracker.py).return-tracker.sh— launchd wrapper (symlinked to~/bin/return-tracker.sh).migrate_json_to_sqlite.py— one-shot v1 state importer.config.py,policies.py,db.py,gmail_client.py,fetch.py,deadlines.py,reminders.py,dashboard.py— modules.parsers/{amazon,costco,nordstrom,generic,llm,dispatch}.py— parser strategy.templates/dashboard.html.j2— Jinja2 dashboard template.examples/{config,policies}.toml— config templates.tests/— pytest suite.LaunchAgents/com.mark.return-tracker.plist— launchd unit (vendored).install.sh/verify.sh— standalone installer + migration ship-gate.PLAN.md— v2 implementation plan.