return-tracker

← Home · ~/return-tracker · updated 5 days ago

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:

  1. 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.
  2. 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.
  3. Persist. Orders with parse_confidence >= confidence_track (default 0.6) upsert into SQLite keyed by gmail_message_id (idempotent). Nordstrom and other return_window_days = "unlimited" merchants are tracked but never get a deadline or reminder.
  4. Remind. For each open order with a deadline, fire exactly one reminder when today + 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.
  5. Render. Write ~/www/returns.html grouped 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, optional bb_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-tracker
  • ProgramArguments: /Users/mark/bin/return-tracker.sh
  • StartCalendarInterval: 08:00 local daily
  • RunAtLoad: false
  • KeepAlive: 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

  1. Drop your Google OAuth client secret at ~/.config/return-tracker/client_secret.json (Desktop app type).
  2. ~/bin/return_tracker.py setup — opens a browser for consent, writes token.json.
  3. (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).
  4. (Optional) If migrating from v1: run ~/bin/migrate_json_to_sqlite.py to import legacy returns.json.
  5. Dry-run sanity check: ~/bin/return-tracker.sh --dry-run.
  6. 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

  • uv on PATH (resolves the return_tracker.py shebang and installs the PEP 723 inline deps into its cache).
  • A running return-tracker-llm poll session (started by poll-bringup) — the LLM fallback drops events for it; no per-run claude subprocess.
  • ~/bin/bb-send.sh from bb-tools/ — sole iMessage sender.

Python (declared inline via PEP 723)

  • google-api-python-client>=2.110
  • google-auth>=2.23
  • google-auth-oauthlib>=1.2
  • jinja2>=3.1
  • tomli>=2.0 on Python <3.11 (stdlib tomllib on 3.11+)
  • pytest for the test suite (python3 -m pytest tests/)

macOS

  • launchd via com.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-llm poll session, for LLM classification (fallback only; regex parsers handle the hot path).

Sibling projects

  • bb-tools/ — supplies bb-send.sh.
  • webpage-server/ — serves ~/www/returns.html on the Tailscale Funnel.

Notes

  • Messaging stays in the wrapper. The LLM fallback never messages — it drops a classification event for the return-tracker-llm poll session, which is instruction-scoped to write JSON to a cache file (see parsers/llm.py). Only the shell wrapper sends messages, and only via bb-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-run never 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 open to expired on 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.