@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +836 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,135 @@
1
+ // Monotonic IMAP UID cursor.
2
+ //
3
+ // Polling is `UID <cursor>+1:*` — never `UNSEEN`, never by SUBJECT. `\Seen` is
4
+ // shared mutable state: any other mail client can flip it and silently eat a
5
+ // reply. The cursor is ours alone and only ever moves forward.
6
+ //
7
+ // Writes are atomic (tmp + rename) so a crash cannot leave a torn file. The daemon writes it,
8
+ // and so does `prime` (from the extension or the CLI); the value only moves forward, so two
9
+ // concurrent primes cannot corrupt it and no lock is needed.
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
12
+ import { dirname } from "node:path"
13
+
14
+ import { statePath } from "./paths.ts"
15
+ import { StateVersionError, checkStateVersion, stamp } from "./schema.ts"
16
+
17
+ interface CursorFile {
18
+ v?: number
19
+ lastUid: number
20
+ /** The mailbox's UIDVALIDITY when this cursor was written. */
21
+ uidValidity?: number
22
+ /**
23
+ * `prime` has run. Existence of this file proves nothing: the daemon writes it on its first
24
+ * connect, before it has read any mail, so "has a file" cannot distinguish a fresh install
25
+ * (prime is wanted) from a mailbox already being watched (prime would skip mail).
26
+ */
27
+ primed?: boolean
28
+ }
29
+
30
+ function path(env: NodeJS.ProcessEnv): string {
31
+ return statePath(env, "uid-cursor.json")
32
+ }
33
+
34
+ /**
35
+ * Is the bridge already watching this mailbox, so that priming would skip mail?
36
+ *
37
+ * True when `prime` ran before (the flag survives a renumbering reset), or when the cursor has
38
+ * actually been advanced past something. A file with `lastUid: 0` and no flag is a fresh install:
39
+ * the daemon writes it on connect before reading anything, and priming there is the whole point.
40
+ */
41
+ export function isPrimed(env: NodeJS.ProcessEnv = process.env): boolean {
42
+ const file = readFile(env)
43
+ return file.primed === true || file.lastUid > 0
44
+ }
45
+
46
+ /** Record that `prime` ran, so a later run refuses. Preserves the cursor itself. */
47
+ export function markPrimed(env: NodeJS.ProcessEnv = process.env): void {
48
+ const file = readFile(env)
49
+ write(env, file.lastUid, file.uidValidity, true)
50
+ }
51
+
52
+ export function readCursor(env: NodeJS.ProcessEnv = process.env): number {
53
+ return readFile(env).lastUid
54
+ }
55
+
56
+ function readFile(env: NodeJS.ProcessEnv): CursorFile {
57
+ const p = path(env)
58
+ try {
59
+ if (!existsSync(p)) return { lastUid: 0 }
60
+ const parsed = JSON.parse(readFileSync(p, "utf8")) as Partial<CursorFile>
61
+ checkStateVersion("uid-cursor.json", parsed)
62
+ const uid = parsed?.lastUid
63
+ const validity = parsed?.uidValidity
64
+ return {
65
+ lastUid: typeof uid === "number" && Number.isFinite(uid) && uid >= 0 ? uid : 0,
66
+ ...(typeof validity === "number" && Number.isFinite(validity) ? { uidValidity: validity } : {}),
67
+ ...(parsed?.primed === true ? { primed: true } : {}),
68
+ }
69
+ } catch (error) {
70
+ if (error instanceof StateVersionError) throw error
71
+ return { lastUid: 0 }
72
+ }
73
+ }
74
+
75
+ function write(env: NodeJS.ProcessEnv, uid: number, uidValidity?: number, primed?: boolean): void {
76
+ const p = path(env)
77
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 })
78
+ const tmp = `${p}.${process.pid}.tmp`
79
+ const keepPrimed = primed ?? readFile(env).primed === true
80
+ const body: CursorFile = {
81
+ ...stamp(),
82
+ lastUid: uid,
83
+ ...(uidValidity === undefined ? {} : { uidValidity }),
84
+ ...(keepPrimed ? { primed: true } : {}),
85
+ }
86
+ writeFileSync(tmp, JSON.stringify(body), { mode: 0o600 })
87
+ renameSync(tmp, p)
88
+ }
89
+
90
+ /**
91
+ * Compare the mailbox's UIDVALIDITY with the one this cursor was written under.
92
+ *
93
+ * UIDs are only meaningful within one UIDVALIDITY. A recreated or migrated folder renumbers
94
+ * them from 1, and a persisted high-water cursor then skips everything for ever - visible
95
+ * only as "examined 0". When it changes, the cursor is reset so the new UID space is read from
96
+ * the start. The cost is the one ARCHITECTURE.md states: the journal is space-scoped, so a
97
+ * renumbering can re-deliver a reply that was acked inside the route TTL.
98
+ *
99
+ * @returns true when the cursor was reset.
100
+ */
101
+ export function syncUidValidity(
102
+ env: NodeJS.ProcessEnv,
103
+ uidValidity: number | undefined,
104
+ uidNext?: number,
105
+ ): boolean {
106
+ if (uidValidity === undefined || !Number.isFinite(uidValidity)) return false
107
+ const current = readFile(env)
108
+ if (current.uidValidity === uidValidity) return false
109
+
110
+ // First observation on a cursor from a version that stored no validity: usually an
111
+ // upgrade over an unchanged mailbox, so keep the cursor. But if the cursor is at or past
112
+ // the mailbox's next UID, it cannot belong to this numbering - the folder was renumbered
113
+ // while we were not looking - and keeping it would skip everything for ever.
114
+ const observedFirst = current.uidValidity === undefined
115
+ const outside = observedFirst && uidNext !== undefined && current.lastUid >= uidNext
116
+ const reset = !observedFirst || outside
117
+ write(env, reset ? 0 : current.lastUid, uidValidity)
118
+ return reset
119
+ }
120
+
121
+ /**
122
+ * Advance the cursor to `uid`, never backwards.
123
+ * @returns the cursor value after the call.
124
+ */
125
+ export function advanceCursor(env: NodeJS.ProcessEnv, uid: number): number {
126
+ const current = readFile(env)
127
+ if (uid <= current.lastUid) return current.lastUid
128
+ write(env, uid, current.uidValidity)
129
+ return uid
130
+ }
131
+
132
+ /** Test helper: force the cursor to a value (ignores monotonicity). */
133
+ export function resetCursor(env: NodeJS.ProcessEnv, uid = 0): void {
134
+ write(env, uid, readFile(env).uidValidity)
135
+ }