message-poster
Pick up recent email, Teams chats and Teams channel posts from Microsoft Graph, render them to plain markdown, and POST them to a webhook you control.
What the receiver does with the payload — store it, index it, feed it to something else, drop it — is out of scope. This tool's only job is to collect and deliver.
- Read-only. Delegated Graph scopes only; it cannot send, delete or modify anything in the source tenant.
- Rendered client-side. Only the text that will actually be posted leaves the tenant — no raw API payloads, no directory GUIDs, no delta tokens, no attachment URLs.
- Attachments are metadata only. Filename, size and type. Never bytes.
- Signed. Every request carries an HMAC-SHA256 signature over the exact bytes sent.
Install
pip install message-poster
Python 3.9+. Two dependencies (msal, requests) — deliberately small,
because this installs on managed machines where every extra package is a
question somebody has to answer.
Quick start
# 1. Register an Azure app (see below) and write config.json
# 2. Sign in — device code, so no redirect URI and no listening socket
message-poster login --profile work
# 3. Check the token works
message-poster whoami --profile work
# 4. See what would be sent, without sending it
message-poster run --profile work --dry-run
# 5. For real
message-poster run --profile work
Commands
| Command | What it does |
|---|---|
login |
Interactive device-code sign-in. Prints a URL and a code; caches the refresh token afterwards. |
whoami |
Fetches /me with the cached token. The fastest way to tell auth from everything else. |
run |
Collects events since the watermark and POSTs them. |
run options:
| Flag | Default | Meaning |
|---|---|---|
--what {chats,channels,mail,all} |
all |
Which sources to collect. |
--dry-run |
off | Render to <config-dir>/dry-run/ and post nothing. |
--since ISO8601 |
— | Explicit window start. Overrides the saved watermark. |
--lookback-hours N |
24 |
Window to use when no watermark is saved. |
--profile NAME (default default) selects a named account within one config
directory; each profile keeps its own token cache and its own watermark.
--config-dir PATH overrides where everything lives.
Exit codes: 0 success, 1 a run or auth failure, 2 a config problem,
130 interrupted.
Configuration
Config lives at ~/.config/message-poster/config.json
(%APPDATA%\message-poster\config.json on Windows). Override with
--config-dir or the MESSAGE_POSTER_HOME environment variable.
{
"azure_client_id": "<application id from your Azure app registration>",
"azure_tenant_id": "organizations",
"account": "you@example.com",
"webhook_url": "https://ingest.example.com/ingest",
"hmac_secret": "<shared secret, same value the receiver holds>",
"webhook_headers": {},
"max_mail_per_run": 500,
"max_chats_per_run": 300,
"max_messages_per_chat": 200,
"max_batch_bytes": 33554432,
"gzip_over_bytes": 65536,
"filters": {
"exclude_folders": ["Junk Email", "Deleted Items"],
"exclude_sender_patterns": ["payroll@", "noreply@"],
"optout_source_ids": [],
"redact_patterns": []
}
}
| Key | Meaning |
|---|---|
azure_client_id |
Application (client) ID of your app registration. Required. |
azure_tenant_id |
organizations, common, or a specific tenant ID. |
account |
Written into each email's Account: header, so a receiver can tell mailboxes apart. |
webhook_url |
Where batches are POSTed. Required for a real run. |
hmac_secret |
Shared secret for request signing. Required for a real run. |
webhook_headers |
Free-form map merged into every request — see below. |
max_batch_bytes |
Split threshold, so no single POST exceeds the receiver's body cap. |
gzip_over_bytes |
Bodies larger than this are gzipped. |
config.json, state.json and .token-cache-*.json all live in the config
directory and none of them belong in version control.
webhook_headers
A free-form map merged into every request. This is where an identity-aware proxy's credentials go, so the tool needs no knowledge of any particular proxy:
"webhook_headers": {
"Proxy-Authorization": "Bearer <token>",
"X-Tenant": "acme"
}
Filters are a deny-list
Read this twice before an unattended run. When you mirror a whole mailbox the failure mode inverts: anything not excluded gets sent. These rules are the safety mechanism, not an optimisation.
| Key | Effect |
|---|---|
exclude_folders |
Mail folder display names to skip entirely. |
exclude_sender_patterns |
Substring match, case-insensitive, against the sender address. |
optout_source_ids |
Chat IDs, teamId/channelId pairs or message IDs to never collect. |
redact_patterns |
Regexes; every match becomes [REDACTED] before the text is posted. |
Every exclusion is counted and reported in the payload's excluded_by_filter,
so a receiver can see that filtering happened without seeing what was filtered.
Azure app registration
This is the main setup hurdle. In the Azure portal, under App registrations:
- New registration. Any name. No redirect URI is needed.
- Under Authentication, enable "Allow public client flows" — the device-code flow will not start without it.
- Under API permissions, add these delegated Microsoft Graph
permissions, all read-only:
Mail.ReadChat.ReadChannelMessage.Read.AllTeam.ReadBasic.AllUser.Read
- Copy the Application (client) ID into
azure_client_id.
Some tenants require an administrator to grant consent for
ChannelMessage.Read.All. If channel collection comes back empty while chats
and mail work, that is usually why.
The watermark
State lives in state.json next to the config, one entry per profile. Two
rules matter:
- The watermark is the newest message actually seen, not wall-clock
now(). Usingnow()would permanently skip anything that arrived while the run was in flight. - It only advances after every batch has landed. A failed POST leaves it where it was, so the next run retries that window.
On a first run with no saved watermark the window defaults to the last 24
hours, not the whole mailbox. --lookback-hours widens it and --since
overrides it outright. There is deliberately no --backfill: a fresh install
should never start by hauling years of history through a webhook.
Payload contract
Anyone can write a receiver. A batch is POSTed as JSON:
{
"profile": "work",
"run_id": "20260916T180000Z-a1b2c3",
"watermark": "2026-09-16T17:55:00Z",
"complete": true,
"excluded_by_filter": {"sender:payroll@": 3},
"items": [
{
"kind": "chat",
"source_id": "19:abc...@thread.v2",
"filename": "chat-project-sync-a1b2c3d4.md",
"rendered": "# Chat: Project Sync (teams)\n\n[2026-09-16T09:10] Alice Chen: ...\n"
}
]
}
| Field | Meaning |
|---|---|
profile |
The --profile the run used. |
run_id |
Unique per batch-set: <UTC timestamp>-<6 hex>. |
watermark |
Where the sender intends to resume. |
complete |
false on every batch but the last of a run. Wait for true before treating the window as fully delivered. |
excluded_by_filter |
Counts per rule. Diagnostic only. |
items[].kind |
chat or email. |
items[].source_id |
Stable Graph identifier for the conversation or message. |
items[].filename |
Suggested filename. Safe: ^[A-Za-z0-9._-]+$. |
items[].rendered |
The markdown. This is the content. |
A successful receiver responds 200 or 207. If it returns JSON, the keys
written, skipped_unchanged and rejected are logged by the sender; any
other body is ignored.
rendered comes in two shapes:
# Chat: <topic> (teams)
[2026-09-16T09:10] Alice Chen: message text
continued lines are indented four spaces
[attachments: budget.xlsx]
From: Alice Chen <alice@example.com>
To: bob@example.com
Subject: Quarterly numbers
Date: 2026-09-16T09:10:00Z
Account: you@example.com
Attachments: budget.xlsx (2048b)
Body text, HTML stripped.
Verifying a request
Each POST carries:
| Header | Value |
|---|---|
X-Timestamp |
Unix seconds when the request was signed. |
X-Signature |
sha256=<hmac_sha256(secret, "<X-Timestamp>." + raw_body)> |
Content-Encoding |
gzip, if the body exceeded gzip_over_bytes. |
The signature covers the compressed bytes as sent. Verify before you decompress.
import hashlib, hmac
def verify(secret, raw_body, timestamp, signature):
expected = "sha256=" + hmac.new(
secret.encode(), str(timestamp).encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Two things a receiver should also do:
- Reject timestamps outside about ±5 minutes of its own clock. Binding the timestamp into the signed bytes is what makes that window meaningful — an old body cannot be replayed under a fresh timestamp without breaking the signature.
- Treat
(source_id, sha256(rendered))as an idempotency key. Chats are re-sent whole when they change, so the samesource_idwill arrive more than once; the content hash is what tells a genuine update from a repeat.
Delivery behaviour
- Batches are split so none exceeds
max_batch_bytes. - Bodies over
gzip_over_bytesare gzipped. - 429 and 5xx retry three times with exponential backoff.
- Any other 4xx fails immediately. A signature or config error will not fix itself by retrying.
- Graph throttling (
429) is honoured viaRetry-Afterduring collection.
Running it unattended
login is interactive exactly once; after that the cached refresh token keeps
runs silent, so run is safe in cron or a scheduled task. Conditional Access
can still force periodic re-auth — when it does, the run exits 1 and tells
you to sign in again rather than failing obscurely.
Device code was chosen for precisely this reason: it needs no redirect URI and no listening socket, so the browser step can be completed in whichever session the tenant's Conditional Access policy is willing to accept — not necessarily the machine running the tool.
Development
pip install -e ".[dev]"
pytest -q
Releasing
The git tag is the version. setuptools-scm derives it at build time, so
there is no version to bump in a file and nothing that can disagree with the
tag:
git tag v0.1.1
git push origin v0.1.1
That runs the tests, builds, verifies the built version matches the tag, and publishes to PyPI via Trusted Publishing — no API token is stored anywhere — then attaches the artifacts to a GitHub Release.
To rehearse the whole path without spending a version number, run the Release
workflow manually from the Actions tab: a workflow_dispatch publishes to
TestPyPI instead of PyPI.
A build from an untagged commit gets a .devN+g<sha> suffix, and the release
job refuses to publish it under a real version number.
License
MIT — see LICENSE.
Release files for message-poster 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| message_poster-0.1.0.tar.gz | 28.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| message_poster-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:49.8 kB
Release files / message_poster-0.1.0.tar.gz
| Download URL | message_poster-0.1.0.tar.gz |
|---|---|
| Size | 28.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b03858d9561a3f2bc1e0e4569dec41e1437cd27fb875192d68472af1b439bc47
|
|
BLAKE2b-256 checksum How to use checksums |
ebfa955db5a8e0cd1711b6247d8db5e5d56a1c423231b66fbac47e9a23b1d3d3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency logRelease files / message_poster-0.1.0-py3-none-any.whl
| Download URL | message_poster-0.1.0-py3-none-any.whl |
|---|---|
| Size | 21.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
21124e40cbf4ecf0864b815b3c30bbb002c1f3c8b7cf0aafa0a084e6f0b07a97
|
|
BLAKE2b-256 checksum How to use checksums |
269e38a2ee091ace0b306da1597d33f4326866764d8363433189113be5da274b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 16, 2026.
Transparency log