229 lines
10 KiB
Markdown
229 lines
10 KiB
Markdown
# 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`.
|
||
|
||
```jsonc
|
||
{
|
||
"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:
|
||
|
||
```jsonc
|
||
{ "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.
|
||
|
||
```bash
|
||
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.
|
||
|
||
```jsonc
|
||
{ "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
|
||
|
||
```bash
|
||
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).
|