Download Attachments from Microsoft 365 with the Graph API
The IMAP intake script that worked for years now fails against the company's Microsoft 365 mailbox with LOGIN failed., and the first Graph API attempt returns errors instead of mail: 401 InvalidAuthenticationToken, 403 ErrorAccessDenied: Access is denied. Check credentials and try again., or a list of messages whose attachments come back without content. Meanwhile the security team asks why an application should be able to read every mailbox in the tenant.
Root Cause
Exchange Online no longer accepts passwords for IMAP, so mailbox automation must use OAuth — and for unattended jobs, the cleanest OAuth route is Microsoft Graph with an application identity rather than a user. That identity needs three separate things before any request succeeds: an app registration with a client secret or certificate, the Mail.Read (or Mail.ReadWrite to move messages) application permission with admin consent, and a token requested for the Graph resource. The 401 usually means the token was requested for the wrong resource or has expired; the 403 means the permission is missing, unconsented, or restricted away from that mailbox. Separately, the Graph message and attachment endpoints behave differently from IMAP: messages are paged, attachment lists may omit content, large attachments need a raw $value download, /me does not exist for app-only tokens, and moving a message changes its id. Finally, Mail.Read as an application permission covers all mailboxes by default, which is why it must be scoped with Exchange's role-based access for applications.
Minimal Diagnostic
Request a token, decode its claims locally to confirm the audience and roles, then make the smallest possible call against the target mailbox and print the exact error body.
# pip install msal requests python-dotenv
import base64
import json
import os
import msal
import requests
from dotenv import load_dotenv
GRAPH = "https://graph.microsoft.com/v1.0"
def token_claims(token: str) -> dict:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
def diagnose() -> None:
load_dotenv()
app = msal.ConfidentialClientApplication(
os.environ["AZURE_CLIENT_ID"],
authority=f"https://login.microsoftonline.com/{os.environ['AZURE_TENANT_ID']}",
client_credential=os.environ["AZURE_CLIENT_SECRET"],
)
result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
if "access_token" not in result:
raise SystemExit(f"token error: {result.get('error')}: {result.get('error_description')}")
claims = token_claims(result["access_token"])
print("aud:", claims.get("aud"), "| roles:", claims.get("roles"), "| app:", claims.get("app_displayname"))
mailbox = os.environ["MAILBOX"]
resp = requests.get(f"{GRAPH}/users/{mailbox}/mailFolders/inbox/messages",
params={"$top": "1", "$select": "id,subject"},
headers={"Authorization": f"Bearer {result['access_token']}"}, timeout=30)
print("status:", resp.status_code)
print(resp.text[:400])
if __name__ == "__main__":
diagnose()
aud: https://graph.microsoft.com | roles: None | app: invoice-intake
status: 403
{"error":{"code":"ErrorAccessDenied","message":"Access is denied. Check credentials and try again."}}
roles: None is the answer: the token carries no application permissions, so the app registration either lacks Mail.Read as an application permission or admin consent was never granted. If roles lists Mail.Read and the call still returns 403, the permission is scoped to other mailboxes.
Fix: Least-Privilege App, Then List, Download and Move
Grant Mail.ReadWrite (to move processed messages) as an application permission with admin consent, and restrict the app to the intake mailbox using Exchange Online's role-based access control for applications. That scoping is done once by an Exchange administrator in PowerShell:
# Exchange Online PowerShell, run once by an administrator
New-ServicePrincipal -AppId <client-id> -ObjectId <enterprise-app-object-id> -DisplayName "invoice-intake"
New-ManagementScope -Name "Invoice intake mailbox" -RecipientRestrictionFilter "PrimarySmtpAddress -eq '[email protected]'"
New-ManagementRoleAssignment -App <client-id> -Role "Application Mail.ReadWrite" -CustomResourceScope "Invoice intake mailbox"
Test-ServicePrincipalAuthorization -Identity <client-id> -Resource [email protected]
With the scoped role assigned, remove the tenant-wide Mail.ReadWrite API permission from the app registration so the only grant is the scoped one. Then the Python job lists unread messages with attachments, downloads each file attachment's raw bytes, and moves the message. Changed lines carry comments.
# pip install msal requests python-dotenv
import hashlib
import os
import re
import time
from pathlib import Path
import msal
import requests
from dotenv import load_dotenv
GRAPH = "https://graph.microsoft.com/v1.0"
OUT = Path("inbox/incoming")
ALLOWED_EXT = {".pdf", ".xlsx", ".csv"}
class Graph:
def __init__(self) -> None:
load_dotenv()
self.mailbox = os.environ["MAILBOX"]
self.app = msal.ConfidentialClientApplication(
os.environ["AZURE_CLIENT_ID"],
authority=f"https://login.microsoftonline.com/{os.environ['AZURE_TENANT_ID']}",
client_credential=os.environ["AZURE_CLIENT_SECRET"])
self.session = requests.Session()
def _token(self) -> str:
result = self.app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"]) # changed
if "access_token" not in result:
raise RuntimeError(result.get("error_description"))
return result["access_token"] # MSAL caches and refreshes automatically
def call(self, method: str, url: str, **kwargs) -> requests.Response:
for attempt in range(5):
resp = self.session.request(method, url, timeout=60,
headers={"Authorization": f"Bearer {self._token()}"}, **kwargs)
if resp.status_code in (429, 503, 504): # changed: honour throttling
time.sleep(int(resp.headers.get("Retry-After", 2 ** attempt)))
continue
resp.raise_for_status()
return resp
raise RuntimeError(f"gave up after throttling: {url}")
def unread_with_attachments(self):
url = f"{GRAPH}/users/{self.mailbox}/mailFolders/inbox/messages" # changed: /users/{mbx}, not /me
params = {"$filter": "hasAttachments eq true and isRead eq false",
"$select": "id,subject,from,receivedDateTime,internetMessageId", "$top": "50"}
while url:
data = self.call("GET", url, params=params).json()
yield from data.get("value", [])
url, params = data.get("@odata.nextLink"), None # changed: follow paging
def file_attachments(self, message_id: str):
url = f"{GRAPH}/users/{self.mailbox}/messages/{message_id}/attachments"
data = self.call("GET", url, params={"$select": "id,name,contentType,size,isInline"}).json()
for att in data.get("value", []):
if att.get("@odata.type") != "#microsoft.graph.fileAttachment" or att.get("isInline"):
continue # changed: skip items and logos
raw = self.call("GET", f"{url}/{att['id']}/$value").content # changed: raw bytes, any size
yield att["name"], raw
def move(self, message_id: str, folder: str = "archive") -> str:
resp = self.call("POST", f"{GRAPH}/users/{self.mailbox}/messages/{message_id}/move",
json={"destinationId": folder})
return resp.json()["id"] # changed: id changes on move
def run() -> int:
graph, saved = Graph(), 0
OUT.mkdir(parents=True, exist_ok=True)
for msg in graph.unread_with_attachments():
for name, raw in graph.file_attachments(msg["id"]):
ext = Path(name).suffix.lower()
if ext not in ALLOWED_EXT:
continue
stem = re.sub(r"[^\w\-. ]+", "_", Path(name).stem)[:80]
dest = OUT / f"{msg['receivedDateTime'][:10]}_{hashlib.sha256(raw).hexdigest()[:10]}_{stem}{ext}"
if not dest.exists():
tmp = dest.with_suffix(dest.suffix + ".part")
tmp.write_bytes(raw)
tmp.replace(dest)
saved += 1
graph.move(msg["id"])
return saved
if __name__ == "__main__":
try:
print(f"saved {run()} attachment(s)")
except (requests.HTTPError, RuntimeError) as exc:
raise SystemExit(f"intake failed: {exc}")
Downloading through /$value returns the file bytes directly, which avoids the base64 contentBytes property that is not reliably included in list responses and becomes impractical for large files. Moving to the well-known archive folder keeps the inbox as a queue of unprocessed mail; use a folder id from /mailFolders for a custom Processed folder.
Variant Fix 1: Certificates Instead of Client Secrets
Client secrets expire (at most two years) and are easy to leak into logs or repositories. Certificate credentials are the recommended option for production jobs:
# pip install msal cryptography python-dotenv
import os
from pathlib import Path
import msal
from cryptography import x509
from cryptography.hazmat.primitives import hashes
def cert_app() -> msal.ConfidentialClientApplication:
pem_key = Path(os.environ["AZURE_CERT_KEY_PATH"]).read_text()
cert = x509.load_pem_x509_certificate(Path(os.environ["AZURE_CERT_PATH"]).read_bytes())
thumbprint = cert.fingerprint(hashes.SHA1()).hex()
return msal.ConfidentialClientApplication(
os.environ["AZURE_CLIENT_ID"],
authority=f"https://login.microsoftonline.com/{os.environ['AZURE_TENANT_ID']}",
client_credential={"private_key": pem_key, "thumbprint": thumbprint},
)
Upload the public certificate to the app registration, keep the private key readable only by the service account that runs the job, and record the certificate's expiry date in your monitoring so renewal is scheduled rather than discovered.
Variant Fix 2: Only Fetch What Is New Since Last Run
Filtering on isRead eq false depends on nobody reading the intake mailbox by hand. A delta query tracks changes on the server side and returns only messages added since the previous call, independent of read state:
# pip install msal requests
from pathlib import Path
DELTA_STATE = Path("state/graph-delta-link.txt")
def new_messages(graph) -> list[dict]:
url = DELTA_STATE.read_text().strip() if DELTA_STATE.exists() else (
f"{GRAPH}/users/{graph.mailbox}/mailFolders/inbox/messages/delta"
"?$select=id,subject,hasAttachments,receivedDateTime")
found = []
while url:
data = graph.call("GET", url).json()
found += [m for m in data.get("value", []) if m.get("hasAttachments") and "@removed" not in m]
if "@odata.deltaLink" in data:
DELTA_STATE.parent.mkdir(parents=True, exist_ok=True)
DELTA_STATE.write_text(data["@odata.deltaLink"]) # resume point for the next run
break
url = data.get("@odata.nextLink")
return found
Save the delta link only after the messages it returned have been processed successfully; otherwise a crash between saving the link and saving the files skips those messages permanently.
Verification
Check the permission scoping as well as the download. An intake job that can read other mailboxes is a finding in any security review, even if it never does.
# pip install msal requests python-dotenv
import requests
def verify_scope(graph, other_mailbox: str) -> None:
ok = graph.call("GET", f"{GRAPH}/users/{graph.mailbox}/mailFolders/inbox", params={"$select": "id"})
assert ok.status_code == 200, "cannot read the intake mailbox"
try:
graph.session.get(f"{GRAPH}/users/{other_mailbox}/mailFolders/inbox",
headers={"Authorization": f"Bearer {graph._token()}"},
timeout=30).raise_for_status()
except requests.HTTPError as exc:
assert exc.response.status_code in (403, 404), f"unexpected status {exc.response.status_code}"
print(f"scope verified: intake mailbox readable, {other_mailbox} denied")
return
raise AssertionError(f"app can read {other_mailbox}: restrict it with an application role scope")
if __name__ == "__main__":
verify_scope(Graph(), "[email protected]")
Run the scope check after any permission change and quarterly. Pair it with the file checks from Processing Email Attachments Automatically — magic bytes, no leftover .part files — to verify the download side.
FAQ
Can I use delegated permissions and my own account instead? For a script someone runs interactively, yes, with a device-code or interactive login. Unattended scheduled jobs should use an application identity so they do not depend on a person's password, MFA prompt or employment.
Why do some attachments have no $value?
Item attachments (attached emails, calendar items) and reference attachments (cloud links) are not files. Handle #microsoft.graph.itemAttachment separately with ?$expand=microsoft.graph.itemattachment/item, and follow reference attachments' sourceUrl with appropriate permissions.
How large can attachments be?
Exchange Online allows messages up to the tenant's configured limit, often 25–150 MB. /$value streams them; pass stream=True to requests and write in chunks for the largest files.
Does moving a message break my state tracking?
It changes the Graph id. Track messages by internetMessageId, which stays constant, as the IMAP intake does with Message-ID.
Related
- Processing Email Attachments Automatically — safe saving, state and filtering
- Fix IMAP Authentication Failed Error — XOAUTH2 when IMAP must be kept
- Process Invoice PDFs from Email into Excel — what happens to the downloaded files next
- Run Python Automation with a GitHub Actions Schedule — running the intake with stored secrets