@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 +1 -1
- package/src/imapflow-connection.test.ts +85 -0
- package/src/imapflow-connection.ts +59 -12
- package/src/mailbox-sync.test.ts +31 -2
- package/src/mailbox-sync.ts +11 -11
- package/src/message-move.ts +8 -4
- package/src/message-sync-changedsince.test.ts +742 -0
- package/src/message-sync.ts +494 -108
- package/src/sync-watermarks.test.ts +418 -0
- package/src/sync-watermarks.ts +322 -0
- package/src/types.ts +22 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -44,7 +44,10 @@ describe("MailboxSyncService.syncMailboxes — UIDVALIDITY cursor detection (#12
|
|
|
44
44
|
shared: [],
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
const buildConnection = (
|
|
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
|
|
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,
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -353,7 +353,13 @@ export class MailboxSyncService {
|
|
|
353
353
|
fullPath: mailboxInfo.fullPath,
|
|
354
354
|
uidValidity: status.uidValidity,
|
|
355
355
|
uidNext: status.uidNext,
|
|
356
|
-
|
|
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:
|
|
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,
|
package/src/message-move.ts
CHANGED
|
@@ -695,10 +695,14 @@ export class MessageMoveService {
|
|
|
695
695
|
/**
|
|
696
696
|
* Delete every ThreadMessage row that points at this messageId.
|
|
697
697
|
*
|
|
698
|
-
* A
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
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,
|