@remit/mailbox-service 0.0.95 → 0.0.97

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,10 +11,8 @@ import type {
11
11
  MailboxItem,
12
12
  } from "@remit/data-ports";
13
13
  import {
14
- CANONICAL_ROLES,
15
- type CanonicalMailboxRoleValue,
16
- ROLE_NAME_HINTS,
17
14
  ROLE_SPECIAL_USE,
15
+ roleForFolderName,
18
16
  } from "@remit/data-ports/folder-role";
19
17
  import {
20
18
  MailboxCursorState,
@@ -59,30 +57,6 @@ const areSpecialUseSetsEqual = (
59
57
  return true;
60
58
  };
61
59
 
62
- /**
63
- * The role a folder's own leaf name is most conventionally for, read from the
64
- * one hint table every special-folder lookup shares (`@remit/data-ports`,
65
- * #837). A name that several roles list goes to the role that ranks it highest,
66
- * so `All Mail` is an All folder rather than a lookalike of Archive.
67
- *
68
- * A second copy of these names lived here, and it is the one that could destroy
69
- * something: it still called `Deleted` and `Bin` Trash names, which #843 dropped
70
- * precisely because they are ordinary folders a user keeps mail in — and this
71
- * lookup does not merely skip a folder, it deletes the row and everything the
72
- * client can see in it.
73
- */
74
- const roleForFolderName = (
75
- name: string,
76
- ): CanonicalMailboxRoleValue | undefined => {
77
- let best: { role: CanonicalMailboxRoleValue; rank: number } | undefined;
78
- for (const role of CANONICAL_ROLES) {
79
- const rank = ROLE_NAME_HINTS[role]?.indexOf(name) ?? -1;
80
- if (rank < 0) continue;
81
- if (!best || rank < best.rank) best = { role, rank };
82
- }
83
- return best?.role;
84
- };
85
-
86
60
  /**
87
61
  * Account info needed for mailbox sync
88
62
  */
@@ -0,0 +1,83 @@
1
+ const stringField = (value: unknown, key: string): string => {
2
+ if (!(value instanceof Object)) return "";
3
+ const field = Reflect.get(value, key);
4
+ return typeof field === "string" ? field : "";
5
+ };
6
+
7
+ /**
8
+ * Read a tagged-NO outcome out of an IMAP failure.
9
+ *
10
+ * The two outcomes below are each a folder operation finding the server already
11
+ * in the state it was asked for — the operation having happened, not failing.
12
+ * Reading them as failures marks the row `failed` and rethrows, and since folder
13
+ * management shares the account's per-account FIFO group with mailbox sync, that
14
+ * un-acked rethrow holds back every later sync for the account.
15
+ *
16
+ * Both places the server can say so are read. `message` carries it when the
17
+ * client raises the error itself; RFC 5530's response code and its text carry it
18
+ * when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
19
+ * with the code on the error, which is why matching the message alone never
20
+ * caught a real Dovecot answer.
21
+ */
22
+ const saidByServer = (error: Error): string =>
23
+ `${error.message} ${stringField(error, "responseText")}`;
24
+
25
+ /** The folder is not on the server: a delete has nothing left to do. */
26
+ export const isMailboxAbsentUpstream = (error: unknown): boolean => {
27
+ if (!(error instanceof Error)) return false;
28
+ if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
29
+ return /not found|does ?n.?t exist/i.test(saidByServer(error));
30
+ };
31
+
32
+ /** The folder is already on the server: a create has nothing left to do. */
33
+ export const isMailboxPresentUpstream = (error: unknown): boolean => {
34
+ if (!(error instanceof Error)) return false;
35
+ if (stringField(error, "serverResponseCode") === "ALREADYEXISTS") return true;
36
+ return /already exists/i.test(saidByServer(error));
37
+ };
38
+
39
+ /**
40
+ * Something after the server RENAME failed — the local settle, not the
41
+ * mutation. The rename landed, so this is never a refused rename: marking the
42
+ * rows `failed` would offer a retry of work the server has already done, and
43
+ * `fullPath` would go on naming a path the server no longer holds.
44
+ *
45
+ * It is rethrown rather than swallowed. The rows are still `pending` with their
46
+ * targets recorded, so the redelivery's guard passes and the settle runs again;
47
+ * swallowing it would ack the message and leave the subtree `pending` forever,
48
+ * skipped by message sync with no route out.
49
+ */
50
+ /**
51
+ * The folder a rename was to move is confirmed gone: the server refused the
52
+ * RENAME as non-existent, and its own listing holds neither the path being left
53
+ * nor the path being aimed at.
54
+ *
55
+ * It is a distinct kind because it is the only answer that may remove a folder
56
+ * and its mail. Re-reading the server's response code at the call site would
57
+ * make every unclassifiable `NONEXISTENT` — a listing that could not be read, a
58
+ * path the comparison normalized wrongly — destroy the user's mail; here the
59
+ * decision is made once, where the evidence is.
60
+ */
61
+ export class FolderGoneUpstreamError extends Error {
62
+ name = "FolderGoneUpstreamError";
63
+ readonly cause: unknown;
64
+
65
+ constructor(mailboxId: string, oldPath: string, cause: unknown) {
66
+ super(
67
+ `Folder ${mailboxId} is gone from the mail server: neither "${oldPath}" nor its rename target is listed`,
68
+ );
69
+ this.cause = cause;
70
+ }
71
+ }
72
+
73
+ export class FolderRenameSettleError extends Error {
74
+ name = "FolderRenameSettleError";
75
+ readonly cause: unknown;
76
+
77
+ constructor(mailboxId: string, confirmedPath: string, cause: unknown) {
78
+ super(
79
+ `Folder ${mailboxId} was renamed to "${confirmedPath}" but the local settle did not finish`,
80
+ );
81
+ this.cause = cause;
82
+ }
83
+ }
@@ -265,11 +265,18 @@ const buildHarness = (options: HarnessOptions): Harness => {
265
265
  },
266
266
  ) as IAddressRepository;
267
267
 
268
+ // The rows sync reads back before it saves, to ask whether a message sighted
269
+ // here has left the folder its row points at (#1146). This harness stores
270
+ // none, so nothing is contested and the probe issues no IMAP.
271
+ const messageRepository = {
272
+ get: async () => [],
273
+ } as unknown as IMessageRepository;
274
+
268
275
  const service = new MessageSyncService(
269
276
  connectionFactory,
270
277
  mailboxService,
271
278
  noFolderRoles,
272
- {} as IMessageRepository,
279
+ messageRepository,
273
280
  {} as IEnvelopeRepository,
274
281
  addressRepository,
275
282
  threadMessageService,
@@ -174,6 +174,13 @@ const buildHarness = (options: {
174
174
  },
175
175
  } satisfies IQuarantineRepository;
176
176
 
177
+ // The rows sync reads back before it saves, to ask whether a message sighted
178
+ // here has left the folder its row points at (#1146). This harness stores
179
+ // none, so nothing is contested and the probe issues no IMAP.
180
+ const messageRepository = {
181
+ get: async () => [],
182
+ } as unknown as IMessageRepository;
183
+
177
184
  const service = new MessageSyncService(
178
185
  {
179
186
  getConnection: () => connection,
@@ -181,7 +188,7 @@ const buildHarness = (options: {
181
188
  } as ManagedConnectionFactory,
182
189
  mailboxService,
183
190
  noFolderRoles,
184
- {} as IMessageRepository,
191
+ messageRepository,
185
192
  {} as IEnvelopeRepository,
186
193
  {} as IAddressRepository,
187
194
  threadMessageService,
@@ -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
- });