@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 +1 -1
- package/src/body-parse.test.ts +55 -0
- package/src/body-parse.ts +61 -0
- package/src/body-sync-quarantine.test.ts +219 -0
- package/src/body-sync.ts +167 -3
- package/src/imapflow-connection.test.ts +49 -0
- package/src/imapflow-connection.ts +19 -23
- package/src/index.ts +15 -0
- package/src/message-sync-quarantine.test.ts +407 -0
- package/src/message-sync.ts +135 -18
- package/src/quarantine.test.ts +191 -0
- package/src/quarantine.ts +228 -0
- package/src/sync-watermarks.test.ts +0 -9
- package/src/sync-watermarks.ts +1 -4
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
1251
|
-
|
|
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
|
+
});
|