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.
- message_poster-0.1.0/.github/workflows/ci.yml +86 -0
- message_poster-0.1.0/.github/workflows/release.yml +114 -0
- message_poster-0.1.0/.gitignore +19 -0
- message_poster-0.1.0/LICENSE +21 -0
- message_poster-0.1.0/PKG-INFO +343 -0
- message_poster-0.1.0/README.md +321 -0
- message_poster-0.1.0/pyproject.toml +51 -0
- message_poster-0.1.0/setup.cfg +4 -0
- message_poster-0.1.0/src/message_poster/__init__.py +10 -0
- message_poster-0.1.0/src/message_poster/_version.py +24 -0
- message_poster-0.1.0/src/message_poster/auth.py +171 -0
- message_poster-0.1.0/src/message_poster/cli.py +234 -0
- message_poster-0.1.0/src/message_poster/collect.py +220 -0
- message_poster-0.1.0/src/message_poster/config.example.json +19 -0
- message_poster-0.1.0/src/message_poster/render.py +171 -0
- message_poster-0.1.0/src/message_poster/webhook.py +112 -0
- message_poster-0.1.0/src/message_poster.egg-info/PKG-INFO +343 -0
- message_poster-0.1.0/src/message_poster.egg-info/SOURCES.txt +23 -0
- message_poster-0.1.0/src/message_poster.egg-info/dependency_links.txt +1 -0
- message_poster-0.1.0/src/message_poster.egg-info/entry_points.txt +2 -0
- message_poster-0.1.0/src/message_poster.egg-info/requires.txt +5 -0
- message_poster-0.1.0/src/message_poster.egg-info/scm_file_list.json +18 -0
- message_poster-0.1.0/src/message_poster.egg-info/scm_version.json +8 -0
- message_poster-0.1.0/src/message_poster.egg-info/top_level.txt +1 -0
- message_poster-0.1.0/tests/test_render.py +324 -0
|
@@ -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).
|