@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
@@ -1,425 +0,0 @@
1
- import { getClient } from "@remit/backend/client";
2
- import { writeFolderRoleAppointment } from "@remit/backend/folder-role-appointments";
3
- import {
4
- bindImportedFolders,
5
- type ConfigBinderDeps,
6
- disableFiltersMissingFolders,
7
- } from "@remit/config-transfer";
8
- import type {
9
- AccountItem,
10
- IAccountRepository,
11
- IMailboxRepository,
12
- IMailboxSpecialUseRepository,
13
- } from "@remit/data-ports";
14
- import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
15
- import { SyncPhase } from "@remit/domain-enums";
16
- import type { Logger } from "@remit/logger";
17
- import { RefreshTokenError } from "@remit/mail-oauth-service";
18
- import {
19
- createConnectionWithCredentials,
20
- MailboxQueueService,
21
- MailboxSyncService,
22
- MailConnectionError,
23
- type MailCredentials,
24
- standardMailboxManagement,
25
- } from "@remit/mailbox-service";
26
- import pMap from "p-map";
27
- import {
28
- isAccountDeleted,
29
- isMailSyncDisabled,
30
- isUnsyncableHost,
31
- } from "../account-check.js";
32
- import { createEmitEvent, type EmitEvent } from "../emit.js";
33
- import type { SyncMailboxesEvent, SyncMessagesEvent } from "../events.js";
34
- import type { WorkerQueues } from "../queues.js";
35
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
36
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
37
- import { createImportedFolder } from "./imported-folder.js";
38
- import { orderMailboxesForSync } from "./mailbox-sync-order.js";
39
-
40
- const EVENT_EMIT_CONCURRENCY = 20;
41
-
42
- /**
43
- * How recently a mailbox must have been synced for a side-effect trigger to
44
- * leave it alone. Short enough that a folder is never more than a minute
45
- * staler than the trigger that arrived, long enough to collapse the burst a
46
- * client produces when it loads (`GET /config` triggers a sync per account)
47
- * into one round of IMAP work.
48
- *
49
- * This gates side-effect triggers only. The web client's automatic poll asks
50
- * by name and so skips it; what bounds that one is its own floor
51
- * (`MIN_POLL_INTERVAL_MS` in useStaleAccountSync, 30s).
52
- */
53
- export const DEFAULT_MAILBOX_FRESHNESS_MS = 60_000;
54
-
55
- /**
56
- * Resolve the freshness window from the environment, defaulting to production's
57
- * {@link DEFAULT_MAILBOX_FRESHNESS_MS}. Deployments that drive sync from a test
58
- * harness rather than a real client set `MAILBOX_FRESHNESS_MS` low so their
59
- * explicit nudges are never no-oped by the gate; nothing else touches it, so
60
- * production keeps the default.
61
- */
62
- export const resolveMailboxFreshnessMs = (
63
- env: NodeJS.ProcessEnv = process.env,
64
- ): number => {
65
- const value = env.MAILBOX_FRESHNESS_MS?.trim();
66
- if (!value) return DEFAULT_MAILBOX_FRESHNESS_MS;
67
- const raw = Number(value);
68
- return Number.isInteger(raw) && raw >= 0 ? raw : DEFAULT_MAILBOX_FRESHNESS_MS;
69
- };
70
-
71
- export const MAILBOX_FRESHNESS_MS = resolveMailboxFreshnessMs();
72
-
73
- /**
74
- * Whether this fan-out should enqueue a mailbox.
75
- *
76
- * A sync asked for by name — POST /sync, which is the refresh control,
77
- * pull-to-refresh, and the client's automatic poll — always syncs every
78
- * mailbox, whatever ran a moment ago. A sync that happens as a side effect of
79
- * something else (config load, OAuth connect, account create, the scheduled
80
- * tick) skips mailboxes synced inside {@link MAILBOX_FRESHNESS_MS}, which is
81
- * what stops those triggers from re-enumerating an account's folders on every
82
- * page load.
83
- *
84
- * A mailbox that has never synced is always due.
85
- */
86
- export const mailboxNeedsSync = (
87
- mailbox: { lastMessageSyncAt?: number },
88
- event: Pick<SyncMailboxesEvent, "explicitRequest">,
89
- now: number,
90
- ): boolean => {
91
- if (event.explicitRequest) return true;
92
- if (!mailbox.lastMessageSyncAt) return true;
93
- return now - mailbox.lastMessageSyncAt >= MAILBOX_FRESHNESS_MS;
94
- };
95
-
96
- export const syncMailboxes = async (
97
- event: SyncMailboxesEvent,
98
- log: Logger,
99
- queues: WorkerQueues,
100
- ): Promise<void> => {
101
- const client = await getClient();
102
- const {
103
- account: accountService,
104
- mailbox: mailboxService,
105
- mailboxSpecialUse: mailboxSpecialUseService,
106
- secrets,
107
- } = client;
108
- const binder: ConfigBinderDeps = {
109
- repositories: client,
110
- appointFolderRole: (configId, accountId, role, mailboxId, lastKnownPath) =>
111
- writeFolderRoleAppointment(
112
- client.accountSetting,
113
- configId,
114
- accountId,
115
- role as CanonicalMailboxRoleValue,
116
- mailboxId,
117
- lastKnownPath,
118
- ),
119
- createFolder: createImportedFolder(
120
- mailboxService,
121
- new MailboxQueueService({
122
- mailboxService,
123
- enqueue: standardMailboxManagement(queues.mailboxManagement),
124
- logger: log,
125
- }),
126
- ),
127
- };
128
-
129
- const { accountId } = event;
130
- log.info({ event: event.type, accountId }, "Handling event");
131
-
132
- const account = await accountService.get(accountId);
133
- if (!account) {
134
- throw new Error(`Account ${accountId} not found`);
135
- }
136
-
137
- if (isAccountDeleted(account, log)) {
138
- return;
139
- }
140
-
141
- if (isMailSyncDisabled(account, log)) {
142
- return;
143
- }
144
-
145
- // A reserved/never-resolvable IMAP host (RFC 2606) can never connect, so a
146
- // sync attempt would retry and dead-letter forever. Skip cleanly — ack the
147
- // event without connecting or throwing.
148
- if (isUnsyncableHost(account, log)) {
149
- return;
150
- }
151
-
152
- // withOAuthLifecycle owns the reauth/ACK contract (skip-if-reauth, flip on
153
- // terminal auth failure, rethrow transient). The inner try/catch only
154
- // records the terminal non-auth error phase before letting the wrapper
155
- // rethrow for SQS retry/DLQ.
156
- await withOAuthLifecycle(
157
- buildLifecycleDeps(secrets, accountService),
158
- account,
159
- log,
160
- async (credentials) => {
161
- try {
162
- await syncMailboxesForAccount(
163
- event,
164
- account,
165
- credentials,
166
- mailboxService,
167
- mailboxSpecialUseService,
168
- accountService,
169
- binder,
170
- log,
171
- createEmitEvent(queues),
172
- );
173
- } catch (err) {
174
- // Auth failures are handled by the wrapper — rethrow untouched so it
175
- // flips the account to reauth_required rather than recording an error
176
- // phase.
177
- if (
178
- err instanceof RefreshTokenError ||
179
- (err instanceof MailConnectionError && err.kind === "auth")
180
- ) {
181
- throw err;
182
- }
183
- // Record the terminal error phase before crashing (let-it-crash:
184
- // record state, then rethrow so the event is retried/DLQ'd).
185
- const message = err instanceof Error ? err.message : String(err);
186
- await accountService.update(accountId, {
187
- syncPhase: SyncPhase.error,
188
- lastError: message,
189
- });
190
- throw err;
191
- }
192
- },
193
- );
194
- };
195
-
196
- const syncMailboxesForAccount = async (
197
- event: SyncMailboxesEvent,
198
- account: AccountItem,
199
- credentials: MailCredentials,
200
- mailboxService: IMailboxRepository,
201
- mailboxSpecialUseService: IMailboxSpecialUseRepository,
202
- accountService: IAccountRepository,
203
- binder: ConfigBinderDeps,
204
- log: Logger,
205
- emit: EmitEvent,
206
- ): Promise<void> => {
207
- const { accountId } = account;
208
-
209
- const connection = createConnectionWithCredentials(
210
- {
211
- username: account.username,
212
- imapHost: account.imapHost,
213
- imapPort: account.imapPort,
214
- imapTls: account.imapTls,
215
- },
216
- credentials,
217
- );
218
-
219
- await connection.connect();
220
-
221
- await accountService.markAuthenticated(accountId);
222
-
223
- // Phase transition: discovering mailboxes
224
- await accountService.update(accountId, {
225
- syncPhase: SyncPhase.discovering_mailboxes,
226
- });
227
-
228
- const mailboxSyncService = new MailboxSyncService(
229
- mailboxService,
230
- mailboxSpecialUseService,
231
- log,
232
- );
233
-
234
- const result = await mailboxSyncService
235
- .syncMailboxes({ accountId }, connection)
236
- .finally(() => connection.disconnect());
237
-
238
- await accountService.update(accountId, { lastSyncAt: Date.now() });
239
-
240
- log.info({ result }, "Mailbox sync complete");
241
-
242
- // The folder list this account holds is now known, which is the one thing a
243
- // configuration import could not know when it ran: every folder in a file is
244
- // named by IMAP path, and the ids are minted here. Binding what is bindable
245
- // belongs at exactly this point, and is idempotent, so a later discovery
246
- // simply finds nothing left to do.
247
- const bound = await bindImportedFolders(
248
- binder,
249
- account.accountConfigId,
250
- accountId,
251
- );
252
- if (Object.values(bound).some((count) => count > 0)) {
253
- log.info({ accountId, ...bound }, "Bound imported folder references");
254
- }
255
-
256
- const disabled = await disableFiltersMissingFolders(
257
- binder.repositories,
258
- account.accountConfigId,
259
- );
260
- if (disabled > 0) {
261
- log.warn(
262
- { accountId, disabled },
263
- "Disabled filters whose move folder no longer exists",
264
- );
265
- }
266
-
267
- const allMailboxes = await collectAllMailboxes(accountId, mailboxService);
268
-
269
- // This fan-out is where an account's IMAP work is decided: one SEARCH per
270
- // mailbox per event, on a queue every account shares. `GET /config` fires a
271
- // trigger per account on every call, so without a gate here an idle client
272
- // re-enumerates every folder it owns on every page load.
273
- //
274
- // The gate is per mailbox and a sync asked for by name skips it outright.
275
- // That distinction is the whole point: the previous cooldown gated
276
- // everything, which is what made a refresh a no-op whenever a side-effect
277
- // trigger had just run (issue #37).
278
- const now = Date.now();
279
- const mailboxes = allMailboxes.filter((mailbox) =>
280
- mailboxNeedsSync(mailbox, event, now),
281
- );
282
-
283
- const skipped = allMailboxes.length - mailboxes.length;
284
- if (skipped > 0) {
285
- log.info(
286
- { accountId, skipped },
287
- "Skipped recently-synced mailboxes for a side-effect trigger",
288
- );
289
- }
290
-
291
- if (allMailboxes.length === 0) {
292
- log.info({ accountId }, "No mailboxes to sync messages for");
293
- await accountService.update(accountId, {
294
- syncPhase: SyncPhase.complete,
295
- mailboxCountTotal: 0,
296
- mailboxCountSynced: 0,
297
- });
298
- return;
299
- }
300
-
301
- if (mailboxes.length === 0) {
302
- log.info({ accountId }, "Every mailbox is fresh; nothing to sync");
303
- await accountService.update(accountId, {
304
- syncPhase: SyncPhase.complete,
305
- mailboxCountTotal: allMailboxes.length,
306
- mailboxCountSynced: allMailboxes.length,
307
- });
308
- return;
309
- }
310
-
311
- log.info(
312
- { accountId, count: mailboxes.length },
313
- "Emitting SYNC_MESSAGES events",
314
- );
315
-
316
- // Phase transition. Only the enqueued mailboxes emit a completion, so the
317
- // gated ones are pre-credited — otherwise synced could never reach total.
318
- //
319
- // The counter is progress through THIS round, not a lifetime total: a new
320
- // round restarts it, which is why it can read lower than a moment ago while
321
- // a previous round is still draining. That is the same instant
322
- // `account.lastSyncAt` is stamped, and the per-mailbox completion guard in
323
- // sync-messages.ts keys off exactly that stamp — so a completion still in
324
- // flight from the previous round counts once towards the new one, and each
325
- // mailbox counts at most once per round.
326
- const inboxEnqueued = mailboxes.some(
327
- (m) => m.fullPath.toUpperCase() === "INBOX",
328
- );
329
- await accountService.update(accountId, {
330
- syncPhase: inboxEnqueued
331
- ? SyncPhase.syncing_inbox
332
- : SyncPhase.syncing_others,
333
- mailboxCountTotal: allMailboxes.length,
334
- mailboxCountSynced: skipped,
335
- });
336
-
337
- await emitSyncMessagesEvents(accountId, mailboxes, emit);
338
- };
339
-
340
- type SyncMessagesInput = Omit<SyncMessagesEvent, "eventId" | "timestamp">;
341
-
342
- /**
343
- * Separate INBOX from the rest of the fan-out. `collectAllMailboxes` sorts it
344
- * first, but sort order alone decides nothing once the emits go out together.
345
- */
346
- export const splitInboxFirst = <T extends { fullPath: string }>(
347
- mailboxes: readonly T[],
348
- ): { inbox: T | undefined; rest: T[] } => {
349
- const at = mailboxes.findIndex(
350
- (mailbox) => mailbox.fullPath.toUpperCase() === "INBOX",
351
- );
352
- if (at === -1) return { inbox: undefined, rest: [...mailboxes] };
353
- return {
354
- inbox: mailboxes[at],
355
- rest: mailboxes.filter((_, index) => index !== at),
356
- };
357
- };
358
-
359
- /**
360
- * Emit INBOX's SYNC_MESSAGES event and wait for it before emitting the rest.
361
- *
362
- * Every event of an account shares one FIFO group (`MessageGroupId =
363
- * accountId`), so the queue serves them strictly in arrival order. Emitting
364
- * the whole list through `pMap` only bounds concurrency — it races the writes,
365
- * and INBOX could land behind up to `EVENT_EMIT_CONCURRENCY - 1` other
366
- * folders. The mail a person pressed refresh for then arrived only after every
367
- * one of those folders had finished syncing.
368
- */
369
- export const emitSyncMessagesEvents = async (
370
- accountId: string,
371
- mailboxes: readonly { mailboxId: string; fullPath: string }[],
372
- emit: (event: SyncMessagesInput) => Promise<unknown>,
373
- ): Promise<void> => {
374
- const eventFor = (mailboxId: string): SyncMessagesInput => ({
375
- type: "SYNC_MESSAGES",
376
- accountId,
377
- mailboxId,
378
- });
379
-
380
- const { inbox, rest } = splitInboxFirst(mailboxes);
381
- if (inbox) await emit(eventFor(inbox.mailboxId));
382
- await pMap(rest, ({ mailboxId }) => emit(eventFor(mailboxId)), {
383
- concurrency: EVENT_EMIT_CONCURRENCY,
384
- });
385
- };
386
-
387
- type MailboxSortEntry = {
388
- mailboxId: string;
389
- fullPath: string;
390
- lastMessageSyncAt: number;
391
- specialUse?: readonly string[];
392
- };
393
-
394
- /**
395
- * Collect all mailboxes for an account, ordered for sync fan-out by
396
- * special-use: INBOX first, then Sent/Drafts, then normal folders, with
397
- * Junk/Spam and Trash last (issue #567). Real mail dispatches ahead of bulk
398
- * folders so a fresh account fills its inbox before its spam.
399
- */
400
- const collectAllMailboxes = async (
401
- accountId: string,
402
- mailboxService: IMailboxRepository,
403
- ): Promise<MailboxSortEntry[]> => {
404
- const mailboxes: MailboxSortEntry[] = [];
405
- let continuationToken: string | undefined;
406
-
407
- do {
408
- const result = await mailboxService.listByAccount(accountId, {
409
- continuationToken,
410
- });
411
-
412
- for (const mailbox of result.items) {
413
- mailboxes.push({
414
- mailboxId: mailbox.mailboxId,
415
- fullPath: mailbox.fullPath,
416
- lastMessageSyncAt: mailbox.lastMessageSyncAt,
417
- specialUse: mailbox.specialUse,
418
- });
419
- }
420
-
421
- continuationToken = result.continuationToken ?? undefined;
422
- } while (continuationToken);
423
-
424
- return orderMailboxesForSync(mailboxes);
425
- };