@dumbmice/pi-mailbridge 0.1.1 → 0.1.2
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 +27 -0
- package/README.md +18 -17
- package/bin/pi-mailbridge.mjs +18 -4
- package/dist/core/config.js +6 -0
- package/dist/core/inline-images.js +15 -2
- package/dist/daemon/script.js +26 -10
- package/docs/SECURITY.md +26 -1
- package/package.json +2 -2
- package/src/core/config.ts +9 -0
- package/src/core/inline-images.ts +15 -2
- package/src/daemon/script.ts +26 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 — 2026-11-13
|
|
4
|
+
|
|
5
|
+
The release that fixes the ways this package could quietly do the wrong thing.
|
|
6
|
+
|
|
7
|
+
- **A renamed file could be mailed as an image.** `` checked magic bytes;
|
|
8
|
+
`[notes](./x.png)` checked only the extension, so any file called `.png` went out labelled
|
|
9
|
+
`image/png` - a config, a key. The link path now sniffs too, refuses a mismatch, and labels the
|
|
10
|
+
part with the type the bytes actually are.
|
|
11
|
+
- **A checkout ran the previous build.** Both the daemon a session spawns and the `pi-mailbridge`
|
|
12
|
+
CLI preferred `dist/` whenever it existed, and `dist/` is only rebuilt by `pretest`
|
|
13
|
+
/`prepublishOnly` - so an edit to `src/daemon/*` ran old code with an unchanged `API_VERSION`
|
|
14
|
+
and no warning. A checkout now runs its sources; an installed package still runs the build.
|
|
15
|
+
- **An incomplete install said the wrong thing.** With no `dist/`, the CLI spawned the TypeScript
|
|
16
|
+
entry from inside `node_modules` and died on Node's `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`
|
|
17
|
+
instead of saying "reinstall the package".
|
|
18
|
+
- **`main` named a file plain Node cannot import.** It points at `dist/extension.js` now; pi never
|
|
19
|
+
read `main` (it uses `pi.extensions`), so the field only misled other tools.
|
|
20
|
+
- **`daemon.host` was never validated** - the one setting that removes the loopback assumption.
|
|
21
|
+
A non-loopback host is now reported by `/mailbridge check` and fails `pi-mailbridge --check`,
|
|
22
|
+
the same way an `allowSelfReplies` + DMARC combination already does.
|
|
23
|
+
- **The package page showed alt text instead of the diagram.** npm renders the README out of the
|
|
24
|
+
tarball, where a relative `src` has no base; the images and the ten doc links are absolute now,
|
|
25
|
+
and a test keeps them that way.
|
|
26
|
+
- SECURITY.md §2.1 states the assumption the DMARC check rests on (the topmost
|
|
27
|
+
`Authentication-Results` is your provider's only if your provider writes one), with the measured
|
|
28
|
+
both-directions failure and the two responses that work.
|
|
29
|
+
|
|
3
30
|
## 0.1.1 — 2026-11-13
|
|
4
31
|
|
|
5
32
|
No behaviour change. This is the release that proves the pipeline: publishing now happens in CI
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ continues**, with your answer as the next message. Notifications also carry rend
|
|
|
8
8
|
images embedded in the body where the agent put them, and any local file it linked attached with
|
|
9
9
|
its real name.
|
|
10
10
|
|
|
11
|
-

|
|
12
12
|
|
|
13
13
|
- **Your inbox is the client.** No app, no account, no relay, no push service. Any device with
|
|
14
14
|
email works: a phone, a tablet, another PC, a webmail tab.
|
|
@@ -22,7 +22,7 @@ its real name.
|
|
|
22
22
|
leaves it unread, so nothing is lost.
|
|
23
23
|
|
|
24
24
|
> **This turns your inbox into a control channel for an agent that can run shell commands.**
|
|
25
|
-
> Read [docs/SECURITY.md](
|
|
25
|
+
> Read [docs/SECURITY.md](https://github.com/DumbMice/pi-mailbridge/blob/main/docs/SECURITY.md) before installing, and point it at a mailbox you
|
|
26
26
|
> would not mind an agent reading.
|
|
27
27
|
|
|
28
28
|
## Install
|
|
@@ -56,9 +56,10 @@ protocol is left alone. Without that, a new extension
|
|
|
56
56
|
would mint routing ids an old daemon cannot resolve, and replies would be rejected with the
|
|
57
57
|
cursor already past them.
|
|
58
58
|
|
|
59
|
-
The replacement is: shut the old daemon down (`POST /shutdown`), spawn the
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
The replacement is: shut the old daemon down (`POST /shutdown`), spawn the daemon entry this copy of
|
|
60
|
+
the package carries — the compiled `dist/daemon/main.js` for an installed package (no build runs at
|
|
61
|
+
runtime: it is built once, at publish time), or `src/daemon/main.ts` when you run from a checkout —
|
|
62
|
+
and have this session re-create its client. Queued
|
|
62
63
|
replies, the cursor and the journal are on disk and untouched, so the gap costs nothing but the
|
|
63
64
|
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
65
|
`/mailbridge restart` forces it.
|
|
@@ -102,7 +103,7 @@ both are quiet:
|
|
|
102
103
|
|
|
103
104
|
Let a turn settle and the first notification arrives. Reply to it from anywhere.
|
|
104
105
|
|
|
105
|
-
Prefer to write the config yourself? Copy [`config.example.json`](
|
|
106
|
+
Prefer to write the config yourself? Copy [`config.example.json`](https://github.com/DumbMice/pi-mailbridge/blob/main/config.example.json).
|
|
106
107
|
|
|
107
108
|
### Start watching from now
|
|
108
109
|
|
|
@@ -289,7 +290,7 @@ Attachments come only from **the agent's project directory**, and only for allow
|
|
|
289
290
|
keeps the obvious key *filenames* out of an accidental attach; it is not a content filter — an
|
|
290
291
|
agent told to `cp ~/.ssh/id_rsa notes.txt` or `tar` a directory can still mail the result. Start
|
|
291
292
|
pi in a project rather than in `$HOME`, and see
|
|
292
|
-
[docs/SECURITY.md](
|
|
293
|
+
[docs/SECURITY.md](https://github.com/DumbMice/pi-mailbridge/blob/main/docs/SECURITY.md) §6b for the full boundary.
|
|
293
294
|
|
|
294
295
|
## When it helps
|
|
295
296
|
|
|
@@ -341,7 +342,7 @@ Every scan records why each message was refused, so this is answerable:
|
|
|
341
342
|
```
|
|
342
343
|
|
|
343
344
|
```
|
|
344
|
-
daemon: healthy (http://127.0.0.1:4177) — daemon version 0.1.
|
|
345
|
+
daemon: healthy (http://127.0.0.1:4177) — daemon version 0.1.2
|
|
345
346
|
device: dev-box-7
|
|
346
347
|
mode: on
|
|
347
348
|
imap: connected
|
|
@@ -416,12 +417,12 @@ loop guard → auto-generated/bounce → sender allowlist → DMARC → our own
|
|
|
416
417
|
durable ack. Routing identifiers never authorize anything: authorization is the allowlist and
|
|
417
418
|
DMARC alone (DMARC is off in single-mailbox mode). Prefer a dedicated account or a filtered
|
|
418
419
|
folder. What is stored, what a leaked token means, and the threat model in full:
|
|
419
|
-
[docs/SECURITY.md](
|
|
420
|
+
[docs/SECURITY.md](https://github.com/DumbMice/pi-mailbridge/blob/main/docs/SECURITY.md).
|
|
420
421
|
|
|
421
422
|
## Configuration
|
|
422
423
|
|
|
423
424
|
`~/.config/pi-mailbridge/config.json`, merged over built-in defaults, then `PI_MAILBRIDGE_*`
|
|
424
|
-
environment variables. A working example: [`config.example.json`](
|
|
425
|
+
environment variables. A working example: [`config.example.json`](https://github.com/DumbMice/pi-mailbridge/blob/main/config.example.json).
|
|
425
426
|
|
|
426
427
|
| Key | Default | Meaning |
|
|
427
428
|
| --- | --- | --- |
|
|
@@ -487,7 +488,7 @@ pi-mailbridge --auth # prints a URL + code, waits for approval
|
|
|
487
488
|
```
|
|
488
489
|
|
|
489
490
|
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](
|
|
491
|
+
Walkthrough, scope tables and the Gmail equivalent: [docs/OAUTH.md](https://github.com/DumbMice/pi-mailbridge/blob/main/docs/OAUTH.md).
|
|
491
492
|
|
|
492
493
|
**In mainland China, use QQ or 163** — 授权码 works exactly like an app password:
|
|
493
494
|
|
|
@@ -506,7 +507,7 @@ tenant, no refresh token that dies weekly. Gmail *OAuth* is the harder route —
|
|
|
506
507
|
**Personal Outlook has a real caveat:** Microsoft sometimes rejects IMAP/SMTP OAuth2 even with a
|
|
507
508
|
valid token (`User is authenticated but not connected`, `SmtpClientAuthentication is disabled`).
|
|
508
509
|
That is their side. The reliable answer is Microsoft Graph, which is not implemented here yet;
|
|
509
|
-
[docs/OAUTH.md](
|
|
510
|
+
[docs/OAUTH.md](https://github.com/DumbMice/pi-mailbridge/blob/main/docs/OAUTH.md) explains the options, including turnkey services.
|
|
510
511
|
|
|
511
512
|
### A token command instead
|
|
512
513
|
|
|
@@ -539,7 +540,7 @@ the daemon without editing your config file:
|
|
|
539
540
|
|
|
540
541
|
| Tool | Behaviour |
|
|
541
542
|
| --- | --- |
|
|
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](
|
|
543
|
+
| `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](https://github.com/DumbMice/pi-mailbridge/blob/main/docs/SECURITY.md) §3. |
|
|
543
544
|
| `notify_user` | One-way update. Does not reserve a slot and does not wait. |
|
|
544
545
|
|
|
545
546
|
Both refuse cleanly when email mode is off or the daemon is unreachable.
|
|
@@ -565,7 +566,7 @@ that is the credential that actually reaches your mailbox.
|
|
|
565
566
|
|
|
566
567
|
## How it works
|
|
567
568
|
|
|
568
|
-

|
|
569
570
|
|
|
570
571
|
The numbered path is the whole product. **1** The agent finishes its turn and goes idle — that is
|
|
571
572
|
the normal case, one mail per finished turn — or it stops to ask you a question; either way the
|
|
@@ -577,12 +578,12 @@ journalled.
|
|
|
577
578
|
|
|
578
579
|
One daemon per machine owns the mailbox; sessions never touch IMAP. `smoke` and `prime` are the
|
|
579
580
|
deliberate exceptions — each opens one short-lived connection. Design notes, the failure-mode
|
|
580
|
-
table and the module map: [docs/ARCHITECTURE.md](
|
|
581
|
-
[docs/MAPPING.md](
|
|
581
|
+
table and the module map: [docs/ARCHITECTURE.md](https://github.com/DumbMice/pi-mailbridge/blob/main/docs/ARCHITECTURE.md) and
|
|
582
|
+
[docs/MAPPING.md](https://github.com/DumbMice/pi-mailbridge/blob/main/docs/MAPPING.md).
|
|
582
583
|
|
|
583
584
|
## Status
|
|
584
585
|
|
|
585
|
-
`0.1.
|
|
586
|
+
`0.1.2` — feature-complete for the single-user, one-mailbox case, and exercised end to end
|
|
586
587
|
against a live QQ mailbox in both layouts: two addresses (DMARC verified on the reply) and one
|
|
587
588
|
address for both roles (self-delivery, the self-echo guard, and a reply routed by the chain).
|
|
588
589
|
|
package/bin/pi-mailbridge.mjs
CHANGED
|
@@ -3,21 +3,30 @@
|
|
|
3
3
|
//
|
|
4
4
|
// From an installed package this loads the compiled `dist/` build: Node refuses to strip
|
|
5
5
|
// TypeScript inside `node_modules` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), which is why
|
|
6
|
-
// the daemon is built rather than shipped as `.ts`.
|
|
7
|
-
//
|
|
6
|
+
// the daemon is built rather than shipped as `.ts`.
|
|
7
|
+
//
|
|
8
|
+
// A checkout is the mirror image: it must run its SOURCES even when a build is sitting there.
|
|
9
|
+
// `dist/` is gitignored and rebuilt only by `pretest`/`prepublishOnly`, so preferring it would
|
|
10
|
+
// silently run the previous build — with an unchanged `API_VERSION`, nothing would notice. The
|
|
11
|
+
// `node_modules` path segment is the discriminator because it is the actual constraint, and it is
|
|
12
|
+
// the same rule src/daemon/script.ts applies to the daemon a session spawns.
|
|
8
13
|
import { existsSync } from "node:fs"
|
|
9
14
|
import { fileURLToPath } from "node:url"
|
|
15
|
+
import { sep } from "node:path"
|
|
10
16
|
|
|
17
|
+
const here = fileURLToPath(import.meta.url)
|
|
18
|
+
const installed = here.split(sep).includes("node_modules")
|
|
11
19
|
const compiled = new URL("../dist/daemon/main.js", import.meta.url)
|
|
12
20
|
const source = new URL("../src/daemon/main.ts", import.meta.url)
|
|
21
|
+
const runCompiled = existsSync(fileURLToPath(compiled)) && (installed || !existsSync(fileURLToPath(source)))
|
|
13
22
|
|
|
14
|
-
if (
|
|
23
|
+
if (runCompiled) {
|
|
15
24
|
const { main } = await import(compiled.href)
|
|
16
25
|
main().catch((error) => {
|
|
17
26
|
console.error(`pi-mailbridge: fatal: ${error instanceof Error ? error.message : String(error)}`)
|
|
18
27
|
process.exit(1)
|
|
19
28
|
})
|
|
20
|
-
} else if (existsSync(fileURLToPath(source))) {
|
|
29
|
+
} else if (!installed && existsSync(fileURLToPath(source))) {
|
|
21
30
|
const { spawn } = await import("node:child_process")
|
|
22
31
|
const child = spawn(
|
|
23
32
|
process.execPath,
|
|
@@ -25,6 +34,11 @@ if (existsSync(fileURLToPath(compiled))) {
|
|
|
25
34
|
{ stdio: "inherit" },
|
|
26
35
|
)
|
|
27
36
|
child.on("exit", (code) => process.exit(code ?? 1))
|
|
37
|
+
} else if (installed) {
|
|
38
|
+
// Spawning the .ts from here would fail with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING,
|
|
39
|
+
// which tells a user nothing about the real problem: the install is incomplete.
|
|
40
|
+
console.error("pi-mailbridge: this install has no compiled build (dist/); reinstall the package")
|
|
41
|
+
process.exit(1)
|
|
28
42
|
} else {
|
|
29
43
|
console.error("pi-mailbridge: no dist/ or src/ found beside this bin; reinstall the package")
|
|
30
44
|
process.exit(1)
|
package/dist/core/config.js
CHANGED
|
@@ -235,6 +235,12 @@ export function loadConfig(env = process.env) {
|
|
|
235
235
|
resolved.daemon = { ...resolved.daemon, scanIntervalSecs: n };
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
|
+
// The one setting that removes the loopback assumption. SECURITY.md says "do not"; saying it
|
|
239
|
+
// only in the docs means a hand-edited config gets no feedback at all.
|
|
240
|
+
const host = String(resolved.daemon.host ?? "").trim().toLowerCase();
|
|
241
|
+
if (host && !(host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1" || host.startsWith("127."))) {
|
|
242
|
+
warnings.push(`daemon.host is "${resolved.daemon.host}", not loopback — there is no TLS and the bearer token is the only guard; leave it at 127.0.0.1 unless you know why`);
|
|
243
|
+
}
|
|
238
244
|
const imap = resolved.mail.imap;
|
|
239
245
|
if (!imap.host || !imap.user)
|
|
240
246
|
warnings.push("mail.imap.host and mail.imap.user are required");
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// relative path and a cid: anchor is unreliable
|
|
9
9
|
//
|
|
10
10
|
// The result is mailed, so the safety rules are the point: an image must sniff as an image
|
|
11
|
-
// (magic bytes, not an extension)
|
|
11
|
+
// (magic bytes, not an extension) whether it was written as an image or as a link, a non-image
|
|
12
|
+
// must have an allowlisted extension, every
|
|
12
13
|
// file is size- and count-capped, and anything unreadable degrades to a sentence instead of
|
|
13
14
|
// a broken link. Content is hashed, so one file referenced twice rides once.
|
|
14
15
|
//
|
|
@@ -331,6 +332,18 @@ export function inlineLocalImages(html, options = {}) {
|
|
|
331
332
|
skipped.push({ src: href, reason: "not-found" });
|
|
332
333
|
return `${inner} ${note("not attached", "file not found")}`;
|
|
333
334
|
}
|
|
335
|
+
// An extension is a claim, not a fact. The image rules above sniff; without this the link
|
|
336
|
+
// path did not, so `[notes](./notes.png)` mailed any bytes at all labelled image/png -
|
|
337
|
+
// the one thing the allowlist exists to prevent. Raster types are checkable; SVG is XML,
|
|
338
|
+
// has no signature to check, and is attached as a file rather than inlined.
|
|
339
|
+
let attachmentType = contentType;
|
|
340
|
+
if (contentType.startsWith("image/") && contentType !== "image/svg+xml") {
|
|
341
|
+
const kind = sniffImage(bytes);
|
|
342
|
+
if (!kind)
|
|
343
|
+
return refuse("not-an-image", "not an image");
|
|
344
|
+
// Name it for what it is: a real PNG called report.jpg should arrive as image/png.
|
|
345
|
+
attachmentType = kind.contentType;
|
|
346
|
+
}
|
|
334
347
|
// Same content, same part: a report linked from two places is attached once.
|
|
335
348
|
const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
|
|
336
349
|
const existing = byCid.get(`file-${digest}`);
|
|
@@ -342,7 +355,7 @@ export function inlineLocalImages(html, options = {}) {
|
|
|
342
355
|
const attachment = {
|
|
343
356
|
filename,
|
|
344
357
|
content: bytes,
|
|
345
|
-
contentType,
|
|
358
|
+
contentType: attachmentType,
|
|
346
359
|
contentDisposition: "attachment",
|
|
347
360
|
};
|
|
348
361
|
byCid.set(`file-${digest}`, attachment);
|
package/dist/daemon/script.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Pure and exported so both layouts can be tested without installing a package.
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
|
-
import { dirname, join } from "node:path";
|
|
10
|
+
import { dirname, join, sep } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
/**
|
|
13
13
|
* Resolve the daemon script for the module at `moduleUrl`.
|
|
@@ -15,26 +15,42 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
* - `dist/extension.js` -> `dist/daemon/main.js`
|
|
16
16
|
* - `src/extension.ts` -> `dist/daemon/main.js` when built (a published install)
|
|
17
17
|
* - `src/extension.ts` only -> `src/daemon/main.ts` (a checkout, run with the flag)
|
|
18
|
+
*
|
|
19
|
+
* In a checkout the SOURCE wins even when `dist/` exists. `dist/` is gitignored and rebuilt only
|
|
20
|
+
* by `pretest`/`prepublishOnly`, so editing `src/daemon/*` without rebuilding would otherwise run
|
|
21
|
+
* the previous build - with an unchanged `API_VERSION`, so nothing detects the mismatch and the
|
|
22
|
+
* only symptom is a fix that appears not to work. A published install has no source to prefer.
|
|
18
23
|
*/
|
|
19
24
|
export function resolveDaemonScript(moduleUrl, exists = existsSync) {
|
|
20
25
|
const here = fileURLToPath(moduleUrl);
|
|
21
26
|
const dir = dirname(here);
|
|
22
27
|
const root = dirname(dir);
|
|
23
28
|
const compiled = here.endsWith(".js");
|
|
29
|
+
// A dependency install is the only place `dist/` is the authority; everything else is a
|
|
30
|
+
// checkout someone is editing.
|
|
31
|
+
const installed = here.split(sep).includes("node_modules");
|
|
32
|
+
const built = join(root, "dist", "daemon", "main.js");
|
|
33
|
+
const source = join(dir, "daemon", "main.ts");
|
|
24
34
|
const candidates = compiled
|
|
25
35
|
? [
|
|
26
36
|
// dist/extension.js -> dist/daemon/main.js
|
|
27
37
|
join(dir, "daemon", "main.js"),
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
built,
|
|
39
|
+
source,
|
|
30
40
|
]
|
|
31
|
-
:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
: installed
|
|
42
|
+
? [
|
|
43
|
+
// src/extension.ts inside node_modules: the compiled daemon, or nothing
|
|
44
|
+
built,
|
|
45
|
+
join(dir, "daemon", "main.js"),
|
|
46
|
+
source,
|
|
47
|
+
]
|
|
48
|
+
: [
|
|
49
|
+
// a checkout: what the editor has, not what the last build left behind
|
|
50
|
+
source,
|
|
51
|
+
built,
|
|
52
|
+
join(dir, "daemon", "main.js"),
|
|
53
|
+
];
|
|
38
54
|
for (const candidate of candidates) {
|
|
39
55
|
if (exists(candidate))
|
|
40
56
|
return candidate;
|
package/docs/SECURITY.md
CHANGED
|
@@ -40,6 +40,28 @@ traverses relays, so the **topmost** `Authentication-Results` is the one the fin
|
|
|
40
40
|
hop — your provider — wrote on delivery. A sender's forged header necessarily ends
|
|
41
41
|
up *below* it.
|
|
42
42
|
|
|
43
|
+
**That argument rests on one assumption: your provider adds a header at all.** If the delivering
|
|
44
|
+
relay stamps no `Authentication-Results`, the topmost one is whatever the sender wrote — so a
|
|
45
|
+
forged `dmarc=pass` is accepted, while your own genuine replies fail `dmarc-missing`. Measured
|
|
46
|
+
here, not theorised:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
From: <an allowlisted address>, only one Authentication-Results, forged by the sender -> accepted
|
|
50
|
+
From: <the same address>, genuinely sent through a relay that stamps nothing -> rejected (dmarc-missing)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It is a quiet failure in both directions at once: a spoofed message gets in *and* real mail appears
|
|
54
|
+
broken, which invites turning `requireDmarcPass` off entirely — the worst response. Two defences:
|
|
55
|
+
|
|
56
|
+
1. **Pin it.** Set `security.trustedAuthservId` to your provider's authserv-id (the token before
|
|
57
|
+
the first `;` in its header). A message without that header then fails closed instead of
|
|
58
|
+
trusting whatever is topmost. Do not pin Microsoft consumer accounts — outlook.com emits bare
|
|
59
|
+
headers with no authserv-id (rule 3 below).
|
|
60
|
+
2. **Measure it.** `pi-mailbridge --smoke` sends one real mail and re-opens the folder; read the
|
|
61
|
+
delivered copy's headers. If it has no `Authentication-Results`, DMARC cannot help on that
|
|
62
|
+
provider and `requireDmarcPass: true` is a coin flip. This is not automated yet — it is the
|
|
63
|
+
reason `--check` prints a note when `trustedAuthservId` is unset.
|
|
64
|
+
|
|
43
65
|
Rules:
|
|
44
66
|
|
|
45
67
|
1. Read the **topmost** `Authentication-Results` header and require `dmarc=pass`
|
|
@@ -243,7 +265,10 @@ model output. The controls, all fail-closed:
|
|
|
243
265
|
- **The daemon's own config and state directories are never attached**, whatever the `cwd`.
|
|
244
266
|
- **An extension allowlist** (`pdf txt log md csv tsv json yml yaml xml html zip gz tar docx xlsx
|
|
245
267
|
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.
|
|
268
|
+
keeps the obvious key filenames out of an accidental attach. A linked file whose extension
|
|
269
|
+
claims a raster image must also **sniff** as one (a `.png` full of text is refused, and a real
|
|
270
|
+
PNG misnamed `.jpg` is attached as `image/png`); SVG is the exception, since XML has no
|
|
271
|
+
signature — it is an attachment, never inlined. It is not a content filter:
|
|
247
272
|
`cp ~/.ssh/id_rsa notes.txt` or `tar czf k.tar ~/.ssh` defeats it, and `realpath` cannot see a
|
|
248
273
|
hardlink. Treat it as a guard rail against a naive single-shot injection, not a sandbox.
|
|
249
274
|
- **Size caps**: 5 MB per inline image and 15 MB per linked file (fixed), and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dumbmice/pi-mailbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Drive Pi coding-agent sessions from your mailbox: get an email when an agent settles, reply to resume that exact session.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"./src/extension.ts"
|
|
49
49
|
]
|
|
50
50
|
},
|
|
51
|
-
"main": "./
|
|
51
|
+
"main": "./dist/extension.js",
|
|
52
52
|
"scripts": {
|
|
53
53
|
"daemon": "node --experimental-strip-types src/daemon/main.ts",
|
|
54
54
|
"typecheck": "tsc --noEmit",
|
package/src/core/config.ts
CHANGED
|
@@ -317,6 +317,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ResolvedConfig
|
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// The one setting that removes the loopback assumption. SECURITY.md says "do not"; saying it
|
|
321
|
+
// only in the docs means a hand-edited config gets no feedback at all.
|
|
322
|
+
const host = String(resolved.daemon.host ?? "").trim().toLowerCase()
|
|
323
|
+
if (host && !(host === "localhost" || host === "::1" || host === "[::1]" || host === "127.0.0.1" || host.startsWith("127."))) {
|
|
324
|
+
warnings.push(
|
|
325
|
+
`daemon.host is "${resolved.daemon.host}", not loopback — there is no TLS and the bearer token is the only guard; leave it at 127.0.0.1 unless you know why`,
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
320
329
|
const imap = resolved.mail.imap
|
|
321
330
|
if (!imap.host || !imap.user) warnings.push("mail.imap.host and mail.imap.user are required")
|
|
322
331
|
if (!resolved.mail.to) warnings.push("mail.to is required")
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// relative path and a cid: anchor is unreliable
|
|
9
9
|
//
|
|
10
10
|
// The result is mailed, so the safety rules are the point: an image must sniff as an image
|
|
11
|
-
// (magic bytes, not an extension)
|
|
11
|
+
// (magic bytes, not an extension) whether it was written as an image or as a link, a non-image
|
|
12
|
+
// must have an allowlisted extension, every
|
|
12
13
|
// file is size- and count-capped, and anything unreadable degrades to a sentence instead of
|
|
13
14
|
// a broken link. Content is hashed, so one file referenced twice rides once.
|
|
14
15
|
//
|
|
@@ -396,6 +397,18 @@ export function inlineLocalImages(html: string, options: InlineOptions = {}): In
|
|
|
396
397
|
return `${inner} ${note("not attached", "file not found")}`
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
// An extension is a claim, not a fact. The image rules above sniff; without this the link
|
|
401
|
+
// path did not, so `[notes](./notes.png)` mailed any bytes at all labelled image/png -
|
|
402
|
+
// the one thing the allowlist exists to prevent. Raster types are checkable; SVG is XML,
|
|
403
|
+
// has no signature to check, and is attached as a file rather than inlined.
|
|
404
|
+
let attachmentType = contentType
|
|
405
|
+
if (contentType.startsWith("image/") && contentType !== "image/svg+xml") {
|
|
406
|
+
const kind = sniffImage(bytes)
|
|
407
|
+
if (!kind) return refuse("not-an-image", "not an image")
|
|
408
|
+
// Name it for what it is: a real PNG called report.jpg should arrive as image/png.
|
|
409
|
+
attachmentType = kind.contentType
|
|
410
|
+
}
|
|
411
|
+
|
|
399
412
|
// Same content, same part: a report linked from two places is attached once.
|
|
400
413
|
const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 16)
|
|
401
414
|
const existing = byCid.get(`file-${digest}`)
|
|
@@ -407,7 +420,7 @@ export function inlineLocalImages(html: string, options: InlineOptions = {}): In
|
|
|
407
420
|
const attachment: InlineAttachment = {
|
|
408
421
|
filename,
|
|
409
422
|
content: bytes,
|
|
410
|
-
contentType,
|
|
423
|
+
contentType: attachmentType,
|
|
411
424
|
contentDisposition: "attachment",
|
|
412
425
|
}
|
|
413
426
|
byCid.set(`file-${digest}`, attachment)
|
package/src/daemon/script.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Pure and exported so both layouts can be tested without installing a package.
|
|
9
9
|
|
|
10
10
|
import { existsSync } from "node:fs"
|
|
11
|
-
import { dirname, join } from "node:path"
|
|
11
|
+
import { dirname, join, sep } from "node:path"
|
|
12
12
|
import { fileURLToPath } from "node:url"
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -17,26 +17,42 @@ import { fileURLToPath } from "node:url"
|
|
|
17
17
|
* - `dist/extension.js` -> `dist/daemon/main.js`
|
|
18
18
|
* - `src/extension.ts` -> `dist/daemon/main.js` when built (a published install)
|
|
19
19
|
* - `src/extension.ts` only -> `src/daemon/main.ts` (a checkout, run with the flag)
|
|
20
|
+
*
|
|
21
|
+
* In a checkout the SOURCE wins even when `dist/` exists. `dist/` is gitignored and rebuilt only
|
|
22
|
+
* by `pretest`/`prepublishOnly`, so editing `src/daemon/*` without rebuilding would otherwise run
|
|
23
|
+
* the previous build - with an unchanged `API_VERSION`, so nothing detects the mismatch and the
|
|
24
|
+
* only symptom is a fix that appears not to work. A published install has no source to prefer.
|
|
20
25
|
*/
|
|
21
26
|
export function resolveDaemonScript(moduleUrl: string | URL, exists: (path: string) => boolean = existsSync): string {
|
|
22
27
|
const here = fileURLToPath(moduleUrl)
|
|
23
28
|
const dir = dirname(here)
|
|
24
29
|
const root = dirname(dir)
|
|
25
30
|
const compiled = here.endsWith(".js")
|
|
31
|
+
// A dependency install is the only place `dist/` is the authority; everything else is a
|
|
32
|
+
// checkout someone is editing.
|
|
33
|
+
const installed = here.split(sep).includes("node_modules")
|
|
34
|
+
const built = join(root, "dist", "daemon", "main.js")
|
|
35
|
+
const source = join(dir, "daemon", "main.ts")
|
|
26
36
|
const candidates = compiled
|
|
27
37
|
? [
|
|
28
38
|
// dist/extension.js -> dist/daemon/main.js
|
|
29
39
|
join(dir, "daemon", "main.js"),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
]
|
|
33
|
-
: [
|
|
34
|
-
// src/extension.ts -> the build, when the package was published
|
|
35
|
-
join(root, "dist", "daemon", "main.js"),
|
|
36
|
-
join(dir, "daemon", "main.js"),
|
|
37
|
-
// a checkout without a build
|
|
38
|
-
join(dir, "daemon", "main.ts"),
|
|
40
|
+
built,
|
|
41
|
+
source,
|
|
39
42
|
]
|
|
43
|
+
: installed
|
|
44
|
+
? [
|
|
45
|
+
// src/extension.ts inside node_modules: the compiled daemon, or nothing
|
|
46
|
+
built,
|
|
47
|
+
join(dir, "daemon", "main.js"),
|
|
48
|
+
source,
|
|
49
|
+
]
|
|
50
|
+
: [
|
|
51
|
+
// a checkout: what the editor has, not what the last build left behind
|
|
52
|
+
source,
|
|
53
|
+
built,
|
|
54
|
+
join(dir, "daemon", "main.js"),
|
|
55
|
+
]
|
|
40
56
|
for (const candidate of candidates) {
|
|
41
57
|
if (exists(candidate)) return candidate
|
|
42
58
|
}
|