@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
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Connection testing utilities for IMAP and SMTP servers
3
+ *
4
+ * Used to validate credentials before saving account configuration.
5
+ */
6
+ import { createConnection as createNetConnection } from "node:net";
7
+ import { connect as tlsConnect } from "node:tls";
8
+ import { createTransport } from "nodemailer";
9
+ import { MailConnectionError } from "./types.js";
10
+ // TODO(#468): Wire OAuth testConnection once authType/oauthRefreshTokenHash are
11
+ // available on Account. For OAuth accounts, mint a token via MailOAuthService
12
+ // before calling testImapConnection / testSmtpConnection, and map
13
+ // RefreshTokenError(reauth-required) to MailConnectionError("auth", ...).
14
+ const TEST_TIMEOUT_MS = 12_000;
15
+ /**
16
+ * Build nodemailer auth object from credentials.
17
+ */
18
+ const buildSmtpAuth = (user, credentials) => {
19
+ if (credentials.kind === "password") {
20
+ return { user, pass: credentials.password };
21
+ }
22
+ return {
23
+ type: "OAuth2",
24
+ user,
25
+ accessToken: credentials.accessToken,
26
+ };
27
+ };
28
+ /**
29
+ * Test IMAP connection with provided credentials.
30
+ *
31
+ * Uses a raw socket to send AUTH PLAIN directly — bypasses the CAPABILITY
32
+ * round-trip that IMAP clients typically perform first, avoiding deadlocks
33
+ * with servers that pipeline their tagged responses.
34
+ *
35
+ * Supports plaintext, direct TLS (secure: true), and STARTTLS (startTls: true).
36
+ */
37
+ export const testImapConnection = async (config) => {
38
+ if (config.credentials.kind !== "password") {
39
+ return { success: false, error: "OAuth not supported for connection test" };
40
+ }
41
+ const { user, credentials } = config;
42
+ const authPlain = Buffer.from(`\x00${user}\x00${credentials.password}`).toString("base64");
43
+ return new Promise((resolve) => {
44
+ let settled = false;
45
+ const done = (result) => {
46
+ if (settled)
47
+ return;
48
+ settled = true;
49
+ clearTimeout(timeoutId);
50
+ resolve(result);
51
+ };
52
+ const timeoutId = setTimeout(() => done({ success: false, error: "Connection timed out" }), TEST_TIMEOUT_MS);
53
+ let buf = "";
54
+ let phase = "greeting";
55
+ // biome-ignore lint/suspicious/noExplicitAny: socket type varies between net.Socket and tls.TLSSocket
56
+ let sock;
57
+ const onData = (chunk) => {
58
+ buf += chunk.toString();
59
+ const lines = buf.split("\r\n");
60
+ buf = lines.pop() ?? "";
61
+ for (const line of lines) {
62
+ if (phase === "greeting" && /^\* OK/i.test(line)) {
63
+ if (config.startTls) {
64
+ phase = "starttls";
65
+ sock.write("A001 STARTTLS\r\n");
66
+ }
67
+ else {
68
+ phase = "auth";
69
+ sock.write(`A001 AUTHENTICATE PLAIN ${authPlain}\r\n`);
70
+ }
71
+ }
72
+ else if (phase === "starttls" && /^A001 OK/i.test(line)) {
73
+ phase = "tls-upgrade";
74
+ // Upgrade socket to TLS in place
75
+ const tlsSock = tlsConnect({
76
+ socket: sock,
77
+ host: config.host,
78
+ rejectUnauthorized: false,
79
+ });
80
+ tlsSock.on("data", onData);
81
+ tlsSock.on("error", onError);
82
+ tlsSock.once("secureConnect", () => {
83
+ phase = "auth";
84
+ sock = tlsSock;
85
+ sock.write(`A002 AUTHENTICATE PLAIN ${authPlain}\r\n`);
86
+ });
87
+ }
88
+ else if (phase === "auth" &&
89
+ (/^A00[12] OK/i.test(line) || /^A001 OK/i.test(line))) {
90
+ phase = "done";
91
+ sock.write("A099 LOGOUT\r\n");
92
+ done({ success: true });
93
+ }
94
+ else if (phase === "auth" &&
95
+ (/^A00[12] NO/i.test(line) || /^A00[12] BAD/i.test(line))) {
96
+ phase = "done";
97
+ sock.destroy();
98
+ done({ success: false, error: "Authentication failed" });
99
+ }
100
+ }
101
+ };
102
+ const onError = (err) => {
103
+ done({ success: false, error: err.message });
104
+ };
105
+ if (config.secure) {
106
+ sock = tlsConnect({
107
+ host: config.host,
108
+ port: config.port,
109
+ rejectUnauthorized: false,
110
+ });
111
+ }
112
+ else {
113
+ sock = createNetConnection({ host: config.host, port: config.port });
114
+ }
115
+ sock.on("data", onData);
116
+ sock.on("error", onError);
117
+ });
118
+ };
119
+ /**
120
+ * Test SMTP connection with provided credentials
121
+ *
122
+ * Uses nodemailer's verify() method to test authentication.
123
+ */
124
+ export const testSmtpConnection = async (config) => {
125
+ const transport = createTransport({
126
+ host: config.host,
127
+ port: config.port,
128
+ secure: config.secure,
129
+ auth: buildSmtpAuth(config.user, config.credentials),
130
+ connectionTimeout: TEST_TIMEOUT_MS,
131
+ greetingTimeout: TEST_TIMEOUT_MS,
132
+ socketTimeout: TEST_TIMEOUT_MS,
133
+ });
134
+ const attempt = transport
135
+ .verify()
136
+ .then(() => ({ success: true }))
137
+ .catch((error) => {
138
+ if (error instanceof MailConnectionError) {
139
+ return { success: false, error: error.message };
140
+ }
141
+ return {
142
+ success: false,
143
+ error: error instanceof Error ? error.message : "Connection failed",
144
+ };
145
+ })
146
+ .finally(() => transport.close());
147
+ const timeout = new Promise((resolve) => setTimeout(() => {
148
+ transport.close();
149
+ resolve({ success: false, error: "Connection timed out" });
150
+ }, TEST_TIMEOUT_MS));
151
+ return Promise.race([attempt, timeout]);
152
+ };
@@ -0,0 +1,94 @@
1
+ import type { IMailboxRepository, IMessageRepository, MailboxItem, MessageItem } from "@remit/data-ports";
2
+ import type { IImapConnection } from "./types.js";
3
+ export interface ExternalMoveLogger {
4
+ info(obj: Record<string, unknown>, msg: string): void;
5
+ warn(obj: Record<string, unknown>, msg: string): void;
6
+ }
7
+ /**
8
+ * Whether a sighting of an already-stored message in this mailbox CONTESTS the
9
+ * placement the row holds (#859). Contesting is not deciding: the sighting is a
10
+ * candidate move, and {@link confirmDepartures} is what turns it into one.
11
+ *
12
+ * Three sightings do not even contest. The same mailbox is the row standing
13
+ * where it already is. Gmail's All Mail, Starred and Important hold every
14
+ * message that also lives in a real folder, so they are views rather than filing
15
+ * locations; following one would empty every other folder's listing. And a row
16
+ * whose own mutation has not settled is in flight, not stale
17
+ * (imap-mutations R1): the server's answer predates the move reader is still
18
+ * waiting on, so taking it would undo the optimistic write. The sync path
19
+ * reconciles rather than waits (imap-mutations R2): it never blocks on a
20
+ * mutation, it re-reads the row on the next sighting after the move settles.
21
+ *
22
+ * In flight is `status`, and `status` alone (#1096, imap-mutations R3). Every
23
+ * outbound mutation writes `moving` or `deleting` and only settling returns the
24
+ * row to `active`, so `active` is precisely the set nothing is coming for —
25
+ * whatever `syncStatus` says about how the row got there. A row put back by
26
+ * `abandonDelete` carries `abandoned`, and it needs this repair more than any
27
+ * other row does: nothing routine settles it, and reader shares its mailboxes,
28
+ * so refusing it left a message whose delete was refused unable to follow a
29
+ * move the user then made in another client.
30
+ */
31
+ export declare const sightingContestsPlacement: (mailbox: MailboxItem, message: Pick<MessageItem, "mailboxId" | "status">) => boolean;
32
+ /**
33
+ * The exact placement a verdict was reached for. The probe reads the row outside
34
+ * the transaction that acts on it, so the verdict names the mailbox and uid it
35
+ * was reached for; a row that moved in between matches no verdict and is left
36
+ * unsettled.
37
+ */
38
+ export declare const placementKey: (messageId: string, mailboxId: string, uid: number) => string;
39
+ export interface DepartureVerdicts {
40
+ /**
41
+ * Placements the source folder no longer holds. A sighting elsewhere of one
42
+ * of these is a move, and re-points the row.
43
+ */
44
+ departed: Set<string>;
45
+ /**
46
+ * Placements a verdict was reached for at all, departed or still held. A
47
+ * contested sighting outside this set was not decided — the source could not
48
+ * be asked, or the row has moved since it was — and its uid is held back from
49
+ * the watermark so the next round serves the sighting again. Without that a
50
+ * single unanswerable round would leave the row mispointed until a cursor
51
+ * rebuild, which is the silent never-repair this whole change is about.
52
+ */
53
+ settled: Set<string>;
54
+ /**
55
+ * Message ids whose sighting has to go through the save pipeline: departed,
56
+ * or contested with no verdict. Every other sighting changes no placement.
57
+ */
58
+ needsSave: Set<string>;
59
+ }
60
+ export interface DepartureProbeDeps {
61
+ connection: Pick<IImapConnection, "openBox" | "search">;
62
+ mailboxService: Pick<IMailboxRepository, "get">;
63
+ messageService: Pick<IMessageRepository, "get">;
64
+ log: ExternalMoveLogger;
65
+ }
66
+ /**
67
+ * Which of this batch's messages have really left the folder their row points at
68
+ * (#1146).
69
+ *
70
+ * A message sighted in a second real folder has not necessarily moved. Living in
71
+ * two folders at once is ordinary mail: a Gmail user label, a Sieve `fileinto`
72
+ * beside a `keep`, a Sent copy a mailing list echoes back. Reading every such
73
+ * sighting as a move takes the message out of the folder it is still in, and
74
+ * whichever folder the sync round enumerates last wins — which is how a labelled
75
+ * message disappeared from the Inbox on the next sync.
76
+ *
77
+ * What separates a move from a copy is the source folder: a move empties it, a
78
+ * copy leaves it alone. So the source is asked, and only a source that no longer
79
+ * holds the uid the row claims makes the sighting a move. A move another client
80
+ * made is still followed — that is the case where the source really is empty —
81
+ * and reader stops fighting a second client over mail neither of them moved.
82
+ *
83
+ * The question is asked with a UID SEARCH rather than a FETCH, for the reason
84
+ * `isMessageGoneFromOpenMailbox` documents: imapflow drops rows on back-to-back
85
+ * FETCHes (#408), and absence read off a FETCH would file live mail as departed.
86
+ * `IImapConnection.search` throws on a failed SEARCH, so an empty answer means
87
+ * the server matched nothing. The SEARCH names the contested uids rather than
88
+ * ALL: the answer needed is about those uids, and a folder's whole uid set is a
89
+ * page of traffic per batch for an account with a large Inbox.
90
+ *
91
+ * One SEARCH per source folder per batch, and no IMAP at all when nothing is
92
+ * contested — which is every round on an account whose folders do not overlap.
93
+ */
94
+ export declare const confirmDepartures: (deps: DepartureProbeDeps, accountId: string, sightedIn: MailboxItem, messageIds: string[]) => Promise<DepartureVerdicts>;
@@ -0,0 +1,173 @@
1
+ import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
2
+ import { MessageStatus } from "@remit/domain-enums";
3
+ /**
4
+ * Whether a sighting of an already-stored message in this mailbox CONTESTS the
5
+ * placement the row holds (#859). Contesting is not deciding: the sighting is a
6
+ * candidate move, and {@link confirmDepartures} is what turns it into one.
7
+ *
8
+ * Three sightings do not even contest. The same mailbox is the row standing
9
+ * where it already is. Gmail's All Mail, Starred and Important hold every
10
+ * message that also lives in a real folder, so they are views rather than filing
11
+ * locations; following one would empty every other folder's listing. And a row
12
+ * whose own mutation has not settled is in flight, not stale
13
+ * (imap-mutations R1): the server's answer predates the move reader is still
14
+ * waiting on, so taking it would undo the optimistic write. The sync path
15
+ * reconciles rather than waits (imap-mutations R2): it never blocks on a
16
+ * mutation, it re-reads the row on the next sighting after the move settles.
17
+ *
18
+ * In flight is `status`, and `status` alone (#1096, imap-mutations R3). Every
19
+ * outbound mutation writes `moving` or `deleting` and only settling returns the
20
+ * row to `active`, so `active` is precisely the set nothing is coming for —
21
+ * whatever `syncStatus` says about how the row got there. A row put back by
22
+ * `abandonDelete` carries `abandoned`, and it needs this repair more than any
23
+ * other row does: nothing routine settles it, and reader shares its mailboxes,
24
+ * so refusing it left a message whose delete was refused unable to follow a
25
+ * move the user then made in another client.
26
+ */
27
+ export const sightingContestsPlacement = (mailbox, message) => {
28
+ if (message.mailboxId === mailbox.mailboxId)
29
+ return false;
30
+ if (isVirtualCopyMailbox(mailbox))
31
+ return false;
32
+ return message.status === MessageStatus.active;
33
+ };
34
+ /**
35
+ * The exact placement a verdict was reached for. The probe reads the row outside
36
+ * the transaction that acts on it, so the verdict names the mailbox and uid it
37
+ * was reached for; a row that moved in between matches no verdict and is left
38
+ * unsettled.
39
+ */
40
+ export const placementKey = (messageId, mailboxId, uid) => `${messageId}:${mailboxId}:${uid}`;
41
+ /**
42
+ * Which of this batch's messages have really left the folder their row points at
43
+ * (#1146).
44
+ *
45
+ * A message sighted in a second real folder has not necessarily moved. Living in
46
+ * two folders at once is ordinary mail: a Gmail user label, a Sieve `fileinto`
47
+ * beside a `keep`, a Sent copy a mailing list echoes back. Reading every such
48
+ * sighting as a move takes the message out of the folder it is still in, and
49
+ * whichever folder the sync round enumerates last wins — which is how a labelled
50
+ * message disappeared from the Inbox on the next sync.
51
+ *
52
+ * What separates a move from a copy is the source folder: a move empties it, a
53
+ * copy leaves it alone. So the source is asked, and only a source that no longer
54
+ * holds the uid the row claims makes the sighting a move. A move another client
55
+ * made is still followed — that is the case where the source really is empty —
56
+ * and reader stops fighting a second client over mail neither of them moved.
57
+ *
58
+ * The question is asked with a UID SEARCH rather than a FETCH, for the reason
59
+ * `isMessageGoneFromOpenMailbox` documents: imapflow drops rows on back-to-back
60
+ * FETCHes (#408), and absence read off a FETCH would file live mail as departed.
61
+ * `IImapConnection.search` throws on a failed SEARCH, so an empty answer means
62
+ * the server matched nothing. The SEARCH names the contested uids rather than
63
+ * ALL: the answer needed is about those uids, and a folder's whole uid set is a
64
+ * page of traffic per batch for an account with a large Inbox.
65
+ *
66
+ * One SEARCH per source folder per batch, and no IMAP at all when nothing is
67
+ * contested — which is every round on an account whose folders do not overlap.
68
+ */
69
+ export const confirmDepartures = async (deps, accountId, sightedIn, messageIds) => {
70
+ const verdicts = {
71
+ departed: new Set(),
72
+ settled: new Set(),
73
+ needsSave: new Set(),
74
+ };
75
+ if (messageIds.length === 0)
76
+ return verdicts;
77
+ const stored = await deps.messageService.get(messageIds);
78
+ // A row carrying no uid names no placement the source can be asked about, so
79
+ // it reaches no verdict here and its sighting is served again next round. It
80
+ // is not reachable from a sync — `upsertWithStatus` writes the uid the FETCH
81
+ // returned — and a folder that somehow produces one stalls its cursor and
82
+ // raises the alert rather than repairing a row against nothing.
83
+ const contested = stored.filter((row) => row.uid > 0 && sightingContestsPlacement(sightedIn, row));
84
+ if (contested.length === 0)
85
+ return verdicts;
86
+ const bySource = new Map();
87
+ for (const row of contested) {
88
+ const group = bySource.get(row.mailboxId);
89
+ if (group === undefined) {
90
+ bySource.set(row.mailboxId, [row]);
91
+ continue;
92
+ }
93
+ group.push(row);
94
+ }
95
+ for (const [sourceMailboxId, rows] of bySource) {
96
+ await askSource(deps, accountId, sightedIn, sourceMailboxId, rows, verdicts);
97
+ }
98
+ for (const row of contested) {
99
+ const key = placementKey(row.messageId, row.mailboxId, row.uid);
100
+ if (verdicts.departed.has(key) || !verdicts.settled.has(key)) {
101
+ verdicts.needsSave.add(row.messageId);
102
+ }
103
+ }
104
+ return verdicts;
105
+ };
106
+ const readSource = async (deps, accountId, sourceMailboxId, rows) => {
107
+ const source = await deps.mailboxService.get(accountId, sourceMailboxId);
108
+ // A row whose source is All Mail, Starred or Important is not filed there:
109
+ // those folders hold a copy of everything and never release anything, so
110
+ // asking them would answer "still here" forever and freeze the row on a view.
111
+ if (isVirtualCopyMailbox(source)) {
112
+ return { kind: "copiesEverything", path: source.fullPath };
113
+ }
114
+ const box = await deps.connection.openBox(source.fullPath, true);
115
+ // A source folder on a different UIDVALIDITY axis holds no uid these rows can
116
+ // be compared against, so its message set is evidence of nothing (RFC 9051
117
+ // 2.3.1.1). The cursor rebuild re-keys that folder; until it has, the
118
+ // sightings stay unsettled and come back next round.
119
+ if (box.uidvalidity !== source.uidValidity) {
120
+ return {
121
+ kind: "rekeyed",
122
+ path: source.fullPath,
123
+ stored: source.uidValidity,
124
+ served: box.uidvalidity,
125
+ };
126
+ }
127
+ const held = await deps.connection.search([
128
+ ["UID", rows.map((row) => row.uid).join(",")],
129
+ ]);
130
+ return { kind: "holds", path: source.fullPath, held: new Set(held) };
131
+ };
132
+ const askSource = async (deps, accountId, sightedIn, sourceMailboxId, rows, verdicts) => {
133
+ const settle = (row, departed) => {
134
+ const key = placementKey(row.messageId, row.mailboxId, row.uid);
135
+ verdicts.settled.add(key);
136
+ if (departed)
137
+ verdicts.departed.add(key);
138
+ };
139
+ // A source folder reader cannot reach decides nothing, and does not fail the
140
+ // round either. Another client renaming or deleting it between the row being
141
+ // written and now is ordinary — reader shares its mailboxes — and the sync of
142
+ // the unrelated folder that sighted these messages must survive it.
143
+ const answer = await readSource(deps, accountId, sourceMailboxId, rows).catch((error) => ({
144
+ kind: "unreachable",
145
+ reason: error instanceof Error ? error.message : String(error),
146
+ }));
147
+ const observed = {
148
+ sourceMailboxId,
149
+ sightedMailboxId: sightedIn.mailboxId,
150
+ contested: rows.length,
151
+ };
152
+ if (answer.kind === "unreachable") {
153
+ deps.log.warn({ ...observed, error: answer.reason }, "Could not ask the source folder whether it still holds these messages; leaving the sightings unsettled this round");
154
+ return;
155
+ }
156
+ if (answer.kind === "rekeyed") {
157
+ deps.log.warn({
158
+ ...observed,
159
+ sourceMailboxPath: answer.path,
160
+ storedUidValidity: answer.stored,
161
+ servedUidValidity: answer.served,
162
+ }, "Source folder is on a different UIDVALIDITY axis; leaving these sightings unsettled this round");
163
+ return;
164
+ }
165
+ if (answer.kind === "copiesEverything") {
166
+ for (const row of rows)
167
+ settle(row, true);
168
+ deps.log.info({ ...observed, sourceMailboxPath: answer.path }, "Row points at a folder that copies every message; the sighting in a real folder is where it lives");
169
+ return;
170
+ }
171
+ for (const row of rows)
172
+ settle(row, !answer.held.has(row.uid));
173
+ };
@@ -0,0 +1,42 @@
1
+ import { type FilterItem, type IAccountRepository, type IFilterRepository, type IMailboxRepository, type MailboxItem, type OrganizeJobRequestItem, type UpdateFilterInput } from "@remit/data-ports";
2
+ import { type OrganizeJobDeps, type OrganizeJobOwner } from "./organize-job.js";
3
+ import { type Refusable } from "./refusal.js";
4
+ export type NewFilter = Pick<FilterItem, "name" | "scope" | "expiresAt" | "matchOperator" | "literalClauses" | "actionLabelId" | "actionMailboxId">;
5
+ export type FilterPatch = Pick<UpdateFilterInput, "name" | "scope" | "expiresAt" | "matchOperator" | "literalClauses" | "actionLabelId" | "actionMailboxId" | "state">;
6
+ export interface FilterPage {
7
+ items: FilterItem[];
8
+ continuationToken?: string;
9
+ }
10
+ export interface CreatedFilter {
11
+ filter: FilterItem;
12
+ organizeJob: OrganizeJobRequestItem;
13
+ }
14
+ interface AccountDeps {
15
+ account: Pick<IAccountRepository, "get">;
16
+ }
17
+ interface ActionFolderDeps {
18
+ mailbox: Pick<IMailboxRepository, "resolveAccountId"> & {
19
+ get(accountId: string, mailboxId: string): Promise<MailboxItem>;
20
+ };
21
+ }
22
+ export declare const resolveFilterUpdate: (current: Pick<FilterItem, "scope" | "expiresAt" | "state" | "disabledReason" | "actionMailboxId">, patch: FilterPatch, now?: number) => Refusable<UpdateFilterInput>;
23
+ export declare const listFilters: (deps: AccountDeps & {
24
+ filter: Pick<IFilterRepository, "listPageByAccountConfig" | "refreshExpiry">;
25
+ }, accountConfigId: string, query: {
26
+ accountId: string;
27
+ continuationToken: string | undefined;
28
+ }) => Promise<Refusable<FilterPage>>;
29
+ export declare const createFilter: (deps: AccountDeps & ActionFolderDeps & OrganizeJobDeps & {
30
+ filter: Pick<IFilterRepository, "create">;
31
+ }, owner: OrganizeJobOwner, input: {
32
+ accountId: string;
33
+ filter: NewFilter;
34
+ }) => Promise<Refusable<CreatedFilter>>;
35
+ export declare const updateFilter: (deps: AccountDeps & ActionFolderDeps & {
36
+ filter: Pick<IFilterRepository, "get" | "update">;
37
+ }, accountConfigId: string, input: {
38
+ accountId: string;
39
+ filterId: string;
40
+ patch: FilterPatch;
41
+ }) => Promise<Refusable<FilterItem>>;
42
+ export {};
@@ -0,0 +1,211 @@
1
+ import { deriveFilterTtl, FILTER_NO_ACTION, } from "@remit/data-ports";
2
+ import { FilterDisabledReason, FilterScope, FilterState, } from "@remit/domain-enums";
3
+ import { queueOrganizeJob, } from "./organize-job.js";
4
+ import { folderInAccount, ownedAccount, settledFolder } from "./ownership.js";
5
+ import { accept, invalid } from "./refusal.js";
6
+ // Only these keys reach the repo, and a key absent from the body stays absent,
7
+ // so a rename never bumps ruleChangedAt and a smuggled ttl is dropped.
8
+ const pickFilterUpdate = (body) => {
9
+ const patch = {};
10
+ if (Object.hasOwn(body, "name"))
11
+ patch.name = body.name;
12
+ if (Object.hasOwn(body, "scope"))
13
+ patch.scope = body.scope;
14
+ if (Object.hasOwn(body, "expiresAt"))
15
+ patch.expiresAt = body.expiresAt;
16
+ if (Object.hasOwn(body, "matchOperator")) {
17
+ patch.matchOperator = body.matchOperator;
18
+ }
19
+ if (Object.hasOwn(body, "literalClauses")) {
20
+ patch.literalClauses = body.literalClauses;
21
+ }
22
+ if (Object.hasOwn(body, "actionLabelId")) {
23
+ patch.actionLabelId = body.actionLabelId;
24
+ }
25
+ if (Object.hasOwn(body, "actionMailboxId")) {
26
+ patch.actionMailboxId = body.actionMailboxId;
27
+ }
28
+ if (Object.hasOwn(body, "state"))
29
+ patch.state = body.state;
30
+ return patch;
31
+ };
32
+ // A Temporary filter always carries expiresAt; outside Temporary both
33
+ // expiresAt and the table-wide ttl stay absent, or the row would be swept.
34
+ const resolveFilterScopeExpiry = (current, patch) => {
35
+ const scope = patch.scope ?? current.scope;
36
+ if (scope !== FilterScope.Temporary) {
37
+ if (Object.hasOwn(patch, "expiresAt") && patch.expiresAt) {
38
+ return invalid("expiresAt only applies to a Temporary filter — switch scope to Temporary to set one.");
39
+ }
40
+ return accept({
41
+ scope,
42
+ expiresAt: undefined,
43
+ ttl: undefined,
44
+ state: FilterState.Active,
45
+ });
46
+ }
47
+ const expiresAt = Object.hasOwn(patch, "expiresAt")
48
+ ? patch.expiresAt
49
+ : current.expiresAt;
50
+ if (!expiresAt) {
51
+ return invalid("A Temporary filter needs expiresAt — pick a date, or switch scope to Standing.");
52
+ }
53
+ const ttl = deriveFilterTtl(scope, expiresAt);
54
+ const state = new Date(expiresAt).getTime() > Date.now()
55
+ ? FilterState.Active
56
+ : FilterState.Expired;
57
+ return accept({ scope, expiresAt, ttl, state });
58
+ };
59
+ const hasLapsed = (filter, now) => filter.scope === FilterScope.Temporary &&
60
+ filter.expiresAt !== undefined &&
61
+ new Date(filter.expiresAt).getTime() <= now;
62
+ const FOLDERLESS_REASONS = new Set([
63
+ FilterDisabledReason.AwaitingFolder,
64
+ FilterDisabledReason.FolderCreateFailed,
65
+ ]);
66
+ const folderlessMessage = (reason) => reason === FilterDisabledReason.AwaitingFolder
67
+ ? "This filter has no folder to move mail into yet. Wait for its folder to be created on the mail server, or pick a folder in the rule, then turn it on."
68
+ : "The mail server refused to create this filter's folder. Pick a folder in the rule, then turn it on.";
69
+ export const resolveFilterUpdate = (current, patch, now = Date.now()) => {
70
+ if (patch.state === FilterState.Expired) {
71
+ return invalid("A filter expires through its date, not its state. Set it Active or Disabled, or change its expiry.");
72
+ }
73
+ const folderless = current.state === FilterState.Disabled &&
74
+ FOLDERLESS_REASONS.has(current.disabledReason);
75
+ const folder = patch.actionMailboxId ?? current.actionMailboxId;
76
+ const picked = folderless && folder !== FILTER_NO_ACTION;
77
+ const pickedReason = picked
78
+ ? { disabledReason: FilterDisabledReason.UserDisabled }
79
+ : {};
80
+ const touchesTiming = Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
81
+ if (!touchesTiming && patch.state === undefined) {
82
+ return accept({ ...patch, ...pickedReason });
83
+ }
84
+ let timing;
85
+ if (touchesTiming) {
86
+ const resolved = resolveFilterScopeExpiry(current, patch);
87
+ if (!resolved.ok)
88
+ return resolved;
89
+ timing = resolved.value;
90
+ }
91
+ const lapsed = timing
92
+ ? timing.state === FilterState.Expired
93
+ : hasLapsed(current, now);
94
+ if (patch.state === FilterState.Active && lapsed) {
95
+ return invalid("This filter has expired. Move its date forward or make it Standing to turn it back on.");
96
+ }
97
+ const wanted = patch.state ?? current.state;
98
+ const merged = { ...patch, ...timing };
99
+ if (wanted === FilterState.Disabled) {
100
+ return accept({
101
+ ...merged,
102
+ state: FilterState.Disabled,
103
+ ...(folderless
104
+ ? pickedReason
105
+ : patch.state === FilterState.Disabled
106
+ ? { disabledReason: FilterDisabledReason.UserDisabled }
107
+ : {}),
108
+ });
109
+ }
110
+ if (lapsed) {
111
+ return accept({
112
+ ...merged,
113
+ state: FilterState.Expired,
114
+ disabledReason: FilterDisabledReason.None,
115
+ });
116
+ }
117
+ if (folderless && folder === FILTER_NO_ACTION) {
118
+ return invalid(folderlessMessage(current.disabledReason));
119
+ }
120
+ return accept({
121
+ ...merged,
122
+ state: FilterState.Active,
123
+ disabledReason: FilterDisabledReason.None,
124
+ });
125
+ };
126
+ // The sentinel means "no move action" and binds to nothing, so it is not
127
+ // gated, and neither is a patch that leaves the field alone.
128
+ const settledActionFolder = async (deps, accountId, actionMailboxId) => {
129
+ if (actionMailboxId === undefined || actionMailboxId === FILTER_NO_ACTION) {
130
+ return accept(actionMailboxId);
131
+ }
132
+ const target = await deps.mailbox.get(accountId, actionMailboxId);
133
+ const inAccount = folderInAccount(target, accountId, "act");
134
+ if (!inAccount.ok)
135
+ return inAccount;
136
+ const settled = settledFolder(target);
137
+ if (!settled.ok)
138
+ return settled;
139
+ return accept(actionMailboxId);
140
+ };
141
+ const existingActionFolder = async (deps, accountId, actionMailboxId) => {
142
+ if (actionMailboxId === FILTER_NO_ACTION)
143
+ return accept(actionMailboxId);
144
+ if ((await deps.mailbox.resolveAccountId(actionMailboxId)) === null) {
145
+ return invalid("The folder this filter moves mail into no longer exists. Pick another folder, then turn the filter on.");
146
+ }
147
+ return settledActionFolder(deps, accountId, actionMailboxId);
148
+ };
149
+ export const listFilters = async (deps, accountConfigId, query) => {
150
+ const owned = ownedAccount(await deps.account.get(query.accountId), accountConfigId, "read");
151
+ if (!owned.ok)
152
+ return owned;
153
+ const page = await deps.filter.listPageByAccountConfig(accountConfigId, {
154
+ continuationToken: query.continuationToken,
155
+ });
156
+ // Reading the list lazily expires a lapsed Temporary filter.
157
+ const items = await Promise.all(page.items.map((item) => deps.filter.refreshExpiry(item)));
158
+ return accept({ items, continuationToken: page.continuationToken });
159
+ };
160
+ export const createFilter = async (deps, owner, input) => {
161
+ const { accountConfigId, userId } = owner;
162
+ const { accountId, filter: rule } = input;
163
+ const owned = ownedAccount(await deps.account.get(accountId), accountConfigId, "act");
164
+ if (!owned.ok)
165
+ return owned;
166
+ const scoped = resolveFilterScopeExpiry({ scope: rule.scope, expiresAt: undefined }, { scope: rule.scope, expiresAt: rule.expiresAt });
167
+ if (!scoped.ok)
168
+ return scoped;
169
+ const target = await settledActionFolder(deps, accountId, rule.actionMailboxId);
170
+ if (!target.ok)
171
+ return target;
172
+ const filter = await deps.filter.create({
173
+ accountConfigId,
174
+ name: rule.name,
175
+ scope: scoped.value.scope,
176
+ expiresAt: scoped.value.expiresAt,
177
+ ttl: scoped.value.ttl,
178
+ matchOperator: rule.matchOperator,
179
+ literalClauses: rule.literalClauses,
180
+ actionLabelId: rule.actionLabelId,
181
+ actionMailboxId: rule.actionMailboxId,
182
+ });
183
+ // The new filter's first pass over existing mail.
184
+ const organizeJob = await queueOrganizeJob(deps, {
185
+ accountConfigId,
186
+ userId,
187
+ filterId: filter.filterId,
188
+ });
189
+ return accept({ filter, organizeJob });
190
+ };
191
+ export const updateFilter = async (deps, accountConfigId, input) => {
192
+ const { accountId, filterId } = input;
193
+ const owned = ownedAccount(await deps.account.get(accountId), accountConfigId, "act");
194
+ if (!owned.ok)
195
+ return owned;
196
+ const patch = pickFilterUpdate(input.patch);
197
+ const target = await settledActionFolder(deps, accountId, patch.actionMailboxId);
198
+ if (!target.ok)
199
+ return target;
200
+ const current = await deps.filter.get(accountConfigId, filterId);
201
+ const resolved = resolveFilterUpdate(current, patch);
202
+ if (!resolved.ok)
203
+ return resolved;
204
+ if (patch.state === FilterState.Active &&
205
+ patch.actionMailboxId === undefined) {
206
+ const existing = await existingActionFolder(deps, accountId, current.actionMailboxId);
207
+ if (!existing.ok)
208
+ return existing;
209
+ }
210
+ return accept(await deps.filter.update(accountConfigId, filterId, resolved.value));
211
+ };