@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-mailbridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,599 @@
1
- # Temporary Holding Version
1
+ # pi-mailbridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Answer your coding agent from your mailbox.
4
+
5
+ When a [pi](https://pi.dev) session needs a decision, it emails you. You reply from your phone,
6
+ your work laptop, a webmail tab — anything that can read mail — and **that same session
7
+ continues**, with your answer as the next message. Notifications also carry rendered Markdown,
8
+ images embedded in the body where the agent put them, and any local file it linked attached with
9
+ its real name.
10
+
11
+ ![A pi-mailbridge notification in a mail client: a decision request with rendered Markdown, a recommendation, and a build log attached](./docs/images/notification.png)
12
+
13
+ - **Your inbox is the client.** No app, no account, no relay, no push service. Any device with
14
+ email works: a phone, a tablet, another PC, a webmail tab.
15
+ - **Reply = resume.** The reply is injected into the session that sent the mail, as a follow-up,
16
+ so it queues correctly if a run is still in flight.
17
+ - **One mailbox connection per machine.** A small loopback daemon owns a single IMAP IDLE
18
+ connection; every pi session is a thin client, so N sessions never race over one mailbox.
19
+ - **Explicit, not inferred.** Email mode is a switch: on while you are away, off when you are at
20
+ the desk. No focus heuristics, no "probably idle".
21
+ - **Crash-safe.** Mail is marked seen only after the reply is delivered and acknowledged. A crash
22
+ leaves it unread, so nothing is lost.
23
+
24
+ > **This turns your inbox into a control channel for an agent that can run shell commands.**
25
+ > Read [docs/SECURITY.md](./docs/SECURITY.md) before installing, and point it at a mailbox you
26
+ > would not mind an agent reading.
27
+
28
+ ## Install
29
+
30
+ Requires **Node.js ≥ 22.6**. `pi`'s own Node is fine. The daemon ships compiled (Node refuses to
31
+ strip TypeScript inside `node_modules`); the pi extension itself is loaded by pi's jiti loader.
32
+
33
+ ```bash
34
+ pi install npm:@dumbmice/pi-mailbridge
35
+ ```
36
+
37
+ ## Lifecycle: who starts what
38
+
39
+ | Event | What happens |
40
+ | --- | --- |
41
+ | `pi install npm:@dumbmice/pi-mailbridge` | **Nothing runs.** The package is placed; no config, no daemon, no state directory. |
42
+ | first pi session after a usable config exists | the extension probes `/health` and, if nothing answers, spawns the daemon detached — this is why a daemon can appear without you running `setup` in that session |
43
+ | `/mailbridge setup` | writes the config, then starts (or reloads) the daemon and wires this session to it |
44
+ | `/mailbridge smoke` | re-read the config in the running daemon (mail password, allowlist) |
45
+ | `/mailbridge restart` | stop the process, so it comes back running the current code |
46
+ | `pi-mailbridge --setup` (headless) | writes the config and exits — **no daemon**. Start it yourself, and run `/mailbridge restart` in an already-open session so that session attaches |
47
+ | last pi session closes | the daemon exits after `daemon.idleTimeoutSecs` (120 s) — always; there is no "stay resident" switch |
48
+
49
+ A session attaches to whatever already answers on the port — a daemon started by hand or by an
50
+ earlier session is reused, never duplicated. Nothing keeps it resident: with no sessions it scans
51
+ but cannot deliver, so a daemon that outlived pi would read a mailbox for nobody.
52
+
53
+ After `pi update` the session notices a daemon on a different **protocol** — `/health` carries
54
+ `apiVersion` — and replaces it (once a minute at most). A different package *version* on the same
55
+ protocol is left alone. Without that, a new extension
56
+ would mint routing ids an old daemon cannot resolve, and replies would be rejected with the
57
+ cursor already past them.
58
+
59
+ The replacement is: shut the old daemon down (`POST /shutdown`), spawn the compiled daemon that is
60
+ already on disk (`dist/daemon/main.js` — no build runs at runtime; the package is built once, at
61
+ publish time, and `pi install` receives the build), and have this session re-create its client. Queued
62
+ replies, the cursor and the journal are on disk and untouched, so the gap costs nothing but the
63
+ seconds the old daemon spent not watching IMAP; the mode survives because it is a file. If the respawn fails, the session warns and keeps polling, retrying within a minute;
64
+ `/mailbridge restart` forces it.
65
+
66
+ The daemon exists whether or not email mode is on; `/mailbridge on` and `off` gate scanning and
67
+ delivery, not the process. So a hung daemon is never "because the mode is off", and the mode being
68
+ off does not stop it from answering `/health`.
69
+
70
+ ## Quick start
71
+
72
+ Inside pi:
73
+
74
+ ```text
75
+ /mailbridge setup pi-you@example.com to you@example.com
76
+ ```
77
+
78
+ `setup` discovers the provider's servers and auth from its published settings, so you never
79
+ hand-write hosts or ports. `to` is optional and defaults to the pi address — that selects the
80
+ single-mailbox layout, described below. It refuses to overwrite an existing config unless you
81
+ add `force`.
82
+
83
+ Setup cannot invent your password. It writes the config with an empty one and tells you where:
84
+
85
+ ```text
86
+ ~/.config/pi-mailbridge/config.json # mail.imap.password, mail.smtp.password
87
+ ```
88
+
89
+ Then prove it, once:
90
+
91
+ ```text
92
+ /mailbridge check # config shape, and that credentials are present (no network)
93
+ /mailbridge smoke # one real mail out, then read the folder back
94
+ ```
95
+
96
+ `check` cannot tell a wrong password from a right one; `smoke` is what proves the login. When
97
+ both are quiet:
98
+
99
+ ```text
100
+ /mailbridge on # email mode on
101
+ ```
102
+
103
+ Let a turn settle and the first notification arrives. Reply to it from anywhere.
104
+
105
+ Prefer to write the config yourself? Copy [`config.example.json`](./config.example.json).
106
+
107
+ ### Start watching from now
108
+
109
+ Do this **before your first `/mailbridge on`** if the mailbox already holds mail you do not want
110
+ the agent to read (a personal inbox, say):
111
+
112
+ ```text
113
+ /mailbridge prime # or: pi-mailbridge --prime
114
+ ```
115
+
116
+ It moves the UID cursor to the newest message without fetching anything, so scanning starts at
117
+ the next arrival. Nothing is read, nothing is marked seen. It is a first-run step: once the
118
+ bridge has a cursor, `prime` refuses, because moving the cursor later would silently skip a reply
119
+ that arrived while nothing was scanning. (Setup starts the daemon in pi; headless `--setup` only
120
+ writes the file. Nothing is read while email mode is off.)
121
+
122
+ ### Your first notification
123
+
124
+ ```
125
+ Subject: [pi:f913c9] pi: fix-login @ dev-box-7 is waiting for you
126
+
127
+ Done: the failing test passes now.
128
+
129
+ --
130
+ device: dev-box-7
131
+ session: fix-login
132
+ session id: 01a1c453-f2fc-7169-8c89-69c93fa1a152
133
+ project: /home/ybc/Work/proj
134
+ turns: 3
135
+
136
+ Reply above this line to continue the session.
137
+ ```
138
+
139
+ Reply normally — plain Reply is enough. The subject id routes it back; `In-Reply-To` and
140
+ `References` are the fallback if the subject is edited. `/mailbridge off` closes the channel in
141
+ both directions: no notifications, and replies stay unread in the mailbox until you turn it on
142
+ again.
143
+
144
+ ## One mailbox or two?
145
+
146
+ Two layouts work; pick before you start, because it changes the allowlist.
147
+
148
+ | | **A. Dedicated mailbox** (recommended) | **B. One mailbox** |
149
+ | --- | --- | --- |
150
+ | Who | pi gets its own address, you use yours | one address for both |
151
+ | `mail.to` | `you@example.com` | `you@qq.com` |
152
+ | `security.allowSelfReplies` | `false` (default) | `true` — required |
153
+ | `security.requireDmarcPass` | `true` (default) | `false` — setup writes this |
154
+ | Isolation | a compromised pi mailbox is not your inbox | none; point it at a mailbox you would not mind an agent reading |
155
+
156
+ ### A. Dedicated mailbox
157
+
158
+ pi gets its own address; your personal address receives notifications and sends replies.
159
+
160
+ ```text
161
+ mail.imap.user / mail.smtp.user = pi@example.com # pi's mailbox
162
+ mail.to = you@example.com # you
163
+ security.allowFrom = ["you@example.com"]
164
+ security.allowSelfReplies = false # the default
165
+ ```
166
+
167
+ ### B. One mailbox (single personal account)
168
+
169
+ pi's mailbox *is* your address, so notifications land in your inbox and you reply from the same
170
+ address.
171
+
172
+ ```text
173
+ mail.imap.user / mail.smtp.user = you@qq.com
174
+ mail.to = you@qq.com
175
+ security.allowFrom = ["you@qq.com"]
176
+ security.allowSelfReplies = true # required, or every reply is a "self" loop
177
+ security.requireDmarcPass = false # see below; /mailbridge setup writes this
178
+ ```
179
+
180
+ Three requirements keep this shape safe, and they constrain how you reply:
181
+
182
+ 1. **A marker header.** Every notification carries `X-Pi-Mailbridge: notification`, and the
183
+ scanner refuses that marker before anything else.
184
+ 2. **Reply evidence.** A self-addressed message must carry `In-Reply-To` or `References`;
185
+ **Reply (or Forward-then-Reply) is the only supported way in.** A freshly composed mail is
186
+ rejected as `no-reply-evidence`.
187
+ 3. **The outbound registry.** Our own `Message-ID`s are recorded when sending, so a copy of our
188
+ own mail is recognised even if the marker was stripped.
189
+
190
+ **DMARC cannot work here**, and that is not a loophole to leave open by accident. A message from
191
+ your address to the same address is delivered by your own provider, and some (QQ is one) add no
192
+ `Authentication-Results` header at all. Requiring one rejects every genuine reply, so `setup`
193
+ turns it off when the addresses match and says so. What that costs: **DMARC is what would stop
194
+ someone forging your address.** With it off, a spoofed `From` passes the allowlist, so what keeps
195
+ a stranger out is **routing** — they would need a live `[pi:…]` route id they had read from one of
196
+ your notifications. If your provider does stamp `Authentication-Results` on self-mail you can set
197
+ it back to `true`, but `/mailbridge check` and `--check` then report the combination —
198
+ `pi-mailbridge --check` exits non-zero, which is what a CI job or a process manager should gate
199
+ on, while the in-pi command only reports it.
200
+
201
+ Once configured, prove it without pi:
202
+
203
+ ```bash
204
+ pi-mailbridge --smoke # sends one real mail, then opens the folder and counts
205
+ ```
206
+
207
+
208
+ ## Commands
209
+
210
+ | Command | Effect |
211
+ | --- | --- |
212
+ | `/mailbridge` or `/mailbridge status` | daemon health, mode, session, secret, mailbox, allowlist, pending replies |
213
+ | `/mailbridge on` | email mode on: decisions mail you and end the turn |
214
+ | `/mailbridge off` | email mode off: decisions ask in this terminal |
215
+ | `/mailbridge setup <pi-address> [to <your-address>] [force]` | discover servers + auth, write the config, start or reload the daemon |
216
+ | `/mailbridge check` | validate config and that credentials are *present* (no network in pi; `--check` runs your `tokenCommand` when XOAUTH2 is configured) |
217
+ | `/mailbridge auth` | start the OAuth2 device login |
218
+ | `/mailbridge smoke` | reload the daemon, send one real mail, read the folder back |
219
+ | `/mailbridge prime` | **first run only**: skip the mailbox's existing mail |
220
+ | `/mailbridge restart` | replace the daemon process (after an update) |
221
+ | `/mailbridge help` | the same list |
222
+
223
+ ### Commands by mail
224
+
225
+ A reply whose body — after the quoted original and signature are stripped — is one command line
226
+ runs that command instead of being read by the agent. Useful when you are away and want the phone
227
+ to do something:
228
+
229
+ | Reply | Effect |
230
+ | --- | --- |
231
+ | `/mailbridge on` | only while the mode is already on (a no-op). It cannot re-open the channel from off: nothing scans and no session drains, so the reply is never read. Use the terminal |
232
+ | `/mailbridge off` | email mode OFF (one-way by mail: re-opening it needs the terminal); anything else in that batch stays pending |
233
+ | `/mailbridge` or `/mailbridge status` | the status panel |
234
+ | `/mailbridge check` / `/mailbridge smoke` | validate the config / send one test mail |
235
+ | `/mailbridge help` | the command list |
236
+
237
+ Output appears in the pi session, **not** in a reply mail. Four are deliberately **not** runnable
238
+ by mail — `/mailbridge setup`, `auth`, `restart` and `prime` — and anything longer than one line
239
+ is a message, so a quoted command never fires. The dispatch is ours, not pi's, so a refused
240
+ command cannot reconfigure the account even if someone forges your address. What the dispatcher
241
+ refuses is still delivered to the agent as text, so §1/§3 of SECURITY apply to it: the refusal is
242
+ about what runs automatically, not about what the model may then do.
243
+
244
+ ### The headless entry point
245
+
246
+ For a container, a headless box, or a first run on a remote machine:
247
+
248
+ | Flag | Effect |
249
+ | --- | --- |
250
+ | *(none)* | run the daemon in the foreground |
251
+ | `--setup <pi-address> [--to <your-address>] [--force]` | write the config and exit; nothing is started. Omitting `--to` selects the single-mailbox layout |
252
+ | `--check` | validate config and credential *presence*, exit non-zero on a problem; runs `tokenCommand` for XOAUTH2 |
253
+ | `--smoke` | send one real mail, read the folder back |
254
+ | `--auth` | OAuth2 device login |
255
+ | `--prime` | first-run cursor move; exits 2 if it has already run |
256
+ | `--print-token` | print the bearer secret to stdout — never paste it anywhere |
257
+
258
+ ```bash
259
+ node "$HOME/.pi/agent/npm/node_modules/pi-mailbridge/dist/daemon/main.js"
260
+ ```
261
+
262
+ The package ships a compiled `dist/` because Node refuses to strip TypeScript inside
263
+ `node_modules`; a checkout without a build can run `npm run build`, or use
264
+ `node --experimental-strip-types src/daemon/main.ts`.
265
+
266
+ ### What gets attached
267
+
268
+ A notification carries what it needs to be useful away from the desk:
269
+
270
+ | In the mail body | What arrives |
271
+ | --- | --- |
272
+ | `![shot](./shot.png)` | the image **inline** — embedded in the body where the agent put it, via `Content-ID` (`<img src="cid:…">`), so it renders exactly there instead of sitting in the attachment tray. PNG/JPEG/GIF/WebP only, up to 5 MB each |
273
+ | `[the log](./build.log)` | `build.log` as an **attachment** — nothing renders a PDF or a zip inside a mail body, so it is named beside the link: `the log [attached: build.log]` |
274
+
275
+ Up to 10 attachments, 15 MB each for a linked file and 5 MB for an inline image, 18 MB in total
276
+ by default (`notify.maxAttachmentMb`). Attachments are
277
+ base64-encoded, which costs about a third, so the default stays under the smallest provider
278
+ ceiling:
279
+
280
+ | Provider | Per-message limit |
281
+ | --- | --- |
282
+ | Gmail (personal) | 25 MB sent, 50 MB received |
283
+ | Outlook.com / Hotmail | 25 MB |
284
+ | Microsoft 365 (work) | 35 MB sent by default; on-prem Exchange often 10 MB |
285
+ | QQ | 50 MB (+5 MB body), 200 MB/day |
286
+
287
+ Attachments come only from **the agent's project directory**, and only for allowlisted types
288
+ (`pdf txt log md csv tsv json yml yaml xml html zip gz tar docx xlsx pptx`, plus images). That
289
+ keeps the obvious key *filenames* out of an accidental attach; it is not a content filter — an
290
+ agent told to `cp ~/.ssh/id_rsa notes.txt` or `tar` a directory can still mail the result. Start
291
+ pi in a project rather than in `$HOME`, and see
292
+ [docs/SECURITY.md](./docs/SECURITY.md) §6b for the full boundary.
293
+
294
+ ## When it helps
295
+
296
+ - Long runs where the agent stops to ask something and you are not at the desk.
297
+ - Work you start and then leave — the decision reaches you wherever you are.
298
+ - Sending an image or a log file as the answer instead of prose.
299
+
300
+ ## When it does not
301
+
302
+ - **You need an answer now.** Mail latency is mail latency; there is no push channel.
303
+ - **The mailbox gets untrusted mail.** Everything in `security.allowFrom` can command the agent;
304
+ keep the mailbox narrow.
305
+ - **You want a new session started from mail.** Not implemented: a reply resumes the session that
306
+ asked. Starting a fresh one from a mail is a different architecture.
307
+ - **You need S/MIME or PGP.** Not implemented; transport encryption is the provider's.
308
+
309
+ ## How a reply is identified
310
+
311
+ A reply is matched to a session by the first rule that succeeds:
312
+
313
+ | # | Rule | Survives | Notes |
314
+ | --- | --- | --- | --- |
315
+ | 1 | **Short route id** — `[pi:f913c9]`, matched anywhere in the subject | subject intact | What every notification carries. A lookup key in this machine's route table, not a credential: an id never minted here resolves to nothing. TTL-bounded. `Re:`, `RE:`, `Aw:`, `Re[2]:` are fine because the id is not anchored. |
316
+ | 2 | **Legacy signed token** — `[pi:<payload>.<mac>]` | subject intact | Still verified (HMAC, TTL-bounded) so older mail keeps working. New notifications no longer use it. |
317
+ | 3 | **The reply chain** — `In-Reply-To`, or any id in `References`, equals a Message-ID we recorded | subject edited, rewritten or purged | Recorded when the notification is sent, kept for the last 2,000 sends, aged out with `security.tokenTtlHours`. |
318
+ | 4 | None of the above | — | Rejected as `no-token`. The mail stays unread, but the cursor has passed it, so reply again rather than editing the old subject. |
319
+
320
+ Every rule is routing only. Authorization happens before it: the sender allowlist, plus DMARC
321
+ (off in the single-mailbox layout — see above).
322
+
323
+ - **Reply normally.** Mail clients set `In-Reply-To` or at least `References`; either is accepted.
324
+ - **Editing the subject is safe** — the chain still routes it until the TTL expires. On a
325
+ provider that rewrites `Message-ID` (QQ does; `setup` names `Sent Messages` for
326
+ `qq.com`/`foxmail.com`), set `mail.imap.sentFolder` or this fallback cannot match: the reply
327
+ references an id the provider invented, not the one we recorded.
328
+ - **Composing a brand-new email** with no id and no reference is not identified.
329
+ - **Quoted text is stripped** before the agent sees it, so leave the quote in.
330
+ - **A reply to a reply** still works: the chain carries the original id.
331
+
332
+ Rule 3 is a convenience fallback, not authentication. Set `security.allowHeaderThreading: false`
333
+ to switch it off; the subject id is an unsigned lookup key either way.
334
+
335
+ ## A reply did not arrive
336
+
337
+ Every scan records why each message was refused, so this is answerable:
338
+
339
+ ```
340
+ /mailbridge status
341
+ ```
342
+
343
+ ```
344
+ daemon: healthy (http://127.0.0.1:4177) — daemon version 0.1.0
345
+ device: dev-box-7
346
+ mode: on
347
+ imap: connected
348
+ session: 01a1c453-…
349
+ secret: present
350
+ mailbox: you@example.com
351
+ allowlist: you@example.com
352
+ pending replies: 0
353
+ last scan with mail: 14:02:11 — examined 2, kept 1, rejected 1
354
+ rejections: not-allowlisted 1
355
+ ```
356
+
357
+ The version on the first line is the daemon's package version. What decides whether the session can
358
+ use it is the **protocol** (`protocol 1`): a daemon on another protocol is replaced within a minute
359
+ (see Lifecycle); a different package version on the same protocol is fine.
360
+
361
+ A non-zero `rejected` count means mail was refused. A reply delivered only as an attachment-drop
362
+ notice counts as **kept**; `attachments dropped:` names its loss separately. `handled:` lines are the
363
+ accepted paths, `attachments dropped:` is a partial loss, and a full queue defers rather than
364
+ discards. Counters of zero are omitted, so a quiet scan prints no scan line; while mode is off
365
+ nothing is scanned and the panel shows the previous scan, so `mode:` — not the scan time — is the
366
+ proof the channel is open.
367
+
368
+ | Reason | What it means | Fix |
369
+ | --- | --- | --- |
370
+ | `no-from` / `not-allowlisted` | no `From:`, or not an exact address in `security.allowFrom` | add the address you reply from |
371
+ | `allowlist-empty` | `security.allowFrom` is empty, which denies everything | set it |
372
+ | `dmarc-missing` / `dmarc-failed` | no `Authentication-Results` with `dmarc=pass` from a trusted relay | check `security.requireDmarcPass`; Microsoft consumer accounts need `trustedAuthservId` unset |
373
+ | `no-token` | the subject lost its `[pi:…]` id **and** no `In-Reply-To`/`References` matched | reply to the original mail |
374
+ | `route-miss` | the subject's id expired or was minted on another machine | nothing, if it routed by the chain — it appears under `handled:` |
375
+ | `other-device` | the notification came from another pi machine, or `device.name` changed | reply to a notification from this machine |
376
+ | `self` | single-mailbox mode with `security.allowSelfReplies: false` | set it `true` |
377
+ | `self-echo` | our own notification looping back — the guard working | nothing |
378
+ | `expired` | a queued reply aged out under queue pressure | send it again; the original never arrived |
379
+ | `no-reply-evidence` | a self-addressed mail with no threading headers | reply to the notification instead |
380
+ | `empty-body` from a `From:`/`>`-leading reply | the first line starts with a quote marker, so the whole body is treated as quoted | write your text above the quote, or paste logs after a sentence |
381
+ | `auto-generated` | a robot, mailing list or bounce | nothing |
382
+ | `attachment-only-dropped` | the reply's only content was an attachment that was dropped (over the 1 MB total, a refused type); a note naming the drop is delivered | send the text, or a smaller file |
383
+ | `empty-body` | no prose, no attachment at all | — |
384
+ | `attachment-*` | an attachment was dropped (type, size, count) | see the suffix |
385
+ | `pending-full` | 1 000 undelivered replies are queued | start a session so they drain |
386
+ | `send_budget` | more than ten notifications in a minute were requested | a loop is mailing; the budget refuses rather than spending the account's quota |
387
+ | `too-large` | over 8 MiB, refused before the body was downloaded | send a smaller one; inbound attachments are capped at 4 files and 1 MB *in total* |
388
+
389
+ Where to look:
390
+
391
+ | Path | What it holds |
392
+ | --- | --- |
393
+ | `$XDG_STATE_HOME/pi-mailbridge/daemon.log` (default `~/.local/state/…`) | scan summaries, connect errors, refusal reasons |
394
+ | `…/uid-cursor.json` | last seen UID + the mailbox UIDVALIDITY |
395
+ | `…/daemon.lock` | the running daemon's pid — the file to look at when a start says another daemon is already running |
396
+ | `…/routes.lock` | held for the instant a routing id is minted |
397
+ | `…/daemon.log` | the daemon's own log (0600, rotates once at 5 MB) |
398
+ | `…/mode.json` | the global on/off state `/mailbridge on` and `off` write |
399
+ | `…/pending.json` | accepted replies waiting for a session (each stamped with the device that scanned it; `held for another device` counts the ones this device will not claim) |
400
+ | `…/routes.json` | short routing ids (`security.tokenTtlHours`) |
401
+ | `…/threads.json` | outbound Message-IDs for threading and the reply chain |
402
+ | `…/journal.json` | acked UIDs, tagged with the UIDVALIDITY |
403
+
404
+ If `imap:` says `disconnected`, the credentials or host are wrong even though the daemon is up —
405
+ the state a revoked app password leaves you in. If `/mailbridge status` says the daemon is
406
+ unreachable, `/mailbridge restart` both starts it and wires this session.
407
+
408
+ Allow up to a minute for a reply: the daemon sweeps on a timer (`daemon.scanIntervalSecs`,
409
+ default 60) as well as on IMAP events, because a provider can stop pushing them. `no mail yet —
410
+ last checked …` means it is polling and has seen nothing.
411
+
412
+ ## Security
413
+
414
+ Gate order, all fail-closed: journal → our own marker header → size ceiling → `From:` present →
415
+ loop guard → auto-generated/bounce → sender allowlist → DMARC → our own Message-ID → routing →
416
+ durable ack. Routing identifiers never authorize anything: authorization is the allowlist and
417
+ DMARC alone (DMARC is off in single-mailbox mode). Prefer a dedicated account or a filtered
418
+ folder. What is stored, what a leaked token means, and the threat model in full:
419
+ [docs/SECURITY.md](./docs/SECURITY.md).
420
+
421
+ ## Configuration
422
+
423
+ `~/.config/pi-mailbridge/config.json`, merged over built-in defaults, then `PI_MAILBRIDGE_*`
424
+ environment variables. A working example: [`config.example.json`](./config.example.json).
425
+
426
+ | Key | Default | Meaning |
427
+ | --- | --- | --- |
428
+ | `mail.imap.host` / `.port` / `.secure` | — / 993 / true | IMAP server |
429
+ | `mail.imap.user` | — | pi's mailbox account |
430
+ | `mail.imap.sentFolder` | — | where the account keeps sent mail (`Sent Messages` on QQ, `Sent Items` on Outlook). Set it when the provider rewrites the `Message-ID` we stamp — QQ does, and `setup` writes it for `qq.com`/`foxmail.com`. Costs one short-lived IMAP connection per notification; the copy must appear within about two minutes |
431
+ | `mail.imap.password` | — | password or app password for `LOGIN` |
432
+ | `mail.imap.folder` | `INBOX` | watched folder |
433
+ | `mail.imap.authType` | `LOGIN` | `LOGIN` or `XOAUTH2` |
434
+ | `mail.imap.tokenCommand` | `[]` | argv whose stdout is an access token |
435
+ | `mail.smtp.host` / `.port` / `.secure` | — / 465 / true | SMTP server |
436
+ | `mail.smtp.user` | — | also the `From:` and the loop-guard identity |
437
+ | `mail.smtp.password` | — | password or app password for `LOGIN` |
438
+ | `mail.smtp.authType` | `LOGIN` | `LOGIN` or `XOAUTH2` |
439
+ | `mail.smtp.tokenCommand` | `[]` | argv whose stdout is an access token |
440
+ | `mail.to` | — | **your** address: where notifications go |
441
+ | `oauth.issuer` | — | OAuth2 discovery base URL |
442
+ | `oauth.clientId` | — | your app registration's client id |
443
+ | `oauth.clientSecret` | — | confidential clients only; omit for device code |
444
+ | `oauth.scope` | — | must include `offline_access` for a refresh token |
445
+ | `device.name` | hostname | machine name in settle notifications and routing records |
446
+ | `security.allowFrom` | `[]` | senders allowed to command the agent; empty denies all |
447
+ | `security.requireDmarcPass` | `true` | require a DMARC pass, pinned to `trustedAuthservId` when set. `false` for a single mailbox unless your provider stamps `Authentication-Results` on self-mail |
448
+ | `security.trustedAuthservId` | unset | pin the trusted `Authentication-Results` host; leave unset for Microsoft consumer accounts |
449
+ | `security.allowHeaderThreading` | `true` | accept a reply whose subject id was lost, by matching `In-Reply-To`/`References` |
450
+ | `security.allowSelfReplies` | `false` | one address for both roles: accept a self-addressed message, but only a real reply |
451
+ | `security.tokenTtlHours` | `720` | lifetime of a route id, signed token and reply-chain entry |
452
+ | `daemon.host` / `.port` | `127.0.0.1` / `4177` | loopback HTTP; a lock file in the state directory is the instance lock, and the port is a second guard |
453
+ | `daemon.idleTimeoutSecs` | `120` | grace period after the last ping |
454
+ | `daemon.scanIntervalSecs` | `60` | catch-up scan while mode is on, so a silent IDLE cannot strand a reply; `0` disables |
455
+ | `notify.onSettled` | `true` | email when an agent settles |
456
+ | `notify.debounceMs` | `10000` | quiet period before the settle mail |
457
+ | `notify.notifyOnAborted` | `false` | also mail when you hit Escape |
458
+ | `notify.maxAttachmentMb` | `18` | total size of files attached to one notification, before base64. Default sits under Gmail/Outlook.com's 25 MB; QQ allows 50 MB, so a QQ account can raise it |
459
+ | `tuning.idleRenewMs` | `1500000` | IDLE renew interval (keep under ~29 min) |
460
+ | `tuning.backoffInitialMs` / `.backoffMaxMs` | `1000` / `60000` | reconnect backoff bounds |
461
+
462
+ ### Provider support
463
+
464
+ | Provider | Auth | Status |
465
+ | --- | --- | --- |
466
+ | Any IMAP/SMTP host (self-hosted, Posteo, Fastmail, Zoho…) | `LOGIN` — account or app password | ✅ works |
467
+ | **QQ Mail / Foxmail** | `LOGIN` 授权码 | ✅ built-in discovery; QQ accounts must be 14 days old before IMAP/SMTP can be enabled |
468
+ | **163 / 126 / yeah.net / 188.com** | `LOGIN` 授权码 | ✅ built-in discovery; no waiting period |
469
+ | Gmail | app password (`LOGIN`) | ✅ works |
470
+ | Microsoft 365 / work accounts | `XOAUTH2` | ✅ usually works |
471
+ | **Personal** `@outlook.com` / `@hotmail.com` | `XOAUTH2` only | ⚠️ app passwords are rejected over IMAP — see below |
472
+
473
+ **Outlook needs OAuth2, not a password.** Enabling IMAP/SMTP in the settings does not restore
474
+ password login. The package ships a device-code flow:
475
+
476
+ ```bash
477
+ pi-mailbridge --check # tells you exactly what is missing
478
+ pi-mailbridge --auth # prints a URL + code, waits for approval
479
+ ```
480
+
481
+ ```json
482
+ "oauth": {
483
+ "issuer": "https://login.microsoftonline.com/consumers/v2.0",
484
+ "clientId": "<application (client) id>",
485
+ "scope": "offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send"
486
+ }
487
+ ```
488
+
489
+ Registering the app is free and takes about two minutes (Entra → *Allow public client flows*).
490
+ Walkthrough, scope tables and the Gmail equivalent: [docs/OAUTH.md](./docs/OAUTH.md).
491
+
492
+ **In mainland China, use QQ or 163** — 授权码 works exactly like an app password:
493
+
494
+ ```text
495
+ /mailbridge setup pi-you@qq.com to you@hotmail.com
496
+ ```
497
+
498
+ Then paste the 授权码 into both password fields and `/mailbridge smoke`. `setup` writes
499
+ `mail.imap.sentFolder: "Sent Messages"` for `qq.com`/`foxmail.com`, because QQ replaces the
500
+ `Message-ID` we stamp; add it by hand for any other provider that rewrites ids.
501
+
502
+ **Elsewhere, Gmail with an app password** is the path with fewest moving parts: no OAuth app, no
503
+ tenant, no refresh token that dies weekly. Gmail *OAuth* is the harder route — restricted scopes
504
+ (verification + CASA) and consent screens in Testing expire refresh tokens every 7 days.
505
+
506
+ **Personal Outlook has a real caveat:** Microsoft sometimes rejects IMAP/SMTP OAuth2 even with a
507
+ valid token (`User is authenticated but not connected`, `SmtpClientAuthentication is disabled`).
508
+ That is their side. The reliable answer is Microsoft Graph, which is not implemented here yet;
509
+ [docs/OAUTH.md](./docs/OAUTH.md) explains the options, including turnkey services.
510
+
511
+ ### A token command instead
512
+
513
+ If you already run a broker, `oauth.*` takes precedence over `tokenCommand` — remove the `oauth`
514
+ block to use it:
515
+
516
+ ```json
517
+ "authType": "XOAUTH2",
518
+ "tokenCommand": ["ortie", "token", "show", "-a", "outlook"]
519
+ ```
520
+
521
+ Three consecutive failures disable OAuth2 for the session rather than spawning a failing process
522
+ on every scan; a single transient failure is retried.
523
+
524
+ ## Environment overrides
525
+
526
+ Config values can be overridden per-process, which is useful for a container or a shell that starts
527
+ the daemon without editing your config file:
528
+
529
+ | Variable | Sets |
530
+ | --- | --- |
531
+ | `PI_MAILBRIDGE_IMAP_HOST`, `PI_MAILBRIDGE_IMAP_USER`, `PI_MAILBRIDGE_IMAP_PASS` | `mail.imap.host`, `.user`, `.password` |
532
+ | `PI_MAILBRIDGE_SMTP_HOST`, `PI_MAILBRIDGE_SMTP_USER`, `PI_MAILBRIDGE_SMTP_PASS` | `mail.smtp.host`, `.user`, `.password` |
533
+ | `PI_MAILBRIDGE_TO` | `mail.to` |
534
+ | `PI_MAILBRIDGE_PORT` | `daemon.port` |
535
+ | `PI_MAILBRIDGE_DEVICE` | `device.name` |
536
+ | `PI_MAILBRIDGE_IMAP_TOKEN_CMD`, `PI_MAILBRIDGE_SMTP_TOKEN_CMD` | `mail.imap.tokenCommand`, `mail.smtp.tokenCommand` (argv list; JSON or comma-separated) |
537
+
538
+ ## Agent tools
539
+
540
+ | Tool | Behaviour |
541
+ | --- | --- |
542
+ | `request_decision` | Emails a question with optional options and a recommendation, then ends the turn. One outstanding decision per session. Ending the turn is a convention, not an enforced gate — see [docs/SECURITY.md](./docs/SECURITY.md) §3. |
543
+ | `notify_user` | One-way update. Does not reserve a slot and does not wait. |
544
+
545
+ Both refuse cleanly when email mode is off or the daemon is unreachable.
546
+
547
+ ## Turning it off for good
548
+
549
+ `/mailbridge off` closes the channel but leaves everything installed. To remove it:
550
+
551
+ ```bash
552
+ # 1. the daemon — stop it however you started it (pi exits it when the last session closes)
553
+ # If you started it yourself, stop that process first.
554
+
555
+ # 2. the package — the reverse of however you installed it, e.g.
556
+ # pi remove npm:@dumbmice/pi-mailbridge
557
+
558
+ # 3. everything it wrote: credentials, routing ids, queued replies
559
+ rm -rf ~/.local/state/pi-mailbridge ~/.config/pi-mailbridge
560
+ ```
561
+
562
+ Nothing is installed system-wide: the daemon runs as you, binds loopback, and its state is the
563
+ two directories above. If you created an Entra app or a provider app password, revoke those too —
564
+ that is the credential that actually reaches your mailbox.
565
+
566
+ ## How it works
567
+
568
+ ![Diagram: the pi session sends through the local daemon over SMTP to your mailbox; you reply from any device; the daemon reads it over IMAP, authorizes, routes and persists it; the session claims it, injects it, and acknowledges, which marks the mail seen](./docs/images/flow.svg)
569
+
570
+ The numbered path is the whole product. **1** The agent finishes its turn and goes idle — that is
571
+ the normal case, one mail per finished turn — or it stops to ask you a question; either way the
572
+ session hands that mail to the daemon. **2** The daemon sends it over SMTP. **3** It reaches you on
573
+ any device. **4** You reply. **5** The daemon reads the reply over IMAP and runs it through the
574
+ gates printed under the diagram. **6** The session claims it and continues: your reply is the next
575
+ message, not a new conversation. **7** It acknowledges — and only then is the mail marked seen and
576
+ journalled.
577
+
578
+ One daemon per machine owns the mailbox; sessions never touch IMAP. `smoke` and `prime` are the
579
+ deliberate exceptions — each opens one short-lived connection. Design notes, the failure-mode
580
+ table and the module map: [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) and
581
+ [docs/MAPPING.md](./docs/MAPPING.md).
582
+
583
+ ## Status
584
+
585
+ `0.1.0` — feature-complete for the single-user, one-mailbox case, and exercised end to end
586
+ against a live QQ mailbox in both layouts: two addresses (DMARC verified on the reply) and one
587
+ address for both roles (self-delivery, the self-echo guard, and a reply routed by the chain).
588
+
589
+ What that does not buy you: there is **no automated live-mailbox test**. Provider behaviour is
590
+ observed, not guaranteed — a provider can change how it rewrites headers or pushes `EXISTS`. The
591
+ hermetic suite covers the token, authz, scan pipeline, stores, daemon HTTP surface, lifecycle,
592
+ extension surface, tools, attachments and the full end-to-end loop; it needs no network, no
593
+ mailbox and no socket.
594
+
595
+ Not implemented: starting a *new* session from mail, and MIME/S-MIME.
596
+
597
+ ## License
598
+
599
+ MIT
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // `pi-mailbridge` — the headless entry point documented in the README.
3
+ //
4
+ // From an installed package this loads the compiled `dist/` build: Node refuses to strip
5
+ // TypeScript inside `node_modules` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), which is why
6
+ // the daemon is built rather than shipped as `.ts`. A checkout without a build falls back to
7
+ // running the sources with the flag, which is how development and the tests use it.
8
+ import { existsSync } from "node:fs"
9
+ import { fileURLToPath } from "node:url"
10
+
11
+ const compiled = new URL("../dist/daemon/main.js", import.meta.url)
12
+ const source = new URL("../src/daemon/main.ts", import.meta.url)
13
+
14
+ if (existsSync(fileURLToPath(compiled))) {
15
+ const { main } = await import(compiled.href)
16
+ main().catch((error) => {
17
+ console.error(`pi-mailbridge: fatal: ${error instanceof Error ? error.message : String(error)}`)
18
+ process.exit(1)
19
+ })
20
+ } else if (existsSync(fileURLToPath(source))) {
21
+ const { spawn } = await import("node:child_process")
22
+ const child = spawn(
23
+ process.execPath,
24
+ ["--experimental-strip-types", fileURLToPath(source), ...process.argv.slice(2)],
25
+ { stdio: "inherit" },
26
+ )
27
+ child.on("exit", (code) => process.exit(code ?? 1))
28
+ } else {
29
+ console.error("pi-mailbridge: no dist/ or src/ found beside this bin; reinstall the package")
30
+ process.exit(1)
31
+ }