@remit/mailbox-service 0.0.111 → 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} +19 -77
- 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} +39 -63
- 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} +19 -50
- 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 -101
- 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 -73
- package/src/account-credentials.ts +0 -210
- package/src/adversarial-mime.e2e.test.ts +0 -307
- 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 -360
- 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-queue.test.ts +0 -124
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-terminal.test.ts +0 -310
- package/src/body-sync-terminal.ts +0 -176
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/body-sync.ts +0 -1970
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -615
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -205
- 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 -369
- package/src/external-move.ts +0 -272
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/filter-config.test.ts +0 -229
- package/src/filters/filter-config.ts +0 -69
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -429
- package/src/filters/match.ts +0 -274
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/filters/pipeline.test.ts +0 -330
- package/src/filters/pipeline.ts +0 -284
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-push.test.ts +0 -408
- package/src/flag-push.ts +0 -198
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/flag-queue.ts +0 -509
- package/src/folder-placement.ts +0 -56
- 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 -134
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/imapflow-connection.test.ts +0 -469
- package/src/imapflow-connection.ts +0 -1475
- package/src/index.ts +0 -350
- package/src/list-id-backfill.test.ts +0 -573
- 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 -249
- package/src/mailbox-delete-intent.test.ts +0 -246
- package/src/mailbox-management.test.ts +0 -356
- package/src/mailbox-management.ts +0 -839
- package/src/mailbox-presence.test.ts +0 -72
- package/src/mailbox-queue.ts +0 -415
- package/src/mailbox-rename-intent.test.ts +0 -389
- package/src/mailbox-rename-subtree.test.ts +0 -664
- package/src/mailbox-sync.test.ts +0 -848
- package/src/mailbox-sync.ts +0 -724
- 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-move.ts +0 -1348
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- 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-flags.test.ts +0 -158
- 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/message-sync.ts +0 -1963
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -305
- 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 -738
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -370
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/outbox-queue.test.ts +0 -291
- package/src/outbox-queue.ts +0 -508
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-terminal.ts +0 -148
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-move.ts +0 -336
- package/src/placement-settled.test.ts +0 -147
- package/src/placement-transition-race.test.ts +0 -482
- package/src/quarantine.test.ts +0 -191
- package/src/quarantine.ts +0 -228
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -801
- package/src/spam-report.ts +0 -357
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- 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/folder-roles.ts +0 -66
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- 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/types/mailparser-augment.d.ts +0 -15
- 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/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/tsconfig.json +0 -8
package/src/spam-report.test.ts
DELETED
|
@@ -1,801 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
AddressFlags,
|
|
5
|
-
IAccountRepository,
|
|
6
|
-
IAddressRepository,
|
|
7
|
-
IMailboxRepository,
|
|
8
|
-
IMailboxSpecialUseRepository,
|
|
9
|
-
IMessageFlagPushRepository,
|
|
10
|
-
IMessageRepository,
|
|
11
|
-
IThreadMessageRepository,
|
|
12
|
-
} from "@remit/data-ports";
|
|
13
|
-
import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
|
|
14
|
-
import { deriveAddressId } from "@remit/data-ports/id";
|
|
15
|
-
import { AddressRole } from "@remit/domain-enums";
|
|
16
|
-
import { FlagPushService } from "./flag-push.js";
|
|
17
|
-
import { MessageMoveService } from "./message-move.js";
|
|
18
|
-
import {
|
|
19
|
-
MoveNotSettledError,
|
|
20
|
-
NoJunkMailboxError,
|
|
21
|
-
SpamReportService,
|
|
22
|
-
} from "./spam-report.js";
|
|
23
|
-
import { NO_JUNK_ROLES, trashRole } from "./test-helpers/folder-roles.js";
|
|
24
|
-
|
|
25
|
-
const ACCOUNT = "acc-1";
|
|
26
|
-
const ACCOUNT_CONFIG = "cfg-1";
|
|
27
|
-
const ACCOUNT_EMAIL = "me@example.com";
|
|
28
|
-
const INBOX_MAILBOX = "mbx-inbox";
|
|
29
|
-
const JUNK_MAILBOX = "mbx-junk";
|
|
30
|
-
const MESSAGE_ID = "msg-1";
|
|
31
|
-
const SENDER_EMAIL = "sender@example.com";
|
|
32
|
-
const ADDRESS_ID = deriveAddressId(ACCOUNT_CONFIG, SENDER_EMAIL);
|
|
33
|
-
const THREAD_ID = "thread-1";
|
|
34
|
-
|
|
35
|
-
interface ThreadRow {
|
|
36
|
-
accountConfigId: string;
|
|
37
|
-
threadMessageId: string;
|
|
38
|
-
threadId: string;
|
|
39
|
-
messageId: string;
|
|
40
|
-
mailboxId: string;
|
|
41
|
-
messageIdHeader: string;
|
|
42
|
-
isRead?: boolean;
|
|
43
|
-
hasStars?: boolean;
|
|
44
|
-
hasAttachment?: boolean;
|
|
45
|
-
isDeleted?: boolean;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface World {
|
|
49
|
-
service: SpamReportService;
|
|
50
|
-
messages: Map<string, Record<string, unknown>>;
|
|
51
|
-
addresses: Map<string, { flags: AddressFlags }>;
|
|
52
|
-
threadRows: ThreadRow[];
|
|
53
|
-
sent: unknown[];
|
|
54
|
-
sqsImpl: { send: (command: unknown) => Promise<unknown> };
|
|
55
|
-
markerPuts: Array<Record<string, unknown>>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const settleMove = (messages: Map<string, Record<string, unknown>>) => {
|
|
59
|
-
const m = messages.get(MESSAGE_ID);
|
|
60
|
-
assert.ok(m !== undefined);
|
|
61
|
-
m.status = "active";
|
|
62
|
-
m.syncStatus = "synced";
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const buildWorld = (
|
|
66
|
-
opts: {
|
|
67
|
-
startMailbox?: string;
|
|
68
|
-
fromEmail?: string;
|
|
69
|
-
originalMailboxId?: string;
|
|
70
|
-
/** Whether the sender was ever harvested into an `address` row. */
|
|
71
|
-
harvestedSender?: boolean;
|
|
72
|
-
/** Flags already standing on the harvested sender's row. */
|
|
73
|
-
senderFlags?: Record<string, unknown>;
|
|
74
|
-
junkMailbox?: { mailboxId: string; fullPath: string } | null;
|
|
75
|
-
} = {},
|
|
76
|
-
): World => {
|
|
77
|
-
const startMailbox = opts.startMailbox ?? INBOX_MAILBOX;
|
|
78
|
-
const fromEmail = opts.fromEmail ?? SENDER_EMAIL;
|
|
79
|
-
const fromAddressId = deriveAddressId(ACCOUNT_CONFIG, fromEmail);
|
|
80
|
-
const harvestedSender = opts.harvestedSender ?? true;
|
|
81
|
-
const junkMailbox =
|
|
82
|
-
opts.junkMailbox === undefined
|
|
83
|
-
? { mailboxId: JUNK_MAILBOX, fullPath: "Junk" }
|
|
84
|
-
: opts.junkMailbox;
|
|
85
|
-
|
|
86
|
-
const messages = new Map<string, Record<string, unknown>>([
|
|
87
|
-
[
|
|
88
|
-
MESSAGE_ID,
|
|
89
|
-
{
|
|
90
|
-
messageId: MESSAGE_ID,
|
|
91
|
-
mailboxId: startMailbox,
|
|
92
|
-
uid: 42,
|
|
93
|
-
rfc822Size: 100,
|
|
94
|
-
internalDate: 1_700_000_000_000,
|
|
95
|
-
envelopeId: "env-1",
|
|
96
|
-
rootBodyPartId: "body-1",
|
|
97
|
-
category: "primary",
|
|
98
|
-
hasListUnsubscribe: false,
|
|
99
|
-
syncStatus: "synced",
|
|
100
|
-
...(opts.originalMailboxId
|
|
101
|
-
? { originalMailboxId: opts.originalMailboxId }
|
|
102
|
-
: {}),
|
|
103
|
-
},
|
|
104
|
-
],
|
|
105
|
-
]);
|
|
106
|
-
|
|
107
|
-
const addresses = new Map<string, { flags: AddressFlags }>(
|
|
108
|
-
harvestedSender
|
|
109
|
-
? [[fromAddressId, { flags: (opts.senderFlags ?? {}) as AddressFlags }]]
|
|
110
|
-
: [],
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
const threadRows: ThreadRow[] = [
|
|
114
|
-
{
|
|
115
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
116
|
-
threadMessageId: `tm:${THREAD_ID}::${MESSAGE_ID}`,
|
|
117
|
-
threadId: THREAD_ID,
|
|
118
|
-
messageId: MESSAGE_ID,
|
|
119
|
-
mailboxId: INBOX_MAILBOX,
|
|
120
|
-
messageIdHeader: "<abc@example.com>",
|
|
121
|
-
isRead: false,
|
|
122
|
-
hasStars: false,
|
|
123
|
-
},
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
const mailboxes = new Map<string, Record<string, unknown>>([
|
|
127
|
-
[
|
|
128
|
-
INBOX_MAILBOX,
|
|
129
|
-
{ mailboxId: INBOX_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
130
|
-
],
|
|
131
|
-
[
|
|
132
|
-
JUNK_MAILBOX,
|
|
133
|
-
{ mailboxId: JUNK_MAILBOX, fullPath: "Junk", accountId: ACCOUNT },
|
|
134
|
-
],
|
|
135
|
-
]);
|
|
136
|
-
|
|
137
|
-
const messageService = {
|
|
138
|
-
get: async (id: string | string[]) => {
|
|
139
|
-
if (Array.isArray(id)) {
|
|
140
|
-
return id.map((i) => messages.get(i)).filter(Boolean);
|
|
141
|
-
}
|
|
142
|
-
const m = messages.get(id);
|
|
143
|
-
if (!m) throw new Error(`no message ${id}`);
|
|
144
|
-
return m;
|
|
145
|
-
},
|
|
146
|
-
describe: async (id: string) => {
|
|
147
|
-
const m = messages.get(id);
|
|
148
|
-
if (!m) throw new Error(`no message ${id}`);
|
|
149
|
-
return {
|
|
150
|
-
message: [m],
|
|
151
|
-
messageFlag: [],
|
|
152
|
-
envelope: [],
|
|
153
|
-
messageReference: [],
|
|
154
|
-
envelopeAddress: [
|
|
155
|
-
{
|
|
156
|
-
envelopeAddressId: "ea-1",
|
|
157
|
-
messageId: id,
|
|
158
|
-
addressId: fromAddressId,
|
|
159
|
-
displayName: "Spammy Sender",
|
|
160
|
-
normalizedEmail: fromEmail,
|
|
161
|
-
addressRole: AddressRole.From,
|
|
162
|
-
addressOrder: 0,
|
|
163
|
-
},
|
|
164
|
-
],
|
|
165
|
-
bodyPart: [],
|
|
166
|
-
bodyPartParameter: [],
|
|
167
|
-
rawMessageStorage: [],
|
|
168
|
-
bodyPartStorage: [],
|
|
169
|
-
bodyPartContent: [],
|
|
170
|
-
};
|
|
171
|
-
},
|
|
172
|
-
update: async (id: string, patch: Record<string, unknown>) => {
|
|
173
|
-
const m = messages.get(id);
|
|
174
|
-
if (m) Object.assign(m, patch);
|
|
175
|
-
return m;
|
|
176
|
-
},
|
|
177
|
-
transitionPlacement: async (
|
|
178
|
-
id: string,
|
|
179
|
-
_expected: Record<string, unknown>,
|
|
180
|
-
patch: Record<string, unknown>,
|
|
181
|
-
) => {
|
|
182
|
-
const m = messages.get(id);
|
|
183
|
-
if (!m) return undefined;
|
|
184
|
-
for (const [field, value] of Object.entries(patch)) {
|
|
185
|
-
if (value === null) {
|
|
186
|
-
delete m[field as keyof typeof m];
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
Object.assign(m, { [field]: value });
|
|
190
|
-
}
|
|
191
|
-
return m;
|
|
192
|
-
},
|
|
193
|
-
clearSpamReport: async (id: string) => {
|
|
194
|
-
const m = messages.get(id);
|
|
195
|
-
if (m) delete m.spamReport;
|
|
196
|
-
return m;
|
|
197
|
-
},
|
|
198
|
-
} as unknown as IMessageRepository;
|
|
199
|
-
|
|
200
|
-
const addressService = {
|
|
201
|
-
getAddress: async (_accountConfigId: string, addressIds: string[]) =>
|
|
202
|
-
addressIds
|
|
203
|
-
.filter((id) => addresses.has(id))
|
|
204
|
-
.map((id) => ({ addressId: id, ...addresses.get(id) })),
|
|
205
|
-
// Deliberately destructive on conflict, mirroring the real repo's
|
|
206
|
-
// `onConflictDoUpdate`: a caller that upserts over a row it did not
|
|
207
|
-
// create loses what stood on it.
|
|
208
|
-
upsertAddress: async (input: { addressId: string }) => {
|
|
209
|
-
addresses.set(input.addressId, { flags: {} });
|
|
210
|
-
return { ...input, flags: {} };
|
|
211
|
-
},
|
|
212
|
-
reconcileJunkOnlyForMessage: async () => {},
|
|
213
|
-
mergeFlags: async (
|
|
214
|
-
_accountConfigId: string,
|
|
215
|
-
addressId: string,
|
|
216
|
-
patch: Record<string, unknown>,
|
|
217
|
-
) => {
|
|
218
|
-
const entry = addresses.get(addressId);
|
|
219
|
-
if (!entry) throw new Error(`no address ${addressId}`);
|
|
220
|
-
const next = { ...entry.flags } as Record<string, unknown>;
|
|
221
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
222
|
-
if (value === undefined) continue;
|
|
223
|
-
if (value === null) {
|
|
224
|
-
delete next[key];
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
next[key] = value;
|
|
228
|
-
}
|
|
229
|
-
entry.flags = next as AddressFlags;
|
|
230
|
-
return { addressId, flags: entry.flags };
|
|
231
|
-
},
|
|
232
|
-
} as unknown as IAddressRepository;
|
|
233
|
-
|
|
234
|
-
const accountService = {
|
|
235
|
-
get: async () => ({ accountId: ACCOUNT, email: ACCOUNT_EMAIL }),
|
|
236
|
-
} as unknown as IAccountRepository;
|
|
237
|
-
|
|
238
|
-
const mailboxSpecialUseService = {
|
|
239
|
-
findJunkMailbox: async () => junkMailbox,
|
|
240
|
-
findTrashMailbox: async () => null,
|
|
241
|
-
resolveTrashRole: async () => trashRole(null),
|
|
242
|
-
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
243
|
-
} as unknown as IMailboxSpecialUseRepository;
|
|
244
|
-
|
|
245
|
-
const mailboxService = {
|
|
246
|
-
get: async (_acc: string, id: string | string[]) => {
|
|
247
|
-
if (Array.isArray(id)) {
|
|
248
|
-
return id.map((i) => mailboxes.get(i)).filter(Boolean);
|
|
249
|
-
}
|
|
250
|
-
return mailboxes.get(id);
|
|
251
|
-
},
|
|
252
|
-
} as unknown as IMailboxRepository;
|
|
253
|
-
|
|
254
|
-
const threadMessageService = {
|
|
255
|
-
getByMessageId: async (_cfg: string, messageId: string) => {
|
|
256
|
-
const row = threadRows.find((r) => r.messageId === messageId);
|
|
257
|
-
if (!row) throw new Error(`no thread message ${messageId}`);
|
|
258
|
-
return row;
|
|
259
|
-
},
|
|
260
|
-
update: async (
|
|
261
|
-
_cfg: string,
|
|
262
|
-
threadMessageId: string,
|
|
263
|
-
patch: Record<string, unknown>,
|
|
264
|
-
) => {
|
|
265
|
-
const row = threadRows.find((r) => r.threadMessageId === threadMessageId);
|
|
266
|
-
if (row) Object.assign(row, patch);
|
|
267
|
-
return row;
|
|
268
|
-
},
|
|
269
|
-
} as unknown as IThreadMessageRepository;
|
|
270
|
-
|
|
271
|
-
const sent: unknown[] = [];
|
|
272
|
-
const sqsImpl = {
|
|
273
|
-
send: async (command: unknown) => {
|
|
274
|
-
sent.push(command);
|
|
275
|
-
return {};
|
|
276
|
-
},
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
const messageMoveService = new MessageMoveService({
|
|
280
|
-
messageService,
|
|
281
|
-
mailboxService,
|
|
282
|
-
mailboxSpecialUseService,
|
|
283
|
-
threadMessageService,
|
|
284
|
-
addressService,
|
|
285
|
-
sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
|
|
286
|
-
moveSettleTimeoutMs: 30,
|
|
287
|
-
moveSettlePollMs: 5,
|
|
288
|
-
});
|
|
289
|
-
(
|
|
290
|
-
messageMoveService as unknown as {
|
|
291
|
-
sqs: { send: (c: unknown) => Promise<unknown> };
|
|
292
|
-
}
|
|
293
|
-
).sqs = sqsImpl;
|
|
294
|
-
|
|
295
|
-
const markerPuts: Array<Record<string, unknown>> = [];
|
|
296
|
-
const markerService: IMessageFlagPushRepository = {
|
|
297
|
-
put: async (input: Record<string, unknown>) => {
|
|
298
|
-
markerPuts.push(input);
|
|
299
|
-
return {
|
|
300
|
-
...input,
|
|
301
|
-
state: "pending",
|
|
302
|
-
createdAt: 1,
|
|
303
|
-
updatedAt: 1,
|
|
304
|
-
} as never;
|
|
305
|
-
},
|
|
306
|
-
find: async () => null,
|
|
307
|
-
updateState: async () => ({}) as never,
|
|
308
|
-
delete: async () => {},
|
|
309
|
-
listByAccountId: async () => [],
|
|
310
|
-
listByMailboxId: async () => [],
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
const flagPushService = new FlagPushService({
|
|
314
|
-
markerService,
|
|
315
|
-
sqsQueueUrl: "http://localhost:9324/000000000000/flag-push",
|
|
316
|
-
});
|
|
317
|
-
(
|
|
318
|
-
flagPushService as unknown as {
|
|
319
|
-
sqs: { send: (c: unknown) => Promise<unknown> };
|
|
320
|
-
}
|
|
321
|
-
).sqs = sqsImpl;
|
|
322
|
-
|
|
323
|
-
const service = new SpamReportService({
|
|
324
|
-
messageService,
|
|
325
|
-
addressService,
|
|
326
|
-
accountService,
|
|
327
|
-
mailboxSpecialUseService,
|
|
328
|
-
messageMoveService,
|
|
329
|
-
flagPushService,
|
|
330
|
-
// Small and fast: these tests simulate settlement explicitly (via
|
|
331
|
-
// settleMove) rather than waiting out a real timeout.
|
|
332
|
-
moveSettleTimeoutMs: 30,
|
|
333
|
-
moveSettlePollMs: 5,
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
return {
|
|
337
|
-
service,
|
|
338
|
-
messages,
|
|
339
|
-
addresses,
|
|
340
|
-
threadRows,
|
|
341
|
-
sent,
|
|
342
|
-
sqsImpl,
|
|
343
|
-
markerPuts,
|
|
344
|
-
};
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
const moveEvents = (sent: unknown[]) =>
|
|
348
|
-
sent.filter(
|
|
349
|
-
(cmd) =>
|
|
350
|
-
JSON.parse((cmd as { input: { MessageBody: string } }).input.MessageBody)
|
|
351
|
-
.type === "MESSAGE_MOVE",
|
|
352
|
-
);
|
|
353
|
-
|
|
354
|
-
describe("SpamReportService.reportSpam", () => {
|
|
355
|
-
it("sets the blocked flag and enqueues the move to Junk", async () => {
|
|
356
|
-
const { service, messages, addresses, sent, markerPuts } = buildWorld();
|
|
357
|
-
|
|
358
|
-
await service.reportSpam({
|
|
359
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
360
|
-
accountId: ACCOUNT,
|
|
361
|
-
messageId: MESSAGE_ID,
|
|
362
|
-
setBy: "user-1",
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
const address = addresses.get(ADDRESS_ID);
|
|
366
|
-
assert.equal(address?.flags.blocked?.value, true);
|
|
367
|
-
assert.equal(address?.flags.blocked?.setBy, "user-1");
|
|
368
|
-
|
|
369
|
-
const message = messages.get(MESSAGE_ID);
|
|
370
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX);
|
|
371
|
-
assert.equal(moveEvents(sent).length, 1);
|
|
372
|
-
|
|
373
|
-
assert.equal(markerPuts.length, 1);
|
|
374
|
-
assert.equal(markerPuts[0].flagName, "$Junk");
|
|
375
|
-
assert.equal(markerPuts[0].operation, "add");
|
|
376
|
-
|
|
377
|
-
assert.ok(message !== undefined);
|
|
378
|
-
const spamReport = message.spamReport as { reportedAt: number };
|
|
379
|
-
assert.ok(spamReport.reportedAt > 0);
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
it("leaves the blocked flag in place when the move fails", async () => {
|
|
383
|
-
const world = buildWorld();
|
|
384
|
-
world.sqsImpl.send = async () => {
|
|
385
|
-
throw new Error("SQS unavailable");
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
await assert.rejects(
|
|
389
|
-
() =>
|
|
390
|
-
world.service.reportSpam({
|
|
391
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
392
|
-
accountId: ACCOUNT,
|
|
393
|
-
messageId: MESSAGE_ID,
|
|
394
|
-
}),
|
|
395
|
-
/SQS unavailable/,
|
|
396
|
-
);
|
|
397
|
-
|
|
398
|
-
const address = world.addresses.get(ADDRESS_ID);
|
|
399
|
-
assert.equal(address?.flags.blocked?.value, true);
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it("is idempotent under a double press", async () => {
|
|
403
|
-
const { service, messages, sent } = buildWorld();
|
|
404
|
-
|
|
405
|
-
await service.reportSpam({
|
|
406
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
407
|
-
accountId: ACCOUNT,
|
|
408
|
-
messageId: MESSAGE_ID,
|
|
409
|
-
});
|
|
410
|
-
settleMove(messages);
|
|
411
|
-
await service.reportSpam({
|
|
412
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
413
|
-
accountId: ACCOUNT,
|
|
414
|
-
messageId: MESSAGE_ID,
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
// The second call's move is a no-op: MessageMoveService.moveMessage sees
|
|
418
|
-
// the local mailboxId already equals Junk and skips without enqueueing.
|
|
419
|
-
// The skip is read off the settled row — a press inside the first move's
|
|
420
|
-
// flight waits for it rather than answering from the folder that move
|
|
421
|
-
// wrote optimistically (#665).
|
|
422
|
-
assert.equal(moveEvents(sent).length, 1);
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
it("refuses a second press inside the first move's flight", async () => {
|
|
426
|
-
// No settleMove between the presses: the row names Junk while still
|
|
427
|
-
// carrying the inbox's uid, so acting on it would address whatever Junk
|
|
428
|
-
// holds at that uid — somebody else's message (#665).
|
|
429
|
-
const { service, sent } = buildWorld();
|
|
430
|
-
|
|
431
|
-
await service.reportSpam({
|
|
432
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
433
|
-
accountId: ACCOUNT,
|
|
434
|
-
messageId: MESSAGE_ID,
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
await assert.rejects(
|
|
438
|
-
() =>
|
|
439
|
-
service.reportSpam({
|
|
440
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
441
|
-
accountId: ACCOUNT,
|
|
442
|
-
messageId: MESSAGE_ID,
|
|
443
|
-
}),
|
|
444
|
-
(error: unknown) =>
|
|
445
|
-
error instanceof MessagePlacementUnsettledError &&
|
|
446
|
-
error.statusCode === 409 &&
|
|
447
|
-
error.publicApiError?.details?.reason === "in_flight",
|
|
448
|
-
);
|
|
449
|
-
|
|
450
|
-
assert.equal(moveEvents(sent).length, 1);
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
it("moves the message but writes no sender block when the message is forged from the account's own address", async () => {
|
|
454
|
-
const { service, messages, addresses, sent } = buildWorld({
|
|
455
|
-
fromEmail: ACCOUNT_EMAIL,
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
await service.reportSpam({
|
|
459
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
460
|
-
accountId: ACCOUNT,
|
|
461
|
-
messageId: MESSAGE_ID,
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
const address = addresses.get(ADDRESS_ID);
|
|
465
|
-
assert.equal(address?.flags.blocked, undefined);
|
|
466
|
-
|
|
467
|
-
const message = messages.get(MESSAGE_ID);
|
|
468
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX);
|
|
469
|
-
assert.equal(moveEvents(sent).length, 1);
|
|
470
|
-
assert.ok(message !== undefined);
|
|
471
|
-
assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it("refuses to mint a row for a sender address carrying no @ at all", async () => {
|
|
475
|
-
// slice() around a lastIndexOf of -1 splits "no-at-sign" into a
|
|
476
|
-
// plausible-looking local part and domain, so a length check alone
|
|
477
|
-
// waves the one input it exists to catch straight through into a
|
|
478
|
-
// corrupt address row.
|
|
479
|
-
const { service, addresses } = buildWorld({
|
|
480
|
-
harvestedSender: false,
|
|
481
|
-
fromEmail: "no-at-sign",
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
await assert.rejects(
|
|
485
|
-
() =>
|
|
486
|
-
service.reportSpam({
|
|
487
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
488
|
-
accountId: ACCOUNT,
|
|
489
|
-
messageId: MESSAGE_ID,
|
|
490
|
-
}),
|
|
491
|
-
/not a usable email address/,
|
|
492
|
-
);
|
|
493
|
-
|
|
494
|
-
assert.equal(addresses.size, 0);
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
it("reports a message whose sender was never harvested into an address row", async () => {
|
|
498
|
-
// The row is what carries the blocked flag, and harvesting is what
|
|
499
|
-
// ordinarily writes it. When it is absent, blocking the sender used to
|
|
500
|
-
// throw NotFoundError and take the whole report down with it — the
|
|
501
|
-
// message never reached Junk and the user got a retry that could not
|
|
502
|
-
// work (test.remit.email, 18 Aug 2026).
|
|
503
|
-
const { service, messages, addresses, sent } = buildWorld({
|
|
504
|
-
harvestedSender: false,
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
await service.reportSpam({
|
|
508
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
509
|
-
accountId: ACCOUNT,
|
|
510
|
-
messageId: MESSAGE_ID,
|
|
511
|
-
setBy: "user-1",
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
const address = addresses.get(ADDRESS_ID);
|
|
515
|
-
assert.equal(address?.flags.blocked?.value, true);
|
|
516
|
-
|
|
517
|
-
const message = messages.get(MESSAGE_ID);
|
|
518
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX);
|
|
519
|
-
assert.equal(moveEvents(sent).length, 1);
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
it("keeps the junkOnly mark that withholds the sender from autocomplete", async () => {
|
|
523
|
-
// Report spam adds the block; it is not a sighting of the sender and
|
|
524
|
-
// must not carry an upsert's on-conflict behaviour onto a row it did
|
|
525
|
-
// not create. The mark that withholds a spammer from autocomplete (#822)
|
|
526
|
-
// lives in these same flags, and clearing it here would put the spammer
|
|
527
|
-
// back in the compose picker — the opposite of what the button means.
|
|
528
|
-
const { service, addresses } = buildWorld({
|
|
529
|
-
senderFlags: {
|
|
530
|
-
junkOnly: { value: true, setAt: 1, setBy: "junk-harvest" },
|
|
531
|
-
},
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
await service.reportSpam({
|
|
535
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
536
|
-
accountId: ACCOUNT,
|
|
537
|
-
messageId: MESSAGE_ID,
|
|
538
|
-
setBy: "user-1",
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
const flags = addresses.get(ADDRESS_ID)?.flags as Record<string, unknown>;
|
|
542
|
-
assert.equal((flags.blocked as { value: boolean }).value, true);
|
|
543
|
-
assert.deepEqual(flags.junkOnly, {
|
|
544
|
-
value: true,
|
|
545
|
-
setAt: 1,
|
|
546
|
-
setBy: "junk-harvest",
|
|
547
|
-
});
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
it("names the missing Junk folder and changes nothing when the account has none", async () => {
|
|
551
|
-
const { service, messages, addresses, sent, markerPuts } = buildWorld({
|
|
552
|
-
junkMailbox: null,
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
await assert.rejects(
|
|
556
|
-
() =>
|
|
557
|
-
service.reportSpam({
|
|
558
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
559
|
-
accountId: ACCOUNT,
|
|
560
|
-
messageId: MESSAGE_ID,
|
|
561
|
-
}),
|
|
562
|
-
(error: unknown) =>
|
|
563
|
-
error instanceof NoJunkMailboxError &&
|
|
564
|
-
/no Junk folder/.test(error.message) &&
|
|
565
|
-
/Create one/.test(error.message),
|
|
566
|
-
);
|
|
567
|
-
|
|
568
|
-
// Nothing half-applied: no sender blocked, no report stamp on a message
|
|
569
|
-
// still sitting where it was, no move, no keyword marker.
|
|
570
|
-
assert.equal(addresses.get(ADDRESS_ID)?.flags.blocked, undefined);
|
|
571
|
-
const message = messages.get(MESSAGE_ID);
|
|
572
|
-
assert.equal(message?.spamReport, undefined);
|
|
573
|
-
assert.equal(message?.mailboxId, INBOX_MAILBOX);
|
|
574
|
-
assert.equal(moveEvents(sent).length, 0);
|
|
575
|
-
assert.equal(markerPuts.length, 0);
|
|
576
|
-
});
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
describe("SpamReportService.notSpam", () => {
|
|
580
|
-
it("mints no address row for a sender that was never harvested", async () => {
|
|
581
|
-
// A sender with no row has no block to lift, so the flag write is
|
|
582
|
-
// already true. Creating the row to write it would put an address
|
|
583
|
-
// nothing ever harvested into the address book, where autocomplete
|
|
584
|
-
// would then offer it.
|
|
585
|
-
const { service, messages, addresses } = buildWorld({
|
|
586
|
-
harvestedSender: false,
|
|
587
|
-
startMailbox: JUNK_MAILBOX,
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
await service.notSpam({
|
|
591
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
592
|
-
accountId: ACCOUNT,
|
|
593
|
-
messageId: MESSAGE_ID,
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
assert.equal(addresses.size, 0);
|
|
597
|
-
assert.equal(messages.get(MESSAGE_ID)?.spamReport, undefined);
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
it("restores the original mailbox and clears the flag without setting trust", async () => {
|
|
601
|
-
const { service, messages, addresses } = buildWorld();
|
|
602
|
-
|
|
603
|
-
await service.reportSpam({
|
|
604
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
605
|
-
accountId: ACCOUNT,
|
|
606
|
-
messageId: MESSAGE_ID,
|
|
607
|
-
});
|
|
608
|
-
settleMove(messages);
|
|
609
|
-
await service.notSpam({
|
|
610
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
611
|
-
accountId: ACCOUNT,
|
|
612
|
-
messageId: MESSAGE_ID,
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
const message = messages.get(MESSAGE_ID);
|
|
616
|
-
assert.equal(message?.mailboxId, INBOX_MAILBOX);
|
|
617
|
-
assert.equal(message?.spamReport, undefined);
|
|
618
|
-
|
|
619
|
-
const address = addresses.get(ADDRESS_ID);
|
|
620
|
-
assert.equal(address?.flags.blocked, undefined);
|
|
621
|
-
assert.equal(address?.flags.wellknown, undefined);
|
|
622
|
-
assert.equal(address?.flags.trusted, undefined);
|
|
623
|
-
assert.equal(address?.flags.vip, undefined);
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
it("clears the block and provenance without a 500 when the message never actually moved (same-mailbox no-op)", async () => {
|
|
627
|
-
// report-spam pressed on a message the provider's filter already placed
|
|
628
|
-
// in Junk: MessageMoveService.moveMessage's same-mailbox guard skips, so
|
|
629
|
-
// originalMailboxId is never set — status never becomes "moving" either,
|
|
630
|
-
// so notSpam has nothing to wait on.
|
|
631
|
-
const { service, messages, addresses } = buildWorld({
|
|
632
|
-
startMailbox: JUNK_MAILBOX,
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
await service.reportSpam({
|
|
636
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
637
|
-
accountId: ACCOUNT,
|
|
638
|
-
messageId: MESSAGE_ID,
|
|
639
|
-
});
|
|
640
|
-
await service.notSpam({
|
|
641
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
642
|
-
accountId: ACCOUNT,
|
|
643
|
-
messageId: MESSAGE_ID,
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
const message = messages.get(MESSAGE_ID);
|
|
647
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX, "left where it is");
|
|
648
|
-
assert.equal(message?.spamReport, undefined);
|
|
649
|
-
|
|
650
|
-
const address = addresses.get(ADDRESS_ID);
|
|
651
|
-
assert.equal(address?.flags.blocked, undefined);
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
it("clears a stale originalMailboxId left by an earlier, unrelated move instead of restoring to it", async () => {
|
|
655
|
-
// The message is already in Junk (an earlier, unrelated move put it
|
|
656
|
-
// there) and still carries that move's originalMailboxId. report-spam's
|
|
657
|
-
// own move is a same-mailbox no-op — moveMessage never touches
|
|
658
|
-
// originalMailboxId — so without an explicit clear, notSpam would
|
|
659
|
-
// restore to a folder this report-spam action never moved it out of.
|
|
660
|
-
const OTHER_MAILBOX = "mbx-other";
|
|
661
|
-
const { service, messages, addresses } = buildWorld({
|
|
662
|
-
startMailbox: JUNK_MAILBOX,
|
|
663
|
-
originalMailboxId: OTHER_MAILBOX,
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
await service.reportSpam({
|
|
667
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
668
|
-
accountId: ACCOUNT,
|
|
669
|
-
messageId: MESSAGE_ID,
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
assert.equal(messages.get(MESSAGE_ID)?.originalMailboxId, undefined);
|
|
673
|
-
|
|
674
|
-
await service.notSpam({
|
|
675
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
676
|
-
accountId: ACCOUNT,
|
|
677
|
-
messageId: MESSAGE_ID,
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
const message = messages.get(MESSAGE_ID);
|
|
681
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX, "left where it is");
|
|
682
|
-
assert.notEqual(message?.mailboxId, OTHER_MAILBOX);
|
|
683
|
-
|
|
684
|
-
const address = addresses.get(ADDRESS_ID);
|
|
685
|
-
assert.equal(address?.flags.blocked, undefined);
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
it("does not clear originalMailboxId set by an earlier report-spam press — a second report then Undo still restores", async () => {
|
|
689
|
-
// The most common reason a message is already in Junk when reportSpam
|
|
690
|
-
// runs is a PREVIOUS reportSpam press, not some unrelated move — and
|
|
691
|
-
// that earlier press is exactly what owns the originalMailboxId Undo
|
|
692
|
-
// needs. A second press must not treat its own prior work as stale.
|
|
693
|
-
const { service, messages, sent } = buildWorld();
|
|
694
|
-
|
|
695
|
-
await service.reportSpam({
|
|
696
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
697
|
-
accountId: ACCOUNT,
|
|
698
|
-
messageId: MESSAGE_ID,
|
|
699
|
-
});
|
|
700
|
-
settleMove(messages);
|
|
701
|
-
|
|
702
|
-
await service.reportSpam({
|
|
703
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
704
|
-
accountId: ACCOUNT,
|
|
705
|
-
messageId: MESSAGE_ID,
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
assert.equal(
|
|
709
|
-
messages.get(MESSAGE_ID)?.originalMailboxId,
|
|
710
|
-
INBOX_MAILBOX,
|
|
711
|
-
"the second press must not destroy the first press's originalMailboxId",
|
|
712
|
-
);
|
|
713
|
-
|
|
714
|
-
await service.notSpam({
|
|
715
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
716
|
-
accountId: ACCOUNT,
|
|
717
|
-
messageId: MESSAGE_ID,
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
const message = messages.get(MESSAGE_ID);
|
|
721
|
-
assert.equal(
|
|
722
|
-
message?.mailboxId,
|
|
723
|
-
INBOX_MAILBOX,
|
|
724
|
-
"undo must actually restore the message, not leave it stuck in Junk",
|
|
725
|
-
);
|
|
726
|
-
// INBOX->Junk (press 1), Junk->INBOX (undo) — press 2 was a same-mailbox
|
|
727
|
-
// no-op and must not have enqueued a move of its own.
|
|
728
|
-
assert.equal(moveEvents(sent).length, 2);
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
it("is idempotent under a double press — a second undo does not re-junk the message", async () => {
|
|
732
|
-
const { service, messages, sent } = buildWorld();
|
|
733
|
-
|
|
734
|
-
await service.reportSpam({
|
|
735
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
736
|
-
accountId: ACCOUNT,
|
|
737
|
-
messageId: MESSAGE_ID,
|
|
738
|
-
});
|
|
739
|
-
settleMove(messages);
|
|
740
|
-
await service.notSpam({
|
|
741
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
742
|
-
accountId: ACCOUNT,
|
|
743
|
-
messageId: MESSAGE_ID,
|
|
744
|
-
});
|
|
745
|
-
// originalMailboxId is already cleared, so the second call has nothing
|
|
746
|
-
// to wait on or restore — no need to settle again.
|
|
747
|
-
await service.notSpam({
|
|
748
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
749
|
-
accountId: ACCOUNT,
|
|
750
|
-
messageId: MESSAGE_ID,
|
|
751
|
-
});
|
|
752
|
-
|
|
753
|
-
const message = messages.get(MESSAGE_ID);
|
|
754
|
-
assert.equal(message?.mailboxId, INBOX_MAILBOX);
|
|
755
|
-
assert.equal(message?.originalMailboxId, undefined);
|
|
756
|
-
// INBOX->Junk (report), Junk->INBOX (undo #1) — undo #2 must not add a
|
|
757
|
-
// third INBOX->Junk move.
|
|
758
|
-
assert.equal(moveEvents(sent).length, 2);
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
it("throws without restoring or clearing provenance when the move has not settled yet (R2 wait)", async () => {
|
|
762
|
-
const { service, messages, addresses } = buildWorld();
|
|
763
|
-
|
|
764
|
-
await service.reportSpam({
|
|
765
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
766
|
-
accountId: ACCOUNT,
|
|
767
|
-
messageId: MESSAGE_ID,
|
|
768
|
-
});
|
|
769
|
-
// Deliberately NOT settled: status stays "moving", as it would while
|
|
770
|
-
// the original MESSAGE_MOVE is still genuinely in flight or retrying.
|
|
771
|
-
|
|
772
|
-
await assert.rejects(
|
|
773
|
-
() =>
|
|
774
|
-
service.notSpam({
|
|
775
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
776
|
-
accountId: ACCOUNT,
|
|
777
|
-
messageId: MESSAGE_ID,
|
|
778
|
-
}),
|
|
779
|
-
(error: unknown) => {
|
|
780
|
-
// Must be the dedicated type, not a plain Error — callers that
|
|
781
|
-
// surface failures to the user (packages/backend) allowlist this
|
|
782
|
-
// specific type rather than relaying an arbitrary message.
|
|
783
|
-
assert.ok(error instanceof MoveNotSettledError);
|
|
784
|
-
assert.match(error.message, /has not settled yet/);
|
|
785
|
-
return true;
|
|
786
|
-
},
|
|
787
|
-
);
|
|
788
|
-
|
|
789
|
-
// The sender block clear is independent of the move (R2 reconcile) and
|
|
790
|
-
// still lands even though the restore did not.
|
|
791
|
-
const address = addresses.get(ADDRESS_ID);
|
|
792
|
-
assert.equal(address?.flags.blocked, undefined);
|
|
793
|
-
|
|
794
|
-
// But nothing move-related was touched — a real move #2 must not be
|
|
795
|
-
// enqueued against an unsettled move #1.
|
|
796
|
-
const message = messages.get(MESSAGE_ID);
|
|
797
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX);
|
|
798
|
-
assert.ok(message !== undefined);
|
|
799
|
-
assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
|
|
800
|
-
});
|
|
801
|
-
});
|