@remit/mailbox-service 0.0.111 → 0.0.112
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/package.json +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +156 -235
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +5 -4
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-message-gone.test.ts +0 -139
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
package/src/filters/pipeline.ts
CHANGED
|
@@ -1,41 +1,19 @@
|
|
|
1
1
|
import { inspect } from "node:util";
|
|
2
2
|
import type {
|
|
3
3
|
FilterItem,
|
|
4
|
-
IFilterAnchorRepository,
|
|
5
4
|
IFilterRepository,
|
|
5
|
+
IMailboxRepository,
|
|
6
6
|
IMessageLabelRepository,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import { FilterState } from "@remit/domain-enums";
|
|
9
|
-
import {
|
|
10
|
-
isEmbeddingCapabilityAbsence,
|
|
11
|
-
isEmbeddingCapabilityUnavailable,
|
|
12
|
-
recordEmbeddingCapabilityAbsence,
|
|
13
|
-
} from "@remit/search-service/capability";
|
|
8
|
+
import { FilterDisabledReason, FilterState } from "@remit/domain-enums";
|
|
14
9
|
import type { PlacementMoveService } from "../placement-move.js";
|
|
15
10
|
import {
|
|
16
|
-
type AnchorEmbedder,
|
|
17
|
-
refreshAnchorForEmbedder,
|
|
18
|
-
} from "./anchor-drift.js";
|
|
19
|
-
import {
|
|
20
|
-
buildMatchText,
|
|
21
|
-
cosineSimilarity,
|
|
22
|
-
DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
23
11
|
type FilterMessage,
|
|
24
12
|
literalClausesMatch,
|
|
25
13
|
NO_ACTION,
|
|
26
14
|
selectMoveWinner,
|
|
27
15
|
} from "./match.js";
|
|
28
16
|
|
|
29
|
-
/**
|
|
30
|
-
* Turns the candidate message's text into a single message-level vector to
|
|
31
|
-
* compare against a filter's persisted `anchorEmbedding`. The anchor side is
|
|
32
|
-
* embedded here only to repair a drifted anchor in place (see
|
|
33
|
-
* {@link refreshAnchorForEmbedder}); the match itself reads `FilterAnchor` as
|
|
34
|
-
* a fixed fact (RFC 034 Decision 2.1/2.3), and the incoming message is
|
|
35
|
-
* embedded once per message, only when a semantic filter is actually in play.
|
|
36
|
-
*/
|
|
37
|
-
export type MessageEmbedder = AnchorEmbedder;
|
|
38
|
-
|
|
39
17
|
export interface FilterLogger {
|
|
40
18
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
41
19
|
debug?(obj: Record<string, unknown>, msg: string): void;
|
|
@@ -47,19 +25,13 @@ export interface FilterLogger {
|
|
|
47
25
|
* Dependencies for the index-time filter pass (RFC 034). Optional on
|
|
48
26
|
* {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
|
|
49
27
|
* without it simply runs no filter matching.
|
|
50
|
-
*
|
|
51
|
-
* `embedder` is optional on purpose: semantic-anchor filters cannot be evaluated
|
|
52
|
-
* without it, so they are skipped when it is absent, but literal-clause filters
|
|
53
|
-
* always run. The `FilterAnchor` write path (a separate ticket) and a wired
|
|
54
|
-
* embedder are what light the semantic half up.
|
|
55
28
|
*/
|
|
56
29
|
export interface FilterConfig {
|
|
57
30
|
filterService: IFilterRepository;
|
|
58
|
-
filterAnchorService: IFilterAnchorRepository;
|
|
59
31
|
messageLabelService: IMessageLabelRepository;
|
|
32
|
+
/** Names the account a filter's folder belongs to. */
|
|
33
|
+
mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
|
|
60
34
|
placementMoveService: PlacementMoveService;
|
|
61
|
-
embedder?: MessageEmbedder;
|
|
62
|
-
similarityThreshold?: number;
|
|
63
35
|
}
|
|
64
36
|
|
|
65
37
|
/**
|
|
@@ -76,13 +48,13 @@ const EMPTY_DECISION: FilterDecision = { labels: [] };
|
|
|
76
48
|
|
|
77
49
|
/**
|
|
78
50
|
* Evaluates a synced message against the account's active filters and resolves
|
|
79
|
-
* the actions to apply (RFC 034). Reads only — listing filters
|
|
80
|
-
* expiry
|
|
51
|
+
* the actions to apply (RFC 034). Reads only — listing filters and refreshing
|
|
52
|
+
* lazy expiry; the caller applies the
|
|
81
53
|
* resulting {@link FilterDecision}.
|
|
82
54
|
*
|
|
83
55
|
* `evaluate` isolates every failure in this read/decision phase from body sync,
|
|
84
|
-
* matching the placement-resolution precedent (#1246): a schema-drifted query
|
|
85
|
-
*
|
|
56
|
+
* matching the placement-resolution precedent (#1246): a schema-drifted query
|
|
57
|
+
* must never fail the surrounding message store, since the body is
|
|
86
58
|
* already durable by the time this runs. The apply of the decision — label
|
|
87
59
|
* writes and the move enqueue — is the caller's responsibility and is *not*
|
|
88
60
|
* swallowed there, so a genuine DDB/SQS write failure still propagates and
|
|
@@ -96,26 +68,57 @@ export class FilterPipeline {
|
|
|
96
68
|
|
|
97
69
|
async evaluate(
|
|
98
70
|
accountConfigId: string,
|
|
71
|
+
accountId: string,
|
|
99
72
|
messageId: string,
|
|
100
73
|
msg: FilterMessage,
|
|
101
74
|
): Promise<FilterDecision> {
|
|
102
|
-
return this.match(accountConfigId, msg).catch(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
75
|
+
return this.match(accountConfigId, accountId, msg).catch(
|
|
76
|
+
(error: unknown) => {
|
|
77
|
+
this.log.error?.(
|
|
78
|
+
{
|
|
79
|
+
alert: "body_sync_filter_match_failed",
|
|
80
|
+
messageId,
|
|
81
|
+
accountConfigId,
|
|
82
|
+
errorName: (error as { name?: string })?.name,
|
|
83
|
+
error: inspect(error),
|
|
84
|
+
},
|
|
85
|
+
"Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)",
|
|
86
|
+
);
|
|
87
|
+
return EMPTY_DECISION;
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A filter sits on the account config and sees every account's mail, but its
|
|
94
|
+
* folder is one account's: mail of another account is not this filter's to
|
|
95
|
+
* act on, exactly as if it had not matched. A folder no account holds any
|
|
96
|
+
* more turns the filter off, with the reason the filter list shows.
|
|
97
|
+
*/
|
|
98
|
+
private async appliesTo(
|
|
99
|
+
accountConfigId: string,
|
|
100
|
+
accountId: string,
|
|
101
|
+
filter: FilterItem,
|
|
102
|
+
): Promise<boolean> {
|
|
103
|
+
if (filter.actionMailboxId === NO_ACTION) return true;
|
|
104
|
+
const owner = await this.config.mailboxService.resolveAccountId(
|
|
105
|
+
filter.actionMailboxId,
|
|
106
|
+
);
|
|
107
|
+
if (owner !== null) return owner === accountId;
|
|
108
|
+
await this.config.filterService.update(accountConfigId, filter.filterId, {
|
|
109
|
+
state: FilterState.Disabled,
|
|
110
|
+
disabledReason: FilterDisabledReason.FolderMissing,
|
|
114
111
|
});
|
|
112
|
+
this.log.warn?.(
|
|
113
|
+
{ filterId: filter.filterId, accountConfigId },
|
|
114
|
+
"Filter turned off: the folder it moves mail into no longer exists",
|
|
115
|
+
);
|
|
116
|
+
return false;
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
private async match(
|
|
118
120
|
accountConfigId: string,
|
|
121
|
+
accountId: string,
|
|
119
122
|
msg: FilterMessage,
|
|
120
123
|
): Promise<FilterDecision> {
|
|
121
124
|
const active = await this.config.filterService.listByAccountAndState(
|
|
@@ -124,44 +127,6 @@ export class FilterPipeline {
|
|
|
124
127
|
);
|
|
125
128
|
if (active.length === 0) return EMPTY_DECISION;
|
|
126
129
|
|
|
127
|
-
// The message is embedded lazily and at most once: `undefined` means not
|
|
128
|
-
// yet computed, `null` means computed-but-unavailable (no embedder). The
|
|
129
|
-
// embed only ever fires when a filter survives the literal pre-filter and
|
|
130
|
-
// carries an anchor — a purely-literal account never pays for it.
|
|
131
|
-
let messageEmbedding: number[] | null | undefined;
|
|
132
|
-
const embed = async (): Promise<number[] | null> => {
|
|
133
|
-
if (messageEmbedding !== undefined) return messageEmbedding;
|
|
134
|
-
const embedder = this.config.embedder;
|
|
135
|
-
if (!embedder) {
|
|
136
|
-
messageEmbedding = null;
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
messageEmbedding = await embedder
|
|
140
|
-
.embed(buildMatchText(msg))
|
|
141
|
-
.catch((error: unknown) => {
|
|
142
|
-
// This deployment carries no embedding pipeline at all — the
|
|
143
|
-
// container images ship without `@huggingface/transformers`. That is
|
|
144
|
-
// the same absence `SEARCH_EMBEDDING_PROVIDER=off` states, so it takes
|
|
145
|
-
// the same designed skip rather than an error-level
|
|
146
|
-
// `filter_anchor_match_failed` per semantic filter per message. The
|
|
147
|
-
// absence is remembered process-wide, so this is the only embed that
|
|
148
|
-
// pays for it: `buildFilterConfig` wires no embedder afterwards.
|
|
149
|
-
if (!isEmbeddingCapabilityAbsence(error)) throw error;
|
|
150
|
-
if (!isEmbeddingCapabilityUnavailable()) {
|
|
151
|
-
this.log.warn?.(
|
|
152
|
-
{
|
|
153
|
-
accountConfigId,
|
|
154
|
-
error: inspect(error),
|
|
155
|
-
},
|
|
156
|
-
"No embedding pipeline in this deployment; semantic filters are skipped (literal filters still run)",
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
recordEmbeddingCapabilityAbsence(error);
|
|
160
|
-
return null;
|
|
161
|
-
});
|
|
162
|
-
return messageEmbedding;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
130
|
const matched: FilterItem[] = [];
|
|
166
131
|
for (const filter of active) {
|
|
167
132
|
// Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
|
|
@@ -169,32 +134,8 @@ export class FilterPipeline {
|
|
|
169
134
|
// evaluation, independent of whether the TTL delete has run.
|
|
170
135
|
const usable = await this.config.filterService.refreshExpiry(filter);
|
|
171
136
|
if (usable.state !== FilterState.Active) continue;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// filter: it is skipped and loudly logged, and every other filter on
|
|
175
|
-
// the message still evaluates and applies. This sits inside the
|
|
176
|
-
// evaluate-level catch — a whole-phase failure still degrades to no
|
|
177
|
-
// filter actions — but stops one poisoned anchor from doing so.
|
|
178
|
-
const isMatch = await this.filterMatches(
|
|
179
|
-
accountConfigId,
|
|
180
|
-
usable,
|
|
181
|
-
msg,
|
|
182
|
-
embed,
|
|
183
|
-
).catch((error: unknown) => {
|
|
184
|
-
this.log.error?.(
|
|
185
|
-
{
|
|
186
|
-
alert: "filter_anchor_match_failed",
|
|
187
|
-
filterId: usable.filterId,
|
|
188
|
-
accountConfigId,
|
|
189
|
-
errorName: (error as { name?: string })?.name,
|
|
190
|
-
error: inspect(error),
|
|
191
|
-
},
|
|
192
|
-
"Filter anchor comparison failed; skipping this filter, other filters still evaluate (bad/stale anchor vector, non-fatal)",
|
|
193
|
-
);
|
|
194
|
-
return "skip" as const;
|
|
195
|
-
});
|
|
196
|
-
if (isMatch === "skip") continue;
|
|
197
|
-
if (isMatch) matched.push(usable);
|
|
137
|
+
if (!(await this.appliesTo(accountConfigId, accountId, usable))) continue;
|
|
138
|
+
if (filterMatches(usable, msg)) matched.push(usable);
|
|
198
139
|
}
|
|
199
140
|
if (matched.length === 0) return EMPTY_DECISION;
|
|
200
141
|
|
|
@@ -223,62 +164,7 @@ export class FilterPipeline {
|
|
|
223
164
|
: {}),
|
|
224
165
|
};
|
|
225
166
|
}
|
|
226
|
-
|
|
227
|
-
private async filterMatches(
|
|
228
|
-
accountConfigId: string,
|
|
229
|
-
filter: FilterItem,
|
|
230
|
-
msg: FilterMessage,
|
|
231
|
-
embed: () => Promise<number[] | null>,
|
|
232
|
-
): Promise<boolean> {
|
|
233
|
-
if (
|
|
234
|
-
!literalClausesMatch(filter.literalClauses, filter.matchOperator, msg)
|
|
235
|
-
) {
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (!filter.hasAnchor) {
|
|
240
|
-
// A filter with neither clauses nor an anchor has no predicate and
|
|
241
|
-
// matches nothing; a literal-only filter matched iff it had clauses.
|
|
242
|
-
return filter.literalClauses.length > 0;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// One deterministic point read per semantic candidate that survived the
|
|
246
|
-
// literal pre-filter — never a scan, never the anchor message itself
|
|
247
|
-
// (RFC 034 Decision 2.3).
|
|
248
|
-
let anchor = await this.config.filterAnchorService.get(
|
|
249
|
-
accountConfigId,
|
|
250
|
-
filter.filterId,
|
|
251
|
-
);
|
|
252
|
-
if (!anchor) {
|
|
253
|
-
this.log.warn?.(
|
|
254
|
-
{ filterId: filter.filterId, accountConfigId },
|
|
255
|
-
"Filter marked hasAnchor but no FilterAnchor row found; skipping semantic match",
|
|
256
|
-
);
|
|
257
|
-
return false;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// A re-embed failure here propagates to the per-filter catch in
|
|
261
|
-
// `match()`, which logs and skips this filter for this one evaluation
|
|
262
|
-
// exactly as an unrecoverable stale anchor does today.
|
|
263
|
-
anchor = await refreshAnchorForEmbedder(
|
|
264
|
-
{
|
|
265
|
-
anchorRepository: this.config.filterAnchorService,
|
|
266
|
-
embedder: this.config.embedder,
|
|
267
|
-
},
|
|
268
|
-
anchor,
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
const vector = await embed();
|
|
272
|
-
if (!vector) {
|
|
273
|
-
this.log.debug?.(
|
|
274
|
-
{ filterId: filter.filterId },
|
|
275
|
-
"No embedder configured; skipping semantic filter",
|
|
276
|
-
);
|
|
277
|
-
return false;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const threshold =
|
|
281
|
-
this.config.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
|
|
282
|
-
return cosineSimilarity(vector, anchor.anchorEmbedding) >= threshold;
|
|
283
|
-
}
|
|
284
167
|
}
|
|
168
|
+
|
|
169
|
+
const filterMatches = (filter: FilterItem, msg: FilterMessage): boolean =>
|
|
170
|
+
literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
+
import type { IMessageRepository, MessageItem } from "@remit/data-ports";
|
|
1
2
|
import type { FlagPushLogger } from "./flag-push.js";
|
|
2
3
|
import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
3
|
-
import {
|
|
4
|
-
reconcileStaleMessage,
|
|
5
|
-
type StaleMessageReconcileDeps,
|
|
6
|
-
} from "./stale-message-reconcile.js";
|
|
7
4
|
import type { IImapConnection } from "./types.js";
|
|
8
5
|
|
|
9
|
-
export interface ResolveExhaustedFlagPushDeps
|
|
10
|
-
|
|
6
|
+
export interface ResolveExhaustedFlagPushDeps {
|
|
7
|
+
messageService: Pick<IMessageRepository, "deleteIfPlacement">;
|
|
11
8
|
markerService: { delete(messageId: string, flagName: string): Promise<void> };
|
|
12
9
|
log: FlagPushLogger;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
export interface ResolveExhaustedFlagPushInput {
|
|
13
|
+
/** Where the row stood when the caller read it; the delete holds only while it still does. */
|
|
14
|
+
placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
|
|
16
15
|
accountId: string;
|
|
17
16
|
accountConfigId: string;
|
|
18
17
|
messageId: string;
|
|
@@ -39,7 +38,7 @@ export interface ResolveExhaustedFlagPushResult {
|
|
|
39
38
|
* IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather than by a
|
|
40
39
|
* FETCH coming back empty. Per invariant 2, an external delete supersedes
|
|
41
40
|
* the marker entirely: the marker is dropped and the stale
|
|
42
|
-
* Message/ThreadMessage rows are deleted via
|
|
41
|
+
* Message/ThreadMessage rows are deleted via `deleteIfPlacement`.
|
|
43
42
|
* Metric only, no alarm — routine.
|
|
44
43
|
* 2. BROKEN — the message still exists, but the flag push keeps failing.
|
|
45
44
|
* Broken code or a broken account, not a transient blip. The marker is
|
|
@@ -79,10 +78,9 @@ export const resolveExhaustedFlagPushFailure = async (
|
|
|
79
78
|
|
|
80
79
|
if (await isMessageGoneFromOpenMailbox(connection, uid)) {
|
|
81
80
|
await deps.markerService.delete(messageId, flagName);
|
|
82
|
-
const
|
|
83
|
-
deps,
|
|
84
|
-
accountConfigId,
|
|
81
|
+
const reconciled = await deps.messageService.deleteIfPlacement(
|
|
85
82
|
messageId,
|
|
83
|
+
input.placement,
|
|
86
84
|
);
|
|
87
85
|
deps.log.info(
|
|
88
86
|
{
|
|
@@ -93,7 +91,7 @@ export const resolveExhaustedFlagPushFailure = async (
|
|
|
93
91
|
flagName,
|
|
94
92
|
uid,
|
|
95
93
|
mailboxPath,
|
|
96
|
-
|
|
94
|
+
reconciled,
|
|
97
95
|
},
|
|
98
96
|
"Message no longer at its flag-push mailbox after retry exhaustion (external delete); marker dropped, stale row reconciled",
|
|
99
97
|
);
|