@remit/mailbox-service 0.0.110 → 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 +226 -200
- 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 +19 -15
- 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/types.ts +1 -1
- 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-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
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { deriveAddressId, deriveCopyMessageId } from "@remit/data-ports/id";
|
|
5
|
+
import {
|
|
6
|
+
MailboxCursorState,
|
|
7
|
+
MessageKeywordFlag,
|
|
8
|
+
MessageSystemFlag,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import { FlagPushService } from "./flag-push.js";
|
|
11
|
+
import { FlagQueueService } from "./flag-queue.js";
|
|
12
|
+
import { MessageMoveService } from "./message-move.js";
|
|
13
|
+
import { MessageSyncService } from "./message-sync.js";
|
|
14
|
+
import { QuarantineService } from "./quarantine.js";
|
|
15
|
+
import { SyncWarningService } from "./sync-warning.js";
|
|
16
|
+
import {
|
|
17
|
+
eml,
|
|
18
|
+
type FakeImapConnection,
|
|
19
|
+
FakeImapServer,
|
|
20
|
+
} from "./test-helpers/fake-imap.js";
|
|
21
|
+
import {
|
|
22
|
+
captureQueue,
|
|
23
|
+
messageAtUid,
|
|
24
|
+
openStore,
|
|
25
|
+
type QueueCapture,
|
|
26
|
+
type Store,
|
|
27
|
+
seedAccount,
|
|
28
|
+
seedMailbox,
|
|
29
|
+
silentLog,
|
|
30
|
+
} from "./test-helpers/test-store.js";
|
|
31
|
+
|
|
32
|
+
const SPAMMER = eml({
|
|
33
|
+
From: "Pharma Deals <sales@pharma.example>",
|
|
34
|
+
To: "victim@ischen.nl",
|
|
35
|
+
"Message-ID": "<deal@pharma.example>",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let store: Store;
|
|
39
|
+
let capture: QueueCapture;
|
|
40
|
+
let imap: FakeImapServer;
|
|
41
|
+
let connection: FakeImapConnection;
|
|
42
|
+
let account: AccountItem;
|
|
43
|
+
let inbox: MailboxItem;
|
|
44
|
+
let junk: MailboxItem;
|
|
45
|
+
let trash: MailboxItem;
|
|
46
|
+
let receipts: MailboxItem;
|
|
47
|
+
let allMail: MailboxItem;
|
|
48
|
+
let logMark: number;
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
capture = captureQueue();
|
|
52
|
+
store = await openStore();
|
|
53
|
+
imap = new FakeImapServer();
|
|
54
|
+
account = await seedAccount(store);
|
|
55
|
+
inbox = await seedMailbox(store, account, "INBOX");
|
|
56
|
+
junk = await seedMailbox(store, account, "INBOX/Rubbish", {
|
|
57
|
+
specialUse: "Junk",
|
|
58
|
+
});
|
|
59
|
+
trash = await seedMailbox(store, account, "Trash", { specialUse: "Trash" });
|
|
60
|
+
receipts = await seedMailbox(store, account, "Receipts");
|
|
61
|
+
allMail = await seedMailbox(store, account, "[Gmail]/All Mail", {
|
|
62
|
+
specialUse: "All",
|
|
63
|
+
});
|
|
64
|
+
for (const mailbox of [inbox, junk, trash, receipts, allMail]) {
|
|
65
|
+
imap.addFolder(mailbox.fullPath);
|
|
66
|
+
}
|
|
67
|
+
connection = imap.connection();
|
|
68
|
+
logMark = 0;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(async () => {
|
|
72
|
+
capture.restore();
|
|
73
|
+
await store.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const flagPushService = () =>
|
|
77
|
+
new FlagPushService({
|
|
78
|
+
markerService: store.flagPush,
|
|
79
|
+
sqsQueueUrl: "https://sqs.test/flags",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const syncService = () =>
|
|
83
|
+
new MessageSyncService(
|
|
84
|
+
{ getConnection: () => connection, close: async () => {} },
|
|
85
|
+
store.mailbox,
|
|
86
|
+
store.mailboxSpecialUse,
|
|
87
|
+
store.message,
|
|
88
|
+
store.envelope,
|
|
89
|
+
store.address,
|
|
90
|
+
store.threadMessage,
|
|
91
|
+
new SyncWarningService(store.syncWarning),
|
|
92
|
+
silentLog,
|
|
93
|
+
store.flagPush,
|
|
94
|
+
store.messageFlag,
|
|
95
|
+
new QuarantineService(
|
|
96
|
+
store.quarantine,
|
|
97
|
+
store.mailboxSpecialUse,
|
|
98
|
+
"sha-test",
|
|
99
|
+
silentLog,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const userMoves = () =>
|
|
104
|
+
new MessageMoveService({
|
|
105
|
+
messageService: store.message,
|
|
106
|
+
mailboxService: store.mailbox,
|
|
107
|
+
mailboxSpecialUseService: store.mailboxSpecialUse,
|
|
108
|
+
threadMessageService: store.threadMessage,
|
|
109
|
+
addressService: store.address,
|
|
110
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const flagQueue = () =>
|
|
114
|
+
new FlagQueueService({
|
|
115
|
+
messageFlagService: store.messageFlag,
|
|
116
|
+
messageService: store.message,
|
|
117
|
+
threadMessageService: store.threadMessage,
|
|
118
|
+
flagPushService: flagPushService(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const sync = (mailbox: MailboxItem, batchSize = 50) =>
|
|
122
|
+
syncService().syncMessages(
|
|
123
|
+
mailbox.mailboxId,
|
|
124
|
+
account.accountId,
|
|
125
|
+
account.accountConfigId,
|
|
126
|
+
batchSize,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const settle = async (mailbox: MailboxItem) => {
|
|
130
|
+
for (;;) {
|
|
131
|
+
const result = await sync(mailbox);
|
|
132
|
+
if (!result.hasMore && result.syncedCount === 0) return;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const stored = (mailbox: MailboxItem) =>
|
|
137
|
+
store.mailbox.get(account.accountId, mailbox.mailboxId);
|
|
138
|
+
|
|
139
|
+
const threadOf = async (messageId: string) =>
|
|
140
|
+
(
|
|
141
|
+
await store.threadMessage.findAllByMessageId(
|
|
142
|
+
account.accountConfigId,
|
|
143
|
+
messageId,
|
|
144
|
+
)
|
|
145
|
+
)[0];
|
|
146
|
+
|
|
147
|
+
const address = (email: string) =>
|
|
148
|
+
store.address.getAddress(
|
|
149
|
+
account.accountConfigId,
|
|
150
|
+
deriveAddressId(account.accountConfigId, email),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const sent = (verb: "SEARCH" | "FETCH", marker = "") =>
|
|
154
|
+
imap.log
|
|
155
|
+
.slice(logMark)
|
|
156
|
+
.filter((line) => line.startsWith(`${verb} `) && line.includes(marker))
|
|
157
|
+
.length;
|
|
158
|
+
|
|
159
|
+
const serverMove = async (from: MailboxItem, uid: number, to: MailboxItem) => {
|
|
160
|
+
const otherClient = imap.connection();
|
|
161
|
+
await otherClient.openBox(from.fullPath);
|
|
162
|
+
const { uidMap } = await otherClient.moveMessages([uid], to.fullPath);
|
|
163
|
+
const moved = uidMap.get(uid);
|
|
164
|
+
if (moved === undefined) throw new Error(`no uid ${uid} in ${from.fullPath}`);
|
|
165
|
+
return moved;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const arrive = async (mailbox: MailboxItem, raw: Buffer = SPAMMER) => {
|
|
169
|
+
const uid = imap.deliver(mailbox.fullPath, raw);
|
|
170
|
+
await sync(mailbox);
|
|
171
|
+
return { uid, message: await messageAtUid(store, mailbox, uid) };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
describe("harvesting envelope display names", () => {
|
|
175
|
+
const spoofed = eml({
|
|
176
|
+
From: '"matthijs@ischen.nl" <aramirez@secresaludguaviare.gov.co>',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("drops a name that is an address the sender does not own, wherever it lands", async () => {
|
|
180
|
+
const { message } = await arrive(inbox, spoofed);
|
|
181
|
+
|
|
182
|
+
const sender = await address("aramirez@secresaludguaviare.gov.co");
|
|
183
|
+
assert.equal(sender.displayName ?? "", "");
|
|
184
|
+
assert.equal(
|
|
185
|
+
sender.normalizedCompound,
|
|
186
|
+
"aramirez@secresaludguaviare.gov.co",
|
|
187
|
+
);
|
|
188
|
+
const data = await store.envelope.getMessageData(message.messageId);
|
|
189
|
+
assert.ok(data.envelopeAddress.length > 0);
|
|
190
|
+
for (const entry of data.envelopeAddress) {
|
|
191
|
+
if (entry.normalizedEmail !== "aramirez@secresaludguaviare.gov.co")
|
|
192
|
+
continue;
|
|
193
|
+
assert.equal(entry.displayName ?? "", "");
|
|
194
|
+
}
|
|
195
|
+
const thread = await threadOf(message.messageId);
|
|
196
|
+
assert.equal(thread.fromName, undefined);
|
|
197
|
+
assert.equal(thread.fromEmail, "aramirez@secresaludguaviare.gov.co");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("keeps what a name says besides the address it claims", async () => {
|
|
201
|
+
const { message } = await arrive(
|
|
202
|
+
inbox,
|
|
203
|
+
eml({ From: '"Support <matthijs@ischen.nl>" <aramirez@example.gov>' }),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
assert.equal((await threadOf(message.messageId)).fromName, "Support");
|
|
207
|
+
assert.equal(
|
|
208
|
+
(await address("aramirez@example.gov")).displayName,
|
|
209
|
+
"Support",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("harvests every envelope role", async () => {
|
|
214
|
+
await arrive(
|
|
215
|
+
inbox,
|
|
216
|
+
eml({
|
|
217
|
+
From: "Someone <from@example.com>",
|
|
218
|
+
To: "Someone <to@example.com>",
|
|
219
|
+
Cc: "Someone <cc@example.com>",
|
|
220
|
+
"Reply-To": "Someone <reply-to@example.com>",
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
for (const email of [
|
|
225
|
+
"from@example.com",
|
|
226
|
+
"to@example.com",
|
|
227
|
+
"cc@example.com",
|
|
228
|
+
"reply-to@example.com",
|
|
229
|
+
]) {
|
|
230
|
+
assert.equal((await address(email)).displayName, "Someone");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("what a folder says about the senders on its mail", () => {
|
|
236
|
+
it("harvests the senders of ordinary mail as correspondents", async () => {
|
|
237
|
+
await arrive(inbox);
|
|
238
|
+
|
|
239
|
+
assert.equal(
|
|
240
|
+
(await address("sales@pharma.example")).flags.junkOnly,
|
|
241
|
+
undefined,
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("withholds the senders of mail in the appointed Junk folder", async () => {
|
|
246
|
+
const { message } = await arrive(junk);
|
|
247
|
+
|
|
248
|
+
assert.equal(
|
|
249
|
+
(await address("sales@pharma.example")).flags.junkOnly?.value,
|
|
250
|
+
true,
|
|
251
|
+
);
|
|
252
|
+
const data = await store.envelope.getMessageData(message.messageId);
|
|
253
|
+
assert.ok(
|
|
254
|
+
data.envelopeAddress.some(
|
|
255
|
+
(entry) => entry.normalizedEmail === "sales@pharma.example",
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("keeps mail in Trash from deciding either way", async () => {
|
|
261
|
+
await arrive(trash);
|
|
262
|
+
|
|
263
|
+
assert.equal(
|
|
264
|
+
(await address("sales@pharma.example")).flags.junkOnly,
|
|
265
|
+
undefined,
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("a message the database already holds, sighted in another folder", () => {
|
|
271
|
+
it("follows another client's move into Junk, with the new folder's UID", async () => {
|
|
272
|
+
const { uid, message } = await arrive(inbox);
|
|
273
|
+
const moved = await serverMove(inbox, uid, junk);
|
|
274
|
+
|
|
275
|
+
const result = await sync(junk);
|
|
276
|
+
|
|
277
|
+
const row = await store.message.get(message.messageId);
|
|
278
|
+
assert.equal(row.mailboxId, junk.mailboxId);
|
|
279
|
+
assert.equal(row.uid, moved);
|
|
280
|
+
const thread = await threadOf(message.messageId);
|
|
281
|
+
assert.equal(thread.mailboxId, junk.mailboxId);
|
|
282
|
+
assert.equal(thread.uid, moved);
|
|
283
|
+
assert.deepEqual(result.syncedMessageIds, [message.messageId]);
|
|
284
|
+
assert.equal(
|
|
285
|
+
(await address("sales@pharma.example")).flags.junkOnly?.value,
|
|
286
|
+
true,
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("follows the rescue back out of Junk and restores the sender", async () => {
|
|
291
|
+
const { uid, message } = await arrive(junk);
|
|
292
|
+
await serverMove(junk, uid, inbox);
|
|
293
|
+
|
|
294
|
+
await sync(inbox);
|
|
295
|
+
|
|
296
|
+
assert.equal(
|
|
297
|
+
(await store.message.get(message.messageId)).mailboxId,
|
|
298
|
+
inbox.mailboxId,
|
|
299
|
+
);
|
|
300
|
+
assert.equal(
|
|
301
|
+
(await address("sales@pharma.example")).flags.junkOnly,
|
|
302
|
+
undefined,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("marks the listing row deleted in Trash and clears it on the way out", async () => {
|
|
307
|
+
imap.condstore = false;
|
|
308
|
+
const { uid, message } = await arrive(inbox);
|
|
309
|
+
const inTrash = await serverMove(inbox, uid, trash);
|
|
310
|
+
await sync(trash);
|
|
311
|
+
assert.equal((await threadOf(message.messageId)).isDeleted, true);
|
|
312
|
+
|
|
313
|
+
await serverMove(trash, inTrash, inbox);
|
|
314
|
+
await sync(inbox);
|
|
315
|
+
|
|
316
|
+
assert.equal((await threadOf(message.messageId)).isDeleted, false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("leaves a labelled message in the folder that still holds it", async () => {
|
|
320
|
+
const { message } = await arrive(inbox);
|
|
321
|
+
imap.deliver("Receipts", SPAMMER);
|
|
322
|
+
|
|
323
|
+
const result = await sync(receipts);
|
|
324
|
+
|
|
325
|
+
assert.equal(
|
|
326
|
+
(await store.message.get(message.messageId)).mailboxId,
|
|
327
|
+
inbox.mailboxId,
|
|
328
|
+
);
|
|
329
|
+
assert.equal(
|
|
330
|
+
(await threadOf(message.messageId)).mailboxId,
|
|
331
|
+
inbox.mailboxId,
|
|
332
|
+
);
|
|
333
|
+
assert.equal(result.cursorStalled, false);
|
|
334
|
+
assert.equal((await stored(receipts)).highWaterMarkUid, 1);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("holds the watermark when the folder it came from cannot be asked", async () => {
|
|
338
|
+
const { uid, message } = await arrive(inbox);
|
|
339
|
+
await serverMove(inbox, uid, junk);
|
|
340
|
+
imap.removeFolder("INBOX");
|
|
341
|
+
|
|
342
|
+
await sync(junk);
|
|
343
|
+
|
|
344
|
+
assert.equal(
|
|
345
|
+
(await store.message.get(message.messageId)).mailboxId,
|
|
346
|
+
inbox.mailboxId,
|
|
347
|
+
);
|
|
348
|
+
assert.equal((await stored(junk)).highWaterMarkUid, 0);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("declines a sighting in a folder that copies every message", async () => {
|
|
352
|
+
const { message } = await arrive(inbox);
|
|
353
|
+
imap.deliver("[Gmail]/All Mail", SPAMMER);
|
|
354
|
+
|
|
355
|
+
await sync(allMail);
|
|
356
|
+
|
|
357
|
+
assert.equal(
|
|
358
|
+
(await store.message.get(message.messageId)).mailboxId,
|
|
359
|
+
inbox.mailboxId,
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("declines a sighting of a copy the user put in that folder", async () => {
|
|
364
|
+
const { message } = await arrive(inbox);
|
|
365
|
+
await userMoves().copyMessage(
|
|
366
|
+
account.accountConfigId,
|
|
367
|
+
message.messageId,
|
|
368
|
+
junk.mailboxId,
|
|
369
|
+
account.accountId,
|
|
370
|
+
);
|
|
371
|
+
const copied = imap.deliver("INBOX/Rubbish", SPAMMER);
|
|
372
|
+
await store.message.updateUid(
|
|
373
|
+
deriveCopyMessageId(message.messageId, junk.mailboxId),
|
|
374
|
+
copied,
|
|
375
|
+
junk.mailboxId,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
await sync(junk);
|
|
379
|
+
|
|
380
|
+
assert.equal(
|
|
381
|
+
(await store.message.get(message.messageId)).mailboxId,
|
|
382
|
+
inbox.mailboxId,
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("follows another client's move into a folder already on CHANGEDSINCE", async () => {
|
|
387
|
+
const { uid, message } = await arrive(inbox);
|
|
388
|
+
await settle(junk);
|
|
389
|
+
await serverMove(inbox, uid, junk);
|
|
390
|
+
|
|
391
|
+
await sync(junk);
|
|
392
|
+
|
|
393
|
+
assert.equal(
|
|
394
|
+
(await store.message.get(message.messageId)).mailboxId,
|
|
395
|
+
junk.mailboxId,
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("holds a row whose own move has not settled", async () => {
|
|
400
|
+
const { uid, message } = await arrive(inbox);
|
|
401
|
+
await userMoves().moveMessage(
|
|
402
|
+
account.accountConfigId,
|
|
403
|
+
message.messageId,
|
|
404
|
+
receipts.mailboxId,
|
|
405
|
+
account.accountId,
|
|
406
|
+
);
|
|
407
|
+
await serverMove(inbox, uid, junk);
|
|
408
|
+
|
|
409
|
+
await sync(junk);
|
|
410
|
+
|
|
411
|
+
const row = await store.message.get(message.messageId);
|
|
412
|
+
assert.equal(row.mailboxId, receipts.mailboxId);
|
|
413
|
+
assert.equal(row.status, "moving");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("CHANGEDSINCE picks up what another client changed", () => {
|
|
418
|
+
const seeded = async (count = 1) => {
|
|
419
|
+
const uids: number[] = [];
|
|
420
|
+
for (let i = 0; i < count; i++) {
|
|
421
|
+
uids.push(
|
|
422
|
+
imap.deliver("INBOX", eml({ "Message-ID": `<m${i}@example.com>` })),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
await settle(inbox);
|
|
426
|
+
logMark = imap.log.length;
|
|
427
|
+
const messages = await Promise.all(
|
|
428
|
+
uids.map((uid) => messageAtUid(store, inbox, uid)),
|
|
429
|
+
);
|
|
430
|
+
return { uids, messages };
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
it("applies a read-state change without enumerating the folder", async () => {
|
|
434
|
+
const {
|
|
435
|
+
uids: [uid],
|
|
436
|
+
messages: [message],
|
|
437
|
+
} = await seeded();
|
|
438
|
+
imap.store("INBOX", [uid], [MessageSystemFlag.Seen]);
|
|
439
|
+
|
|
440
|
+
await sync(inbox);
|
|
441
|
+
|
|
442
|
+
assert.equal(sent("SEARCH"), 0);
|
|
443
|
+
assert.equal((await threadOf(message.messageId)).isRead, true);
|
|
444
|
+
assert.ok(
|
|
445
|
+
await store.messageFlag.hasFlag(
|
|
446
|
+
message.messageId,
|
|
447
|
+
MessageSystemFlag.Seen,
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("stars with the default colour and unstars clearing it", async () => {
|
|
453
|
+
const {
|
|
454
|
+
uids: [uid],
|
|
455
|
+
messages: [message],
|
|
456
|
+
} = await seeded();
|
|
457
|
+
imap.store("INBOX", [uid], [MessageSystemFlag.Flagged]);
|
|
458
|
+
await sync(inbox);
|
|
459
|
+
let thread = await threadOf(message.messageId);
|
|
460
|
+
assert.equal(thread.hasStars, true);
|
|
461
|
+
assert.equal(thread.star, "yellow");
|
|
462
|
+
|
|
463
|
+
imap.store("INBOX", [uid], []);
|
|
464
|
+
await sync(inbox);
|
|
465
|
+
|
|
466
|
+
thread = await threadOf(message.messageId);
|
|
467
|
+
assert.equal(thread.hasStars, false);
|
|
468
|
+
assert.equal(thread.star, "none");
|
|
469
|
+
assert.equal(
|
|
470
|
+
await store.messageFlag.hasFlag(
|
|
471
|
+
message.messageId,
|
|
472
|
+
MessageSystemFlag.Flagged,
|
|
473
|
+
),
|
|
474
|
+
false,
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("keeps a colour the user chose when the server re-flags", async () => {
|
|
479
|
+
const {
|
|
480
|
+
uids: [uid],
|
|
481
|
+
messages: [message],
|
|
482
|
+
} = await seeded();
|
|
483
|
+
await flagQueue().updateFlags(
|
|
484
|
+
account.accountConfigId,
|
|
485
|
+
message.messageId,
|
|
486
|
+
account.accountId,
|
|
487
|
+
{ isStarred: false, starColor: "purple" },
|
|
488
|
+
);
|
|
489
|
+
await store.flagPush.delete(message.messageId, MessageSystemFlag.Flagged);
|
|
490
|
+
imap.store("INBOX", [uid], [MessageSystemFlag.Flagged]);
|
|
491
|
+
|
|
492
|
+
await sync(inbox);
|
|
493
|
+
|
|
494
|
+
assert.equal((await threadOf(message.messageId)).star, "purple");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("leaves a field alone while the user's flip is still owed to the server", async () => {
|
|
498
|
+
const {
|
|
499
|
+
uids: [uid],
|
|
500
|
+
messages: [message],
|
|
501
|
+
} = await seeded();
|
|
502
|
+
const flags = new FlagQueueService({
|
|
503
|
+
messageFlagService: store.messageFlag,
|
|
504
|
+
messageService: store.message,
|
|
505
|
+
threadMessageService: store.threadMessage,
|
|
506
|
+
flagPushService: flagPushService(),
|
|
507
|
+
});
|
|
508
|
+
await flags.markAsRead(
|
|
509
|
+
account.accountConfigId,
|
|
510
|
+
message.messageId,
|
|
511
|
+
account.accountId,
|
|
512
|
+
);
|
|
513
|
+
imap.store("INBOX", [uid], [MessageSystemFlag.Flagged]);
|
|
514
|
+
|
|
515
|
+
await sync(inbox);
|
|
516
|
+
|
|
517
|
+
const thread = await threadOf(message.messageId);
|
|
518
|
+
assert.equal(thread.isRead, true);
|
|
519
|
+
assert.equal(thread.hasStars, true);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("leaves the user's next flip a real flip", async () => {
|
|
523
|
+
const {
|
|
524
|
+
uids: [uid],
|
|
525
|
+
messages: [message],
|
|
526
|
+
} = await seeded();
|
|
527
|
+
imap.store("INBOX", [uid], [MessageSystemFlag.Seen]);
|
|
528
|
+
await sync(inbox);
|
|
529
|
+
|
|
530
|
+
await new FlagQueueService({
|
|
531
|
+
messageFlagService: store.messageFlag,
|
|
532
|
+
messageService: store.message,
|
|
533
|
+
threadMessageService: store.threadMessage,
|
|
534
|
+
flagPushService: flagPushService(),
|
|
535
|
+
}).markAsUnread(
|
|
536
|
+
account.accountConfigId,
|
|
537
|
+
message.messageId,
|
|
538
|
+
account.accountId,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
assert.equal(
|
|
542
|
+
(await store.flagPush.find(message.messageId, MessageSystemFlag.Seen))
|
|
543
|
+
?.operation,
|
|
544
|
+
"remove",
|
|
545
|
+
);
|
|
546
|
+
assert.equal((await threadOf(message.messageId)).isRead, false);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("mirrors $Junk and $NotJunk as message state, never as sender standing", async () => {
|
|
550
|
+
const {
|
|
551
|
+
uids: [first, second],
|
|
552
|
+
messages: [junked, cleared],
|
|
553
|
+
} = await seeded(2);
|
|
554
|
+
const before = await address("sender@example.com");
|
|
555
|
+
imap.store("INBOX", [first], [MessageKeywordFlag.Junk]);
|
|
556
|
+
imap.store("INBOX", [second], [MessageKeywordFlag.NotJunk]);
|
|
557
|
+
|
|
558
|
+
await sync(inbox);
|
|
559
|
+
|
|
560
|
+
assert.ok(
|
|
561
|
+
await store.messageFlag.hasFlag(
|
|
562
|
+
junked.messageId,
|
|
563
|
+
MessageKeywordFlag.Junk,
|
|
564
|
+
),
|
|
565
|
+
);
|
|
566
|
+
assert.ok(
|
|
567
|
+
await store.messageFlag.hasFlag(
|
|
568
|
+
cleared.messageId,
|
|
569
|
+
MessageKeywordFlag.NotJunk,
|
|
570
|
+
),
|
|
571
|
+
);
|
|
572
|
+
assert.deepEqual((await address("sender@example.com")).flags, before.flags);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("discovers new mail and hands it to body sync", async () => {
|
|
576
|
+
await seeded();
|
|
577
|
+
const uid = imap.deliver(
|
|
578
|
+
"INBOX",
|
|
579
|
+
eml({ "Message-ID": "<new@example.com>" }),
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
const result = await sync(inbox);
|
|
583
|
+
|
|
584
|
+
assert.equal(sent("SEARCH"), 0);
|
|
585
|
+
assert.deepEqual(
|
|
586
|
+
result.syncedMessages.map((message) => message.uid),
|
|
587
|
+
[uid],
|
|
588
|
+
);
|
|
589
|
+
assert.equal((await stored(inbox)).highWaterMarkUid, uid);
|
|
590
|
+
assert.equal(
|
|
591
|
+
(await stored(inbox)).highestModseq,
|
|
592
|
+
imap.highestModseq().toString(),
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("splits one bulk change across rounds without losing its tail", async () => {
|
|
597
|
+
const { uids, messages } = await seeded(60);
|
|
598
|
+
imap.store("INBOX", uids, [MessageSystemFlag.Seen]);
|
|
599
|
+
|
|
600
|
+
const first = await sync(inbox, 50);
|
|
601
|
+
assert.equal(first.hasMore, true);
|
|
602
|
+
imap.store("INBOX", uids, [
|
|
603
|
+
MessageSystemFlag.Seen,
|
|
604
|
+
MessageSystemFlag.Flagged,
|
|
605
|
+
]);
|
|
606
|
+
await settle(inbox);
|
|
607
|
+
|
|
608
|
+
for (const message of messages) {
|
|
609
|
+
const thread = await threadOf(message.messageId);
|
|
610
|
+
assert.equal(thread.isRead, true);
|
|
611
|
+
assert.equal(thread.hasStars, true);
|
|
612
|
+
}
|
|
613
|
+
assert.equal(
|
|
614
|
+
(await stored(inbox)).highestModseq,
|
|
615
|
+
imap.highestModseq().toString(),
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
describe("a server without CONDSTORE", () => {
|
|
621
|
+
it("enumerates every round and never seeds a mod-sequence", async () => {
|
|
622
|
+
imap.condstore = false;
|
|
623
|
+
imap.deliver("INBOX", eml({}));
|
|
624
|
+
await settle(inbox);
|
|
625
|
+
|
|
626
|
+
await sync(inbox);
|
|
627
|
+
|
|
628
|
+
assert.equal(sent("FETCH", "CHANGEDSINCE"), 0);
|
|
629
|
+
assert.ok(sent("SEARCH") > 0);
|
|
630
|
+
assert.equal((await stored(inbox)).highestModseq, "0");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("seeds the mod-sequence only once enumeration has nothing left", async () => {
|
|
634
|
+
imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
|
|
635
|
+
imap.deliver("INBOX", eml({ "Message-ID": "<b@example.com>" }));
|
|
636
|
+
|
|
637
|
+
await sync(inbox, 1);
|
|
638
|
+
assert.equal((await stored(inbox)).highestModseq, "0");
|
|
639
|
+
|
|
640
|
+
await settle(inbox);
|
|
641
|
+
assert.equal(
|
|
642
|
+
(await stored(inbox)).highestModseq,
|
|
643
|
+
imap.highestModseq().toString(),
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
describe("a UIDVALIDITY change", () => {
|
|
649
|
+
it("trips the cursor, then rebuilds on the new axis and reseeds", async () => {
|
|
650
|
+
imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
|
|
651
|
+
await settle(inbox);
|
|
652
|
+
imap.renumber("INBOX", 777);
|
|
653
|
+
|
|
654
|
+
const tripped = await sync(inbox);
|
|
655
|
+
assert.deepEqual(tripped.syncedMessages, []);
|
|
656
|
+
assert.equal(
|
|
657
|
+
(await stored(inbox)).cursorState,
|
|
658
|
+
MailboxCursorState.cursor_invalid,
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
await sync(inbox);
|
|
662
|
+
|
|
663
|
+
const rebuilt = await stored(inbox);
|
|
664
|
+
assert.equal(rebuilt.cursorState, MailboxCursorState.normal);
|
|
665
|
+
assert.equal(rebuilt.uidValidity, 777);
|
|
666
|
+
assert.equal(rebuilt.highestModseq, imap.highestModseq().toString());
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("withholds the seed and the watermark when the rebuild missed a UID", async () => {
|
|
670
|
+
for (const id of ["a", "b", "c"]) {
|
|
671
|
+
imap.deliver("INBOX", eml({ "Message-ID": `<${id}@example.com>` }));
|
|
672
|
+
}
|
|
673
|
+
await settle(inbox);
|
|
674
|
+
imap.renumber("INBOX", 777);
|
|
675
|
+
const arrived = imap.deliver(
|
|
676
|
+
"INBOX",
|
|
677
|
+
eml({ "Message-ID": "<d@example.com>" }),
|
|
678
|
+
);
|
|
679
|
+
await sync(inbox);
|
|
680
|
+
const fetch = connection.fetchMessages;
|
|
681
|
+
connection.fetchMessages = async (uids) =>
|
|
682
|
+
(await fetch(uids)).filter((row) => row.uid !== arrived);
|
|
683
|
+
|
|
684
|
+
await sync(inbox);
|
|
685
|
+
|
|
686
|
+
const rebuilt = await stored(inbox);
|
|
687
|
+
assert.equal(rebuilt.highestModseq, "0");
|
|
688
|
+
assert.ok(rebuilt.highWaterMarkUid < arrived);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe("a FETCH that leaves a UID unaccounted for", () => {
|
|
693
|
+
it("holds the watermark below a row the FETCH dropped", async () => {
|
|
694
|
+
for (const id of ["a", "b", "c"]) {
|
|
695
|
+
imap.deliver("INBOX", eml({ "Message-ID": `<${id}@example.com>` }));
|
|
696
|
+
}
|
|
697
|
+
const fetch = connection.fetchMessages;
|
|
698
|
+
connection.fetchMessages = async (uids) =>
|
|
699
|
+
(await fetch(uids)).filter((row) => row.uid !== 2);
|
|
700
|
+
|
|
701
|
+
await sync(inbox);
|
|
702
|
+
|
|
703
|
+
const mailbox = await stored(inbox);
|
|
704
|
+
assert.equal(mailbox.highWaterMarkUid, 1);
|
|
705
|
+
assert.equal(mailbox.highestModseq, "0");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("holds a row with no ENVELOPE for retry rather than setting it aside", async () => {
|
|
709
|
+
imap.deliver("INBOX", eml({}));
|
|
710
|
+
const fetch = connection.fetchMessages;
|
|
711
|
+
connection.fetchMessages = async (uids) =>
|
|
712
|
+
(await fetch(uids)).map((row) => ({ ...row, envelope: undefined }));
|
|
713
|
+
|
|
714
|
+
const result = await sync(inbox);
|
|
715
|
+
|
|
716
|
+
assert.equal(result.syncedCount, 0);
|
|
717
|
+
assert.equal((await stored(inbox)).highWaterMarkUid, 0);
|
|
718
|
+
assert.deepEqual(
|
|
719
|
+
await store.quarantine.listByAccountConfigId(account.accountConfigId),
|
|
720
|
+
[],
|
|
721
|
+
);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("passes over a UID already quarantined without fetching it", async () => {
|
|
725
|
+
const first = imap.deliver(
|
|
726
|
+
"INBOX",
|
|
727
|
+
eml({ "Message-ID": "<a@example.com>" }),
|
|
728
|
+
);
|
|
729
|
+
const second = imap.deliver(
|
|
730
|
+
"INBOX",
|
|
731
|
+
eml({ "Message-ID": "<b@example.com>" }),
|
|
732
|
+
);
|
|
733
|
+
await new QuarantineService(
|
|
734
|
+
store.quarantine,
|
|
735
|
+
store.mailboxSpecialUse,
|
|
736
|
+
"sha-test",
|
|
737
|
+
silentLog,
|
|
738
|
+
).record(
|
|
739
|
+
{
|
|
740
|
+
accountId: account.accountId,
|
|
741
|
+
accountConfigId: account.accountConfigId,
|
|
742
|
+
mailboxId: inbox.mailboxId,
|
|
743
|
+
mailboxPath: "INBOX",
|
|
744
|
+
uidValidity: 7,
|
|
745
|
+
attempts: 3,
|
|
746
|
+
},
|
|
747
|
+
first,
|
|
748
|
+
{ stage: "BodyParse", code: "UnreadableBody", message: "no" },
|
|
749
|
+
);
|
|
750
|
+
const asked: number[][] = [];
|
|
751
|
+
const fetch = connection.fetchMessages;
|
|
752
|
+
connection.fetchMessages = async (uids) => {
|
|
753
|
+
asked.push(uids);
|
|
754
|
+
return fetch(uids);
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
await sync(inbox);
|
|
758
|
+
|
|
759
|
+
assert.deepEqual(asked, [[second]]);
|
|
760
|
+
assert.equal((await stored(inbox)).highWaterMarkUid, second);
|
|
761
|
+
});
|
|
762
|
+
});
|