dsh-email 0.10.0 → 0.10.2

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 CHANGED
@@ -42,7 +42,7 @@ Example:
42
42
 
43
43
  ## Compatibility
44
44
 
45
- Verified against `@deepseek-ai/dsh@0.1.2-alpha.3` on 2026-09-01 (settings page / asset route / email_watch fully regression-tested). Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
45
+ Verified against source-run `@deepseek-ai/dsh@0.1.2-alpha.4` on 2026-09-02 (69 tests plus a Web-profile startup smoke test). Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
46
46
 
47
47
  ## Installation
48
48
 
package/README.md CHANGED
@@ -30,6 +30,7 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
30
30
  | `email_attachment` | 按序号下载邮件附件(默认存到会话工作区,模型可直接读取;大小受 maxAttachmentBytes 限制) |
31
31
  | `email_watch` | 增量检查新邮件:首次调用建立基线,之后每次只报告比上次多出来的未读邮件,适合定时任务做新邮件提醒 |
32
32
  | `email_mark` | 修改邮件状态:标记已读/未读、加/取消星标,或移动到别的文件夹(归档、丢回收站),收发闭环的「收完之后」那一半 |
33
+ | `email_reply` | 回复/回复全部/转发已有邮件:自动带上 In-Reply-To/References 线程头与原文引文,收件人自动排除自己,主题不重复叠 Re:/Fwd:;同样走发信审批门 |
33
34
 
34
35
  ### 新邮件提醒(Web 端)
35
36
 
@@ -43,7 +44,8 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
43
44
 
44
45
  ### 版本记录
45
46
 
46
- - **0.10.0**:新增 `email_mark` 工具——标记已读/未读、加/取消星标、移动文件夹(归档/整理),补齐收发闭环;连接池按读/写模式分别管理邮箱打开状态。
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`(回复/回复全部/转发,自动线程头+引文,走发信审批门);连接池按读/写模式分别管理邮箱打开状态。
47
49
  - **0.9.1**:修复设置页空主机遮蔽 provider 预设(#3/#6);IMAP 连接超时不再杀死整个 DSH 进程(#4);暗色模式输入控件可见(#2);密码栏提示环境变量 `DSH_EMAIL_PASSWORD` 免明文方案(#5)。
48
50
  - **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
49
51
  - **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
@@ -53,7 +55,7 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
53
55
 
54
56
  ## 兼容性
55
57
 
56
- 在 `@deepseek-ai/dsh@0.1.2-alpha.3` 上实测通过(2026-09-01,设置页/素材路由/email_watch 全回归)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
58
+ 在 `@deepseek-ai/dsh@0.1.2-alpha.4` 源码模式下实测通过(2026-09-02,69 项测试及 Web profile 启动冒烟)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
57
59
 
58
60
  ## 安装
59
61
 
@@ -192,4 +194,4 @@ MIT。这是一个社区插件,与 DeepSeek 官方无关;`@deepseek-ai/*`
192
194
 
193
195
  - [dsh-slack](https://github.com/STARDUSTLC666/dsh-slack) — Slack 通知/收件箱
194
196
  - [dsh-dingtalk](https://github.com/STARDUSTLC666/dsh-dingtalk) — 钉钉群通知(零依赖)
195
- - [dsh-email](https://github.com/STARDUSTLC666/dsh-email) — 邮件八件套 + Web 设置页 + 新邮件弹窗
197
+ - [dsh-email](https://github.com/STARDUSTLC666/dsh-email) — 邮件八件套 + Web 设置页 + 新邮件弹窗
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
@@ -152,6 +152,7 @@ const markSchema = {
152
152
  additionalProperties: true,
153
153
  };
154
154
  const MARK_ACTIONS = ['read', 'unread', 'star', 'unstar', 'move'];
155
+ const REPLY_MODES = ['reply', 'reply-all', 'forward'];
155
156
  function oneText(text) {
156
157
  return [{ type: 'text', text }];
157
158
  }
@@ -226,6 +227,15 @@ function renderMark(value) {
226
227
  }
227
228
  return oneText(text);
228
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
+ }
229
239
  function fingerprintSettings(settings) {
230
240
  return JSON.stringify({
231
241
  accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
@@ -403,6 +413,54 @@ export function apply(ctx, config = {}) {
403
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));
404
414
  },
405
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
+ });
406
464
  ctx.tools.register({
407
465
  name: 'email_folders',
408
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.',
@@ -548,11 +606,12 @@ export function apply(ctx, config = {}) {
548
606
  return await watchCore(args.account?.trim() || '', args.folder?.trim() || '', limit, 'tool');
549
607
  },
550
608
  });
551
- // Approval gate: the user must confirm every send (recipient + subject).
552
- // Runs before other listeners; degrades to the tool-time failure only
553
- // 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.
554
613
  ctx.on('tools/pre-execute', async (exec, next) => {
555
- if (exec?.name !== 'email_send')
614
+ if (exec?.name !== 'email_send' && exec?.name !== 'email_reply')
556
615
  return next();
557
616
  const descriptor = (ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
558
617
  const userSection = descriptor?.user;
@@ -565,9 +624,18 @@ export function apply(ctx, config = {}) {
565
624
  catch {
566
625
  return next(); // unconfigured: let the tool report the actionable hint
567
626
  }
568
- const args = (exec.args ?? {});
569
- const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
570
- const reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
627
+ let reason;
628
+ if (exec.name === 'email_send') {
629
+ const args = (exec.arguments ?? {});
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.arguments ?? {});
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
+ }
571
639
  // Gate-owned approval: we run the approval round-trip ourselves so the
572
640
  // denial reason is always honest and actionable — including the Full
573
641
  // Access case where the harness policy answers 'rejected' without ever
@@ -601,6 +669,6 @@ export function apply(ctx, config = {}) {
601
669
  export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
602
670
  export { resolveEmailConfig, resolveEmailSettings, parseAccountsYaml, clampInt, defaultDownloadDir } from './config.js';
603
671
  export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
604
- 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';
605
673
  export { SETTINGS_NAMESPACE, EmailSettingsSchema, toSettingsBase, toEmailConfig, validateSettingsValue } from './settings.js';
606
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, EmailMarkAction, EmailMarkResult, 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.
@@ -54,6 +85,7 @@ export declare class EmailPool {
54
85
  folders(accountName: string | undefined, subscribedOnly: boolean): Promise<EmailFoldersResult>;
55
86
  downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number, workspaceHint?: string): Promise<EmailAttachmentResult>;
56
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>;
57
89
  }
58
90
  /** Stat every attachment path up front; total size must stay under the cap. */
59
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 ?? ''))
@@ -483,6 +569,42 @@ export class EmailPool {
483
569
  response: info.response,
484
570
  };
485
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
+ }
486
608
  }
487
609
  /** Stat every attachment path up front; total size must stay under the cap. */
488
610
  export async function validateAttachmentPaths(paths, maxBytes) {
package/lib/types.d.ts CHANGED
@@ -144,6 +144,29 @@ export interface EmailMarkResult {
144
144
  /** Set after a successful move: uid in the destination when the server reports it. */
145
145
  movedUid?: number;
146
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
+ }
147
170
  export interface EmailWatchResult {
148
171
  account: string;
149
172
  folder: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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",