104 lines
4.3 KiB
Markdown
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
|