@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,44 @@
1
+ // Which build is this? Diagnostic only - compatibility is `daemon/api.ts` (API_VERSION).
2
+ //
3
+ // The extension is loaded by pi and the daemon is a child process; after `pi update` the child
4
+ // can still be executing the previous build. `/health` carries this string so a session can
5
+ // notice and replace it, instead of talking to a daemon that mints routing ids it cannot resolve.
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ const cache = new Map();
10
+ /**
11
+ * The version from the package.json that owns this module.
12
+ *
13
+ * `dist/daemon/main.js` and `src/daemon/main.ts` are both two levels below the package root, so
14
+ * one lookup works for a checkout and for an installed build. Unknown is reported as `"unknown"`
15
+ * rather than guessing: a caller that cannot compare should do nothing.
16
+ */
17
+ export function packageVersion(moduleUrl) {
18
+ const key = String(moduleUrl);
19
+ const cached = cache.get(key);
20
+ if (cached !== undefined)
21
+ return cached;
22
+ // Walk up rather than assuming a depth: `src/extension.ts` is one level below the package
23
+ // root, `src/daemon/main.ts` and its compiled twin are two, and a test file is one.
24
+ let version = "unknown";
25
+ let dir = dirname(fileURLToPath(moduleUrl));
26
+ for (let depth = 0; depth < 5; depth += 1) {
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
29
+ if (typeof parsed.version === "string" && parsed.version.length > 0) {
30
+ version = parsed.version;
31
+ break;
32
+ }
33
+ }
34
+ catch {
35
+ /* keep walking */
36
+ }
37
+ const parent = dirname(dir);
38
+ if (parent === dir)
39
+ break;
40
+ dir = parent;
41
+ }
42
+ cache.set(key, version);
43
+ return version;
44
+ }
@@ -0,0 +1,46 @@
1
+ // The wire contract between the extension and the daemon.
2
+ //
3
+ // Two numbers, two clocks, deliberately separate (AGENTS.md "Compatibility"):
4
+ //
5
+ // API_VERSION - the HTTP surface: endpoints, payload fields, semantics. Bumped by any
6
+ // breaking change to it; additive changes are gated by CAPABILITIES instead.
7
+ // package version (version.ts) - diagnostic only: which build someone is running.
8
+ //
9
+ // The daemon and the extension are spawned from one package, so a mismatch is recovered by
10
+ // replacing the process rather than negotiating - but the *number* compared must be the
11
+ // protocol, not the build, or a harmless patch bump would restart a working daemon.
12
+ /** Bumped only for a breaking change to an endpoint, a payload shape, or a meaning. */
13
+ export const API_VERSION = 1;
14
+ /**
15
+ * Additive features inside API_VERSION.
16
+ *
17
+ * A client must check for a capability before using anything newer than the base contract, and
18
+ * must not fake it when absent (no fallback paths: tell the user to update, or skip the feature).
19
+ */
20
+ export const CAPABILITIES = ["health", "version", "pending", "claim", "ack", "send"];
21
+ /** Every endpoint, in one place, so the schema test can freeze the surface. */
22
+ export const ENDPOINTS = [
23
+ "GET /events",
24
+ "GET /health",
25
+ "GET /pending",
26
+ "GET /mode",
27
+ "POST /claim",
28
+ "POST /ack",
29
+ "POST /register",
30
+ "DELETE /register",
31
+ "POST /ping",
32
+ "POST /bye",
33
+ "POST /shutdown",
34
+ "POST /reload",
35
+ "POST /mode",
36
+ "POST /send",
37
+ ];
38
+ /**
39
+ * Is a daemon speaking `theirs` usable by this build?
40
+ *
41
+ * Missing means "from before the field existed" - that is protocol 0, which this build cannot
42
+ * assume anything about.
43
+ */
44
+ export function apiCompatible(mine, theirs) {
45
+ return typeof theirs === "number" && theirs === mine;
46
+ }
@@ -0,0 +1,75 @@
1
+ // Real imapflow client factory.
2
+ //
3
+ // Kept separate from watcher.ts so the watcher stays testable: nothing here runs
4
+ // in a unit test. Note the two auth modes mirror core/mailer.ts — LOGIN uses an
5
+ // app password, XOAUTH2 needs an access token fetched before connect().
6
+ import { ImapFlow, } from "imapflow";
7
+ import { createCommandTokenProvider } from "../core/oauth.js";
8
+ /** Auth block for imapflow, honouring the configured auth type. */
9
+ export async function buildImapAuth(config, getAccessToken) {
10
+ const imap = config.mail.imap;
11
+ if (imap.authType === "XOAUTH2") {
12
+ if (!getAccessToken) {
13
+ throw new Error("pi-mailbridge: imap.authType is XOAUTH2 but no token provider is configured");
14
+ }
15
+ const accessToken = await getAccessToken();
16
+ if (!accessToken)
17
+ throw new Error("pi-mailbridge: OAuth2 token provider returned an empty token for IMAP");
18
+ return { user: imap.user, accessToken };
19
+ }
20
+ return { user: imap.user, pass: imap.password };
21
+ }
22
+ /**
23
+ * Adapt the real client to the narrow interface the watcher uses.
24
+ *
25
+ * An explicit adapter rather than a cast: every call below is checked against
26
+ * imapflow's real signature, so a major bump that renames or re-shapes a method
27
+ * fails the build instead of failing at 3am.
28
+ */
29
+ export function adapt(client) {
30
+ return {
31
+ connect: () => client.connect(),
32
+ logout: () => client.logout(),
33
+ mailboxOpen: (path) => client.mailboxOpen(path),
34
+ // imapflow returns `false`/`undefined` for an empty result set. Normalizing
35
+ // here is not cosmetic: without it a quiet mailbox would throw inside the scan.
36
+ search: async (query, options) => {
37
+ const result = await client.search(query, options);
38
+ return Array.isArray(result) ? result : [];
39
+ },
40
+ fetchOne: (uid, query, options) => client.fetchOne(uid, query, options),
41
+ messageFlagsAdd: (uid, flags, options) => client.messageFlagsAdd(uid, flags, options),
42
+ // ImapFlow's event map cannot be expressed generically; this is the one
43
+ // boundary that needs a cast, and the events we listen for are covered by
44
+ // the watcher's own tests.
45
+ on: (event, listener) => {
46
+ client.on(event, listener);
47
+ },
48
+ get usable() {
49
+ return client.usable;
50
+ },
51
+ // Pass the whole mailbox through, never a hand-built subset: an enumerated copy has
52
+ // silently dropped a field twice (uidValidity, then uidNext) and both times the guard
53
+ // that read it went inert in production with the tests still green.
54
+ get mailbox() {
55
+ return client.mailbox === false ? undefined : client.mailbox;
56
+ },
57
+ };
58
+ }
59
+ export function createImapflowFactory(options = {}) {
60
+ return async (config, tuning) => {
61
+ const auth = await buildImapAuth(config, options.getAccessToken);
62
+ const client = new ImapFlow({
63
+ host: config.mail.imap.host,
64
+ port: config.mail.imap.port,
65
+ secure: config.mail.imap.secure,
66
+ // imapflow 2 exposes a typed AuthOptions; no cast needed.
67
+ auth,
68
+ // Renew IDLE well under the server's ~29 minute cutoff.
69
+ maxIdleTime: tuning.idleRenewMs,
70
+ // Never let imapflow log credentials or raw protocol.
71
+ logger: false,
72
+ });
73
+ return adapt(client);
74
+ };
75
+ }