@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
|
@@ -1,24 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for `classifyParsedMessage` (issue #1197): the one classification
|
|
3
|
-
* implementation shared by the body-store path and the classification
|
|
4
|
-
* backfill, so the two can never classify the same bytes differently. Pins
|
|
5
|
-
* the `Address.flags.category` override contract (issue #299, RFC 039
|
|
6
|
-
* Decision 3) on its own, rather than only through body-sync's public
|
|
7
|
-
* surface: the override wins outright, a genuinely-absent Address is "no
|
|
8
|
-
* override", and any other read failure propagates rather than silently
|
|
9
|
-
* classifying by headers when the user asked for something else.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
1
|
import assert from "node:assert/strict";
|
|
13
|
-
import { describe, it } from "node:test";
|
|
14
|
-
import type { AddressItem, IAddressRepository } from "@remit/data-ports";
|
|
15
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
2
|
+
import { after, before, describe, it } from "node:test";
|
|
16
3
|
import { DisplayNameCorrespondence } from "@remit/domain-enums";
|
|
17
4
|
import { parseMessageBody } from "./body-parse.js";
|
|
18
5
|
import {
|
|
19
6
|
classifyParsedMessage,
|
|
20
7
|
extractPrimaryFromEmail,
|
|
21
8
|
} from "./classify-message.js";
|
|
9
|
+
import { openStore, type Store } from "./test-helpers/test-store.js";
|
|
22
10
|
|
|
23
11
|
const PLAIN_EML = Buffer.from(
|
|
24
12
|
[
|
|
@@ -56,29 +44,18 @@ const IMPERSONATION_EML = Buffer.from(
|
|
|
56
44
|
].join("\r\n"),
|
|
57
45
|
);
|
|
58
46
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
getAddress: (async (accountConfigId: string, addressId: string) =>
|
|
63
|
-
behavior(
|
|
64
|
-
accountConfigId,
|
|
65
|
-
addressId,
|
|
66
|
-
)) as unknown as IAddressRepository["getAddress"],
|
|
47
|
+
let store: Store;
|
|
48
|
+
before(async () => {
|
|
49
|
+
store = await openStore();
|
|
67
50
|
});
|
|
68
|
-
|
|
69
|
-
const withFlags =
|
|
70
|
-
(flags: AddressItem["flags"]): (() => AddressItem) =>
|
|
71
|
-
() =>
|
|
72
|
-
({ flags }) as AddressItem;
|
|
51
|
+
after(() => store.close());
|
|
73
52
|
|
|
74
53
|
describe("classifyParsedMessage", () => {
|
|
75
54
|
it("answers from the header rule table when the sender has no override", async () => {
|
|
76
55
|
const parsed = await parseMessageBody(LIST_EML);
|
|
77
56
|
|
|
78
57
|
const classification = await classifyParsedMessage(
|
|
79
|
-
|
|
80
|
-
throw new NotFoundError("no Address row");
|
|
81
|
-
}),
|
|
58
|
+
store.address,
|
|
82
59
|
"acc-1",
|
|
83
60
|
parsed,
|
|
84
61
|
"inbox",
|
|
@@ -88,38 +65,7 @@ describe("classifyParsedMessage", () => {
|
|
|
88
65
|
assert.equal(classification.hasListUnsubscribe, true);
|
|
89
66
|
});
|
|
90
67
|
|
|
91
|
-
it("
|
|
92
|
-
const parsed = await parseMessageBody(PLAIN_EML);
|
|
93
|
-
|
|
94
|
-
const classification = await classifyParsedMessage(
|
|
95
|
-
addressService(withFlags({ category: { value: "marketing", setAt: 1 } })),
|
|
96
|
-
"acc-1",
|
|
97
|
-
parsed,
|
|
98
|
-
"inbox",
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
// The header table alone would answer `personal`; the override wins
|
|
102
|
-
// without blending (RFC 039 Decision 3).
|
|
103
|
-
assert.equal(classification.category, "marketing");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("propagates an Address read failure rather than classifying by headers", async () => {
|
|
107
|
-
const parsed = await parseMessageBody(PLAIN_EML);
|
|
108
|
-
|
|
109
|
-
await assert.rejects(
|
|
110
|
-
classifyParsedMessage(
|
|
111
|
-
addressService(async () => {
|
|
112
|
-
throw new Error("throttled");
|
|
113
|
-
}),
|
|
114
|
-
"acc-1",
|
|
115
|
-
parsed,
|
|
116
|
-
"inbox",
|
|
117
|
-
),
|
|
118
|
-
/throttled/,
|
|
119
|
-
);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("reads no Address at all when the message has no From address", async () => {
|
|
68
|
+
it("classifies mail with no From address by headers alone", async () => {
|
|
123
69
|
const parsed = await parseMessageBody(
|
|
124
70
|
Buffer.from(
|
|
125
71
|
[
|
|
@@ -131,33 +77,24 @@ describe("classifyParsedMessage", () => {
|
|
|
131
77
|
].join("\r\n"),
|
|
132
78
|
),
|
|
133
79
|
);
|
|
134
|
-
let reads = 0;
|
|
135
80
|
|
|
136
81
|
const classification = await classifyParsedMessage(
|
|
137
|
-
|
|
138
|
-
reads++;
|
|
139
|
-
throw new NotFoundError("no Address row");
|
|
140
|
-
}),
|
|
82
|
+
store.address,
|
|
141
83
|
"acc-1",
|
|
142
84
|
parsed,
|
|
143
85
|
"inbox",
|
|
144
86
|
);
|
|
145
87
|
|
|
146
|
-
assert.equal(reads, 0);
|
|
147
88
|
assert.equal(classification.category, "personal");
|
|
148
89
|
});
|
|
149
90
|
});
|
|
150
91
|
|
|
151
92
|
describe("classifyParsedMessage sender mismatch", () => {
|
|
152
|
-
const noAddress = addressService(async () => {
|
|
153
|
-
throw new NotFoundError("no Address row");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
93
|
it("compares the sender of mail in Junk the provider rated clean", async () => {
|
|
157
94
|
const parsed = await parseMessageBody(IMPERSONATION_EML);
|
|
158
95
|
|
|
159
96
|
const classification = await classifyParsedMessage(
|
|
160
|
-
|
|
97
|
+
store.address,
|
|
161
98
|
"acc-1",
|
|
162
99
|
parsed,
|
|
163
100
|
"junk",
|
|
@@ -176,7 +113,7 @@ describe("classifyParsedMessage sender mismatch", () => {
|
|
|
176
113
|
const parsed = await parseMessageBody(IMPERSONATION_EML);
|
|
177
114
|
|
|
178
115
|
const classification = await classifyParsedMessage(
|
|
179
|
-
|
|
116
|
+
store.address,
|
|
180
117
|
"acc-1",
|
|
181
118
|
parsed,
|
|
182
119
|
"inbox",
|
|
@@ -1,36 +1,32 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
IMailboxRepository,
|
|
5
|
-
IMessageRepository,
|
|
6
|
-
MailboxItem,
|
|
7
|
-
MessageItem,
|
|
8
|
-
} from "@remit/data-ports";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, MailboxItem, MessageItem } from "@remit/data-ports";
|
|
9
4
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
10
5
|
import {
|
|
11
6
|
confirmDepartures,
|
|
12
7
|
placementKey,
|
|
13
8
|
sightingContestsPlacement,
|
|
14
9
|
} from "./external-move.js";
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
10
|
+
import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
|
|
11
|
+
import {
|
|
12
|
+
openStore,
|
|
13
|
+
type Store,
|
|
14
|
+
seedAccount,
|
|
15
|
+
seedMailbox,
|
|
16
|
+
seedMessage,
|
|
17
|
+
silentLog,
|
|
18
|
+
} from "./test-helpers/test-store.js";
|
|
19
|
+
|
|
20
|
+
const mailboxAt = (fullPath: string): MailboxItem =>
|
|
22
21
|
({
|
|
23
22
|
mailboxId: `mbx-${fullPath}`,
|
|
24
23
|
fullPath,
|
|
25
|
-
uidValidity,
|
|
24
|
+
uidValidity: 1,
|
|
26
25
|
hierarchyDelimiter: "/",
|
|
27
|
-
...(specialUse ? { specialUse } : {}),
|
|
28
26
|
}) as MailboxItem;
|
|
29
27
|
|
|
30
28
|
const INBOX = mailboxAt("INBOX");
|
|
31
|
-
const LABEL = mailboxAt("Receipts");
|
|
32
29
|
const JUNK = mailboxAt("INBOX/Rubbish");
|
|
33
|
-
const ALL_MAIL = mailboxAt("[Gmail]/All Mail", ["All"]);
|
|
34
30
|
|
|
35
31
|
const storedIn = (
|
|
36
32
|
mailbox: MailboxItem,
|
|
@@ -45,234 +41,131 @@ const storedIn = (
|
|
|
45
41
|
...overrides,
|
|
46
42
|
}) as MessageItem;
|
|
47
43
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const connection = {
|
|
72
|
-
openBox: async (fullPath: string) => {
|
|
73
|
-
if (unreachable.includes(fullPath)) {
|
|
74
|
-
throw new Error(`no such mailbox on the server: ${fullPath}`);
|
|
75
|
-
}
|
|
76
|
-
opened.push(fullPath);
|
|
77
|
-
const source = sources.find((box) => box.fullPath === fullPath);
|
|
78
|
-
return { uidvalidity: served[fullPath] ?? source?.uidValidity ?? 1 };
|
|
79
|
-
},
|
|
80
|
-
search: async (criteria: unknown[]) => {
|
|
81
|
-
const [[, uids]] = criteria as Array<[string, string]>;
|
|
82
|
-
searched.push(uids);
|
|
83
|
-
const current = opened[opened.length - 1];
|
|
84
|
-
const asked = uids.split(",").map(Number);
|
|
85
|
-
return (held[current] ?? []).filter((uid) => asked.includes(uid));
|
|
86
|
-
},
|
|
87
|
-
} as unknown as Pick<IImapConnection, "openBox" | "search">;
|
|
44
|
+
let store: Store;
|
|
45
|
+
let imap: FakeImapServer;
|
|
46
|
+
let account: AccountItem;
|
|
47
|
+
let inbox: MailboxItem;
|
|
48
|
+
let receipts: MailboxItem;
|
|
49
|
+
let allMail: MailboxItem;
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
store = await openStore();
|
|
53
|
+
imap = new FakeImapServer();
|
|
54
|
+
account = await seedAccount(store);
|
|
55
|
+
inbox = await seedMailbox(store, account, "INBOX");
|
|
56
|
+
receipts = await seedMailbox(store, account, "Receipts");
|
|
57
|
+
allMail = await seedMailbox(store, account, "[Gmail]/All Mail", {
|
|
58
|
+
specialUse: "All",
|
|
59
|
+
});
|
|
60
|
+
for (const path of ["INBOX", "Receipts", "[Gmail]/All Mail"]) {
|
|
61
|
+
imap.addFolder(path);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => store.close());
|
|
88
66
|
|
|
67
|
+
const rowsIn = async (mailbox: MailboxItem, count: number) => {
|
|
68
|
+
const ids: string[] = [];
|
|
69
|
+
for (let i = 0; i < count; i++) {
|
|
70
|
+
const uid = imap.deliver(mailbox.fullPath, eml({}));
|
|
71
|
+
ids.push(
|
|
72
|
+
(await seedMessage(store, account, mailbox, uid)).message.messageId,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return ids;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const probe = async (sightedIn: MailboxItem, messageIds: string[]) => {
|
|
79
|
+
const connection = imap.connection();
|
|
80
|
+
const before = imap.log.length;
|
|
89
81
|
const verdicts = await confirmDepartures(
|
|
90
82
|
{
|
|
91
83
|
connection,
|
|
92
|
-
mailboxService:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!source) throw new Error(`no such mailbox: ${mailboxId}`);
|
|
96
|
-
return source;
|
|
97
|
-
},
|
|
98
|
-
} as unknown as Pick<IMailboxRepository, "get">,
|
|
99
|
-
messageService: {
|
|
100
|
-
get: async () => rows,
|
|
101
|
-
} as unknown as Pick<IMessageRepository, "get">,
|
|
102
|
-
log: { info: () => {}, warn: () => {} },
|
|
84
|
+
mailboxService: store.mailbox,
|
|
85
|
+
messageService: store.message,
|
|
86
|
+
log: silentLog,
|
|
103
87
|
},
|
|
104
|
-
|
|
88
|
+
account.accountId,
|
|
105
89
|
sightedIn,
|
|
106
|
-
|
|
90
|
+
messageIds,
|
|
107
91
|
);
|
|
108
|
-
|
|
109
|
-
return {
|
|
92
|
+
const asked = imap.log.slice(before);
|
|
93
|
+
return {
|
|
94
|
+
...verdicts,
|
|
95
|
+
opened: asked.filter((line) => /^(SELECT|EXAMINE) /.test(line)),
|
|
96
|
+
searched: asked.filter((line) => line.startsWith("SEARCH ")),
|
|
97
|
+
};
|
|
110
98
|
};
|
|
111
99
|
|
|
112
100
|
describe("whether a sighting in a second folder is a move", () => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
* folders. Reading the second folder's sighting as a move took the message
|
|
117
|
-
* out of the Inbox on the round that enumerated the label.
|
|
118
|
-
*/
|
|
119
|
-
it("is not a move while the folder the row points at still holds it", async () => {
|
|
120
|
-
const observed = await probe(LABEL, [storedIn(INBOX)], {
|
|
121
|
-
INBOX: [7],
|
|
122
|
-
});
|
|
101
|
+
it("asks each source folder once, about the contested uids only", async () => {
|
|
102
|
+
const ids = await rowsIn(inbox, 3);
|
|
103
|
+
imap.expunge("INBOX", 2);
|
|
123
104
|
|
|
124
|
-
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("is a move once the folder the row points at has let it go", async () => {
|
|
128
|
-
const observed = await probe(LABEL, [storedIn(INBOX)], {
|
|
129
|
-
INBOX: [3, 11],
|
|
130
|
-
});
|
|
105
|
+
const observed = await probe(receipts, ids);
|
|
131
106
|
|
|
107
|
+
assert.deepEqual(observed.opened, ["EXAMINE INBOX"]);
|
|
108
|
+
assert.equal(observed.searched.length, 1);
|
|
109
|
+
const [, uids] = /"UID","([\d,]+)"/.exec(observed.searched[0]) ?? [];
|
|
110
|
+
assert.deepEqual(uids?.split(",").sort(), ["1", "2", "3"]);
|
|
132
111
|
assert.deepEqual(
|
|
133
112
|
[...observed.departed],
|
|
134
|
-
[placementKey(
|
|
135
|
-
);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("names the placement it was answered for, so a row that moved since is left alone", async () => {
|
|
139
|
-
const observed = await probe(JUNK, [storedIn(INBOX, { uid: 42 })], {
|
|
140
|
-
INBOX: [],
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
assert.equal(
|
|
144
|
-
observed.departed.has(placementKey("msg-1", INBOX.mailboxId, 7)),
|
|
145
|
-
false,
|
|
146
|
-
);
|
|
147
|
-
assert.equal(
|
|
148
|
-
observed.departed.has(placementKey("msg-1", INBOX.mailboxId, 42)),
|
|
149
|
-
true,
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("asks each source folder once for the whole batch", async () => {
|
|
154
|
-
const observed = await probe(
|
|
155
|
-
LABEL,
|
|
156
|
-
[
|
|
157
|
-
storedIn(INBOX, { messageId: "msg-1", uid: 7 }),
|
|
158
|
-
storedIn(INBOX, { messageId: "msg-2", uid: 8 }),
|
|
159
|
-
storedIn(INBOX, { messageId: "msg-3", uid: 9 }),
|
|
160
|
-
],
|
|
161
|
-
{ INBOX: [7] },
|
|
113
|
+
[placementKey(ids[1], inbox.mailboxId, 2)],
|
|
162
114
|
);
|
|
163
|
-
|
|
164
|
-
assert.deepEqual(observed.opened, ["INBOX"]);
|
|
165
|
-
assert.deepEqual(observed.searched, ["7,8,9"]);
|
|
115
|
+
assert.equal(observed.settled.size, 3);
|
|
166
116
|
});
|
|
167
117
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
* page of uids per batch out of every source folder an account has, which on
|
|
171
|
-
* a large Inbox is traffic proportional to the mailbox rather than to the
|
|
172
|
-
* question.
|
|
173
|
-
*/
|
|
174
|
-
it("asks about the contested uids, not the whole source folder", async () => {
|
|
175
|
-
const observed = await probe(LABEL, [storedIn(INBOX, { uid: 42 })], {
|
|
176
|
-
INBOX: [1, 2, 42],
|
|
177
|
-
});
|
|
118
|
+
it("asks nothing when no row is contested", async () => {
|
|
119
|
+
const ids = await rowsIn(inbox, 1);
|
|
178
120
|
|
|
179
|
-
|
|
180
|
-
assert.deepEqual([...observed.departed], []);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("asks nothing at all when no row is contested", async () => {
|
|
184
|
-
const observed = await probe(INBOX, [storedIn(INBOX)], { INBOX: [7] });
|
|
121
|
+
const observed = await probe(inbox, ids);
|
|
185
122
|
|
|
186
123
|
assert.deepEqual(observed.opened, []);
|
|
187
|
-
assert.deepEqual([...observed.departed], []);
|
|
188
124
|
});
|
|
189
125
|
|
|
190
|
-
it("
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
assert.deepEqual(observed.opened, []);
|
|
194
|
-
assert.deepEqual([...observed.departed], []);
|
|
195
|
-
});
|
|
126
|
+
it("frees a row whose source copies every message, without asking it", async () => {
|
|
127
|
+
const ids = await rowsIn(allMail, 1);
|
|
196
128
|
|
|
197
|
-
|
|
198
|
-
const observed = await probe(
|
|
199
|
-
LABEL,
|
|
200
|
-
[storedIn(INBOX, { status: MessageStatus.moving })],
|
|
201
|
-
{ INBOX: [] },
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
assert.deepEqual(observed.opened, []);
|
|
205
|
-
assert.deepEqual([...observed.departed], []);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* All Mail, Starred and Important hold a copy of every message and release
|
|
210
|
-
* nothing, so a row that landed on one — its sync ran before the real
|
|
211
|
-
* folder's — would answer "still here" forever and freeze there. Reachable on
|
|
212
|
-
* any Gmail account whose mail takes more than one batch.
|
|
213
|
-
*/
|
|
214
|
-
it("frees a row whose source is a folder that copies every message", async () => {
|
|
215
|
-
const observed = await probe(
|
|
216
|
-
LABEL,
|
|
217
|
-
[storedIn(ALL_MAIL)],
|
|
218
|
-
{ "[Gmail]/All Mail": [7] },
|
|
219
|
-
{},
|
|
220
|
-
[ALL_MAIL],
|
|
221
|
-
);
|
|
129
|
+
const observed = await probe(receipts, ids);
|
|
222
130
|
|
|
223
131
|
assert.deepEqual(observed.opened, []);
|
|
224
132
|
assert.deepEqual(
|
|
225
133
|
[...observed.departed],
|
|
226
|
-
[placementKey(
|
|
134
|
+
[placementKey(ids[0], allMail.mailboxId, 1)],
|
|
227
135
|
);
|
|
228
136
|
});
|
|
229
137
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
*/
|
|
236
|
-
it("leaves a source folder it cannot reach undecided rather than failing the round", async () => {
|
|
237
|
-
const observed = await probe(
|
|
238
|
-
LABEL,
|
|
239
|
-
[storedIn(INBOX)],
|
|
240
|
-
{ INBOX: [] },
|
|
241
|
-
{},
|
|
242
|
-
[INBOX],
|
|
243
|
-
["INBOX"],
|
|
244
|
-
);
|
|
138
|
+
it("leaves a source it cannot reach undecided rather than failing the round", async () => {
|
|
139
|
+
const ids = await rowsIn(inbox, 1);
|
|
140
|
+
imap.removeFolder("INBOX");
|
|
141
|
+
|
|
142
|
+
const observed = await probe(receipts, ids);
|
|
245
143
|
|
|
246
|
-
assert.
|
|
247
|
-
assert.
|
|
144
|
+
assert.equal(observed.departed.size, 0);
|
|
145
|
+
assert.equal(observed.settled.size, 0);
|
|
248
146
|
});
|
|
249
147
|
|
|
250
|
-
it("
|
|
251
|
-
const
|
|
148
|
+
it("asks for a save only where the placement changes or is undecided", async () => {
|
|
149
|
+
const [held, gone] = await rowsIn(inbox, 2);
|
|
150
|
+
imap.expunge("INBOX", 2);
|
|
252
151
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
152
|
+
const decided = await probe(receipts, [held, gone]);
|
|
153
|
+
imap.removeFolder("INBOX");
|
|
154
|
+
const undecided = await probe(receipts, [held]);
|
|
155
|
+
|
|
156
|
+
assert.deepEqual([...decided.needsSave], [gone]);
|
|
157
|
+
assert.deepEqual([...undecided.needsSave], [held]);
|
|
257
158
|
});
|
|
258
159
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const observed = await probe(
|
|
266
|
-
LABEL,
|
|
267
|
-
[storedIn(INBOX)],
|
|
268
|
-
{ INBOX: [] },
|
|
269
|
-
{
|
|
270
|
-
INBOX: 99,
|
|
271
|
-
},
|
|
272
|
-
);
|
|
160
|
+
it("refuses a source folder re-keyed under it", async () => {
|
|
161
|
+
const ids = await rowsIn(inbox, 1);
|
|
162
|
+
imap.expunge("INBOX", 1);
|
|
163
|
+
imap.renumber("INBOX", 99);
|
|
164
|
+
|
|
165
|
+
const observed = await probe(receipts, ids);
|
|
273
166
|
|
|
274
|
-
assert.
|
|
275
|
-
assert.
|
|
167
|
+
assert.equal(observed.departed.size, 0);
|
|
168
|
+
assert.equal(observed.settled.size, 0);
|
|
276
169
|
});
|
|
277
170
|
});
|
|
278
171
|
|
package/src/external-move.ts
CHANGED
|
@@ -73,6 +73,11 @@ export interface DepartureVerdicts {
|
|
|
73
73
|
* rebuild, which is the silent never-repair this whole change is about.
|
|
74
74
|
*/
|
|
75
75
|
settled: Set<string>;
|
|
76
|
+
/**
|
|
77
|
+
* Message ids whose sighting has to go through the save pipeline: departed,
|
|
78
|
+
* or contested with no verdict. Every other sighting changes no placement.
|
|
79
|
+
*/
|
|
80
|
+
needsSave: Set<string>;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
export interface DepartureProbeDeps {
|
|
@@ -119,6 +124,7 @@ export const confirmDepartures = async (
|
|
|
119
124
|
const verdicts: DepartureVerdicts = {
|
|
120
125
|
departed: new Set<string>(),
|
|
121
126
|
settled: new Set<string>(),
|
|
127
|
+
needsSave: new Set<string>(),
|
|
122
128
|
};
|
|
123
129
|
if (messageIds.length === 0) return verdicts;
|
|
124
130
|
|
|
@@ -154,6 +160,13 @@ export const confirmDepartures = async (
|
|
|
154
160
|
);
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
for (const row of contested) {
|
|
164
|
+
const key = placementKey(row.messageId, row.mailboxId, row.uid);
|
|
165
|
+
if (verdicts.departed.has(key) || !verdicts.settled.has(key)) {
|
|
166
|
+
verdicts.needsSave.add(row.messageId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
157
170
|
return verdicts;
|
|
158
171
|
};
|
|
159
172
|
|