@remit/mailbox-service 0.0.1
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.
- package/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { FilterItem } from "@remit/data-ports";
|
|
2
|
+
import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
|
|
3
|
+
|
|
4
|
+
type FilterClause = FilterItem["literalClauses"][number];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `"None"` sentinel a filter's action fields carry when that action is
|
|
8
|
+
* absent (RFC 034 Decision 3.1) — `actionLabelId`/`actionMailboxId` are never
|
|
9
|
+
* empty/optional strings, so a missing action is this exact value, never `""`.
|
|
10
|
+
*/
|
|
11
|
+
export const NO_ACTION = "None";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default cosine cut-off for a semantic-anchor match (RFC 031 "the semantic
|
|
15
|
+
* anchor evaluates as a kNN threshold against the new message's embedding").
|
|
16
|
+
* Tunable per pipeline via {@link FilterConfig.similarityThreshold}.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_SEMANTIC_MATCH_THRESHOLD = 0.75;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bound on the text embedded for a semantic match — the same 512-char budget
|
|
22
|
+
* `buildTextPreview` applies to chunk vectors and `FilterAnchor.anchorSourceText`
|
|
23
|
+
* persists, so the candidate side of the comparison is derived on the same
|
|
24
|
+
* footing as the anchor side.
|
|
25
|
+
*/
|
|
26
|
+
const MATCH_TEXT_LIMIT = 512;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The message fields a filter evaluates against — the literal-clause targets
|
|
30
|
+
* (from / subject / body) plus the text embedded for a semantic anchor. A plain
|
|
31
|
+
* value object so the matcher stays independent of the `ParsedMail` shape and is
|
|
32
|
+
* trivially constructed in a test.
|
|
33
|
+
*/
|
|
34
|
+
export interface FilterMessage {
|
|
35
|
+
from: string;
|
|
36
|
+
fromName: string;
|
|
37
|
+
subject: string;
|
|
38
|
+
text: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const includesFold = (haystack: string, needle: string): boolean =>
|
|
42
|
+
haystack.toLowerCase().includes(needle.toLowerCase());
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether one literal clause matches the message. From matches against the
|
|
46
|
+
* sender address and display name; Subject against the subject; HasWords against
|
|
47
|
+
* subject or body. An empty clause value never matches.
|
|
48
|
+
*/
|
|
49
|
+
export const clauseMatches = (
|
|
50
|
+
clause: FilterClause,
|
|
51
|
+
msg: FilterMessage,
|
|
52
|
+
): boolean => {
|
|
53
|
+
const value = clause.value.trim();
|
|
54
|
+
if (value === "") return false;
|
|
55
|
+
switch (clause.field) {
|
|
56
|
+
case FilterClauseField.From:
|
|
57
|
+
return includesFold(msg.from, value) || includesFold(msg.fromName, value);
|
|
58
|
+
case FilterClauseField.Subject:
|
|
59
|
+
return includesFold(msg.subject, value);
|
|
60
|
+
case FilterClauseField.HasWords:
|
|
61
|
+
return includesFold(msg.subject, value) || includesFold(msg.text, value);
|
|
62
|
+
default:
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether the literal clauses match under their combine operator. Empty clauses
|
|
69
|
+
* carry no literal constraint and pass vacuously — a purely-semantic filter (no
|
|
70
|
+
* clauses, only an anchor) is gated by its anchor alone, not by this.
|
|
71
|
+
*/
|
|
72
|
+
export const literalClausesMatch = (
|
|
73
|
+
clauses: readonly FilterClause[],
|
|
74
|
+
operator: FilterItem["matchOperator"],
|
|
75
|
+
msg: FilterMessage,
|
|
76
|
+
): boolean => {
|
|
77
|
+
if (clauses.length === 0) return true;
|
|
78
|
+
return operator === FilterMatchOperator.Or
|
|
79
|
+
? clauses.some((clause) => clauseMatches(clause, msg))
|
|
80
|
+
: clauses.every((clause) => clauseMatches(clause, msg));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Cosine similarity of two equal-length vectors. Throws on a dimension mismatch
|
|
85
|
+
* rather than silently scoring incomparable vectors — a mismatch means a stale
|
|
86
|
+
* anchor embedded under a different model (`anchorEmbeddingId`), which is a
|
|
87
|
+
* migration concern, not a match to guess at.
|
|
88
|
+
*/
|
|
89
|
+
export const cosineSimilarity = (
|
|
90
|
+
a: readonly number[],
|
|
91
|
+
b: readonly number[],
|
|
92
|
+
): number => {
|
|
93
|
+
if (a.length !== b.length) {
|
|
94
|
+
throw new Error(`Vector dimension mismatch: ${a.length} vs ${b.length}`);
|
|
95
|
+
}
|
|
96
|
+
let dot = 0;
|
|
97
|
+
let normA = 0;
|
|
98
|
+
let normB = 0;
|
|
99
|
+
for (let i = 0; i < a.length; i++) {
|
|
100
|
+
dot += a[i] * b[i];
|
|
101
|
+
normA += a[i] * a[i];
|
|
102
|
+
normB += b[i] * b[i];
|
|
103
|
+
}
|
|
104
|
+
if (normA === 0 || normB === 0) return 0;
|
|
105
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The move a message ends in when several filters matched: the most-recently
|
|
110
|
+
* *changed* filter wins (RFC 034 Decision 3.2), tie-broken on `filterId` for the
|
|
111
|
+
* unreachable identical-timestamp case. `ruleChangedAt` — not `updatedAt` — is
|
|
112
|
+
* the signal, so a cosmetic rename never flips an exclusive move.
|
|
113
|
+
*/
|
|
114
|
+
export const selectMoveWinner = (
|
|
115
|
+
candidates: readonly FilterItem[],
|
|
116
|
+
): FilterItem | undefined => {
|
|
117
|
+
let winner: FilterItem | undefined;
|
|
118
|
+
for (const candidate of candidates) {
|
|
119
|
+
if (!winner) {
|
|
120
|
+
winner = candidate;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (candidate.ruleChangedAt > winner.ruleChangedAt) {
|
|
124
|
+
winner = candidate;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
candidate.ruleChangedAt === winner.ruleChangedAt &&
|
|
129
|
+
candidate.filterId > winner.filterId
|
|
130
|
+
) {
|
|
131
|
+
winner = candidate;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return winner;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The candidate text embedded once for a semantic match — subject then body,
|
|
139
|
+
* bounded to the anchor's `anchorSourceText` budget so both sides of the cosine
|
|
140
|
+
* comparison are derived from comparable inputs.
|
|
141
|
+
*/
|
|
142
|
+
export const buildMatchText = (msg: FilterMessage): string =>
|
|
143
|
+
`${msg.subject}\n${msg.text}`.slice(0, MATCH_TEXT_LIMIT);
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
import type {
|
|
3
|
+
FilterItem,
|
|
4
|
+
IFilterAnchorRepository,
|
|
5
|
+
IFilterRepository,
|
|
6
|
+
IMessageLabelRepository,
|
|
7
|
+
} from "@remit/data-ports";
|
|
8
|
+
import { FilterState } from "@remit/domain-enums";
|
|
9
|
+
import type { PlacementMoveService } from "../placement-move.js";
|
|
10
|
+
import {
|
|
11
|
+
buildMatchText,
|
|
12
|
+
cosineSimilarity,
|
|
13
|
+
DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
14
|
+
type FilterMessage,
|
|
15
|
+
literalClausesMatch,
|
|
16
|
+
NO_ACTION,
|
|
17
|
+
selectMoveWinner,
|
|
18
|
+
} from "./match.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Turns the candidate message's text into a single message-level vector to
|
|
22
|
+
* compare against a filter's persisted `anchorEmbedding`. The anchor side is
|
|
23
|
+
* never embedded here — it is read from `FilterAnchor` as a fixed fact (RFC 034
|
|
24
|
+
* Decision 2.1/2.3); only the incoming message is embedded, and only once per
|
|
25
|
+
* message, and only when a semantic filter is actually in play.
|
|
26
|
+
*/
|
|
27
|
+
export interface MessageEmbedder {
|
|
28
|
+
embed(text: string): Promise<number[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FilterLogger {
|
|
32
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
33
|
+
debug?(obj: Record<string, unknown>, msg: string): void;
|
|
34
|
+
warn?(obj: Record<string, unknown>, msg: string): void;
|
|
35
|
+
error?(obj: Record<string, unknown>, msg: string): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Dependencies for the index-time filter pass (RFC 034). Optional on
|
|
40
|
+
* {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
|
|
41
|
+
* without it simply runs no filter matching.
|
|
42
|
+
*
|
|
43
|
+
* `embedder` is optional on purpose: semantic-anchor filters cannot be evaluated
|
|
44
|
+
* without it, so they are skipped when it is absent, but literal-clause filters
|
|
45
|
+
* always run. The `FilterAnchor` write path (a separate ticket) and a wired
|
|
46
|
+
* embedder are what light the semantic half up.
|
|
47
|
+
*/
|
|
48
|
+
export interface FilterConfig {
|
|
49
|
+
filterService: IFilterRepository;
|
|
50
|
+
filterAnchorService: IFilterAnchorRepository;
|
|
51
|
+
messageLabelService: IMessageLabelRepository;
|
|
52
|
+
placementMoveService: PlacementMoveService;
|
|
53
|
+
embedder?: MessageEmbedder;
|
|
54
|
+
similarityThreshold?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What the matched filters resolve to for one message: every matching filter's
|
|
59
|
+
* label (additive — RFC 034 Decision 3.1), and at most one move (exclusive — the
|
|
60
|
+
* most-recently-changed matching filter, Decision 3.2).
|
|
61
|
+
*/
|
|
62
|
+
export interface FilterDecision {
|
|
63
|
+
labels: Array<{ labelId: string; filterId: string }>;
|
|
64
|
+
move?: { destinationMailboxId: string; filterId: string };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const EMPTY_DECISION: FilterDecision = { labels: [] };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Evaluates a synced message against the account's active filters and resolves
|
|
71
|
+
* the actions to apply (RFC 034). Reads only — listing filters, refreshing lazy
|
|
72
|
+
* expiry, reading anchors, and embedding the message; the caller applies the
|
|
73
|
+
* resulting {@link FilterDecision}.
|
|
74
|
+
*
|
|
75
|
+
* `evaluate` isolates every failure in this read/decision phase from body sync,
|
|
76
|
+
* matching the placement-resolution precedent (#1246): a schema-drifted query or
|
|
77
|
+
* a bad anchor must never fail the surrounding message store, since the body is
|
|
78
|
+
* already durable by the time this runs. The apply of the decision — label
|
|
79
|
+
* writes and the move enqueue — is the caller's responsibility and is *not*
|
|
80
|
+
* swallowed there, so a genuine DDB/SQS write failure still propagates and
|
|
81
|
+
* requeues the message.
|
|
82
|
+
*/
|
|
83
|
+
export class FilterPipeline {
|
|
84
|
+
constructor(
|
|
85
|
+
private readonly config: FilterConfig,
|
|
86
|
+
private readonly log: FilterLogger,
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
async evaluate(
|
|
90
|
+
accountConfigId: string,
|
|
91
|
+
messageId: string,
|
|
92
|
+
msg: FilterMessage,
|
|
93
|
+
): Promise<FilterDecision> {
|
|
94
|
+
return this.match(accountConfigId, msg).catch((error: unknown) => {
|
|
95
|
+
this.log.error?.(
|
|
96
|
+
{
|
|
97
|
+
alert: "body_sync_filter_match_failed",
|
|
98
|
+
messageId,
|
|
99
|
+
accountConfigId,
|
|
100
|
+
errorName: (error as { name?: string })?.name,
|
|
101
|
+
error: inspect(error),
|
|
102
|
+
},
|
|
103
|
+
"Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)",
|
|
104
|
+
);
|
|
105
|
+
return EMPTY_DECISION;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async match(
|
|
110
|
+
accountConfigId: string,
|
|
111
|
+
msg: FilterMessage,
|
|
112
|
+
): Promise<FilterDecision> {
|
|
113
|
+
const active = await this.config.filterService.listByAccountAndState(
|
|
114
|
+
accountConfigId,
|
|
115
|
+
FilterState.Active,
|
|
116
|
+
);
|
|
117
|
+
if (active.length === 0) return EMPTY_DECISION;
|
|
118
|
+
|
|
119
|
+
// The message is embedded lazily and at most once: `undefined` means not
|
|
120
|
+
// yet computed, `null` means computed-but-unavailable (no embedder). The
|
|
121
|
+
// embed only ever fires when a filter survives the literal pre-filter and
|
|
122
|
+
// carries an anchor — a purely-literal account never pays for it.
|
|
123
|
+
let messageEmbedding: number[] | null | undefined;
|
|
124
|
+
const embed = async (): Promise<number[] | null> => {
|
|
125
|
+
if (messageEmbedding !== undefined) return messageEmbedding;
|
|
126
|
+
const embedder = this.config.embedder;
|
|
127
|
+
if (!embedder) {
|
|
128
|
+
messageEmbedding = null;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
messageEmbedding = await embedder.embed(buildMatchText(msg));
|
|
132
|
+
return messageEmbedding;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const matched: FilterItem[] = [];
|
|
136
|
+
for (const filter of active) {
|
|
137
|
+
// Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
|
|
138
|
+
// its expiresAt patches it to Expired on this read and drops it from
|
|
139
|
+
// evaluation, independent of whether the TTL delete has run.
|
|
140
|
+
const usable = await this.config.filterService.refreshExpiry(filter);
|
|
141
|
+
if (usable.state !== FilterState.Active) continue;
|
|
142
|
+
// A bad or stale anchor vector (a dimension mismatch under a changed
|
|
143
|
+
// embedding model) throws from cosineSimilarity. Isolate it to this
|
|
144
|
+
// filter: it is skipped and loudly logged, and every other filter on
|
|
145
|
+
// the message still evaluates and applies. This sits inside the
|
|
146
|
+
// evaluate-level catch — a whole-phase failure still degrades to no
|
|
147
|
+
// filter actions — but stops one poisoned anchor from doing so.
|
|
148
|
+
const isMatch = await this.filterMatches(
|
|
149
|
+
accountConfigId,
|
|
150
|
+
usable,
|
|
151
|
+
msg,
|
|
152
|
+
embed,
|
|
153
|
+
).catch((error: unknown) => {
|
|
154
|
+
this.log.error?.(
|
|
155
|
+
{
|
|
156
|
+
alert: "filter_anchor_match_failed",
|
|
157
|
+
filterId: usable.filterId,
|
|
158
|
+
accountConfigId,
|
|
159
|
+
errorName: (error as { name?: string })?.name,
|
|
160
|
+
error: inspect(error),
|
|
161
|
+
},
|
|
162
|
+
"Filter anchor comparison failed; skipping this filter, other filters still evaluate (bad/stale anchor vector, non-fatal)",
|
|
163
|
+
);
|
|
164
|
+
return "skip" as const;
|
|
165
|
+
});
|
|
166
|
+
if (isMatch === "skip") continue;
|
|
167
|
+
if (isMatch) matched.push(usable);
|
|
168
|
+
}
|
|
169
|
+
if (matched.length === 0) return EMPTY_DECISION;
|
|
170
|
+
|
|
171
|
+
const labels: FilterDecision["labels"] = [];
|
|
172
|
+
const seenLabels = new Set<string>();
|
|
173
|
+
for (const filter of matched) {
|
|
174
|
+
if (filter.actionLabelId === NO_ACTION) continue;
|
|
175
|
+
if (seenLabels.has(filter.actionLabelId)) continue;
|
|
176
|
+
seenLabels.add(filter.actionLabelId);
|
|
177
|
+
labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const winner = selectMoveWinner(
|
|
181
|
+
matched.filter((filter) => filter.actionMailboxId !== NO_ACTION),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
labels,
|
|
186
|
+
...(winner
|
|
187
|
+
? {
|
|
188
|
+
move: {
|
|
189
|
+
destinationMailboxId: winner.actionMailboxId,
|
|
190
|
+
filterId: winner.filterId,
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
: {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async filterMatches(
|
|
198
|
+
accountConfigId: string,
|
|
199
|
+
filter: FilterItem,
|
|
200
|
+
msg: FilterMessage,
|
|
201
|
+
embed: () => Promise<number[] | null>,
|
|
202
|
+
): Promise<boolean> {
|
|
203
|
+
if (
|
|
204
|
+
!literalClausesMatch(filter.literalClauses, filter.matchOperator, msg)
|
|
205
|
+
) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!filter.hasAnchor) {
|
|
210
|
+
// A filter with neither clauses nor an anchor has no predicate and
|
|
211
|
+
// matches nothing; a literal-only filter matched iff it had clauses.
|
|
212
|
+
return filter.literalClauses.length > 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// One deterministic point read per semantic candidate that survived the
|
|
216
|
+
// literal pre-filter — never a scan, never the anchor message itself
|
|
217
|
+
// (RFC 034 Decision 2.3).
|
|
218
|
+
const anchor = await this.config.filterAnchorService.get(
|
|
219
|
+
accountConfigId,
|
|
220
|
+
filter.filterId,
|
|
221
|
+
);
|
|
222
|
+
if (!anchor) {
|
|
223
|
+
this.log.warn?.(
|
|
224
|
+
{ filterId: filter.filterId, accountConfigId },
|
|
225
|
+
"Filter marked hasAnchor but no FilterAnchor row found; skipping semantic match",
|
|
226
|
+
);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const vector = await embed();
|
|
231
|
+
if (!vector) {
|
|
232
|
+
this.log.debug?.(
|
|
233
|
+
{ filterId: filter.filterId },
|
|
234
|
+
"No embedder configured; skipping semantic filter",
|
|
235
|
+
);
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const threshold =
|
|
240
|
+
this.config.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
|
|
241
|
+
return cosineSimilarity(vector, anchor.anchorEmbedding) >= threshold;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IMessageRepository,
|
|
5
|
+
IThreadMessageRepository,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import type { FlagPushLogger } from "./flag-push.js";
|
|
8
|
+
import {
|
|
9
|
+
type ResolveExhaustedFlagPushDeps,
|
|
10
|
+
resolveExhaustedFlagPushFailure,
|
|
11
|
+
} from "./flag-push-terminal.js";
|
|
12
|
+
import type { IImapConnection } from "./types.js";
|
|
13
|
+
|
|
14
|
+
interface LogEntry {
|
|
15
|
+
obj: Record<string, unknown>;
|
|
16
|
+
msg: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const buildLogger = (): {
|
|
20
|
+
log: FlagPushLogger;
|
|
21
|
+
infos: LogEntry[];
|
|
22
|
+
errors: LogEntry[];
|
|
23
|
+
} => {
|
|
24
|
+
const infos: LogEntry[] = [];
|
|
25
|
+
const errors: LogEntry[] = [];
|
|
26
|
+
return {
|
|
27
|
+
log: {
|
|
28
|
+
info: (obj, msg) => infos.push({ obj, msg }),
|
|
29
|
+
error: (obj, msg) => errors.push({ obj, msg }),
|
|
30
|
+
},
|
|
31
|
+
infos,
|
|
32
|
+
errors,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const buildConnection = (present: Set<number>): IImapConnection =>
|
|
37
|
+
({
|
|
38
|
+
openBox: async () => ({}) as never,
|
|
39
|
+
fetchMessages: async (uids: number[]) =>
|
|
40
|
+
uids
|
|
41
|
+
.filter((uid) => present.has(uid))
|
|
42
|
+
.map((uid) => ({ uid }) as unknown as never),
|
|
43
|
+
}) as unknown as IImapConnection;
|
|
44
|
+
|
|
45
|
+
describe("resolveExhaustedFlagPushFailure — the two terminal outcomes (mirrors #1289/#1270 for flag pushes)", () => {
|
|
46
|
+
it("RECONCILED (expected): the message is gone from its mailbox — marker dropped, stale row reconciled, no alarm", async () => {
|
|
47
|
+
const deletedMessages: string[] = [];
|
|
48
|
+
const deletedThreadMessages: Array<{
|
|
49
|
+
accountConfigId: string;
|
|
50
|
+
threadMessageId: string;
|
|
51
|
+
}> = [];
|
|
52
|
+
const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
|
|
53
|
+
const { log, infos, errors } = buildLogger();
|
|
54
|
+
|
|
55
|
+
const deps: ResolveExhaustedFlagPushDeps = {
|
|
56
|
+
markerService: {
|
|
57
|
+
delete: async (messageId: string, flagName: string) => {
|
|
58
|
+
markerDeletes.push({ messageId, flagName });
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
messageService: {
|
|
62
|
+
delete: async (id: string) => {
|
|
63
|
+
deletedMessages.push(id);
|
|
64
|
+
},
|
|
65
|
+
} as unknown as Pick<IMessageRepository, "delete">,
|
|
66
|
+
threadMessageService: {
|
|
67
|
+
findAllByMessageId: async (
|
|
68
|
+
accountConfigId: string,
|
|
69
|
+
messageId: string,
|
|
70
|
+
) => [{ accountConfigId, threadMessageId: `tm-${messageId}` }],
|
|
71
|
+
deleteMany: async (
|
|
72
|
+
keys: Array<{ accountConfigId: string; threadMessageId: string }>,
|
|
73
|
+
) => {
|
|
74
|
+
deletedThreadMessages.push(...keys);
|
|
75
|
+
},
|
|
76
|
+
} as unknown as Pick<
|
|
77
|
+
IThreadMessageRepository,
|
|
78
|
+
"findAllByMessageId" | "deleteMany"
|
|
79
|
+
>,
|
|
80
|
+
log,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = await resolveExhaustedFlagPushFailure(deps, {
|
|
84
|
+
accountId: "acc-1",
|
|
85
|
+
accountConfigId: "cfg-1",
|
|
86
|
+
messageId: "msg-gone",
|
|
87
|
+
flagName: "\\Seen",
|
|
88
|
+
uid: 101,
|
|
89
|
+
mailboxPath: "INBOX",
|
|
90
|
+
getConnection: async () => buildConnection(new Set()),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.equal(result.outcome, "reconciled");
|
|
94
|
+
assert.deepEqual(markerDeletes, [
|
|
95
|
+
{ messageId: "msg-gone", flagName: "\\Seen" },
|
|
96
|
+
]);
|
|
97
|
+
assert.deepEqual(deletedMessages, ["msg-gone"]);
|
|
98
|
+
assert.equal(deletedThreadMessages.length, 1);
|
|
99
|
+
|
|
100
|
+
assert.equal(errors.length, 0, "reconciled outcome must not log an alert");
|
|
101
|
+
const metricLog = infos.find(
|
|
102
|
+
(entry) => entry.obj.metric === "flag_push_stale_row_reconciled",
|
|
103
|
+
);
|
|
104
|
+
assert.ok(metricLog, "expected a routine reconciliation metric log");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("BROKEN (should never happen): the message still exists — marker left in place, alert logged", async () => {
|
|
108
|
+
const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
|
|
109
|
+
const { log, errors } = buildLogger();
|
|
110
|
+
|
|
111
|
+
const deps: ResolveExhaustedFlagPushDeps = {
|
|
112
|
+
markerService: {
|
|
113
|
+
delete: async (messageId: string, flagName: string) => {
|
|
114
|
+
markerDeletes.push({ messageId, flagName });
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
messageService: {
|
|
118
|
+
delete: async () => {
|
|
119
|
+
throw new Error("must not be called — message still exists");
|
|
120
|
+
},
|
|
121
|
+
} as unknown as Pick<IMessageRepository, "delete">,
|
|
122
|
+
threadMessageService: {
|
|
123
|
+
findAllByMessageId: async () => {
|
|
124
|
+
throw new Error("must not be called — message still exists");
|
|
125
|
+
},
|
|
126
|
+
deleteMany: async () => {
|
|
127
|
+
throw new Error("must not be called — message still exists");
|
|
128
|
+
},
|
|
129
|
+
} as unknown as Pick<
|
|
130
|
+
IThreadMessageRepository,
|
|
131
|
+
"findAllByMessageId" | "deleteMany"
|
|
132
|
+
>,
|
|
133
|
+
log,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const result = await resolveExhaustedFlagPushFailure(deps, {
|
|
137
|
+
accountId: "acc-1",
|
|
138
|
+
accountConfigId: "cfg-1",
|
|
139
|
+
messageId: "msg-still-here",
|
|
140
|
+
flagName: "\\Flagged",
|
|
141
|
+
uid: 202,
|
|
142
|
+
mailboxPath: "INBOX",
|
|
143
|
+
getConnection: async () => buildConnection(new Set([202])),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
assert.equal(result.outcome, "broken");
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
markerDeletes,
|
|
149
|
+
[],
|
|
150
|
+
"marker must be left in place — resync never reverts a pending flag while it is",
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const alerted = errors.find((e) => e.obj.alert === "flag_push_failed");
|
|
154
|
+
assert.ok(alerted, "expected an alertable flag_push_failed log");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { FlagPushLogger } from "./flag-push.js";
|
|
2
|
+
import {
|
|
3
|
+
reconcileStaleMessage,
|
|
4
|
+
type StaleMessageReconcileDeps,
|
|
5
|
+
} from "./stale-message-reconcile.js";
|
|
6
|
+
import type { IImapConnection } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export interface ResolveExhaustedFlagPushDeps
|
|
9
|
+
extends StaleMessageReconcileDeps {
|
|
10
|
+
markerService: { delete(messageId: string, flagName: string): Promise<void> };
|
|
11
|
+
log: FlagPushLogger;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ResolveExhaustedFlagPushInput {
|
|
15
|
+
accountId: string;
|
|
16
|
+
accountConfigId: string;
|
|
17
|
+
messageId: string;
|
|
18
|
+
flagName: string;
|
|
19
|
+
uid: number;
|
|
20
|
+
mailboxPath: string;
|
|
21
|
+
getConnection: () => Promise<IImapConnection>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FlagPushTerminalOutcome = "reconciled" | "broken";
|
|
25
|
+
|
|
26
|
+
export interface ResolveExhaustedFlagPushResult {
|
|
27
|
+
outcome: FlagPushTerminalOutcome;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a FLAG_PUSH failure that has exhausted the delivering queue's
|
|
32
|
+
* redelivery budget into exactly one of two terminal outcomes, mirroring
|
|
33
|
+
* `resolveExhaustedPlacementMoveFailure` (#1289) and
|
|
34
|
+
* `resolveExhaustedBodySyncFailures` (#1270) for the same failure taxonomy
|
|
35
|
+
* (epic #1281 invariant 3) — no third, softer outcome.
|
|
36
|
+
*
|
|
37
|
+
* 1. RECONCILED (expected) — the message no longer exists at its mailbox on
|
|
38
|
+
* IMAP. Per invariant 2, an external delete supersedes the marker
|
|
39
|
+
* entirely: the marker is dropped and the stale Message/ThreadMessage rows
|
|
40
|
+
* are deleted via {@link reconcileStaleMessage}. Metric only, no alarm —
|
|
41
|
+
* routine.
|
|
42
|
+
* 2. BROKEN — the message still exists, but the flag push keeps failing.
|
|
43
|
+
* Broken code or a broken account, not a transient blip. The marker is
|
|
44
|
+
* left in place (not cleared) — while pending, resync never reverts the
|
|
45
|
+
* local flag (invariant 3 of this issue's own spec), so leaving it does
|
|
46
|
+
* not risk correctness, and dropping it here would discard the only
|
|
47
|
+
* record that IMAP still owes this push. Logged with an `alert`-shaped
|
|
48
|
+
* entry for an operator alarm; never re-thrown (terminal — the caller acks
|
|
49
|
+
* either way, since retrying a stale or permanently-broken push can never
|
|
50
|
+
* succeed).
|
|
51
|
+
*/
|
|
52
|
+
export const resolveExhaustedFlagPushFailure = async (
|
|
53
|
+
deps: ResolveExhaustedFlagPushDeps,
|
|
54
|
+
input: ResolveExhaustedFlagPushInput,
|
|
55
|
+
): Promise<ResolveExhaustedFlagPushResult> => {
|
|
56
|
+
const {
|
|
57
|
+
accountId,
|
|
58
|
+
accountConfigId,
|
|
59
|
+
messageId,
|
|
60
|
+
flagName,
|
|
61
|
+
uid,
|
|
62
|
+
mailboxPath,
|
|
63
|
+
getConnection,
|
|
64
|
+
} = input;
|
|
65
|
+
|
|
66
|
+
const connection = await getConnection();
|
|
67
|
+
await connection.openBox(mailboxPath, true);
|
|
68
|
+
const found = await connection.fetchMessages([uid]);
|
|
69
|
+
|
|
70
|
+
if (found.length === 0) {
|
|
71
|
+
await deps.markerService.delete(messageId, flagName);
|
|
72
|
+
const { threadMessagesDeleted } = await reconcileStaleMessage(
|
|
73
|
+
deps,
|
|
74
|
+
accountConfigId,
|
|
75
|
+
messageId,
|
|
76
|
+
);
|
|
77
|
+
deps.log.info(
|
|
78
|
+
{
|
|
79
|
+
metric: "flag_push_stale_row_reconciled",
|
|
80
|
+
accountId,
|
|
81
|
+
accountConfigId,
|
|
82
|
+
messageId,
|
|
83
|
+
flagName,
|
|
84
|
+
uid,
|
|
85
|
+
mailboxPath,
|
|
86
|
+
threadMessagesDeleted,
|
|
87
|
+
},
|
|
88
|
+
"Message no longer at its flag-push mailbox after retry exhaustion (external delete); marker dropped, stale row reconciled",
|
|
89
|
+
);
|
|
90
|
+
return { outcome: "reconciled" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
deps.log.error(
|
|
94
|
+
{
|
|
95
|
+
alert: "flag_push_failed",
|
|
96
|
+
accountId,
|
|
97
|
+
accountConfigId,
|
|
98
|
+
messageId,
|
|
99
|
+
flagName,
|
|
100
|
+
uid,
|
|
101
|
+
mailboxPath,
|
|
102
|
+
},
|
|
103
|
+
"Flag push could not be pushed to IMAP after retry exhaustion; message still exists — marker left pending for operator investigation",
|
|
104
|
+
);
|
|
105
|
+
return { outcome: "broken" };
|
|
106
|
+
};
|