@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.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. 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
- const addressService = (
60
- behavior: (accountConfigId: string, addressId: string) => unknown,
61
- ): Pick<IAddressRepository, "getAddress"> => ({
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
- addressService(async () => {
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("substitutes the sender's flags.category override outright", async () => {
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
- addressService(async () => {
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
- noAddress,
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
- noAddress,
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 type { IImapConnection } from "./types.js";
16
-
17
- const mailboxAt = (
18
- fullPath: string,
19
- specialUse?: string[],
20
- uidValidity = 1,
21
- ): MailboxItem =>
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
- interface Probe {
49
- opened: string[];
50
- searched: string[];
51
- departed: Set<string>;
52
- settled: Set<string>;
53
- }
54
-
55
- /**
56
- * Ask whether the messages this batch sighted in `sightedIn` have left the
57
- * folders their rows point at, against a server where `held` names the uids each
58
- * folder still holds and `served` the UIDVALIDITY it answers on SELECT.
59
- */
60
- const probe = async (
61
- sightedIn: MailboxItem,
62
- rows: MessageItem[],
63
- held: Record<string, number[]>,
64
- served: Record<string, number> = {},
65
- sources: MailboxItem[] = [INBOX],
66
- unreachable: string[] = [],
67
- ): Promise<Probe> => {
68
- const opened: string[] = [];
69
- const searched: string[] = [];
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
- get: async (_accountId: string, mailboxId: string) => {
94
- const source = sources.find((box) => box.mailboxId === mailboxId);
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
- "acct-1",
88
+ account.accountId,
105
89
  sightedIn,
106
- rows.map((row) => row.messageId),
90
+ messageIds,
107
91
  );
108
-
109
- return { opened, searched, ...verdicts };
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
- * The reported bug (#1146). A Gmail user label, a Sieve `fileinto` beside a
115
- * `keep` and a Sent copy a list echoes back all put one message in two real
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
- assert.deepEqual([...observed.departed], []);
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("msg-1", INBOX.mailboxId, 7)],
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
- * The answer needed is about the contested uids. A SEARCH ALL would pull a
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
- assert.deepEqual(observed.searched, ["42"]);
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("asks nothing for a folder that holds a copy of every message", async () => {
191
- const observed = await probe(ALL_MAIL, [storedIn(INBOX)], { INBOX: [] });
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
- it("asks nothing for a row whose own mutation has not settled", async () => {
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("msg-1", ALL_MAIL.mailboxId, 7)],
134
+ [placementKey(ids[0], allMail.mailboxId, 1)],
227
135
  );
228
136
  });
229
137
 
230
- /**
231
- * Reader shares its mailboxes, so another client renaming or deleting the
232
- * source folder between the row being written and this round is ordinary. It
233
- * decides nothing about these rows, and it may not fail the sync of the
234
- * unrelated folder that happened to sight them.
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.deepEqual([...observed.departed], []);
247
- assert.deepEqual([...observed.settled], []);
144
+ assert.equal(observed.departed.size, 0);
145
+ assert.equal(observed.settled.size, 0);
248
146
  });
249
147
 
250
- it("settles a decided sighting, so nothing holds the watermark for it", async () => {
251
- const observed = await probe(LABEL, [storedIn(INBOX)], { INBOX: [7] });
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
- assert.deepEqual(
254
- [...observed.settled],
255
- [placementKey("msg-1", INBOX.mailboxId, 7)],
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
- * A uid means nothing without the axis it was issued on (RFC 9051 2.3.1.1),
261
- * so a source that answers a different UIDVALIDITY is evidence of nothing.
262
- * The cursor rebuild re-keys that folder; until it has, the sighting waits.
263
- */
264
- it("refuses a source folder that has been re-keyed under it", async () => {
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.deepEqual([...observed.departed], []);
275
- assert.deepEqual([...observed.settled], []);
167
+ assert.equal(observed.departed.size, 0);
168
+ assert.equal(observed.settled.size, 0);
276
169
  });
277
170
  });
278
171
 
@@ -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