@remit/imap-worker 0.0.90 → 0.0.92

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 (176) 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 +22 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +29 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +9 -28
  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/dist/scheduler/handler.js +50 -0
  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 +66 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/dist/scheduler/runner.js +102 -0
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/scheduler/tick.d.ts +5 -0
  92. package/dist/scheduler/tick.js +5 -0
  93. package/dist/sqs-queues.d.ts +4 -0
  94. package/dist/sqs-queues.js +22 -0
  95. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  96. package/dist/with-oauth-lifecycle-deps.js +17 -0
  97. package/dist/with-oauth-lifecycle.d.ts +56 -0
  98. package/dist/with-oauth-lifecycle.js +92 -0
  99. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  100. package/dist/worker-version.js +12 -0
  101. package/package.json +79 -69
  102. package/src/account-check.test.ts +0 -149
  103. package/src/account-check.ts +0 -101
  104. package/src/body-sync-gate.test.ts +0 -185
  105. package/src/body-sync-gate.ts +0 -86
  106. package/src/cli.ts +0 -218
  107. package/src/connection-scope.test.ts +0 -266
  108. package/src/connection-scope.ts +0 -314
  109. package/src/credential-less-sync-fence.test.ts +0 -152
  110. package/src/e2e-processor-shim.ts +0 -232
  111. package/src/emit.test.ts +0 -145
  112. package/src/emit.ts +0 -115
  113. package/src/events.ts +0 -252
  114. package/src/failure-kind.test.ts +0 -42
  115. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  116. package/src/handlers/append-sent-message.ts +0 -264
  117. package/src/handlers/delete-account-objects.test.ts +0 -126
  118. package/src/handlers/delete-account-objects.ts +0 -96
  119. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  120. package/src/handlers/empty-trash.ts +0 -352
  121. package/src/handlers/flag-push.ts +0 -285
  122. package/src/handlers/imported-folder.test.ts +0 -63
  123. package/src/handlers/imported-folder.ts +0 -38
  124. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  125. package/src/handlers/mailbox-management.ts +0 -486
  126. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  127. package/src/handlers/mailbox-sync-order.ts +0 -65
  128. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  129. package/src/handlers/message-copy.ts +0 -569
  130. package/src/handlers/message-delete-terminal.ts +0 -147
  131. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  132. package/src/handlers/message-delete.ts +0 -888
  133. package/src/handlers/message-move.sqlite.test.ts +0 -271
  134. package/src/handlers/message-move.test.ts +0 -216
  135. package/src/handlers/message-move.ts +0 -755
  136. package/src/handlers/placement-move-push.test.ts +0 -137
  137. package/src/handlers/placement-move-push.ts +0 -478
  138. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  139. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  140. package/src/handlers/probe-message-id.ts +0 -15
  141. package/src/handlers/sync-mailboxes.test.ts +0 -183
  142. package/src/handlers/sync-mailboxes.ts +0 -425
  143. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  144. package/src/handlers/sync-message-body.test.ts +0 -150
  145. package/src/handlers/sync-message-body.ts +0 -366
  146. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  147. package/src/handlers/sync-messages.ts +0 -537
  148. package/src/index.test.ts +0 -22
  149. package/src/index.ts +0 -82
  150. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  151. package/src/mailbox-row.ts +0 -20
  152. package/src/pending-sync.sqlite.test.ts +0 -165
  153. package/src/poller.ts +0 -55
  154. package/src/processor.ts +0 -62
  155. package/src/queues.ts +0 -40
  156. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  157. package/src/scheduler/attachment-sweep.ts +0 -46
  158. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  159. package/src/scheduler/calendar-subscriptions.ts +0 -103
  160. package/src/scheduler/config.test.ts +0 -40
  161. package/src/scheduler/decide-due.test.ts +0 -44
  162. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  163. package/src/scheduler/handler.ts +0 -69
  164. package/src/scheduler/loop.test.ts +0 -143
  165. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  166. package/src/scheduler/run-tick.ts +0 -281
  167. package/src/scheduler/runner.ts +0 -125
  168. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  169. package/src/stranded-placement.sqlite.test.ts +0 -799
  170. package/src/test-helpers/test-world.ts +0 -425
  171. package/src/test-helpers/worker-queues.ts +0 -11
  172. package/src/thread-join.sqlite.test.ts +0 -106
  173. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  174. package/src/with-oauth-lifecycle-deps.ts +0 -71
  175. package/src/with-oauth-lifecycle.ts +0 -158
  176. package/tsconfig.json +0 -8
@@ -0,0 +1,294 @@
1
+ import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
2
+ import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
3
+ import { MailboxSyncStatus } from "@remit/domain-enums";
4
+ import { FolderGoneUpstreamError, FolderRenameSettleError, isMailboxAbsentUpstream, isMailboxPresentUpstream, isMailboxRefusedUpstream, MailboxManagementService, upstreamFailureReason, } from "@remit/mailbox-service";
5
+ import { isAccountDeleted } from "../account-check.js";
6
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
7
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
8
+ /**
9
+ * The outcome of a create, written against the state a create in flight is:
10
+ * `pending` with no recorded rename target (D3, D10). Without the absence
11
+ * check, a create redelivered after a lost acknowledgement settles a row a
12
+ * rename has since claimed and kills the rename silently.
13
+ */
14
+ const recordCreateOutcome = async (mailboxService, accountId, mailboxId, to, syncFailureReason = "") => {
15
+ const written = await mailboxService.transition(accountId, mailboxId, {
16
+ from: [MailboxSyncStatus.pending],
17
+ wherePendingPath: null,
18
+ to,
19
+ set: { syncFailureReason },
20
+ });
21
+ if (!written)
22
+ throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
23
+ };
24
+ /**
25
+ * Pinned invariant for the whole-chain terminal guards below.
26
+ *
27
+ * Each handler wraps its `MailboxManagementService.sync*` chain in a try/catch
28
+ * that treats a NotFoundError as terminal (ack with a WARN, issue #289). That is
29
+ * only sound while the sole row a NotFoundError can refer to is the target
30
+ * mailbox, so its absence can only mean the user deleted the folder mid-sync —
31
+ * never an unrelated missing entity that should have been retried.
32
+ *
33
+ * `syncCreate` and `syncDelete` hold to that by touching nothing but the target
34
+ * row. `syncRename` also writes every other row that recorded the same intent,
35
+ * and keeps the invariant by treating a lost predicate as an outcome rather than
36
+ * an error: a descendant deleted mid-settle is skipped, never raised. The
37
+ * appointment-label refresh writes `account_setting` rows, whose reads answer
38
+ * absent rather than throwing. Any sync method that reads or writes a second
39
+ * entity owes the same, or these catches must be narrowed (match the mailboxId /
40
+ * re-check existence) before a NotFoundError from elsewhere is silently acked.
41
+ */
42
+ /**
43
+ * Handle MAILBOX_CREATE event
44
+ */
45
+ const handleCreate = async (event, ctx) => {
46
+ const { log, pool } = ctx;
47
+ const { account: accountService, mailbox: mailboxService, secrets, } = ctx.client;
48
+ const { accountId, mailboxId, path, subscribe } = event;
49
+ log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
50
+ const account = await accountService.get(accountId);
51
+ if (!account) {
52
+ throw new Error(`Account ${accountId} not found`);
53
+ }
54
+ if (isAccountDeleted(account, log)) {
55
+ return;
56
+ }
57
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
58
+ const scope = pool.borrow(account, credentials);
59
+ const managementService = new MailboxManagementService(mailboxService, log);
60
+ try {
61
+ await managementService
62
+ .syncCreate(accountId, mailboxId, path, scope.getConnection, subscribe)
63
+ .then((result) => {
64
+ if (result.success) {
65
+ log.info({
66
+ accountId,
67
+ mailboxId,
68
+ path,
69
+ existingMailboxId: result.existing?.mailboxId,
70
+ }, "Mailbox created on IMAP");
71
+ }
72
+ else {
73
+ log.error({ accountId, mailboxId, path, error: result.error }, "Failed to create mailbox on IMAP");
74
+ }
75
+ })
76
+ .catch(async (error) => {
77
+ // Check if mailbox already exists (idempotent)
78
+ if (isMailboxPresentUpstream(error)) {
79
+ log.info({ accountId, mailboxId, path }, "Mailbox already exists, marking as synced");
80
+ await recordCreateOutcome(mailboxService, accountId, mailboxId, MailboxSyncStatus.synced);
81
+ }
82
+ else {
83
+ await recordCreateOutcome(mailboxService, accountId, mailboxId, MailboxSyncStatus.failed, upstreamFailureReason(error));
84
+ if (isMailboxRefusedUpstream(error)) {
85
+ log.error({ accountId, mailboxId, path, intent: "create", error }, "Mail server refused the create");
86
+ return;
87
+ }
88
+ throw error;
89
+ }
90
+ })
91
+ .finally(() => scope.release());
92
+ }
93
+ catch (error) {
94
+ // The mailbox row was deleted between enqueue and this sync — the
95
+ // create-then-status-write throws NotFoundError, which can never
96
+ // succeed on retry and would poison the account's per-group FIFO
97
+ // forever (issue #289, same class as #287/#290). A folder the user
98
+ // deleted is an expected terminal outcome: ack with a WARN. Real
99
+ // IMAP/infra failures carry other errors and still propagate.
100
+ if (isNotFoundError(error)) {
101
+ log.warn({ accountId, mailboxId, path, eventId: event.eventId }, "Skipping MAILBOX_CREATE: mailbox no longer exists (deleted)");
102
+ return;
103
+ }
104
+ throw error;
105
+ }
106
+ });
107
+ };
108
+ /**
109
+ * Handle MAILBOX_RENAME event
110
+ */
111
+ const handleRename = async (event, ctx) => {
112
+ const { log, pool } = ctx;
113
+ const { account: accountService, accountSetting: accountSettingService, mailbox: mailboxService, secrets, } = ctx.client;
114
+ const { accountId, mailboxId, oldPath, newPath } = event;
115
+ log.info({ event: event.type, accountId, mailboxId, oldPath, newPath }, "Handling event");
116
+ const account = await accountService.get(accountId);
117
+ if (!account) {
118
+ throw new Error(`Account ${accountId} not found`);
119
+ }
120
+ if (isAccountDeleted(account, log)) {
121
+ return;
122
+ }
123
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
124
+ const scope = pool.borrow(account, credentials);
125
+ const managementService = new MailboxManagementService(mailboxService, log);
126
+ try {
127
+ await managementService
128
+ .syncRename(accountId, mailboxId, oldPath, newPath, scope.getConnection)
129
+ .then(async (result) => {
130
+ if (!result.success) {
131
+ log.error({ accountId, mailboxId, oldPath, newPath, error: result.error }, "Failed to rename mailbox on IMAP");
132
+ return;
133
+ }
134
+ log.info({ accountId, mailboxId, oldPath, newPath }, "Mailbox renamed on IMAP");
135
+ if (!result.renamed)
136
+ return;
137
+ // The path recorded beside each role appointment (#887) moves with
138
+ // the settle, because under D2 that is the first moment the new
139
+ // path is one the server holds.
140
+ //
141
+ // A label is display-only and this runs after the server rename
142
+ // landed, so letting it fail would take the job down the failure
143
+ // path and mark a completed rename refused. Logged and dropped;
144
+ // the next appointment write records the folder's current path
145
+ // anyway.
146
+ await refreshFolderAppointmentLabels(accountSettingService, account.accountConfigId, accountId, {
147
+ mailboxId,
148
+ oldPath: result.renamed.oldPath,
149
+ newPath: result.renamed.newPath,
150
+ }, result.renamed.delimiter).catch((error) => {
151
+ log.error({ accountId, mailboxId, oldPath, newPath, error }, "Renamed folder settled, but its role-appointment labels did not move");
152
+ });
153
+ })
154
+ .catch(async (error) => {
155
+ // The server executed the rename and only the local settle did not
156
+ // finish. That is not a refused rename: marking the rows `failed`
157
+ // would offer a retry of work already done and leave `fullPath`
158
+ // naming a path the server no longer holds. Rethrow, so the
159
+ // redelivery finishes the settle against an intent still standing.
160
+ if (error instanceof FolderRenameSettleError) {
161
+ log.error({ accountId, mailboxId, oldPath, newPath, error }, "Rename landed on the server; the settle will finish on redelivery");
162
+ throw error;
163
+ }
164
+ // The folder the rename was to move is confirmed gone: the
165
+ // server refused the RENAME as non-existent and its own listing
166
+ // held neither path. The rename decides that, once, where the
167
+ // evidence is — re-reading the response code here would make
168
+ // every `NONEXISTENT` this code cannot classify destroy the
169
+ // folder's mail. Removing the root row alone is the orphaning
170
+ // bug: every descendant recorded the same intent (D6) and would
171
+ // be left `pending` at a path that does not exist, with its mail
172
+ // stranded out of every reader (D8).
173
+ if (error instanceof FolderGoneUpstreamError) {
174
+ log.info({ accountId, mailboxId, oldPath, intent: "rename" }, "Source mailbox not found, deleting local folder and its mail");
175
+ await managementService.abandonRenameSubtree(accountId, mailboxId, oldPath, newPath);
176
+ return;
177
+ }
178
+ // T6. Nothing is restored — `fullPath` was never written, and
179
+ // each intent-carrying row keeps its target so the client can
180
+ // name what the rename was aiming at and offer a retry.
181
+ await managementService.failRename(accountId, mailboxId, oldPath, newPath, upstreamFailureReason(error));
182
+ if (isMailboxRefusedUpstream(error)) {
183
+ log.error({
184
+ accountId,
185
+ mailboxId,
186
+ oldPath,
187
+ newPath,
188
+ intent: "rename",
189
+ error,
190
+ }, "Mail server refused the rename");
191
+ return;
192
+ }
193
+ throw error;
194
+ })
195
+ .finally(() => scope.release());
196
+ }
197
+ catch (error) {
198
+ // The mailbox row was deleted between enqueue and this sync — the
199
+ // status write-back throws NotFoundError, unretryable and would poison
200
+ // the account's per-group FIFO forever (issue #289 class). Ack with a
201
+ // WARN; real IMAP/infra failures carry other errors and still propagate.
202
+ if (isNotFoundError(error)) {
203
+ log.warn({ accountId, mailboxId, oldPath, newPath, eventId: event.eventId }, "Skipping MAILBOX_RENAME: mailbox no longer exists (deleted)");
204
+ return;
205
+ }
206
+ throw error;
207
+ }
208
+ });
209
+ };
210
+ /**
211
+ * Handle MAILBOX_DELETE event
212
+ */
213
+ const handleDelete = async (event, ctx) => {
214
+ const { log, pool } = ctx;
215
+ const { account: accountService, mailbox: mailboxService, secrets, } = ctx.client;
216
+ const { accountId, mailboxId, path } = event;
217
+ log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
218
+ const account = await accountService.get(accountId);
219
+ if (!account) {
220
+ throw new Error(`Account ${accountId} not found`);
221
+ }
222
+ if (isAccountDeleted(account, log)) {
223
+ return;
224
+ }
225
+ await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
226
+ const scope = pool.borrow(account, credentials);
227
+ const managementService = new MailboxManagementService(mailboxService, log);
228
+ try {
229
+ await managementService
230
+ .syncDelete(accountId, mailboxId, path, scope.getConnection)
231
+ .then((result) => {
232
+ if (result.success) {
233
+ log.info({ accountId, mailboxId, path }, "Mailbox deleted on IMAP");
234
+ }
235
+ else {
236
+ log.error({ accountId, mailboxId, path, error: result.error }, "Failed to delete mailbox on IMAP");
237
+ }
238
+ })
239
+ .catch(async (error) => {
240
+ // The folder is already gone from the server, so the delete has
241
+ // happened. Its mail still goes with it (D8), which is the whole
242
+ // point of the settle.
243
+ if (isMailboxAbsentUpstream(error)) {
244
+ log.info({ accountId, mailboxId, path, intent: "delete" }, "Mailbox not found on IMAP, removing the local folder and its mail");
245
+ await managementService.settleDelete(accountId, mailboxId);
246
+ }
247
+ else {
248
+ // T9, INBOX included. The API refuses a delete of INBOX, so the
249
+ // worker's backstop is unreachable from it; if something else
250
+ // reaches it, the honest outcome is `failed`, not `synced` —
251
+ // the folder was never deleted and nothing was undone.
252
+ await managementService.failDelete(accountId, mailboxId, upstreamFailureReason(error));
253
+ if (error instanceof Error &&
254
+ error.message.includes("Cannot delete INBOX")) {
255
+ log.error({ accountId, mailboxId, path, intent: "delete" }, "Cannot delete INBOX");
256
+ // Don't rethrow — no retry can make this succeed.
257
+ return;
258
+ }
259
+ if (isMailboxRefusedUpstream(error)) {
260
+ log.error({ accountId, mailboxId, path, intent: "delete", error }, "Mail server refused the delete");
261
+ return;
262
+ }
263
+ throw error;
264
+ }
265
+ })
266
+ .finally(() => scope.release());
267
+ }
268
+ catch (error) {
269
+ // The mailbox row was already deleted (a duplicate/racing delete) — the
270
+ // error-recovery status write throws NotFoundError, unretryable and
271
+ // would poison the account's per-group FIFO forever (issue #289 class).
272
+ // The delete has effectively happened: ack with a WARN. Real IMAP/infra
273
+ // failures carry other errors and still propagate.
274
+ if (isNotFoundError(error)) {
275
+ log.warn({ accountId, mailboxId, path, eventId: event.eventId }, "Skipping MAILBOX_DELETE: mailbox no longer exists (deleted)");
276
+ return;
277
+ }
278
+ throw error;
279
+ }
280
+ });
281
+ };
282
+ /**
283
+ * Process mailbox management events
284
+ */
285
+ export const processMailboxManagement = async (event, ctx) => {
286
+ switch (event.type) {
287
+ case "MAILBOX_CREATE":
288
+ return handleCreate(event, ctx);
289
+ case "MAILBOX_RENAME":
290
+ return handleRename(event, ctx);
291
+ case "MAILBOX_DELETE":
292
+ return handleDelete(event, ctx);
293
+ }
294
+ };
@@ -0,0 +1,21 @@
1
+ export type MailboxSyncOrderEntry = {
2
+ mailboxId: string;
3
+ fullPath: string;
4
+ specialUse?: readonly string[];
5
+ };
6
+ /**
7
+ * Sync priority for a single mailbox. INBOX always leads; otherwise the
8
+ * mailbox's special-use flags resolve to a priority, defaulting to
9
+ * NORMAL_PRIORITY for plain user folders and unflagged mailboxes. When a
10
+ * mailbox carries both a leading flag (Sent/Drafts) and a trailing one
11
+ * (Junk/Trash) — rare, but possible with misconfigured servers — the trailing
12
+ * one wins: de-prioritising a bulk folder is the safer first-impression
13
+ * choice.
14
+ */
15
+ export declare const mailboxSyncPriority: (entry: MailboxSyncOrderEntry) => number;
16
+ /**
17
+ * Order mailboxes for sync fan-out: INBOX first, then Sent/Drafts, then normal
18
+ * folders, with Junk/Spam and Trash last. Ties break alphabetically by
19
+ * fullPath so the order is deterministic.
20
+ */
21
+ export declare const orderMailboxesForSync: <T extends MailboxSyncOrderEntry>(mailboxes: readonly T[]) => T[];
@@ -0,0 +1,50 @@
1
+ import { MailboxSpecialUse } from "@remit/domain-enums";
2
+ // Sync priority by special-use, lowest first. Real mail leads (INBOX, then
3
+ // Sent and Drafts), normal folders sit in the middle at NORMAL_PRIORITY, and
4
+ // the low-value bulk folders (Junk/Spam, Trash) are pushed to the end so a
5
+ // fresh account fills its inbox before its spam (issue #567). Keyed by the
6
+ // bare runtime values from `@remit/domain-enums` (e.g. "Junk"), which match the
7
+ // values stored in the Mailbox `specialUse` DynamoDB set.
8
+ const INBOX_PRIORITY = 0;
9
+ const NORMAL_PRIORITY = 4;
10
+ const SPECIAL_USE_PRIORITY = {
11
+ [MailboxSpecialUse.Sent]: 1,
12
+ [MailboxSpecialUse.Drafts]: 2,
13
+ [MailboxSpecialUse.Flagged]: 3,
14
+ [MailboxSpecialUse.Junk]: 5,
15
+ [MailboxSpecialUse.Trash]: 6,
16
+ };
17
+ const isInbox = (fullPath) => fullPath.toUpperCase() === "INBOX";
18
+ /**
19
+ * Sync priority for a single mailbox. INBOX always leads; otherwise the
20
+ * mailbox's special-use flags resolve to a priority, defaulting to
21
+ * NORMAL_PRIORITY for plain user folders and unflagged mailboxes. When a
22
+ * mailbox carries both a leading flag (Sent/Drafts) and a trailing one
23
+ * (Junk/Trash) — rare, but possible with misconfigured servers — the trailing
24
+ * one wins: de-prioritising a bulk folder is the safer first-impression
25
+ * choice.
26
+ */
27
+ export const mailboxSyncPriority = (entry) => {
28
+ if (isInbox(entry.fullPath))
29
+ return INBOX_PRIORITY;
30
+ const priorities = (entry.specialUse ?? [])
31
+ .map((flag) => SPECIAL_USE_PRIORITY[flag])
32
+ .filter((priority) => priority !== undefined);
33
+ if (priorities.length === 0)
34
+ return NORMAL_PRIORITY;
35
+ const trailing = priorities.filter((priority) => priority > NORMAL_PRIORITY);
36
+ if (trailing.length > 0)
37
+ return Math.max(...trailing);
38
+ return Math.min(...priorities);
39
+ };
40
+ /**
41
+ * Order mailboxes for sync fan-out: INBOX first, then Sent/Drafts, then normal
42
+ * folders, with Junk/Spam and Trash last. Ties break alphabetically by
43
+ * fullPath so the order is deterministic.
44
+ */
45
+ export const orderMailboxesForSync = (mailboxes) => [...mailboxes].sort((a, b) => {
46
+ const priorityDelta = mailboxSyncPriority(a) - mailboxSyncPriority(b);
47
+ if (priorityDelta !== 0)
48
+ return priorityDelta;
49
+ return a.fullPath.localeCompare(b.fullPath);
50
+ });
@@ -0,0 +1,40 @@
1
+ import type { ProcessContext } from "../context.js";
2
+ import type { MessageCopyEvent } from "../events.js";
3
+ /**
4
+ * Where the destination says this copy is. `unprobeable` is the row that
5
+ * carries no usable Message-ID header (drafts, automated senders and bounces
6
+ * do arrive without one): the server was never
7
+ * asked, so its silence says nothing.
8
+ */
9
+ export type CopyProbe = {
10
+ kind: "confirmed";
11
+ uid: number;
12
+ } | {
13
+ kind: "absent";
14
+ } | {
15
+ kind: "unprobeable";
16
+ };
17
+ /**
18
+ * Handle MESSAGE_COPY events.
19
+ * Executes IMAP COPY command and updates local state with new UID.
20
+ *
21
+ * IMAP COPY is not idempotent and the message queue is FIFO per account, so
22
+ * this handler never manufactures a retry to resolve an ambiguous answer: a
23
+ * second COPY duplicates the mail, and a record that keeps coming back blocks
24
+ * every later move, copy and delete for that account (issues #287, #289,
25
+ * #290). Ambiguity is resolved in place, by asking the destination.
26
+ *
27
+ * A fault the server threw is still retried up to
28
+ * the `config.attempts.messageCopy` budget, because it may be a dropped connection.
29
+ * Two guards make that safe: a redelivery asks the destination BEFORE issuing
30
+ * COPY again, and the last attempt settles the row instead of dead-lettering
31
+ * it. The copy row is a reconcile-model dependent (`copySettledMessage` writes
32
+ * its ThreadMessage row at `uid: 0` before the server has confirmed anything,
33
+ * docs/architecture/imap-mutations.md R2) and these terminal settles are its
34
+ * reconciliation path: every verdict the server gives an answer for settles the
35
+ * row out of `uid: 0`/`moving` (#1097). A server that cannot be reached at all
36
+ * gives no verdict — the record dead-letters with the row untouched, the same
37
+ * caveat `resolveExhaustedMessageMoveFailure` carries, because absence is only
38
+ * ever concluded from an answer.
39
+ */
40
+ export declare const handleMessageCopy: (event: MessageCopyEvent, ctx: ProcessContext, receiveCount: number) => Promise<void>;