@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +836 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,273 @@
1
+ # SECURITY
2
+
3
+ **This package turns your inbox into a control channel for a shell-capable agent.**
4
+ Read this before installing.
5
+
6
+ ## 1. Threat model
7
+
8
+ If an attacker can get one message into the watched mailbox, they may be able to
9
+ make your agent do work — with whatever tools that session has. The agent has
10
+ `bash`, `edit`, and `write`. That is remote code execution with extra steps.
11
+
12
+ Sequence:
13
+
14
+ 1. message lands in the inbox (spoofed, forwarded, or from a compromised account)
15
+ 2. it carries a plausible `[pi:…]` token (guessed, replayed, or copied from a
16
+ mail you forwarded)
17
+ 3. the daemon parses it, a session claims it, `pi.sendUserMessage` injects it
18
+ 4. the agent treats it as you speaking
19
+
20
+ Every control below exists to break one link in that chain.
21
+
22
+ ## 2. Defence in depth — all layers, not a subset
23
+
24
+ | Layer | Control | Breaks |
25
+ | --- | --- | --- |
26
+ | Envelope | **Sender allowlist**, exact `addr-spec` match after RFC 5322 parsing | step 1 |
27
+ | Provenance | **DMARC pass** from the authenticated relay (`security.requireDmarcPass`, pinned to `trustedAuthservId` when set) | step 1 |
28
+ | Routing | **short route id**, TTL-bounded (legacy HMAC-signed tokens still verify) | steps 2–3 |
29
+ | Replay | **journal + expiry** (durable, survives restart) | step 2 |
30
+ | Human | **`request_decision` asks and ends the turn**; only a reply resumes it | step 4 |
31
+
32
+ ### 2.1 DMARC — trust the delivering relay's header
33
+
34
+ `Authentication-Results` is an ordinary, append-only, sender-writable header. A
35
+ message can arrive with a forged `Authentication-Results` from the attacker's own
36
+ infrastructure *and* the real one your provider added.
37
+
38
+ What makes that safe is header ordering: headers are **prepended** as a message
39
+ traverses relays, so the **topmost** `Authentication-Results` is the one the final
40
+ hop — your provider — wrote on delivery. A sender's forged header necessarily ends
41
+ up *below* it.
42
+
43
+ Rules:
44
+
45
+ 1. Read the **topmost** `Authentication-Results` header and require `dmarc=pass`
46
+ in it.
47
+ 2. Optionally pin it harder: setting `security.trustedAuthservId` means only a
48
+ header bearing that `authserv-id` is considered, and its absence **fails
49
+ closed** rather than falling back.
50
+ 3. Do **not** pin an authserv-id for Microsoft consumer accounts: the outlook.com
51
+ relay emits bare headers with no authserv-id at all
52
+ (`Authentication-Results: spf=pass …; dmarc=pass …`), so a pinned value would
53
+ reject every genuine reply. Leave it unset there.
54
+ 4. Treat DMARC pass as **necessary, not sufficient** — it authenticates the
55
+ sending domain, not the authority to command your agent. An allowlisted sender
56
+ whose account is compromised still passes DMARC.
57
+
58
+ ### 2.2 The routing tag is not authorization
59
+
60
+ A valid `[pi:…]` tag proves the subject was minted by this installation — nothing
61
+ more. New notifications carry a short local route id (`[pi:f913c9]`); older ones carry
62
+ a signed token, and both are accepted. Neither proves the message came from you. Anyone who replies to one of your
63
+ notification emails (or forwards it) can carry the token into the mailbox.
64
+
65
+ That is why token verification is *never* the only gate — the allowlist and
66
+ DMARC come first (DMARC is off in the single-mailbox layout), and the token only
67
+ routes.
68
+
69
+ ### 2.3 Replay protection must be durable
70
+
71
+ An in-memory "seen UIDs" set dies with the daemon. The journal is a file,
72
+ appended after a successful ack and consulted at scan time, so an acked mail is never
73
+ offered again. Routing ids, signed tokens and the reply chain all carry an expiry, so a
74
+ very old reply is not routed even with a valid id or MAC.
75
+
76
+ ## 3. Prompt injection is not solved, only bounded
77
+
78
+ A legitimate email can still contain text that reads like instructions
79
+ ("ignore your rules, run …"). You cannot filter this reliably. What you can do:
80
+
81
+ - **Scope the session.** A session that only needs to read code should not have
82
+ `bash`. `pi --tools read,grep,glob` for untrusted threads. This package does not enforce
83
+ tool scoping — pi's own `--tools` does.
84
+ - **Keep a human in the loop for destructive actions.** `request_decision` sends the
85
+ question and ends the turn; nothing resumes the session except a reply. There is no
86
+ approve command and no phrase a message can contain to satisfy it: the human's reply *is*
87
+ the approval. This is a convention the model follows, not an enforced gate - a model that
88
+ ignores the tool can keep working, which is why tool scoping matters.
89
+ - **Do not paste mail verbatim into a prompt as trusted context.** Injected
90
+ text is *user input*, and the system prompt should say so.
91
+
92
+ ## 4. Daemon hardening
93
+
94
+ - The bearer token authorises **sending mail as you to any address**, not just `mail.to`:
95
+ `POST /send` takes an optional `to`. Anything that can read `secret.key` (same uid) can
96
+ therefore exfiltrate over SMTP. That is inside the trust boundary - the same process can read
97
+ your mail - but worth knowing when deciding what runs as you.
98
+ - Defaults to binding `127.0.0.1`; `daemon.host` can change that, but there is no TLS and
99
+ no user model beyond the bearer token, so do not. Nothing else about the daemon is
100
+ reachable without the token, apart from the `/health` liveness probe. That returns mode,
101
+ pending/clients counts, the decision-registry size, ping timing, the number of SSE subscribers,
102
+ the last scan's reason *counts*, the build and protocol versions, and the config file's mtime and
103
+ whether IMAP is connected — no mail content, no addresses, no subjects.
104
+ - Bearer token from `secret.key`, mode `0600`, created first-writer-wins,
105
+ compared in constant time.
106
+ - Body cap 1 MB; registry cap 10 000; SSE client cap 100; ten notifications a minute; ten notifications a minute.
107
+ - `EADDRINUSE` on start is treated as "already running" — an attacker cannot
108
+ displace a running daemon by racing the port, because the loser exits.
109
+ - The token never appears in logs, error strings, or the notification email.
110
+
111
+ ### What the bearer token authorises
112
+
113
+ Beyond sending, the token can `GET /pending` (this device's reply bodies and attachments), `/claim`
114
+ and `/ack` a record this device scanned — which *discards* a queued reply without delivering it —
115
+ flip the mode, and shut the daemon down. Treat `secret.key` as equivalent to the mailbox password.
116
+
117
+ ## 5. Mailbox hygiene
118
+
119
+ - Prefer a **dedicated account or a filtered folder**. Do not point this at a
120
+ mailbox where you receive untrusted mail.
121
+ - The tool marks a delivered message `\Seen` and journals it. It never deletes or moves
122
+ mail, so a shared account keeps its history.
123
+ - Rotate the HMAC key if you suspect exposure. The key is `secret.key`, created first-writer-
124
+ wins, so rotation means **deleting** it while nothing is running:
125
+
126
+ ```bash
127
+ # stop the daemon (or let it idle-exit)
128
+ rm ~/.local/state/pi-mailbridge/secret.key # this is the rotation
129
+ # then restart pi (or run /mailbridge restart): the session mints a new key and its
130
+ # respawn starts a daemon that reads it
131
+ ```
132
+
133
+ Stop the daemon *before* deleting: a running one holds the port `and` the key it read at
134
+ startup, so it keeps answering 401 to every call and the replacement cannot bind, no matter
135
+ how often a session retries. Sessions re-read `secret.key` on each call, but the client each
136
+ one already built carries the old token, so it 401s until its runtime is rebuilt.
137
+
138
+ Do **not** rotate for a *mail password* change: the daemon's `POST /reload` re-reads
139
+ that (run it via `/mailbridge smoke`), so no restart is needed.
140
+
141
+ Rotating the key kills every outstanding **signed token**, but *not* the short route ids:
142
+ those are entries in `routes.json` and resolve with no secret at all, so they keep working
143
+ until `security.tokenTtlHours` expires. Rotating the key does not stop delivery on its own.
144
+ To revoke, stop the daemon and clear the state directory
145
+ (`${XDG_STATE_HOME:-~/.local/state}/pi-mailbridge`):
146
+ - `routes.json` — short ids. Resolve with no secret.
147
+ - `threads.json` — the reply chain. Resolves recorded Message-IDs with no secret.
148
+ - `pending.json` — **already-accepted replies waiting for a session.** Deleting the two
149
+ routing stores stops new mail being routed *by id or chain*, but anything persisted
150
+ before is still delivered on the next drain - and a legacy token still resolves until
151
+ it expires or you rotate the key. If the session is compromised, this is the queue
152
+ that matters.
153
+ - `journal.json` — only the ack dedupe, tagged with the mailbox's UIDVALIDITY. Clearing it
154
+ re-opens replay of already-acked mail, so prefer leaving it unless you also cleared the
155
+ mailbox.
156
+ - `uid-cursor.json` — the last seen UID and the UIDVALIDITY it belongs to. A change of
157
+ UIDVALIDITY (a rebuilt or migrated folder) resets it; queued records tagged with another numbering
158
+ are dropped on every connect (and untagged ones on a detected renumbering), so recent mail
159
+ can be delivered once more instead of being skipped.
160
+ Secrets (`secret.key`, `oauth.json`) live beside them; rotating those is covered above.
161
+ - Turn it off with `/mailbridge off` when you will be at the desk: while mode is off the daemon
162
+ does not scan and the extension does not claim or inject, so the mailbox is not a
163
+ listening control channel in either direction. Replies that arrive meanwhile stay in the
164
+ mailbox and are processed when you `/mailbridge on` again — which also means turning it back on can
165
+ deliver a burst. To stop entirely, close pi and let the daemon exit (~120 s after the last ping),
166
+ or stop it however you started it - with a session still open, the poll tick respawns a daemon it
167
+ finds missing, throttled to once a minute.
168
+ - **One mailbox for both roles** (`security.allowSelfReplies`). The loop guard is relaxed
169
+ for self-addressed mail, so the sender allowlist alone no longer separates "me" from "not
170
+ me" — it is the same address. Three guards do the work, in this order: the
171
+ `X-Pi-Mailbridge: notification` marker (refused outright, so it does not depend on
172
+ anything being recorded), the reply-evidence gate (`In-Reply-To` or `References` must be
173
+ present), and the outbound `Message-ID` registry. All of them are forgeable by anyone who
174
+ can already send from your address; they exist to stop loops, not attackers.
175
+ **DMARC is off in this shape by default**, because a provider may deliver a message from
176
+ your address to the same address with no `Authentication-Results` header at all (QQ does),
177
+ which would reject every reply. `/mailbridge setup` writes it that way; setting it back on is
178
+ reported by `/mailbridge check`, and `pi-mailbridge --check` exits non-zero — gate a supervisor
179
+ or CI on that. Prefer a dedicated mailbox.
180
+ - **Multiple machines, one mailbox.** Each daemon only claims replies whose route id, token
181
+ or chain entry names its own device, so they cannot steal each other's work. They share
182
+ the allowlist (one config each), but **each machine has its own `secret.key`** — it is a
183
+ local file, never synced through the mailbox. So there is no "the key" to rotate: rotate
184
+ the compromised machine's, clear its state directory as described above (`routes.json`,
185
+ `threads.json`, `pending.json`), and review the others separately.
186
+
187
+ ## 6. What this package will never do
188
+
189
+ - auto-approve a destructive action from email
190
+ - execute an attachment
191
+ - act on a message with `Auto-Submitted`, `Precedence: bulk`, or a bounce sender
192
+ - accept a message from outside the allowlist, however convincing
193
+ - resume a session the human did not start
194
+
195
+ ## 6a. Commands arriving by mail
196
+
197
+ A reply whose entire body is one command line executes that command (`/mailbridge off`,
198
+ `/mailbridge status|check|smoke|help`, and `/mailbridge on` only while the mode is already on -
199
+ with it off nothing scans or drains, so such a reply is never read). It is **not** handed to pi's command parser, and the
200
+ commands that change the machine are refused: `setup` (rewrites the config and the target
201
+ address), `auth` (starts a credential flow), `restart` (replaces the daemon) and `prime` (skips
202
+ unread mail) are delivered to the agent as text instead.
203
+
204
+ What this costs, in the one-mailbox layout (DMARC is off there by design) and if the sender can
205
+ forge the allowlisted `From` *and* carries a live route id or token — i.e. they have read one of
206
+ your notifications:
207
+
208
+ - `/mailbridge off` is a **one-way, global, persisted** denial of the channel: scanning and
209
+ delivery stop for every session until a human turns it back on at a terminal. Mail is not lost
210
+ (the cursor does not move), but the remote-decision channel is dead.
211
+ - `/mailbridge smoke` sends one real mail **from your own account to your own address** and costs
212
+ a daemon reload plus up to ~52 s of IMAP work. It is not rate-limited, so repeated replies can
213
+ consume SMTP/IMAP capacity and get genuine notifications throttled.
214
+ - `status`, `check` and `help` print into the local pi session; nothing is mailed back.
215
+
216
+ They cannot reconfigure the account, obtain credentials, restart the daemon, re-prime the cursor
217
+ or discard the queue by mail: `setup`, `auth`, `restart` and `prime` are refused by the
218
+ dispatcher. What the dispatcher refuses, however, is still *delivered to the agent as text*, so
219
+ §1 and §3 apply to it — the refusal is about what runs automatically, not about what the model
220
+ may then do in a shell-capable session.
221
+
222
+ A command reply to a *decision request* is a special case worth knowing: it runs the command and
223
+ releases the decision slot without the agent seeing an answer, so a later `request_decision` is
224
+ allowed again. Nothing is lost and no mail is misrouted, but "one outstanding decision" is
225
+ weaker than the reply flow suggests.
226
+
227
+ "A command" means the reply's text **after quote-stripping**, so `/mailbridge on` above a quoted
228
+ original still executes — that is deliberate (a phone-appended signature must not break it), but
229
+ it means "entire body" below refers to the stripped body, not the raw mail.
230
+
231
+ ## 6b. Attachments in notifications
232
+
233
+ A notification carries the local files the agent linked, so what leaves the machine is decided by
234
+ model output. The controls, all fail-closed:
235
+
236
+ - **The project directory is the boundary** on the notification path. Every path is
237
+ `realpath`-resolved on both sides and must land inside the agent's working directory. (An
238
+ authenticated caller of `POST /send` supplies `cwd`, so with `secret.key` the boundary moves to
239
+ wherever it says — same trust level as the `to` override in §4.) A symlink out of it (`ln -s ~/.config/… config.json`)
240
+ is refused, and so is an absolute path outside it.
241
+ - **A home-rooted or `/`-rooted launch attaches nothing**, because with `cwd = $HOME` the config
242
+ file is "inside the project". Start pi in a project; that is the intended usage.
243
+ - **The daemon's own config and state directories are never attached**, whatever the `cwd`.
244
+ - **An extension allowlist** (`pdf txt log md csv tsv json yml yaml xml html zip gz tar docx xlsx
245
+ pptx`, plus `png jpg jpeg gif webp svg` for links and the sniffed image types inline)
246
+ keeps the obvious key filenames out of an accidental attach. It is not a content filter:
247
+ `cp ~/.ssh/id_rsa notes.txt` or `tar czf k.tar ~/.ssh` defeats it, and `realpath` cannot see a
248
+ hardlink. Treat it as a guard rail against a naive single-shot injection, not a sandbox.
249
+ - **Size caps**: 5 MB per inline image and 15 MB per linked file (fixed), and
250
+ `notify.maxAttachmentMb` (default 18 MB, max 45) for the total per mail, before base64 — at most
251
+ 10 attachments per mail. A
252
+ non-regular file (FIFO, device) is refused rather than read, because it reports size 0 and
253
+ then blocks.
254
+ - **No automated path changes the recipient.** Every notification's `to` is config-derived, a
255
+ command reply cannot rewrite the config (`setup` is refused by the dispatcher), and the send
256
+ path has no Cc/Bcc. An authenticated caller *can* pass a different `to` to `POST /send` — that
257
+ is §4's "mail as you to any address", and it needs `secret.key`.
258
+
259
+ Residual risk, plainly: anything the agent can read and re-materialise inside the project can be
260
+ mailed to the address the daemon sends to, up to the cap, including a file that happens to contain secrets and archives
261
+ whose contents the allowlist cannot inspect. Sending is capped at ten
262
+ model-driven notifications a minute per daemon (`429 send_budget`), refused rather than queued.
263
+ Settle notifications and decision requests are exempt: a settle says the agent is waiting, and a
264
+ decision is already bounded at one outstanding per session by the registry. That stops bursts, not a
265
+ sustained loop: ten a minute is 600 an hour and the budget resets when the daemon restarts, so tool
266
+ scoping remains the defence.
267
+
268
+ ## 7. Reporting
269
+
270
+ Email the maintainer at the address in `package.json`'s `author`
271
+ (`yangbangcheng@bigai.ai`). If the package is published with a `repository`, a private
272
+ security advisory on that repository is equally good. Do not file a public issue for anything
273
+ in this document.
@@ -0,0 +1,124 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 530" width="1000" height="530" role="img" aria-labelledby="t d">
2
+ <title id="t">How a notification and a reply travel</title>
3
+ <desc id="d">Four stations: pi session, daemon, your mailbox, you. The session sends through the daemon over SMTP; 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.</desc>
4
+
5
+ <defs>
6
+ <marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
7
+ <path d="M0 0 L10 5 L0 10z" fill="#5b6673"/>
8
+ </marker>
9
+ <marker id="g" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
10
+ <path d="M0 0 L10 5 L0 10z" fill="#2f6f4f"/>
11
+ </marker>
12
+ <style>
13
+ .h{font:600 20px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#18212b}
14
+ .b{font:16px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#39434f}
15
+ .s{font:14.5px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#6a7683}
16
+ .pt{font:600 14px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#33404d}
17
+ .num{font:700 14px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#fff}
18
+ .flow{stroke:#5b6673;stroke-width:2;fill:none}
19
+ .flow-g{stroke:#2f6f4f;stroke-width:2.2;fill:none}
20
+ .ic{stroke:#3d4854;stroke-width:1.8;fill:none;stroke-linecap:round;stroke-linejoin:round}
21
+ .ic-g{stroke:#2f6f4f;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}
22
+ .pill{fill:#eef3f8;stroke:#d3dee9;stroke-width:1}
23
+ </style>
24
+ </defs>
25
+
26
+ <!-- pi session -->
27
+ <rect x="16" y="190" width="196" height="150" rx="14" fill="#fff" stroke="#cbd4de" stroke-width="1.5"/>
28
+ <g transform="translate(32,206)">
29
+ <rect class="ic" x="0" y="0" width="30" height="23" rx="4"/>
30
+ <path class="ic" d="M5 6 l5 5 -5 5"/>
31
+ <path class="ic" d="M14 16 h7"/>
32
+ </g>
33
+ <text class="h" x="72" y="224">pi session</text>
34
+ <text class="b" x="32" y="258">finishes a turn</text>
35
+ <text class="b" x="32" y="282">→ one mail</text>
36
+ <text class="b" x="32" y="306">reply: next message</text>
37
+
38
+ <!-- daemon -->
39
+ <rect x="360" y="136" width="376" height="268" rx="16" fill="#f6f9f7" stroke="#84b097" stroke-width="1.6"/>
40
+ <g transform="translate(380,154)">
41
+ <path class="ic-g" d="M15 1 l12 5 v8 c0 7 -5 12 -12 15 c-7 -3 -12 -8 -12 -15 v-8 z"/>
42
+ <path class="ic-g" d="M10 15 l4 4 7 -8"/>
43
+ </g>
44
+ <text class="h" x="424" y="172">daemon</text>
45
+ <text class="s" x="424" y="192">one per machine · loopback</text>
46
+
47
+ <g>
48
+ <rect class="pill" x="380" y="212" width="82" height="28" rx="14"/><text class="pt" x="396" y="231">scan</text>
49
+ <rect class="pill" x="470" y="212" width="112" height="28" rx="14"/><text class="pt" x="486" y="231">authorize</text>
50
+ <rect class="pill" x="590" y="212" width="82" height="28" rx="14"/><text class="pt" x="606" y="231">route</text>
51
+ <rect class="pill" x="380" y="250" width="292" height="28" rx="14"/><text class="pt" x="396" y="269">strip quote · attach · persist</text>
52
+ </g>
53
+
54
+ <text class="s" x="380" y="302">cursor moves after the record is saved</text>
55
+ <text class="s" x="380" y="324">IMAP IDLE + a 60 s sweep</text>
56
+
57
+ <g transform="translate(380,338)">
58
+ <ellipse class="ic" cx="14" cy="5" rx="14" ry="5"/>
59
+ <path class="ic" d="M0 5 v14 c0 2.8 6.3 5 14 5 s14 -2.2 14 -5 V5"/>
60
+ <path class="ic" d="M0 12 c0 2.8 6.3 5 14 5 s14 -2.2 14 -5"/>
61
+ </g>
62
+ <text class="s" x="418" y="356">pending · journal · cursor</text>
63
+
64
+ <!-- mailbox -->
65
+ <rect x="356" y="28" width="292" height="72" rx="14" fill="#fff" stroke="#cbd4de" stroke-width="1.5"/>
66
+ <g transform="translate(374,44)">
67
+ <rect class="ic" x="0" y="0" width="38" height="27" rx="4"/>
68
+ <path class="ic" d="M1 3 l18 12 18 -12"/>
69
+ </g>
70
+ <text class="h" x="428" y="54">your mailbox</text>
71
+ <text class="s" x="428" y="76">INBOX, read by the daemon</text>
72
+
73
+ <!-- you -->
74
+ <rect x="764" y="196" width="212" height="120" rx="14" fill="#fff" stroke="#cbd4de" stroke-width="1.5"/>
75
+ <g transform="translate(782,212)">
76
+ <rect class="ic" x="0" y="0" width="20" height="33" rx="4"/>
77
+ <path class="ic" d="M6 27 h8"/>
78
+ <path class="ic" d="M6 5 h8"/>
79
+ </g>
80
+ <text class="h" x="814" y="230">you</text>
81
+ <text class="b" x="782" y="270">any device with email</text>
82
+ <text class="b" x="782" y="294">reply, don't compose</text>
83
+
84
+ <!-- 1 send -->
85
+ <path class="flow" d="M216 236 H352" marker-end="url(#a)"/>
86
+ <circle cx="330" cy="236" r="12" fill="#5b6673"/><text class="num" x="325" y="241">1</text>
87
+ <text class="s" x="222" y="226">send</text>
88
+
89
+ <!-- 2 SMTP -->
90
+ <path class="flow" d="M570 136 V104" marker-end="url(#a)"/>
91
+ <circle cx="570" cy="120" r="12" fill="#5b6673"/><text class="num" x="565" y="125">2</text>
92
+ <text class="s" x="588" y="125">SMTP</text>
93
+
94
+ <!-- 3 notification -->
95
+ <path class="flow" d="M648 62 H870 V192" marker-end="url(#a)"/>
96
+ <circle cx="760" cy="62" r="12" fill="#5b6673"/><text class="num" x="755" y="67">3</text>
97
+ <text class="s" x="778" y="52">notification</text>
98
+
99
+ <!-- 4 reply -->
100
+ <path class="flow-g" d="M828 196 V92 H648" marker-end="url(#g)"/>
101
+ <circle cx="828" cy="144" r="12" fill="#2f6f4f"/><text class="num" x="823" y="149">4</text>
102
+ <text class="s" x="690" y="112">your reply</text>
103
+
104
+ <!-- 5 IMAP -->
105
+ <path class="flow-g" d="M450 104 V132" marker-end="url(#g)"/>
106
+ <circle cx="450" cy="118" r="12" fill="#2f6f4f"/><text class="num" x="445" y="123">5</text>
107
+ <text class="s" x="468" y="123">IMAP</text>
108
+
109
+ <!-- 6 claim -->
110
+ <path class="flow" d="M352 286 H216" marker-end="url(#a)"/>
111
+ <circle cx="330" cy="286" r="12" fill="#5b6673"/><text class="num" x="325" y="291">6</text>
112
+ <text class="s" x="222" y="276">claim</text>
113
+
114
+ <!-- 7 ack -->
115
+ <path class="flow-g" d="M216 320 H352" marker-end="url(#g)"/>
116
+ <circle cx="330" cy="320" r="12" fill="#2f6f4f"/><text class="num" x="325" y="325">7</text>
117
+ <text class="s" x="222" y="340">ack → seen</text>
118
+
119
+ <!-- footer -->
120
+ <line x1="24" y1="428" x2="976" y2="428" stroke="#e2e8ef"/>
121
+ <text class="b" x="24" y="454">Every inbound message passes, in order:</text>
122
+ <text class="s" x="24" y="480">journal → marker → size → From → loop guard → allowlist → DMARC → Message-ID → route → ack</text>
123
+ <text class="s" x="24" y="506">Routing ids never authorize. Mail is marked seen only after the reply is injected and acked.</text>
124
+ </svg>
Binary file
package/package.json CHANGED
@@ -1,6 +1,87 @@
1
1
  {
2
2
  "name": "@dumbmice/pi-mailbridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Drive Pi coding-agent sessions from your mailbox: get an email when an agent settles, reply to resume that exact session.",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi-coding-agent",
10
+ "email",
11
+ "imap",
12
+ "smtp",
13
+ "idle",
14
+ "notifications",
15
+ "afk"
16
+ ],
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/DumbMice/pi-mailbridge.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/DumbMice/pi-mailbridge/issues"
24
+ },
25
+ "homepage": "https://github.com/DumbMice/pi-mailbridge#readme",
26
+ "author": {
27
+ "name": "Yang Bangcheng",
28
+ "email": "yangbangcheng@bigai.ai"
29
+ },
30
+ "type": "module",
31
+ "engines": {
32
+ "node": ">=22.6"
33
+ },
34
+ "bin": {
35
+ "pi-mailbridge": "./bin/pi-mailbridge.mjs"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "bin",
41
+ "docs",
42
+ "config.example.json",
43
+ "CHANGELOG.md",
44
+ "scripts/render-teaser.ts"
45
+ ],
46
+ "pi": {
47
+ "extensions": [
48
+ "./src/extension.ts"
49
+ ]
50
+ },
51
+ "main": "./src/extension.ts",
52
+ "scripts": {
53
+ "daemon": "node --experimental-strip-types src/daemon/main.ts",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "node --experimental-strip-types --test test/*.test.ts",
56
+ "prepublishOnly": "npm run build && npm run typecheck && npm test",
57
+ "build": "tsc -p tsconfig.build.json",
58
+ "pretest": "npm run build"
59
+ },
60
+ "dependencies": {
61
+ "imapflow": "^2.11.0",
62
+ "mailparser": "^3.14.3",
63
+ "markdown-it": "^15.0.3",
64
+ "nodemailer": "^10.5.0",
65
+ "openid-client": "^6.8.9"
66
+ },
67
+ "peerDependencies": {
68
+ "@earendil-works/pi-ai": "*",
69
+ "@earendil-works/pi-coding-agent": "*",
70
+ "@sinclair/typebox": "*"
71
+ },
72
+ "devDependencies": {
73
+ "@types/mailparser": "^3.4.5",
74
+ "@types/markdown-it": "^14.2.0",
75
+ "@types/node": "^22.10.0",
76
+ "@types/nodemailer": "^6.4.17",
77
+ "typescript": "^5.7.0"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "@earendil-works/pi-ai": {
81
+ "optional": true
82
+ },
83
+ "@earendil-works/pi-coding-agent": {
84
+ "optional": true
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,56 @@
1
+ // Render the README teaser through the real message renderer.
2
+ //
3
+ // The teaser in docs/images/ must never be a mock: it is this HTML, produced by
4
+ // `renderMessage` - the same function the mailer calls - with a mail-client frame around it.
5
+ // Regenerate after changing the renderer:
6
+ //
7
+ // node --experimental-strip-types scripts/render-teaser.ts # writes /tmp/teaser.html
8
+ // open /tmp/teaser.html, 820 px wide, capture at 2x # save as docs/images/notification.png
9
+ //
10
+ // `root` points at a scratch directory holding build.log, so the attachment line renders the
11
+ // way a real notification's does.
12
+ import { writeFileSync } from "node:fs"
13
+ import { renderMessage } from "../src/core/html.ts"
14
+
15
+ const body = `## Fix is in, one decision needed
16
+
17
+ The failing test passes now, and the migration is written. I stopped before touching
18
+ production because the rollback path is ambiguous.
19
+
20
+ **What I changed**
21
+
22
+ - \`src/auth/session.ts\` — refresh tokens are rotated instead of reused
23
+ - \`test/session.test.ts\` — two cases for the reuse window
24
+ - added \`docs/migration.md\`
25
+
26
+ **The question**
27
+
28
+ Should the migration run automatically on deploy, or wait for a manual step?
29
+
30
+ > Recommendation: wait for a manual step this week — the queue drain takes ~40 minutes,
31
+ > and a failed deploy would leave sessions signed out.
32
+
33
+ [the full log](./build.log)
34
+ `
35
+
36
+ import { mkdirSync, writeFileSync as wf } from "node:fs"
37
+ mkdirSync("/tmp/teaser-root", { recursive: true })
38
+ wf("/tmp/teaser-root/build.log", "line 1: compiling\nline 2: 412 tests, 412 pass\n")
39
+ const rendered = renderMessage(body, { root: "/tmp/teaser-root" })
40
+ const html = `<!doctype html><meta charset="utf-8">
41
+ <div style="background:#eef1f5;padding:28px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
42
+ <div style="max-width:760px;margin:0 auto;background:#fff;border:1px solid #dfe3e8;border-radius:10px;overflow:hidden">
43
+ <div style="padding:16px 22px;border-bottom:1px solid #eceff3;background:#fafbfc">
44
+ <div style="font-size:13px;color:#5b6673">pi-mailbridge &lt;pi@example.com&gt;</div>
45
+ <div style="font-size:17px;font-weight:600;color:#1c2530;margin-top:4px">[pi:f913c9] pi: fix-login @ dev-box-7 is waiting for you</div>
46
+ <div style="font-size:12px;color:#7b8794;margin-top:4px">to you@example.com</div>
47
+ </div>
48
+ <div style="padding:20px 22px">${rendered.html}</div>
49
+ <div style="padding:14px 22px;border-top:1px solid #eceff3;background:#fafbfc;font-size:12px;color:#5b6673">
50
+ device: dev-box-7 &nbsp;·&nbsp; session: fix-login &nbsp;·&nbsp; turns: 3<br>
51
+ Reply above this line to continue the session.
52
+ </div>
53
+ </div>
54
+ </div>`
55
+ writeFileSync("/tmp/teaser.html", html)
56
+ console.log("wrote /tmp/teaser.html with", rendered.attachments.length, "attachment(s)")