@dumbmice/pi-mailbridge 0.1.0 → 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 CHANGED
@@ -1,5 +1,44 @@
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.** `![shot](./x.png)` 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
+
30
+ ## 0.1.1 — 2026-11-13
31
+
32
+ No behaviour change. This is the release that proves the pipeline: publishing now happens in CI
33
+ with npm trusted publishing (OIDC), so the tarball is signed with a provenance attestation tying it
34
+ to this repository and this tag. Nothing to upgrade for, unless you want that attestation.
35
+
36
+ - `.github/workflows/publish.yml`: publishes on a `v*` tag over OIDC (no stored token), runs the
37
+ same build/typecheck/test gate a local `npm publish` runs, refuses a version that is already on
38
+ the registry, and validates the tarball without touching the registry on a manual dry run.
39
+ - `repository` now uses GitHub's canonical case (`DumbMice`), which trusted publishing compares
40
+ against the OIDC claim.
41
+
3
42
  ## 0.1.0 — 2026-11-13
4
43
 
5
44
  ### A crash, a vanished reply, and a corrupt queue
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
- ![A pi-mailbridge notification in a mail client: a decision request with rendered Markdown, a recommendation, and a build log attached](./docs/images/notification.png)
11
+ ![A pi-mailbridge notification in a mail client: a decision request with rendered Markdown, a recommendation, and a build log attached](https://raw.githubusercontent.com/DumbMice/pi-mailbridge/main/docs/images/notification.png)
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](./docs/SECURITY.md) before installing, and point it at a mailbox you
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 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
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`](./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](./docs/SECURITY.md) §6b for the full boundary.
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.0
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](./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`](./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](./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](./docs/OAUTH.md) explains the options, including turnkey services.
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](./docs/SECURITY.md) §3. |
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
- ![Diagram: the pi session sends through the local daemon over SMTP to your mailbox; you reply from any device; the daemon reads it over IMAP, authorizes, routes and persists it; the session claims it, injects it, and acknowledges, which marks the mail seen](./docs/images/flow.svg)
569
+ ![Diagram: the pi session sends through the local daemon over SMTP to your mailbox; you reply from any device; the daemon reads it over IMAP, authorizes, routes and persists it; the session claims it, injects it, and acknowledges, which marks the mail seen](https://raw.githubusercontent.com/DumbMice/pi-mailbridge/main/docs/images/flow.svg)
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](./docs/ARCHITECTURE.md) and
581
- [docs/MAPPING.md](./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.0` — feature-complete for the single-user, one-mailbox case, and exercised end to end
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
 
@@ -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`. A checkout without a build falls back to
7
- // running the sources with the flag, which is how development and the tests use it.
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 (existsSync(fileURLToPath(compiled))) {
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)
@@ -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), a non-image must have an allowlisted extension, every
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);
@@ -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
- join(root, "dist", "daemon", "main.js"),
29
- join(dir, "daemon", "main.ts"),
38
+ built,
39
+ source,
30
40
  ]
31
- : [
32
- // src/extension.ts -> the build, when the package was published
33
- join(root, "dist", "daemon", "main.js"),
34
- join(dir, "daemon", "main.js"),
35
- // a checkout without a build
36
- join(dir, "daemon", "main.ts"),
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. It is not a content filter:
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.0",
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",
@@ -17,12 +17,12 @@
17
17
  "license": "MIT",
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "git+https://github.com/dumbmice/pi-mailbridge.git"
20
+ "url": "git+https://github.com/DumbMice/pi-mailbridge.git"
21
21
  },
22
22
  "bugs": {
23
- "url": "https://github.com/dumbmice/pi-mailbridge/issues"
23
+ "url": "https://github.com/DumbMice/pi-mailbridge/issues"
24
24
  },
25
- "homepage": "https://github.com/dumbmice/pi-mailbridge#readme",
25
+ "homepage": "https://github.com/DumbMice/pi-mailbridge#readme",
26
26
  "author": {
27
27
  "name": "Yang Bangcheng",
28
28
  "email": "yangbangcheng@bigai.ai"
@@ -48,7 +48,7 @@
48
48
  "./src/extension.ts"
49
49
  ]
50
50
  },
51
- "main": "./src/extension.ts",
51
+ "main": "./dist/extension.js",
52
52
  "scripts": {
53
53
  "daemon": "node --experimental-strip-types src/daemon/main.ts",
54
54
  "typecheck": "tsc --noEmit",
@@ -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), a non-image must have an allowlisted extension, every
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)
@@ -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
- join(root, "dist", "daemon", "main.js"),
31
- join(dir, "daemon", "main.ts"),
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
  }