Microsoft Graph in Practice #4: Automating Mail and Calendar — Reading, Sending, Creating Events
Time to stack the first real scenario on part 3’s skeleton. Mail and calendar carry the largest share of Graph automation demand: sending the morning report, watching a mailbox to collect attachments, booking meetings automatically. The examples assume unattended automation with the application-permission client; switching to delegated just means replacing users.by_user_id(...) with me, per part 3’s mapping rules.
Permissions first: this part uses Mail.Read for reading, Mail.Send for sending, Calendars.ReadWrite for calendars, and Calendars.Read for availability (getSchedule also accepts Schedule.Read.All). As application permissions, all of these need admin consent — and per part 2, constraining reachable mailboxes to a few automation accounts via ApplicationAccessPolicy is the safe configuration.
Reading the inbox — with filters #
The base form of mail queries: “mail from the last 24 hours whose subject starts with [DailyReport].”
from datetime import datetime, timedelta, timezone
from msgraph.generated.users.item.messages.messages_request_builder import (
MessagesRequestBuilder,
)
async def recent_reports(client, user_id: str):
since = (datetime.now(timezone.utc) - timedelta(days=1)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
params = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
filter=f"receivedDateTime ge {since} and startsWith(subject, '[DailyReport]')",
select=["subject", "from", "receivedDateTime", "hasAttachments"],
orderby=["receivedDateTime desc"],
top=50,
)
config = MessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration(
query_parameters=params,
)
result = await client.users.by_user_id(user_id).messages.get(
request_configuration=config
)
return result.value or []Two working notes. First, the default query target is the whole mailbox; to read only the inbox, route through the folder: mail_folders.by_mail_folder_id("inbox").messages. Second, the body field is large — leave body out of $select at the list stage and fetch it individually for the messages you selected. The two-stage pattern wins on both throttling and speed.
Attachment collection takes one more step: for messages with hasAttachments, query messages/{id}/attachments; regular file attachments (fileAttachment) carry their content Base64-encoded in contentBytes — decode and save.
Sending mail — sendMail does it in one call #
Draft-then-send exists, but 99% of automation only needs the one-shot sendMail.
from msgraph.generated.models.message import Message
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.recipient import Recipient
from msgraph.generated.models.email_address import EmailAddress
from msgraph.generated.users.item.send_mail.send_mail_post_request_body import (
SendMailPostRequestBody,
)
def to_recipient(addr: str) -> Recipient:
return Recipient(email_address=EmailAddress(address=addr))
async def send_report(client, sender_id: str, to: list[str], html: str):
body = SendMailPostRequestBody(
message=Message(
subject="[Automated] Weekly deployment status",
body=ItemBody(content_type=BodyType.Html, content=html),
to_recipients=[to_recipient(a) for a in to],
),
save_to_sent_items=True,
)
await client.users.by_user_id(sender_id).send_mail.post(body)With application permissions, sender_id can be any user — but the standard practice is a dedicated automation account (or shared mailbox) like noreply@... that all automated mail goes through. Send from a human account and replies and out-of-office messages pile into that person’s mailbox — and from an audit standpoint, mixing automated and human sending is exactly what you avoid. Attachments go in message.attachments as fileAttachment (name + Base64 content); attachments beyond 3MB need a separate upload-session-style procedure like the one part 5 covers for files.
Bulk sending deserves caution. Exchange enforces per-account sending limits (on the order of ten thousand recipients per day) and recipient caps, and a loop mailing hundreds of people is also where you first hit part 7’s throttling (429). Newsletter-scale mail belongs to dedicated sending services, not Graph — a boundary worth drawing now.
Creating events — invitations included #
The base form of calendar writes is event creation. Add attendees and Exchange sends the invitations for you.
from msgraph.generated.models.event import Event
from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone
from msgraph.generated.models.attendee import Attendee
from msgraph.generated.models.attendee_type import AttendeeType
async def create_meeting(client, organizer_id: str):
event = Event(
subject="Deployment retrospective",
start=DateTimeTimeZone(
date_time="2026-08-20T10:00:00", time_zone="Asia/Seoul"
),
end=DateTimeTimeZone(
date_time="2026-08-20T11:00:00", time_zone="Asia/Seoul"
),
attendees=[
Attendee(
email_address=EmailAddress(address="dev-team@contoso.com"),
type=AttendeeType.Required,
),
],
is_online_meeting=True, # auto-creates a Teams meeting link
online_meeting_provider="teamsForBusiness",
)
return await client.users.by_user_id(organizer_id).events.post(event)The two extensions you’ll reach for most: is_online_meeting=True, which attaches a Teams link in one line, and the recurrence property for repeating meetings.
Time zones — the calendar automation trap #
Most calendar bugs are time zone bugs. Two rules prevent nearly all of them.
- When writing, always pair
dateTime + timeZoneexplicitly, as above. The “it’ll be local time” assumption shifts everything by nine hours the moment the code runs on a UTC server. - When reading, either expect UTC (Graph’s default) or send the
Prefer: outlook.timezone="Asia/Seoul"header to receive values converted to your zone. For automations that show results directly to people (daily agenda digests), the latter is easier.
And when querying by date range, use calendarView, not events: calendarView?startDateTime=...&endDateTime=... expands recurring events into actual occurrences. events returns a recurring meeting as one definition — which is how weekly summaries silently lose every recurring meeting.
Finding free time — getSchedule #
The raw material for “find a time everyone can make” is getSchedule, which returns free/busy for multiple users at once (meeting rooms have mail addresses too, so the same call covers rooms).
from msgraph.generated.users.item.calendar.get_schedule.get_schedule_post_request_body import (
GetSchedulePostRequestBody,
)
async def check_availability(client, user_id: str, emails: list[str]):
body = GetSchedulePostRequestBody(
schedules=emails,
start_time=DateTimeTimeZone(
date_time="2026-08-20T09:00:00", time_zone="Asia/Seoul"
),
end_time=DateTimeTimeZone(
date_time="2026-08-20T18:00:00", time_zone="Asia/Seoul"
),
availability_view_interval=30, # 30-minute slots
)
result = await client.users.by_user_id(user_id).calendar.get_schedule.post(body)
for sched in result.value:
# availability_view: "0"=free "1"=tentative "2"=busy "3"=OOF as a string
print(sched.schedule_id, sched.availability_view)The response’s availabilityView is a digit string per interval (30 minutes above) — something like "002200...". Find the positions where every attendee’s string reads 0, and those are the common free slots. A few lines of string handling gets you a meeting-time recommender — one of Graph’s best effort-to-value APIs.
Summary #
- The base permission set is Mail.Read / Mail.Send / Calendars.ReadWrite; with application permissions, narrow the reach to automation accounts via ApplicationAccessPolicy.
- Mail lists: go through folders, keep body out of
$select, fetch details in a second pass. Sending is one sendMail call, always from a dedicated automation account. - Bulk sending is a minefield of Exchange limits and throttling. Newsletter-scale mail is not Graph’s job.
- Calendars: always pair dateTime+timeZone, read with
Prefer: outlook.timezone, and query ranges with calendarView, not events. Most recurring-event incidents trace to these three. - Overlay getSchedule’s availabilityView strings to find “when everyone is free” — rooms included.
Next: files. The drive/site structure of OneDrive and SharePoint, large-file upload sessions, and sharing links.