@remit/mailbox-service 0.0.80 → 0.0.82

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.80",
3
+ "version": "0.0.82",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,374 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IAddressRepository,
5
+ IEnvelopeRepository,
6
+ IMailboxRepository,
7
+ IMessageRepository,
8
+ IThreadMessageRepository,
9
+ MailboxItem,
10
+ MessageItem,
11
+ ThreadMessageItem,
12
+ UpdateThreadMessageInput,
13
+ } from "@remit/data-ports";
14
+ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
15
+ import type { ManagedConnectionFactory } from "./connection-factory.js";
16
+ import {
17
+ type AccountFolderRoles,
18
+ MessageSyncService,
19
+ repointsOnSighting,
20
+ } from "./message-sync.js";
21
+ import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
22
+ import type { ImapEnvelope, ImapMessage } from "./types.js";
23
+
24
+ const stub = <T>(): T => ({}) as T;
25
+
26
+ const envelope: ImapEnvelope = {
27
+ date: new Date(0).toISOString(),
28
+ messageId: "<root@example.com>",
29
+ subject: "Subject",
30
+ from: [{ name: "Pharma Deals", mailbox: "sales", host: "pharma.example" }],
31
+ sender: [],
32
+ replyTo: [],
33
+ to: [{ name: "", mailbox: "victim", host: "ischen.nl" }],
34
+ cc: [],
35
+ bcc: [],
36
+ inReplyTo: "",
37
+ };
38
+
39
+ const mailboxAt = (fullPath: string, specialUse?: string[]): MailboxItem =>
40
+ ({
41
+ mailboxId: `mbx-${fullPath}`,
42
+ fullPath,
43
+ hierarchyDelimiter: "/",
44
+ ...(specialUse ? { specialUse } : {}),
45
+ }) as MailboxItem;
46
+
47
+ const INBOX = mailboxAt("INBOX");
48
+ const JUNK = mailboxAt("INBOX/Rubbish");
49
+ const TRASH = mailboxAt("Trash");
50
+ const ALL_MAIL = mailboxAt("[Gmail]/All Mail", ["All"]);
51
+
52
+ /**
53
+ * `pending` is what an ordinary inbound row carries: `upsertWithStatus` is
54
+ * called without a `syncStatus` and the repository defaults to it, and nothing
55
+ * on the sync path ever promotes it (#1096). The fixture states the reachable
56
+ * state, not the one the gate used to demand.
57
+ */
58
+ const storedIn = (
59
+ mailbox: MailboxItem,
60
+ overrides: Partial<MessageItem> = {},
61
+ ): MessageItem =>
62
+ ({
63
+ mailboxId: mailbox.mailboxId,
64
+ uid: 7,
65
+ status: MessageStatus.active,
66
+ syncStatus: MessageSyncStatus.pending,
67
+ ...overrides,
68
+ }) as MessageItem;
69
+
70
+ const threadRowIn = (mailbox: MailboxItem): ThreadMessageItem =>
71
+ ({
72
+ threadMessageId: "tm-1",
73
+ accountConfigId: "cfg-1",
74
+ mailboxId: mailbox.mailboxId,
75
+ uid: 7,
76
+ sentDate: 0,
77
+ isRead: false,
78
+ isDeleted: mailbox === TRASH,
79
+ hasStars: false,
80
+ hasAttachment: false,
81
+ }) as ThreadMessageItem;
82
+
83
+ interface Observed {
84
+ repointedTo: Array<{ mailboxId: string; uid: number }>;
85
+ threadUpdates: UpdateThreadMessageInput[];
86
+ reconciled: string[];
87
+ owned: boolean;
88
+ }
89
+
90
+ /**
91
+ * Sync one message out of `sighting`, against a database that already holds it
92
+ * under `stored`. The account's Junk folder is `JUNK` and its Trash is `TRASH`,
93
+ * so the sighting's own role follows from which mailbox it is.
94
+ */
95
+ const sync = async (
96
+ sighting: MailboxItem,
97
+ stored: MessageItem,
98
+ threadRow: ThreadMessageItem | null = threadRowIn(INBOX),
99
+ copiesHere: MessageItem[] = [],
100
+ ): Promise<Observed> => {
101
+ const observed: Observed = {
102
+ repointedTo: [],
103
+ threadUpdates: [],
104
+ reconciled: [],
105
+ owned: false,
106
+ };
107
+
108
+ const messageService = {
109
+ upsertWithStatus: async () => ({ item: stored, created: false }),
110
+ get: async () => copiesHere,
111
+ updateUid: async (_id: string, uid: number, mailboxId: string) => {
112
+ observed.repointedTo.push({ mailboxId, uid });
113
+ return stored;
114
+ },
115
+ } as unknown as IMessageRepository;
116
+
117
+ const threadMessageService = {
118
+ findByMessageId: async () => threadRow,
119
+ update: async (
120
+ _config: string,
121
+ _id: string,
122
+ input: UpdateThreadMessageInput,
123
+ ) => {
124
+ observed.threadUpdates.push(input);
125
+ return threadRow as ThreadMessageItem;
126
+ },
127
+ create: async (input: unknown) => input as ThreadMessageItem,
128
+ } as unknown as IThreadMessageRepository;
129
+
130
+ const envelopeService = {
131
+ upsertEnvelope: async () => {},
132
+ upsertBodyParts: async () => {},
133
+ } as unknown as IEnvelopeRepository;
134
+
135
+ const addressService = {
136
+ upsertCorrespondentAddress: async () => {},
137
+ upsertJunkAddress: async () => {},
138
+ upsertAddress: async () => {},
139
+ upsertEnvelopeAddress: async () => {},
140
+ reconcileJunkOnlyForMessage: async (messageId: string) => {
141
+ observed.reconciled.push(messageId);
142
+ },
143
+ } as unknown as IAddressRepository;
144
+
145
+ const service = new MessageSyncService(
146
+ stub<ManagedConnectionFactory>(),
147
+ stub<IMailboxRepository>(),
148
+ folderRoles({
149
+ junkMailboxId: JUNK.mailboxId,
150
+ trashMailboxId: TRASH.mailboxId,
151
+ }),
152
+ messageService,
153
+ envelopeService,
154
+ addressService,
155
+ threadMessageService,
156
+ );
157
+
158
+ const msg = {
159
+ uid: 42,
160
+ seq: 1,
161
+ size: 100,
162
+ internalDate: new Date(0),
163
+ flags: [],
164
+ envelope,
165
+ } as unknown as ImapMessage;
166
+
167
+ const result = (await (
168
+ service as unknown as {
169
+ saveMessage: (
170
+ mailbox: MailboxItem,
171
+ accountId: string,
172
+ accountConfigId: string,
173
+ msg: ImapMessage,
174
+ roles: AccountFolderRoles,
175
+ ) => Promise<{ owned: boolean }>;
176
+ }
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 };
182
+
183
+ observed.owned = result.owned;
184
+ return observed;
185
+ };
186
+
187
+ describe("which folder a message the database already holds lives in", () => {
188
+ it("follows the last move when another client files it into Junk", async () => {
189
+ const observed = await sync(JUNK, storedIn(INBOX));
190
+
191
+ assert.deepEqual(observed.repointedTo, [
192
+ { mailboxId: JUNK.mailboxId, uid: 42 },
193
+ ]);
194
+ });
195
+
196
+ it("follows the last move back when another client rescues it", async () => {
197
+ const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
198
+
199
+ assert.deepEqual(observed.repointedTo, [
200
+ { mailboxId: INBOX.mailboxId, uid: 42 },
201
+ ]);
202
+ });
203
+
204
+ it("takes the UID of the folder that issued it, never the old one", async () => {
205
+ const observed = await sync(JUNK, storedIn(INBOX, { uid: 7 }));
206
+
207
+ assert.equal(observed.repointedTo[0].uid, 42);
208
+ });
209
+
210
+ it("leaves a row this folder already owns alone", async () => {
211
+ const observed = await sync(INBOX, storedIn(INBOX));
212
+
213
+ assert.deepEqual(observed.repointedTo, []);
214
+ assert.equal(observed.owned, true);
215
+ });
216
+
217
+ it("holds a row whose own move has not settled", async () => {
218
+ const observed = await sync(
219
+ INBOX,
220
+ storedIn(JUNK, {
221
+ status: MessageStatus.moving,
222
+ syncStatus: MessageSyncStatus.pending,
223
+ }),
224
+ );
225
+
226
+ assert.deepEqual(observed.repointedTo, []);
227
+ assert.equal(observed.owned, false);
228
+ });
229
+
230
+ it("declines a sighting in a folder that copies every message", async () => {
231
+ const observed = await sync(ALL_MAIL, storedIn(INBOX));
232
+
233
+ assert.deepEqual(observed.repointedTo, []);
234
+ assert.equal(observed.owned, false);
235
+ });
236
+
237
+ it("declines a sighting of a copy the user put in this folder", async () => {
238
+ const observed = await sync(JUNK, storedIn(INBOX), threadRowIn(INBOX), [
239
+ storedIn(JUNK),
240
+ ]);
241
+
242
+ assert.deepEqual(observed.repointedTo, []);
243
+ assert.deepEqual(observed.threadUpdates, []);
244
+ });
245
+
246
+ it("hands the re-pointed message to this folder's body sync", async () => {
247
+ const observed = await sync(JUNK, storedIn(INBOX));
248
+
249
+ assert.equal(observed.owned, true);
250
+ });
251
+ });
252
+
253
+ describe("what a re-pointed message does to the listing that renders it", () => {
254
+ it("moves the row the listing and the folder counts read", async () => {
255
+ const observed = await sync(JUNK, storedIn(INBOX));
256
+
257
+ assert.deepEqual(observed.threadUpdates, [
258
+ { mailboxId: JUNK.mailboxId, uid: 42, isDeleted: false },
259
+ ]);
260
+ });
261
+
262
+ it("marks the row deleted when the move was into Trash", async () => {
263
+ const observed = await sync(TRASH, storedIn(INBOX));
264
+
265
+ assert.equal(observed.threadUpdates[0].isDeleted, true);
266
+ });
267
+
268
+ it("clears the deleted mark when the move was out of Trash", async () => {
269
+ const observed = await sync(INBOX, storedIn(TRASH), threadRowIn(TRASH));
270
+
271
+ assert.equal(observed.threadUpdates[0].isDeleted, false);
272
+ });
273
+
274
+ it("writes nothing when no row renders the message yet", async () => {
275
+ const observed = await sync(JUNK, storedIn(INBOX), null);
276
+
277
+ assert.deepEqual(observed.threadUpdates, []);
278
+ assert.deepEqual(observed.repointedTo, [
279
+ { mailboxId: JUNK.mailboxId, uid: 42 },
280
+ ]);
281
+ });
282
+ });
283
+
284
+ describe("what a re-pointed message does to its sender's standing", () => {
285
+ it("re-asks the question when the move was into Junk", async () => {
286
+ const observed = await sync(JUNK, storedIn(INBOX));
287
+
288
+ assert.equal(observed.reconciled.length, 1);
289
+ });
290
+
291
+ it("re-asks it again when the move was back out of Junk", async () => {
292
+ const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
293
+
294
+ assert.equal(observed.reconciled.length, 1);
295
+ });
296
+
297
+ it("asks nothing when the pointer did not move", async () => {
298
+ const observed = await sync(INBOX, storedIn(INBOX));
299
+
300
+ assert.deepEqual(observed.reconciled, []);
301
+ });
302
+ });
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
+ it("refuses a row whose move failed and has not been re-tried", () => {
340
+ assert.equal(
341
+ repointsOnSighting(
342
+ JUNK,
343
+ storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
344
+ ),
345
+ false,
346
+ );
347
+ });
348
+
349
+ it("refuses a row whose own move is still in flight", () => {
350
+ assert.equal(
351
+ repointsOnSighting(
352
+ JUNK,
353
+ storedIn(INBOX, {
354
+ status: MessageStatus.moving,
355
+ syncStatus: MessageSyncStatus.pending,
356
+ }),
357
+ ),
358
+ false,
359
+ );
360
+ });
361
+
362
+ it("refuses a row whose own delete is still in flight", () => {
363
+ assert.equal(
364
+ repointsOnSighting(
365
+ JUNK,
366
+ storedIn(INBOX, {
367
+ status: MessageStatus.deleting,
368
+ syncStatus: MessageSyncStatus.pending,
369
+ }),
370
+ ),
371
+ false,
372
+ );
373
+ });
374
+ });
@@ -10,6 +10,7 @@ import type {
10
10
  IThreadMessageRepository,
11
11
  IUnitOfWork,
12
12
  MailboxItem,
13
+ MessageItem,
13
14
  ThreadMessageItem,
14
15
  } from "@remit/data-ports";
15
16
  import { storedDisplayName } from "@remit/data-ports/display-name";
@@ -17,15 +18,19 @@ import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
17
18
  import {
18
19
  deriveAddressId,
19
20
  deriveBodyPartId,
21
+ deriveCopyMessageId,
20
22
  deriveEnvelopeId,
21
23
  deriveMessageIdFromSource,
22
24
  deriveThreadId,
23
25
  isValidMessageId,
24
26
  } from "@remit/data-ports/id";
27
+ import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
25
28
  import {
26
29
  AddressRole,
27
30
  MailboxCursorState,
28
31
  MessageKeywordFlag,
32
+ MessageStatus,
33
+ MessageSyncStatus,
29
34
  MessageSystemFlag,
30
35
  StarColor,
31
36
  } from "@remit/domain-enums";
@@ -128,6 +133,40 @@ export const addressSightingIn = (
128
133
  return "correspondent";
129
134
  };
130
135
 
136
+ /**
137
+ * Whether a sighting of an already-stored message in this mailbox re-points the
138
+ * row at this folder (#859). A message lives in one folder and the model never
139
+ * holds it in two, so a sighting somewhere else is not a rival claim to the
140
+ * row — it is a move another client made under reader. The last move wins, in
141
+ * both directions, and junk withholding derives from the pointer that results.
142
+ *
143
+ * Two sightings are not moves. Gmail's All Mail, Starred and Important hold
144
+ * every message that also lives in a real folder, so they are views rather than
145
+ * filing locations; following one would empty every other folder's listing. And
146
+ * a row whose own mutation has not settled is in flight, not stale
147
+ * (imap-mutations R1): the server's answer predates the move reader is still
148
+ * waiting on, so taking it would undo the optimistic write. The sync path
149
+ * reconciles rather than waits (imap-mutations R2): it never blocks on a
150
+ * mutation, it re-reads the row on the next sighting after the move settles.
151
+ *
152
+ * In flight is read off `status`, never off `syncStatus` (#1096). Every
153
+ * outbound mutation writes `moving` or `deleting` and only settling returns the
154
+ * row to `active`, while an ordinary inbound row stays `pending` for its whole
155
+ * life because nothing on the sync path promotes it — so demanding `synced`
156
+ * here meant ordinary mail never re-pointed at all. `failed` is still refused:
157
+ * a mutation that gave up leaves it set on a row put back to `active`, and
158
+ * nothing routine settles that row afterwards.
159
+ */
160
+ export const repointsOnSighting = (
161
+ mailbox: MailboxItem,
162
+ message: Pick<MessageItem, "mailboxId" | "status" | "syncStatus">,
163
+ ): boolean => {
164
+ if (message.mailboxId === mailbox.mailboxId) return false;
165
+ if (isVirtualCopyMailbox(mailbox)) return false;
166
+ if (message.status !== MessageStatus.active) return false;
167
+ return message.syncStatus !== MessageSyncStatus.failed;
168
+ };
169
+
131
170
  /**
132
171
  * The name an envelope carries for an address, as it should be stored: any
133
172
  * claim to be some other address removed (issue #826), the rest of the name
@@ -194,8 +233,9 @@ export interface SyncedMessage {
194
233
 
195
234
  /**
196
235
  * Per-message save outcome. `owned` is true when the row was created by this
197
- * sync or already belongs to the current mailbox; false for a residual
198
- * cross-mailbox collision whose stored row points at a different mailbox.
236
+ * sync, already belongs to the current mailbox, or was re-pointed at it by this
237
+ * sighting; false for a collision this sync declined to follow — a virtual-copy
238
+ * folder, or a row whose own mutation has not settled.
199
239
  */
200
240
  interface SaveMessageResult extends SyncedMessage {
201
241
  owned: boolean;
@@ -1443,9 +1483,35 @@ export class MessageSyncService {
1443
1483
  envelopeId,
1444
1484
  rootBodyPartId,
1445
1485
  });
1446
- owned = created || item.mailboxId === mailboxId;
1447
1486
 
1448
- if (sighting === "junk") {
1487
+ // The server has just named this folder and this UID for a message the
1488
+ // database holds under another folder. `upsertWithStatus` left the row
1489
+ // alone, so the pointer is repaired here — mailbox and UID together,
1490
+ // because a UID only means anything inside the folder that issued it.
1491
+ const repointed =
1492
+ !created &&
1493
+ repointsOnSighting(mailbox, item) &&
1494
+ !(await this.holdsCopyOf(repos.message, messageId, mailboxId));
1495
+ if (repointed) {
1496
+ await repos.message.updateUid(messageId, msg.uid, mailboxId);
1497
+ await this.repointThreadMessage(
1498
+ repos.threadMessage,
1499
+ accountConfigId,
1500
+ messageId,
1501
+ mailboxId,
1502
+ msg.uid,
1503
+ sighting === "discarded",
1504
+ );
1505
+ }
1506
+
1507
+ owned = created || item.mailboxId === mailboxId || repointed;
1508
+
1509
+ // Withholding reads the pointer, so it is re-derived whenever the
1510
+ // pointer could have changed the answer: a save into Junk, and a
1511
+ // re-point in either direction. The reconcile both applies and lifts,
1512
+ // so a sender whose only mail was moved out of Junk is restored by the
1513
+ // same call that would have withheld them (#859).
1514
+ if (sighting === "junk" || repointed) {
1449
1515
  await repos.address.reconcileJunkOnlyForMessage(
1450
1516
  messageId,
1451
1517
  roles.configJunkRoles,
@@ -1479,6 +1545,69 @@ export class MessageSyncService {
1479
1545
  return { messageId, uid: msg.uid, owned };
1480
1546
  }
1481
1547
 
1548
+ /**
1549
+ * Whether this folder already holds a copy the user made of this message
1550
+ * (#75). A copy is a second row under its own derived id, so the sighting is
1551
+ * that copy standing where it was put — the original has not moved, and
1552
+ * following it would take the mail out of the folder the user copied it from.
1553
+ */
1554
+ private async holdsCopyOf(
1555
+ messageService: IMessageRepository,
1556
+ messageId: string,
1557
+ mailboxId: string,
1558
+ ): Promise<boolean> {
1559
+ const rows = await messageService.get([
1560
+ deriveCopyMessageId(messageId, mailboxId),
1561
+ ]);
1562
+ return rows.length > 0;
1563
+ }
1564
+
1565
+ /**
1566
+ * Follow a re-pointed Message with the ThreadMessage row that renders it.
1567
+ * The listing and every folder count read that row, so leaving it behind
1568
+ * would show the message in the folder it just left and hide it from the one
1569
+ * it landed in. `isDeleted` follows the same rule the move path uses — true
1570
+ * in Trash, false anywhere else — so a message another client rescues out of
1571
+ * Trash stops being filtered out of listings.
1572
+ */
1573
+ private async repointThreadMessage(
1574
+ threadMessageService: IThreadMessageRepository,
1575
+ accountConfigId: string,
1576
+ messageId: string,
1577
+ mailboxId: string,
1578
+ uid: number,
1579
+ isDeleted: boolean,
1580
+ ): Promise<void> {
1581
+ const row = await threadMessageService.findByMessageId(
1582
+ accountConfigId,
1583
+ messageId,
1584
+ );
1585
+ if (row === null) return;
1586
+ if (
1587
+ row.mailboxId === mailboxId &&
1588
+ row.uid === uid &&
1589
+ row.isDeleted === isDeleted
1590
+ ) {
1591
+ return;
1592
+ }
1593
+
1594
+ await threadMessageService.update(
1595
+ row.accountConfigId,
1596
+ row.threadMessageId,
1597
+ { mailboxId, uid, isDeleted },
1598
+ {
1599
+ composites: {
1600
+ mailboxId: row.mailboxId,
1601
+ sentDate: row.sentDate,
1602
+ isRead: row.isRead,
1603
+ isDeleted: row.isDeleted,
1604
+ hasStars: row.hasStars,
1605
+ hasAttachment: row.hasAttachment,
1606
+ },
1607
+ },
1608
+ );
1609
+ }
1610
+
1482
1611
  private async saveAddresses(
1483
1612
  addressService: IAddressRepository,
1484
1613
  messageId: string,