Metadata-Version: 2.4
Name: message-poster
Version: 0.3.0
Summary: Mirror mail and chat from Microsoft Graph to a webhook, as plain markdown
License-Expression: MIT
Project-URL: Homepage, https://github.com/omarmciver/message-poster
Project-URL: Issues, https://github.com/omarmciver/message-poster/issues
Keywords: microsoft-graph,outlook,teams,export,mirror,webhook
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: System Administrators
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Communications :: Email
Classifier: Topic :: System :: Archiving :: Mirroring
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: msal>=1.28
Requires-Dist: requests>=2.31
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Dynamic: license-file

# 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

```bash
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:

```bash
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

```bash
# 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.

```json
{
  "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:

```json
"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:

```bash
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:

```json
"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:

```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.

```python
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

```bash
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:

```bash
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](https://docs.pypi.org/trusted-publishers/)
— 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](LICENSE).
