@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
@@ -0,0 +1,79 @@
1
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
+ import { MessageStatus } from "@remit/domain-enums";
3
+ import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
4
+ /**
5
+ * Put a row back on the pre-mutation source pair and hand its listing rows back
6
+ * with it.
7
+ *
8
+ * `status: moving` has to be cleared here or the row is stuck: a give-up that
9
+ * writes a sync status and returns leaves the row naming the destination with
10
+ * the source's uid — the pair `placementBindingOf` calls a lie and
11
+ * `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs it
12
+ * (sync does not touch `status`, and the cursor rebuild's `updateUid` runs only
13
+ * on a UIDVALIDITY change), so the message is undeletable and unmovable for good
14
+ * (issue #1005).
15
+ *
16
+ * A transition, not a plain update (imap-mutations R3): the restore is only
17
+ * owed while a mutation is still outstanding on the row, so the predicate is
18
+ * `moving` or `deleting`. A row another lane has already settled — the
19
+ * placement-move push runs off a standard queue and is ordered against nothing
20
+ * on the account's FIFO — is the winner, and dragging it back to a source it
21
+ * has since left is the corruption this predicate exists to refuse. The loser
22
+ * writes nothing at all, listing rows included, and answers `superseded`.
23
+ *
24
+ * The caller must have evidence, not an inference: this writes a placement, and
25
+ * a placement written on a guess binds live mail to a uid that names somebody
26
+ * else's message. Two kinds of caller qualify, and nothing else does.
27
+ *
28
+ * A terminal resolver reaches here from {@link isMessageGoneFromOpenMailbox}
29
+ * answering that the message is STILL at `sourceMailboxId`/`uid`, which makes
30
+ * this the server's own answer rather than a revert on ambiguity (what PR #652
31
+ * was pulled for).
32
+ *
33
+ * A paused-cursor caller reaches here from a `MailboxCursorPausedError` the
34
+ * openBox guard threw before the outbound command was issued (issue #1203), and
35
+ * restores the pre-mutation pair off its own event. On a FIRST delivery that is
36
+ * not an inference but an undo: the command provably never left, so the row is
37
+ * put back exactly as it was found. A redelivery cannot claim that — the
38
+ * earlier attempt's tagged OK can be lost with the connection — so it asks
39
+ * `probePausedPlacement` which folder holds the message and only reaches here
40
+ * once the answer rules the destination out. Either way the source mailbox is
41
+ * by definition awaiting a cursor rebuild, which matches its rows by Message-ID
42
+ * and adjudicates the uid: handing the row back is what puts it in the set the
43
+ * rebuild walks, since a row still naming the destination is in neither
44
+ * folder's.
45
+ *
46
+ * A row another path deleted while the probe was in flight has nothing left to
47
+ * restore, and it fails the predicate like any other row that is no longer
48
+ * owed one. A listing row that other path has already rewritten is its to
49
+ * finish; the update for it comes back `NotFoundError`, and that is a settled
50
+ * state here rather than a fault. Throwing on it would escape the caller's
51
+ * `.catch()` and cost the record its ack and its resync, which is the contract
52
+ * the terminal resolvers state: never re-thrown.
53
+ */
54
+ export const restoreSourcePlacement = async (deps, input) => {
55
+ const { accountConfigId, messageId, sourceMailboxId, uid, syncStatus, abandonedMutation, } = input;
56
+ const skipNotFound = (error) => {
57
+ if (isNotFoundError(error))
58
+ return;
59
+ throw error;
60
+ };
61
+ const restored = await deps.messageService.transitionPlacement(messageId, { status: [MessageStatus.moving, MessageStatus.deleting] }, {
62
+ mailboxId: sourceMailboxId,
63
+ uid,
64
+ status: MessageStatus.active,
65
+ syncStatus,
66
+ abandonedMutation,
67
+ });
68
+ if (!restored)
69
+ return "superseded";
70
+ const threadMessages = input.threadMessages ??
71
+ (await deps.threadMessageService.findAllByMessageId(accountConfigId, messageId));
72
+ for (const threadMessage of threadMessages) {
73
+ const args = buildThreadMessageMoveRevert(threadMessage, uid, sourceMailboxId);
74
+ await deps.threadMessageService
75
+ .update(threadMessage.accountConfigId, threadMessage.threadMessageId, args.set, { composites: args.composites })
76
+ .catch(skipNotFound);
77
+ }
78
+ return "restored";
79
+ };
@@ -0,0 +1,15 @@
1
+ import type { IMessageRepository } from "@remit/data-ports";
2
+ import { type StorageService } from "@remit/storage-service";
3
+ interface SearchTextBackfillDeps {
4
+ messageService: Pick<IMessageRepository, "listMissingSearchText" | "setSearchText" | "syncSearch">;
5
+ storageService: Pick<StorageService, "retrieve">;
6
+ }
7
+ interface SearchTextBackfillLogger {
8
+ warn(obj: Record<string, unknown>, msg: string): void;
9
+ }
10
+ interface SearchTextBackfillResult {
11
+ indexed: number;
12
+ skipped: number;
13
+ }
14
+ export declare const backfillSearchText: (deps: SearchTextBackfillDeps, log: SearchTextBackfillLogger) => Promise<SearchTextBackfillResult>;
15
+ export {};
@@ -0,0 +1,33 @@
1
+ import { isStorageNotFoundError, } from "@remit/storage-service";
2
+ import { BodyParseError, parseMessageBody } from "./body-parse.js";
3
+ import { extractSearchText } from "./snippet.js";
4
+ const PAGE_SIZE = 200;
5
+ const isUnreadableBody = (error) => error instanceof BodyParseError || isStorageNotFoundError(error);
6
+ const recordSearchText = async (deps, messageId, bodyStorageKey) => {
7
+ const parsed = await parseMessageBody(await deps.storageService.retrieve(bodyStorageKey));
8
+ await deps.messageService.setSearchText(messageId, extractSearchText(parsed));
9
+ };
10
+ export const backfillSearchText = async (deps, log) => {
11
+ const result = { indexed: 0, skipped: 0 };
12
+ let after = "";
13
+ for (;;) {
14
+ const page = await deps.messageService.listMissingSearchText(after, PAGE_SIZE);
15
+ const last = page.at(-1);
16
+ if (!last)
17
+ return result;
18
+ const recorded = [];
19
+ for (const { messageId, bodyStorageKey } of page) {
20
+ await recordSearchText(deps, messageId, bodyStorageKey).then(() => {
21
+ recorded.push(messageId);
22
+ }, (error) => {
23
+ if (!isUnreadableBody(error))
24
+ throw error;
25
+ result.skipped++;
26
+ log.warn({ messageId, error }, "Search text backfill skipped an unreadable stored body");
27
+ });
28
+ }
29
+ await deps.messageService.syncSearch(recorded);
30
+ result.indexed += recorded.length;
31
+ after = last.messageId;
32
+ }
33
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Snippet generation utilities for email threading.
3
+ *
4
+ * Extracts preview text from email bodies by removing quoted content
5
+ * and normalizing whitespace.
6
+ */
7
+ import type { ParsedMail } from "mailparser";
8
+ import { type SupportedLanguage } from "./text/normalizer.js";
9
+ /**
10
+ * Remove quoted reply content from email body.
11
+ *
12
+ * Stops at common quote indicators:
13
+ * - Lines starting with ">"
14
+ * - "On ... wrote:" patterns
15
+ * - "--- Original Message ---" separators
16
+ * - Outlook-style underline separators
17
+ */
18
+ export declare const removeQuotedContent: (text: string) => string;
19
+ /**
20
+ * Normalize subject for thread grouping and search.
21
+ *
22
+ * Processing steps:
23
+ * 1. Remove Re:/Fwd:/etc prefixes (multi-language support)
24
+ * 2. Auto-detect language or use provided language
25
+ * 3. Normalize with stemming and stopword removal
26
+ * 4. Deduplicate consecutive words
27
+ *
28
+ * Handles common prefixes across languages:
29
+ * - Re, Fwd, Fw (English)
30
+ * - Aw, Sv, Vs (German, Swedish, Danish)
31
+ * - Ref, Rif (Italian, Spanish)
32
+ * - Odp (Polish)
33
+ * - Ynt (Turkish)
34
+ * - Antw (Dutch)
35
+ * - Res (Portuguese)
36
+ *
37
+ * @param subject - The email subject line
38
+ * @param language - Optional language override (auto-detected if not provided)
39
+ */
40
+ export declare const normalizeSubject: (subject: string, language?: SupportedLanguage) => string;
41
+ /**
42
+ * Generate a snippet from email text content.
43
+ *
44
+ * @param text - The text content (plain text or stripped HTML)
45
+ * @param maxLength - Maximum snippet length (default 256)
46
+ * @returns Truncated snippet with ellipsis if needed
47
+ */
48
+ export declare const generateSnippet: (text: string, maxLength?: number) => string;
49
+ /**
50
+ * Extract text content from parsed email for snippet generation.
51
+ *
52
+ * Prefers plain text over HTML, stripping the HTML's tags when it is the only
53
+ * part.
54
+ *
55
+ * @param textContent - Plain text content (may be undefined)
56
+ * @param htmlContent - HTML content (may be undefined)
57
+ * @param maxLength - Maximum snippet length
58
+ * @returns Generated snippet or empty string
59
+ */
60
+ export declare const extractSnippetFromEmail: (textContent: string | undefined, htmlContent: string | undefined, maxLength?: number) => string;
61
+ export declare const extractSearchText: (parsed: Pick<ParsedMail, "text" | "html">) => string;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Snippet generation utilities for email threading.
3
+ *
4
+ * Extracts preview text from email bodies by removing quoted content
5
+ * and normalizing whitespace.
6
+ */
7
+ import { createTextNormalizer, } from "./text/normalizer.js";
8
+ /**
9
+ * Remove quoted reply content from email body.
10
+ *
11
+ * Stops at common quote indicators:
12
+ * - Lines starting with ">"
13
+ * - "On ... wrote:" patterns
14
+ * - "--- Original Message ---" separators
15
+ * - Outlook-style underline separators
16
+ */
17
+ export const removeQuotedContent = (text) => {
18
+ const lines = text.split("\n");
19
+ const result = [];
20
+ for (const line of lines) {
21
+ // Stop at quote indicators
22
+ if (/^>/.test(line))
23
+ break;
24
+ if (/^On .+ wrote:$/i.test(line))
25
+ break;
26
+ if (/^-{3,}\s*Original Message\s*-{3,}$/i.test(line))
27
+ break;
28
+ if (/^_{3,}$/.test(line))
29
+ break; // Outlook separator
30
+ if (/^From:.*Sent:.*To:/i.test(line))
31
+ break; // Outlook forward header
32
+ result.push(line);
33
+ }
34
+ return result.join("\n");
35
+ };
36
+ const normalizer = createTextNormalizer();
37
+ /**
38
+ * Normalize subject for thread grouping and search.
39
+ *
40
+ * Processing steps:
41
+ * 1. Remove Re:/Fwd:/etc prefixes (multi-language support)
42
+ * 2. Auto-detect language or use provided language
43
+ * 3. Normalize with stemming and stopword removal
44
+ * 4. Deduplicate consecutive words
45
+ *
46
+ * Handles common prefixes across languages:
47
+ * - Re, Fwd, Fw (English)
48
+ * - Aw, Sv, Vs (German, Swedish, Danish)
49
+ * - Ref, Rif (Italian, Spanish)
50
+ * - Odp (Polish)
51
+ * - Ynt (Turkish)
52
+ * - Antw (Dutch)
53
+ * - Res (Portuguese)
54
+ *
55
+ * @param subject - The email subject line
56
+ * @param language - Optional language override (auto-detected if not provided)
57
+ */
58
+ export const normalizeSubject = (subject, language) => {
59
+ const SUBJECT_PREFIX_PATTERN = /^(\s*(Re|Fwd|Fw|Aw|Sv|Vs|Ref|Rif|Odp|Ynt|Antw|Res)(\[\d+\])?:\s*)+/i;
60
+ // Step 1: Remove prefixes
61
+ const withoutPrefix = subject.replace(SUBJECT_PREFIX_PATTERN, "").trim();
62
+ // Step 2: Auto-detect language if not provided
63
+ const detectedLanguage = language ?? normalizer.detectLanguage(withoutPrefix);
64
+ // Step 3: Normalize with stemming and stopword removal
65
+ return normalizer.normalize(withoutPrefix, {
66
+ language: detectedLanguage,
67
+ stem: true,
68
+ removeStopwords: true,
69
+ });
70
+ };
71
+ const flattenBody = (text) => removeQuotedContent(text).replace(/\s+/g, " ").trim();
72
+ /**
73
+ * Generate a snippet from email text content.
74
+ *
75
+ * @param text - The text content (plain text or stripped HTML)
76
+ * @param maxLength - Maximum snippet length (default 256)
77
+ * @returns Truncated snippet with ellipsis if needed
78
+ */
79
+ export const generateSnippet = (text, maxLength = 256) => {
80
+ const normalized = flattenBody(text);
81
+ if (normalized.length <= maxLength) {
82
+ return normalized;
83
+ }
84
+ // Truncate at word boundary
85
+ const truncated = normalized.slice(0, maxLength);
86
+ const lastSpace = truncated.lastIndexOf(" ");
87
+ // If no space found, just truncate
88
+ if (lastSpace === -1) {
89
+ return `${truncated.slice(0, maxLength - 1)}...`;
90
+ }
91
+ // Truncate at word boundary and add ellipsis
92
+ return `${truncated.slice(0, lastSpace)}...`;
93
+ };
94
+ const emailPlainText = (textContent, htmlContent) => {
95
+ if (textContent)
96
+ return textContent;
97
+ if (!htmlContent)
98
+ return "";
99
+ // Basic HTML tag stripping - for more robust handling, use html-to-text
100
+ return htmlContent
101
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
102
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
103
+ .replace(/<[^>]+>/g, " ")
104
+ .replace(/&nbsp;/g, " ")
105
+ .replace(/&amp;/g, "&")
106
+ .replace(/&lt;/g, "<")
107
+ .replace(/&gt;/g, ">")
108
+ .replace(/&quot;/g, '"');
109
+ };
110
+ /**
111
+ * Extract text content from parsed email for snippet generation.
112
+ *
113
+ * Prefers plain text over HTML, stripping the HTML's tags when it is the only
114
+ * part.
115
+ *
116
+ * @param textContent - Plain text content (may be undefined)
117
+ * @param htmlContent - HTML content (may be undefined)
118
+ * @param maxLength - Maximum snippet length
119
+ * @returns Generated snippet or empty string
120
+ */
121
+ export const extractSnippetFromEmail = (textContent, htmlContent, maxLength = 256) => generateSnippet(emailPlainText(textContent, htmlContent), maxLength);
122
+ const SEARCH_TEXT_LENGTH = 524288;
123
+ export const extractSearchText = (parsed) => {
124
+ const text = flattenBody(emailPlainText(parsed.text, typeof parsed.html === "string" ? parsed.html : undefined));
125
+ if (text.length <= SEARCH_TEXT_LENGTH)
126
+ return text;
127
+ const boundary = text.lastIndexOf(" ", SEARCH_TEXT_LENGTH);
128
+ return text.slice(0, boundary > 0 ? boundary : SEARCH_TEXT_LENGTH);
129
+ };
@@ -0,0 +1,86 @@
1
+ import type { IAccountRepository, IAddressRepository, IMailboxSpecialUseRepository, IMessageRepository } from "@remit/data-ports";
2
+ import type { FlagPushService } from "./flag-push.js";
3
+ import type { MessageMoveService } from "./message-move.js";
4
+ export interface SpamReportLogger {
5
+ info(obj: Record<string, unknown>, msg: string): void;
6
+ error(obj: Record<string, unknown>, msg: string): void;
7
+ }
8
+ /**
9
+ * The one designed, user-facing outcome of `notSpam`'s R2 wait timing out —
10
+ * distinct from any other failure so a caller surfacing failures to the user
11
+ * (the bulk handlers in packages/backend) can allowlist this specific,
12
+ * pre-written message instead of relaying an arbitrary thrown error's text.
13
+ */
14
+ export declare class MoveNotSettledError extends Error {
15
+ constructor(messageId: string);
16
+ }
17
+ /**
18
+ * The account advertises no Junk folder and has none under any conventional
19
+ * name, so there is nowhere to file the report. A designed, user-facing
20
+ * outcome — the user has to create the folder, and no amount of retrying
21
+ * changes that — so it carries its own text through the bulk handler's
22
+ * allowlist rather than being flattened to the generic retry copy.
23
+ */
24
+ export declare class NoJunkMailboxError extends Error {
25
+ constructor();
26
+ }
27
+ export interface SpamReportConfig {
28
+ messageService: IMessageRepository;
29
+ addressService: IAddressRepository;
30
+ accountService: IAccountRepository;
31
+ mailboxSpecialUseService: IMailboxSpecialUseRepository;
32
+ messageMoveService: MessageMoveService;
33
+ flagPushService: FlagPushService;
34
+ logger?: SpamReportLogger;
35
+ /** How long `notSpam` waits for a still-in-flight move before giving up. */
36
+ moveSettleTimeoutMs?: number;
37
+ moveSettlePollMs?: number;
38
+ }
39
+ export interface SpamReportParams {
40
+ accountConfigId: string;
41
+ accountId: string;
42
+ messageId: string;
43
+ setBy?: string;
44
+ }
45
+ export declare class SpamReportService {
46
+ private messageService;
47
+ private addressService;
48
+ private accountService;
49
+ private mailboxSpecialUseService;
50
+ private messageMoveService;
51
+ private flagPushService;
52
+ private log;
53
+ private moveSettleTimeoutMs;
54
+ private moveSettlePollMs;
55
+ constructor(config: SpamReportConfig);
56
+ /**
57
+ * The sender to act on. `addressId` is derived rather than read off the
58
+ * envelope row so it always names the row `ensureSenderAddress` writes and
59
+ * the harvester writes — one derivation, one row, whatever the envelope
60
+ * happens to carry.
61
+ */
62
+ private resolveFromAddress;
63
+ /**
64
+ * The list form answers `[]` for a row that is not there, where the single
65
+ * form throws — absence is a case both callers handle, not a fault.
66
+ */
67
+ private senderIsKnown;
68
+ /**
69
+ * Blocking a sender owns the row it writes to. Harvesting is what ordinarily
70
+ * creates it, but a message whose harvest never landed — or whose row was
71
+ * taken by a cascade — still has a sender the user is entitled to block, and
72
+ * a missing row used to abort report-spam before the message ever reached
73
+ * Junk. The id is the harvester's own derivation, so this converges on the
74
+ * one row rather than minting a parallel one.
75
+ *
76
+ * A row that already exists is left exactly as it stands. Reporting spam is
77
+ * evidence about the sender, never about the account's standing record of
78
+ * them: an upsert here would carry whatever the upsert path does on
79
+ * conflict — today a display name chosen by the spammer, and any flag a
80
+ * later sighting-clearing rule hangs off it — into an operation that means
81
+ * the opposite.
82
+ */
83
+ private ensureSenderAddress;
84
+ reportSpam: (params: SpamReportParams) => Promise<void>;
85
+ notSpam: (params: SpamReportParams) => Promise<void>;
86
+ }
@@ -0,0 +1,241 @@
1
+ import { deriveAddressId } from "@remit/data-ports/id";
2
+ import { AddressRole, MessageKeywordFlag } from "@remit/domain-enums";
3
+ import { isPlacementUnsettled, waitForPlacementToSettle, } from "./placement-settled.js";
4
+ /**
5
+ * The one designed, user-facing outcome of `notSpam`'s R2 wait timing out —
6
+ * distinct from any other failure so a caller surfacing failures to the user
7
+ * (the bulk handlers in packages/backend) can allowlist this specific,
8
+ * pre-written message instead of relaying an arbitrary thrown error's text.
9
+ */
10
+ export class MoveNotSettledError extends Error {
11
+ constructor(messageId) {
12
+ super(`Message ${messageId}'s move to Junk has not settled yet; try again in a moment.`);
13
+ this.name = "MoveNotSettledError";
14
+ }
15
+ }
16
+ /**
17
+ * The account advertises no Junk folder and has none under any conventional
18
+ * name, so there is nowhere to file the report. A designed, user-facing
19
+ * outcome — the user has to create the folder, and no amount of retrying
20
+ * changes that — so it carries its own text through the bulk handler's
21
+ * allowlist rather than being flattened to the generic retry copy.
22
+ */
23
+ export class NoJunkMailboxError extends Error {
24
+ constructor() {
25
+ super("This account has no Junk folder. Create one named Junk or Spam in your mail provider, then report this message again.");
26
+ this.name = "NoJunkMailboxError";
27
+ }
28
+ }
29
+ const noopLogger = {
30
+ info: () => { },
31
+ error: () => { },
32
+ };
33
+ const DEFAULT_MOVE_SETTLE_TIMEOUT_MS = 5_000;
34
+ const DEFAULT_MOVE_SETTLE_POLL_MS = 250;
35
+ export class SpamReportService {
36
+ messageService;
37
+ addressService;
38
+ accountService;
39
+ mailboxSpecialUseService;
40
+ messageMoveService;
41
+ flagPushService;
42
+ log;
43
+ moveSettleTimeoutMs;
44
+ moveSettlePollMs;
45
+ constructor(config) {
46
+ this.messageService = config.messageService;
47
+ this.addressService = config.addressService;
48
+ this.accountService = config.accountService;
49
+ this.mailboxSpecialUseService = config.mailboxSpecialUseService;
50
+ this.messageMoveService = config.messageMoveService;
51
+ this.flagPushService = config.flagPushService;
52
+ this.log = config.logger ?? noopLogger;
53
+ this.moveSettleTimeoutMs =
54
+ config.moveSettleTimeoutMs ?? DEFAULT_MOVE_SETTLE_TIMEOUT_MS;
55
+ this.moveSettlePollMs =
56
+ config.moveSettlePollMs ?? DEFAULT_MOVE_SETTLE_POLL_MS;
57
+ }
58
+ /**
59
+ * The sender to act on. `addressId` is derived rather than read off the
60
+ * envelope row so it always names the row `ensureSenderAddress` writes and
61
+ * the harvester writes — one derivation, one row, whatever the envelope
62
+ * happens to carry.
63
+ */
64
+ resolveFromAddress = async (accountConfigId, messageId) => {
65
+ const description = await this.messageService.describe(messageId);
66
+ const from = description.envelopeAddress.find((a) => a.addressRole === AddressRole.From);
67
+ if (!from) {
68
+ throw new Error(`Message ${messageId} has no From address to act on`);
69
+ }
70
+ return {
71
+ addressId: deriveAddressId(accountConfigId, from.normalizedEmail),
72
+ normalizedEmail: from.normalizedEmail,
73
+ displayName: from.displayName ?? "",
74
+ };
75
+ };
76
+ /**
77
+ * The list form answers `[]` for a row that is not there, where the single
78
+ * form throws — absence is a case both callers handle, not a fault.
79
+ */
80
+ senderIsKnown = async (accountConfigId, addressId) => {
81
+ const [known] = await this.addressService.getAddress(accountConfigId, [
82
+ addressId,
83
+ ]);
84
+ return known !== undefined;
85
+ };
86
+ /**
87
+ * Blocking a sender owns the row it writes to. Harvesting is what ordinarily
88
+ * creates it, but a message whose harvest never landed — or whose row was
89
+ * taken by a cascade — still has a sender the user is entitled to block, and
90
+ * a missing row used to abort report-spam before the message ever reached
91
+ * Junk. The id is the harvester's own derivation, so this converges on the
92
+ * one row rather than minting a parallel one.
93
+ *
94
+ * A row that already exists is left exactly as it stands. Reporting spam is
95
+ * evidence about the sender, never about the account's standing record of
96
+ * them: an upsert here would carry whatever the upsert path does on
97
+ * conflict — today a display name chosen by the spammer, and any flag a
98
+ * later sighting-clearing rule hangs off it — into an operation that means
99
+ * the opposite.
100
+ */
101
+ ensureSenderAddress = async (accountConfigId, from) => {
102
+ const separator = from.normalizedEmail.lastIndexOf("@");
103
+ const localPart = from.normalizedEmail.slice(0, separator);
104
+ const domain = from.normalizedEmail.slice(separator + 1);
105
+ if (separator < 0 || localPart.length === 0 || domain.length === 0) {
106
+ throw new Error(`Sender address ${from.normalizedEmail} is not a usable email address`);
107
+ }
108
+ if (await this.senderIsKnown(accountConfigId, from.addressId))
109
+ return;
110
+ await this.addressService.upsertAddress({
111
+ addressId: from.addressId,
112
+ accountConfigId,
113
+ localPart,
114
+ domain,
115
+ normalizedEmail: from.normalizedEmail,
116
+ normalizedCompound: `${from.displayName.toLowerCase()} ${from.normalizedEmail}`.trim(),
117
+ displayName: from.displayName,
118
+ });
119
+ };
120
+ reportSpam = async (params) => {
121
+ const { accountConfigId, accountId, messageId, setBy } = params;
122
+ const now = Date.now();
123
+ // Resolved before anything is written: with nowhere to file the report,
124
+ // the operation cannot happen, and a blocked sender plus a `spamReport`
125
+ // stamp on a message still sitting in the inbox is a report that half
126
+ // happened. Fail here and the press changed nothing.
127
+ const junkMailbox = await this.mailboxSpecialUseService.findJunkMailbox(accountId);
128
+ if (!junkMailbox) {
129
+ this.log.error({ accountId, messageId }, "No Junk mailbox for account");
130
+ throw new NoJunkMailboxError();
131
+ }
132
+ const from = await this.resolveFromAddress(accountConfigId, messageId);
133
+ const account = await this.accountService.get(accountId);
134
+ const isOwnAddress = from.normalizedEmail.toLowerCase() === account.email.toLowerCase();
135
+ if (!isOwnAddress) {
136
+ await this.ensureSenderAddress(accountConfigId, from);
137
+ await this.addressService.mergeFlags(accountConfigId, from.addressId, {
138
+ blocked: { value: true, setAt: now, setBy },
139
+ });
140
+ }
141
+ // Captured before this call writes anything: `before.spamReport` tells
142
+ // apart the two reasons the message can already be in Junk. If it is
143
+ // already set, a PREVIOUS report-spam press put it there and owns
144
+ // `originalMailboxId` — that value is exactly what Undo needs and must
145
+ // survive a repeated press. Only when it is absent is the message in
146
+ // Junk for some unrelated reason (a provider filter, a manual move),
147
+ // making any `originalMailboxId` on the row stale.
148
+ const before = await this.messageService.get(messageId);
149
+ const hadSpamReportAlready = Boolean(before.spamReport);
150
+ await this.messageService.update(messageId, {
151
+ spamReport: { reportedAt: now },
152
+ });
153
+ const alreadyInJunk = before.mailboxId === junkMailbox.mailboxId;
154
+ await this.messageMoveService.moveMessage(accountConfigId, messageId, junkMailbox.mailboxId, accountId);
155
+ if (alreadyInJunk && before.originalMailboxId && !hadSpamReportAlready) {
156
+ // moveMessage's same-mailbox guard no-opped — it never touches
157
+ // originalMailboxId, so a stale value left by some earlier, unrelated
158
+ // move would otherwise survive and send a later notSpam to the wrong
159
+ // folder. This report-spam action established no move of its own.
160
+ //
161
+ // A transition off the row this call read (imap-mutations R3): the pair
162
+ // being cleared belongs to a placement, and a lane that has claimed the
163
+ // row since keeps its own — its settle manages the pair.
164
+ await this.messageService.transitionPlacement(messageId, {
165
+ status: before.status,
166
+ mailboxId: before.mailboxId,
167
+ uid: before.uid,
168
+ }, { originalMailboxId: null, originalUid: null });
169
+ }
170
+ await this.flagPushService.flip({
171
+ accountId,
172
+ accountConfigId,
173
+ messageId,
174
+ mailboxId: junkMailbox.mailboxId,
175
+ flagName: MessageKeywordFlag.Junk,
176
+ operation: "add",
177
+ });
178
+ this.log.info({
179
+ accountId,
180
+ messageId,
181
+ addressId: from.addressId,
182
+ junkMailboxId: junkMailbox.mailboxId,
183
+ skippedBlock: isOwnAddress,
184
+ }, "Reported message as spam");
185
+ };
186
+ notSpam = async (params) => {
187
+ const { accountConfigId, accountId, messageId } = params;
188
+ // Unblocking a sender the account has no row for is already true, and
189
+ // minting one here would put an address nothing ever harvested into the
190
+ // address book — the withdrawal of a judgement is not a sighting.
191
+ const from = await this.resolveFromAddress(accountConfigId, messageId);
192
+ if (await this.senderIsKnown(accountConfigId, from.addressId)) {
193
+ await this.addressService.mergeFlags(accountConfigId, from.addressId, {
194
+ blocked: null,
195
+ });
196
+ }
197
+ const message = await this.messageService.get(messageId);
198
+ if (message.originalMailboxId) {
199
+ // Only wait on the move's settlement when there is actually a
200
+ // dependent write to make (the restore below) — a move in flight for
201
+ // an unrelated reason is not this operation's concern.
202
+ const settled = await waitForPlacementToSettle(this.messageService, messageId, {
203
+ timeoutMs: this.moveSettleTimeoutMs,
204
+ pollMs: this.moveSettlePollMs,
205
+ });
206
+ if (isPlacementUnsettled(settled)) {
207
+ throw new MoveNotSettledError(messageId);
208
+ }
209
+ await this.messageMoveService.restoreMessage(accountConfigId, messageId, accountId);
210
+ // Re-read, then clear against what was read. The restore just wrote a
211
+ // placement, so the row here is not the one above; predicating on it is
212
+ // what keeps this from erasing a pair some other lane is relying on
213
+ // (imap-mutations R3).
214
+ const restored = await this.messageService.get(messageId);
215
+ await this.messageService.transitionPlacement(messageId, {
216
+ status: restored.status,
217
+ mailboxId: restored.mailboxId,
218
+ uid: restored.uid,
219
+ }, { originalMailboxId: null, originalUid: null });
220
+ }
221
+ const current = await this.messageService.get(messageId);
222
+ await this.flagPushService.flip({
223
+ accountId,
224
+ accountConfigId,
225
+ messageId,
226
+ mailboxId: current.mailboxId,
227
+ flagName: MessageKeywordFlag.Junk,
228
+ operation: "remove",
229
+ });
230
+ await this.flagPushService.flip({
231
+ accountId,
232
+ accountConfigId,
233
+ messageId,
234
+ mailboxId: current.mailboxId,
235
+ flagName: MessageKeywordFlag.NotJunk,
236
+ operation: "add",
237
+ });
238
+ await this.messageService.clearSpamReport(messageId);
239
+ this.log.info({ accountId, messageId, addressId: from.addressId }, "Undid spam report");
240
+ };
241
+ }
@@ -0,0 +1,21 @@
1
+ import type { ISyncWarningRepository, SyncWarningItem, SyncWarningKey } from "@remit/data-ports";
2
+ export type SyncWarningContext = Omit<SyncWarningKey, "uid" | "stage">;
3
+ export type ServerUids = {
4
+ kind: "Listed";
5
+ uids: readonly number[];
6
+ } | {
7
+ kind: "OnRequest";
8
+ list: () => Promise<readonly number[]>;
9
+ } | {
10
+ kind: "Unlisted";
11
+ };
12
+ export interface SyncWarningRound {
13
+ failedUids: readonly number[];
14
+ syncedUids: readonly number[];
15
+ serverUids: ServerUids;
16
+ }
17
+ export declare class SyncWarningService {
18
+ private readonly repository;
19
+ constructor(repository: ISyncWarningRepository);
20
+ settle(context: SyncWarningContext, stage: SyncWarningItem["stage"], round: SyncWarningRound): Promise<void>;
21
+ }