@remit/mailbox-service 0.0.9 → 0.0.11

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.9",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -181,3 +181,88 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
181
181
  assert.strictEqual(BigInt(status.highestModseq), modseq);
182
182
  });
183
183
  });
184
+
185
+ describe("ImapFlowConnection CONDSTORE (reader#20)", () => {
186
+ const buildCondstoreConnection = (options: {
187
+ enabled: Set<string>;
188
+ noModseq?: boolean;
189
+ fetched?: Array<Record<string, unknown>>;
190
+ record?: Array<{ range: string; options: Record<string, unknown> }>;
191
+ }): ImapFlowConnection => {
192
+ const connection = buildConnectionWithClient({
193
+ enabled: options.enabled,
194
+ mailbox: { ...fakeMailbox("INBOX", 1), noModseq: options.noModseq },
195
+ mailboxOpen: async (path: string) => fakeMailbox(path, 1),
196
+ fetch: (
197
+ range: string,
198
+ _query: Record<string, unknown>,
199
+ fetchOptions: Record<string, unknown>,
200
+ ) => {
201
+ options.record?.push({ range, options: fetchOptions });
202
+ return (async function* () {
203
+ for (const row of options.fetched ?? []) yield row;
204
+ })();
205
+ },
206
+ });
207
+ Object.assign(connection as unknown as Record<string, unknown>, {
208
+ currentMailbox: "INBOX",
209
+ });
210
+ return connection;
211
+ };
212
+
213
+ it("reports CONDSTORE only when the session enabled it and the mailbox keeps mod-sequences", () => {
214
+ assert.strictEqual(
215
+ buildCondstoreConnection({
216
+ enabled: new Set(["CONDSTORE"]),
217
+ }).supportsCondstore(),
218
+ true,
219
+ );
220
+ assert.strictEqual(
221
+ buildCondstoreConnection({ enabled: new Set() }).supportsCondstore(),
222
+ false,
223
+ );
224
+ assert.strictEqual(
225
+ buildCondstoreConnection({
226
+ enabled: new Set(["CONDSTORE"]),
227
+ noModseq: true,
228
+ }).supportsCondstore(),
229
+ false,
230
+ );
231
+ });
232
+
233
+ it("refuses CHANGEDSINCE without CONDSTORE rather than fetching the whole mailbox", async () => {
234
+ const connection = buildCondstoreConnection({ enabled: new Set() });
235
+
236
+ await assert.rejects(
237
+ () => connection.fetchMessagesChangedSince(500n),
238
+ /CONDSTORE/,
239
+ );
240
+ });
241
+
242
+ it("passes the mod-sequence through as a BigInt over the whole UID space", async () => {
243
+ const record: Array<{ range: string; options: Record<string, unknown> }> =
244
+ [];
245
+ const connection = buildCondstoreConnection({
246
+ enabled: new Set(["CONDSTORE"]),
247
+ record,
248
+ fetched: [
249
+ {
250
+ uid: 7,
251
+ seq: 7,
252
+ flags: new Set(["\\Seen"]),
253
+ internalDate: new Date(0),
254
+ size: 10,
255
+ modseq: 18446744073709551615n,
256
+ },
257
+ ],
258
+ });
259
+
260
+ const messages = await connection.fetchMessagesChangedSince(500n);
261
+
262
+ assert.deepStrictEqual(record, [
263
+ { range: "1:*", options: { uid: true, changedSince: 500n } },
264
+ ]);
265
+ assert.strictEqual(messages[0].modseq, "18446744073709551615");
266
+ assert.deepStrictEqual(messages[0].flags, ["\\Seen"]);
267
+ });
268
+ });
@@ -502,6 +502,61 @@ export class ImapFlowConnection {
502
502
  */
503
503
  fetchMessages = async (uids: number[]): Promise<ImapMessage[]> => {
504
504
  this.ensureConnected();
505
+
506
+ if (uids.length === 0) {
507
+ return [];
508
+ }
509
+
510
+ return this.runMessageFetch(uids.join(","), {});
511
+ };
512
+
513
+ /**
514
+ * True when this session negotiated CONDSTORE (RFC 7162) and the open
515
+ * mailbox keeps persistent mod-sequences.
516
+ *
517
+ * Both halves matter. `enabled` reflects the ENABLE exchange, not merely
518
+ * the advertised capability, and a server may still answer NOMODSEQ for an
519
+ * individual mailbox — in which case RFC 7162 Section 3.1.2.2 requires it
520
+ * to reject CHANGEDSINCE with a BAD response.
521
+ */
522
+ supportsCondstore = (): boolean => {
523
+ const { client } = this;
524
+ if (!client?.mailbox) return false;
525
+ return client.enabled.has("CONDSTORE") && !client.mailbox.noModseq;
526
+ };
527
+
528
+ /**
529
+ * FETCH everything in the open mailbox whose MODSEQ is strictly greater
530
+ * than `sinceModseq` — new arrivals and metadata changes to messages
531
+ * already synced, in one pass over the whole UID space.
532
+ *
533
+ * The CONDSTORE guard is load-bearing rather than defensive: imapflow drops
534
+ * the CHANGEDSINCE modifier when the extension is unavailable, so the same
535
+ * call would degrade into a fetch of every message in the mailbox.
536
+ */
537
+ fetchMessagesChangedSince = async (
538
+ sinceModseq: bigint,
539
+ ): Promise<ImapMessage[]> => {
540
+ this.ensureConnected();
541
+
542
+ if (!this.supportsCondstore()) {
543
+ throw new Error(
544
+ "CHANGEDSINCE requires CONDSTORE on both the session and the open mailbox",
545
+ );
546
+ }
547
+
548
+ return this.runMessageFetch("1:*", { changedSince: sinceModseq });
549
+ };
550
+
551
+ /**
552
+ * One UID FETCH over `range`, mapped to {@link ImapMessage}. The shared
553
+ * body of {@link fetchMessages} and {@link fetchMessagesChangedSince} —
554
+ * both need identical message shapes and differ only in what they select.
555
+ */
556
+ private runMessageFetch = async (
557
+ range: string,
558
+ options: { changedSince?: bigint },
559
+ ): Promise<ImapMessage[]> => {
505
560
  const { client } = this;
506
561
 
507
562
  if (!client) {
@@ -512,17 +567,10 @@ export class ImapFlowConnection {
512
567
  throw new Error("No mailbox selected");
513
568
  }
514
569
 
515
- if (uids.length === 0) {
516
- return [];
517
- }
518
-
519
570
  const messages: ImapMessage[] = [];
520
571
 
521
- // ImapFlow fetch with native envelope support + References header
522
- const uidRange = uids.join(",");
523
-
524
572
  const fetchIterator = client.fetch(
525
- uidRange,
573
+ range,
526
574
  {
527
575
  uid: true,
528
576
  flags: true,
@@ -532,14 +580,12 @@ export class ImapFlowConnection {
532
580
  size: true,
533
581
  headers: ["references"],
534
582
  },
535
- { uid: true },
583
+ { uid: true, ...options },
536
584
  );
537
585
 
538
586
  // Connection may have been lost - fetch returns an async iterable
539
587
  if (!fetchIterator) {
540
- throw new Error(
541
- `IMAP connection lost while fetching messages: ${uidRange}`,
542
- );
588
+ throw new Error(`IMAP connection lost while fetching messages: ${range}`);
543
589
  }
544
590
 
545
591
  for await (const msg of fetchIterator) {
@@ -572,6 +618,7 @@ export class ImapFlowConnection {
572
618
  envelope: this.convertEnvelope(msg.envelope),
573
619
  references,
574
620
  bodyStructure: msg.bodyStructure,
621
+ ...(msg.modseq != null ? { modseq: msg.modseq.toString() } : {}),
575
622
  });
576
623
  }
577
624
 
@@ -44,7 +44,10 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
44
44
  shared: [],
45
45
  };
46
46
 
47
- const buildConnection = (uidValidity: number): IImapConnection =>
47
+ const buildConnection = (
48
+ uidValidity: number,
49
+ highestModseq = "0",
50
+ ): IImapConnection =>
48
51
  ({
49
52
  getNamespaces: async () => namespaces,
50
53
  listMailboxes: async () => [
@@ -62,7 +65,7 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
62
65
  unseen: 1,
63
66
  uidNext: 100,
64
67
  uidValidity,
65
- highestModseq: "0",
68
+ highestModseq,
66
69
  deletedCount: 0,
67
70
  }),
68
71
  }) as unknown as IImapConnection;
@@ -136,6 +139,32 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
136
139
  assert.equal(updateCalls.length, 0);
137
140
  });
138
141
 
142
+ it("never writes the message-sync cursor, whatever the server reports", async () => {
143
+ // `highestModseq` on the mailbox row is message sync's own cursor over
144
+ // applied changes, not a status projection. The sweep overwriting it with
145
+ // the server's current value would step it over every change message sync
146
+ // had not yet applied.
147
+ const { mailboxService, specialUseService, updateCalls } = buildServices(1);
148
+ const service = new MailboxSyncService(mailboxService, specialUseService);
149
+ const connection = buildConnection(2, "99999");
150
+
151
+ await service.syncMailboxes({ accountId: "acc-1" }, connection);
152
+
153
+ for (const call of updateCalls) {
154
+ assert.equal("highestModseq" in call, false);
155
+ }
156
+ });
157
+
158
+ it("does not sweep-write a mailbox whose only difference is the server mod-sequence", async () => {
159
+ const { mailboxService, specialUseService, updateCalls } = buildServices(1);
160
+ const service = new MailboxSyncService(mailboxService, specialUseService);
161
+ const connection = buildConnection(1, "4242");
162
+
163
+ await service.syncMailboxes({ accountId: "acc-1" }, connection);
164
+
165
+ assert.equal(updateCalls.length, 0);
166
+ });
167
+
139
168
  it("does not re-trip (no cursorState write) when the mailbox is already cursor_invalid", async () => {
140
169
  const { mailboxService, specialUseService, updateCalls } = buildServices(
141
170
  1,
@@ -353,7 +353,13 @@ export class MailboxSyncService {
353
353
  fullPath: mailboxInfo.fullPath,
354
354
  uidValidity: status.uidValidity,
355
355
  uidNext: status.uidNext,
356
- highestModseq: status.highestModseq,
356
+ // The message-sync cursor, not a status projection: it records how
357
+ // far THIS mailbox's messages have been applied, and nothing has
358
+ // been. Seeding it from the server's current HIGHESTMODSEQ would
359
+ // declare an unsynced folder already caught up and skip every
360
+ // message in it. Message sync seeds it once it has enumerated the
361
+ // folder.
362
+ highestModseq: "0",
357
363
  messageCount: status.messages,
358
364
  unseenCount: status.unseen,
359
365
  deletedCount: status.deletedCount,
@@ -429,8 +435,6 @@ export class MailboxSyncService {
429
435
  existing.messageCount !== status.messages ||
430
436
  existing.unseenCount !== status.unseen ||
431
437
  existing.deletedCount !== status.deletedCount ||
432
- (BigInt(status.highestModseq) > 0n &&
433
- existing.highestModseq !== status.highestModseq) ||
434
438
  specialUseChanged;
435
439
 
436
440
  // Sync special-use attributes (handles migration of existing mailboxes)
@@ -458,13 +462,6 @@ export class MailboxSyncService {
458
462
  changes.push(
459
463
  `deletedCount: ${existing.deletedCount} -> ${status.deletedCount}`,
460
464
  );
461
- if (
462
- BigInt(status.highestModseq) > 0n &&
463
- existing.highestModseq !== status.highestModseq
464
- )
465
- changes.push(
466
- `highestModseq: ${existing.highestModseq} -> ${status.highestModseq}`,
467
- );
468
465
  if (specialUseChanged)
469
466
  changes.push(
470
467
  `specialUse: [${(existing.specialUse ?? []).join(",")}] -> [${parsed.specialUse.join(",")}]`,
@@ -480,7 +477,10 @@ export class MailboxSyncService {
480
477
  hierarchyDelimiter: mailboxInfo.delimiter,
481
478
  uidValidity: status.uidValidity,
482
479
  uidNext: status.uidNext,
483
- highestModseq: status.highestModseq,
480
+ // `highestModseq` is deliberately absent: it is message sync's
481
+ // cursor over this mailbox's own applied changes, and overwriting it
482
+ // with the server's current value here would step it over every
483
+ // change message sync had not yet applied.
484
484
  messageCount: status.messages,
485
485
  unseenCount: status.unseen,
486
486
  deletedCount: status.deletedCount,
@@ -695,10 +695,14 @@ export class MessageMoveService {
695
695
  /**
696
696
  * Delete every ThreadMessage row that points at this messageId.
697
697
  *
698
- * A single Message can have multiple ThreadMessage rows one per mailbox
699
- * the message exists in (e.g. INBOX + a label/folder copy). Deleting them
700
- * up-front in the permanent-delete optimistic step prevents stale rows from
701
- * leaking into mailbox listings while IMAP catches up. See issue #212.
698
+ * A row is keyed by (threadId, messageId), so this is one row in practice —
699
+ * a message filed in several folders keeps a single row, because its
700
+ * messageId is derived from the Message-ID header and not from the mailbox.
701
+ * The query stays a list because the key permits more than one thread per
702
+ * message and nothing enforces otherwise.
703
+ *
704
+ * Deleting up-front in the permanent-delete optimistic step prevents stale
705
+ * rows from leaking into mailbox listings while IMAP catches up (#212).
702
706
  */
703
707
  private deleteThreadMessagesForMessage = async (
704
708
  accountConfigId: string,