This document is authoritative for the Microsoft Graph provider client
(provider-graph) — the first external cloud-mail adapter. Read it before
touching provider-graph or the Graph mail sync path, alongside providers.md
(the Provider Contract), store-and-sync.md (the apply/lease model), and
modeling.md.
Graph is the cloud-API counterpart to JMAP (OAuth bearer + JSON over HTTP), but its mail sync shape is IMAP/CalDAV-like, not JMAP-like: there is no account-wide message delta, so sync is per folder.
provider-graph implements the engine_provider::Provider contract for mail
(read/sync + submission + writes) and calendar (read/sync + writes) — mail on
GraphProvider (folder-bound), calendar on GraphCalendarProvider (calendar-bound),
each over its own GraphClient on the same token. The mail layers:
error—GraphError(Status/Json/Protocol/Transport) → the engine-neutralFailureClass. Graph error bodies are a documented{ "error": { "code", "message" } }envelope; thecodeis captured for diagnostics, the HTTP status drives classification (401→auth,429→rate limit,410 Gone→NeedsResyncfor an expired delta token,5xx→retryable).json/normalize— pureserde_json::Value→Mailbox/Message, unit-tested against captured fixtures.transport— aGraphTransportseam over bearer HTTP.HttpTransport(reqwest + rustls, built from the caller-suppliedTlsClientConfigpassed toGraphClient::connect/for_mailbox/with_base—tls.md) is production; the seam lets the fetch/provider orchestration run offline against fixtures. There is no session discovery (the v1.0 root is fixed); requests carryPrefer: IdType="ImmutableId". Having no connect-time request, Graph is the one adapter whoseConnectionInfo::http_versionisNoneuntil its first fetch — the transport records it at its singlesendfunnel (providers.md,tls.md). For the same reason it takes noConnectObserverand emits noConnectStep: there is no connect exchange to observe, andGraphClient::connectperforms no I/O at all. That absence is documented, never faked with a synthetic step.GraphClient::with_baseoverrides the API origin (a forward proxy, a regional/ sovereign endpoint, or the test replay server), rebasing the absolute@odata.nextLink/deltaLinkURLs Graph returns onto that origin so link-following stays on the chosen endpoint.fetch— folder-list resolution and the message snapshot/delta + re-fetch paging.submit— mail submission viaPOST /me/sendMailin MIME format (see Submission below). Adds apostverb to theGraphTransportseam.mutate— mutating mail writes (edit_mail: mark-read/flag, move, delete) via thepatch/postverbs (see Mail writes below). Account-level like submission, keyed by the message's immutable id, so any folder-bound provider can edit any message.provider—GraphProvider, bound to one folder for email; submission and writes are account-level, so every bound provider advertises them.
-
Per-folder mail delta.
/me/messages/deltareturns400— there is no account-wide message delta. Message delta is rooted at a folder (/me/mailFolders/{id}/messages/delta) with a per-folder@odata.deltaLinkcursor. So aGraphProvideris bound to one folder (itsemail_scopeisSyncScope::GraphFolder), the folder list syncs under the per-accountSyncScope::GraphFolderList, and the cross-folder fan-out is the orchestrator's job — the same shape asprovider-imap. -
Immutable ids are the
ProviderKey.Prefer: IdType="ImmutableId"yields ids that are stable across folder moves and URL-safe (Graph's default ids change on move). A message's single-folder membership comes fromparentFolderId(Graph mail is one-folder, like an IMAP copy — not the multi-membership JMAP/Gmail shape). -
Roles resolved by id, never by name. A personal
mailFoldercarries nowellKnownName(selecting it400s) and a localizeddisplayName(e.g. Dutch "Postvak IN"). The providerGETs the well-known aliases (inbox,archive,drafts,sentitems,deleteditems,junkemail) to learn their ids and matches by id;msgfolderrootis resolved to null the parent of top-level folders.outbox/conversation-history have no standard role. -
Snapshot = the initial delta enumeration (full objects): drain
@odata.nextLinkpages, ending at the@odata.deltaLinkthat becomes the persisted cursor.$topdoes not paginate consumer delta (page size is server-controlled;@odata.nextLinkappears only on large result sets). -
Incremental delta: full objects, except lightweight changes. Microsoft's delta-query-messages guidance says a changed entry is a full object — and it is for substantive edits (verified live: a flag change returns every selected field +
@odata.etag). The exception, not in the docs and observed on consumer mailboxes, is a lightweight property change (notablyisRead): it returns only the changed property +id, with no@odata.etag. So the adapter uses the entry directly when it carries@odata.etag(the common case) and resolves the etag-less partials as state changes through the narrowMESSAGE_STATE_SELECTrather than re-fetching the whole message.That narrow read cannot ask for the etag:
@odata.etagis an OData annotation, not a property, so naming it in a$selectis an error — whether a$selected single-entityGETanswers with one is the service's choice. It does (live-verified, captured astests/fixtures/mail/message_state.json, where@odata.etagisW/"{changeKey}"verbatim), and both the offline test andlive_an_is_read_change_comes_back_as_state_not_a_whole_messageassert it, because the etag is the token anIf-Matchquotes and nothing in the request asks for it. If it ever stops, the store keeps the stored token rather than blanking it (RevisionTokens::or,store-and-sync.md), so the failure degrades to a stale guard instead of to none. A removal is{ id, @removed: { reason } }→ an inline tombstone. (JMAP differs again:Foo/changes→Foo/getalways yields full objects.) The rest of the flow follows the doc verbatim: initialmessages/deltawith$select, drain@odata.nextLinkto the terminal@odata.deltaLink(the persisted cursor), following the returned URLs as-is since the token encodes the$select. -
Streaming + sync-depth window.
stream_emailstreams the folder's mail asEmailChunks: eachmessages/deltapage is fetched whole over HTTP and re-chunked withsplit_page— a first-sync snapshot intoReconcilechunks (whose accumulatedpresentset tombstones absent rows at end of pass), a delta intoAdditivechunks — and a final marker chunk carries the@odata.deltaLinkcursor. A consumer delta is not cheaply resumable mid-pass, so intermediate chunks hold the cursor and a crash re-runs the pass. ASyncWindow { since }passed per sync bounds the initial snapshot via areceivedDateTime ge$filter, so a large folder syncs only recent mail. A delta cannot carry a filter (thedeltaLinkis opaque), so a message moved into the folder is reported however old it is; the engine drops it on apply (SyncWindow::admits,store-and-sync.md), which is where the bound holds for every adapter.GraphProvider::with_sincesurvives only as thedefault_sync_windowthe whole-scopesync_emaildrain fetches under. Thechunk_sizeknob is the commit granularity; thefetch_batchknob has no lever yet (page size is server-controlled — see Known limitations). -
Keyword/revision mapping.
isRead→$seen,isDraft→$draft,flag.flagStatus == "flagged"→$flagged;internetMessageIdis preserved bracket-stripped as a threading hint (never identity);conversationId→thread provenance;@odata.etag→ETagand (fullGETonly)changeKey→ChangeKeyrevision tokens;bodyPreview→the snippet.
submit_email sends via POST /me/sendMail in MIME format (Content-Type: text/plain, the RFC 5322 message base64-encoded as the body), not the JSON
message resource. The reason is the Write Contract (store-and-sync.md): the JSON
form lets Graph mint its own Message-ID, which breaks reconcile-by-Message-ID
when the sent copy syncs back and cannot carry In-Reply-To/References (Graph's
internetMessageHeaders only accepts custom x-* headers). The MIME form ships the
whole message the caller assembled — pre-generated Message-ID, threading, Cc/
Bcc, an HTML alternative, attachments — verbatim, exactly like IMAP's SMTP DATA.
- One shared assembler. The RFC 5322 / MIME bytes come from
engine-rfc5322(assemble_filed_message) — the same crateprovider-imapfeeds to SMTPDATA, hoisted out ofprovider-imapso both adapters share one hardened, tested assembler rather than duplicating RFC 5322 correctness. The filed variant is used (it keeps theBccheader): Graph reads every recipient from the MIME to build the delivery envelope and stripsBccbefore delivering, so the Sent-Items copy records whom the sender Bcc'd while no recipient sees it. Graph files the Sent copy itself; there is no separateAPPENDstep (unlike IMAP). - No returned id, like SMTP.
sendMailanswers202 Acceptedwith no body, so there is no server key for the sent copy. TheSubmissionReceiptcarries aMessage-ID-derived placeholder key (sent:<Message-ID>, mirroring IMAP's no-UIDPLUSfiling key) and echoes theMessage-ID; the real sent object reconciles byMessage-IDwhen Sent Items next syncs. A malformed MIME body is the documented400 ErrorMimeContentInvalidBase64String(permanent);401/429/5xxclassify as auth/rate-limit/retryable through the shared status mapping. - Live-verified. A self-addressed send against the real account is confirmed to
come back into the Inbox carrying the exact pre-generated
Message-ID— proving Graph preserves it in the MIME form (tests/live_provider.rs, gated onGRAPH_ACCESS_TOKEN). TheMail.Senddelegated scope is required.
edit_mail applies a neutral [MailEdit] to an already-synced message, keyed by its
immutable id — so, like the IMAP adapter, one folder-bound provider can edit a message
in any of the account's folders (the target's mailbox comes from its key, not the
bound folder). Graph advertises mail_writes. The three neutral edits map onto three
different Graph shapes (Graph models mail state as typed properties, not a keyword set):
-
SetKeywords→PATCH /messages/{id}{isRead, flag}.$seen→isRead(bool),$flagged→flag.flagStatus(flagged/notFlagged). These are the only two writable keyword-like properties Graph exposes, so any other keyword is rejected (InvalidState), never silently dropped —$draftis read-only and Graph categories are a separate concept. Both sides empty is a no-op (no request). The edits are unconditional (noIf-Match): theMailEditshape carries no ETag guard, like IMAPUID STOREand JMAPEmail/set. -
MoveTo→POST /messages/{id}/move{destinationId}. Immutable ids are stable across a move (live-verified: the201echo keeps the id, updatesparentFolderId), so the receipt key is the unchanged target and the destination folder reconciles the new membership on its next sync — the JMAP shape, not IMAP's synthesize-a-new-key.A consequence worth knowing before writing a read: because the id survives and mail sync is per folder, one provider key is legitimately held by two scopes between the destination folder's delta creating it and the source folder's delta reporting it
@removed. Nothing in the store forbids it — its key is(scope, key)— so an account-wide read must not assume a key resolves to one row.Engine::composecomposes such a key once, keeping the row with the laterlast_modified; seeengine-api.md. -
Delete→POST /messages/{id}/permanentDelete. The neutralDeleteis a permanent, irreversible delete (a Trash move isMoveTo(trash)), so it usespermanentDelete, notDELETE /messages/{id}(which only soft-deletes to Deleted Items). The bodylessPOSTmust sendContent-Length: 0or Graph returns411 Length Required(reqwest omits the header for an empty body, so the transport sets it). An already-gone message (404) is idempotent success; the ambiguous re-delete Graph answers with403 ErrorCannotDeleteObject(the purged item lingers, stillGET-able by id during retention) propagates, left to the outbox'sNeedsConfirmation— the same shape as the calendar delete.
Live-verified. tests/live_provider.rs sends a self-addressed message, then
mark-reads + flags it (asserting the re-sync reflects both keywords), moves it to Archive
(asserting it leaves the inbox), and permanent-deletes it — all against the real account.
The Mail.ReadWrite delegated scope is required.
One signed-in user (one OAuth credential) can access several mailboxes: their own
and any shared/other mailbox they hold delegate access to — Graph addresses the
latter as …/users/{address}/mailFolders('Inbox')/messages, using the user's
token plus the *.Shared delegated scopes (an Exchange Online / work-school
feature; the tools/graph-oauth helper already requests them).
The engine models this without any engine-core change, because it is already
multi-account:
- Each mailbox is a separate
AccountId— its own folders,GraphFolder/GraphFolderListscopes, cursors, search, and threading, exactly like any other account. A shared mailbox reuses the entire existing machinery; nothing about it is special at the store/sync/search layer. - The credential is shared. Credentials live outside the store (host-owned —
north-star.md), so several accounts can map to the same token. The host's account onboarding owns the credential → accounts mapping and the add-a-shared-mailbox flow (deferred). - The provider differs only by a
MailboxPrincipal.GraphClient::for_mailboxroots every request at/me(MailboxPrincipal::Me) or/users/{address}(MailboxPrincipal::user); the rest of the provider — folder list, role resolution, snapshot/delta, re-fetch — is principal-agnostic. This stays inprovider-graph: a Graph-specific URL detail does not belong in genericengine-coretypes (AGENTS hard rule). - Unified "all my mailboxes" views are host-composed, not storage joins
(
north-star.md). Search/threading remain per-account.
So adding a shared mailbox is, for the engine, just another account pointed at a
User principal. (Not live-verified — a personal Microsoft account cannot host
shared mailboxes; verification awaits a work/school account.)
-
Editing one occurrence: the same derived id, and Graph flips its
typetoexception.PATCH /me/events/OID.<seriesMasterId>.<date>applies to that occurrence alone — measured:200, and the series keeps its own subject. TheETagthe caller read is the series', so it is not sent; the occurrence's own revision is not something a base of the series carries.⚠️ The patch makesidopaque, butoccurrenceIdkeeps the derived form. Once an occurrence has been edited itsidis a normal Graph id with no date in it, so nothing can be parsed back out of it. The delta entry carriesoccurrenceId— stillOID.<master>.<original date>— besideseriesMasterId, and that is what the read keys on (cal_override). Measured: an occurrence moved to the previous day still reads its own original date there, so the key is the recurrence id rather than wherever it landed. -
Removing one occurrence: the id is derived, and reading it back costs one request. Graph addresses an occurrence as
OID.<seriesMasterId>.<YYYY-MM-DD>— the shape it uses itself in the series master'scancelledOccurrences— so a removal needs no/instanceslookup at write time. Measured against the real account:DELETEon that id answers204, the occurrence leavescalendarView, and it appears incancelledOccurrences; a date the rule does not produce answers404 ErrorItemNotFound, and so does a date already removed.The delta re-sends the series and its surviving occurrences rather than a
@removedentry (measured), so the master'scancelledOccurrencesis the only thing that says an occurrence is gone — and it reaches no collection response. Measured:$selecting it on/eventsor on the delta returns everything but that property, while the same$selecton a single event returns it. So eachseriesMasteron a page is re-read on its own (cal_fetch::read_master). A cancel and an edit both re-send the master in the next delta, measured, so the read fires on the passes that matter.The
OIDid encodes a date, so it cannot name two occurrences on one day. Irrelevant while the expander has no sub-daily frequencies, and the reason that stays a stated limit. -
Tier-1 metadata only. The body/MIME and Graph
uniqueBodyare fetched on demand in a later store sub-step, not materialized here. -
No cross-folder orchestration yet. The provider is folder-bound; syncing every folder is the orchestrator's job (the live test binds the inbox alias).
-
Top-level folders only.
GET /me/mailFolderslists the children ofmsgfolderroot; a folder nested under another folder is not yet discovered (achildFolderstraversal is a follow-up).folder_from_jsonalready preserves a non-root parent for when nested discovery lands. The list is fully paginated (@odata.nextLinkdrained), and a well-known role alias that 404s (unprovisioned on the account) is skipped rather than failing the whole folder list. -
Per-id delta re-fetch (and role resolution) are sequential GETs. A changed id is re-fetched with one
GETeach, and the 6 role aliases +msgfolderrootare resolved with oneGETeach per folder-list pass; both could collapse to a few round-trips via$batch(≤20 sub-requests) — a follow-up optimization. A changed-id re-fetch that404s (deleted/moved in the race since the delta) is skipped, so a single vanished message cannot wedge the pass. -
Page size is server-controlled. The delta cycle drains every server page (correct), but the adapter does not yet send
Prefer: odata.maxpagesize— the page-size control the delta-query-messages doc documents — so it ignores thestream_emailfetch_batch. A follow-up for responsive streaming. ($topdoes not paginate consumer delta, which is why the header is the right lever.) -
National clouds aren't auto-rebased.
with_baserebasing rewrites only the commercial-cloud origin (graph.microsoft.com/v1.0); links a national-cloud endpoint (e.g.graph.microsoft.us) returns would be followed verbatim — fine for the replay server and a same-origin proxy, a gap for true national clouds. -
Snapshot order is delta-defined, not newest-first (consumer delta has no
$orderby). A streaming newest-first snapshot via the list endpoint is a possible later refinement. -
Mail read/sync + submission + writes (
GraphProvider). The provider advertisesmail+mail_writes+submission(see Submission and Mail writes above). Both submission and writes are account-level (keyed by immutable id), so they need no per-folder config. Calendar is a separate provider (see Calendar below), not this one.
GraphCalendarProvider implements the calendar read/sync and write spine, bound to
one calendar (SyncScope::GraphCalendar), with the calendar list under the per-account
SyncScope::GraphCalendarList — the same shape as the mail folder/folder-list split.
Layers: cal_fetch (calendar list + calendarView/delta paging), cal_normalize
(event/calendar JSON → model) + cal_recur (patternedRecurrence → Recurrence) +
cal_override (changed/removed occurrences → Recurrence::overrides), windows_zones
(Windows→IANA), cal_write (create/patch/delete), calendar (the provider). It advertises
calendars and calendar_writes(WriteGuard::Enforced).
calendarView/deltais the source, masters + local expansion. Event delta isGET /me/calendars/{id}/calendarView/delta?startDateTime=…&endDateTime=…(v1.0's only windowed delta;/me/eventshas no v1.0 delta). It returns the seriesseriesMaster(withpatternedRecurrence), standalonesingleInstances, the server's pre-expandedoccurrences, and per-instanceexceptions, ending at an@odata.deltaLink. The engine stores a master + rule and expands locally, so the adapter stores onlyseriesMaster/singleInstanceand dropsoccurrence(re-expanded from the master). Anexceptionis not an object of its own here — it is folded onto the series it names (cal_override). This reuses the mail delta machinery (@odata.nextLink/deltaLink/@removed,410→snapshot restart).- A series master is re-read on its own, in its own zone. One
GET /me/events/{id}?$select=start,end,cancelledOccurrencesperseriesMasterper page, fanned outMAX_CONCURRENT_MASTER_READSat a time. It carriesPrefer: outlook.timezoneset to the master'soriginalStartTimeZone, not the display zone, and itsstart/endreplace the delta's. The reason is that Graph names an occurrence by its date in the zone the series was authored in, and that name does not follow the header whilestartdoes — measured, a 23:30 Amsterdam series read inPacific/Aucklandstarts on the 6th while its own ids still say the 5th, so keying overrides off the display-zone reading would miss by a day for any series near midnight. Also measured: the header accepts the name Graph itself reported, IANA or Windows (W. Europe Standard Time), and an all-day series' date does not move under any zone. - Time-windowed, per calendar. The mandatory date range comes from a host-supplied
CalendarWindow(its recurrence-expansion horizon;providers.md); thedeltaLinkencodes it, so it is applied only to the initial request. - Authoring-zone times via
Prefer: outlook.timezone. A plain read returns UTC, which expands a recurring master DST-incorrectly. The provider sendsPrefer: outlook.timezone="<display_zone IANA>"(the host's home/display zone) on everycalendarViewrequest, so Graph returns each event's wall clock in that zone (echoing the IANA name), which the adapter stores. Windows zone names (a read without the header) still map through the CLDRwindowsZonestable (windows_zones, CLDR 49); an unknown ortzone://Microsoft/Customzone is preserved as a custom zone, never guessed. - Recurrence mapping.
patternedRecurrencepattern+range→ oneRecurrenceRule:daily/weekly/absoluteMonthly(BYMONTHDAY)/relativeMonthly(BYDAY+index)/absoluteYearly/relativeYearly→FREQ+BY*;rangenoEnd/numbered/endDate→ unbounded/COUNT/UNTIL. Graph's full weekday names map to the engineWeekday. - Writes — the server does the surgery (like JMAP).
create_eventPOSTs to/me/calendars/{id}/events(Graph assigns id andiCalUId— a clientUIDis not accepted, so the receipt carries the server's id/uid);patch_eventtranslates the neutralEventEditintent into a partial eventPATCH(never re-serializing the projection);delete_eventDELETEs. All areIf-Match-ETag guarded — a stale one is a412→Conflict— so Graph advertisesWriteGuard::Enforced(unlike JMAP). A start move is rejected if it would change the time form (has_same_form). The raw Graph event JSON is preserved beside the projection inEvent::extended("microsoft.graph/event"), since Graph is neither iCal nor JSCalendar. - Both scheduling capabilities are constants here (issue #105).
Capabilities::calendar_schedulingistrue— the service sends the iTIPREQUEST/REPLY/CANCELa write implies, with no opt-out a client can reach (the notify controls above choose whom it tells, not whether it is the one telling them), so unlike CalDAV there is nothing to discover.Capabilities::scheduling_submissionis alsotrue, from the mail side: this adapter submits assembled RFC 5322 bytes throughengine-rfc5322, so it owns everyContent-Typeparameter including themethod=that makes an iTIP object a scheduling message. It is therefore usable as the sending transport for an account whose calendar lives on a plain CalDAV server (providers.md). - RSVP (
cal_write::rsvp_event):POST /me/events/{id}/accept|tentativelyAccept|declinewith{comment, sendResponse}. Proven against two real accounts (tests/live_calendar_rsvp.rs— the only test in this repo that needs a second mailbox, because Graph cannot fake an invitation: an event created in a mailbox always has that mailbox as organizer, and a mailbox cannot answer its own meeting). Live findings:sendResponse: truereally schedules the reply — the organizer's copy showstentativelyAcceptedwithin seconds. Unobservable from the answering mailbox, whose own copy changes either way, which is why the test reads the counterparty's mailbox.- An invitee's copy lists the organizer twice — as
organizerand as anattendees[]entry — while the organizer's own copy omits them fromattendees.cal_normalize::participantsmerges the pair into one participant (roles unioned), and deliberately does not take that entry's status: Graph writes"none"there because it never records a response from an organizer, so adopting it would report the person who called the meeting as not having answered."none"on a real guest still means "has not responded". Google is the mirror image (it does track the organizer's own status) —calendar-semantics.md. WriteGuard::Absentis observed, not cautious. The action endpoint has no working precondition: a matchingIf-Matchis accepted and ignored (202), and a malformed one is a500 ErrorInternalServerErrorrather than a412. Sorsvp.guardcannot be sent, answering from a stale read is not refused, and the live test asserts that.- Declining removes the event from the invitee's calendar (Outlook's default), so a
declined event cannot be re-read or re-answered — a subsequent
GETis404 ErrorItemNotFound. Any test answering more than once must decline last.
- One display zone per provider — except a series master. All events are read in the
provider's
display_zone(Outlook's own behavior), so a non-recurring event authored in another zone is correct to the instant but carries the display zone's name. AseriesMasteris re-read in its ownoriginalStartTimeZone(above), so a recurring event expands in the zone it was written in and its DST transitions are its own. - Windowed coverage. Sync covers the
CalendarWindowonly; events outside it are not synced (theproviders.md"possibly-incomplete coverage" model). Coverage reporting is a follow-up. - No alerts /
SEQUENCE. Graph reminders → engineAlerts, and iTIPSEQUENCE, are not yet mapped (the reminder rides the preserved raw).create_eventrejects a floating-time start (Graph has no floating events).
- Offline (always green, no network): the normalizers and error mapping are
driven by scrubbed real Graph responses captured from a throwaway account
(
tests/fixtures/, with aREADME.mdrecording provenance + the scrub). A fixture-routing fake transport (test_support) exercises the folder/snapshot/ delta/re-fetch/tombstone/pagination orchestration; a blocking mock HTTP server exercises the real reqwest transport and the status/transport classification. For the calendar slice, the same fake transport drives the calendar-list /calendarView-delta orchestration (masters/singles kept, occurrences dropped, exceptions and the master'scancelledOccurrencesfolded onto the series, the zone the master is re-read in,@removedtombstones), thepatternedRecurrence→Recurrenceand Windows/ IANA-zone normalizers, and the create/patch/delete body shapes + form guard +409/412conflict mapping. The MIMEsendMailrequest shape is asserted by a capturing mock server that decodes the posted base64 (test_support::capturing_server); the same capturing server asserts the mail-write shapes — thePATCH {isRead,flag}body, thePOST …/move {destinationId}body, and thePOST …/permanentDeleteverb with itsContent-Length: 0— since the fixture-routing fake ignores request bodies. - End-to-end replay (deterministic, runs in CI, no token): a fixture-replay
HTTP server (
test_support::replay_server) serves the captured responses over real HTTP, andGraphClient::with_basepoints the real client at it. Tests drive the whole stack — reqwest transport +@odata-link rebasing + orchestration — without a token, for both the mail folder/snapshot/delta path and the calendar event snapshot. - Live (gated on
GRAPH_ACCESS_TOKEN, skips otherwise):tests/live_provider.rschecks folder role resolution, the snapshot→delta cycle, a self-addressedsendMailwhose exact pre-generatedMessage-IDis polled back out of the Inbox (proving Graph preserves it in the MIME form), and a calendar cycle — list calendars, snapshot+delta the events, then create→patch (asserting the ETag advances)→delete a throwaway event — all against a real account, an occasional drift check against the actual API, not the CI gate. There is no CI harness (no live account in CI); the token is obtained withtools/graph-oauth(a standalone PKCE-loopback login + refresh helper, outside the engine workspace). Excluded from the offline coverage metric via theci.yml--ignore-filename-regex, like the other providers' live tests.
GraphContactProvider is source-bound independently from the mail/calendar
providers. Personal root/folder contacts use per-collection delta links and are
writable; contact folders are discovered recursively. Organizational contacts
and directory users use global Graph endpoints and degrade to source-level
Unavailable on missing optional permission, so personal contacts still sync.
Every normalized card preserves raw JSON and changeKey.
The tenant sources do not answer 403 on a personal account. A personal MSA
refuses them by shape, not by permission: /contacts/delta answers
400 BadRequest ("This API is not supported for MSA accounts") and /users/delta
answers 401 with an empty message. The degradation rule therefore treats
400/401/403 as "source unavailable" for the optional sources only.
Swallowing 401 there cannot hide an expired token, because the same credential
drives the personal source, which is never optional and still surfaces the
failure. Captured in tests/fixtures/error/contacts_*.json.
Two normalization rules the captured fixtures pin, both invisible to hand-written
JSON. birthday reads back as a full timestamp anchored near local noon
("1815-12-10T11:59:00Z" from a date-only write), so only the date part becomes
Anniversary.date — that field is JSContact date text, which the Google and
CardDAV adapters fill with YYYY-MM-DD. categories maps to neutral
keywords in both directions; contact_write already emitted it, so the read
mapping is what keeps the round-trip lossless for a field Graph advertises as
supported.
Graph contact writes deliberately advertise WriteGuard::Absent: the current
contact update contract documents no enforceable per-object conditional guard.
Personal-contact create/patch/delete are supported and the engine refetches the
canonical contact after a successful outbox write. Organization contacts and
directory users remain read-only. Photos are fetched only on demand.
Birthday and homepage are retained on reads but are not writable capabilities:
Graph exposes one scalar for each while the neutral model permits multiple
anniversaries and links, so choosing one would silently lose intent.
businessHomePage is the only web address a Graph contact carries — the
resource has no personal-homepage counterpart, so there is nothing to pair it
with. (A second mapping here once read personalNotes, the notes field, and
republished any note beginning with http as a URL resource.)
The capture helper defaults include delegated Contacts.ReadWrite,
OrgContact.Read.All, User.ReadBasic.All, and ProfilePhoto.Read.All.
Directory permissions can require administrator consent and must never become a
prerequisite for personal contact sync.
A Graph card never says whether an image exists, so the contact normalizer emits a
photo ContactResource with an empty URI and the fetch derives the URL from the
card id. Whether it resolves is only knowable by asking, and the answer is cached —
including the negative (contacts.md → "Absence is an outcome, not a failure").
The sized route belongs to user, not to contact. /users/{id}/photos/240x240/$value
is valid; /me/contacts/{id}/photos/240x240/$value is 400 RequestBroker--ParseUri
("Resource not found for the segment 'photos'"). A contact has only the singular
photo/$value. So only the directory source asks for a size — which is also where it
matters. Measured against a real tenant directory user:
| Route | Bytes | Pixels |
|---|---|---|
/photos/240x240/$value |
13.7 KB | 240x240 |
/photo/$value |
799 KB | 2454x2453 |
58x, for a picture drawn at avatar size. A fallback to the unsized route still returns a
valid photo, so live_directory_photos asserts the returned image's pixels — nothing
weaker distinguishes the two.
User.ReadBasic.All is enough to read a colleague's photo; ProfilePhoto.Read.All is
not needed. That matters because the latter requires admin consent, so if it were
required, tenant directory avatars would be gated behind an administrator for every
customer. Verified live against a work/school account whose token's scp claim was first
asserted not to carry ProfilePhoto.Read.All — the app registration holds admin consent
for it in its home tenant, so without that control a successful read would have proved
nothing.
One incidental measurement worth keeping: 13 directory users were asked before one had a photo. Most people in a tenant have none, and each miss costs two requests (sized, then unsized), which is the case the negative cache exists for.
Every relevant failure here is a 404, separated only by code:
| Code | Means | Seen on |
|---|---|---|
ImageNotFound |
the resource exists, there is no image | user |
ErrorItemNotFound |
the same, for a personal contact | contact |
ErrorInvalidImageId |
the requested size is not one Graph offers | user, with a bad size |
The third is the dangerous one: a mis-set size is indistinguishable by status from
every contact simply lacking a photo, so it would blank every avatar and fail nothing.
Keep the size on Microsoft's documented list; the fallback to the unsized resource
bounds the damage to an extra request. The first and third bodies are captured in
tests/fixtures/error/photo_image_not_found.json and photo_invalid_size.json.
@odata.mediaEtag is Graph's documented cache key for a photo, and is not used: it
lives on the photo metadata resource, so reading it costs a second request per contact
during sync, while the cache key is computed from the card before any fetch happens.
Photos are invalidated by the card's revision instead — and for a personal contact
that is enough. Measured, not assumed:
changeKeydoes move on a photo-only change (PUT …/photo/$valuewith no other edit:…AAImXDTo→…AAImXDTs), and- that change is delivered by
contacts/delta— replaying a draineddeltaLinkreturned zero entries before the upload and exactly one, carrying the newchangeKey, after.
So a saved contact's new picture arrives on the next contact sync with no extra request
and no special handling: a different changeKey is a different fingerprint, which is a
cache miss.
/usersreturns neither@odata.etagnorchangeKey, and the photo resource the normalizer emits carries an empty URI — so every fallback in the fingerprint chain is exhausted. Nothing on the card tracks the photo./users/deltacannot carry photo information:$select=id,displayName,photois rejected outright ("Invalid request for delta query: for this entity set, $expand/$select…").photois a navigation property; delta carries scalars.- A photo-only change does not appear in
/users/deltaat all. Changing a user's profile photo moved its@odata.mediaEtag(W/"c8325ba8…"→W/"316d8d65…") while a saveddeltaLinkreplayed 0 changed entries — immediately, and again after a delay, with the cursor provably live (HTTP 200, freshdeltaLink). The positive control rules out a dead cursor: renaming the same user through the same link returned 1 entry carrying the newdisplayNameandsurname.
So a directory user's details refresh normally through delta, and their picture has no
signal whatsoever. This is the opposite of a personal contact, where a photo-only change
both moves changeKey and arrives through contacts/delta — same provider, two sources,
genuinely different behaviour, which is why the engine derives this per card from the
fingerprint rather than declaring it per provider.
Reported upstream, with this reproduction:
https://feedbackportal.microsoft.com/feedback/idea/4a6c7737-8a9c-f111-a3d0-7c1e52cf64f0.
If Graph ever fires the delta on a photo change, or allows $select=photo, or honours
If-None-Match against the media ETag, the age bound below stops being the only option —
check that item before assuming it is still the state of the world.
@odata.mediaEtag on the photo metadata resource (GET /users/{id}/photo, no
/$value) is the only true photo revision Graph exposes. It is not used as the cache
validator because reading it costs a request per photo per check, where the cache's
purpose is to make that zero — a max age on an unrevisioned entry costs nothing until it
expires. It remains the obvious basis for a future conditional refresh: ~200 bytes of JSON
to decide whether to spend 13.7 KB on the image.
Two saved contacts are kept on each Microsoft test account and should not be deleted:
one with a profile picture and one without. The engine never writes a contact photo, so
a live test cannot create the thing it needs to read — the present direction is only
coverable against a contact someone set up by hand. live_a_saved_contact_with_a_picture…
walks whatever the account has rather than naming them, so renaming is fine; removing the
one with a picture is what would silently uncover the path.
GET {principal}?$select=displayName,mail,userPrincipalName backs the neutral
sender_identities verb (providers.md). Graph is the one transport that advertises
IdentityControls::ReadOnly, and not because this adapter is unfinished: a mailbox's
display name is a directory attribute a tenant administrator owns, so an account
holder changing it is not a smaller version of the same operation. There is no
set_sender_name path here at all.
-
One identity, never a list. The principal is the mailbox, and a shared mailbox the signed-in user also reaches is a separate engine account with its own provider, so a
/users/{…}principal correctly reads that mailbox's name rather than the delegate's. That also means it needs the directory-read scope the shared-mailbox setup already grants. -
mailis null on a mailbox the tenant gave no SMTP address, so the read falls back touserPrincipalName— the same fallback the rest of this workspace already applies to a Graph address, so the two agree. -
$selectis acknowledged and not applied, at least on a personal (MSA) account.GET /me?$select=displayNamecomes back with@odata.contextnaming#users(displayName)/$entityand a body carrying all twelve defaultuserproperties anyway:ageGroup,businessPhones,givenName,preferredLanguage,surnameand the rest. Measured; a bareGET /mereturns the same twelve. So the$selectdocuments intent and may narrow a work/school response, but nothing may assume the payload is narrow: the normalizer picks its three properties out of whatever arrives, and the captured fixture is the full response precisely so an offline test shows that. -
User.Readis enough, the scope every Graph account here already grants. Reading the mailbox's own name therefore costs a host no new consent.
✅ Live-verified (tests/live_identity.rs, against a throwaway personal account): the
read returns the mailbox's own address with a directory displayName, and the rename is
refused locally rather than attempted.
POST {beta}/messages/{id}/reportMessage, in crate::report. Four facts, each
established by driving a real personal account on 2026-08-21, and three contradict the
published documentation — an adapter written from the docs alone is broken in three
places:
- There is no v1.0 endpoint.
markAsJunk/markAsNotJunkare deprecated and stopped returning data on 2025-12-30;reportMessage— the action Microsoft's own deprecation notice points to — is beta-only. SoGraphClient::beta_urlexists for this one call and nothing else. This is not reaching for a preview feature; it is the only door. - The response is not a
message. The docs say the action returns a message object. It returns{"properties":[{"key":"Status","value":"Success"}]}— areportMessageCommandResult.check_reportedreads thatStatus, because a200whose status is notSuccessis otherwise a silent success. A missing body or missingStatusis accepted rather than invented into a failure. IsMessageMoveRequested: falsedoes not keep the message in place. It moves to Junk regardless. Both the JSON boolean and the string"false"(the form the doc's own example uses) were sent, so this is the flag being ignored rather than the wrong type — which is the only way that claim could have been made. The adapter therefore sendstrue.- Only three of the five documented
reportActionvalues exist.unknownandunknownFutureValueare both400 RequestBodyRead.
Two more, both load-bearing:
- The sender is not blocked. After three junk reports and one phishing report against
the same external sender, six subsequent messages from it landed in the Inbox. Its
deprecated predecessor
markAsJunkexplicitly did block. Caveat this honestly: the blocked-senders list has no Graph surface (/me/mailboxSettingsand.../messageRulesboth403on a personal account), so this is behavioural evidence, not the list read back. - The immutable id survives the move, so the receipt carries the unchanged target —
but only because every request sends
Prefer: IdType="ImmutableId". A probe without that header saw the id404the instant the message reached Junk.
Only a personal Microsoft account has been tested. A work/school tenant may honour the move flag, may route reports to Defender, and may apply tenant reporting policy; a run against one is owed before the capability claims to describe every Graph account.