dsh-email 0.11.0 → 0.13.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/CHANGELOG.md +36 -0
- package/README.en.md +8 -12
- package/README.md +8 -15
- package/lib/client.js +109 -22
- package/lib/config.d.ts +20 -0
- package/lib/config.js +16 -4
- package/lib/mail-client.d.ts +41 -5
- package/lib/mail-client.js +284 -52
- package/lib/parse.js +4 -2
- package/lib/runtime.d.ts +1 -1
- package/lib/runtime.js +33 -15
- package/lib/tool-contract.d.ts +3 -0
- package/lib/tool-contract.js +20 -2
- package/lib/tools.d.ts +1 -0
- package/lib/tools.js +15 -1
- package/lib/types.d.ts +12 -0
- package/lib/web.d.ts +23 -0
- package/lib/web.js +62 -6
- package/package.json +4 -3
package/lib/mail-client.js
CHANGED
|
@@ -3,7 +3,7 @@ import nodemailer from 'nodemailer';
|
|
|
3
3
|
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { getFreshAccessToken, OAuth2Error } from './oauth2.js';
|
|
6
|
-
import { flattenAddresses, parseRawMessage, sanitizeFilename } from './parse.js';
|
|
6
|
+
import { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
|
|
7
7
|
export class MailError extends Error {
|
|
8
8
|
constructor(message) {
|
|
9
9
|
super(message);
|
|
@@ -41,8 +41,8 @@ export function redactCredentials(text) {
|
|
|
41
41
|
*/
|
|
42
42
|
export function imapAuthOf(cfg, accessToken) {
|
|
43
43
|
return cfg.authKind === 'oauth2'
|
|
44
|
-
? { user: cfg.
|
|
45
|
-
: { user: cfg.
|
|
44
|
+
? { user: cfg.authUser, accessToken: accessToken ?? '' }
|
|
45
|
+
: { user: cfg.authUser, pass: cfg.authPassword };
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
48
|
* Nodemailer consumes an OAuth2 token through accessToken, not pass.
|
|
@@ -50,8 +50,8 @@ export function imapAuthOf(cfg, accessToken) {
|
|
|
50
50
|
*/
|
|
51
51
|
export function smtpAuthOf(cfg, accessToken) {
|
|
52
52
|
return cfg.authKind === 'oauth2'
|
|
53
|
-
? { type: 'OAuth2', user: cfg.
|
|
54
|
-
: { user: cfg.
|
|
53
|
+
? { type: 'OAuth2', user: cfg.authUser, accessToken: accessToken ?? '' }
|
|
54
|
+
: { user: cfg.authUser, pass: cfg.authPassword };
|
|
55
55
|
}
|
|
56
56
|
/** The message an OAuth2 account gets when the mailbox has to be logged into again. */
|
|
57
57
|
export const OAUTH2_RELOGIN_MESSAGE = '邮箱登录失败:请到设置页重新登录(Microsoft 账号使用设备码登录,不使用授权码)';
|
|
@@ -118,6 +118,43 @@ export function selectAttachmentPart(readAttachments, parts, index) {
|
|
|
118
118
|
const byTypeAndSize = parts.find(part => part.contentType === meta.contentType && Math.abs(part.size - meta.size) <= tolerance);
|
|
119
119
|
return byTypeAndSize;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Leaf text/* parts that can carry the message body, attachment parts excluded.
|
|
123
|
+
* This is what email_read / the search fallback fetch instead of the whole
|
|
124
|
+
* source: a 20 MiB attachment must not be downloaded just to read the text.
|
|
125
|
+
*/
|
|
126
|
+
function collectTextParts(node, out = []) {
|
|
127
|
+
if (node === null || node === undefined || typeof node !== 'object')
|
|
128
|
+
return out;
|
|
129
|
+
// message/rfc822 整段是一封内嵌邮件(附件),它的正文分段不是本封的正文。
|
|
130
|
+
if (typeof node.type === 'string' && node.type.toLowerCase().startsWith('message/rfc822'))
|
|
131
|
+
return out;
|
|
132
|
+
const children = Array.isArray(node.childNodes) ? node.childNodes : [];
|
|
133
|
+
if (children.length === 0) {
|
|
134
|
+
const type = typeof node.type === 'string' ? node.type.toLowerCase() : '';
|
|
135
|
+
if (type.startsWith('text/') && node.disposition !== 'attachment') {
|
|
136
|
+
out.push({ key: node.part === undefined || node.part === null ? '1' : String(node.part), type });
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
for (const child of children)
|
|
141
|
+
collectTextParts(child, out);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** The body parts worth fetching: text/plain first, text/html as the fallback. */
|
|
145
|
+
function selectBodyParts(node) {
|
|
146
|
+
const parts = collectTextParts(node);
|
|
147
|
+
const plain = parts.find(part => part.type === 'text/plain');
|
|
148
|
+
const html = parts.find(part => part.type === 'text/html');
|
|
149
|
+
return {
|
|
150
|
+
...(plain !== undefined ? { plain } : {}),
|
|
151
|
+
...(html !== undefined ? { html } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** The From header: `user` is the visible address, `senderName` only labels it. */
|
|
155
|
+
function senderOf(cfg) {
|
|
156
|
+
return cfg.senderName === '' ? cfg.user : { name: cfg.senderName, address: cfg.user };
|
|
157
|
+
}
|
|
121
158
|
/** Case-insensitive match of a query against subject/from/body text. */
|
|
122
159
|
export function messageMatchesQuery(subject, fromText, body, query) {
|
|
123
160
|
const q = query.toLowerCase();
|
|
@@ -142,10 +179,11 @@ function formatAddress(entry) {
|
|
|
142
179
|
}
|
|
143
180
|
function dedupeAddresses(entries, exclude) {
|
|
144
181
|
const seen = new Set();
|
|
182
|
+
const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).map(a => a.trim().toLowerCase()).filter(a => a !== ''));
|
|
145
183
|
const out = [];
|
|
146
184
|
for (const entry of entries) {
|
|
147
185
|
const addr = (entry.address ?? '').toLowerCase();
|
|
148
|
-
if (addr === '' || addr
|
|
186
|
+
if (addr === '' || excluded.has(addr) || seen.has(addr))
|
|
149
187
|
continue;
|
|
150
188
|
seen.add(addr);
|
|
151
189
|
out.push(entry);
|
|
@@ -164,7 +202,7 @@ const FORWARD_MAX_CHARS = 4000;
|
|
|
164
202
|
*/
|
|
165
203
|
export function buildReplyMessage(original, mode, selfAddress, text, forwardTo = '') {
|
|
166
204
|
const fromText = original.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知发件人)';
|
|
167
|
-
const self = selfAddress.
|
|
205
|
+
const self = (Array.isArray(selfAddress) ? selfAddress : [selfAddress]).filter(a => a.trim() !== '');
|
|
168
206
|
if (mode === 'forward') {
|
|
169
207
|
const to = forwardTo.trim();
|
|
170
208
|
if (to === '')
|
|
@@ -231,6 +269,11 @@ function listedFrom(envelope, size, hasAttachments) {
|
|
|
231
269
|
hasAttachments,
|
|
232
270
|
};
|
|
233
271
|
}
|
|
272
|
+
/** email_attachment reuses the MIME index email_read already parsed; keep a few. */
|
|
273
|
+
const READ_CACHE_MAX = 16;
|
|
274
|
+
const READ_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
275
|
+
/** Folder names change rarely; a short TTL keeps email_folders off the wire. */
|
|
276
|
+
const FOLDER_CACHE_TTL_MS = 60 * 1000;
|
|
234
277
|
/**
|
|
235
278
|
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
236
279
|
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
@@ -265,6 +308,72 @@ export class EmailPool {
|
|
|
265
308
|
this.queues.set(name, next.then(() => undefined, () => undefined));
|
|
266
309
|
return next;
|
|
267
310
|
}
|
|
311
|
+
readCache = new Map();
|
|
312
|
+
folderCache = new Map();
|
|
313
|
+
/** UIDVALIDITY 变了以后同一 uid 可能指向另一封邮件,缓存键必须带上它。 */
|
|
314
|
+
uidValidityOf(client) {
|
|
315
|
+
const mailbox = client.mailbox;
|
|
316
|
+
return mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
|
|
317
|
+
}
|
|
318
|
+
/** Remember a parsed attachment index so email_attachment can skip the refetch. */
|
|
319
|
+
rememberRead(account, folder, uidValidity, uid, parsed) {
|
|
320
|
+
const key = account + '\u0000' + folder + '\u0000' + uidValidity + '\u0000' + uid;
|
|
321
|
+
this.readCache.delete(key);
|
|
322
|
+
this.readCache.set(key, { ...parsed, at: Date.now() });
|
|
323
|
+
while (this.readCache.size > READ_CACHE_MAX) {
|
|
324
|
+
const oldest = this.readCache.keys().next();
|
|
325
|
+
if (oldest.done === true)
|
|
326
|
+
break;
|
|
327
|
+
this.readCache.delete(oldest.value);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The attachment index for one message: the cached one when email_read already
|
|
332
|
+
* produced it, otherwise a fresh parse of the full source plus its bodyStructure.
|
|
333
|
+
*/
|
|
334
|
+
async attachmentIndexOf(client, account, folder, uid, signal) {
|
|
335
|
+
const uidValidity = this.uidValidityOf(client);
|
|
336
|
+
const cached = this.recallRead(account, folder, uidValidity, uid);
|
|
337
|
+
if (cached !== undefined)
|
|
338
|
+
return cached;
|
|
339
|
+
// 附件索引只需要 MIME 结构:bodyStructure 已经给出每个附件的 part/名称/大小,
|
|
340
|
+
// 不必为了拿它先拉整封 source(第一次就下载附件的邮件也一样)。
|
|
341
|
+
const message = await client.fetchOne(uid, { uid: true, bodyStructure: true }, { uid: true });
|
|
342
|
+
if (message === false) {
|
|
343
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
|
|
344
|
+
}
|
|
345
|
+
signal?.throwIfAborted();
|
|
346
|
+
if (message.bodyStructure !== undefined) {
|
|
347
|
+
const parts = collectAttachmentParts(message.bodyStructure);
|
|
348
|
+
const attachments = parts.map(part => ({ filename: part.filename, contentType: part.contentType, size: part.size, part: part.part }));
|
|
349
|
+
const parsed = { attachments, parts };
|
|
350
|
+
this.rememberRead(account, folder, uidValidity, uid, parsed);
|
|
351
|
+
return parsed;
|
|
352
|
+
}
|
|
353
|
+
// 服务器没给 bodyStructure:退回整封解析,行为和以前一样。
|
|
354
|
+
const full = message.source !== undefined
|
|
355
|
+
? message
|
|
356
|
+
: await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
|
|
357
|
+
if (full === false || full.source === undefined) {
|
|
358
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
|
|
359
|
+
}
|
|
360
|
+
const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
|
|
361
|
+
signal?.throwIfAborted();
|
|
362
|
+
const parsed = { attachments: body.attachments, parts: collectAttachmentParts(full.bodyStructure) };
|
|
363
|
+
this.rememberRead(account, folder, uidValidity, uid, parsed);
|
|
364
|
+
return parsed;
|
|
365
|
+
}
|
|
366
|
+
recallRead(account, folder, uidValidity, uid) {
|
|
367
|
+
const key = account + '\u0000' + folder + '\u0000' + uidValidity + '\u0000' + uid;
|
|
368
|
+
const hit = this.readCache.get(key);
|
|
369
|
+
if (hit === undefined)
|
|
370
|
+
return undefined;
|
|
371
|
+
if (Date.now() - hit.at > READ_CACHE_TTL_MS) {
|
|
372
|
+
this.readCache.delete(key);
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
return hit;
|
|
376
|
+
}
|
|
268
377
|
async withImap(accountName, folder, run, readOnly = true, signal) {
|
|
269
378
|
const name = this.resolveName(accountName);
|
|
270
379
|
const cfg = this.account(name);
|
|
@@ -525,6 +634,33 @@ export class EmailPool {
|
|
|
525
634
|
}
|
|
526
635
|
}
|
|
527
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* Download one MIME part through imapflow's decode pipeline: transfer
|
|
639
|
+
* encoding and charset are handled there, maxBytes caps what is fetched.
|
|
640
|
+
*/
|
|
641
|
+
async downloadPartText(client, uid, part, maxBytes, signal) {
|
|
642
|
+
const dl = await client.download(uid, part.key, { uid: true, maxBytes });
|
|
643
|
+
signal?.throwIfAborted();
|
|
644
|
+
const buf = await collectStream(dl.content, maxBytes, signal);
|
|
645
|
+
signal?.throwIfAborted();
|
|
646
|
+
return buf.toString('utf8');
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* The message body without its attachments. undefined when the structure has
|
|
650
|
+
* no usable text part or the server refuses the part fetch, so the caller can
|
|
651
|
+
* fall back to the full-source path for that one message.
|
|
652
|
+
*/
|
|
653
|
+
async bodyTextFromParts(client, uid, structure, maxBytes, signal) {
|
|
654
|
+
const { plain, html } = selectBodyParts(structure);
|
|
655
|
+
if (plain !== undefined) {
|
|
656
|
+
const text = await this.downloadPartText(client, uid, plain, maxBytes, signal);
|
|
657
|
+
if (text.trim() !== '' || html === undefined)
|
|
658
|
+
return text;
|
|
659
|
+
}
|
|
660
|
+
if (html !== undefined)
|
|
661
|
+
return stripHtml(await this.downloadPartText(client, uid, html, maxBytes, signal));
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
528
664
|
async list(accountName, folder, limit, offset, unreadOnly, since, until, signal) {
|
|
529
665
|
const name = this.resolveName(accountName);
|
|
530
666
|
const cfg = this.account(name);
|
|
@@ -532,6 +668,8 @@ export class EmailPool {
|
|
|
532
668
|
return this.withImap(name, folderName, async (client) => {
|
|
533
669
|
const mailbox = client.mailbox;
|
|
534
670
|
const total = mailbox === false ? 0 : mailbox.exists;
|
|
671
|
+
// imapflow types uidValidity as number | bigint; the wire format is 32-bit.
|
|
672
|
+
const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
|
|
535
673
|
let scopeCount = total;
|
|
536
674
|
let uids = [];
|
|
537
675
|
const hasDateFilter = since !== undefined || until !== undefined;
|
|
@@ -557,10 +695,36 @@ export class EmailPool {
|
|
|
557
695
|
uids.reverse();
|
|
558
696
|
const window = uids.slice(offset, offset + limit);
|
|
559
697
|
const messages = await this.fetchListed(client, window, signal);
|
|
560
|
-
return { account: name, count: scopeCount, folder: folderName, messages };
|
|
698
|
+
return { account: name, count: scopeCount, folder: folderName, uidValidity, messages };
|
|
699
|
+
}, true, signal);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* The uid index behind email_watch: SEARCH UNSEEN only, no envelopes and no
|
|
703
|
+
* bodies. The caller decides which uids it actually needs to report.
|
|
704
|
+
*/
|
|
705
|
+
async unseenUids(accountName, folder, signal) {
|
|
706
|
+
const name = this.resolveName(accountName);
|
|
707
|
+
const cfg = this.account(name);
|
|
708
|
+
const folderName = folder || cfg.inboxFolder;
|
|
709
|
+
return this.withImap(name, folderName, async (client) => {
|
|
710
|
+
const mailbox = client.mailbox;
|
|
711
|
+
const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
|
|
712
|
+
const found = await client.search({ seen: false }, { uid: true });
|
|
713
|
+
signal?.throwIfAborted();
|
|
714
|
+
const uids = (found === false ? [] : found).slice().sort((a, b) => b - a);
|
|
715
|
+
return { account: name, folder: folderName, uidValidity, count: uids.length, uids };
|
|
561
716
|
}, true, signal);
|
|
562
717
|
}
|
|
563
|
-
|
|
718
|
+
/** Fetch the envelopes for one uid batch: the rows email_watch will report. */
|
|
719
|
+
async fetchByUids(accountName, folder, uids, signal) {
|
|
720
|
+
if (uids.length === 0)
|
|
721
|
+
return [];
|
|
722
|
+
const name = this.resolveName(accountName);
|
|
723
|
+
const cfg = this.account(name);
|
|
724
|
+
const folderName = folder || cfg.inboxFolder;
|
|
725
|
+
return this.withImap(name, folderName, (client) => this.fetchListed(client, uids, signal), true, signal);
|
|
726
|
+
}
|
|
727
|
+
async search(accountName, query, folder, limit, offset, since, until, signal) {
|
|
564
728
|
const name = this.resolveName(accountName);
|
|
565
729
|
const cfg = this.account(name);
|
|
566
730
|
const folderName = folder || cfg.inboxFolder;
|
|
@@ -586,20 +750,28 @@ export class EmailPool {
|
|
|
586
750
|
signal?.throwIfAborted();
|
|
587
751
|
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => b - a);
|
|
588
752
|
if (uids.length > 0) {
|
|
589
|
-
|
|
753
|
+
// The sample has to cover the requested page (offset + limit) — the same
|
|
754
|
+
// window the fallback scan looks at — so one FETCH serves both the
|
|
755
|
+
// verification and the rows that are handed out.
|
|
756
|
+
const confirmed = await this.searchHits(client, uids, query, offset + limit, signal);
|
|
590
757
|
if (confirmed.length > 0) {
|
|
591
758
|
// The server's list holds up, so its size is reported as the match
|
|
592
759
|
// count; only rows that were confirmed are ever handed out.
|
|
593
|
-
return { account: name, query, count: uids.length, folder: folderName, messages: confirmed.slice(
|
|
760
|
+
return { account: name, query, count: uids.length, folder: folderName, offset, messages: confirmed.slice(offset, offset + limit) };
|
|
594
761
|
}
|
|
595
762
|
}
|
|
596
763
|
// Nothing believable came back (empty answer, or hits that did not
|
|
597
764
|
// survive verification): scan the newest messages locally instead.
|
|
598
765
|
if (this.settings.bodySearchFallback) {
|
|
599
|
-
const messages = await this.searchBodies(client, query, folderName, limit, since, until, signal);
|
|
600
|
-
|
|
766
|
+
const messages = await this.searchBodies(client, query, folderName, limit, offset, since, until, signal);
|
|
767
|
+
// 本地扫描不知道全文件夹有多少匹配,count 只是本页条数:用 countKind
|
|
768
|
+
// 让渲染说「本页 N 条(仅扫描最近 bodySearchLimit 封)」,不能说「共 N 条」。
|
|
769
|
+
return {
|
|
770
|
+
account: name, query, count: messages.length, folder: folderName, offset, messages,
|
|
771
|
+
countKind: 'scanned', scannedLimit: this.settings.bodySearchLimit,
|
|
772
|
+
};
|
|
601
773
|
}
|
|
602
|
-
return { account: name, query, count: 0, folder: folderName, messages: [] };
|
|
774
|
+
return { account: name, query, count: 0, folder: folderName, offset, messages: [] };
|
|
603
775
|
}, true, signal);
|
|
604
776
|
}
|
|
605
777
|
/**
|
|
@@ -609,8 +781,8 @@ export class EmailPool {
|
|
|
609
781
|
* the four fields the server was asked about. No body is downloaded here,
|
|
610
782
|
* and uids the server made up simply return nothing.
|
|
611
783
|
*/
|
|
612
|
-
async searchHits(client, uids, query,
|
|
613
|
-
const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit,
|
|
784
|
+
async searchHits(client, uids, query, need, signal) {
|
|
785
|
+
const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, need)));
|
|
614
786
|
signal?.throwIfAborted();
|
|
615
787
|
const fetched = await client.fetchAll(sample, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
|
|
616
788
|
signal?.throwIfAborted();
|
|
@@ -624,18 +796,19 @@ export class EmailPool {
|
|
|
624
796
|
.sort((a, b) => b.uid - a.uid);
|
|
625
797
|
}
|
|
626
798
|
/** Client-side scan of the tail of the mailbox, newest first. */
|
|
627
|
-
async searchBodies(client, query, folder, limit, since, until, signal) {
|
|
799
|
+
async searchBodies(client, query, folder, limit, offset, since, until, signal) {
|
|
628
800
|
signal?.throwIfAborted();
|
|
629
801
|
const mailbox = client.mailbox;
|
|
630
802
|
const total = mailbox === false ? 0 : mailbox.exists;
|
|
631
803
|
if (total === 0)
|
|
632
804
|
return [];
|
|
633
805
|
const start = Math.max(1, total - this.settings.bodySearchLimit + 1);
|
|
634
|
-
|
|
806
|
+
// 只取信封与 MIME 结构;正文在下面按 text/* 分段下载,附件不进正文匹配。
|
|
807
|
+
const fetched = await client.fetchAll(start + ':*', { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, internalDate: true });
|
|
635
808
|
const out = [];
|
|
636
809
|
for (const message of [...fetched].reverse()) {
|
|
637
810
|
signal?.throwIfAborted();
|
|
638
|
-
if (out.length >= limit)
|
|
811
|
+
if (out.length >= offset + limit)
|
|
639
812
|
break;
|
|
640
813
|
const receivedAt = message.internalDate ?? message.envelope?.date;
|
|
641
814
|
if (since !== undefined && (receivedAt === undefined || receivedAt < since))
|
|
@@ -647,6 +820,7 @@ export class EmailPool {
|
|
|
647
820
|
.map(flattenAddressText).join(' ');
|
|
648
821
|
let body = '';
|
|
649
822
|
if (message.source !== undefined) {
|
|
823
|
+
// 服务器多送了整封 source(测试/旧行为):直接解析,不必再多一次往返。
|
|
650
824
|
try {
|
|
651
825
|
const parsed = await parseRawMessage(message.source, 4096);
|
|
652
826
|
signal?.throwIfAborted();
|
|
@@ -657,11 +831,20 @@ export class EmailPool {
|
|
|
657
831
|
// 单封邮件解析失败不应中断整批回退扫描,继续用 subject/from/to/cc 匹配。
|
|
658
832
|
}
|
|
659
833
|
}
|
|
834
|
+
else if (message.bodyStructure !== undefined) {
|
|
835
|
+
try {
|
|
836
|
+
body = await this.bodyTextFromParts(client, message.uid, message.bodyStructure, 4096 * 4, signal) ?? '';
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
signal?.throwIfAborted();
|
|
840
|
+
// 单封邮件分段下载失败不应中断整批回退扫描,继续用 subject/from/to/cc 匹配。
|
|
841
|
+
}
|
|
842
|
+
}
|
|
660
843
|
if (messageMatchesQuery(subject, recipientSearchText, body, query)) {
|
|
661
844
|
out.push(listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
|
|
662
845
|
}
|
|
663
846
|
}
|
|
664
|
-
return out;
|
|
847
|
+
return out.slice(offset, offset + limit);
|
|
665
848
|
}
|
|
666
849
|
async fetchListed(client, uids, signal) {
|
|
667
850
|
signal?.throwIfAborted();
|
|
@@ -678,12 +861,56 @@ export class EmailPool {
|
|
|
678
861
|
const cfg = this.account(name);
|
|
679
862
|
const folderName = folder || cfg.inboxFolder;
|
|
680
863
|
return this.withImap(name, folderName, async (client) => {
|
|
681
|
-
|
|
682
|
-
|
|
864
|
+
// 先只要信封与 MIME 结构,正文按 text/* 分段下载:带 20 MiB 附件的邮件
|
|
865
|
+
// 只为看正文时不再整封拉下来,附件元数据直接复用 bodyStructure。
|
|
866
|
+
const message = await client.fetchOne(uid, { uid: true, envelope: true, bodyStructure: true }, { uid: true });
|
|
867
|
+
if (message === false) {
|
|
683
868
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
684
869
|
}
|
|
685
|
-
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
686
870
|
signal?.throwIfAborted();
|
|
871
|
+
if (message.source === undefined && message.envelope !== undefined && message.bodyStructure !== undefined) {
|
|
872
|
+
try {
|
|
873
|
+
const text = await this.bodyTextFromParts(client, uid, message.bodyStructure, this.settings.maxBodyChars * 4 + 4096, signal);
|
|
874
|
+
if (text !== undefined) {
|
|
875
|
+
const limited = truncateText(text, this.settings.maxBodyChars);
|
|
876
|
+
const parts = collectAttachmentParts(message.bodyStructure);
|
|
877
|
+
const attachments = parts.map(part => ({
|
|
878
|
+
filename: part.filename,
|
|
879
|
+
contentType: part.contentType,
|
|
880
|
+
size: part.size,
|
|
881
|
+
part: part.part,
|
|
882
|
+
}));
|
|
883
|
+
this.rememberRead(name, folderName, this.uidValidityOf(client), uid, { attachments, parts });
|
|
884
|
+
const envelopeDate = message.envelope.date;
|
|
885
|
+
return {
|
|
886
|
+
account: name,
|
|
887
|
+
uid,
|
|
888
|
+
folder: folderName,
|
|
889
|
+
date: envelopeDate instanceof Date ? envelopeDate.toISOString() : '',
|
|
890
|
+
from: flattenAddresses(message.envelope.from),
|
|
891
|
+
to: flattenAddresses(message.envelope.to),
|
|
892
|
+
cc: flattenAddresses(message.envelope.cc),
|
|
893
|
+
subject: message.envelope.subject ?? '',
|
|
894
|
+
text: limited.text,
|
|
895
|
+
attachments,
|
|
896
|
+
truncated: limited.truncated,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
signal?.throwIfAborted();
|
|
902
|
+
// 分段读取失败(服务器拒绝该分段或结构异常):这一封退回整封解析。
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const full = message.source !== undefined
|
|
906
|
+
? message
|
|
907
|
+
: await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
|
|
908
|
+
if (full === false || full.source === undefined) {
|
|
909
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
910
|
+
}
|
|
911
|
+
const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
|
|
912
|
+
signal?.throwIfAborted();
|
|
913
|
+
this.rememberRead(name, folderName, this.uidValidityOf(client), uid, { attachments: body.attachments, parts: collectAttachmentParts(full.bodyStructure) });
|
|
687
914
|
return { account: name, uid, folder: folderName, ...body };
|
|
688
915
|
}, true, signal);
|
|
689
916
|
}
|
|
@@ -746,17 +973,23 @@ export class EmailPool {
|
|
|
746
973
|
async folders(accountName, subscribedOnly, signal) {
|
|
747
974
|
const name = this.resolveName(accountName);
|
|
748
975
|
return this.withImap(name, null, async (client) => {
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
976
|
+
const cached = this.folderCache.get(name);
|
|
977
|
+
let rows;
|
|
978
|
+
if (cached !== undefined && Date.now() - cached.at < FOLDER_CACHE_TTL_MS) {
|
|
979
|
+
rows = cached.folders;
|
|
980
|
+
}
|
|
981
|
+
else {
|
|
982
|
+
const list = await client.list();
|
|
983
|
+
signal?.throwIfAborted();
|
|
984
|
+
rows = list.map(row => ({
|
|
985
|
+
name: row.name ?? row.path,
|
|
986
|
+
path: row.path,
|
|
987
|
+
specialUse: row.specialUse ?? '',
|
|
988
|
+
subscribed: row.subscribed !== false,
|
|
989
|
+
}));
|
|
990
|
+
this.folderCache.set(name, { at: Date.now(), folders: rows });
|
|
991
|
+
}
|
|
992
|
+
return { account: name, folders: rows.filter(row => !subscribedOnly || row.subscribed !== false) };
|
|
760
993
|
}, true, signal);
|
|
761
994
|
}
|
|
762
995
|
async downloadAttachment(accountName, folder, uid, index, workspaceHint, signal) {
|
|
@@ -764,23 +997,19 @@ export class EmailPool {
|
|
|
764
997
|
const cfg = this.account(name);
|
|
765
998
|
const folderName = folder || cfg.inboxFolder;
|
|
766
999
|
return this.withImap(name, folderName, async (client) => {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
774
|
-
signal?.throwIfAborted();
|
|
775
|
-
const parts = collectAttachmentParts(message.bodyStructure);
|
|
776
|
-
if (body.attachments.length === 0)
|
|
1000
|
+
// The mailparser list is authoritative for the index email_read showed and
|
|
1001
|
+
// the bodyStructure walk supplies the IMAP part to download. email_read
|
|
1002
|
+
// already produced both in the usual read-then-download flow, so reuse that
|
|
1003
|
+
// instead of pulling the whole message — attachments included — again.
|
|
1004
|
+
const { attachments, parts } = await this.attachmentIndexOf(client, name, folderName, uid, signal);
|
|
1005
|
+
if (attachments.length === 0)
|
|
777
1006
|
throw new MailError('该邮件没有附件');
|
|
778
|
-
if (
|
|
779
|
-
throw new MailError('附件序号 ' + index + ' 越界:共 ' +
|
|
1007
|
+
if (attachments[index] === undefined) {
|
|
1008
|
+
throw new MailError('附件序号 ' + index + ' 越界:共 ' + attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
|
|
780
1009
|
}
|
|
781
|
-
const att = selectAttachmentPart(
|
|
1010
|
+
const att = selectAttachmentPart(attachments, parts, index);
|
|
782
1011
|
if (att === undefined) {
|
|
783
|
-
throw new MailError('附件 #' + index + '(' +
|
|
1012
|
+
throw new MailError('附件 #' + index + '(' + attachments[index].filename + ')无法在邮件结构中定位(可能是内嵌图片,暂不支持下载)');
|
|
784
1013
|
}
|
|
785
1014
|
if (att.size > this.settings.maxAttachmentBytes) {
|
|
786
1015
|
throw new MailError('附件 "' + att.filename + '" 大小 ' + att.size + ' 字节,超过上限 maxAttachmentBytes=' + this.settings.maxAttachmentBytes);
|
|
@@ -788,7 +1017,7 @@ export class EmailPool {
|
|
|
788
1017
|
const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
|
|
789
1018
|
signal?.throwIfAborted();
|
|
790
1019
|
const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes, signal);
|
|
791
|
-
const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ??
|
|
1020
|
+
const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? attachments[index].filename);
|
|
792
1021
|
// Default the destination to the session workspace so the model can
|
|
793
1022
|
// read the file back; an explicit downloadDir always wins.
|
|
794
1023
|
const dir = this.settings.downloadDirExplicit
|
|
@@ -809,7 +1038,7 @@ export class EmailPool {
|
|
|
809
1038
|
const cfg = this.account(name);
|
|
810
1039
|
const attachments = await validateAttachmentPaths(attachmentPaths ?? [], this.settings.maxAttachmentBytes, signal);
|
|
811
1040
|
const info = await this.sendMail(name, cfg, {
|
|
812
|
-
from: cfg
|
|
1041
|
+
from: senderOf(cfg),
|
|
813
1042
|
to,
|
|
814
1043
|
cc,
|
|
815
1044
|
subject,
|
|
@@ -838,10 +1067,13 @@ export class EmailPool {
|
|
|
838
1067
|
const ids = extractMessageIds(message.source);
|
|
839
1068
|
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
840
1069
|
signal?.throwIfAborted();
|
|
841
|
-
return buildReplyMessage({ from: body.from, to: body.to, cc: body.cc, subject: body.subject, date: body.date, text: body.text, messageId: ids.messageId, references: ids.references }, mode,
|
|
1070
|
+
return buildReplyMessage({ from: body.from, to: body.to, cc: body.cc, subject: body.subject, date: body.date, text: body.text, messageId: ids.messageId, references: ids.references }, mode,
|
|
1071
|
+
// Both the visible address and the login are "me": a reply-all that
|
|
1072
|
+
// keeps either of them would mail the sender his own message.
|
|
1073
|
+
cfg.authUser === cfg.user ? cfg.user : [cfg.user, cfg.authUser], text, forwardTo);
|
|
842
1074
|
}, true, signal);
|
|
843
1075
|
const info = await this.sendMail(name, cfg, {
|
|
844
|
-
from: cfg
|
|
1076
|
+
from: senderOf(cfg),
|
|
845
1077
|
to: built.to,
|
|
846
1078
|
cc,
|
|
847
1079
|
subject: built.subject,
|
package/lib/parse.js
CHANGED
|
@@ -38,8 +38,10 @@ export function truncateText(text, maxChars) {
|
|
|
38
38
|
if (text.length <= maxChars)
|
|
39
39
|
return { text, truncated: false };
|
|
40
40
|
const cut = text.slice(0, maxChars);
|
|
41
|
-
const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' ')
|
|
42
|
-
|
|
41
|
+
const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' '));
|
|
42
|
+
// 窗口内没有空格/换行时必须硬切:否则 lastBreak 为 0,正文会被整段丢成空串。
|
|
43
|
+
const head = lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
|
|
44
|
+
return { text: head + '\n\n…[正文过长,已截断,共 ' + text.length + ' 字符]', truncated: true };
|
|
43
45
|
}
|
|
44
46
|
/**
|
|
45
47
|
* Turn an untrusted attachment filename into a safe basename: no directory
|
package/lib/runtime.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type EmailConfig, type ResolvedEmailSettings } from './config.js';
|
|
|
3
3
|
import { EmailPool } from './mail-client.js';
|
|
4
4
|
import { type EmailSettingsValue } from './settings.js';
|
|
5
5
|
import type { EmailWatchResult } from './types.js';
|
|
6
|
-
export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'reply' | 'folders' | 'downloadAttachment' | 'startIdleSweep' | 'dispose'>;
|
|
6
|
+
export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose'>;
|
|
7
7
|
export interface EmailSettingsScope {
|
|
8
8
|
get(): unknown;
|
|
9
9
|
}
|
package/lib/runtime.js
CHANGED
|
@@ -63,24 +63,42 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
|
|
|
63
63
|
const watchCursors = new Map();
|
|
64
64
|
const watch = async (account, folder, limit, scope, signal) => {
|
|
65
65
|
const capped = clampInt(limit, 20, 1, 100);
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
const pool = getPool();
|
|
67
|
+
// 先只 SEARCH UNSEEN 拿 uid 列表与 totalUnread,再只为本轮要报告的最旧
|
|
68
|
+
// limit 封取信封:网页弹窗每 30 秒轮询一次,不能因为未读多就整批 FETCH。
|
|
69
|
+
const index = await pool.unseenUids(account, folder, signal);
|
|
70
|
+
const key = scope + '\u0000' + index.account + '\u0000' + index.folder;
|
|
71
|
+
const stored = watchCursors.get(key);
|
|
72
|
+
const uidValidity = typeof index.uidValidity === 'number' ? index.uidValidity : 0;
|
|
73
|
+
// A UIDVALIDITY change renumbers every message in the mailbox: keeping the
|
|
74
|
+
// old cursor would either report the whole folder as new or miss everything
|
|
75
|
+
// that renumbered below it. Re-seed the baseline instead and say so.
|
|
76
|
+
const reset = stored !== undefined && stored.uidValidity !== 0 && uidValidity !== 0 && stored.uidValidity !== uidValidity;
|
|
77
|
+
const isFirst = stored === undefined || reset;
|
|
78
|
+
const cursor = stored === undefined || reset ? 0 : stored.uid;
|
|
79
|
+
const fresh = isFirst ? [] : index.uids.filter(uid => uid > cursor);
|
|
80
|
+
// 后续每次只返回 fresh 中最旧的 limit 条,游标也只推进到这批的最大 uid:
|
|
81
|
+
// 窗口里更旧的新邮件留给下一轮,不能因为本次只返回 limit 条就被永久跳过。
|
|
82
|
+
const batch = fresh.slice(Math.max(0, fresh.length - capped));
|
|
83
|
+
const messages = batch.length > 0 ? await pool.fetchByUids(account, folder, batch, signal) : [];
|
|
84
|
+
if (isFirst) {
|
|
85
|
+
// 首次调用(或 UIDVALIDITY 重建)只落基线:游标取窗口最新一封,旧邮件不算新邮件。
|
|
86
|
+
watchCursors.set(key, {
|
|
87
|
+
uid: index.uids.length > 0 ? index.uids[0] : 0,
|
|
88
|
+
uidValidity,
|
|
89
|
+
});
|
|
73
90
|
}
|
|
74
|
-
else if (
|
|
75
|
-
watchCursors.set(key,
|
|
91
|
+
else if (batch.length > 0) {
|
|
92
|
+
watchCursors.set(key, { uid: Math.max(...batch), uidValidity });
|
|
76
93
|
}
|
|
77
94
|
return {
|
|
78
|
-
account:
|
|
79
|
-
folder:
|
|
80
|
-
firstRun:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
account: index.account,
|
|
96
|
+
folder: index.folder,
|
|
97
|
+
firstRun: stored === undefined,
|
|
98
|
+
...(reset ? { reset: true } : {}),
|
|
99
|
+
newCount: messages.length,
|
|
100
|
+
messages,
|
|
101
|
+
totalUnread: index.count,
|
|
84
102
|
};
|
|
85
103
|
};
|
|
86
104
|
const dispose = () => {
|