01Sign in with the device code flow and create a Graph clientVerified

Use DeviceCodeCredential from azure-identity with GraphServiceClient from msgraph-sdk so a script calls Microsoft Graph as the signed-in user. Reading /me first confirms that sign-in and consent work.

Approach and steps
  1. Register an app in the Microsoft Entra admin center and leave the redirect URI empty
  2. Under Authentication > Advanced settings, set Allow public client flows to Yes and save
  3. Put the client ID and tenant ID in the CLIENT_ID and TENANT_ID environment variables, not in the code
  4. Install azure-identity and msgraph-sdk with pip and run the script
  5. Open the URL it prints, enter the code, and complete sign-in and consent
  6. Once your display name appears you are set; later examples change only SCOPES
PythonSign-in required (Microsoft Entra)
"""Sign in with the device code flow and read your own profile (User.Read)."""
import asyncio
import os

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.user_item_request_builder import UserItemRequestBuilder

SCOPES = ["User.Read"]


def make_client(scopes):
    # Public client app registration; IDs come from environment variables
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    return GraphServiceClient(credentials=cred, scopes=scopes)


async def main():
    client = make_client(SCOPES)
    query = UserItemRequestBuilder.UserItemRequestBuilderGetQueryParameters(
        select=["displayName", "mail", "userPrincipalName"])
    me = await client.me.get(request_configuration=RequestConfiguration(query_parameters=query))
    print("signed in as:", me.display_name, "/", me.mail or me.user_principal_name)


if __name__ == "__main__":
    asyncio.run(main())
Example instruction for Copilot

Write an async Python script that uses DeviceCodeCredential from azure-identity and GraphServiceClient from msgraph-sdk to print displayName and mail from /me. Read the client ID and tenant ID from the CLIENT_ID and TENANT_ID environment variables and request only User.Read.

Caution

Tokens live only in memory here, so every run asks you to sign in again; token caching is covered on the security page. Some organizations don't allow user consent, in which case ask an admin. Agree the app registration name and its permissions with your admins.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). A work or school account with an Exchange Online mailbox. Checked 2026-09-12. The tutorial was written with Python 3.10.4.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
User.Read (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365) (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "Build Python apps with Microsoft Graph"
Microsoft Learn, "Create a Microsoft Graph client"
Microsoft Learn, "Get a user"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Select Authentication under Manage. Locate the Advanced settings section and change the Allow public client flows toggle to Yes, then choose Save.
Include import statements for DeviceCodeCredential from azure.identity and GraphServiceClient from msgraph.graph_service_client to run this code.
Notice that you didn't configure any Microsoft Graph permissions on the app registration. The sample uses dynamic consent to request specific permissions for user authentication.

02Count unread Inbox mail per sender with $select and $filterVerified

Query Inbox messages with $filter=isRead eq false and $select so only subject, sender and received time come back. Because no body is read, the least privileged Mail.ReadBasic is enough. Counting lives in its own function, checked against sample JSON.

Approach and steps
  1. Set SCOPES to Mail.ReadBasic; it cannot read body, previewBody or attachments
  2. Pass select, filter and top in MessagesRequestBuilderGetQueryParameters
  3. Address the Inbox with by_mail_folder_id("inbox")
  4. Turn each Message into a dict with to_row and pass the list to count_by_sender
  5. Print senders by count and have a person check for unanswered mail
PythonSign-in required (Microsoft Entra)
"""Count unread Inbox messages per sender using $select, $filter and $top (Mail.ReadBasic)."""
import asyncio
import os
from collections import Counter

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
    MessagesRequestBuilder,
)

SCOPES = ["Mail.ReadBasic"]  # subject, sender, dates; no body


def to_row(m):
    sender = m.from_.email_address.address if m.from_ and m.from_.email_address else ""
    return {"from": sender, "subject": m.subject or "", "received": m.received_date_time}


def count_by_sender(rows):
    return Counter(r["from"] for r in rows)


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
        select=["subject", "from", "receivedDateTime"],
        filter="isRead eq false",
        top=50,
    )
    page = await client.me.mail_folders.by_mail_folder_id("inbox").messages.get(
        request_configuration=RequestConfiguration(query_parameters=query))
    rows = [to_row(m) for m in page.value or []]
    print("unread on the first page:", len(rows))
    for sender, n in count_by_sender(rows).most_common(10):
        print(n, sender)


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
[('[email protected]', 10), ('[email protected]', 8), ('[email protected]', 6)]
Example instruction for Copilot

Write Python with msgraph-sdk that fetches unread Inbox messages and counts them per sender address. $select only subject, from and receivedDateTime, and use Mail.ReadBasic. Put the counting in a function that doesn't call Graph so it can be tested with dicts shaped like messages.json.

Caution

This counts only the first page; combine it with the paging example to count everything. Properties not in $select come back as None. Mail.ReadBasic cannot return bodies, so it doesn't suit tasks that inspect message text. Sender addresses are personal data, so decide who may see the tally.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). A work or school account with an Exchange Online mailbox. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Mail.ReadBasic (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "List messages"
Microsoft Learn, "Microsoft Graph permissions reference"
Microsoft Learn, "Use the $filter query parameter"
Verified
2026-09-12 (v1)
Supporting passages from the sources
To improve the operation response time, use $select to specify the exact properties you need
Allows the app to read email in the signed-in user's mailbox except body, previewBody, attachments and any extended properties.
GET ~/me/mailFolders/inbox/messages?$filter=isRead eq false

03Follow @odata.nextLink through every page and group mail by request IDVerified

Messages come 10 per page by default, adjustable with $top between 1 and 1000. Fetch the next page by passing odata_next_link unchanged to with_url. Mail is grouped by the request ID in the subject, and counts, replies and the latest received time go to a CSV.

Approach and steps
  1. Get the first page with $select and $top (the default is 10 messages)
  2. If the response has odata_next_link, pass that URL to with_url(...).get() for the next page
  3. Don't pull $skip out of the URL and rebuild it; the official page warns against this
  4. Stop with MAX_PAGES; a large $top with many properties can end in HTTP 504
  5. Group by the request ID in the subject (the R- number) with group_by_request and write out_requests.csv
PythonSign-in required (Microsoft Entra)
"""Fetch every Inbox page via @odata.nextLink and group messages by the request ID in the subject."""
import asyncio
import csv
import os
import re
from collections import defaultdict

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
    MessagesRequestBuilder,
)

SCOPES = ["Mail.ReadBasic"]
MAX_PAGES = int(os.environ.get("MAX_PAGES", "20"))
REQUEST_ID = re.compile(r"R-\d{4}-\d{3}")


def to_row(m):
    return {"subject": m.subject or "", "received": str(m.received_date_time or "")}


def group_by_request(rows):
    groups = defaultdict(list)
    for r in rows:
        hit = REQUEST_ID.search(r["subject"])
        groups[hit.group(0) if hit else "(none)"].append(r)
    out = []
    for rid, items in sorted(groups.items()):
        replies = sum(1 for r in items if r["subject"].lower().startswith("re:"))
        out.append({"request_id": rid, "messages": len(items), "replies": replies,
                    "latest": max(r["received"] for r in items)})
    return out


async def fetch_all(client):
    builder = client.me.mail_folders.by_mail_folder_id("inbox").messages
    query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
        select=["subject", "receivedDateTime"], top=100)
    page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
    rows, pages = [], 0
    while page:
        rows += [to_row(m) for m in page.value or []]
        pages += 1
        if not page.odata_next_link or pages >= MAX_PAGES:
            break
        # Use the whole nextLink as-is; it already carries $select and $top
        page = await builder.with_url(page.odata_next_link).get()
    return rows, pages


def write_csv(summary, path="out_requests.csv"):
    with open(path, "w", encoding="utf-8-sig", newline="") as f:
        w = csv.DictWriter(f, fieldnames=["request_id", "messages", "replies", "latest"])
        w.writeheader()
        w.writerows(summary)


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    rows, pages = await fetch_all(client)
    summary = group_by_request(rows)
    write_csv(summary)
    print("pages:", pages, "messages:", len(rows), "request IDs:", len(summary))


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
{'request_id': '(none)', 'messages': 5, 'replies': 0, 'latest': '2026-09-07T17:00:00Z'}
{'request_id': 'R-2026-017', 'messages': 2, 'replies': 0, 'latest': '2026-09-06T20:00:00Z'}
{'request_id': 'R-2026-020', 'messages': 2, 'replies': 0, 'latest': '2026-09-03T01:00:00Z'}
request IDs: 18
Example instruction for Copilot

Write Python with msgraph-sdk that reads Inbox messages until odata_next_link runs out, then writes a CSV per request ID (R-, 4 digits, -, 3 digits) in the subject: message count, replies starting with Re:, and latest received time. Add a page limit that can be changed through an environment variable.

Caution

Counts reflect the moment you fetched them. msgraph-core also has a PageIterator class, but the official page saved for this tip has no Python example, so this uses nextLink. Adjust the request ID pattern to your own numbering. The CSV contains subjects, so mind who can open where you save it.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). A work or school account with an Exchange Online mailbox. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Mail.ReadBasic (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "List messages"
Microsoft Learn, "Paging Microsoft Graph data in your app"
Verified
2026-09-12 (v1)
Supporting passages from the sources
To get the next page of messages, simply apply the entire URL returned in @odata.nextLink to the next get-messages request.
Don't try to extract the $skip value from the @odata.nextLink URL to manipulate responses.
Use the entire URL in the @odata.nextLink property in a GET request to retrieve the next page of results.

04Filter by a receivedDateTime range and count mail per local dayVerified

Put receivedDateTime ge and lt in $filter to fetch only mail from a chosen period, converting local midnight boundaries to UTC. When you sort by receivedDateTime, the same property must also appear in $filter.

Approach and steps
  1. Pass START_DATE and END_DATE (YYYY-MM-DD) as environment variables; without them the last 7 days are used
  2. utc_range turns local midnight into UTC and builds the ge/lt expression (the end date is excluded)
  3. Sort with orderby receivedDateTime desc; because the same property is in $filter, you avoid the InefficientFilter error
  4. Follow odata_next_link to collect the period's mail
  5. Count per local date with per_local_day and print the result
PythonSign-in required (Microsoft Entra)
"""Count Inbox mail per local day for a receivedDateTime range ($filter ge/lt with $orderby)."""
import asyncio
import os
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
    MessagesRequestBuilder,
)

SCOPES = ["Mail.ReadBasic"]
TZ = timezone(timedelta(hours=float(os.environ.get("UTC_OFFSET_HOURS", "9"))))
MAX_PAGES = int(os.environ.get("MAX_PAGES", "20"))


def utc_range(start, end):
    """Local dates [start, end) -> UTC strings for $filter."""
    lo = datetime.combine(start, time.min, TZ).astimezone(timezone.utc)
    hi = datetime.combine(end, time.min, TZ).astimezone(timezone.utc)
    return lo.strftime("%Y-%m-%dT%H:%M:%SZ"), hi.strftime("%Y-%m-%dT%H:%M:%SZ")


def per_local_day(received):
    counts = Counter()
    for v in received:
        if isinstance(v, str):
            v = datetime.fromisoformat(v.replace("Z", "+00:00"))
        counts[v.astimezone(TZ).date().isoformat()] += 1
    return dict(sorted(counts.items()))


def period():
    end = date.fromisoformat(os.environ["END_DATE"]) if os.environ.get("END_DATE") else date.today() + timedelta(days=1)
    start = date.fromisoformat(os.environ["START_DATE"]) if os.environ.get("START_DATE") else end - timedelta(days=7)
    return start, end


async def main():
    lo, hi = utc_range(*period())
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    builder = client.me.mail_folders.by_mail_folder_id("inbox").messages
    query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
        select=["receivedDateTime"],
        filter="receivedDateTime ge %s and receivedDateTime lt %s" % (lo, hi),
        orderby=["receivedDateTime desc"],  # same property as in $filter
        top=100,
    )
    page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
    received, pages = [], 0
    while page:
        received += [m.received_date_time for m in page.value or []]
        pages += 1
        if not page.odata_next_link or pages >= MAX_PAGES:
            break
        page = await builder.with_url(page.odata_next_link).get()
    print("period (UTC):", lo, "to", hi, "messages:", len(received))
    for day, n in per_local_day(received).items():
        print(day, n)


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
('2026-08-31T15:00:00Z', '2026-09-07T15:00:00Z')
{'2026-09-01': 3, '2026-09-02': 3, '2026-09-03': 3, '2026-09-04': 4, '2026-09-05': 3, '2026-09-06': 4, '2026-09-07': 3, '2026-09-08': 1}
Example instruction for Copilot

Write Python with msgraph-sdk that fetches Inbox mail received in a local date range using receivedDateTime ge/lt in $filter, and prints counts per local date. Keep the function that converts the date boundaries to UTC and the per-day counter free of Graph calls.

Caution

UTC_OFFSET_HOURS is a fixed offset; use zoneinfo where daylight saving time applies. Changing how $filter and $orderby combine can bring back the InefficientFilter error. Counts cover only mail still in the Inbox, not messages moved or deleted.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). A work or school account with an Exchange Online mailbox. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Mail.ReadBasic (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "List messages"
Microsoft Learn, "Use the $filter query parameter"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Properties that appear in $orderby must also appear in $filter.
GET ~/me/mailFolders/inbox/messages?$filter=ReceivedDateTime ge 2017-04-01 and receivedDateTime lt 2017-05-01
Error message: The restriction or sort order is too complex for this operation.

05Expand a week with calendarView and total meeting hours per dayVerified

calendarView returns the occurrences, exceptions and single instances of events inside the time range you give. Pass startDateTime and endDateTime, skip all-day and cancelled events, and total meeting hours per day. The least privileged Calendars.ReadBasic is enough.

Approach and steps
  1. week_range turns local midnight plus one week into UTC values; values without an offset are read as UTC
  2. Pass start_date_time, end_date_time and select in CalendarViewRequestBuilderGetQueryParameters
  3. Without Prefer: outlook.timezone, start and end come back in UTC; this code converts them locally
  4. Follow odata_next_link to collect the week's events
  5. hours_per_day skips all-day and cancelled events and totals hours per local date
PythonSign-in required (Microsoft Entra)
"""Total meeting hours per local day for one week from calendarView (Calendars.ReadBasic)."""
import asyncio
import os
from collections import defaultdict
from datetime import date, datetime, time, timedelta, timezone

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.calendar_view.calendar_view_request_builder import (
    CalendarViewRequestBuilder,
)

SCOPES = ["Calendars.ReadBasic"]
TZ = timezone(timedelta(hours=float(os.environ.get("UTC_OFFSET_HOURS", "9"))))
MAX_PAGES = int(os.environ.get("MAX_PAGES", "20"))


def week_range(first_day):
    """Local midnight of first_day plus 7 days, as UTC values without an offset."""
    lo = datetime.combine(first_day, time.min, TZ).astimezone(timezone.utc)
    hi = lo + timedelta(days=7)
    return lo.strftime("%Y-%m-%dT%H:%M:%S"), hi.strftime("%Y-%m-%dT%H:%M:%S")


def to_row(e):
    return {"subject": e.subject or "", "start": e.start.date_time, "end": e.end.date_time,
            "all_day": bool(e.is_all_day), "cancelled": bool(e.is_cancelled)}


def parse_utc(s):
    # e.g. 2026-09-14T00:30:00.0000000 (UTC when no Prefer header is sent)
    return datetime.fromisoformat(s[:19]).replace(tzinfo=timezone.utc)


def hours_per_day(rows):
    hours = defaultdict(float)
    for r in rows:
        if r["all_day"] or r["cancelled"]:
            continue
        start, end = parse_utc(r["start"]).astimezone(TZ), parse_utc(r["end"]).astimezone(TZ)
        hours[start.date().isoformat()] += (end - start).total_seconds() / 3600
    return {d: round(h, 2) for d, h in sorted(hours.items())}


async def main():
    first = date.fromisoformat(os.environ.get("START_DATE", date.today().isoformat()))
    lo, hi = week_range(first)
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    builder = client.me.calendar_view
    query = CalendarViewRequestBuilder.CalendarViewRequestBuilderGetQueryParameters(
        start_date_time=lo, end_date_time=hi,
        select=["subject", "start", "end", "isAllDay", "isCancelled"], top=100)
    page = await builder.get(request_configuration=RequestConfiguration(query_parameters=query))
    rows, pages = [], 0
    while page:
        rows += [to_row(e) for e in page.value or []]
        pages += 1
        if not page.odata_next_link or pages >= MAX_PAGES:
            break
        page = await builder.with_url(page.odata_next_link).get()
    print("events:", len(rows))
    for day, h in hours_per_day(rows).items():
        print(day, h, "h")


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
('2026-09-13T15:00:00', '2026-09-20T15:00:00')
{'2026-09-14': 2.5, '2026-09-15': 1.0, '2026-09-16': 3.5, '2026-09-18': 1.0}
Example instruction for Copilot

Write Python with msgraph-sdk calendar_view that reads one week of events from a given date, skips all-day and cancelled ones, and totals meeting hours per day. Use Calendars.ReadBasic. Receive times in UTC, and keep the conversion to local time and the totalling in functions that don't call Graph.

Caution

Calendars.ReadBasic cannot read bodies, attachments or extensions; consider a higher permission only if you truly need meeting content. Events that cross midnight are counted on their start date. Work that isn't on the calendar doesn't show up, so treat the result as a rough guide.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). A work or school account with an Exchange Online mailbox. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Calendars.ReadBasic (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "List calendarView"
Microsoft Learn, "Microsoft Graph permissions reference"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Get the occurrences, exceptions, and single instances of events in a calendar view defined by a time range
If no timezone offset is included in the value, it is interpreted as UTC.
Use this to specify the time zone for start and end times in the response. If not specified, those time values are returned in UTC.
Allows the app to read events in user calendars, except for properties such as body, attachments, and extensions.

06Pick up only what changed since last run with the messages delta functionVerified

The first call to messages/delta on the Inbox returns every message, and the last page carries @odata.deltaLink. Save that URL and call it next time to get only additions, updates and deletions. Changes are tracked folder by folder.

Approach and steps
  1. On the first run call mail_folders.by_mail_folder_id("inbox").messages.delta.get() with $select
  2. Prefer: odata.maxpagesize sets the maximum number of messages per page
  3. While odata_next_link is present, continue with with_url; an odata_delta_link ends that round
  4. Save odata_delta_link and the collected items to out_delta_state.json
  5. Next time pass the saved delta_link to with_url and drop items marked @removed from the local list
PythonSign-in required (Microsoft Entra)
"""Incremental Inbox sync with messages/delta; the deltaLink is kept in a local state file."""
import asyncio
import json
import os
from pathlib import Path

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.item.mail_folders.item.messages.delta.delta_request_builder import (
    DeltaRequestBuilder,
)

SCOPES = ["Mail.ReadBasic"]
STATE = Path(os.environ.get("DELTA_STATE", "out_delta_state.json"))


def to_row(m):
    if "@removed" in (m.additional_data or {}):
        return {"id": m.id, "removed": True}
    sender = m.from_.email_address.address if m.from_ and m.from_.email_address else ""
    return {"id": m.id, "subject": m.subject or "", "from": sender, "is_read": bool(m.is_read)}


def apply_changes(items, rows):
    added = updated = removed = 0
    for r in rows:
        if r.get("removed"):
            removed += 1 if items.pop(r["id"], None) is not None else 0
        elif r["id"] in items:
            items[r["id"]] = r
            updated += 1
        else:
            items[r["id"]] = r
            added += 1
    return {"added": added, "updated": updated, "removed": removed}


def load_state():
    if STATE.exists():
        return json.loads(STATE.read_text(encoding="utf-8"))
    return {"delta_link": None, "items": {}}


async def sync(client, state):
    builder = client.me.mail_folders.by_mail_folder_id("inbox").messages.delta
    if state["delta_link"]:
        page = await builder.with_url(state["delta_link"]).get()
    else:
        query = DeltaRequestBuilder.DeltaRequestBuilderGetQueryParameters(select=["subject", "from", "isRead"])
        config = RequestConfiguration(query_parameters=query)
        config.headers.add("Prefer", "odata.maxpagesize=50")
        page = await builder.get(request_configuration=config)
    rows = []
    while page:
        rows += [to_row(m) for m in page.value or []]
        if page.odata_next_link:
            page = await builder.with_url(page.odata_next_link).get()
            continue
        state["delta_link"] = page.odata_delta_link  # end of this round
        break
    return rows


async def main():
    state = load_state()
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    stats = apply_changes(state["items"], await sync(client, state))
    STATE.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
    print("changes:", stats, "items kept:", len(state["items"]))


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
{'added': 24, 'updated': 0, 'removed': 0}
{'added': 0, 'updated': 1, 'removed': 1} kept: 23
Example instruction for Copilot

Write Python that syncs the Inbox with msgraph-sdk messages.delta: fetch everything on the first run, save odata_delta_link to a JSON file, and fetch only changes from that URL next time. Remove items marked @removed from a local dict and print added, updated and removed counts. Use Mail.ReadBasic.

Caution

Delta is a per-folder operation; a folder hierarchy has to be tracked folder by folder. The saved delta_link can be used to read that mailbox, so don't share it. Deleting the state file means the next run starts over with a full sync. Check the @removed reason before deciding what to do locally.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). A work or school account with an Exchange Online mailbox. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Mail.ReadBasic (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "Get incremental changes to messages in a folder"
Microsoft Learn, "message: delta"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Delta query is a per-folder operation. To track the changes of the messages in a folder hierarchy, you need to track each folder individually.
signifies that the current round of change tracking is complete.
the request header, Prefer: odata.maxpagesize={x}, to set the maximum number of messages in a response.

07Mail an unread-mail summary to yourself (DRY_RUN by default)Verified

Count unread mail per sender and send the result with sendMail. With the default DRY_RUN nothing is sent; the recipient and body are only printed. Set DRY_RUN=0 and REPORT_TO only when you want the mail to go out.

Approach and steps
  1. Use two scopes: Mail.ReadBasic to read and Mail.Send to send
  2. Fetch unread mail and build the body text with build_summary
  3. DRY_RUN defaults to 1; check the printed recipient and body
  4. To send, put the address in REPORT_TO and run with DRY_RUN=0
  5. Wrap a Message in SendMailPostRequestBody and pass it to me.send_mail.post; success returns 202 Accepted
PythonSign-in required (Microsoft Entra)
"""Mail yourself a summary of unread Inbox mail; DRY_RUN by default (Mail.ReadBasic + Mail.Send)."""
import asyncio
import os
from collections import Counter

from azure.identity import DeviceCodeCredential
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.email_address import EmailAddress
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.message import Message
from msgraph.generated.models.recipient import Recipient
from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import (
    MessagesRequestBuilder,
)
from msgraph.generated.users.item.send_mail.send_mail_post_request_body import SendMailPostRequestBody

SCOPES = ["Mail.ReadBasic", "Mail.Send"]
DRY_RUN = os.environ.get("DRY_RUN", "1") != "0"
REPORT_TO = os.environ.get("REPORT_TO", "")


def build_summary(rows):
    counts = Counter(r["from"] for r in rows)
    lines = ["Unread messages: %d" % len(rows), ""]
    lines += ["%3d  %s" % (n, sender) for sender, n in counts.most_common(10)]
    return "\n".join(lines)


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    query = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
        select=["from"], filter="isRead eq false", top=100)
    page = await client.me.mail_folders.by_mail_folder_id("inbox").messages.get(
        request_configuration=RequestConfiguration(query_parameters=query))
    rows = [{"from": m.from_.email_address.address if m.from_ and m.from_.email_address else ""}
            for m in page.value or []]
    text = build_summary(rows)
    if DRY_RUN or not REPORT_TO:
        print("DRY_RUN: would send to", REPORT_TO or "(set REPORT_TO)")
        print(text)
        return
    body = SendMailPostRequestBody(
        message=Message(
            subject="Unread mail summary",
            body=ItemBody(content_type=BodyType.Text, content=text),
            to_recipients=[Recipient(email_address=EmailAddress(address=REPORT_TO))],
        ),
        save_to_sent_items=True,
    )
    await client.me.send_mail.post(body)  # 202 Accepted: queued, not yet delivered
    print("sent to", REPORT_TO)


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
Unread messages: 24

 10  [email protected]
  8  [email protected]
  6  [email protected]
Example instruction for Copilot

Write Python with msgraph-sdk that counts unread mail per sender and sends that list with send_mail. Unless the DRY_RUN environment variable is 0, only print the recipient and body. Read the recipient from REPORT_TO, and build the body in a function that doesn't call Graph.

Caution

202 Accepted means the request was accepted, not that delivery finished. Sent mail is saved to Sent Items by default. Mail.Send can send even without Mail.ReadWrite. You can't unsend, so confirm the recipient in the DRY_RUN output first. Keep the recipients and frequency of automated mail within your organization's rules.

Availability
msgraph-sdk (tested 1.62.0) and azure-identity (tested 1.25.3). A work or school account with an Exchange Online mailbox. Checked 2026-09-12.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Mail.ReadBasic, Mail.Send (delegated) — writes data (does nothing by default: DRY_RUN)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "user: sendMail"
Microsoft Learn, "Microsoft Graph permissions reference"
Verified
2026-09-12 (v1)
Supporting passages from the sources
If successful, this method returns 202 Accepted response code. It doesn't return anything in the response body.
Note: A 202 Accepted response code indicates that the request has been accepted; however, it doesn't indicate that the request processing has completed.
With the Mail.Send permission, an app can send mail and save a copy to the user's Sent Items folder, even if the app isn't granted the Mail.ReadWrite or Mail.ReadWrite.Shared permission.