@remit/backend 0.0.143 → 0.0.144

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 (269) hide show
  1. package/dist/dev-server/content-handler.d.ts +38 -0
  2. package/dist/dev-server/content-handler.js +46 -0
  3. package/dist/src/api.d.ts +7 -0
  4. package/dist/src/api.js +144 -0
  5. package/dist/src/auth.d.ts +30 -0
  6. package/dist/src/auth.js +73 -0
  7. package/dist/src/config/msoauth.d.ts +30 -0
  8. package/dist/src/config/msoauth.js +30 -0
  9. package/{src/data-backend.ts → dist/src/data-backend.d.ts} +2 -4
  10. package/dist/src/data-backend.js +12 -0
  11. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  12. package/dist/src/derive/autoMoved.js +49 -0
  13. package/dist/src/derive/contentSignature.d.ts +41 -0
  14. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +27 -72
  15. package/dist/src/derive/contentUrl.d.ts +46 -0
  16. package/dist/src/derive/contentUrl.js +46 -0
  17. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  18. package/dist/src/derive/deriveMuted.js +7 -0
  19. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  20. package/dist/src/derive/enrichThreadRows.js +151 -0
  21. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  22. package/dist/src/derive/filterThreadCriteria.js +24 -0
  23. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  24. package/dist/src/derive/pendingMoveCounts.js +53 -0
  25. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  26. package/dist/src/derive/senderTrust.js +19 -0
  27. package/dist/src/error.d.ts +21 -0
  28. package/dist/src/error.js +85 -0
  29. package/dist/src/handlers/account-guards.d.ts +36 -0
  30. package/dist/src/handlers/account-guards.js +107 -0
  31. package/dist/src/handlers/account-oauth.d.ts +14 -0
  32. package/dist/src/handlers/account-oauth.js +387 -0
  33. package/dist/src/handlers/account-overrides.d.ts +53 -0
  34. package/dist/src/handlers/account-overrides.js +233 -0
  35. package/dist/src/handlers/account-signature.d.ts +28 -0
  36. package/dist/src/handlers/account-signature.js +85 -0
  37. package/dist/src/handlers/account.d.ts +6 -0
  38. package/dist/src/handlers/account.js +350 -0
  39. package/dist/src/handlers/address.d.ts +35 -0
  40. package/dist/src/handlers/address.js +141 -0
  41. package/dist/src/handlers/calendar-event.d.ts +70 -0
  42. package/dist/src/handlers/calendar-event.js +425 -0
  43. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  44. package/dist/src/handlers/calendar-feed.js +192 -0
  45. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  46. package/dist/src/handlers/calendar-suggestion.js +212 -0
  47. package/dist/src/handlers/calendar.d.ts +116 -0
  48. package/dist/src/handlers/calendar.js +330 -0
  49. package/dist/src/handlers/config.d.ts +43 -0
  50. package/dist/src/handlers/config.js +176 -0
  51. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  52. package/dist/src/handlers/ensure-account-config.js +27 -0
  53. package/dist/src/handlers/filter.d.ts +3 -0
  54. package/dist/src/handlers/filter.js +103 -0
  55. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  56. package/dist/src/handlers/folder-role-appointments.js +168 -0
  57. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  58. package/dist/src/handlers/folder-role-labels.js +40 -0
  59. package/dist/src/handlers/folder-role.d.ts +9 -0
  60. package/dist/src/handlers/folder-role.js +40 -0
  61. package/dist/src/handlers/index.d.ts +2 -0
  62. package/dist/src/handlers/index.js +60 -0
  63. package/dist/src/handlers/label.d.ts +43 -0
  64. package/dist/src/handlers/label.js +114 -0
  65. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  66. package/dist/src/handlers/mailbox-bindings.js +61 -0
  67. package/dist/src/handlers/mailbox.d.ts +23 -0
  68. package/dist/src/handlers/mailbox.js +219 -0
  69. package/dist/src/handlers/me.d.ts +2 -0
  70. package/dist/src/handlers/me.js +138 -0
  71. package/dist/src/handlers/message.d.ts +84 -0
  72. package/dist/src/handlers/message.js +607 -0
  73. package/dist/src/handlers/organize.d.ts +10 -0
  74. package/dist/src/handlers/organize.js +112 -0
  75. package/dist/src/handlers/outbox-attachment.d.ts +23 -0
  76. package/dist/src/handlers/outbox-attachment.js +81 -0
  77. package/dist/src/handlers/outbox.d.ts +4 -0
  78. package/dist/src/handlers/outbox.js +144 -0
  79. package/dist/src/handlers/sync-progress.d.ts +46 -0
  80. package/dist/src/handlers/sync-progress.js +63 -0
  81. package/dist/src/handlers/sync.d.ts +28 -0
  82. package/dist/src/handlers/sync.js +82 -0
  83. package/dist/src/handlers/system-update.d.ts +2 -0
  84. package/dist/src/handlers/system-update.js +172 -0
  85. package/dist/src/handlers/thread.d.ts +148 -0
  86. package/dist/src/handlers/thread.js +233 -0
  87. package/dist/src/handlers/unified-threads.d.ts +247 -0
  88. package/dist/src/handlers/unified-threads.js +409 -0
  89. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  90. package/dist/src/handlers/vip-suggestions.js +16 -0
  91. package/dist/src/index.d.ts +2 -0
  92. package/dist/src/index.js +37 -0
  93. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  94. package/dist/src/json.js +10 -0
  95. package/dist/src/jwt-auth.d.ts +18 -0
  96. package/dist/src/jwt-auth.js +97 -0
  97. package/dist/src/request-context.d.ts +9 -0
  98. package/dist/src/request-context.js +22 -0
  99. package/dist/src/request.d.ts +3 -0
  100. package/dist/src/request.js +7 -0
  101. package/dist/src/response.d.ts +27 -0
  102. package/dist/src/response.js +87 -0
  103. package/dist/src/service/account-deletion.d.ts +22 -0
  104. package/dist/src/service/account-deletion.js +39 -0
  105. package/dist/src/service/compose-sqlite.d.ts +2 -0
  106. package/dist/src/service/compose-sqlite.js +11 -0
  107. package/dist/src/service/create-remit-client.d.ts +95 -0
  108. package/dist/src/service/create-remit-client.js +207 -0
  109. package/dist/src/service/data-client.d.ts +12 -0
  110. package/dist/src/service/data-client.js +44 -0
  111. package/dist/src/service/export-identity.d.ts +3 -0
  112. package/dist/src/service/export-identity.js +35 -0
  113. package/dist/src/service/fire-and-forget.d.ts +54 -0
  114. package/dist/src/service/fire-and-forget.js +38 -0
  115. package/dist/src/service/organize.d.ts +184 -0
  116. package/dist/src/service/organize.js +384 -0
  117. package/dist/src/service/refusal.d.ts +4 -0
  118. package/dist/src/service/refusal.js +20 -0
  119. package/dist/src/service/sender-category.d.ts +51 -0
  120. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  121. package/dist/src/service/sqs.d.ts +1 -0
  122. package/{src/service/sqs.ts → dist/src/service/sqs.js} +0 -1
  123. package/dist/src/service/trigger-sync.d.ts +51 -0
  124. package/dist/src/service/trigger-sync.js +39 -0
  125. package/dist/src/types.d.ts +49 -0
  126. package/dist/src/types.js +1 -0
  127. package/package.json +113 -98
  128. package/.env.test +0 -4
  129. package/dev-server/content-auth.test.ts +0 -113
  130. package/dev-server/content-auth.ts +0 -54
  131. package/dev-server/content-handler.test.ts +0 -105
  132. package/dev-server/content-handler.ts +0 -79
  133. package/dev-server/content-path.test.ts +0 -37
  134. package/dev-server/content-path.ts +0 -27
  135. package/dev-server/cors.test.ts +0 -48
  136. package/dev-server/cors.ts +0 -25
  137. package/dev-server/keep-alive.test.ts +0 -24
  138. package/dev-server/lambda-helpers.ts +0 -69
  139. package/dev-server/log-output.test.ts +0 -173
  140. package/dev-server/oauth-callback-route.test.ts +0 -66
  141. package/dev-server/relational-health.test.ts +0 -83
  142. package/dev-server/relational-health.ts +0 -39
  143. package/dev-server/server.ts +0 -384
  144. package/dev-server/sync-age.test.ts +0 -91
  145. package/dev-server/sync-age.ts +0 -55
  146. package/dev-server/upload-handler.ts +0 -132
  147. package/dev-server/upload-route.test.ts +0 -196
  148. package/scripts/backfill-classification.ts +0 -91
  149. package/scripts/backfill-list-id.ts +0 -83
  150. package/scripts/config-save.ts +0 -128
  151. package/src/auth.test.ts +0 -12
  152. package/src/auth.ts +0 -92
  153. package/src/config/msoauth.test.ts +0 -48
  154. package/src/config/msoauth.ts +0 -48
  155. package/src/data-backend.test.ts +0 -23
  156. package/src/derive/autoMoved.test.ts +0 -104
  157. package/src/derive/contentSignature.test.ts +0 -161
  158. package/src/derive/contentUrl.test.ts +0 -172
  159. package/src/derive/contentUrl.ts +0 -86
  160. package/src/derive/deriveMuted.test.ts +0 -34
  161. package/src/derive/enrichThreadRows.test.ts +0 -360
  162. package/src/derive/enrichThreadRows.ts +0 -234
  163. package/src/derive/filterThreadCriteria.test.ts +0 -126
  164. package/src/derive/filterThreadCriteria.ts +0 -51
  165. package/src/derive/senderTrust.test.ts +0 -67
  166. package/src/error.test.ts +0 -201
  167. package/src/error.ts +0 -123
  168. package/src/handlers/account-guards.test.ts +0 -67
  169. package/src/handlers/account-guards.ts +0 -169
  170. package/src/handlers/account-oauth-callback.test.ts +0 -155
  171. package/src/handlers/account-oauth-consent.test.ts +0 -336
  172. package/src/handlers/account-oauth-start.test.ts +0 -191
  173. package/src/handlers/account-oauth.test.ts +0 -439
  174. package/src/handlers/account-oauth.ts +0 -635
  175. package/src/handlers/account-overrides.test.ts +0 -88
  176. package/src/handlers/account-overrides.ts +0 -323
  177. package/src/handlers/account-ownership.ts +0 -25
  178. package/src/handlers/account-signature.ts +0 -117
  179. package/src/handlers/account-synced-services.test.ts +0 -245
  180. package/src/handlers/account.ts +0 -552
  181. package/src/handlers/address.test.ts +0 -381
  182. package/src/handlers/address.ts +0 -200
  183. package/src/handlers/calendar-event.test.ts +0 -685
  184. package/src/handlers/calendar-event.ts +0 -738
  185. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  186. package/src/handlers/calendar-feed.test.ts +0 -439
  187. package/src/handlers/calendar-feed.ts +0 -291
  188. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  189. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  190. package/src/handlers/calendar-suggestion.ts +0 -425
  191. package/src/handlers/calendar.test.ts +0 -1080
  192. package/src/handlers/calendar.ts +0 -517
  193. package/src/handlers/config.test.ts +0 -245
  194. package/src/handlers/config.ts +0 -332
  195. package/src/handlers/ensure-account-config.ts +0 -30
  196. package/src/handlers/filter.test.ts +0 -644
  197. package/src/handlers/filter.ts +0 -518
  198. package/src/handlers/folder-role-appointments.test.ts +0 -438
  199. package/src/handlers/folder-role-appointments.ts +0 -249
  200. package/src/handlers/folder-role-labels.ts +0 -63
  201. package/src/handlers/folder-role.test.ts +0 -209
  202. package/src/handlers/folder-role.ts +0 -80
  203. package/src/handlers/index.ts +0 -83
  204. package/src/handlers/label.test.ts +0 -112
  205. package/src/handlers/label.ts +0 -210
  206. package/src/handlers/mailbox-bindings.ts +0 -111
  207. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  208. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  209. package/src/handlers/mailbox.test.ts +0 -280
  210. package/src/handlers/mailbox.ts +0 -581
  211. package/src/handlers/me.test.ts +0 -81
  212. package/src/handlers/me.ts +0 -203
  213. package/src/handlers/message-read-backfill.test.ts +0 -103
  214. package/src/handlers/message.test.ts +0 -131
  215. package/src/handlers/message.ts +0 -1131
  216. package/src/handlers/organize.test.ts +0 -277
  217. package/src/handlers/organize.ts +0 -197
  218. package/src/handlers/outbox-attachment.test.ts +0 -765
  219. package/src/handlers/outbox-attachment.ts +0 -126
  220. package/src/handlers/outbox-list.test.ts +0 -179
  221. package/src/handlers/outbox.test.ts +0 -772
  222. package/src/handlers/outbox.ts +0 -258
  223. package/src/handlers/search.test.ts +0 -161
  224. package/src/handlers/search.ts +0 -105
  225. package/src/handlers/sync-progress.test.ts +0 -205
  226. package/src/handlers/sync-progress.ts +0 -99
  227. package/src/handlers/sync.test.ts +0 -168
  228. package/src/handlers/sync.ts +0 -118
  229. package/src/handlers/system-update.test.ts +0 -497
  230. package/src/handlers/system-update.ts +0 -230
  231. package/src/handlers/thread.test.ts +0 -396
  232. package/src/handlers/thread.ts +0 -427
  233. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  234. package/src/handlers/unified-threads.test.ts +0 -785
  235. package/src/handlers/unified-threads.ts +0 -669
  236. package/src/index.test.ts +0 -83
  237. package/src/index.ts +0 -228
  238. package/src/jwt-auth.test.ts +0 -152
  239. package/src/jwt-auth.ts +0 -132
  240. package/src/log-redaction.test.ts +0 -144
  241. package/src/openapi-spec.test.ts +0 -118
  242. package/src/request-context.test.ts +0 -61
  243. package/src/request-context.ts +0 -33
  244. package/src/request-pipeline.test.ts +0 -88
  245. package/src/request.ts +0 -10
  246. package/src/response.test.ts +0 -130
  247. package/src/response.ts +0 -144
  248. package/src/service/compose-sqlite.ts +0 -98
  249. package/src/service/config-import.ts +0 -39
  250. package/src/service/create-remit-client.ts +0 -522
  251. package/src/service/data-client.test.ts +0 -97
  252. package/src/service/data-client.ts +0 -58
  253. package/src/service/export-identity.ts +0 -39
  254. package/src/service/filter.ts +0 -40
  255. package/src/service/fire-and-forget.test.ts +0 -126
  256. package/src/service/fire-and-forget.ts +0 -79
  257. package/src/service/localhost-env-config.test.ts +0 -36
  258. package/src/service/organize-queue.ts +0 -98
  259. package/src/service/organize.test.ts +0 -1451
  260. package/src/service/organize.ts +0 -1025
  261. package/src/service/read-path-filter-config.test.ts +0 -243
  262. package/src/service/semantic-capability.test.ts +0 -78
  263. package/src/service/semantic-capability.ts +0 -95
  264. package/src/service/sender-category.test.ts +0 -235
  265. package/src/service/trigger-sync.test.ts +0 -205
  266. package/src/service/trigger-sync.ts +0 -100
  267. package/src/stdout-capture-fixture.ts +0 -40
  268. package/src/types.ts +0 -271
  269. package/tsconfig.json +0 -7
@@ -1,1025 +0,0 @@
1
- import { inspect } from "node:util";
2
- import type {
3
- FilterAnchorItem,
4
- FilterItem,
5
- IFilterAnchorRepository,
6
- OrganizeJobRequestItem,
7
- } from "@remit/data-ports";
8
- import { NotFoundError } from "@remit/data-ports/errors";
9
- import { FilterClauseField, FilterState } from "@remit/domain-enums";
10
- import { logger } from "@remit/logger-lambda";
11
- import {
12
- DEFAULT_SEMANTIC_MATCH_THRESHOLD,
13
- type FilterMessage,
14
- FilterPipeline,
15
- type LiteralClauseNarrowing,
16
- literalClausesMatch,
17
- literalClauseTerms,
18
- type MessageEmbedder,
19
- NO_ACTION,
20
- PlacementMoveService,
21
- refreshAnchorForEmbedder,
22
- } from "@remit/mailbox-service";
23
- import { buildFilterConfig } from "@remit/mailbox-service/filter-config";
24
- import {
25
- type AnchorPayload,
26
- buildMessageAnchor,
27
- type VectorRecord,
28
- type VectorStoreService,
29
- } from "@remit/search-service";
30
- import {
31
- buildEmbeddingServiceFromEnv,
32
- buildVectorStoreFromEnv,
33
- } from "@remit/search-service/from-env";
34
- import type { RemitClient } from "./data-client.js";
35
- import {
36
- isSemanticCapabilityAbsence,
37
- noteSemanticCapabilityAbsence,
38
- } from "./semantic-capability.js";
39
-
40
- /**
41
- * Hard cap on both the previewed and the applied set. A back-apply is a
42
- * data-heavy corpus pass (remit-data-heavy: frugal): the semantic side is
43
- * bounded by the vector query's `topK`, the literal-only side by counting
44
- * MATCHES as it pages a store-side narrowed listing, and the final match set
45
- * never exceeds this. It bounds the answer, never the corpus the question is
46
- * asked of (#459).
47
- */
48
- export const ORGANIZE_MATCH_LIMIT = 500;
49
-
50
- /**
51
- * How many chunk matches to pull per requested message. A message contributes
52
- * several chunk vectors (sender / subject / body / …); over-fetching chunks
53
- * keeps enough distinct messages after de-duplication to fill the cap.
54
- */
55
- const VECTOR_CHUNK_FACTOR = 8;
56
-
57
- /**
58
- * The predicate + action for one back-apply pass — the OrganizeInput fields,
59
- * flattened. `anchorMessageId` uses the `"None"` sentinel (never optional) to
60
- * mean "purely literal", matching how the job row and the action fields carry
61
- * absence (RFC 034 Decision 3.1).
62
- */
63
- export interface OrganizePredicate {
64
- anchorMessageId: string;
65
- matchOperator: FilterItem["matchOperator"];
66
- literalClauses: FilterItem["literalClauses"];
67
- similarityThreshold: number;
68
- actionLabelId: string;
69
- actionMailboxId: string;
70
- }
71
-
72
- /**
73
- * The predicate a back-apply job snapshotted onto its row, read back verbatim so
74
- * the worker runs exactly what the request asked for.
75
- */
76
- export const predicateFromJob = (
77
- job: OrganizeJobRequestItem,
78
- ): OrganizePredicate => ({
79
- anchorMessageId: job.anchorMessageId,
80
- matchOperator: job.matchOperator,
81
- literalClauses: job.literalClauses,
82
- similarityThreshold: job.similarityThreshold,
83
- actionLabelId: job.actionLabelId,
84
- actionMailboxId: job.actionMailboxId,
85
- });
86
-
87
- /**
88
- * The vector-backed semantic side of the matcher — the only part that reaches
89
- * `sqlite-vec`/`@huggingface/transformers`. Constructed lazily (see
90
- * {@link OrganizeMatchDeps.semantic}) so a literal-only predicate never touches
91
- * the vector extension, and so a deployment that ships no vector pipeline only
92
- * fails on the import when a predicate actually asks to widen — where the
93
- * failure is caught and degraded rather than surfaced as a 500 (#226/#201).
94
- */
95
- export interface OrganizeSemanticDeps {
96
- buildAnchor: (
97
- accountConfigId: string,
98
- anchorMessageId: string,
99
- ) => Promise<AnchorPayload | null>;
100
- vectorStore: Pick<VectorStoreService, "query" | "getByMessage">;
101
- /**
102
- * Embed one text — the `AnchorEmbedder` half of these deps, used to
103
- * re-embed a drifted persisted anchor in place ({@link
104
- * refreshAnchorForEmbedder}). Never called by the anchor widen itself,
105
- * which only ever runs a vector-store kNN read (see
106
- * `semantic-capability.ts`).
107
- */
108
- embed: (text: string) => Promise<number[]>;
109
- /**
110
- * The configured model's `<modelId>@<dimensions>` id, compared against a
111
- * persisted anchor's `anchorEmbeddingId` to catch a same-dimension model
112
- * swap that would otherwise score a current-model vector against a
113
- * stale-model anchor (RFC 039 Decision 1a, reader #399).
114
- */
115
- readonly embeddingId: string;
116
- }
117
-
118
- /**
119
- * One corpus message projected onto the fields a literal clause matches
120
- * against, paired with its id. Sourced from the core message rows, never the
121
- * vector store, so literal matching runs on any deployment.
122
- */
123
- export interface OrganizeCandidate {
124
- messageId: string;
125
- message: FilterMessage;
126
- }
127
-
128
- /**
129
- * One page of the narrowed corpus, cursored exactly as the underlying listing
130
- * is: an absent `continuationToken` means the store has no more rows matching
131
- * the terms, never that a read window ran out.
132
- */
133
- export interface OrganizeCandidatePage {
134
- items: OrganizeCandidate[];
135
- continuationToken?: string;
136
- }
137
-
138
- /** What to ask the store for, and where to resume. */
139
- export interface OrganizeCandidateQuery extends LiteralClauseNarrowing {
140
- limit: number;
141
- continuationToken?: string;
142
- mailboxIds?: readonly string[];
143
- }
144
-
145
- /**
146
- * Where a pass looks and which standing filter it runs for. A filter
147
- * back-apply reads that filter's own FilterAnchor row by id, narrows both the
148
- * literal corpus and the vector query to the inbox, and matches a body-content
149
- * clause on the stored preview; an "all like these" pass sets neither.
150
- */
151
- export interface OrganizeMatchScope {
152
- filterId?: string;
153
- mailboxIds?: readonly string[];
154
- }
155
-
156
- export interface OrganizeMatchDeps {
157
- /**
158
- * Build the semantic side on demand. Invoked only for an anchored predicate,
159
- * so constructing the embedder and vector store — and, on first use, the
160
- * lazy import of the vector extension — is deferred until a widen is actually
161
- * requested.
162
- */
163
- semantic: () => OrganizeSemanticDeps;
164
- /**
165
- * One page of the account's messages as literal-match candidates,
166
- * vector-free, with the clause terms evaluated by the store — the corpus a
167
- * purely-literal (or degraded) pass reads. Paged rather than capped, so the
168
- * caller's bound falls on matches instead of on how far back the read
169
- * reached (#459).
170
- */
171
- listAccountFilterMessages: (
172
- accountConfigId: string,
173
- query: OrganizeCandidateQuery,
174
- ) => Promise<OrganizeCandidatePage>;
175
- /**
176
- * Every persisted FilterAnchor for the account — read-only, always
177
- * available (never gated behind {@link semantic}, since it never touches
178
- * the vector store). A filter back-apply reads its own row by `filterId`
179
- * (#1354); an "all like these" predicate carries only a bare
180
- * `anchorMessageId`, so this is how {@link matchSemantic} finds "the
181
- * standing filter this anchor came from," if one still exists, to read its
182
- * fixed-at-save-time vector instead of re-deriving one from the anchor
183
- * message's current chunks (reader #350 / RFC 039 Decision 1).
184
- * `put` is the write half of the lazy anchor-drift repair
185
- * ({@link refreshAnchorForEmbedder}), never a new standing rule.
186
- */
187
- filterAnchors: Pick<
188
- IFilterAnchorRepository,
189
- "get" | "listByAccountConfig" | "put"
190
- >;
191
- }
192
-
193
- /**
194
- * Why the matcher refused a predicate. A refusal is an expected outcome of
195
- * asking for a rule this deployment cannot evaluate, not a fault: the HTTP
196
- * boundary words it as a 400 and the back-apply worker fails the job row on it,
197
- * neither of which may treat it as the infrastructure failure a throw would
198
- * make it (reader #463).
199
- */
200
- export interface OrganizeRejection {
201
- reason:
202
- | "BodyContentWithoutVectorPipeline"
203
- | "FilterNotActive"
204
- | "FilterMissing";
205
- message: string;
206
- }
207
-
208
- /** The matched ids plus what the semantic side had to say about the zero it may have returned. */
209
- export interface OrganizeMatched {
210
- rejected: null;
211
- messageIds: string[];
212
- semanticUnavailable: boolean;
213
- /**
214
- * The widen ran and the index had nothing to answer with (issue #452): the
215
- * account holds no vectors at all, or none yet for the anchor message. It is
216
- * the difference between "this rule matches nothing" and "nothing is indexed
217
- * yet", which a bare zero collapses into one wrong sentence. Distinct from
218
- * {@link OrganizeMatched.semanticUnavailable}, which is the deployment
219
- * shipping no vector pipeline at all; an empty index is not an error, so it
220
- * cannot be inferred from a throw.
221
- */
222
- semanticIndexEmpty: boolean;
223
- }
224
-
225
- /** The predicate was refused: nothing matched, and nothing may be applied. */
226
- export interface OrganizeMatchRejected {
227
- rejected: OrganizeRejection;
228
- }
229
-
230
- export type OrganizeMatchResult = OrganizeMatched | OrganizeMatchRejected;
231
-
232
- const hasAnchor = (predicate: OrganizePredicate): boolean =>
233
- predicate.anchorMessageId !== NO_ACTION && predicate.anchorMessageId !== "";
234
-
235
- /**
236
- * Reconstruct the literal-match view of a message from its already-indexed chunk
237
- * vectors — no body fetch, no re-embedding (remit-data-heavy: frugal). The
238
- * sender chunk carries the from address, the subject/body chunks the text; each
239
- * is the same 512-char preview the semantic side is derived from.
240
- */
241
- const filterMessageFromChunks = (
242
- records: VectorRecord[],
243
- ): FilterMessage | null => {
244
- if (records.length === 0) return null;
245
- let subject = "";
246
- let fromName = "";
247
- let from = "";
248
- let listId = "";
249
- const textParts: string[] = [];
250
- for (const record of records) {
251
- const meta = record.metadata;
252
- if (meta.subject && !subject) subject = meta.subject;
253
- if (meta.fromName && !fromName) fromName = meta.fromName;
254
- if (meta.listId && !listId) listId = meta.listId;
255
- if (meta.chunkType === "sender" && meta.textPreview && !from) {
256
- from = meta.textPreview;
257
- }
258
- if (
259
- (meta.chunkType === "body" || meta.chunkType === "subject") &&
260
- meta.textPreview
261
- ) {
262
- textParts.push(meta.textPreview);
263
- }
264
- }
265
- return { from, fromName, subject, listId, text: textParts.join("\n") };
266
- };
267
-
268
- /**
269
- * The persisted FilterAnchor whose `anchorMessageId` matches this predicate's
270
- * anchor, if the account has a standing filter built from that message (RFC
271
- * 034 Decision 2 / RFC 039 Decision 1, reader #350). An "all like these"
272
- * predicate carries no `filterId` — that job structurally is not a Filter — so
273
- * the only way to recover "the filter this anchor came from" is by the value it
274
- * was anchored on. The scan is over one account's (small, bounded) anchor
275
- * rows, read-only, and never touches the vector store. Returns `undefined`
276
- * when no standing filter was ever anchored on this message (or it has since
277
- * been deleted), in which case the caller falls back to deriving the anchor
278
- * live from the message's current chunk vectors, exactly as before. The whole
279
- * row is returned, not just its payload, so the caller can run the same
280
- * anchor-drift repair index-time matching does.
281
- */
282
- const findPersistedAnchor = async (
283
- filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">,
284
- accountConfigId: string,
285
- anchorMessageId: string,
286
- ): Promise<FilterAnchorItem | undefined> =>
287
- (await filterAnchors.listByAccountConfig(accountConfigId)).find(
288
- (anchor) => anchor.anchorMessageId === anchorMessageId,
289
- );
290
-
291
- /**
292
- * The anchor the widen queries with: the persisted row, re-embedded in place
293
- * when the embedding model has drifted since it was written. A deployment that
294
- * ships no embedding model cannot repair it, and the kNN read itself needs
295
- * none, so that case keeps querying with the stored vector rather than going
296
- * dark — and is classified without recording the absence, which would
297
- * otherwise disable every later `/search/semantic` (see
298
- * `semantic-capability.ts`).
299
- *
300
- * The classifier is wider than a missing embedder: `LocalEmbeddingService`
301
- * raises ERR_EMBEDDING_MODEL_UNAVAILABLE for anything that stops the pipeline
302
- * loading, a corrupt model file included, and a write failure carrying one of
303
- * those codes lands here too. Any of them falls back to the stale vector, so
304
- * the fallback is logged — it is the wrong-score condition this repair exists
305
- * to close, taken deliberately over returning nothing. Every other failure
306
- * propagates.
307
- */
308
- const anchorForWiden = async (
309
- deps: OrganizeMatchDeps,
310
- semantic: OrganizeSemanticDeps,
311
- persisted: FilterAnchorItem,
312
- ): Promise<FilterAnchorItem> =>
313
- refreshAnchorForEmbedder(
314
- { anchorRepository: deps.filterAnchors, embedder: semantic },
315
- persisted,
316
- ).catch((error: unknown) => {
317
- if (!isSemanticCapabilityAbsence(error)) throw error;
318
- logger.warn(
319
- {
320
- alert: "filter_anchor_repair_unavailable",
321
- filterId: persisted.filterId,
322
- accountConfigId: persisted.accountConfigId,
323
- errorName: (error as { name?: string })?.name,
324
- error: inspect(error),
325
- },
326
- "Cannot re-embed a drifted anchor in this deployment; widening on the stored (previous-model) vector rather than returning nothing",
327
- );
328
- return persisted;
329
- });
330
-
331
- /**
332
- * The semantic (anchor) arm: read the anchor vector — the persisted
333
- * `FilterAnchor` for a message a standing filter was built from (fixed at
334
- * save time, unaffected by the anchor message's later deletion or
335
- * re-chunking, and re-embedded in place when the embedding model has drifted
336
- * since — {@link refreshAnchorForEmbedder}), or else pool it fresh from the
337
- * anchor message's existing chunk vectors, the same as before this filter
338
- * existed or ever had one.
339
- * Fan out with a k-NN query gated on the cosine threshold, then refine by
340
- * literal clauses reconstructed from the same chunk vectors. Every read here
341
- * goes through the vector store; a deployment without the vector pipeline
342
- * fails on the first call, which {@link matchOrganize} catches.
343
- *
344
- * `indexEmpty` separates the two ways this arm returns nothing (issue #452):
345
- * neither a persisted anchor nor the anchor message's own chunk vectors exist
346
- * to pool from, or the k-NN read came back with no rows at all for the account.
347
- * Both mean the index has nothing to answer with — it is empty or still
348
- * building — rather than that no mail resembles the anchor. A thresholded-away
349
- * result set is a real answer and is not flagged.
350
- */
351
- const matchSemantic = async (
352
- deps: OrganizeMatchDeps,
353
- accountConfigId: string,
354
- predicate: OrganizePredicate,
355
- limit: number,
356
- scope: OrganizeMatchScope,
357
- ): Promise<{ messageIds: string[]; indexEmpty: boolean }> => {
358
- const semantic = deps.semantic();
359
- const persisted = scope.filterId
360
- ? ((await deps.filterAnchors.get(accountConfigId, scope.filterId)) ??
361
- undefined)
362
- : await findPersistedAnchor(
363
- deps.filterAnchors,
364
- accountConfigId,
365
- predicate.anchorMessageId,
366
- );
367
- const anchor: AnchorPayload | null = persisted
368
- ? await anchorForWiden(deps, semantic, persisted)
369
- : await semantic.buildAnchor(accountConfigId, predicate.anchorMessageId);
370
- if (!anchor) return { messageIds: [], indexEmpty: true };
371
- const threshold =
372
- predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
373
- const folders: readonly (string | undefined)[] = scope.mailboxIds ?? [
374
- undefined,
375
- ];
376
- const pages = await Promise.all(
377
- folders.map((mailboxId) =>
378
- semantic.vectorStore.query({
379
- vector: anchor.anchorEmbedding,
380
- topK: limit * VECTOR_CHUNK_FACTOR,
381
- filter: mailboxId
382
- ? { accountConfigId, mailboxId }
383
- : { accountConfigId },
384
- }),
385
- ),
386
- );
387
- const matches = pages.flat();
388
- if (matches.length === 0) return { messageIds: [], indexEmpty: true };
389
- const bestScore = new Map<string, number>();
390
- for (const match of matches) {
391
- const messageId = match.metadata.messageId;
392
- const prev = bestScore.get(messageId);
393
- if (prev === undefined || match.score > prev) {
394
- bestScore.set(messageId, match.score);
395
- }
396
- }
397
- const base = [...bestScore.entries()]
398
- .filter(([, score]) => score >= threshold)
399
- .map(([messageId]) => messageId);
400
-
401
- const clauses = predicate.literalClauses;
402
- if (clauses.length === 0)
403
- return { messageIds: base.slice(0, limit), indexEmpty: false };
404
-
405
- const matched: string[] = [];
406
- for (const messageId of base) {
407
- if (matched.length >= limit) break;
408
- const records = await semantic.vectorStore.getByMessage(messageId);
409
- const message = filterMessageFromChunks(records);
410
- if (!message) continue;
411
- if (literalClausesMatch(clauses, predicate.matchOperator, message)) {
412
- matched.push(messageId);
413
- }
414
- }
415
- return { messageIds: matched, indexEmpty: false };
416
- };
417
-
418
- /**
419
- * A `HasWords` clause matches the message body (RFC 031, `clauseMatches`). The
420
- * live index-time filter evaluates it against the full parsed body
421
- * (`body-sync.ts` `toFilterMessage`); the vector-free corpus slice carries only
422
- * `From`/`Subject` at full fidelity and no faithful body, so it cannot serve a
423
- * body-content clause without silently narrowing the match to whatever preview
424
- * happened to be indexed. Body-content matching therefore requires the widen
425
- * (vector) path — {@link matchSemantic} reconstructs body text from chunk
426
- * previews there. This guard keeps the two matchers from diverging silently: a
427
- * body-content clause reaching the vector-free path is refused instead of
428
- * returning a wrong set.
429
- */
430
- export const BODY_CONTENT_REJECTION_MESSAGE =
431
- "Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path";
432
-
433
- const bodyContentRejection = (
434
- clauses: OrganizePredicate["literalClauses"],
435
- ): OrganizeRejection | null =>
436
- clauses.some((clause) => clause.field === FilterClauseField.HasWords)
437
- ? {
438
- reason: "BodyContentWithoutVectorPipeline",
439
- message: BODY_CONTENT_REJECTION_MESSAGE,
440
- }
441
- : null;
442
-
443
- /**
444
- * The refusal a predicate carries on its own, decidable without reading the
445
- * corpus: an anchorless predicate can only ever take the vector-free literal
446
- * path, so a body-content clause on it is refused up front. `createOrganizeJob`
447
- * runs this before enqueueing and {@link matchOrganize} runs it on the
448
- * anchorless arm, so preview, the worker and the request boundary all refuse the
449
- * same rule (reader #463).
450
- */
451
- export const organizePredicateRejection = (
452
- predicate: OrganizePredicate,
453
- ): OrganizeRejection | null =>
454
- hasAnchor(predicate) ? null : bodyContentRejection(predicate.literalClauses);
455
-
456
- /**
457
- * The literal-only arm: the store selects on the clause terms, this refines
458
- * what it returned and stops at `limit` MATCHES. Used both for a purely-literal
459
- * predicate and as the degraded fallback when a widen is requested on a
460
- * deployment without the vector pipeline. Serves `From`/`Subject` clauses at
461
- * full fidelity from the core thread rows; body-content (`HasWords`) clauses are
462
- * refused before this runs (see {@link bodyContentRejection}).
463
- *
464
- * The terms are a narrowing, not the verdict ({@link literalClauseTerms}), so
465
- * every page is still run through {@link literalClausesMatch} — and paging
466
- * continues until the cap fills or the store runs out, which is what makes the
467
- * bound a bound on results rather than on the newest `limit` rows (#459).
468
- * Deduped by message id: the same mail filed in two folders is one match.
469
- */
470
- const matchLiteral = async (
471
- deps: OrganizeMatchDeps,
472
- accountConfigId: string,
473
- predicate: OrganizePredicate,
474
- limit: number,
475
- scope: OrganizeMatchScope,
476
- ): Promise<string[]> => {
477
- const clauses = predicate.literalClauses;
478
- const narrowing = literalClauseTerms(clauses, predicate.matchOperator);
479
- if (!narrowing) return [];
480
-
481
- const matched: string[] = [];
482
- const seen = new Set<string>();
483
- let continuationToken: string | undefined;
484
- do {
485
- const page = await deps.listAccountFilterMessages(accountConfigId, {
486
- ...narrowing,
487
- limit,
488
- continuationToken,
489
- mailboxIds: scope.mailboxIds,
490
- });
491
- for (const { messageId, message } of page.items) {
492
- if (seen.has(messageId)) continue;
493
- seen.add(messageId);
494
- if (!literalClausesMatch(clauses, predicate.matchOperator, message)) {
495
- continue;
496
- }
497
- matched.push(messageId);
498
- if (matched.length >= limit) return matched;
499
- }
500
- continuationToken = page.continuationToken;
501
- } while (continuationToken);
502
- return matched;
503
- };
504
-
505
- /**
506
- * The matcher shared by preview and apply — the previewed set equals the applied
507
- * set for the same input. Read-only. Returns the matching message ids, bounded,
508
- * and whether the semantic widen was skipped as unavailable.
509
- *
510
- * - Semantic anchor: {@link matchSemantic}, built lazily so a literal-only
511
- * predicate never touches the vector extension.
512
- * - Literal clauses (RFC 031) refine the candidate set; a purely-literal
513
- * back-apply scans a bounded vector-free slice of the corpus instead.
514
- * - A predicate with neither an anchor nor a clause matches nothing, mirroring
515
- * the index-time filter matcher.
516
- * - When a widen is requested but this deployment ships no vector pipeline, the
517
- * capability-absence is absorbed exactly as `/search/semantic` absorbs it:
518
- * the literal matches are returned (empty for an anchor-only predicate) with
519
- * `semanticUnavailable` set, never a 500. Any other failure propagates.
520
- * - A predicate the vector-free path cannot evaluate (a body-content clause,
521
- * see {@link bodyContentRejection}) comes back as a rejection, so a caller
522
- * can tell a refused rule from a broken deployment (reader #463).
523
- */
524
- export const matchOrganize = async (
525
- deps: OrganizeMatchDeps,
526
- accountConfigId: string,
527
- predicate: OrganizePredicate,
528
- limit: number = ORGANIZE_MATCH_LIMIT,
529
- scope: OrganizeMatchScope = {},
530
- ): Promise<OrganizeMatchResult> => {
531
- const anchored = hasAnchor(predicate);
532
- const clauses = predicate.literalClauses;
533
- if (!anchored && clauses.length === 0) {
534
- return {
535
- rejected: null,
536
- messageIds: [],
537
- semanticUnavailable: false,
538
- semanticIndexEmpty: false,
539
- };
540
- }
541
-
542
- if (!anchored) {
543
- const rejection = scope.filterId
544
- ? null
545
- : organizePredicateRejection(predicate);
546
- if (rejection) return { rejected: rejection };
547
- const messageIds = await matchLiteral(
548
- deps,
549
- accountConfigId,
550
- predicate,
551
- limit,
552
- scope,
553
- );
554
- return {
555
- rejected: null,
556
- messageIds,
557
- semanticUnavailable: false,
558
- semanticIndexEmpty: false,
559
- };
560
- }
561
-
562
- try {
563
- const semantic = await matchSemantic(
564
- deps,
565
- accountConfigId,
566
- predicate,
567
- limit,
568
- scope,
569
- );
570
- return {
571
- rejected: null,
572
- messageIds: semantic.messageIds,
573
- semanticUnavailable: false,
574
- semanticIndexEmpty: semantic.indexEmpty,
575
- };
576
- } catch (error) {
577
- if (!noteSemanticCapabilityAbsence(error)) throw error;
578
- // The widen cannot run on this deployment. Fall back to the literal
579
- // clauses over the corpus — nothing when the predicate is anchor-only —
580
- // and flag the absence so the client can say so.
581
- if (clauses.length === 0) {
582
- return {
583
- rejected: null,
584
- messageIds: [],
585
- semanticUnavailable: true,
586
- semanticIndexEmpty: false,
587
- };
588
- }
589
- const rejection = scope.filterId ? null : bodyContentRejection(clauses);
590
- if (rejection) return { rejected: rejection };
591
- const messageIds = await matchLiteral(
592
- deps,
593
- accountConfigId,
594
- predicate,
595
- limit,
596
- scope,
597
- );
598
- return {
599
- rejected: null,
600
- messageIds,
601
- semanticUnavailable: true,
602
- semanticIndexEmpty: false,
603
- };
604
- }
605
- };
606
-
607
- let cachedSemantic: OrganizeSemanticDeps | null = null;
608
-
609
- /**
610
- * Construct the semantic side from env on first use and memoize it. The embedder
611
- * and vector store are selected by env, independent of `DATA_BACKEND`; building
612
- * them is cheap (the vector extension imports lazily on the first store call),
613
- * but deferring construction keeps a literal-only preview clear of the whole
614
- * semantic graph.
615
- */
616
- const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
617
- if (cachedSemantic) return cachedSemantic;
618
- const embedder = buildEmbeddingServiceFromEnv();
619
- const store = buildVectorStoreFromEnv(embedder.dimensions);
620
- cachedSemantic = {
621
- buildAnchor: (accountConfigId, anchorMessageId) =>
622
- buildMessageAnchor({ store }, { accountConfigId, anchorMessageId }),
623
- vectorStore: store,
624
- embed: async (text) => (await embedder.embed([text]))[0],
625
- embeddingId: embedder.embeddingId,
626
- };
627
- return cachedSemantic;
628
- };
629
-
630
- /**
631
- * The vector-free corpus read for a literal back-apply: one page of the
632
- * account's messages, newest-first across every mailbox, with the clause terms
633
- * evaluated in the query. Reads the core thread rows, so it runs on a
634
- * deployment that ships no vector pipeline.
635
- *
636
- * `From`/`Subject`/`listId` come from the row verbatim (full fidelity), so a
637
- * `From`, `Subject`, `FromDomain` or `ListId` clause matches here exactly as it
638
- * does at index time. `text` is the row's stored body preview (`snippet`),
639
- * the same text search matches free words against. An "all like these" pass
640
- * refuses a body-content (`HasWords`) clause before reading it (see
641
- * {@link bodyContentRejection}); a filter back-apply matches it on the
642
- * preview, the mail search showed the user when the rule was made (#1354).
643
- */
644
- const listAccountFilterMessagesFromClient =
645
- (client: RemitClient): OrganizeMatchDeps["listAccountFilterMessages"] =>
646
- async (accountConfigId, query) => {
647
- const page = await client.threadMessage.listByFieldTerms(
648
- accountConfigId,
649
- query.terms,
650
- {
651
- operator: query.operator,
652
- limit: query.limit,
653
- continuationToken: query.continuationToken,
654
- excludeDeleted: true,
655
- mailboxIds: query.mailboxIds,
656
- },
657
- );
658
- return {
659
- items: page.items.map((row) => ({
660
- messageId: row.messageId,
661
- message: {
662
- from: row.fromEmail ?? "",
663
- fromName: row.fromName ?? "",
664
- subject: row.subject ?? "",
665
- text: row.snippet ?? "",
666
- listId: row.listId ?? "",
667
- },
668
- })),
669
- continuationToken: page.continuationToken,
670
- };
671
- };
672
-
673
- /**
674
- * The env-wired matcher deps for the running backend/worker. The semantic side
675
- * is built lazily so a literal-only preview never constructs it.
676
- */
677
- export const buildOrganizeMatchDeps = (
678
- client: RemitClient,
679
- ): OrganizeMatchDeps => ({
680
- semantic: buildSemanticFromEnv,
681
- listAccountFilterMessages: listAccountFilterMessagesFromClient(client),
682
- filterAnchors: client.filterAnchor,
683
- });
684
-
685
- /**
686
- * The exclusive-move arm of a back-apply, wired exactly like the body-sync
687
- * placement mover (`sync-message-body.ts`): the local-first
688
- * {@link PlacementMoveService} over the shared message-management queue. A move
689
- * commits locally (marker + ThreadMessage + Message row) and enqueues a
690
- * `PLACEMENT_MOVE_PUSH` for the reconciler; its marker state engine makes a
691
- * redelivered job idempotent (mirrors #1297). Returns `undefined` when no
692
- * message-management queue is wired — the same gate body sync uses to keep the
693
- * move path off — so a move requested in that environment is counted as failed
694
- * rather than silently dropped (see {@link applyOrganize}).
695
- */
696
- export const buildOrganizeMoveService = (
697
- client: RemitClient,
698
- ): PlacementMoveService | undefined => {
699
- const queueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
700
- if (!queueUrl) return undefined;
701
- return new PlacementMoveService({
702
- messageService: client.message,
703
- threadMessageService: client.threadMessage,
704
- markerService: client.placementMove,
705
- addressService: client.address,
706
- mailboxSpecialUseService: client.mailboxSpecialUse,
707
- sqsQueueUrl: queueUrl,
708
- });
709
- };
710
-
711
- export interface ApplyOrganizeDeps {
712
- client: RemitClient;
713
- moveService?: PlacementMoveService;
714
- }
715
-
716
- export interface ApplyOrganizeResult {
717
- applied: number;
718
- failed: number;
719
- }
720
-
721
- /**
722
- * Apply the back-apply action to every matched message, reusing the index-time
723
- * apply plumbing: an idempotent MessageLabel upsert (additive) with
724
- * `appliedByFilterId` deliberately ABSENT — this path attributes to no filter,
725
- * exactly like a hand-applied label (RFC 034 Decision 3.3) — and an idempotent
726
- * folder move (exclusive). One poisoned message never fails the batch; it is
727
- * counted as failed and the pass continues.
728
- *
729
- * Every requested move happens. A standing filter acts at exactly two moments —
730
- * when a message is first seen, and when the user presses "run rules now" — and
731
- * holds no claim on that message afterwards, so it never arbitrates against a
732
- * later user-initiated apply (reader #497). Cross-filter precedence stays where
733
- * two filters collide over the same message at the same trigger
734
- * (`FilterPipeline`/`selectMoveWinner`), and `applied` therefore counts moves
735
- * that actually happened.
736
- */
737
- export const applyOrganize = async (
738
- deps: ApplyOrganizeDeps,
739
- accountConfigId: string,
740
- messageIds: readonly string[],
741
- predicate: OrganizePredicate,
742
- ): Promise<ApplyOrganizeResult> => {
743
- const { client, moveService } = deps;
744
- const applyLabel =
745
- predicate.actionLabelId !== NO_ACTION && predicate.actionLabelId !== "";
746
- const applyMove =
747
- predicate.actionMailboxId !== NO_ACTION && predicate.actionMailboxId !== "";
748
-
749
- const applyToMessage = async (messageId: string): Promise<void> => {
750
- if (applyLabel) {
751
- await client.messageLabel.apply({
752
- messageId,
753
- labelId: predicate.actionLabelId,
754
- accountConfigId,
755
- });
756
- }
757
- if (applyMove) {
758
- if (!moveService) {
759
- // An exclusive move was requested but this caller wired no move
760
- // service. Never silently pretend it applied — surface it as a
761
- // failed message so the job's failedCount reflects reality.
762
- throw new Error(
763
- "Organize move action requested but no move service is wired",
764
- );
765
- }
766
- const message = await client.message.get(messageId);
767
- const accountId = await client.mailbox.resolveAccountId(
768
- message.mailboxId,
769
- );
770
- if (!accountId) {
771
- throw new Error(
772
- `Cannot resolve owning account for mailbox ${message.mailboxId}`,
773
- );
774
- }
775
- await moveService.moveMessage(
776
- accountConfigId,
777
- messageId,
778
- predicate.actionMailboxId,
779
- accountId,
780
- );
781
- }
782
- };
783
-
784
- let applied = 0;
785
- let failed = 0;
786
- for (const messageId of messageIds) {
787
- // Per-message isolation: one poisoned message is counted and skipped, the
788
- // pass continues. `.catch()` (not a block try/catch) keeps this inside the
789
- // no-silent-catch ban.
790
- const ok = await applyToMessage(messageId)
791
- .then(() => true)
792
- .catch(() => false);
793
- if (ok) {
794
- applied += 1;
795
- } else {
796
- failed += 1;
797
- }
798
- }
799
- return { applied, failed };
800
- };
801
-
802
- const FILTER_NOT_ACTIVE_MESSAGE =
803
- "This filter is turned off or has expired. Turn it on to run it over the inbox.";
804
-
805
- const FILTER_MISSING_MESSAGE =
806
- "This filter was deleted before it could run over the inbox.";
807
-
808
- const rejectFilter = (
809
- reason: OrganizeRejection["reason"],
810
- message: string,
811
- ): OrganizeMatchRejected => ({ rejected: { reason, message } });
812
-
813
- /**
814
- * A standing filter's predicate and action, read the way index time reads
815
- * them. `matchSemantic` reads the anchor vector by `filterId`; the anchor
816
- * message id here only says the filter widens.
817
- */
818
- const predicateFromFilter = (
819
- filter: FilterItem,
820
- anchorMessageId: string,
821
- ): OrganizePredicate => ({
822
- anchorMessageId,
823
- matchOperator: filter.matchOperator,
824
- literalClauses: filter.literalClauses,
825
- similarityThreshold: DEFAULT_SEMANTIC_MATCH_THRESHOLD,
826
- actionLabelId: filter.actionLabelId,
827
- actionMailboxId: filter.actionMailboxId,
828
- });
829
-
830
- interface LoadedFilter {
831
- rejected: null;
832
- filter: FilterItem;
833
- predicate: OrganizePredicate;
834
- /**
835
- * Marked `hasAnchor` with its FilterAnchor row gone. Index time matches
836
- * such a filter against nothing, so the back-apply does too.
837
- */
838
- matchesNothing: boolean;
839
- }
840
-
841
- /**
842
- * Read the filter a job names: refused when it was deleted or is not Active,
843
- * since index time evaluates neither.
844
- */
845
- const loadFilter = async (
846
- client: Pick<RemitClient, "filter" | "filterAnchor">,
847
- accountConfigId: string,
848
- filterId: string,
849
- ): Promise<LoadedFilter | OrganizeMatchRejected> => {
850
- const stored = await client.filter
851
- .get(accountConfigId, filterId)
852
- .catch((error: unknown) => {
853
- if (error instanceof NotFoundError) return null;
854
- throw error;
855
- });
856
- if (!stored) return rejectFilter("FilterMissing", FILTER_MISSING_MESSAGE);
857
- const filter = await client.filter.refreshExpiry(stored);
858
- if (filter.state !== FilterState.Active) {
859
- return rejectFilter("FilterNotActive", FILTER_NOT_ACTIVE_MESSAGE);
860
- }
861
- if (!filter.hasAnchor) {
862
- return {
863
- rejected: null,
864
- filter,
865
- predicate: predicateFromFilter(filter, NO_ACTION),
866
- matchesNothing: false,
867
- };
868
- }
869
- const anchor = await client.filterAnchor.get(accountConfigId, filterId);
870
- return {
871
- rejected: null,
872
- filter,
873
- predicate: predicateFromFilter(
874
- filter,
875
- anchor?.anchorMessageId ?? NO_ACTION,
876
- ),
877
- matchesNothing: anchor === null,
878
- };
879
- };
880
-
881
- /** Every inbox under the config — the folders a filter back-apply runs over. */
882
- const inboxMailboxIds = async (
883
- client: Pick<RemitClient, "account" | "mailboxSpecialUse">,
884
- accountConfigId: string,
885
- ): Promise<string[]> => {
886
- const accounts =
887
- await client.account.listMailSourcesByAccountConfig(accountConfigId);
888
- const inboxes = await Promise.all(
889
- accounts.map((account) =>
890
- client.mailboxSpecialUse.findInboxMailbox(account.accountId),
891
- ),
892
- );
893
- return inboxes.flatMap((inbox) => (inbox ? [inbox.mailboxId] : []));
894
- };
895
-
896
- /**
897
- * The message as the index-time pipeline reads it, from its thread row. The
898
- * body is the stored preview, so a `HasWords` clause matches what search
899
- * matched. A message deleted since its vector was written has no row.
900
- */
901
- const filterMessageOf = async (
902
- client: Pick<RemitClient, "threadMessage">,
903
- accountConfigId: string,
904
- messageId: string,
905
- ): Promise<FilterMessage | null> => {
906
- const row = await client.threadMessage
907
- .getByMessageId(accountConfigId, messageId)
908
- .catch((error: unknown) => {
909
- if (error instanceof NotFoundError) return null;
910
- throw error;
911
- });
912
- if (!row) return null;
913
- return {
914
- from: row.fromEmail ?? "",
915
- fromName: row.fromName ?? "",
916
- subject: row.subject ?? "",
917
- text: row.snippet ?? "",
918
- listId: row.listId ?? "",
919
- };
920
- };
921
-
922
- export interface FilterBackApplyResult {
923
- rejected: null;
924
- matched: number;
925
- applied: number;
926
- failed: number;
927
- semanticUnavailable: boolean;
928
- }
929
-
930
- export interface FilterBackApplyDeps {
931
- client: RemitClient;
932
- matchDeps: OrganizeMatchDeps;
933
- moveService?: PlacementMoveService;
934
- /** The embedder index time uses; read from env when absent. */
935
- embedder?: MessageEmbedder;
936
- }
937
-
938
- /**
939
- * Run one standing filter over the inbox as if each matching message had just
940
- * arrived. Every message the matcher returns is a match: its label always
941
- * applies, being additive. The index-time {@link FilterPipeline} only orders
942
- * the exclusive move, so the move is skipped where it names another active
943
- * filter. The pipeline re-scores a similarity filter against the stored
944
- * preview rather than the full body, so its verdict on whether this filter
945
- * matches is not asked; and an evaluate it could not finish names no other
946
- * filter, so the move goes ahead rather than silently dropping out.
947
- */
948
- export const backApplyFilter = async (
949
- deps: FilterBackApplyDeps,
950
- accountConfigId: string,
951
- filterId: string,
952
- ): Promise<FilterBackApplyResult | OrganizeMatchRejected> => {
953
- const { client, matchDeps, moveService } = deps;
954
- const loaded = await loadFilter(client, accountConfigId, filterId);
955
- if (loaded.rejected) return loaded;
956
- const config = buildFilterConfig(
957
- {
958
- filterService: client.filter,
959
- filterAnchorService: client.filterAnchor,
960
- messageLabelService: client.messageLabel,
961
- placementMoveService: moveService,
962
- },
963
- deps.embedder,
964
- );
965
- if (!config) {
966
- throw new Error(
967
- "Filter back-apply needs the message-management queue to move mail",
968
- );
969
- }
970
- const pipeline = new FilterPipeline(config, logger);
971
-
972
- const mailboxIds = await inboxMailboxIds(client, accountConfigId);
973
- const nothing: FilterBackApplyResult = {
974
- rejected: null,
975
- matched: 0,
976
- applied: 0,
977
- failed: 0,
978
- semanticUnavailable: false,
979
- };
980
- if (loaded.matchesNothing || mailboxIds.length === 0) return nothing;
981
-
982
- const match = await matchOrganize(
983
- matchDeps,
984
- accountConfigId,
985
- loaded.predicate,
986
- ORGANIZE_MATCH_LIMIT,
987
- { filterId, mailboxIds },
988
- );
989
- if (match.rejected) return match;
990
-
991
- const { filter, predicate } = loaded;
992
- const result: FilterBackApplyResult = {
993
- ...nothing,
994
- matched: match.messageIds.length,
995
- semanticUnavailable: match.semanticUnavailable,
996
- };
997
- for (const messageId of match.messageIds) {
998
- const message = await filterMessageOf(client, accountConfigId, messageId);
999
- if (!message) continue;
1000
- const decision = await pipeline.evaluate(
1001
- accountConfigId,
1002
- messageId,
1003
- message,
1004
- );
1005
- const move = (decision.move?.filterId ?? filterId) === filterId;
1006
- if (!move && filter.actionLabelId === NO_ACTION) continue;
1007
- const outcome = await applyOrganize(
1008
- { client, moveService },
1009
- accountConfigId,
1010
- [messageId],
1011
- {
1012
- ...predicate,
1013
- actionMailboxId: move ? filter.actionMailboxId : NO_ACTION,
1014
- },
1015
- );
1016
- result.applied += outcome.applied;
1017
- result.failed += outcome.failed;
1018
- }
1019
- return result;
1020
- };
1021
-
1022
- /** Whether a job row runs a standing filter rather than its own snapshot. */
1023
- export const isFilterJob = (
1024
- job: Pick<OrganizeJobRequestItem, "filterId">,
1025
- ): boolean => job.filterId !== NO_ACTION;