4.3 KiB
| title | aliases | tags | sources | created | updated | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Microsoft Graph API — App-Only Mailbox Migration |
|
|
|
2026-04-16 | 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.Allis the required application permission — notMail.Read(delegated)User.Read.Allapplication permission is only needed forGET /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
# 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.Allas an Application permission (not Delegated) - Grant admin consent — application permissions always require tenant admin consent
User.Read.Allapplication permission is only needed if you callGET /usersto 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:
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:
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.Allnot required for direct mailbox access by email