@remit/mailbox-service 0.0.112 → 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 (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 +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} +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,225 +0,0 @@
1
- import {
2
- FILTER_NO_ACTION,
3
- type FilterItem,
4
- type ThreadMessageFieldTerm,
5
- } from "@remit/data-ports";
6
- import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
7
- import { getDomain } from "tldts";
8
- import { normalizeListId } from "./list-id.js";
9
-
10
- type FilterClause = FilterItem["literalClauses"][number];
11
-
12
- /**
13
- * The `"None"` sentinel a filter's action fields carry when that action is
14
- * absent (RFC 034 Decision 3.1) — `actionLabelId`/`actionMailboxId` are never
15
- * empty/optional strings, so a missing action is this exact value, never `""`.
16
- */
17
- export const NO_ACTION = FILTER_NO_ACTION;
18
-
19
- /**
20
- * The message fields a filter evaluates against — the literal-clause targets
21
- * (from / subject / body / list). A plain value object so the matcher stays independent of the `ParsedMail` shape and is
22
- * trivially constructed in a test.
23
- */
24
- export interface FilterMessage {
25
- from: string;
26
- fromName: string;
27
- subject: string;
28
- text: string;
29
- /** Normalized `List-Id` header value (see `normalizeListId`); `""` when absent. */
30
- listId: string;
31
- }
32
-
33
- const includesFold = (haystack: string, needle: string): boolean =>
34
- haystack.toLowerCase().includes(needle.toLowerCase());
35
-
36
- /**
37
- * Whether every whitespace-separated word of `value` appears somewhere in the
38
- * sender address, sender name, subject or body — the shape free-text search
39
- * matches with (`buildSearchConditions`), so a filter made from a search keeps
40
- * the mail that search showed.
41
- */
42
- const hasEveryWord = (value: string, msg: FilterMessage): boolean => {
43
- const fields = [msg.from, msg.fromName, msg.subject, msg.text];
44
- return value
45
- .split(/\s+/)
46
- .filter(Boolean)
47
- .every((word) => fields.some((field) => includesFold(field, word)));
48
- };
49
-
50
- const hostOf = (address: string): string => {
51
- const at = address.lastIndexOf("@");
52
- return at >= 0 ? address.slice(at + 1) : address;
53
- };
54
-
55
- /**
56
- * The registrable, public-suffix-aware domain of an email address or host, or
57
- * `null` when none resolves. `getDomain` folds case and applies the ICANN
58
- * suffix list, so `github.com.evil.example` yields `evil.example`, never
59
- * `github.com` — a `FromDomain` clause cannot be spoofed by a crafted subdomain.
60
- */
61
- const registrableDomain = (addressOrHost: string): string | null =>
62
- getDomain(hostOf(addressOrHost.trim()));
63
-
64
- /**
65
- * Whether one literal clause matches the message. From matches against the
66
- * sender address and display name; Subject against the subject; HasWords
67
- * requires every word somewhere in sender, subject or body; ListId against the exact normalized `List-Id`; FromDomain
68
- * against the sender's registrable domain. An empty clause value never matches.
69
- */
70
- export const clauseMatches = (
71
- clause: FilterClause,
72
- msg: FilterMessage,
73
- ): boolean => {
74
- const value = clause.value.trim();
75
- if (value === "") return false;
76
- switch (clause.field) {
77
- case FilterClauseField.From:
78
- return includesFold(msg.from, value) || includesFold(msg.fromName, value);
79
- case FilterClauseField.Subject:
80
- return includesFold(msg.subject, value);
81
- case FilterClauseField.HasWords:
82
- return hasEveryWord(value, msg);
83
- case FilterClauseField.ListId: {
84
- const target = normalizeListId(value);
85
- return target !== "" && normalizeListId(msg.listId) === target;
86
- }
87
- case FilterClauseField.FromDomain: {
88
- const target = registrableDomain(value);
89
- if (target === null) return false;
90
- return registrableDomain(msg.from) === target;
91
- }
92
- default:
93
- return false;
94
- }
95
- };
96
-
97
- /**
98
- * Whether the literal clauses match under their combine operator. Empty clauses
99
- * carry no literal constraint and pass vacuously; a caller that needs a
100
- * predicate checks for clauses itself.
101
- */
102
- export const literalClausesMatch = (
103
- clauses: readonly FilterClause[],
104
- operator: FilterItem["matchOperator"],
105
- msg: FilterMessage,
106
- ): boolean => {
107
- if (clauses.length === 0) return true;
108
- return operator === FilterMatchOperator.Or
109
- ? clauses.some((clause) => clauseMatches(clause, msg))
110
- : clauses.every((clause) => clauseMatches(clause, msg));
111
- };
112
-
113
- /**
114
- * What a store can be asked for on behalf of one clause: a column term, or the
115
- * two answers a term cannot express — a clause nothing can satisfy, and a
116
- * clause selecting on something no ThreadMessage column carries.
117
- */
118
- type ClauseNarrowing =
119
- | { kind: "Term"; term: ThreadMessageFieldTerm }
120
- | { kind: "NeverMatches" }
121
- | { kind: "Unnarrowable" };
122
-
123
- const clauseNarrowing = (clause: FilterClause): ClauseNarrowing => {
124
- const value = clause.value.trim();
125
- if (value === "") return { kind: "NeverMatches" };
126
- switch (clause.field) {
127
- case FilterClauseField.From:
128
- return { kind: "Term", term: { field: "sender", contains: value } };
129
- case FilterClauseField.Subject:
130
- return { kind: "Term", term: { field: "subject", contains: value } };
131
- case FilterClauseField.ListId: {
132
- const target = normalizeListId(value);
133
- if (target === "") return { kind: "NeverMatches" };
134
- return { kind: "Term", term: { field: "listId", contains: target } };
135
- }
136
- case FilterClauseField.FromDomain: {
137
- const target = registrableDomain(value);
138
- if (target === null) return { kind: "NeverMatches" };
139
- return { kind: "Term", term: { field: "sender", contains: target } };
140
- }
141
- default:
142
- return { kind: "Unnarrowable" };
143
- }
144
- };
145
-
146
- /**
147
- * The terms to query with, and how to combine them. Empty terms narrow nothing
148
- * — the caller reads the whole corpus and refines it.
149
- */
150
- export interface LiteralClauseNarrowing {
151
- terms: ThreadMessageFieldTerm[];
152
- operator: "and" | "or";
153
- }
154
-
155
- /**
156
- * The store-side half of {@link literalClausesMatch}: the column terms a query
157
- * can evaluate on behalf of these clauses, or `null` when they can match
158
- * nothing at all and there is no query worth running.
159
- *
160
- * A term is deliberately WIDER than the clause it stands for — a `FromDomain`
161
- * clause narrows to the registrable domain appearing anywhere in the sender,
162
- * so `github.com.evil.example` still comes back — and a `HasWords` clause
163
- * cannot be narrowed at all, since no row column carries the body. The rows a
164
- * term selects are candidates; `literalClausesMatch` still decides. Pushing the
165
- * terms down is what makes the caller's bound a bound on RESULTS: filtering a
166
- * date-ordered page instead answers "matches among the newest N messages"
167
- * (#459).
168
- */
169
- export const literalClauseTerms = (
170
- clauses: readonly FilterClause[],
171
- operator: FilterItem["matchOperator"],
172
- ): LiteralClauseNarrowing | null => {
173
- const combine = operator === FilterMatchOperator.Or ? "or" : "and";
174
- if (clauses.length === 0) return { terms: [], operator: combine };
175
- const narrowings = clauses.map(clauseNarrowing);
176
- const terms = narrowings.flatMap((narrowing) =>
177
- narrowing.kind === "Term" ? [narrowing.term] : [],
178
- );
179
- if (combine === "and") {
180
- if (narrowings.some((narrowing) => narrowing.kind === "NeverMatches")) {
181
- return null;
182
- }
183
- return { terms, operator: "and" };
184
- }
185
- if (narrowings.every((narrowing) => narrowing.kind === "NeverMatches")) {
186
- return null;
187
- }
188
- if (narrowings.some((narrowing) => narrowing.kind === "Unnarrowable")) {
189
- return { terms: [], operator: "or" };
190
- }
191
- return { terms, operator: "or" };
192
- };
193
-
194
- /**
195
- * The move a message ends in when several filters matched: the filter whose
196
- * predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
197
- * tie-broken on `filterId` for the unreachable identical-timestamp case.
198
- * `actionChangedAt` — not `ruleChangedAt` — is the signal: `ruleChangedAt` also
199
- * bumps on a scope/expiry-only edit (reader #266), which changes a filter's
200
- * lifecycle, not what it matches or does, and must not reorder exclusive-move
201
- * precedence (reader #384). Nor is it `updatedAt`, so a cosmetic rename never
202
- * flips an exclusive move either.
203
- */
204
- export const selectMoveWinner = (
205
- candidates: readonly FilterItem[],
206
- ): FilterItem | undefined => {
207
- let winner: FilterItem | undefined;
208
- for (const candidate of candidates) {
209
- if (!winner) {
210
- winner = candidate;
211
- continue;
212
- }
213
- if (candidate.actionChangedAt > winner.actionChangedAt) {
214
- winner = candidate;
215
- continue;
216
- }
217
- if (
218
- candidate.actionChangedAt === winner.actionChangedAt &&
219
- candidate.filterId > winner.filterId
220
- ) {
221
- winner = candidate;
222
- }
223
- }
224
- return winner;
225
- };
@@ -1,199 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, beforeEach, describe, it, mock } from "node:test";
3
- import type { AccountItem, CreateFilterInput } from "@remit/data-ports";
4
- import {
5
- AccountAuthType,
6
- ConnectionState,
7
- FilterClauseField,
8
- FilterMatchOperator,
9
- FilterScope,
10
- } from "@remit/domain-enums";
11
- import { PlacementMoveService } from "../placement-move.js";
12
- import {
13
- openStore,
14
- type Store,
15
- seedAccount,
16
- seedMailbox,
17
- } from "../test-helpers/test-store.js";
18
- import type { FilterMessage } from "./match.js";
19
- import { FilterPipeline } from "./pipeline.js";
20
-
21
- const ACCOUNT = "cfg-1";
22
-
23
- let store: Store;
24
- let account: AccountItem;
25
- let archive: string;
26
- let projects: string;
27
-
28
- beforeEach(async () => {
29
- store = await openStore();
30
- account = await seedAccount(store, { accountConfigId: ACCOUNT });
31
- archive = (await seedMailbox(store, account, "Archive")).mailboxId;
32
- projects = (await seedMailbox(store, account, "Projects")).mailboxId;
33
- });
34
-
35
- afterEach(async () => {
36
- mock.timers.reset();
37
- await store.close();
38
- });
39
-
40
- const fromGithub: FilterMessage = {
41
- from: "npm@github.com",
42
- fromName: "npm",
43
- subject: "A new version of left-pad is available",
44
- text: "body",
45
- listId: "",
46
- };
47
-
48
- const receipt: FilterMessage = {
49
- from: "billing@stripe.com",
50
- fromName: "Stripe",
51
- subject: "Your receipt",
52
- text: "Thanks for your payment",
53
- listId: "",
54
- };
55
-
56
- const senders = (actionMailboxId: string) =>
57
- store.filter.create({
58
- accountConfigId: ACCOUNT,
59
- name: "rule",
60
- scope: FilterScope.Standing,
61
- matchOperator: FilterMatchOperator.Or,
62
- literalClauses: [
63
- { field: FilterClauseField.From, value: "npm@github.com" },
64
- { field: FilterClauseField.From, value: "notifications@github.com" },
65
- ],
66
- actionMailboxId,
67
- });
68
-
69
- const pipeline = () =>
70
- new FilterPipeline(
71
- {
72
- filterService: store.filter,
73
- messageLabelService: store.messageLabel,
74
- mailboxService: store.mailbox,
75
- placementMoveService: new PlacementMoveService({
76
- messageService: store.message,
77
- threadMessageService: store.threadMessage,
78
- markerService: store.placementMove,
79
- addressService: store.address,
80
- mailboxSpecialUseService: store.mailboxSpecialUse,
81
- mailboxService: store.mailbox,
82
- sqsQueueUrl: "https://sqs.test/message-mgmt",
83
- }),
84
- },
85
- { info: () => {}, error: () => {} },
86
- );
87
-
88
- describe("FilterPipeline over literal filters", () => {
89
- it("resolves the move for a matching sender", async () => {
90
- const filter = await senders(archive);
91
-
92
- const decision = await pipeline().evaluate(
93
- ACCOUNT,
94
- account.accountId,
95
- "m-1",
96
- fromGithub,
97
- );
98
-
99
- assert.deepEqual(decision.move, {
100
- destinationMailboxId: archive,
101
- filterId: filter.filterId,
102
- });
103
- });
104
-
105
- it("resolves nothing for a sender no clause names", async () => {
106
- await senders(archive);
107
-
108
- const decision = await pipeline().evaluate(
109
- ACCOUNT,
110
- account.accountId,
111
- "m-2",
112
- {
113
- ...fromGithub,
114
- from: "hello@stripe.com",
115
- },
116
- );
117
-
118
- assert.equal(decision.move, undefined);
119
- assert.deepEqual(decision.labels, []);
120
- });
121
-
122
- it("gives a contested message to the more recently changed action", async () => {
123
- mock.timers.enable({ apis: ["Date"], now: 1_000_000 });
124
- await senders(archive);
125
- mock.timers.setTime(9_000_000);
126
- const rival = await senders(projects);
127
-
128
- const decision = await pipeline().evaluate(
129
- ACCOUNT,
130
- account.accountId,
131
- "m-3",
132
- fromGithub,
133
- );
134
-
135
- assert.deepEqual(decision.move, {
136
- destinationMailboxId: projects,
137
- filterId: rival.filterId,
138
- });
139
- });
140
- });
141
-
142
- describe("FilterPipeline scopes a filter to its folder's account", () => {
143
- it("leaves another account's mail alone, as if the filter had not matched", async () => {
144
- const sibling = await store.account.create({
145
- accountConfigId: ACCOUNT,
146
- username: "me-too@example.com",
147
- email: "me-too@example.com",
148
- authType: AccountAuthType.Password,
149
- passwordHash: '{"ciphertext":"x"}',
150
- imapHost: "imap.example.com",
151
- imapPort: 993,
152
- imapTls: true,
153
- imapStartTls: false,
154
- isActive: true,
155
- connectionState: ConnectionState.Authenticated,
156
- });
157
- const travel = await seedMailbox(store, sibling, "Travel");
158
- const filter = await senders(travel.mailboxId);
159
-
160
- const mine = await pipeline().evaluate(
161
- ACCOUNT,
162
- account.accountId,
163
- "m-1",
164
- fromGithub,
165
- );
166
- const theirs = await pipeline().evaluate(
167
- ACCOUNT,
168
- sibling.accountId,
169
- "m-2",
170
- fromGithub,
171
- );
172
-
173
- assert.deepEqual(mine, { labels: [] });
174
- assert.deepEqual(theirs.move, {
175
- destinationMailboxId: travel.mailboxId,
176
- filterId: filter.filterId,
177
- });
178
- assert.equal(
179
- (await store.filter.get(ACCOUNT, filter.filterId)).state,
180
- "Active",
181
- );
182
- });
183
-
184
- it("turns the filter off when no account holds its folder any more", async () => {
185
- const filter = await senders("mbx-gone");
186
-
187
- const decision = await pipeline().evaluate(
188
- ACCOUNT,
189
- account.accountId,
190
- "m-1",
191
- fromGithub,
192
- );
193
-
194
- assert.deepEqual(decision, { labels: [] });
195
- const stored = await store.filter.get(ACCOUNT, filter.filterId);
196
- assert.equal(stored.state, "Disabled");
197
- assert.equal(stored.disabledReason, "FolderMissing");
198
- });
199
- });
@@ -1,170 +0,0 @@
1
- import { inspect } from "node:util";
2
- import type {
3
- FilterItem,
4
- IFilterRepository,
5
- IMailboxRepository,
6
- IMessageLabelRepository,
7
- } from "@remit/data-ports";
8
- import { FilterDisabledReason, FilterState } from "@remit/domain-enums";
9
- import type { PlacementMoveService } from "../placement-move.js";
10
- import {
11
- type FilterMessage,
12
- literalClausesMatch,
13
- NO_ACTION,
14
- selectMoveWinner,
15
- } from "./match.js";
16
-
17
- export interface FilterLogger {
18
- info(obj: Record<string, unknown>, msg: string): void;
19
- debug?(obj: Record<string, unknown>, msg: string): void;
20
- warn?(obj: Record<string, unknown>, msg: string): void;
21
- error?(obj: Record<string, unknown>, msg: string): void;
22
- }
23
-
24
- /**
25
- * Dependencies for the index-time filter pass (RFC 034). Optional on
26
- * {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
27
- * without it simply runs no filter matching.
28
- */
29
- export interface FilterConfig {
30
- filterService: IFilterRepository;
31
- messageLabelService: IMessageLabelRepository;
32
- /** Names the account a filter's folder belongs to. */
33
- mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
34
- placementMoveService: PlacementMoveService;
35
- }
36
-
37
- /**
38
- * What the matched filters resolve to for one message: every matching filter's
39
- * label (additive — RFC 034 Decision 3.1), and at most one move (exclusive — the
40
- * most-recently-changed matching filter, Decision 3.2).
41
- */
42
- export interface FilterDecision {
43
- labels: Array<{ labelId: string; filterId: string }>;
44
- move?: { destinationMailboxId: string; filterId: string };
45
- }
46
-
47
- const EMPTY_DECISION: FilterDecision = { labels: [] };
48
-
49
- /**
50
- * Evaluates a synced message against the account's active filters and resolves
51
- * the actions to apply (RFC 034). Reads only — listing filters and refreshing
52
- * lazy expiry; the caller applies the
53
- * resulting {@link FilterDecision}.
54
- *
55
- * `evaluate` isolates every failure in this read/decision phase from body sync,
56
- * matching the placement-resolution precedent (#1246): a schema-drifted query
57
- * must never fail the surrounding message store, since the body is
58
- * already durable by the time this runs. The apply of the decision — label
59
- * writes and the move enqueue — is the caller's responsibility and is *not*
60
- * swallowed there, so a genuine DDB/SQS write failure still propagates and
61
- * requeues the message.
62
- */
63
- export class FilterPipeline {
64
- constructor(
65
- private readonly config: FilterConfig,
66
- private readonly log: FilterLogger,
67
- ) {}
68
-
69
- async evaluate(
70
- accountConfigId: string,
71
- accountId: string,
72
- messageId: string,
73
- msg: FilterMessage,
74
- ): Promise<FilterDecision> {
75
- return this.match(accountConfigId, accountId, msg).catch(
76
- (error: unknown) => {
77
- this.log.error?.(
78
- {
79
- alert: "body_sync_filter_match_failed",
80
- messageId,
81
- accountConfigId,
82
- errorName: (error as { name?: string })?.name,
83
- error: inspect(error),
84
- },
85
- "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)",
86
- );
87
- return EMPTY_DECISION;
88
- },
89
- );
90
- }
91
-
92
- /**
93
- * A filter sits on the account config and sees every account's mail, but its
94
- * folder is one account's: mail of another account is not this filter's to
95
- * act on, exactly as if it had not matched. A folder no account holds any
96
- * more turns the filter off, with the reason the filter list shows.
97
- */
98
- private async appliesTo(
99
- accountConfigId: string,
100
- accountId: string,
101
- filter: FilterItem,
102
- ): Promise<boolean> {
103
- if (filter.actionMailboxId === NO_ACTION) return true;
104
- const owner = await this.config.mailboxService.resolveAccountId(
105
- filter.actionMailboxId,
106
- );
107
- if (owner !== null) return owner === accountId;
108
- await this.config.filterService.update(accountConfigId, filter.filterId, {
109
- state: FilterState.Disabled,
110
- disabledReason: FilterDisabledReason.FolderMissing,
111
- });
112
- this.log.warn?.(
113
- { filterId: filter.filterId, accountConfigId },
114
- "Filter turned off: the folder it moves mail into no longer exists",
115
- );
116
- return false;
117
- }
118
-
119
- private async match(
120
- accountConfigId: string,
121
- accountId: string,
122
- msg: FilterMessage,
123
- ): Promise<FilterDecision> {
124
- const active = await this.config.filterService.listByAccountAndState(
125
- accountConfigId,
126
- FilterState.Active,
127
- );
128
- if (active.length === 0) return EMPTY_DECISION;
129
-
130
- const matched: FilterItem[] = [];
131
- for (const filter of active) {
132
- // Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
133
- // its expiresAt patches it to Expired on this read and drops it from
134
- // evaluation, independent of whether the TTL delete has run.
135
- const usable = await this.config.filterService.refreshExpiry(filter);
136
- if (usable.state !== FilterState.Active) continue;
137
- if (!(await this.appliesTo(accountConfigId, accountId, usable))) continue;
138
- if (filterMatches(usable, msg)) matched.push(usable);
139
- }
140
- if (matched.length === 0) return EMPTY_DECISION;
141
-
142
- const labels: FilterDecision["labels"] = [];
143
- const seenLabels = new Set<string>();
144
- for (const filter of matched) {
145
- if (filter.actionLabelId === NO_ACTION) continue;
146
- if (seenLabels.has(filter.actionLabelId)) continue;
147
- seenLabels.add(filter.actionLabelId);
148
- labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
149
- }
150
-
151
- const winner = selectMoveWinner(
152
- matched.filter((filter) => filter.actionMailboxId !== NO_ACTION),
153
- );
154
-
155
- return {
156
- labels,
157
- ...(winner
158
- ? {
159
- move: {
160
- destinationMailboxId: winner.actionMailboxId,
161
- filterId: winner.filterId,
162
- },
163
- }
164
- : {}),
165
- };
166
- }
167
- }
168
-
169
- const filterMatches = (filter: FilterItem, msg: FilterMessage): boolean =>
170
- literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
@@ -1,112 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, beforeEach, describe, it } from "node:test";
3
- import { MessageSystemFlag } from "@remit/domain-enums";
4
- import { FlagPushService } from "./flag-push.js";
5
- import {
6
- captureQueue,
7
- openStore,
8
- type QueueCapture,
9
- type Store,
10
- } from "./test-helpers/test-store.js";
11
-
12
- const target = {
13
- accountId: "acc-1",
14
- accountConfigId: "cfg-1",
15
- messageId: "msg-1",
16
- mailboxId: "mbx-1",
17
- flagName: MessageSystemFlag.Seen,
18
- operation: "add" as const,
19
- };
20
-
21
- let store: Store;
22
- let capture: QueueCapture;
23
- let errors: Array<Record<string, unknown>>;
24
-
25
- beforeEach(async () => {
26
- store = await openStore();
27
- capture = captureQueue();
28
- errors = [];
29
- });
30
-
31
- afterEach(async () => {
32
- capture.restore();
33
- await store.close();
34
- });
35
-
36
- const queueAnswers = (answer: () => Promise<unknown>) => capture.answer(answer);
37
-
38
- const flips = (queueUrl = "http://localhost:9324/000/remit-flags") =>
39
- new FlagPushService({
40
- markerService: store.flagPush,
41
- sqsQueueUrl: queueUrl,
42
- logger: {
43
- info: () => {},
44
- error: (fields: Record<string, unknown>) => errors.push(fields),
45
- },
46
- });
47
-
48
- const marker = () => store.flagPush.find(target.messageId, target.flagName);
49
-
50
- describe("FlagPushService.flip", () => {
51
- it("records the owed push and marks it queued once the hint is sent", async () => {
52
- queueAnswers(async () => ({}));
53
-
54
- await flips().flip(target);
55
-
56
- const recorded = await marker();
57
- assert.equal(recorded?.operation, "add");
58
- assert.equal(recorded?.mailboxId, target.mailboxId);
59
- assert.equal(recorded?.state, "queued");
60
- });
61
-
62
- it("names the message and flag in the hint, never the operation or a uid", async () => {
63
- queueAnswers(async () => ({}));
64
-
65
- await flips().flip(target);
66
-
67
- const event = JSON.parse(capture.inputs()[0].MessageBody ?? "{}");
68
- assert.equal(event.type, "FLAG_PUSH");
69
- assert.equal(event.messageId, target.messageId);
70
- assert.equal(event.flagName, target.flagName);
71
- assert.equal("operation" in event, false);
72
- assert.equal("uid" in event, false);
73
- });
74
-
75
- it("groups a FIFO hint by account and leaves a standard queue ungrouped", async () => {
76
- queueAnswers(async () => ({}));
77
-
78
- await flips("http://localhost:9324/000/remit-flags.fifo").flip(target);
79
- await flips("http://localhost:9324/000/remit-flags").flip(target);
80
-
81
- assert.equal(capture.inputs()[0].MessageGroupId, target.accountId);
82
- assert.equal(capture.inputs()[1].MessageGroupId, undefined);
83
- });
84
-
85
- it("keeps the push owed and alarms, without rejecting, when the queue is down", async () => {
86
- queueAnswers(async () => {
87
- throw Object.assign(new Error("connect ECONNREFUSED"), {
88
- code: "ECONNREFUSED",
89
- });
90
- });
91
-
92
- await flips().flip(target);
93
-
94
- assert.equal((await marker())?.state, "pending");
95
- const alarm = errors.find(
96
- (entry) => entry.alert === "flag_push_hint_enqueue_failed",
97
- );
98
- assert.equal(alarm?.errorCode, "ECONNREFUSED");
99
- });
100
-
101
- it("raises no alarm when the worker drained the push before it was marked queued", async () => {
102
- queueAnswers(async () => {
103
- await store.flagPush.delete(target.messageId, target.flagName);
104
- return {};
105
- });
106
-
107
- await flips().flip(target);
108
-
109
- assert.equal(await marker(), null);
110
- assert.deepEqual(errors, []);
111
- });
112
- });