@remit/mailbox-service 0.0.14 → 0.0.16

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.14",
3
+ "version": "0.0.16",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The boundary the whole quarantine feature rests on: a message defect is a
3
+ * `BodyParseError`, everything else is not. If this type ever wraps something
4
+ * that is not the parser's refusal, an outage becomes a pile of records saying
5
+ * the user's mail is unreadable — and the cursor moves past it.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { BodyParseError, parseMessageBody } from "./body-parse.js";
11
+
12
+ const WELL_FORMED = Buffer.from(
13
+ [
14
+ "From: someone@example.com",
15
+ "Subject: hello",
16
+ "Content-Type: text/plain",
17
+ "",
18
+ "body",
19
+ ].join("\r\n"),
20
+ );
21
+
22
+ describe("parseMessageBody", () => {
23
+ it("returns the parsed message when the body is readable", async () => {
24
+ const parsed = await parseMessageBody(WELL_FORMED);
25
+ assert.equal(parsed.subject, "hello");
26
+ });
27
+
28
+ it("wraps a parser refusal so the catch site can attribute it", async () => {
29
+ const error = await parseMessageBody(
30
+ // A source the parser has to reject rather than tolerate.
31
+ null as unknown as Buffer,
32
+ ).then(
33
+ () => null,
34
+ (err: unknown) => err,
35
+ );
36
+
37
+ assert.ok(error instanceof BodyParseError);
38
+ assert.equal(error.name, "BodyParseError");
39
+ });
40
+
41
+ it("names an unknown charset, because that defect is identifiable", () => {
42
+ const error = new BodyParseError(new Error("Unknown charset: x-nonesuch"));
43
+ assert.equal(error.failureCode, "UnknownCharset");
44
+ });
45
+
46
+ it("refuses to guess a code from parser prose it cannot read", () => {
47
+ const error = new BodyParseError(new Error("something went sideways"));
48
+ assert.equal(error.failureCode, "UnreadableBody");
49
+ });
50
+
51
+ it("keeps the parser's own words, which stay on screen and off a report", () => {
52
+ const error = new BodyParseError(new Error("boundary never closed"));
53
+ assert.equal(error.message, "boundary never closed");
54
+ });
55
+ });
@@ -0,0 +1,61 @@
1
+ import type { QuarantineItem } from "@remit/data-ports";
2
+ import { QuarantineFailureCode } from "@remit/domain-enums";
3
+ import { type ParsedMail, simpleParser } from "mailparser";
4
+
5
+ type FailureCode = QuarantineItem["failureCode"];
6
+
7
+ /**
8
+ * The message body could not be parsed.
9
+ *
10
+ * This type is the whole reason the sync path can quarantine anything. Before
11
+ * it, every catch site on the body path saw one undifferentiated `unknown`
12
+ * covering mailparser, S3, DynamoDB and SQS alike, so "the message is built in
13
+ * a way Remit could not read" was indistinguishable from "S3 returned a 503".
14
+ * Recording the second as the first advances the cursor past mail that is
15
+ * perfectly fine and never fetches it again.
16
+ *
17
+ * Only {@link parseMessageBody} constructs one, and its try block contains the
18
+ * parse call and nothing else — so an instance of this type is proof that the
19
+ * message, not the infrastructure, is what failed. Everything else propagates.
20
+ */
21
+ export class BodyParseError extends Error {
22
+ readonly failureCode: FailureCode;
23
+
24
+ constructor(cause: unknown) {
25
+ const message = cause instanceof Error ? cause.message : String(cause);
26
+ super(message, { cause });
27
+ this.name = "BodyParseError";
28
+ this.failureCode = classifyBodyParseFailure(message);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * mailparser reports its refusals as free text with no stable type, code or
34
+ * class, so the closed failure vocabulary can only name a defect it can
35
+ * recognise without reading parser prose. The charset decoder is the one that
36
+ * says the same thing every time, because the sentence comes from iconv rather
37
+ * than the parser. Anything else is `UnreadableBody`, and the parser's own
38
+ * words go to `failureMessage`, which is shown on screen and never published.
39
+ *
40
+ * Guessing more finely than this would put a wrong, unfalsifiable code in the
41
+ * title of an issue filed under the user's own account.
42
+ */
43
+ const UNKNOWN_CHARSET = /unknown charset|unsupported charset|invalid encoding/i;
44
+
45
+ const classifyBodyParseFailure = (message: string): FailureCode =>
46
+ UNKNOWN_CHARSET.test(message)
47
+ ? QuarantineFailureCode.UnknownCharset
48
+ : QuarantineFailureCode.UnreadableBody;
49
+
50
+ /**
51
+ * Parse a raw RFC822 body, distinguishing a message defect from everything
52
+ * else. The try block is the parse call alone; nothing that touches storage,
53
+ * the queue or the database may be moved inside it.
54
+ */
55
+ export const parseMessageBody = async (body: Buffer): Promise<ParsedMail> => {
56
+ try {
57
+ return await simpleParser(body);
58
+ } catch (error) {
59
+ throw new BodyParseError(error);
60
+ }
61
+ };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The boundary this feature lives or dies on (issue #72).
3
+ *
4
+ * A message the parser refuses is set aside and stops being requeued. Storage
5
+ * and database failures — which the same per-message frame catches — keep
6
+ * propagating to the requeue path, because recording one as a quarantine would
7
+ * tell the user that mail Remit could not reach was mail Remit could not read,
8
+ * and let go of it.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type {
14
+ IAddressRepository,
15
+ IEnvelopeRepository,
16
+ IMailboxSpecialUseRepository,
17
+ IMessageRepository,
18
+ IQuarantineRepository,
19
+ IThreadMessageRepository,
20
+ MessageItem,
21
+ QuarantineItem,
22
+ QuarantineUpsertInput,
23
+ UpdateMessageInput,
24
+ } from "@remit/data-ports";
25
+ import { MessageCategory } from "@remit/domain-enums";
26
+ import type { StorageService } from "@remit/storage-service";
27
+ import { BodySyncService } from "./body-sync.js";
28
+ import { QuarantineService } from "./quarantine.js";
29
+
30
+ const UNPARSEABLE = Symbol("unparseable");
31
+
32
+ const buildHarness = (options: {
33
+ retrieve?: (key: string) => Promise<Buffer>;
34
+ existing?: QuarantineItem[];
35
+ upsertFails?: boolean;
36
+ }) => {
37
+ const writes: QuarantineUpsertInput[] = [];
38
+ const messageUpdates: string[] = [];
39
+
40
+ const message: MessageItem = {
41
+ messageId: "m-1",
42
+ mailboxId: "mbx-1",
43
+ uid: 40217,
44
+ rfc822Size: 2048,
45
+ messageIdHeader: "<abc@example.com>",
46
+ bodyStorageKey: "s3://bodies/m-1",
47
+ category: MessageCategory.uncategorized,
48
+ } as MessageItem;
49
+
50
+ const messageService = {
51
+ get: async () => message,
52
+ update: async (messageId: string, _input: UpdateMessageInput) => {
53
+ messageUpdates.push(messageId);
54
+ },
55
+ } as unknown as IMessageRepository;
56
+
57
+ const storageService = {
58
+ retrieve:
59
+ options.retrieve ??
60
+ (async () => {
61
+ throw new Error("no body configured");
62
+ }),
63
+ } as unknown as StorageService;
64
+
65
+ const envelopeService = {
66
+ getMessageData: async () => ({ bodyPart: [], bodyPartParameter: [] }),
67
+ } as unknown as IEnvelopeRepository;
68
+
69
+ const repository = {
70
+ listByAccountConfigId: async () => options.existing ?? [],
71
+ upsert: async (input: QuarantineUpsertInput) => {
72
+ if (options.upsertFails) throw new Error("database unavailable");
73
+ writes.push(input);
74
+ },
75
+ } satisfies IQuarantineRepository;
76
+
77
+ const service = new BodySyncService(
78
+ messageService,
79
+ storageService,
80
+ {
81
+ getByMessageId: async () => ({
82
+ threadMessageId: "tm-1",
83
+ sentDate: 1,
84
+ mailboxId: "mbx-1",
85
+ isRead: false,
86
+ isDeleted: false,
87
+ hasStars: false,
88
+ hasAttachment: false,
89
+ }),
90
+ update: async () => {},
91
+ } as unknown as IThreadMessageRepository,
92
+ {} as unknown as IAddressRepository,
93
+ envelopeService,
94
+ { info: () => {}, error: () => {}, debug: () => {} },
95
+ undefined,
96
+ undefined,
97
+ {
98
+ quarantineService: new QuarantineService(
99
+ repository,
100
+ {
101
+ listByMailboxId: async () => [],
102
+ } as unknown as IMailboxSpecialUseRepository,
103
+ "sha-abc",
104
+ { info: () => {}, warn: () => {} },
105
+ ),
106
+ mailboxId: "mbx-1",
107
+ uidValidity: 1_712_000_000,
108
+ attempts: 2,
109
+ },
110
+ );
111
+
112
+ return { service, writes, messageUpdates };
113
+ };
114
+
115
+ const sync = (service: BodySyncService) =>
116
+ service.syncBodies(["m-1"], "acc-1", "cfg-1", "INBOX", async () => {
117
+ throw new Error("this test must not open IMAP");
118
+ });
119
+
120
+ describe("body sync quarantines a message the parser refuses", () => {
121
+ const retrieveUnparseable = async () => UNPARSEABLE as unknown as Buffer;
122
+
123
+ it("records the failure instead of requeueing the message forever", async () => {
124
+ const harness = buildHarness({ retrieve: retrieveUnparseable });
125
+
126
+ const result = await sync(harness.service);
127
+
128
+ assert.equal(harness.writes.length, 1);
129
+ assert.deepEqual(result.failedMessageIds, []);
130
+ });
131
+
132
+ it("names the message by uid and UIDVALIDITY, so the record is idempotent", async () => {
133
+ const harness = buildHarness({ retrieve: retrieveUnparseable });
134
+
135
+ await sync(harness.service);
136
+
137
+ assert.equal(harness.writes[0]?.uid, 40217);
138
+ assert.equal(harness.writes[0]?.uidValidity, 1_712_000_000);
139
+ assert.equal(harness.writes[0]?.failureStage, "BodyParse");
140
+ });
141
+
142
+ it("does not mark the message synced — it was set aside, not applied", async () => {
143
+ const harness = buildHarness({ retrieve: retrieveUnparseable });
144
+
145
+ const result = await sync(harness.service);
146
+
147
+ assert.deepEqual(result.syncedMessageIds, []);
148
+ assert.deepEqual(harness.messageUpdates, []);
149
+ });
150
+ });
151
+
152
+ describe("body sync leaves infrastructure failures alone", () => {
153
+ it("requeues a storage failure rather than calling the message unreadable", async () => {
154
+ const harness = buildHarness({
155
+ retrieve: async () => {
156
+ throw new Error("S3 503 SlowDown");
157
+ },
158
+ });
159
+
160
+ const result = await sync(harness.service);
161
+
162
+ assert.deepEqual(harness.writes, []);
163
+ assert.deepEqual(result.failedMessageIds, ["m-1"]);
164
+ });
165
+
166
+ it("requeues a database failure the same way", async () => {
167
+ const harness = buildHarness({
168
+ retrieve: async () => {
169
+ const error = new Error("ProvisionedThroughputExceededException");
170
+ error.name = "ProvisionedThroughputExceededException";
171
+ throw error;
172
+ },
173
+ });
174
+
175
+ const result = await sync(harness.service);
176
+
177
+ assert.deepEqual(harness.writes, []);
178
+ assert.deepEqual(result.failedMessageIds, ["m-1"]);
179
+ });
180
+ });
181
+
182
+ describe("body sync contains a failure to write the record", () => {
183
+ it("requeues the message instead of aborting the batch", async () => {
184
+ const harness = buildHarness({
185
+ retrieve: async () => UNPARSEABLE as unknown as Buffer,
186
+ upsertFails: true,
187
+ });
188
+
189
+ // Writing the record is database work, so its failure is infrastructure:
190
+ // the message keeps its place in the queue rather than being let go of
191
+ // with nothing written down, and the rest of the batch is unaffected.
192
+ const result = await sync(harness.service);
193
+
194
+ assert.deepEqual(harness.writes, []);
195
+ assert.deepEqual(result.failedMessageIds, ["m-1"]);
196
+ });
197
+ });
198
+
199
+ describe("body sync skips what is already quarantined", () => {
200
+ it("does not fetch or re-parse a uid already set aside", async () => {
201
+ const harness = buildHarness({
202
+ existing: [
203
+ {
204
+ mailboxId: "mbx-1",
205
+ uidValidity: 1_712_000_000,
206
+ uid: 40217,
207
+ } as QuarantineItem,
208
+ ],
209
+ retrieve: async () => {
210
+ throw new Error("a quarantined message must not be read again");
211
+ },
212
+ });
213
+
214
+ const result = await sync(harness.service);
215
+
216
+ assert.equal(result.skippedCount, 1);
217
+ assert.deepEqual(harness.writes, []);
218
+ });
219
+ });
package/src/body-sync.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  MessageCategory,
19
19
  PlacementAction,
20
20
  PlacementConfidence,
21
+ QuarantineFailureStage,
21
22
  SenderTrust,
22
23
  } from "@remit/domain-enums";
23
24
  import {
@@ -27,6 +28,7 @@ import {
27
28
  } from "@remit/storage-service";
28
29
  import { type ParsedMail, simpleParser } from "mailparser";
29
30
  import pMap from "p-map";
31
+ import { BodyParseError, parseMessageBody } from "./body-parse.js";
30
32
  import { mapBodyPartsToContent } from "./body-part-mapper.js";
31
33
  import type { FilterMessage } from "./filters/match.js";
32
34
  import {
@@ -46,6 +48,7 @@ import {
46
48
  type FolderPlacement,
47
49
  } from "./heuristics/classifyPlacement.js";
48
50
  import type { PlacementMoveService } from "./placement-move.js";
51
+ import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
49
52
  import { extractSnippetFromEmail } from "./snippet.js";
50
53
  import { type IImapConnection, MailConnectionError } from "./types.js";
51
54
 
@@ -186,6 +189,24 @@ export interface PlacementConfig {
186
189
  placementMoveService: PlacementMoveService;
187
190
  }
188
191
 
192
+ /**
193
+ * What body sync needs to set a message aside (issue #72). The mailbox facts
194
+ * are supplied by the caller because they are properties of the round, not of
195
+ * any one message.
196
+ */
197
+ export interface QuarantineConfig {
198
+ quarantineService: QuarantineService;
199
+ mailboxId: string;
200
+ /**
201
+ * The mailbox's stored UIDVALIDITY. Taken from the mailbox row rather than
202
+ * from the open box because a round whose two disagree is paused by the
203
+ * cursor guard before it reaches any UID.
204
+ */
205
+ uidValidity: number;
206
+ /** Redeliveries of this batch so far, so the record says how hard we tried. */
207
+ attempts: number;
208
+ }
209
+
189
210
  export class BodySyncService {
190
211
  private log: BodySyncLogger;
191
212
  private readonly filterPipeline?: FilterPipeline;
@@ -199,6 +220,7 @@ export class BodySyncService {
199
220
  logger?: BodySyncLogger,
200
221
  private readonly placementConfig?: PlacementConfig,
201
222
  private readonly filterConfig?: FilterConfig,
223
+ private readonly quarantineConfig?: QuarantineConfig,
202
224
  ) {
203
225
  this.log = logger ?? noopLogger;
204
226
  this.filterPipeline = filterConfig
@@ -235,6 +257,7 @@ export class BodySyncService {
235
257
  ): Promise<SyncBodiesResult> {
236
258
  const syncedMessageIds: string[] = [];
237
259
  let skippedCount = 0;
260
+ const location = { accountId, accountConfigId, mailboxPath };
238
261
 
239
262
  // Resolve every message up front so we can issue ONE ranged FETCH for the
240
263
  // whole batch (the desktop-client pattern) instead of a SELECT + download
@@ -249,8 +272,34 @@ export class BodySyncService {
249
272
  // classification failed. They are NOT in `pending` — nothing about them
250
273
  // needs fetching — so they are merged into failedMessageIds separately.
251
274
  const backfillFailedMessageIds: string[] = [];
275
+
276
+ // One read per round, not per message (issue #72). The list is small by
277
+ // design and almost always empty, so a lookup per message would put a
278
+ // query on the hot path for a state that is nearly never set.
279
+ const quarantined =
280
+ await this.quarantineConfig?.quarantineService.load(accountConfigId);
281
+
252
282
  for (const messageId of messageIds) {
253
283
  const message = await this.messageService.get(messageId);
284
+
285
+ // A quarantined message has no stored body, so nothing else here would
286
+ // stop it being fetched and re-parsed on every redelivery, forever.
287
+ if (
288
+ this.quarantineConfig &&
289
+ quarantined?.has(
290
+ this.quarantineConfig.mailboxId,
291
+ this.quarantineConfig.uidValidity,
292
+ message.uid,
293
+ )
294
+ ) {
295
+ this.log.debug?.(
296
+ { messageId, uid: message.uid },
297
+ "Message is quarantined, skipping",
298
+ );
299
+ skippedCount++;
300
+ continue;
301
+ }
302
+
254
303
  if (message.bodyStorageKey && !force) {
255
304
  this.log.debug?.({ messageId }, "Body already stored, skipping");
256
305
  // The skip guard keys on the body, but classification is a separate
@@ -270,6 +319,21 @@ export class BodySyncService {
270
319
  () => null,
271
320
  (error: unknown) => error,
272
321
  );
322
+ // The stored bytes will not parse, and they will not start parsing on
323
+ // a later attempt. Requeueing forever is the stall; set the message
324
+ // aside instead. Everything else this call can throw is storage or
325
+ // database work and stays on the requeue path below.
326
+ if (
327
+ backfillError instanceof BodyParseError &&
328
+ (await this.quarantineBodyParse(
329
+ message.messageId,
330
+ location,
331
+ backfillError,
332
+ ))
333
+ ) {
334
+ skippedCount++;
335
+ continue;
336
+ }
273
337
  if (backfillError !== null) {
274
338
  this.log.error?.(
275
339
  {
@@ -330,6 +394,22 @@ export class BodySyncService {
330
394
  // batch requeues just this message. A dropped connection is handled
331
395
  // by the outer catch (fail-fast on the whole remaining batch).
332
396
  if (isConnectionDrop(error)) throw error;
397
+
398
+ // The one error in this frame that is the message's own fault. It
399
+ // reaches here from the parse call and nowhere else, so the S3, DDB
400
+ // and SQS work this same frame wraps cannot be mistaken for it and
401
+ // keeps requeueing below. The row is durable before the UID leaves
402
+ // `pending`, so the message is never let go of unrecorded.
403
+ if (
404
+ error instanceof BodyParseError &&
405
+ (await this.quarantineBodyParse(messageId, location, error))
406
+ ) {
407
+ pending.delete(uid);
408
+ skippedCount++;
409
+ source.resume();
410
+ continue;
411
+ }
412
+
333
413
  this.log.error?.(
334
414
  {
335
415
  messageId,
@@ -381,6 +461,86 @@ export class BodySyncService {
381
461
  return this.buildResult(syncedMessageIds, skippedCount, failedMessageIds);
382
462
  }
383
463
 
464
+ /**
465
+ * Set a message aside because its body will not parse (issue #72).
466
+ *
467
+ * Returns false when no record was written — either because this service was
468
+ * built without a quarantine writer, or because writing it failed. The
469
+ * caller then falls back to requeueing rather than dropping a message no
470
+ * record survives. The failure the row carries is the parser's, and only the
471
+ * parser's — see {@link BodyParseError}.
472
+ *
473
+ * Reading and writing the record is storage and database work, so its own
474
+ * failure is contained per message the same way the save it replaces is: it
475
+ * must not take down a batch that has other messages to fetch, and in the
476
+ * stream loop a throw would also skip `source.resume()` and leave the
477
+ * literal undrained on an otherwise usable connection.
478
+ *
479
+ * The fingerprint comes from the BodyPart rows metadata sync already wrote:
480
+ * the body path streams raw bytes and has no FETCH result to read, and
481
+ * re-reading headers the parser has just refused would be reading the very
482
+ * thing that is broken.
483
+ */
484
+ private async quarantineBodyParse(
485
+ messageId: string,
486
+ location: {
487
+ accountId: string;
488
+ accountConfigId: string;
489
+ mailboxPath: string;
490
+ },
491
+ error: BodyParseError,
492
+ ): Promise<boolean> {
493
+ const config = this.quarantineConfig;
494
+ if (!config) return false;
495
+
496
+ return this.recordQuarantine(config, messageId, location, error).then(
497
+ () => true,
498
+ (writeError: unknown) => {
499
+ this.log.error?.(
500
+ {
501
+ messageId,
502
+ errorName: (writeError as { name?: string }).name,
503
+ error: inspect(writeError),
504
+ },
505
+ "Could not record quarantine; leaving the message for requeue",
506
+ );
507
+ return false;
508
+ },
509
+ );
510
+ }
511
+
512
+ private async recordQuarantine(
513
+ config: QuarantineConfig,
514
+ messageId: string,
515
+ location: {
516
+ accountId: string;
517
+ accountConfigId: string;
518
+ mailboxPath: string;
519
+ },
520
+ error: BodyParseError,
521
+ ): Promise<void> {
522
+ const message = await this.messageService.get(messageId);
523
+ const messageData = await this.envelopeService.getMessageData(messageId);
524
+
525
+ await config.quarantineService.record(
526
+ {
527
+ accountId: location.accountId,
528
+ accountConfigId: location.accountConfigId,
529
+ mailboxId: config.mailboxId,
530
+ mailboxPath: location.mailboxPath,
531
+ uidValidity: config.uidValidity,
532
+ attempts: config.attempts,
533
+ },
534
+ message.uid,
535
+ {
536
+ stage: QuarantineFailureStage.BodyParse,
537
+ code: error.failureCode,
538
+ message: error.message,
539
+ },
540
+ shapeFromMessageData(message, messageData),
541
+ );
542
+ }
543
+
384
544
  private buildResult(
385
545
  syncedMessageIds: string[],
386
546
  skippedCount: number,
@@ -749,7 +909,7 @@ export class BodySyncService {
749
909
  }
750
910
 
751
911
  const body = await this.storageService.retrieve(message.bodyStorageKey);
752
- const parsed = await simpleParser(body);
912
+ const parsed = await parseMessageBody(body);
753
913
  const classification = this.classifyMessage(parsed);
754
914
 
755
915
  await this.messageService.update(message.messageId, classification);
@@ -1247,8 +1407,12 @@ export class BodySyncService {
1247
1407
  accountConfigId: string,
1248
1408
  body: Buffer,
1249
1409
  ): Promise<ParsedMail> {
1250
- // Parse the email body
1251
- const parsed = await simpleParser(body);
1410
+ // The one operation on this path that can fail because of how the message
1411
+ // is built. Its own try block lives in `parseMessageBody`, so the caller's
1412
+ // frame — which also wraps the S3 body write, the parsed-body cache, the
1413
+ // placement move, the label writes and the counter update — can tell the
1414
+ // two apart and quarantine only this one (issue #72).
1415
+ const parsed = await parseMessageBody(body);
1252
1416
 
1253
1417
  // Extract snippet from text or HTML content
1254
1418
  const snippet = extractSnippetFromEmail(
@@ -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", () => {
@@ -266,3 +267,51 @@ describe("ImapFlowConnection CONDSTORE (reader#20)", () => {
266
267
  assert.deepStrictEqual(messages[0].flags, ["\\Seen"]);
267
268
  });
268
269
  });
270
+
271
+ describe("ImapFlowConnection message envelopes (issue #72)", () => {
272
+ const fetchRows = async (
273
+ rows: Array<Record<string, unknown>>,
274
+ ): Promise<ImapMessage[]> => {
275
+ const connection = buildConnectionWithClient({
276
+ enabled: new Set(),
277
+ mailbox: fakeMailbox("INBOX", 1),
278
+ mailboxOpen: async (path: string) => fakeMailbox(path, 1),
279
+ fetch: () =>
280
+ (async function* () {
281
+ for (const row of rows) yield row;
282
+ })(),
283
+ });
284
+ Object.assign(connection as unknown as Record<string, unknown>, {
285
+ currentMailbox: "INBOX",
286
+ });
287
+ return connection.fetchMessages([1]);
288
+ };
289
+
290
+ it("leaves an absent ENVELOPE absent instead of synthesising an empty one", async () => {
291
+ // A synthesised envelope made every `if (!msg.envelope)` guard downstream
292
+ // unreachable, so the row was saved as a message with no sender, subject
293
+ // or date under a `generated:` key — indistinguishable from real mail.
294
+ const [message] = await fetchRows([
295
+ { uid: 1, seq: 1, internalDate: new Date(0), size: 10 },
296
+ ]);
297
+ assert.equal(message?.envelope, undefined);
298
+ });
299
+
300
+ it("still converts an envelope the server did send", async () => {
301
+ const [message] = await fetchRows([
302
+ {
303
+ uid: 1,
304
+ seq: 1,
305
+ internalDate: new Date(0),
306
+ size: 10,
307
+ envelope: {
308
+ subject: "Hello",
309
+ messageId: "<a@example.com>",
310
+ from: [{ address: "sender@example.com" }],
311
+ },
312
+ },
313
+ ]);
314
+ assert.equal(message?.envelope?.subject, "Hello");
315
+ assert.equal(message?.envelope?.from[0]?.mailbox, "sender");
316
+ });
317
+ });