dsh-email 0.1.0 → 0.2.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.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # dsh-email
2
2
 
3
- DeepSeek Harness 邮件工具插件:让 agent 能**查收件箱、读邮件、搜邮件、代发邮件**。纯插件实现,零核心改动,安装即可用。
3
+ DeepSeek Harness 邮件工具插件:让 agent 能**查收件箱、读邮件、搜邮件、代发邮件、收发附件**。纯插件实现,零核心改动,安装即可用。
4
4
 
5
- Email tools for DeepSeek Harness: list, read, search and send mail through standard IMAP/SMTP — with one-line presets for QQ / 163 / 126 / Sina / Aliyun / Gmail / Outlook / iCloud.
5
+ Email tools for DeepSeek Harness: list, read, search and send mail through standard IMAP/SMTP — with one-line presets for QQ / 163 / 126 / Sina / Aliyun / Gmail / Outlook / iCloud.
6
+
7
+ 纯 Node 实现,**全平台通用**(Windows / macOS / Linux 同一份代码),不依赖 shell、无原生二进制。
6
8
 
7
9
  ## 工具一览
8
10
 
@@ -11,7 +13,9 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
11
13
  | `email_list` | 列出文件夹里最新的邮件(未读过滤、分页、只看摘要不带正文) |
12
14
  | `email_read` | 按 uid 读取一封邮件的全文(HTML 邮件自动转纯文本,超长截断) |
13
15
  | `email_search` | 按关键词搜索发件人/收件人/主题(服务器端 IMAP SEARCH,不搜正文) |
14
- | `email_send` | 代发邮件。**默认发信前会弹确认**,显示收件人和主题,由你批准后才发出 |
16
+ | `email_send` | 代发邮件(支持带附件)。**默认发信前会弹确认**,显示收件人、主题和附件数,由你批准后才发出 |
17
+ | `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
18
+ | `email_attachment` | 按序号下载邮件附件到本地文件(大小受 maxAttachmentBytes 限制) |
15
19
 
16
20
  示例对话:
17
21
 
@@ -51,6 +55,20 @@ dsh plugin --profile web add dsh-email
51
55
  inboxFolder: INBOX
52
56
  ```
53
57
 
58
+ 多账号:一个 `tool-email` 行可以配多个邮箱,工具调用时用 `account` 参数选择:
59
+
60
+ ```yaml
61
+ - id: tool-email
62
+ config:
63
+ accounts:
64
+ work: { provider: qq, user: work@qq.com, password: 授权码1 }
65
+ home: { provider: '163', user: home@163.com, password: 授权码2 }
66
+ defaultAccount: work # 省略 account 参数时用这个
67
+ downloadDir: E:/attachments # 可选,默认 $DSH_HOME/email-downloads
68
+ ```
69
+
70
+ 顶层的 `provider`/`user`/`password`/`imap`/`smtp`/`inboxFolder` 仍然可用,作为各账号的共享默认值(v0.1 单账号写法完全兼容)。
71
+
54
72
  ### 常用邮箱预设
55
73
 
56
74
  | provider | IMAP | SMTP |
@@ -75,7 +93,12 @@ dsh plugin --profile web add dsh-email
75
93
  | `smtp.host/port/secure` | 按预设 | 发信服务器 |
76
94
  | `inboxFolder` | `INBOX` | 收发工具默认使用的文件夹 |
77
95
  | `sendApproval` | `true` | 发信前弹确认(强烈建议保留) |
78
- | `maxBodyChars` | `20000` | email_read 正文截断上限(1000–200000) |
96
+ | `maxBodyChars` | `20000` | email_read 正文截断上限(1000–200000) |
97
+ | `accounts` | 无 | 具名账号表;账号级字段覆盖顶层简写 |
98
+ | `defaultAccount` | 单账号时自动 | 工具省略 account 参数时使用的账号(多账号必填) |
99
+ | `downloadDir` | `$DSH_HOME/email-downloads` | email_attachment 的落盘目录 |
100
+ | `maxAttachmentBytes` | 20 MiB | 单个附件与附件总大小上限(1024–512 MiB) |
101
+ | `idleTimeoutMs` | `60000` | IMAP 空闲连接回收时间(连接复用,连续操作更快) |
79
102
 
80
103
  ## 第一步:拿到授权码
81
104
 
@@ -93,20 +116,20 @@ dsh plugin --profile web add dsh-email
93
116
  - 注意:会话处于 **Full Access(完全访问)** 模式时,harness 会把审批策略置为 never,`email_send` 会被**静默拒绝**(不弹框)。想发信请把访问模式切回 Read Only 或 Write。
94
117
  - 本插件不做任何联网上报,凭证只在内存中用于连接你的邮箱服务器。
95
118
 
96
- ## 已知限制(v0.1
119
+ ## 已知限制(v0.2
97
120
 
98
- - **每个工具调用独立建立/关闭连接**:正确、无状态,但连续读多封会比常驻连接慢一点。
99
- - **单账号**:一个 `tool-email` 行对应一个邮箱;多账号可复制多行(改 id 即可)。
100
- - **附件只给元数据**(文件名/类型/大小),不下载内容;下载附件列入后续版本。
121
+ - **连接复用**:IMAP 按账号池化(空闲自动回收),SMTP 用 nodemailer 连接池;同一账号的并发调用会排队串行(一个连接一次只服务一个操作,这是有意的)。
122
+ - **多账号**:每个账号独立连接池;一个 `tool-email` 行可以配任意多个账号。
123
+ - **附件下载**:email_attachment 按序号下载(与 email_read 的 attachments 顺序一致);文件名会被清洗防路径穿越,已有同名文件自动加后缀,大小受 maxAttachmentBytes 限制。
101
124
  - **不支持 OAuth2**:强制 OAuth 的企业环境(部分 M365/Google Workspace)暂不可用。
102
- - 正文搜索不提供:多数服务器(如 QQ)的 IMAP `TEXT`/`HEADER` 搜索要么全量匹配要么不支持,所以 v0.1 只搜主题/发件人/收件人;正文搜索列入后续版本(需客户端下载解析,较慢)。
125
+ - 正文搜索不提供:多数服务器(如 QQ)的 IMAP `TEXT`/`HEADER` 搜索要么全量匹配要么不支持,所以只搜主题/发件人/收件人;正文搜索列入后续版本(需客户端下载解析,较慢)。
103
126
 
104
127
  ## 开发
105
128
 
106
129
  ```sh
107
130
  pnpm install
108
131
  pnpm run build # tsc → lib/
109
- pnpm test # 构建 + node --test(配置/解析/注册与审批门,19 个用例,无需真实邮箱)
132
+ pnpm test # 构建 + node --test(配置/解析/注册与审批门,27 个用例,无需真实邮箱)
110
133
  ```
111
134
 
112
135
  ## 协议
package/lib/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type ProviderName = 'qq' | '163' | '126' | 'sina' | 'aliyun' | 'gmail' | 'outlook' | 'icloud';
1
2
  export interface ImapConfig {
2
3
  host?: string;
3
4
  port?: number;
@@ -10,21 +11,30 @@ export interface SmtpConfig {
10
11
  port?: number;
11
12
  secure?: boolean;
12
13
  }
13
- export interface EmailConfig {
14
- /** Built-in preset that fills imap/smtp host+port+secure. */
15
- provider?: 'qq' | '163' | '126' | 'sina' | 'aliyun' | 'gmail' | 'outlook' | 'icloud';
16
- /** Login address, e.g. you@qq.com. */
14
+ /** One mailbox account. Top-level shorthand fields act as shared defaults. */
15
+ export interface AccountConfig {
16
+ provider?: ProviderName;
17
17
  user?: string;
18
- /** App password / authorization code. Falls back to $DSH_EMAIL_PASSWORD. */
19
18
  password?: string;
20
19
  imap?: ImapConfig;
21
20
  smtp?: SmtpConfig;
22
- /** Mailbox used by the read/search/list tools. Default 'INBOX'. */
23
21
  inboxFolder?: string;
22
+ }
23
+ export interface EmailConfig extends AccountConfig {
24
24
  /** Ask the user for approval before email_send. Default true. */
25
25
  sendApproval?: boolean;
26
26
  /** Plain-text body cap for email_read. Default 20000. */
27
27
  maxBodyChars?: number;
28
+ /** Named accounts. Account-level fields override the top-level shorthand. */
29
+ accounts?: Record<string, AccountConfig>;
30
+ /** Which account tools use when the call omits account. Required with 2+ accounts. */
31
+ defaultAccount?: string;
32
+ /** Directory email_attachment writes into. Default $DSH_HOME/email-downloads. */
33
+ downloadDir?: string;
34
+ /** Per-attachment and total-attachment byte cap. Default 20 MiB. */
35
+ maxAttachmentBytes?: number;
36
+ /** Unused IMAP connections close after this many ms. Default 60000. */
37
+ idleTimeoutMs?: number;
28
38
  }
29
39
  export interface ProviderPreset {
30
40
  imap: {
@@ -41,7 +51,7 @@ export interface ProviderPreset {
41
51
  export declare const PROVIDER_PRESETS: Record<string, ProviderPreset>;
42
52
  export declare const PROVIDER_NAMES: string[];
43
53
  export declare const EMAIL_PASSWORD_ENV = "DSH_EMAIL_PASSWORD";
44
- /** Fully resolved, validated configuration. */
54
+ /** Fully resolved, validated configuration for one account. */
45
55
  export interface ResolvedEmailConfig {
46
56
  user: string;
47
57
  password: string;
@@ -56,14 +66,24 @@ export interface ResolvedEmailConfig {
56
66
  secure: boolean;
57
67
  };
58
68
  inboxFolder: string;
69
+ }
70
+ /** Fully resolved plugin settings: the account map plus shared policy. */
71
+ export interface ResolvedEmailSettings {
72
+ accounts: Map<string, ResolvedEmailConfig>;
73
+ defaultAccount: string;
59
74
  sendApproval: boolean;
60
75
  maxBodyChars: number;
61
- providerName: string | null;
76
+ downloadDir: string;
77
+ maxAttachmentBytes: number;
78
+ idleTimeoutMs: number;
62
79
  }
80
+ export declare function defaultDownloadDir(): string;
63
81
  /**
64
82
  * Resolve and validate the raw row config. Throws with an actionable message
65
83
  * (in Chinese, since it is what the user and the model both read) when the
66
84
  * account is not fully specified.
67
85
  */
86
+ export declare function resolveEmailSettings(config: EmailConfig | undefined): ResolvedEmailSettings;
87
+ /** v0.1-compatible wrapper: resolve the single (or default) account. */
68
88
  export declare function resolveEmailConfig(config: EmailConfig | undefined): ResolvedEmailConfig;
69
89
  export declare function clampInt(value: unknown, fallback: number, min: number, max: number): number;
package/lib/config.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
1
3
  export const PROVIDER_PRESETS = {
2
4
  qq: { imap: { host: 'imap.qq.com', port: 993, secure: true }, smtp: { host: 'smtp.qq.com', port: 465, secure: true } },
3
5
  '163': { imap: { host: 'imap.163.com', port: 993, secure: true }, smtp: { host: 'smtp.163.com', port: 465, secure: true } },
@@ -10,40 +12,94 @@ export const PROVIDER_PRESETS = {
10
12
  };
11
13
  export const PROVIDER_NAMES = Object.keys(PROVIDER_PRESETS);
12
14
  export const EMAIL_PASSWORD_ENV = 'DSH_EMAIL_PASSWORD';
15
+ const DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
16
+ const DEFAULT_IDLE_TIMEOUT_MS = 60000;
17
+ export function defaultDownloadDir() {
18
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
19
+ return join(home, 'email-downloads');
20
+ }
13
21
  /**
14
22
  * Resolve and validate the raw row config. Throws with an actionable message
15
23
  * (in Chinese, since it is what the user and the model both read) when the
16
24
  * account is not fully specified.
17
25
  */
18
- export function resolveEmailConfig(config) {
26
+ export function resolveEmailSettings(config) {
19
27
  const raw = config ?? {};
20
- const preset = raw.provider === undefined ? undefined : PROVIDER_PRESETS[raw.provider];
21
- if (raw.provider !== undefined && preset === undefined) {
22
- throw new Error(`dsh-email:未知的邮箱服务商 "${raw.provider}",可选:${PROVIDER_NAMES.join('/')};或省略 provider,直接填写 imap.host 与 smtp.host`);
28
+ const common = {
29
+ provider: raw.provider,
30
+ user: raw.user,
31
+ password: raw.password,
32
+ imap: raw.imap,
33
+ smtp: raw.smtp,
34
+ inboxFolder: raw.inboxFolder,
35
+ };
36
+ const entries = raw.accounts === undefined || Object.keys(raw.accounts).length === 0
37
+ ? undefined
38
+ : raw.accounts;
39
+ const accounts = new Map();
40
+ if (entries === undefined) {
41
+ accounts.set('default', resolveAccount('default', common, {}, true));
42
+ }
43
+ else {
44
+ for (const [name, acc] of Object.entries(entries)) {
45
+ accounts.set(name, resolveAccount(name, common, acc ?? {}, false));
46
+ }
47
+ }
48
+ let defaultName;
49
+ if (raw.defaultAccount !== undefined && raw.defaultAccount !== '') {
50
+ if (!accounts.has(raw.defaultAccount)) {
51
+ throw new Error(`dsh-email:defaultAccount "${raw.defaultAccount}" 不存在,可用账号:${[...accounts.keys()].join('、')}`);
52
+ }
53
+ defaultName = raw.defaultAccount;
54
+ }
55
+ else if (accounts.size === 1) {
56
+ defaultName = [...accounts.keys()][0];
57
+ }
58
+ else if (accounts.has('default')) {
59
+ defaultName = 'default';
60
+ }
61
+ else {
62
+ throw new Error(`dsh-email:配置了多个账号(${[...accounts.keys()].join('、')}),请设置 defaultAccount 指定默认账号`);
63
+ }
64
+ return {
65
+ accounts,
66
+ defaultAccount: defaultName,
67
+ sendApproval: raw.sendApproval !== false,
68
+ maxBodyChars: clampInt(raw.maxBodyChars, 20000, 1000, 200000),
69
+ downloadDir: raw.downloadDir?.trim() || defaultDownloadDir(),
70
+ maxAttachmentBytes: clampInt(raw.maxAttachmentBytes, DEFAULT_MAX_ATTACHMENT_BYTES, 1024, 512 * 1024 * 1024),
71
+ idleTimeoutMs: clampInt(raw.idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS, 5000, 600000),
72
+ };
73
+ }
74
+ /** Merge one account over the shared shorthand and validate it. */
75
+ function resolveAccount(name, common, acc, allowEnvPassword) {
76
+ const preset = acc.provider === undefined ? PROVIDER_PRESETS[common.provider ?? ''] : PROVIDER_PRESETS[acc.provider];
77
+ if ((acc.provider ?? common.provider) !== undefined && preset === undefined) {
78
+ throw new Error(`dsh-email:账号 "${name}" 的 provider "${acc.provider ?? common.provider}" 未知,可选:${PROVIDER_NAMES.join('/')};或省略 provider 直接填 imap.host 与 smtp.host`);
23
79
  }
24
- const user = raw.user?.trim() ?? '';
25
- const password = raw.password ?? process.env[EMAIL_PASSWORD_ENV] ?? '';
80
+ const user = (acc.user ?? common.user ?? '').trim();
81
+ const password = acc.password ?? common.password ?? (allowEnvPassword ? process.env[EMAIL_PASSWORD_ENV] ?? '' : '');
26
82
  const imap = {
27
- host: raw.imap?.host ?? preset?.imap.host,
28
- port: raw.imap?.port ?? preset?.imap.port,
29
- secure: raw.imap?.secure ?? preset?.imap.secure,
30
- connectionTimeoutMs: raw.imap?.connectionTimeoutMs,
31
- socketTimeoutMs: raw.imap?.socketTimeoutMs,
83
+ host: acc.imap?.host ?? common.imap?.host ?? preset?.imap.host,
84
+ port: acc.imap?.port ?? common.imap?.port ?? preset?.imap.port,
85
+ secure: acc.imap?.secure ?? common.imap?.secure ?? preset?.imap.secure,
86
+ connectionTimeoutMs: acc.imap?.connectionTimeoutMs ?? common.imap?.connectionTimeoutMs,
87
+ socketTimeoutMs: acc.imap?.socketTimeoutMs ?? common.imap?.socketTimeoutMs,
32
88
  };
33
89
  const smtp = {
34
- host: raw.smtp?.host ?? preset?.smtp.host,
35
- port: raw.smtp?.port ?? preset?.smtp.port,
36
- secure: raw.smtp?.secure ?? preset?.smtp.secure,
90
+ host: acc.smtp?.host ?? common.smtp?.host ?? preset?.smtp.host,
91
+ port: acc.smtp?.port ?? common.smtp?.port ?? preset?.smtp.port,
92
+ secure: acc.smtp?.secure ?? common.smtp?.secure ?? preset?.smtp.secure,
37
93
  };
38
94
  const problems = [];
39
95
  if (user === '')
40
- problems.push('user(邮箱地址)未填写');
96
+ problems.push(`账号 "${name}" 的 user(邮箱地址)未填写`);
41
97
  if (password === '')
42
- problems.push(`password 未填写(或用环境变量 ${EMAIL_PASSWORD_ENV})`);
98
+ problems.push(`账号 "${name}" 的 password 未填写(单账号可用环境变量 ${EMAIL_PASSWORD_ENV})`);
43
99
  if (imap.host === undefined || imap.host === '')
44
- problems.push(`imap.host 未填写(可填 provider 预设:${PROVIDER_NAMES.join('/')})`);
100
+ problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${PROVIDER_NAMES.join('/')})`);
45
101
  if (smtp.host === undefined || smtp.host === '')
46
- problems.push('smtp.host 未填写(同上)');
102
+ problems.push(`账号 "${name}" 的 smtp.host 未填写(同上)`);
47
103
  if (problems.length > 0) {
48
104
  throw new Error(`dsh-email 未配置:${problems.join(';')}。请在 profile 的 cordis.patch.yml 中覆盖 tool-email 行并重启(见插件 README)`);
49
105
  }
@@ -52,12 +108,14 @@ export function resolveEmailConfig(config) {
52
108
  password,
53
109
  imap: { ...imap, host: imap.host, port: imap.port, secure: imap.secure },
54
110
  smtp: { ...smtp, host: smtp.host, port: smtp.port, secure: smtp.secure },
55
- inboxFolder: raw.inboxFolder?.trim() || 'INBOX',
56
- sendApproval: raw.sendApproval !== false,
57
- maxBodyChars: clampInt(raw.maxBodyChars, 20000, 1000, 200000),
58
- providerName: raw.provider ?? null,
111
+ inboxFolder: (acc.inboxFolder ?? common.inboxFolder ?? '').trim() || 'INBOX',
59
112
  };
60
113
  }
114
+ /** v0.1-compatible wrapper: resolve the single (or default) account. */
115
+ export function resolveEmailConfig(config) {
116
+ const settings = resolveEmailSettings(config);
117
+ return settings.accounts.get(settings.defaultAccount);
118
+ }
61
119
  export function clampInt(value, fallback, min, max) {
62
120
  const n = typeof value === 'number' ? Math.trunc(value) : fallback;
63
121
  if (!Number.isFinite(n))
package/lib/index.d.ts CHANGED
@@ -4,6 +4,6 @@ export declare const inject: string[];
4
4
  export type Config = EmailConfig;
5
5
  export declare function apply(ctx: any, config?: Config): void;
6
6
  export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
7
- export { resolveEmailConfig, clampInt } from './config.js';
8
- export { stripHtml, truncateText, flattenAddresses, parseRawMessage } from './parse.js';
9
- export { MailClient, MailError, messageOf } from './mail-client.js';
7
+ export { resolveEmailConfig, resolveEmailSettings, clampInt, defaultDownloadDir } from './config.js';
8
+ export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
9
+ export { EmailPool, MailError, messageOf, validateAttachmentPaths } from './mail-client.js';