message-poster 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ test:
15
+ runs-on: ${{ matrix.os }}
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ # 3.9 is the floor declared in pyproject; 3.13 is the ceiling in use.
20
+ # Windows is in the matrix because the config path branches on APPDATA.
21
+ os: [ubuntu-latest]
22
+ python-version: ["3.9", "3.12", "3.13"]
23
+ include:
24
+ - os: windows-latest
25
+ python-version: "3.12"
26
+
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ with:
30
+ # setuptools-scm derives the version from tags, so a shallow
31
+ # clone without them builds as 0.1.dev1+... and looks broken.
32
+ fetch-depth: 0
33
+
34
+ - uses: actions/setup-python@v5
35
+ with:
36
+ python-version: ${{ matrix.python-version }}
37
+
38
+ - name: Install
39
+ run: python -m pip install --upgrade pip && pip install -e ".[dev]"
40
+
41
+ - name: Test
42
+ run: pytest -q
43
+
44
+ - name: Smoke-test the CLI entry point
45
+ run: |
46
+ message-poster --help
47
+ message-poster run --help
48
+
49
+ anonymity:
50
+ # This package must carry no hostnames, employer names, client names,
51
+ # colleague names or real addresses. The only identifying reference
52
+ # allowed is the GitHub URL in pyproject.toml.
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - uses: actions/checkout@v4
56
+
57
+ - name: Scan for identifying references
58
+ run: |
59
+ if grep -rniE "virpnet|mode3|192\.168|10\.[0-9]+\.[0-9]+\.[0-9]+|symrise|cura|lionrose|interos|oars|\.local\b" \
60
+ src/ tests/ README.md pyproject.toml LICENSE; then
61
+ echo "::error::identifying reference found — see matches above"
62
+ exit 1
63
+ fi
64
+ echo "clean"
65
+
66
+ build:
67
+ runs-on: ubuntu-latest
68
+ steps:
69
+ - uses: actions/checkout@v4
70
+ with:
71
+ fetch-depth: 0
72
+
73
+ - uses: actions/setup-python@v5
74
+ with:
75
+ python-version: "3.12"
76
+
77
+ - name: Build
78
+ run: python -m pip install --upgrade pip build && python -m build
79
+
80
+ - name: Check metadata
81
+ run: pip install twine && twine check dist/*
82
+
83
+ - uses: actions/upload-artifact@v4
84
+ with:
85
+ name: dist
86
+ path: dist/
@@ -0,0 +1,114 @@
1
+ name: Release
2
+
3
+ # The git tag IS the version. setuptools-scm derives it at build time, so
4
+ # there is nothing to bump in a file and nothing that can disagree with the
5
+ # tag. To cut a release:
6
+ #
7
+ # git tag v0.1.1 && git push origin v0.1.1
8
+ #
9
+ # A manual dispatch builds and publishes to TestPyPI instead, so the whole
10
+ # path can be rehearsed without burning a real version number.
11
+
12
+ on:
13
+ push:
14
+ tags: ["v*"]
15
+ workflow_dispatch:
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ build:
22
+ runs-on: ubuntu-latest
23
+ outputs:
24
+ version: ${{ steps.v.outputs.version }}
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ with:
28
+ fetch-depth: 0 # tags, or setuptools-scm cannot see the version
29
+
30
+ - uses: actions/setup-python@v5
31
+ with:
32
+ python-version: "3.12"
33
+
34
+ - name: Test before publishing anything
35
+ run: |
36
+ python -m pip install --upgrade pip
37
+ pip install -e ".[dev]"
38
+ pytest -q
39
+
40
+ - name: Build
41
+ run: pip install build && python -m build
42
+
43
+ - name: Check metadata
44
+ run: pip install twine && twine check dist/*
45
+
46
+ - name: Confirm the built version matches the tag
47
+ id: v
48
+ run: |
49
+ BUILT=$(ls dist/*.whl | sed -E 's/.*message_poster-([^-]+)-py3.*/\1/')
50
+ echo "version=$BUILT" >> "$GITHUB_OUTPUT"
51
+ echo "built version: $BUILT"
52
+ if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
53
+ TAG="${GITHUB_REF_NAME#v}"
54
+ # A dirty or untagged tree yields a .devN+g<sha> suffix. Catching
55
+ # it here is what stops a dev build reaching PyPI under a real
56
+ # version number, where it could never be replaced.
57
+ if [ "$BUILT" != "$TAG" ]; then
58
+ echo "::error::tag v$TAG but built $BUILT — is the tag on this exact commit?"
59
+ exit 1
60
+ fi
61
+ fi
62
+
63
+ - uses: actions/upload-artifact@v4
64
+ with:
65
+ name: dist
66
+ path: dist/
67
+
68
+ testpypi:
69
+ # Manual dispatch only: rehearse the publish without spending a version.
70
+ if: github.event_name == 'workflow_dispatch'
71
+ needs: build
72
+ runs-on: ubuntu-latest
73
+ environment: testpypi
74
+ permissions:
75
+ id-token: write # OIDC — no API token stored anywhere
76
+ steps:
77
+ - uses: actions/download-artifact@v4
78
+ with:
79
+ name: dist
80
+ path: dist/
81
+ - uses: pypa/gh-action-pypi-publish@release/v1
82
+ with:
83
+ repository-url: https://test.pypi.org/legacy/
84
+ skip-existing: true
85
+
86
+ pypi:
87
+ if: github.ref_type == 'tag'
88
+ needs: build
89
+ runs-on: ubuntu-latest
90
+ environment: pypi
91
+ permissions:
92
+ id-token: write # OIDC — no API token stored anywhere
93
+ steps:
94
+ - uses: actions/download-artifact@v4
95
+ with:
96
+ name: dist
97
+ path: dist/
98
+ - uses: pypa/gh-action-pypi-publish@release/v1
99
+
100
+ github-release:
101
+ if: github.ref_type == 'tag'
102
+ needs: [build, pypi]
103
+ runs-on: ubuntu-latest
104
+ permissions:
105
+ contents: write
106
+ steps:
107
+ - uses: actions/download-artifact@v4
108
+ with:
109
+ name: dist
110
+ path: dist/
111
+ - uses: softprops/action-gh-release@v2
112
+ with:
113
+ files: dist/*
114
+ generate_release_notes: true
@@ -0,0 +1,19 @@
1
+ # Secrets and local state — never commit these.
2
+ config.json
3
+ state.json
4
+ .token-cache-*.json
5
+ dry-run/
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.egg-info/
11
+ build/
12
+ dist/
13
+ .venv/
14
+ venv/
15
+ .pytest_cache/
16
+ .venv/
17
+
18
+ # generated by setuptools-scm from the git tag
19
+ src/message_poster/_version.py
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Omar McIver
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,343 @@
1
+ Metadata-Version: 2.4
2
+ Name: message-poster
3
+ Version: 0.1.0
4
+ Summary: Mirror mail and chat from Microsoft Graph to a webhook, as plain markdown
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/omarmciver/message-poster
7
+ Project-URL: Issues, https://github.com/omarmciver/message-poster/issues
8
+ Keywords: microsoft-graph,outlook,teams,export,mirror,webhook
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: System Administrators
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Communications :: Email
13
+ Classifier: Topic :: System :: Archiving :: Mirroring
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: msal>=1.28
18
+ Requires-Dist: requests>=2.31
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # message-poster
24
+
25
+ Pick up recent **email, Teams chats and Teams channel posts** from Microsoft
26
+ Graph, render them to plain markdown, and POST them to a webhook you control.
27
+
28
+ What the receiver does with the payload — store it, index it, feed it to
29
+ something else, drop it — is out of scope. This tool's only job is to collect
30
+ and deliver.
31
+
32
+ - **Read-only.** Delegated Graph scopes only; it cannot send, delete or modify
33
+ anything in the source tenant.
34
+ - **Rendered client-side.** Only the text that will actually be posted leaves
35
+ the tenant — no raw API payloads, no directory GUIDs, no delta tokens, no
36
+ attachment URLs.
37
+ - **Attachments are metadata only.** Filename, size and type. Never bytes.
38
+ - **Signed.** Every request carries an HMAC-SHA256 signature over the exact
39
+ bytes sent.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install message-poster
45
+ ```
46
+
47
+ Python 3.9+. Two dependencies (`msal`, `requests`) — deliberately small,
48
+ because this installs on managed machines where every extra package is a
49
+ question somebody has to answer.
50
+
51
+ ## Quick start
52
+
53
+ ```bash
54
+ # 1. Register an Azure app (see below) and write config.json
55
+ # 2. Sign in — device code, so no redirect URI and no listening socket
56
+ message-poster login --profile work
57
+
58
+ # 3. Check the token works
59
+ message-poster whoami --profile work
60
+
61
+ # 4. See what would be sent, without sending it
62
+ message-poster run --profile work --dry-run
63
+
64
+ # 5. For real
65
+ message-poster run --profile work
66
+ ```
67
+
68
+ ## Commands
69
+
70
+ | Command | What it does |
71
+ |---|---|
72
+ | `login` | Interactive device-code sign-in. Prints a URL and a code; caches the refresh token afterwards. |
73
+ | `whoami` | Fetches `/me` with the cached token. The fastest way to tell auth from everything else. |
74
+ | `run` | Collects events since the watermark and POSTs them. |
75
+
76
+ `run` options:
77
+
78
+ | Flag | Default | Meaning |
79
+ |---|---|---|
80
+ | `--what {chats,channels,mail,all}` | `all` | Which sources to collect. |
81
+ | `--dry-run` | off | Render to `<config-dir>/dry-run/` and post nothing. |
82
+ | `--since ISO8601` | — | Explicit window start. Overrides the saved watermark. |
83
+ | `--lookback-hours N` | `24` | Window to use when no watermark is saved. |
84
+
85
+ `--profile NAME` (default `default`) selects a named account within one config
86
+ directory; each profile keeps its own token cache and its own watermark.
87
+ `--config-dir PATH` overrides where everything lives.
88
+
89
+ Exit codes: `0` success, `1` a run or auth failure, `2` a config problem,
90
+ `130` interrupted.
91
+
92
+ ## Configuration
93
+
94
+ Config lives at `~/.config/message-poster/config.json`
95
+ (`%APPDATA%\message-poster\config.json` on Windows). Override with
96
+ `--config-dir` or the `MESSAGE_POSTER_HOME` environment variable.
97
+
98
+ ```json
99
+ {
100
+ "azure_client_id": "<application id from your Azure app registration>",
101
+ "azure_tenant_id": "organizations",
102
+ "account": "you@example.com",
103
+ "webhook_url": "https://ingest.example.com/ingest",
104
+ "hmac_secret": "<shared secret, same value the receiver holds>",
105
+ "webhook_headers": {},
106
+ "max_mail_per_run": 500,
107
+ "max_chats_per_run": 300,
108
+ "max_messages_per_chat": 200,
109
+ "max_batch_bytes": 33554432,
110
+ "gzip_over_bytes": 65536,
111
+ "filters": {
112
+ "exclude_folders": ["Junk Email", "Deleted Items"],
113
+ "exclude_sender_patterns": ["payroll@", "noreply@"],
114
+ "optout_source_ids": [],
115
+ "redact_patterns": []
116
+ }
117
+ }
118
+ ```
119
+
120
+ | Key | Meaning |
121
+ |---|---|
122
+ | `azure_client_id` | Application (client) ID of your app registration. Required. |
123
+ | `azure_tenant_id` | `organizations`, `common`, or a specific tenant ID. |
124
+ | `account` | Written into each email's `Account:` header, so a receiver can tell mailboxes apart. |
125
+ | `webhook_url` | Where batches are POSTed. Required for a real run. |
126
+ | `hmac_secret` | Shared secret for request signing. Required for a real run. |
127
+ | `webhook_headers` | Free-form map merged into every request — see below. |
128
+ | `max_batch_bytes` | Split threshold, so no single POST exceeds the receiver's body cap. |
129
+ | `gzip_over_bytes` | Bodies larger than this are gzipped. |
130
+
131
+ `config.json`, `state.json` and `.token-cache-*.json` all live in the config
132
+ directory and none of them belong in version control.
133
+
134
+ ### `webhook_headers`
135
+
136
+ A free-form map merged into every request. This is where an identity-aware
137
+ proxy's credentials go, so the tool needs no knowledge of any particular proxy:
138
+
139
+ ```json
140
+ "webhook_headers": {
141
+ "Proxy-Authorization": "Bearer <token>",
142
+ "X-Tenant": "acme"
143
+ }
144
+ ```
145
+
146
+ ### Filters are a **deny-list**
147
+
148
+ Read this twice before an unattended run. When you mirror a whole mailbox the
149
+ failure mode inverts: **anything not excluded gets sent.** These rules are the
150
+ safety mechanism, not an optimisation.
151
+
152
+ | Key | Effect |
153
+ |---|---|
154
+ | `exclude_folders` | Mail folder display names to skip entirely. |
155
+ | `exclude_sender_patterns` | Substring match, case-insensitive, against the sender address. |
156
+ | `optout_source_ids` | Chat IDs, `teamId/channelId` pairs or message IDs to never collect. |
157
+ | `redact_patterns` | Regexes; every match becomes `[REDACTED]` before the text is posted. |
158
+
159
+ Every exclusion is counted and reported in the payload's `excluded_by_filter`,
160
+ so a receiver can see that filtering happened without seeing what was filtered.
161
+
162
+ ## Azure app registration
163
+
164
+ This is the main setup hurdle. In the Azure portal, under **App registrations**:
165
+
166
+ 1. **New registration.** Any name. No redirect URI is needed.
167
+ 2. Under **Authentication**, enable **"Allow public client flows"** — the
168
+ device-code flow will not start without it.
169
+ 3. Under **API permissions**, add these **delegated** Microsoft Graph
170
+ permissions, all read-only:
171
+ - `Mail.Read`
172
+ - `Chat.Read`
173
+ - `ChannelMessage.Read.All`
174
+ - `Team.ReadBasic.All`
175
+ - `User.Read`
176
+ 4. Copy the **Application (client) ID** into `azure_client_id`.
177
+
178
+ Some tenants require an administrator to grant consent for
179
+ `ChannelMessage.Read.All`. If channel collection comes back empty while chats
180
+ and mail work, that is usually why.
181
+
182
+ ## The watermark
183
+
184
+ State lives in `state.json` next to the config, one entry per profile. Two
185
+ rules matter:
186
+
187
+ - **The watermark is the newest message actually seen**, not wall-clock `now()`.
188
+ Using `now()` would permanently skip anything that arrived while the run was
189
+ in flight.
190
+ - **It only advances after every batch has landed.** A failed POST leaves it
191
+ where it was, so the next run retries that window.
192
+
193
+ On a first run with no saved watermark the window defaults to the **last 24
194
+ hours**, not the whole mailbox. `--lookback-hours` widens it and `--since`
195
+ overrides it outright. There is deliberately no `--backfill`: a fresh install
196
+ should never start by hauling years of history through a webhook.
197
+
198
+ ## Payload contract
199
+
200
+ Anyone can write a receiver. A batch is POSTed as JSON:
201
+
202
+ ```json
203
+ {
204
+ "profile": "work",
205
+ "run_id": "20260916T180000Z-a1b2c3",
206
+ "watermark": "2026-09-16T17:55:00Z",
207
+ "complete": true,
208
+ "excluded_by_filter": {"sender:payroll@": 3},
209
+ "items": [
210
+ {
211
+ "kind": "chat",
212
+ "source_id": "19:abc...@thread.v2",
213
+ "filename": "chat-project-sync-a1b2c3d4.md",
214
+ "rendered": "# Chat: Project Sync (teams)\n\n[2026-09-16T09:10] Alice Chen: ...\n"
215
+ }
216
+ ]
217
+ }
218
+ ```
219
+
220
+ | Field | Meaning |
221
+ |---|---|
222
+ | `profile` | The `--profile` the run used. |
223
+ | `run_id` | Unique per batch-set: `<UTC timestamp>-<6 hex>`. |
224
+ | `watermark` | Where the sender intends to resume. |
225
+ | `complete` | `false` on every batch but the last of a run. Wait for `true` before treating the window as fully delivered. |
226
+ | `excluded_by_filter` | Counts per rule. Diagnostic only. |
227
+ | `items[].kind` | `chat` or `email`. |
228
+ | `items[].source_id` | Stable Graph identifier for the conversation or message. |
229
+ | `items[].filename` | Suggested filename. Safe: `^[A-Za-z0-9._-]+$`. |
230
+ | `items[].rendered` | The markdown. This is the content. |
231
+
232
+ A successful receiver responds `200` or `207`. If it returns JSON, the keys
233
+ `written`, `skipped_unchanged` and `rejected` are logged by the sender; any
234
+ other body is ignored.
235
+
236
+ `rendered` comes in two shapes:
237
+
238
+ ```
239
+ # Chat: <topic> (teams)
240
+
241
+ [2026-09-16T09:10] Alice Chen: message text
242
+ continued lines are indented four spaces
243
+ [attachments: budget.xlsx]
244
+ ```
245
+
246
+ ```
247
+ From: Alice Chen <alice@example.com>
248
+ To: bob@example.com
249
+ Subject: Quarterly numbers
250
+ Date: 2026-09-16T09:10:00Z
251
+ Account: you@example.com
252
+ Attachments: budget.xlsx (2048b)
253
+
254
+ Body text, HTML stripped.
255
+ ```
256
+
257
+ ### Verifying a request
258
+
259
+ Each POST carries:
260
+
261
+ | Header | Value |
262
+ |---|---|
263
+ | `X-Timestamp` | Unix seconds when the request was signed. |
264
+ | `X-Signature` | `sha256=<hmac_sha256(secret, "<X-Timestamp>." + raw_body)>` |
265
+ | `Content-Encoding` | `gzip`, if the body exceeded `gzip_over_bytes`. |
266
+
267
+ **The signature covers the compressed bytes as sent.** Verify before you
268
+ decompress.
269
+
270
+ ```python
271
+ import hashlib, hmac
272
+
273
+ def verify(secret, raw_body, timestamp, signature):
274
+ expected = "sha256=" + hmac.new(
275
+ secret.encode(), str(timestamp).encode() + b"." + raw_body, hashlib.sha256
276
+ ).hexdigest()
277
+ return hmac.compare_digest(expected, signature)
278
+ ```
279
+
280
+ Two things a receiver should also do:
281
+
282
+ - **Reject timestamps outside about ±5 minutes** of its own clock. Binding the
283
+ timestamp into the signed bytes is what makes that window meaningful — an old
284
+ body cannot be replayed under a fresh timestamp without breaking the
285
+ signature.
286
+ - **Treat `(source_id, sha256(rendered))` as an idempotency key.** Chats are
287
+ re-sent whole when they change, so the same `source_id` will arrive more than
288
+ once; the content hash is what tells a genuine update from a repeat.
289
+
290
+ ## Delivery behaviour
291
+
292
+ - Batches are split so none exceeds `max_batch_bytes`.
293
+ - Bodies over `gzip_over_bytes` are gzipped.
294
+ - **429 and 5xx retry** three times with exponential backoff.
295
+ - **Any other 4xx fails immediately.** A signature or config error will not fix
296
+ itself by retrying.
297
+ - Graph throttling (`429`) is honoured via `Retry-After` during collection.
298
+
299
+ ## Running it unattended
300
+
301
+ `login` is interactive exactly once; after that the cached refresh token keeps
302
+ runs silent, so `run` is safe in cron or a scheduled task. Conditional Access
303
+ can still force periodic re-auth — when it does, the run exits `1` and tells
304
+ you to sign in again rather than failing obscurely.
305
+
306
+ Device code was chosen for precisely this reason: it needs no redirect URI and
307
+ no listening socket, so the browser step can be completed in whichever session
308
+ the tenant's Conditional Access policy is willing to accept — not necessarily
309
+ the machine running the tool.
310
+
311
+ ## Development
312
+
313
+ ```bash
314
+ pip install -e ".[dev]"
315
+ pytest -q
316
+ ```
317
+
318
+ ## Releasing
319
+
320
+ The **git tag is the version**. `setuptools-scm` derives it at build time, so
321
+ there is no version to bump in a file and nothing that can disagree with the
322
+ tag:
323
+
324
+ ```bash
325
+ git tag v0.1.1
326
+ git push origin v0.1.1
327
+ ```
328
+
329
+ That runs the tests, builds, verifies the built version matches the tag, and
330
+ publishes to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
331
+ — no API token is stored anywhere — then attaches the artifacts to a GitHub
332
+ Release.
333
+
334
+ To rehearse the whole path without spending a version number, run the Release
335
+ workflow manually from the Actions tab: a `workflow_dispatch` publishes to
336
+ **TestPyPI** instead of PyPI.
337
+
338
+ A build from an untagged commit gets a `.devN+g<sha>` suffix, and the release
339
+ job refuses to publish it under a real version number.
340
+
341
+ ## License
342
+
343
+ MIT — see [LICENSE](LICENSE).