@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
@@ -0,0 +1,14 @@
1
+ import type { IFilterRepository, IMessageLabelRepository } from "@remit/data-ports";
2
+ import type { PlacementMoveService } from "../placement-move.js";
3
+ import type { FilterConfig } from "./pipeline.js";
4
+ export interface FilterConfigDeps {
5
+ filterService: IFilterRepository;
6
+ messageLabelService: IMessageLabelRepository;
7
+ mailboxService: FilterConfig["mailboxService"];
8
+ placementMoveService: PlacementMoveService;
9
+ }
10
+ /**
11
+ * Assemble the filter config a body-materializing pass runs (RFC 034), shared by
12
+ * the imap-worker's sync path and the backend's read-path backfill.
13
+ */
14
+ export declare const buildFilterConfig: (deps: FilterConfigDeps) => FilterConfig;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Assemble the filter config a body-materializing pass runs (RFC 034), shared by
3
+ * the imap-worker's sync path and the backend's read-path backfill.
4
+ */
5
+ export const buildFilterConfig = (deps) => ({
6
+ filterService: deps.filterService,
7
+ messageLabelService: deps.messageLabelService,
8
+ mailboxService: deps.mailboxService,
9
+ placementMoveService: deps.placementMoveService,
10
+ });
@@ -1,5 +1,4 @@
1
1
  import type { ParsedMail } from "mailparser";
2
-
3
2
  /**
4
3
  * Canonical form of a `List-Id` value for exact comparison: the bracketed
5
4
  * identifier when the header carries the RFC 2919 `Name <list.id>` shape,
@@ -7,23 +6,10 @@ import type { ParsedMail } from "mailparser";
7
6
  * a `ListId` clause pass through this, so `<weekly.news.example.com>` and
8
7
  * `weekly.news.example.com` are one list. An empty input normalizes to `""`.
9
8
  */
10
- export const normalizeListId = (value: string): string => {
11
- const trimmed = value.trim();
12
- const bracketed = trimmed.match(/<([^>]+)>/);
13
- return (bracketed ? bracketed[1] : trimmed).trim().toLowerCase();
14
- };
15
-
9
+ export declare const normalizeListId: (value: string) => string;
16
10
  /**
17
11
  * The normalized `List-Id` header value of a parsed message, or `""` when the
18
12
  * message carries no `List-Id`. Read from the raw header line so the exact
19
13
  * value survives regardless of how the parser structures the header.
20
14
  */
21
- export const extractListId = (parsed: ParsedMail): string => {
22
- const line = parsed.headerLines.find(
23
- (header) => header.key.toLowerCase() === "list-id",
24
- );
25
- if (!line) return "";
26
- const colon = line.line.indexOf(":");
27
- if (colon < 0) return "";
28
- return normalizeListId(line.line.slice(colon + 1));
29
- };
15
+ export declare const extractListId: (parsed: ParsedMail) => string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Canonical form of a `List-Id` value for exact comparison: the bracketed
3
+ * identifier when the header carries the RFC 2919 `Name <list.id>` shape,
4
+ * otherwise the whole value, trimmed and case-folded. Both the stored copy and
5
+ * a `ListId` clause pass through this, so `<weekly.news.example.com>` and
6
+ * `weekly.news.example.com` are one list. An empty input normalizes to `""`.
7
+ */
8
+ export const normalizeListId = (value) => {
9
+ const trimmed = value.trim();
10
+ const bracketed = trimmed.match(/<([^>]+)>/);
11
+ return (bracketed ? bracketed[1] : trimmed).trim().toLowerCase();
12
+ };
13
+ /**
14
+ * The normalized `List-Id` header value of a parsed message, or `""` when the
15
+ * message carries no `List-Id`. Read from the raw header line so the exact
16
+ * value survives regardless of how the parser structures the header.
17
+ */
18
+ export const extractListId = (parsed) => {
19
+ const line = parsed.headerLines.find((header) => header.key.toLowerCase() === "list-id");
20
+ if (!line)
21
+ return "";
22
+ const colon = line.line.indexOf(":");
23
+ if (colon < 0)
24
+ return "";
25
+ return normalizeListId(line.line.slice(colon + 1));
26
+ };
@@ -0,0 +1,69 @@
1
+ import { type FilterItem, type ThreadMessageFieldTerm } from "@remit/data-ports";
2
+ type FilterClause = FilterItem["literalClauses"][number];
3
+ /**
4
+ * The `"None"` sentinel a filter's action fields carry when that action is
5
+ * absent (RFC 034 Decision 3.1) — `actionLabelId`/`actionMailboxId` are never
6
+ * empty/optional strings, so a missing action is this exact value, never `""`.
7
+ */
8
+ export declare const NO_ACTION = "None";
9
+ /**
10
+ * The message fields a filter evaluates against — the literal-clause targets
11
+ * (from / subject / body / list). A plain value object so the matcher stays independent of the `ParsedMail` shape and is
12
+ * trivially constructed in a test.
13
+ */
14
+ export interface FilterMessage {
15
+ from: string;
16
+ fromName: string;
17
+ subject: string;
18
+ text: string;
19
+ /** Normalized `List-Id` header value (see `normalizeListId`); `""` when absent. */
20
+ listId: string;
21
+ }
22
+ /**
23
+ * Whether one literal clause matches the message. From matches against the
24
+ * sender address and display name; Subject against the subject; HasWords
25
+ * requires every word somewhere in sender, subject or body; ListId against the exact normalized `List-Id`; FromDomain
26
+ * against the sender's registrable domain. An empty clause value never matches.
27
+ */
28
+ export declare const clauseMatches: (clause: FilterClause, msg: FilterMessage) => boolean;
29
+ /**
30
+ * Whether the literal clauses match under their combine operator. Empty clauses
31
+ * carry no literal constraint and pass vacuously; a caller that needs a
32
+ * predicate checks for clauses itself.
33
+ */
34
+ export declare const literalClausesMatch: (clauses: readonly FilterClause[], operator: FilterItem["matchOperator"], msg: FilterMessage) => boolean;
35
+ /**
36
+ * The terms to query with, and how to combine them. Empty terms narrow nothing
37
+ * — the caller reads the whole corpus and refines it.
38
+ */
39
+ export interface LiteralClauseNarrowing {
40
+ terms: ThreadMessageFieldTerm[];
41
+ operator: "and" | "or";
42
+ }
43
+ /**
44
+ * The store-side half of {@link literalClausesMatch}: the column terms a query
45
+ * can evaluate on behalf of these clauses, or `null` when they can match
46
+ * nothing at all and there is no query worth running.
47
+ *
48
+ * A term is deliberately WIDER than the clause it stands for — a `FromDomain`
49
+ * clause narrows to the registrable domain appearing anywhere in the sender,
50
+ * so `github.com.evil.example` still comes back — and a `HasWords` clause
51
+ * cannot be narrowed at all, since no row column carries the body. The rows a
52
+ * term selects are candidates; `literalClausesMatch` still decides. Pushing the
53
+ * terms down is what makes the caller's bound a bound on RESULTS: filtering a
54
+ * date-ordered page instead answers "matches among the newest N messages"
55
+ * (#459).
56
+ */
57
+ export declare const literalClauseTerms: (clauses: readonly FilterClause[], operator: FilterItem["matchOperator"]) => LiteralClauseNarrowing | null;
58
+ /**
59
+ * The move a message ends in when several filters matched: the filter whose
60
+ * predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
61
+ * tie-broken on `filterId` for the unreachable identical-timestamp case.
62
+ * `actionChangedAt` — not `ruleChangedAt` — is the signal: `ruleChangedAt` also
63
+ * bumps on a scope/expiry-only edit (reader #266), which changes a filter's
64
+ * lifecycle, not what it matches or does, and must not reorder exclusive-move
65
+ * precedence (reader #384). Nor is it `updatedAt`, so a cosmetic rename never
66
+ * flips an exclusive move either.
67
+ */
68
+ export declare const selectMoveWinner: (candidates: readonly FilterItem[]) => FilterItem | undefined;
69
+ export {};
@@ -0,0 +1,165 @@
1
+ import { FILTER_NO_ACTION, } from "@remit/data-ports";
2
+ import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
3
+ import { getDomain } from "tldts";
4
+ import { normalizeListId } from "./list-id.js";
5
+ /**
6
+ * The `"None"` sentinel a filter's action fields carry when that action is
7
+ * absent (RFC 034 Decision 3.1) — `actionLabelId`/`actionMailboxId` are never
8
+ * empty/optional strings, so a missing action is this exact value, never `""`.
9
+ */
10
+ export const NO_ACTION = FILTER_NO_ACTION;
11
+ const includesFold = (haystack, needle) => haystack.toLowerCase().includes(needle.toLowerCase());
12
+ /**
13
+ * Whether every whitespace-separated word of `value` appears somewhere in the
14
+ * sender address, sender name, subject or body — the shape free-text search
15
+ * matches with (`buildSearchConditions`), so a filter made from a search keeps
16
+ * the mail that search showed.
17
+ */
18
+ const hasEveryWord = (value, msg) => {
19
+ const fields = [msg.from, msg.fromName, msg.subject, msg.text];
20
+ return value
21
+ .split(/\s+/)
22
+ .filter(Boolean)
23
+ .every((word) => fields.some((field) => includesFold(field, word)));
24
+ };
25
+ const hostOf = (address) => {
26
+ const at = address.lastIndexOf("@");
27
+ return at >= 0 ? address.slice(at + 1) : address;
28
+ };
29
+ /**
30
+ * The registrable, public-suffix-aware domain of an email address or host, or
31
+ * `null` when none resolves. `getDomain` folds case and applies the ICANN
32
+ * suffix list, so `github.com.evil.example` yields `evil.example`, never
33
+ * `github.com` — a `FromDomain` clause cannot be spoofed by a crafted subdomain.
34
+ */
35
+ const registrableDomain = (addressOrHost) => getDomain(hostOf(addressOrHost.trim()));
36
+ /**
37
+ * Whether one literal clause matches the message. From matches against the
38
+ * sender address and display name; Subject against the subject; HasWords
39
+ * requires every word somewhere in sender, subject or body; ListId against the exact normalized `List-Id`; FromDomain
40
+ * against the sender's registrable domain. An empty clause value never matches.
41
+ */
42
+ export const clauseMatches = (clause, msg) => {
43
+ const value = clause.value.trim();
44
+ if (value === "")
45
+ return false;
46
+ switch (clause.field) {
47
+ case FilterClauseField.From:
48
+ return includesFold(msg.from, value) || includesFold(msg.fromName, value);
49
+ case FilterClauseField.Subject:
50
+ return includesFold(msg.subject, value);
51
+ case FilterClauseField.HasWords:
52
+ return hasEveryWord(value, msg);
53
+ case FilterClauseField.ListId: {
54
+ const target = normalizeListId(value);
55
+ return target !== "" && normalizeListId(msg.listId) === target;
56
+ }
57
+ case FilterClauseField.FromDomain: {
58
+ const target = registrableDomain(value);
59
+ if (target === null)
60
+ return false;
61
+ return registrableDomain(msg.from) === target;
62
+ }
63
+ default:
64
+ return false;
65
+ }
66
+ };
67
+ /**
68
+ * Whether the literal clauses match under their combine operator. Empty clauses
69
+ * carry no literal constraint and pass vacuously; a caller that needs a
70
+ * predicate checks for clauses itself.
71
+ */
72
+ export const literalClausesMatch = (clauses, operator, msg) => {
73
+ if (clauses.length === 0)
74
+ return true;
75
+ return operator === FilterMatchOperator.Or
76
+ ? clauses.some((clause) => clauseMatches(clause, msg))
77
+ : clauses.every((clause) => clauseMatches(clause, msg));
78
+ };
79
+ const clauseNarrowing = (clause) => {
80
+ const value = clause.value.trim();
81
+ if (value === "")
82
+ return { kind: "NeverMatches" };
83
+ switch (clause.field) {
84
+ case FilterClauseField.From:
85
+ return { kind: "Term", term: { field: "sender", contains: value } };
86
+ case FilterClauseField.Subject:
87
+ return { kind: "Term", term: { field: "subject", contains: value } };
88
+ case FilterClauseField.ListId: {
89
+ const target = normalizeListId(value);
90
+ if (target === "")
91
+ return { kind: "NeverMatches" };
92
+ return { kind: "Term", term: { field: "listId", contains: target } };
93
+ }
94
+ case FilterClauseField.FromDomain: {
95
+ const target = registrableDomain(value);
96
+ if (target === null)
97
+ return { kind: "NeverMatches" };
98
+ return { kind: "Term", term: { field: "sender", contains: target } };
99
+ }
100
+ default:
101
+ return { kind: "Unnarrowable" };
102
+ }
103
+ };
104
+ /**
105
+ * The store-side half of {@link literalClausesMatch}: the column terms a query
106
+ * can evaluate on behalf of these clauses, or `null` when they can match
107
+ * nothing at all and there is no query worth running.
108
+ *
109
+ * A term is deliberately WIDER than the clause it stands for — a `FromDomain`
110
+ * clause narrows to the registrable domain appearing anywhere in the sender,
111
+ * so `github.com.evil.example` still comes back — and a `HasWords` clause
112
+ * cannot be narrowed at all, since no row column carries the body. The rows a
113
+ * term selects are candidates; `literalClausesMatch` still decides. Pushing the
114
+ * terms down is what makes the caller's bound a bound on RESULTS: filtering a
115
+ * date-ordered page instead answers "matches among the newest N messages"
116
+ * (#459).
117
+ */
118
+ export const literalClauseTerms = (clauses, operator) => {
119
+ const combine = operator === FilterMatchOperator.Or ? "or" : "and";
120
+ if (clauses.length === 0)
121
+ return { terms: [], operator: combine };
122
+ const narrowings = clauses.map(clauseNarrowing);
123
+ const terms = narrowings.flatMap((narrowing) => narrowing.kind === "Term" ? [narrowing.term] : []);
124
+ if (combine === "and") {
125
+ if (narrowings.some((narrowing) => narrowing.kind === "NeverMatches")) {
126
+ return null;
127
+ }
128
+ return { terms, operator: "and" };
129
+ }
130
+ if (narrowings.every((narrowing) => narrowing.kind === "NeverMatches")) {
131
+ return null;
132
+ }
133
+ if (narrowings.some((narrowing) => narrowing.kind === "Unnarrowable")) {
134
+ return { terms: [], operator: "or" };
135
+ }
136
+ return { terms, operator: "or" };
137
+ };
138
+ /**
139
+ * The move a message ends in when several filters matched: the filter whose
140
+ * predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
141
+ * tie-broken on `filterId` for the unreachable identical-timestamp case.
142
+ * `actionChangedAt` — not `ruleChangedAt` — is the signal: `ruleChangedAt` also
143
+ * bumps on a scope/expiry-only edit (reader #266), which changes a filter's
144
+ * lifecycle, not what it matches or does, and must not reorder exclusive-move
145
+ * precedence (reader #384). Nor is it `updatedAt`, so a cosmetic rename never
146
+ * flips an exclusive move either.
147
+ */
148
+ export const selectMoveWinner = (candidates) => {
149
+ let winner;
150
+ for (const candidate of candidates) {
151
+ if (!winner) {
152
+ winner = candidate;
153
+ continue;
154
+ }
155
+ if (candidate.actionChangedAt > winner.actionChangedAt) {
156
+ winner = candidate;
157
+ continue;
158
+ }
159
+ if (candidate.actionChangedAt === winner.actionChangedAt &&
160
+ candidate.filterId > winner.filterId) {
161
+ winner = candidate;
162
+ }
163
+ }
164
+ return winner;
165
+ };
@@ -0,0 +1,64 @@
1
+ import type { IFilterRepository, IMailboxRepository, IMessageLabelRepository } from "@remit/data-ports";
2
+ import type { PlacementMoveService } from "../placement-move.js";
3
+ import { type FilterMessage } from "./match.js";
4
+ export interface FilterLogger {
5
+ info(obj: Record<string, unknown>, msg: string): void;
6
+ debug?(obj: Record<string, unknown>, msg: string): void;
7
+ warn?(obj: Record<string, unknown>, msg: string): void;
8
+ error?(obj: Record<string, unknown>, msg: string): void;
9
+ }
10
+ /**
11
+ * Dependencies for the index-time filter pass (RFC 034). Optional on
12
+ * {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
13
+ * without it simply runs no filter matching.
14
+ */
15
+ export interface FilterConfig {
16
+ filterService: IFilterRepository;
17
+ messageLabelService: IMessageLabelRepository;
18
+ /** Names the account a filter's folder belongs to. */
19
+ mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
20
+ placementMoveService: PlacementMoveService;
21
+ }
22
+ /**
23
+ * What the matched filters resolve to for one message: every matching filter's
24
+ * label (additive — RFC 034 Decision 3.1), and at most one move (exclusive — the
25
+ * most-recently-changed matching filter, Decision 3.2).
26
+ */
27
+ export interface FilterDecision {
28
+ labels: Array<{
29
+ labelId: string;
30
+ filterId: string;
31
+ }>;
32
+ move?: {
33
+ destinationMailboxId: string;
34
+ filterId: string;
35
+ };
36
+ }
37
+ /**
38
+ * Evaluates a synced message against the account's active filters and resolves
39
+ * the actions to apply (RFC 034). Reads only — listing filters and refreshing
40
+ * lazy expiry; the caller applies the
41
+ * resulting {@link FilterDecision}.
42
+ *
43
+ * `evaluate` isolates every failure in this read/decision phase from body sync,
44
+ * matching the placement-resolution precedent (#1246): a schema-drifted query
45
+ * must never fail the surrounding message store, since the body is
46
+ * already durable by the time this runs. The apply of the decision — label
47
+ * writes and the move enqueue — is the caller's responsibility and is *not*
48
+ * swallowed there, so a genuine DDB/SQS write failure still propagates and
49
+ * requeues the message.
50
+ */
51
+ export declare class FilterPipeline {
52
+ private readonly config;
53
+ private readonly log;
54
+ constructor(config: FilterConfig, log: FilterLogger);
55
+ evaluate(accountConfigId: string, accountId: string, messageId: string, msg: FilterMessage): Promise<FilterDecision>;
56
+ /**
57
+ * A filter sits on the account config and sees every account's mail, but its
58
+ * folder is one account's: mail of another account is not this filter's to
59
+ * act on, exactly as if it had not matched. A folder no account holds any
60
+ * more turns the filter off, with the reason the filter list shows.
61
+ */
62
+ private appliesTo;
63
+ private match;
64
+ }
@@ -0,0 +1,100 @@
1
+ import { inspect } from "node:util";
2
+ import { FilterDisabledReason, FilterState } from "@remit/domain-enums";
3
+ import { literalClausesMatch, NO_ACTION, selectMoveWinner, } from "./match.js";
4
+ const EMPTY_DECISION = { labels: [] };
5
+ /**
6
+ * Evaluates a synced message against the account's active filters and resolves
7
+ * the actions to apply (RFC 034). Reads only — listing filters and refreshing
8
+ * lazy expiry; the caller applies the
9
+ * resulting {@link FilterDecision}.
10
+ *
11
+ * `evaluate` isolates every failure in this read/decision phase from body sync,
12
+ * matching the placement-resolution precedent (#1246): a schema-drifted query
13
+ * must never fail the surrounding message store, since the body is
14
+ * already durable by the time this runs. The apply of the decision — label
15
+ * writes and the move enqueue — is the caller's responsibility and is *not*
16
+ * swallowed there, so a genuine DDB/SQS write failure still propagates and
17
+ * requeues the message.
18
+ */
19
+ export class FilterPipeline {
20
+ config;
21
+ log;
22
+ constructor(config, log) {
23
+ this.config = config;
24
+ this.log = log;
25
+ }
26
+ async evaluate(accountConfigId, accountId, messageId, msg) {
27
+ return this.match(accountConfigId, accountId, msg).catch((error) => {
28
+ this.log.error?.({
29
+ alert: "body_sync_filter_match_failed",
30
+ messageId,
31
+ accountConfigId,
32
+ errorName: error?.name,
33
+ error: inspect(error),
34
+ }, "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)");
35
+ return EMPTY_DECISION;
36
+ });
37
+ }
38
+ /**
39
+ * A filter sits on the account config and sees every account's mail, but its
40
+ * folder is one account's: mail of another account is not this filter's to
41
+ * act on, exactly as if it had not matched. A folder no account holds any
42
+ * more turns the filter off, with the reason the filter list shows.
43
+ */
44
+ async appliesTo(accountConfigId, accountId, filter) {
45
+ if (filter.actionMailboxId === NO_ACTION)
46
+ return true;
47
+ const owner = await this.config.mailboxService.resolveAccountId(filter.actionMailboxId);
48
+ if (owner !== null)
49
+ return owner === accountId;
50
+ await this.config.filterService.update(accountConfigId, filter.filterId, {
51
+ state: FilterState.Disabled,
52
+ disabledReason: FilterDisabledReason.FolderMissing,
53
+ });
54
+ this.log.warn?.({ filterId: filter.filterId, accountConfigId }, "Filter turned off: the folder it moves mail into no longer exists");
55
+ return false;
56
+ }
57
+ async match(accountConfigId, accountId, msg) {
58
+ const active = await this.config.filterService.listByAccountAndState(accountConfigId, FilterState.Active);
59
+ if (active.length === 0)
60
+ return EMPTY_DECISION;
61
+ const matched = [];
62
+ for (const filter of active) {
63
+ // Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
64
+ // its expiresAt patches it to Expired on this read and drops it from
65
+ // evaluation, independent of whether the TTL delete has run.
66
+ const usable = await this.config.filterService.refreshExpiry(filter);
67
+ if (usable.state !== FilterState.Active)
68
+ continue;
69
+ if (!(await this.appliesTo(accountConfigId, accountId, usable)))
70
+ continue;
71
+ if (filterMatches(usable, msg))
72
+ matched.push(usable);
73
+ }
74
+ if (matched.length === 0)
75
+ return EMPTY_DECISION;
76
+ const labels = [];
77
+ const seenLabels = new Set();
78
+ for (const filter of matched) {
79
+ if (filter.actionLabelId === NO_ACTION)
80
+ continue;
81
+ if (seenLabels.has(filter.actionLabelId))
82
+ continue;
83
+ seenLabels.add(filter.actionLabelId);
84
+ labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
85
+ }
86
+ const winner = selectMoveWinner(matched.filter((filter) => filter.actionMailboxId !== NO_ACTION));
87
+ return {
88
+ labels,
89
+ ...(winner
90
+ ? {
91
+ move: {
92
+ destinationMailboxId: winner.actionMailboxId,
93
+ filterId: winner.filterId,
94
+ },
95
+ }
96
+ : {}),
97
+ };
98
+ }
99
+ }
100
+ const filterMatches = (filter, msg) => literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
@@ -1,32 +1,28 @@
1
1
  import type { IMessageRepository, MessageItem } from "@remit/data-ports";
2
2
  import type { FlagPushLogger } from "./flag-push.js";
3
- import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
4
3
  import type { IImapConnection } from "./types.js";
5
-
6
4
  export interface ResolveExhaustedFlagPushDeps {
7
- messageService: Pick<IMessageRepository, "deleteIfPlacement">;
8
- markerService: { delete(messageId: string, flagName: string): Promise<void> };
9
- log: FlagPushLogger;
5
+ messageService: Pick<IMessageRepository, "deleteIfPlacement">;
6
+ markerService: {
7
+ delete(messageId: string, flagName: string): Promise<void>;
8
+ };
9
+ log: FlagPushLogger;
10
10
  }
11
-
12
11
  export interface ResolveExhaustedFlagPushInput {
13
- /** Where the row stood when the caller read it; the delete holds only while it still does. */
14
- placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
15
- accountId: string;
16
- accountConfigId: string;
17
- messageId: string;
18
- flagName: string;
19
- uid: number;
20
- mailboxPath: string;
21
- getConnection: () => Promise<IImapConnection>;
12
+ /** Where the row stood when the caller read it; the delete holds only while it still does. */
13
+ placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
14
+ accountId: string;
15
+ accountConfigId: string;
16
+ messageId: string;
17
+ flagName: string;
18
+ uid: number;
19
+ mailboxPath: string;
20
+ getConnection: () => Promise<IImapConnection>;
22
21
  }
23
-
24
22
  export type FlagPushTerminalOutcome = "reconciled" | "broken";
25
-
26
23
  export interface ResolveExhaustedFlagPushResult {
27
- outcome: FlagPushTerminalOutcome;
24
+ outcome: FlagPushTerminalOutcome;
28
25
  }
29
-
30
26
  /**
31
27
  * Resolve a FLAG_PUSH failure that has exhausted the delivering queue's
32
28
  * redelivery budget into exactly one of two terminal outcomes, mirroring
@@ -59,56 +55,4 @@ export interface ResolveExhaustedFlagPushResult {
59
55
  * mail, so the cost is paid deliberately: a stale alert is recoverable, a
60
56
  * deleted message is not.
61
57
  */
62
- export const resolveExhaustedFlagPushFailure = async (
63
- deps: ResolveExhaustedFlagPushDeps,
64
- input: ResolveExhaustedFlagPushInput,
65
- ): Promise<ResolveExhaustedFlagPushResult> => {
66
- const {
67
- accountId,
68
- accountConfigId,
69
- messageId,
70
- flagName,
71
- uid,
72
- mailboxPath,
73
- getConnection,
74
- } = input;
75
-
76
- const connection = await getConnection();
77
- await connection.openBox(mailboxPath, true);
78
-
79
- if (await isMessageGoneFromOpenMailbox(connection, uid)) {
80
- await deps.markerService.delete(messageId, flagName);
81
- const reconciled = await deps.messageService.deleteIfPlacement(
82
- messageId,
83
- input.placement,
84
- );
85
- deps.log.info(
86
- {
87
- metric: "flag_push_stale_row_reconciled",
88
- accountId,
89
- accountConfigId,
90
- messageId,
91
- flagName,
92
- uid,
93
- mailboxPath,
94
- reconciled,
95
- },
96
- "Message no longer at its flag-push mailbox after retry exhaustion (external delete); marker dropped, stale row reconciled",
97
- );
98
- return { outcome: "reconciled" };
99
- }
100
-
101
- deps.log.error(
102
- {
103
- alert: "flag_push_failed",
104
- accountId,
105
- accountConfigId,
106
- messageId,
107
- flagName,
108
- uid,
109
- mailboxPath,
110
- },
111
- "Flag push could not be pushed to IMAP after retry exhaustion; message still exists — marker left pending for operator investigation",
112
- );
113
- return { outcome: "broken" };
114
- };
58
+ export declare const resolveExhaustedFlagPushFailure: (deps: ResolveExhaustedFlagPushDeps, input: ResolveExhaustedFlagPushInput) => Promise<ResolveExhaustedFlagPushResult>;
@@ -0,0 +1,63 @@
1
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
2
+ /**
3
+ * Resolve a FLAG_PUSH failure that has exhausted the delivering queue's
4
+ * redelivery budget into exactly one of two terminal outcomes, mirroring
5
+ * `resolveExhaustedPlacementMoveFailure` (#1289) and
6
+ * `resolveExhaustedBodySyncFailures` (#1270) for the same failure taxonomy
7
+ * (epic #1281 invariant 3) — no third, softer outcome.
8
+ *
9
+ * 1. RECONCILED (expected) — the message no longer exists at its mailbox on
10
+ * IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather than by a
11
+ * FETCH coming back empty. Per invariant 2, an external delete supersedes
12
+ * the marker entirely: the marker is dropped and the stale
13
+ * Message/ThreadMessage rows are deleted via `deleteIfPlacement`.
14
+ * Metric only, no alarm — routine.
15
+ * 2. BROKEN — the message still exists, but the flag push keeps failing.
16
+ * Broken code or a broken account, not a transient blip. The marker is
17
+ * left in place (not cleared) — while pending, resync never reverts the
18
+ * local flag (invariant 3 of this issue's own spec), so leaving it does
19
+ * not risk correctness, and dropping it here would discard the only
20
+ * record that IMAP still owes this push. Logged with an `alert`-shaped
21
+ * entry for an operator alarm; never re-thrown (terminal — the caller acks
22
+ * either way, since retrying a stale or permanently-broken push can never
23
+ * succeed).
24
+ *
25
+ * An operator reading `flag_push_failed` should know one case where the
26
+ * message is not actually there: a message another client expunged
27
+ * mid-session can answer an empty FETCH while the server still lists its UID
28
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
29
+ * lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
30
+ * alert stands until someone clears it. The reverse mistake deletes live
31
+ * mail, so the cost is paid deliberately: a stale alert is recoverable, a
32
+ * deleted message is not.
33
+ */
34
+ export const resolveExhaustedFlagPushFailure = async (deps, input) => {
35
+ const { accountId, accountConfigId, messageId, flagName, uid, mailboxPath, getConnection, } = input;
36
+ const connection = await getConnection();
37
+ await connection.openBox(mailboxPath, true);
38
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
39
+ await deps.markerService.delete(messageId, flagName);
40
+ const reconciled = await deps.messageService.deleteIfPlacement(messageId, input.placement);
41
+ deps.log.info({
42
+ metric: "flag_push_stale_row_reconciled",
43
+ accountId,
44
+ accountConfigId,
45
+ messageId,
46
+ flagName,
47
+ uid,
48
+ mailboxPath,
49
+ reconciled,
50
+ }, "Message no longer at its flag-push mailbox after retry exhaustion (external delete); marker dropped, stale row reconciled");
51
+ return { outcome: "reconciled" };
52
+ }
53
+ deps.log.error({
54
+ alert: "flag_push_failed",
55
+ accountId,
56
+ accountConfigId,
57
+ messageId,
58
+ flagName,
59
+ uid,
60
+ mailboxPath,
61
+ }, "Flag push could not be pushed to IMAP after retry exhaustion; message still exists — marker left pending for operator investigation");
62
+ return { outcome: "broken" };
63
+ };