@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.0

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