@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.
- package/CHANGELOG.md +836 -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/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
|
-
#
|
|
1
|
+
# pi-mailbridge
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+

|
|
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
|
+
| `` | 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.1
|
|
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
|
+

|
|
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.1` — 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
|
+
}
|