@remit/backend 0.0.137 → 0.0.139

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.
@@ -5,18 +5,22 @@ import type {
5
5
  IFilterAnchorRepository,
6
6
  OrganizeJobRequestItem,
7
7
  } from "@remit/data-ports";
8
- import { FilterClauseField } from "@remit/domain-enums";
8
+ import { NotFoundError } from "@remit/data-ports/errors";
9
+ import { FilterClauseField, FilterState } from "@remit/domain-enums";
9
10
  import { logger } from "@remit/logger-lambda";
10
11
  import {
11
12
  DEFAULT_SEMANTIC_MATCH_THRESHOLD,
12
13
  type FilterMessage,
14
+ FilterPipeline,
13
15
  type LiteralClauseNarrowing,
14
16
  literalClausesMatch,
15
17
  literalClauseTerms,
18
+ type MessageEmbedder,
16
19
  NO_ACTION,
17
20
  PlacementMoveService,
18
21
  refreshAnchorForEmbedder,
19
22
  } from "@remit/mailbox-service";
23
+ import { buildFilterConfig } from "@remit/mailbox-service/filter-config";
20
24
  import {
21
25
  type AnchorPayload,
22
26
  buildMessageAnchor,
@@ -135,6 +139,18 @@ export interface OrganizeCandidatePage {
135
139
  export interface OrganizeCandidateQuery extends LiteralClauseNarrowing {
136
140
  limit: number;
137
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[];
138
154
  }
139
155
 
140
156
  export interface OrganizeMatchDeps {
@@ -159,16 +175,19 @@ export interface OrganizeMatchDeps {
159
175
  /**
160
176
  * Every persisted FilterAnchor for the account — read-only, always
161
177
  * available (never gated behind {@link semantic}, since it never touches
162
- * the vector store). A back-apply predicate carries only a bare
163
- * `anchorMessageId`, never a `filterId` (RFC 034 recap: this job is
164
- * deliberately not a Filter), so this is how {@link matchSemantic} finds
165
- * "the standing filter this anchor came from," if one still exists, to
166
- * read its fixed-at-save-time vector instead of re-deriving one from the
167
- * anchor message's current chunks (reader #350 / RFC 039 Decision 1).
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).
168
184
  * `put` is the write half of the lazy anchor-drift repair
169
185
  * ({@link refreshAnchorForEmbedder}), never a new standing rule.
170
186
  */
171
- filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig" | "put">;
187
+ filterAnchors: Pick<
188
+ IFilterAnchorRepository,
189
+ "get" | "listByAccountConfig" | "put"
190
+ >;
172
191
  }
173
192
 
174
193
  /**
@@ -179,7 +198,10 @@ export interface OrganizeMatchDeps {
179
198
  * make it (reader #463).
180
199
  */
181
200
  export interface OrganizeRejection {
182
- reason: "BodyContentWithoutVectorPipeline";
201
+ reason:
202
+ | "BodyContentWithoutVectorPipeline"
203
+ | "FilterNotActive"
204
+ | "FilterMissing";
183
205
  message: string;
184
206
  }
185
207
 
@@ -246,9 +268,9 @@ const filterMessageFromChunks = (
246
268
  /**
247
269
  * The persisted FilterAnchor whose `anchorMessageId` matches this predicate's
248
270
  * anchor, if the account has a standing filter built from that message (RFC
249
- * 034 Decision 2 / RFC 039 Decision 1, reader #350). A back-apply predicate
250
- * never carries a `filterId` — this job structurally is not a Filter — so the
251
- * only way to recover "the filter this anchor came from" is by the value it
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
252
274
  * was anchored on. The scan is over one account's (small, bounded) anchor
253
275
  * rows, read-only, and never touches the vector store. Returns `undefined`
254
276
  * when no standing filter was ever anchored on this message (or it has since
@@ -331,24 +353,38 @@ const matchSemantic = async (
331
353
  accountConfigId: string,
332
354
  predicate: OrganizePredicate,
333
355
  limit: number,
356
+ scope: OrganizeMatchScope,
334
357
  ): Promise<{ messageIds: string[]; indexEmpty: boolean }> => {
335
358
  const semantic = deps.semantic();
336
- const persisted = await findPersistedAnchor(
337
- deps.filterAnchors,
338
- accountConfigId,
339
- predicate.anchorMessageId,
340
- );
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
+ );
341
367
  const anchor: AnchorPayload | null = persisted
342
368
  ? await anchorForWiden(deps, semantic, persisted)
343
369
  : await semantic.buildAnchor(accountConfigId, predicate.anchorMessageId);
344
370
  if (!anchor) return { messageIds: [], indexEmpty: true };
345
371
  const threshold =
346
372
  predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
347
- const matches = await semantic.vectorStore.query({
348
- vector: anchor.anchorEmbedding,
349
- topK: limit * VECTOR_CHUNK_FACTOR,
350
- filter: { accountConfigId },
351
- });
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();
352
388
  if (matches.length === 0) return { messageIds: [], indexEmpty: true };
353
389
  const bestScore = new Map<string, number>();
354
390
  for (const match of matches) {
@@ -436,6 +472,7 @@ const matchLiteral = async (
436
472
  accountConfigId: string,
437
473
  predicate: OrganizePredicate,
438
474
  limit: number,
475
+ scope: OrganizeMatchScope,
439
476
  ): Promise<string[]> => {
440
477
  const clauses = predicate.literalClauses;
441
478
  const narrowing = literalClauseTerms(clauses, predicate.matchOperator);
@@ -449,6 +486,7 @@ const matchLiteral = async (
449
486
  ...narrowing,
450
487
  limit,
451
488
  continuationToken,
489
+ mailboxIds: scope.mailboxIds,
452
490
  });
453
491
  for (const { messageId, message } of page.items) {
454
492
  if (seen.has(messageId)) continue;
@@ -488,6 +526,7 @@ export const matchOrganize = async (
488
526
  accountConfigId: string,
489
527
  predicate: OrganizePredicate,
490
528
  limit: number = ORGANIZE_MATCH_LIMIT,
529
+ scope: OrganizeMatchScope = {},
491
530
  ): Promise<OrganizeMatchResult> => {
492
531
  const anchored = hasAnchor(predicate);
493
532
  const clauses = predicate.literalClauses;
@@ -501,13 +540,16 @@ export const matchOrganize = async (
501
540
  }
502
541
 
503
542
  if (!anchored) {
504
- const rejection = organizePredicateRejection(predicate);
543
+ const rejection = scope.filterId
544
+ ? null
545
+ : organizePredicateRejection(predicate);
505
546
  if (rejection) return { rejected: rejection };
506
547
  const messageIds = await matchLiteral(
507
548
  deps,
508
549
  accountConfigId,
509
550
  predicate,
510
551
  limit,
552
+ scope,
511
553
  );
512
554
  return {
513
555
  rejected: null,
@@ -523,6 +565,7 @@ export const matchOrganize = async (
523
565
  accountConfigId,
524
566
  predicate,
525
567
  limit,
568
+ scope,
526
569
  );
527
570
  return {
528
571
  rejected: null,
@@ -543,13 +586,14 @@ export const matchOrganize = async (
543
586
  semanticIndexEmpty: false,
544
587
  };
545
588
  }
546
- const rejection = bodyContentRejection(clauses);
589
+ const rejection = scope.filterId ? null : bodyContentRejection(clauses);
547
590
  if (rejection) return { rejected: rejection };
548
591
  const messageIds = await matchLiteral(
549
592
  deps,
550
593
  accountConfigId,
551
594
  predicate,
552
595
  limit,
596
+ scope,
553
597
  );
554
598
  return {
555
599
  rejected: null,
@@ -591,12 +635,11 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
591
635
  *
592
636
  * `From`/`Subject`/`listId` come from the row verbatim (full fidelity), so a
593
637
  * `From`, `Subject`, `FromDomain` or `ListId` clause matches here exactly as it
594
- * does at index time. `text` (the body) is left empty: the thread row carries
595
- * only a truncated `snippet`, and matching
596
- * a body-content clause against a preview would silently diverge from the live
597
- * index-time filter's full-body match. Body-content (`HasWords`) clauses are
598
- * rejected before this is read (see {@link bodyContentRejection}), so the
599
- * empty `text` is never matched against.
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).
600
643
  */
601
644
  const listAccountFilterMessagesFromClient =
602
645
  (client: RemitClient): OrganizeMatchDeps["listAccountFilterMessages"] =>
@@ -609,6 +652,7 @@ const listAccountFilterMessagesFromClient =
609
652
  limit: query.limit,
610
653
  continuationToken: query.continuationToken,
611
654
  excludeDeleted: true,
655
+ mailboxIds: query.mailboxIds,
612
656
  },
613
657
  );
614
658
  return {
@@ -618,7 +662,7 @@ const listAccountFilterMessagesFromClient =
618
662
  from: row.fromEmail ?? "",
619
663
  fromName: row.fromName ?? "",
620
664
  subject: row.subject ?? "",
621
- text: "",
665
+ text: row.snippet ?? "",
622
666
  listId: row.listId ?? "",
623
667
  },
624
668
  })),
@@ -754,3 +798,228 @@ export const applyOrganize = async (
754
798
  }
755
799
  return { applied, failed };
756
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;
package/src/types.ts CHANGED
@@ -88,7 +88,8 @@ export type OperationIds =
88
88
  | "MessageCalendarSuggestionOperations_listMessageCalendarSuggestions"
89
89
  | "CalendarSuggestionActionOperations_acceptCalendarSuggestion"
90
90
  | "CalendarSuggestionActionOperations_declineCalendarSuggestion"
91
- | "CalendarSuggestionActionOperations_dismissCalendarSuggestion";
91
+ | "CalendarSuggestionActionOperations_dismissCalendarSuggestion"
92
+ | "CalendarSuggestionActionOperations_reopenCalendarSuggestion";
92
93
 
93
94
  export type MeOperationIds = MatchPrefix<"MeOperations_", OperationIds>;
94
95