@remit/mailbox-service 0.0.112 → 0.0.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-credentials.d.ts +83 -0
- package/dist/account-credentials.js +104 -0
- package/dist/attribute-mapper.d.ts +36 -0
- package/dist/attribute-mapper.js +119 -0
- package/dist/body-parse.d.ts +28 -0
- package/{src/body-parse.ts → dist/body-parse.js} +18 -26
- package/dist/body-part-mapper.d.ts +92 -0
- package/dist/body-part-mapper.js +301 -0
- package/dist/body-sync-queue.d.ts +59 -0
- package/dist/body-sync-queue.js +56 -0
- package/dist/body-sync-terminal.d.ts +58 -0
- package/dist/body-sync-terminal.js +95 -0
- package/dist/body-sync.d.ts +351 -0
- package/dist/body-sync.js +1204 -0
- package/dist/calendar-parts.d.ts +23 -0
- package/dist/calendar-parts.js +18 -0
- package/dist/classification-backfill.d.ts +91 -0
- package/dist/classification-backfill.js +180 -0
- package/dist/classify-message.d.ts +31 -0
- package/dist/classify-message.js +89 -0
- package/dist/connection-factory.d.ts +54 -0
- package/dist/connection-factory.js +54 -0
- package/dist/connection-test.d.ts +41 -0
- package/dist/connection-test.js +152 -0
- package/dist/external-move.d.ts +94 -0
- package/dist/external-move.js +173 -0
- package/dist/filter-operations.d.ts +42 -0
- package/dist/filter-operations.js +211 -0
- package/dist/filters/filter-config.d.ts +14 -0
- package/dist/filters/filter-config.js +10 -0
- package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
- package/dist/filters/list-id.js +26 -0
- package/dist/filters/match.d.ts +69 -0
- package/dist/filters/match.js +165 -0
- package/dist/filters/pipeline.d.ts +64 -0
- package/dist/filters/pipeline.js +100 -0
- package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
- package/dist/flag-push-terminal.js +63 -0
- package/dist/flag-push.d.ts +69 -0
- package/dist/flag-push.js +111 -0
- package/dist/flag-queue.d.ts +175 -0
- package/dist/flag-queue.js +291 -0
- package/dist/folder-operations.d.ts +39 -0
- package/dist/folder-operations.js +112 -0
- package/dist/folder-placement.d.ts +13 -0
- package/dist/folder-placement.js +23 -0
- package/dist/heuristics/authenticationResults.d.ts +10 -0
- package/dist/heuristics/authenticationResults.js +187 -0
- package/dist/heuristics/classifyByHeaders.d.ts +82 -0
- package/dist/heuristics/classifyByHeaders.js +365 -0
- package/dist/heuristics/classifyPlacement.d.ts +67 -0
- package/dist/heuristics/classifyPlacement.js +153 -0
- package/dist/heuristics/machineSenders.d.ts +21 -0
- package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
- package/dist/heuristics/senderMismatch.d.ts +81 -0
- package/dist/heuristics/senderMismatch.js +192 -0
- package/dist/heuristics/socialDomains.d.ts +14 -0
- package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
- package/dist/heuristics/transactionalDomains.d.ts +18 -0
- package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
- package/dist/imapflow-connection.d.ts +377 -0
- package/dist/imapflow-connection.js +1147 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +65 -0
- package/dist/list-id-backfill.d.ts +83 -0
- package/dist/list-id-backfill.js +132 -0
- package/dist/mailbox-cursor-rebuild.d.ts +74 -0
- package/dist/mailbox-cursor-rebuild.js +71 -0
- package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
- package/dist/mailbox-cursor.js +102 -0
- package/dist/mailbox-intent.d.ts +41 -0
- package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
- package/dist/mailbox-management.d.ts +240 -0
- package/dist/mailbox-management.js +576 -0
- package/dist/mailbox-presence.d.ts +52 -0
- package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
- package/dist/mailbox-queue.d.ts +126 -0
- package/dist/mailbox-queue.js +214 -0
- package/dist/mailbox-sync.d.ts +93 -0
- package/dist/mailbox-sync.js +490 -0
- package/dist/mailbox-upstream.d.ts +38 -0
- package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
- package/dist/message-category.d.ts +51 -0
- package/{src/message-category.ts → dist/message-category.js} +28 -71
- package/dist/message-move.d.ts +285 -0
- package/dist/message-move.js +858 -0
- package/dist/message-parser.d.ts +14 -0
- package/dist/message-parser.js +17 -0
- package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
- package/dist/message-presence.js +29 -0
- package/dist/message-read.d.ts +51 -0
- package/dist/message-read.js +31 -0
- package/dist/message-sync.d.ts +345 -0
- package/dist/message-sync.js +1358 -0
- package/dist/mime-walker.d.ts +102 -0
- package/dist/mime-walker.js +198 -0
- package/dist/organize-job.d.ts +20 -0
- package/dist/organize-job.js +37 -0
- package/dist/outbox-attachment-content.d.ts +37 -0
- package/dist/outbox-attachment-content.js +50 -0
- package/dist/outbox-attachment-filename.d.ts +19 -0
- package/dist/outbox-attachment-filename.js +64 -0
- package/dist/outbox-attachment.d.ts +148 -0
- package/dist/outbox-attachment.js +273 -0
- package/dist/outbox-queue.d.ts +73 -0
- package/dist/outbox-queue.js +284 -0
- package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
- package/dist/outbox-status.js +18 -0
- package/dist/ownership.d.ts +24 -0
- package/dist/ownership.js +83 -0
- package/dist/placement-move-terminal.d.ts +67 -0
- package/dist/placement-move-terminal.js +88 -0
- package/dist/placement-move.d.ts +83 -0
- package/dist/placement-move.js +216 -0
- package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
- package/dist/placement-settled.js +75 -0
- package/dist/quarantine.d.ts +95 -0
- package/dist/quarantine.js +131 -0
- package/dist/refusal.d.ts +31 -0
- package/dist/refusal.js +3 -0
- package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
- package/dist/restore-source-placement.js +79 -0
- package/dist/search-text-backfill.d.ts +15 -0
- package/dist/search-text-backfill.js +33 -0
- package/dist/snippet.d.ts +61 -0
- package/dist/snippet.js +129 -0
- package/dist/spam-report.d.ts +86 -0
- package/dist/spam-report.js +241 -0
- package/dist/sync-warning.d.ts +21 -0
- package/dist/sync-warning.js +26 -0
- package/dist/sync-watermarks.d.ts +138 -0
- package/dist/sync-watermarks.js +205 -0
- package/dist/test-helpers/fake-imap.d.ts +151 -0
- package/dist/test-helpers/fake-imap.js +508 -0
- package/dist/test-helpers/folder-roles.d.ts +34 -0
- package/dist/test-helpers/folder-roles.js +47 -0
- package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
- package/dist/test-helpers/mime-tree-generator.js +329 -0
- package/dist/test-helpers/test-store.d.ts +69 -0
- package/dist/test-helpers/test-store.js +193 -0
- package/dist/text/normalizer.d.ts +20 -0
- package/dist/text/normalizer.js +102 -0
- package/dist/thread-message-rows.d.ts +69 -0
- package/dist/thread-message-rows.js +40 -0
- package/dist/thread-root.d.ts +7 -0
- package/dist/thread-root.js +25 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +19 -0
- package/package.json +86 -88
- package/src/account-credentials.ts +0 -210
- package/src/attribute-mapper.test.ts +0 -104
- package/src/attribute-mapper.ts +0 -148
- package/src/body-parse.test.ts +0 -55
- package/src/body-part-mapper.property.test.ts +0 -354
- package/src/body-part-mapper.ts +0 -410
- package/src/body-sync-queue.test.ts +0 -64
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-terminal.test.ts +0 -148
- package/src/body-sync-terminal.ts +0 -174
- package/src/body-sync.test.ts +0 -925
- package/src/body-sync.ts +0 -1891
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -285
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -142
- package/src/classify-message.ts +0 -116
- package/src/connection-factory.ts +0 -102
- package/src/connection-test.ts +0 -208
- package/src/credentials.test.ts +0 -181
- package/src/external-move.test.ts +0 -262
- package/src/external-move.ts +0 -285
- package/src/filter-operations.test.ts +0 -319
- package/src/filter-operations.ts +0 -377
- package/src/filter-update.test.ts +0 -313
- package/src/filters/filter-config.test.ts +0 -87
- package/src/filters/filter-config.ts +0 -34
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -392
- package/src/filters/match.ts +0 -225
- package/src/filters/pipeline.test.ts +0 -199
- package/src/filters/pipeline.ts +0 -170
- package/src/flag-push.test.ts +0 -112
- package/src/flag-push.ts +0 -198
- package/src/flag-queue.test.ts +0 -197
- package/src/flag-queue.ts +0 -509
- package/src/folder-operations.test.ts +0 -273
- package/src/folder-operations.ts +0 -217
- package/src/folder-placement.ts +0 -56
- package/src/forwarded-message.test.ts +0 -122
- package/src/heuristics/authenticationResults.test.ts +0 -189
- package/src/heuristics/authenticationResults.ts +0 -212
- package/src/heuristics/classifyByHeaders.test.ts +0 -889
- package/src/heuristics/classifyByHeaders.ts +0 -467
- package/src/heuristics/classifyPlacement.test.ts +0 -366
- package/src/heuristics/classifyPlacement.ts +0 -211
- package/src/heuristics/classifyRealMail.test.ts +0 -242
- package/src/heuristics/machineSenders.test.ts +0 -101
- package/src/heuristics/senderMismatch.test.ts +0 -346
- package/src/heuristics/senderMismatch.ts +0 -259
- package/src/imapflow-connect-retry.test.ts +0 -97
- package/src/imapflow-connection.test.ts +0 -503
- package/src/imapflow-connection.ts +0 -1476
- package/src/index.ts +0 -397
- package/src/list-id-backfill.test.ts +0 -207
- package/src/list-id-backfill.ts +0 -274
- package/src/mailbox-cursor-rebuild.test.ts +0 -171
- package/src/mailbox-cursor-rebuild.ts +0 -146
- package/src/mailbox-cursor.test.ts +0 -141
- package/src/mailbox-delete-intent.test.ts +0 -119
- package/src/mailbox-management.test.ts +0 -243
- package/src/mailbox-management.ts +0 -838
- package/src/mailbox-presence.test.ts +0 -74
- package/src/mailbox-queue.ts +0 -397
- package/src/mailbox-rename-intent.test.ts +0 -220
- package/src/mailbox-rename-subtree.test.ts +0 -287
- package/src/mailbox-sync.test.ts +0 -286
- package/src/mailbox-sync.ts +0 -724
- package/src/message-move.test.ts +0 -726
- package/src/message-move.ts +0 -1518
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- package/src/message-read.ts +0 -87
- package/src/message-save-retry.test.ts +0 -90
- package/src/message-sync-flags.test.ts +0 -167
- package/src/message-sync.test.ts +0 -762
- package/src/message-sync.ts +0 -2133
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -306
- package/src/organize-job.ts +0 -93
- package/src/outbox-attachment-content.test.ts +0 -153
- package/src/outbox-attachment-content.ts +0 -104
- package/src/outbox-attachment-filename.test.ts +0 -132
- package/src/outbox-attachment-filename.ts +0 -76
- package/src/outbox-attachment.test.ts +0 -626
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -299
- package/src/outbox-queue.test.ts +0 -315
- package/src/outbox-queue.ts +0 -525
- package/src/ownership.ts +0 -140
- package/src/placement-move-terminal.ts +0 -145
- package/src/placement-move.ts +0 -363
- package/src/placement-settled.test.ts +0 -147
- package/src/placement.test.ts +0 -404
- package/src/quarantine.test.ts +0 -133
- package/src/quarantine.ts +0 -229
- package/src/refusal.ts +0 -29
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -329
- package/src/spam-report.ts +0 -357
- package/src/sync-warning.test.ts +0 -213
- package/src/sync-warning.ts +0 -56
- package/src/sync-watermarks.test.ts +0 -409
- package/src/sync-watermarks.ts +0 -319
- package/src/system-flag-wire-format.test.ts +0 -77
- package/src/test-helpers/fake-imap.ts +0 -690
- package/src/test-helpers/folder-roles.ts +0 -66
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- package/src/test-helpers/test-queue.ts +0 -56
- package/src/test-helpers/test-store.ts +0 -299
- package/src/text/normalizer.test.ts +0 -223
- package/src/text/normalizer.ts +0 -140
- package/src/thread-message-rows.ts +0 -77
- package/src/thread-root.test.ts +0 -29
- package/src/thread-root.ts +0 -51
- package/src/types/mailparser-augment.d.ts +0 -23
- package/src/types/natural-porter-stemmer.d.ts +0 -50
- package/src/types/stopword.d.ts +0 -69
- package/src/types.ts +0 -371
- package/tsconfig.json +0 -8
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IFilterRepository, IMessageLabelRepository } from "@remit/data-ports";
|
|
2
|
+
import type { PlacementMoveService } from "../placement-move.js";
|
|
3
|
+
import type { FilterConfig } from "./pipeline.js";
|
|
4
|
+
export interface FilterConfigDeps {
|
|
5
|
+
filterService: IFilterRepository;
|
|
6
|
+
messageLabelService: IMessageLabelRepository;
|
|
7
|
+
mailboxService: FilterConfig["mailboxService"];
|
|
8
|
+
placementMoveService: PlacementMoveService;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Assemble the filter config a body-materializing pass runs (RFC 034), shared by
|
|
12
|
+
* the imap-worker's sync path and the backend's read-path backfill.
|
|
13
|
+
*/
|
|
14
|
+
export declare const buildFilterConfig: (deps: FilterConfigDeps) => FilterConfig;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assemble the filter config a body-materializing pass runs (RFC 034), shared by
|
|
3
|
+
* the imap-worker's sync path and the backend's read-path backfill.
|
|
4
|
+
*/
|
|
5
|
+
export const buildFilterConfig = (deps) => ({
|
|
6
|
+
filterService: deps.filterService,
|
|
7
|
+
messageLabelService: deps.messageLabelService,
|
|
8
|
+
mailboxService: deps.mailboxService,
|
|
9
|
+
placementMoveService: deps.placementMoveService,
|
|
10
|
+
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ParsedMail } from "mailparser";
|
|
2
|
-
|
|
3
2
|
/**
|
|
4
3
|
* Canonical form of a `List-Id` value for exact comparison: the bracketed
|
|
5
4
|
* identifier when the header carries the RFC 2919 `Name <list.id>` shape,
|
|
@@ -7,23 +6,10 @@ import type { ParsedMail } from "mailparser";
|
|
|
7
6
|
* a `ListId` clause pass through this, so `<weekly.news.example.com>` and
|
|
8
7
|
* `weekly.news.example.com` are one list. An empty input normalizes to `""`.
|
|
9
8
|
*/
|
|
10
|
-
export const normalizeListId
|
|
11
|
-
const trimmed = value.trim();
|
|
12
|
-
const bracketed = trimmed.match(/<([^>]+)>/);
|
|
13
|
-
return (bracketed ? bracketed[1] : trimmed).trim().toLowerCase();
|
|
14
|
-
};
|
|
15
|
-
|
|
9
|
+
export declare const normalizeListId: (value: string) => string;
|
|
16
10
|
/**
|
|
17
11
|
* The normalized `List-Id` header value of a parsed message, or `""` when the
|
|
18
12
|
* message carries no `List-Id`. Read from the raw header line so the exact
|
|
19
13
|
* value survives regardless of how the parser structures the header.
|
|
20
14
|
*/
|
|
21
|
-
export const extractListId
|
|
22
|
-
const line = parsed.headerLines.find(
|
|
23
|
-
(header) => header.key.toLowerCase() === "list-id",
|
|
24
|
-
);
|
|
25
|
-
if (!line) return "";
|
|
26
|
-
const colon = line.line.indexOf(":");
|
|
27
|
-
if (colon < 0) return "";
|
|
28
|
-
return normalizeListId(line.line.slice(colon + 1));
|
|
29
|
-
};
|
|
15
|
+
export declare const extractListId: (parsed: ParsedMail) => string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical form of a `List-Id` value for exact comparison: the bracketed
|
|
3
|
+
* identifier when the header carries the RFC 2919 `Name <list.id>` shape,
|
|
4
|
+
* otherwise the whole value, trimmed and case-folded. Both the stored copy and
|
|
5
|
+
* a `ListId` clause pass through this, so `<weekly.news.example.com>` and
|
|
6
|
+
* `weekly.news.example.com` are one list. An empty input normalizes to `""`.
|
|
7
|
+
*/
|
|
8
|
+
export const normalizeListId = (value) => {
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
const bracketed = trimmed.match(/<([^>]+)>/);
|
|
11
|
+
return (bracketed ? bracketed[1] : trimmed).trim().toLowerCase();
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The normalized `List-Id` header value of a parsed message, or `""` when the
|
|
15
|
+
* message carries no `List-Id`. Read from the raw header line so the exact
|
|
16
|
+
* value survives regardless of how the parser structures the header.
|
|
17
|
+
*/
|
|
18
|
+
export const extractListId = (parsed) => {
|
|
19
|
+
const line = parsed.headerLines.find((header) => header.key.toLowerCase() === "list-id");
|
|
20
|
+
if (!line)
|
|
21
|
+
return "";
|
|
22
|
+
const colon = line.line.indexOf(":");
|
|
23
|
+
if (colon < 0)
|
|
24
|
+
return "";
|
|
25
|
+
return normalizeListId(line.line.slice(colon + 1));
|
|
26
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type FilterItem, type ThreadMessageFieldTerm } from "@remit/data-ports";
|
|
2
|
+
type FilterClause = FilterItem["literalClauses"][number];
|
|
3
|
+
/**
|
|
4
|
+
* The `"None"` sentinel a filter's action fields carry when that action is
|
|
5
|
+
* absent (RFC 034 Decision 3.1) — `actionLabelId`/`actionMailboxId` are never
|
|
6
|
+
* empty/optional strings, so a missing action is this exact value, never `""`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const NO_ACTION = "None";
|
|
9
|
+
/**
|
|
10
|
+
* The message fields a filter evaluates against — the literal-clause targets
|
|
11
|
+
* (from / subject / body / list). A plain value object so the matcher stays independent of the `ParsedMail` shape and is
|
|
12
|
+
* trivially constructed in a test.
|
|
13
|
+
*/
|
|
14
|
+
export interface FilterMessage {
|
|
15
|
+
from: string;
|
|
16
|
+
fromName: string;
|
|
17
|
+
subject: string;
|
|
18
|
+
text: string;
|
|
19
|
+
/** Normalized `List-Id` header value (see `normalizeListId`); `""` when absent. */
|
|
20
|
+
listId: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether one literal clause matches the message. From matches against the
|
|
24
|
+
* sender address and display name; Subject against the subject; HasWords
|
|
25
|
+
* requires every word somewhere in sender, subject or body; ListId against the exact normalized `List-Id`; FromDomain
|
|
26
|
+
* against the sender's registrable domain. An empty clause value never matches.
|
|
27
|
+
*/
|
|
28
|
+
export declare const clauseMatches: (clause: FilterClause, msg: FilterMessage) => boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the literal clauses match under their combine operator. Empty clauses
|
|
31
|
+
* carry no literal constraint and pass vacuously; a caller that needs a
|
|
32
|
+
* predicate checks for clauses itself.
|
|
33
|
+
*/
|
|
34
|
+
export declare const literalClausesMatch: (clauses: readonly FilterClause[], operator: FilterItem["matchOperator"], msg: FilterMessage) => boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The terms to query with, and how to combine them. Empty terms narrow nothing
|
|
37
|
+
* — the caller reads the whole corpus and refines it.
|
|
38
|
+
*/
|
|
39
|
+
export interface LiteralClauseNarrowing {
|
|
40
|
+
terms: ThreadMessageFieldTerm[];
|
|
41
|
+
operator: "and" | "or";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The store-side half of {@link literalClausesMatch}: the column terms a query
|
|
45
|
+
* can evaluate on behalf of these clauses, or `null` when they can match
|
|
46
|
+
* nothing at all and there is no query worth running.
|
|
47
|
+
*
|
|
48
|
+
* A term is deliberately WIDER than the clause it stands for — a `FromDomain`
|
|
49
|
+
* clause narrows to the registrable domain appearing anywhere in the sender,
|
|
50
|
+
* so `github.com.evil.example` still comes back — and a `HasWords` clause
|
|
51
|
+
* cannot be narrowed at all, since no row column carries the body. The rows a
|
|
52
|
+
* term selects are candidates; `literalClausesMatch` still decides. Pushing the
|
|
53
|
+
* terms down is what makes the caller's bound a bound on RESULTS: filtering a
|
|
54
|
+
* date-ordered page instead answers "matches among the newest N messages"
|
|
55
|
+
* (#459).
|
|
56
|
+
*/
|
|
57
|
+
export declare const literalClauseTerms: (clauses: readonly FilterClause[], operator: FilterItem["matchOperator"]) => LiteralClauseNarrowing | null;
|
|
58
|
+
/**
|
|
59
|
+
* The move a message ends in when several filters matched: the filter whose
|
|
60
|
+
* predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
|
|
61
|
+
* tie-broken on `filterId` for the unreachable identical-timestamp case.
|
|
62
|
+
* `actionChangedAt` — not `ruleChangedAt` — is the signal: `ruleChangedAt` also
|
|
63
|
+
* bumps on a scope/expiry-only edit (reader #266), which changes a filter's
|
|
64
|
+
* lifecycle, not what it matches or does, and must not reorder exclusive-move
|
|
65
|
+
* precedence (reader #384). Nor is it `updatedAt`, so a cosmetic rename never
|
|
66
|
+
* flips an exclusive move either.
|
|
67
|
+
*/
|
|
68
|
+
export declare const selectMoveWinner: (candidates: readonly FilterItem[]) => FilterItem | undefined;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { FILTER_NO_ACTION, } from "@remit/data-ports";
|
|
2
|
+
import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
|
|
3
|
+
import { getDomain } from "tldts";
|
|
4
|
+
import { normalizeListId } from "./list-id.js";
|
|
5
|
+
/**
|
|
6
|
+
* The `"None"` sentinel a filter's action fields carry when that action is
|
|
7
|
+
* absent (RFC 034 Decision 3.1) — `actionLabelId`/`actionMailboxId` are never
|
|
8
|
+
* empty/optional strings, so a missing action is this exact value, never `""`.
|
|
9
|
+
*/
|
|
10
|
+
export const NO_ACTION = FILTER_NO_ACTION;
|
|
11
|
+
const includesFold = (haystack, needle) => haystack.toLowerCase().includes(needle.toLowerCase());
|
|
12
|
+
/**
|
|
13
|
+
* Whether every whitespace-separated word of `value` appears somewhere in the
|
|
14
|
+
* sender address, sender name, subject or body — the shape free-text search
|
|
15
|
+
* matches with (`buildSearchConditions`), so a filter made from a search keeps
|
|
16
|
+
* the mail that search showed.
|
|
17
|
+
*/
|
|
18
|
+
const hasEveryWord = (value, msg) => {
|
|
19
|
+
const fields = [msg.from, msg.fromName, msg.subject, msg.text];
|
|
20
|
+
return value
|
|
21
|
+
.split(/\s+/)
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.every((word) => fields.some((field) => includesFold(field, word)));
|
|
24
|
+
};
|
|
25
|
+
const hostOf = (address) => {
|
|
26
|
+
const at = address.lastIndexOf("@");
|
|
27
|
+
return at >= 0 ? address.slice(at + 1) : address;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The registrable, public-suffix-aware domain of an email address or host, or
|
|
31
|
+
* `null` when none resolves. `getDomain` folds case and applies the ICANN
|
|
32
|
+
* suffix list, so `github.com.evil.example` yields `evil.example`, never
|
|
33
|
+
* `github.com` — a `FromDomain` clause cannot be spoofed by a crafted subdomain.
|
|
34
|
+
*/
|
|
35
|
+
const registrableDomain = (addressOrHost) => getDomain(hostOf(addressOrHost.trim()));
|
|
36
|
+
/**
|
|
37
|
+
* Whether one literal clause matches the message. From matches against the
|
|
38
|
+
* sender address and display name; Subject against the subject; HasWords
|
|
39
|
+
* requires every word somewhere in sender, subject or body; ListId against the exact normalized `List-Id`; FromDomain
|
|
40
|
+
* against the sender's registrable domain. An empty clause value never matches.
|
|
41
|
+
*/
|
|
42
|
+
export const clauseMatches = (clause, msg) => {
|
|
43
|
+
const value = clause.value.trim();
|
|
44
|
+
if (value === "")
|
|
45
|
+
return false;
|
|
46
|
+
switch (clause.field) {
|
|
47
|
+
case FilterClauseField.From:
|
|
48
|
+
return includesFold(msg.from, value) || includesFold(msg.fromName, value);
|
|
49
|
+
case FilterClauseField.Subject:
|
|
50
|
+
return includesFold(msg.subject, value);
|
|
51
|
+
case FilterClauseField.HasWords:
|
|
52
|
+
return hasEveryWord(value, msg);
|
|
53
|
+
case FilterClauseField.ListId: {
|
|
54
|
+
const target = normalizeListId(value);
|
|
55
|
+
return target !== "" && normalizeListId(msg.listId) === target;
|
|
56
|
+
}
|
|
57
|
+
case FilterClauseField.FromDomain: {
|
|
58
|
+
const target = registrableDomain(value);
|
|
59
|
+
if (target === null)
|
|
60
|
+
return false;
|
|
61
|
+
return registrableDomain(msg.from) === target;
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Whether the literal clauses match under their combine operator. Empty clauses
|
|
69
|
+
* carry no literal constraint and pass vacuously; a caller that needs a
|
|
70
|
+
* predicate checks for clauses itself.
|
|
71
|
+
*/
|
|
72
|
+
export const literalClausesMatch = (clauses, operator, msg) => {
|
|
73
|
+
if (clauses.length === 0)
|
|
74
|
+
return true;
|
|
75
|
+
return operator === FilterMatchOperator.Or
|
|
76
|
+
? clauses.some((clause) => clauseMatches(clause, msg))
|
|
77
|
+
: clauses.every((clause) => clauseMatches(clause, msg));
|
|
78
|
+
};
|
|
79
|
+
const clauseNarrowing = (clause) => {
|
|
80
|
+
const value = clause.value.trim();
|
|
81
|
+
if (value === "")
|
|
82
|
+
return { kind: "NeverMatches" };
|
|
83
|
+
switch (clause.field) {
|
|
84
|
+
case FilterClauseField.From:
|
|
85
|
+
return { kind: "Term", term: { field: "sender", contains: value } };
|
|
86
|
+
case FilterClauseField.Subject:
|
|
87
|
+
return { kind: "Term", term: { field: "subject", contains: value } };
|
|
88
|
+
case FilterClauseField.ListId: {
|
|
89
|
+
const target = normalizeListId(value);
|
|
90
|
+
if (target === "")
|
|
91
|
+
return { kind: "NeverMatches" };
|
|
92
|
+
return { kind: "Term", term: { field: "listId", contains: target } };
|
|
93
|
+
}
|
|
94
|
+
case FilterClauseField.FromDomain: {
|
|
95
|
+
const target = registrableDomain(value);
|
|
96
|
+
if (target === null)
|
|
97
|
+
return { kind: "NeverMatches" };
|
|
98
|
+
return { kind: "Term", term: { field: "sender", contains: target } };
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
return { kind: "Unnarrowable" };
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The store-side half of {@link literalClausesMatch}: the column terms a query
|
|
106
|
+
* can evaluate on behalf of these clauses, or `null` when they can match
|
|
107
|
+
* nothing at all and there is no query worth running.
|
|
108
|
+
*
|
|
109
|
+
* A term is deliberately WIDER than the clause it stands for — a `FromDomain`
|
|
110
|
+
* clause narrows to the registrable domain appearing anywhere in the sender,
|
|
111
|
+
* so `github.com.evil.example` still comes back — and a `HasWords` clause
|
|
112
|
+
* cannot be narrowed at all, since no row column carries the body. The rows a
|
|
113
|
+
* term selects are candidates; `literalClausesMatch` still decides. Pushing the
|
|
114
|
+
* terms down is what makes the caller's bound a bound on RESULTS: filtering a
|
|
115
|
+
* date-ordered page instead answers "matches among the newest N messages"
|
|
116
|
+
* (#459).
|
|
117
|
+
*/
|
|
118
|
+
export const literalClauseTerms = (clauses, operator) => {
|
|
119
|
+
const combine = operator === FilterMatchOperator.Or ? "or" : "and";
|
|
120
|
+
if (clauses.length === 0)
|
|
121
|
+
return { terms: [], operator: combine };
|
|
122
|
+
const narrowings = clauses.map(clauseNarrowing);
|
|
123
|
+
const terms = narrowings.flatMap((narrowing) => narrowing.kind === "Term" ? [narrowing.term] : []);
|
|
124
|
+
if (combine === "and") {
|
|
125
|
+
if (narrowings.some((narrowing) => narrowing.kind === "NeverMatches")) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return { terms, operator: "and" };
|
|
129
|
+
}
|
|
130
|
+
if (narrowings.every((narrowing) => narrowing.kind === "NeverMatches")) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (narrowings.some((narrowing) => narrowing.kind === "Unnarrowable")) {
|
|
134
|
+
return { terms: [], operator: "or" };
|
|
135
|
+
}
|
|
136
|
+
return { terms, operator: "or" };
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* The move a message ends in when several filters matched: the filter whose
|
|
140
|
+
* predicate or action was most recently *changed* wins (RFC 034 Decision 3.2),
|
|
141
|
+
* tie-broken on `filterId` for the unreachable identical-timestamp case.
|
|
142
|
+
* `actionChangedAt` — not `ruleChangedAt` — is the signal: `ruleChangedAt` also
|
|
143
|
+
* bumps on a scope/expiry-only edit (reader #266), which changes a filter's
|
|
144
|
+
* lifecycle, not what it matches or does, and must not reorder exclusive-move
|
|
145
|
+
* precedence (reader #384). Nor is it `updatedAt`, so a cosmetic rename never
|
|
146
|
+
* flips an exclusive move either.
|
|
147
|
+
*/
|
|
148
|
+
export const selectMoveWinner = (candidates) => {
|
|
149
|
+
let winner;
|
|
150
|
+
for (const candidate of candidates) {
|
|
151
|
+
if (!winner) {
|
|
152
|
+
winner = candidate;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (candidate.actionChangedAt > winner.actionChangedAt) {
|
|
156
|
+
winner = candidate;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (candidate.actionChangedAt === winner.actionChangedAt &&
|
|
160
|
+
candidate.filterId > winner.filterId) {
|
|
161
|
+
winner = candidate;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return winner;
|
|
165
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { IFilterRepository, IMailboxRepository, IMessageLabelRepository } from "@remit/data-ports";
|
|
2
|
+
import type { PlacementMoveService } from "../placement-move.js";
|
|
3
|
+
import { type FilterMessage } from "./match.js";
|
|
4
|
+
export interface FilterLogger {
|
|
5
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
6
|
+
debug?(obj: Record<string, unknown>, msg: string): void;
|
|
7
|
+
warn?(obj: Record<string, unknown>, msg: string): void;
|
|
8
|
+
error?(obj: Record<string, unknown>, msg: string): void;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Dependencies for the index-time filter pass (RFC 034). Optional on
|
|
12
|
+
* {@link BodySyncService} the same way {@link PlacementConfig} is — a deployment
|
|
13
|
+
* without it simply runs no filter matching.
|
|
14
|
+
*/
|
|
15
|
+
export interface FilterConfig {
|
|
16
|
+
filterService: IFilterRepository;
|
|
17
|
+
messageLabelService: IMessageLabelRepository;
|
|
18
|
+
/** Names the account a filter's folder belongs to. */
|
|
19
|
+
mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
|
|
20
|
+
placementMoveService: PlacementMoveService;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What the matched filters resolve to for one message: every matching filter's
|
|
24
|
+
* label (additive — RFC 034 Decision 3.1), and at most one move (exclusive — the
|
|
25
|
+
* most-recently-changed matching filter, Decision 3.2).
|
|
26
|
+
*/
|
|
27
|
+
export interface FilterDecision {
|
|
28
|
+
labels: Array<{
|
|
29
|
+
labelId: string;
|
|
30
|
+
filterId: string;
|
|
31
|
+
}>;
|
|
32
|
+
move?: {
|
|
33
|
+
destinationMailboxId: string;
|
|
34
|
+
filterId: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Evaluates a synced message against the account's active filters and resolves
|
|
39
|
+
* the actions to apply (RFC 034). Reads only — listing filters and refreshing
|
|
40
|
+
* lazy expiry; the caller applies the
|
|
41
|
+
* resulting {@link FilterDecision}.
|
|
42
|
+
*
|
|
43
|
+
* `evaluate` isolates every failure in this read/decision phase from body sync,
|
|
44
|
+
* matching the placement-resolution precedent (#1246): a schema-drifted query
|
|
45
|
+
* must never fail the surrounding message store, since the body is
|
|
46
|
+
* already durable by the time this runs. The apply of the decision — label
|
|
47
|
+
* writes and the move enqueue — is the caller's responsibility and is *not*
|
|
48
|
+
* swallowed there, so a genuine DDB/SQS write failure still propagates and
|
|
49
|
+
* requeues the message.
|
|
50
|
+
*/
|
|
51
|
+
export declare class FilterPipeline {
|
|
52
|
+
private readonly config;
|
|
53
|
+
private readonly log;
|
|
54
|
+
constructor(config: FilterConfig, log: FilterLogger);
|
|
55
|
+
evaluate(accountConfigId: string, accountId: string, messageId: string, msg: FilterMessage): Promise<FilterDecision>;
|
|
56
|
+
/**
|
|
57
|
+
* A filter sits on the account config and sees every account's mail, but its
|
|
58
|
+
* folder is one account's: mail of another account is not this filter's to
|
|
59
|
+
* act on, exactly as if it had not matched. A folder no account holds any
|
|
60
|
+
* more turns the filter off, with the reason the filter list shows.
|
|
61
|
+
*/
|
|
62
|
+
private appliesTo;
|
|
63
|
+
private match;
|
|
64
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
import { FilterDisabledReason, FilterState } from "@remit/domain-enums";
|
|
3
|
+
import { literalClausesMatch, NO_ACTION, selectMoveWinner, } from "./match.js";
|
|
4
|
+
const EMPTY_DECISION = { labels: [] };
|
|
5
|
+
/**
|
|
6
|
+
* Evaluates a synced message against the account's active filters and resolves
|
|
7
|
+
* the actions to apply (RFC 034). Reads only — listing filters and refreshing
|
|
8
|
+
* lazy expiry; the caller applies the
|
|
9
|
+
* resulting {@link FilterDecision}.
|
|
10
|
+
*
|
|
11
|
+
* `evaluate` isolates every failure in this read/decision phase from body sync,
|
|
12
|
+
* matching the placement-resolution precedent (#1246): a schema-drifted query
|
|
13
|
+
* must never fail the surrounding message store, since the body is
|
|
14
|
+
* already durable by the time this runs. The apply of the decision — label
|
|
15
|
+
* writes and the move enqueue — is the caller's responsibility and is *not*
|
|
16
|
+
* swallowed there, so a genuine DDB/SQS write failure still propagates and
|
|
17
|
+
* requeues the message.
|
|
18
|
+
*/
|
|
19
|
+
export class FilterPipeline {
|
|
20
|
+
config;
|
|
21
|
+
log;
|
|
22
|
+
constructor(config, log) {
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.log = log;
|
|
25
|
+
}
|
|
26
|
+
async evaluate(accountConfigId, accountId, messageId, msg) {
|
|
27
|
+
return this.match(accountConfigId, accountId, msg).catch((error) => {
|
|
28
|
+
this.log.error?.({
|
|
29
|
+
alert: "body_sync_filter_match_failed",
|
|
30
|
+
messageId,
|
|
31
|
+
accountConfigId,
|
|
32
|
+
errorName: error?.name,
|
|
33
|
+
error: inspect(error),
|
|
34
|
+
}, "Filter matching failed; body already stored, continuing without filter actions (best-effort, non-fatal)");
|
|
35
|
+
return EMPTY_DECISION;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A filter sits on the account config and sees every account's mail, but its
|
|
40
|
+
* folder is one account's: mail of another account is not this filter's to
|
|
41
|
+
* act on, exactly as if it had not matched. A folder no account holds any
|
|
42
|
+
* more turns the filter off, with the reason the filter list shows.
|
|
43
|
+
*/
|
|
44
|
+
async appliesTo(accountConfigId, accountId, filter) {
|
|
45
|
+
if (filter.actionMailboxId === NO_ACTION)
|
|
46
|
+
return true;
|
|
47
|
+
const owner = await this.config.mailboxService.resolveAccountId(filter.actionMailboxId);
|
|
48
|
+
if (owner !== null)
|
|
49
|
+
return owner === accountId;
|
|
50
|
+
await this.config.filterService.update(accountConfigId, filter.filterId, {
|
|
51
|
+
state: FilterState.Disabled,
|
|
52
|
+
disabledReason: FilterDisabledReason.FolderMissing,
|
|
53
|
+
});
|
|
54
|
+
this.log.warn?.({ filterId: filter.filterId, accountConfigId }, "Filter turned off: the folder it moves mail into no longer exists");
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
async match(accountConfigId, accountId, msg) {
|
|
58
|
+
const active = await this.config.filterService.listByAccountAndState(accountConfigId, FilterState.Active);
|
|
59
|
+
if (active.length === 0)
|
|
60
|
+
return EMPTY_DECISION;
|
|
61
|
+
const matched = [];
|
|
62
|
+
for (const filter of active) {
|
|
63
|
+
// Lazy expiry (RFC 034 Decision 1.2): reading a Temporary filter past
|
|
64
|
+
// its expiresAt patches it to Expired on this read and drops it from
|
|
65
|
+
// evaluation, independent of whether the TTL delete has run.
|
|
66
|
+
const usable = await this.config.filterService.refreshExpiry(filter);
|
|
67
|
+
if (usable.state !== FilterState.Active)
|
|
68
|
+
continue;
|
|
69
|
+
if (!(await this.appliesTo(accountConfigId, accountId, usable)))
|
|
70
|
+
continue;
|
|
71
|
+
if (filterMatches(usable, msg))
|
|
72
|
+
matched.push(usable);
|
|
73
|
+
}
|
|
74
|
+
if (matched.length === 0)
|
|
75
|
+
return EMPTY_DECISION;
|
|
76
|
+
const labels = [];
|
|
77
|
+
const seenLabels = new Set();
|
|
78
|
+
for (const filter of matched) {
|
|
79
|
+
if (filter.actionLabelId === NO_ACTION)
|
|
80
|
+
continue;
|
|
81
|
+
if (seenLabels.has(filter.actionLabelId))
|
|
82
|
+
continue;
|
|
83
|
+
seenLabels.add(filter.actionLabelId);
|
|
84
|
+
labels.push({ labelId: filter.actionLabelId, filterId: filter.filterId });
|
|
85
|
+
}
|
|
86
|
+
const winner = selectMoveWinner(matched.filter((filter) => filter.actionMailboxId !== NO_ACTION));
|
|
87
|
+
return {
|
|
88
|
+
labels,
|
|
89
|
+
...(winner
|
|
90
|
+
? {
|
|
91
|
+
move: {
|
|
92
|
+
destinationMailboxId: winner.actionMailboxId,
|
|
93
|
+
filterId: winner.filterId,
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
: {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const filterMatches = (filter, msg) => literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
|
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
import type { IMessageRepository, MessageItem } from "@remit/data-ports";
|
|
2
2
|
import type { FlagPushLogger } from "./flag-push.js";
|
|
3
|
-
import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
4
3
|
import type { IImapConnection } from "./types.js";
|
|
5
|
-
|
|
6
4
|
export interface ResolveExhaustedFlagPushDeps {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
messageService: Pick<IMessageRepository, "deleteIfPlacement">;
|
|
6
|
+
markerService: {
|
|
7
|
+
delete(messageId: string, flagName: string): Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
log: FlagPushLogger;
|
|
10
10
|
}
|
|
11
|
-
|
|
12
11
|
export interface ResolveExhaustedFlagPushInput {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
/** Where the row stood when the caller read it; the delete holds only while it still does. */
|
|
13
|
+
placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
|
|
14
|
+
accountId: string;
|
|
15
|
+
accountConfigId: string;
|
|
16
|
+
messageId: string;
|
|
17
|
+
flagName: string;
|
|
18
|
+
uid: number;
|
|
19
|
+
mailboxPath: string;
|
|
20
|
+
getConnection: () => Promise<IImapConnection>;
|
|
22
21
|
}
|
|
23
|
-
|
|
24
22
|
export type FlagPushTerminalOutcome = "reconciled" | "broken";
|
|
25
|
-
|
|
26
23
|
export interface ResolveExhaustedFlagPushResult {
|
|
27
|
-
|
|
24
|
+
outcome: FlagPushTerminalOutcome;
|
|
28
25
|
}
|
|
29
|
-
|
|
30
26
|
/**
|
|
31
27
|
* Resolve a FLAG_PUSH failure that has exhausted the delivering queue's
|
|
32
28
|
* redelivery budget into exactly one of two terminal outcomes, mirroring
|
|
@@ -59,56 +55,4 @@ export interface ResolveExhaustedFlagPushResult {
|
|
|
59
55
|
* mail, so the cost is paid deliberately: a stale alert is recoverable, a
|
|
60
56
|
* deleted message is not.
|
|
61
57
|
*/
|
|
62
|
-
export const resolveExhaustedFlagPushFailure
|
|
63
|
-
deps: ResolveExhaustedFlagPushDeps,
|
|
64
|
-
input: ResolveExhaustedFlagPushInput,
|
|
65
|
-
): Promise<ResolveExhaustedFlagPushResult> => {
|
|
66
|
-
const {
|
|
67
|
-
accountId,
|
|
68
|
-
accountConfigId,
|
|
69
|
-
messageId,
|
|
70
|
-
flagName,
|
|
71
|
-
uid,
|
|
72
|
-
mailboxPath,
|
|
73
|
-
getConnection,
|
|
74
|
-
} = input;
|
|
75
|
-
|
|
76
|
-
const connection = await getConnection();
|
|
77
|
-
await connection.openBox(mailboxPath, true);
|
|
78
|
-
|
|
79
|
-
if (await isMessageGoneFromOpenMailbox(connection, uid)) {
|
|
80
|
-
await deps.markerService.delete(messageId, flagName);
|
|
81
|
-
const reconciled = await deps.messageService.deleteIfPlacement(
|
|
82
|
-
messageId,
|
|
83
|
-
input.placement,
|
|
84
|
-
);
|
|
85
|
-
deps.log.info(
|
|
86
|
-
{
|
|
87
|
-
metric: "flag_push_stale_row_reconciled",
|
|
88
|
-
accountId,
|
|
89
|
-
accountConfigId,
|
|
90
|
-
messageId,
|
|
91
|
-
flagName,
|
|
92
|
-
uid,
|
|
93
|
-
mailboxPath,
|
|
94
|
-
reconciled,
|
|
95
|
-
},
|
|
96
|
-
"Message no longer at its flag-push mailbox after retry exhaustion (external delete); marker dropped, stale row reconciled",
|
|
97
|
-
);
|
|
98
|
-
return { outcome: "reconciled" };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
deps.log.error(
|
|
102
|
-
{
|
|
103
|
-
alert: "flag_push_failed",
|
|
104
|
-
accountId,
|
|
105
|
-
accountConfigId,
|
|
106
|
-
messageId,
|
|
107
|
-
flagName,
|
|
108
|
-
uid,
|
|
109
|
-
mailboxPath,
|
|
110
|
-
},
|
|
111
|
-
"Flag push could not be pushed to IMAP after retry exhaustion; message still exists — marker left pending for operator investigation",
|
|
112
|
-
);
|
|
113
|
-
return { outcome: "broken" };
|
|
114
|
-
};
|
|
58
|
+
export declare const resolveExhaustedFlagPushFailure: (deps: ResolveExhaustedFlagPushDeps, input: ResolveExhaustedFlagPushInput) => Promise<ResolveExhaustedFlagPushResult>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a FLAG_PUSH failure that has exhausted the delivering queue's
|
|
4
|
+
* redelivery budget into exactly one of two terminal outcomes, mirroring
|
|
5
|
+
* `resolveExhaustedPlacementMoveFailure` (#1289) and
|
|
6
|
+
* `resolveExhaustedBodySyncFailures` (#1270) for the same failure taxonomy
|
|
7
|
+
* (epic #1281 invariant 3) — no third, softer outcome.
|
|
8
|
+
*
|
|
9
|
+
* 1. RECONCILED (expected) — the message no longer exists at its mailbox on
|
|
10
|
+
* IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather than by a
|
|
11
|
+
* FETCH coming back empty. Per invariant 2, an external delete supersedes
|
|
12
|
+
* the marker entirely: the marker is dropped and the stale
|
|
13
|
+
* Message/ThreadMessage rows are deleted via `deleteIfPlacement`.
|
|
14
|
+
* Metric only, no alarm — routine.
|
|
15
|
+
* 2. BROKEN — the message still exists, but the flag push keeps failing.
|
|
16
|
+
* Broken code or a broken account, not a transient blip. The marker is
|
|
17
|
+
* left in place (not cleared) — while pending, resync never reverts the
|
|
18
|
+
* local flag (invariant 3 of this issue's own spec), so leaving it does
|
|
19
|
+
* not risk correctness, and dropping it here would discard the only
|
|
20
|
+
* record that IMAP still owes this push. Logged with an `alert`-shaped
|
|
21
|
+
* entry for an operator alarm; never re-thrown (terminal — the caller acks
|
|
22
|
+
* either way, since retrying a stale or permanently-broken push can never
|
|
23
|
+
* succeed).
|
|
24
|
+
*
|
|
25
|
+
* An operator reading `flag_push_failed` should know one case where the
|
|
26
|
+
* message is not actually there: a message another client expunged
|
|
27
|
+
* mid-session can answer an empty FETCH while the server still lists its UID
|
|
28
|
+
* in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
|
|
29
|
+
* lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
|
|
30
|
+
* alert stands until someone clears it. The reverse mistake deletes live
|
|
31
|
+
* mail, so the cost is paid deliberately: a stale alert is recoverable, a
|
|
32
|
+
* deleted message is not.
|
|
33
|
+
*/
|
|
34
|
+
export const resolveExhaustedFlagPushFailure = async (deps, input) => {
|
|
35
|
+
const { accountId, accountConfigId, messageId, flagName, uid, mailboxPath, getConnection, } = input;
|
|
36
|
+
const connection = await getConnection();
|
|
37
|
+
await connection.openBox(mailboxPath, true);
|
|
38
|
+
if (await isMessageGoneFromOpenMailbox(connection, uid)) {
|
|
39
|
+
await deps.markerService.delete(messageId, flagName);
|
|
40
|
+
const reconciled = await deps.messageService.deleteIfPlacement(messageId, input.placement);
|
|
41
|
+
deps.log.info({
|
|
42
|
+
metric: "flag_push_stale_row_reconciled",
|
|
43
|
+
accountId,
|
|
44
|
+
accountConfigId,
|
|
45
|
+
messageId,
|
|
46
|
+
flagName,
|
|
47
|
+
uid,
|
|
48
|
+
mailboxPath,
|
|
49
|
+
reconciled,
|
|
50
|
+
}, "Message no longer at its flag-push mailbox after retry exhaustion (external delete); marker dropped, stale row reconciled");
|
|
51
|
+
return { outcome: "reconciled" };
|
|
52
|
+
}
|
|
53
|
+
deps.log.error({
|
|
54
|
+
alert: "flag_push_failed",
|
|
55
|
+
accountId,
|
|
56
|
+
accountConfigId,
|
|
57
|
+
messageId,
|
|
58
|
+
flagName,
|
|
59
|
+
uid,
|
|
60
|
+
mailboxPath,
|
|
61
|
+
}, "Flag push could not be pushed to IMAP after retry exhaustion; message still exists — marker left pending for operator investigation");
|
|
62
|
+
return { outcome: "broken" };
|
|
63
|
+
};
|