@remit/mailbox-service 0.0.95 → 0.0.96

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.
@@ -11,13 +11,11 @@ import type {
11
11
  ThreadMessageItem,
12
12
  UpdateThreadMessageInput,
13
13
  } from "@remit/data-ports";
14
+ import { deriveMessageId } from "@remit/data-ports/id";
14
15
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
15
16
  import type { ManagedConnectionFactory } from "./connection-factory.js";
16
- import {
17
- type AccountFolderRoles,
18
- MessageSyncService,
19
- repointsOnSighting,
20
- } from "./message-sync.js";
17
+ import { type DepartureVerdicts, placementKey } from "./external-move.js";
18
+ import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
21
19
  import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
22
20
  import type { ImapEnvelope, ImapMessage } from "./types.js";
23
21
 
@@ -85,8 +83,22 @@ interface Observed {
85
83
  threadUpdates: UpdateThreadMessageInput[];
86
84
  reconciled: string[];
87
85
  owned: boolean;
86
+ unresolved: boolean;
88
87
  }
89
88
 
89
+ /**
90
+ * What `confirmDepartures` had answered about the stored placement by the time
91
+ * the batch was saved (#1146).
92
+ *
93
+ * - `departed` — the source folder no longer holds it: a move another client made.
94
+ * - `held` — the source folder still holds it: the labelled message, in two
95
+ * folders at once.
96
+ * - `none` — no verdict at all: the source could not be asked this round.
97
+ * - `stale` — a verdict, but for a placement this row no longer has, because it
98
+ * moved between the probe and the save.
99
+ */
100
+ type Verdict = "departed" | "held" | "none" | "stale";
101
+
90
102
  /**
91
103
  * Sync one message out of `sighting`, against a database that already holds it
92
104
  * under `stored`. The account's Junk folder is `JUNK` and its Trash is `TRASH`,
@@ -97,12 +109,14 @@ const sync = async (
97
109
  stored: MessageItem,
98
110
  threadRow: ThreadMessageItem | null = threadRowIn(INBOX),
99
111
  copiesHere: MessageItem[] = [],
112
+ verdict: Verdict = "departed",
100
113
  ): Promise<Observed> => {
101
114
  const observed: Observed = {
102
115
  repointedTo: [],
103
116
  threadUpdates: [],
104
117
  reconciled: [],
105
118
  owned: false,
119
+ unresolved: false,
106
120
  };
107
121
 
108
122
  const messageService = {
@@ -164,6 +178,19 @@ const sync = async (
164
178
  envelope,
165
179
  } as unknown as ImapMessage;
166
180
 
181
+ const answeredFor = placementKey(
182
+ deriveMessageId("acct-1", envelope.messageId),
183
+ stored.mailboxId,
184
+ verdict === "stale" ? stored.uid + 1 : stored.uid,
185
+ );
186
+ const departures: DepartureVerdicts = {
187
+ departed:
188
+ verdict === "held" || verdict === "none"
189
+ ? new Set()
190
+ : new Set([answeredFor]),
191
+ settled: verdict === "none" ? new Set() : new Set([answeredFor]),
192
+ };
193
+
167
194
  const result = (await (
168
195
  service as unknown as {
169
196
  saveMessage: (
@@ -172,15 +199,24 @@ const sync = async (
172
199
  accountConfigId: string,
173
200
  msg: ImapMessage,
174
201
  roles: AccountFolderRoles,
175
- ) => Promise<{ owned: boolean }>;
202
+ departures: DepartureVerdicts,
203
+ ) => Promise<{ owned: boolean; unresolvedSighting: boolean }>;
176
204
  }
177
- ).saveMessage(sighting, "acct-1", "cfg-1", msg, {
178
- junkMailboxId: JUNK.mailboxId,
179
- trashMailboxId: TRASH.mailboxId,
180
- configJunkRoles: NO_JUNK_ROLES,
181
- })) as { owned: boolean };
205
+ ).saveMessage(
206
+ sighting,
207
+ "acct-1",
208
+ "cfg-1",
209
+ msg,
210
+ {
211
+ junkMailboxId: JUNK.mailboxId,
212
+ trashMailboxId: TRASH.mailboxId,
213
+ configJunkRoles: NO_JUNK_ROLES,
214
+ },
215
+ departures,
216
+ )) as { owned: boolean; unresolvedSighting: boolean };
182
217
 
183
218
  observed.owned = result.owned;
219
+ observed.unresolved = result.unresolvedSighting;
184
220
  return observed;
185
221
  };
186
222
 
@@ -227,6 +263,87 @@ describe("which folder a message the database already holds lives in", () => {
227
263
  assert.equal(observed.owned, false);
228
264
  });
229
265
 
266
+ /**
267
+ * The reported bug (#1146): a message that legitimately sits in two real
268
+ * folders — a Gmail user label, a Sieve `fileinto` beside a `keep`, a Sent
269
+ * copy a list echoes back — was re-pointed out of the Inbox by whichever
270
+ * folder the round enumerated last.
271
+ */
272
+ it("leaves a labelled message in the folder that still holds it", async () => {
273
+ const observed = await sync(
274
+ mailboxAt("Receipts"),
275
+ storedIn(INBOX),
276
+ threadRowIn(INBOX),
277
+ [],
278
+ "held",
279
+ );
280
+
281
+ assert.deepEqual(observed.repointedTo, []);
282
+ assert.deepEqual(observed.threadUpdates, []);
283
+ assert.equal(observed.owned, false);
284
+ });
285
+
286
+ /**
287
+ * A decided sighting is decided for good: the message is in two folders and
288
+ * asking again next round would get the same answer. Only an UNDECIDED one
289
+ * comes back, so a settled decline must not hold the watermark.
290
+ */
291
+ it("finishes with a labelled message rather than holding the watermark for it", async () => {
292
+ const observed = await sync(
293
+ mailboxAt("Receipts"),
294
+ storedIn(INBOX),
295
+ threadRowIn(INBOX),
296
+ [],
297
+ "held",
298
+ );
299
+
300
+ assert.equal(observed.unresolved, false);
301
+ });
302
+
303
+ it("holds the watermark when the source folder could not be asked", async () => {
304
+ const observed = await sync(
305
+ JUNK,
306
+ storedIn(INBOX),
307
+ threadRowIn(INBOX),
308
+ [],
309
+ "none",
310
+ );
311
+
312
+ assert.deepEqual(observed.repointedTo, []);
313
+ assert.equal(observed.unresolved, true);
314
+ });
315
+
316
+ /**
317
+ * The probe reads the row outside the transaction that acts on it, so a user
318
+ * move landing in between leaves a verdict about a placement this row no
319
+ * longer has. Spending it would re-point the row against an answer reached
320
+ * for somewhere else.
321
+ */
322
+ it("spends no verdict reached for a placement the row has since left", async () => {
323
+ const observed = await sync(
324
+ JUNK,
325
+ storedIn(INBOX),
326
+ threadRowIn(INBOX),
327
+ [],
328
+ "stale",
329
+ );
330
+
331
+ assert.deepEqual(observed.repointedTo, []);
332
+ assert.equal(observed.unresolved, true);
333
+ });
334
+
335
+ it("holds the watermark for nothing when the row is already ours", async () => {
336
+ const observed = await sync(
337
+ INBOX,
338
+ storedIn(INBOX),
339
+ threadRowIn(INBOX),
340
+ [],
341
+ "none",
342
+ );
343
+
344
+ assert.equal(observed.unresolved, false);
345
+ });
346
+
230
347
  it("declines a sighting in a folder that copies every message", async () => {
231
348
  const observed = await sync(ALL_MAIL, storedIn(INBOX));
232
349
 
@@ -300,95 +417,3 @@ describe("what a re-pointed message does to its sender's standing", () => {
300
417
  assert.deepEqual(observed.reconciled, []);
301
418
  });
302
419
  });
303
-
304
- describe("repointsOnSighting", () => {
305
- it("refuses a Gmail virtual folder the server never flagged", () => {
306
- assert.equal(
307
- repointsOnSighting(mailboxAt("[Gmail]/All Mail"), storedIn(INBOX)),
308
- false,
309
- );
310
- });
311
-
312
- it("accepts a folder the user named after a virtual one", () => {
313
- assert.equal(
314
- repointsOnSighting(mailboxAt("Starred ideas"), storedIn(INBOX)),
315
- true,
316
- );
317
- });
318
-
319
- it("accepts an ordinary inbound row the sync path left pending", () => {
320
- assert.equal(
321
- repointsOnSighting(
322
- JUNK,
323
- storedIn(INBOX, { syncStatus: MessageSyncStatus.pending }),
324
- ),
325
- true,
326
- );
327
- });
328
-
329
- it("accepts a row a settled mutation marked synced", () => {
330
- assert.equal(
331
- repointsOnSighting(
332
- JUNK,
333
- storedIn(INBOX, { syncStatus: MessageSyncStatus.synced }),
334
- ),
335
- true,
336
- );
337
- });
338
-
339
- /**
340
- * The row `abandonDelete` hands back: reader refused the delete, put the
341
- * message back where the server still has it, and nothing else is coming
342
- * for the row. Reader shares its mailboxes, so the user moving that same
343
- * message in another client is ordinary — and before R3 the sighting was
344
- * refused on `syncStatus` alone and the row never followed the move.
345
- */
346
- it("accepts a row whose delete was abandoned, so a move made elsewhere still lands", () => {
347
- assert.equal(
348
- repointsOnSighting(
349
- JUNK,
350
- storedIn(INBOX, {
351
- status: MessageStatus.active,
352
- syncStatus: MessageSyncStatus.abandoned,
353
- }),
354
- ),
355
- true,
356
- );
357
- });
358
-
359
- it("accepts a row left `failed` by a transient attempt that has since settled", () => {
360
- assert.equal(
361
- repointsOnSighting(
362
- JUNK,
363
- storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
364
- ),
365
- true,
366
- );
367
- });
368
-
369
- it("refuses a row whose own move is still in flight", () => {
370
- assert.equal(
371
- repointsOnSighting(
372
- JUNK,
373
- storedIn(INBOX, {
374
- status: MessageStatus.moving,
375
- syncStatus: MessageSyncStatus.pending,
376
- }),
377
- ),
378
- false,
379
- );
380
- });
381
-
382
- it("refuses a row whose own delete is still in flight", () => {
383
- assert.equal(
384
- repointsOnSighting(
385
- JUNK,
386
- storedIn(INBOX, {
387
- status: MessageStatus.deleting,
388
- syncStatus: MessageSyncStatus.pending,
389
- }),
390
- ),
391
- false,
392
- );
393
- });
394
- });