@remit/mailbox-service 0.0.111 → 0.0.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +156 -235
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +5 -4
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-message-gone.test.ts +0 -139
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
CreateAddressInput,
|
|
5
|
-
CreateEnvelopeAddressInput,
|
|
6
|
-
CreateThreadMessageInput,
|
|
7
|
-
IAddressRepository,
|
|
8
|
-
IEnvelopeRepository,
|
|
9
|
-
IMailboxRepository,
|
|
10
|
-
IMessageRepository,
|
|
11
|
-
IThreadMessageRepository,
|
|
12
|
-
MailboxItem,
|
|
13
|
-
MessageItem,
|
|
14
|
-
ThreadMessageItem,
|
|
15
|
-
} from "@remit/data-ports";
|
|
16
|
-
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
17
|
-
import {
|
|
18
|
-
type AccountFolderRoles,
|
|
19
|
-
addressSightingIn,
|
|
20
|
-
MessageSyncService,
|
|
21
|
-
} from "./message-sync.js";
|
|
22
|
-
import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
23
|
-
import type { ImapEnvelope, ImapMessage } from "./types.js";
|
|
24
|
-
|
|
25
|
-
const stub = <T>(): T => ({}) as T;
|
|
26
|
-
|
|
27
|
-
const envelope: ImapEnvelope = {
|
|
28
|
-
date: new Date(0).toISOString(),
|
|
29
|
-
messageId: "<root@example.com>",
|
|
30
|
-
subject: "Subject",
|
|
31
|
-
from: [{ name: "Pharma Deals", mailbox: "sales", host: "pharma.example" }],
|
|
32
|
-
sender: [],
|
|
33
|
-
replyTo: [],
|
|
34
|
-
to: [{ name: "", mailbox: "victim", host: "ischen.nl" }],
|
|
35
|
-
cc: [],
|
|
36
|
-
bcc: [],
|
|
37
|
-
inReplyTo: "",
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
interface Saved {
|
|
41
|
-
correspondents: CreateAddressInput[];
|
|
42
|
-
junk: CreateAddressInput[];
|
|
43
|
-
neutral: CreateAddressInput[];
|
|
44
|
-
envelopeAddresses: CreateEnvelopeAddressInput[];
|
|
45
|
-
reconciled: string[];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const mailboxAt = (fullPath: string): MailboxItem =>
|
|
49
|
-
({
|
|
50
|
-
mailboxId: `mbx-${fullPath}`,
|
|
51
|
-
fullPath,
|
|
52
|
-
hierarchyDelimiter: "/",
|
|
53
|
-
}) as MailboxItem;
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Save one message into `mailbox`, with the account holding `role` in that same
|
|
57
|
-
* folder. `undefined` is an account whose Junk and Trash are elsewhere.
|
|
58
|
-
*/
|
|
59
|
-
const save = async (
|
|
60
|
-
mailbox: MailboxItem,
|
|
61
|
-
role?: "Junk" | "Trash",
|
|
62
|
-
): Promise<Saved> => {
|
|
63
|
-
const saved: Saved = {
|
|
64
|
-
correspondents: [],
|
|
65
|
-
junk: [],
|
|
66
|
-
neutral: [],
|
|
67
|
-
envelopeAddresses: [],
|
|
68
|
-
reconciled: [],
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const messageService = {
|
|
72
|
-
upsertWithStatus: async (input: unknown) => ({
|
|
73
|
-
item: input as MessageItem,
|
|
74
|
-
created: true,
|
|
75
|
-
}),
|
|
76
|
-
} as unknown as IMessageRepository;
|
|
77
|
-
|
|
78
|
-
const threadMessageService = {
|
|
79
|
-
create: async (input: CreateThreadMessageInput) =>
|
|
80
|
-
input as unknown as ThreadMessageItem,
|
|
81
|
-
} as unknown as IThreadMessageRepository;
|
|
82
|
-
|
|
83
|
-
const envelopeService = {
|
|
84
|
-
upsertEnvelope: async () => {},
|
|
85
|
-
upsertBodyParts: async () => {},
|
|
86
|
-
} as unknown as IEnvelopeRepository;
|
|
87
|
-
|
|
88
|
-
const addressService = {
|
|
89
|
-
upsertCorrespondentAddress: async (input: CreateAddressInput) => {
|
|
90
|
-
saved.correspondents.push(input);
|
|
91
|
-
},
|
|
92
|
-
upsertJunkAddress: async (input: CreateAddressInput) => {
|
|
93
|
-
saved.junk.push(input);
|
|
94
|
-
},
|
|
95
|
-
upsertAddress: async (input: CreateAddressInput) => {
|
|
96
|
-
saved.neutral.push(input);
|
|
97
|
-
},
|
|
98
|
-
upsertEnvelopeAddress: async (input: CreateEnvelopeAddressInput) => {
|
|
99
|
-
saved.envelopeAddresses.push(input);
|
|
100
|
-
},
|
|
101
|
-
reconcileJunkOnlyForMessage: async (messageId: string) => {
|
|
102
|
-
saved.reconciled.push(messageId);
|
|
103
|
-
},
|
|
104
|
-
} as unknown as IAddressRepository;
|
|
105
|
-
|
|
106
|
-
const service = new MessageSyncService(
|
|
107
|
-
stub<ManagedConnectionFactory>(),
|
|
108
|
-
stub<IMailboxRepository>(),
|
|
109
|
-
folderRoles(
|
|
110
|
-
role === "Junk"
|
|
111
|
-
? { junkMailboxId: mailbox.mailboxId }
|
|
112
|
-
: role === "Trash"
|
|
113
|
-
? { trashMailboxId: mailbox.mailboxId }
|
|
114
|
-
: {},
|
|
115
|
-
),
|
|
116
|
-
messageService,
|
|
117
|
-
envelopeService,
|
|
118
|
-
addressService,
|
|
119
|
-
threadMessageService,
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
const msg = {
|
|
123
|
-
uid: 42,
|
|
124
|
-
seq: 1,
|
|
125
|
-
size: 100,
|
|
126
|
-
internalDate: new Date(0),
|
|
127
|
-
flags: [],
|
|
128
|
-
envelope,
|
|
129
|
-
} as unknown as ImapMessage;
|
|
130
|
-
|
|
131
|
-
await (
|
|
132
|
-
service as unknown as {
|
|
133
|
-
saveMessage: (
|
|
134
|
-
mailbox: MailboxItem,
|
|
135
|
-
accountId: string,
|
|
136
|
-
accountConfigId: string,
|
|
137
|
-
msg: ImapMessage,
|
|
138
|
-
roles: AccountFolderRoles,
|
|
139
|
-
) => Promise<unknown>;
|
|
140
|
-
}
|
|
141
|
-
).saveMessage(mailbox, "acct-1", "cfg-1", msg, {
|
|
142
|
-
junkMailboxId: role === "Junk" ? mailbox.mailboxId : null,
|
|
143
|
-
trashMailboxId: role === "Trash" ? mailbox.mailboxId : null,
|
|
144
|
-
configJunkRoles: NO_JUNK_ROLES,
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
return saved;
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const emails = (inputs: Array<{ normalizedEmail: string }>): string[] =>
|
|
151
|
-
inputs.map((input) => input.normalizedEmail);
|
|
152
|
-
|
|
153
|
-
const BOTH = ["sales@pharma.example", "victim@ischen.nl"];
|
|
154
|
-
|
|
155
|
-
describe("what a mailbox says about the addresses on its messages", () => {
|
|
156
|
-
const ROLES = (junk: string | null, trash: string | null) => ({
|
|
157
|
-
junkMailboxId: junk,
|
|
158
|
-
trashMailboxId: trash,
|
|
159
|
-
configJunkRoles: NO_JUNK_ROLES,
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("reads the folder the account appointed, not the folder’s name", () => {
|
|
163
|
-
// The account appointed `INBOX/Rubbish` as Junk. A second folder called
|
|
164
|
-
// `Spam` is an ordinary folder, and its senders are correspondents —
|
|
165
|
-
// a role belongs to one folder (RFC 032), so a name cannot claim it.
|
|
166
|
-
const roles = ROLES("mbx-rubbish", null);
|
|
167
|
-
assert.equal(addressSightingIn("mbx-rubbish", roles), "junk");
|
|
168
|
-
assert.equal(addressSightingIn("mbx-spam", roles), "correspondent");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("keeps a message in Trash from deciding either way", () => {
|
|
172
|
-
assert.equal(
|
|
173
|
-
addressSightingIn("mbx-trash", ROLES(null, "mbx-trash")),
|
|
174
|
-
"discarded",
|
|
175
|
-
);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("reads an account with neither role as all correspondents", () => {
|
|
179
|
-
assert.equal(
|
|
180
|
-
addressSightingIn("mbx-anything", ROLES(null, null)),
|
|
181
|
-
"correspondent",
|
|
182
|
-
);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("harvests every envelope address of an ordinary message", async () => {
|
|
186
|
-
const saved = await save(mailboxAt("INBOX"));
|
|
187
|
-
|
|
188
|
-
assert.deepEqual(emails(saved.correspondents), BOTH);
|
|
189
|
-
assert.equal(saved.junk.length, 0);
|
|
190
|
-
assert.equal(saved.neutral.length, 0);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("withholds every envelope address of a message in Junk", async () => {
|
|
194
|
-
const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
|
|
195
|
-
|
|
196
|
-
assert.deepEqual(emails(saved.junk), BOTH);
|
|
197
|
-
assert.equal(saved.correspondents.length, 0);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("still records the envelope a message in Junk renders", async () => {
|
|
201
|
-
const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
|
|
202
|
-
|
|
203
|
-
assert.deepEqual(emails(saved.envelopeAddresses), BOTH);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it("leaves the identity of an envelope address to the store", async () => {
|
|
207
|
-
const saved = await save(mailboxAt("INBOX"));
|
|
208
|
-
|
|
209
|
-
assert.ok(saved.envelopeAddresses.length > 0);
|
|
210
|
-
for (const envelopeAddress of saved.envelopeAddresses) {
|
|
211
|
-
assert.ok(!("envelopeAddressId" in envelopeAddress));
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("keeps a message in Trash from deciding either way", async () => {
|
|
216
|
-
const saved = await save(mailboxAt("Trash"), "Trash");
|
|
217
|
-
|
|
218
|
-
assert.deepEqual(emails(saved.neutral), BOTH);
|
|
219
|
-
assert.equal(saved.correspondents.length, 0);
|
|
220
|
-
assert.equal(saved.junk.length, 0);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("re-asks every sighting of a sender a message in Junk carries", async () => {
|
|
224
|
-
const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
|
|
225
|
-
|
|
226
|
-
assert.equal(saved.reconciled.length, 1);
|
|
227
|
-
assert.deepEqual(saved.reconciled, [saved.envelopeAddresses[0].messageId]);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("asks nothing of a sender met on live mail", async () => {
|
|
231
|
-
const inbox = await save(mailboxAt("INBOX"));
|
|
232
|
-
const trash = await save(mailboxAt("Trash"), "Trash");
|
|
233
|
-
|
|
234
|
-
assert.deepEqual(inbox.reconciled, []);
|
|
235
|
-
assert.deepEqual(trash.reconciled, []);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("harvests the same message once an ordinary folder holds it", async () => {
|
|
239
|
-
await save(mailboxAt("INBOX/Rubbish"), "Junk");
|
|
240
|
-
const moved = await save(mailboxAt("INBOX"));
|
|
241
|
-
|
|
242
|
-
assert.deepEqual(emails(moved.correspondents), BOTH);
|
|
243
|
-
});
|
|
244
|
-
});
|
|
@@ -1,416 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The ways an enumeration round used to let go of a message without a record of
|
|
3
|
-
* it (issue #72).
|
|
4
|
-
*
|
|
5
|
-
* A row carrying no ENVELOPE was counted as saved and the watermark moved past
|
|
6
|
-
* it; so was a UID the FETCH never returned a row for. Neither is attributable
|
|
7
|
-
* to the message — an absent envelope is indistinguishable from the FETCH row
|
|
8
|
-
* glitching — so neither is quarantined. Both are now failures the watermark
|
|
9
|
-
* must stop below, which is the only treatment that survives a gap in the
|
|
10
|
-
* middle of a batch.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import assert from "node:assert/strict";
|
|
14
|
-
import { describe, it } from "node:test";
|
|
15
|
-
import type {
|
|
16
|
-
IAddressRepository,
|
|
17
|
-
IEnvelopeRepository,
|
|
18
|
-
IMailboxRepository,
|
|
19
|
-
IMailboxSpecialUseRepository,
|
|
20
|
-
IMessageRepository,
|
|
21
|
-
IQuarantineRepository,
|
|
22
|
-
IThreadMessageRepository,
|
|
23
|
-
IUnitOfWork,
|
|
24
|
-
MailboxItem,
|
|
25
|
-
QuarantineItem,
|
|
26
|
-
QuarantineUpsertInput,
|
|
27
|
-
UpdateMailboxInput,
|
|
28
|
-
} from "@remit/data-ports";
|
|
29
|
-
import { MailboxCursorState } from "@remit/domain-enums";
|
|
30
|
-
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
31
|
-
import { MessageSyncService, selectUidsToSync } from "./message-sync.js";
|
|
32
|
-
import { QuarantineService } from "./quarantine.js";
|
|
33
|
-
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
34
|
-
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
35
|
-
|
|
36
|
-
const ACCOUNT_ID = "acc-1";
|
|
37
|
-
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
38
|
-
const MAILBOX_ID = "mbx-1";
|
|
39
|
-
const UID_VALIDITY = 100;
|
|
40
|
-
|
|
41
|
-
const buildMailbox = (over: Partial<MailboxItem> = {}): MailboxItem =>
|
|
42
|
-
({
|
|
43
|
-
mailboxId: MAILBOX_ID,
|
|
44
|
-
accountId: ACCOUNT_ID,
|
|
45
|
-
fullPath: "INBOX",
|
|
46
|
-
uidValidity: UID_VALIDITY,
|
|
47
|
-
lastSyncUid: 0,
|
|
48
|
-
highWaterMarkUid: 20,
|
|
49
|
-
highestModseq: "0",
|
|
50
|
-
cursorState: MailboxCursorState.normal,
|
|
51
|
-
...over,
|
|
52
|
-
}) as MailboxItem;
|
|
53
|
-
|
|
54
|
-
const withEnvelope = (uid: number): ImapMessage => ({
|
|
55
|
-
uid,
|
|
56
|
-
seq: uid,
|
|
57
|
-
flags: [],
|
|
58
|
-
internalDate: new Date("2026-01-01T00:00:00Z"),
|
|
59
|
-
size: 42,
|
|
60
|
-
envelope: {
|
|
61
|
-
date: "Thu, 01 Jan 2026 00:00:00 +0000",
|
|
62
|
-
subject: "Hello",
|
|
63
|
-
from: [{ mailbox: "sender", host: "example.com" }],
|
|
64
|
-
sender: [],
|
|
65
|
-
replyTo: [],
|
|
66
|
-
to: [{ mailbox: "me", host: "example.com" }],
|
|
67
|
-
cc: [],
|
|
68
|
-
bcc: [],
|
|
69
|
-
inReplyTo: "",
|
|
70
|
-
messageId: `<${uid}@example.com>`,
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const withoutEnvelope = (uid: number): ImapMessage => ({
|
|
75
|
-
uid,
|
|
76
|
-
seq: uid,
|
|
77
|
-
flags: [],
|
|
78
|
-
internalDate: new Date("2026-01-01T00:00:00Z"),
|
|
79
|
-
size: 42,
|
|
80
|
-
bodyStructure: { type: "text/plain", encoding: "7bit" },
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const buildHarness = (options: {
|
|
84
|
-
allUids: number[];
|
|
85
|
-
enumerated: ImapMessage[];
|
|
86
|
-
existing?: QuarantineItem[];
|
|
87
|
-
lastSyncUid?: number;
|
|
88
|
-
highWaterMarkUid?: number;
|
|
89
|
-
/** UIDs the cheap snapshot FETCH returns; defaults to `allUids`. */
|
|
90
|
-
snapshotUids?: number[];
|
|
91
|
-
/** Set to `cursor_invalid` to take the rebuild path. */
|
|
92
|
-
cursorState?: MailboxItem["cursorState"];
|
|
93
|
-
}) => {
|
|
94
|
-
const mailbox = buildMailbox({
|
|
95
|
-
...(options.lastSyncUid !== undefined
|
|
96
|
-
? { lastSyncUid: options.lastSyncUid }
|
|
97
|
-
: {}),
|
|
98
|
-
...(options.highWaterMarkUid !== undefined
|
|
99
|
-
? { highWaterMarkUid: options.highWaterMarkUid }
|
|
100
|
-
: {}),
|
|
101
|
-
...(options.cursorState ? { cursorState: options.cursorState } : {}),
|
|
102
|
-
});
|
|
103
|
-
const mailboxUpdates: UpdateMailboxInput[] = [];
|
|
104
|
-
const writes: QuarantineUpsertInput[] = [];
|
|
105
|
-
const fetched: number[][] = [];
|
|
106
|
-
|
|
107
|
-
const connection = {
|
|
108
|
-
openBox: async () => ({ uidvalidity: UID_VALIDITY, uidnext: 99 }),
|
|
109
|
-
getMailboxStatus: async () => ({
|
|
110
|
-
messages: 10,
|
|
111
|
-
recent: 0,
|
|
112
|
-
unseen: 1,
|
|
113
|
-
uidNext: 99,
|
|
114
|
-
uidValidity: UID_VALIDITY,
|
|
115
|
-
highestModseq: "600",
|
|
116
|
-
deletedCount: 0,
|
|
117
|
-
}),
|
|
118
|
-
supportsCondstore: () => false,
|
|
119
|
-
search: async () => options.allUids,
|
|
120
|
-
fetchEnvelopeSnapshots: async () =>
|
|
121
|
-
(options.snapshotUids ?? options.allUids).map((uid) => ({
|
|
122
|
-
uid,
|
|
123
|
-
messageId: `<${uid}@example.com>`,
|
|
124
|
-
internalDate: new Date("2026-01-01T00:00:00Z"),
|
|
125
|
-
})),
|
|
126
|
-
fetchMessages: async (uids: number[]) => {
|
|
127
|
-
fetched.push(uids);
|
|
128
|
-
return options.enumerated.filter((msg) => uids.includes(msg.uid));
|
|
129
|
-
},
|
|
130
|
-
} as unknown as IImapConnection;
|
|
131
|
-
|
|
132
|
-
const mailboxService = {
|
|
133
|
-
get: async () => mailbox,
|
|
134
|
-
update: async (_a: string, _m: string, input: UpdateMailboxInput) => {
|
|
135
|
-
mailboxUpdates.push(input);
|
|
136
|
-
return mailbox;
|
|
137
|
-
},
|
|
138
|
-
} as unknown as IMailboxRepository;
|
|
139
|
-
|
|
140
|
-
const threadMessageService = {
|
|
141
|
-
findByMessageId: async () => null,
|
|
142
|
-
findAllByMessageId: async () => [],
|
|
143
|
-
listByMailbox: async () => ({ items: [], continuationToken: undefined }),
|
|
144
|
-
create: async () => ({ threadMessageId: "tm-1" }),
|
|
145
|
-
update: async () => ({ threadMessageId: "tm-1" }),
|
|
146
|
-
} as unknown as IThreadMessageRepository;
|
|
147
|
-
|
|
148
|
-
const unitOfWork: IUnitOfWork = {
|
|
149
|
-
transaction: (fn) =>
|
|
150
|
-
fn({
|
|
151
|
-
message: {
|
|
152
|
-
upsertWithStatus: async (input: { mailboxId: string }) => ({
|
|
153
|
-
item: { mailboxId: input.mailboxId },
|
|
154
|
-
created: true,
|
|
155
|
-
}),
|
|
156
|
-
updateUid: async () => undefined,
|
|
157
|
-
} as unknown as IMessageRepository,
|
|
158
|
-
envelope: {
|
|
159
|
-
upsertEnvelope: async () => undefined,
|
|
160
|
-
upsertBodyParts: async () => undefined,
|
|
161
|
-
} as unknown as IEnvelopeRepository,
|
|
162
|
-
address: {
|
|
163
|
-
upsertCorrespondentAddress: async () => undefined,
|
|
164
|
-
upsertEnvelopeAddress: async () => undefined,
|
|
165
|
-
} as unknown as IAddressRepository,
|
|
166
|
-
threadMessage: threadMessageService,
|
|
167
|
-
}),
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const repository = {
|
|
171
|
-
listByAccountConfigId: async () => options.existing ?? [],
|
|
172
|
-
upsert: async (input: QuarantineUpsertInput) => {
|
|
173
|
-
writes.push(input);
|
|
174
|
-
},
|
|
175
|
-
} satisfies IQuarantineRepository;
|
|
176
|
-
|
|
177
|
-
// The rows sync reads back before it saves, to ask whether a message sighted
|
|
178
|
-
// here has left the folder its row points at (#1146). This harness stores
|
|
179
|
-
// none, so nothing is contested and the probe issues no IMAP.
|
|
180
|
-
const messageRepository = {
|
|
181
|
-
get: async () => [],
|
|
182
|
-
} as unknown as IMessageRepository;
|
|
183
|
-
|
|
184
|
-
const service = new MessageSyncService(
|
|
185
|
-
{
|
|
186
|
-
getConnection: () => connection,
|
|
187
|
-
close: async () => {},
|
|
188
|
-
} as ManagedConnectionFactory,
|
|
189
|
-
mailboxService,
|
|
190
|
-
noFolderRoles,
|
|
191
|
-
messageRepository,
|
|
192
|
-
{} as IEnvelopeRepository,
|
|
193
|
-
{} as IAddressRepository,
|
|
194
|
-
threadMessageService,
|
|
195
|
-
{ info: () => {}, warn: () => {}, error: () => {} },
|
|
196
|
-
unitOfWork,
|
|
197
|
-
undefined,
|
|
198
|
-
undefined,
|
|
199
|
-
new QuarantineService(
|
|
200
|
-
repository,
|
|
201
|
-
{
|
|
202
|
-
listByMailboxId: async () => [],
|
|
203
|
-
} as unknown as IMailboxSpecialUseRepository,
|
|
204
|
-
"sha-abc",
|
|
205
|
-
{ info: () => {}, warn: () => {} },
|
|
206
|
-
),
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
return { service, mailboxUpdates, writes, fetched };
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const sync = (service: MessageSyncService) =>
|
|
213
|
-
service.syncMessages(MAILBOX_ID, ACCOUNT_ID, ACCOUNT_CONFIG_ID, 50);
|
|
214
|
-
|
|
215
|
-
describe("a row that carried no ENVELOPE", () => {
|
|
216
|
-
it("is held for retry rather than set aside", async () => {
|
|
217
|
-
const harness = buildHarness({
|
|
218
|
-
allUids: [21],
|
|
219
|
-
enumerated: [withoutEnvelope(21)],
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
await sync(harness.service);
|
|
223
|
-
|
|
224
|
-
// Nothing can tell an envelope-less row apart from the FETCH glitching,
|
|
225
|
-
// and the client is far more often the cause than the message. Recording
|
|
226
|
-
// it would set aside mail that is fine.
|
|
227
|
-
assert.deepEqual(harness.writes, []);
|
|
228
|
-
assert.equal(harness.mailboxUpdates[0]?.highWaterMarkUid, 20);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("keeps the mailbox on enumeration rather than seeding a mod-sequence over it", async () => {
|
|
232
|
-
const harness = buildHarness({
|
|
233
|
-
allUids: [21],
|
|
234
|
-
enumerated: [withoutEnvelope(21)],
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
await sync(harness.service);
|
|
238
|
-
|
|
239
|
-
assert.equal(harness.mailboxUpdates[0]?.highestModseq, undefined);
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
describe("a uid already quarantined", () => {
|
|
244
|
-
it("is not fetched again, but the watermark still passes it", async () => {
|
|
245
|
-
const harness = buildHarness({
|
|
246
|
-
allUids: [21, 22],
|
|
247
|
-
enumerated: [withEnvelope(22)],
|
|
248
|
-
existing: [
|
|
249
|
-
{
|
|
250
|
-
mailboxId: MAILBOX_ID,
|
|
251
|
-
uidValidity: UID_VALIDITY,
|
|
252
|
-
uid: 21,
|
|
253
|
-
} as QuarantineItem,
|
|
254
|
-
],
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
await sync(harness.service);
|
|
258
|
-
|
|
259
|
-
assert.deepEqual(harness.fetched, [[22]]);
|
|
260
|
-
assert.equal(harness.mailboxUpdates[0]?.highWaterMarkUid, 22);
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
describe("a uid the FETCH returned no row for", () => {
|
|
265
|
-
it("stops the watermark below it when it is the highest of the batch", async () => {
|
|
266
|
-
const harness = buildHarness({
|
|
267
|
-
allUids: [22, 21],
|
|
268
|
-
enumerated: [withEnvelope(21)],
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
await sync(harness.service);
|
|
272
|
-
|
|
273
|
-
assert.equal(harness.mailboxUpdates[0]?.highWaterMarkUid, 21);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
it("stops the watermark below it when it sits in the middle of the batch", async () => {
|
|
277
|
-
const harness = buildHarness({
|
|
278
|
-
allUids: [23, 22, 21],
|
|
279
|
-
// 22's row is dropped; 23 above it saves fine.
|
|
280
|
-
enumerated: [withEnvelope(23), withEnvelope(21)],
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
await sync(harness.service);
|
|
284
|
-
|
|
285
|
-
// The watermark advances by MAX of what was applied, so absence alone
|
|
286
|
-
// would let 23 carry it straight over the gap and lose 22 for good.
|
|
287
|
-
assert.equal(harness.mailboxUpdates[0]?.highWaterMarkUid, 21);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("stops the backfill floor above a gap, so it stays selectable", async () => {
|
|
291
|
-
const harness = buildHarness({
|
|
292
|
-
allUids: [12, 11, 10],
|
|
293
|
-
enumerated: [withEnvelope(12), withEnvelope(10)],
|
|
294
|
-
lastSyncUid: 20,
|
|
295
|
-
highWaterMarkUid: 20,
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
await sync(harness.service);
|
|
299
|
-
|
|
300
|
-
assert.equal(harness.mailboxUpdates[0]?.lastSyncUid, 12);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it("keeps the mailbox on enumeration rather than seeding a mod-sequence over it", async () => {
|
|
304
|
-
const harness = buildHarness({
|
|
305
|
-
allUids: [22, 21],
|
|
306
|
-
enumerated: [withEnvelope(21)],
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
await sync(harness.service);
|
|
310
|
-
|
|
311
|
-
assert.equal(harness.mailboxUpdates[0]?.highestModseq, undefined);
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* The cursor rebuild is the third save path, and the one where a lost UID is
|
|
317
|
-
* lost for good: its covered region is computed from the server snapshot
|
|
318
|
-
* rather than from what it applied, and it seeds the mod-sequence and returns
|
|
319
|
-
* the mailbox to `normal` — so the next round takes CHANGEDSINCE, which never
|
|
320
|
-
* enumerates.
|
|
321
|
-
*/
|
|
322
|
-
describe("cursor rebuild", () => {
|
|
323
|
-
const rebuild = (over: {
|
|
324
|
-
allUids: number[];
|
|
325
|
-
enumerated: ImapMessage[];
|
|
326
|
-
snapshotUids?: number[];
|
|
327
|
-
}) =>
|
|
328
|
-
buildHarness({
|
|
329
|
-
...over,
|
|
330
|
-
cursorState: MailboxCursorState.cursor_invalid,
|
|
331
|
-
lastSyncUid: 0,
|
|
332
|
-
highWaterMarkUid: 0,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it("holds the watermark below a UID whose row carried no ENVELOPE", async () => {
|
|
336
|
-
const harness = rebuild({
|
|
337
|
-
allUids: [23, 22, 21],
|
|
338
|
-
enumerated: [withEnvelope(23), withoutEnvelope(22), withEnvelope(21)],
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
await sync(harness.service);
|
|
342
|
-
|
|
343
|
-
const final = harness.mailboxUpdates.at(-1);
|
|
344
|
-
assert.equal(final?.highWaterMarkUid, 21);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it("holds the watermark below a UID the message FETCH did not return", async () => {
|
|
348
|
-
const harness = rebuild({
|
|
349
|
-
allUids: [23, 22, 21],
|
|
350
|
-
enumerated: [withEnvelope(23), withEnvelope(21)],
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
await sync(harness.service);
|
|
354
|
-
|
|
355
|
-
assert.equal(harness.mailboxUpdates.at(-1)?.highWaterMarkUid, 21);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it("holds the watermark below a UID the snapshot FETCH did not return", async () => {
|
|
359
|
-
const harness = rebuild({
|
|
360
|
-
allUids: [23, 22, 21],
|
|
361
|
-
snapshotUids: [23, 21],
|
|
362
|
-
enumerated: [withEnvelope(23), withEnvelope(21)],
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
await sync(harness.service);
|
|
366
|
-
|
|
367
|
-
// This UID never reaches the snapshot, so it is not even a candidate for
|
|
368
|
-
// saving — but the covered region spans it, which is the whole hazard.
|
|
369
|
-
assert.equal(harness.mailboxUpdates.at(-1)?.highWaterMarkUid, 21);
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
it("withholds the mod-sequence seed, so the mailbox stays on enumeration", async () => {
|
|
373
|
-
const harness = rebuild({
|
|
374
|
-
allUids: [23, 22, 21],
|
|
375
|
-
enumerated: [withEnvelope(23), withEnvelope(21)],
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
await sync(harness.service);
|
|
379
|
-
|
|
380
|
-
// Seeding it would flip the mailbox to CHANGEDSINCE, which never
|
|
381
|
-
// enumerates — the UID would never be looked for again.
|
|
382
|
-
assert.equal(harness.mailboxUpdates.at(-1)?.highestModseq, "0");
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("leaves the missing UID selectable by the next round", async () => {
|
|
386
|
-
const harness = rebuild({
|
|
387
|
-
allUids: [23, 22, 21],
|
|
388
|
-
enumerated: [withEnvelope(23), withEnvelope(21)],
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
await sync(harness.service);
|
|
392
|
-
|
|
393
|
-
const final = harness.mailboxUpdates.at(-1);
|
|
394
|
-
assert.deepEqual(
|
|
395
|
-
selectUidsToSync(
|
|
396
|
-
[23, 22, 21],
|
|
397
|
-
final?.lastSyncUid ?? 0,
|
|
398
|
-
final?.highWaterMarkUid ?? 0,
|
|
399
|
-
),
|
|
400
|
-
[23, 22],
|
|
401
|
-
);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
it("seeds the mod-sequence when every UID was accounted for", async () => {
|
|
405
|
-
const harness = rebuild({
|
|
406
|
-
allUids: [23, 22, 21],
|
|
407
|
-
enumerated: [withEnvelope(23), withEnvelope(22), withEnvelope(21)],
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
await sync(harness.service);
|
|
411
|
-
|
|
412
|
-
const final = harness.mailboxUpdates.at(-1);
|
|
413
|
-
assert.equal(final?.highestModseq, "600");
|
|
414
|
-
assert.equal(final?.highWaterMarkUid, 23);
|
|
415
|
-
});
|
|
416
|
-
});
|