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.
- Register an app in the Microsoft Entra admin center and leave the redirect URI empty
- Under Authentication > Advanced settings, set Allow public client flows to Yes and save
- Put the client ID and tenant ID in the CLIENT_ID and TENANT_ID environment variables, not in the code
- Install azure-identity and msgraph-sdk with pip and run the script
- Open the URL it prints, enter the code, and complete sign-in and consent
- Once your display name appears you are set; later examples change only SCOPES
"""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())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.
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.
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.
- Set SCOPES to Mail.ReadBasic; it cannot read body, previewBody or attachments
- Pass select, filter and top in MessagesRequestBuilderGetQueryParameters
- Address the Inbox with by_mail_folder_id("inbox")
- Turn each Message into a dict with to_row and pass the list to count_by_sender
- Print senders by count and have a person check for unanswered mail
"""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)]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.
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.
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.
- Get the first page with $select and $top (the default is 10 messages)
- If the response has odata_next_link, pass that URL to with_url(...).get() for the next page
- Don't pull $skip out of the URL and rebuild it; the official page warns against this
- Stop with MAX_PAGES; a large $top with many properties can end in HTTP 504
- Group by the request ID in the subject (the R- number) with group_by_request and write out_requests.csv
"""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: 18Write 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.
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.
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.
- Pass START_DATE and END_DATE (YYYY-MM-DD) as environment variables; without them the last 7 days are used
- utc_range turns local midnight into UTC and builds the ge/lt expression (the end date is excluded)
- Sort with orderby receivedDateTime desc; because the same property is in $filter, you avoid the InefficientFilter error
- Follow odata_next_link to collect the period's mail
- Count per local date with per_local_day and print the result
"""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}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.
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.
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.
- week_range turns local midnight plus one week into UTC values; values without an offset are read as UTC
- Pass start_date_time, end_date_time and select in CalendarViewRequestBuilderGetQueryParameters
- Without Prefer: outlook.timezone, start and end come back in UTC; this code converts them locally
- Follow odata_next_link to collect the week's events
- hours_per_day skips all-day and cancelled events and totals hours per local date
"""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}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.
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.
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.
- On the first run call mail_folders.by_mail_folder_id("inbox").messages.delta.get() with $select
- Prefer: odata.maxpagesize sets the maximum number of messages per page
- While odata_next_link is present, continue with with_url; an odata_delta_link ends that round
- Save odata_delta_link and the collected items to out_delta_state.json
- Next time pass the saved delta_link to with_url and drop items marked @removed from the local list
"""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: 23Write 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.
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.
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.
- Use two scopes: Mail.ReadBasic to read and Mail.Send to send
- Fetch unread mail and build the body text with build_summary
- DRY_RUN defaults to 1; check the printed recipient and body
- To send, put the address in REPORT_TO and run with DRY_RUN=0
- Wrap a Message in SendMailPostRequestBody and pass it to me.send_mail.post; success returns 202 Accepted
"""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]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.
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.
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.