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,
  "outlook_folders": ["inbox", "sent"],
  "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.

By default it walks Inbox and Sent Items. Sent matters: a commitment you made in a reply nobody answered exists only there, so collecting the inbox alone silently loses your own side of every conversation. Sent mail is rendered identically but filenamed sent-*, and the exclude_sender_patterns deny-list is matched against the recipient there — in Sent Items the sender is always you, so a sender rule would otherwise never match.

Narrow it with outlook_folders in the config if you want one or the other:

"outlook_folders": ["inbox", "sent"]

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

message_poster-0.3.0.tar.gz (36.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

message_poster-0.3.0-py3-none-any.whl (27.1 kB view details)

Uploaded Python 3

File details

Details for the file message_poster-0.3.0.tar.gz.

File metadata

  • Download URL: message_poster-0.3.0.tar.gz
  • Upload date:
  • Size: 36.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for message_poster-0.3.0.tar.gz
Algorithm Hash digest
SHA256 d7f13c79834adeddc3a0d0b7322edddf8045b6bca9fcc93c9c49ca62ef80c434
MD5 9210e7f06975b2b93239e1dfc0f6d637
BLAKE2b-256 322e59c831894333bd2823a35a35758ef09d1c1ab0c9180eef5abf1044dfaec2

See more details on using hashes here.

Provenance

The following attestation bundles were made for message_poster-0.3.0.tar.gz:

Publisher: release.yml on omarmciver/message-poster

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file message_poster-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: message_poster-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 27.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for message_poster-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d96fb8057ad3d0f3cf8d7a554fb4fac4e251ee87a317eae063a643f69d51c384
MD5 406c017fee66d873fe0179df4d968712
BLAKE2b-256 f38fff72fe1efeaa7c75fbc0f11e5d17bb0708e007d172d80e4fc9b8d840cf36

See more details on using hashes here.

Provenance

The following attestation bundles were made for message_poster-0.3.0-py3-none-any.whl:

Publisher: release.yml on omarmciver/message-poster

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.3.1

2 files

This release

0.3.0 This release

2 files

0.2.0

2 files

0.1.1

2 files

0.1.0

2 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