@remit/mailbox-service 0.0.111 → 0.0.113

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 (298) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. package/tsconfig.json +0 -8
@@ -1,1475 +0,0 @@
1
- /**
2
- * ImapFlow-based IMAP connection
3
- *
4
- * Modern async/await replacement for the node-imap based ImapConnection.
5
- * Provides the same interface but uses ImapFlow under the hood.
6
- */
7
-
8
- import { Readable } from "node:stream";
9
- import { ImapFlow } from "imapflow";
10
- import { simpleParser } from "mailparser";
11
- import type {
12
- FlatMailboxInfo,
13
- ImapAddress,
14
- ImapBoxStatus,
15
- ImapConnectionConfig,
16
- ImapConnectionState,
17
- ImapEnvelopeSnapshot,
18
- ImapMailboxStatus,
19
- ImapMessage,
20
- ImapNamespaces,
21
- MailCredentials,
22
- } from "./types.js";
23
- import { MailConnectionError } from "./types.js";
24
-
25
- /**
26
- * Convert an envelope date into an ISO string, tolerating non-Date values.
27
- *
28
- * imapflow types `envelope.date` as a Date, but a malformed `Date:` header can
29
- * leave it as a string or other value at runtime. Returns "" for absent or
30
- * invalid dates so one bad header never poisons the whole fetch batch.
31
- */
32
- export const toIsoDateString = (value: unknown): string => {
33
- if (value instanceof Date) {
34
- return Number.isNaN(value.getTime()) ? "" : value.toISOString();
35
- }
36
- if (typeof value === "string" || typeof value === "number") {
37
- const date = new Date(value);
38
- return Number.isNaN(date.getTime()) ? "" : date.toISOString();
39
- }
40
- return "";
41
- };
42
-
43
- /**
44
- * Coerce an IMAP INTERNALDATE into a valid `Date`, tolerating malformed input.
45
- *
46
- * INTERNALDATE is the server's receive time and is almost always a clean Date.
47
- * But a broken server (or an unexpected imapflow runtime value) can yield a
48
- * string that `new Date` cannot parse, or a wholly unexpected type. Either way
49
- * `getTime()` would be `NaN`, which is the message's date-of-last-resort — it
50
- * is the fallback `parseHeaderDate` uses and is stored as the message's
51
- * `internalDate` — so a `NaN` here silently corrupts every downstream date and
52
- * gets rejected by ElectroDB, permanently failing the message.
53
- *
54
- * When the value cannot produce a valid Date we fall back to `now` so the
55
- * message still syncs with an approximate receive time rather than being
56
- * dropped. Returns `null` only for a genuinely absent value, which the caller
57
- * skips (a transient imapflow artifact, see #408 — not bad data).
58
- */
59
- export const toInternalDate = (value: unknown): Date | null => {
60
- if (value == null) return null;
61
- if (value instanceof Date) {
62
- return Number.isNaN(value.getTime()) ? new Date() : value;
63
- }
64
- if (typeof value === "string" || typeof value === "number") {
65
- const date = new Date(value);
66
- return Number.isNaN(date.getTime()) ? new Date() : date;
67
- }
68
- return new Date();
69
- };
70
-
71
- const FLAG_CRITERIA: Record<string, Record<string, boolean>> = {
72
- ALL: {},
73
- SEEN: { seen: true },
74
- UNSEEN: { seen: false },
75
- FLAGGED: { flagged: true },
76
- UNFLAGGED: { flagged: false },
77
- DELETED: { deleted: true },
78
- UNDELETED: { deleted: false },
79
- ANSWERED: { answered: true },
80
- UNANSWERED: { answered: false },
81
- DRAFT: { draft: true },
82
- UNDRAFT: { draft: false },
83
- };
84
-
85
- const VALUE_CRITERIA: Record<string, string> = {
86
- UID: "uid",
87
- FROM: "from",
88
- TO: "to",
89
- SUBJECT: "subject",
90
- SINCE: "since",
91
- BEFORE: "before",
92
- };
93
-
94
- const unsupported = (criterion: unknown): Error =>
95
- new Error(
96
- `Unsupported IMAP search criterion ${JSON.stringify(criterion)} — dropping it would widen the query to SEARCH ALL`,
97
- );
98
-
99
- const applyValueCriterion = (
100
- result: Record<string, unknown>,
101
- header: Map<string, string>,
102
- criterion: unknown[],
103
- ): void => {
104
- const [key, ...values] = criterion;
105
- if (typeof key !== "string") throw unsupported(criterion);
106
-
107
- if (key.toUpperCase() === "HEADER") {
108
- const [name, value] = values;
109
- if (values.length !== 2 || typeof name !== "string") {
110
- throw unsupported(criterion);
111
- }
112
- if (typeof value !== "string" && value !== true) {
113
- throw unsupported(criterion);
114
- }
115
- header.set(name.toLowerCase(), value === true ? "" : value);
116
- return;
117
- }
118
-
119
- const field = VALUE_CRITERIA[key.toUpperCase()];
120
- if (field === undefined || values.length !== 1) throw unsupported(criterion);
121
- result[field] = values[0];
122
- };
123
-
124
- /**
125
- * Compile node-imap style search criteria into an ImapFlow search object.
126
- *
127
- * A criterion this does not understand throws, and so does an empty criteria
128
- * list. imapflow reads an empty object as `SEARCH ALL`
129
- * (`imapflow/lib/commands/search.js`), so quietly skipping one turns a narrow
130
- * probe into "every message in the mailbox" — the reconcile and move paths then
131
- * act on a stranger's UID (#912). Header names accumulate in a Map so that a
132
- * name like `__proto__` lands as a query term rather than hitting the
133
- * prototype setter and vanishing.
134
- *
135
- * Values travel as structured tokens, never interpolated into a criterion
136
- * string, so a quote or a CRLF in a header value cannot alter the query.
137
- */
138
- export const convertSearchCriteria = (
139
- criteria: unknown[],
140
- ): Record<string, unknown> => {
141
- if (criteria.length === 0) {
142
- throw new Error(
143
- "Empty IMAP search criteria list — it would compile to SEARCH ALL",
144
- );
145
- }
146
-
147
- const result: Record<string, unknown> = {};
148
- const header = new Map<string, string>();
149
-
150
- for (const criterion of criteria) {
151
- if (typeof criterion === "string") {
152
- const flags = FLAG_CRITERIA[criterion.toUpperCase()];
153
- if (flags === undefined) throw unsupported(criterion);
154
- Object.assign(result, flags);
155
- continue;
156
- }
157
- if (Array.isArray(criterion)) {
158
- applyValueCriterion(result, header, criterion);
159
- continue;
160
- }
161
- throw unsupported(criterion);
162
- }
163
-
164
- if (header.size > 0) result.header = Object.fromEntries(header);
165
- return result;
166
- };
167
-
168
- /**
169
- * ImapFlow-based IMAP connection
170
- *
171
- * Drop-in replacement for ImapConnection using the ImapFlow library.
172
- * Benefits:
173
- * - Native async/await API (no callback wrapping)
174
- * - Built-in envelope parsing that works with mokapi
175
- * - Proper IDLE support with events
176
- * - Built-in TypeScript types
177
- * - Active maintenance
178
- */
179
- export class ImapFlowConnection {
180
- private client: ImapFlow | null = null;
181
- private _state: ImapConnectionState = "disconnected";
182
- private config: ImapConnectionConfig;
183
- private currentMailbox: string | null = null;
184
-
185
- constructor(config: ImapConnectionConfig) {
186
- this.config = config;
187
- }
188
-
189
- /**
190
- * Current connection state
191
- */
192
- get state(): ImapConnectionState {
193
- return this._state;
194
- }
195
-
196
- /**
197
- * Whether the connection is established and authenticated
198
- */
199
- get isConnected(): boolean {
200
- return this._state === "authenticated";
201
- }
202
-
203
- /**
204
- * Connect to the IMAP server with retry logic.
205
- *
206
- * Retries up to 3 times with exponential backoff (1s, 2s, 4s) on connection errors.
207
- * Authentication errors are not retried.
208
- */
209
- connect = async (): Promise<void> => {
210
- if (this.client) {
211
- throw new Error("Already connected");
212
- }
213
-
214
- const maxRetries = 3;
215
- const baseDelayMs = 1000;
216
-
217
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
218
- try {
219
- await this.attemptConnect();
220
- return;
221
- } catch (error) {
222
- const classified = classifyImapError(
223
- error,
224
- `${this.config.host}:${this.config.port}`,
225
- );
226
-
227
- // Auth errors won't fix themselves on retry — throw immediately.
228
- if (classified?.kind === "auth") {
229
- throw classified;
230
- }
231
-
232
- // Final attempt exhausted: throw the classified error if we have
233
- // one (network failures), otherwise let the raw error bubble.
234
- if (attempt === maxRetries) {
235
- throw classified ?? error;
236
- }
237
-
238
- // Network and unknown errors fall through to retry-with-backoff.
239
- // Exponential backoff: 1s, 2s, 4s
240
- const delay = baseDelayMs * 2 ** (attempt - 1);
241
- await this.sleep(delay);
242
-
243
- // Reset state for retry
244
- this.cleanup();
245
- }
246
- }
247
- };
248
-
249
- /**
250
- * Single connection attempt
251
- */
252
- private attemptConnect = async (): Promise<void> => {
253
- this._state = "connecting";
254
-
255
- // Determine TLS options
256
- // When secure is false, STARTTLS may still be used, so we need to handle self-signed certs
257
- const tlsOptions = this.config.tlsOptions
258
- ? {
259
- rejectUnauthorized: this.config.tlsOptions.rejectUnauthorized ?? true,
260
- }
261
- : !this.config.tls
262
- ? {
263
- // Allow self-signed certs when TLS is disabled (for STARTTLS)
264
- rejectUnauthorized: false,
265
- }
266
- : undefined;
267
-
268
- const auth = buildImapAuth(this.config.user, this.config.credentials);
269
-
270
- this.client = new ImapFlow({
271
- host: this.config.host,
272
- port: this.config.port,
273
- secure: this.config.tls,
274
- servername: this.config.tlsOptions?.servername,
275
- auth,
276
- tls: tlsOptions,
277
- // Disable auto IDLE to work around servers that don't handle IDLE correctly
278
- // This is equivalent to node-imap's forceNoop: true
279
- disableAutoIdle: true,
280
- logger: false, // Disable verbose logging
281
- });
282
-
283
- this.client.on("close", () => {
284
- this._state = "disconnected";
285
- this.client = null;
286
- this.currentMailbox = null;
287
- });
288
-
289
- this.client.on("error", () => {
290
- this._state = "error";
291
- });
292
-
293
- await this.client.connect();
294
- this._state = "authenticated";
295
- };
296
-
297
- /**
298
- * Cleanup client state for retry
299
- */
300
- private cleanup = (): void => {
301
- if (this.client) {
302
- this.client.close();
303
- this.client = null;
304
- }
305
- this._state = "disconnected";
306
- this.currentMailbox = null;
307
- };
308
-
309
- /**
310
- * Sleep for a specified duration
311
- */
312
- private sleep = (ms: number): Promise<void> =>
313
- new Promise((resolve) => setTimeout(resolve, ms));
314
-
315
- /**
316
- * Disconnect from the IMAP server
317
- */
318
- disconnect = async (): Promise<void> => {
319
- if (!this.client) {
320
- return;
321
- }
322
-
323
- const client = this.client;
324
-
325
- // Use a timeout to avoid hanging on logout
326
- const timeoutPromise = new Promise<void>((resolve) => {
327
- setTimeout(() => {
328
- // Force close if logout doesn't complete
329
- client.close();
330
- resolve();
331
- }, 5000);
332
- });
333
-
334
- try {
335
- await Promise.race([client.logout(), timeoutPromise]);
336
- } catch {
337
- // Ignore errors during disconnect, force close
338
- client.close();
339
- } finally {
340
- this._state = "disconnected";
341
- this.client = null;
342
- this.currentMailbox = null;
343
- }
344
- };
345
-
346
- /**
347
- * Get IMAP namespaces
348
- *
349
- * Note: ImapFlow doesn't expose namespaces directly, so we return
350
- * the default namespace. The mailbox listing works without explicit
351
- * namespace handling in ImapFlow.
352
- */
353
- getNamespaces = async (): Promise<ImapNamespaces> => {
354
- this.ensureConnected();
355
-
356
- // ImapFlow doesn't expose NAMESPACE info, return defaults
357
- // This is sufficient as ImapFlow handles namespaces internally
358
- return {
359
- personal: [{ prefix: "", delimiter: "/" }],
360
- other: [],
361
- shared: [],
362
- };
363
- };
364
-
365
- /**
366
- * List all mailboxes as a flat list, preserving original paths from server.
367
- * This avoids path corruption from split/join operations.
368
- *
369
- * @param nsPrefix - Optional namespace prefix to filter mailboxes
370
- */
371
- listMailboxes = async (nsPrefix?: string): Promise<FlatMailboxInfo[]> => {
372
- this.ensureConnected();
373
-
374
- const mailboxes = await this.client?.list();
375
- if (!mailboxes) {
376
- return [];
377
- }
378
- const result: FlatMailboxInfo[] = [];
379
-
380
- for (const mailbox of mailboxes) {
381
- // Filter by namespace prefix if provided
382
- if (nsPrefix !== undefined && !mailbox.path.startsWith(nsPrefix)) {
383
- continue;
384
- }
385
-
386
- // Extract name from path (last component)
387
- const pathParts = mailbox.path.split(mailbox.delimiter);
388
- const name = pathParts[pathParts.length - 1] || mailbox.path;
389
- const parentPath =
390
- pathParts.length > 1
391
- ? pathParts.slice(0, -1).join(mailbox.delimiter)
392
- : null;
393
-
394
- result.push({
395
- fullPath: mailbox.path, // Use original path from server
396
- name,
397
- delimiter: mailbox.delimiter,
398
- attributes: this.convertFlags(mailbox.flags),
399
- parentPath,
400
- });
401
- }
402
-
403
- return result;
404
- };
405
-
406
- /**
407
- * Convert ImapFlow flags Set to string array
408
- */
409
- private convertFlags = (flags: Set<string> | undefined): string[] => {
410
- if (!flags) return [];
411
- return Array.from(flags);
412
- };
413
-
414
- /**
415
- * Open a mailbox for reading
416
- *
417
- * @param mailboxPath - Full path to the mailbox (e.g., "INBOX", "[Gmail]/Sent")
418
- * @param readOnly - Whether to open read-only (default: true)
419
- */
420
- openBox = async (
421
- mailboxPath: string,
422
- readOnly = true,
423
- ): Promise<ImapBoxStatus> => {
424
- this.ensureConnected();
425
-
426
- // Idempotency guard: re-selecting the currently-open mailbox is a no-op
427
- // SELECT on the wire. `mailbox` exposes the live, already-selected box, so
428
- // we can return its status without paying for another round-trip — this is
429
- // what lets a batch issue one SELECT and many fetches on the same box.
430
- if (this.currentMailbox === mailboxPath && this.client?.mailbox) {
431
- return this.toBoxStatus(this.client.mailbox, readOnly);
432
- }
433
-
434
- const mailbox = await this.client?.mailboxOpen(mailboxPath, {
435
- readOnly,
436
- });
437
-
438
- if (!mailbox) {
439
- throw new Error(`Failed to open mailbox: ${mailboxPath}`);
440
- }
441
-
442
- this.currentMailbox = mailboxPath;
443
-
444
- return this.toBoxStatus(mailbox, readOnly);
445
- };
446
-
447
- /**
448
- * Build an ImapBoxStatus from an imapflow MailboxObject.
449
- */
450
- private toBoxStatus = (
451
- mailbox: {
452
- path: string;
453
- delimiter: string;
454
- flags: Set<string>;
455
- permanentFlags?: Set<string>;
456
- uidValidity: bigint;
457
- uidNext: number;
458
- exists: number;
459
- readOnly?: boolean;
460
- },
461
- readOnly: boolean,
462
- ): ImapBoxStatus => {
463
- const pathParts = mailbox.path.split(mailbox.delimiter);
464
- const name = pathParts[pathParts.length - 1] || mailbox.path;
465
-
466
- return {
467
- name,
468
- readOnly: mailbox.readOnly ?? readOnly,
469
- uidvalidity: Number(mailbox.uidValidity),
470
- uidnext: mailbox.uidNext,
471
- flags: Array.from(mailbox.flags || []),
472
- permFlags: Array.from(mailbox.permanentFlags || []),
473
- persistentUIDs: true, // ImapFlow assumes persistent UIDs
474
- messages: {
475
- total: mailbox.exists,
476
- new: 0, // ImapFlow doesn't provide unseen count on open
477
- },
478
- newKeywords: mailbox.permanentFlags?.has("\\*") ?? false,
479
- };
480
- };
481
-
482
- /**
483
- * Close the currently open mailbox
484
- *
485
- * @param _expunge - Whether to permanently remove deleted messages (not used in ImapFlow)
486
- */
487
- closeBox = async (_expunge = false): Promise<void> => {
488
- this.ensureConnected();
489
-
490
- if (this.currentMailbox) {
491
- await this.client?.mailboxClose();
492
- this.currentMailbox = null;
493
- }
494
- };
495
-
496
- /**
497
- * Search for messages, answering UIDs.
498
- *
499
- * imapflow answers a successful SEARCH with an array — empty when the
500
- * server matched nothing — and `false` when the command failed or no
501
- * mailbox was selected. Collapsing the two into `[]` makes a failed SEARCH
502
- * indistinguishable from an empty mailbox, and every caller here acts on
503
- * emptiness by deleting something: the cursor rebuild reads it as "every
504
- * local row is stale", and {@link isMessageGoneFromOpenMailbox} reads it as
505
- * "this message is gone". A failure therefore throws.
506
- */
507
- search = async (criteria: unknown[]): Promise<number[]> => {
508
- this.ensureConnected();
509
-
510
- if (!this.currentMailbox) {
511
- throw new Error("No mailbox selected");
512
- }
513
-
514
- const searchQuery = convertSearchCriteria(criteria);
515
-
516
- const result = await this.client?.search(searchQuery, { uid: true });
517
- if (!Array.isArray(result)) {
518
- throw new Error(`IMAP SEARCH failed in mailbox ${this.currentMailbox}`);
519
- }
520
- return result;
521
- };
522
-
523
- /**
524
- * Fetch messages by UID
525
- *
526
- * Uses ImapFlow's native envelope parsing which works correctly
527
- * with all IMAP servers including mokapi.
528
- */
529
- fetchMessages = async (uids: number[]): Promise<ImapMessage[]> => {
530
- this.ensureConnected();
531
-
532
- if (uids.length === 0) {
533
- return [];
534
- }
535
-
536
- return this.runMessageFetch(uids.join(","), {});
537
- };
538
-
539
- /**
540
- * True when this session negotiated CONDSTORE (RFC 7162) and the open
541
- * mailbox keeps persistent mod-sequences.
542
- *
543
- * Both halves matter. `enabled` reflects the ENABLE exchange, not merely
544
- * the advertised capability, and a server may still answer NOMODSEQ for an
545
- * individual mailbox — in which case RFC 7162 Section 3.1.2.2 requires it
546
- * to reject CHANGEDSINCE with a BAD response.
547
- */
548
- supportsCondstore = (): boolean => {
549
- const { client } = this;
550
- if (!client?.mailbox) return false;
551
- return client.enabled.has("CONDSTORE") && !client.mailbox.noModseq;
552
- };
553
-
554
- /**
555
- * FETCH everything in the open mailbox whose MODSEQ is strictly greater
556
- * than `sinceModseq` — new arrivals and metadata changes to messages
557
- * already synced, in one pass over the whole UID space.
558
- *
559
- * The CONDSTORE guard is load-bearing rather than defensive: imapflow drops
560
- * the CHANGEDSINCE modifier when the extension is unavailable, so the same
561
- * call would degrade into a fetch of every message in the mailbox.
562
- */
563
- fetchMessagesChangedSince = async (
564
- sinceModseq: bigint,
565
- ): Promise<ImapMessage[]> => {
566
- this.ensureConnected();
567
-
568
- if (!this.supportsCondstore()) {
569
- throw new Error(
570
- "CHANGEDSINCE requires CONDSTORE on both the session and the open mailbox",
571
- );
572
- }
573
-
574
- return this.runMessageFetch("1:*", { changedSince: sinceModseq });
575
- };
576
-
577
- /**
578
- * One UID FETCH over `range`, mapped to {@link ImapMessage}. The shared
579
- * body of {@link fetchMessages} and {@link fetchMessagesChangedSince} —
580
- * both need identical message shapes and differ only in what they select.
581
- */
582
- private runMessageFetch = async (
583
- range: string,
584
- options: { changedSince?: bigint },
585
- ): Promise<ImapMessage[]> => {
586
- const { client } = this;
587
-
588
- if (!client) {
589
- throw new Error("Not connected to IMAP server");
590
- }
591
-
592
- if (!this.currentMailbox) {
593
- throw new Error("No mailbox selected");
594
- }
595
-
596
- const messages: ImapMessage[] = [];
597
-
598
- const fetchIterator = client.fetch(
599
- range,
600
- {
601
- uid: true,
602
- flags: true,
603
- envelope: true,
604
- bodyStructure: true,
605
- internalDate: true,
606
- size: true,
607
- headers: ["references"],
608
- },
609
- { uid: true, ...options },
610
- );
611
-
612
- // Connection may have been lost - fetch returns an async iterable
613
- if (!fetchIterator) {
614
- throw new Error(`IMAP connection lost while fetching messages: ${range}`);
615
- }
616
-
617
- for await (const msg of fetchIterator) {
618
- // imapflow occasionally yields a row with undefined uid or internalDate
619
- // on back-to-back FETCH calls (e.g. after a body-fetch on the same UID).
620
- // See #408 for the investigation. This is the client library glitching,
621
- // not the message being malformed, so the row is dropped rather than
622
- // quarantined — recording it as a defective message would advance a
623
- // cursor past mail that is fine (issue #72).
624
- //
625
- // Dropping it is only safe because the caller treats a requested UID
626
- // with no row as unconsumed and holds its watermark below it. It used
627
- // to advance regardless, which is how a transient client glitch turned
628
- // into a message never fetched again.
629
- if (msg.uid == null || msg.internalDate == null) {
630
- continue;
631
- }
632
-
633
- // Coerce INTERNALDATE without ever throwing: a malformed value must not
634
- // abort the whole fetch batch. A bad value falls back to now; an absent
635
- // one cannot reach here, having been dropped above.
636
- const internalDate = toInternalDate(msg.internalDate) ?? new Date();
637
-
638
- // Parse References header if present
639
- const references = await this.parseReferencesHeader(msg.headers);
640
-
641
- messages.push({
642
- uid: msg.uid,
643
- seq: msg.seq,
644
- flags: Array.from(msg.flags || []),
645
- internalDate,
646
- size: msg.size ?? 0,
647
- envelope: this.convertEnvelope(msg.envelope),
648
- references,
649
- bodyStructure: msg.bodyStructure,
650
- ...(msg.modseq != null ? { modseq: msg.modseq.toString() } : {}),
651
- });
652
- }
653
-
654
- return messages;
655
- };
656
-
657
- /**
658
- * Cheap envelope-only FETCH for the UIDVALIDITY cursor rebuild (#1272):
659
- * UID + Message-ID + INTERNALDATE only — no flags, no BODYSTRUCTURE, no
660
- * References header, no body. Deliberately lighter than {@link
661
- * fetchMessages} so a rebuild pass over a large mailbox stays cheap (epic
662
- * #1281 invariant 6).
663
- */
664
- fetchEnvelopeSnapshots = async (
665
- uids: number[],
666
- ): Promise<ImapEnvelopeSnapshot[]> => {
667
- this.ensureConnected();
668
- const { client } = this;
669
-
670
- if (!client) {
671
- throw new Error("Not connected to IMAP server");
672
- }
673
-
674
- if (!this.currentMailbox) {
675
- throw new Error("No mailbox selected");
676
- }
677
-
678
- if (uids.length === 0) {
679
- return [];
680
- }
681
-
682
- const uidRange = uids.join(",");
683
-
684
- const fetchIterator = client.fetch(
685
- uidRange,
686
- { uid: true, envelope: true, internalDate: true },
687
- { uid: true },
688
- );
689
-
690
- if (!fetchIterator) {
691
- throw new Error(
692
- `IMAP connection lost while fetching envelope snapshots: ${uidRange}`,
693
- );
694
- }
695
-
696
- const snapshots: ImapEnvelopeSnapshot[] = [];
697
-
698
- for await (const msg of fetchIterator) {
699
- // Same defensive skip as fetchMessages (#408) — an occasional row
700
- // with no uid/internalDate must not abort the whole batch.
701
- if (msg.uid == null || msg.internalDate == null) {
702
- continue;
703
- }
704
-
705
- const internalDate = toInternalDate(msg.internalDate);
706
- if (internalDate === null) {
707
- continue;
708
- }
709
-
710
- snapshots.push({
711
- uid: msg.uid,
712
- messageId: msg.envelope?.messageId ?? "",
713
- internalDate,
714
- });
715
- }
716
-
717
- return snapshots;
718
- };
719
-
720
- /**
721
- * Fetch the full message body (RFC822 source) for a single message by UID.
722
- *
723
- * @param uid - The UID of the message to fetch
724
- * @returns The raw message body, or null when the UID FETCH matched no message
725
- */
726
- fetchMessageBody = async (uid: number): Promise<Buffer | null> => {
727
- this.ensureConnected();
728
- const { client } = this;
729
-
730
- if (!client) {
731
- throw new Error("Not connected to IMAP server");
732
- }
733
-
734
- if (!this.currentMailbox) {
735
- throw new Error("No mailbox selected");
736
- }
737
-
738
- const result = await client.download(String(uid), undefined, {
739
- uid: true,
740
- });
741
-
742
- if (result?.content) {
743
- const chunks: Buffer[] = [];
744
- for await (const chunk of result.content) {
745
- chunks.push(chunk);
746
- }
747
- return Buffer.concat(chunks);
748
- }
749
-
750
- if (!client.usable || !client.mailbox) {
751
- throw new MailConnectionError(
752
- "network",
753
- `IMAP connection lost while downloading message UID ${uid}`,
754
- );
755
- }
756
-
757
- return null;
758
- };
759
-
760
- /**
761
- * Fetch full message bodies (RFC822 source) for many UIDs in ONE pipelined
762
- * ranged UID FETCH on a single connection — the desktop-client pattern.
763
- *
764
- * Mirrors `fetchMessages`: one comma-joined UID range, one `client.fetch`,
765
- * one SELECT (the caller opens the box once for the whole batch). Yields
766
- * `{ uid, source }` as each message arrives so the caller can stream each
767
- * body straight to storage without buffering the whole batch.
768
- *
769
- * `source` is a readable stream over the message bytes — callers must
770
- * consume it (e.g. pipe to an upload) before requesting the next item.
771
- */
772
- async *fetchMessageBodies(
773
- uids: number[],
774
- ): AsyncGenerator<{ uid: number; source: Readable }> {
775
- this.ensureConnected();
776
- const { client } = this;
777
-
778
- if (!client) {
779
- throw new Error("Not connected to IMAP server");
780
- }
781
-
782
- if (!this.currentMailbox) {
783
- throw new Error("No mailbox selected");
784
- }
785
-
786
- if (uids.length === 0) {
787
- return;
788
- }
789
-
790
- const uidRange = uids.join(",");
791
-
792
- const fetchIterator = client.fetch(
793
- uidRange,
794
- { uid: true, source: true },
795
- { uid: true },
796
- );
797
-
798
- if (!fetchIterator) {
799
- throw new MailConnectionError(
800
- "network",
801
- `IMAP connection lost while fetching message bodies: ${uidRange}`,
802
- );
803
- }
804
-
805
- try {
806
- for await (const msg of fetchIterator) {
807
- // imapflow can yield a row with an undefined uid or no source on
808
- // back-to-back FETCH calls; skip it rather than crash the batch — the
809
- // caller treats any UID it never sees as failed and re-enqueues it.
810
- // See #408.
811
- if (msg.uid == null || msg.source == null) {
812
- continue;
813
- }
814
-
815
- yield { uid: msg.uid, source: Readable.from(msg.source) };
816
- }
817
- } catch (error) {
818
- // A mid-stream socket drop surfaces as imapflow's
819
- // `new Error("Connection closed")` with code `EConnectionClosed` — a
820
- // string the rest of the code never matches on. Re-throw it as the
821
- // typed MailConnectionError so the caller's fail-fast path triggers and
822
- // re-enqueues the not-yet-yielded UIDs instead of failing the record.
823
- throw (
824
- classifyImapError(error, `${this.config.host}:${this.config.port}`) ??
825
- error
826
- );
827
- }
828
- }
829
-
830
- /**
831
- * Parse the References header from IMAP headers buffer.
832
- * Returns an array of Message-IDs, with the first being the thread root.
833
- */
834
- private parseReferencesHeader = async (
835
- headers: Buffer | undefined,
836
- ): Promise<string[] | undefined> => {
837
- if (!headers) return undefined;
838
-
839
- // Raw header bytes are untrusted. simpleParser is tolerant but can still
840
- // throw on sufficiently broken input; a throw here would abort the whole
841
- // fetch batch. Missing references just means "no thread parent", so on any
842
- // parse failure treat the message as a thread root rather than crashing.
843
- const parsed = await simpleParser(headers).catch(() => null);
844
-
845
- if (!parsed?.references) return undefined;
846
-
847
- // mailparser returns references as string or array
848
- if (Array.isArray(parsed.references)) {
849
- return parsed.references.length > 0 ? parsed.references : undefined;
850
- }
851
-
852
- // Single reference as string
853
- return [parsed.references];
854
- };
855
-
856
- /**
857
- * Convert ImapFlow envelope to our ImapEnvelope format
858
- */
859
- private convertEnvelope = (
860
- envelope:
861
- | {
862
- date?: Date;
863
- subject?: string;
864
- from?: Array<{ name?: string; address?: string }>;
865
- sender?: Array<{ name?: string; address?: string }>;
866
- replyTo?: Array<{ name?: string; address?: string }>;
867
- to?: Array<{ name?: string; address?: string }>;
868
- cc?: Array<{ name?: string; address?: string }>;
869
- bcc?: Array<{ name?: string; address?: string }>;
870
- inReplyTo?: string;
871
- messageId?: string;
872
- }
873
- | undefined,
874
- ): ImapMessage["envelope"] => {
875
- // An absent ENVELOPE stays absent. Synthesising an empty one here made
876
- // every `if (!msg.envelope)` guard downstream unreachable, so a FETCH row
877
- // that carried no envelope was saved as a row with no sender, no subject
878
- // and no date, keyed by the `generated:` fallback — indistinguishable
879
- // from a real message. The field is optional on `ImapMessage` precisely
880
- // so callers can see the difference (issue #72).
881
- if (!envelope) return undefined;
882
-
883
- const convertAddresses = (
884
- addrs?: Array<{ name?: string; address?: string }>,
885
- ): ImapAddress[] => {
886
- if (!addrs) return [];
887
- return addrs
888
- .filter((a) => a.address)
889
- .map((a) => {
890
- const [mailbox, host] = (a.address || "").split("@");
891
- return {
892
- name: a.name || undefined,
893
- mailbox: mailbox || "",
894
- host: host || "",
895
- };
896
- });
897
- };
898
-
899
- return {
900
- date: toIsoDateString(envelope.date),
901
- subject: envelope.subject ?? "",
902
- from: convertAddresses(envelope.from),
903
- sender: convertAddresses(envelope.sender),
904
- replyTo: convertAddresses(envelope.replyTo),
905
- to: convertAddresses(envelope.to),
906
- cc: convertAddresses(envelope.cc),
907
- bcc: convertAddresses(envelope.bcc),
908
- inReplyTo: envelope.inReplyTo ?? "",
909
- messageId: envelope.messageId ?? "",
910
- };
911
- };
912
-
913
- /**
914
- * Add flags to messages by UID.
915
- * Requires mailbox to be open (not read-only).
916
- *
917
- * @param uids - Array of message UIDs
918
- * @param flags - Array of flags to add (e.g., ["\\Seen", "\\Flagged"])
919
- */
920
- addFlags = async (uids: number[], flags: string[]): Promise<void> => {
921
- this.ensureConnected();
922
- const { client } = this;
923
-
924
- if (!client) {
925
- throw new Error("Not connected to IMAP server");
926
- }
927
-
928
- if (!this.currentMailbox) {
929
- throw new Error("No mailbox selected");
930
- }
931
-
932
- if (uids.length === 0 || flags.length === 0) {
933
- return;
934
- }
935
-
936
- const uidRange = uids.join(",");
937
- await client.messageFlagsAdd(uidRange, flags, { uid: true });
938
- };
939
-
940
- /**
941
- * Remove flags from messages by UID.
942
- * Requires mailbox to be open (not read-only).
943
- *
944
- * @param uids - Array of message UIDs
945
- * @param flags - Array of flags to remove (e.g., ["\\Seen", "\\Flagged"])
946
- */
947
- removeFlags = async (uids: number[], flags: string[]): Promise<void> => {
948
- this.ensureConnected();
949
- const { client } = this;
950
-
951
- if (!client) {
952
- throw new Error("Not connected to IMAP server");
953
- }
954
-
955
- if (!this.currentMailbox) {
956
- throw new Error("No mailbox selected");
957
- }
958
-
959
- if (uids.length === 0 || flags.length === 0) {
960
- return;
961
- }
962
-
963
- const uidRange = uids.join(",");
964
- await client.messageFlagsRemove(uidRange, flags, { uid: true });
965
- };
966
-
967
- /**
968
- * Replace all flags on messages by UID.
969
- * Requires mailbox to be open (not read-only).
970
- *
971
- * @param uids - Array of message UIDs
972
- * @param flags - Array of flags to set (replaces all existing flags)
973
- */
974
- setFlags = async (uids: number[], flags: string[]): Promise<void> => {
975
- this.ensureConnected();
976
- const { client } = this;
977
-
978
- if (!client) {
979
- throw new Error("Not connected to IMAP server");
980
- }
981
-
982
- if (!this.currentMailbox) {
983
- throw new Error("No mailbox selected");
984
- }
985
-
986
- if (uids.length === 0) {
987
- return;
988
- }
989
-
990
- const uidRange = uids.join(",");
991
- await client.messageFlagsSet(uidRange, flags, { uid: true });
992
- };
993
-
994
- /**
995
- * Create a new mailbox.
996
- *
997
- * @param path - Full path of the mailbox to create (e.g., "Projects/ClientA")
998
- * @returns Object with path and whether it was created (false if already exists)
999
- */
1000
- createMailbox = async (
1001
- path: string,
1002
- ): Promise<{ path: string; created: boolean }> => {
1003
- this.ensureConnected();
1004
- const { client } = this;
1005
-
1006
- if (!client) {
1007
- throw new Error("Not connected to IMAP server");
1008
- }
1009
-
1010
- const result = await client.mailboxCreate(path);
1011
- return {
1012
- path: result.path,
1013
- created: result.created ?? true,
1014
- };
1015
- };
1016
-
1017
- /**
1018
- * Delete a mailbox.
1019
- *
1020
- * @param path - Full path of the mailbox to delete
1021
- * @returns Object with the deleted path
1022
- */
1023
- deleteMailbox = async (path: string): Promise<{ path: string }> => {
1024
- this.ensureConnected();
1025
- const { client } = this;
1026
-
1027
- if (!client) {
1028
- throw new Error("Not connected to IMAP server");
1029
- }
1030
-
1031
- // Cannot delete INBOX
1032
- if (path.toUpperCase() === "INBOX") {
1033
- throw new Error("Cannot delete INBOX");
1034
- }
1035
-
1036
- const result = await client.mailboxDelete(path);
1037
- return { path: result.path };
1038
- };
1039
-
1040
- /**
1041
- * Rename a mailbox.
1042
- *
1043
- * @param oldPath - Current path of the mailbox
1044
- * @param newPath - New path for the mailbox
1045
- * @returns Object with old and new paths
1046
- */
1047
- renameMailbox = async (
1048
- oldPath: string,
1049
- newPath: string,
1050
- ): Promise<{ path: string; newPath: string }> => {
1051
- this.ensureConnected();
1052
- const { client } = this;
1053
-
1054
- if (!client) {
1055
- throw new Error("Not connected to IMAP server");
1056
- }
1057
-
1058
- const result = await client.mailboxRename(oldPath, newPath);
1059
- // `mailboxRename` resolves `undefined` when the connection is not
1060
- // AUTHENTICATED or SELECTED — RENAME was never issued. Dereferencing it
1061
- // raised a `TypeError` where the settle now reads the resolved path, and a
1062
- // `TypeError` is not an outcome the rename settle can classify.
1063
- if (!result) {
1064
- throw new Error(
1065
- `Rename of "${oldPath}" was not issued: the IMAP connection is not ready`,
1066
- );
1067
- }
1068
- return {
1069
- path: result.path,
1070
- newPath: result.newPath,
1071
- };
1072
- };
1073
-
1074
- /**
1075
- * Subscribe to a mailbox.
1076
- *
1077
- * @param path - Full path of the mailbox to subscribe to
1078
- */
1079
- subscribeMailbox = async (path: string): Promise<void> => {
1080
- this.ensureConnected();
1081
- const { client } = this;
1082
-
1083
- if (!client) {
1084
- throw new Error("Not connected to IMAP server");
1085
- }
1086
-
1087
- await client.mailboxSubscribe(path);
1088
- };
1089
-
1090
- /**
1091
- * Unsubscribe from a mailbox.
1092
- *
1093
- * @param path - Full path of the mailbox to unsubscribe from
1094
- */
1095
- unsubscribeMailbox = async (path: string): Promise<void> => {
1096
- this.ensureConnected();
1097
- const { client } = this;
1098
-
1099
- if (!client) {
1100
- throw new Error("Not connected to IMAP server");
1101
- }
1102
-
1103
- await client.mailboxUnsubscribe(path);
1104
- };
1105
-
1106
- /**
1107
- * Move messages to another mailbox.
1108
- * Uses ImapFlow's messageMove which wraps UID MOVE command.
1109
- * Returns mapping of source UIDs to destination UIDs from COPYUID response.
1110
- *
1111
- * @param uids - Array of message UIDs to move
1112
- * @param destination - Destination mailbox path
1113
- * @returns Object with destination path, uidValidity, and uidMap
1114
- */
1115
- moveMessages = async (
1116
- uids: number[],
1117
- destination: string,
1118
- ): Promise<{
1119
- destination: string;
1120
- uidValidity: number;
1121
- uidMap: Map<number, number>;
1122
- }> => {
1123
- this.ensureConnected();
1124
- const { client } = this;
1125
-
1126
- if (!client) {
1127
- throw new Error("Not connected to IMAP server");
1128
- }
1129
-
1130
- if (!this.currentMailbox) {
1131
- throw new Error("No mailbox selected");
1132
- }
1133
-
1134
- if (uids.length === 0) {
1135
- return { destination, uidValidity: 0, uidMap: new Map() };
1136
- }
1137
-
1138
- const uidRange = uids.join(",");
1139
- const result = await client.messageMove(uidRange, destination, {
1140
- uid: true,
1141
- });
1142
-
1143
- // messageMove returns false if no messages were moved
1144
- if (result === false) {
1145
- return { destination, uidValidity: 0, uidMap: new Map() };
1146
- }
1147
-
1148
- return {
1149
- destination: result.destination,
1150
- uidValidity: Number(result.uidValidity ?? 0),
1151
- uidMap: result.uidMap ?? new Map(),
1152
- };
1153
- };
1154
-
1155
- /**
1156
- * Copy messages to another mailbox.
1157
- * Uses ImapFlow's messageCopy which wraps UID COPY command.
1158
- * Returns mapping of source UIDs to destination UIDs from COPYUID response.
1159
- *
1160
- * @param uids - Array of message UIDs to copy
1161
- * @param destination - Destination mailbox path
1162
- * @returns Object with destination path, uidValidity, and uidMap
1163
- */
1164
- copyMessages = async (
1165
- uids: number[],
1166
- destination: string,
1167
- ): Promise<{
1168
- destination: string;
1169
- uidValidity: number;
1170
- uidMap: Map<number, number>;
1171
- }> => {
1172
- this.ensureConnected();
1173
- const { client } = this;
1174
-
1175
- if (!client) {
1176
- throw new Error("Not connected to IMAP server");
1177
- }
1178
-
1179
- if (!this.currentMailbox) {
1180
- throw new Error("No mailbox selected");
1181
- }
1182
-
1183
- if (uids.length === 0) {
1184
- return { destination, uidValidity: 0, uidMap: new Map() };
1185
- }
1186
-
1187
- const uidRange = uids.join(",");
1188
- const result = await client.messageCopy(uidRange, destination, {
1189
- uid: true,
1190
- });
1191
-
1192
- // messageCopy returns false if no messages were copied
1193
- if (result === false) {
1194
- return { destination, uidValidity: 0, uidMap: new Map() };
1195
- }
1196
-
1197
- return {
1198
- destination: result.destination,
1199
- uidValidity: Number(result.uidValidity ?? 0),
1200
- uidMap: result.uidMap ?? new Map(),
1201
- };
1202
- };
1203
-
1204
- /**
1205
- * Permanently delete messages (mark \Deleted + expunge).
1206
- * Uses ImapFlow's messageDelete which wraps STORE +FLAGS.SILENT \Deleted
1207
- * followed by UID EXPUNGE.
1208
- *
1209
- * @param uids - Array of message UIDs to delete
1210
- * @returns Number of messages deleted
1211
- */
1212
- deleteMessages = async (uids: number[]): Promise<number> => {
1213
- this.ensureConnected();
1214
- const { client } = this;
1215
-
1216
- if (!client) {
1217
- throw new Error("Not connected to IMAP server");
1218
- }
1219
-
1220
- if (!this.currentMailbox) {
1221
- throw new Error("No mailbox selected");
1222
- }
1223
-
1224
- if (uids.length === 0) {
1225
- return 0;
1226
- }
1227
-
1228
- const uidRange = uids.join(",");
1229
- const result = await client.messageDelete(uidRange, { uid: true });
1230
-
1231
- // messageDelete returns boolean or deleted count
1232
- if (typeof result === "number") {
1233
- return result;
1234
- }
1235
- return result ? uids.length : 0;
1236
- };
1237
-
1238
- /**
1239
- * Get mailbox status.
1240
- *
1241
- * Message/unseen counts come from the IMAP STATUS command (no SELECT). The
1242
- * \Deleted count is not part of imapflow's typed STATUS query (there is no
1243
- * IMAP4rev2 STATUS=DELETED in the API), so it is derived by selecting the
1244
- * mailbox read-only and running SEARCH \Deleted — a projection re-read from
1245
- * the server, never a locally computed value.
1246
- *
1247
- * @param mailboxPath - Full path to the mailbox
1248
- * @returns Mailbox status including unseen and deleted counts
1249
- */
1250
- getMailboxStatus = async (
1251
- mailboxPath: string,
1252
- ): Promise<ImapMailboxStatus> => {
1253
- this.ensureConnected();
1254
- const { client } = this;
1255
-
1256
- if (!client) {
1257
- throw new Error("Not connected to IMAP server");
1258
- }
1259
-
1260
- const status = await client.status(mailboxPath, {
1261
- messages: true,
1262
- recent: true,
1263
- unseen: true,
1264
- uidNext: true,
1265
- uidValidity: true,
1266
- highestModseq: true,
1267
- });
1268
-
1269
- const deletedCount = await this.countDeleted(mailboxPath);
1270
-
1271
- return {
1272
- messages: status.messages ?? 0,
1273
- recent: status.recent ?? 0,
1274
- unseen: status.unseen ?? 0,
1275
- uidNext: status.uidNext ?? 0,
1276
- uidValidity: Number(status.uidValidity ?? 0),
1277
- highestModseq: (status.highestModseq ?? 0n).toString(),
1278
- deletedCount,
1279
- };
1280
- };
1281
-
1282
- /**
1283
- * Count messages flagged \Deleted but not yet expunged.
1284
- *
1285
- * Selects the mailbox read-only (no state change) and counts SEARCH \Deleted
1286
- * hits. Read-only SELECT is idempotent against an already-open mailbox.
1287
- */
1288
- private countDeleted = async (mailboxPath: string): Promise<number> => {
1289
- await this.openBox(mailboxPath, true);
1290
- const deletedUids = await this.search(["DELETED"]);
1291
- return deletedUids.length;
1292
- };
1293
-
1294
- /**
1295
- * Append a message to a mailbox.
1296
- * Used primarily for testing to seed messages.
1297
- *
1298
- * @param mailbox - Mailbox path to append to
1299
- * @param message - RFC 822 message content
1300
- * @param flags - Optional flags to set on the message
1301
- * @returns Object with destination path, uidValidity, and uid of the appended message
1302
- */
1303
- append = async (
1304
- mailbox: string,
1305
- message: string | Buffer,
1306
- flags?: string[],
1307
- ): Promise<{
1308
- destination: string;
1309
- uidValidity: number;
1310
- uid: number;
1311
- }> => {
1312
- this.ensureConnected();
1313
- const { client } = this;
1314
-
1315
- if (!client) {
1316
- throw new Error("Not connected to IMAP server");
1317
- }
1318
-
1319
- const result = await client.append(mailbox, message, flags);
1320
-
1321
- // append returns false if the message could not be appended
1322
- if (result === false) {
1323
- throw new Error(`Failed to append message to ${mailbox}`);
1324
- }
1325
-
1326
- return {
1327
- destination: result.destination,
1328
- uidValidity: Number(result.uidValidity ?? 0),
1329
- uid: result.uid ?? 0,
1330
- };
1331
- };
1332
-
1333
- /**
1334
- * Ensure the connection is established
1335
- */
1336
- private ensureConnected = (): void => {
1337
- if (!this.client || this._state !== "authenticated") {
1338
- throw new Error("Not connected to IMAP server");
1339
- }
1340
- };
1341
- }
1342
-
1343
- /**
1344
- * Create an ImapFlow connection from account data using password credentials.
1345
- */
1346
- export const createImapFlowConnectionFromAccount = (
1347
- account: {
1348
- imapHost: string;
1349
- imapPort: number;
1350
- imapTls: boolean;
1351
- username: string;
1352
- },
1353
- password: string,
1354
- ): ImapFlowConnection => {
1355
- return new ImapFlowConnection({
1356
- host: account.imapHost,
1357
- port: account.imapPort,
1358
- tls: account.imapTls,
1359
- user: account.username,
1360
- credentials: { kind: "password", password },
1361
- });
1362
- };
1363
-
1364
- /**
1365
- * Create an ImapFlow connection using a credentials union (password or OAuth access token).
1366
- */
1367
- export const createImapFlowConnectionWithCredentials = (
1368
- account: {
1369
- imapHost: string;
1370
- imapPort: number;
1371
- imapTls: boolean;
1372
- username: string;
1373
- },
1374
- credentials: import("./types.js").MailCredentials,
1375
- ): ImapFlowConnection => {
1376
- return new ImapFlowConnection({
1377
- host: account.imapHost,
1378
- port: account.imapPort,
1379
- tls: account.imapTls,
1380
- user: account.username,
1381
- credentials,
1382
- });
1383
- };
1384
-
1385
- /**
1386
- * Build the imapflow auth object from mail credentials.
1387
- *
1388
- * IMPORTANT: never include access-token values in error messages.
1389
- */
1390
- const buildImapAuth = (
1391
- user: string,
1392
- credentials: MailCredentials,
1393
- ): { user: string; pass: string } | { user: string; accessToken: string } => {
1394
- if (credentials.kind === "password") {
1395
- return { user, pass: credentials.password };
1396
- }
1397
- if (credentials.kind === "accessToken") {
1398
- return { user, accessToken: credentials.accessToken };
1399
- }
1400
- // Exhaustiveness check — fails to compile if a new credential kind is added
1401
- // without handling it here.
1402
- const _exhaustive: never = credentials;
1403
- throw new Error(`Unknown credential kind: ${JSON.stringify(_exhaustive)}`);
1404
- };
1405
-
1406
- /**
1407
- * The server's own words for the failure: the text of the tagged NO/BAD, which
1408
- * imapflow hangs off the error as `responseText`. It is the server's side of
1409
- * the exchange only — never the command we sent, so no credential can ride out
1410
- * on it. "SmtpClientAuthentication is disabled" and "User is authenticated but
1411
- * not connected" arrive this way, and neither is fixed by re-authenticating, so
1412
- * dropping the text leaves the account card telling the user to do the one
1413
- * thing that cannot help.
1414
- */
1415
- const serverResponseText = (error: Error): string => {
1416
- const text = (error as { responseText?: unknown }).responseText;
1417
- return typeof text === "string" ? text.trim() : "";
1418
- };
1419
-
1420
- /**
1421
- * Classify a raw IMAP error into a MailConnectionError, or return null if
1422
- * the error is not recognisable (let it bubble as-is).
1423
- *
1424
- * IMPORTANT: never include access-token values in error messages.
1425
- */
1426
- const classifyImapError = (
1427
- error: unknown,
1428
- endpoint?: string,
1429
- ): MailConnectionError | null => {
1430
- if (!(error instanceof Error)) {
1431
- return null;
1432
- }
1433
-
1434
- const msg = error.message;
1435
- const code = (error as NodeJS.ErrnoException).code ?? "";
1436
-
1437
- // Authentication failures
1438
- if (
1439
- msg.includes("Invalid credentials") ||
1440
- msg.includes("Authentication failed") ||
1441
- msg.includes("AUTHENTICATIONFAILED") ||
1442
- msg.includes("AUTHENTICATE") ||
1443
- (error as { authenticationFailed?: boolean }).authenticationFailed === true
1444
- ) {
1445
- const serverText = serverResponseText(error);
1446
- return new MailConnectionError(
1447
- "auth",
1448
- serverText
1449
- ? `IMAP authentication failed: ${serverText}`
1450
- : "IMAP authentication failed",
1451
- );
1452
- }
1453
-
1454
- // Network-level failures — NEVER include the original message as it may
1455
- // echo back tokens in some server implementations. The endpoint (host:port)
1456
- // is safe and makes DNS/connect failures self-describing.
1457
- if (
1458
- code === "ECONNREFUSED" ||
1459
- code === "ETIMEDOUT" ||
1460
- code === "ENOTFOUND" ||
1461
- code === "ECONNRESET" ||
1462
- code === "EHOSTUNREACH" ||
1463
- // imapflow raises these when the socket is gone mid-command.
1464
- code === "EConnectionClosed" ||
1465
- code === "NoConnection"
1466
- ) {
1467
- const where = endpoint ? ` (${endpoint})` : "";
1468
- return new MailConnectionError(
1469
- "network",
1470
- `IMAP connection failed: ${code}${where}`,
1471
- );
1472
- }
1473
-
1474
- return null;
1475
- };