message-poster 0.1.0__py3-none-any.whl

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,10 @@
1
+ """message-poster — pick up email and Teams events via OAuth, POST to a webhook."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _version
4
+
5
+ try:
6
+ __version__ = _version("message-poster")
7
+ except PackageNotFoundError: # running from a source tree that was never installed
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ USER_AGENT = "message-poster/" + __version__
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
message_poster/auth.py ADDED
@@ -0,0 +1,171 @@
1
+ """
2
+ Microsoft Graph auth and HTTP, via MSAL device-code flow.
3
+
4
+ Device code is the default on purpose:
5
+ - it needs no redirect URI and no listening socket, so it works on managed
6
+ machines and under a scheduled task;
7
+ - the browser step can be completed in whichever session the tenant's
8
+ Conditional Access policy is willing to accept, which is not necessarily
9
+ the machine running this code.
10
+
11
+ After the first sign-in the refresh token keeps later runs silent. Conditional
12
+ Access can force periodic re-auth; when that happens the silent path returns
13
+ nothing and we say so plainly rather than failing obscurely.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import time
21
+ from pathlib import Path
22
+
23
+ import msal
24
+ import requests
25
+
26
+ from . import USER_AGENT # version-tracking, single definition
27
+
28
+ GRAPH = "https://graph.microsoft.com/v1.0"
29
+
30
+ # Delegated, read-only. This tool must never be able to send, delete or modify
31
+ # anything in the source tenant.
32
+ SCOPES = [
33
+ "Mail.Read",
34
+ "Chat.Read",
35
+ "ChannelMessage.Read.All",
36
+ "Team.ReadBasic.All",
37
+ "User.Read",
38
+ ]
39
+
40
+
41
+ def cache_path(config_dir: Path, profile: str) -> Path:
42
+ return config_dir / f".token-cache-{profile}.json"
43
+
44
+
45
+ def _build_app(cfg: dict, config_dir: Path, profile: str):
46
+ cache = msal.SerializableTokenCache()
47
+ p = cache_path(config_dir, profile)
48
+ if p.is_file():
49
+ cache.deserialize(p.read_text(encoding="utf-8"))
50
+
51
+ authority = "https://login.microsoftonline.com/" + cfg.get(
52
+ "azure_tenant_id", "organizations"
53
+ )
54
+ app = msal.PublicClientApplication(
55
+ client_id=cfg["azure_client_id"], authority=authority, token_cache=cache
56
+ )
57
+ return app, cache
58
+
59
+
60
+ def _persist(cache, config_dir: Path, profile: str) -> None:
61
+ if cache.has_state_changed:
62
+ p = cache_path(config_dir, profile)
63
+ tmp = p.with_suffix(".tmp")
64
+ tmp.write_text(cache.serialize(), encoding="utf-8")
65
+ os.replace(tmp, p)
66
+ try:
67
+ os.chmod(p, 0o600) # best effort; a no-op on some filesystems
68
+ except OSError:
69
+ pass
70
+
71
+
72
+ def get_token(cfg: dict, config_dir: Path, profile: str, *, interactive: bool = False) -> str:
73
+ app, cache = _build_app(cfg, config_dir, profile)
74
+
75
+ accounts = app.get_accounts()
76
+ if accounts and not interactive:
77
+ result = app.acquire_token_silent(SCOPES, account=accounts[0])
78
+ if result and "access_token" in result:
79
+ _persist(cache, config_dir, profile)
80
+ return result["access_token"]
81
+
82
+ if not interactive:
83
+ raise RuntimeError(
84
+ f"no usable cached token — run: message-poster login --profile {profile}\n"
85
+ "(Conditional Access can force periodic re-auth; this is that.)"
86
+ )
87
+
88
+ flow = app.initiate_device_flow(scopes=SCOPES)
89
+ if "user_code" not in flow:
90
+ raise RuntimeError(
91
+ f"device flow failed to start: {flow.get('error_description')}"
92
+ )
93
+
94
+ print("\n" + "=" * 68)
95
+ print(f" Go to: {flow['verification_uri']}")
96
+ print(f" Code: {flow['user_code']}")
97
+ print("=" * 68)
98
+ print(" Complete this in a browser session the tenant's Conditional")
99
+ print(" Access policy will accept. Waiting...\n", flush=True)
100
+
101
+ result = app.acquire_token_by_device_flow(flow) # blocks until done/expired
102
+ if "access_token" not in result:
103
+ raise RuntimeError(
104
+ f"sign-in failed: {result.get('error')}: {result.get('error_description')}"
105
+ )
106
+ _persist(cache, config_dir, profile)
107
+ return result["access_token"]
108
+
109
+
110
+ class GraphClient:
111
+ """Minimal Graph client: GET with paging, throttling and retry."""
112
+
113
+ def __init__(self, cfg: dict, config_dir: Path, profile: str):
114
+ self.cfg = cfg
115
+ self.config_dir = config_dir
116
+ self.profile = profile
117
+ self._token = None
118
+ self._fetched_at = 0.0
119
+ self.session = requests.Session()
120
+ self.session.headers["User-Agent"] = USER_AGENT
121
+
122
+ def _auth_header(self) -> dict:
123
+ # Tokens last ~1h; refresh early rather than racing expiry mid-run.
124
+ if not self._token or time.time() - self._fetched_at > 2400:
125
+ self._token = get_token(self.cfg, self.config_dir, self.profile)
126
+ self._fetched_at = time.time()
127
+ return {"Authorization": f"Bearer {self._token}"}
128
+
129
+ def get(self, path: str, params: dict | None = None) -> dict:
130
+ url = path if path.startswith("http") else GRAPH + path
131
+ for attempt in range(1, 5):
132
+ r = self.session.get(
133
+ url, headers=self._auth_header(), params=params, timeout=60
134
+ )
135
+
136
+ if r.status_code == 200:
137
+ return r.json()
138
+
139
+ # Graph throttles hard on a full-mailbox walk. Honour Retry-After.
140
+ if r.status_code == 429:
141
+ wait = int(r.headers.get("Retry-After", 2 ** attempt))
142
+ print(f" throttled, waiting {wait}s", flush=True)
143
+ time.sleep(wait)
144
+ continue
145
+
146
+ if r.status_code == 401 and attempt == 1:
147
+ self._token = None # force one refresh, then give up
148
+ continue
149
+
150
+ if r.status_code in (500, 502, 503, 504):
151
+ time.sleep(2 ** attempt)
152
+ continue
153
+
154
+ raise RuntimeError(f"GET {url} -> {r.status_code}: {r.text[:300]}")
155
+
156
+ raise RuntimeError(f"GET {url}: gave up after retries")
157
+
158
+ def paged(self, path: str, params: dict | None = None, cap: int = 1000):
159
+ """Yield items across @odata.nextLink pages, up to cap."""
160
+ seen = 0
161
+ data = self.get(path, params)
162
+ while True:
163
+ for item in data.get("value", []):
164
+ yield item
165
+ seen += 1
166
+ if seen >= cap:
167
+ return
168
+ nxt = data.get("@odata.nextLink")
169
+ if not nxt:
170
+ return
171
+ data = self.get(nxt) # nextLink already carries the query string
message_poster/cli.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ message-poster — pick up email and Teams events, POST them to a webhook.
3
+
4
+ message-poster login --profile work
5
+ message-poster whoami --profile work
6
+ message-poster run --profile work --dry-run
7
+ message-poster run --profile work
8
+
9
+ State lives next to the config file: a watermark per profile, so each run only
10
+ picks up what is new. On a first run, with no saved watermark, the window
11
+ defaults to the last 24 hours rather than the whole mailbox — a fresh install
12
+ should never start by hauling years of history.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from datetime import datetime, timedelta, timezone
22
+ from pathlib import Path
23
+
24
+ from . import collect as collect_mod
25
+ from .auth import GraphClient, get_token
26
+ from .render import Filters, newest_timestamp
27
+ from .webhook import build_payload, chunked, post_batch
28
+
29
+ ISO = "%Y-%m-%dT%H:%M:%SZ"
30
+ DEFAULT_LOOKBACK_HOURS = 24
31
+
32
+
33
+ def default_config_dir() -> Path:
34
+ env = os.environ.get("MESSAGE_POSTER_HOME")
35
+ if env:
36
+ return Path(env)
37
+ base = os.environ.get("APPDATA") # Windows
38
+ if base:
39
+ return Path(base) / "message-poster"
40
+ return Path(os.path.expanduser("~")) / ".config" / "message-poster"
41
+
42
+
43
+ def log(msg):
44
+ stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
45
+ print("[" + stamp + "] " + str(msg), flush=True)
46
+
47
+
48
+ def load_json(path: Path, default=None):
49
+ if path.is_file():
50
+ try:
51
+ return json.loads(path.read_text(encoding="utf-8"))
52
+ except json.JSONDecodeError:
53
+ log("WARNING: " + path.name + " is not valid JSON")
54
+ return default
55
+
56
+
57
+ def save_json(path: Path, data) -> None:
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ tmp = path.with_suffix(".tmp")
60
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
61
+ os.replace(tmp, path)
62
+
63
+
64
+ def resolve_since(state_entry, args) -> str:
65
+ """Work out the window start.
66
+
67
+ Priority: explicit --since, then the saved watermark, then a 24h default.
68
+ The default matters: without it a first run would either fetch everything
69
+ or need a flag nobody remembers to pass.
70
+ """
71
+ if getattr(args, "since", None):
72
+ return args.since
73
+ saved = (state_entry or {}).get("watermark")
74
+ if saved:
75
+ return saved
76
+ hours = getattr(args, "lookback_hours", None) or DEFAULT_LOOKBACK_HOURS
77
+ start = datetime.now(timezone.utc) - timedelta(hours=hours)
78
+ log("no saved watermark — defaulting to the last " + str(hours) + "h")
79
+ return start.strftime(ISO)
80
+
81
+
82
+ def cmd_login(cfg, cfg_dir, args) -> int:
83
+ get_token(cfg, cfg_dir, args.profile, interactive=True)
84
+ log("signed in")
85
+ return cmd_whoami(cfg, cfg_dir, args)
86
+
87
+
88
+ def cmd_whoami(cfg, cfg_dir, args) -> int:
89
+ me = GraphClient(cfg, cfg_dir, args.profile).get("/me")
90
+ print(" " + str(me.get("displayName")) + " <" + str(me.get("userPrincipalName")) + ">")
91
+ return 0
92
+
93
+
94
+ def cmd_run(cfg, cfg_dir, args) -> int:
95
+ state_path = cfg_dir / "state.json"
96
+ state = load_json(state_path, {}) or {}
97
+ entry = state.setdefault(args.profile, {})
98
+
99
+ since = resolve_since(entry, args)
100
+ log("profile=" + args.profile + " what=" + args.what + " since=" + since
101
+ + (" DRY-RUN" if args.dry_run else ""))
102
+
103
+ filters = Filters(cfg)
104
+ gc = GraphClient(cfg, cfg_dir, args.profile)
105
+
106
+ items = []
107
+ try:
108
+ if args.what in ("chats", "all"):
109
+ items += collect_mod.collect_chats(gc, filters, since, cfg, log)
110
+ if args.what in ("channels", "all"):
111
+ items += collect_mod.collect_channels(gc, filters, since, cfg, log)
112
+ if args.what in ("mail", "all"):
113
+ items += collect_mod.collect_mail(gc, filters, since, cfg, log)
114
+ except RuntimeError as e:
115
+ log("FATAL: " + str(e))
116
+ log("If this is an auth error: message-poster login --profile " + args.profile)
117
+ return 1
118
+
119
+ if filters.counts:
120
+ log("excluded by filter: " + json.dumps(filters.counts))
121
+
122
+ # Advance to the newest item actually seen, not wall-clock now: anything
123
+ # arriving mid-run would otherwise be skipped forever.
124
+ watermark = newest_timestamp(items) or since
125
+
126
+ if not items:
127
+ log("nothing new")
128
+ entry["watermark"] = watermark
129
+ entry["last_run"] = datetime.now(timezone.utc).strftime(ISO)
130
+ save_json(state_path, state)
131
+ return 0
132
+
133
+ log(str(len(items)) + " item(s) to post")
134
+
135
+ if args.dry_run:
136
+ out = cfg_dir / "dry-run"
137
+ out.mkdir(parents=True, exist_ok=True)
138
+ for it in items:
139
+ (out / it["filename"]).write_text(it["rendered"], encoding="utf-8")
140
+ log("DRY RUN: wrote " + str(len(items)) + " file(s) to " + str(out)
141
+ + " — nothing posted")
142
+ return 0
143
+
144
+ batches = list(chunked(items, cfg.get("max_batch_bytes", 32 * 1024 * 1024)))
145
+ written = 0
146
+ skipped = 0
147
+
148
+ for i, batch in enumerate(batches, 1):
149
+ payload = build_payload(
150
+ args.profile, batch, watermark,
151
+ complete=(i == len(batches)), excluded=filters.counts,
152
+ )
153
+ try:
154
+ res = post_batch(cfg, payload, log)
155
+ except RuntimeError as e:
156
+ log("FATAL: " + str(e))
157
+ log("watermark NOT advanced — the next run retries this window")
158
+ save_json(state_path, state)
159
+ return 1
160
+ written += res.get("written", 0) or 0
161
+ skipped += res.get("skipped_unchanged", 0) or 0
162
+ log(" batch " + str(i) + "/" + str(len(batches))
163
+ + ": " + json.dumps({k: res.get(k) for k in
164
+ ("written", "skipped_unchanged", "rejected")}))
165
+
166
+ # Only advance once every batch has landed.
167
+ entry["watermark"] = watermark
168
+ entry["last_run"] = datetime.now(timezone.utc).strftime(ISO)
169
+ entry["last_result"] = {"written": written, "skipped": skipped}
170
+ save_json(state_path, state)
171
+ log("done: written=" + str(written) + " skipped=" + str(skipped)
172
+ + "; watermark -> " + watermark)
173
+ return 0
174
+
175
+
176
+ def main(argv=None) -> int:
177
+ ap = argparse.ArgumentParser(
178
+ prog="message-poster",
179
+ description="Pick up email and Teams events via OAuth, POST them to a webhook.",
180
+ )
181
+ ap.add_argument("--config-dir", type=Path, default=default_config_dir(),
182
+ help="where config.json, state.json and token caches live")
183
+ sub = ap.add_subparsers(dest="command", required=True)
184
+
185
+ def common(p):
186
+ p.add_argument("--profile", default="default",
187
+ help="named account/config to use (default: default)")
188
+
189
+ p_login = sub.add_parser("login", help="interactive device-code sign-in")
190
+ common(p_login)
191
+
192
+ p_who = sub.add_parser("whoami", help="verify the cached token")
193
+ common(p_who)
194
+
195
+ p_run = sub.add_parser("run", help="collect events and post them")
196
+ common(p_run)
197
+ p_run.add_argument("--what", choices=["chats", "channels", "mail", "all"],
198
+ default="all")
199
+ p_run.add_argument("--dry-run", action="store_true",
200
+ help="render locally and post nothing")
201
+ p_run.add_argument("--since", help="ISO8601 start; overrides the saved watermark")
202
+ p_run.add_argument("--lookback-hours", type=int, default=DEFAULT_LOOKBACK_HOURS,
203
+ help="window to use when no watermark is saved (default: 24)")
204
+
205
+ args = ap.parse_args(argv)
206
+
207
+ cfg_dir = args.config_dir
208
+ cfg_path = cfg_dir / "config.json"
209
+ cfg = load_json(cfg_path)
210
+ if not cfg:
211
+ print("no config at " + str(cfg_path), file=sys.stderr)
212
+ print("create it — see the example in the README", file=sys.stderr)
213
+ return 2
214
+
215
+ required = ["azure_client_id"]
216
+ if args.command == "run" and not args.dry_run:
217
+ required += ["webhook_url", "hmac_secret"]
218
+ missing = [k for k in required if not cfg.get(k)]
219
+ if missing:
220
+ print("config missing: " + ", ".join(missing), file=sys.stderr)
221
+ return 2
222
+
223
+ handlers = {"login": cmd_login, "whoami": cmd_whoami, "run": cmd_run}
224
+ try:
225
+ return handlers[args.command](cfg, cfg_dir, args)
226
+ except RuntimeError as e:
227
+ print("ERROR: " + str(e), file=sys.stderr)
228
+ return 1
229
+ except KeyboardInterrupt:
230
+ return 130
231
+
232
+
233
+ if __name__ == "__main__":
234
+ sys.exit(main())
@@ -0,0 +1,220 @@
1
+ """
2
+ Collect message events from Microsoft Graph.
3
+
4
+ Endpoints used (all delegated, all read-only):
5
+
6
+ GET /me/chats ?$expand=members
7
+ GET /me/chats/{id}/messages
8
+ GET /me/joinedTeams
9
+ GET /teams/{id}/channels
10
+ GET /teams/{id}/channels/{id}/messages
11
+ GET /me/mailFolders
12
+ GET /me/messages ?$filter=receivedDateTime ge ...
13
+
14
+ Every function returns items in the shape the webhook receives:
15
+ kind, source_id, filename, rendered.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+
22
+ from .render import Filters, render_chat, render_email, slug
23
+
24
+
25
+ def _hash8(value):
26
+ return hashlib.sha1(value.encode()).hexdigest()[:8]
27
+
28
+
29
+ def _chat_title(chat, me_id=None):
30
+ """Group chats carry a topic; 1:1 chats do not, so name them by participant."""
31
+ if chat.get("topic"):
32
+ return chat["topic"]
33
+ names = [
34
+ m.get("displayName")
35
+ for m in (chat.get("members") or [])
36
+ if m.get("displayName") and m.get("userId") != me_id
37
+ ]
38
+ if names:
39
+ extra = len(names) - 3
40
+ return ", ".join(names[:3]) + (" +" + str(extra) if extra > 0 else "")
41
+ return "Direct chat"
42
+
43
+
44
+ def _readable(chat):
45
+ """Skip calendar-derived stubs the caller cannot actually read.
46
+
47
+ Graph returns these with chatType 'unknownFutureValue' and no members;
48
+ fetching their messages fails with an ACL error.
49
+ """
50
+ return not (chat.get("chatType") == "unknownFutureValue" and not chat.get("members"))
51
+
52
+
53
+ def collect_chats(gc, filters, since, cfg, log=print):
54
+ items = []
55
+ me_id = None
56
+ try:
57
+ me_id = gc.get("/me").get("id")
58
+ except RuntimeError:
59
+ pass # only used to prettify 1:1 titles
60
+
61
+ chats = list(
62
+ gc.paged("/me/chats", {"$expand": "members", "$top": 50},
63
+ cap=cfg.get("max_chats_per_run", 300))
64
+ )
65
+ log("chats: " + str(len(chats)) + " visible")
66
+
67
+ skipped_stub = 0
68
+ skipped_old = 0
69
+ for chat in chats:
70
+ cid = chat.get("id", "")
71
+ if not cid or filters.skip_source(cid):
72
+ continue
73
+ if not _readable(chat):
74
+ skipped_stub += 1
75
+ continue
76
+ if since and (chat.get("lastUpdatedDateTime") or "") <= since:
77
+ skipped_old += 1
78
+ continue
79
+
80
+ try:
81
+ msgs = list(
82
+ gc.paged("/me/chats/" + cid + "/messages",
83
+ {"$top": 50, "$orderby": "createdDateTime desc"},
84
+ cap=cfg.get("max_messages_per_chat", 200))
85
+ )
86
+ except RuntimeError as e:
87
+ log(" skip " + cid[:28] + ": " + str(e))
88
+ continue
89
+
90
+ msgs = [m for m in msgs if (m.get("body") or {}).get("content")]
91
+ if not msgs:
92
+ continue
93
+ msgs.sort(key=lambda m: m.get("createdDateTime") or "")
94
+
95
+ topic = _chat_title(chat, me_id)
96
+ rendered = render_chat(topic, "teams", msgs, filters)
97
+ if rendered.count("\n") < 3:
98
+ continue # nothing but system events
99
+
100
+ items.append({
101
+ "kind": "chat",
102
+ "source_id": cid,
103
+ "filename": "chat-" + slug(topic) + "-" + _hash8(cid) + ".md",
104
+ "rendered": rendered,
105
+ })
106
+ log(" + " + topic[:52] + " (" + str(len(msgs)) + " msgs)")
107
+
108
+ if skipped_stub or skipped_old:
109
+ log(" (" + str(skipped_stub) + " unreadable stubs, "
110
+ + str(skipped_old) + " unchanged)")
111
+ return items
112
+
113
+
114
+ def collect_channels(gc, filters, since, cfg, log=print):
115
+ """Team channel posts — often where decisions land, unlike 1:1 chat."""
116
+ items = []
117
+ try:
118
+ teams = list(gc.paged("/me/joinedTeams", cap=50))
119
+ except RuntimeError as e:
120
+ log("channels: skipped (" + str(e) + ")")
121
+ return items
122
+ log("teams: " + str(len(teams)))
123
+
124
+ for team in teams:
125
+ tid = team.get("id")
126
+ tname = team.get("displayName") or "team"
127
+ if not tid:
128
+ continue
129
+ try:
130
+ channels = list(gc.paged("/teams/" + tid + "/channels", cap=50))
131
+ except RuntimeError as e:
132
+ log(" " + tname + ": no channels (" + str(e) + ")")
133
+ continue
134
+
135
+ for ch in channels:
136
+ chid = ch.get("id")
137
+ chname = ch.get("displayName") or "channel"
138
+ key = tid + "/" + str(chid)
139
+ if not chid or filters.skip_source(key):
140
+ continue
141
+ try:
142
+ msgs = list(
143
+ gc.paged("/teams/" + tid + "/channels/" + chid + "/messages",
144
+ {"$top": 50},
145
+ cap=cfg.get("max_messages_per_chat", 200))
146
+ )
147
+ except RuntimeError as e:
148
+ log(" " + tname + "/" + chname + ": " + str(e))
149
+ continue
150
+
151
+ msgs = [m for m in msgs if (m.get("body") or {}).get("content")]
152
+ if since:
153
+ msgs = [m for m in msgs if (m.get("createdDateTime") or "") > since]
154
+ if not msgs:
155
+ continue
156
+ msgs.sort(key=lambda m: m.get("createdDateTime") or "")
157
+
158
+ topic = tname + " / " + chname
159
+ rendered = render_chat(topic, "teams", msgs, filters)
160
+ if rendered.count("\n") < 3:
161
+ continue
162
+
163
+ items.append({
164
+ "kind": "chat",
165
+ "source_id": key,
166
+ "filename": "chat-" + slug(topic) + "-" + _hash8(key) + ".md",
167
+ "rendered": rendered,
168
+ })
169
+ log(" + " + topic[:52] + " (" + str(len(msgs)) + " msgs)")
170
+ return items
171
+
172
+
173
+ def collect_mail(gc, filters, since, cfg, log=print):
174
+ items = []
175
+ account = cfg.get("account", "unknown")
176
+
177
+ # Resolve excluded folder names to ids, so exclusion can be checked against
178
+ # each message's parentFolderId.
179
+ excluded_ids = set()
180
+ try:
181
+ for f in gc.paged("/me/mailFolders", {"$top": 100}, cap=200):
182
+ if filters.skip_folder(f.get("displayName", "")):
183
+ excluded_ids.add(f.get("id"))
184
+ except RuntimeError as e:
185
+ log("mailFolders unavailable (" + str(e) + "); folder excludes not applied")
186
+
187
+ params = {
188
+ "$top": 50,
189
+ "$orderby": "receivedDateTime desc",
190
+ "$select": ("id,subject,from,toRecipients,receivedDateTime,"
191
+ "body,bodyPreview,parentFolderId,hasAttachments"),
192
+ }
193
+ if since:
194
+ params["$filter"] = "receivedDateTime ge " + since
195
+
196
+ raw = list(gc.paged("/me/messages", params, cap=cfg.get("max_mail_per_run", 500)))
197
+ log("mail: " + str(len(raw)) + " candidates")
198
+
199
+ for m in raw:
200
+ mid = m.get("id", "")
201
+ if not mid or filters.skip_source(mid):
202
+ continue
203
+ if m.get("parentFolderId") in excluded_ids:
204
+ filters.bump("folder:excluded")
205
+ continue
206
+ frm = (m.get("from") or {}).get("emailAddress", {}).get("address", "")
207
+ if filters.skip_sender(frm):
208
+ continue
209
+
210
+ stamp = (m.get("receivedDateTime") or "")[:10].replace("-", "")
211
+ subj = slug(m.get("subject") or "no-subject", 40)
212
+ items.append({
213
+ "kind": "email",
214
+ "source_id": mid,
215
+ "filename": "mail-" + stamp + "-" + subj + "-" + _hash8(mid) + ".md",
216
+ "rendered": render_email(m, account, filters),
217
+ })
218
+
219
+ log("mail: " + str(len(items)) + " after filters")
220
+ return items
@@ -0,0 +1,19 @@
1
+ {
2
+ "azure_client_id": "<application id from your Azure app registration>",
3
+ "azure_tenant_id": "organizations",
4
+ "account": "you@example.com",
5
+ "webhook_url": "https://ingest.example.com/ingest",
6
+ "hmac_secret": "<shared secret, same value the receiver holds>",
7
+ "webhook_headers": {},
8
+ "max_mail_per_run": 500,
9
+ "max_chats_per_run": 300,
10
+ "max_messages_per_chat": 200,
11
+ "max_batch_bytes": 33554432,
12
+ "gzip_over_bytes": 65536,
13
+ "filters": {
14
+ "exclude_folders": ["Junk Email", "Deleted Items"],
15
+ "exclude_sender_patterns": ["payroll@", "noreply@"],
16
+ "optout_source_ids": [],
17
+ "redact_patterns": []
18
+ }
19
+ }
@@ -0,0 +1,171 @@
1
+ """
2
+ Render Graph objects to markdown, and filter what is allowed to leave.
3
+
4
+ Rendering happens client-side so that only the text which will actually be
5
+ posted ever leaves the tenant: no raw API payloads, no directory GUIDs, no
6
+ delta tokens, no attachment URLs.
7
+
8
+ Two output shapes, both plain text and both trivially greppable:
9
+
10
+ chat -> chat-<slug>-<hash8>.md
11
+ # Chat: <topic> (<channel>)
12
+ [YYYY-MM-DDTHH:MM] Sender: message
13
+
14
+ email -> mail-<YYYYMMDD>-<slug>-<hash8>.md
15
+ From:/To:/Subject:/Date:/Account: headers, blank line, body
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import unicodedata
22
+
23
+
24
+ def slug(value: str, maxlen: int = 50) -> str:
25
+ value = unicodedata.normalize("NFKD", value or "")
26
+ value = value.encode("ascii", "ignore").decode("ascii")
27
+ value = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-").lower()
28
+ return value[:maxlen] or "untitled"
29
+
30
+
31
+ def html_to_text(html: str) -> str:
32
+ """Chat and mail bodies are HTML. Keep the text, drop the markup."""
33
+ if not html:
34
+ return ""
35
+ s = re.sub(r"(?is)<(script|style).*?</\1>", "", html)
36
+ s = re.sub(r"(?i)<br\s*/?>", "\n", s)
37
+ s = re.sub(r"(?i)</(p|div|li|tr)>", "\n", s)
38
+ s = re.sub(r"(?i)<li[^>]*>", "- ", s)
39
+ s = re.sub(r"<[^>]+>", "", s)
40
+ for a, b in (
41
+ ("&nbsp;", " "), ("&amp;", "&"), ("&lt;", "<"),
42
+ ("&gt;", ">"), ("&quot;", '"'), ("&#39;", "'"),
43
+ ):
44
+ s = s.replace(a, b)
45
+ return re.sub(r"\n{3,}", "\n\n", s).strip()
46
+
47
+
48
+ class Filters:
49
+ """Deny-list applied before anything is rendered or posted.
50
+
51
+ When mirroring a whole mailbox the failure mode inverts: anything NOT
52
+ excluded here gets sent. These rules are the safety mechanism, not an
53
+ optimisation, and deserve a deliberate read before an unattended run.
54
+ """
55
+
56
+ def __init__(self, cfg: dict):
57
+ f = (cfg or {}).get("filters", {})
58
+ self.folders = {x.lower() for x in f.get("exclude_folders", [])}
59
+ self.senders = [x.lower() for x in f.get("exclude_sender_patterns", [])]
60
+ self.optout = set(f.get("optout_source_ids", []))
61
+ self.redactions = [re.compile(p, re.I) for p in f.get("redact_patterns", [])]
62
+ self.counts: dict = {}
63
+
64
+ def bump(self, rule: str) -> None:
65
+ self.counts[rule] = self.counts.get(rule, 0) + 1
66
+
67
+ def skip_folder(self, name: str) -> bool:
68
+ if (name or "").lower() in self.folders:
69
+ self.bump("folder:" + str(name))
70
+ return True
71
+ return False
72
+
73
+ def skip_source(self, source_id: str) -> bool:
74
+ if source_id in self.optout:
75
+ self.bump("optout")
76
+ return True
77
+ return False
78
+
79
+ def skip_sender(self, addr: str) -> bool:
80
+ a = (addr or "").lower()
81
+ for pat in self.senders:
82
+ if pat in a:
83
+ self.bump("sender:" + pat)
84
+ return True
85
+ return False
86
+
87
+ def redact(self, text: str) -> str:
88
+ for rx in self.redactions:
89
+ text, n = rx.subn("[REDACTED]", text)
90
+ if n:
91
+ self.bump("redacted")
92
+ return text
93
+
94
+
95
+ def render_chat(topic: str, channel: str, messages: list, filters: Filters) -> str:
96
+ lines = ["# Chat: " + (topic or "Untitled") + " (" + channel + ")", ""]
97
+ for m in messages:
98
+ ts = (m.get("createdDateTime") or "")[:16] # YYYY-MM-DDTHH:MM
99
+ if not ts:
100
+ continue
101
+ frm = m.get("from") or {}
102
+ sender = (
103
+ (frm.get("user") or {}).get("displayName")
104
+ or (frm.get("application") or {}).get("displayName")
105
+ or "Unknown"
106
+ )
107
+ body = html_to_text((m.get("body") or {}).get("content", ""))
108
+ if not body.strip():
109
+ continue # system events and reactions carry no text
110
+ body = filters.redact(body)
111
+
112
+ att = m.get("attachments") or []
113
+ if att:
114
+ names = ", ".join(
115
+ a.get("name") or a.get("contentType") or "file" for a in att
116
+ )
117
+ body += "\n[attachments: " + names + "]" # metadata only, never bytes
118
+
119
+ lines.append("[" + ts + "] " + sender + ": " + body.replace("\n", "\n "))
120
+ lines.append("")
121
+ return "\n".join(lines).rstrip() + "\n"
122
+
123
+
124
+ def render_email(msg: dict, account: str, filters: Filters) -> str:
125
+ frm = (msg.get("from") or {}).get("emailAddress", {})
126
+ to = ", ".join(
127
+ r.get("emailAddress", {}).get("address", "")
128
+ for r in (msg.get("toRecipients") or [])
129
+ )
130
+ body = html_to_text((msg.get("body") or {}).get("content", "")) or (
131
+ msg.get("bodyPreview") or ""
132
+ )
133
+ body = filters.redact(body)
134
+
135
+ hdr = [
136
+ "From: " + str(frm.get("name", "")) + " <" + str(frm.get("address", "")) + ">",
137
+ "To: " + to,
138
+ "Subject: " + str(msg.get("subject", "(no subject)")),
139
+ "Date: " + str(msg.get("receivedDateTime", "")),
140
+ "Account: " + account,
141
+ ]
142
+ att = msg.get("attachments") or []
143
+ if att:
144
+ hdr.append(
145
+ "Attachments: "
146
+ + ", ".join(
147
+ str(a.get("name", "file")) + " (" + str(a.get("size", "?")) + "b)"
148
+ for a in att
149
+ )
150
+ )
151
+ return "\n".join(hdr) + "\n\n" + body.strip() + "\n"
152
+
153
+
154
+ def newest_timestamp(items: list):
155
+ """Highest timestamp actually seen — a safer watermark than wall-clock now.
156
+
157
+ Using now() risks skipping anything that arrives while the run is in
158
+ flight; using the newest item actually processed cannot.
159
+ """
160
+ best = None
161
+ for it in items:
162
+ for line in it["rendered"].splitlines():
163
+ if line.startswith("[") and len(line) > 17:
164
+ ts = line[1:17]
165
+ elif line.startswith("Date: "):
166
+ ts = line[6:].strip()[:16]
167
+ else:
168
+ continue
169
+ if best is None or ts > best:
170
+ best = ts
171
+ return (best + ":00Z") if best and len(best) == 16 else None
@@ -0,0 +1,112 @@
1
+ """
2
+ POST collected events to a webhook.
3
+
4
+ The payload is deliberately plain: a profile name, a run id, a watermark, and a
5
+ list of items each carrying pre-rendered markdown. What the receiver does with
6
+ them — store, index, forward, discard — is none of this tool's business.
7
+
8
+ Every request is signed with HMAC-SHA256 over "<timestamp>.<raw body>" so the
9
+ receiver can verify both origin and integrity, and reject replays. Optional
10
+ extra headers (for an identity-aware proxy in front of the receiver) are passed
11
+ through verbatim from config.
12
+
13
+ NOTE: requests, never urllib. Some proxies and WAFs reject unknown or absent
14
+ User-Agent strings outright, which surfaces as a confusing 403 that looks like
15
+ an auth failure. A real session with an explicit User-Agent avoids it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import gzip
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import os
25
+ import time
26
+
27
+ import requests
28
+
29
+ from . import USER_AGENT # version-tracking, single definition
30
+
31
+
32
+ def build_payload(profile, items, watermark, complete=True, excluded=None):
33
+ run_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "-" + os.urandom(3).hex()
34
+ return {
35
+ "profile": profile,
36
+ "run_id": run_id,
37
+ "items": items,
38
+ "watermark": watermark,
39
+ "complete": complete,
40
+ "excluded_by_filter": excluded or {},
41
+ }
42
+
43
+
44
+ def sign(secret, body, timestamp):
45
+ """HMAC-SHA256 over '<timestamp>.<body>'.
46
+
47
+ Binding the timestamp into the signed bytes is what makes a replay window
48
+ meaningful: an old body cannot be re-sent with a fresh timestamp without
49
+ invalidating the signature.
50
+ """
51
+ return "sha256=" + hmac.new(
52
+ secret.encode(), str(timestamp).encode() + b"." + body, hashlib.sha256
53
+ ).hexdigest()
54
+
55
+
56
+ def post_batch(cfg, payload, log=print):
57
+ body = json.dumps(payload).encode()
58
+
59
+ headers = {
60
+ "Content-Type": "application/json",
61
+ "User-Agent": USER_AGENT,
62
+ }
63
+ headers.update(cfg.get("webhook_headers") or {})
64
+
65
+ if len(body) > cfg.get("gzip_over_bytes", 65536):
66
+ body = gzip.compress(body)
67
+ headers["Content-Encoding"] = "gzip"
68
+
69
+ ts = int(time.time())
70
+ headers["X-Timestamp"] = str(ts)
71
+ headers["X-Signature"] = sign(cfg["hmac_secret"], body, ts)
72
+
73
+ url = cfg["webhook_url"]
74
+ last = None
75
+ for attempt in range(1, 4):
76
+ try:
77
+ r = requests.post(url, data=body, headers=headers, timeout=60)
78
+ if r.status_code in (200, 207):
79
+ try:
80
+ return r.json()
81
+ except ValueError:
82
+ return {"status_code": r.status_code, "text": r.text[:200]}
83
+ # A 4xx other than 429 is our own bug; retrying will not fix it.
84
+ if 400 <= r.status_code < 500 and r.status_code != 429:
85
+ raise RuntimeError(
86
+ "HTTP " + str(r.status_code) + ": " + r.text[:200]
87
+ )
88
+ last = "HTTP " + str(r.status_code) + ": " + r.text[:120]
89
+ except requests.RequestException as e:
90
+ last = str(e)
91
+ if attempt < 3:
92
+ wait = 2 ** attempt
93
+ log(" retry " + str(attempt) + "/3 in " + str(wait) + "s (" + str(last) + ")")
94
+ time.sleep(wait)
95
+
96
+ raise RuntimeError("upload failed after 3 attempts: " + str(last))
97
+
98
+
99
+ def chunked(items, max_bytes):
100
+ """Split so no single POST exceeds the receiver's body cap."""
101
+ batch = []
102
+ size = 0
103
+ for it in items:
104
+ n = len(it["rendered"].encode()) + 400
105
+ if batch and size + n > max_bytes:
106
+ yield batch
107
+ batch = []
108
+ size = 0
109
+ batch.append(it)
110
+ size += n
111
+ if batch:
112
+ yield batch
@@ -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).
@@ -0,0 +1,14 @@
1
+ message_poster/__init__.py,sha256=xrBKRA6I7GMFsFFZz8CxKy0Yp6bz49hfu6hrQvTfdGU,375
2
+ message_poster/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
3
+ message_poster/auth.py,sha256=ZdU5lvhAggeEzXZwpM85r3omNX-VcA0V0LXcBMxR_Ag,5955
4
+ message_poster/cli.py,sha256=jt-7hoiF89TGzAT5B-9R-1tNmEhQEIqEJh91rqpQYUg,8298
5
+ message_poster/collect.py,sha256=GzZHiaI-8Dgb2dBWYMkwzgsN4uJG-Pp3PysJr5W5TuA,7346
6
+ message_poster/config.example.json,sha256=blrFQiD4-1GYbpH0w9IlWLGou_9CONfCGPXNZ-Zy_Xs,627
7
+ message_poster/render.py,sha256=CtvPupw5oz0FDamlDeE_1eOIB4PpR_ddY8FwwhsZIss,5917
8
+ message_poster/webhook.py,sha256=D5gFo4N4R-zuVSGw5ftbEPVaRBS--eRrfM2NJF1V4mw,3674
9
+ message_poster-0.1.0.dist-info/licenses/LICENSE,sha256=jtFXXGHSU3uBIfHbaMfC9YaHg6bcsVnRObY5_c8HImY,1068
10
+ message_poster-0.1.0.dist-info/METADATA,sha256=m6Lxg6CCFc2SPTENK09S6mPBfH3BcKlCiyuEINNeJaU,12145
11
+ message_poster-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ message_poster-0.1.0.dist-info/entry_points.txt,sha256=QzR-Z5cSSlg4OPKnrpSdqyYqg8kYB0zGnV4rv6oPr2g,59
13
+ message_poster-0.1.0.dist-info/top_level.txt,sha256=8FhrYyGlwHxZ_WYDwco4HivNk7K1G0z_2EkyVU2-NZc,15
14
+ message_poster-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ message-poster = message_poster.cli:main
@@ -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 @@
1
+ message_poster