references/graph-api.md — Approval-Gated Mail

From version 0.2.1 of Approval-Gated Mail, shown publicly by its creator. 10.0 KiB · SHA-256 80a04e9a4b8f0f6fb534db13e10c151392d6e2ada91f5fc80fa555f490116e7c.

# Microsoft Graph mail API reference

This reference records the endpoint and permission assumptions used by
`scripts/m365_mail.py`. It is intentionally limited to this skill's read,
search, send, reply, and Sent Items verification paths.

## Contents

- [Authentication](#authentication)
- [Application permissions](#application-permissions)
- [Mail endpoints](#mail-endpoints)
- [Query options](#query-options)
- [Reply and threading behavior](#reply-and-threading-behavior)
- [Sent Items verification](#sent-items-verification)
- [Errors and throttling](#errors-and-throttling)
- [Fake transport fixtures](#fake-transport-fixtures)

## Authentication

The CLI uses the OAuth 2.0 client-credentials flow. It posts form-encoded data
to:

```text
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
```

The request includes `client_id`, `client_secret`,
`grant_type=client_credentials`, and
`scope=https://graph.microsoft.com/.default`. Credentials come from the
documented environment variables or a caller-supplied JSON config file. The
CLI keeps access tokens in memory only and does not print or persist them.
Fake transport mode bypasses credential loading and token acquisition entirely.
It requires both `M365_MAIL_FAKE_TRANSPORT` and `M365_MAIL_ALLOW_FAKE=1` to be
set, and prints a stderr warning on every command while engaged.

Graph requests use `Authorization: Bearer {access-token}` and target:

```text
https://graph.microsoft.com/v1.0
```

## Application permissions

The tenant application needs these Microsoft Graph permissions of type
Application:

- `Mail.Read` for listing, searching, reading, threading, attachment metadata,
  Markdown export, and reading Sent Items.
- `Mail.Send` for new messages and replies.

A tenant administrator must consent to both permissions. Tenant-wide consent
does not by itself express a mailbox boundary. When applicable, configure the
tenant's supported application-access controls so the application can reach
only the intended mailbox set. Verify current permission and mailbox-scoping
guidance against Microsoft documentation before production use.

## Mail endpoints

All `{user}`, `{message-id}`, and `{attachment-id}` values are URL encoded.

| Operation | Method and path |
| --- | --- |
| Recent, keyword search, sender search | `GET /users/{user}/messages` |
| Read one message | `GET /users/{user}/messages/{message-id}` |
| List a conversation | `GET /users/{user}/messages` with a `conversationId` filter |
| List attachment metadata | `GET /users/{user}/messages/{message-id}/attachments` |
| Send a new message | `POST /users/{user}/sendMail` |
| Reply to a message | `POST /users/{user}/messages/{message-id}/reply` |
| Read Sent Items | `GET /users/{user}/mailFolders/sentitems/messages` |

Endpoint paths, required headers, and response shapes can change across Graph
API versions; verify against Microsoft documentation.

`sendMail` receives a `message` object and `saveToSentItems: true`. A successful
submission commonly returns an empty response with status `202`, which is not
the verification record. The CLI never calls a draft-creation endpoint and
never uses `createReply`.

The attachment command requests metadata only. It does not request or decode
attachment content bytes and does not write them to disk.

## Query options

The CLI uses a small selected field set to keep output bounded:

```text
$select=id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,sentDateTime,isRead,hasAttachments,conversationId,body
```

The exact selection varies by command. Listing commands use `$top` and an
appropriate `$orderby`, normally newest first. Thread output filters on
`conversationId` and sorts messages oldest to newest. Sent Items queries use
the well-known folder and can combine a bounded `$top`, `$select`, `$orderby`,
and supported filters.

Keyword and sender searches use Graph `$search`, with the search expression
quoted and URL encoded. Microsoft documents backslash-escaping double quotes
and backslashes inside a quoted `$search` clause in the directory-object rules
at <https://learn.microsoft.com/en-us/graph/search-query-parameter>. The CLI
applies that same escaping to message searches; the cited rules do not make a
separate message-specific statement. Search syntax, supported properties,
header requirements, `$search` result ordering, and compatibility with other
query options can change. Verify these details against Microsoft documentation
when changing the implementation.

## Reply and threading behavior

The `conversationId` property is server managed. A caller cannot reliably put
a new `sendMail` message into an existing thread merely by including a
conversation identifier. For deterministic reply behavior, use
`POST /users/{user}/messages/{message-id}/reply` with the exact target message.

Use `thread CONVERSATION_ID` to identify the intended message, then pass
`--reply-to-message-id MESSAGE_ID` to `send`. `send --in-reply-to` is refused
before any request. The CLI does not create a mailbox draft.

`send --dry-run` prints the method, full URL, exact JSON body, and a SHA-256
approval digest over their canonical JSON representation. The digest binds
the mailbox and all message fields, including recipients, subject, body and
content type. For a reply, the URL contains the real target message ID and
binds it in the digest. For a new message, the body contains and binds
`saveToSentItems`. Live `--approve <digest>` must match before a token request.

Exact reply recipient behavior, whether every supported message-body shape is
accepted, whether the service preserves or derives the reply subject, and how
conversation grouping appears across clients must be
verified against Microsoft documentation before changing or extending this
workflow. Do not claim that plain `sendMail` guarantees reply-in-thread
semantics.

## Sent Items verification

The `send` command performs an automatic read-back after every successful
submission. The companion then immediately invokes the standalone
`verify-sent` command, which reads:

```text
GET /users/{user}/mailFolders/sentitems/messages
```

`verify-sent` reads up to `--count` records (default 25), ordered by
`sentDateTime` descending. It requires an exact, case-sensitive Subject and,
for each supplied `--to`, `--cc`, or `--bcc`, equality of case-insensitive
address sets. Address order and repeated addresses do not matter. Omitted
recipient flags skip that field; use `--cc "" --bcc ""` to require empty fields.
Pass all exact recipients from the approved request:

```bash
python3 scripts/m365_mail.py --user user@example.com verify-sent --subject "Approved subject" --to recipient@example.com --cc "" --bcc "" --count 25
```

Automatic `send` captures IDs from the latest 25 Sent Items before submission,
then checks the latest 25 up to three times, waiting 1 and 2 seconds between
checks outside fixture mode. It requires matching Subject and all three
recipient sets, excluding captured IDs. Standalone verification checks once
and has no baseline exclusion. Sent Items can appear after these checks or
fall outside the count bound; a missing match does not establish that no send
occurred. Reconcile before retrying.

Matching does not compare body, sender, attachments, RFC Message-ID, approval
digest, or a submission timestamp. Duplicate subjects with identical recipient
sets can match another message: standalone checks may find an older record;
automatic checks may find a concurrent message or a record outside the baseline.
The returned timestamp is reported, not a correlation condition. A match
confirms a Sent Items record satisfying those fields, not uniquely the approved
content, downstream delivery, acceptance by every recipient system, or reading.

## Errors and throttling

Non-success HTTP responses become clear stderr errors and exit status `1`.
Authentication failures, permission denials, missing messages, mailbox scope
failures, malformed queries, and throttling are never treated as empty results.

Graph can return `429` and service-specific error bodies, sometimes with a
`Retry-After` header. Exact throttling rules, retry timing, and error-code
meanings vary by operation and service state. Verify against Microsoft
documentation. The CLI reports the failure rather than performing an unbounded
retry loop.

## Fake transport fixtures

Set `M365_MAIL_FAKE_TRANSPORT` to a caller-supplied JSON file, and also set
`M365_MAIL_ALLOW_FAKE=1` to confirm the choice is deliberate, to bypass
credentials and token acquisition and replace all network access with canned
responses; the CLI refuses to start if only `M365_MAIL_FAKE_TRANSPORT` is set,
and prints a stderr warning on every command while fake mode is engaged. The
root object has a `responses` array.
Matching entries are consumed in fixture order and contain:

- `method`: expected uppercase HTTP method.
- `url_contains`: substring required in the requested URL.
- `status`: simulated HTTP status.
- `json`: optional JSON response value.
- `body`: optional response text when `json` is absent.
- `headers`: optional object of simulated response headers; only
  `Retry-After` is currently read, and only for error statuses.

The root may instead be the response array itself. Entries can use `url` for
an exact request URL or `path` for the request path instead of `url_contains`.
Matching entries are consumed as requests are made. No match, an exhausted
fixture, a malformed entry, or a simulated error status is reported as an
error. Fixtures must contain synthetic data only and must never contain
credentials or real mailbox content.

## Output escaping

Text output uses `\uXXXX` for unsafe BMP characters and `\UXXXXXXXX` for
code points above U+FFFF. Single-line fields escape backslashes, controls,
NBSP, ZWJ, fillers and variation selectors. Bodies retain newlines and tabs,
escape unsafe categories and invisible characters, and escape a backslash
followed by `u` or `U` as `\u005c`, so literal escape text cannot impersonate
an escape. JSON output stays raw Unicode.

Repeating a live Graph send with the same approval sends again; run
`verify-sent` before any retry.

By Zero Width Lab (zerowidthlab.com)

License: MIT (read it).

MITMIT License

Copyright (c) 2026 Zero Width Lab

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Back to the listing