HR-ATS-Portal/Sync_read.md

10 KiB
Raw Blame History

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_pages bounds the work. Hit the cap and the call returns complete: 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.
  • limit only trims the JSON. It has no effect on how much is fetched. count stays 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:

  1. It holds the untruncated lists — this is how you get the other 990 items when limit trimmed the response.
  2. 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 with EMAIL_API_DELTA_CACHE; in Docker it lives on the /data volume beside the token cache). Keyed by user + folder — change either and the cache is ignored rather than misapplied.
  • A round stopped by max_pages has 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=true throws the cursor away deliberately — expect a full backfill, and pass since with it unless you want the whole history again.

Limits worth knowing

  • Folder-scoped only. /me/messages/delta is not supported by Graph. Watch another folder by passing folder=, 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_changes buffer 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).