Email Microsoft Graph adapter
src/graphBackend.ts implements the existing MailBackend from src/types.ts.
It imports no Microsoft Teams implementation and owns neither OAuth nor credentials.
Acquisition and lifecycle
The integration factory resolves the optional microsoftIdentity control for each operation,
authorizes a persisted account through EmailStore.require(ownerId, accountId), then calls
GraphBackend.open(control, ownerId, account). Its input is the canonical NewMailAccount;
persisted MailAccount records also satisfy that contract. Admission's VerifyMailConnection
uses the same method before storage assigns createdAt: the connection layer selects the
candidate ref from actor-bound mailAccounts(), opens the backend, verifies listFolders() and
closes it before saving. No fabricated persistence metadata or alternate adapter is needed.
The adapter checks the explicit owner and Microsoft provider before calling
mailGraphFor(ownerId, account.microsoftRef). A null
client rejects with the exported GraphSignInRequiredError from graphBackend.ts.
The integration's registerEmailAlerts(ctx, store, factory, isReconnectRequired) classifier
and account API must distinguish that exact case with error instanceof GraphSignInRequiredError,
never by matching message text. Authentication refusals and transport errors retain their
original identity and propagate as faults, never becoming reconnect signals or an empty
mailbox. The scoped client remains private. Neither tokens nor an unrestricted Graph client
cross this seam.
Always await backend.close() in finally. Closing aborts outstanding scoped requests,
waits for their settlement and refuses further operations. It does not disconnect the
identity or revoke its token; account removal uses the control's mailDisconnect separately.
An adapter is operation-local, not a long-lived cached control instance.
Live reads and cursors
All calls are scoped relative paths below /me/messages, /me/mailFolders,
/me/outlook/masterCategories or /me/sendMail. Every request, including attachment downloads, sends
Prefer: IdType="ImmutableId". Public message IDs use the foundation's account-bound
encodeMessageId/decodeMessageId; foreign-account and IMAP references fail before
a message request.
Folder enumeration resolves the six well-known folder roles by ID, then walks every page
of the root and child collections. Display names are never used to infer a role.
Message listing accepts a folder ID or one of those well-known names. Only well-known
names match case-insensitively: the tools/workspace default INBOX and mixed-case aliases
use the canonical lowercase name for lookup. Opaque folder IDs retain their original case.
Well-known names are resolved to real folder IDs before paging, since Graph continuations
use actual IDs. list(MailQuery) accepts exactly one folder or opaque label ID and zero-based
page/pageSize (1–100). It returns ungrouped headers, the matching message count and an optional
searchLimit; no list cursor remains. Headers carry read/star/attachment/category state,
conversationId as threadKey, and same-key counts from the returned page.
Non-text queries request $top, $skip=page*pageSize, $count=true and descending
receivedDateTime. The order property leads $filter as Graph requires; when no since bound
is supplied, 0001-01-01T00:00:00Z includes the provider date range. Sender literals escape
apostrophes. The count must be a nonnegative integer, not an invented page-length total.
Short provider pages follow their exact next link until this page fills or enumeration ends.
Text uses Graph's $search, whose documented order is sent time and whose result cap is 1,000.
The adapter walks exact next links with $select=id, never manufactured skip offsets or
$count/$filter/$orderby alongside search. It hydrates only the requested slice when there
are no additional predicates. Exact sender/read/star/attachment/since or label predicates
require hydrating the bounded candidate set before counting and slicing. Thus an extra-filter
search costs up to 1,000 header reads plus search pages, never body or attachment reads.
At the cap, searchLimit:1000 explicitly says that total covers only accessible search results.
Graph's sent-time order is retained rather than silently reordered by received time.
Label views filter the mailbox-wide message collection and exclude the resolved Trash and Junk
folder IDs. Outlook category names are immutable, so public label IDs are opaque canonical
base64url name envelopes, not provider path fragments or display names. The same encoding on
headers preserves assignments whose category was removed from the master list. listLabels
returns supported:true, maps masterCategories colors to the closed palette, and rejects
unknown color constants. Create uses the master collection; delete resolves the current master
ID from the name before deletion. Deletion removes only the master category, not assignments
on existing messages. The browser's MailboxDialog selects the Outlook-specific confirmation
from the account's kind so it states that existing messages keep the label; other providers
retain their own deletion description and the DELETE request is unchanged. renameLabel explicitly rejects with
code:'unsupported_operation': Graph can update color, not the existing name. Replacing categories
and migrating all assignments is not an implicit rename operation.
Microsoft's current update contract
states: "Update the writable property, color, of the specified outlookCategory object. You can't
modify the displayName property once you have created the category." The request-body table lists
only color (checked 2026-10-21).
PATCH accounts/:accountId/labels/:labelId accepts exactly {color} or {name}.
Microsoft uses {color} and the browser omits Rename; a manual rename remains
unsupported. recolorLabel(id,color) resolves the category's current master ID by
its opaque name ID, PATCHes only color and returns the parsed updated label.
MAIL_LABEL_COLORS is canonical. The write mapping is gray→none, red→preset0,
orange→preset1, yellow→preset3, green→preset4, teal→preset5, blue→preset7,
purple→preset8 and pink→preset9. The identity control must admit
PATCH /me/outlook/masterCategories/{id}; no unrestricted-client fallback exists.
The error contract for an attempted rename is:
renameLabel(id,name)rejects the canonicalMailUnsupportedOperationErrorfromsrc/operationError.tsbefore any Graph call. The existing label ID, name, color and every message assignment remain unchanged. Role-folder rename/delete and permanent-delete requests outside Trash use this same adapter-owned policy refusal, never a provider-code imitation.- The HTTP integration identifies that class with
instanceofand maps it to409with{error:{code:EmailApiErrorCode,message:string,details?:MailConnectionDetails}}:{error:{code:'unsupported_operation',message:'This mailbox does not support the requested operation.'}}. A raw provider error carrying the samecodeis not trusted and retains the generic failure path. This is a valid request unsupported by the provider, not a reconnect, transient failure or success. - Browser callers use the typed code for localized cs/sk/en explanatory copy. They retain the old label and selection, make no optimistic rename, and do not automatically retry or start reconnect. Omitting Rename from the Microsoft UI does not remove this error contract for manual requests.
- The HTTP mapping and final action visibility are integration prerequisites; the adapter-only fixture does not prove either browser behavior or the route response.
Only alert cursors remain canonical base64url envelopes binding account/resource/query.
They are continuation state, not authorization: every request uses the already-owner-scoped client.
The URL parser checks the Graph origin and exact normalized resource path before keeping
the complete opaque query. Documented OData folder-key URLs and lowercase mailfolders
are normalized back to the scoped slash form. Credentials, fragments, other origins,
other folders, other resources and path traversal outside that resource are refused.
No absolute continuation is sent to the control. Multi-page operations reject loops,
missing delta terminals and conflicting next/delta links.
Folder enumeration returns live unread/total counts and parent IDs for child folders, omitting
the hidden root as a parent. Create supports a parent ID. Rename/delete refuse the six role folders.
Bulk methods prevalidate all 1–100 unique account-bound message IDs before writes. Flags PATCH
only requested fields; category changes read and retain unrelated assignments. Move uses the
native action but accepts only the six role aliases or an exact case-sensitive ID from this
owner's current listFolders() visible tree, including nested custom folders. Non-role destinations
cost one complete visible-folder enumeration per bulk operation, before any move POST. Listing
retains Graph's default exclusion of hidden folders; no hidden-folder lookup or alternate tree is
introduced. Unknown aliases and IDs outside that tree reject with MailUnsupportedOperationError,
so EmailOrganize cannot bypass its Trash-only boundary by naming a recovery folder or its opaque ID.
Archive/trash still target archive/deleteditems. Permanent deletion prechecks every
message's parent and then uses the Trash-folder-scoped permanentDelete action, so a concurrent
move out of Trash is not deleted through the unscoped message endpoint. Bulk writes are sequential,
not transactional; a later provider failure is reported and does not roll back earlier successes
or retry any write.
POST accounts/:accountId/folders/:folderId/bulk is human-only and returns 204.
{action:'markRead'} lists unread IDs with $select=id&$filter=isRead eq false
and PATCHes each message's isRead:true. {action:'empty',confirmed:true} first
checks the actual folder ID against resolved Deleted Items/Junk roles, lists IDs,
then POSTs the folder-scoped permanentDelete action for each. Both operations
finish every continuation read before the first write: changing read state or
deleting while following offset-based pages would skip remaining messages.
The existing scoped next-link validator and visited-path guard apply to every page.
IDs are deduplicated; operations do not recurse into child folders, retry writes
or promise transaction rollback. Arrivals after enumeration are not part of that batch.
thread(id) resolves the source conversation and current parent, enumerates that folder and Sent,
deduplicates immutable IDs, reads their complete messages and sorts oldest first. List, get,
thread and poll perform GETs only and never mark messages read.
Message bodies and attachments
Graph objects are parsed through typed Zod schemas; malformed collection/message shapes
reject rather than synthesizing success-shaped empty data. HTML is parsed by the host's
existing node-html-parser, not regular expressions. A separate parsed tree derives
plain text with scripts, styles and the document head excluded. HTML remains untrusted
source for the host's SandboxedHtml; the adapter never loads remote images or URLs.
Attachments are enumerated even when hasAttachments is false: Graph excludes inline-only
attachments from that flag. Referenced CID src and legacy background attributes are
replaced with file-attachment data URIs. If a successful attachment enumeration has no matching
part, the unresolved attribute is removed; available text and the rest of the conversation
remain readable, including through EmailRead. Attachment enumeration/download failures and
references to non-file attachments still reject explicitly; no transport error is suppressed.
Other HTML, including remote-image URLs, stays subject to the host frame's policy.
Attachment metadata retains file and item attachments. Download uses the scoped
/attachments/{id}/$value endpoint; Graph returns raw file bytes or an attached item's
MIME bytes. Reference attachments retain their metadata but Graph refuses raw download
with HTTP 405. Download limits and deadlines remain those of the delegated client.
No body or attachment is persisted locally.
List, search hydration, get() and delta select internetMessageHeaders. Graph can omit
this selected property in successful HTTP 200 list and message responses (confirmed with a
read-only mailbox reproduction on 2026-10-23). The shared header schema therefore accepts
an absent property; present values must still be arrays of string name/value pairs. Absence
means no unsubscribe target is available, not that the message or its whole thread is invalid.
No message is skipped, no additional request is made, and other validation errors still propagate.
MailHeader.listUnsubscribe combines repeated case-insensitive List-Unsubscribe
fields with commas, and calls the shared parseListUnsubscribe(value:string|undefined) from
src/listUnsubscribe.ts. The same parser serves IMAP; Graph adds no separate URL grammar.
The result is the first safe HTTPS/mailto targets or null for absent/invalid headers. Parsing
never follows an unsubscribe URL or changes read state.
Drafts, threading and sending
A new draft uses POST /me/messages and returns its account-bound immutable ID without
sending. Attachments are Graph file attachments with base64 content. Reply, reply-all and
forward first use createReply, createReplyAll or createForward, preserving Graph's
threading. The caller owns the complete subject/body/recipient and attachment inventory shown
in its confirmation preview. PATCH replaces recipients even with empty arrays; omitted cc/bcc
means empty, not Graph-derived recipients. The draft's inherited attachment collection is
enumerated and cleared before requested bytes are added through attachment POSTs. The complete
caller-authored body is used verbatim; source quoting belongs to the composer, not a second
provider-generated quotation. A forward therefore cannot silently send attachments removed
from the preview. Source forward parts have already been resolved to bytes by the caller.
Sending a new unthreaded message uses /me/sendMail with saveToSentItems: true.
Sending a draft uses /me/messages/{id}/send. A threaded outgoing message creates and
updates its draft before that send action. A 202 response means Graph accepted the
request, not that delivery is complete. The adapter never retries writes. Errors propagate;
if a later update, attachment or send fails, a provider-created draft may remain available
for recovery. Existing drafts are never deleted by backend cleanup.
Access checks and user confirmation belong to the caller and are not duplicated here.
The adapter uses ordinary JSON attachment operations; it does not implement the distinct
large-attachment upload-session protocol. Microsoft's
attachment POST contract
requires files under 3 MB, while the
large-attachment contract
uses upload sessions from 3 MB to 150 MB. V1 conservatively expresses that documented
boundary as strictly fewer than 3,000,000 raw bytes per supplied attachment (decimal MB,
not a claim about an undocumented exact server byte threshold), MAX_MICROSOFT_ATTACHMENT_BYTES
in src/composeRules.ts; the browser composer blocks the same files before a request. The canonical outgoing
attachment conversion checks the complete input before base64 encoding or any write,
including before createReply, createReplyAll, createForward, PATCH and sendMail.
A file at or above that boundary raises an explicit size error with zero writes; the tool's
larger Project-file read allowance does not enlarge this Graph adapter capability.
Sending an already stored provider draft carries no new attachment bytes to preflight.
Provider limits on aggregate message size and other operational refusals still propagate.
Inbox polling
poll(null) resolves the actual inbox ID, follows a complete
messages/delta?changeType=created round and returns a silent baseline. A silent round
keeps only its terminal delta link: it does not parse the messages it discards, so an old
message whose shape the header schema rejects cannot keep a mailbox from ever establishing
a baseline. Every next and
delta continuation is resource-checked before use. Later rounds return deduplicated new
headers, remove tombstoned IDs from the current round and persist no message cache.
The alert owner commits the returned cursor only after successful delivery.
The same optional-header projection applies to delta messages. A missing
internetMessageHeaders property does not discard an otherwise valid new message;
HTTP failures and malformed present properties still reject a delivering round.
Live list/get omission is reproduced; delta coverage uses neutral fixtures, not a live cursor.
A delegated error with status 410, or a 4xx syncStateNotFound, restarts a full, silent
baseline. Only one reset is attempted; failure of that baseline propagates. Reset URLs in
error responses are not followed. Authentication, network, malformed-page, malformed
delivered-message and other failures never advance the cursor or masquerade as a
successful empty poll.
Verification and upstream contracts
tests/emailGraphBackend.test.ts replays neutral response fixtures in
tests/fixtures/emailGraph.json plus scenario-specific pages and failures. They are
Graph-shaped documentation-derived fixtures, not captures from a real mailbox.
A focused TypeScript fixture checks the unpersisted NewMailAccount admission call.
Coverage includes admission and persisted owner/ref acquisition, immutable IDs, scoped continuation boundaries,
folder/attachment pagination, HTML/CID, draft/send distinction, reply actions, forward
content, silent baselines, reset and close/cancellation. Foundation message-ID tests remain
in tests/emailFoundation.test.ts. No live Microsoft mailbox or OAuth consent is needed.
Primary Microsoft contracts checked on 2026-10-21; list, category update and permanent-delete contracts rechecked on 2026-10-22:
- Message resource
says
internetMessageHeadersis returned only when selected; it does not promise that every message includes it. The observed HTTP 200 omission, not an inferred permission failure, is the regression input for optional headers (checked 2026-10-23). - Immutable IDs: the Prefer header applies per request; IDs survive moves within a mailbox, not archive-mailbox moves.
- List messages: full next-link reuse, page bounds and filter/order restrictions.
- Search query parameter: message searches return at most 1,000 results, sorted by sent date/time.
- Query parameters:
the count table explicitly uses
/me/messages?$top=2&$count=true; inline count returns the total matching collection, not this page's size. Search counting deliberately enumerates bounded IDs instead of treating that total as an unbounded search count. - List categories,
create category,
update category
and category resource:
listing needs
MailboxSettings.Read, mutation needsMailboxSettings.ReadWrite, names are immutable, and preset colors map to the Outlook palette. Only the mail sign-in requests the new scope. - Move message
defines
destinationIdas a folder ID or well-known name; the linked mailFolder resource listsrecoverableitemsdeletionsas soft-deleted mail (deleted from Deleted Items or Shift+Delete), invisible in Outlook except recovery. Its hidden-folder section states that ordinary listings exclude hidden folders. These documented provider semantics justify the visible-tree move boundary; regression tests mock the scoped Graph transport and do not claim live-tenant verification (checked 2026-10-21). - Permanent delete: the folder-scoped action removes the message from clients into the mailbox's purges retention area.
- Message delta and delta overview: initial enumeration, created-only subsequent rounds, opaque links and reset.
- Create reply, create forward and update message: native draft actions and mutable draft fields.
- List attachments and get attachment: file/item/reference response shapes and raw-value downloads.
- Send draft and send mail: existing-draft actions, Sent Items and asynchronous 202 acceptance.