Self-hosted USPS Intelligent Mail barcode generator and letter tracker. Generate envelope or Avery-label PDFs with a scannable IMb, then watch USPS scan events as your letters move through the sort network.
- Renders printable envelopes (#10, 9.5" × 4.125") and 2×5 label sheets (Avery 8163) with a valid USPS IMb
- Queries USPS IV-MTR for scan events on your generated barcodes
- Receives push webhooks from USPS IV-MTR for real-time tracking updates
- Stores scan history in SQLite, replicated via Litestream if configured
- Python 3.12+
- A USPS Business Customer Gateway (BCG) account with:
- A Mailer ID (MID)
- Informed Visibility – Mail Tracking & Reporting (IV-MTR) access
- A USPS developer API OAuth client (https://developer.usps.com)
- Optional: publicly reachable HTTPS endpoint for the IV-MTR push feed
The canonical dev workflow is nix-driven — nix run .#dev and nix run .#test
set the WeasyPrint native-library path correctly for both linux
(LD_LIBRARY_PATH) and darwin (DYLD_FALLBACK_LIBRARY_PATH — SIP strips
LD_LIBRARY_PATH on macOS):
cp .env.example .env
# Fill in real values in .env
nix run .#dev # uvicorn reload-server
nix run .#test # pytest
nix run .#dev -- --port 5000 # extra args pass through
nix run .#test -- -k envelope -vvIf you prefer uv directly, nix develop drops you into a shell with
UV_PYTHON + the library path pre-set, after which:
uv run uvicorn --factory mailwatch.app:create_app --reload
uv run pytestOpen http://127.0.0.1:8000 to generate an envelope.
Canonical (matches CI):
nix flake check -L # lint + typecheck + tests + nix hygiene
nix build .#checks.$SYSTEM.tests # just pytest, any $SYSTEMFast iteration:
nix run .#test # pytest with libs pre-wired
uv run pytest # inside `nix develop`
uv run ruff check .
uv run ruff format --check .
uv run mypy mailwatchConsume this flake as an input:
{
inputs.mailwatch.url = "github:ak2k/mailwatch";
# In your NixOS configuration:
imports = [ inputs.mailwatch.nixosModules.mailwatch ];
services.mailwatch = {
enable = true;
domain = "mail.example.com";
environmentFile = "/run/secrets/mailwatch.env"; # sops-nix, agenix, etc.
};
}- Run
uv run gunicorn 'mailwatch.app:create_app()' --workers 2 --worker-class uvicorn.workers.UvicornWorker --bind 127.0.0.1:8082(factory syntax — the module exposescreate_app, not a singletonapp) - Front with a reverse proxy that terminates TLS and passes
X-Forwarded-For - Gate most routes behind your auth layer; exclude
/usps_feed— USPS must be able to POST to it by source IP only
All configuration is read from environment variables (see .env.example). Secrets must never be committed.
| Variable | Purpose |
|---|---|
MAILER_ID |
USPS-assigned Mailer ID (6 or 9 digits) |
SRV_TYPE |
IMb Service Type (40 = First-Class single-piece full-service) |
BARCODE_ID |
2-digit barcode ID (0 unless routing) |
BSG_USERNAME / BSG_PASSWORD |
USPS Business Customer Gateway credentials (IV-MTR auth) |
USPS_NEWAPI_CUSTOMER_ID / _SECRET |
developer.usps.com OAuth client |
SESSION_KEY |
32-byte hex string for session cookie signing |
DB_PATH |
SQLite file path |
RATE_LIMIT_PER_HOUR |
Client-side cap on USPS API calls |
USPS_FEED_CIDRS |
Comma-separated CIDRs allowed to POST /usps_feed |
USPS's default tier for new developer accounts is 60 req/hr per application across all endpoints on apis.usps.com (addresses + tracking + labels). Request a tier upgrade to 300 req/hr by emailing emailus.usps.com with your CRID, app name, and mailer-use justification. IV-MTR tracking calls (iv.usps.com) are on a separate bucket.
Address standardization normally goes through the official apis.usps.com
Addresses API (NewApiClient). If that API is down — or after USPS's
2026-07-12 move of the Addresses API to a paid, licensed tier — a standby
resolver can keep /generate producing full ZIP+4 + 11-digit-routing barcodes
for $0.
It drives USPS's free consumer ZIP-lookup (tools.usps.com) with a real
browser. That endpoint sits behind Akamai Bot Manager, which requires a live
JS-sensor session, so plain HTTP clients are blocked; the resolver uses
nodriver + Chromium, headful under xvfb. It writes results straight into
the address_cache table — keyed identically to what /generate looks up — so
the service and its request path are unchanged; they simply get a cache hit
and skip the API.
Linux-only (needs Chromium + Xvfb), in the optional browser dependency group:
# On the mailwatch host. Pass the SAME recipient fields you'd enter on /generate
# (the cache key is the hash of that raw input):
nix run .#resolve-address -- "475 L'Enfant Plaza SW" \
--city Washington --state DC --zip 20260
# --company / --address2 if the recipient has them
# --print-only resolve without writing the cache
# --db <path> target DB (default: $DB_PATH)Verify the resolver still beats Akamai (live canary, opt-in):
nix run .#test -- -m integrationCaveats: this automates a free public tool, which is a gray area in USPS's
terms of use — it's a low-volume fallback, not the default path. Keep volume
low; results are cached for a year. The browser engine is isolated behind
mailwatch/web_lookup.py, so it can be swapped (e.g. for Camoufox) without
touching callers.
MIT. See LICENSE.
Ported from 1997cui/envelope (MIT). IMb algorithm is USPS-B-3200.