@remit/mailbox-service 0.0.1

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 (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. package/tsconfig.json +8 -0
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Mailbox synchronization service
3
+ *
4
+ * Orchestrates syncing mailbox data from IMAP server to the data backend.
5
+ */
6
+
7
+ import type {
8
+ CreateMailboxInput,
9
+ IMailboxRepository,
10
+ IMailboxSpecialUseRepository,
11
+ MailboxItem,
12
+ } from "@remit/data-ports";
13
+ import {
14
+ MailboxCursorState,
15
+ MailboxSpecialUse,
16
+ NamespaceType,
17
+ } from "@remit/domain-enums";
18
+ import pMap from "p-map";
19
+ import { isNoSelect, parseImapAttributes } from "./attribute-mapper.js";
20
+ import { isCursorRebuildNeeded } from "./mailbox-cursor.js";
21
+ import type {
22
+ FlatMailboxInfo,
23
+ IImapConnection,
24
+ ImapNamespaces,
25
+ MailboxSyncResult,
26
+ } from "./types.js";
27
+
28
+ // Type for namespace type values
29
+ type NamespaceTypeValue = (typeof NamespaceType)[keyof typeof NamespaceType];
30
+ type MailboxSpecialUseValue =
31
+ (typeof MailboxSpecialUse)[keyof typeof MailboxSpecialUse];
32
+
33
+ /**
34
+ * Compare two unordered special-use lists/sets for equality. Treats `undefined`
35
+ * and an empty array as equivalent (a mailbox with no flags).
36
+ */
37
+ const areSpecialUseSetsEqual = (
38
+ a: readonly MailboxSpecialUseValue[] | undefined,
39
+ b: readonly MailboxSpecialUseValue[] | undefined,
40
+ ): boolean => {
41
+ const aArr = a ?? [];
42
+ const bArr = b ?? [];
43
+ if (aArr.length !== bArr.length) return false;
44
+ const aSet = new Set<string>(aArr);
45
+ for (const value of bArr) {
46
+ if (!aSet.has(value)) return false;
47
+ }
48
+ return true;
49
+ };
50
+
51
+ /**
52
+ * Map common folder names to their expected special-use designation.
53
+ * Used to detect duplicate folders (e.g., "Trash" vs "[Gmail]/Trash").
54
+ */
55
+ const FOLDER_NAME_TO_SPECIAL_USE: Record<string, MailboxSpecialUseValue> = {
56
+ trash: MailboxSpecialUse.Trash,
57
+ "deleted items": MailboxSpecialUse.Trash,
58
+ deleted: MailboxSpecialUse.Trash,
59
+ bin: MailboxSpecialUse.Trash,
60
+ drafts: MailboxSpecialUse.Drafts,
61
+ draft: MailboxSpecialUse.Drafts,
62
+ sent: MailboxSpecialUse.Sent,
63
+ "sent items": MailboxSpecialUse.Sent,
64
+ "sent mail": MailboxSpecialUse.Sent,
65
+ junk: MailboxSpecialUse.Junk,
66
+ spam: MailboxSpecialUse.Junk,
67
+ archive: MailboxSpecialUse.Archive,
68
+ archives: MailboxSpecialUse.Archive,
69
+ };
70
+
71
+ /**
72
+ * Account info needed for mailbox sync
73
+ */
74
+ export interface SyncAccountInfo {
75
+ accountId: string;
76
+ }
77
+
78
+ /**
79
+ * Service for synchronizing mailbox metadata between IMAP and the data backend.
80
+ */
81
+ export class MailboxSyncService {
82
+ private mailboxService: IMailboxRepository;
83
+ private specialUseService: IMailboxSpecialUseRepository;
84
+
85
+ constructor(
86
+ mailboxService: IMailboxRepository,
87
+ specialUseService: IMailboxSpecialUseRepository,
88
+ ) {
89
+ this.mailboxService = mailboxService;
90
+ this.specialUseService = specialUseService;
91
+ }
92
+
93
+ /**
94
+ * Sync all mailboxes for an account from IMAP server
95
+ *
96
+ * @param account - Account info including accountId
97
+ * @param connection - Active IMAP connection
98
+ */
99
+ syncMailboxes = async (
100
+ account: SyncAccountInfo,
101
+ connection: IImapConnection,
102
+ ): Promise<MailboxSyncResult> => {
103
+ const result: MailboxSyncResult = {
104
+ created: 0,
105
+ updated: 0,
106
+ deleted: 0,
107
+ };
108
+
109
+ // Get existing mailboxes from database
110
+ const existingMailboxes = await this.getAllMailboxes(account.accountId);
111
+ const existingByPath = new Map(
112
+ existingMailboxes.map((m) => [m.fullPath, m]),
113
+ );
114
+
115
+ // Get namespaces and mailboxes from IMAP
116
+ const namespaces = await connection.getNamespaces();
117
+ const remoteMailboxes = await this.fetchAllMailboxes(
118
+ connection,
119
+ namespaces,
120
+ );
121
+
122
+ // Build a map of special-use designations claimed by mailboxes with IMAP attributes.
123
+ // Used to:
124
+ // 1. Skip duplicate folders (e.g., "Trash" vs "[Gmail]/Trash") at sync time.
125
+ // 2. Skip duplicate localized folders (e.g., "Sent" vs "Verzonden items") when one
126
+ // of them carries the IMAP \Sent flag — issue #194.
127
+ const claimedSpecialUse = this.buildSpecialUseMap(remoteMailboxes);
128
+
129
+ // Track which paths we've seen from remote
130
+ const seenPaths = new Set<string>();
131
+
132
+ // Process each remote mailbox (concurrency 3 for IMAP pipelining)
133
+ await pMap(
134
+ remoteMailboxes,
135
+ async (mailboxInfo) => {
136
+ // Skip non-selectable mailboxes (container folders that can't hold messages)
137
+ if (isNoSelect(mailboxInfo.attributes)) {
138
+ // Mark as seen so we don't try to delete again in cleanup loop
139
+ seenPaths.add(mailboxInfo.fullPath);
140
+ // If this mailbox exists in DB, delete it
141
+ const existing = existingByPath.get(mailboxInfo.fullPath);
142
+ if (existing) {
143
+ await this.mailboxService.delete(
144
+ account.accountId,
145
+ existing.mailboxId,
146
+ );
147
+ console.info(
148
+ `Deleted non-selectable mailbox: ${existing.mailboxId} (${existing.fullPath})`,
149
+ );
150
+ result.deleted++;
151
+ }
152
+ return;
153
+ }
154
+
155
+ // Skip duplicate special-use folders (e.g., "Trash" when "[Gmail]/Trash" exists)
156
+ if (this.isDuplicateSpecialUse(mailboxInfo, claimedSpecialUse)) {
157
+ seenPaths.add(mailboxInfo.fullPath);
158
+ const existing = existingByPath.get(mailboxInfo.fullPath);
159
+ if (existing) {
160
+ await this.specialUseService.deleteByMailboxId(existing.mailboxId);
161
+ await this.mailboxService.delete(
162
+ account.accountId,
163
+ existing.mailboxId,
164
+ );
165
+ console.info(
166
+ `Deleted duplicate special-use mailbox: ${existing.mailboxId} (${existing.fullPath})`,
167
+ );
168
+ result.deleted++;
169
+ }
170
+ return;
171
+ }
172
+
173
+ seenPaths.add(mailboxInfo.fullPath);
174
+ const existing = existingByPath.get(mailboxInfo.fullPath);
175
+
176
+ if (existing) {
177
+ const updated = await this.updateMailbox(
178
+ account.accountId,
179
+ existing,
180
+ mailboxInfo,
181
+ connection,
182
+ );
183
+ if (updated) {
184
+ result.updated++;
185
+ }
186
+ } else {
187
+ await this.createMailbox(
188
+ account.accountId,
189
+ mailboxInfo,
190
+ namespaces,
191
+ connection,
192
+ );
193
+ result.created++;
194
+ }
195
+ },
196
+ { concurrency: 3 },
197
+ );
198
+
199
+ // Handle deleted mailboxes (exist in DB but not on server)
200
+ for (const existing of existingMailboxes) {
201
+ if (!seenPaths.has(existing.fullPath)) {
202
+ await this.mailboxService.delete(account.accountId, existing.mailboxId);
203
+ console.info(
204
+ `Deleted mailbox: ${existing.mailboxId} (${existing.fullPath})`,
205
+ );
206
+ result.deleted++;
207
+ }
208
+ }
209
+
210
+ return result;
211
+ };
212
+
213
+ /**
214
+ * Sync metadata for a specific mailbox
215
+ *
216
+ * Opens the mailbox to get current UID validity, counts, etc.
217
+ */
218
+ syncMailboxMetadata = async (
219
+ accountId: string,
220
+ mailboxId: string,
221
+ connection: IImapConnection,
222
+ ): Promise<MailboxItem> => {
223
+ const mailbox = await this.mailboxService.get(accountId, mailboxId);
224
+ const boxStatus = await connection.openBox(mailbox.fullPath, true);
225
+
226
+ // See `updateMailbox` above for why this trips (not skips) on a
227
+ // UIDVALIDITY change instead of silently overwriting the stored value.
228
+ const uidValidityChanged = mailbox.uidValidity !== boxStatus.uidvalidity;
229
+ const cursorTrip =
230
+ uidValidityChanged && !isCursorRebuildNeeded(mailbox.cursorState)
231
+ ? { cursorState: MailboxCursorState.cursor_invalid }
232
+ : {};
233
+
234
+ return this.mailboxService
235
+ .update(accountId, mailboxId, {
236
+ uidValidity: boxStatus.uidvalidity,
237
+ uidNext: boxStatus.uidnext,
238
+ messageCount: boxStatus.messages.total,
239
+ ...cursorTrip,
240
+ })
241
+ .finally(() => connection.closeBox(false));
242
+ };
243
+
244
+ /**
245
+ * Get all mailboxes for an account, handling pagination
246
+ */
247
+ private getAllMailboxes = async (
248
+ accountId: string,
249
+ ): Promise<MailboxItem[]> => {
250
+ const allMailboxes: MailboxItem[] = [];
251
+ let continuationToken: string | undefined;
252
+
253
+ do {
254
+ const result = await this.mailboxService.listByAccount(accountId, {
255
+ continuationToken,
256
+ });
257
+ allMailboxes.push(...result.items);
258
+ continuationToken = result.continuationToken;
259
+ } while (continuationToken);
260
+
261
+ return allMailboxes;
262
+ };
263
+
264
+ /**
265
+ * Fetch all mailboxes from IMAP server across all namespaces.
266
+ * Uses listMailboxes() to preserve original paths from the server.
267
+ */
268
+ private fetchAllMailboxes = async (
269
+ connection: IImapConnection,
270
+ namespaces: ImapNamespaces,
271
+ ): Promise<
272
+ Array<
273
+ FlatMailboxInfo & {
274
+ namespaceType: NamespaceTypeValue;
275
+ namespacePrefix: string;
276
+ }
277
+ >
278
+ > => {
279
+ // Flatten all namespaces with their types
280
+ const allNamespaces = [
281
+ ...namespaces.personal.map((ns) => ({
282
+ type: NamespaceType.Personal as NamespaceTypeValue,
283
+ prefix: ns.prefix || "",
284
+ })),
285
+ ...namespaces.other.map((ns) => ({
286
+ type: NamespaceType.OtherUsers as NamespaceTypeValue,
287
+ prefix: ns.prefix || "",
288
+ })),
289
+ ...namespaces.shared.map((ns) => ({
290
+ type: NamespaceType.Shared as NamespaceTypeValue,
291
+ prefix: ns.prefix || "",
292
+ })),
293
+ ];
294
+
295
+ // Fetch mailboxes for each namespace and flatten
296
+ const nestedResults = await Promise.all(
297
+ allNamespaces.map(async ({ type, prefix }) => {
298
+ const mailboxes = await connection.listMailboxes(prefix);
299
+ return mailboxes.map((mailbox) => ({
300
+ ...mailbox,
301
+ namespaceType: type,
302
+ namespacePrefix: prefix,
303
+ }));
304
+ }),
305
+ );
306
+ const results = nestedResults.flat();
307
+
308
+ // INBOX is implicit in IMAP and may not be returned by LIST commands
309
+ const hasInbox = results.some((m) => m.fullPath.toUpperCase() === "INBOX");
310
+ if (!hasInbox) {
311
+ const nsDelimiter = namespaces.personal[0]?.delimiter;
312
+ const delimiter = typeof nsDelimiter === "string" ? nsDelimiter : "/";
313
+ results.unshift({
314
+ fullPath: "INBOX",
315
+ name: "INBOX",
316
+ delimiter,
317
+ attributes: [],
318
+ parentPath: null,
319
+ namespaceType: NamespaceType.Personal,
320
+ namespacePrefix: "",
321
+ });
322
+ }
323
+
324
+ return results;
325
+ };
326
+
327
+ /**
328
+ * Create a new mailbox in the database
329
+ */
330
+ private createMailbox = async (
331
+ accountId: string,
332
+ mailboxInfo: FlatMailboxInfo & {
333
+ namespaceType: NamespaceTypeValue;
334
+ namespacePrefix: string;
335
+ },
336
+ _namespaces: ImapNamespaces,
337
+ connection: IImapConnection,
338
+ ): Promise<MailboxItem> => {
339
+ // Fetch mailbox status using STATUS command (doesn't require SELECT/EXAMINE)
340
+ // This gets us message counts including unseen without opening the mailbox
341
+ const status = await connection.getMailboxStatus(mailboxInfo.fullPath);
342
+
343
+ // Parse special-use attributes (RFC 6154) up front so the row stores a
344
+ // denormalized copy. Frontends list mailboxes by account; threading a join
345
+ // through MailboxSpecialUseEntry per row would be O(N) extra round-trips.
346
+ const parsed = parseImapAttributes(mailboxInfo.attributes);
347
+
348
+ const input: CreateMailboxInput = {
349
+ accountId,
350
+ namespaceType: mailboxInfo.namespaceType,
351
+ namespacePrefix: mailboxInfo.namespacePrefix,
352
+ hierarchyDelimiter: mailboxInfo.delimiter,
353
+ fullPath: mailboxInfo.fullPath,
354
+ uidValidity: status.uidValidity,
355
+ uidNext: status.uidNext,
356
+ highestModseq: status.highestModseq,
357
+ messageCount: status.messages,
358
+ unseenCount: status.unseen,
359
+ deletedCount: status.deletedCount,
360
+ totalSize: 0,
361
+ lastSyncUid: 0,
362
+ highWaterMarkUid: 0,
363
+ lastMessageSyncAt: 0,
364
+ specialUse: parsed.specialUse.length > 0 ? parsed.specialUse : undefined,
365
+ // parentMailboxId would need to be resolved from parentPath
366
+ };
367
+
368
+ const mailbox = await this.mailboxService.create(input);
369
+
370
+ // Keep the MailboxSpecialUseEntry table in sync — other services (e.g.
371
+ // MessageMoveService.findTrashMailbox) still query by entry. Denormalized
372
+ // copy on Mailbox is the read-side optimization, the entries remain the
373
+ // authoritative join source for cross-mailbox lookups.
374
+ if (parsed.specialUse.length > 0) {
375
+ await this.specialUseService.createMany(
376
+ mailbox.mailboxId,
377
+ parsed.specialUse,
378
+ );
379
+ console.info(
380
+ `Created mailbox: ${mailbox.mailboxId} (${mailboxInfo.fullPath}) [special-use: ${parsed.specialUse.join(", ")}]`,
381
+ );
382
+ } else {
383
+ console.info(
384
+ `Created mailbox: ${mailbox.mailboxId} (${mailboxInfo.fullPath})`,
385
+ );
386
+ }
387
+
388
+ return mailbox;
389
+ };
390
+
391
+ /**
392
+ * Update an existing mailbox with fresh data.
393
+ * Skips DB write if nothing has changed.
394
+ *
395
+ * @returns The updated mailbox, or null if skipped due to no changes
396
+ */
397
+ private updateMailbox = async (
398
+ accountId: string,
399
+ existing: MailboxItem,
400
+ mailboxInfo: FlatMailboxInfo,
401
+ connection: IImapConnection,
402
+ ): Promise<MailboxItem | null> => {
403
+ // Fetch mailbox status using STATUS command (doesn't require SELECT/EXAMINE)
404
+ const status = await connection.getMailboxStatus(mailboxInfo.fullPath);
405
+
406
+ const parsed = parseImapAttributes(mailboxInfo.attributes);
407
+ const specialUseChanged = !areSpecialUseSetsEqual(
408
+ existing.specialUse,
409
+ parsed.specialUse,
410
+ );
411
+
412
+ // UIDVALIDITY detection (#1272): this STATUS-based sweep persists a fresh
413
+ // uidValidity below regardless (harmless — it never touches a stored UID),
414
+ // but if the server's value disagrees with what's stored and the mailbox
415
+ // was still `normal`, the axis just changed. Trip the cursor here so the
416
+ // message-sync/flag-push/move/body-fetch paths pause outbound IMAP until
417
+ // the rebuild resolves it, instead of silently overwriting the old value
418
+ // and erasing the only evidence a bump happened.
419
+ const uidValidityChanged = existing.uidValidity !== status.uidValidity;
420
+ const cursorTrip =
421
+ uidValidityChanged && !isCursorRebuildNeeded(existing.cursorState)
422
+ ? { cursorState: MailboxCursorState.cursor_invalid }
423
+ : {};
424
+
425
+ // Check if anything actually changed
426
+ const hasChanges =
427
+ existing.uidNext !== status.uidNext ||
428
+ existing.uidValidity !== status.uidValidity ||
429
+ existing.messageCount !== status.messages ||
430
+ existing.unseenCount !== status.unseen ||
431
+ existing.deletedCount !== status.deletedCount ||
432
+ (status.highestModseq > 0 &&
433
+ existing.highestModseq !== status.highestModseq) ||
434
+ specialUseChanged;
435
+
436
+ // Sync special-use attributes (handles migration of existing mailboxes)
437
+ await this.syncSpecialUseAttributes(existing.mailboxId, mailboxInfo);
438
+
439
+ if (!hasChanges) {
440
+ return null;
441
+ }
442
+
443
+ // Debug: log what changed
444
+ const changes: string[] = [];
445
+ if (existing.uidNext !== status.uidNext)
446
+ changes.push(`uidNext: ${existing.uidNext} -> ${status.uidNext}`);
447
+ if (existing.uidValidity !== status.uidValidity)
448
+ changes.push(
449
+ `uidValidity: ${existing.uidValidity} -> ${status.uidValidity}`,
450
+ );
451
+ if (existing.messageCount !== status.messages)
452
+ changes.push(
453
+ `messageCount: ${existing.messageCount} -> ${status.messages}`,
454
+ );
455
+ if (existing.unseenCount !== status.unseen)
456
+ changes.push(`unseenCount: ${existing.unseenCount} -> ${status.unseen}`);
457
+ if (existing.deletedCount !== status.deletedCount)
458
+ changes.push(
459
+ `deletedCount: ${existing.deletedCount} -> ${status.deletedCount}`,
460
+ );
461
+ if (
462
+ status.highestModseq > 0 &&
463
+ existing.highestModseq !== status.highestModseq
464
+ )
465
+ changes.push(
466
+ `highestModseq: ${existing.highestModseq} -> ${status.highestModseq}`,
467
+ );
468
+ if (specialUseChanged)
469
+ changes.push(
470
+ `specialUse: [${(existing.specialUse ?? []).join(",")}] -> [${parsed.specialUse.join(",")}]`,
471
+ );
472
+
473
+ console.info(
474
+ `Updating mailbox: ${existing.mailboxId} (${mailboxInfo.fullPath}) [${changes.join(", ")}]`,
475
+ );
476
+
477
+ // Update mailbox with fresh status. ElectroDB rejects empty sets, so we
478
+ // pass undefined when no flags are present rather than [].
479
+ return this.mailboxService.update(accountId, existing.mailboxId, {
480
+ hierarchyDelimiter: mailboxInfo.delimiter,
481
+ uidValidity: status.uidValidity,
482
+ uidNext: status.uidNext,
483
+ highestModseq: status.highestModseq,
484
+ messageCount: status.messages,
485
+ unseenCount: status.unseen,
486
+ deletedCount: status.deletedCount,
487
+ specialUse: parsed.specialUse.length > 0 ? parsed.specialUse : undefined,
488
+ ...cursorTrip,
489
+ });
490
+ };
491
+
492
+ /**
493
+ * Sync special-use attributes for a mailbox.
494
+ * Creates entries if they don't exist, updates if changed.
495
+ */
496
+ private syncSpecialUseAttributes = async (
497
+ mailboxId: string,
498
+ mailboxInfo: FlatMailboxInfo,
499
+ ): Promise<void> => {
500
+ const parsed = parseImapAttributes(mailboxInfo.attributes);
501
+ const existingEntries =
502
+ await this.specialUseService.listByMailboxId(mailboxId);
503
+
504
+ const existingSpecialUses = new Set(
505
+ existingEntries.map((e) => e.specialUse),
506
+ );
507
+ const newSpecialUses = new Set(parsed.specialUse);
508
+
509
+ // Check if sets are equal
510
+ const areEqual =
511
+ existingSpecialUses.size === newSpecialUses.size &&
512
+ [...existingSpecialUses].every((use) => newSpecialUses.has(use));
513
+
514
+ if (areEqual) return;
515
+
516
+ // Delete and recreate (simpler than diff)
517
+ if (existingEntries.length > 0) {
518
+ await this.specialUseService.deleteByMailboxId(mailboxId);
519
+ }
520
+
521
+ if (parsed.specialUse.length > 0) {
522
+ await this.specialUseService.createMany(mailboxId, parsed.specialUse);
523
+ console.info(
524
+ `Synced special-use for ${mailboxInfo.fullPath}: ${parsed.specialUse.join(", ")}`,
525
+ );
526
+ }
527
+ };
528
+
529
+ /**
530
+ * Build a map of special-use designations to mailbox paths.
531
+ * Only includes mailboxes that have the IMAP special-use attribute.
532
+ */
533
+ private buildSpecialUseMap = (
534
+ mailboxes: FlatMailboxInfo[],
535
+ ): Map<MailboxSpecialUseValue, string> => {
536
+ const map = new Map<MailboxSpecialUseValue, string>();
537
+
538
+ for (const mailbox of mailboxes) {
539
+ const parsed = parseImapAttributes(mailbox.attributes);
540
+ for (const specialUse of parsed.specialUse) {
541
+ // First mailbox with this special-use wins (usually the canonical one)
542
+ if (!map.has(specialUse)) {
543
+ map.set(specialUse, mailbox.fullPath);
544
+ }
545
+ }
546
+ }
547
+
548
+ return map;
549
+ };
550
+
551
+ /**
552
+ * Check if a mailbox is a duplicate special-use folder.
553
+ * A folder is considered duplicate if:
554
+ * 1. Its name matches a common special-use folder name (e.g., "Trash")
555
+ * 2. It does NOT have the IMAP special-use attribute
556
+ * 3. Another folder already claimed that special-use designation
557
+ */
558
+ private isDuplicateSpecialUse = (
559
+ mailbox: FlatMailboxInfo,
560
+ claimedSpecialUse: Map<MailboxSpecialUseValue, string>,
561
+ ): boolean => {
562
+ // Get the folder name (last segment of path)
563
+ const folderName = mailbox.fullPath.split(mailbox.delimiter).pop() ?? "";
564
+ const normalizedName = folderName.toLowerCase();
565
+
566
+ // Check if this folder name maps to a special-use designation
567
+ const expectedSpecialUse = FOLDER_NAME_TO_SPECIAL_USE[normalizedName];
568
+ if (!expectedSpecialUse) {
569
+ return false; // Not a special-use folder name
570
+ }
571
+
572
+ // Check if this mailbox has the special-use attribute
573
+ const parsed = parseImapAttributes(mailbox.attributes);
574
+ if (parsed.specialUse.includes(expectedSpecialUse)) {
575
+ return false; // This IS the canonical folder
576
+ }
577
+
578
+ // Check if another folder already claimed this special-use
579
+ const claimedPath = claimedSpecialUse.get(expectedSpecialUse);
580
+ if (!claimedPath) {
581
+ return false; // No other folder has this special-use
582
+ }
583
+
584
+ // This is a duplicate - another folder has the attribute
585
+ console.info(
586
+ `Skipping duplicate folder "${mailbox.fullPath}" - "${claimedPath}" has \\${expectedSpecialUse} attribute`,
587
+ );
588
+ return true;
589
+ };
590
+ }