dsh-email 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +137 -8
- package/lib/mail-client.d.ts +35 -2
- package/lib/mail-client.js +180 -6
- package/lib/types.d.ts +43 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
|
|
|
29
29
|
| `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
|
|
30
30
|
| `email_attachment` | 按序号下载邮件附件(默认存到会话工作区,模型可直接读取;大小受 maxAttachmentBytes 限制) |
|
|
31
31
|
| `email_watch` | 增量检查新邮件:首次调用建立基线,之后每次只报告比上次多出来的未读邮件,适合定时任务做新邮件提醒 |
|
|
32
|
+
| `email_mark` | 修改邮件状态:标记已读/未读、加/取消星标,或移动到别的文件夹(归档、丢回收站),收发闭环的「收完之后」那一半 |
|
|
33
|
+
| `email_reply` | 回复/回复全部/转发已有邮件:自动带上 In-Reply-To/References 线程头与原文引文,收件人自动排除自己,主题不重复叠 Re:/Fwd:;同样走发信审批门 |
|
|
32
34
|
|
|
33
35
|
### 新邮件提醒(Web 端)
|
|
34
36
|
|
|
@@ -42,6 +44,8 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
|
|
|
42
44
|
|
|
43
45
|
### 版本记录
|
|
44
46
|
|
|
47
|
+
- **0.10.1**:补发制品——已发布的 0.10.0 打包时只含 `email_mark`,本版同时包含 `email_mark` 与 `email_reply`,代码与 0.10.0 的 main 一致。
|
|
48
|
+
- **0.10.0**:新增 `email_mark`(已读/未读/星标/移动文件夹,补齐收发闭环的整理侧)与 `email_reply`(回复/回复全部/转发,自动线程头+引文,走发信审批门);连接池按读/写模式分别管理邮箱打开状态。
|
|
45
49
|
- **0.9.1**:修复设置页空主机遮蔽 provider 预设(#3/#6);IMAP 连接超时不再杀死整个 DSH 进程(#4);暗色模式输入控件可见(#2);密码栏提示环境变量 `DSH_EMAIL_PASSWORD` 免明文方案(#5)。
|
|
46
50
|
- **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
|
|
47
51
|
- **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
|
package/lib/index.d.ts
CHANGED
|
@@ -11,6 +11,6 @@ export declare function apply(ctx: any, config?: Config): void;
|
|
|
11
11
|
export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
|
|
12
12
|
export { resolveEmailConfig, resolveEmailSettings, parseAccountsYaml, clampInt, defaultDownloadDir } from './config.js';
|
|
13
13
|
export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
|
|
14
|
-
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart, messageMatchesQuery } from './mail-client.js';
|
|
14
|
+
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart, messageMatchesQuery, buildReplyMessage, extractMessageIds } from './mail-client.js';
|
|
15
15
|
export { SETTINGS_NAMESPACE, EmailSettingsSchema, toSettingsBase, toEmailConfig, validateSettingsValue } from './settings.js';
|
|
16
16
|
export { SETTINGS_ROUTE, EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
|
package/lib/index.js
CHANGED
|
@@ -137,6 +137,22 @@ const attachmentSchema = {
|
|
|
137
137
|
},
|
|
138
138
|
additionalProperties: true,
|
|
139
139
|
};
|
|
140
|
+
const markSchema = {
|
|
141
|
+
type: 'object',
|
|
142
|
+
properties: {
|
|
143
|
+
account: { type: 'string' },
|
|
144
|
+
uid: { type: 'integer' },
|
|
145
|
+
folder: { type: 'string' },
|
|
146
|
+
action: { type: 'string' },
|
|
147
|
+
seen: { type: 'boolean' },
|
|
148
|
+
flagged: { type: 'boolean' },
|
|
149
|
+
movedTo: { type: 'string' },
|
|
150
|
+
movedUid: { type: 'integer' },
|
|
151
|
+
},
|
|
152
|
+
additionalProperties: true,
|
|
153
|
+
};
|
|
154
|
+
const MARK_ACTIONS = ['read', 'unread', 'star', 'unstar', 'move'];
|
|
155
|
+
const REPLY_MODES = ['reply', 'reply-all', 'forward'];
|
|
140
156
|
function oneText(text) {
|
|
141
157
|
return [{ type: 'text', text }];
|
|
142
158
|
}
|
|
@@ -194,6 +210,32 @@ function renderFolders(value) {
|
|
|
194
210
|
function renderAttachment(value) {
|
|
195
211
|
return oneText('账号 ' + value.account + ' 已下载附件 "' + value.filename + '"(' + value.contentType + ',' + value.size + ' 字节)到:\n' + value.path + '\n可用 read 工具读取该文件。');
|
|
196
212
|
}
|
|
213
|
+
const MARK_LABELS = {
|
|
214
|
+
read: '标记为已读',
|
|
215
|
+
unread: '标记为未读',
|
|
216
|
+
star: '加星标',
|
|
217
|
+
unstar: '取消星标',
|
|
218
|
+
move: '移动',
|
|
219
|
+
};
|
|
220
|
+
function renderMark(value) {
|
|
221
|
+
let text = '账号 ' + value.account + ':文件夹 "' + value.folder + '" 中 uid=' + value.uid + ' 已' + (MARK_LABELS[value.action] ?? value.action);
|
|
222
|
+
if (value.action === 'move') {
|
|
223
|
+
text += '到 "' + (value.movedTo ?? '') + '"' + (typeof value.movedUid === 'number' ? '(新 uid=' + value.movedUid + ')' : '');
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
text += '(当前:' + (value.seen ? '已读' : '未读') + (value.flagged ? '、已标星' : '') + ')';
|
|
227
|
+
}
|
|
228
|
+
return oneText(text);
|
|
229
|
+
}
|
|
230
|
+
const REPLY_LABELS = {
|
|
231
|
+
reply: '回复',
|
|
232
|
+
'reply-all': '回复全部',
|
|
233
|
+
forward: '转发',
|
|
234
|
+
};
|
|
235
|
+
function renderReply(value) {
|
|
236
|
+
const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
|
|
237
|
+
return oneText('账号 ' + value.account + ' 已' + REPLY_LABELS[value.mode] + ' uid=' + value.originalUid + ' 的邮件:收件人 ' + value.to.join(', ') + ',主题「' + value.subject + '」,messageId: ' + value.messageId + rejected);
|
|
238
|
+
}
|
|
197
239
|
function fingerprintSettings(settings) {
|
|
198
240
|
return JSON.stringify({
|
|
199
241
|
accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
|
|
@@ -293,6 +335,35 @@ export function apply(ctx, config = {}) {
|
|
|
293
335
|
return await getPool().read(args.account, args.uid, args.folder?.trim() || '');
|
|
294
336
|
},
|
|
295
337
|
});
|
|
338
|
+
ctx.tools.register({
|
|
339
|
+
name: 'email_mark',
|
|
340
|
+
description: 'Change an existing message: mark it read/unread, star/unstar it, or move it to another folder. Use after email_list/email_search when the user wants to tidy the mailbox (archive, clear unread, flag important mail). Moving uses the server MOVE/COPY so the uid changes; the new uid is reported when the server provides it.',
|
|
341
|
+
parameters: compileParameters({
|
|
342
|
+
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
343
|
+
action: { type: 'string', required: true, description: 'What to do: read, unread, star, unstar, or move' },
|
|
344
|
+
toFolder: { type: 'string', description: 'Destination folder path for action=move (see email_folders for valid paths)' },
|
|
345
|
+
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
346
|
+
account: { type: 'string', description: ACCOUNT_HINT },
|
|
347
|
+
}),
|
|
348
|
+
output: {
|
|
349
|
+
schema: markSchema,
|
|
350
|
+
render: (_args, value) => renderMark(value),
|
|
351
|
+
},
|
|
352
|
+
async execute(rawArgs) {
|
|
353
|
+
const args = rawArgs;
|
|
354
|
+
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
355
|
+
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
356
|
+
}
|
|
357
|
+
const action = (typeof args.action === 'string' ? args.action.trim().toLowerCase() : '');
|
|
358
|
+
if (!MARK_ACTIONS.includes(action)) {
|
|
359
|
+
throw new Error('action 必须是 ' + MARK_ACTIONS.join('、') + ' 之一');
|
|
360
|
+
}
|
|
361
|
+
if (action === 'move' && (typeof args.toFolder !== 'string' || args.toFolder.trim() === '')) {
|
|
362
|
+
throw new Error('action=move 时需要 toFolder 参数(用 email_folders 查看可用文件夹)');
|
|
363
|
+
}
|
|
364
|
+
return await getPool().mark(args.account, args.folder?.trim() || '', args.uid, action, args.toFolder);
|
|
365
|
+
},
|
|
366
|
+
});
|
|
296
367
|
ctx.tools.register({
|
|
297
368
|
name: 'email_search',
|
|
298
369
|
description: 'Search emails by a keyword matched against sender, recipient and subject (server-side IMAP SEARCH). Body search is not supported by every server and is not attempted; returns the same compact rows as email_list.',
|
|
@@ -342,6 +413,54 @@ export function apply(ctx, config = {}) {
|
|
|
342
413
|
return await getPool().send(args.account, args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined, normalizeAttachmentPaths(args.attachments));
|
|
343
414
|
},
|
|
344
415
|
});
|
|
416
|
+
ctx.tools.register({
|
|
417
|
+
name: 'email_reply',
|
|
418
|
+
description: 'Reply to, reply-all to, or forward an existing message (mode: reply | reply-all | forward). Recipients come from the original message (your own address is excluded automatically), the subject gets a single Re:/Fwd: prefix, the original text is quoted underneath, and In-Reply-To/References headers keep mail clients threading correctly. mode=forward needs the to parameter. Like email_send, this asks the user for approval before sending. Never invent recipients or content without the user\'s instruction.',
|
|
419
|
+
parameters: compileParameters({
|
|
420
|
+
uid: { type: 'integer', required: true, description: 'Message uid to answer/forward, from email_list or email_search' },
|
|
421
|
+
text: { type: 'string', required: true, description: 'The new text to write; the original is quoted below it automatically' },
|
|
422
|
+
mode: { type: 'string', description: 'reply (default), reply-all, or forward' },
|
|
423
|
+
to: { type: 'string', description: 'Recipient(s) for mode=forward, comma-separated' },
|
|
424
|
+
cc: { type: 'string', description: 'Extra CC recipient(s), comma-separated (optional)' },
|
|
425
|
+
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
|
|
426
|
+
account: { type: 'string', description: ACCOUNT_HINT },
|
|
427
|
+
}),
|
|
428
|
+
output: {
|
|
429
|
+
schema: {
|
|
430
|
+
type: 'object',
|
|
431
|
+
properties: {
|
|
432
|
+
account: { type: 'string' },
|
|
433
|
+
mode: { type: 'string' },
|
|
434
|
+
originalUid: { type: 'integer' },
|
|
435
|
+
messageId: { type: 'string' },
|
|
436
|
+
accepted: strArray,
|
|
437
|
+
rejected: strArray,
|
|
438
|
+
response: { type: 'string' },
|
|
439
|
+
to: strArray,
|
|
440
|
+
subject: { type: 'string' },
|
|
441
|
+
},
|
|
442
|
+
additionalProperties: true,
|
|
443
|
+
},
|
|
444
|
+
render: (_args, value) => renderReply(value),
|
|
445
|
+
},
|
|
446
|
+
async execute(rawArgs) {
|
|
447
|
+
const args = rawArgs;
|
|
448
|
+
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
449
|
+
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
450
|
+
}
|
|
451
|
+
if (typeof args.text !== 'string' || args.text.trim() === '')
|
|
452
|
+
throw new Error('text 不能为空');
|
|
453
|
+
const mode = ((typeof args.mode === 'string' && args.mode.trim() !== '' ? args.mode.trim().toLowerCase() : 'reply'));
|
|
454
|
+
if (!REPLY_MODES.includes(mode)) {
|
|
455
|
+
throw new Error('mode 必须是 ' + REPLY_MODES.join('、') + ' 之一');
|
|
456
|
+
}
|
|
457
|
+
if (mode === 'forward' && (typeof args.to !== 'string' || args.to.trim() === '')) {
|
|
458
|
+
throw new Error('mode=forward 时需要 to 参数指定转发收件人');
|
|
459
|
+
}
|
|
460
|
+
const cc = typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined;
|
|
461
|
+
return await getPool().reply(args.account, args.folder?.trim() || '', args.uid, mode, args.text, args.to?.trim() ?? '', cc);
|
|
462
|
+
},
|
|
463
|
+
});
|
|
345
464
|
ctx.tools.register({
|
|
346
465
|
name: 'email_folders',
|
|
347
466
|
description: 'List the mailbox folders of an account (INBOX, Sent, Trash, custom folders, ...). Use the returned path values as the folder argument of the other email tools.',
|
|
@@ -487,11 +606,12 @@ export function apply(ctx, config = {}) {
|
|
|
487
606
|
return await watchCore(args.account?.trim() || '', args.folder?.trim() || '', limit, 'tool');
|
|
488
607
|
},
|
|
489
608
|
});
|
|
490
|
-
// Approval gate: the user must confirm every
|
|
491
|
-
//
|
|
492
|
-
//
|
|
609
|
+
// Approval gate: the user must confirm every outgoing message
|
|
610
|
+
// (email_send: recipient + subject; email_reply: target + subject when
|
|
611
|
+
// known). Runs before other listeners; degrades to the tool-time failure
|
|
612
|
+
// only when the account itself is not configured yet.
|
|
493
613
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
494
|
-
if (exec?.name !== 'email_send')
|
|
614
|
+
if (exec?.name !== 'email_send' && exec?.name !== 'email_reply')
|
|
495
615
|
return next();
|
|
496
616
|
const descriptor = (ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
|
|
497
617
|
const userSection = descriptor?.user;
|
|
@@ -504,9 +624,18 @@ export function apply(ctx, config = {}) {
|
|
|
504
624
|
catch {
|
|
505
625
|
return next(); // unconfigured: let the tool report the actionable hint
|
|
506
626
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
627
|
+
let reason;
|
|
628
|
+
if (exec.name === 'email_send') {
|
|
629
|
+
const args = (exec.args ?? {});
|
|
630
|
+
const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
|
|
631
|
+
reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
const args = (exec.args ?? {});
|
|
635
|
+
const mode = typeof args.mode === 'string' && args.mode.trim() !== '' ? args.mode.trim().toLowerCase() : 'reply';
|
|
636
|
+
const modeLabel = mode === 'forward' ? '转发' : mode === 'reply-all' ? '回复全部' : '回复';
|
|
637
|
+
reason = modeLabel + '邮件(原邮件 uid=' + args.uid + ')' + (mode === 'forward' && typeof args.to === 'string' && args.to.trim() !== '' ? ',收件人 ' + args.to : '');
|
|
638
|
+
}
|
|
510
639
|
// Gate-owned approval: we run the approval round-trip ourselves so the
|
|
511
640
|
// denial reason is always honest and actionable — including the Full
|
|
512
641
|
// Access case where the harness policy answers 'rejected' without ever
|
|
@@ -540,6 +669,6 @@ export function apply(ctx, config = {}) {
|
|
|
540
669
|
export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
|
|
541
670
|
export { resolveEmailConfig, resolveEmailSettings, parseAccountsYaml, clampInt, defaultDownloadDir } from './config.js';
|
|
542
671
|
export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
|
|
543
|
-
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart, messageMatchesQuery } from './mail-client.js';
|
|
672
|
+
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart, messageMatchesQuery, buildReplyMessage, extractMessageIds } from './mail-client.js';
|
|
544
673
|
export { SETTINGS_NAMESPACE, EmailSettingsSchema, toSettingsBase, toEmailConfig, validateSettingsValue } from './settings.js';
|
|
545
674
|
export { SETTINGS_ROUTE, EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
|
package/lib/mail-client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ImapFlow } from 'imapflow';
|
|
2
2
|
import type { ResolvedEmailConfig, ResolvedEmailSettings } from './config.js';
|
|
3
|
-
import type { EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailReadResult, EmailSearchResult, EmailSendResult } from './types.js';
|
|
3
|
+
import type { AddressEntry, EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailMarkAction, EmailMarkResult, EmailReadResult, EmailReplyMode, EmailReplyResult, EmailSearchResult, EmailSendResult } from './types.js';
|
|
4
4
|
export declare class MailError extends Error {
|
|
5
5
|
constructor(message: string);
|
|
6
6
|
}
|
|
@@ -20,6 +20,37 @@ interface AttachmentPart {
|
|
|
20
20
|
export declare function selectAttachmentPart(readAttachments: EmailAttachmentMeta[], parts: AttachmentPart[], index: number): AttachmentPart | undefined;
|
|
21
21
|
/** Case-insensitive match of a query against subject/from/body text. */
|
|
22
22
|
export declare function messageMatchesQuery(subject: string, fromText: string, body: string, query: string): boolean;
|
|
23
|
+
export interface OriginalDigest {
|
|
24
|
+
from: AddressEntry[];
|
|
25
|
+
to: AddressEntry[];
|
|
26
|
+
cc: AddressEntry[];
|
|
27
|
+
subject: string;
|
|
28
|
+
date: string;
|
|
29
|
+
text: string;
|
|
30
|
+
/** Bare id without angle brackets, '' when absent. */
|
|
31
|
+
messageId: string;
|
|
32
|
+
/** Space-joined bare ids from the References header, '' when absent. */
|
|
33
|
+
references: string;
|
|
34
|
+
}
|
|
35
|
+
export interface BuiltReply {
|
|
36
|
+
to: string;
|
|
37
|
+
cc?: string;
|
|
38
|
+
subject: string;
|
|
39
|
+
text: string;
|
|
40
|
+
inReplyTo?: string;
|
|
41
|
+
references?: string;
|
|
42
|
+
}
|
|
43
|
+
/** Pull Message-ID / References out of a raw RFC822 source (header section only). */
|
|
44
|
+
export declare function extractMessageIds(source: Buffer): {
|
|
45
|
+
messageId: string;
|
|
46
|
+
references: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Compose the outgoing message for a reply/reply-all/forward. Pure so it can
|
|
50
|
+
* be tested without a connection: recipients exclude the sending account,
|
|
51
|
+
* subject prefixes never stack, the original text is quoted underneath.
|
|
52
|
+
*/
|
|
53
|
+
export declare function buildReplyMessage(original: OriginalDigest, mode: EmailReplyMode, selfAddress: string, text: string, forwardTo?: string): BuiltReply;
|
|
23
54
|
/**
|
|
24
55
|
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
25
56
|
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
@@ -35,7 +66,7 @@ export declare class EmailPool {
|
|
|
35
66
|
resolveName(name?: string): string;
|
|
36
67
|
/** Serialize operations per account: one IMAP connection serves one op at a time. */
|
|
37
68
|
private enqueue;
|
|
38
|
-
withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T
|
|
69
|
+
withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>, readOnly?: boolean): Promise<T>;
|
|
39
70
|
private createImap;
|
|
40
71
|
private imapRun;
|
|
41
72
|
private normalizeImapError;
|
|
@@ -50,9 +81,11 @@ export declare class EmailPool {
|
|
|
50
81
|
private searchBodies;
|
|
51
82
|
private fetchListed;
|
|
52
83
|
read(accountName: string | undefined, uid: number, folder: string): Promise<EmailReadResult>;
|
|
84
|
+
mark(accountName: string | undefined, folder: string, uid: number, action: EmailMarkAction, toFolder?: string): Promise<EmailMarkResult>;
|
|
53
85
|
folders(accountName: string | undefined, subscribedOnly: boolean): Promise<EmailFoldersResult>;
|
|
54
86
|
downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number, workspaceHint?: string): Promise<EmailAttachmentResult>;
|
|
55
87
|
send(accountName: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, attachmentPaths: string[] | undefined): Promise<EmailSendResult>;
|
|
88
|
+
reply(accountName: string | undefined, folder: string, uid: number, mode: EmailReplyMode, text: string, forwardTo: string, cc: string | undefined): Promise<EmailReplyResult>;
|
|
56
89
|
}
|
|
57
90
|
/** Stat every attachment path up front; total size must stay under the cap. */
|
|
58
91
|
export declare function validateAttachmentPaths(paths: string[], maxBytes: number): Promise<Array<{
|
package/lib/mail-client.js
CHANGED
|
@@ -64,6 +64,92 @@ export function messageMatchesQuery(subject, fromText, body, query) {
|
|
|
64
64
|
|| fromText.toLowerCase().includes(q)
|
|
65
65
|
|| body.toLowerCase().includes(q);
|
|
66
66
|
}
|
|
67
|
+
/** Pull Message-ID / References out of a raw RFC822 source (header section only). */
|
|
68
|
+
export function extractMessageIds(source) {
|
|
69
|
+
const headerEnd = source.indexOf('\r\n\r\n');
|
|
70
|
+
const head = source.slice(0, headerEnd === -1 ? Math.min(source.length, 32768) : headerEnd).toString('latin1');
|
|
71
|
+
const idMatch = head.match(/^message-id:\s*<([^>]+)>/im);
|
|
72
|
+
// References can fold across continuation lines; collect every <id> token up to the next header.
|
|
73
|
+
const refBlock = head.match(/^references:((?:[^\r\n]|\r?\n[ \t])*)/im);
|
|
74
|
+
const refs = refBlock === null ? [] : [...refBlock[1].matchAll(/<([^>]+)>/g)].map(m => m[1]);
|
|
75
|
+
return { messageId: idMatch === null ? '' : idMatch[1], references: refs.join(' ') };
|
|
76
|
+
}
|
|
77
|
+
function formatAddress(entry) {
|
|
78
|
+
if (entry.address === undefined)
|
|
79
|
+
return entry.name ?? '';
|
|
80
|
+
return entry.name !== undefined && entry.name !== '' ? entry.name + ' <' + entry.address + '>' : entry.address;
|
|
81
|
+
}
|
|
82
|
+
function dedupeAddresses(entries, exclude) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const addr = (entry.address ?? '').toLowerCase();
|
|
87
|
+
if (addr === '' || addr === exclude || seen.has(addr))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(addr);
|
|
90
|
+
out.push(entry);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function stripReplyPrefix(subject, prefix) {
|
|
95
|
+
return subject.replace(new RegExp('^(?:' + prefix.source + '\\s*)+', 'i'), '').trim();
|
|
96
|
+
}
|
|
97
|
+
const QUOTE_MAX_CHARS = 2000;
|
|
98
|
+
const FORWARD_MAX_CHARS = 4000;
|
|
99
|
+
/**
|
|
100
|
+
* Compose the outgoing message for a reply/reply-all/forward. Pure so it can
|
|
101
|
+
* be tested without a connection: recipients exclude the sending account,
|
|
102
|
+
* subject prefixes never stack, the original text is quoted underneath.
|
|
103
|
+
*/
|
|
104
|
+
export function buildReplyMessage(original, mode, selfAddress, text, forwardTo = '') {
|
|
105
|
+
const fromText = original.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知发件人)';
|
|
106
|
+
const self = selfAddress.toLowerCase();
|
|
107
|
+
if (mode === 'forward') {
|
|
108
|
+
const to = forwardTo.trim();
|
|
109
|
+
if (to === '')
|
|
110
|
+
throw new MailError('forward 模式需要 to 参数指定转发收件人');
|
|
111
|
+
const fwdBody = original.text.length > FORWARD_MAX_CHARS
|
|
112
|
+
? original.text.slice(0, FORWARD_MAX_CHARS) + '\n…[原文过长,已截断]'
|
|
113
|
+
: original.text;
|
|
114
|
+
const header = '---------- 转发的邮件 ----------\n发件人: ' + fromText
|
|
115
|
+
+ (original.date !== '' ? '\n时间: ' + original.date : '')
|
|
116
|
+
+ '\n主题: ' + (original.subject || '(无主题)')
|
|
117
|
+
+ (original.to.length > 0 ? '\n收件人: ' + original.to.map(a => a.address).filter(Boolean).join(', ') : '');
|
|
118
|
+
return {
|
|
119
|
+
to,
|
|
120
|
+
subject: 'Fwd: ' + stripReplyPrefix(original.subject, /fwd:|fw:|re:/),
|
|
121
|
+
text: text + '\n\n' + header + '\n\n' + fwdBody,
|
|
122
|
+
...(original.messageId !== '' ? { references: (original.references !== '' ? original.references + ' ' : '') + original.messageId } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
let recipients;
|
|
126
|
+
if (mode === 'reply-all') {
|
|
127
|
+
recipients = dedupeAddresses([...original.from, ...original.to, ...original.cc], self);
|
|
128
|
+
if (recipients.length === 0)
|
|
129
|
+
recipients = dedupeAddresses(original.from, '');
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
recipients = dedupeAddresses(original.from, '');
|
|
133
|
+
}
|
|
134
|
+
if (recipients.length === 0) {
|
|
135
|
+
throw new MailError('原邮件没有可用的发件人地址,无法回复;可用 email_send 手动发送');
|
|
136
|
+
}
|
|
137
|
+
const quoteText = original.text.length > QUOTE_MAX_CHARS
|
|
138
|
+
? original.text.slice(0, QUOTE_MAX_CHARS) + '\n…[原文过长,已截断]'
|
|
139
|
+
: original.text;
|
|
140
|
+
const quote = '在 ' + (original.date || '未知时间') + ',' + fromText + ' 写道:\n'
|
|
141
|
+
+ quoteText.split('\n').map(line => '> ' + line).join('\n');
|
|
142
|
+
const built = {
|
|
143
|
+
to: recipients.map(formatAddress).join(', '),
|
|
144
|
+
subject: 'Re: ' + stripReplyPrefix(original.subject, /re:/),
|
|
145
|
+
text: text + '\n\n' + quote,
|
|
146
|
+
};
|
|
147
|
+
if (original.messageId !== '') {
|
|
148
|
+
built.inReplyTo = original.messageId;
|
|
149
|
+
built.references = (original.references !== '' ? original.references + ' ' : '') + original.messageId;
|
|
150
|
+
}
|
|
151
|
+
return built;
|
|
152
|
+
}
|
|
67
153
|
function flattenAddressText(value) {
|
|
68
154
|
return flattenAddresses(value)
|
|
69
155
|
.map(a => (a.name ?? '') + ' ' + (a.address ?? ''))
|
|
@@ -114,10 +200,10 @@ export class EmailPool {
|
|
|
114
200
|
this.queues.set(name, next.then(() => undefined, () => undefined));
|
|
115
201
|
return next;
|
|
116
202
|
}
|
|
117
|
-
async withImap(accountName, folder, run) {
|
|
203
|
+
async withImap(accountName, folder, run, readOnly = true) {
|
|
118
204
|
const name = this.resolveName(accountName);
|
|
119
205
|
const cfg = this.account(name);
|
|
120
|
-
return this.enqueue(name, () => this.imapRun(name, cfg, folder, run));
|
|
206
|
+
return this.enqueue(name, () => this.imapRun(name, cfg, folder, readOnly, run));
|
|
121
207
|
}
|
|
122
208
|
createImap(cfg) {
|
|
123
209
|
const client = new ImapFlow({
|
|
@@ -144,7 +230,7 @@ export class EmailPool {
|
|
|
144
230
|
});
|
|
145
231
|
return client;
|
|
146
232
|
}
|
|
147
|
-
async imapRun(name, cfg, folder, run) {
|
|
233
|
+
async imapRun(name, cfg, folder, readOnly, run) {
|
|
148
234
|
let entry = this.imaps.get(name);
|
|
149
235
|
try {
|
|
150
236
|
if (entry === undefined || !entry.client.usable) {
|
|
@@ -152,14 +238,17 @@ export class EmailPool {
|
|
|
152
238
|
await this.evictImap(name);
|
|
153
239
|
const client = this.createImap(cfg);
|
|
154
240
|
await client.connect();
|
|
155
|
-
entry = { client, selected: null, lastUsed: Date.now(), inUse: 0 };
|
|
241
|
+
entry = { client, selected: null, selectedReadOnly: true, lastUsed: Date.now(), inUse: 0 };
|
|
156
242
|
this.imaps.set(name, entry);
|
|
157
243
|
}
|
|
158
244
|
entry.lastUsed = Date.now();
|
|
159
245
|
entry.inUse += 1;
|
|
160
|
-
|
|
161
|
-
|
|
246
|
+
// Reopen when the folder changes or when the caller needs a different
|
|
247
|
+
// access mode (email_mark writes flags / moves messages).
|
|
248
|
+
if (folder !== null && (entry.selected !== folder || entry.selectedReadOnly !== readOnly)) {
|
|
249
|
+
await entry.client.mailboxOpen(folder, { readOnly });
|
|
162
250
|
entry.selected = folder;
|
|
251
|
+
entry.selectedReadOnly = readOnly;
|
|
163
252
|
}
|
|
164
253
|
const result = await run(entry.client);
|
|
165
254
|
entry.lastUsed = Date.now();
|
|
@@ -355,6 +444,55 @@ export class EmailPool {
|
|
|
355
444
|
return { account: name, uid, folder: folderName, ...body };
|
|
356
445
|
});
|
|
357
446
|
}
|
|
447
|
+
async mark(accountName, folder, uid, action, toFolder) {
|
|
448
|
+
const name = this.resolveName(accountName);
|
|
449
|
+
const cfg = this.account(name);
|
|
450
|
+
const folderName = folder || cfg.inboxFolder;
|
|
451
|
+
return this.withImap(name, folderName, async (client) => {
|
|
452
|
+
const before = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
|
|
453
|
+
if (before === false) {
|
|
454
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
455
|
+
}
|
|
456
|
+
let seen = before.flags?.has('\\Seen') === true;
|
|
457
|
+
let flagged = before.flags?.has('\\Flagged') === true;
|
|
458
|
+
if (action === 'read' && !seen) {
|
|
459
|
+
await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
|
|
460
|
+
seen = true;
|
|
461
|
+
}
|
|
462
|
+
else if (action === 'unread' && seen) {
|
|
463
|
+
await client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
|
|
464
|
+
seen = false;
|
|
465
|
+
}
|
|
466
|
+
else if (action === 'star' && !flagged) {
|
|
467
|
+
await client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
|
|
468
|
+
flagged = true;
|
|
469
|
+
}
|
|
470
|
+
else if (action === 'unstar' && flagged) {
|
|
471
|
+
await client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
|
|
472
|
+
flagged = false;
|
|
473
|
+
}
|
|
474
|
+
else if (action === 'move') {
|
|
475
|
+
const target = (toFolder ?? '').trim();
|
|
476
|
+
if (target === '')
|
|
477
|
+
throw new MailError('move 操作需要 toFolder 参数(用 email_folders 查看可用文件夹)');
|
|
478
|
+
if (target === folderName)
|
|
479
|
+
throw new MailError('邮件已在文件夹 "' + folderName + '" 中,无需移动');
|
|
480
|
+
const folders = await client.list();
|
|
481
|
+
if (!folders.some(row => row.path === target)) {
|
|
482
|
+
throw new MailError('找不到目标文件夹 "' + target + '",可用:' + folders.map(row => row.path).join('、'));
|
|
483
|
+
}
|
|
484
|
+
const moved = await client.messageMove(uid, target, { uid: true });
|
|
485
|
+
if (moved === false)
|
|
486
|
+
throw new MailError('移动 uid=' + uid + ' 到 "' + target + '" 失败(服务器拒绝了 MOVE/COPY)');
|
|
487
|
+
const result = { account: name, uid, folder: folderName, action, seen, flagged, movedTo: target };
|
|
488
|
+
const destUid = moved?.destinationUid;
|
|
489
|
+
if (typeof destUid === 'number')
|
|
490
|
+
result.movedUid = destUid;
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
return { account: name, uid, folder: folderName, action, seen, flagged };
|
|
494
|
+
}, false);
|
|
495
|
+
}
|
|
358
496
|
async folders(accountName, subscribedOnly) {
|
|
359
497
|
const name = this.resolveName(accountName);
|
|
360
498
|
return this.withImap(name, null, async (client) => {
|
|
@@ -431,6 +569,42 @@ export class EmailPool {
|
|
|
431
569
|
response: info.response,
|
|
432
570
|
};
|
|
433
571
|
}
|
|
572
|
+
async reply(accountName, folder, uid, mode, text, forwardTo, cc) {
|
|
573
|
+
const name = this.resolveName(accountName);
|
|
574
|
+
const cfg = this.account(name);
|
|
575
|
+
const folderName = folder || cfg.inboxFolder;
|
|
576
|
+
// Read the original first (read-only), send second: a failed compose never
|
|
577
|
+
// leaves a half-written mailbox state behind.
|
|
578
|
+
const built = await this.withImap(name, folderName, async (client) => {
|
|
579
|
+
const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
|
|
580
|
+
if (message === false || message.source === undefined) {
|
|
581
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
582
|
+
}
|
|
583
|
+
const ids = extractMessageIds(message.source);
|
|
584
|
+
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
585
|
+
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, cfg.user, text, forwardTo);
|
|
586
|
+
});
|
|
587
|
+
const info = await this.transporter(name, cfg).sendMail({
|
|
588
|
+
from: cfg.user,
|
|
589
|
+
to: built.to,
|
|
590
|
+
cc,
|
|
591
|
+
subject: built.subject,
|
|
592
|
+
text: built.text,
|
|
593
|
+
...(built.inReplyTo !== undefined ? { inReplyTo: '<' + built.inReplyTo + '>' } : {}),
|
|
594
|
+
...(built.references !== undefined ? { references: built.references.split(' ').map(id => '<' + id + '>') } : {}),
|
|
595
|
+
});
|
|
596
|
+
return {
|
|
597
|
+
account: name,
|
|
598
|
+
mode,
|
|
599
|
+
originalUid: uid,
|
|
600
|
+
messageId: info.messageId,
|
|
601
|
+
accepted: info.accepted.map(String),
|
|
602
|
+
rejected: info.rejected.map(String),
|
|
603
|
+
response: info.response,
|
|
604
|
+
to: built.to.split(',').map(part => part.trim()).filter(part => part !== ''),
|
|
605
|
+
subject: built.subject,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
434
608
|
}
|
|
435
609
|
/** Stat every attachment path up front; total size must stay under the cap. */
|
|
436
610
|
export async function validateAttachmentPaths(paths, maxBytes) {
|
package/lib/types.d.ts
CHANGED
|
@@ -124,6 +124,49 @@ export interface EmailWatchArgs extends AccountArg {
|
|
|
124
124
|
/** Max number of new messages to return per call, default 20. */
|
|
125
125
|
limit?: number;
|
|
126
126
|
}
|
|
127
|
+
export type EmailMarkAction = 'read' | 'unread' | 'star' | 'unstar' | 'move';
|
|
128
|
+
export interface EmailMarkArgs extends AccountArg {
|
|
129
|
+
uid: number;
|
|
130
|
+
action: EmailMarkAction;
|
|
131
|
+
/** Target folder path; only used (and required) when action is 'move'. */
|
|
132
|
+
toFolder?: string;
|
|
133
|
+
folder?: string;
|
|
134
|
+
}
|
|
135
|
+
export interface EmailMarkResult {
|
|
136
|
+
account: string;
|
|
137
|
+
uid: number;
|
|
138
|
+
folder: string;
|
|
139
|
+
action: EmailMarkAction;
|
|
140
|
+
seen: boolean;
|
|
141
|
+
flagged: boolean;
|
|
142
|
+
/** Set after a successful move: the destination folder path. */
|
|
143
|
+
movedTo?: string;
|
|
144
|
+
/** Set after a successful move: uid in the destination when the server reports it. */
|
|
145
|
+
movedUid?: number;
|
|
146
|
+
}
|
|
147
|
+
export type EmailReplyMode = 'reply' | 'reply-all' | 'forward';
|
|
148
|
+
export interface EmailReplyArgs extends AccountArg {
|
|
149
|
+
uid: number;
|
|
150
|
+
/** The new text to write; the original is quoted below it automatically. */
|
|
151
|
+
text: string;
|
|
152
|
+
mode?: EmailReplyMode;
|
|
153
|
+
/** Recipient(s) for mode=forward, comma-separated. */
|
|
154
|
+
to?: string;
|
|
155
|
+
cc?: string;
|
|
156
|
+
folder?: string;
|
|
157
|
+
}
|
|
158
|
+
export interface EmailReplyResult {
|
|
159
|
+
account: string;
|
|
160
|
+
mode: EmailReplyMode;
|
|
161
|
+
/** uid of the original message that was answered/forwarded. */
|
|
162
|
+
originalUid: number;
|
|
163
|
+
messageId: string;
|
|
164
|
+
accepted: string[];
|
|
165
|
+
rejected: string[];
|
|
166
|
+
response: string;
|
|
167
|
+
to: string[];
|
|
168
|
+
subject: string;
|
|
169
|
+
}
|
|
127
170
|
export interface EmailWatchResult {
|
|
128
171
|
account: string;
|
|
129
172
|
folder: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "IMAP/SMTP email tools for DeepSeek Harness: list, read, search and send mail, with QQ/163/126/Sina/Aliyun/Gmail/Outlook/iCloud presets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|