@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.
- package/CHANGELOG.md +824 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/bin/pi-mailbridge.mjs +31 -0
- package/config.example.json +54 -0
- package/dist/core/attachments.js +87 -0
- package/dist/core/authz.js +65 -0
- package/dist/core/commands.js +72 -0
- package/dist/core/config-freshness.js +24 -0
- package/dist/core/config.js +286 -0
- package/dist/core/deliveries.js +180 -0
- package/dist/core/discover.js +282 -0
- package/dist/core/header-encoding.js +70 -0
- package/dist/core/html.js +48 -0
- package/dist/core/inline-images.js +355 -0
- package/dist/core/mail-command.js +62 -0
- package/dist/core/mailer.js +126 -0
- package/dist/core/markdown.js +96 -0
- package/dist/core/oauth-device.js +119 -0
- package/dist/core/oauth-openid.js +48 -0
- package/dist/core/oauth.js +137 -0
- package/dist/core/prime.js +51 -0
- package/dist/core/reply-parse.js +134 -0
- package/dist/core/sent-index.js +135 -0
- package/dist/core/settle.js +122 -0
- package/dist/core/setup.js +100 -0
- package/dist/core/smoke.js +164 -0
- package/dist/core/status.js +56 -0
- package/dist/core/token-source.js +27 -0
- package/dist/core/token.js +196 -0
- package/dist/core/version.js +44 -0
- package/dist/daemon/api.js +46 -0
- package/dist/daemon/imapflow.js +75 -0
- package/dist/daemon/main.js +802 -0
- package/dist/daemon/process.js +270 -0
- package/dist/daemon/scan-queue.js +38 -0
- package/dist/daemon/script.js +44 -0
- package/dist/daemon/send-budget.js +30 -0
- package/dist/daemon/service.js +455 -0
- package/dist/daemon/source-proxy.js +64 -0
- package/dist/daemon/spawn.js +227 -0
- package/dist/daemon/watcher.js +308 -0
- package/dist/extension.js +1233 -0
- package/dist/store/journal.js +92 -0
- package/dist/store/mode-store.js +47 -0
- package/dist/store/oauth-store.js +50 -0
- package/dist/store/paths.js +33 -0
- package/dist/store/pending-store.js +212 -0
- package/dist/store/registry.js +61 -0
- package/dist/store/routes.js +223 -0
- package/dist/store/schema.js +93 -0
- package/dist/store/secret.js +70 -0
- package/dist/store/threads.js +145 -0
- package/dist/store/uid-cursor.js +112 -0
- package/docs/ARCHITECTURE.md +356 -0
- package/docs/MAPPING.md +201 -0
- package/docs/OAUTH.md +448 -0
- package/docs/SECURITY.md +273 -0
- package/docs/images/flow.svg +124 -0
- package/docs/images/notification.png +0 -0
- package/package.json +85 -4
- package/scripts/render-teaser.ts +56 -0
- package/src/core/attachments.ts +129 -0
- package/src/core/authz.ts +87 -0
- package/src/core/commands.ts +99 -0
- package/src/core/config-freshness.ts +33 -0
- package/src/core/config.ts +373 -0
- package/src/core/deliveries.ts +233 -0
- package/src/core/discover.ts +333 -0
- package/src/core/header-encoding.ts +75 -0
- package/src/core/html.ts +62 -0
- package/src/core/inline-images.ts +421 -0
- package/src/core/mail-command.ts +76 -0
- package/src/core/mailer.ts +193 -0
- package/src/core/markdown.ts +114 -0
- package/src/core/oauth-device.ts +195 -0
- package/src/core/oauth-openid.ts +61 -0
- package/src/core/oauth.ts +182 -0
- package/src/core/prime.ts +74 -0
- package/src/core/reply-parse.ts +153 -0
- package/src/core/sent-index.ts +164 -0
- package/src/core/settle.ts +179 -0
- package/src/core/setup.ts +139 -0
- package/src/core/smoke.ts +207 -0
- package/src/core/status.ts +75 -0
- package/src/core/token-source.ts +34 -0
- package/src/core/token.ts +240 -0
- package/src/core/version.ts +45 -0
- package/src/daemon/api.ts +52 -0
- package/src/daemon/imapflow.ts +101 -0
- package/src/daemon/main.ts +832 -0
- package/src/daemon/process.ts +348 -0
- package/src/daemon/scan-queue.ts +45 -0
- package/src/daemon/script.ts +45 -0
- package/src/daemon/send-budget.ts +36 -0
- package/src/daemon/service.ts +550 -0
- package/src/daemon/source-proxy.ts +88 -0
- package/src/daemon/spawn.ts +332 -0
- package/src/daemon/watcher.ts +427 -0
- package/src/extension.ts +1381 -0
- package/src/store/journal.ts +104 -0
- package/src/store/mode-store.ts +60 -0
- package/src/store/oauth-store.ts +55 -0
- package/src/store/paths.ts +38 -0
- package/src/store/pending-store.ts +282 -0
- package/src/store/registry.ts +91 -0
- package/src/store/routes.ts +267 -0
- package/src/store/schema.ts +104 -0
- package/src/store/secret.ts +69 -0
- package/src/store/threads.ts +177 -0
- 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).
|