@remit/mailbox-service 0.0.15 → 0.0.17

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.
@@ -33,13 +33,21 @@ const buildLogger = (): {
33
33
  };
34
34
  };
35
35
 
36
- const buildConnection = (present: Set<number>): IImapConnection =>
36
+ const buildConnection = (
37
+ present: Set<number>,
38
+ fetchDrops: Set<number> = new Set(),
39
+ ): IImapConnection =>
37
40
  ({
38
41
  openBox: async () => ({}) as never,
39
42
  fetchMessages: async (uids: number[]) =>
40
43
  uids
41
- .filter((uid) => present.has(uid))
44
+ .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
42
45
  .map((uid) => ({ uid }) as unknown as never),
46
+ search: async (criteria: unknown[]) => {
47
+ const [, value] = (criteria as Array<[string, string]>)[0];
48
+ const uid = Number(value);
49
+ return present.has(uid) ? [uid] : [];
50
+ },
43
51
  }) as unknown as IImapConnection;
44
52
 
45
53
  describe("resolveExhaustedFlagPushFailure — the two terminal outcomes (mirrors #1289/#1270 for flag pushes)", () => {
@@ -104,6 +112,51 @@ describe("resolveExhaustedFlagPushFailure — the two terminal outcomes (mirrors
104
112
  assert.ok(metricLog, "expected a routine reconciliation metric log");
105
113
  });
106
114
 
115
+ it("a dropped FETCH row is not absence: the message is still in the mailbox — marker and local rows survive", async () => {
116
+ const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
117
+ const { log, errors } = buildLogger();
118
+
119
+ const deps: ResolveExhaustedFlagPushDeps = {
120
+ markerService: {
121
+ delete: async (messageId: string, flagName: string) => {
122
+ markerDeletes.push({ messageId, flagName });
123
+ },
124
+ },
125
+ messageService: {
126
+ delete: async () => {
127
+ throw new Error("must not be called — the message still exists");
128
+ },
129
+ } as unknown as Pick<IMessageRepository, "delete">,
130
+ threadMessageService: {
131
+ findAllByMessageId: async () => {
132
+ throw new Error("must not be called — the message still exists");
133
+ },
134
+ deleteMany: async () => {
135
+ throw new Error("must not be called — the message still exists");
136
+ },
137
+ } as unknown as Pick<
138
+ IThreadMessageRepository,
139
+ "findAllByMessageId" | "deleteMany"
140
+ >,
141
+ log,
142
+ };
143
+
144
+ const result = await resolveExhaustedFlagPushFailure(deps, {
145
+ accountId: "acc-1",
146
+ accountConfigId: "cfg-1",
147
+ messageId: "msg-live",
148
+ flagName: "\\Seen",
149
+ uid: 303,
150
+ mailboxPath: "INBOX",
151
+ getConnection: async () =>
152
+ buildConnection(new Set([303]), new Set([303])),
153
+ });
154
+
155
+ assert.equal(result.outcome, "broken");
156
+ assert.deepEqual(markerDeletes, []);
157
+ assert.ok(errors.some((e) => e.obj.alert === "flag_push_failed"));
158
+ });
159
+
107
160
  it("BROKEN (should never happen): the message still exists — marker left in place, alert logged", async () => {
108
161
  const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
109
162
  const { log, errors } = buildLogger();
@@ -1,4 +1,5 @@
1
1
  import type { FlagPushLogger } from "./flag-push.js";
2
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
2
3
  import {
3
4
  reconcileStaleMessage,
4
5
  type StaleMessageReconcileDeps,
@@ -35,10 +36,11 @@ export interface ResolveExhaustedFlagPushResult {
35
36
  * (epic #1281 invariant 3) — no third, softer outcome.
36
37
  *
37
38
  * 1. RECONCILED (expected) — the message no longer exists at its mailbox on
38
- * IMAP. Per invariant 2, an external delete supersedes the marker
39
- * entirely: the marker is dropped and the stale Message/ThreadMessage rows
40
- * are deleted via {@link reconcileStaleMessage}. Metric only, no alarm
41
- * routine.
39
+ * IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather than by a
40
+ * FETCH coming back empty. Per invariant 2, an external delete supersedes
41
+ * the marker entirely: the marker is dropped and the stale
42
+ * Message/ThreadMessage rows are deleted via {@link reconcileStaleMessage}.
43
+ * Metric only, no alarm — routine.
42
44
  * 2. BROKEN — the message still exists, but the flag push keeps failing.
43
45
  * Broken code or a broken account, not a transient blip. The marker is
44
46
  * left in place (not cleared) — while pending, resync never reverts the
@@ -48,6 +50,15 @@ export interface ResolveExhaustedFlagPushResult {
48
50
  * entry for an operator alarm; never re-thrown (terminal — the caller acks
49
51
  * either way, since retrying a stale or permanently-broken push can never
50
52
  * succeed).
53
+ *
54
+ * An operator reading `flag_push_failed` should know one case where the
55
+ * message is not actually there: a message another client expunged
56
+ * mid-session can answer an empty FETCH while the server still lists its UID
57
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
58
+ * lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
59
+ * alert stands until someone clears it. The reverse mistake deletes live
60
+ * mail, so the cost is paid deliberately: a stale alert is recoverable, a
61
+ * deleted message is not.
51
62
  */
52
63
  export const resolveExhaustedFlagPushFailure = async (
53
64
  deps: ResolveExhaustedFlagPushDeps,
@@ -65,9 +76,8 @@ export const resolveExhaustedFlagPushFailure = async (
65
76
 
66
77
  const connection = await getConnection();
67
78
  await connection.openBox(mailboxPath, true);
68
- const found = await connection.fetchMessages([uid]);
69
79
 
70
- if (found.length === 0) {
80
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
71
81
  await deps.markerService.delete(messageId, flagName);
72
82
  const { threadMessagesDeleted } = await reconcileStaleMessage(
73
83
  deps,
@@ -5,6 +5,7 @@ import {
5
5
  toInternalDate,
6
6
  toIsoDateString,
7
7
  } from "./imapflow-connection.js";
8
+ import type { ImapMessage } from "./types.js";
8
9
 
9
10
  describe("toIsoDateString", () => {
10
11
  it("converts a Date to an ISO string", () => {
@@ -111,6 +112,52 @@ const fakeMailbox = (path: string, exists: number) => ({
111
112
  readOnly: true,
112
113
  });
113
114
 
115
+ describe("ImapFlowConnection.search — the probe every stale-row reconcile rests on (#102)", () => {
116
+ const buildSearchConnection = (
117
+ result: unknown,
118
+ calls: Array<{ query: unknown; options: unknown }> = [],
119
+ ): ImapFlowConnection => {
120
+ const connection = buildConnectionWithClient({
121
+ mailboxOpen: async (path: string) => fakeMailbox(path, 1),
122
+ search: async (query: unknown, options: unknown) => {
123
+ calls.push({ query, options });
124
+ return result;
125
+ },
126
+ });
127
+ return connection;
128
+ };
129
+
130
+ it("translates a UID criterion into a UID SEARCH answering UIDs, not sequence numbers", async () => {
131
+ const calls: Array<{ query: unknown; options: unknown }> = [];
132
+ const connection = buildSearchConnection([42], calls);
133
+ await connection.openBox("INBOX", true);
134
+
135
+ const result = await connection.search([["UID", "42"]]);
136
+
137
+ assert.deepStrictEqual(result, [42]);
138
+ assert.deepStrictEqual(calls, [
139
+ { query: { uid: "42" }, options: { uid: true } },
140
+ ]);
141
+ });
142
+
143
+ it("answers an empty array when the server matched nothing", async () => {
144
+ const connection = buildSearchConnection([]);
145
+ await connection.openBox("INBOX", true);
146
+
147
+ assert.deepStrictEqual(await connection.search([["UID", "42"]]), []);
148
+ });
149
+
150
+ it("throws on a failed SEARCH instead of reporting it as no matches", async () => {
151
+ const connection = buildSearchConnection(false);
152
+ await connection.openBox("INBOX", true);
153
+
154
+ await assert.rejects(
155
+ () => connection.search([["UID", "42"]]),
156
+ /SEARCH failed/,
157
+ );
158
+ });
159
+ });
160
+
114
161
  describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
115
162
  it("returns the SEARCH \\Deleted count alongside the STATUS counts", async () => {
116
163
  const searchQueries: Array<Record<string, unknown>> = [];
@@ -151,7 +198,7 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
151
198
  highestModseq: 0n,
152
199
  }),
153
200
  mailboxOpen: async (path: string) => fakeMailbox(path, 4),
154
- search: async () => false,
201
+ search: async () => [],
155
202
  });
156
203
 
157
204
  const status = await connection.getMailboxStatus("INBOX");
@@ -172,7 +219,7 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
172
219
  highestModseq: modseq,
173
220
  }),
174
221
  mailboxOpen: async (path: string) => fakeMailbox(path, 1),
175
- search: async () => false,
222
+ search: async () => [],
176
223
  });
177
224
 
178
225
  const status = await connection.getMailboxStatus("INBOX");
@@ -266,3 +313,51 @@ describe("ImapFlowConnection CONDSTORE (reader#20)", () => {
266
313
  assert.deepStrictEqual(messages[0].flags, ["\\Seen"]);
267
314
  });
268
315
  });
316
+
317
+ describe("ImapFlowConnection message envelopes (issue #72)", () => {
318
+ const fetchRows = async (
319
+ rows: Array<Record<string, unknown>>,
320
+ ): Promise<ImapMessage[]> => {
321
+ const connection = buildConnectionWithClient({
322
+ enabled: new Set(),
323
+ mailbox: fakeMailbox("INBOX", 1),
324
+ mailboxOpen: async (path: string) => fakeMailbox(path, 1),
325
+ fetch: () =>
326
+ (async function* () {
327
+ for (const row of rows) yield row;
328
+ })(),
329
+ });
330
+ Object.assign(connection as unknown as Record<string, unknown>, {
331
+ currentMailbox: "INBOX",
332
+ });
333
+ return connection.fetchMessages([1]);
334
+ };
335
+
336
+ it("leaves an absent ENVELOPE absent instead of synthesising an empty one", async () => {
337
+ // A synthesised envelope made every `if (!msg.envelope)` guard downstream
338
+ // unreachable, so the row was saved as a message with no sender, subject
339
+ // or date under a `generated:` key — indistinguishable from real mail.
340
+ const [message] = await fetchRows([
341
+ { uid: 1, seq: 1, internalDate: new Date(0), size: 10 },
342
+ ]);
343
+ assert.equal(message?.envelope, undefined);
344
+ });
345
+
346
+ it("still converts an envelope the server did send", async () => {
347
+ const [message] = await fetchRows([
348
+ {
349
+ uid: 1,
350
+ seq: 1,
351
+ internalDate: new Date(0),
352
+ size: 10,
353
+ envelope: {
354
+ subject: "Hello",
355
+ messageId: "<a@example.com>",
356
+ from: [{ address: "sender@example.com" }],
357
+ },
358
+ },
359
+ ]);
360
+ assert.equal(message?.envelope?.subject, "Hello");
361
+ assert.equal(message?.envelope?.from[0]?.mailbox, "sender");
362
+ });
363
+ });
@@ -397,7 +397,15 @@ export class ImapFlowConnection {
397
397
  };
398
398
 
399
399
  /**
400
- * Search for messages
400
+ * Search for messages, answering UIDs.
401
+ *
402
+ * imapflow answers a successful SEARCH with an array — empty when the
403
+ * server matched nothing — and `false` when the command failed or no
404
+ * mailbox was selected. Collapsing the two into `[]` makes a failed SEARCH
405
+ * indistinguishable from an empty mailbox, and every caller here acts on
406
+ * emptiness by deleting something: the cursor rebuild reads it as "every
407
+ * local row is stale", and {@link isMessageGoneFromOpenMailbox} reads it as
408
+ * "this message is gone". A failure therefore throws.
401
409
  */
402
410
  search = async (criteria: unknown[]): Promise<number[]> => {
403
411
  this.ensureConnected();
@@ -410,9 +418,8 @@ export class ImapFlowConnection {
410
418
  const searchQuery = this.convertSearchCriteria(criteria);
411
419
 
412
420
  const result = await this.client?.search(searchQuery, { uid: true });
413
- // search can return false if no messages match, or undefined if client is null
414
- if (!result) {
415
- return [];
421
+ if (!Array.isArray(result)) {
422
+ throw new Error(`IMAP SEARCH failed in mailbox ${this.currentMailbox}`);
416
423
  }
417
424
  return result;
418
425
  };
@@ -591,20 +598,23 @@ export class ImapFlowConnection {
591
598
  for await (const msg of fetchIterator) {
592
599
  // imapflow occasionally yields a row with undefined uid or internalDate
593
600
  // on back-to-back FETCH calls (e.g. after a body-fetch on the same UID).
594
- // Skipping the row is safe: the caller asked for a specific UID set and
595
- // will simply not see that entry rather than the whole call crashing.
596
- // See #408 for the investigation.
601
+ // See #408 for the investigation. This is the client library glitching,
602
+ // not the message being malformed, so the row is dropped rather than
603
+ // quarantined recording it as a defective message would advance a
604
+ // cursor past mail that is fine (issue #72).
605
+ //
606
+ // Dropping it is only safe because the caller treats a requested UID
607
+ // with no row as unconsumed and holds its watermark below it. It used
608
+ // to advance regardless, which is how a transient client glitch turned
609
+ // into a message never fetched again.
597
610
  if (msg.uid == null || msg.internalDate == null) {
598
611
  continue;
599
612
  }
600
613
 
601
614
  // Coerce INTERNALDATE without ever throwing: a malformed value must not
602
- // abort the whole fetch batch. `null` only for an absent value (already
603
- // handled by the guard above); a bad value falls back to now.
604
- const internalDate = toInternalDate(msg.internalDate);
605
- if (internalDate === null) {
606
- continue;
607
- }
615
+ // abort the whole fetch batch. A bad value falls back to now; an absent
616
+ // one cannot reach here, having been dropped above.
617
+ const internalDate = toInternalDate(msg.internalDate) ?? new Date();
608
618
 
609
619
  // Parse References header if present
610
620
  const references = await this.parseReferencesHeader(msg.headers);
@@ -840,20 +850,13 @@ export class ImapFlowConnection {
840
850
  }
841
851
  | undefined,
842
852
  ): ImapMessage["envelope"] => {
843
- if (!envelope) {
844
- return {
845
- date: "",
846
- subject: "",
847
- from: [],
848
- sender: [],
849
- replyTo: [],
850
- to: [],
851
- cc: [],
852
- bcc: [],
853
- inReplyTo: "",
854
- messageId: "",
855
- };
856
- }
853
+ // An absent ENVELOPE stays absent. Synthesising an empty one here made
854
+ // every `if (!msg.envelope)` guard downstream unreachable, so a FETCH row
855
+ // that carried no envelope was saved as a row with no sender, no subject
856
+ // and no date, keyed by the `generated:` fallback — indistinguishable
857
+ // from a real message. The field is optional on `ImapMessage` precisely
858
+ // so callers can see the difference (issue #72).
859
+ if (!envelope) return undefined;
857
860
 
858
861
  const convertAddresses = (
859
862
  addrs?: Array<{ name?: string; address?: string }>,
package/src/index.ts CHANGED
@@ -15,6 +15,10 @@ export {
15
15
  type ParsedAttributes,
16
16
  parseImapAttributes,
17
17
  } from "./attribute-mapper.js";
18
+ export {
19
+ BodyParseError,
20
+ parseMessageBody,
21
+ } from "./body-parse.js";
18
22
  export {
19
23
  type BodySyncLogger,
20
24
  BodySyncService,
@@ -22,6 +26,7 @@ export {
22
26
  extractPrimaryFromEmail,
23
27
  type FetchBodyResult,
24
28
  type PlacementConfig,
29
+ type QuarantineConfig,
25
30
  type SyncBodiesResult,
26
31
  toParsedBody,
27
32
  } from "./body-sync.js";
@@ -162,6 +167,7 @@ export {
162
167
  type ParsedMessageContent,
163
168
  parseMessageContent,
164
169
  } from "./message-parser.js";
170
+ export { isMessageGoneFromOpenMailbox } from "./message-presence.js";
165
171
  export {
166
172
  type ImapConnectionFactory,
167
173
  MessageSyncService,
@@ -188,6 +194,16 @@ export {
188
194
  type ResolveExhaustedPlacementMoveResult,
189
195
  resolveExhaustedPlacementMoveFailure,
190
196
  } from "./placement-move-terminal.js";
197
+ export {
198
+ type QuarantineContext,
199
+ QuarantinedUids,
200
+ type QuarantineFailure,
201
+ type QuarantineLogger,
202
+ type QuarantineMessageShape,
203
+ QuarantineService,
204
+ resolveMailboxRole,
205
+ shapeFromMessageData,
206
+ } from "./quarantine.js";
191
207
  export {
192
208
  extractSnippetFromEmail,
193
209
  generateSnippet,
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
4
+ import type { IImapConnection } from "./types.js";
5
+
6
+ type Probe = Pick<IImapConnection, "fetchMessages" | "search">;
7
+
8
+ const buildProbe = (
9
+ fetched: number[],
10
+ searched: number[],
11
+ searchCalls: unknown[][] = [],
12
+ ): Probe => ({
13
+ fetchMessages: async () => fetched.map((uid) => ({ uid }) as never),
14
+ search: async (criteria: unknown[]) => {
15
+ searchCalls.push(criteria);
16
+ return searched;
17
+ },
18
+ });
19
+
20
+ describe("isMessageGoneFromOpenMailbox", () => {
21
+ it("a FETCH row is proof of presence — no SEARCH needed", async () => {
22
+ const searchCalls: unknown[][] = [];
23
+ const gone = await isMessageGoneFromOpenMailbox(
24
+ buildProbe([7], [], searchCalls),
25
+ 7,
26
+ );
27
+
28
+ assert.equal(gone, false);
29
+ assert.equal(searchCalls.length, 0);
30
+ });
31
+
32
+ it("an empty FETCH the SEARCH contradicts is a dropped row, not an absence", async () => {
33
+ const gone = await isMessageGoneFromOpenMailbox(buildProbe([], [7]), 7);
34
+
35
+ assert.equal(gone, false);
36
+ });
37
+
38
+ it("only a SEARCH that does not list the uid confirms it is gone", async () => {
39
+ const gone = await isMessageGoneFromOpenMailbox(buildProbe([], []), 7);
40
+
41
+ assert.equal(gone, true);
42
+ });
43
+
44
+ it("asks the server for the uid it is about to reconcile", async () => {
45
+ const searchCalls: unknown[][] = [];
46
+ await isMessageGoneFromOpenMailbox(buildProbe([], [], searchCalls), 42);
47
+
48
+ assert.deepEqual(searchCalls, [[["UID", "42"]]]);
49
+ });
50
+ });
@@ -0,0 +1,35 @@
1
+ import type { IImapConnection } from "./types.js";
2
+
3
+ /**
4
+ * Whether a UID is confirmed gone from the currently open mailbox.
5
+ *
6
+ * A FETCH that yields no row is not proof of absence. imapflow drops rows on
7
+ * back-to-back FETCHes (#408) — the same client glitch #100 had to stop
8
+ * reading as authoritative on the sync path — so a transient blip returns an
9
+ * empty result for a message that is still on the server. Anything that
10
+ * deletes local rows on that reading destroys live mail.
11
+ *
12
+ * Absence is therefore confirmed with a UID SEARCH, which the server answers
13
+ * with a plain UID set rather than a stream of message rows, and which
14
+ * `placement-move-push.ts` already uses as its verification probe. Only a
15
+ * SEARCH that does not list the UID counts as gone; an empty FETCH the SEARCH
16
+ * contradicts leaves the message present, and the caller treats it as such.
17
+ *
18
+ * The absence verdict rests on an empty SEARCH meaning the server matched
19
+ * nothing, so `IImapConnection.search` must throw when a SEARCH fails rather
20
+ * than answer `[]` — an implementation that reports failure as an empty array
21
+ * hands this function a deletion authority it cannot tell apart from a genuine
22
+ * miss.
23
+ */
24
+ export const isMessageGoneFromOpenMailbox = async (
25
+ connection: Pick<IImapConnection, "fetchMessages" | "search">,
26
+ uid: number,
27
+ ): Promise<boolean> => {
28
+ const found = await connection.fetchMessages([uid]);
29
+ if (found.length > 0) {
30
+ return false;
31
+ }
32
+
33
+ const matched = await connection.search([["UID", String(uid)]]);
34
+ return !matched.includes(uid);
35
+ };