A small self-contained service that watches an email inbox via IMAP IDLE for incoming Garmin LiveTrack notification emails, extracts the LiveTrack session link, and serves it itself as a public web page — so you get one fixed URL that always points to whichever LiveTrack session is currently active. Ships as a single Docker image; no external file hosting (FTP, S3, ...) required.
- A background thread keeps an IMAP IDLE connection open and reacts to new mail in near real time, with a periodic session renewal and reconnect-with-backoff as a safety net.
- Whenever a new link appears (from an email or an admin override), the server makes one HTTP
request to it and inspects the
X-Frame-Options/Content-Security-Policyresponse headers to decide whether the LiveTrack page can be embedded directly. See Display mode below. - A lightweight built-in web server (Flask + waitress) serves:
GET /— the public page: embeds the current LiveTrack link in an iframe if possible, falls back to a redirect page if not (or shows an "offline" placeholder if no session is active).GET /healthz— JSON status for container health checks / uptime monitoring.GET /admin— a Basic-Auth-protected dashboard: current link, IMAP status, recent history, and a manual set/clear override.
- The current link and a short history are persisted to a JSON file on a Docker volume, so a container restart keeps serving the last known link instead of going blank.
cp .env.example .env
# edit .env with your IMAP credentials and admin password
docker compose up -dThe service is now available at http://localhost:8080/, with the admin dashboard at
http://localhost:8080/admin. State is persisted to the waypoint-data named volume declared in
docker-compose.yml.
docker run -d \
--name waypoint \
--env-file .env \
-p 8080:8080 \
-v waypoint-data:/data \
--restart unless-stopped \
ghcr.io/bin101/waypoint:latestPre-built multi-arch images (linux/amd64 + linux/arm64) are published to GHCR on every tagged
release:
docker pull ghcr.io/bin101/waypoint:latest
# or a pinned version:
docker pull ghcr.io/bin101/waypoint:1.0.0docker build -t waypoint .
# or:
docker compose buildBuilding the image runs the full test suite as part of the build (see Development below) — the build fails if any test fails, so a broken image can never be produced.
All configuration is via environment variables — copy .env.example to .env and adjust.
| Variable | Default | Description |
|---|---|---|
IMAP_SERVER |
— (required) | IMAP server hostname |
IMAP_PORT |
993 |
IMAP server port |
IMAP_SSL |
true |
Use a direct SSL/TLS connection |
IMAP_STARTTLS |
false |
Use STARTTLS (plain connection upgraded to TLS) |
EMAIL_USER |
— (required) | IMAP username |
EMAIL_PASS |
— (required) | IMAP password |
TRASH_FOLDER |
Trash |
IMAP folder processed emails are moved to |
IMAP_SSL and IMAP_STARTTLS are mutually exclusive — enabling both is a configuration error
and the service will refuse to start.
| Security method | IMAP_SSL |
IMAP_STARTTLS |
Typical port | Notes |
|---|---|---|---|---|
| SSL/TLS (recommended) | true |
false |
993 | Direct encrypted connection |
| STARTTLS | false |
true |
993 | Plain connection upgraded to TLS |
| Unencrypted | false |
false |
143 | Not recommended — testing/internal networks only |
Common providers: Gmail, Outlook/Hotmail and Yahoo all use port 993 with IMAP_SSL=true. Many
custom/corporate servers use port 993 with IMAP_STARTTLS=true.
| Variable | Default | Description |
|---|---|---|
HEALTHCHECK_URL |
— (optional) | External URL pinged periodically (e.g. healthchecks.io) once the service is confirmed healthy |
REDIRECT_COUNTDOWN |
10 |
Seconds shown on the redirect fallback page before it navigates to the LiveTrack link (only used when the link can't be embedded — see below) |
| Variable | Default | Description |
|---|---|---|
WEB_PORT |
8080 |
Port the web server listens on inside the container |
ADMIN_USER |
— (optional) | Basic Auth username for /admin |
ADMIN_PASSWORD |
— (optional) | Basic Auth password for /admin |
STATE_DIR |
/data |
Directory the current link/history are persisted to (point this at a mounted volume) |
LOG_LEVEL |
INFO |
Logging verbosity: DEBUG, INFO, WARNING, ERROR |
The admin interface at /admin is only mounted if both ADMIN_USER and ADMIN_PASSWORD are
set — otherwise it responds 404 rather than existing unauthenticated. The public page (GET /)
is intentionally unauthenticated, since it's the link you actually share.
At /admin (Basic Auth):
- Status dashboard — current link, when it was last updated, IMAP connection status, last error.
- Manual override — set the current link directly, or clear it (e.g. to show the "offline"
page when no activity is live). The link must match the same
https://livetrack.garmin.com/session/.../token/...shape as an email-extracted one (400on anything else). - Recent history — the last 20 links that were published, with timestamp and source
(
emailoradmin), including whether each was served as an iframe or a redirect.
There is no manual toggle for this — it's decided automatically, per link, and always falls back to something that works:
- When a link is set (from an email or the admin override), the server makes one HTTP request to
it and checks the
X-Frame-OptionsandContent-Security-Policy: frame-ancestorsresponse headers. - If neither header forbids framing by a third-party site,
/embeds the LiveTrack page directly in an iframe. - Otherwise — or if the probe request fails or times out for any reason —
/serves the redirect page instead (a short countdown, thenwindow.locationnavigates to the LiveTrack link).
This split exists because a browser can't reliably tell from inside the parent page that a
cross-origin iframe was blocked: a blocked frame just renders blank, no onerror fires, and
contentWindow/contentDocument don't distinguish "blocked" from "still loading". Making the
decision server-side, from the actual response headers, is the only reliable way to know in
advance.
The current link and history are written to STATE_DIR/state.json. docker-compose.yml mounts
this to the named volume waypoint-data by default. If you'd rather bind-mount a host directory
instead, mount it to /data and make sure it's writable by UID/GID 1000 (the non-root user the
container runs as), e.g.:
mkdir -p ./data && sudo chown 1000:1000 ./dataRequires Python 3.12+.
pip install -r requirements-dev.txt
pytestThe test suite covers the pure link-extraction and page-rendering logic (using a sanitized sample
Garmin LiveTrack email fixture, see tests/fixtures/), the persisted state, IMAP config validation,
and the web routes (via Flask's test client) — all without needing a real IMAP or SMTP server.
- Feature branches merge directly into
mainvia PR — there is nodevelop/integration branch. PRs only run the test suite (.github/workflows/ci.yml); nothing gets built or released until the PR is merged. - Every push to
mainruns Release Please (.github/workflows/release-please.yml), which inspects the Conventional Commits merged in since the last release and keeps a standing release PR up to date with the accumulated version bump andCHANGELOG.mdentry — it never pushes tomaindirectly. - Merging that release PR (like any other PR, via review) is what actually cuts the
vX.Y.Ztag + GitHub Release — and, in the same workflow run, builds the multi-arch image and publishes it to GHCR. (The build has to live in that same run rather than a separateon: push: tags:workflow: GitHub Actions doesn't let a tag created via the defaultGITHUB_TOKEN— which is how release-please creates it — trigger a second workflow run.) A plain commit landing onmainnever builds or publishes anything. .github/workflows/publish.ymlis a manual-only fallback (workflow_dispatch) for re-publishing an existing tag on demand, e.g. after a registry outage.- Commit messages need a Conventional Commits prefix for Release Please to pick them up correctly:
fix:→ patch,feat:→ minor,BREAKING CHANGE:(in the footer) → major. Anything else (chore:,docs:,test:, ...) is included in the changelog but doesn't bump the version.
Browser console shows net::ERR_BLOCKED_BY_CLIENT. This is not a server/reverse-proxy issue —
Waypoint doesn't ship or require any nginx config, and this error never comes from the server; the
browser generates it locally, before the request even leaves the machine. It means a client-side
ad/tracker blocker (uBlock Origin, Brave Shields, Pi-hole-style browser extensions, ...) intercepted
a request. Garmin's livetrack.garmin.com URLs contain the substring track, which matches generic
tracker-blocklist rules in some filter lists, so the extension blocks the iframe/redirect target
itself. Check the failing request's URL in the Network tab to confirm, then allow-list
livetrack.garmin.com in the blocker — there's nothing to change in Waypoint's own config for this.
- Credentials are only ever read from environment variables (typically via
.env, which is gitignored) — never hardcode or log them. - Use strong, unique passwords for both the mailbox and the admin interface.
/adminis protected by HTTP Basic Auth; since Basic Auth sends credentials on every request, put the service behind TLS (a reverse proxy such as Traefik/Caddy/nginx) if it's reachable over the public internet.