@remit/mailbox-service 0.0.10 → 0.0.12

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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Classification against realistic mail, drawn from the senders reported in
3
+ * issue #45 ("only personal and marketing seem to work").
4
+ *
5
+ * The rule table is easy to satisfy with synthetic one-header fixtures and
6
+ * still wrong on real mail, because real bulk senders set several signals at
7
+ * once. Every case here carries the full header set the sender actually emits,
8
+ * so the test fails when rule ORDER regresses even though each individual rule
9
+ * still works.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+ import { MessageCategory } from "@remit/domain-enums";
15
+ import { simpleParser } from "mailparser";
16
+ import { classifyByHeaders } from "./classifyByHeaders.js";
17
+
18
+ const classify = async (lines: string[]) =>
19
+ classifyByHeaders(await simpleParser(Buffer.from(lines.join("\r\n"))));
20
+
21
+ describe("classifyByHeaders on realistic mail", () => {
22
+ describe("platform notifications", () => {
23
+ it("classifies an npm publish notification as automated", async () => {
24
+ const category = await classify([
25
+ "From: npm <notifications@npmjs.com>",
26
+ "To: me@example.com",
27
+ "Subject: A new version of left-pad was published",
28
+ "DKIM-Signature: v=1; a=rsa-sha256; d=npmjs.com; s=s1; h=from:to",
29
+ "Content-Type: text/plain",
30
+ "",
31
+ "published",
32
+ ]);
33
+ assert.equal(category, MessageCategory.automated);
34
+ });
35
+
36
+ it("classifies an npm mail relayed through SES as automated", async () => {
37
+ const category = await classify([
38
+ "Return-Path: <bounces@amazonses.com>",
39
+ "From: npm <support@npmjs.com>",
40
+ "To: me@example.com",
41
+ "Subject: A new device signed in to your npm account",
42
+ "Feedback-ID: 1.eu-west-1.abc:AmazonSES",
43
+ "DKIM-Signature: v=1; a=rsa-sha256; d=amazonses.com; s=x; h=from:to",
44
+ "Content-Type: text/html",
45
+ "",
46
+ "<p>signed in</p>",
47
+ ]);
48
+ assert.equal(category, MessageCategory.automated);
49
+ });
50
+
51
+ it("classifies a no-reply notification with no bulk headers as automated", async () => {
52
+ // The reported npm case: one-to-one machine mail, aligned DKIM, and no
53
+ // List-* or Precedence header at all. It used to reach the `personal`
54
+ // fallback and sit among real correspondence.
55
+ const category = await classify([
56
+ "From: CircleCI <no-reply@circleci.com>",
57
+ "To: me@example.com",
58
+ "Subject: Your build failed",
59
+ "DKIM-Signature: v=1; a=rsa-sha256; d=circleci.com; s=s1; h=from:to",
60
+ "Content-Type: text/plain",
61
+ "",
62
+ "build failed",
63
+ ]);
64
+ assert.equal(category, MessageCategory.automated);
65
+ });
66
+
67
+ it("classifies X-Auto-Response-Suppress mail as automated", async () => {
68
+ const category = await classify([
69
+ "From: Helpdesk <ticketing@corp.example>",
70
+ "To: me@example.com",
71
+ "Subject: Ticket 4711 updated",
72
+ "X-Auto-Response-Suppress: All",
73
+ "Content-Type: text/plain",
74
+ "",
75
+ "updated",
76
+ ]);
77
+ assert.equal(category, MessageCategory.automated);
78
+ });
79
+ });
80
+
81
+ describe("allow-listed sender domains outrank bulk headers", () => {
82
+ it("classifies a GitHub pull-request notification as transactional", async () => {
83
+ // GitHub sets List-ID, List-Unsubscribe AND Precedence: list. Before the
84
+ // reorder, Precedence matched first and every GitHub mail — security
85
+ // alerts and receipts included — landed in `automated`, contradicting
86
+ // the reason GitHub is on the transactional allow-list at all.
87
+ const category = await classify([
88
+ "From: contributor <notifications@github.com>",
89
+ "To: me@example.com",
90
+ "Subject: Re: [org/repo] Fix the thing (PR #45)",
91
+ "List-ID: org/repo <repo.org.github.com>",
92
+ "List-Unsubscribe: <https://github.com/unsub>",
93
+ "Precedence: list",
94
+ "DKIM-Signature: v=1; a=rsa-sha256; d=github.com; s=pf2014; h=from:to",
95
+ "Content-Type: text/plain",
96
+ "",
97
+ "comment",
98
+ ]);
99
+ assert.equal(category, MessageCategory.transactional);
100
+ });
101
+
102
+ it("classifies a GitHub security alert as transactional", async () => {
103
+ const category = await classify([
104
+ "From: GitHub <noreply@github.com>",
105
+ "To: me@example.com",
106
+ "Subject: [org/repo] Dependabot alert",
107
+ "Precedence: bulk",
108
+ "Content-Type: text/plain",
109
+ "",
110
+ "alert",
111
+ ]);
112
+ assert.equal(category, MessageCategory.transactional);
113
+ });
114
+
115
+ it("classifies a LinkedIn notification as social", async () => {
116
+ // The reported LinkedIn case. List-Unsubscribe matched before the social
117
+ // allow-list, so LinkedIn mail was filed as generic `marketing` and the
118
+ // Social bucket stayed empty.
119
+ const category = await classify([
120
+ "Return-Path: <s-hbhcfzp@bounce.linkedin.com>",
121
+ "From: LinkedIn <messages-noreply@linkedin.com>",
122
+ "To: me@example.com",
123
+ "Subject: You have a new invitation",
124
+ "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
125
+ "DKIM-Signature: v=1; a=rsa-sha256; d=linkedin.com; s=proddkim; h=from:to",
126
+ "Content-Type: text/html",
127
+ "",
128
+ "<p>invitation</p>",
129
+ ]);
130
+ assert.equal(category, MessageCategory.social);
131
+ });
132
+
133
+ it("classifies a LinkedIn job alert from a subdomain as social", async () => {
134
+ const category = await classify([
135
+ "From: LinkedIn Job Alerts <jobalerts-noreply@e.linkedin.com>",
136
+ "To: me@example.com",
137
+ "Subject: 20 new jobs for you",
138
+ "Precedence: bulk",
139
+ "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
140
+ "Content-Type: text/html",
141
+ "",
142
+ "<p>jobs</p>",
143
+ ]);
144
+ assert.equal(category, MessageCategory.social);
145
+ });
146
+ });
147
+
148
+ describe("bulk mail keeps its intent bucket", () => {
149
+ it("classifies a marketing blast that also sets Precedence: bulk as marketing", async () => {
150
+ // Nearly every marketing platform sets Precedence: bulk. Matching it
151
+ // first swallowed the whole Marketing bucket into `automated`.
152
+ const category = await classify([
153
+ "From: Shop <deals@shop.example>",
154
+ "To: me@example.com",
155
+ "Subject: 50% off everything",
156
+ "Precedence: bulk",
157
+ "List-Unsubscribe: <https://shop.example/unsub>",
158
+ "List-Unsubscribe-Post: List-Unsubscribe=One-Click",
159
+ "Content-Type: text/html",
160
+ "",
161
+ "<p>sale</p>",
162
+ ]);
163
+ assert.equal(category, MessageCategory.marketing);
164
+ });
165
+
166
+ it("classifies a newsletter that also sets Precedence: list as newsletter", async () => {
167
+ const category = await classify([
168
+ "From: Some Writer <writer@substack.example>",
169
+ "To: me@example.com",
170
+ "Subject: This week's issue",
171
+ "Precedence: list",
172
+ "List-ID: <someletter.substack.example>",
173
+ "List-Unsubscribe: <https://substack.example/unsub>",
174
+ "Content-Type: text/html",
175
+ "",
176
+ "<p>news</p>",
177
+ ]);
178
+ assert.equal(category, MessageCategory.newsletter);
179
+ });
180
+
181
+ it("classifies a mailing-list post as newsletter, not automated", async () => {
182
+ const category = await classify([
183
+ "From: Contributor <dev@lists.example>",
184
+ "To: dev@lists.example",
185
+ "Subject: [PATCH v2] fix the parser",
186
+ "Precedence: list",
187
+ "List-ID: <dev.lists.example>",
188
+ "List-Unsubscribe: <mailto:dev-unsubscribe@lists.example>",
189
+ "Content-Type: text/plain",
190
+ "",
191
+ "patch",
192
+ ]);
193
+ assert.equal(category, MessageCategory.newsletter);
194
+ });
195
+ });
196
+
197
+ describe("personal mail stays personal", () => {
198
+ it("classifies a person writing from Gmail as personal", async () => {
199
+ const category = await classify([
200
+ "From: Alice <alice@gmail.com>",
201
+ "To: me@example.com",
202
+ "Subject: lunch?",
203
+ "DKIM-Signature: v=1; a=rsa-sha256; d=gmail.com; s=20230601; h=from:to",
204
+ "Content-Type: text/plain",
205
+ "",
206
+ "lunch tomorrow?",
207
+ ]);
208
+ assert.equal(category, MessageCategory.personal);
209
+ });
210
+
211
+ it("does not treat a human support mailbox as a machine sender", async () => {
212
+ // `support@` is answered by people. Adding it to the machine local-parts
213
+ // would quietly bury real correspondence in `automated`.
214
+ const category = await classify([
215
+ "From: Acme Support <support@acme.example>",
216
+ "To: me@example.com",
217
+ "Subject: Re: your question",
218
+ "Content-Type: text/plain",
219
+ "",
220
+ "answering your question",
221
+ ]);
222
+ assert.equal(category, MessageCategory.personal);
223
+ });
224
+
225
+ it("classifies a calendar invite from a colleague as transactional", async () => {
226
+ const category = await classify([
227
+ "From: Bob <bob@corp.example>",
228
+ "To: me@example.com",
229
+ "Subject: Invitation: standup",
230
+ 'Content-Type: multipart/mixed; boundary="b1"',
231
+ "",
232
+ "--b1",
233
+ "Content-Type: text/calendar; method=REQUEST",
234
+ "",
235
+ "BEGIN:VCALENDAR",
236
+ "END:VCALENDAR",
237
+ "--b1--",
238
+ ]);
239
+ assert.equal(category, MessageCategory.transactional);
240
+ });
241
+ });
242
+ });
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { hasMachineHeader, isMachineLocalPart } from "./machineSenders.js";
4
+
5
+ describe("isMachineLocalPart", () => {
6
+ it("matches the no-reply spellings", () => {
7
+ for (const localPart of [
8
+ "noreply",
9
+ "no-reply",
10
+ "no_reply",
11
+ "NoReply",
12
+ "donotreply",
13
+ "do-not-reply",
14
+ ]) {
15
+ assert.equal(isMachineLocalPart(localPart), true, localPart);
16
+ }
17
+ });
18
+
19
+ it("matches no-reply prefixes used by platforms", () => {
20
+ for (const localPart of [
21
+ "noreply-github",
22
+ "no-reply+abc123",
23
+ "messages-noreply",
24
+ ]) {
25
+ assert.equal(isMachineLocalPart(localPart), true, localPart);
26
+ }
27
+ });
28
+
29
+ it("matches notification and bounce mailboxes", () => {
30
+ for (const localPart of [
31
+ "notifications",
32
+ "notify",
33
+ "alerts",
34
+ "bounces",
35
+ "mailer-daemon",
36
+ "postmaster",
37
+ ]) {
38
+ assert.equal(isMachineLocalPart(localPart), true, localPart);
39
+ }
40
+ });
41
+
42
+ it("does not match mailboxes a person answers", () => {
43
+ // A wrong entry here silently buries real correspondence in `automated`,
44
+ // which is the failure mode this whole change exists to remove.
45
+ for (const localPart of [
46
+ "support",
47
+ "info",
48
+ "contact",
49
+ "hello",
50
+ "sales",
51
+ "alice",
52
+ "team",
53
+ ]) {
54
+ assert.equal(isMachineLocalPart(localPart), false, localPart);
55
+ }
56
+ });
57
+
58
+ it("does not read a machine name across two words of a person's name", () => {
59
+ // `bruno.reply` strips to "brunoreply", which contains "noreply" spanning
60
+ // the boundary between the two words. Matching on whole words is what
61
+ // keeps Bruno out of `automated`.
62
+ for (const localPart of [
63
+ "bruno.reply",
64
+ "bruno-reply",
65
+ "bruno_reply",
66
+ "juno.replies",
67
+ "toni.fyi",
68
+ "bruno.reply+newsletter",
69
+ ]) {
70
+ assert.equal(isMachineLocalPart(localPart), false, localPart);
71
+ }
72
+ });
73
+
74
+ it("still matches the qualified machine forms", () => {
75
+ for (const localPart of [
76
+ "noreply-github",
77
+ "messages-noreply",
78
+ "jobalerts-noreply",
79
+ "mailer.daemon",
80
+ "do-not-reply",
81
+ "team.notifications",
82
+ ]) {
83
+ assert.equal(isMachineLocalPart(localPart), true, localPart);
84
+ }
85
+ });
86
+ });
87
+
88
+ describe("hasMachineHeader", () => {
89
+ it("matches Feedback-ID regardless of case", () => {
90
+ assert.equal(hasMachineHeader(["from", "Feedback-ID"]), true);
91
+ assert.equal(hasMachineHeader(["feedback-id"]), true);
92
+ });
93
+
94
+ it("matches X-Auto-Response-Suppress", () => {
95
+ assert.equal(hasMachineHeader(["x-auto-response-suppress"]), true);
96
+ });
97
+
98
+ it("does not match ordinary headers", () => {
99
+ assert.equal(hasMachineHeader(["from", "to", "subject", "date"]), false);
100
+ });
101
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Signals that a message was sent by a machine that does not read replies.
3
+ *
4
+ * Distinct from the bulk signals (`Precedence`, `List-Unsubscribe`): a platform
5
+ * notification — an npm publish or 2FA mail, a CI result, a password reset —
6
+ * carries none of those. It is a one-to-one message with an aligned DKIM
7
+ * signature, so before issue #45 it reached the `personal` fallback and sat
8
+ * alongside actual human correspondence.
9
+ */
10
+
11
+ /**
12
+ * From local-parts that mean "this mailbox is not read by a person". Matched
13
+ * case-insensitively against whole separator-delimited words, so `no-reply`,
14
+ * `no_reply` and `noreply` are one entry.
15
+ *
16
+ * Deliberately excludes ambiguous, human-reachable local-parts (`support`,
17
+ * `info`, `contact`, `hello`, `sales`): a person does answer those, and a
18
+ * wrong entry here silently buries real mail in `automated`.
19
+ */
20
+ const MACHINE_LOCAL_PARTS = new Set([
21
+ "noreply",
22
+ "donotreply",
23
+ "notification",
24
+ "notifications",
25
+ "notify",
26
+ "automailer",
27
+ "automated",
28
+ "autoreply",
29
+ "mailerdaemon",
30
+ "postmaster",
31
+ "bounce",
32
+ "bounces",
33
+ "alert",
34
+ "alerts",
35
+ ]);
36
+
37
+ /**
38
+ * Machine mailbox names that senders spell across separators. Joined into one
39
+ * word before the local-part is split, so `no-reply` and `mailer.daemon` reduce
40
+ * to a single token that {@link MACHINE_LOCAL_PARTS} can match.
41
+ */
42
+ const SPELLED_OUT_MACHINE_NAMES: ReadonlyArray<[RegExp, string]> = [
43
+ [/do-not-reply/g, "donotreply"],
44
+ [/donot-reply/g, "donotreply"],
45
+ [/no-reply/g, "noreply"],
46
+ [/mailer-daemon/g, "mailerdaemon"],
47
+ [/auto-reply/g, "autoreply"],
48
+ [/auto-mailer/g, "automailer"],
49
+ ];
50
+
51
+ /**
52
+ * Headers only bulk/notification infrastructure sets. `Feedback-ID` is the
53
+ * per-campaign identifier SES, Google and other ESPs attach to programmatic
54
+ * sends; `X-Auto-Response-Suppress` tells the receiving client not to send
55
+ * vacation replies back, which only a machine sender asks for.
56
+ */
57
+ const MACHINE_HEADERS = ["feedback-id", "x-auto-response-suppress"];
58
+
59
+ /**
60
+ * Split a local-part into its separator-delimited words, with the spelled-out
61
+ * machine names joined back up first. The `+tag` suffix is dropped: it labels a
62
+ * subaddress, never the mailbox.
63
+ */
64
+ const toWords = (localPart: string): string[] => {
65
+ let canonical = localPart.toLowerCase().split("+")[0].replace(/[._]/g, "-");
66
+ for (const [pattern, replacement] of SPELLED_OUT_MACHINE_NAMES) {
67
+ canonical = canonical.replace(pattern, replacement);
68
+ }
69
+ return canonical.split("-").filter(Boolean);
70
+ };
71
+
72
+ /**
73
+ * True when any whole word of the From local-part is a known machine mailbox.
74
+ *
75
+ * Word-boundary, not substring: platforms qualify the mailbox on either side
76
+ * (`noreply-github`, `messages-noreply`, `jobalerts-noreply`), so a bare prefix
77
+ * test misses half of them — but a substring test reads `bruno.reply` as
78
+ * "bru|noreply" and files a real person as `automated`. Splitting on the
79
+ * separators the sender wrote catches the qualified forms without inventing a
80
+ * match that spans two words.
81
+ */
82
+ export const isMachineLocalPart = (localPart: string): boolean =>
83
+ toWords(localPart).some((word) => MACHINE_LOCAL_PARTS.has(word));
84
+
85
+ export const hasMachineHeader = (headerKeys: readonly string[]): boolean => {
86
+ for (const key of headerKeys) {
87
+ if (MACHINE_HEADERS.includes(key.toLowerCase())) return true;
88
+ }
89
+ return false;
90
+ };
@@ -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,