@remit/mailbox-service 0.0.109 → 0.0.111
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/adversarial-mime.e2e.test.ts +1 -1
- package/src/body-sync-message-gone.test.ts +139 -0
- package/src/body-sync.ts +110 -5
- package/src/filters/match.test.ts +36 -0
- package/src/filters/match.ts +17 -3
- package/src/imapflow-connection.e2e.test.ts +1 -1
- package/src/imapflow-connection.ts +14 -11
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -223,7 +223,7 @@ describe("Adversarial MIME corpus (Dovecot) — #402", {
|
|
|
223
223
|
// (multipart walking, base64/qp decoding, charset folding).
|
|
224
224
|
const rfc822 = await connection.fetchMessageBody(appendResult.uid);
|
|
225
225
|
assert.ok(
|
|
226
|
-
rfc822.length > 0,
|
|
226
|
+
rfc822 && rfc822.length > 0,
|
|
227
227
|
`fetchMessageBody should return bytes for ${name}`,
|
|
228
228
|
);
|
|
229
229
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IAddressRepository,
|
|
5
|
+
IEnvelopeRepository,
|
|
6
|
+
IMessageRepository,
|
|
7
|
+
IThreadMessageRepository,
|
|
8
|
+
MessageItem,
|
|
9
|
+
PlacementPredicate,
|
|
10
|
+
} from "@remit/data-ports";
|
|
11
|
+
import {
|
|
12
|
+
MessageGoneFromServerError,
|
|
13
|
+
MessagePlacementUnsettledError,
|
|
14
|
+
} from "@remit/data-ports/errors";
|
|
15
|
+
import type { StorageService } from "@remit/storage-service";
|
|
16
|
+
import { BodySyncService } from "./body-sync.js";
|
|
17
|
+
import type { IImapConnection } from "./types.js";
|
|
18
|
+
|
|
19
|
+
const MESSAGE_ID = "m-gone";
|
|
20
|
+
|
|
21
|
+
interface Scenario {
|
|
22
|
+
status: MessageItem["status"];
|
|
23
|
+
rowStillMatches?: boolean;
|
|
24
|
+
serverStillLists?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const readUnstoredMessage = async ({
|
|
28
|
+
status,
|
|
29
|
+
rowStillMatches = true,
|
|
30
|
+
serverStillLists = false,
|
|
31
|
+
}: Scenario) => {
|
|
32
|
+
const predicates: PlacementPredicate[] = [];
|
|
33
|
+
const deletedMessages: string[] = [];
|
|
34
|
+
const deletedThreadMessages: string[] = [];
|
|
35
|
+
|
|
36
|
+
const messageService = {
|
|
37
|
+
get: async () =>
|
|
38
|
+
({
|
|
39
|
+
messageId: MESSAGE_ID,
|
|
40
|
+
mailboxId: "mb-inbox",
|
|
41
|
+
uid: 42,
|
|
42
|
+
status,
|
|
43
|
+
}) as unknown as MessageItem,
|
|
44
|
+
deleteIfPlacement: async (
|
|
45
|
+
messageId: string,
|
|
46
|
+
expected: PlacementPredicate,
|
|
47
|
+
) => {
|
|
48
|
+
predicates.push(expected);
|
|
49
|
+
if (!rowStillMatches) return false;
|
|
50
|
+
deletedMessages.push(messageId);
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
} as unknown as IMessageRepository;
|
|
54
|
+
|
|
55
|
+
const threadMessageService = {
|
|
56
|
+
findAllByMessageId: async () => [
|
|
57
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-1" },
|
|
58
|
+
],
|
|
59
|
+
deleteMany: async (keys: { threadMessageId: string }[]) => {
|
|
60
|
+
deletedThreadMessages.push(...keys.map((key) => key.threadMessageId));
|
|
61
|
+
},
|
|
62
|
+
} as unknown as IThreadMessageRepository;
|
|
63
|
+
|
|
64
|
+
const service = new BodySyncService(
|
|
65
|
+
messageService,
|
|
66
|
+
{} as unknown as StorageService,
|
|
67
|
+
threadMessageService,
|
|
68
|
+
{} as unknown as IAddressRepository,
|
|
69
|
+
{} as unknown as IEnvelopeRepository,
|
|
70
|
+
{ info: () => {}, error: () => {} },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const connection = {
|
|
74
|
+
openBox: async () => {},
|
|
75
|
+
fetchMessageBody: async () => null,
|
|
76
|
+
fetchMessages: async () => [],
|
|
77
|
+
search: async () => (serverStillLists ? [42] : []),
|
|
78
|
+
} as unknown as IImapConnection;
|
|
79
|
+
|
|
80
|
+
const outcome = await service
|
|
81
|
+
.fetchAndGetBody(
|
|
82
|
+
MESSAGE_ID,
|
|
83
|
+
"acc-1",
|
|
84
|
+
"cfg-1",
|
|
85
|
+
{ fullPath: "INBOX", placement: "inbox" },
|
|
86
|
+
async () => connection,
|
|
87
|
+
)
|
|
88
|
+
.then(
|
|
89
|
+
() => assert.fail("expected the read to be refused"),
|
|
90
|
+
(error: unknown) => error,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return { outcome, predicates, deletedMessages, deletedThreadMessages };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
describe("reading a message the mail server no longer holds", () => {
|
|
97
|
+
it("answers 404 and removes the settled row with its thread entries", async () => {
|
|
98
|
+
const { outcome, predicates, deletedMessages, deletedThreadMessages } =
|
|
99
|
+
await readUnstoredMessage({ status: "active" });
|
|
100
|
+
|
|
101
|
+
assert.ok(outcome instanceof MessageGoneFromServerError);
|
|
102
|
+
assert.equal(outcome.statusCode, 404);
|
|
103
|
+
assert.deepEqual(predicates, [
|
|
104
|
+
{ status: "active", mailboxId: "mb-inbox", uid: 42 },
|
|
105
|
+
]);
|
|
106
|
+
assert.deepEqual(deletedMessages, [MESSAGE_ID]);
|
|
107
|
+
assert.deepEqual(deletedThreadMessages, ["tm-1"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("answers 409 and deletes nothing while our own move is in flight", async () => {
|
|
111
|
+
const { outcome, predicates, deletedThreadMessages } =
|
|
112
|
+
await readUnstoredMessage({ status: "moving" });
|
|
113
|
+
|
|
114
|
+
assert.ok(outcome instanceof MessagePlacementUnsettledError);
|
|
115
|
+
assert.equal(outcome.statusCode, 409);
|
|
116
|
+
assert.deepEqual(predicates, []);
|
|
117
|
+
assert.deepEqual(deletedThreadMessages, []);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("answers 409 and keeps the thread entries when the row changed after it was read", async () => {
|
|
121
|
+
const { outcome, deletedMessages, deletedThreadMessages } =
|
|
122
|
+
await readUnstoredMessage({ status: "active", rowStillMatches: false });
|
|
123
|
+
|
|
124
|
+
assert.ok(outcome instanceof MessagePlacementUnsettledError);
|
|
125
|
+
assert.deepEqual(deletedMessages, []);
|
|
126
|
+
assert.deepEqual(deletedThreadMessages, []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("fails loudly and deletes nothing when the server still lists the UID", async () => {
|
|
130
|
+
const { outcome, predicates } = await readUnstoredMessage({
|
|
131
|
+
status: "active",
|
|
132
|
+
serverStillLists: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
assert.ok(outcome instanceof Error);
|
|
136
|
+
assert.ok(!(outcome instanceof MessageGoneFromServerError));
|
|
137
|
+
assert.deepEqual(predicates, []);
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -13,12 +13,17 @@ import {
|
|
|
13
13
|
type IMailboxSpecialUseRepository,
|
|
14
14
|
type IMessageRepository,
|
|
15
15
|
type IThreadMessageRepository,
|
|
16
|
+
type IUnitOfWork,
|
|
16
17
|
isSenderMuted,
|
|
17
18
|
type MessageItem,
|
|
18
19
|
type ThreadMessageItem,
|
|
19
20
|
type UpdateMessageInput,
|
|
20
21
|
} from "@remit/data-ports";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
MessageGoneFromServerError,
|
|
24
|
+
MessagePlacementUnsettledError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
} from "@remit/data-ports/errors";
|
|
22
27
|
import { deriveAddressId, deriveBodyPartId } from "@remit/data-ports/id";
|
|
23
28
|
import { isBulkSender } from "@remit/data-ports/wellknown";
|
|
24
29
|
import {
|
|
@@ -62,7 +67,10 @@ import {
|
|
|
62
67
|
denormalizeMessageCategory,
|
|
63
68
|
hasDecidedCategory,
|
|
64
69
|
} from "./message-category.js";
|
|
70
|
+
import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
71
|
+
import { PassThroughUnitOfWork } from "./pass-through-unit-of-work.js";
|
|
65
72
|
import type { PlacementMoveService } from "./placement-move.js";
|
|
73
|
+
import { isPlacementUnsettled } from "./placement-settled.js";
|
|
66
74
|
import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
|
|
67
75
|
import { extractSnippetFromEmail } from "./snippet.js";
|
|
68
76
|
import { type IImapConnection, MailConnectionError } from "./types.js";
|
|
@@ -308,6 +316,7 @@ export interface CalendarSuggestionConfig {
|
|
|
308
316
|
export class BodySyncService {
|
|
309
317
|
private log: BodySyncLogger;
|
|
310
318
|
private readonly filterPipeline?: FilterPipeline;
|
|
319
|
+
private readonly unitOfWork: IUnitOfWork;
|
|
311
320
|
|
|
312
321
|
constructor(
|
|
313
322
|
private messageService: IMessageRepository,
|
|
@@ -321,8 +330,17 @@ export class BodySyncService {
|
|
|
321
330
|
private readonly quarantineConfig?: QuarantineConfig,
|
|
322
331
|
private readonly unsubscribeConfig?: UnsubscribeConfig,
|
|
323
332
|
private readonly calendarConfig?: CalendarSuggestionConfig,
|
|
333
|
+
unitOfWork?: IUnitOfWork,
|
|
324
334
|
) {
|
|
325
335
|
this.log = logger ?? noopLogger;
|
|
336
|
+
this.unitOfWork =
|
|
337
|
+
unitOfWork ??
|
|
338
|
+
new PassThroughUnitOfWork({
|
|
339
|
+
message: messageService,
|
|
340
|
+
envelope: envelopeService,
|
|
341
|
+
address: addressService,
|
|
342
|
+
threadMessage: threadMessageService,
|
|
343
|
+
});
|
|
326
344
|
this.filterPipeline = filterConfig
|
|
327
345
|
? new FilterPipeline(filterConfig, this.log)
|
|
328
346
|
: undefined;
|
|
@@ -1047,7 +1065,9 @@ export class BodySyncService {
|
|
|
1047
1065
|
);
|
|
1048
1066
|
needsStore = true;
|
|
1049
1067
|
body = await this.fetchFromImap(
|
|
1050
|
-
message
|
|
1068
|
+
message,
|
|
1069
|
+
accountId,
|
|
1070
|
+
accountConfigId,
|
|
1051
1071
|
folder.fullPath,
|
|
1052
1072
|
getConnection,
|
|
1053
1073
|
);
|
|
@@ -1056,7 +1076,9 @@ export class BodySyncService {
|
|
|
1056
1076
|
// Fetch from IMAP and store
|
|
1057
1077
|
needsStore = true;
|
|
1058
1078
|
body = await this.fetchFromImap(
|
|
1059
|
-
message
|
|
1079
|
+
message,
|
|
1080
|
+
accountId,
|
|
1081
|
+
accountConfigId,
|
|
1060
1082
|
folder.fullPath,
|
|
1061
1083
|
getConnection,
|
|
1062
1084
|
);
|
|
@@ -1099,14 +1121,97 @@ export class BodySyncService {
|
|
|
1099
1121
|
}
|
|
1100
1122
|
|
|
1101
1123
|
private async fetchFromImap(
|
|
1102
|
-
|
|
1124
|
+
message: MessageItem,
|
|
1125
|
+
accountId: string,
|
|
1126
|
+
accountConfigId: string,
|
|
1103
1127
|
mailboxPath: string,
|
|
1104
1128
|
getConnection: ConnectionGetter,
|
|
1105
1129
|
): Promise<Buffer> {
|
|
1130
|
+
const { uid } = message;
|
|
1106
1131
|
this.log.debug?.({ uid }, "Fetching body from IMAP");
|
|
1107
1132
|
const connection = await getConnection();
|
|
1108
1133
|
await connection.openBox(mailboxPath);
|
|
1109
|
-
|
|
1134
|
+
const body = await connection.fetchMessageBody(uid);
|
|
1135
|
+
if (body) return body;
|
|
1136
|
+
|
|
1137
|
+
if (!(await isMessageGoneFromOpenMailbox(connection, uid))) {
|
|
1138
|
+
throw new Error(
|
|
1139
|
+
`IMAP FETCH returned no source for UID ${uid} in ${mailboxPath}, which the server still lists`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return this.reconcileGoneMessage(
|
|
1144
|
+
message,
|
|
1145
|
+
accountId,
|
|
1146
|
+
accountConfigId,
|
|
1147
|
+
mailboxPath,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* The server no longer holds the message at the row's UID. A row with our
|
|
1153
|
+
* own move or delete in flight belongs to that mutation (409). A settled row
|
|
1154
|
+
* is removed with its thread entries, but only while its placement still
|
|
1155
|
+
* matches what was read (imap-mutations R3); losing that predicate is a 409
|
|
1156
|
+
* like any other lost placement transition.
|
|
1157
|
+
*/
|
|
1158
|
+
private async reconcileGoneMessage(
|
|
1159
|
+
message: MessageItem,
|
|
1160
|
+
accountId: string,
|
|
1161
|
+
accountConfigId: string,
|
|
1162
|
+
mailboxPath: string,
|
|
1163
|
+
): Promise<never> {
|
|
1164
|
+
const { messageId, uid } = message;
|
|
1165
|
+
if (isPlacementUnsettled(message)) {
|
|
1166
|
+
throw new MessagePlacementUnsettledError(
|
|
1167
|
+
`Message ${messageId} is not at its UID while a move or delete is in flight`,
|
|
1168
|
+
accountId,
|
|
1169
|
+
messageId,
|
|
1170
|
+
"in_flight",
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const reconciled = await this.unitOfWork.transaction(async (repos) => {
|
|
1175
|
+
const deleted = await repos.message.deleteIfPlacement(messageId, {
|
|
1176
|
+
status: message.status,
|
|
1177
|
+
mailboxId: message.mailboxId,
|
|
1178
|
+
uid,
|
|
1179
|
+
});
|
|
1180
|
+
if (!deleted) return false;
|
|
1181
|
+
const threadMessages = await repos.threadMessage.findAllByMessageId(
|
|
1182
|
+
accountConfigId,
|
|
1183
|
+
messageId,
|
|
1184
|
+
);
|
|
1185
|
+
if (threadMessages.length > 0) {
|
|
1186
|
+
await repos.threadMessage.deleteMany(
|
|
1187
|
+
threadMessages.map((row) => ({
|
|
1188
|
+
accountConfigId: row.accountConfigId,
|
|
1189
|
+
threadMessageId: row.threadMessageId,
|
|
1190
|
+
})),
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
return true;
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
if (!reconciled) {
|
|
1197
|
+
throw new MessagePlacementUnsettledError(
|
|
1198
|
+
`Message ${messageId} changed placement while its absence from ${mailboxPath} was being reconciled`,
|
|
1199
|
+
accountId,
|
|
1200
|
+
messageId,
|
|
1201
|
+
"in_flight",
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
this.log.info(
|
|
1206
|
+
{
|
|
1207
|
+
metric: "message_gone_stale_row_reconciled",
|
|
1208
|
+
messageId,
|
|
1209
|
+
uid,
|
|
1210
|
+
mailboxPath,
|
|
1211
|
+
},
|
|
1212
|
+
"Message no longer on the mail server (expunged or moved by another client); stale row reconciled",
|
|
1213
|
+
);
|
|
1214
|
+
throw new MessageGoneFromServerError(messageId);
|
|
1110
1215
|
}
|
|
1111
1216
|
|
|
1112
1217
|
/**
|
|
@@ -65,6 +65,42 @@ describe("clauseMatches", () => {
|
|
|
65
65
|
);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
it("matches HasWords on the sender display name alone, as search does", () => {
|
|
69
|
+
const msg = message({
|
|
70
|
+
from: "booking@mail.example",
|
|
71
|
+
fromName: "Austrian Airlines Booking",
|
|
72
|
+
subject: "Your trip to Vienna",
|
|
73
|
+
text: "Check in opens 24 hours before departure.",
|
|
74
|
+
});
|
|
75
|
+
assert.equal(
|
|
76
|
+
clauseMatches(
|
|
77
|
+
clause(FilterClauseField.HasWords, "Austrian airlines"),
|
|
78
|
+
msg,
|
|
79
|
+
),
|
|
80
|
+
true,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("matches HasWords regardless of word order and case", () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
clauseMatches(
|
|
87
|
+
clause(FilterClauseField.HasWords, "ALICE invoice"),
|
|
88
|
+
message(),
|
|
89
|
+
),
|
|
90
|
+
true,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does not match HasWords when one word is missing", () => {
|
|
95
|
+
assert.equal(
|
|
96
|
+
clauseMatches(
|
|
97
|
+
clause(FilterClauseField.HasWords, "invoice receipt"),
|
|
98
|
+
message(),
|
|
99
|
+
),
|
|
100
|
+
false,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
68
104
|
it("never matches an empty clause value", () => {
|
|
69
105
|
assert.equal(
|
|
70
106
|
clauseMatches(clause(FilterClauseField.HasWords, " "), message()),
|
package/src/filters/match.ts
CHANGED
|
@@ -49,6 +49,20 @@ export interface FilterMessage {
|
|
|
49
49
|
const includesFold = (haystack: string, needle: string): boolean =>
|
|
50
50
|
haystack.toLowerCase().includes(needle.toLowerCase());
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Whether every whitespace-separated word of `value` appears somewhere in the
|
|
54
|
+
* sender address, sender name, subject or body — the shape free-text search
|
|
55
|
+
* matches with (`buildSearchConditions`), so a filter made from a search keeps
|
|
56
|
+
* the mail that search showed.
|
|
57
|
+
*/
|
|
58
|
+
const hasEveryWord = (value: string, msg: FilterMessage): boolean => {
|
|
59
|
+
const fields = [msg.from, msg.fromName, msg.subject, msg.text];
|
|
60
|
+
return value
|
|
61
|
+
.split(/\s+/)
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.every((word) => fields.some((field) => includesFold(field, word)));
|
|
64
|
+
};
|
|
65
|
+
|
|
52
66
|
const hostOf = (address: string): string => {
|
|
53
67
|
const at = address.lastIndexOf("@");
|
|
54
68
|
return at >= 0 ? address.slice(at + 1) : address;
|
|
@@ -65,8 +79,8 @@ const registrableDomain = (addressOrHost: string): string | null =>
|
|
|
65
79
|
|
|
66
80
|
/**
|
|
67
81
|
* Whether one literal clause matches the message. From matches against the
|
|
68
|
-
* sender address and display name; Subject against the subject; HasWords
|
|
69
|
-
* subject or body; ListId against the exact normalized `List-Id`; FromDomain
|
|
82
|
+
* sender address and display name; Subject against the subject; HasWords
|
|
83
|
+
* requires every word somewhere in sender, subject or body; ListId against the exact normalized `List-Id`; FromDomain
|
|
70
84
|
* against the sender's registrable domain. An empty clause value never matches.
|
|
71
85
|
*/
|
|
72
86
|
export const clauseMatches = (
|
|
@@ -81,7 +95,7 @@ export const clauseMatches = (
|
|
|
81
95
|
case FilterClauseField.Subject:
|
|
82
96
|
return includesFold(msg.subject, value);
|
|
83
97
|
case FilterClauseField.HasWords:
|
|
84
|
-
return
|
|
98
|
+
return hasEveryWord(value, msg);
|
|
85
99
|
case FilterClauseField.ListId: {
|
|
86
100
|
const target = normalizeListId(value);
|
|
87
101
|
return target !== "" && normalizeListId(msg.listId) === target;
|
|
@@ -113,7 +113,7 @@ describe("Message bodies (Dovecot)", {
|
|
|
113
113
|
assert.ok(uids.length > 0, "Should have messages to fetch body for");
|
|
114
114
|
|
|
115
115
|
const body = await connection.fetchMessageBody(uids[0]);
|
|
116
|
-
assert.ok(body.length > 0, "Body should not be empty");
|
|
116
|
+
assert.ok(body && body.length > 0, "Body should not be empty");
|
|
117
117
|
|
|
118
118
|
const bodyText = body.toString("utf-8");
|
|
119
119
|
assert.ok(bodyText.length > 0, "Body text should not be empty");
|
|
@@ -721,9 +721,9 @@ export class ImapFlowConnection {
|
|
|
721
721
|
* Fetch the full message body (RFC822 source) for a single message by UID.
|
|
722
722
|
*
|
|
723
723
|
* @param uid - The UID of the message to fetch
|
|
724
|
-
* @returns The raw message body
|
|
724
|
+
* @returns The raw message body, or null when the UID FETCH matched no message
|
|
725
725
|
*/
|
|
726
|
-
fetchMessageBody = async (uid: number): Promise<Buffer> => {
|
|
726
|
+
fetchMessageBody = async (uid: number): Promise<Buffer | null> => {
|
|
727
727
|
this.ensureConnected();
|
|
728
728
|
const { client } = this;
|
|
729
729
|
|
|
@@ -739,19 +739,22 @@ export class ImapFlowConnection {
|
|
|
739
739
|
uid: true,
|
|
740
740
|
});
|
|
741
741
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
742
|
+
if (result?.content) {
|
|
743
|
+
const chunks: Buffer[] = [];
|
|
744
|
+
for await (const chunk of result.content) {
|
|
745
|
+
chunks.push(chunk);
|
|
746
|
+
}
|
|
747
|
+
return Buffer.concat(chunks);
|
|
747
748
|
}
|
|
748
749
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
750
|
+
if (!client.usable || !client.mailbox) {
|
|
751
|
+
throw new MailConnectionError(
|
|
752
|
+
"network",
|
|
753
|
+
`IMAP connection lost while downloading message UID ${uid}`,
|
|
754
|
+
);
|
|
752
755
|
}
|
|
753
756
|
|
|
754
|
-
return
|
|
757
|
+
return null;
|
|
755
758
|
};
|
|
756
759
|
|
|
757
760
|
/**
|
package/src/types.ts
CHANGED
|
@@ -253,7 +253,7 @@ export interface IImapConnection {
|
|
|
253
253
|
* no body. Requires a mailbox to be open.
|
|
254
254
|
*/
|
|
255
255
|
fetchEnvelopeSnapshots(uids: number[]): Promise<ImapEnvelopeSnapshot[]>;
|
|
256
|
-
fetchMessageBody(uid: number): Promise<Buffer>;
|
|
256
|
+
fetchMessageBody(uid: number): Promise<Buffer | null>;
|
|
257
257
|
/**
|
|
258
258
|
* Fetch full message bodies for many UIDs in ONE pipelined ranged UID FETCH.
|
|
259
259
|
* Yields `{ uid, source }` per message; `source` is a readable stream the
|