@remit/mailbox-service 0.0.110 → 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.
- package/package.json +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +226 -200
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +19 -15
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/types.ts +1 -1
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- 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.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
|
-
});
|