Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ DB_PATH=./mailwatch.db # SQLite file path; use /var/lib/mailwatch/mail
# Rate limiting (USPS apis.usps.com default = 60 req/hr; request tier upgrade to 300)
RATE_LIMIT_PER_HOUR=50 # Client-side cap, leaves headroom under the USPS limit

# Tracking page UX.
# When true, /tracking lists recently generated letters (serial + recipient ZIP
# + status) as clickable references. The list exposes recipient ZIPs/serials to
# anyone who can reach the page — set false where /tracking is broadly reachable.
SHOW_RECENT_TRACKING=true
RECENT_TRACKING_LIMIT=20 # How many recent letters to surface (1–100)

# Webhook IP allowlist for /usps_feed (IV-MTR push feed source ranges).
# Comma-separated CIDRs. Verify current list at provisioning time.
USPS_FEED_CIDRS=56.0.0.0/8
80 changes: 78 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
Expand Down Expand Up @@ -122,6 +126,78 @@
"path": "detect_secrets.filters.heuristic.is_templated_secret"
}
],
"results": {},
"generated_at": "2026-04-21T18:31:33Z"
"results": {
"tests/test_app.py": [
{
"type": "Secret Keyword",
"filename": "tests/test_app.py",
"hashed_secret": "9fa974b13be42cbc6379d9841c52277c4399e3a4",
"is_verified": false,
"line_number": 25
},
{
"type": "Secret Keyword",
"filename": "tests/test_app.py",
"hashed_secret": "a0281cd072cea8e80e7866b05dc124815760b6c9",
"is_verified": false,
"line_number": 27
}
],
"tests/test_config.py": [
{
"type": "Secret Keyword",
"filename": "tests/test_config.py",
"hashed_secret": "d4539be1ab3f5a453c9c0dc107f2d989b3d22168",
"is_verified": false,
"line_number": 25
},
{
"type": "Secret Keyword",
"filename": "tests/test_config.py",
"hashed_secret": "76816e3dd24434df5ee83ae03730fd126e9a65da",
"is_verified": false,
"line_number": 27
},
{
"type": "Secret Keyword",
"filename": "tests/test_config.py",
"hashed_secret": "f09ac38e6897794f539a8aa7e096732765faa337",
"is_verified": false,
"line_number": 240
}
],
"tests/test_routes.py": [
{
"type": "Secret Keyword",
"filename": "tests/test_routes.py",
"hashed_secret": "9fa974b13be42cbc6379d9841c52277c4399e3a4",
"is_verified": false,
"line_number": 32
},
{
"type": "Secret Keyword",
"filename": "tests/test_routes.py",
"hashed_secret": "a0281cd072cea8e80e7866b05dc124815760b6c9",
"is_verified": false,
"line_number": 34
}
],
"tests/test_usps_api.py": [
{
"type": "Secret Keyword",
"filename": "tests/test_usps_api.py",
"hashed_secret": "9fa974b13be42cbc6379d9841c52277c4399e3a4",
"is_verified": false,
"line_number": 54
},
{
"type": "Secret Keyword",
"filename": "tests/test_usps_api.py",
"hashed_secret": "a0281cd072cea8e80e7866b05dc124815760b6c9",
"is_verified": false,
"line_number": 56
}
]
},
"generated_at": "2026-06-05T22:34:03Z"
}
10 changes: 10 additions & 0 deletions mailwatch/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ class Settings(BaseSettings):
# IV-MTR pull-poll daemon (read by `python -m mailwatch.poll`)
POLL_LOOKBACK_DAYS: int = Field(14, ge=1, le=365)

# Tracking page UX. When True, ``/tracking`` lists the most recently
# generated letters (serial + recipient ZIP + status) as clickable
# references so an operator need not retype the serial/ZIP from memory.
# The list exposes recipient ZIPs and serials to anyone who can reach
# the page (the site has no auth beyond the webhook IP gate), so set this
# to False on any deployment where /tracking is broadly reachable.
SHOW_RECENT_TRACKING: bool = Field(True)
# How many recent letters to surface in that list.
RECENT_TRACKING_LIMIT: int = Field(20, ge=1, le=100)

# IV-MTR push feed source IP allowlist.
# ``NoDecode`` disables pydantic-settings' default JSON-decoding for list
# fields so the string form from env (``"a,b,c"``) reaches the
Expand Down
111 changes: 104 additions & 7 deletions mailwatch/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@
);

CREATE TABLE IF NOT EXISTS tracked_imbs (
imb TEXT PRIMARY KEY,
serial INTEGER NOT NULL,
recipient_zip TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
imb TEXT PRIMARY KEY,
serial INTEGER NOT NULL,
recipient_zip TEXT,
recipient_name TEXT,
recipient_company TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS tracked_imbs_serial_idx ON tracked_imbs(serial);

Expand Down Expand Up @@ -96,8 +98,43 @@ def connect(path: str | Path) -> sqlite3.Connection:


def init_db(conn: sqlite3.Connection) -> None:
"""Create all tables and indexes if they don't exist. Idempotent."""
"""Create all tables and indexes if they don't exist, then migrate.

Idempotent: safe to call on every startup against a fresh or existing DB.
"""
conn.executescript(SCHEMA)
_migrate(conn)


def _add_columns_if_missing(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None:
"""``ALTER TABLE ADD COLUMN`` for each column not already present.

``columns`` maps column name to its SQL type declaration. Existing columns
are detected via ``PRAGMA table_info`` and skipped, so re-running is a
no-op. ``table``/column names come from internal callers only (never user
input) — ``PRAGMA`` and ``ALTER`` can't be parameterized, so they're
interpolated directly.
"""
existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
for name, decl in columns.items():
if name not in existing:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {decl}")


def _migrate(conn: sqlite3.Connection) -> None:
"""Apply idempotent additive migrations to an existing database.

``CREATE TABLE IF NOT EXISTS`` only creates *missing* tables; it never adds
columns to a table that predates a schema change. New nullable columns are
therefore added here, so an older on-disk DB picks them up on next startup
without a destructive rebuild. Keep migrations additive and nullable —
Litestream replicates the resulting WAL frames like any other write.
"""
_add_columns_if_missing(
conn,
"tracked_imbs",
{"recipient_name": "TEXT", "recipient_company": "TEXT"},
)


# --- app_state K/V -----------------------------------------------------------
Expand Down Expand Up @@ -177,6 +214,8 @@ def register_imb(
imb: str,
serial: int,
recipient_zip: str | None = None,
recipient_name: str | None = None,
recipient_company: str | None = None,
) -> bool:
"""Record a generated IMb so the poller can track it without a prior scan.

Expand All @@ -187,16 +226,74 @@ def register_imb(
:func:`get_pollable_imbs` bootstrap a freshly mailed letter that has no
scan event yet.

``recipient_name`` / ``recipient_company`` are display-only metadata for
the tracking-page list (:func:`recent_tracked_imbs`); they play no part in
routing or IV-MTR keying.

Idempotent via ``INSERT OR IGNORE`` on the IMb primary key. Returns True
when a new row was inserted.
"""
cur = conn.execute(
"INSERT OR IGNORE INTO tracked_imbs (imb, serial, recipient_zip) VALUES (?, ?, ?)",
(imb, serial, recipient_zip),
"INSERT OR IGNORE INTO tracked_imbs "
"(imb, serial, recipient_zip, recipient_name, recipient_company) "
"VALUES (?, ?, ?, ?, ?)",
(imb, serial, recipient_zip, recipient_name, recipient_company),
)
return cur.rowcount > 0


def recent_tracked_imbs(conn: sqlite3.Connection, limit: int = 20) -> list[dict[str, Any]]:
"""Return the most recently registered letters for the tracking-page list.

Newest first (by ``created_at``, tiebreaking on ``serial`` so the order is
deterministic when several pieces in a batch share a timestamp). Each row
is enriched with a coarse ``status`` derived from its latest stored scan:

- ``"delivered"`` — latest scan carries a delivery code
(:data:`DELIVERED_SCAN_CODES`).
- ``"in_transit"`` — at least one scan exists but it isn't a delivery code.
- ``"awaiting"`` — no scan event has been recorded yet.

The status reflects only locally stored scans (what the poller/webhook have
persisted), not a live IV-MTR pull; the live pull happens when the operator
actually opens the letter via ``/track-ws``. Rows without a
``recipient_zip`` are omitted — they can't form a usable tracking link.
``recipient_name`` / ``recipient_company`` may be ``None`` for letters
registered before that metadata was captured.
"""
rows = conn.execute(
"SELECT imb, serial, recipient_zip, recipient_name, recipient_company, created_at "
"FROM tracked_imbs "
"WHERE recipient_zip IS NOT NULL "
"ORDER BY created_at DESC, serial DESC LIMIT ?",
(limit,),
).fetchall()
result: list[dict[str, Any]] = []
for imb, serial, recipient_zip, recipient_name, recipient_company, created_at in rows:
latest = conn.execute(
"SELECT event_json FROM scan_events WHERE imb = ? "
"ORDER BY created_at DESC, event_id DESC LIMIT 1",
(imb,),
).fetchone()
if latest is None:
status = "awaiting"
elif _is_delivered_payload(latest[0]):
status = "delivered"
else:
status = "in_transit"
result.append(
{
"serial": serial,
"recipient_zip": recipient_zip,
"recipient_name": recipient_name,
"recipient_company": recipient_company,
"created_at": created_at,
"status": status,
}
)
return result


def get_imb_by_serial(conn: sqlite3.Connection, serial: int) -> str | None:
"""Return the full registered IMb for ``serial``, or None if unregistered.

Expand Down
Loading