Navigation
ELOWEN · Documentation

Last updated: October 27, 2026

Browse documentation · Email Microsoft Graph adapter
Developer reference

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 canonical MailUnsupportedOperationError from src/operationError.ts before 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 instanceof and maps it to 409 with {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 same code is 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 internetMessageHeaders is 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 needs MailboxSettings.ReadWrite, names are immutable, and preset colors map to the Outlook palette. Only the mail sign-in requests the new scope.
  • Move message defines destinationId as a folder ID or well-known name; the linked mailFolder resource lists recoverableitemsdeletions as 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.