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