@remit/mailbox-service 0.0.111 → 0.0.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +19 -77
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +39 -63
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +126 -0
  78. package/dist/mailbox-queue.js +214 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +284 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +19 -50
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -101
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. package/tsconfig.json +0 -8
@@ -1,848 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- IMailboxRepository,
5
- IMailboxSpecialUseRepository,
6
- } from "@remit/data-ports";
7
- import { NotFoundError } from "@remit/data-ports/errors";
8
- import {
9
- MailboxCursorState,
10
- MailboxSpecialUse,
11
- MailboxSyncStatus,
12
- } from "@remit/domain-enums";
13
- import { parseImapAttributes } from "./attribute-mapper.js";
14
- import { type MailboxSyncLogger, MailboxSyncService } from "./mailbox-sync.js";
15
-
16
- const silentLogger: MailboxSyncLogger = {
17
- info: () => {},
18
- debug: () => {},
19
- error: () => {},
20
- };
21
-
22
- import type { IImapConnection, ImapNamespaces } from "./types.js";
23
-
24
- describe("parseImapAttributes – locale invariance (#194)", () => {
25
- it("recognizes \\Sent regardless of folder name", () => {
26
- const dutch = parseImapAttributes(["\\HasNoChildren", "\\Sent"]);
27
- assert.deepEqual(dutch.specialUse, [MailboxSpecialUse.Sent]);
28
-
29
- const english = parseImapAttributes(["\\Sent"]);
30
- assert.deepEqual(english.specialUse, [MailboxSpecialUse.Sent]);
31
- });
32
-
33
- it("recognizes \\Drafts on a localized 'Concepten' folder", () => {
34
- // IMAP server tells us the flag — the folder name is irrelevant.
35
- const parsed = parseImapAttributes(["\\Drafts"]);
36
- assert.deepEqual(parsed.specialUse, [MailboxSpecialUse.Drafts]);
37
- });
38
-
39
- it("returns an empty list when no flag is present", () => {
40
- // A real user folder like Outlook NL's "Nieuwsbrieven" carries no
41
- // SPECIAL-USE attributes — leave it untouched downstream.
42
- const parsed = parseImapAttributes(["\\HasNoChildren"]);
43
- assert.deepEqual(parsed.specialUse, []);
44
- });
45
-
46
- it("normalizes case: \\sent is treated the same as \\Sent", () => {
47
- const parsed = parseImapAttributes(["\\sent"]);
48
- assert.deepEqual(parsed.specialUse, [MailboxSpecialUse.Sent]);
49
- });
50
- });
51
-
52
- describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#1272)", () => {
53
- const namespaces: ImapNamespaces = {
54
- personal: [{ prefix: "", delimiter: "/" }],
55
- other: [],
56
- shared: [],
57
- };
58
-
59
- const buildConnection = (
60
- uidValidity: number,
61
- highestModseq = "0",
62
- ): IImapConnection =>
63
- ({
64
- getNamespaces: async () => namespaces,
65
- listMailboxes: async () => [
66
- {
67
- fullPath: "INBOX",
68
- name: "INBOX",
69
- delimiter: "/",
70
- attributes: [],
71
- parentPath: null,
72
- },
73
- ],
74
- getMailboxStatus: async () => ({
75
- messages: 5,
76
- recent: 0,
77
- unseen: 1,
78
- uidNext: 100,
79
- uidValidity,
80
- highestModseq,
81
- deletedCount: 0,
82
- }),
83
- }) as unknown as IImapConnection;
84
-
85
- const buildServices = (
86
- existingUidValidity: number,
87
- existingCursorState?: string,
88
- ) => {
89
- const updateCalls: Array<Record<string, unknown>> = [];
90
- const mailboxService = {
91
- listByAccount: async () => ({
92
- items: [
93
- {
94
- mailboxId: "mbx-1",
95
- fullPath: "INBOX",
96
- uidNext: 100,
97
- uidValidity: existingUidValidity,
98
- messageCount: 5,
99
- unseenCount: 1,
100
- deletedCount: 0,
101
- highestModseq: "0",
102
- specialUse: undefined,
103
- cursorState: existingCursorState,
104
- },
105
- ],
106
- continuationToken: undefined,
107
- }),
108
- update: async (
109
- _accountId: string,
110
- _mailboxId: string,
111
- patch: Record<string, unknown>,
112
- ) => {
113
- updateCalls.push(patch);
114
- return {};
115
- },
116
- delete: async () => undefined,
117
- create: async () => ({
118
- outcome: "Created",
119
- mailbox: { mailboxId: "new" },
120
- }),
121
- } as unknown as IMailboxRepository;
122
-
123
- const specialUseService = {
124
- listByMailboxId: async () => [],
125
- deleteByMailboxId: async () => undefined,
126
- createMany: async () => undefined,
127
- } as unknown as IMailboxSpecialUseRepository;
128
-
129
- return { mailboxService, specialUseService, updateCalls };
130
- };
131
-
132
- it("trips cursor_invalid when the STATUS sweep observes a changed UIDVALIDITY", async () => {
133
- const { mailboxService, specialUseService, updateCalls } = buildServices(1);
134
- const service = new MailboxSyncService(
135
- mailboxService,
136
- specialUseService,
137
- silentLogger,
138
- );
139
- const connection = buildConnection(2);
140
-
141
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
142
-
143
- const uidValidityUpdate = updateCalls.find((c) => "uidValidity" in c);
144
- assert.ok(uidValidityUpdate, "expected the sweep to write the mailbox");
145
- assert.equal(
146
- uidValidityUpdate?.cursorState,
147
- MailboxCursorState.cursor_invalid,
148
- );
149
- });
150
-
151
- it("does not write anything when UIDVALIDITY (and everything else) is unchanged", async () => {
152
- const { mailboxService, specialUseService, updateCalls } = buildServices(1);
153
- const service = new MailboxSyncService(
154
- mailboxService,
155
- specialUseService,
156
- silentLogger,
157
- );
158
- const connection = buildConnection(1);
159
-
160
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
161
-
162
- assert.equal(updateCalls.length, 0);
163
- });
164
-
165
- it("never writes the message-sync cursor, whatever the server reports", async () => {
166
- // `highestModseq` on the mailbox row is message sync's own cursor over
167
- // applied changes, not a status projection. The sweep overwriting it with
168
- // the server's current value would step it over every change message sync
169
- // had not yet applied.
170
- const { mailboxService, specialUseService, updateCalls } = buildServices(1);
171
- const service = new MailboxSyncService(
172
- mailboxService,
173
- specialUseService,
174
- silentLogger,
175
- );
176
- const connection = buildConnection(2, "99999");
177
-
178
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
179
-
180
- for (const call of updateCalls) {
181
- assert.equal("highestModseq" in call, false);
182
- }
183
- });
184
-
185
- it("does not sweep-write a mailbox whose only difference is the server mod-sequence", async () => {
186
- const { mailboxService, specialUseService, updateCalls } = buildServices(1);
187
- const service = new MailboxSyncService(
188
- mailboxService,
189
- specialUseService,
190
- silentLogger,
191
- );
192
- const connection = buildConnection(1, "4242");
193
-
194
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
195
-
196
- assert.equal(updateCalls.length, 0);
197
- });
198
-
199
- it("does not re-trip (no cursorState write) when the mailbox is already cursor_invalid", async () => {
200
- const { mailboxService, specialUseService, updateCalls } = buildServices(
201
- 1,
202
- MailboxCursorState.cursor_invalid,
203
- );
204
- const service = new MailboxSyncService(
205
- mailboxService,
206
- specialUseService,
207
- silentLogger,
208
- );
209
- const connection = buildConnection(2);
210
-
211
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
212
-
213
- const uidValidityUpdate = updateCalls.find((c) => "uidValidity" in c);
214
- assert.ok(uidValidityUpdate);
215
- assert.equal("cursorState" in (uidValidityUpdate ?? {}), false);
216
- });
217
- });
218
-
219
- describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending folders (#290)", () => {
220
- const namespaces: ImapNamespaces = {
221
- personal: [{ prefix: "", delimiter: "/" }],
222
- other: [],
223
- shared: [],
224
- };
225
-
226
- // The server lists only INBOX unless a test says otherwise: neither user
227
- // folder is on it yet.
228
- const serverConnection = (
229
- folders: Array<{ fullPath: string; attributes?: string[] }> = [
230
- { fullPath: "INBOX" },
231
- ],
232
- ): IImapConnection =>
233
- ({
234
- getNamespaces: async () => namespaces,
235
- listMailboxes: async () =>
236
- folders.map((folder) => ({
237
- fullPath: folder.fullPath,
238
- name: folder.fullPath.split("/").pop() ?? folder.fullPath,
239
- delimiter: "/",
240
- attributes: folder.attributes ?? [],
241
- parentPath: null,
242
- })),
243
- getMailboxStatus: async () => ({
244
- messages: 0,
245
- recent: 0,
246
- unseen: 0,
247
- uidNext: 1,
248
- uidValidity: 1,
249
- highestModseq: "0",
250
- deletedCount: 0,
251
- }),
252
- openBox: async () => ({
253
- uidvalidity: 1,
254
- uidnext: 1,
255
- messages: { total: 0 },
256
- }),
257
- closeBox: async () => undefined,
258
- }) as unknown as IImapConnection;
259
-
260
- const buildServices = (
261
- existing: Array<{
262
- mailboxId: string;
263
- fullPath: string;
264
- syncStatus?: string;
265
- pendingPath?: string;
266
- }>,
267
- createOutcome: "Created" | "PathTaken" = "Created",
268
- ) => {
269
- const deleted: string[] = [];
270
- const created: Array<Record<string, unknown>> = [];
271
- const mailboxService = {
272
- listByAccount: async () => ({
273
- items: existing.map((m) => ({
274
- mailboxId: m.mailboxId,
275
- fullPath: m.fullPath,
276
- uidNext: 1,
277
- uidValidity: 1,
278
- messageCount: 0,
279
- unseenCount: 0,
280
- deletedCount: 0,
281
- highestModseq: "0",
282
- specialUse: undefined,
283
- syncStatus: m.syncStatus,
284
- pendingPath: m.pendingPath,
285
- })),
286
- continuationToken: undefined,
287
- }),
288
- update: async () => ({}),
289
- delete: async (_accountId: string, mailboxId: string) => {
290
- deleted.push(mailboxId);
291
- },
292
- create: async (input: Record<string, unknown>) => {
293
- created.push(input);
294
- if (createOutcome === "PathTaken") return { outcome: "PathTaken" };
295
- return {
296
- outcome: "Created",
297
- mailbox: { ...input, mailboxId: `new-${created.length}` },
298
- };
299
- },
300
- } as unknown as IMailboxRepository;
301
-
302
- const specialUseService = {
303
- listByMailboxId: async () => [],
304
- deleteByMailboxId: async () => undefined,
305
- createMany: async () => undefined,
306
- } as unknown as IMailboxSpecialUseRepository;
307
-
308
- return { mailboxService, specialUseService, deleted, created };
309
- };
310
-
311
- for (const syncStatus of [
312
- MailboxSyncStatus.pending,
313
- MailboxSyncStatus.deleting,
314
- ]) {
315
- it(`keeps a \`${syncStatus}\` folder the server has not listed`, async () => {
316
- // `pending`: the folder was just created locally and MAILBOX_CREATE has
317
- // not reached the server, so the LIST omits it — deleting the row races
318
- // the create and wedges the account's FIFO group for a visibility
319
- // window (#290).
320
- //
321
- // `deleting`: the delete has landed on the server and the worker has not
322
- // written back. Hard-deleting the row removes it under the client
323
- // holding its id — the "Mailbox not found" of #333 — and removes it
324
- // without the folder's mail, which the worker owns (D8).
325
- const { mailboxService, specialUseService, deleted } = buildServices([
326
- {
327
- mailboxId: "inbox",
328
- fullPath: "INBOX",
329
- syncStatus: MailboxSyncStatus.synced,
330
- },
331
- {
332
- mailboxId: "in-flight",
333
- fullPath: "New Folder",
334
- syncStatus,
335
- },
336
- ]);
337
- const service = new MailboxSyncService(
338
- mailboxService,
339
- specialUseService,
340
- silentLogger,
341
- );
342
-
343
- const result = await service.syncMailboxes(
344
- { accountId: "acc-1" },
345
- serverConnection(),
346
- );
347
-
348
- assert.deepEqual(deleted, []);
349
- assert.equal(result.deleted, 0);
350
- });
351
-
352
- it(`keeps a \`${syncStatus}\` folder the server lists as \\Noselect`, async () => {
353
- // A folder mid-create can transiently LIST as `\Noselect`, and the
354
- // non-selectable branch deleted the row before looking at its state.
355
- const { mailboxService, specialUseService, deleted } = buildServices([
356
- {
357
- mailboxId: "in-flight",
358
- fullPath: "New Folder",
359
- syncStatus,
360
- },
361
- ]);
362
- const service = new MailboxSyncService(
363
- mailboxService,
364
- specialUseService,
365
- silentLogger,
366
- );
367
-
368
- await service.syncMailboxes(
369
- { accountId: "acc-1" },
370
- serverConnection([
371
- { fullPath: "INBOX" },
372
- { fullPath: "New Folder", attributes: ["\\Noselect"] },
373
- ]),
374
- );
375
-
376
- assert.deepEqual(deleted, []);
377
- });
378
- }
379
-
380
- it("does not insert a second row for a path a recorded rename claims", async () => {
381
- // D14. `fullPath` stays the path the server held when the intent was
382
- // recorded, so once the server executes RENAME the LIST returns a path no
383
- // row is keyed to. Without the pendingPath index the insert branch makes a
384
- // second row under a fresh mailboxId and initial-syncs the folder's mail
385
- // into it, leaving the original as a permanent phantom.
386
- const { mailboxService, specialUseService, deleted, created } =
387
- buildServices([
388
- {
389
- mailboxId: "inbox",
390
- fullPath: "INBOX",
391
- syncStatus: MailboxSyncStatus.synced,
392
- },
393
- {
394
- mailboxId: "renaming",
395
- fullPath: "Archive/2025",
396
- syncStatus: MailboxSyncStatus.pending,
397
- pendingPath: "Records/2025",
398
- },
399
- ]);
400
- const service = new MailboxSyncService(
401
- mailboxService,
402
- specialUseService,
403
- silentLogger,
404
- );
405
-
406
- const result = await service.syncMailboxes(
407
- { accountId: "acc-1" },
408
- serverConnection([{ fullPath: "INBOX" }, { fullPath: "Records/2025" }]),
409
- );
410
-
411
- assert.deepEqual(created, []);
412
- assert.equal(result.created, 0);
413
- assert.deepEqual(deleted, []);
414
- });
415
-
416
- it("counts no insert and keeps the account sweep going when the path is already taken", async () => {
417
- const { mailboxService, specialUseService, created } = buildServices(
418
- [
419
- {
420
- mailboxId: "inbox",
421
- fullPath: "INBOX",
422
- syncStatus: MailboxSyncStatus.synced,
423
- },
424
- ],
425
- "PathTaken",
426
- );
427
- const errors: Array<Record<string, unknown>> = [];
428
- const service = new MailboxSyncService(mailboxService, specialUseService, {
429
- ...silentLogger,
430
- error: (obj) => {
431
- errors.push(obj);
432
- },
433
- });
434
-
435
- const result = await service.syncMailboxes(
436
- { accountId: "acc-1" },
437
- serverConnection([{ fullPath: "INBOX" }, { fullPath: "Projects" }]),
438
- );
439
-
440
- assert.equal(created.length, 1);
441
- assert.equal(result.created, 0);
442
- assert.deepEqual(errors, [{ accountId: "acc-1", fullPath: "Projects" }]);
443
- });
444
-
445
- it("lets a failed rename's target be discovered as a folder of its own", async () => {
446
- // A failed rename keeps its target so the UI can name what it was aiming
447
- // at (T6), and nothing clears it until the user retries or dismisses. If
448
- // the guard honoured that claim, one stuck folder would block the sweep
449
- // from ever seeing a real folder another client later created at that
450
- // path — permanently, and silently.
451
- const { mailboxService, specialUseService, created } = buildServices([
452
- {
453
- mailboxId: "stuck",
454
- fullPath: "Archive/2025",
455
- syncStatus: MailboxSyncStatus.failed,
456
- pendingPath: "Records/2025",
457
- },
458
- ]);
459
- const service = new MailboxSyncService(
460
- mailboxService,
461
- specialUseService,
462
- silentLogger,
463
- );
464
-
465
- await service.syncMailboxes(
466
- { accountId: "acc-1" },
467
- serverConnection([
468
- { fullPath: "INBOX" },
469
- { fullPath: "Archive/2025" },
470
- { fullPath: "Records/2025" },
471
- ]),
472
- );
473
-
474
- assert.deepEqual(
475
- created.map((input) => input.fullPath),
476
- ["INBOX", "Records/2025"],
477
- );
478
- });
479
-
480
- it("writes syncStatus synced on the row it discovers", async () => {
481
- const { mailboxService, specialUseService, created } = buildServices([]);
482
- const service = new MailboxSyncService(
483
- mailboxService,
484
- specialUseService,
485
- silentLogger,
486
- );
487
-
488
- await service.syncMailboxes(
489
- { accountId: "acc-1" },
490
- serverConnection([{ fullPath: "INBOX" }]),
491
- );
492
-
493
- assert.equal(created.length, 1);
494
- assert.equal(created[0].syncStatus, MailboxSyncStatus.synced);
495
- });
496
-
497
- it("still deletes a synced folder that has left the server", async () => {
498
- // A folder that was confirmed on the server and is now gone is a genuine
499
- // server-side deletion; the reconcile must still reap it.
500
- const { mailboxService, specialUseService, deleted } = buildServices([
501
- {
502
- mailboxId: "inbox",
503
- fullPath: "INBOX",
504
- syncStatus: MailboxSyncStatus.synced,
505
- },
506
- {
507
- mailboxId: "synced-gone",
508
- fullPath: "Old Folder",
509
- syncStatus: MailboxSyncStatus.synced,
510
- },
511
- ]);
512
- const service = new MailboxSyncService(
513
- mailboxService,
514
- specialUseService,
515
- silentLogger,
516
- );
517
-
518
- const result = await service.syncMailboxes(
519
- { accountId: "acc-1" },
520
- serverConnection(),
521
- );
522
-
523
- assert.deepEqual(deleted, ["synced-gone"]);
524
- assert.equal(result.deleted, 1);
525
- });
526
- });
527
-
528
- describe("MailboxSyncService.syncMailboxes — a lookalike is not a folder the user keeps mail in (#837)", () => {
529
- const namespaces: ImapNamespaces = {
530
- personal: [{ prefix: "", delimiter: "/" }],
531
- other: [],
532
- shared: [],
533
- };
534
-
535
- const serverConnection = (
536
- folders: Array<{ fullPath: string; attributes: string[] }>,
537
- ): IImapConnection =>
538
- ({
539
- getNamespaces: async () => namespaces,
540
- listMailboxes: async () =>
541
- folders.map((folder) => ({
542
- fullPath: folder.fullPath,
543
- name: folder.fullPath.split("/").pop() ?? folder.fullPath,
544
- delimiter: "/",
545
- attributes: folder.attributes,
546
- parentPath: null,
547
- })),
548
- getMailboxStatus: async () => ({
549
- messages: 0,
550
- recent: 0,
551
- unseen: 0,
552
- uidNext: 1,
553
- uidValidity: 1,
554
- highestModseq: "0",
555
- deletedCount: 0,
556
- }),
557
- }) as unknown as IImapConnection;
558
-
559
- const buildServices = (
560
- existing: Array<{ mailboxId: string; fullPath: string }>,
561
- ) => {
562
- const deleted: string[] = [];
563
- const mailboxService = {
564
- listByAccount: async () => ({
565
- items: existing.map((m) => ({
566
- mailboxId: m.mailboxId,
567
- fullPath: m.fullPath,
568
- uidNext: 1,
569
- uidValidity: 1,
570
- messageCount: 0,
571
- unseenCount: 0,
572
- deletedCount: 0,
573
- highestModseq: "0",
574
- specialUse: undefined,
575
- syncStatus: MailboxSyncStatus.synced,
576
- })),
577
- continuationToken: undefined,
578
- }),
579
- update: async () => ({}),
580
- delete: async (_accountId: string, mailboxId: string) => {
581
- deleted.push(mailboxId);
582
- },
583
- create: async () => ({
584
- outcome: "Created",
585
- mailbox: { mailboxId: "new" },
586
- }),
587
- } as unknown as IMailboxRepository;
588
-
589
- const specialUseService = {
590
- listByMailboxId: async () => [],
591
- deleteByMailboxId: async () => undefined,
592
- createMany: async () => undefined,
593
- } as unknown as IMailboxSpecialUseRepository;
594
-
595
- return { mailboxService, specialUseService, deleted };
596
- };
597
-
598
- const syncWith = async (
599
- folders: Array<{ fullPath: string; attributes: string[] }>,
600
- existing: Array<{ mailboxId: string; fullPath: string }>,
601
- ): Promise<string[]> => {
602
- const { mailboxService, specialUseService, deleted } =
603
- buildServices(existing);
604
- const service = new MailboxSyncService(
605
- mailboxService,
606
- specialUseService,
607
- silentLogger,
608
- );
609
- await service.syncMailboxes(
610
- { accountId: "acc-1" },
611
- serverConnection(folders),
612
- );
613
- return deleted;
614
- };
615
-
616
- it("keeps a folder called Deleted beside the folder the server flagged", async () => {
617
- // `deleted` and `bin` were Trash names in this file's own copy of the hint
618
- // table long after #843 dropped them from the shared one. Every sync of an
619
- // account holding a flagged Trash and a user folder called `Deleted` took
620
- // the user's folder, and its mail, out of the client.
621
- const deleted = await syncWith(
622
- [
623
- { fullPath: "INBOX", attributes: [] },
624
- { fullPath: "Trash", attributes: ["\\Trash"] },
625
- { fullPath: "Deleted", attributes: [] },
626
- { fullPath: "Bin", attributes: [] },
627
- ],
628
- [
629
- { mailboxId: "inbox", fullPath: "INBOX" },
630
- { mailboxId: "trash", fullPath: "Trash" },
631
- { mailboxId: "keepsakes", fullPath: "Deleted" },
632
- { mailboxId: "bin", fullPath: "Bin" },
633
- ],
634
- );
635
-
636
- assert.deepEqual(deleted, []);
637
- });
638
-
639
- it("still hides the unflagged twin of a folder the server flagged", async () => {
640
- const deleted = await syncWith(
641
- [
642
- { fullPath: "INBOX", attributes: [] },
643
- { fullPath: "[Gmail]/Trash", attributes: ["\\Trash"] },
644
- { fullPath: "Trash", attributes: [] },
645
- ],
646
- [
647
- { mailboxId: "inbox", fullPath: "INBOX" },
648
- { mailboxId: "gmail-trash", fullPath: "[Gmail]/Trash" },
649
- { mailboxId: "lookalike", fullPath: "Trash" },
650
- ],
651
- );
652
-
653
- assert.deepEqual(deleted, ["lookalike"]);
654
- });
655
-
656
- it("keeps a folder that holds a role of its own", async () => {
657
- // `all mail` is a conventional name for Archive as well as for All, and the
658
- // account already has a flagged Archive. The folder holds the All role, so
659
- // it is a folder in its own right rather than a lookalike of Archive.
660
- const deleted = await syncWith(
661
- [
662
- { fullPath: "INBOX", attributes: [] },
663
- { fullPath: "Archive", attributes: ["\\Archive"] },
664
- { fullPath: "INBOX/All Mail", attributes: [] },
665
- ],
666
- [
667
- { mailboxId: "inbox", fullPath: "INBOX" },
668
- { mailboxId: "archive", fullPath: "Archive" },
669
- { mailboxId: "all-mail", fullPath: "INBOX/All Mail" },
670
- ],
671
- );
672
-
673
- assert.deepEqual(deleted, []);
674
- });
675
- });
676
-
677
- /**
678
- * The folder set can also change under a running sweep: a delete asked for while
679
- * the account is enumerating lands between the LIST and one folder's STATUS.
680
- * Failing the whole account's enumeration over that one folder stalls every
681
- * later sync for the account, on a per-account FIFO queue, for the queue's whole
682
- * visibility window (issue #339).
683
- */
684
- describe("MailboxSyncService.syncMailboxes — a folder leaving mid-sweep (#339)", () => {
685
- const namespaces: ImapNamespaces = {
686
- personal: [{ prefix: "", delimiter: "/" }],
687
- other: [],
688
- shared: [],
689
- };
690
-
691
- const status = () => ({
692
- messages: 3,
693
- recent: 0,
694
- unseen: 0,
695
- uidNext: 100,
696
- uidValidity: 1,
697
- highestModseq: "0",
698
- deletedCount: 0,
699
- });
700
-
701
- const buildConnection = (
702
- statusFor: (path: string) => Promise<ReturnType<typeof status>>,
703
- ): { connection: IImapConnection; statusPaths: string[] } => {
704
- const statusPaths: string[] = [];
705
- const connection = {
706
- getNamespaces: async () => namespaces,
707
- listMailboxes: async () =>
708
- ["INBOX", "Doomed"].map((fullPath) => ({
709
- fullPath,
710
- name: fullPath,
711
- delimiter: "/",
712
- attributes: [],
713
- parentPath: null,
714
- })),
715
- getMailboxStatus: async (path: string) => {
716
- statusPaths.push(path);
717
- return statusFor(path);
718
- },
719
- } as unknown as IImapConnection;
720
- return { connection, statusPaths };
721
- };
722
-
723
- const buildServices = (options: {
724
- doomedSyncStatus?: string;
725
- getDoomed?: () => Promise<unknown>;
726
- }) => {
727
- const updatedIds: string[] = [];
728
- const row = (mailboxId: string, fullPath: string, syncStatus?: string) => ({
729
- mailboxId,
730
- fullPath,
731
- uidNext: 1,
732
- uidValidity: 1,
733
- messageCount: 0,
734
- unseenCount: 0,
735
- deletedCount: 0,
736
- highestModseq: "0",
737
- specialUse: undefined,
738
- syncStatus,
739
- });
740
-
741
- const mailboxService = {
742
- listByAccount: async () => ({
743
- items: [
744
- row("mbx-inbox", "INBOX", MailboxSyncStatus.synced),
745
- row("mbx-doomed", "Doomed", options.doomedSyncStatus),
746
- ],
747
- continuationToken: undefined,
748
- }),
749
- get: async (_accountId: string, mailboxId: string) => {
750
- if (mailboxId === "mbx-doomed" && options.getDoomed) {
751
- return options.getDoomed();
752
- }
753
- return row(mailboxId, mailboxId, MailboxSyncStatus.synced);
754
- },
755
- update: async (
756
- _accountId: string,
757
- mailboxId: string,
758
- _patch: Record<string, unknown>,
759
- ) => {
760
- updatedIds.push(mailboxId);
761
- return {};
762
- },
763
- delete: async () => undefined,
764
- create: async () => ({
765
- outcome: "Created",
766
- mailbox: { mailboxId: "new" },
767
- }),
768
- } as unknown as IMailboxRepository;
769
-
770
- const specialUseService = {
771
- listByMailboxId: async () => [],
772
- deleteByMailboxId: async () => undefined,
773
- createMany: async () => undefined,
774
- } as unknown as IMailboxSpecialUseRepository;
775
-
776
- return { mailboxService, specialUseService, updatedIds };
777
- };
778
-
779
- for (const syncStatus of [
780
- MailboxSyncStatus.pending,
781
- MailboxSyncStatus.deleting,
782
- ]) {
783
- it(`leaves a \`${syncStatus}\` folder untouched — no STATUS, no write`, async () => {
784
- const { mailboxService, specialUseService, updatedIds } = buildServices({
785
- doomedSyncStatus: syncStatus,
786
- });
787
- const { connection, statusPaths } = buildConnection(async () => status());
788
- const service = new MailboxSyncService(
789
- mailboxService,
790
- specialUseService,
791
- silentLogger,
792
- );
793
-
794
- await service.syncMailboxes({ accountId: "acc-1" }, connection);
795
-
796
- assert.deepEqual(statusPaths, ["INBOX"]);
797
- assert.deepEqual(updatedIds, ["mbx-inbox"]);
798
- });
799
- }
800
-
801
- it("finishes the sweep when a folder's STATUS fails and that folder has since been deleted", async () => {
802
- const { mailboxService, specialUseService, updatedIds } = buildServices({
803
- doomedSyncStatus: MailboxSyncStatus.synced,
804
- getDoomed: async () => {
805
- throw new NotFoundError("Mailbox not found: mbx-doomed");
806
- },
807
- });
808
- const { connection } = buildConnection(async (path) => {
809
- if (path === "Doomed") throw new Error("Mailbox doesn't exist: Doomed");
810
- return status();
811
- });
812
- const service = new MailboxSyncService(
813
- mailboxService,
814
- specialUseService,
815
- silentLogger,
816
- );
817
-
818
- await assert.doesNotReject(
819
- service.syncMailboxes({ accountId: "acc-1" }, connection),
820
- );
821
- assert.deepEqual(updatedIds, ["mbx-inbox"]);
822
- });
823
-
824
- it("fails the sweep when a folder's STATUS fails and the folder is still live", async () => {
825
- const { mailboxService, specialUseService } = buildServices({
826
- doomedSyncStatus: MailboxSyncStatus.synced,
827
- getDoomed: async () => ({
828
- mailboxId: "mbx-doomed",
829
- fullPath: "Doomed",
830
- syncStatus: MailboxSyncStatus.synced,
831
- }),
832
- });
833
- const { connection } = buildConnection(async (path) => {
834
- if (path === "Doomed") throw new Error("connection reset by peer");
835
- return status();
836
- });
837
- const service = new MailboxSyncService(
838
- mailboxService,
839
- specialUseService,
840
- silentLogger,
841
- );
842
-
843
- await assert.rejects(
844
- service.syncMailboxes({ accountId: "acc-1" }, connection),
845
- /connection reset by peer/,
846
- );
847
- });
848
- });