@remit/imap-worker 0.0.90 → 0.0.91

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 (172) hide show
  1. package/dist/account-check.d.ts +27 -0
  2. package/dist/account-check.js +70 -0
  3. package/dist/body-sync-gate.d.ts +12 -0
  4. package/dist/body-sync-gate.js +53 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +193 -0
  7. package/dist/config-env.d.ts +5 -0
  8. package/dist/config-env.js +37 -0
  9. package/dist/connection-pool.d.ts +22 -0
  10. package/dist/connection-pool.js +97 -0
  11. package/dist/connection-scope.d.ts +8 -0
  12. package/dist/connection-scope.js +10 -0
  13. package/dist/context.d.ts +46 -0
  14. package/dist/context.js +15 -0
  15. package/dist/e2e-processor-shim.d.ts +2 -0
  16. package/dist/e2e-processor-shim.js +181 -0
  17. package/dist/emit.d.ts +15 -0
  18. package/dist/emit.js +76 -0
  19. package/dist/events.d.ts +203 -0
  20. package/dist/events.js +1 -0
  21. package/dist/failure-kind.d.ts +10 -0
  22. package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
  23. package/dist/handlers/append-sent-message.d.ts +3 -0
  24. package/dist/handlers/append-sent-message.js +152 -0
  25. package/dist/handlers/delete-account-objects.d.ts +3 -0
  26. package/dist/handlers/delete-account-objects.js +35 -0
  27. package/dist/handlers/empty-trash.d.ts +14 -0
  28. package/dist/handlers/empty-trash.js +217 -0
  29. package/dist/handlers/flag-push.d.ts +38 -0
  30. package/dist/handlers/flag-push.js +195 -0
  31. package/dist/handlers/imported-folder.d.ts +4 -0
  32. package/dist/handlers/imported-folder.js +23 -0
  33. package/dist/handlers/mailbox-management.d.ts +6 -0
  34. package/dist/handlers/mailbox-management.js +294 -0
  35. package/dist/handlers/mailbox-sync-order.d.ts +21 -0
  36. package/dist/handlers/mailbox-sync-order.js +50 -0
  37. package/dist/handlers/message-copy.d.ts +40 -0
  38. package/dist/handlers/message-copy.js +371 -0
  39. package/dist/handlers/message-delete-terminal.d.ts +60 -0
  40. package/dist/handlers/message-delete-terminal.js +77 -0
  41. package/dist/handlers/message-delete.d.ts +57 -0
  42. package/dist/handlers/message-delete.js +520 -0
  43. package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
  44. package/dist/handlers/message-move-terminal.js +94 -0
  45. package/dist/handlers/message-move.d.ts +146 -0
  46. package/dist/handlers/message-move.js +490 -0
  47. package/dist/handlers/placement-move-push.d.ts +62 -0
  48. package/dist/handlers/placement-move-push.js +307 -0
  49. package/dist/handlers/probe-message-id.d.ts +2 -0
  50. package/dist/handlers/probe-message-id.js +6 -0
  51. package/dist/handlers/sync-mailboxes.d.ts +45 -0
  52. package/dist/handlers/sync-mailboxes.js +233 -0
  53. package/dist/handlers/sync-message-body.d.ts +23 -0
  54. package/dist/handlers/sync-message-body.js +182 -0
  55. package/dist/handlers/sync-messages.d.ts +29 -0
  56. package/dist/handlers/sync-messages.js +285 -0
  57. package/dist/index.d.ts +9 -0
  58. package/dist/index.js +67 -0
  59. package/dist/lambda-context.d.ts +8 -0
  60. package/dist/lambda-context.js +33 -0
  61. package/dist/mailbox-row.d.ts +9 -0
  62. package/dist/mailbox-row.js +6 -0
  63. package/dist/poller.d.ts +1 -0
  64. package/dist/poller.js +51 -0
  65. package/dist/process.d.ts +6 -0
  66. package/dist/process.js +3 -0
  67. package/dist/processor.d.ts +3 -0
  68. package/dist/processor.js +44 -0
  69. package/dist/queues.d.ts +10 -0
  70. package/dist/queues.js +1 -0
  71. package/dist/s3-account-objects.d.ts +3 -0
  72. package/dist/s3-account-objects.js +26 -0
  73. package/dist/scheduler/attachment-sweep.d.ts +11 -0
  74. package/dist/scheduler/attachment-sweep.js +23 -0
  75. package/dist/scheduler/calendar-subscriptions.d.ts +26 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +27 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +7 -26
  79. package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
  80. package/dist/scheduler/decide-due.js +17 -0
  81. package/dist/scheduler/handler.d.ts +21 -0
  82. package/{src/scheduler/handler.ts → dist/scheduler/handler.js} +25 -42
  83. package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
  84. package/dist/scheduler/loop.js +8 -0
  85. package/dist/scheduler/run-tick.d.ts +62 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/{src/scheduler/runner.ts → dist/scheduler/runner.js} +57 -78
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/sqs-queues.d.ts +4 -0
  92. package/dist/sqs-queues.js +22 -0
  93. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  94. package/dist/with-oauth-lifecycle-deps.js +17 -0
  95. package/dist/with-oauth-lifecycle.d.ts +56 -0
  96. package/dist/with-oauth-lifecycle.js +92 -0
  97. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  98. package/dist/worker-version.js +12 -0
  99. package/package.json +75 -69
  100. package/src/account-check.test.ts +0 -149
  101. package/src/account-check.ts +0 -101
  102. package/src/body-sync-gate.test.ts +0 -185
  103. package/src/body-sync-gate.ts +0 -86
  104. package/src/cli.ts +0 -218
  105. package/src/connection-scope.test.ts +0 -266
  106. package/src/connection-scope.ts +0 -314
  107. package/src/credential-less-sync-fence.test.ts +0 -152
  108. package/src/e2e-processor-shim.ts +0 -232
  109. package/src/emit.test.ts +0 -145
  110. package/src/emit.ts +0 -115
  111. package/src/events.ts +0 -252
  112. package/src/failure-kind.test.ts +0 -42
  113. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  114. package/src/handlers/append-sent-message.ts +0 -264
  115. package/src/handlers/delete-account-objects.test.ts +0 -126
  116. package/src/handlers/delete-account-objects.ts +0 -96
  117. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  118. package/src/handlers/empty-trash.ts +0 -352
  119. package/src/handlers/flag-push.ts +0 -285
  120. package/src/handlers/imported-folder.test.ts +0 -63
  121. package/src/handlers/imported-folder.ts +0 -38
  122. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  123. package/src/handlers/mailbox-management.ts +0 -486
  124. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  125. package/src/handlers/mailbox-sync-order.ts +0 -65
  126. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  127. package/src/handlers/message-copy.ts +0 -569
  128. package/src/handlers/message-delete-terminal.ts +0 -147
  129. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  130. package/src/handlers/message-delete.ts +0 -888
  131. package/src/handlers/message-move.sqlite.test.ts +0 -271
  132. package/src/handlers/message-move.test.ts +0 -216
  133. package/src/handlers/message-move.ts +0 -755
  134. package/src/handlers/placement-move-push.test.ts +0 -137
  135. package/src/handlers/placement-move-push.ts +0 -478
  136. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  137. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  138. package/src/handlers/probe-message-id.ts +0 -15
  139. package/src/handlers/sync-mailboxes.test.ts +0 -183
  140. package/src/handlers/sync-mailboxes.ts +0 -425
  141. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  142. package/src/handlers/sync-message-body.test.ts +0 -150
  143. package/src/handlers/sync-message-body.ts +0 -366
  144. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  145. package/src/handlers/sync-messages.ts +0 -537
  146. package/src/index.test.ts +0 -22
  147. package/src/index.ts +0 -82
  148. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  149. package/src/mailbox-row.ts +0 -20
  150. package/src/pending-sync.sqlite.test.ts +0 -165
  151. package/src/poller.ts +0 -55
  152. package/src/processor.ts +0 -62
  153. package/src/queues.ts +0 -40
  154. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  155. package/src/scheduler/attachment-sweep.ts +0 -46
  156. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  157. package/src/scheduler/calendar-subscriptions.ts +0 -103
  158. package/src/scheduler/config.test.ts +0 -40
  159. package/src/scheduler/decide-due.test.ts +0 -44
  160. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  161. package/src/scheduler/loop.test.ts +0 -143
  162. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  163. package/src/scheduler/run-tick.ts +0 -281
  164. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  165. package/src/stranded-placement.sqlite.test.ts +0 -799
  166. package/src/test-helpers/test-world.ts +0 -425
  167. package/src/test-helpers/worker-queues.ts +0 -11
  168. package/src/thread-join.sqlite.test.ts +0 -106
  169. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  170. package/src/with-oauth-lifecycle-deps.ts +0 -71
  171. package/src/with-oauth-lifecycle.ts +0 -158
  172. package/tsconfig.json +0 -8
@@ -1,486 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
3
- import type { IMailboxRepository } from "@remit/data-ports";
4
- import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
5
- import { MailboxSyncStatus } from "@remit/domain-enums";
6
- import type { Logger } from "@remit/logger";
7
- import {
8
- FolderGoneUpstreamError,
9
- FolderRenameSettleError,
10
- isMailboxAbsentUpstream,
11
- isMailboxPresentUpstream,
12
- isMailboxRefusedUpstream,
13
- MailboxManagementService,
14
- upstreamFailureReason,
15
- } from "@remit/mailbox-service";
16
- import { isAccountDeleted } from "../account-check.js";
17
- import { createConnectionScopeWithCredentials } from "../connection-scope.js";
18
- import type {
19
- MailboxCreateEvent,
20
- MailboxDeleteEvent,
21
- MailboxManagementEvent,
22
- MailboxRenameEvent,
23
- } from "../events.js";
24
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
25
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
26
-
27
- /**
28
- * The outcome of a create, written against the state a create in flight is:
29
- * `pending` with no recorded rename target (D3, D10). Without the absence
30
- * check, a create redelivered after a lost acknowledgement settles a row a
31
- * rename has since claimed and kills the rename silently.
32
- */
33
- const recordCreateOutcome = async (
34
- mailboxService: Pick<IMailboxRepository, "transition">,
35
- accountId: string,
36
- mailboxId: string,
37
- to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
38
- syncFailureReason = "",
39
- ): Promise<void> => {
40
- const written = await mailboxService.transition(accountId, mailboxId, {
41
- from: [MailboxSyncStatus.pending],
42
- wherePendingPath: null,
43
- to,
44
- set: { syncFailureReason },
45
- });
46
- if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
47
- };
48
-
49
- /**
50
- * Pinned invariant for the whole-chain terminal guards below.
51
- *
52
- * Each handler wraps its `MailboxManagementService.sync*` chain in a try/catch
53
- * that treats a NotFoundError as terminal (ack with a WARN, issue #289). That is
54
- * only sound while the sole row a NotFoundError can refer to is the target
55
- * mailbox, so its absence can only mean the user deleted the folder mid-sync —
56
- * never an unrelated missing entity that should have been retried.
57
- *
58
- * `syncCreate` and `syncDelete` hold to that by touching nothing but the target
59
- * row. `syncRename` also writes every other row that recorded the same intent,
60
- * and keeps the invariant by treating a lost predicate as an outcome rather than
61
- * an error: a descendant deleted mid-settle is skipped, never raised. The
62
- * appointment-label refresh writes `account_setting` rows, whose reads answer
63
- * absent rather than throwing. Any sync method that reads or writes a second
64
- * entity owes the same, or these catches must be narrowed (match the mailboxId /
65
- * re-check existence) before a NotFoundError from elsewhere is silently acked.
66
- */
67
-
68
- /**
69
- * Handle MAILBOX_CREATE event
70
- */
71
- const handleCreate = async (
72
- event: MailboxCreateEvent,
73
- log: Logger,
74
- createConnectionScope: typeof createConnectionScopeWithCredentials,
75
- ): Promise<void> => {
76
- const {
77
- account: accountService,
78
- mailbox: mailboxService,
79
- secrets,
80
- } = await getClient();
81
-
82
- const { accountId, mailboxId, path, subscribe } = event;
83
-
84
- log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
85
-
86
- const account = await accountService.get(accountId);
87
- if (!account) {
88
- throw new Error(`Account ${accountId} not found`);
89
- }
90
-
91
- if (isAccountDeleted(account, log)) {
92
- return;
93
- }
94
-
95
- await withOAuthLifecycle(
96
- buildLifecycleDeps(secrets, accountService),
97
- account,
98
- log,
99
- async (credentials) => {
100
- const scope = createConnectionScope(account, credentials);
101
- const managementService = new MailboxManagementService(
102
- mailboxService,
103
- log,
104
- );
105
-
106
- try {
107
- await managementService
108
- .syncCreate(
109
- accountId,
110
- mailboxId,
111
- path,
112
- scope.getConnection,
113
- subscribe,
114
- )
115
- .then((result) => {
116
- if (result.success) {
117
- log.info(
118
- {
119
- accountId,
120
- mailboxId,
121
- path,
122
- existingMailboxId: result.existing?.mailboxId,
123
- },
124
- "Mailbox created on IMAP",
125
- );
126
- } else {
127
- log.error(
128
- { accountId, mailboxId, path, error: result.error },
129
- "Failed to create mailbox on IMAP",
130
- );
131
- }
132
- })
133
- .catch(async (error) => {
134
- // Check if mailbox already exists (idempotent)
135
- if (isMailboxPresentUpstream(error)) {
136
- log.info(
137
- { accountId, mailboxId, path },
138
- "Mailbox already exists, marking as synced",
139
- );
140
- await recordCreateOutcome(
141
- mailboxService,
142
- accountId,
143
- mailboxId,
144
- MailboxSyncStatus.synced,
145
- );
146
- } else {
147
- await recordCreateOutcome(
148
- mailboxService,
149
- accountId,
150
- mailboxId,
151
- MailboxSyncStatus.failed,
152
- upstreamFailureReason(error),
153
- );
154
- if (isMailboxRefusedUpstream(error)) {
155
- log.error(
156
- { accountId, mailboxId, path, intent: "create", error },
157
- "Mail server refused the create",
158
- );
159
- return;
160
- }
161
- throw error;
162
- }
163
- })
164
- .finally(() => scope.disconnect());
165
- } catch (error) {
166
- // The mailbox row was deleted between enqueue and this sync — the
167
- // create-then-status-write throws NotFoundError, which can never
168
- // succeed on retry and would poison the account's per-group FIFO
169
- // forever (issue #289, same class as #287/#290). A folder the user
170
- // deleted is an expected terminal outcome: ack with a WARN. Real
171
- // IMAP/infra failures carry other errors and still propagate.
172
- if (isNotFoundError(error)) {
173
- log.warn(
174
- { accountId, mailboxId, path, eventId: event.eventId },
175
- "Skipping MAILBOX_CREATE: mailbox no longer exists (deleted)",
176
- );
177
- return;
178
- }
179
- throw error;
180
- }
181
- },
182
- );
183
- };
184
-
185
- /**
186
- * Handle MAILBOX_RENAME event
187
- */
188
- const handleRename = async (
189
- event: MailboxRenameEvent,
190
- log: Logger,
191
- createConnectionScope: typeof createConnectionScopeWithCredentials,
192
- ): Promise<void> => {
193
- const {
194
- account: accountService,
195
- accountSetting: accountSettingService,
196
- mailbox: mailboxService,
197
- secrets,
198
- } = await getClient();
199
-
200
- const { accountId, mailboxId, oldPath, newPath } = event;
201
-
202
- log.info(
203
- { event: event.type, accountId, mailboxId, oldPath, newPath },
204
- "Handling event",
205
- );
206
-
207
- const account = await accountService.get(accountId);
208
- if (!account) {
209
- throw new Error(`Account ${accountId} not found`);
210
- }
211
-
212
- if (isAccountDeleted(account, log)) {
213
- return;
214
- }
215
-
216
- await withOAuthLifecycle(
217
- buildLifecycleDeps(secrets, accountService),
218
- account,
219
- log,
220
- async (credentials) => {
221
- const scope = createConnectionScope(account, credentials);
222
- const managementService = new MailboxManagementService(
223
- mailboxService,
224
- log,
225
- );
226
-
227
- try {
228
- await managementService
229
- .syncRename(
230
- accountId,
231
- mailboxId,
232
- oldPath,
233
- newPath,
234
- scope.getConnection,
235
- )
236
- .then(async (result) => {
237
- if (!result.success) {
238
- log.error(
239
- { accountId, mailboxId, oldPath, newPath, error: result.error },
240
- "Failed to rename mailbox on IMAP",
241
- );
242
- return;
243
- }
244
- log.info(
245
- { accountId, mailboxId, oldPath, newPath },
246
- "Mailbox renamed on IMAP",
247
- );
248
- if (!result.renamed) return;
249
- // The path recorded beside each role appointment (#887) moves with
250
- // the settle, because under D2 that is the first moment the new
251
- // path is one the server holds.
252
- //
253
- // A label is display-only and this runs after the server rename
254
- // landed, so letting it fail would take the job down the failure
255
- // path and mark a completed rename refused. Logged and dropped;
256
- // the next appointment write records the folder's current path
257
- // anyway.
258
- await refreshFolderAppointmentLabels(
259
- accountSettingService,
260
- account.accountConfigId,
261
- accountId,
262
- {
263
- mailboxId,
264
- oldPath: result.renamed.oldPath,
265
- newPath: result.renamed.newPath,
266
- },
267
- result.renamed.delimiter,
268
- ).catch((error: unknown) => {
269
- log.error(
270
- { accountId, mailboxId, oldPath, newPath, error },
271
- "Renamed folder settled, but its role-appointment labels did not move",
272
- );
273
- });
274
- })
275
- .catch(async (error) => {
276
- // The server executed the rename and only the local settle did not
277
- // finish. That is not a refused rename: marking the rows `failed`
278
- // would offer a retry of work already done and leave `fullPath`
279
- // naming a path the server no longer holds. Rethrow, so the
280
- // redelivery finishes the settle against an intent still standing.
281
- if (error instanceof FolderRenameSettleError) {
282
- log.error(
283
- { accountId, mailboxId, oldPath, newPath, error },
284
- "Rename landed on the server; the settle will finish on redelivery",
285
- );
286
- throw error;
287
- }
288
- // The folder the rename was to move is confirmed gone: the
289
- // server refused the RENAME as non-existent and its own listing
290
- // held neither path. The rename decides that, once, where the
291
- // evidence is — re-reading the response code here would make
292
- // every `NONEXISTENT` this code cannot classify destroy the
293
- // folder's mail. Removing the root row alone is the orphaning
294
- // bug: every descendant recorded the same intent (D6) and would
295
- // be left `pending` at a path that does not exist, with its mail
296
- // stranded out of every reader (D8).
297
- if (error instanceof FolderGoneUpstreamError) {
298
- log.info(
299
- { accountId, mailboxId, oldPath, intent: "rename" },
300
- "Source mailbox not found, deleting local folder and its mail",
301
- );
302
- await managementService.abandonRenameSubtree(
303
- accountId,
304
- mailboxId,
305
- oldPath,
306
- newPath,
307
- );
308
- return;
309
- }
310
- // T6. Nothing is restored — `fullPath` was never written, and
311
- // each intent-carrying row keeps its target so the client can
312
- // name what the rename was aiming at and offer a retry.
313
- await managementService.failRename(
314
- accountId,
315
- mailboxId,
316
- oldPath,
317
- newPath,
318
- upstreamFailureReason(error),
319
- );
320
- if (isMailboxRefusedUpstream(error)) {
321
- log.error(
322
- {
323
- accountId,
324
- mailboxId,
325
- oldPath,
326
- newPath,
327
- intent: "rename",
328
- error,
329
- },
330
- "Mail server refused the rename",
331
- );
332
- return;
333
- }
334
- throw error;
335
- })
336
- .finally(() => scope.disconnect());
337
- } catch (error) {
338
- // The mailbox row was deleted between enqueue and this sync — the
339
- // status write-back throws NotFoundError, unretryable and would poison
340
- // the account's per-group FIFO forever (issue #289 class). Ack with a
341
- // WARN; real IMAP/infra failures carry other errors and still propagate.
342
- if (isNotFoundError(error)) {
343
- log.warn(
344
- { accountId, mailboxId, oldPath, newPath, eventId: event.eventId },
345
- "Skipping MAILBOX_RENAME: mailbox no longer exists (deleted)",
346
- );
347
- return;
348
- }
349
- throw error;
350
- }
351
- },
352
- );
353
- };
354
-
355
- /**
356
- * Handle MAILBOX_DELETE event
357
- */
358
- const handleDelete = async (
359
- event: MailboxDeleteEvent,
360
- log: Logger,
361
- createConnectionScope: typeof createConnectionScopeWithCredentials,
362
- ): Promise<void> => {
363
- const {
364
- account: accountService,
365
- mailbox: mailboxService,
366
- secrets,
367
- } = await getClient();
368
-
369
- const { accountId, mailboxId, path } = event;
370
-
371
- log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
372
-
373
- const account = await accountService.get(accountId);
374
- if (!account) {
375
- throw new Error(`Account ${accountId} not found`);
376
- }
377
-
378
- if (isAccountDeleted(account, log)) {
379
- return;
380
- }
381
-
382
- await withOAuthLifecycle(
383
- buildLifecycleDeps(secrets, accountService),
384
- account,
385
- log,
386
- async (credentials) => {
387
- const scope = createConnectionScope(account, credentials);
388
- const managementService = new MailboxManagementService(
389
- mailboxService,
390
- log,
391
- );
392
-
393
- try {
394
- await managementService
395
- .syncDelete(accountId, mailboxId, path, scope.getConnection)
396
- .then((result) => {
397
- if (result.success) {
398
- log.info(
399
- { accountId, mailboxId, path },
400
- "Mailbox deleted on IMAP",
401
- );
402
- } else {
403
- log.error(
404
- { accountId, mailboxId, path, error: result.error },
405
- "Failed to delete mailbox on IMAP",
406
- );
407
- }
408
- })
409
- .catch(async (error) => {
410
- // The folder is already gone from the server, so the delete has
411
- // happened. Its mail still goes with it (D8), which is the whole
412
- // point of the settle.
413
- if (isMailboxAbsentUpstream(error)) {
414
- log.info(
415
- { accountId, mailboxId, path, intent: "delete" },
416
- "Mailbox not found on IMAP, removing the local folder and its mail",
417
- );
418
- await managementService.settleDelete(accountId, mailboxId);
419
- } else {
420
- // T9, INBOX included. The API refuses a delete of INBOX, so the
421
- // worker's backstop is unreachable from it; if something else
422
- // reaches it, the honest outcome is `failed`, not `synced` —
423
- // the folder was never deleted and nothing was undone.
424
- await managementService.failDelete(
425
- accountId,
426
- mailboxId,
427
- upstreamFailureReason(error),
428
- );
429
- if (
430
- error instanceof Error &&
431
- error.message.includes("Cannot delete INBOX")
432
- ) {
433
- log.error(
434
- { accountId, mailboxId, path, intent: "delete" },
435
- "Cannot delete INBOX",
436
- );
437
- // Don't rethrow — no retry can make this succeed.
438
- return;
439
- }
440
- if (isMailboxRefusedUpstream(error)) {
441
- log.error(
442
- { accountId, mailboxId, path, intent: "delete", error },
443
- "Mail server refused the delete",
444
- );
445
- return;
446
- }
447
- throw error;
448
- }
449
- })
450
- .finally(() => scope.disconnect());
451
- } catch (error) {
452
- // The mailbox row was already deleted (a duplicate/racing delete) — the
453
- // error-recovery status write throws NotFoundError, unretryable and
454
- // would poison the account's per-group FIFO forever (issue #289 class).
455
- // The delete has effectively happened: ack with a WARN. Real IMAP/infra
456
- // failures carry other errors and still propagate.
457
- if (isNotFoundError(error)) {
458
- log.warn(
459
- { accountId, mailboxId, path, eventId: event.eventId },
460
- "Skipping MAILBOX_DELETE: mailbox no longer exists (deleted)",
461
- );
462
- return;
463
- }
464
- throw error;
465
- }
466
- },
467
- );
468
- };
469
-
470
- /**
471
- * Process mailbox management events
472
- */
473
- export const processMailboxManagement = async (
474
- event: MailboxManagementEvent,
475
- log: Logger,
476
- createConnectionScope = createConnectionScopeWithCredentials,
477
- ): Promise<void> => {
478
- switch (event.type) {
479
- case "MAILBOX_CREATE":
480
- return handleCreate(event, log, createConnectionScope);
481
- case "MAILBOX_RENAME":
482
- return handleRename(event, log, createConnectionScope);
483
- case "MAILBOX_DELETE":
484
- return handleDelete(event, log, createConnectionScope);
485
- }
486
- };
@@ -1,93 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, it } from "node:test";
3
- import { MailboxSpecialUse } from "@remit/domain-enums";
4
- import {
5
- type MailboxSyncOrderEntry,
6
- mailboxSyncPriority,
7
- orderMailboxesForSync,
8
- } from "./mailbox-sync-order.js";
9
-
10
- const mailbox = (
11
- fullPath: string,
12
- specialUse?: readonly string[],
13
- ): MailboxSyncOrderEntry => ({
14
- mailboxId: `id-${fullPath}`,
15
- fullPath,
16
- specialUse,
17
- });
18
-
19
- describe("orderMailboxesForSync", () => {
20
- it("orders INBOX first, Sent/Drafts next, Junk and Trash last", () => {
21
- const mailboxes = [
22
- mailbox("INBOX/Spam", [MailboxSpecialUse.Junk]),
23
- mailbox("Trash", [MailboxSpecialUse.Trash]),
24
- mailbox("Projects"),
25
- mailbox("INBOX"),
26
- mailbox("Sent", [MailboxSpecialUse.Sent]),
27
- mailbox("Drafts", [MailboxSpecialUse.Drafts]),
28
- ];
29
-
30
- const ordered = orderMailboxesForSync(mailboxes).map((m) => m.fullPath);
31
-
32
- assert.deepStrictEqual(ordered, [
33
- "INBOX",
34
- "Sent",
35
- "Drafts",
36
- "Projects",
37
- "INBOX/Spam",
38
- "Trash",
39
- ]);
40
- });
41
-
42
- it("places INBOX ahead of an alphabetically-earlier Junk folder", () => {
43
- const ordered = orderMailboxesForSync([
44
- mailbox("Bulk", [MailboxSpecialUse.Junk]),
45
- mailbox("INBOX"),
46
- ]).map((m) => m.fullPath);
47
-
48
- assert.deepStrictEqual(ordered, ["INBOX", "Bulk"]);
49
- });
50
-
51
- it("is deterministic: ties break alphabetically by fullPath", () => {
52
- const ordered = orderMailboxesForSync([
53
- mailbox("Work"),
54
- mailbox("Archive", [MailboxSpecialUse.Archive]),
55
- mailbox("Newsletters"),
56
- ]).map((m) => m.fullPath);
57
-
58
- assert.deepStrictEqual(ordered, ["Archive", "Newsletters", "Work"]);
59
- });
60
-
61
- it("does not mutate the input array", () => {
62
- const input = [
63
- mailbox("Trash", [MailboxSpecialUse.Trash]),
64
- mailbox("INBOX"),
65
- ];
66
- const before = input.map((m) => m.fullPath);
67
- orderMailboxesForSync(input);
68
- assert.deepStrictEqual(
69
- input.map((m) => m.fullPath),
70
- before,
71
- );
72
- });
73
-
74
- it("treats INBOX case-insensitively", () => {
75
- assert.strictEqual(mailboxSyncPriority(mailbox("inbox")), 0);
76
- assert.strictEqual(mailboxSyncPriority(mailbox("Inbox")), 0);
77
- });
78
-
79
- it("de-prioritises a folder carrying both a leading and a Junk flag", () => {
80
- assert.ok(
81
- mailboxSyncPriority(
82
- mailbox("Weird", [MailboxSpecialUse.Sent, MailboxSpecialUse.Junk]),
83
- ) > mailboxSyncPriority(mailbox("Plain")),
84
- );
85
- });
86
-
87
- it("gives unflagged user folders the normal priority", () => {
88
- assert.strictEqual(
89
- mailboxSyncPriority(mailbox("Projects")),
90
- mailboxSyncPriority(mailbox("Personal", [])),
91
- );
92
- });
93
- });
@@ -1,65 +0,0 @@
1
- import { MailboxSpecialUse } from "@remit/domain-enums";
2
-
3
- export type MailboxSyncOrderEntry = {
4
- mailboxId: string;
5
- fullPath: string;
6
- specialUse?: readonly string[];
7
- };
8
-
9
- // Sync priority by special-use, lowest first. Real mail leads (INBOX, then
10
- // Sent and Drafts), normal folders sit in the middle at NORMAL_PRIORITY, and
11
- // the low-value bulk folders (Junk/Spam, Trash) are pushed to the end so a
12
- // fresh account fills its inbox before its spam (issue #567). Keyed by the
13
- // bare runtime values from `@remit/domain-enums` (e.g. "Junk"), which match the
14
- // values stored in the Mailbox `specialUse` DynamoDB set.
15
- const INBOX_PRIORITY = 0;
16
- const NORMAL_PRIORITY = 4;
17
-
18
- const SPECIAL_USE_PRIORITY: Record<string, number> = {
19
- [MailboxSpecialUse.Sent]: 1,
20
- [MailboxSpecialUse.Drafts]: 2,
21
- [MailboxSpecialUse.Flagged]: 3,
22
- [MailboxSpecialUse.Junk]: 5,
23
- [MailboxSpecialUse.Trash]: 6,
24
- };
25
-
26
- const isInbox = (fullPath: string): boolean =>
27
- fullPath.toUpperCase() === "INBOX";
28
-
29
- /**
30
- * Sync priority for a single mailbox. INBOX always leads; otherwise the
31
- * mailbox's special-use flags resolve to a priority, defaulting to
32
- * NORMAL_PRIORITY for plain user folders and unflagged mailboxes. When a
33
- * mailbox carries both a leading flag (Sent/Drafts) and a trailing one
34
- * (Junk/Trash) — rare, but possible with misconfigured servers — the trailing
35
- * one wins: de-prioritising a bulk folder is the safer first-impression
36
- * choice.
37
- */
38
- export const mailboxSyncPriority = (entry: MailboxSyncOrderEntry): number => {
39
- if (isInbox(entry.fullPath)) return INBOX_PRIORITY;
40
-
41
- const priorities = (entry.specialUse ?? [])
42
- .map((flag) => SPECIAL_USE_PRIORITY[flag])
43
- .filter((priority): priority is number => priority !== undefined);
44
-
45
- if (priorities.length === 0) return NORMAL_PRIORITY;
46
-
47
- const trailing = priorities.filter((priority) => priority > NORMAL_PRIORITY);
48
- if (trailing.length > 0) return Math.max(...trailing);
49
-
50
- return Math.min(...priorities);
51
- };
52
-
53
- /**
54
- * Order mailboxes for sync fan-out: INBOX first, then Sent/Drafts, then normal
55
- * folders, with Junk/Spam and Trash last. Ties break alphabetically by
56
- * fullPath so the order is deterministic.
57
- */
58
- export const orderMailboxesForSync = <T extends MailboxSyncOrderEntry>(
59
- mailboxes: readonly T[],
60
- ): T[] =>
61
- [...mailboxes].sort((a, b) => {
62
- const priorityDelta = mailboxSyncPriority(a) - mailboxSyncPriority(b);
63
- if (priorityDelta !== 0) return priorityDelta;
64
- return a.fullPath.localeCompare(b.fullPath);
65
- });