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 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 send (recipient + subject).
491
- // Runs before other listeners; degrades to the tool-time failure only
492
- // when the account itself is not configured yet.
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
- const args = (exec.args ?? {});
508
- const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
509
- const reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
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';
@@ -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>): 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<{
@@ -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
- if (folder !== null && entry.selected !== folder) {
161
- await entry.client.mailboxOpen(folder, { readOnly: true });
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.9.1",
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",