@framers/agentos-ext-channel-email 0.4.4 → 0.5.0
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/README.md +73 -8
- package/dist/EmailService.d.ts +261 -9
- package/dist/EmailService.d.ts.map +1 -1
- package/dist/EmailService.js +594 -35
- package/dist/EmailService.js.map +1 -1
- package/dist/attachments.d.ts +118 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +294 -0
- package/dist/attachments.js.map +1 -0
- package/dist/headers.d.ts +102 -0
- package/dist/headers.d.ts.map +1 -0
- package/dist/headers.js +261 -0
- package/dist/headers.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/messageHeaders.d.ts +8 -0
- package/dist/messageHeaders.d.ts.map +1 -1
- package/dist/messageHeaders.js +2 -0
- package/dist/messageHeaders.js.map +1 -1
- package/dist/preferences/RecipientCapability.d.ts +3 -2
- package/dist/preferences/RecipientCapability.d.ts.map +1 -1
- package/dist/preferences/RecipientCapability.js +2 -1
- package/dist/preferences/RecipientCapability.js.map +1 -1
- package/dist/tools/messages.d.ts +21 -0
- package/dist/tools/messages.d.ts.map +1 -0
- package/dist/tools/messages.js +18 -0
- package/dist/tools/messages.js.map +1 -0
- package/dist/tools/read.d.ts +2 -2
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +6 -4
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +2 -1
- package/dist/tools/search.js.map +1 -1
- package/manifest.json +3 -2
- package/package.json +4 -4
package/dist/EmailService.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Sends through nodemailer (SMTP) or Resend's HTTPS API, and reads,
|
|
7
7
|
* searches, and fetches the messages it replies to through imapflow (IMAP).
|
|
8
|
+
* A read gives each message's recipients, threading and authentication
|
|
9
|
+
* headers, its text and HTML parts under a bound, and its attachments, whose
|
|
10
|
+
* bytes are fetched only when asked; one message is moved, labelled, flagged
|
|
11
|
+
* or deleted by its UID.
|
|
8
12
|
*/
|
|
9
13
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
10
14
|
if (k2 === undefined) k2 = k;
|
|
@@ -40,13 +44,31 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
40
44
|
};
|
|
41
45
|
})();
|
|
42
46
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
-
exports.EmailService = void 0;
|
|
47
|
+
exports.EmailService = exports.EmailMailboxError = void 0;
|
|
44
48
|
const nodemailer = __importStar(require("nodemailer"));
|
|
45
49
|
const imapflow_1 = require("imapflow");
|
|
46
50
|
const ResendTransport_js_1 = require("./ResendTransport.js");
|
|
47
51
|
const messageHeaders_js_1 = require("./messageHeaders.js");
|
|
48
52
|
const messageText_js_1 = require("./messageText.js");
|
|
49
53
|
const reads_js_1 = require("./reads.js");
|
|
54
|
+
const headers_js_1 = require("./headers.js");
|
|
55
|
+
const attachments_js_1 = require("./attachments.js");
|
|
56
|
+
/**
|
|
57
|
+
* A change to one message, or a read of an attachment, that failed: `gone` when the folder holds no message with the UID
|
|
58
|
+
* named, which a caller that wants the message gone counts as done; `refused` when the server refused the command or
|
|
59
|
+
* would not open the folder; `unsupported` when the server lacks the extension the change needs; `too_large` when an
|
|
60
|
+
* attachment passes the bound a read named. Its message never holds a UID, a folder's name or an address, and it
|
|
61
|
+
* carries nothing the server said.
|
|
62
|
+
*/
|
|
63
|
+
class EmailMailboxError extends Error {
|
|
64
|
+
constructor(code, message) {
|
|
65
|
+
super(message);
|
|
66
|
+
/** The type's own name. */
|
|
67
|
+
this.name = 'EmailMailboxError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
exports.EmailMailboxError = EmailMailboxError;
|
|
50
72
|
// ---------------------------------------------------------------------------
|
|
51
73
|
// Helpers
|
|
52
74
|
// ---------------------------------------------------------------------------
|
|
@@ -65,21 +87,244 @@ function smtpField(key, value) {
|
|
|
65
87
|
function firstAddress(list) {
|
|
66
88
|
return list?.find((entry) => typeof entry?.address === 'string' && entry.address !== '')?.address;
|
|
67
89
|
}
|
|
90
|
+
/** The most bytes of a message's text part, and of its HTML part, a read gives when it names no bound: 256 KiB. */
|
|
91
|
+
const DEFAULT_PART_BYTES = 262144;
|
|
92
|
+
/** The most bytes of an attachment `read()` gives when it names no bound: 25 MiB. */
|
|
93
|
+
const DEFAULT_ATTACHMENT_BYTES = 26214400;
|
|
94
|
+
/**
|
|
95
|
+
* The largest bound a read takes: 256 MiB, so that the bytes a fetch asks for, at most four times the bound, stay a
|
|
96
|
+
* 32-bit number, as IMAP's partial fetch writes them (RFC 3501 section 9, `number`).
|
|
97
|
+
*/
|
|
98
|
+
const MOST_BYTES = 268435456;
|
|
99
|
+
/** The largest UID, a 32-bit number (RFC 3501 section 9, `nz-number`). */
|
|
100
|
+
const LARGEST_UID = 4294967295;
|
|
101
|
+
/** The header fields a read fetches beside the envelope (`BODY.PEEK[HEADER.FIELDS (...)]`), which marks nothing seen. */
|
|
102
|
+
const READ_FIELDS = [
|
|
103
|
+
'delivered-to',
|
|
104
|
+
'in-reply-to',
|
|
105
|
+
'references',
|
|
106
|
+
'authentication-results',
|
|
107
|
+
'auto-submitted',
|
|
108
|
+
'precedence',
|
|
109
|
+
'x-autoreply',
|
|
110
|
+
'x-auto-response-suppress',
|
|
111
|
+
];
|
|
112
|
+
/** What a read's first fetch asks for of each message; the text and HTML parts follow by UID, under the bound. */
|
|
113
|
+
const READ_QUERY = { uid: true, envelope: true, flags: true, bodyStructure: true, headers: READ_FIELDS };
|
|
114
|
+
/** A control character, which no folder, label or flag name holds. */
|
|
115
|
+
const CONTROL = /[\x00-\x1f\x7f]/;
|
|
116
|
+
/** A flag a STORE may carry: an atom (RFC 3501 section 9, `flag-keyword`), or a system flag with its backslash. */
|
|
117
|
+
const FLAG = /^\\?[^\s()\{\}%*"\\\]\x00-\x1f\x7f-\uffff]+$/;
|
|
118
|
+
/** `folder` when it names a mailbox, INBOX when it is `undefined` or `null`; anything else throws an `Error`. */
|
|
119
|
+
function requireFolder(folder, field = 'folder') {
|
|
120
|
+
if (folder === undefined || folder === null)
|
|
121
|
+
return 'INBOX';
|
|
122
|
+
return requireName(folder, field);
|
|
123
|
+
}
|
|
124
|
+
/** `name` when it is text with no control character, which a folder, a label or a flag name is; anything else throws. */
|
|
125
|
+
function requireName(name, field) {
|
|
126
|
+
if (typeof name === 'string' && name !== '' && !CONTROL.test(name))
|
|
127
|
+
return name;
|
|
128
|
+
throw new Error(`The ${field} was refused: give it as a name of text with no control character`);
|
|
129
|
+
}
|
|
130
|
+
/** The UID a read follows, or `undefined` when none is given; anything but a whole number from 0 throws. */
|
|
131
|
+
function requireAfterUid(afterUid) {
|
|
132
|
+
if (afterUid === undefined || afterUid === null)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (typeof afterUid === 'number' && Number.isSafeInteger(afterUid) && afterUid >= 0)
|
|
135
|
+
return afterUid;
|
|
136
|
+
throw new Error('The afterUid was refused: give it as a whole number of at least 0');
|
|
137
|
+
}
|
|
138
|
+
/** A bound in bytes: `fallback` when it is `undefined` or `null`; anything but a whole number from 1 to 256 MiB throws. */
|
|
139
|
+
function requireBytes(maxBytes, fallback) {
|
|
140
|
+
if (maxBytes === undefined || maxBytes === null)
|
|
141
|
+
return fallback;
|
|
142
|
+
if (typeof maxBytes === 'number' && Number.isSafeInteger(maxBytes) && maxBytes >= 1 && maxBytes <= MOST_BYTES)
|
|
143
|
+
return maxBytes;
|
|
144
|
+
throw new Error(`The maxBytes was refused: give it as a whole number from 1 to ${MOST_BYTES}`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The receiving host's authserv-id in lowercase, or `undefined` when none is given; a value that is not text, or that is
|
|
148
|
+
* white space alone, which would let the first field of any host count, throws.
|
|
149
|
+
*/
|
|
150
|
+
function requireAuthServId(authServId) {
|
|
151
|
+
if (authServId === undefined || authServId === null || authServId === '')
|
|
152
|
+
return undefined;
|
|
153
|
+
if (typeof authServId === 'string' && !CONTROL.test(authServId) && authServId.trim() !== '')
|
|
154
|
+
return authServId.trim().toLowerCase();
|
|
155
|
+
throw new Error('The authServId was refused: give it as the name of the host that received the mail');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The UIDVALIDITY a read's `afterUid` was read under, in decimal, or `undefined` when none is given; anything but a whole
|
|
159
|
+
* number from 1 to the largest UIDVALIDITY, or its digits with no leading zero, throws.
|
|
160
|
+
*/
|
|
161
|
+
function requireUidValidity(uidValidity) {
|
|
162
|
+
if (uidValidity === undefined || uidValidity === null || uidValidity === '')
|
|
163
|
+
return undefined;
|
|
164
|
+
const digits = typeof uidValidity === 'number' && Number.isSafeInteger(uidValidity) ? String(uidValidity) : uidValidity;
|
|
165
|
+
if (typeof digits === 'string' && /^[1-9][0-9]{0,9}$/.test(digits) && Number(digits) <= LARGEST_UID)
|
|
166
|
+
return digits;
|
|
167
|
+
throw new Error(`The uidValidity was refused: give it as the folder's UIDVALIDITY, a whole number from 1 to ${LARGEST_UID}`);
|
|
168
|
+
}
|
|
169
|
+
/** A message's UID; anything but a whole number from 1 to the largest UID throws. */
|
|
170
|
+
function requireUid(uid) {
|
|
171
|
+
if (typeof uid === 'number' && Number.isSafeInteger(uid) && uid >= 1 && uid <= LARGEST_UID)
|
|
172
|
+
return uid;
|
|
173
|
+
throw new Error(`The uid was refused: give it as a whole number from 1 to ${LARGEST_UID}`);
|
|
174
|
+
}
|
|
175
|
+
/** The flags a STORE sets: a list of flag names, `\Recent` not among them, which the server alone sets; else throws. */
|
|
176
|
+
function requireFlags(flags) {
|
|
177
|
+
if (!Array.isArray(flags) || !flags.every((flag) => typeof flag === 'string' && FLAG.test(flag))) {
|
|
178
|
+
throw new Error('The flags were refused: give them as a list of flag names, such as \\Seen or $Handled');
|
|
179
|
+
}
|
|
180
|
+
if (flags.some((flag) => flag.toLowerCase() === '\\recent'))
|
|
181
|
+
throw new Error('The flags were refused: the server alone sets \\Recent');
|
|
182
|
+
return [...flags];
|
|
183
|
+
}
|
|
184
|
+
/** A read's bounds, checked. */
|
|
185
|
+
function readPlanOf(options) {
|
|
186
|
+
return { maxBytes: requireBytes(options.maxBytes, DEFAULT_PART_BYTES), authServId: requireAuthServId(options.authServId) };
|
|
187
|
+
}
|
|
188
|
+
/** A flag as imapflow 1.2.9 writes it into a STORE (`formatFlag` in `lib/tools.js`): a system flag in its own case. */
|
|
189
|
+
function storedFlag(flag) {
|
|
190
|
+
const lowercase = flag.toLowerCase();
|
|
191
|
+
return ['\\seen', '\\answered', '\\flagged', '\\deleted', '\\draft'].includes(lowercase) ? lowercase.replace(/^\\./, (start) => start.toUpperCase()) : flag;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The flags a STORE gives imapflow so that it keeps every one named, or `undefined` when the folder's PERMANENTFLAGS
|
|
195
|
+
* keep one of them in no spelling. imapflow 1.2.9 keeps a flag only in the spelling PERMANENTFLAGS gives it, unless they
|
|
196
|
+
* hold `\*` (`canUseFlag` in `lib/tools.js`), and IMAP reads a flag's name ignoring case (RFC 3501 section 9, note 1),
|
|
197
|
+
* so a keyword is given in the spelling the folder keeps; a system flag goes as imapflow writes it. With no
|
|
198
|
+
* PERMANENTFLAGS imapflow keeps every flag.
|
|
199
|
+
*/
|
|
200
|
+
function keptFlags(wanted, permanent) {
|
|
201
|
+
if (!(permanent instanceof Set))
|
|
202
|
+
return wanted;
|
|
203
|
+
const kept = [];
|
|
204
|
+
for (const flag of wanted) {
|
|
205
|
+
let spelled;
|
|
206
|
+
if (flag.startsWith('\\')) {
|
|
207
|
+
spelled = permanent.has('\\*') || permanent.has(storedFlag(flag)) ? flag : undefined;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const lowercase = flag.toLowerCase();
|
|
211
|
+
spelled = [...permanent].find((known) => typeof known === 'string' && known.toLowerCase() === lowercase) ?? (permanent.has('\\*') ? flag : undefined);
|
|
212
|
+
}
|
|
213
|
+
if (spelled === undefined)
|
|
214
|
+
return undefined;
|
|
215
|
+
if (!kept.includes(spelled))
|
|
216
|
+
kept.push(spelled);
|
|
217
|
+
}
|
|
218
|
+
return kept;
|
|
219
|
+
}
|
|
220
|
+
/** The rejection of a change or a read whose message the folder no longer holds. */
|
|
221
|
+
function gone() {
|
|
222
|
+
return new EmailMailboxError('gone', 'The message is gone: the folder holds no message with that UID');
|
|
223
|
+
}
|
|
224
|
+
/** Whether the server announced `capability`; imapflow keeps the names in uppercase. */
|
|
225
|
+
function offers(client, capability) {
|
|
226
|
+
return client?.capabilities instanceof Map && client.capabilities.has(capability);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether the selected folder holds a message with `uid`, by `UID SEARCH`; a server that refuses the search rejects with
|
|
230
|
+
* `EmailMailboxError` `refused`.
|
|
231
|
+
*/
|
|
232
|
+
async function holds(client, uid) {
|
|
233
|
+
const found = await client.search({ uid: String(uid) }, { uid: true });
|
|
234
|
+
if (!Array.isArray(found))
|
|
235
|
+
throw new EmailMailboxError('refused', 'The change was refused: the server refused the search');
|
|
236
|
+
return found.includes(uid);
|
|
237
|
+
}
|
|
68
238
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
239
|
+
* Opens `folder` and holds it, as imapflow's `getMailboxLock` does. A server that refuses the SELECT, as it refuses a
|
|
240
|
+
* folder that does not exist, rejects with `EmailMailboxError` `refused` and `refusal` as its message: imapflow's own
|
|
241
|
+
* error carries the server's text and the command it sent (imapflow 1.2.9 `lib/imap-flow.js`), which name the folder.
|
|
242
|
+
* Any other failure, such as a lost connection, rejects as imapflow rejected.
|
|
71
243
|
*/
|
|
72
|
-
function
|
|
244
|
+
async function lockOf(client, folder, refusal) {
|
|
245
|
+
try {
|
|
246
|
+
return await client.getMailboxLock(folder);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
const status = typeof error === 'object' && error !== null ? error.responseStatus : undefined;
|
|
250
|
+
if (status === 'NO' || status === 'BAD')
|
|
251
|
+
throw new EmailMailboxError('refused', refusal);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Whether a COPY or MOVE copied the message whose UID is `uid`, by the COPYUID a server with UIDPLUS sends when it
|
|
257
|
+
* copied a message (RFC 4315 section 3), which imapflow gives as `uidMap`; `undefined` when the server sent none.
|
|
258
|
+
*/
|
|
259
|
+
function copiedBy(answer, uid) {
|
|
260
|
+
return answer?.uidMap instanceof Map ? answer.uidMap.has(uid) : undefined;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Every address of an envelope's To and Cc and of the Delivered-To fields, in lowercase, each once, a local part that
|
|
264
|
+
* needs quotes in them. A group's name comes through imapflow as an address with nothing after its `@`, and is none.
|
|
265
|
+
*/
|
|
266
|
+
function recipientsOf(envelope, deliveredTo) {
|
|
267
|
+
const listed = [...(Array.isArray(envelope?.to) ? envelope.to : []), ...(Array.isArray(envelope?.cc) ? envelope.cc : [])].map((entry) => entry?.address);
|
|
268
|
+
const recipients = [];
|
|
269
|
+
for (const address of [...listed, ...deliveredTo]) {
|
|
270
|
+
if (typeof address !== 'string')
|
|
271
|
+
continue;
|
|
272
|
+
const form = (0, headers_js_1.addressOf)(address);
|
|
273
|
+
if (form === undefined || recipients.includes(form))
|
|
274
|
+
continue;
|
|
275
|
+
recipients.push(form);
|
|
276
|
+
}
|
|
277
|
+
return recipients;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* A fetched message as `readInbox` and `searchEmails` give it, from its first fetch, the parts its body structure
|
|
281
|
+
* names, and the text and HTML bytes the second fetch gave under each part's window. A Date field that does not read as
|
|
282
|
+
* a date gives an empty `date`: imapflow gives the field's text in place of a `Date` then, so one such message cannot
|
|
283
|
+
* fail the whole read.
|
|
284
|
+
*/
|
|
285
|
+
function emailMessageOf(planned, bodies, context) {
|
|
286
|
+
const { msg, parts } = planned;
|
|
73
287
|
const envelope = msg.envelope;
|
|
288
|
+
const from = envelope?.from?.[0]?.address ?? '';
|
|
289
|
+
const header = Buffer.isBuffer(msg.headers) ? msg.headers.toString('utf8') : '';
|
|
290
|
+
const fields = (0, headers_js_1.readHeaderFields)(header, { from, authServId: context.authServId });
|
|
291
|
+
const bytesOf = (info) => bodies?.get(info.part);
|
|
292
|
+
const text = (0, attachments_js_1.joinedTextOf)(parts.texts, planned.textWindows, parts.texts.map(bytesOf), context.maxBytes);
|
|
293
|
+
const html = (0, attachments_js_1.joinedTextOf)(parts.htmls, planned.htmlWindows, parts.htmls.map(bytesOf), context.maxBytes);
|
|
294
|
+
const messageId = (0, messageHeaders_js_1.messageIdIn)(envelope?.messageId);
|
|
295
|
+
const key = context.serverIds && typeof msg.emailId === 'string' && msg.emailId !== ''
|
|
296
|
+
? msg.emailId
|
|
297
|
+
: messageId !== undefined
|
|
298
|
+
? `${context.folder} ${messageId}`
|
|
299
|
+
: `${context.folder} ${context.uidValidity}:${msg.uid}`;
|
|
74
300
|
return {
|
|
75
301
|
messageId: envelope?.messageId ?? '',
|
|
76
|
-
from
|
|
302
|
+
from,
|
|
77
303
|
to: envelope?.to?.[0]?.address ?? '',
|
|
78
304
|
subject: envelope?.subject ?? '',
|
|
79
305
|
date: (0, reads_js_1.isoDateOf)(envelope?.date),
|
|
80
|
-
body,
|
|
306
|
+
body: parts.texts.length > 0 ? text.text : (0, reads_js_1.withoutTags)(html.text).replace(/\s+/g, ' ').trim(),
|
|
307
|
+
html: html.text,
|
|
81
308
|
flags: Array.from(msg.flags ?? []),
|
|
82
309
|
uid: msg.uid,
|
|
310
|
+
uidValidity: context.uidValidity,
|
|
311
|
+
key,
|
|
312
|
+
recipients: recipientsOf(envelope, fields.deliveredTo),
|
|
313
|
+
inReplyTo: fields.inReplyTo,
|
|
314
|
+
references: fields.references,
|
|
315
|
+
autoSubmitted: fields.autoSubmitted,
|
|
316
|
+
precedence: fields.precedence,
|
|
317
|
+
autoreply: fields.autoreply,
|
|
318
|
+
isReport: parts.isReport,
|
|
319
|
+
authentication: fields.authentication,
|
|
320
|
+
text: text.text,
|
|
321
|
+
truncated: text.truncated || html.truncated,
|
|
322
|
+
attachments: parts.attachments.map((info) => ({
|
|
323
|
+
name: info.name,
|
|
324
|
+
type: info.type,
|
|
325
|
+
bytes: info.size,
|
|
326
|
+
read: (maxBytes) => context.readPart(msg.uid, info, maxBytes),
|
|
327
|
+
})),
|
|
83
328
|
};
|
|
84
329
|
}
|
|
85
330
|
// ---------------------------------------------------------------------------
|
|
@@ -218,37 +463,71 @@ class EmailService {
|
|
|
218
463
|
}
|
|
219
464
|
// ── Read Inbox ──
|
|
220
465
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
466
|
+
* Reads `folder`'s messages. Given a folder and a limit, or neither, it gives the last `limit` messages of `folder`,
|
|
467
|
+
* newest first: INBOX and 20 when they are not given, and 20 when `limit` is `null`. Given one object
|
|
468
|
+
* (`ReadInboxOptions`), it reads the same way, or, with `afterUid`, gives the first `limit` messages whose UID is
|
|
469
|
+
* greater, oldest first, whatever their flags: it searches `UID <afterUid + 1>:*` and keeps only the greater UIDs,
|
|
470
|
+
* since a range ending in `*` names the last message even when its UID is not greater (RFC 3501 section 9). Given
|
|
471
|
+
* `uidValidity` with `afterUid`, a folder whose UIDVALIDITY is another is read from its first message, since its UIDs
|
|
472
|
+
* were given anew and `afterUid` names nothing of it.
|
|
473
|
+
*
|
|
474
|
+
* `limit` must be a whole number of at least 1, `afterUid` a whole number from 0, `uidValidity` a UIDVALIDITY,
|
|
475
|
+
* `maxBytes` a whole number from 1 to 268,435,456, `folder` a name with no control character and `authServId` text that
|
|
476
|
+
* is not white space alone: any other value throws an `Error` before any mail is read, where the fetch would name a
|
|
477
|
+
* message past the last one, which a server answers BAD. A server that refuses the search after a UID rejects the read
|
|
478
|
+
* with `EmailMailboxError` `refused`. A message whose Date field does not read as a date is given with an empty `date`.
|
|
479
|
+
*
|
|
480
|
+
* Each message is fetched with its envelope, flags, body structure and the header fields its recipients, threading
|
|
481
|
+
* and authentication come from, then its text and HTML parts by UID, each up to the bytes that can decode to what
|
|
482
|
+
* `maxBytes` leaves, in one fetch for the messages whose parts share their sections and windows. No fetch marks a
|
|
483
|
+
* message seen, and no attachment's bytes are fetched until its `read()` is called. A FETCH response the server sends
|
|
484
|
+
* unasked during a fetch, such as the flags another session changed, is no message of the read.
|
|
225
485
|
*/
|
|
226
|
-
async readInbox(folder
|
|
486
|
+
async readInbox(folder, limit) {
|
|
227
487
|
this.requireRunning();
|
|
228
|
-
const
|
|
488
|
+
const given = typeof folder === 'object' && folder !== null ? folder : { folder, limit };
|
|
489
|
+
const path = requireFolder(given.folder);
|
|
490
|
+
const count = (0, reads_js_1.requireLimit)(given.limit, 20);
|
|
491
|
+
const afterUid = requireAfterUid(given.afterUid);
|
|
492
|
+
const uidValidity = requireUidValidity(given.uidValidity);
|
|
493
|
+
const plan = readPlanOf(given);
|
|
229
494
|
const client = await this.getImapClient();
|
|
230
495
|
try {
|
|
231
|
-
const lock = await client.getMailboxLock(
|
|
232
|
-
const messages = [];
|
|
496
|
+
const lock = await client.getMailboxLock(path);
|
|
233
497
|
try {
|
|
498
|
+
if (afterUid !== undefined) {
|
|
499
|
+
// A UIDVALIDITY other than the cursor's means the folder's UIDs were given anew (RFC 3501 section 2.3.1.1), so
|
|
500
|
+
// the cursor's UID names nothing of it and the read starts from its first message.
|
|
501
|
+
const current = client.mailbox?.uidValidity;
|
|
502
|
+
const changed = uidValidity !== undefined && current !== undefined && current !== null && String(current) !== uidValidity;
|
|
503
|
+
const after = changed ? 0 : afterUid;
|
|
504
|
+
// No UID is greater than the largest, and the search below could not name one.
|
|
505
|
+
if (after >= LARGEST_UID)
|
|
506
|
+
return [];
|
|
507
|
+
const found = await client.search({ uid: `${after + 1}:*` }, { uid: true });
|
|
508
|
+
if (!Array.isArray(found))
|
|
509
|
+
throw new EmailMailboxError('refused', 'The read was refused: the server refused the search');
|
|
510
|
+
// A range ending in "*" names the last message even when its UID is not greater (RFC 3501 section 9).
|
|
511
|
+
const uids = found
|
|
512
|
+
.filter((uid) => Number.isSafeInteger(uid) && uid > after)
|
|
513
|
+
.sort((a, b) => a - b)
|
|
514
|
+
.slice(0, count);
|
|
515
|
+
if (uids.length === 0)
|
|
516
|
+
return [];
|
|
517
|
+
const later = await this.fetchMessages(client, path, uids, true, plan);
|
|
518
|
+
return later.sort((a, b) => a.uid - b.uid);
|
|
519
|
+
}
|
|
234
520
|
// Fetch the most recent messages
|
|
235
521
|
const totalMessages = client.mailbox ? client.mailbox.exists : 0;
|
|
236
|
-
if (totalMessages
|
|
522
|
+
if (!totalMessages)
|
|
237
523
|
return [];
|
|
238
524
|
const startSeq = Math.max(1, totalMessages - count + 1);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
source: true,
|
|
242
|
-
flags: true,
|
|
243
|
-
uid: true,
|
|
244
|
-
})) {
|
|
245
|
-
messages.push(emailMessageOf(msg, this.extractPlainText(msg.source?.toString() ?? '')));
|
|
246
|
-
}
|
|
525
|
+
const messages = await this.fetchMessages(client, path, `${startSeq}:*`, false, plan);
|
|
526
|
+
return messages.reverse(); // Most recent first
|
|
247
527
|
}
|
|
248
528
|
finally {
|
|
249
529
|
lock.release();
|
|
250
530
|
}
|
|
251
|
-
return messages.reverse(); // Most recent first
|
|
252
531
|
}
|
|
253
532
|
finally {
|
|
254
533
|
await client.logout();
|
|
@@ -258,24 +537,28 @@ class EmailService {
|
|
|
258
537
|
/**
|
|
259
538
|
* The messages of `folder` (INBOX unless given) whose subject, body or From holds `query`, ignoring case, received on
|
|
260
539
|
* or after `since` when it is given: the last `limit` (20 unless given) the search finds, newest first. The search
|
|
261
|
-
* answers UIDs, and the messages are fetched by those UIDs, which need not follow the messages' sequence numbers.
|
|
540
|
+
* answers UIDs, and the messages are fetched by those UIDs, which need not follow the messages' sequence numbers. Each
|
|
541
|
+
* message carries what `readInbox` gives, its parts bounded by `maxBytes` and its Authentication-Results read as
|
|
542
|
+
* `authServId` says.
|
|
262
543
|
*
|
|
263
544
|
* `limit` must be a whole number of at least 1, and `since` an ISO 8601 date, such as `2026-10-15`, alone or followed
|
|
264
545
|
* by a time and an offset, which are checked and then disregarded: the search starts from the day the date names, as
|
|
265
546
|
* written, on every server. Either counts as not given when `null`, and `since` when empty. Any other value of either,
|
|
266
547
|
* a day its month lacks such as `2026-02-30` among them, throws an `Error` before the search: imapflow leaves a date it
|
|
267
548
|
* cannot read out of the search, which would answer mail of every date, and `Date` rolls a day past its month's end
|
|
268
|
-
* over into the next month.
|
|
549
|
+
* over into the next month. `maxBytes` and `authServId` are checked as `readInbox` checks them. A message whose Date
|
|
550
|
+
* field does not read as a date is given with an empty `date`.
|
|
269
551
|
*/
|
|
270
552
|
async searchEmails(options) {
|
|
271
553
|
this.requireRunning();
|
|
272
554
|
const limit = (0, reads_js_1.requireLimit)(options.limit, 20);
|
|
273
555
|
const since = (0, reads_js_1.requireSince)(options.since);
|
|
556
|
+
const plan = readPlanOf(options);
|
|
274
557
|
const client = await this.getImapClient();
|
|
275
558
|
const folder = options.folder ?? 'INBOX';
|
|
276
559
|
try {
|
|
277
560
|
const lock = await client.getMailboxLock(folder);
|
|
278
|
-
|
|
561
|
+
let messages = [];
|
|
279
562
|
try {
|
|
280
563
|
const searchCriteria = {};
|
|
281
564
|
// Build IMAP search criteria
|
|
@@ -296,14 +579,7 @@ class EmailService {
|
|
|
296
579
|
const limitedUids = uidList.slice(-limit);
|
|
297
580
|
if (limitedUids.length > 0) {
|
|
298
581
|
// The search answered UIDs, so the fetch reads its range as UIDs rather than sequence numbers.
|
|
299
|
-
|
|
300
|
-
envelope: true,
|
|
301
|
-
source: true,
|
|
302
|
-
flags: true,
|
|
303
|
-
uid: true,
|
|
304
|
-
}, { uid: true })) {
|
|
305
|
-
messages.push(emailMessageOf(msg, this.extractPlainText(msg.source?.toString() ?? '')));
|
|
306
|
-
}
|
|
582
|
+
messages = await this.fetchMessages(client, folder, limitedUids, true, plan);
|
|
307
583
|
}
|
|
308
584
|
}
|
|
309
585
|
finally {
|
|
@@ -315,6 +591,142 @@ class EmailService {
|
|
|
315
591
|
await client.logout();
|
|
316
592
|
}
|
|
317
593
|
}
|
|
594
|
+
// ── Move, Label, Flag and Delete ──
|
|
595
|
+
/**
|
|
596
|
+
* Moves the message whose UID is `uid` in `options.folder` (INBOX unless given) to `destination`: by `UID MOVE`
|
|
597
|
+
* (RFC 6851) where the server offers MOVE, else by `UID COPY`, the `\Deleted` flag and `UID EXPUNGE` (RFC 4315), which
|
|
598
|
+
* expunges no other message marked deleted. The copy must succeed before the original is touched.
|
|
599
|
+
*
|
|
600
|
+
* Rejects with `EmailMailboxError`: `gone` when the folder holds no message with that UID, which comes before any other
|
|
601
|
+
* answer, and when another session expunges the message before the move, as the COPYUID a server with UIDPLUS sends
|
|
602
|
+
* tells, or as a search after a copy that named none tells; `unsupported`, before anything changes, when the server
|
|
603
|
+
* offers neither MOVE nor UIDPLUS, where `EXPUNGE` would remove every message marked deleted; `refused` when the server
|
|
604
|
+
* refuses a command, as it does a destination that does not exist, or will not open the folder. A move another
|
|
605
|
+
* session's expunge overtakes on a server that sends no COPYUID resolves as done, the server having answered OK. A
|
|
606
|
+
* `uid` that is not a whole number from 1 to 4,294,967,295, or a folder or destination that is not a name, throws an
|
|
607
|
+
* `Error` before connecting.
|
|
608
|
+
*/
|
|
609
|
+
async moveTo(uid, destination, options) {
|
|
610
|
+
this.requireRunning();
|
|
611
|
+
const target = requireUid(uid);
|
|
612
|
+
const to = requireName(destination, 'destination');
|
|
613
|
+
const folder = requireFolder(options?.folder);
|
|
614
|
+
await this.onMessage(target, folder, async (client, range) => {
|
|
615
|
+
if (offers(client, 'MOVE')) {
|
|
616
|
+
const moved = await client.messageMove(range, to, { uid: true });
|
|
617
|
+
if (!moved)
|
|
618
|
+
throw new EmailMailboxError('refused', 'The move was refused by the server');
|
|
619
|
+
// The COPYUID names each UID the server moved (RFC 6851 section 4.3): one it leaves out was expunged first.
|
|
620
|
+
if (copiedBy(moved, target) === false)
|
|
621
|
+
throw gone();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (!offers(client, 'UIDPLUS'))
|
|
625
|
+
throw new EmailMailboxError('unsupported', 'The move was refused: the server offers neither MOVE nor UIDPLUS');
|
|
626
|
+
// imapflow's own MOVE without the extension deletes the original whatever its COPY answered.
|
|
627
|
+
const copy = await client.messageCopy(range, to, { uid: true });
|
|
628
|
+
if (!copy)
|
|
629
|
+
throw new EmailMailboxError('refused', 'The move was refused by the server');
|
|
630
|
+
const copied = copiedBy(copy, target);
|
|
631
|
+
if (copied === false)
|
|
632
|
+
throw gone();
|
|
633
|
+
if (!(await holds(client, target))) {
|
|
634
|
+
// Another session expunged the original: the move is done when the copy was made, and gone when nothing says so.
|
|
635
|
+
if (copied === true)
|
|
636
|
+
return;
|
|
637
|
+
throw gone();
|
|
638
|
+
}
|
|
639
|
+
await this.expungeOne(client, range, 'move');
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Labels the message whose UID is `uid` in `options.folder` (INBOX unless given) with `label`: through Gmail's
|
|
644
|
+
* `X-GM-LABELS` where the server offers X-GM-EXT-1, else by `UID COPY` into the folder named `label`. The message
|
|
645
|
+
* stays where it is.
|
|
646
|
+
*
|
|
647
|
+
* Rejects with `EmailMailboxError` `gone` when the folder holds no message with that UID, and when another session
|
|
648
|
+
* expunges the message between the search for it and the change, which then did nothing (RFC 3501 section 6.4.8), as
|
|
649
|
+
* the COPYUID a server with UIDPLUS sends, or a search after the change, tells; and `refused` when the server refuses
|
|
650
|
+
* the command, as it does a copy into a folder that does not exist, or will not open the folder. A `uid` that is not
|
|
651
|
+
* a whole number from 1 to 4,294,967,295, or a folder or label that is not a name, throws an `Error` before
|
|
652
|
+
* connecting.
|
|
653
|
+
*/
|
|
654
|
+
async addLabel(uid, label, options) {
|
|
655
|
+
this.requireRunning();
|
|
656
|
+
const target = requireUid(uid);
|
|
657
|
+
const name = requireName(label, 'label');
|
|
658
|
+
const folder = requireFolder(options?.folder);
|
|
659
|
+
await this.onMessage(target, folder, async (client, range) => {
|
|
660
|
+
if (offers(client, 'X-GM-EXT-1')) {
|
|
661
|
+
if ((await client.messageFlagsAdd(range, [name], { uid: true, useLabels: true })) !== true) {
|
|
662
|
+
throw new EmailMailboxError('refused', 'The label was refused by the server');
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
const copy = await client.messageCopy(range, name, { uid: true });
|
|
667
|
+
if (!copy)
|
|
668
|
+
throw new EmailMailboxError('refused', 'The label was refused by the server');
|
|
669
|
+
const copied = copiedBy(copy, target);
|
|
670
|
+
if (copied === true)
|
|
671
|
+
return;
|
|
672
|
+
if (copied === false)
|
|
673
|
+
throw gone();
|
|
674
|
+
}
|
|
675
|
+
if (!(await holds(client, target)))
|
|
676
|
+
throw gone();
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Replaces the flags of the message whose UID is `uid` in `options.folder` (INBOX unless given) with `flags`, by
|
|
681
|
+
* `UID STORE ... FLAGS`; an empty list clears them. Each flag is a flag name such as `\Seen` or `$Handled`; `\Recent`,
|
|
682
|
+
* which the server alone sets, is refused. A keyword goes in the spelling the folder's PERMANENTFLAGS give it, matched
|
|
683
|
+
* ignoring case, as IMAP reads a flag's name.
|
|
684
|
+
*
|
|
685
|
+
* Rejects with `EmailMailboxError` `gone` when the folder holds no message with that UID, which comes before any other
|
|
686
|
+
* answer, and when another session expunges the message between the search for it and the STORE, as a search after the
|
|
687
|
+
* STORE tells; `refused`, before anything is stored, when the folder's PERMANENTFLAGS keep neither `\*` nor every flag
|
|
688
|
+
* named, in any spelling, since imapflow would leave such a flag out and a set left with none clears every flag; and
|
|
689
|
+
* `refused` when the server refuses the STORE or will not open the folder. A `uid` that is not a whole number from 1
|
|
690
|
+
* to 4,294,967,295, flags that are not a list of flag names, or a folder that is not a name, throw an `Error` before
|
|
691
|
+
* connecting.
|
|
692
|
+
*/
|
|
693
|
+
async setFlags(uid, flags, options) {
|
|
694
|
+
this.requireRunning();
|
|
695
|
+
const target = requireUid(uid);
|
|
696
|
+
const wanted = requireFlags(flags);
|
|
697
|
+
const folder = requireFolder(options?.folder);
|
|
698
|
+
await this.onMessage(target, folder, async (client, range) => {
|
|
699
|
+
const kept = keptFlags(wanted, client.mailbox?.permanentFlags);
|
|
700
|
+
if (kept === undefined)
|
|
701
|
+
throw new EmailMailboxError('refused', 'The flags were refused: the folder does not keep every flag named');
|
|
702
|
+
if ((await client.messageFlagsSet(range, kept, { uid: true })) !== true)
|
|
703
|
+
throw new EmailMailboxError('refused', 'The flags were refused by the server');
|
|
704
|
+
if (!(await holds(client, target)))
|
|
705
|
+
throw gone();
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Deletes the message whose UID is `uid` in `options.folder` (INBOX unless given): the `\Deleted` flag, then
|
|
710
|
+
* `UID EXPUNGE` of that UID alone (RFC 4315), so no other message marked deleted is expunged. What an expunge does with
|
|
711
|
+
* the message beyond the folder is the server's to decide.
|
|
712
|
+
*
|
|
713
|
+
* Rejects with `EmailMailboxError`: `gone` when the folder holds no message with that UID, which a caller that wants
|
|
714
|
+
* the message gone counts as done, so a second call rejects with it, and which comes before any other answer;
|
|
715
|
+
* `unsupported`, before anything changes, when the server does not offer UIDPLUS, where `EXPUNGE` would remove every
|
|
716
|
+
* message marked deleted; `refused` when the server refuses the STORE or the expunge, or will not open the folder. A
|
|
717
|
+
* message another session expunges during the delete is gone either way, and the delete resolves. A `uid` that is not
|
|
718
|
+
* a whole number from 1 to 4,294,967,295, or a folder that is not a name, throws an `Error` before connecting.
|
|
719
|
+
*/
|
|
720
|
+
async deleteMessage(uid, options) {
|
|
721
|
+
this.requireRunning();
|
|
722
|
+
const target = requireUid(uid);
|
|
723
|
+
const folder = requireFolder(options?.folder);
|
|
724
|
+
await this.onMessage(target, folder, async (client, range) => {
|
|
725
|
+
if (!offers(client, 'UIDPLUS'))
|
|
726
|
+
throw new EmailMailboxError('unsupported', 'The delete was refused: the server does not offer UIDPLUS');
|
|
727
|
+
await this.expungeOne(client, range, 'delete');
|
|
728
|
+
});
|
|
729
|
+
}
|
|
318
730
|
// ── Extract Verification Codes ──
|
|
319
731
|
/**
|
|
320
732
|
* The codes in the INBOX message whose Message-ID is `messageId`, given with or without its angle brackets: the
|
|
@@ -488,6 +900,153 @@ class EmailService {
|
|
|
488
900
|
await client.connect();
|
|
489
901
|
return client;
|
|
490
902
|
}
|
|
903
|
+
// ── Private: Reads ──
|
|
904
|
+
/**
|
|
905
|
+
* The messages a set names in the selected folder, as a read gives them: first their envelopes, flags, body
|
|
906
|
+
* structures and read header fields, then their text and HTML parts by UID under the bound.
|
|
907
|
+
*/
|
|
908
|
+
async fetchMessages(client, folder, range, byUid, plan) {
|
|
909
|
+
const fetched = [];
|
|
910
|
+
const taken = new Set();
|
|
911
|
+
// imapflow runs no other command while a fetch's loop is open, so the parts are fetched once it has closed.
|
|
912
|
+
for await (const msg of client.fetch(range, READ_QUERY, byUid ? { uid: true } : undefined)) {
|
|
913
|
+
// A server may send FETCH responses of its own during the fetch, such as the flags another session changed (RFC 3501
|
|
914
|
+
// section 7.4.2), and imapflow yields each in the loop: only an answer that carries the envelope asked for is a
|
|
915
|
+
// message read, once for its UID, so a read gives no message it did not ask for and none twice.
|
|
916
|
+
if (typeof msg?.envelope !== 'object' || msg.envelope === null || !Number.isSafeInteger(msg.uid) || taken.has(msg.uid))
|
|
917
|
+
continue;
|
|
918
|
+
taken.add(msg.uid);
|
|
919
|
+
fetched.push(msg);
|
|
920
|
+
}
|
|
921
|
+
const planned = fetched.map((msg) => {
|
|
922
|
+
const parts = (0, attachments_js_1.partsOf)(msg.bodyStructure);
|
|
923
|
+
return { msg, parts, textWindows: (0, attachments_js_1.windowsOf)(parts.texts, plan.maxBytes), htmlWindows: (0, attachments_js_1.windowsOf)(parts.htmls, plan.maxBytes) };
|
|
924
|
+
});
|
|
925
|
+
const bodies = await this.fetchTextParts(client, planned);
|
|
926
|
+
const path = typeof client.mailbox?.path === 'string' ? client.mailbox.path : folder;
|
|
927
|
+
const uidValidity = client.mailbox?.uidValidity === undefined || client.mailbox?.uidValidity === null ? '' : String(client.mailbox.uidValidity);
|
|
928
|
+
const context = {
|
|
929
|
+
...plan,
|
|
930
|
+
folder: path,
|
|
931
|
+
uidValidity,
|
|
932
|
+
serverIds: offers(client, 'OBJECTID') || offers(client, 'X-GM-EXT-1'),
|
|
933
|
+
readPart: (uid, info, maxBytes) => this.readAttachment(path, uidValidity, uid, info, maxBytes),
|
|
934
|
+
};
|
|
935
|
+
return planned.map((entry) => emailMessageOf(entry, bodies.get(entry.msg.uid), context));
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* The text and HTML bytes of each message by UID, each part up to one byte past its window: one fetch for the messages
|
|
939
|
+
* whose parts share their sections and windows, and no fetch of a part whose window is 0, since the parts before it
|
|
940
|
+
* fill the bound.
|
|
941
|
+
*/
|
|
942
|
+
async fetchTextParts(client, planned) {
|
|
943
|
+
const groups = new Map();
|
|
944
|
+
for (const { msg, parts, textWindows, htmlWindows } of planned) {
|
|
945
|
+
if (!Number.isSafeInteger(msg.uid))
|
|
946
|
+
continue;
|
|
947
|
+
const wanted = [
|
|
948
|
+
...parts.texts.map((info, index) => ({ info, window: textWindows[index] ?? 0 })),
|
|
949
|
+
...parts.htmls.map((info, index) => ({ info, window: htmlWindows[index] ?? 0 })),
|
|
950
|
+
].filter(({ window }) => window > 0);
|
|
951
|
+
if (wanted.length === 0)
|
|
952
|
+
continue;
|
|
953
|
+
const id = wanted.map(({ info, window }) => `${info.part}<${window}>`).join(' ');
|
|
954
|
+
const group = groups.get(id) ?? {
|
|
955
|
+
bodyParts: wanted.map(({ info, window }) => ({ key: info.part, start: 0, maxLength: window + 1 })),
|
|
956
|
+
uids: [],
|
|
957
|
+
};
|
|
958
|
+
group.uids.push(msg.uid);
|
|
959
|
+
groups.set(id, group);
|
|
960
|
+
}
|
|
961
|
+
const bodies = new Map();
|
|
962
|
+
for (const group of groups.values()) {
|
|
963
|
+
for await (const msg of client.fetch(group.uids, { uid: true, bodyParts: group.bodyParts }, { uid: true })) {
|
|
964
|
+
if (msg.bodyParts instanceof Map)
|
|
965
|
+
bodies.set(msg.uid, msg.bodyParts);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return bodies;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* An attachment's bytes, decoded, fetched by the message's UID in `folder` over a connection of its own, up to one byte
|
|
972
|
+
* past the bytes that can decode to the bound: an attachment's `read()`.
|
|
973
|
+
*/
|
|
974
|
+
async readAttachment(folder, uidValidity, uid, info, maxBytes) {
|
|
975
|
+
const bound = requireBytes(maxBytes, DEFAULT_ATTACHMENT_BYTES);
|
|
976
|
+
this.requireRunning();
|
|
977
|
+
if ((0, attachments_js_1.tooLargeBySize)(info, bound))
|
|
978
|
+
throw new EmailMailboxError('too_large', 'The attachment was refused: it holds more bytes than the bound');
|
|
979
|
+
const client = await this.getImapClient();
|
|
980
|
+
let found = false;
|
|
981
|
+
let raw;
|
|
982
|
+
try {
|
|
983
|
+
const lock = await lockOf(client, folder, 'The attachment was refused: the server would not open the folder');
|
|
984
|
+
try {
|
|
985
|
+
// A new UIDVALIDITY means the folder's UIDs were given anew, so the UID no longer names the message read.
|
|
986
|
+
const current = client.mailbox?.uidValidity;
|
|
987
|
+
if (uidValidity !== '' && current !== undefined && String(current) !== uidValidity)
|
|
988
|
+
throw gone();
|
|
989
|
+
const query = { uid: true, bodyParts: [{ key: info.part, start: 0, maxLength: (0, attachments_js_1.encodedBound)(info.encoding, bound) + 1 }] };
|
|
990
|
+
for await (const msg of client.fetch(String(uid), query, { uid: true })) {
|
|
991
|
+
if (msg.uid !== uid)
|
|
992
|
+
continue;
|
|
993
|
+
found = true;
|
|
994
|
+
const part = msg.bodyParts instanceof Map ? msg.bodyParts.get(info.part) : undefined;
|
|
995
|
+
if (Buffer.isBuffer(part))
|
|
996
|
+
raw = part;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
finally {
|
|
1000
|
+
lock.release();
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
await client.logout();
|
|
1005
|
+
}
|
|
1006
|
+
if (!found)
|
|
1007
|
+
throw gone();
|
|
1008
|
+
if (raw === undefined)
|
|
1009
|
+
throw new EmailMailboxError('refused', 'The attachment was refused: the server gave none of its bytes');
|
|
1010
|
+
const read = (0, attachments_js_1.boundedBytesOf)(raw, info, bound);
|
|
1011
|
+
if (read.kind === 'too_large')
|
|
1012
|
+
throw new EmailMailboxError('too_large', 'The attachment was refused: it holds more bytes than the bound');
|
|
1013
|
+
return read.bytes;
|
|
1014
|
+
}
|
|
1015
|
+
// ── Private: Changes to One Message ──
|
|
1016
|
+
/**
|
|
1017
|
+
* Runs `work` on the message whose UID is `uid` in `folder`, over a connection of its own, once a search finds the UID:
|
|
1018
|
+
* a UID STORE, COPY or MOVE of a UID no message has succeeds having done nothing (RFC 3501 section 6.4.8, which RFC 6851
|
|
1019
|
+
* section 3.2 extends to UID MOVE), so no message with it rejects with `gone`, before `work` checks what the server
|
|
1020
|
+
* offers, since a caller that wants the message gone counts gone as done.
|
|
1021
|
+
*/
|
|
1022
|
+
async onMessage(uid, folder, work) {
|
|
1023
|
+
const client = await this.getImapClient();
|
|
1024
|
+
try {
|
|
1025
|
+
const lock = await lockOf(client, folder, 'The change was refused: the server would not open the folder');
|
|
1026
|
+
try {
|
|
1027
|
+
if (!(await holds(client, uid)))
|
|
1028
|
+
throw gone();
|
|
1029
|
+
await work(client, String(uid));
|
|
1030
|
+
}
|
|
1031
|
+
finally {
|
|
1032
|
+
lock.release();
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
finally {
|
|
1036
|
+
await client.logout();
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Marks one message deleted and expunges it by UID, on a server the caller found to offer UIDPLUS. imapflow's delete
|
|
1041
|
+
* goes on to the expunge whatever its STORE answered, so the STORE is sent and checked first.
|
|
1042
|
+
*/
|
|
1043
|
+
async expungeOne(client, range, action) {
|
|
1044
|
+
if ((await client.messageFlagsAdd(range, ['\\Deleted'], { uid: true })) !== true) {
|
|
1045
|
+
throw new EmailMailboxError('refused', `The ${action} was refused: the server refused the \\Deleted flag`);
|
|
1046
|
+
}
|
|
1047
|
+
if ((await client.messageDelete(range, { uid: true })) !== true)
|
|
1048
|
+
throw new EmailMailboxError('refused', `The ${action} was refused: the server refused the expunge`);
|
|
1049
|
+
}
|
|
491
1050
|
// ── Private: Email Parsing ──
|
|
492
1051
|
extractPlainText(rawSource) {
|
|
493
1052
|
// Simple extraction of plain text body from raw email source
|