Skip to main content

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

If message-poster is then "not recognized" as a command, pip's scripts directory is not on your PATH — common on a managed machine, where PATH is not yours to change. Use the module form instead; it needs no PATH entry and is otherwise identical:

python -m message_poster login --profile work

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.

When Conditional Access blocks the app registration

Some tenants refuse to approve a new app registration at all. Conditional Access evaluates the application identity, so no variation helps — a different client ID, a different flow, running on a managed laptop, or completing the device-code step in a compliant browser session all fail the same way. The Graph path is simply closed in that tenant.

There is a way round it for mail, on Windows:

pip install pywin32
message-poster run --profile work --source outlook --dry-run

--source outlook attaches to a locally running classic Outlook over COM. It acquires no token and registers no app — it reads the client you are already signed in to, so there is nothing for Conditional Access to refuse. No azure_client_id is needed in the config, and login is not required.

Everything downstream is unchanged: the same rendering, the same deny-list filters and redaction, the same HMAC signing, the same payload. Output is byte-identical to the Graph path.

Three limits, stated plainly:

  • Mail only. Teams exposes no equivalent local interface. --what chats or channels with --source outlook is refused rather than silently empty.
  • Classic Outlook only. New Outlook is a web app in a shell and has no COM interface. The "New Outlook" toggle switches back.
  • It reads the local cache (the .ost), not the server. You get what that mailbox has cached locally, which for an account with a limited sync window is less than Graph would return.

Azure app registration

This is the main setup hurdle. In the Azure portal, under App registrations:

  1. New registration. Any name. No redirect URI is needed.
  2. Under Authentication, enable "Allow public client flows" — the device-code flow will not start without it.
  3. Under API permissions, add these delegated Microsoft Graph permissions, all read-only:
    • Mail.Read
    • Chat.Read
    • ChannelMessage.Read.All
    • Team.ReadBasic.All
    • User.Read
  4. 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(). Using now() 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 same source_id will 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_bytes are 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 via Retry-After during 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.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for message-poster 0.2.0
File Size Uploaded
message_poster-0.2.0.tar.gz 34.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for message-poster 0.2.0
File Interpreter ABI Platform
message_poster-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size:60.9 kB

Release files / message_poster-0.2.0.tar.gz

Download URL message_poster-0.2.0.tar.gz
Size 34.8 kB
Tags Source
SHA-256 checksum
How to use checksums
50bd2a92169508f8154da666a2ad8d67f110902b973982639142799f296fb216
BLAKE2b-256 checksum
How to use checksums
8876272b3ef978787455a916764e84674de19053ebca43150d40fe85e296657d
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 17, 2026.

Transparency log

Release files / message_poster-0.2.0-py3-none-any.whl

Download URL message_poster-0.2.0-py3-none-any.whl
Size 26.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
69f27e6660a27fcf65ce3a5b354722913181d65f5f86569d639b9576af6efdc2
BLAKE2b-256 checksum
How to use checksums
7d55be6a9fbcca0386a8f958f1c16f9cf37da9f2f68c947e97e2e0763d100508
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 17, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.1

2 release files

0.3.0

2 release files

This release

0.2.0 This release

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page