dsh-email 0.10.7 → 0.12.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.en.md +86 -8
- package/README.md +66 -9
- package/lib/client.js +2344 -191
- package/lib/config.d.ts +161 -1
- package/lib/config.js +244 -11
- package/lib/index.d.ts +6 -2
- package/lib/index.js +3 -2
- package/lib/mail-client.d.ts +102 -3
- package/lib/mail-client.js +318 -73
- package/lib/oauth2.d.ts +124 -0
- package/lib/oauth2.js +417 -0
- package/lib/runtime.js +28 -8
- package/lib/settings.d.ts +22 -1
- package/lib/settings.js +122 -34
- package/lib/tool-contract.d.ts +3 -0
- package/lib/tool-contract.js +11 -2
- package/lib/tools.js +22 -6
- package/lib/types.d.ts +7 -0
- package/lib/web.d.ts +209 -2
- package/lib/web.js +917 -14
- package/package.json +1 -1
package/lib/mail-client.js
CHANGED
|
@@ -2,6 +2,7 @@ import { ImapFlow } from 'imapflow';
|
|
|
2
2
|
import nodemailer from 'nodemailer';
|
|
3
3
|
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { getFreshAccessToken, OAuth2Error } from './oauth2.js';
|
|
5
6
|
import { flattenAddresses, parseRawMessage, sanitizeFilename } from './parse.js';
|
|
6
7
|
export class MailError extends Error {
|
|
7
8
|
constructor(message) {
|
|
@@ -12,6 +13,66 @@ export class MailError extends Error {
|
|
|
12
13
|
export function messageOf(error, fallback) {
|
|
13
14
|
return error instanceof Error && error.message !== '' ? error.message : fallback;
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Replace anything credential-shaped in a server's own error text before it
|
|
18
|
+
* reaches a user.
|
|
19
|
+
*
|
|
20
|
+
* IMAP and SMTP servers routinely quote back the authentication string they
|
|
21
|
+
* rejected. For XOAUTH2 that string is `user=…\x01auth=Bearer <token>\x01\x01`,
|
|
22
|
+
* usually base64'd — so the raw message carries a live access token, and these
|
|
23
|
+
* messages are rendered in the settings panel, returned by the mail tools, and
|
|
24
|
+
* pasted into bug reports.
|
|
25
|
+
*
|
|
26
|
+
* Two shapes are masked: a JWT (three base64url segments, which is what every
|
|
27
|
+
* OAuth2 access token looks like) and a long base64 run (the quoted XOAUTH2
|
|
28
|
+
* blob). The replacement keeps the length so a report still says how big the
|
|
29
|
+
* thing was, without saying what it was.
|
|
30
|
+
*/
|
|
31
|
+
export function redactCredentials(text) {
|
|
32
|
+
return text
|
|
33
|
+
.replace(/[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}/g, match => `<已隐去 ${match.length} 字符的令牌>`)
|
|
34
|
+
.replace(/[A-Za-z0-9+/]{40,}={0,2}/g, match => `<已隐去 ${match.length} 字符的凭据>`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The IMAP `auth` block for one account. Pure so the shape the library
|
|
38
|
+
* receives is testable without a socket: an OAuth2 account authenticates with
|
|
39
|
+
* `accessToken` (imapflow then runs AUTHENTICATE XOAUTH2) and a password
|
|
40
|
+
* account with `pass`, exactly as before.
|
|
41
|
+
*/
|
|
42
|
+
export function imapAuthOf(cfg, accessToken) {
|
|
43
|
+
return cfg.authKind === 'oauth2'
|
|
44
|
+
? { user: cfg.authUser, accessToken: accessToken ?? '' }
|
|
45
|
+
: { user: cfg.authUser, pass: cfg.authPassword };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Nodemailer consumes an OAuth2 token through accessToken, not pass.
|
|
49
|
+
* Refresh remains owned by this plugin; no refresh credentials leave here.
|
|
50
|
+
*/
|
|
51
|
+
export function smtpAuthOf(cfg, accessToken) {
|
|
52
|
+
return cfg.authKind === 'oauth2'
|
|
53
|
+
? { type: 'OAuth2', user: cfg.authUser, accessToken: accessToken ?? '' }
|
|
54
|
+
: { user: cfg.authUser, pass: cfg.authPassword };
|
|
55
|
+
}
|
|
56
|
+
/** The message an OAuth2 account gets when the mailbox has to be logged into again. */
|
|
57
|
+
export const OAUTH2_RELOGIN_MESSAGE = '邮箱登录失败:请到设置页重新登录(Microsoft 账号使用设备码登录,不使用授权码)';
|
|
58
|
+
/**
|
|
59
|
+
* True for the errors both libraries report when the server rejects the
|
|
60
|
+
* credentials. An expired access token is indistinguishable from a wrong
|
|
61
|
+
* password at this level, so the connection retries once with a forced refresh
|
|
62
|
+
* before it believes the token is really dead.
|
|
63
|
+
*/
|
|
64
|
+
export function looksLikeAuthFailure(error) {
|
|
65
|
+
const raw = messageOf(error, '').toLowerCase();
|
|
66
|
+
if (raw === '')
|
|
67
|
+
return false;
|
|
68
|
+
return raw.includes('authentication')
|
|
69
|
+
|| raw.includes('authenticate')
|
|
70
|
+
|| raw.includes('auth failed')
|
|
71
|
+
|| raw.includes('login')
|
|
72
|
+
|| raw.includes('command failed')
|
|
73
|
+
|| raw.includes('invalid credentials')
|
|
74
|
+
|| raw.includes('xoauth2');
|
|
75
|
+
}
|
|
15
76
|
/** True when any bodyStructure node declares an attachment disposition. */
|
|
16
77
|
function structureHasAttachment(node) {
|
|
17
78
|
if (node === null || node === undefined || typeof node !== 'object')
|
|
@@ -57,6 +118,10 @@ export function selectAttachmentPart(readAttachments, parts, index) {
|
|
|
57
118
|
const byTypeAndSize = parts.find(part => part.contentType === meta.contentType && Math.abs(part.size - meta.size) <= tolerance);
|
|
58
119
|
return byTypeAndSize;
|
|
59
120
|
}
|
|
121
|
+
/** The From header: `user` is the visible address, `senderName` only labels it. */
|
|
122
|
+
function senderOf(cfg) {
|
|
123
|
+
return cfg.senderName === '' ? cfg.user : { name: cfg.senderName, address: cfg.user };
|
|
124
|
+
}
|
|
60
125
|
/** Case-insensitive match of a query against subject/from/body text. */
|
|
61
126
|
export function messageMatchesQuery(subject, fromText, body, query) {
|
|
62
127
|
const q = query.toLowerCase();
|
|
@@ -81,10 +146,11 @@ function formatAddress(entry) {
|
|
|
81
146
|
}
|
|
82
147
|
function dedupeAddresses(entries, exclude) {
|
|
83
148
|
const seen = new Set();
|
|
149
|
+
const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).map(a => a.trim().toLowerCase()).filter(a => a !== ''));
|
|
84
150
|
const out = [];
|
|
85
151
|
for (const entry of entries) {
|
|
86
152
|
const addr = (entry.address ?? '').toLowerCase();
|
|
87
|
-
if (addr === '' || addr
|
|
153
|
+
if (addr === '' || excluded.has(addr) || seen.has(addr))
|
|
88
154
|
continue;
|
|
89
155
|
seen.add(addr);
|
|
90
156
|
out.push(entry);
|
|
@@ -103,7 +169,7 @@ const FORWARD_MAX_CHARS = 4000;
|
|
|
103
169
|
*/
|
|
104
170
|
export function buildReplyMessage(original, mode, selfAddress, text, forwardTo = '') {
|
|
105
171
|
const fromText = original.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知发件人)';
|
|
106
|
-
const self = selfAddress.
|
|
172
|
+
const self = (Array.isArray(selfAddress) ? selfAddress : [selfAddress]).filter(a => a.trim() !== '');
|
|
107
173
|
if (mode === 'forward') {
|
|
108
174
|
const to = forwardTo.trim();
|
|
109
175
|
if (to === '')
|
|
@@ -170,6 +236,11 @@ function listedFrom(envelope, size, hasAttachments) {
|
|
|
170
236
|
hasAttachments,
|
|
171
237
|
};
|
|
172
238
|
}
|
|
239
|
+
/** email_attachment reuses the MIME index email_read already parsed; keep a few. */
|
|
240
|
+
const READ_CACHE_MAX = 16;
|
|
241
|
+
const READ_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
242
|
+
/** Folder names change rarely; a short TTL keeps email_folders off the wire. */
|
|
243
|
+
const FOLDER_CACHE_TTL_MS = 60 * 1000;
|
|
173
244
|
/**
|
|
174
245
|
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
175
246
|
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
@@ -204,17 +275,60 @@ export class EmailPool {
|
|
|
204
275
|
this.queues.set(name, next.then(() => undefined, () => undefined));
|
|
205
276
|
return next;
|
|
206
277
|
}
|
|
278
|
+
readCache = new Map();
|
|
279
|
+
folderCache = new Map();
|
|
280
|
+
/** Remember a parsed attachment index so email_attachment can skip the refetch. */
|
|
281
|
+
rememberRead(account, folder, uid, parsed) {
|
|
282
|
+
const key = account + '\u0000' + folder + '\u0000' + uid;
|
|
283
|
+
this.readCache.delete(key);
|
|
284
|
+
this.readCache.set(key, { ...parsed, at: Date.now() });
|
|
285
|
+
while (this.readCache.size > READ_CACHE_MAX) {
|
|
286
|
+
const oldest = this.readCache.keys().next();
|
|
287
|
+
if (oldest.done === true)
|
|
288
|
+
break;
|
|
289
|
+
this.readCache.delete(oldest.value);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The attachment index for one message: the cached one when email_read already
|
|
294
|
+
* produced it, otherwise a fresh parse of the full source plus its bodyStructure.
|
|
295
|
+
*/
|
|
296
|
+
async attachmentIndexOf(client, account, folder, uid, signal) {
|
|
297
|
+
const cached = this.recallRead(account, folder, uid);
|
|
298
|
+
if (cached !== undefined)
|
|
299
|
+
return cached;
|
|
300
|
+
const message = await client.fetchOne(uid, { uid: true, bodyStructure: true, source: true }, { uid: true });
|
|
301
|
+
if (message === false || message.source === undefined) {
|
|
302
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
|
|
303
|
+
}
|
|
304
|
+
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
305
|
+
signal?.throwIfAborted();
|
|
306
|
+
const parsed = { attachments: body.attachments, parts: collectAttachmentParts(message.bodyStructure) };
|
|
307
|
+
this.rememberRead(account, folder, uid, parsed);
|
|
308
|
+
return parsed;
|
|
309
|
+
}
|
|
310
|
+
recallRead(account, folder, uid) {
|
|
311
|
+
const key = account + '\u0000' + folder + '\u0000' + uid;
|
|
312
|
+
const hit = this.readCache.get(key);
|
|
313
|
+
if (hit === undefined)
|
|
314
|
+
return undefined;
|
|
315
|
+
if (Date.now() - hit.at > READ_CACHE_TTL_MS) {
|
|
316
|
+
this.readCache.delete(key);
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return hit;
|
|
320
|
+
}
|
|
207
321
|
async withImap(accountName, folder, run, readOnly = true, signal) {
|
|
208
322
|
const name = this.resolveName(accountName);
|
|
209
323
|
const cfg = this.account(name);
|
|
210
324
|
return this.enqueue(name, () => this.imapRun(name, cfg, folder, readOnly, run, signal), signal);
|
|
211
325
|
}
|
|
212
|
-
createImap(cfg) {
|
|
326
|
+
createImap(auth, cfg) {
|
|
213
327
|
const client = new ImapFlow({
|
|
214
328
|
host: cfg.imap.host,
|
|
215
329
|
port: cfg.imap.port,
|
|
216
330
|
secure: cfg.imap.secure,
|
|
217
|
-
auth
|
|
331
|
+
auth,
|
|
218
332
|
logger: false,
|
|
219
333
|
connectionTimeout: cfg.imap.connectionTimeoutMs ?? 30000,
|
|
220
334
|
greetingTimeout: 30000,
|
|
@@ -234,6 +348,54 @@ export class EmailPool {
|
|
|
234
348
|
});
|
|
235
349
|
return client;
|
|
236
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* Dial and authenticate one fresh IMAP connection.
|
|
353
|
+
*
|
|
354
|
+
* A password account connects once. An OAuth2 account connects with a fresh
|
|
355
|
+
* access token and, when the server rejects it, refreshes once and tries
|
|
356
|
+
* again: a token that expired between the freshness check and the dial is
|
|
357
|
+
* indistinguishable from a wrong password at the socket, and guessing wrong
|
|
358
|
+
* would send the user through a browser login for nothing.
|
|
359
|
+
*/
|
|
360
|
+
async connectImap(name, cfg, forceToken = false) {
|
|
361
|
+
const oauth2 = cfg.authKind === 'oauth2';
|
|
362
|
+
const attempt = async (token) => {
|
|
363
|
+
const client = this.createImap(imapAuthOf(cfg, token), cfg);
|
|
364
|
+
await client.connect();
|
|
365
|
+
return client;
|
|
366
|
+
};
|
|
367
|
+
let token;
|
|
368
|
+
if (oauth2) {
|
|
369
|
+
try {
|
|
370
|
+
token = await getFreshAccessToken(name, cfg, { force: forceToken });
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
throw this.oauth2ErrorOf(error);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
return await attempt(token);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
if (!oauth2 || !looksLikeAuthFailure(error))
|
|
381
|
+
throw error;
|
|
382
|
+
try {
|
|
383
|
+
token = await getFreshAccessToken(name, cfg, { force: true });
|
|
384
|
+
}
|
|
385
|
+
catch (refreshError) {
|
|
386
|
+
throw this.oauth2ErrorOf(refreshError);
|
|
387
|
+
}
|
|
388
|
+
return await attempt(token);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** The token store's own errors are already actionable; never dress them as IMAP failures. */
|
|
392
|
+
oauth2ErrorOf(error) {
|
|
393
|
+
if (error instanceof OAuth2Error)
|
|
394
|
+
return new MailError(error.message);
|
|
395
|
+
if (error instanceof MailError)
|
|
396
|
+
return error;
|
|
397
|
+
return new MailError(OAUTH2_RELOGIN_MESSAGE + '(' + redactCredentials(messageOf(error, '未知错误')) + ')');
|
|
398
|
+
}
|
|
237
399
|
async imapRun(name, cfg, folder, readOnly, run, signal) {
|
|
238
400
|
let entry = this.imaps.get(name);
|
|
239
401
|
let activeClient = entry?.client;
|
|
@@ -252,11 +414,9 @@ export class EmailPool {
|
|
|
252
414
|
if (entry === undefined || !entry.client.usable) {
|
|
253
415
|
if (entry !== undefined)
|
|
254
416
|
await this.evictImap(name);
|
|
255
|
-
const client = this.
|
|
417
|
+
const client = await this.connectImap(name, cfg);
|
|
256
418
|
activeClient = client;
|
|
257
419
|
signal?.throwIfAborted();
|
|
258
|
-
await client.connect();
|
|
259
|
-
signal?.throwIfAborted();
|
|
260
420
|
entry = { client, selected: null, selectedReadOnly: true, lastUsed: Date.now(), inUse: 0 };
|
|
261
421
|
this.imaps.set(name, entry);
|
|
262
422
|
}
|
|
@@ -279,7 +439,7 @@ export class EmailPool {
|
|
|
279
439
|
catch (error) {
|
|
280
440
|
await this.evictImap(name);
|
|
281
441
|
signal?.throwIfAborted();
|
|
282
|
-
throw this.normalizeImapError(error, folder);
|
|
442
|
+
throw this.normalizeImapError(error, folder, cfg.authKind);
|
|
283
443
|
}
|
|
284
444
|
finally {
|
|
285
445
|
signal?.removeEventListener('abort', onAbort);
|
|
@@ -287,11 +447,14 @@ export class EmailPool {
|
|
|
287
447
|
entry.inUse = Math.max(0, entry.inUse - 1);
|
|
288
448
|
}
|
|
289
449
|
}
|
|
290
|
-
normalizeImapError(error, folder) {
|
|
450
|
+
normalizeImapError(error, folder, authKind = 'password') {
|
|
291
451
|
const raw = messageOf(error, 'IMAP 操作失败');
|
|
292
452
|
const lower = raw.toLowerCase();
|
|
293
453
|
if (lower.includes('authentication') || lower.includes('login')) {
|
|
294
|
-
|
|
454
|
+
// An OAuth2 account has no 授权码 to check: the only fix is a new login.
|
|
455
|
+
return new MailError(authKind === 'oauth2'
|
|
456
|
+
? OAUTH2_RELOGIN_MESSAGE + '(' + raw + ')'
|
|
457
|
+
: '邮箱登录失败:' + raw + '(请检查 user 与授权码)');
|
|
295
458
|
}
|
|
296
459
|
if (lower.includes('nonselect') || lower.includes('does not exist') || lower.includes('nonexistent')) {
|
|
297
460
|
return new MailError('找不到邮箱文件夹 "' + (folder ?? '') + '":' + raw);
|
|
@@ -333,7 +496,12 @@ export class EmailPool {
|
|
|
333
496
|
transporter.close();
|
|
334
497
|
this.smtps.clear();
|
|
335
498
|
}
|
|
336
|
-
|
|
499
|
+
/**
|
|
500
|
+
* A pooled transporter for one account. The token is captured when the
|
|
501
|
+
* transporter is built; an OAuth2 token that turns out to be stale is
|
|
502
|
+
* re-minted in sendMail, which rebuilds the transporter.
|
|
503
|
+
*/
|
|
504
|
+
transporter(name, cfg, accessToken) {
|
|
337
505
|
let t = this.smtps.get(name);
|
|
338
506
|
if (t === undefined) {
|
|
339
507
|
t = nodemailer.createTransport({
|
|
@@ -341,7 +509,7 @@ export class EmailPool {
|
|
|
341
509
|
host: cfg.smtp.host,
|
|
342
510
|
port: cfg.smtp.port,
|
|
343
511
|
secure: cfg.smtp.secure,
|
|
344
|
-
auth:
|
|
512
|
+
auth: smtpAuthOf(cfg, accessToken),
|
|
345
513
|
connectionTimeout: 30000,
|
|
346
514
|
greetingTimeout: 10000,
|
|
347
515
|
socketTimeout: 60000,
|
|
@@ -352,27 +520,62 @@ export class EmailPool {
|
|
|
352
520
|
}
|
|
353
521
|
return t;
|
|
354
522
|
}
|
|
355
|
-
|
|
523
|
+
dropTransporter(name, transporter) {
|
|
524
|
+
if (this.smtps.get(name) === transporter)
|
|
525
|
+
this.smtps.delete(name);
|
|
526
|
+
transporter.close();
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Send through the pooled transporter while making cancellation close it.
|
|
530
|
+
*
|
|
531
|
+
* An OAuth2 transporter carries a token that was minted when it was built,
|
|
532
|
+
* so a rejection is retried once against a freshly built one (and a fresh
|
|
533
|
+
* form of whatever stored token state exists). Password accounts keep the
|
|
534
|
+
* single attempt they always had.
|
|
535
|
+
*/
|
|
356
536
|
async sendMail(name, cfg, message, signal) {
|
|
357
537
|
signal?.throwIfAborted();
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
538
|
+
const attempt = async (forceToken) => {
|
|
539
|
+
const token = cfg.authKind === 'oauth2' ? await getFreshAccessToken(name, cfg, { force: forceToken }) : undefined;
|
|
540
|
+
const transporter = this.transporter(name, cfg, token);
|
|
541
|
+
const onAbort = () => {
|
|
542
|
+
this.dropTransporter(name, transporter);
|
|
543
|
+
};
|
|
544
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
545
|
+
try {
|
|
546
|
+
const info = await transporter.sendMail(message);
|
|
547
|
+
signal?.throwIfAborted();
|
|
548
|
+
return info;
|
|
549
|
+
}
|
|
550
|
+
finally {
|
|
551
|
+
signal?.removeEventListener('abort', onAbort);
|
|
552
|
+
}
|
|
363
553
|
};
|
|
364
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
365
554
|
try {
|
|
366
|
-
|
|
367
|
-
signal?.throwIfAborted();
|
|
368
|
-
return info;
|
|
555
|
+
return await attempt(false);
|
|
369
556
|
}
|
|
370
557
|
catch (error) {
|
|
371
558
|
signal?.throwIfAborted();
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
559
|
+
if (cfg.authKind !== 'oauth2')
|
|
560
|
+
throw error;
|
|
561
|
+
// A pooled connection that already authenticated can fail for reasons no
|
|
562
|
+
// token can fix (a rejected recipient, a full mailbox). Only a credential
|
|
563
|
+
// rejection is worth a second, freshly-tokened attempt — and a token
|
|
564
|
+
// store that refused outright is reported as itself.
|
|
565
|
+
if (!looksLikeAuthFailure(error))
|
|
566
|
+
throw this.oauth2ErrorOf(error);
|
|
567
|
+
// The cached transporter holds the old token: it has to go, or the retry
|
|
568
|
+
// would reuse the very credential that was just refused.
|
|
569
|
+
const stale = this.smtps.get(name);
|
|
570
|
+
if (stale !== undefined)
|
|
571
|
+
this.dropTransporter(name, stale);
|
|
572
|
+
try {
|
|
573
|
+
return await attempt(true);
|
|
574
|
+
}
|
|
575
|
+
catch (retryError) {
|
|
576
|
+
signal?.throwIfAborted();
|
|
577
|
+
throw this.oauth2ErrorOf(retryError);
|
|
578
|
+
}
|
|
376
579
|
}
|
|
377
580
|
}
|
|
378
581
|
async list(accountName, folder, limit, offset, unreadOnly, since, until, signal) {
|
|
@@ -382,6 +585,8 @@ export class EmailPool {
|
|
|
382
585
|
return this.withImap(name, folderName, async (client) => {
|
|
383
586
|
const mailbox = client.mailbox;
|
|
384
587
|
const total = mailbox === false ? 0 : mailbox.exists;
|
|
588
|
+
// imapflow types uidValidity as number | bigint; the wire format is 32-bit.
|
|
589
|
+
const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
|
|
385
590
|
let scopeCount = total;
|
|
386
591
|
let uids = [];
|
|
387
592
|
const hasDateFilter = since !== undefined || until !== undefined;
|
|
@@ -407,17 +612,21 @@ export class EmailPool {
|
|
|
407
612
|
uids.reverse();
|
|
408
613
|
const window = uids.slice(offset, offset + limit);
|
|
409
614
|
const messages = await this.fetchListed(client, window, signal);
|
|
410
|
-
return { account: name, count: scopeCount, folder: folderName, messages };
|
|
615
|
+
return { account: name, count: scopeCount, folder: folderName, uidValidity, messages };
|
|
411
616
|
}, true, signal);
|
|
412
617
|
}
|
|
413
|
-
async search(accountName, query, folder, limit, since, until, signal) {
|
|
618
|
+
async search(accountName, query, folder, limit, offset, since, until, signal) {
|
|
414
619
|
const name = this.resolveName(accountName);
|
|
415
620
|
const cfg = this.account(name);
|
|
416
621
|
const folderName = folder || cfg.inboxFolder;
|
|
417
622
|
return this.withImap(name, folderName, async (client) => {
|
|
418
623
|
// No nested OR and no TEXT search: several servers (QQ among them)
|
|
419
|
-
// silently answer those with empty
|
|
420
|
-
//
|
|
624
|
+
// silently answer those with empty results, and some answer with a
|
|
625
|
+
// non-empty list that has nothing to do with the query at all (QQ again:
|
|
626
|
+
// an impossible keyword still「matches」every uid in the folder). The
|
|
627
|
+
// server's hit list is therefore a hint, not an answer: confirm it
|
|
628
|
+
// against the envelopes before reporting anything, otherwise scan
|
|
629
|
+
// locally.
|
|
421
630
|
const dateRange = {};
|
|
422
631
|
if (since !== undefined)
|
|
423
632
|
dateRange.since = since;
|
|
@@ -430,20 +639,50 @@ export class EmailPool {
|
|
|
430
639
|
client.search({ cc: query, ...dateRange }, { uid: true }),
|
|
431
640
|
]);
|
|
432
641
|
signal?.throwIfAborted();
|
|
433
|
-
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) =>
|
|
434
|
-
uids.
|
|
435
|
-
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
642
|
+
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => b - a);
|
|
643
|
+
if (uids.length > 0) {
|
|
644
|
+
// The sample has to cover the requested page (offset + limit) — the same
|
|
645
|
+
// window the fallback scan looks at — so one FETCH serves both the
|
|
646
|
+
// verification and the rows that are handed out.
|
|
647
|
+
const confirmed = await this.searchHits(client, uids, query, offset + limit, signal);
|
|
648
|
+
if (confirmed.length > 0) {
|
|
649
|
+
// The server's list holds up, so its size is reported as the match
|
|
650
|
+
// count; only rows that were confirmed are ever handed out.
|
|
651
|
+
return { account: name, query, count: uids.length, folder: folderName, offset, messages: confirmed.slice(offset, offset + limit) };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// Nothing believable came back (empty answer, or hits that did not
|
|
655
|
+
// survive verification): scan the newest messages locally instead.
|
|
656
|
+
if (this.settings.bodySearchFallback) {
|
|
657
|
+
const messages = await this.searchBodies(client, query, folderName, limit, offset, since, until, signal);
|
|
658
|
+
return { account: name, query, count: messages.length, folder: folderName, offset, messages };
|
|
659
|
+
}
|
|
660
|
+
return { account: name, query, count: 0, folder: folderName, offset, messages: [] };
|
|
443
661
|
}, true, signal);
|
|
444
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* Confirm server-side hits against the mailbox itself: fetch the envelopes
|
|
665
|
+
* of the newest candidates — the same window the body-scan fallback looks at
|
|
666
|
+
* — and keep only those that really carry the query in subject/from/to/cc,
|
|
667
|
+
* the four fields the server was asked about. No body is downloaded here,
|
|
668
|
+
* and uids the server made up simply return nothing.
|
|
669
|
+
*/
|
|
670
|
+
async searchHits(client, uids, query, need, signal) {
|
|
671
|
+
const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, need)));
|
|
672
|
+
signal?.throwIfAborted();
|
|
673
|
+
const fetched = await client.fetchAll(sample, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
|
|
674
|
+
signal?.throwIfAborted();
|
|
675
|
+
return fetched
|
|
676
|
+
.filter(message => {
|
|
677
|
+
const envelope = message.envelope;
|
|
678
|
+
const addressText = [envelope?.from, envelope?.to, envelope?.cc].map(flattenAddressText).join(' ');
|
|
679
|
+
return messageMatchesQuery(envelope?.subject ?? '', addressText, '', query);
|
|
680
|
+
})
|
|
681
|
+
.map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)))
|
|
682
|
+
.sort((a, b) => b.uid - a.uid);
|
|
683
|
+
}
|
|
445
684
|
/** Client-side scan of the tail of the mailbox, newest first. */
|
|
446
|
-
async searchBodies(client, query, folder, limit, since, until, signal) {
|
|
685
|
+
async searchBodies(client, query, folder, limit, offset, since, until, signal) {
|
|
447
686
|
signal?.throwIfAborted();
|
|
448
687
|
const mailbox = client.mailbox;
|
|
449
688
|
const total = mailbox === false ? 0 : mailbox.exists;
|
|
@@ -454,7 +693,7 @@ export class EmailPool {
|
|
|
454
693
|
const out = [];
|
|
455
694
|
for (const message of [...fetched].reverse()) {
|
|
456
695
|
signal?.throwIfAborted();
|
|
457
|
-
if (out.length >= limit)
|
|
696
|
+
if (out.length >= offset + limit)
|
|
458
697
|
break;
|
|
459
698
|
const receivedAt = message.internalDate ?? message.envelope?.date;
|
|
460
699
|
if (since !== undefined && (receivedAt === undefined || receivedAt < since))
|
|
@@ -480,7 +719,7 @@ export class EmailPool {
|
|
|
480
719
|
out.push(listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
|
|
481
720
|
}
|
|
482
721
|
}
|
|
483
|
-
return out;
|
|
722
|
+
return out.slice(offset, offset + limit);
|
|
484
723
|
}
|
|
485
724
|
async fetchListed(client, uids, signal) {
|
|
486
725
|
signal?.throwIfAborted();
|
|
@@ -497,12 +736,13 @@ export class EmailPool {
|
|
|
497
736
|
const cfg = this.account(name);
|
|
498
737
|
const folderName = folder || cfg.inboxFolder;
|
|
499
738
|
return this.withImap(name, folderName, async (client) => {
|
|
500
|
-
const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
|
|
739
|
+
const message = await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
|
|
501
740
|
if (message === false || message.source === undefined) {
|
|
502
741
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
503
742
|
}
|
|
504
743
|
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
505
744
|
signal?.throwIfAborted();
|
|
745
|
+
this.rememberRead(name, folderName, uid, { attachments: body.attachments, parts: collectAttachmentParts(message.bodyStructure) });
|
|
506
746
|
return { account: name, uid, folder: folderName, ...body };
|
|
507
747
|
}, true, signal);
|
|
508
748
|
}
|
|
@@ -565,17 +805,23 @@ export class EmailPool {
|
|
|
565
805
|
async folders(accountName, subscribedOnly, signal) {
|
|
566
806
|
const name = this.resolveName(accountName);
|
|
567
807
|
return this.withImap(name, null, async (client) => {
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
808
|
+
const cached = this.folderCache.get(name);
|
|
809
|
+
let rows;
|
|
810
|
+
if (cached !== undefined && Date.now() - cached.at < FOLDER_CACHE_TTL_MS) {
|
|
811
|
+
rows = cached.folders;
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
const list = await client.list();
|
|
815
|
+
signal?.throwIfAborted();
|
|
816
|
+
rows = list.map(row => ({
|
|
817
|
+
name: row.name ?? row.path,
|
|
818
|
+
path: row.path,
|
|
819
|
+
specialUse: row.specialUse ?? '',
|
|
820
|
+
subscribed: row.subscribed !== false,
|
|
821
|
+
}));
|
|
822
|
+
this.folderCache.set(name, { at: Date.now(), folders: rows });
|
|
823
|
+
}
|
|
824
|
+
return { account: name, folders: rows.filter(row => !subscribedOnly || row.subscribed !== false) };
|
|
579
825
|
}, true, signal);
|
|
580
826
|
}
|
|
581
827
|
async downloadAttachment(accountName, folder, uid, index, workspaceHint, signal) {
|
|
@@ -583,23 +829,19 @@ export class EmailPool {
|
|
|
583
829
|
const cfg = this.account(name);
|
|
584
830
|
const folderName = folder || cfg.inboxFolder;
|
|
585
831
|
return this.withImap(name, folderName, async (client) => {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
593
|
-
signal?.throwIfAborted();
|
|
594
|
-
const parts = collectAttachmentParts(message.bodyStructure);
|
|
595
|
-
if (body.attachments.length === 0)
|
|
832
|
+
// The mailparser list is authoritative for the index email_read showed and
|
|
833
|
+
// the bodyStructure walk supplies the IMAP part to download. email_read
|
|
834
|
+
// already produced both in the usual read-then-download flow, so reuse that
|
|
835
|
+
// instead of pulling the whole message — attachments included — again.
|
|
836
|
+
const { attachments, parts } = await this.attachmentIndexOf(client, name, folderName, uid, signal);
|
|
837
|
+
if (attachments.length === 0)
|
|
596
838
|
throw new MailError('该邮件没有附件');
|
|
597
|
-
if (
|
|
598
|
-
throw new MailError('附件序号 ' + index + ' 越界:共 ' +
|
|
839
|
+
if (attachments[index] === undefined) {
|
|
840
|
+
throw new MailError('附件序号 ' + index + ' 越界:共 ' + attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
|
|
599
841
|
}
|
|
600
|
-
const att = selectAttachmentPart(
|
|
842
|
+
const att = selectAttachmentPart(attachments, parts, index);
|
|
601
843
|
if (att === undefined) {
|
|
602
|
-
throw new MailError('附件 #' + index + '(' +
|
|
844
|
+
throw new MailError('附件 #' + index + '(' + attachments[index].filename + ')无法在邮件结构中定位(可能是内嵌图片,暂不支持下载)');
|
|
603
845
|
}
|
|
604
846
|
if (att.size > this.settings.maxAttachmentBytes) {
|
|
605
847
|
throw new MailError('附件 "' + att.filename + '" 大小 ' + att.size + ' 字节,超过上限 maxAttachmentBytes=' + this.settings.maxAttachmentBytes);
|
|
@@ -607,7 +849,7 @@ export class EmailPool {
|
|
|
607
849
|
const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
|
|
608
850
|
signal?.throwIfAborted();
|
|
609
851
|
const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes, signal);
|
|
610
|
-
const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ??
|
|
852
|
+
const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? attachments[index].filename);
|
|
611
853
|
// Default the destination to the session workspace so the model can
|
|
612
854
|
// read the file back; an explicit downloadDir always wins.
|
|
613
855
|
const dir = this.settings.downloadDirExplicit
|
|
@@ -628,7 +870,7 @@ export class EmailPool {
|
|
|
628
870
|
const cfg = this.account(name);
|
|
629
871
|
const attachments = await validateAttachmentPaths(attachmentPaths ?? [], this.settings.maxAttachmentBytes, signal);
|
|
630
872
|
const info = await this.sendMail(name, cfg, {
|
|
631
|
-
from: cfg
|
|
873
|
+
from: senderOf(cfg),
|
|
632
874
|
to,
|
|
633
875
|
cc,
|
|
634
876
|
subject,
|
|
@@ -657,10 +899,13 @@ export class EmailPool {
|
|
|
657
899
|
const ids = extractMessageIds(message.source);
|
|
658
900
|
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
659
901
|
signal?.throwIfAborted();
|
|
660
|
-
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,
|
|
902
|
+
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,
|
|
903
|
+
// Both the visible address and the login are "me": a reply-all that
|
|
904
|
+
// keeps either of them would mail the sender his own message.
|
|
905
|
+
cfg.authUser === cfg.user ? cfg.user : [cfg.user, cfg.authUser], text, forwardTo);
|
|
661
906
|
}, true, signal);
|
|
662
907
|
const info = await this.sendMail(name, cfg, {
|
|
663
|
-
from: cfg
|
|
908
|
+
from: senderOf(cfg),
|
|
664
909
|
to: built.to,
|
|
665
910
|
cc,
|
|
666
911
|
subject: built.subject,
|