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

4.3 KiB

title aliases tags sources created updated
Microsoft Graph API — App-Only Mailbox Migration
graph-api-mail-migration
graph-api-app-only
graph-api-shared-mailboxes
microsoft365
graph-api
email
migration
oauth
app-permissions
daily/2026-04-16.md
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.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

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

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

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