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