@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +824 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,129 @@
1
+ // Inbound attachments.
2
+ //
3
+ // The point: reply to a pi notification from your phone with an image or a log
4
+ // and have the agent actually see it.
5
+ //
6
+ // Deliberately restrictive. An attachment that reaches the model is untrusted
7
+ // input from a mail server, so:
8
+ // - MIME allowlist, not a denylist
9
+ // - hard caps on count and total bytes, enforced before anything is stored
10
+ // - anything dropped is reported with a reason rather than silently ignored
11
+
12
+ /** Images the model can actually consume. */
13
+ export const ALLOWED_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
14
+ /** Text-like attachments we inline as text. */
15
+ export const ALLOWED_TEXT_MIMES = ["text/plain", "text/markdown", "application/json"] as const
16
+
17
+ export const MAX_ATTACHMENTS = 4
18
+ export const MAX_TOTAL_BYTES = 1_000_000
19
+
20
+ export type UserContent =
21
+ | { type: "text"; text: string }
22
+ | { type: "image"; data: string; mimeType: string }
23
+
24
+ /** What the MIME parser hands us. */
25
+ export interface RawAttachment {
26
+ filename?: string
27
+ contentType: string
28
+ content: Buffer
29
+ }
30
+
31
+ /** What we store in the pending record (base64, so JSON keeps it). */
32
+ export interface InboundAttachment {
33
+ name: string
34
+ mimeType: string
35
+ size: number
36
+ data: string
37
+ }
38
+
39
+ export interface DroppedAttachment {
40
+ name: string
41
+ reason: "mime-not-allowed" | "too-many" | "too-large" | "empty"
42
+ }
43
+
44
+ export interface SelectResult {
45
+ accepted: InboundAttachment[]
46
+ dropped: DroppedAttachment[]
47
+ }
48
+
49
+ function isImage(mime: string): boolean {
50
+ return (ALLOWED_IMAGE_MIMES as readonly string[]).includes(mime)
51
+ }
52
+
53
+ function isText(mime: string): boolean {
54
+ return (ALLOWED_TEXT_MIMES as readonly string[]).includes(mime)
55
+ }
56
+
57
+ /** Lowercase, strip parameters (`text/plain; charset=utf-8` → `text/plain`). */
58
+ export function normalizeMime(contentType: string | undefined): string {
59
+ return (contentType ?? "").split(";")[0]!.trim().toLowerCase()
60
+ }
61
+
62
+ /**
63
+ * Apply the allowlist and the caps.
64
+ * Order matters: mime first (cheap), then count, then size.
65
+ */
66
+ export function selectAttachments(
67
+ raw: RawAttachment[],
68
+ limits: { maxCount?: number; maxTotalBytes?: number } = {},
69
+ ): SelectResult {
70
+ const maxCount = limits.maxCount ?? MAX_ATTACHMENTS
71
+ const maxTotalBytes = limits.maxTotalBytes ?? MAX_TOTAL_BYTES
72
+
73
+ const accepted: InboundAttachment[] = []
74
+ const dropped: DroppedAttachment[] = []
75
+ let total = 0
76
+
77
+ for (const item of raw) {
78
+ const mimeType = normalizeMime(item.contentType)
79
+ const name = (item.filename ?? "").trim() || `attachment-${accepted.length + dropped.length + 1}`
80
+
81
+ if (!isImage(mimeType) && !isText(mimeType)) {
82
+ dropped.push({ name, reason: "mime-not-allowed" })
83
+ continue
84
+ }
85
+ if (accepted.length >= maxCount) {
86
+ dropped.push({ name, reason: "too-many" })
87
+ continue
88
+ }
89
+ if (item.content.length === 0) {
90
+ dropped.push({ name, reason: "empty" })
91
+ continue
92
+ }
93
+ if (total + item.content.length > maxTotalBytes) {
94
+ dropped.push({ name, reason: "too-large" })
95
+ continue
96
+ }
97
+
98
+ total += item.content.length
99
+ accepted.push({
100
+ name,
101
+ mimeType,
102
+ size: item.content.length,
103
+ data: item.content.toString("base64"),
104
+ })
105
+ }
106
+
107
+ return { accepted, dropped }
108
+ }
109
+
110
+ /**
111
+ * Build the payload for `pi.sendUserMessage`.
112
+ *
113
+ * Returns a plain string when there is nothing to attach: that keeps the common
114
+ * case identical to a typed message, so no client special-cases it.
115
+ */
116
+ export function toUserContent(text: string, attachments: InboundAttachment[] = []): string | UserContent[] {
117
+ if (attachments.length === 0) return text
118
+ // An attachment-only reply has no prose; an empty text block ahead of the image is noise
119
+ // the model has to read past.
120
+ const content: UserContent[] = text ? [{ type: "text", text }] : []
121
+ for (const attachment of attachments) {
122
+ if (isImage(attachment.mimeType)) {
123
+ content.push({ type: "image", data: attachment.data, mimeType: attachment.mimeType })
124
+ } else {
125
+ content.push({ type: "text", text: Buffer.from(attachment.data, "base64").toString("utf8") })
126
+ }
127
+ }
128
+ return content
129
+ }
@@ -0,0 +1,87 @@
1
+ // Authorization for inbound mail.
2
+ //
3
+ // Order matters and is fail-closed at every step:
4
+ // 1. loop guard — never act on our own outbound address
5
+ // 2. auto-generated — no replies to robots (that is how loops start)
6
+ // 3. allowlist — exact addr-spec after RFC 5322-ish normalization
7
+ // 4. DMARC — dmarc=pass; authserv-id pinned only when configured
8
+ //
9
+ // The routing token is NOT consulted here. It routes; it does not authorize.
10
+ // See docs/SECURITY.md §2.2.
11
+
12
+ import type { ResolvedConfig } from "./config.ts"
13
+ import { dmarcPasses, findTrustedAuthResults, isAutoGenerated, normalizeAddress, type HeaderLike } from "./reply-parse.ts"
14
+
15
+ export type AuthzDenial =
16
+ | "no-from"
17
+ | "self"
18
+ | "self-echo"
19
+ | "no-reply-evidence"
20
+ | "auto-generated"
21
+ | "not-allowlisted"
22
+ | "allowlist-empty"
23
+ | "dmarc-missing"
24
+ | "dmarc-failed"
25
+
26
+ export type AuthzResult =
27
+ | { ok: true; from: string }
28
+ | { ok: false; reason: AuthzDenial }
29
+
30
+ export interface AuthzInput {
31
+ /** Raw `From:` header value, display name included. */
32
+ from: string | undefined
33
+ headers: HeaderLike
34
+ }
35
+
36
+ export function authorize(input: AuthzInput, config: ResolvedConfig): AuthzResult {
37
+ const from = normalizeAddress(input.from)
38
+ if (!from) return { ok: false, reason: "no-from" }
39
+
40
+ // 1. Loop guard: the pi account must never be treated as the human.
41
+ // `mail.to` is the HUMAN's address, so it must not be used here — doing so
42
+ // rejected every genuine reply (the human is the one writing to us).
43
+ //
44
+ // Single-mailbox mode (`security.allowSelfReplies`): one address is both the
45
+ // mailbox and the human, which is the normal case for a personal Hotmail or
46
+ // Gmail. Then `from === self` is expected — but we still must not treat our
47
+ // OWN outbound notification as a reply. The discriminator is `In-Reply-To`:
48
+ // a reply carries it - but so does every threaded notification we send, so this
49
+ // header alone is not enough. process.ts rejects our own Message-IDs first, using
50
+ // the outbound registry; this check remains as the cheap early gate.
51
+ const self = normalizeAddress(config.mail.smtp.user) ?? normalizeAddress(config.mail.imap.user)
52
+ if (self && from === self) {
53
+ if (!config.security.allowSelfReplies) return { ok: false, reason: "self" }
54
+ // Reply evidence is either header the router accepts. Requiring only In-Reply-To
55
+ // killed genuine replies from clients that set References alone (or a
56
+ // forward-then-reply), because the router would have routed them by chain.
57
+ const threaded =
58
+ Boolean(input.headers.inReplyTo?.trim()) || Boolean(input.headers.references?.some((r) => r.trim()))
59
+ // Distinct from the self-echo guards (marker header, outbound Message-ID): this is a
60
+ // message from your own address with no reply evidence at all. The two are different
61
+ // problems for the user, so they get different names.
62
+ if (!threaded) return { ok: false, reason: "no-reply-evidence" }
63
+ }
64
+
65
+ // 2. Never answer robots, and never act on a bounce: a null sender or a postmaster
66
+ // address is a delivery failure report, not a human.
67
+ if (!from.includes("@") || /^(mailer-daemon|postmaster|mail-daemon)$/i.test(from.split("@")[0] ?? "")) {
68
+ return { ok: false, reason: "auto-generated" }
69
+ }
70
+ if (isAutoGenerated(input.headers)) return { ok: false, reason: "auto-generated" }
71
+
72
+ // 3. Allowlist. An empty list denies everything.
73
+ const allow = config.security.allowFrom.map((a) => normalizeAddress(a)).filter((a): a is string => Boolean(a))
74
+ if (allow.length === 0) return { ok: false, reason: "allowlist-empty" }
75
+ if (!allow.includes(from)) return { ok: false, reason: "not-allowlisted" }
76
+
77
+ // 4. DMARC pinned to our relay.
78
+ if (config.security.requireDmarcPass) {
79
+ // Unset means "trust the topmost header, i.e. the delivering relay's".
80
+ // Pinned means "only that authserv-id".
81
+ const ar = findTrustedAuthResults(input.headers, config.security.trustedAuthservId)
82
+ if (!ar) return { ok: false, reason: "dmarc-missing" }
83
+ if (!dmarcPasses(ar)) return { ok: false, reason: "dmarc-failed" }
84
+ }
85
+
86
+ return { ok: true, from }
87
+ }
@@ -0,0 +1,99 @@
1
+ // `/mailbridge` argument routing.
2
+ //
3
+ // Pure, so the command surface is testable without pi. Keeping the parse separate
4
+ // from the handlers is also what lets the same subcommand set back both the in-pi
5
+ // command and (where it makes sense) the headless entry point.
6
+
7
+ export type MailbridgeSubcommand =
8
+ | "on"
9
+ | "off"
10
+ | "status"
11
+ | "setup"
12
+ | "check"
13
+ | "auth"
14
+ | "smoke"
15
+ | "prime"
16
+ | "restart"
17
+ | "help"
18
+
19
+ export interface ParsedCommand {
20
+ sub: MailbridgeSubcommand
21
+ /** Setup: the pi address. */
22
+ address?: string
23
+ /** Setup: where the human reads mail. */
24
+ to?: string
25
+ /** Setup: overwrite an existing config. */
26
+ force: boolean
27
+ }
28
+
29
+ export const USAGE = [
30
+ "/mailbridge status",
31
+ "/mailbridge on email mode on: decisions mail you",
32
+ "/mailbridge off email mode off: decisions ask in this terminal",
33
+ "/mailbridge setup <pi-address> [to <your-address>] [force]",
34
+ "/mailbridge check validate config, and that credentials are present",
35
+ "/mailbridge auth approve OAuth2 device login",
36
+ "/mailbridge smoke send one real mail, then read the folder back",
37
+ "/mailbridge prime start watching from now, ignoring existing mail",
38
+ "/mailbridge restart replace the daemon process (picks up new code, not just config)",
39
+ "/mailbridge help this list",
40
+ ].join("\n")
41
+
42
+ const SUBCOMMANDS: readonly MailbridgeSubcommand[] = [
43
+ "on",
44
+ "off",
45
+ "status",
46
+ "setup",
47
+ "check",
48
+ "auth",
49
+ "smoke",
50
+ "prime",
51
+ "restart",
52
+ "help",
53
+ ]
54
+
55
+ export function isSubcommand(value: string): value is MailbridgeSubcommand {
56
+ return (SUBCOMMANDS as readonly string[]).includes(value)
57
+ }
58
+
59
+ /**
60
+ * Parse the text after `/mailbridge`.
61
+ *
62
+ * Accepts both `--to` and bare `to` because a TUI prompt is not a shell and people
63
+ * type either. Unknown words that are not addresses are ignored rather than
64
+ * treated as errors, so a stray flag cannot silently change the target address.
65
+ */
66
+ export function parseMailbridgeCommand(args: string): ParsedCommand {
67
+ const tokens = args.trim().split(/\s+/).filter(Boolean)
68
+ const first = tokens[0]?.toLowerCase()
69
+
70
+ if (!first || !isSubcommand(first)) {
71
+ return { sub: first ? "help" : "status", force: false }
72
+ }
73
+
74
+ const parsed: ParsedCommand = { sub: first, force: false }
75
+ if (first !== "setup") return parsed
76
+
77
+ const rest = tokens.slice(1)
78
+ const addresses: string[] = []
79
+ for (let i = 0; i < rest.length; i++) {
80
+ const token = rest[i]!
81
+ const lower = token.toLowerCase()
82
+ if (lower === "force" || lower === "--force") {
83
+ parsed.force = true
84
+ continue
85
+ }
86
+ if (lower === "to" || lower === "--to") {
87
+ const next = rest[i + 1]
88
+ if (next && !next.startsWith("-") && next.toLowerCase() !== "force") {
89
+ parsed.to = next
90
+ i++
91
+ }
92
+ continue
93
+ }
94
+ if (token.includes("@")) addresses.push(token)
95
+ }
96
+
97
+ if (addresses[0]) parsed.address = addresses[0]
98
+ return parsed
99
+ }
@@ -0,0 +1,33 @@
1
+ // Should a session tell the running daemon to re-read its config?
2
+ //
3
+ // A session that attaches to an already-running daemon inherits whatever config that daemon
4
+ // read - which may predate a hand-edited config.json or a headless `--setup`. Sending mail
5
+ // with a replaced address is the failure this exists to prevent.
6
+ //
7
+ // The daemon reports the *mtime of the file it is running on*, and the extension compares it
8
+ // with the file's current mtime. mtime-to-mtime deliberately: a clock reading would make a
9
+ // preserved/backdated mtime reload forever, and would hide a write that landed between the
10
+ // read and the timestamp.
11
+
12
+ export interface Freshness {
13
+ /**
14
+ * The mtime of the config file the daemon is running on (NOT the time it read it), or 0
15
+ * when it loaded with no file, or null/undefined if it did not report one.
16
+ */
17
+ configLoadedAt: number | null | undefined
18
+ /** The config file's mtime, or null when there is no file (built-in defaults). */
19
+ configMtimeMs: number | null | undefined
20
+ }
21
+
22
+ export function shouldReloadConfig({ configLoadedAt, configMtimeMs }: Freshness): boolean {
23
+ // No file: nothing changed, leave the daemon alone.
24
+ if (!configMtimeMs) return false
25
+ // The daemon reports 0 when it loaded while there was no config file. That is not
26
+ // "nothing to compare" - it is a daemon running on defaults, and a file that has since
27
+ // appeared must be picked up. Treating 0 as absent made this state permanent.
28
+ if (!configLoadedAt) return true
29
+ // Strictly newer. No slack: a re-trigger is impossible when both sides are the same
30
+ // file's mtime, and a tolerance would permanently miss a save that lands within it
31
+ // (an editor that writes twice), which is the failure this check exists to prevent.
32
+ return configMtimeMs > configLoadedAt
33
+ }