@remit/mailbox-service 0.0.111 → 0.0.112

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.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +156 -235
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +5 -4
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -1,307 +0,0 @@
1
- /**
2
- * E2E adversarial MIME corpus (#402).
3
- *
4
- * Each .eml fixture under `test/fixtures/mime-adversarial-e2e/` is APPENDed to
5
- * the mailfuzz Dovecot INBOX, fetched back via IMAP, then parsed end-to-end
6
- * with mailparser. The suite asserts the two behaviours called out in #402:
7
- *
8
- * 1. body renders — `parsed.text` or `parsed.html` is non-empty
9
- * 2. attachments are listable — when the fixture declares attachments, every
10
- * expected filename appears in `parsed.attachments`
11
- * with non-zero content length
12
- *
13
- * The shapes here are content-driven adversarial cases that real-world senders
14
- * actually emit (PDF as application/octet-stream, TNEF, nested multipart/related,
15
- * forwarded message/rfc822, calendar invites, quoted-printable soft breaks,
16
- * 8-bit UTF-8 bodies, empty text/plain alternatives) — the same class of shape
17
- * that caused the body-sync regression in #394.
18
- *
19
- * Mapper unit-level defence lives in `body-part-mapper.property.test.ts`; this
20
- * suite is the integration-layer belt-and-braces against the same class.
21
- */
22
-
23
- import assert from "node:assert/strict";
24
- import { readdirSync, readFileSync } from "node:fs";
25
- import { dirname, join, resolve } from "node:path";
26
- import { after, before, describe, test } from "node:test";
27
- import { fileURLToPath } from "node:url";
28
- import { type ParsedMail, simpleParser } from "mailparser";
29
- import { uniqueMailboxName } from "./test-helpers/isolated-mailbox.js";
30
- import {
31
- createMailfuzzConnection,
32
- withMailfuzzConnection,
33
- } from "./test-helpers/mailfuzz-connection.js";
34
-
35
- const __filename = fileURLToPath(import.meta.url);
36
- const __dirname = dirname(__filename);
37
- const FIXTURES_DIR = resolve(
38
- __dirname,
39
- "../test/fixtures/mime-adversarial-e2e",
40
- );
41
-
42
- interface AttachmentExpectation {
43
- filename: string;
44
- contentType?: string;
45
- }
46
-
47
- interface FixtureExpectation {
48
- /** Subject must round-trip — proves the message we fetched is the one we appended. */
49
- subjectIncludes: string;
50
- /** At least one of `text` or `html` must be non-empty after parsing. */
51
- bodyRenders: true;
52
- /** Attachments expected after parsing. Empty array means "no attachments". */
53
- attachments: AttachmentExpectation[];
54
- /** Optional substring that must appear in parsed.text or parsed.html. */
55
- bodyContains?: string;
56
- /**
57
- * Skip the BODYSTRUCTURE re-fetch for this fixture.
58
- * Set when the fixture shape is synthetic and doesn't produce a meaningful
59
- * BODYSTRUCTURE response (e.g. bare TNEF blob — see fixture 05 and #408).
60
- */
61
- skipBodyStructure?: true;
62
- }
63
-
64
- /**
65
- * Expectations indexed by .eml basename. Each fixture must have one entry.
66
- *
67
- * Filename is the contract — match it exactly to the file on disk.
68
- */
69
- const EXPECTATIONS: Record<string, FixtureExpectation> = {
70
- "01-octet-stream-pdf.eml": {
71
- subjectIncludes: "Adversarial fixture 01",
72
- bodyRenders: true,
73
- attachments: [{ filename: "contract.pdf" }],
74
- bodyContains: "contract PDF",
75
- },
76
- "02-nested-related-cid-image.eml": {
77
- subjectIncludes: "Adversarial fixture 02",
78
- bodyRenders: true,
79
- attachments: [
80
- { filename: "logo.png", contentType: "image/png" },
81
- { filename: "invoice.pdf" },
82
- ],
83
- bodyContains: "Inline logo",
84
- },
85
- "03-calendar-invite.eml": {
86
- subjectIncludes: "Adversarial fixture 03",
87
- bodyRenders: true,
88
- attachments: [{ filename: "invite.ics" }],
89
- bodyContains: "Meeting invite",
90
- },
91
- "04-forwarded-rfc822-with-attachments.eml": {
92
- subjectIncludes: "Adversarial fixture 04",
93
- bodyRenders: true,
94
- // mailparser surfaces the forwarded rfc822 as an attachment whose name
95
- // derives from the Content-Disposition filename. The inner pdf is not
96
- // surfaced as a top-level attachment because it lives inside the nested
97
- // rfc822 — listing the rfc822 itself is the integration-layer contract.
98
- attachments: [{ filename: "forwarded.eml" }],
99
- bodyContains: "Forwarding",
100
- },
101
- "05-tnef-winmail.eml": {
102
- subjectIncludes: "Adversarial fixture 05",
103
- bodyRenders: true,
104
- attachments: [{ filename: "winmail.dat" }],
105
- bodyContains: "TNEF",
106
- // skipBodyStructure: The TNEF fixture uses a synthetic magic-byte stub
107
- // rather than a real-world winmail.dat capture, so the BODYSTRUCTURE
108
- // returned by Dovecot for this fixture is not meaningful. The re-fetch
109
- // is skipped here to avoid asserting on a shape that will change once
110
- // the fixture is replaced with a real capture. See #408.
111
- skipBodyStructure: true,
112
- },
113
- "06-8bit-non-ascii.eml": {
114
- subjectIncludes: "Café façade",
115
- bodyRenders: true,
116
- attachments: [],
117
- bodyContains: "café",
118
- },
119
- "07-quoted-printable-soft-breaks.eml": {
120
- subjectIncludes: "Adversarial fixture 07",
121
- bodyRenders: true,
122
- attachments: [],
123
- // "single" is split across a soft line break in the encoded source;
124
- // only an actually-decoded quoted-printable reassembles it.
125
- bodyContains: "single logical line",
126
- },
127
- "08-alternative-empty-text.eml": {
128
- subjectIncludes: "Adversarial fixture 08",
129
- bodyRenders: true,
130
- attachments: [],
131
- // text/plain is empty; rendering must fall back to html.
132
- bodyContains: "intentionally empty",
133
- },
134
- };
135
-
136
- const listEmlFixtures = (): string[] =>
137
- readdirSync(FIXTURES_DIR)
138
- .filter((name) => name.endsWith(".eml"))
139
- .sort();
140
-
141
- const loadFixtureRaw = (name: string): string => {
142
- const path = join(FIXTURES_DIR, name);
143
- // Read as utf8 — fixture 06 includes raw 8-bit UTF-8 bytes which IMAP
144
- // servers accept on APPEND as long as they're valid UTF-8 octets.
145
- return readFileSync(path, "utf8");
146
- };
147
-
148
- const bodyText = (parsed: ParsedMail): string => {
149
- const text = parsed.text ?? "";
150
- const html = typeof parsed.html === "string" ? parsed.html : "";
151
- return `${text}\n${html}`;
152
- };
153
-
154
- describe("Adversarial MIME corpus (Dovecot) — #402", {
155
- skip: !process.env.RUN_E2E_TESTS,
156
- }, () => {
157
- // Own mailbox instead of the shared INBOX so appending fixtures never
158
- // collides with another e2e file's count/flag assertions (#508).
159
- const inbox = uniqueMailboxName("E2E_adversarial");
160
-
161
- before(async () => {
162
- await withMailfuzzConnection(async (connection) => {
163
- await connection.createMailbox(inbox);
164
- });
165
- });
166
-
167
- after(async () => {
168
- const connection = createMailfuzzConnection();
169
- await connection.connect();
170
- await connection.deleteMailbox(inbox).catch(() => {});
171
- if (connection.isConnected) {
172
- await connection.disconnect();
173
- }
174
- });
175
-
176
- // Sanity: every .eml on disk has an expectation entry, and every
177
- // expectation entry points at an existing file. Catches a fixture being
178
- // added without an assertion (or vice versa).
179
- test("every fixture has an expectation entry", () => {
180
- const onDisk = new Set(listEmlFixtures());
181
- const expected = new Set(Object.keys(EXPECTATIONS));
182
- for (const name of onDisk) {
183
- assert.ok(
184
- expected.has(name),
185
- `Fixture ${name} has no expectation entry in EXPECTATIONS`,
186
- );
187
- }
188
- for (const name of expected) {
189
- assert.ok(
190
- onDisk.has(name),
191
- `Expectation ${name} has no .eml file on disk`,
192
- );
193
- }
194
- // Pin the count: #402 calls for 8 adversarial shapes.
195
- assert.equal(
196
- onDisk.size,
197
- 8,
198
- "Expected exactly 8 adversarial MIME fixtures",
199
- );
200
- });
201
-
202
- for (const name of Object.keys(EXPECTATIONS).sort()) {
203
- const expectation = EXPECTATIONS[name];
204
- if (!expectation) continue;
205
-
206
- test(`fixture ${name} round-trips through IMAP and parses`, async () => {
207
- const raw = loadFixtureRaw(name);
208
-
209
- await withMailfuzzConnection(async (connection) => {
210
- // APPEND the fixture into this file's own mailbox. Mailfuzz uses
211
- // Dovecot which returns a proper UID on APPEND (mokapi does not —
212
- // see outbox-roundtrip.e2e.test.ts and the RFC 025 notes).
213
- const appendResult = await connection.append(inbox, raw);
214
- assert.ok(
215
- appendResult.uid > 0,
216
- `APPEND should return a UID for ${name}`,
217
- );
218
-
219
- await connection.openBox(inbox, true);
220
-
221
- // Fetch the message we just appended back. fetchMessageBody
222
- // returns the raw RFC822 source; mailparser handles the rest
223
- // (multipart walking, base64/qp decoding, charset folding).
224
- const rfc822 = await connection.fetchMessageBody(appendResult.uid);
225
- assert.ok(
226
- rfc822 && rfc822.length > 0,
227
- `fetchMessageBody should return bytes for ${name}`,
228
- );
229
-
230
- // BODYSTRUCTURE re-fetch: exercises the IMAP mapper path at the
231
- // integration boundary (the exact regression class from #394).
232
- // Skipped for fixtures with synthetic shapes that don't produce
233
- // meaningful BODYSTRUCTURE (see skipBodyStructure flag + #408).
234
- // The guard in fetchMessages skips rows with undefined uid/internalDate
235
- // so back-to-back FETCH is safe on Dovecot (see imapflow-connection.ts).
236
- if (!expectation.skipBodyStructure) {
237
- const refetched = await connection.fetchMessages([appendResult.uid]);
238
- assert.ok(
239
- refetched.length > 0,
240
- `BODYSTRUCTURE re-fetch should return a message for ${name}`,
241
- );
242
- assert.ok(
243
- refetched[0]?.bodyStructure != null,
244
- `BODYSTRUCTURE should be present for ${name}`,
245
- );
246
- }
247
-
248
- const parsed = await simpleParser(rfc822);
249
-
250
- // Subject round-trips (proves we fetched the right message and
251
- // any encoded-word headers decoded correctly).
252
- assert.ok(
253
- parsed.subject,
254
- `Parsed subject should be present for ${name}`,
255
- );
256
- assert.ok(
257
- parsed.subject?.includes(expectation.subjectIncludes),
258
- `Subject "${parsed.subject}" should include "${expectation.subjectIncludes}" for ${name}`,
259
- );
260
-
261
- // Body renders — at least one of text/html is non-empty. This
262
- // is the headline "body renders" assertion from #402.
263
- const text = parsed.text ?? "";
264
- const html = typeof parsed.html === "string" ? parsed.html : "";
265
- assert.ok(
266
- text.trim().length > 0 || html.trim().length > 0,
267
- `Body should render (text or html non-empty) for ${name}`,
268
- );
269
-
270
- if (expectation.bodyContains) {
271
- const haystack = bodyText(parsed).toLowerCase();
272
- const needle = expectation.bodyContains.toLowerCase();
273
- assert.ok(
274
- haystack.includes(needle),
275
- `Body should contain "${expectation.bodyContains}" for ${name}`,
276
- );
277
- }
278
-
279
- // Attachments are listable — every expected attachment appears
280
- // in parsed.attachments with a matching filename and non-zero
281
- // content length. This is the "attachments listable" half of
282
- // the #402 contract.
283
- const attachments = parsed.attachments ?? [];
284
- for (const expectedAtt of expectation.attachments) {
285
- const found = attachments.find(
286
- (a) => a.filename === expectedAtt.filename,
287
- );
288
- assert.ok(
289
- found,
290
- `Attachment "${expectedAtt.filename}" should be listable for ${name}; got [${attachments.map((a) => a.filename).join(", ")}]`,
291
- );
292
- assert.ok(
293
- (found.size ?? 0) > 0,
294
- `Attachment "${expectedAtt.filename}" should have non-zero size for ${name}`,
295
- );
296
- if (expectedAtt.contentType) {
297
- assert.equal(
298
- found.contentType,
299
- expectedAtt.contentType,
300
- `Attachment "${expectedAtt.filename}" should have contentType "${expectedAtt.contentType}" for ${name}`,
301
- );
302
- }
303
- }
304
- });
305
- });
306
- }
307
- });
@@ -1,219 +0,0 @@
1
- /**
2
- * Issue #499: `Address.flags.unsubscribed` auto-mark-read is a first-
3
- * classification decision, like `Message.category` (#355) and the placement
4
- * verdict (#383). Re-deriving it on the two shipped re-entrant paths —
5
- * `fetchAndGetBody`'s `NoSuchKey` fallback and `syncBodies(..., force: true)`
6
- * — undid a user who had deliberately marked such a message unread.
7
- *
8
- * The re-entrant fixtures below keep `flags.unsubscribed` set, so a regression
9
- * that drops the guard shows up as a real `FlagQueueService.markAsRead`
10
- * round-trip against a message the user owns, not as a fixture that happens to
11
- * disagree with the flag.
12
- */
13
-
14
- import assert from "node:assert/strict";
15
- import { Readable } from "node:stream";
16
- import { describe, it } from "node:test";
17
- import type {
18
- AddressItem,
19
- IAddressRepository,
20
- IEnvelopeRepository,
21
- IMessageRepository,
22
- IThreadMessageRepository,
23
- MessageItem,
24
- UpdateMessageInput,
25
- } from "@remit/data-ports";
26
- import type { StorageService } from "@remit/storage-service";
27
- import { BodySyncService } from "./body-sync.js";
28
- import type { FlagQueueService } from "./flag-queue.js";
29
- import type { IImapConnection } from "./types.js";
30
-
31
- const UNSUBSCRIBED_SENDER = "newsletter@example.com";
32
-
33
- const PLAIN_EML = Buffer.from(
34
- [
35
- `From: Newsletter <${UNSUBSCRIBED_SENDER}>`,
36
- "To: me@example.com",
37
- "Subject: This week",
38
- "List-Unsubscribe: <mailto:stop@example.com>",
39
- "Content-Type: text/plain",
40
- "",
41
- "body",
42
- ].join("\r\n"),
43
- );
44
-
45
- interface MarkReadCall {
46
- accountConfigId: string;
47
- messageId: string;
48
- accountId: string;
49
- }
50
-
51
- interface Harness {
52
- service: BodySyncService;
53
- markReadCalls: MarkReadCall[];
54
- }
55
-
56
- const buildHarness = (
57
- message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
58
- retrieve: () => Promise<Buffer>,
59
- ): Harness => {
60
- const markReadCalls: MarkReadCall[] = [];
61
-
62
- const messageRow = {
63
- uid: 1,
64
- mailboxId: "mb-inbox",
65
- ...message,
66
- } as unknown as MessageItem;
67
-
68
- const messageService = {
69
- get: async () => messageRow,
70
- update: async (_messageId: string, input: UpdateMessageInput) => {
71
- Object.assign(messageRow, input);
72
- },
73
- } as unknown as IMessageRepository;
74
-
75
- const threadMessageService = {
76
- findAllByMessageId: async () => [
77
- {
78
- threadMessageId: "tm-1",
79
- messageId: message.messageId,
80
- mailboxId: messageRow.mailboxId,
81
- sentDate: 1,
82
- isRead: false,
83
- isDeleted: false,
84
- hasStars: false,
85
- hasAttachment: false,
86
- },
87
- ],
88
- update: async () => {},
89
- } as unknown as IThreadMessageRepository;
90
-
91
- const storageService = {
92
- retrieve,
93
- storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
94
- storeMessageBodyStream: async () => ({
95
- uri: `s3://bodies/${message.messageId}`,
96
- }),
97
- storeParsedBody: async () => {},
98
- listBodyParts: async () => [],
99
- } as unknown as StorageService;
100
-
101
- const addressService = {
102
- getAddress: async () =>
103
- ({
104
- flags: { unsubscribed: { value: true, setAt: 1 } },
105
- }) as unknown as AddressItem,
106
- incrementInboundCount: async () => {},
107
- } as unknown as IAddressRepository;
108
-
109
- const envelopeService = {
110
- listBodyParts: async () => [],
111
- } as unknown as IEnvelopeRepository;
112
-
113
- const flagQueueService = {
114
- markAsRead: async (
115
- accountConfigId: string,
116
- messageId: string,
117
- accountId: string,
118
- ) => {
119
- markReadCalls.push({ accountConfigId, messageId, accountId });
120
- },
121
- } as unknown as FlagQueueService;
122
-
123
- const service = new BodySyncService(
124
- messageService,
125
- storageService,
126
- threadMessageService,
127
- addressService,
128
- envelopeService,
129
- { info: () => {}, error: () => {} },
130
- undefined,
131
- undefined,
132
- undefined,
133
- { flagQueueService },
134
- );
135
-
136
- return { service, markReadCalls };
137
- };
138
-
139
- const noSuchKeyError = () =>
140
- Object.assign(new Error("missing"), { name: "NoSuchKey" });
141
-
142
- const bodyConnection = () =>
143
- ({
144
- openBox: async () => {},
145
- fetchMessageBody: async () => PLAIN_EML,
146
- async *fetchMessageBodies(uids: number[]) {
147
- for (const uid of uids) {
148
- yield { uid, source: Readable.from([PLAIN_EML]) };
149
- }
150
- },
151
- }) as unknown as IImapConnection;
152
-
153
- describe("unsubscribed auto-mark-read is decided once (issue #499)", () => {
154
- it("marks a message read on the pass that first classifies it", async () => {
155
- const harness = buildHarness({ messageId: "m-1" }, async () => {
156
- throw new Error("no body stored yet; must not retrieve");
157
- });
158
-
159
- await harness.service.fetchAndGetBody(
160
- "m-1",
161
- "acc-1",
162
- "cfg-1",
163
- { fullPath: "INBOX", placement: "inbox" },
164
- async () => bodyConnection(),
165
- );
166
-
167
- assert.deepEqual(harness.markReadCalls, [
168
- { accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
169
- ]);
170
- });
171
-
172
- it("leaves a manually-unread message alone through the NoSuchKey IMAP re-fetch", async () => {
173
- const harness = buildHarness(
174
- { messageId: "m-1", bodyStorageKey: "s3://bodies/m-1" },
175
- async () => {
176
- throw noSuchKeyError();
177
- },
178
- );
179
-
180
- await harness.service.fetchAndGetBody(
181
- "m-1",
182
- "acc-1",
183
- "cfg-1",
184
- { fullPath: "INBOX", placement: "inbox" },
185
- async () => bodyConnection(),
186
- );
187
-
188
- assert.deepEqual(
189
- harness.markReadCalls,
190
- [],
191
- "a re-entrant pass must not re-apply auto-read over the user's unread",
192
- );
193
- });
194
-
195
- it("leaves a manually-unread message alone when syncBodies re-fetches with force", async () => {
196
- const harness = buildHarness(
197
- { messageId: "m-1", bodyStorageKey: "s3://bodies/m-1" },
198
- async () => {
199
- throw new Error("force path must not retrieve from storage");
200
- },
201
- );
202
-
203
- const result = await harness.service.syncBodies(
204
- ["m-1"],
205
- "acc-1",
206
- "cfg-1",
207
- { fullPath: "INBOX", placement: "inbox" },
208
- async () => bodyConnection(),
209
- true,
210
- );
211
-
212
- assert.deepEqual(result.syncedMessageIds, ["m-1"]);
213
- assert.deepEqual(
214
- harness.markReadCalls,
215
- [],
216
- "a forced re-sync must not re-apply auto-read over the user's unread",
217
- );
218
- });
219
- });