@remit/mailbox-service 0.0.112 → 0.0.114

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 (270) 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} +16 -72
  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} +38 -64
  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 +129 -0
  78. package/dist/mailbox-queue.js +216 -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 +280 -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} +13 -68
  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 -97
  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 -88
  150. package/src/account-credentials.ts +0 -210
  151. package/src/attribute-mapper.test.ts +0 -104
  152. package/src/attribute-mapper.ts +0 -148
  153. package/src/body-parse.test.ts +0 -55
  154. package/src/body-part-mapper.property.test.ts +0 -354
  155. package/src/body-part-mapper.ts +0 -410
  156. package/src/body-sync-queue.test.ts +0 -64
  157. package/src/body-sync-queue.ts +0 -131
  158. package/src/body-sync-terminal.test.ts +0 -148
  159. package/src/body-sync-terminal.ts +0 -174
  160. package/src/body-sync.test.ts +0 -925
  161. package/src/body-sync.ts +0 -1891
  162. package/src/calendar-parts.ts +0 -41
  163. package/src/classification-backfill.test.ts +0 -285
  164. package/src/classification-backfill.ts +0 -363
  165. package/src/classify-message.test.ts +0 -142
  166. package/src/classify-message.ts +0 -116
  167. package/src/connection-factory.ts +0 -102
  168. package/src/connection-test.ts +0 -208
  169. package/src/credentials.test.ts +0 -181
  170. package/src/external-move.test.ts +0 -262
  171. package/src/external-move.ts +0 -285
  172. package/src/filter-operations.test.ts +0 -319
  173. package/src/filter-operations.ts +0 -377
  174. package/src/filter-update.test.ts +0 -313
  175. package/src/filters/filter-config.test.ts +0 -87
  176. package/src/filters/filter-config.ts +0 -34
  177. package/src/filters/list-id.test.ts +0 -50
  178. package/src/filters/match.test.ts +0 -392
  179. package/src/filters/match.ts +0 -225
  180. package/src/filters/pipeline.test.ts +0 -199
  181. package/src/filters/pipeline.ts +0 -170
  182. package/src/flag-push.test.ts +0 -112
  183. package/src/flag-push.ts +0 -198
  184. package/src/flag-queue.test.ts +0 -197
  185. package/src/flag-queue.ts +0 -509
  186. package/src/folder-operations.test.ts +0 -273
  187. package/src/folder-operations.ts +0 -217
  188. package/src/folder-placement.ts +0 -56
  189. package/src/forwarded-message.test.ts +0 -122
  190. package/src/heuristics/authenticationResults.test.ts +0 -189
  191. package/src/heuristics/authenticationResults.ts +0 -212
  192. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  193. package/src/heuristics/classifyByHeaders.ts +0 -467
  194. package/src/heuristics/classifyPlacement.test.ts +0 -366
  195. package/src/heuristics/classifyPlacement.ts +0 -211
  196. package/src/heuristics/classifyRealMail.test.ts +0 -242
  197. package/src/heuristics/machineSenders.test.ts +0 -101
  198. package/src/heuristics/senderMismatch.test.ts +0 -346
  199. package/src/heuristics/senderMismatch.ts +0 -259
  200. package/src/imapflow-connect-retry.test.ts +0 -97
  201. package/src/imapflow-connection.test.ts +0 -503
  202. package/src/imapflow-connection.ts +0 -1476
  203. package/src/index.ts +0 -397
  204. package/src/list-id-backfill.test.ts +0 -207
  205. package/src/list-id-backfill.ts +0 -274
  206. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  207. package/src/mailbox-cursor-rebuild.ts +0 -146
  208. package/src/mailbox-cursor.test.ts +0 -141
  209. package/src/mailbox-delete-intent.test.ts +0 -119
  210. package/src/mailbox-management.test.ts +0 -243
  211. package/src/mailbox-management.ts +0 -838
  212. package/src/mailbox-presence.test.ts +0 -74
  213. package/src/mailbox-queue.ts +0 -397
  214. package/src/mailbox-rename-intent.test.ts +0 -220
  215. package/src/mailbox-rename-subtree.test.ts +0 -287
  216. package/src/mailbox-sync.test.ts +0 -286
  217. package/src/mailbox-sync.ts +0 -724
  218. package/src/message-move.test.ts +0 -726
  219. package/src/message-move.ts +0 -1518
  220. package/src/message-parser.ts +0 -26
  221. package/src/message-presence.test.ts +0 -50
  222. package/src/message-read.ts +0 -87
  223. package/src/message-save-retry.test.ts +0 -90
  224. package/src/message-sync-flags.test.ts +0 -167
  225. package/src/message-sync.test.ts +0 -762
  226. package/src/message-sync.ts +0 -2133
  227. package/src/mime-walker.test.ts +0 -311
  228. package/src/mime-walker.ts +0 -306
  229. package/src/organize-job.ts +0 -93
  230. package/src/outbox-attachment-content.test.ts +0 -153
  231. package/src/outbox-attachment-content.ts +0 -104
  232. package/src/outbox-attachment-filename.test.ts +0 -132
  233. package/src/outbox-attachment-filename.ts +0 -76
  234. package/src/outbox-attachment.test.ts +0 -626
  235. package/src/outbox-attachment.ts +0 -536
  236. package/src/outbox-failed-recovery.test.ts +0 -299
  237. package/src/outbox-queue.test.ts +0 -315
  238. package/src/outbox-queue.ts +0 -525
  239. package/src/ownership.ts +0 -140
  240. package/src/placement-move-terminal.ts +0 -145
  241. package/src/placement-move.ts +0 -363
  242. package/src/placement-settled.test.ts +0 -147
  243. package/src/placement.test.ts +0 -404
  244. package/src/quarantine.test.ts +0 -133
  245. package/src/quarantine.ts +0 -229
  246. package/src/refusal.ts +0 -29
  247. package/src/snippet.test.ts +0 -213
  248. package/src/snippet.ts +0 -152
  249. package/src/spam-report.test.ts +0 -329
  250. package/src/spam-report.ts +0 -357
  251. package/src/sync-warning.test.ts +0 -213
  252. package/src/sync-warning.ts +0 -56
  253. package/src/sync-watermarks.test.ts +0 -409
  254. package/src/sync-watermarks.ts +0 -319
  255. package/src/system-flag-wire-format.test.ts +0 -77
  256. package/src/test-helpers/fake-imap.ts +0 -690
  257. package/src/test-helpers/folder-roles.ts +0 -66
  258. package/src/test-helpers/mime-tree-generator.ts +0 -461
  259. package/src/test-helpers/test-queue.ts +0 -56
  260. package/src/test-helpers/test-store.ts +0 -299
  261. package/src/text/normalizer.test.ts +0 -223
  262. package/src/text/normalizer.ts +0 -140
  263. package/src/thread-message-rows.ts +0 -77
  264. package/src/thread-root.test.ts +0 -29
  265. package/src/thread-root.ts +0 -51
  266. package/src/types/mailparser-augment.d.ts +0 -23
  267. package/src/types/natural-porter-stemmer.d.ts +0 -50
  268. package/src/types/stopword.d.ts +0 -69
  269. package/src/types.ts +0 -371
  270. package/tsconfig.json +0 -8
@@ -1,285 +0,0 @@
1
- import type {
2
- IMailboxRepository,
3
- IMessageRepository,
4
- MailboxItem,
5
- MessageItem,
6
- } from "@remit/data-ports";
7
- import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
8
- import { MessageStatus } from "@remit/domain-enums";
9
- import type { IImapConnection } from "./types.js";
10
-
11
- export interface ExternalMoveLogger {
12
- info(obj: Record<string, unknown>, msg: string): void;
13
- warn(obj: Record<string, unknown>, msg: string): void;
14
- }
15
-
16
- /**
17
- * Whether a sighting of an already-stored message in this mailbox CONTESTS the
18
- * placement the row holds (#859). Contesting is not deciding: the sighting is a
19
- * candidate move, and {@link confirmDepartures} is what turns it into one.
20
- *
21
- * Three sightings do not even contest. The same mailbox is the row standing
22
- * where it already is. Gmail's All Mail, Starred and Important hold every
23
- * message that also lives in a real folder, so they are views rather than filing
24
- * locations; following one would empty every other folder's listing. And a row
25
- * whose own mutation has not settled is in flight, not stale
26
- * (imap-mutations R1): the server's answer predates the move reader is still
27
- * waiting on, so taking it would undo the optimistic write. The sync path
28
- * reconciles rather than waits (imap-mutations R2): it never blocks on a
29
- * mutation, it re-reads the row on the next sighting after the move settles.
30
- *
31
- * In flight is `status`, and `status` alone (#1096, imap-mutations R3). Every
32
- * outbound mutation writes `moving` or `deleting` and only settling returns the
33
- * row to `active`, so `active` is precisely the set nothing is coming for —
34
- * whatever `syncStatus` says about how the row got there. A row put back by
35
- * `abandonDelete` carries `abandoned`, and it needs this repair more than any
36
- * other row does: nothing routine settles it, and reader shares its mailboxes,
37
- * so refusing it left a message whose delete was refused unable to follow a
38
- * move the user then made in another client.
39
- */
40
- export const sightingContestsPlacement = (
41
- mailbox: MailboxItem,
42
- message: Pick<MessageItem, "mailboxId" | "status">,
43
- ): boolean => {
44
- if (message.mailboxId === mailbox.mailboxId) return false;
45
- if (isVirtualCopyMailbox(mailbox)) return false;
46
- return message.status === MessageStatus.active;
47
- };
48
-
49
- /**
50
- * The exact placement a verdict was reached for. The probe reads the row outside
51
- * the transaction that acts on it, so the verdict names the mailbox and uid it
52
- * was reached for; a row that moved in between matches no verdict and is left
53
- * unsettled.
54
- */
55
- export const placementKey = (
56
- messageId: string,
57
- mailboxId: string,
58
- uid: number,
59
- ): string => `${messageId}:${mailboxId}:${uid}`;
60
-
61
- export interface DepartureVerdicts {
62
- /**
63
- * Placements the source folder no longer holds. A sighting elsewhere of one
64
- * of these is a move, and re-points the row.
65
- */
66
- departed: Set<string>;
67
- /**
68
- * Placements a verdict was reached for at all, departed or still held. A
69
- * contested sighting outside this set was not decided — the source could not
70
- * be asked, or the row has moved since it was — and its uid is held back from
71
- * the watermark so the next round serves the sighting again. Without that a
72
- * single unanswerable round would leave the row mispointed until a cursor
73
- * rebuild, which is the silent never-repair this whole change is about.
74
- */
75
- settled: Set<string>;
76
- /**
77
- * Message ids whose sighting has to go through the save pipeline: departed,
78
- * or contested with no verdict. Every other sighting changes no placement.
79
- */
80
- needsSave: Set<string>;
81
- }
82
-
83
- export interface DepartureProbeDeps {
84
- connection: Pick<IImapConnection, "openBox" | "search">;
85
- mailboxService: Pick<IMailboxRepository, "get">;
86
- messageService: Pick<IMessageRepository, "get">;
87
- log: ExternalMoveLogger;
88
- }
89
-
90
- /**
91
- * Which of this batch's messages have really left the folder their row points at
92
- * (#1146).
93
- *
94
- * A message sighted in a second real folder has not necessarily moved. Living in
95
- * two folders at once is ordinary mail: a Gmail user label, a Sieve `fileinto`
96
- * beside a `keep`, a Sent copy a mailing list echoes back. Reading every such
97
- * sighting as a move takes the message out of the folder it is still in, and
98
- * whichever folder the sync round enumerates last wins — which is how a labelled
99
- * message disappeared from the Inbox on the next sync.
100
- *
101
- * What separates a move from a copy is the source folder: a move empties it, a
102
- * copy leaves it alone. So the source is asked, and only a source that no longer
103
- * holds the uid the row claims makes the sighting a move. A move another client
104
- * made is still followed — that is the case where the source really is empty —
105
- * and reader stops fighting a second client over mail neither of them moved.
106
- *
107
- * The question is asked with a UID SEARCH rather than a FETCH, for the reason
108
- * `isMessageGoneFromOpenMailbox` documents: imapflow drops rows on back-to-back
109
- * FETCHes (#408), and absence read off a FETCH would file live mail as departed.
110
- * `IImapConnection.search` throws on a failed SEARCH, so an empty answer means
111
- * the server matched nothing. The SEARCH names the contested uids rather than
112
- * ALL: the answer needed is about those uids, and a folder's whole uid set is a
113
- * page of traffic per batch for an account with a large Inbox.
114
- *
115
- * One SEARCH per source folder per batch, and no IMAP at all when nothing is
116
- * contested — which is every round on an account whose folders do not overlap.
117
- */
118
- export const confirmDepartures = async (
119
- deps: DepartureProbeDeps,
120
- accountId: string,
121
- sightedIn: MailboxItem,
122
- messageIds: string[],
123
- ): Promise<DepartureVerdicts> => {
124
- const verdicts: DepartureVerdicts = {
125
- departed: new Set<string>(),
126
- settled: new Set<string>(),
127
- needsSave: new Set<string>(),
128
- };
129
- if (messageIds.length === 0) return verdicts;
130
-
131
- const stored = await deps.messageService.get(messageIds);
132
- // A row carrying no uid names no placement the source can be asked about, so
133
- // it reaches no verdict here and its sighting is served again next round. It
134
- // is not reachable from a sync — `upsertWithStatus` writes the uid the FETCH
135
- // returned — and a folder that somehow produces one stalls its cursor and
136
- // raises the alert rather than repairing a row against nothing.
137
- const contested = stored.filter(
138
- (row) => row.uid > 0 && sightingContestsPlacement(sightedIn, row),
139
- );
140
- if (contested.length === 0) return verdicts;
141
-
142
- const bySource = new Map<string, MessageItem[]>();
143
- for (const row of contested) {
144
- const group = bySource.get(row.mailboxId);
145
- if (group === undefined) {
146
- bySource.set(row.mailboxId, [row]);
147
- continue;
148
- }
149
- group.push(row);
150
- }
151
-
152
- for (const [sourceMailboxId, rows] of bySource) {
153
- await askSource(
154
- deps,
155
- accountId,
156
- sightedIn,
157
- sourceMailboxId,
158
- rows,
159
- verdicts,
160
- );
161
- }
162
-
163
- for (const row of contested) {
164
- const key = placementKey(row.messageId, row.mailboxId, row.uid);
165
- if (verdicts.departed.has(key) || !verdicts.settled.has(key)) {
166
- verdicts.needsSave.add(row.messageId);
167
- }
168
- }
169
-
170
- return verdicts;
171
- };
172
-
173
- /**
174
- * What one source folder said about the rows pointed at it. Only `holds` decides
175
- * anything; the other three are the folder declining to be evidence, each for
176
- * its own reason and each leaving the sightings to come back next round.
177
- */
178
- type SourceAnswer =
179
- | { kind: "holds"; path: string; held: Set<number> }
180
- | { kind: "copiesEverything"; path: string }
181
- | { kind: "rekeyed"; path: string; stored: number; served: number }
182
- | { kind: "unreachable"; reason: string };
183
-
184
- const readSource = async (
185
- deps: DepartureProbeDeps,
186
- accountId: string,
187
- sourceMailboxId: string,
188
- rows: MessageItem[],
189
- ): Promise<SourceAnswer> => {
190
- const source = await deps.mailboxService.get(accountId, sourceMailboxId);
191
-
192
- // A row whose source is All Mail, Starred or Important is not filed there:
193
- // those folders hold a copy of everything and never release anything, so
194
- // asking them would answer "still here" forever and freeze the row on a view.
195
- if (isVirtualCopyMailbox(source)) {
196
- return { kind: "copiesEverything", path: source.fullPath };
197
- }
198
-
199
- const box = await deps.connection.openBox(source.fullPath, true);
200
-
201
- // A source folder on a different UIDVALIDITY axis holds no uid these rows can
202
- // be compared against, so its message set is evidence of nothing (RFC 9051
203
- // 2.3.1.1). The cursor rebuild re-keys that folder; until it has, the
204
- // sightings stay unsettled and come back next round.
205
- if (box.uidvalidity !== source.uidValidity) {
206
- return {
207
- kind: "rekeyed",
208
- path: source.fullPath,
209
- stored: source.uidValidity,
210
- served: box.uidvalidity,
211
- };
212
- }
213
-
214
- const held = await deps.connection.search([
215
- ["UID", rows.map((row) => row.uid).join(",")],
216
- ]);
217
- return { kind: "holds", path: source.fullPath, held: new Set(held) };
218
- };
219
-
220
- const askSource = async (
221
- deps: DepartureProbeDeps,
222
- accountId: string,
223
- sightedIn: MailboxItem,
224
- sourceMailboxId: string,
225
- rows: MessageItem[],
226
- verdicts: DepartureVerdicts,
227
- ): Promise<void> => {
228
- const settle = (row: MessageItem, departed: boolean): void => {
229
- const key = placementKey(row.messageId, row.mailboxId, row.uid);
230
- verdicts.settled.add(key);
231
- if (departed) verdicts.departed.add(key);
232
- };
233
-
234
- // A source folder reader cannot reach decides nothing, and does not fail the
235
- // round either. Another client renaming or deleting it between the row being
236
- // written and now is ordinary — reader shares its mailboxes — and the sync of
237
- // the unrelated folder that sighted these messages must survive it.
238
- const answer = await readSource(
239
- deps,
240
- accountId,
241
- sourceMailboxId,
242
- rows,
243
- ).catch<SourceAnswer>((error: unknown) => ({
244
- kind: "unreachable",
245
- reason: error instanceof Error ? error.message : String(error),
246
- }));
247
-
248
- const observed = {
249
- sourceMailboxId,
250
- sightedMailboxId: sightedIn.mailboxId,
251
- contested: rows.length,
252
- };
253
-
254
- if (answer.kind === "unreachable") {
255
- deps.log.warn(
256
- { ...observed, error: answer.reason },
257
- "Could not ask the source folder whether it still holds these messages; leaving the sightings unsettled this round",
258
- );
259
- return;
260
- }
261
-
262
- if (answer.kind === "rekeyed") {
263
- deps.log.warn(
264
- {
265
- ...observed,
266
- sourceMailboxPath: answer.path,
267
- storedUidValidity: answer.stored,
268
- servedUidValidity: answer.served,
269
- },
270
- "Source folder is on a different UIDVALIDITY axis; leaving these sightings unsettled this round",
271
- );
272
- return;
273
- }
274
-
275
- if (answer.kind === "copiesEverything") {
276
- for (const row of rows) settle(row, true);
277
- deps.log.info(
278
- { ...observed, sourceMailboxPath: answer.path },
279
- "Row points at a folder that copies every message; the sighting in a real folder is where it lives",
280
- );
281
- return;
282
- }
283
-
284
- for (const row of rows) settle(row, !answer.held.has(row.uid));
285
- };
@@ -1,319 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, beforeEach, describe, it } from "node:test";
3
- import type { AccountItem, FilterItem, MailboxItem } from "@remit/data-ports";
4
- import {
5
- FilterDisabledReason,
6
- FilterMatchOperator,
7
- FilterScope,
8
- FilterState,
9
- MailboxSyncStatus,
10
- } from "@remit/domain-enums";
11
- import {
12
- createFilter,
13
- type FilterPatch,
14
- listFilters,
15
- type NewFilter,
16
- updateFilter,
17
- } from "./filter-operations.js";
18
- import type { Refusable } from "./refusal.js";
19
- import {
20
- captureQueue,
21
- openStore,
22
- type QueueCapture,
23
- type Store,
24
- seedAccount,
25
- seedMailbox,
26
- } from "./test-helpers/test-store.js";
27
-
28
- let store: Store;
29
- let account: AccountItem;
30
- let capture: QueueCapture;
31
-
32
- const FANOUT_QUEUE = "http://localhost:4566/000000000000/account-fanout";
33
-
34
- beforeEach(async () => {
35
- capture = captureQueue();
36
- store = await openStore();
37
- account = await seedAccount(store);
38
- });
39
-
40
- afterEach(async () => {
41
- capture.restore();
42
- await store.close();
43
- });
44
-
45
- const deps = () => ({
46
- account: store.account,
47
- mailbox: store.mailbox,
48
- filter: store.filter,
49
- organizeJobRequest: store.organizeJobRequest,
50
- sqsQueueUrl: FANOUT_QUEUE,
51
- logger: { info: () => {} },
52
- });
53
-
54
- const rule = (actionMailboxId: string): NewFilter => ({
55
- name: "Receipts",
56
- scope: FilterScope.Standing,
57
- expiresAt: undefined,
58
- matchOperator: FilterMatchOperator.And,
59
- literalClauses: [{ field: "From", value: "billing@example.com" }],
60
- actionLabelId: "None",
61
- actionMailboxId,
62
- });
63
-
64
- const refusalOf = <T>(outcome: Refusable<T>) => {
65
- assert.equal(outcome.ok, false, "expected a refusal");
66
- return outcome.ok ? undefined : outcome.refusal;
67
- };
68
-
69
- const pendingFolder = async (fullPath: string): Promise<MailboxItem> => {
70
- const created = await store.mailbox.create({
71
- accountId: account.accountId,
72
- namespacePrefix: "",
73
- hierarchyDelimiter: "/",
74
- fullPath,
75
- uidValidity: 0,
76
- uidNext: 1,
77
- highestModseq: "0",
78
- messageCount: 0,
79
- unseenCount: 0,
80
- deletedCount: 0,
81
- totalSize: 0,
82
- lastSyncUid: 0,
83
- highWaterMarkUid: 0,
84
- lastMessageSyncAt: 0,
85
- syncStatus: MailboxSyncStatus.pending,
86
- });
87
- if (created.outcome !== "Created") throw new Error(`${fullPath} exists`);
88
- return created.mailbox;
89
- };
90
-
91
- const storedFilter = (over: Partial<FilterItem>): Promise<FilterItem> =>
92
- store.filter.create({
93
- accountConfigId: account.accountConfigId,
94
- ...rule("None"),
95
- ...over,
96
- });
97
-
98
- describe("createFilter", () => {
99
- it("stores the filter and queues its pass over existing mail", async () => {
100
- const folder = await seedMailbox(store, account, "Receipts");
101
-
102
- const outcome = await createFilter(
103
- deps(),
104
- { accountConfigId: account.accountConfigId, userId: "user-1" },
105
- { accountId: account.accountId, filter: rule(folder.mailboxId) },
106
- );
107
-
108
- assert.equal(outcome.ok, true);
109
- if (!outcome.ok) return;
110
- const job = await store.organizeJobRequest.get(
111
- outcome.value.organizeJob.organizeJobId,
112
- );
113
- assert.equal(job.filterId, outcome.value.filter.filterId);
114
- assert.equal(job.userId, "user-1");
115
- assert.deepEqual(job.literalClauses, []);
116
- assert.equal(job.actionMailboxId, "None");
117
- assert.deepEqual(capture.sent, [
118
- {
119
- queueUrl: FANOUT_QUEUE,
120
- body: {
121
- type: "OrganizeJob",
122
- accountConfigId: account.accountConfigId,
123
- organizeJobId: job.organizeJobId,
124
- },
125
- },
126
- ]);
127
- });
128
-
129
- it("waits for the folder it moves mail into, storing nothing", async () => {
130
- const folder = await pendingFolder("Receipts");
131
-
132
- const outcome = await createFilter(
133
- deps(),
134
- { accountConfigId: account.accountConfigId, userId: "user-1" },
135
- { accountId: account.accountId, filter: rule(folder.mailboxId) },
136
- );
137
-
138
- assert.deepEqual(refusalOf(outcome), {
139
- kind: "NotSettled",
140
- message:
141
- "“Receipts” isn’t ready yet — the mail server hasn’t confirmed it.",
142
- mailboxId: folder.mailboxId,
143
- syncStatus: MailboxSyncStatus.pending,
144
- });
145
- assert.deepEqual(
146
- await store.filter.listByAccountConfig(account.accountConfigId),
147
- [],
148
- );
149
- assert.deepEqual(capture.sent, []);
150
- });
151
-
152
- it("refuses a Temporary filter without an expiry, storing nothing", async () => {
153
- const folder = await seedMailbox(store, account, "Receipts");
154
-
155
- const outcome = await createFilter(
156
- deps(),
157
- { accountConfigId: account.accountConfigId, userId: "user-1" },
158
- {
159
- accountId: account.accountId,
160
- filter: { ...rule(folder.mailboxId), scope: FilterScope.Temporary },
161
- },
162
- );
163
-
164
- assert.deepEqual(refusalOf(outcome), {
165
- kind: "Invalid",
166
- message:
167
- "A Temporary filter needs expiresAt — pick a date, or switch scope to Standing.",
168
- });
169
- assert.deepEqual(
170
- await store.filter.listByAccountConfig(account.accountConfigId),
171
- [],
172
- );
173
- });
174
- });
175
-
176
- describe("updateFilter", () => {
177
- it("writes only the fields a patch may set, dropping server-derived ones", async () => {
178
- const filter = await storedFilter({});
179
- const smuggled: Record<string, unknown> = {
180
- name: "Bills",
181
- ttl: 123,
182
- disabledReason: FilterDisabledReason.FolderMissing,
183
- ruleChangedAt: 999,
184
- filterId: "sneaky",
185
- };
186
-
187
- const outcome = await updateFilter(deps(), account.accountConfigId, {
188
- accountId: account.accountId,
189
- filterId: filter.filterId,
190
- patch: smuggled as FilterPatch,
191
- });
192
-
193
- assert.equal(outcome.ok, true);
194
- const stored = await store.filter.get(
195
- account.accountConfigId,
196
- filter.filterId,
197
- );
198
- assert.equal(stored.name, "Bills");
199
- assert.equal(stored.ttl, undefined);
200
- assert.equal(stored.disabledReason, FilterDisabledReason.None);
201
- assert.equal(stored.ruleChangedAt, filter.ruleChangedAt);
202
- });
203
-
204
- it("records UserDisabled when the user turns a filter off", async () => {
205
- const filter = await storedFilter({});
206
-
207
- await updateFilter(deps(), account.accountConfigId, {
208
- accountId: account.accountId,
209
- filterId: filter.filterId,
210
- patch: { state: FilterState.Disabled },
211
- });
212
-
213
- const stored = await store.filter.get(
214
- account.accountConfigId,
215
- filter.filterId,
216
- );
217
- assert.equal(stored.state, FilterState.Disabled);
218
- assert.equal(stored.disabledReason, FilterDisabledReason.UserDisabled);
219
- });
220
-
221
- it("refuses to bind to a folder the mail server has not confirmed", async () => {
222
- const filter = await storedFilter({});
223
- const folder = await pendingFolder("Receipts");
224
-
225
- const outcome = await updateFilter(deps(), account.accountConfigId, {
226
- accountId: account.accountId,
227
- filterId: filter.filterId,
228
- patch: { actionMailboxId: folder.mailboxId },
229
- });
230
-
231
- assert.equal(refusalOf(outcome)?.kind, "NotSettled");
232
- const stored = await store.filter.get(
233
- account.accountConfigId,
234
- filter.filterId,
235
- );
236
- assert.equal(stored.actionMailboxId, "None");
237
- });
238
-
239
- it("will not turn on a filter whose folder is gone", async () => {
240
- const filter = await storedFilter({
241
- actionMailboxId: "mbx-gone",
242
- state: FilterState.Disabled,
243
- disabledReason: FilterDisabledReason.UserDisabled,
244
- });
245
-
246
- const outcome = await updateFilter(deps(), account.accountConfigId, {
247
- accountId: account.accountId,
248
- filterId: filter.filterId,
249
- patch: { state: FilterState.Active },
250
- });
251
-
252
- assert.deepEqual(refusalOf(outcome), {
253
- kind: "Invalid",
254
- message:
255
- "The folder this filter moves mail into no longer exists. Pick another folder, then turn the filter on.",
256
- });
257
- });
258
-
259
- it("refuses a Temporary filter without an expiry", async () => {
260
- const filter = await storedFilter({});
261
-
262
- const outcome = await updateFilter(deps(), account.accountConfigId, {
263
- accountId: account.accountId,
264
- filterId: filter.filterId,
265
- patch: { scope: FilterScope.Temporary },
266
- });
267
-
268
- assert.deepEqual(refusalOf(outcome), {
269
- kind: "Invalid",
270
- message:
271
- "A Temporary filter needs expiresAt — pick a date, or switch scope to Standing.",
272
- });
273
- });
274
-
275
- it("renames a filter", async () => {
276
- const filter = await storedFilter({});
277
-
278
- const outcome = await updateFilter(deps(), account.accountConfigId, {
279
- accountId: account.accountId,
280
- filterId: filter.filterId,
281
- patch: { name: "Bills" },
282
- });
283
-
284
- assert.equal(outcome.ok && outcome.value.name, "Bills");
285
- });
286
- });
287
-
288
- describe("listFilters", () => {
289
- it("expires a lapsed Temporary filter as it reads it", async () => {
290
- await storedFilter({
291
- scope: FilterScope.Temporary,
292
- expiresAt: "2020-01-01T00:00:00+00:00",
293
- });
294
-
295
- const outcome = await listFilters(deps(), account.accountConfigId, {
296
- accountId: account.accountId,
297
- continuationToken: undefined,
298
- });
299
-
300
- assert.deepEqual(
301
- outcome.ok && outcome.value.items.map((item) => item.state),
302
- [FilterState.Expired],
303
- );
304
- });
305
-
306
- it("answers another tenant's account as not found", async () => {
307
- const other = await seedAccount(store, { accountConfigId: "cfg-other" });
308
-
309
- const outcome = await listFilters(deps(), account.accountConfigId, {
310
- accountId: other.accountId,
311
- continuationToken: undefined,
312
- });
313
-
314
- assert.deepEqual(refusalOf(outcome), {
315
- kind: "NotFound",
316
- message: `Account not found: ${other.accountId}`,
317
- });
318
- });
319
- });