outlook_example.py
messages_update is PATCH /me/messages/{id}, which is how
Graph marks a message read, flags it, categorises it and edits a draft. Where an
operation is served at more than one path, or takes its body in more than one
format, it is still one tool — the difference is an optional argument, and the
model never chooses a URL.
Authenticating
Before the first call, Outlook needs a token from an app registered with Microsoft Entra. Microsoft issues no API key for mail. Register an app, grant it the delegated Graph permissions these tools ask for —Mail.ReadWrite, Mail.Send and MailboxSettings.ReadWrite cover every write — and sign in through it. To try a tool on your own mailbox first, Graph Explorer shows the signed-in account’s token on its Access token tab; export MICROSOFT_ACCESS_TOKEN=….
This pack takes an OAuth bearer token, and configure() is optional when $MICROSOFT_ACCESS_TOKEN is set. Which credential provider you hand it depends on whose account the calls run as.
A token you hold
For a script, or a notebook.EnvTokenProvider re-reads the variable on every call, so a token rotated beside the process is picked up without a restart; StaticTokenProvider takes one you already hold as a string. Neither renews anything, so the calls stop when the token expires.
outlook_script.py
One account, refreshed
For an agent or a server acting as you.OAuth2Client turns a client registration and a stored refresh token into an access token, and renews it before it lapses.
outlook_agent.py
No refresh token yet? Getting the grant is the one-time consent flow that hands you one.
Many end users
For a product whose users each connect their own account.SubjectProvider builds one credential per user through a factory you write, and use_subject names the user a call acts for. Your users’ accounts is the consent route inside your app; serving many users is the per-subject cache and its eviction.
The client is handed a function that reads the stored grant, not a copy of the refresh token. A server runs several workers, and a copy goes stale the first time another worker refreshes: More than one process.
outlook_server.py
/me.
Microsoft’s authorization server publishes its metadata, so the MICROSOFT
constant the snippets above name is one call —
OAuth2Server.discover against
https://login.microsoftonline.com/{tenant}/v2.0. Ask for offline_access
beside the Graph scopes, or Microsoft issues no refresh token.
The client
oauth_tool_factory is the whole client: a thin wrapper over httpx that attaches your token and these endpoint constants to each request. No vendor SDK enters your dependency tree.credentials is whichever of the three you built in Authenticating. A pack takes it through configure(); a client you build takes the same object as credential_provider, and has no configure() of its own.
Paging through a list
A cursor belongs to the tool that returns it, so it is declared on that tool’s builder call:outlook_pagination.py
Pagination is declared on
messages_list, messages_delta, attachments_list, mail_folders_list and mail_folders_delta. The other 51 take no cursor.Tools
Each is aTool, called with
ainvoke as in the snippet above. The name
links to its parameters, its response and what it costs.
Messagesmessages_listGETList or search messages, across the mailbox or in one folder (an ID, or a well-known name such as inbox or sentitems).messages_getGETRead one message: sender, recipients, subject, date and the body as plain text.messages_get_contentGETGet a message’s MIME content as text: every header, and each body part as encoded on the wire.messages_deltaGETTrack changes to the messages in one folder.messages_createPOSTSave a new draft, in Drafts unless folder_id names another folder, as a JSON message or as MIME. messages_send sends it later.messages_updatePATCHChange some of a message’s properties, leaving the rest: isRead, flag, importance, categories, and on a draft its recipients, subject and body.messages_deleteDELETEDelete a message or a draft.messages_permanent_deletePOSTPermanently delete a message.messages_movePOSTMove a message to another folder — archive, deleteditems, or any folder ID.messages_copyPOSTCopy a message into another folder, by folder ID or well-known name.messages_sendPOSTSend a saved draft — a new message, reply, reply-all or forward draft.messages_replyPOSTReply to the sender of a message right away.messages_reply_allPOSTReply to the sender and every recipient of a message right away, as JSON or MIME.messages_forwardPOSTForward a message right away.messages_create_replyPOSTCreate a draft reply to the sender of a message, to edit with messages_update and send with messages_send.messages_create_reply_allPOSTCreate a draft reply to the sender and every recipient of a message.messages_create_forwardPOSTCreate a draft forward of a message, to edit and send later.send_mailPOSTSend a new email right away, as a JSON message or as MIME.Attachmentsattachments_listGETList a message’s attachments: name, type, size and id, without the contents.attachments_getGETRead one attachment.attachments_get_contentGETGet an attachment’s raw contents: a file’s bytes (as text when they are text, otherwise base64), or an attached message’s MIME.attachments_createPOSTAttach a file (under 3 MB) or a message to a draft.attachments_create_upload_sessionPOSTStart attaching a file of 3 MB to 150 MB to a draft: returns the uploadUrl the file’s bytes are then PUT to, in ranges of up to 4 MB, before expirationDateTime.attachments_deleteDELETEDelete an attachment from a message or a draft.Foldersmail_folders_listGETList the folders under the mailbox root, or under parent_folder_id, with their unread and total counts.mail_folders_getGETRead one folder, by ID or well-known name, with its unread and total counts.mail_folders_createPOSTCreate a folder under the root or under parent_folder_id, or a search folder — a saved filter over folders you name.mail_folders_updatePATCHRename a folder or set its extended properties, or change a search folder’s source folders or filter.mail_folders_deleteDELETEDelete a folder, or a search folder, and what is in it.mail_folders_permanent_deletePOSTPermanently delete a folder and remove its items from the mailbox.mail_folders_movePOSTMove a folder and everything in it into another folder.mail_folders_copyPOSTCopy a folder and everything in it into another folder.mail_folders_deltaGETTrack changes to the mailbox’s folders.Categoriesmaster_categories_listGETList the categories defined for the mailbox, with their colors.master_categories_getGETRead one category.master_categories_createPOSTCreate a category.master_categories_updatePATCHChange a category’s color.master_categories_deleteDELETEDelete a category from the mailbox’s list.Inbox rulesmessage_rules_listGETList the Inbox rules, in the order they run.message_rules_getGETRead one Inbox rule: its conditions, exceptions and actions.message_rules_createPOSTCreate an Inbox rule: when an incoming message meets the conditions, and none of the exceptions, Outlook takes the actions.message_rules_updatePATCHChange some of an Inbox rule’s properties, leaving the rest as they are.message_rules_deleteDELETEDelete an Inbox rule.Mailbox settingsmailbox_settings_getGETRead the mailbox’s settings: automatic replies (out of office), time zone, working hours, language, and date and time formats.mailbox_settings_updatePATCHChange some of the mailbox’s settings, leaving the rest: automatic replies, time zone, working hours, language, formats.supported_languagesGETList the languages and locales the user’s mailbox server supports.supported_time_zonesGETList the time zones the user’s mailbox server supports, as Windows or IANA names.inference_overrides_listGETList the senders whose mail is always sorted into Focused or Other.Focused Inboxinference_overrides_createPOSTAlways sort a sender’s future mail into Focused or Other.inference_overrides_updatePATCHSwitch an override between Focused and Other.inference_overrides_deleteDELETERemove an override, so the sender’s mail is sorted automatically again.Extensions and extended propertiesextensions_getGETRead an open extension — an app’s own named data — stored on a message.extensions_createPOSTStore an open extension on a message: an extensionName and any custom name-value pairs of primitive values or arrays of them.extensions_deleteDELETEDelete an open extension from a message.MailTips and IDsget_mail_tipsPOSTCheck recipients before sending: automatic replies, full mailboxes, delivery restrictions, moderation, external recipients and distribution-list sizes.translate_exchange_idsPOSTConvert up to 1,000 Outlook item IDs from one format to another — for example from restId to the immutable restImmutableEntryId, which survives a move.
One operation, one tool
Several paths. Graph lists messages at/me/messages and at
/me/mailFolders/{id}/messages, and folders at the root and under a parent’s
childFolders. Each is one tool: messages_list(folder_id=...),
mail_folders_list(parent_folder_id=...), messages_create(folder_id=...),
mail_folders_create(parent_folder_id=...). Leave the scope out for the wider
route. The routes are declared on the tool, most specific first, and the runtime
calls the first one the arguments fill — see routes.
Two body formats. send_mail, messages_create and the reply, reply-all and
forward operations take a JSON message or a MIME document. Fill message (and
comment, toRecipients) for JSON, or mime with an
EmailContent for MIME, which
Format("mime_base64") builds and
encodes in standard base64 — padded, with + and /, the alphabet Graph decodes.
mime is Body(media_type="text/plain"):
set, it is the whole body and the JSON arguments are refused beside it.
Narrower tools are a projection. A deployment that wants an action-sized tool
derives one:
outlook_mark_read.py
messageId and isRead, and nothing else of the message.
What crosses the boundary
On the way out
A file attachment’scontentBytes takes the file’s text, and is base64-encoded
on the way out. Its description says “base64-encoded contents”, which is Graph’s
wire format; a Gloss tells the model not to encode
it twice.
On the way back
Graph wraps every object in envelope keys and every recipient twice. The response handlers drop@odata.context, @odata.etag, changeKey and
conversationIndex, and flatten each recipient to Name <address>.
messages_list leaves each message’s full body out — bodyPreview carries the
first 255 characters, and a selected uniqueBody stays, which is the way to read
a thread: filter="conversationId eq '…'", select="from,receivedDateTime,uniqueBody".
messages_get and messages_list ask Graph for bodies as plain text
(Prefer: outlook.body-content-type="text"). A host that wants the HTML — to
edit a draft in its own formatting — passes
headers={"Prefer": 'outlook.body-content-type="html"'} to ainvoke, which
applies last.
An attachment is decoded only when it is text, and strictly. A binary file is
reported by name, type and size, and attachments_get_content hands over its
bytes.
Paging
Graph answers a long collection with@odata.nextLink, the whole URL of the next
page, and Microsoft is explicit that the $skip or $skiptoken inside it is not
for extracting. So the handlers return it as next_link, every paging tool takes
it back under that name, and the tool sends the link’s own query string in place
of the parameters it would otherwise build.
The link also says which collection it is a page of — mailFolders('drafts') —
and that is a path parameter, not part of the query. So the tool takes the folder
from the link too: a next_link passed on its own stays in its folder, and one
passed beside a different folder_id follows the link. The link’s path is matched
against the tool’s own routes and one that matches none is refused, so a rewritten
link can change which page of which folder, never which endpoint.
outlook_paging.py
messages_delta and mail_folders_delta end a round with delta_link instead. Keep
it, and pass it as next_link later: the next round returns only what was
created, updated or removed since.
messages_delta sizes its pages with max_page_size, which is Graph’s
Prefer: odata.maxpagesize header, and does not offer $top. Graph lists $top
among the options delta supports, but on a delta round it ends the round: $top=2
on a folder of twelve messages returned two and a delta_link, the next round
returned nothing, and the other ten were never delivered.
Gotchas
Delete does not go to Deleted Items
Delete does not go to Deleted Items
messages_delete (DELETE /me/messages/{id}) skips Deleted Items: the message
lands in Recoverable Items (recoverableitemsdeletions), where Outlook keeps it
for the retention period. A folder deleted with mail_folders_delete does not
appear in Deleted Items either. To put either there, move it: messages_move
or mail_folders_move to deleteditems. messages_permanent_delete calls
Graph’s permanentDelete action, after which Outlook clients cannot reach the
message, and a folder deleted with mail_folders_permanent_delete is removed
outright, not kept in the purges folder.Some query options are accepted and ignored
Some query options are accepted and ignored
Graph takes
$select on mailbox settings, $filter on the category list, and
$top and $filter on a message’s attachments without complaint, and returns
everything anyway. Those tools do not offer them, because a model handed the
whole collection reads it as the subset it asked for. The rule list takes
$filter, but most rule properties refuse it with a 400 (isEnabled,
sequence); with a handful of rules, list them all.A moved message has a new id
A moved message has a new id
messages_move returns the message in its new folder, under a new id. An id
saved before the move no longer finds it. translate_exchange_ids converts to
restImmutableEntryId, which survives a move.$search wants its query in double quotes
$search wants its query in double quotes
search takes "pizza" or "from:ada subject:invoice", quotes included, and
the call is refused locally without them. It returns at most 1,000 matches,
most recently sent first.Filtering and sorting at once has three rules
Filtering and sorting at once has three rules
Graph refuses a
$filter beside an $orderby unless every sorted property is
filtered on, in the same order, ahead of the others — InefficientFilter.
Newest first with a filter is orderby="receivedDateTime desc" and a filter
that starts receivedDateTime ge 1900-01-01T00:00:00Z and ….Extended properties live on the item
Extended properties live on the item
There is no endpoint for one. Write them through
messages_update or
mail_folders_update (singleValueExtendedProperties), and read them with
expand="singleValueExtendedProperties($filter=id eq '…')" on messages_get
or mail_folders_get.A large attachment is two steps, and the second is yours
A large attachment is two steps, and the second is yours
attachments_create_upload_session returns the session’s uploadUrl. The
file’s bytes go to that URL in PUTs of up to 4 MB, which a model has no bytes
to make; the host does that part.Related
- Microsoft Graph mail API overview
- Throttling limits for Outlook
- Gmail, the same job against Google’s API