10 KiB
Read-status sync (/sync/*)
Tracks which messages got read or unread — and which were deleted — without re-downloading the mailbox. It sits on Microsoft Graph's delta query: Graph hands you a cursor, and every later call with that cursor returns only what changed since it was issued.
Five of the six endpoints share one piece of state: a delta cursor per (signed-in user + folder), persisted to disk so a restart doesn't re-backfill the whole folder. The sixth — the per-message lookup — is deliberately outside that machinery: it reads one id live and touches no cursor.
| Method | Path | Purpose |
|---|---|---|
| GET | /sync/read-status |
Run one sync round now (synchronous) |
| GET | /sync/read-status/changes |
Replay the last round's full result |
| GET | /sync/read-status/message/{id} |
One message's status, by id — cursor-free |
| GET | /sync/read-status/status |
Watcher health + cursor state |
| POST | /sync/read-status/watch |
Start the background poller |
| DELETE | /sync/read-status/watch |
Stop the background poller |
All require the API bearer token, and act on the signed-in user's mailbox —
they answer 401 until device-code sign-in completes.
GET /sync/read-status
The workhorse. Asks Graph "what changed in this folder since my cursor?", emits the changes, and advances the cursor.
The first call has no cursor, so it backfills the entire folder — an Inbox with 4,700 messages is 47 pages of 100. Every call after that is incremental and usually near-empty.
| Param | Default | Meaning |
|---|---|---|
folder |
inbox |
Well-known name (inbox, sentitems, …) or folder id. Graph delta is folder-scoped — there is no all-mail delta |
since |
– | ISO8601 lower bound, initial sync only (receivedDateTime ge …). The way to keep a first backfill small |
reset |
false |
Discard the saved cursor and start a fresh baseline |
max_pages |
10 |
Cap on Graph pages (100 msgs each) fetched per call |
limit |
10 |
Cap on messages returned in this response |
max_pages and limit are independent and easy to confuse:
max_pagesbounds the work. Hit the cap and the call returnscomplete: false, having saved its position; the next call resumes exactly where it stopped. No changes are skipped, and no cursor is written until the backfill genuinely finishes.limitonly trims the JSON. It has no effect on how much is fetched.countstays the true total, and the untruncated set is on/sync/read-status/changes.
{
"synced_at": "2026-08-07T10:15:00Z",
"folder": "inbox",
"count": 1000, // changed messages this call actually fetched
"removed_count": 0, // deleted / moved out of the folder
"initial_sync": true, // this round is part of the first backfill
"complete": false, // hit max_pages — call again to continue
"pages": 10, // Graph pages fetched by this call
"truncated": true, // limit cut the lists below
"value": [ { "id": "AAMk…", "isRead": true,
"lastModifiedDateTime": "2026-08-07T10:14:52Z",
"subject": "Invoice #421" } ],
"removed": [ { "id": "AAMk…", "reason": "deleted" } ]
}
value is sorted newest-modified first before limit is applied, so a
truncated response shows the most recent changes rather than an arbitrary slice.
Only the four $select fields above come back — this endpoint is about status,
not content; use GET /emails/{id} for bodies.
GET /sync/read-status/changes
Read-only replay of whatever the last round produced. No Graph call, cursor untouched, safe to hit repeatedly.
Two reasons it exists:
- It holds the untruncated lists — this is how you get the other 990 items
when
limittrimmed the response. - It's the only way to collect what the background watcher found, since the watcher has no caller to return to.
404 until some sync has run. One buffer, last-writer-wins: the next round
overwrites it, so with the watcher running you must read it faster than
interval or you will miss rounds.
GET /sync/read-status/message/{message_id}
One message, one record — a point lookup rather than a batch:
{ "id": "AAMk…", "isRead": true,
"lastModifiedDateTime": "2026-08-07T10:14:52Z", "subject": "Invoice #421" }
Identical shape to an entry in a sync value list, so both parse with the same
code. What makes it different from the endpoints above:
- Cursor-free. Touches no delta cursor, no cached state, and advances nothing. Call it as often as you like without affecting a sync in progress.
- Live. Reports the mailbox now, straight from Graph — not what the last round happened to capture. That makes it the right tool for re-checking one message ("has this been read yet?") and for confirming a status after the fact.
- Any id. Works whether or not the message appeared in a sync, and whatever folder it lives in.
It costs one Graph call per message, so it's a lookup, not a substitute for delta — walking a mailbox with it would be far slower than a single sync round.
curl -H "$A" "$B/sync/read-status/message/AAMkAGI2TG93..."
URL-encode the id. Ids containing /, +, or = are handled (the route uses a
:path converter), so an already-encoded %2F works too. Unknown or deleted
ids surface Graph's own 404 ErrorItemNotFound.
GET /sync/read-status/status
Health check for the whole subsystem.
| Field | Meaning |
|---|---|
watching / interval |
Is the poller thread alive, and at what period |
folder |
Folder the cursor belongs to |
last_sync_at |
Timestamp of the most recent round |
last_change_count / last_removed_count |
Size of that round |
has_delta_link |
A real cursor exists ⇒ running incrementally |
backfill_in_progress |
Paused mid-backfill at the page cap ⇒ more rounds to go |
last_error |
Last Graph failure from the background thread, else null |
has_delta_link: false + backfill_in_progress: true is the normal state
during a long first sync.
POST /sync/read-status/watch
Starts a daemon thread that runs the same sync every interval seconds and
writes each change to stdout.
{ "interval": 60, "folder": "inbox" } // interval min 10, both optional
- Idempotent — a second POST while running just answers
{"message": "Already watching read-status changes"}. - While a backfill is still incomplete the loop continues immediately instead of sleeping out the interval, so a big first sync finishes in consecutive chunks.
- Delivery is
_emit_read_status_changes(), which prints. That's the hook point — replace it to push to Slack, a webhook, or a queue.
DELETE /sync/read-status/watch
Signals the thread to stop; 404 if nothing is running. The cursor survives, so
restarting the watcher resumes from where it left off rather than re-backfilling.
Typical first run
export EMAIL_API_TOKEN=...
A="Authorization: Bearer $EMAIL_API_TOKEN"
B=http://localhost:5000
curl -X POST -H "$A" $B/auth/start # sign in once (see README)
# Baseline. Keep calling while "complete": false.
curl -H "$A" "$B/sync/read-status?since=2026-08-01T00:00:00Z"
# From here on, each call returns only what changed.
curl -H "$A" "$B/sync/read-status"
# Or hand it to the background poller and read results out of /changes.
curl -X POST -H "$A" -H "Content-Type: application/json" \
-d '{"interval":60,"folder":"inbox"}' $B/sync/read-status/watch
curl -H "$A" $B/sync/read-status/status
curl -H "$A" $B/sync/read-status/changes
# Re-check one message any time — no cursor involved.
curl -H "$A" "$B/sync/read-status/message/AAMkAGI2TG93..."
Which endpoint do I want?
| You want | Use |
|---|---|
| Everything that changed since last time | GET /sync/read-status |
| The full list a round produced (or the watcher's) | GET /sync/read-status/changes |
| The status of one message you already have an id for | GET /sync/read-status/message/{id} |
| Continuous tracking without calling in a loop | POST /sync/read-status/watch |
| Whether any of the above is healthy | GET /sync/read-status/status |
Rule of thumb: delta for "what changed", point lookup for "what about this one". Using the lookup in a loop over a mailbox works but costs one Graph call per message — a single sync round does the same job in pages of 100.
How the cursor works
- A finished round returns Graph's deltaLink, saved to
.delta_cache.json(override withEMAIL_API_DELTA_CACHE; in Docker it lives on the/datavolume beside the token cache). Keyed by user + folder — change either and the cache is ignored rather than misapplied. - A round stopped by
max_pageshas no deltaLink yet, so it saves Graph's nextLink instead. That resume position takes priority over any older deltaLink on the following call, which is what makes a capped backfill safe: the cursor never advances past data you haven't received. - Cursors expire. Graph answers
410 Gone, and the sync automatically falls back to a fresh baseline for that folder. reset=truethrows the cursor away deliberately — expect a full backfill, and passsincewith it unless you want the whole history again.
Limits worth knowing
- Folder-scoped only.
/me/messages/deltais not supported by Graph. Watch another folder by passingfolder=, but each folder is its own cursor and the disk cache holds one at a time — switching folders forces a re-backfill. - Polling, not push. Latency floor is the poll
interval. True push needs a Graph change-notification subscription (public HTTPS endpoint, validation handshake, ~3-day renewals) — and you'd keep delta anyway as the catch-up path for dropped notifications. - Single worker. Cursor, watcher thread, and the
last_changesbuffer are in-memory per process, so this only behaves with one uvicorn worker (which is what the Docker service runs, for the same reason auth needs it).