obsidian/wiki/concepts/microsoft-graph-api-mailbox-migration.md
2026-04-18 22:04:01 +01:00

104 lines
4.3 KiB
Markdown

---
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