--- title: "Microsoft Graph API — App-Only Mailbox Migration" aliases: [graph-api-mail-migration, graph-api-app-only, graph-api-shared-mailboxes] tags: [microsoft365, graph-api, email, migration, oauth, app-permissions] sources: - "daily/2026-04-16.md" created: 2026-04-16 updated: 2026-04-16 --- # Microsoft Graph API — App-Only Mailbox Migration Migrating email from Microsoft 365 via Graph API requires an app-only (client credentials) OAuth flow, not a delegated (user) token. Delegated tokens cannot read other users' or shared mailboxes — they return 403 on any mailbox that isn't the authenticated user's own. ## Key Points - **Delegated tokens cannot read shared mailboxes** — Graph API returns 403; must switch to app-only (client credentials) flow - **`Mail.Read.All`** is the required application permission — not `Mail.Read` (delegated) - **`User.Read.All`** application permission is only needed for `GET /users`; direct mailbox access by email (`/users/user@domain.com/mailFolders`) works without it - Shared mailbox messages can be merged directly into another mailbox's Inbox without creating subfolders — attachments are embedded in EML - Practical scale from 2026-04-16: primary mailbox 2713 messages / 384 MB + 8 shared mailboxes 539 messages → merged total 2956 messages / 443 MB ## Details ### App-Only Authentication Flow ```python # list_mailboxes.py — app-only token using client credentials import msal, requests app = msal.ConfidentialClientApplication( CLIENT_ID, authority=f"https://login.microsoftonline.com/{TENANT_ID}", client_credential=CLIENT_SECRET, ) result = app.acquire_token_for_client( scopes=["https://graph.microsoft.com/.default"] ) token = result["access_token"] # Read a specific mailbox directly by email — no User.Read.All needed resp = requests.get( f"https://graph.microsoft.com/v1.0/users/{USER_EMAIL}/mailFolders", headers={"Authorization": f"Bearer {token}"} ) ``` The `.default` scope acquires all application permissions configured in Azure AD — no per-call scope selection needed with client credentials. ### Azure AD App Registration Requirements In Azure Portal → App Registration → API Permissions: - Add `Mail.Read.All` as an **Application** permission (not Delegated) - Grant admin consent — application permissions always require tenant admin consent - `User.Read.All` application permission is only needed if you call `GET /users` to enumerate all mailboxes To enumerate shared mailboxes without `User.Read.All`: maintain a hardcoded list of shared mailbox emails and access each directly by email address. ### Shared Mailbox Merge Strategy Shared mailbox messages merged into a primary inbox: ```python import requests, json def fetch_messages(token, mailbox_email, folder="inbox"): url = f"https://graph.microsoft.com/v1.0/users/{mailbox_email}/mailFolders/{folder}/messages" messages = [] while url: resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}) data = resp.json() messages.extend(data.get("value", [])) url = data.get("@odata.nextLink") # pagination return messages def download_message_as_eml(token, mailbox_email, message_id): url = f"https://graph.microsoft.com/v1.0/users/{mailbox_email}/messages/{message_id}/$value" resp = requests.get(url, headers={"Authorization": f"Bearer {token}"}) return resp.content # raw RFC 2822 EML bytes ``` The `/$value` endpoint returns the raw EML — attachments are already embedded. Save to disk and import into any IMAP server. ### Pagination Pattern Graph API paginates at 10 items by default; use `$top=50` and follow `@odata.nextLink`: ```python url = f".../messages?$top=50" while url: data = requests.get(url, ...).json() # process data["value"] url = data.get("@odata.nextLink") ``` ## Related Concepts - [[wiki/concepts/mailcow-maildir-import]] — what to do with the downloaded EML files (import into Mailcow) - [[wiki/connections/graph-api-vs-msal-app-vs-delegated]] — choosing app-only vs delegated auth for Azure AD - [[wiki/concepts/msal-vanilla-js-pkce]] — the user-delegated side of Azure AD OAuth ## Sources - [[daily/2026-04-16.md]] — M365 mail migration for ai-impress.com → Mailcow; list_mailboxes.py rewrite from delegated to app-only token; discovered `User.Read.All` not required for direct mailbox access by email