Gmail → Go → Discord
Automatic delivery tracking that just works.
DeliveryRadar is a self-hosted daemon that monitors your Gmail inbox for shipping notification emails, extracts tracking numbers, queries carrier APIs for real-time status updates, and pushes rich formatted alerts to a Discord channel.
No web UI needed — Discord is your dashboard.
Gmail Inbox DeliveryRadar Discord
┌───────────┐ ┌─────────────┐ ┌───────────┐
│ 📧 Shipped│──────▶│ Parse │──────▶│ 🚚 New! │
│ 📧 Tracked│ │ Track │ │ 📍 Update │
│ 📧 Order │ │ Notify │ │ ✅ Done! │
└───────────┘ └──────┬──────┘ │ 📊 Digest │
│ └───────────┘
┌──────┴──────┐
│ SQLite DB │
└─────────────┘
| Feature | Description |
|---|---|
| Auto-Detection | Polls Gmail every 5 minutes for shipping emails |
| Smart Parsing | Extracts tracking numbers from email bodies, subjects, and HTML attributes |
| Multi-Carrier | UPS, FedEx, USPS, DHL, Amazon (TBA), with generic fallback |
| Rich Embeds | Beautiful Discord embeds for new packages, status changes, and deliveries |
| Nightly Digest | Consolidated summary at 8 PM with all active and delivered packages |
| Zero Maintenance | Runs as a background daemon with automatic restarts |
| Local-First | SQLite database, no cloud services beyond Gmail API |
| Deduplication | Tracks Gmail message IDs and digest dates to prevent duplicate alerts |
🚚 New Shipment Detected 📦 Description: iPhone 15 Pro Case 🏪 From: Amazon 📬 Carrier: UPS 🔢 Tracking:
1Z999AA10123456784📍 Status: Pre-Transit 🗓️ ETA: Friday, Mar 27 🌍 Route: 🇨🇳 → 🇺🇸
📍 Package Update 📦 iPhone 15 Pro Case (UPS) 🔄 Status: In Transit → Out for Delivery 📌 Location: Memphis, TN 🗓️ ETA: Today
✅ Package Delivered! 📦 iPhone 15 Pro Case ⏱️ Transit Time: 3 days, 4 hours 📬 Carrier: UPS ·
1Z999AA10123456784🎉 Delivered at your door
📊 Delivery Digest — Wednesday, Mar 25
🚛 IN TRANSIT (2) • iPhone Case — UPS — ETA Tomorrow • Running Shoes — FedEx — ETA Fri Mar 27
🚪 OUT FOR DELIVERY (1) • Office Chair — Amazon — Expected Today
✅ DELIVERED TODAY (1) • HDMI Cable — USPS — Delivered 2:14 PM
📦 TOTAL ACTIVE: 3 packages
| Terminal Output | Discord Notification |
![]() |
![]() |
- Go 1.22+ — Install Go
- GCC (for SQLite CGo) — TDM-GCC on Windows
- Git
git clone https://github.com/SNMiguel/deliveryradar.git
cd deliveryradar
# Linux / macOS
CGO_ENABLED=1 go build -o deliveryradar ./cmd/deliveryradar
# Windows
set CGO_ENABLED=1 && go build -o deliveryradar.exe ./cmd/deliveryradar- Go to Google Cloud Console
- Create a new project named
deliveryradar - Enable the Gmail API (APIs & Services → Library → search "Gmail API")
- Create OAuth 2.0 credentials:
- Go to APIs & Services → Credentials → Create Credentials → OAuth client ID
- Application type: Desktop app
- Download the JSON and save as
credentials.jsonin the project root
- Configure the OAuth consent screen:
- Add your Gmail address as a test user
- Scopes needed:
gmail.readonly,gmail.labels
- Open your Discord server
- Go to the channel where you want notifications
- Channel Settings → Integrations → Webhooks → New Webhook
- Copy the webhook URL
cp internal/config/config.yaml.example config.yamlEdit config.yaml:
gmail:
credentials_file: "credentials.json"
token_file: "token.json"
poll_interval_minutes: 5
discord:
webhook_url: "https://discord.com/api/webhooks/YOUR/WEBHOOK_URL"
digest_time: "20:00"
timezone: "America/Chicago"
database:
path: "./deliveryradar.db"
tracking:
ups_client_id: "" # Optional: for native UPS API
ups_client_secret: ""
fedex_client_id: "" # Optional: for native FedEx API
fedex_client_secret: ""
scrape_delay_seconds: 2
log:
level: "info" # debug | info | warn | error
format: "text" # text | json./deliveryradarOn first run, DeliveryRadar will:
- Open a URL in your terminal for Gmail OAuth consent
- You paste the authorization code back
- Token is saved to
token.json(never needs to be done again) - Immediately polls your inbox and starts tracking
- Schedules the nightly digest
deliveryradar/
├── cmd/
│ └── deliveryradar/
│ └── main.go # Entry point, daemon loop
├── internal/
│ ├── config/
│ │ ├── config.go # Config struct and loader
│ │ ├── config.yaml.example # Template for users
│ │ └── config_test.go # Config validation tests
│ ├── models/
│ │ └── models.go # Shared types (PackageStatus, TrackingResult)
│ ├── gmail/
│ │ ├── client.go # OAuth2 setup, Gmail API client
│ │ ├── poller.go # Poll inbox, filter shipping emails
│ │ ├── parser.go # Extract tracking numbers from emails
│ │ └── parser_test.go # Parser tests (12 test cases)
│ ├── tracking/
│ │ ├── engine.go # Orchestrates carrier detection + status fetch
│ │ ├── carriers.go # Carrier regex patterns
│ │ ├── carriers_test.go # Carrier detection tests
│ │ ├── ups.go # UPS REST API tracker
│ │ ├── fedex.go # FedEx REST API tracker
│ │ ├── usps.go # USPS tracker
│ │ ├── dhl.go # DHL Tracking API tracker
│ │ └── generic.go # AfterShip fallback scraper
│ ├── store/
│ │ ├── db.go # SQLite init, migrations, types
│ │ ├── packages.go # Package CRUD operations
│ │ ├── events.go # Tracking event CRUD, digest log
│ │ └── store_test.go # Database tests (7 test cases)
│ ├── discord/
│ │ ├── webhook.go # Discord webhook sender with retry
│ │ ├── embeds.go # Embed builders (new, update, delivered, digest)
│ │ └── flags.go # Country code → flag emoji mapping
│ └── scheduler/
│ └── scheduler.go # Cron job for nightly digest
├── migrations/
│ ├── 001_initial.sql # Packages + events tables
│ └── 002_add_digest_log.sql # Digest deduplication table
├── config.yaml # Your config (gitignored)
├── go.mod
├── go.sum
├── Makefile
└── README.md
| Carrier | Detection Pattern | API Mode |
|---|---|---|
| UPS | 1Z + 16 alphanumeric chars |
Native REST API (with credentials) or fallback |
| FedEx | 12, 15, or 20 digits | Native REST API (with credentials) or fallback |
| USPS | 9[2-5] + 20-28 digits, or XX123456789US |
USPS Web Tools or fallback |
| DHL | 10 digits or JD + 18 digits |
DHL Tracking API v2 or fallback |
| Amazon | TBA + 12 digits |
AfterShip fallback |
| Other | Context clues in email text | AfterShip universal fallback |
Native API keys give higher rate limits and more reliable results, but are not required — the fallback scraper works for most cases.
UPS:
- Register at UPS Developer Kit
- Create an app → get Client ID and Client Secret
- Add to
config.yamlundertracking.ups_client_id/tracking.ups_client_secret
FedEx:
- Register at FedEx Developer Portal
- Create a project → get API Key and Secret Key
- Add to
config.yamlundertracking.fedex_client_id/tracking.fedex_client_secret
1. Query Gmail API for unread emails matching shipping keywords
2. For each email:
a. Decode email body (base64, handle multipart/MIME)
b. Extract tracking numbers using 3-tier strategy:
├── Structured HTML attributes (data-tracking-number, itemprop)
├── Known carrier regex patterns
└── Context clues ("tracking number:", "track your order")
c. Deduplicate against existing database entries
d. Query carrier API for initial status
e. Save to SQLite and send Discord notification
f. Label email as processed in Gmail
1. Query all non-delivered packages from database
2. For each package:
a. Fetch latest status from carrier API
b. Compare events against database to find new ones
c. On status change → send Discord update embed
d. On delivery → send celebration embed with transit time
1. Check digest_log table to prevent duplicates
2. Query packages grouped by status
3. Build consolidated Discord embed
4. Record digest as sent
DeliveryRadar uses a local SQLite file (deliveryradar.db) with WAL mode for concurrent reads. The schema auto-migrates on startup.
| Table | Purpose |
|---|---|
packages |
One row per tracked shipment (status, carrier, ETA, timestamps) |
events |
Tracking history per package (timestamp, location, description) |
digest_log |
Prevents duplicate nightly digest sends |
sqlite3 deliveryradar.db
-- View all packages
SELECT tracking_number, carrier, status, description FROM packages;
-- View recent events
SELECT p.tracking_number, e.description, e.location, e.timestamp
FROM events e JOIN packages p ON e.package_id = p.id
ORDER BY e.timestamp DESC LIMIT 20;
-- Check digest history
SELECT * FROM digest_log ORDER BY sent_at DESC;- Open Task Scheduler → Create Basic Task
- Trigger: "When the computer starts"
- Action: Start a program
- Program:
C:\path\to\deliveryradar.exe - Start in:
C:\path\to\(directory withconfig.yaml)
- Program:
- Check "Run whether user is logged on or not"
# /etc/systemd/system/deliveryradar.service
[Unit]
Description=DeliveryRadar - Gmail Delivery Tracker
After=network.target
[Service]
Type=simple
User=youruser
WorkingDirectory=/opt/deliveryradar
ExecStart=/opt/deliveryradar/deliveryradar
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now deliveryradar
sudo journalctl -u deliveryradar -f # View logs<!-- ~/Library/LaunchAgents/com.deliveryradar.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.deliveryradar</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/deliveryradar</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/youruser/deliveryradar</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/deliveryradar.log</string>
<key>StandardErrorPath</key>
<string>/tmp/deliveryradar.err</string>
</dict>
</plist>launchctl load ~/Library/LaunchAgents/com.deliveryradar.plist| Key | Type | Default | Description |
|---|---|---|---|
gmail.credentials_file |
string | credentials.json |
Path to Google OAuth credentials |
gmail.token_file |
string | token.json |
Path to stored OAuth token (auto-created) |
gmail.poll_interval_minutes |
int | 5 |
How often to check for new emails |
gmail.label_filter |
string | INBOX |
Gmail label to search within |
discord.webhook_url |
string | (required) | Discord webhook URL for notifications |
discord.digest_time |
string | 20:00 |
When to send the nightly digest (HH:MM) |
discord.timezone |
string | America/Chicago |
Timezone for digest scheduling |
database.path |
string | ./deliveryradar.db |
Path to SQLite database file |
tracking.ups_client_id |
string | "" |
UPS API Client ID (optional) |
tracking.ups_client_secret |
string | "" |
UPS API Client Secret (optional) |
tracking.fedex_client_id |
string | "" |
FedEx API Key (optional) |
tracking.fedex_client_secret |
string | "" |
FedEx Secret Key (optional) |
tracking.scrape_delay_seconds |
int | 2 |
Politeness delay for fallback scraping |
log.level |
string | info |
Log verbosity: debug, info, warn, error |
log.format |
string | text |
Log format: text (dev) or json (prod) |
| Scenario | Behavior |
|---|---|
| Gmail API rate limit (429) | Exponential backoff, max 5 retries |
| Carrier API failure | Log warning, retry next poll cycle, no Discord alert |
| Unrecognized tracking number | Saved with carrier=unknown, skips tracking updates |
| SQLite locked | Retry with 100ms backoff, up to 10 attempts |
| Discord webhook failure | Log error, retry once after 5 seconds |
| Missing config field | Fatal error on startup with clear message |
| OAuth token expired | Auto-refresh via golang.org/x/oauth2 |
# All tests
go test ./... -v
# Specific package
go test ./internal/gmail/... -v
go test ./internal/store/... -v
# With race detection
go test ./... -v -race| Package | Tests | Coverage |
|---|---|---|
internal/config |
7 tests | Config loading, defaults, validation |
internal/gmail |
19 tests | Tracking number extraction across carriers |
internal/store |
7 tests | CRUD operations, digest log, in-memory SQLite |
internal/tracking |
12 tests | Carrier detection regex patterns |
| Total | 45 tests |
# Build
make build
# Run directly
make run
# Run tests
make test
# Lint (requires golangci-lint)
make lint
# Clean artifacts
make cleancredentials.json,token.json, andconfig.yamlare in.gitignore— never commit them- The Discord webhook URL is treated as a secret and is never logged
- Gmail OAuth scope is
gmail.readonly+gmail.labels— no write access to email content - The service has no open ports — it is outbound-only
- SQLite database is local and not exposed over any network
Download your OAuth credentials from Google Cloud Console and place them in the project root directory.
Delete token.json and restart DeliveryRadar. It will re-prompt for OAuth authorization.
- Check that emails match the search query:
is:unread subject:(shipped OR tracking OR delivery OR order OR shipment) - Verify the
deliveryradar-processedlabel isn't already applied to emails - Set
log.level: "debug"to see detailed polling output
- Verify the webhook URL in
config.yamlis correct - Test the webhook manually:
curl -X POST -H "Content-Type: application/json" -d '{"content":"test"}' YOUR_WEBHOOK_URL - Check logs for Discord API errors
Ensure you have GCC installed and CGO_ENABLED=1 is set. On Windows, use TDM-GCC.
MIT License. See LICENSE for details.
Built with Go, SQLite, and too many tracking numbers.


