@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +836 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,836 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 — 2026-11-13
4
+
5
+ No behaviour change. This is the release that proves the pipeline: publishing now happens in CI
6
+ with npm trusted publishing (OIDC), so the tarball is signed with a provenance attestation tying it
7
+ to this repository and this tag. Nothing to upgrade for, unless you want that attestation.
8
+
9
+ - `.github/workflows/publish.yml`: publishes on a `v*` tag over OIDC (no stored token), runs the
10
+ same build/typecheck/test gate a local `npm publish` runs, refuses a version that is already on
11
+ the registry, and validates the tarball without touching the registry on a manual dry run.
12
+ - `repository` now uses GitHub's canonical case (`DumbMice`), which trusted publishing compares
13
+ against the OIDC claim.
14
+
15
+ ## 0.1.0 — 2026-11-13
16
+
17
+ ### A crash, a vanished reply, and a corrupt queue
18
+
19
+ - **A fork failure crashed pi.** `spawn()` reports `EAGAIN`/`EMFILE`/a sandbox refusing `exec` as
20
+ an `error` event; with no listener Node rethrows it in the caller - the pi session - while no
21
+ daemon existed. The spawn now listens and reports `{started:true, healthy:false}`.
22
+ - **A reply whose only content was an over-cap attachment vanished.** A phone photo above the
23
+ 1 MB inbound total, sent with no text, was rejected `empty-body` with the cursor advanced past
24
+ it: nothing persisted, nothing delivered, no way to ask again. Such a reply is now persisted
25
+ with a note naming the drop, so the agent can ask for the text.
26
+ - **A corrupt `pending.json` was replaced by the next write.** Only a schema-version refusal was
27
+ rethrown; other parse errors returned `[]` and the next `addPending` rewrote the file with one
28
+ record. The bytes are now moved to `pending.json.corrupt-<ts>` and the queue starts clean.
29
+ - **A 401 read as "mode off".** A rotated `secret.key` made every call fail; the session silently
30
+ stopped notifying and draining while the panel said "daemon: healthy". It now says the bearer was
31
+ rejected, once, and leaves the mode alone.
32
+ - **The frozen endpoint list missed `DELETE /register`** (the call that frees a decision slot), so
33
+ a change to it needed no protocol bump; the snapshot regex now covers `DELETE`.
34
+
35
+ ### A restart that reported the attempt, not the outcome
36
+
37
+ - **`/mailbridge restart` could not tell five outcomes apart.** It skipped the stop entirely when
38
+ the session had no client (then advised running it again); an `ok` reply from `/shutdown`, a
39
+ refusal, and a timeout all read as "restarted" even though `/shutdown` answers *before* it
40
+ closes the listener, so a daemon still logging out of IMAP keeps `/health` alive; a daemon that
41
+ had already exited read as "could not stop the old one"; and the protocol-replacement path it
42
+ shares said "retrying" after a failure that had left no poll loop to retry with. The port is now
43
+ the authority for every outcome, each one carries the level it deserves, and the loop is parked
44
+ and re-armed around the stop - joining a tick that is already in flight, since one of its awaits
45
+ can spawn the very daemon the restart is about to measure.
46
+
47
+
48
+ ### Four bug classes fixed at the root, not at the symptom
49
+
50
+ **Runtime replacement.** Three paths (dead daemon, protocol mismatch, `/mailbridge restart`) each
51
+ rebuilt the client/notifier/runner themselves, and each dropped a debounced settle in its own way -
52
+ the same bug had to be fixed three times. One helper (`replaceRuntime`) now owns the sequence, so a
53
+ new replacement path cannot forget it.
54
+
55
+ **Mail for another machine.** A pending record now carries the device that scanned it, `/pending`
56
+ returns only this device's (plus untagged, from before the field), and `/claim` refuses a record
57
+ another device scanned (`409 foreign-device`). Two machines sharing a state directory, or two hosts
58
+ that happen to share a `device.name`, no longer hand each other's replies around - and `/pending`
59
+ stops shipping the whole queue over loopback every 10 s.
60
+
61
+ **Unbounded sending.** `/send` has a one-minute budget of ten mails, refused with `429
62
+ send_budget`. A model-driven `notify_user` loop can no longer mail without bound in a minute - the eleventh is
63
+ refused, not queued, so the agent sees the error and no stale backlog is mailed later. A settle
64
+ notification is exempt: the burst must not be able to silence "your agent is waiting". A sustained
65
+ loop can still reach a daily cap (ten a minute is 600 an hour), so tool scoping remains the
66
+ defence.
67
+
68
+
69
+ ### The daemon always exits when idle; no residency switch
70
+
71
+ (0.1.0 is unreleased, so removing a `/health` field does not bump `API_VERSION`: no client ever
72
+ saw protocol 1 with it. A released protocol would have needed the bump, per AGENTS.md.)
73
+
74
+ `daemon.exitWhenIdle` is gone, along with `PI_MAILBRIDGE_EXIT_WHEN_IDLE` and the `/health`
75
+ field that reported it. The design has one rule: the daemon is pi's child, it serves sessions,
76
+ and it stops when the last one has been quiet for `daemon.idleTimeoutSecs`. A "stay resident"
77
+ option only ever existed to work around systemd, and it invited the reading that a sessionless
78
+ daemon is useful - it is not: it scans but cannot deliver.
79
+
80
+ Idleness is measured from the daemon's own start until a session pings, then from the last ping.
81
+ So a hand-started daemon with no pi attached now exits one `daemon.idleTimeoutSecs` after it
82
+ starts; there is no supported way to keep it running on its own. The timeout's floor is 30 s
83
+ (three poll intervals), because a shorter one reaps the daemon between pings.
84
+
85
+ ### The systemd unit is gone
86
+
87
+ It was never required, and framing it as a way to keep watching mail when nobody was at pi
88
+ contradicted the design: no session means no consumer, so a sessionless daemon scans for nobody.
89
+ Removed with it: the unit, its documentation, the `PI_MAILBRIDGE_DIR` variable it introduced, and
90
+ every "three things the unit cannot guess" instruction. If your environment cannot let pi own a
91
+ child (a sandbox, or a container whose process tree is replaced), there is now no supported way to
92
+ keep a daemon resident: it is pi's child and exits with the last session.
93
+
94
+ Feature-complete for the single-user, one-mailbox case. Exercised end to end against a live QQ
95
+ mailbox in both layouts (two addresses, and one address for both roles); personal Outlook was
96
+ tried and rejected IMAP/SMTP OAuth2 server-side, as docs/OAUTH.md describes. No automated
97
+ live-mailbox test exists.
98
+
99
+ ### Core
100
+
101
+ - `core/token.ts` — HMAC-signed routing tokens (`[pi:<payload>.<mac>]`) with TTL,
102
+ constant-time verification, prefix-tolerant subject extraction, and an
103
+ optional **device claim** so replies route to the machine that owns the session.
104
+ - `core/authz.ts` — fail-closed chain: loop guard (pi account) → machine-generated
105
+ → allowlist (empty denies all) → DMARC (`Authentication-Results`, optionally pinned
106
+ to `security.trustedAuthservId` when set).
107
+ - `core/reply-parse.ts` — quote stripping that cuts at the first recognisable
108
+ boundary, addr-spec normalization, auto-generated detection,
109
+ `findTrustedAuthResults` (topmost matching `authserv-id` only).
110
+ - `core/attachments.ts` — MIME allowlist, 4 files / 1 MB caps, reasons for every
111
+ drop; images become `ImageContent`, text is inlined.
112
+ - `core/mailer.ts` — SMTP send, `LOGIN` and `XOAUTH2`, injectable transport.
113
+ - `core/oauth.ts` — token from an argv command (argc-safe, `shell:false`), cached
114
+ until TTL, one refresh on 401, disabled after three consecutive failing commands.
115
+ - `core/settle.ts` — `agent_settled`-only notifier, debounced, abort-aware,
116
+ id-stamped subject naming the **device and session** (`pi: fix-login @ dev-box-7
117
+ is waiting for you`), body with device/session id/project/turns/last text.
118
+ - `core/deliveries.ts` — claim → inject → ack runner; never acks a failed
119
+ injection; absorbs replays after a failed ack.
120
+
121
+ ### Single mailbox: safe to run, and no longer silently lossy
122
+
123
+ One address for both pi and the human is a first-class layout, so the guards around it are
124
+ now explicit and tested:
125
+
126
+ - every notification carries `X-Pi-Mailbridge: notification`, and inbound scanning refuses
127
+ that marker before authorization - so our own mail cannot come back as an instruction even
128
+ if the outbound id was never recorded;
129
+ - the outbound `Message-ID` is minted and recorded **before** the send, closing the window in
130
+ which a notification could return before the guard knew about it;
131
+ - a message from your own address is accepted only with reply evidence (`In-Reply-To` or
132
+ `References`), so a freshly composed mail is rejected instead of being injected;
133
+ - `/mailbridge setup` writes `requireDmarcPass: false` for this shape: providers that stamp
134
+ no `Authentication-Results` on self-addressed mail (QQ) would otherwise reject every reply,
135
+ and the rejection advanced the cursor past it.
136
+
137
+ **Upgrading a single-mailbox config:** if `security.allowSelfReplies` is already `true` and
138
+ every reply reports `dmarc-missing`, set `security.requireDmarcPass: false`. Setup writes that
139
+ for new configs; it never rewrites an existing one. `/mailbridge check` reports it, and
140
+ `pi-mailbridge --check` exits non-zero, so a CI job or a process manager can gate on it rather
141
+ than start a daemon that rejects every reply.
142
+
143
+ ### A reply can no longer sit unscanned behind a silent IDLE
144
+
145
+ Live: a reply sat in INBOX for minutes while `/health` reported `imap: connected`. The daemon
146
+ only scanned on connect, on an IMAP `EXISTS` event, and on mode-on — and the provider stopped
147
+ pushing `EXISTS`. `daemon.scanIntervalSecs` (default 60, `0` disables) sweeps on a timer while
148
+ mode is on, and `/mailbridge status` now distinguishes "no mail yet — last checked …" from a
149
+ dead daemon. The smoke echo probe was fixed in the same pass: it finds the delivered copy by the stamped id
150
+ and retries instead of reading "the newest message" once (which reported a false alarm on a
151
+ live account), and it now classifies what actually survived — our marker, our `Message-ID`, or
152
+ neither (safe only with the Sent-folder read-back). It used to claim the `Message-ID` survived
153
+ without comparing it, which on QQ printed an assurance that was false.
154
+
155
+ ### The reply chain can match providers that rewrite Message-ID
156
+
157
+ A live test against QQ showed our stamped `<uuid@qq.com>` replaced by `<tencent_…@qq.com>` on
158
+ delivery, with the human's reply referencing the tencent id. The reply-chain fallback matches
159
+ ids we recorded, so on such a provider it never matched and the subject tag was the only route.
160
+ Setting `mail.imap.sentFolder` makes the daemon read the just-sent copy and record the
161
+ delivered id too. `setup` writes that key for `qq.com` and `foxmail.com` — the one provider
162
+ measured doing this, and the one you cannot fix from the reply side. Elsewhere it stays opt-in.
163
+ The read-back is best-effort: six attempts over ~112 s, one short-lived IMAP connection per
164
+ notification, and a miss is logged rather than failing the send.
165
+
166
+ ### A queued reply cannot be delivered against the wrong numbering
167
+
168
+ Pending records now carry the `UIDVALIDITY` they were scanned under. Records tagged with
169
+ another numbering are dropped on every connect, and untagged ones (written before the field
170
+ existed) on a detected renumbering — live, one of those was claimed in a new space and
171
+ delivered a different message's body. On an ordinary connect untagged records are kept, so an
172
+ upgrade does not discard a reply that was still waiting.
173
+
174
+ Daemon log lines carry a full UTC timestamp (`2026-11-12T16:56:56Z`), so lines from different
175
+ runs can be told apart — a live review had to read TCP counters to establish timing because the
176
+ log carried no dates. Note that an empty sweep still logs nothing, so the log cannot prove the
177
+ poll cadence; `/health`'s `lastScan.checkedAt` is the instrument for that. `/health` also
178
+ reports `registry` (the same number as `sessions`, which counts sessions with an **outstanding
179
+ decision request** — the `POST /register` registry — not connected clients; the raw payload read
180
+ wrong next to `clients: 2`). `/mailbridge status` shows *last checked* whenever the daemon has
181
+ checked more than a minute after the last scan that saw mail, so a poll loop that died at 10:05
182
+ is visible behind a scan at 10:00.
183
+
184
+ `/mailbridge status` distinguishes "no mail yet — last checked …" from a daemon that has never
185
+ scanned, and reports when the last scan that saw mail happened. `daemon.scanIntervalSecs` is
186
+ validated (`null`/negative/strings are refused rather than read as "0", which means off), and
187
+ `/reload` can now turn the sweep back on after it has been disabled.
188
+
189
+ `loadConfig` no longer throws or silently loses defaults on a null config section. Every
190
+ section (`daemon`, `mail`, `mail.imap`, `mail.smtp`, `security`, `notify`, `tuning`, `device`)
191
+ is repaired by filling the defaults underneath whatever survived, with a warning naming it, so
192
+ `{"mail": {"smtp": null}}` cannot throw and `{"notify": null}` cannot quietly disable settle
193
+ notifications. A *partial* section produced by the env merge is repaired the same way, which
194
+ matters because `{"daemon": null}` plus any daemon env var used to yield a daemon with no host
195
+ and no port.
196
+
197
+ ### `prime` runs once, and refuses after that
198
+
199
+ Priming moves the UID cursor past everything already in the mailbox. That is a first-run
200
+ decision: the cursor is what makes mail visible, so moving it after the bridge has been watching
201
+ silently skips any reply that arrived unscanned (mode off, daemon down) — no ack, no `\Seen`, no
202
+ error, just a mail nobody answers. Both surfaces (`--prime` and `/mailbridge prime`) now refuse
203
+ once prime has run or the cursor has moved past mail, say which uid they are watching from, and point at `uid-cursor.json` for a
204
+ deliberate re-prime; `--prime` exits 2. The rule lives in `core/prime.ts` so the two cannot
205
+ disagree.
206
+
207
+ ### `/afk` and `/back` are now `/mailbridge on` and `/mailbridge off`
208
+
209
+ Mode was two top-level commands in pi's global command namespace. `back` is generic enough to be
210
+ shadowed silently by another extension — pi resolves commands by first match and warns about
211
+ nothing — and neither name appeared in `/mailbridge help`. They are subcommands now, named for the mode they set, and the usage text lists them. Both still
212
+ work as a mail reply, except `/mailbridge on`: with the mode off nothing is scanned or drained, so
213
+ it can only be re-opened from the terminal. Nothing was published, so there is no alias to carry.
214
+
215
+ ### A session settles before it lets go
216
+
217
+ Quitting pi inside `notify.debounceMs` of a settle used to drop the notification: the shutdown
218
+ handler cancelled the debounce and the `flush()` path had no caller. A one-shot run therefore
219
+ mailed nothing at all. Shutdown now flushes a pending settle before dropping the notifier; the
220
+ daemon outlives the session for the idle timeout, so the send can still land.
221
+
222
+ ### The extension spawns the build, and a replacement checks what it got
223
+
224
+ The build shipped for `bin` and the unit, but the extension - the path `pi install` actually uses -
225
+ still resolved `src/daemon/main.ts` and spawned it with `--experimental-strip-types` from inside
226
+ `node_modules`, which Node refuses. It now resolves the compiled daemon through one pure function
227
+ (`daemon/script.ts`), preferring `dist/` whenever it exists, and both layouts (checkout, installed)
228
+ are unit-tested.
229
+
230
+ Replacement had two ways to lie: `/shutdown` answers before the listener closes, so an immediate
231
+ probe found the dying daemon and "reused" it - reporting success while the old protocol was still
232
+ served; and the reuse probe never looked at the protocol at all. The replacement now waits for the
233
+ port to stop answering (bounded), and `ensureDaemon` refuses to reuse a daemon on another protocol,
234
+ reporting `incompatible` so the caller replaces rather than adopts. `startRuntime` is unchanged in
235
+ spirit but a respawn that still speaks another protocol now latches and says "restart pi", instead
236
+ of killing the daemon once a minute forever.
237
+
238
+ A legacy state file is schema 1 by name (`LEGACY_SCHEMA`), not "whatever is current": mapping it to
239
+ `STATE_VERSION` would have labelled pre-versioning files as current and skipped their migration on
240
+ the day the constant moved. `checkStateVersion` also refuses an older file when no migration is
241
+ registered, so a version bump cannot ship without one.
242
+
243
+ ### A protocol version, separate from the package version
244
+
245
+ The daemon and the extension now have a wire contract with its own number (`API_VERSION`, reported
246
+ as `apiVersion` on `/health`), and additive features are advertised as `capabilities`. Compatibility
247
+ is strict equality on the protocol - a mismatch replaces the daemon, never negotiates - while the
248
+ package version becomes diagnostic. Comparing package versions was the wrong knob: a harmless patch
249
+ bump would have restarted a working daemon, and the number would mean something different the day a
250
+ second client exists.
251
+
252
+ The files whose shape matters - `pending`, `journal`, `routes`, `threads`, `uid-cursor` - gained a
253
+ schema version (`v`) under the "refuse newer, migrate older" rule: a file
254
+ without one is schema 1 and is stamped on its next save; a file from a newer build is refused for
255
+ reading and writing. `mode.json` and the OAuth token cache are deliberately unversioned: both are
256
+ regenerable, and refusing to start over a cache would be worse than rebuilding it, with one clear message naming both versions, instead of having its
257
+ unrecognised records filtered out and the file rewritten without them.
258
+
259
+ ### A session replaces a daemon left over from the previous build
260
+
261
+ `pi update` reloads the extension while the daemon keeps executing what it started with; it mints
262
+ and resolves routing ids from the old code, so replies after an upgrade could be rejected with the
263
+ cursor already past them. `/health` carries the daemon's protocol, and a session
264
+ compares it against its own once a minute: a daemon on another protocol (or one from before the
265
+ field existed) is shut down and respawned. A different package *version* on the same protocol is
266
+ left alone - that number is diagnostic. A replacement that cannot respawn no
267
+ longer stops the poll loop - it warns and retries - because the loop had been re-armed only while a
268
+ runner existed, and the replacement clears it.
269
+
270
+ ### The package ships a build, because Node cannot strip types inside node_modules
271
+
272
+ `pi install npm:@dumbmice/pi-mailbridge` was broken in a way no local test could see: the extension loads
273
+ through jiti (so TypeScript works), but the daemon it spawns ran `node --experimental-strip-types`
274
+ on `src/daemon/main.ts` from inside `node_modules`, and Node refuses that with
275
+ `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. The first pi session after a usable config existed then showed "daemon not reachable;
276
+ replies will not arrive" and a stack trace in `daemon.log` (install itself starts nothing). Every live test until now ran from a checkout,
277
+ where the path has no `node_modules` segment.
278
+
279
+ `npm run build` now emits `dist/` (tsc with `rewriteRelativeImportExtensions`, so the `.ts` import
280
+ specifiers become `.js`), `prepublishOnly` builds first, the tarball ships `dist`, and `bin`, the
281
+ spawned daemon run plain `node` against the compiled entry. A packaging test
282
+ copies the built package into a temporary `node_modules` and starts it, so this cannot regress.
283
+
284
+ ### One daemon per state directory, whatever port it thinks it owns
285
+
286
+ The port was the only single-instance guard, and `daemon.port` is a documented knob (plus a
287
+ per-process env override). Two processes that disagreed about it could each start a daemon on one
288
+ state directory — two IMAP watchers, and stores whose read-modify-write has no cross-process
289
+ lock, so a reply could be lost and another injected twice. A `daemon.lock` in the state directory
290
+ now decides: a live holder makes the newcomer exit 0, a dead one is stolen, and SIGTERM releases
291
+ it. A spawned daemon is also told the port the session probed (`--port`), so it cannot bind a
292
+ different one and leave the session reporting "unreachable".
293
+
294
+ ### Smaller fixes from the same review
295
+
296
+ - `/mailbridge on` with no runtime (the "copy config.example.json yourself" path) now builds one
297
+ before reporting ON; previously it wrote `mode.json`, toasted "ON", and nothing could mail or
298
+ deliver until `setup`/`restart`.
299
+ - `daemon.idleTimeoutSecs` below 30 (three poll intervals) and `security.tokenTtlHours` below 1
300
+ are refused with a warning. A timeout under the polling period reaps the daemon between pings,
301
+ which looks like a crash loop; a NaN TTL made every route immortal.
302
+ - a journal written before `UIDVALIDITY` tagging no longer answers "already seen" once the live
303
+ numbering is known — after a renumbering that could skip a new message whose UID was reused.
304
+ - a failed `/reload` restores the token provider as well as the config.
305
+ - a failing `tokenCommand` no longer puts its full argv and stderr into `daemon.log` and
306
+ `--check` output; the args are what `describeCommand()` hides because they may hold a secret.
307
+ - expiry under queue pressure releases the dead session's decision slot, like the UID-space drop
308
+ already did.
309
+ - `daemon.log` is created 0600 (an older build left it 0644, and it carries addresses and session
310
+ names) and rotates once at 5 MB.
311
+ - docs: `/mailbridge on` by mail cannot re-open the channel (`off` is one-way, as §6a said);
312
+ stopping the daemon while pi is open is undone by the poll tick's respawn; a refused command is
313
+ still delivered to the agent as text, so "cannot reconfigure" was too strong; §4 now lists what
314
+ the bearer token actually authorises, including discarding queued replies.
315
+
316
+ ### Notifications attach the files they link to
317
+
318
+ A notification could inline images but a link to any other local file stayed a relative href no
319
+ mail client can follow. Links to local files are attachments now, named with the file's own
320
+ basename and listed in the body as "[attached: report.pdf]". Caps are up and provider-aware: images 5 MB,
321
+ files 15 MB each, 18 MB in total and 10 attachments by default - chosen because base64 costs
322
+ about a third and Gmail and Outlook.com both stop at 25 MB. `notify.maxAttachmentMb` raises the
323
+ budget where the provider allows more (QQ: 50 MB per message). A file type outside the allowlist (`id_rsa`, `.env`) is
324
+ refused rather than mailed, because the path comes from model output.
325
+
326
+ ### `off` by mail stops the batch it arrived in
327
+
328
+ A drain takes a snapshot of pending records and delivers them in order. If the human's
329
+ `/mailbridge off` reply was in that snapshot, the records behind it were still claimed and
330
+ injected — the opposite of "leave me alone at the terminal". The runner re-checks the mode
331
+ between records now: the switch is delivered and acked, everything behind it stays pending and
332
+ unseen for the next `/mailbridge on`.
333
+
334
+ ### Commands by mail, dispatched here
335
+
336
+ A command that throws is reported and the delivery is still acked: `deliveries` retries an inject
337
+ failure on every poll tick, so re-running a failing `/mailbridge smoke` would have re-sent a real
338
+ mail every ten seconds.
339
+
340
+ A reply whose entire body is one command line now runs that command:
341
+ `/mailbridge [on|off|status|check|smoke|help]`. `setup`, `auth`, `restart` and `prime` are refused and
342
+ delivered as text, and anything longer than a single line is a message (so a quoted
343
+ `/mailbridge off` cannot change your mode). Dispatch happens in `core/mail-command.ts`, not through pi's
344
+ `expandPromptTemplates`, because pi's dispatcher returns `void`: a caller cannot distinguish a
345
+ command that ran from a prompt pi rejected, and this package acks and marks `\Seen` after the
346
+ call returns. Sending mail through that dispatcher would have consumed a reply the agent never
347
+ saw — which is also the failure this replaces, since pi can reject a plain-text prompt the same
348
+ way (no model, expired credentials, compaction).
349
+
350
+ ### Daemon
351
+
352
+ - A session that attaches to an already-running daemon now reloads it when `config.json` is
353
+ newer than the config the daemon read (`/health` carries `configLoadedAt`). Without this a
354
+ hand-edited config or a headless `--setup` kept mailing the replaced address. The reload is
355
+ fire-and-forget, single-flight, and its failure is reported rather than swallowed.
356
+
357
+ - `daemon/process.ts` — UID-cursor scan → authorize → route (short id / token / chain) → strip quotes
358
+ → filter attachments → persist → advance. Ordering is the correctness property.
359
+ - `daemon/watcher.ts` — IMAP IDLE over an injectable client factory; renews under
360
+ the ~29 min cutoff; bounded backoff; `start()` never throws on a dead server.
361
+ - `daemon/service.ts` — `/health`, `/events` (SSE), `/pending`, `/claim`, `/ack`,
362
+ `/register`, `/ping`, `/bye`, `/mode`, `/send`, `/reload`, `/shutdown`;
363
+ constant-time bearer auth; bounded bodies and streams. Ack removes the record, then
364
+ journals it (the journal is consulted at scan time, so the reverse would open a
365
+ double-injection window).
366
+ - `daemon/spawn.ts` — `probeHealth`, `ensureDaemon` (detached, unref'd), typed
367
+ client.
368
+ - `daemon/main.ts` — server first, then catch-up scan, then watcher; SIGTERM
369
+ handlers registered before dialling; `--check` (exit 2 config / 3 auth) and
370
+ `--print-token`.
371
+
372
+ ### Stores
373
+
374
+ - `pending-store` — durable deliveries with exclusive claim, same-instance
375
+ re-claim, abandoned-claim TTL, claimant-only ack.
376
+ - `uid-cursor` — records the mailbox's UIDVALIDITY beside the cursor; a renumbering resets
377
+ it (with `uidNext`-based detection for a cursor written by an older version), so a rebuilt
378
+ folder is re-read instead of skipped.
379
+ - `journal` — capped acked-UID set for durable replay protection, tagged with the mailbox
380
+ UIDVALIDITY so a renumbered folder is not treated as already-acked.
381
+ - `mode-store` — global on/off, default off, atomic replace.
382
+ - `registry` — one outstanding decision per session, TTL + hard cap.
383
+
384
+ ### Extension
385
+
386
+ - Wires **`agent_settled` and never `agent_end`** (a test pins this).
387
+ - Mode mirror seeded from the daemon and refreshed each poll.
388
+ - `/mailbridge`; `request_decision`, `notify_user`.
389
+ - Reply deliveries drain on `session_start` and on a 10 s poll, injected as
390
+ `followUp` with attachments as content blocks.
391
+
392
+ ### Reply identification
393
+
394
+ A reply is identified by the first rule that succeeds: the **short route id**
395
+ (`[pi:f913c9]`, a lookup key in `store/routes.ts`), then a **legacy signed token**
396
+ (still verified, no longer minted), then the **reply chain** (`In-Reply-To` /
397
+ `References` matched against Message-IDs recorded at send time in
398
+ `store/threads.ts`), then rejected as `no-token` and left unread. All three age out
399
+ with `security.tokenTtlHours`, and none of them authorizes anything. The chain
400
+ fallback means editing the subject no longer loses a reply;
401
+ `security.allowHeaderThreading: false` disables that rule only.
402
+
403
+ ### In-pi commands
404
+
405
+ `/mailbridge` now takes subcommands, so the whole user-facing flow is inside pi:
406
+ `setup`, `check`, `auth`, `smoke`, `prime`, `restart`, plus the existing status. Only the
407
+ daemon itself stays a process (it must outlive pi); its CLI entry is documented as
408
+ the headless path, sharing `core/setup.ts`, `core/smoke.ts` and
409
+ `core/token-source.ts` with the commands so behaviour cannot drift.
410
+
411
+ - daemon: `POST /reload` re-reads config and restarts the watcher, so `setup` and
412
+ `auth` take effect without killing anything by hand.
413
+ - fixed: the extension resolved the daemon script with `URL.pathname` instead of
414
+ `fileURLToPath`, which mangles paths containing spaces or non-ASCII.
415
+ - `/mailbridge check` now warns when `oauth` is configured but neither account
416
+ uses `XOAUTH2`, which would otherwise be silently ignored.
417
+
418
+ ### Lifetime: singleton, and it exits with the last session
419
+
420
+ The daemon was already a singleton (the port is the lock) and already started by pi
421
+ (`ensureDaemon` from `session_start`). What it did not do was stop.
422
+
423
+ It now exits once no pi session has pinged for `daemon.idleTimeoutSecs` (default 120).
424
+ Sessions ping on their poll tick and send `/bye` on shutdown. Motivated by a real
425
+ failure: a daemon started at 15:09 kept serving old code through several package
426
+ changes because nothing could replace a running process. Stopping also means no session
427
+ means no watcher - nothing fetches and parses every incoming message for a session that
428
+ cannot claim it. Mail that arrives meanwhile is covered by the catch-up scan.
429
+
430
+ idleness counts from the daemon's own start until a session pings, then from the last ping,
431
+ so a daemon nobody pings exits one grace period after it starts.
432
+
433
+ ### Fixed: the catch-up scan never ran, and the daemon logged nothing
434
+
435
+ Found in a live `daemon.log` after a restart:
436
+
437
+ imap ready; running catch-up scan
438
+ catch-up: scan failed: pi-mailbridge: imap watcher not started
439
+
440
+ `start()` fires `onReady`, whose catch-up scan reads through the source proxy, but
441
+ `currentWatcher` was only assigned *after* `await watcher.start()`. Every catch-up scan
442
+ failed, so mail that arrived while the daemon was down was picked up only on the next
443
+ IDLE event - the opposite of what ARCHITECTURE.md promises.
444
+
445
+ - main.ts: assign `currentWatcher` before starting, with a packaging test that fails if
446
+ the two statements are ever reordered
447
+ - spawn.ts: a detached daemon now appends to `daemon.log` instead of discarding stdio.
448
+ `stdio: "ignore"` is why this failure was invisible; the log is what found it.
449
+
450
+ ### Fixed: the daemon could die and nothing brought it back
451
+
452
+ Live symptom: a reply was received, injected and acked, then the bridge went silent.
453
+
454
+ The daemon's idle exit (added earlier this session) stopped the process 120s after the last
455
+ ping - correct - but nothing restarts it. The extension's poll tick only talked to a daemon
456
+ it assumed was alive, and `ensureDaemon` runs only on `session_start`. So once the daemon
457
+ exited, the session polled a dead port until a manual reload.
458
+
459
+ - the poll tick now treats a failed `ping` as "the daemon is gone": it respawns through
460
+ `ensureDaemon`, re-creates the client/notifier/runner, and resumes draining. Throttled to one
461
+ attempt a minute so a dead daemon cannot cause a spawn storm. (A respawn starts the compiled
462
+ `dist/` already on disk; nothing is built at runtime — `prepublishOnly` builds once and
463
+ `pi install` receives that.)
464
+ - `session_start` and the recovery path share one `startRuntime()`, so they cannot drift.
465
+ - `/health` now reports `clients` and `lastPingAgoMs`, which is what
466
+ made this diagnosable: `clients: 0` with a live session proves the poll is not running.
467
+
468
+ ### All notifications for a session thread into one conversation
469
+
470
+ Each notification used to be its own thread: the route id changed per message, so no two
471
+ subjects matched, and no `In-Reply-To`/`References` were set.
472
+
473
+ Two changes make one conversation per session:
474
+
475
+ - **One route per session**, reused and refreshed, instead of one per message. The subject
476
+ is then byte-identical across a session's notifications, which is what Outlook's
477
+ conversation topic keys on.
478
+ - **RFC 5322 headers**: `In-Reply-To` is the session's previous outbound Message-ID and
479
+ `References` is its root, both derived from the outbound id registry that already
480
+ existed for reply routing.
481
+
482
+ The extension passes `sessionId` on `/send`; the daemon looks the thread up before
483
+ sending, so the ids are read before the new message is recorded.
484
+
485
+ Threading is worth it for more than tidiness: a reply is now `In-Reply-To` a message we
486
+ already recorded, so the reply-chain fallback and the subject route agree.
487
+
488
+ ### The signature block no longer prints `--` twice
489
+
490
+ Reported from a rendered notification: the block showed a top border *and* the literal
491
+ `--` line, reading as a duplicate divider.
492
+
493
+ The `--` line is now dropped from the HTML part only - the border is the divider there.
494
+ It stays in the text part, where RFC 3676 needs it and where quote stripping relies on
495
+ it, and the metadata below it is untouched.
496
+
497
+ ### Local images are inlined as cid attachments
498
+
499
+ A Markdown image pointing at a local file rendered as `<img src="./shot.png">`, which a
500
+ mail client cannot resolve - the reader saw a broken image. Local images are now read and
501
+ attached with `multipart/related`, referenced by `cid:`, so an image the agent
502
+ produced arrives as an actual picture.
503
+
504
+ Safety, because the src comes from model output and the result leaves the machine:
505
+
506
+ - the file must **sniff** as an image from magic bytes (PNG/JPEG/GIF/WebP), so a renamed
507
+ text file or key is never attached
508
+ - 128 KB per image, 384 KB total, 4 images at the time (superseded: see the attachment-caps
509
+ section below) - a notification cannot become a file transfer
510
+ - unreadable, oversized or non-image paths degrade to a short bracketed note, never a
511
+ broken image
512
+ - cids are content-addressed, so one image referenced twice is attached once
513
+
514
+ Relative paths resolve against the sending session's project directory, which the
515
+ extension now passes to `/send` as `cwd`.
516
+
517
+ ### Tables in mail need inline borders
518
+
519
+ Reported from a rendered notification: a Markdown table arrived as loose text. markdown-it
520
+ emits a bare <table>, and mail clients default to no borders, while a <style> block is
521
+ stripped by several of them.
522
+
523
+ The renderer rules for table_open/th_open/td_open now carry inline styles:
524
+ border-collapse, cellspacing/cellpadding attributes, and a 1px border plus padding on
525
+ every cell, with a shaded header row. Fenced blocks and inline code get monospace and a
526
+ background at the same time, since they had the same problem. Markdown column alignment
527
+ is preserved by merging with the attributes markdown-it already set.
528
+
529
+ ### Fixed: the session name was never read
530
+
531
+ The notification printed `session:` and `session id:` with the same value, because the
532
+ code called `sessionManager.getName()` — which does not exist. The documented method is
533
+ `getSessionName()`, so the name was always undefined and fell back to the id. Naming a
534
+ session had no effect until now. The body also no longer repeats the id when a session is
535
+ unnamed.
536
+
537
+ ### Fixed: the body collapsed onto one line
538
+
539
+ Reported after a live notification. The HTML part relied on `white-space: pre-wrap`
540
+ to preserve line breaks, and clients that sanitise inline styles - Outlook web does -
541
+ collapsed the whole body into a single line.
542
+
543
+ Newlines are now `<br>` in the markup, which survives sanitising. Structure belongs in
544
+ the markup, not in CSS.
545
+
546
+ ### restart replaces the daemon process, not just its config
547
+
548
+ Found by testing short-id routing against a live daemon: the daemon was still running
549
+ the previous version, so it could not see `[pi:c05698]` and rejected the reply. A config
550
+ reload cannot swap running code, which means an updated package would also have kept
551
+ executing the old version after `pi update` until reboot.
552
+
553
+ - daemon: POST /shutdown, authenticated, acknowledges before exiting
554
+ - /mailbridge restart: stop the process, wait for the port, respawn through the
555
+ supervisor, and report whether it came back
556
+
557
+ ### Mail is now multipart: text plus HTML, and the HTML renders Markdown
558
+
559
+ The agent answers in Markdown, so the HTML part is rendered rather than escaped: the
560
+ reader sees headings, lists, tables, fenced code and clickable links instead of `**bold**`
561
+ and pipe tables.
562
+
563
+ Rendering is delegated to **markdown-it** (15.0.3) instead of the hand-rolled subset I
564
+ wrote first. The library earns its six small transitive dependencies:
565
+
566
+ - `html: false` escapes raw HTML by default, so there is no sanitiser to get wrong
567
+ - markdown-it's own `validateLink` refuses `javascript:`, `vbscript:` and `data:` hrefs
568
+ - `linkify` turns bare URLs into links, which is most of the value of a mail
569
+ - `breaks` keeps single newlines, matching how the body is written
570
+
571
+ The `--` signature block stays literal text (`<pre>`), because a line of `--` after text
572
+ is a setext heading underline and would swallow our metadata into an `<h2>`. It now
573
+ carries a top border so it reads as a divider rather than running into the answer.
574
+
575
+
576
+
577
+ ### Multipart mail, so quoted history folds
578
+
579
+ Reported from a live reply: the quoted original did not fold away, it showed as raw
580
+ repeated text. Cause: every message was text/plain only, and clients collapse quoted
581
+ history only when the original is HTML (they quote as a <blockquote> they can fold).
582
+
583
+ - core/html.ts: escapeHtml + textToHtml, using white-space:pre-wrap so the layout
584
+ survives without converting every newline to <br>
585
+ - mailer sends text and html, so it is multipart/alternative: plain clients and the
586
+ scanner still get the text part unchanged
587
+ - escaping is mandatory, not cosmetic: the body is model output and an unescaped
588
+ interpolation would inject markup into the recipient's mail client. Tested with
589
+ <img onerror=...> and a javascript: link.
590
+
591
+ ### Shorter subject: a local route id instead of a signed blob
592
+
593
+ The subject carried a signed token whose length dominated the line, so a notification preview showed
594
+ almost nothing but base64. New mail now carries a six-hex local route id:
595
+
596
+ before [pi:AgGhxFPy_HFpjIlpyT-hoVJRP2HY.e2uUO7fw] pi: fix-login @ ybc-linux is waiting for you (87)
597
+ after [pi:f913c9] pi: fix-login @ ybc-linux is waiting for you (56)
598
+
599
+ - store/routes.ts: id -> {sessionId, device, expiry}, capped at 2000, atomic, expired
600
+ entries pruned on write. Routing only; authorization is still allowlist + DMARC.
601
+ - Routing preference is now: short id, then signed token (mail already in flight),
602
+ then the reply chain.
603
+ - ids are local, so a route minted on one machine does not resolve on another - the
604
+ behaviour we already wanted, now with no base64 in the subject.
605
+
606
+ ### Fixed: the notification never contained the agent's answer
607
+
608
+ Every settle email said "(the agent produced no text this turn)". The extension never
609
+ populated `lastAssistantText`, so the body - the whole reason to read the mail from a
610
+ phone - was a placeholder.
611
+
612
+ It now reads the current session branch: the last assistant message whose `text` parts
613
+ are non-empty, ignoring thinking and tool calls, plus a turn count. Answer text is
614
+ capped at 4000 characters with an explicit truncation note, so a long response cannot
615
+ turn into a 200-line email.
616
+
617
+ ### Fixed: /mailbridge reported a stale config
618
+
619
+ The extension loaded config once at startup and every command reused that snapshot.
620
+ A config written by `--setup` from a shell - or any hand edit - was therefore invisible
621
+ to `/mailbridge check` and `/mailbridge smoke`, which would keep reporting the previous
622
+ account. Commands now re-read config first; it is a small synchronous file read.
623
+
624
+ ### Fixed: replies from real mail clients were rejected as no-token
625
+
626
+ Found by replying to a live test mail from Outlook. The subject came back as RFC 2047
627
+ encoded-words in gb2312:
628
+
629
+ =?gb2312?B?UmU6IFtwaTpleUp6...?= =?gb2312?Q?ease_reply_to_this?=
630
+
631
+ Headers are read raw on purpose - Authentication-Results must keep its order and
632
+ multiplicity - so nothing decoded them and the routing token was unreadable. Every
633
+ reply from a client that encodes a subject (any non-ASCII character, including a
634
+ Chinese session name or an em dash) was rejected as no-token.
635
+
636
+ - core/header-encoding.ts: RFC 2047 decoder for B and Q words, dropping whitespace
637
+ between adjacent encoded words per section 6.2, with a charset fallback instead of
638
+ a throw
639
+ - watcher: From and Subject are decoded after extraction; Authentication-Results
640
+ stays raw and ordered
641
+ - TextDecoder handles gb2312/gbk via Node's full ICU, so no new dependency
642
+ - reply-parse: strip the Outlook mobile signature, which the live reply carried into
643
+ the body
644
+
645
+ Proof: rewinding the cursor and rescanning the same message turned
646
+ rejected/no-token into persisted/reasons {} with the correct session id.
647
+
648
+ ### Verified: QQ attaches Authentication-Results
649
+
650
+ Checked a live QQ mailbox: external mail carries `Authentication-Results` with
651
+ authserv-id `mx.qq.com`, so the DMARC gate works there and needs no disabling. Mail
652
+ sent from QQ to QQ carries no header at all (nothing to authenticate on the same
653
+ server), so a self-addressed test never exercises that path - documented, because it
654
+ is an easy way to reach a false conclusion.
655
+
656
+ ### prime: start watching from now
657
+
658
+ The first scan walks the entire existing mailbox to look for a routing token. On a
659
+ personal inbox that is slow and reads mail that was never addressed to pi (nothing
660
+ is stored or marked seen, but it is fetched and parsed).
661
+
662
+ `--prime` / `/mailbridge prime` sets the UID cursor to the newest message without
663
+ fetching anything, so watching begins at the next arrival.
664
+
665
+ ### NetEase family added; QQ has a 14-day wait
666
+
667
+ QQ Mail refuses to enable IMAP/SMTP for accounts younger than 14 days
668
+ ('你的QQ邮箱激活时间不满14天'), which blocks a freshly created mailbox outright.
669
+ NetEase has no such rule - only SMS verification - so 163, 126, yeah.net and 188.com
670
+ are added to the built-in provider table as the immediate-use option.
671
+
672
+ ### check: an empty app password is now a reported problem
673
+
674
+ Running `--check` on a freshly written QQ config printed "config ok" while
675
+ `mail.imap.password` was still empty - which sends the user straight into a
676
+ confusing smoke failure. It now reports, per side, that the password is missing and
677
+ names the 授权码, because that is the string QQ's UI shows. Exit 2 instead of 0.
678
+
679
+ ### Costs: Entra ID Free is enough
680
+
681
+ Documented that no paid Entra license is required. App registration, device-code
682
+ and PKCE OAuth2, and delegated IMAP/SMTP/Graph scopes all run on **Entra ID Free**,
683
+ included with any Azure subscription or Microsoft 365 tenant. P1/P2 buy Conditional
684
+ Access, PIM and Identity Protection, none of which this package touches - so the
685
+ 'Entra ID P2, one month free then $10.50/user/month' offer should be declined.
686
+
687
+ Also recorded what happens when the Azure free trial ends without upgrading: the
688
+ subscription expires, the directory and app registration survive, and OAuth keeps
689
+ working. Nothing here consumes Azure resources, so there is nothing to bill.
690
+
691
+ ### Correction: the Developer Program sandbox is gated
692
+
693
+ The E5 sandbox is only granted to Visual Studio Professional/Enterprise
694
+ subscribers, ISV Success members, AI Cloud Partner participants and Premier/Unified
695
+ Support customers. A personal account that joins gets the learning resources and a
696
+ Graph tutorial and **no option to provision a sandbox** - which is exactly what the
697
+ dashboard shows. docs/OAUTH.md no longer presents it as a route; the Azure free
698
+ account is the only way to a tenant for a personal mailbox.
699
+
700
+ ### Microsoft 365 work/school tenants
701
+
702
+ A developer-sandbox or work mailbox has no ISPDB entry and an arbitrary subdomain.
703
+ Discovery now recognises the `*.onmicrosoft.com` suffix and resolves it to the work
704
+ preset: imap outlook.office365.com:993, smtp smtp.office365.com:587 (note the
705
+ different host from consumer mail), and the organizations authority rather than
706
+ consumers. A dev tenant therefore offers two usable mailboxes with different
707
+ trade-offs, documented in docs/OAUTH.md.
708
+
709
+ ### China trap: Azure China cannot authenticate global Outlook
710
+
711
+ `azure.cn` (Azure operated by 21Vianet) is a physically isolated sovereign cloud
712
+ with its own identity endpoints (`login.partner.microsoftonline.cn`). An app
713
+ registered there cannot authenticate against global Outlook.com, so the device
714
+ flow against `login.microsoftonline.com` fails. config now warns when
715
+ `oauth.issuer` points at a sovereign-cloud domain.
716
+
717
+ docs/OAUTH.md gains the concrete steps for getting a tenant with a personal
718
+ Hotmail — global Azure free account (creates a default directory) or the
719
+ Microsoft 365 Developer Program (free sandbox tenant, no credit card) — **corrected below: the sandbox is now gated** — and the
720
+ note that an MSA already attached to a tenant joins that tenant instead of
721
+ getting a fresh directory.
722
+
723
+ ### China: QQ Mail, Foxmail, 163 and 126 built in
724
+
725
+ Google is unreachable in mainland China, so the Gmail recommendation does not
726
+ apply there. Those providers authenticate with an app-specific code (授权码) used as
727
+ a password, which needs no OAuth app at all.
728
+
729
+ - KNOWN_PROVIDERS gains qq.com, foxmail.com, 163.com, 126.com — all LOGIN, over
730
+ implicit TLS — so discovery works even when the autoconfig service is blocked
731
+ - verified from imapflow's source that it sends the IMAP `ID` command whenever the
732
+ server advertises it, which is what 163/126 require ("Unsafe Login" otherwise)
733
+ - docs/OAUTH.md gains a mainland-China section including the `dmarc-missing`
734
+ fallback (`requireDmarcPass: false`) for providers that emit no
735
+ Authentication-Results header
736
+
737
+ ### Setup: Gmail defaults to an app password
738
+
739
+ Corrected a wrong recommendation. Gmail *OAuth* is harder than Outlook's, not
740
+ easier: mail scopes are restricted (app verification plus an annual CASA
741
+ assessment to publish) and a consent screen in Testing issues refresh tokens that
742
+ expire after 7 days. Gmail's genuine advantage is the app password, which Microsoft
743
+ retired.
744
+
745
+ - discover: Google now resolves to `LOGIN`, Microsoft stays `XOAUTH2`
746
+ - buildSetupConfig: an OAuth preset is attached only when `XOAUTH2` is the chosen
747
+ mode, so a Gmail app-password config no longer ships a clientId nobody needs
748
+ - docs/OAUTH.md and the README provider matrix carry the measured comparison,
749
+ including the caveat that Google could retire app passwords
750
+
751
+ ### Docs: personal Outlook needs an Entra tenant
752
+
753
+ Corrected a misleading instruction. A new Hotmail address is a Microsoft account,
754
+ not a tenant; signing in to the Entra admin centre lands in the shared "Microsoft
755
+ Services" tenant and reports "account does not exist". Registering an app requires
756
+ a directory (free Azure signup, or a work tenant). docs/OAUTH.md now explains the
757
+ distinction, the three routes, and recommends Gmail for a dedicated pi mailbox —
758
+ which needs no tenant and avoids the personal-Outlook IMAP/SMTP refusals too.
759
+
760
+ ### DMARC: trust the delivering relay, pin only when possible
761
+
762
+ `trustedAuthservId` is now optional. Unset means the **topmost**
763
+ `Authentication-Results` header is used — correct because headers are prepended,
764
+ so the final hop's verdict is first and a sender's forged header ends up beneath
765
+ it. Pinning still works and still fails closed when the id is absent.
766
+
767
+ This was a real blocker rather than a refinement: Microsoft's consumer relay emits
768
+ bare `Authentication-Results` headers with no authserv-id, so requiring a pin
769
+ rejected every genuine Hotmail reply. `--check` now reports it as a note instead of
770
+ a config failure.
771
+
772
+ ### Setup robustness
773
+
774
+ - Built-in provider table for the Microsoft and Google families (hotmail.com,
775
+ outlook.com, live.com, msn.com, gmail.com, googlemail.com) as a fallback when
776
+ ISPDB is unreachable or has no entry. ISPDB still wins when it answers, so the
777
+ published document stays authoritative.
778
+ - A domain with no dot (e.g. someone typing `you@hotmail`) is now named as such
779
+ and given the exact fix, instead of a generic "no published mail settings".
780
+ - Trailing dots on the domain are stripped (`hotmail.com.` works).
781
+
782
+ ### Setup
783
+
784
+ - `pi-mailbridge --setup <address> [--to <your-address>]`: discovers the
785
+ provider's IMAP/SMTP endpoints, ports and TLS mode from Thunderbird's
786
+ autoconfig database, infers `LOGIN` vs `XOAUTH2` from the advertised
787
+ authentication, attaches the Microsoft/Google OAuth preset when it recognises
788
+ the provider, and writes `config.json`. Refuses to clobber without `--force`.
789
+ - Single-mailbox mode is chosen automatically when `--to` equals the address.
790
+
791
+ ### Device identity
792
+
793
+ An idle notification identifies the machine as well as the session, and the
794
+ route record carries the device so several machines can share one mailbox
795
+ without competing for the same reply. `device.name` defaults to the first label
796
+ of `os.hostname()`; override with config or `PI_MAILBRIDGE_DEVICE`.
797
+
798
+ ### Works from any device
799
+
800
+ No app, account, relay or push service. Any client that can read the mailbox —
801
+ another PC, a laptop, a tablet, a phone, webmail in a browser — can continue a
802
+ session, because replies carry the same short routing id whichever device answers —
803
+ routing never depends on the sender's device.
804
+
805
+ ### Dependency upgrades
806
+
807
+ - `openid-client` 6.8.9 added — device-code OAuth2 for any provider.
808
+ - `imapflow` 1.7.8 → **2.11.0** (major), `nodemailer` 9.1.1 → **10.5.0** (major),
809
+ `mailparser` → 3.14.3.
810
+ - The watcher and mailer now reach the real clients through **explicit adapters**
811
+ instead of `as unknown as` casts, so every call is checked against the library's
812
+ actual signature and a future major bump fails the build rather than the daemon.
813
+ - Fixed as a direct result: `imapflow.search()` can return `false`/`undefined` for
814
+ an empty result set. The old cast hid it, so a quiet mailbox would have thrown
815
+ inside the scan. The adapter normalizes to `[]` and a test pins it.
816
+
817
+ ### Fixed during development
818
+
819
+ - Loop guard treated `mail.to` (the human) as "self", rejecting every genuine
820
+ reply. Now uses the pi account only.
821
+ - `stripQuoted` fell back to the raw body, which would have replayed the agent's
822
+ own quoted output back at it. A fully-quoted reply now yields empty and is
823
+ rejected as `empty-body`.
824
+ - Settle gate consulted "client exists" instead of the daemon's mode.
825
+
826
+ ### Known gaps
827
+
828
+ - **Microsoft Graph transport.** Personal `outlook.com` can reject IMAP/SMTP
829
+ OAuth2 at the server (`User is authenticated but not connected`,
830
+ `SmtpClientAuthentication is disabled`). Graph (`/me/sendMail`, `messages/delta`)
831
+ avoids SMTP entirely and is the correct fix; it is not implemented yet. Paid
832
+ turnkey alternative: EmailEngine (self-hosted) or Nylas (hosted).
833
+ - Creating a new session from mail (deliberately out of scope: `ctx.newSession()`
834
+ replaces the active session).
835
+ - MIME/S-MIME signing and encryption.
836
+ - Live-mailbox integration test (everything is hermetic today).