dsh-email 0.1.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/LICENSE +21 -0
- package/README.md +114 -0
- package/cordis.patch.yml +26 -0
- package/lib/config.d.ts +69 -0
- package/lib/config.js +66 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +216 -0
- package/lib/mail-client.d.ts +20 -0
- package/lib/mail-client.js +170 -0
- package/lib/parse.d.ts +10 -0
- package/lib/parse.js +66 -0
- package/lib/types.d.ts +75 -0
- package/lib/types.js +1 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-email contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# dsh-email
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness 邮件工具插件:让 agent 能**查收件箱、读邮件、搜邮件、代发邮件**。纯插件实现,零核心改动,安装即可用。
|
|
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.
|
|
6
|
+
|
|
7
|
+
## 工具一览
|
|
8
|
+
|
|
9
|
+
| 工具 | 作用 |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `email_list` | 列出文件夹里最新的邮件(未读过滤、分页、只看摘要不带正文) |
|
|
12
|
+
| `email_read` | 按 uid 读取一封邮件的全文(HTML 邮件自动转纯文本,超长截断) |
|
|
13
|
+
| `email_search` | 按关键词搜索发件人/收件人/主题(服务器端 IMAP SEARCH,不搜正文) |
|
|
14
|
+
| `email_send` | 代发邮件。**默认发信前会弹确认**,显示收件人和主题,由你批准后才发出 |
|
|
15
|
+
|
|
16
|
+
示例对话:
|
|
17
|
+
|
|
18
|
+
> 帮我看下 QQ 邮箱最新的 10 封未读,把要回复的列出来。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile web add dsh-email
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
(或从 GitHub 安装:`dsh plugin --profile web add github:你的账号/dsh-email#<commit>`,随后按提示在 profile 的 `pnpm-workspace.yaml` 里授权 `prepare` 构建。)
|
|
27
|
+
|
|
28
|
+
装好后重启 `dsh web`。插件自带空配置,**不会弄崩启动**;配置前调用任何 email 工具都会返回明确的配置提示。
|
|
29
|
+
|
|
30
|
+
## 配置
|
|
31
|
+
|
|
32
|
+
在你 profile 的 `cordis.patch.yml` 里覆盖 `tool-email` 行(在 `$DSH_HOME/profiles/<name>/` 下),然后重启:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- id: tool-email
|
|
36
|
+
config:
|
|
37
|
+
provider: qq # qq | 163 | 126 | sina | aliyun | gmail | outlook | icloud
|
|
38
|
+
user: you@qq.com
|
|
39
|
+
password: 你的授权码 # 强烈建议改用环境变量 DSH_EMAIL_PASSWORD,见下
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
不需要预设?手填任意 IMAP/SMTP 服务器即可:
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
- id: tool-email
|
|
46
|
+
config:
|
|
47
|
+
user: you@corp.example
|
|
48
|
+
password: 你的授权码
|
|
49
|
+
imap: { host: imap.corp.example, port: 993, secure: true }
|
|
50
|
+
smtp: { host: smtp.corp.example, port: 465, secure: true }
|
|
51
|
+
inboxFolder: INBOX
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 常用邮箱预设
|
|
55
|
+
|
|
56
|
+
| provider | IMAP | SMTP |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `qq` | imap.qq.com:993 (SSL) | smtp.qq.com:465 (SSL) |
|
|
59
|
+
| `163` | imap.163.com:993 | smtp.163.com:465 |
|
|
60
|
+
| `126` | imap.126.com:993 | smtp.126.com:465 |
|
|
61
|
+
| `sina` | imap.sina.com:993 | smtp.sina.com:465 |
|
|
62
|
+
| `aliyun` | imap.aliyun.com:993 | smtp.aliyun.com:465 |
|
|
63
|
+
| `gmail` | imap.gmail.com:993 | smtp.gmail.com:465 |
|
|
64
|
+
| `outlook` | outlook.office365.com:993 | smtp.office365.com:587 (STARTTLS) |
|
|
65
|
+
| `icloud` | imap.mail.me.com:993 | smtp.mail.me.com:587 (STARTTLS) |
|
|
66
|
+
|
|
67
|
+
### 完整配置项
|
|
68
|
+
|
|
69
|
+
| 字段 | 默认 | 说明 |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `provider` | 无 | 预设名,自动填 imap/smtp 地址;显式写的 host/port/secure 优先 |
|
|
72
|
+
| `user` | 必填 | 登录邮箱地址 |
|
|
73
|
+
| `password` | 必填* | 授权码/应用专用密码;*也可用环境变量 `DSH_EMAIL_PASSWORD` |
|
|
74
|
+
| `imap.host/port/secure` | 按预设 | 收信服务器 |
|
|
75
|
+
| `smtp.host/port/secure` | 按预设 | 发信服务器 |
|
|
76
|
+
| `inboxFolder` | `INBOX` | 收发工具默认使用的文件夹 |
|
|
77
|
+
| `sendApproval` | `true` | 发信前弹确认(强烈建议保留) |
|
|
78
|
+
| `maxBodyChars` | `20000` | email_read 正文截断上限(1000–200000) |
|
|
79
|
+
|
|
80
|
+
## 第一步:拿到授权码
|
|
81
|
+
|
|
82
|
+
各邮箱都要求用「授权码/应用专用密码」而不是登录密码:
|
|
83
|
+
|
|
84
|
+
- **QQ 邮箱**:设置 → 账户 → 开启 IMAP/SMTP 服务 → 生成授权码
|
|
85
|
+
- **163/126**:设置 → POP3/SMTP/IMAP → 开启 → 新增授权码
|
|
86
|
+
- **Gmail**:开启两步验证 → 安全 → 应用专用密码
|
|
87
|
+
- **Outlook**:Microsoft 账户安全 → 应用密码(部分账号需先开两步验证)
|
|
88
|
+
|
|
89
|
+
## 安全须知
|
|
90
|
+
|
|
91
|
+
- **授权码就是你的邮箱钥匙**。它写在 profile 的 `cordis.patch.yml` 里,请勿把它提交到任何 Git 仓库;更推荐用环境变量 `DSH_EMAIL_PASSWORD`。
|
|
92
|
+
- `email_send` 默认走 DSH 审批通道:每次发信都显示「发送邮件给 xx,主题「xx」」,你批准才发出。没有审批通道的环境(如无 UI 的 headless)会**直接拒绝发信**,这是安全默认。
|
|
93
|
+
- 注意:会话处于 **Full Access(完全访问)** 模式时,harness 会把审批策略置为 never,`email_send` 会被**静默拒绝**(不弹框)。想发信请把访问模式切回 Read Only 或 Write。
|
|
94
|
+
- 本插件不做任何联网上报,凭证只在内存中用于连接你的邮箱服务器。
|
|
95
|
+
|
|
96
|
+
## 已知限制(v0.1)
|
|
97
|
+
|
|
98
|
+
- **每个工具调用独立建立/关闭连接**:正确、无状态,但连续读多封会比常驻连接慢一点。
|
|
99
|
+
- **单账号**:一个 `tool-email` 行对应一个邮箱;多账号可复制多行(改 id 即可)。
|
|
100
|
+
- **附件只给元数据**(文件名/类型/大小),不下载内容;下载附件列入后续版本。
|
|
101
|
+
- **不支持 OAuth2**:强制 OAuth 的企业环境(部分 M365/Google Workspace)暂不可用。
|
|
102
|
+
- 正文搜索不提供:多数服务器(如 QQ)的 IMAP `TEXT`/`HEADER` 搜索要么全量匹配要么不支持,所以 v0.1 只搜主题/发件人/收件人;正文搜索列入后续版本(需客户端下载解析,较慢)。
|
|
103
|
+
|
|
104
|
+
## 开发
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
pnpm install
|
|
108
|
+
pnpm run build # tsc → lib/
|
|
109
|
+
pnpm test # 构建 + node --test(配置/解析/注册与审批门,19 个用例,无需真实邮箱)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## 协议
|
|
113
|
+
|
|
114
|
+
MIT。这是一个社区插件,与 DeepSeek 官方无关;`@deepseek-ai/*` 为官方保留命名空间。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dsh-email: IMAP/SMTP email tools for DeepSeek Harness.
|
|
2
|
+
#
|
|
3
|
+
# The row ships with an EMPTY config so installing the bundle never breaks
|
|
4
|
+
# boot; each email_* tool reports a configuration hint until you fill the
|
|
5
|
+
# account. Configure it in your profile's cordis.patch.yml by overriding this
|
|
6
|
+
# row (later layers win), for example:
|
|
7
|
+
#
|
|
8
|
+
# - id: tool-email
|
|
9
|
+
# config:
|
|
10
|
+
# provider: qq # qq | 163 | 126 | sina | aliyun | gmail | outlook | icloud
|
|
11
|
+
# user: you@qq.com
|
|
12
|
+
# password: 你的授权码 # 或改用环境变量 DSH_EMAIL_PASSWORD
|
|
13
|
+
# # sendApproval: true # 发信前弹确认(默认开启,强烈建议保留)
|
|
14
|
+
#
|
|
15
|
+
# Or skip the preset and spell out any IMAP/SMTP server:
|
|
16
|
+
#
|
|
17
|
+
# - id: tool-email
|
|
18
|
+
# config:
|
|
19
|
+
# user: you@corp.example
|
|
20
|
+
# password: 你的授权码
|
|
21
|
+
# imap: { host: imap.corp.example, port: 993, secure: true }
|
|
22
|
+
# smtp: { host: smtp.corp.example, port: 465, secure: true }
|
|
23
|
+
- insert:
|
|
24
|
+
- id: tool-email
|
|
25
|
+
name: dsh-email
|
|
26
|
+
config: {}
|
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface ImapConfig {
|
|
2
|
+
host?: string;
|
|
3
|
+
port?: number;
|
|
4
|
+
secure?: boolean;
|
|
5
|
+
connectionTimeoutMs?: number;
|
|
6
|
+
socketTimeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface SmtpConfig {
|
|
9
|
+
host?: string;
|
|
10
|
+
port?: number;
|
|
11
|
+
secure?: boolean;
|
|
12
|
+
}
|
|
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. */
|
|
17
|
+
user?: string;
|
|
18
|
+
/** App password / authorization code. Falls back to $DSH_EMAIL_PASSWORD. */
|
|
19
|
+
password?: string;
|
|
20
|
+
imap?: ImapConfig;
|
|
21
|
+
smtp?: SmtpConfig;
|
|
22
|
+
/** Mailbox used by the read/search/list tools. Default 'INBOX'. */
|
|
23
|
+
inboxFolder?: string;
|
|
24
|
+
/** Ask the user for approval before email_send. Default true. */
|
|
25
|
+
sendApproval?: boolean;
|
|
26
|
+
/** Plain-text body cap for email_read. Default 20000. */
|
|
27
|
+
maxBodyChars?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface ProviderPreset {
|
|
30
|
+
imap: {
|
|
31
|
+
host: string;
|
|
32
|
+
port: number;
|
|
33
|
+
secure: boolean;
|
|
34
|
+
};
|
|
35
|
+
smtp: {
|
|
36
|
+
host: string;
|
|
37
|
+
port: number;
|
|
38
|
+
secure: boolean;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export declare const PROVIDER_PRESETS: Record<string, ProviderPreset>;
|
|
42
|
+
export declare const PROVIDER_NAMES: string[];
|
|
43
|
+
export declare const EMAIL_PASSWORD_ENV = "DSH_EMAIL_PASSWORD";
|
|
44
|
+
/** Fully resolved, validated configuration. */
|
|
45
|
+
export interface ResolvedEmailConfig {
|
|
46
|
+
user: string;
|
|
47
|
+
password: string;
|
|
48
|
+
imap: ImapConfig & {
|
|
49
|
+
host: string;
|
|
50
|
+
port: number;
|
|
51
|
+
secure: boolean;
|
|
52
|
+
};
|
|
53
|
+
smtp: SmtpConfig & {
|
|
54
|
+
host: string;
|
|
55
|
+
port: number;
|
|
56
|
+
secure: boolean;
|
|
57
|
+
};
|
|
58
|
+
inboxFolder: string;
|
|
59
|
+
sendApproval: boolean;
|
|
60
|
+
maxBodyChars: number;
|
|
61
|
+
providerName: string | null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve and validate the raw row config. Throws with an actionable message
|
|
65
|
+
* (in Chinese, since it is what the user and the model both read) when the
|
|
66
|
+
* account is not fully specified.
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveEmailConfig(config: EmailConfig | undefined): ResolvedEmailConfig;
|
|
69
|
+
export declare function clampInt(value: unknown, fallback: number, min: number, max: number): number;
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const PROVIDER_PRESETS = {
|
|
2
|
+
qq: { imap: { host: 'imap.qq.com', port: 993, secure: true }, smtp: { host: 'smtp.qq.com', port: 465, secure: true } },
|
|
3
|
+
'163': { imap: { host: 'imap.163.com', port: 993, secure: true }, smtp: { host: 'smtp.163.com', port: 465, secure: true } },
|
|
4
|
+
'126': { imap: { host: 'imap.126.com', port: 993, secure: true }, smtp: { host: 'smtp.126.com', port: 465, secure: true } },
|
|
5
|
+
sina: { imap: { host: 'imap.sina.com', port: 993, secure: true }, smtp: { host: 'smtp.sina.com', port: 465, secure: true } },
|
|
6
|
+
aliyun: { imap: { host: 'imap.aliyun.com', port: 993, secure: true }, smtp: { host: 'smtp.aliyun.com', port: 465, secure: true } },
|
|
7
|
+
gmail: { imap: { host: 'imap.gmail.com', port: 993, secure: true }, smtp: { host: 'smtp.gmail.com', port: 465, secure: true } },
|
|
8
|
+
outlook: { imap: { host: 'outlook.office365.com', port: 993, secure: true }, smtp: { host: 'smtp.office365.com', port: 587, secure: false } },
|
|
9
|
+
icloud: { imap: { host: 'imap.mail.me.com', port: 993, secure: true }, smtp: { host: 'smtp.mail.me.com', port: 587, secure: false } },
|
|
10
|
+
};
|
|
11
|
+
export const PROVIDER_NAMES = Object.keys(PROVIDER_PRESETS);
|
|
12
|
+
export const EMAIL_PASSWORD_ENV = 'DSH_EMAIL_PASSWORD';
|
|
13
|
+
/**
|
|
14
|
+
* Resolve and validate the raw row config. Throws with an actionable message
|
|
15
|
+
* (in Chinese, since it is what the user and the model both read) when the
|
|
16
|
+
* account is not fully specified.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveEmailConfig(config) {
|
|
19
|
+
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`);
|
|
23
|
+
}
|
|
24
|
+
const user = raw.user?.trim() ?? '';
|
|
25
|
+
const password = raw.password ?? process.env[EMAIL_PASSWORD_ENV] ?? '';
|
|
26
|
+
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,
|
|
32
|
+
};
|
|
33
|
+
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,
|
|
37
|
+
};
|
|
38
|
+
const problems = [];
|
|
39
|
+
if (user === '')
|
|
40
|
+
problems.push('user(邮箱地址)未填写');
|
|
41
|
+
if (password === '')
|
|
42
|
+
problems.push(`password 未填写(或用环境变量 ${EMAIL_PASSWORD_ENV})`);
|
|
43
|
+
if (imap.host === undefined || imap.host === '')
|
|
44
|
+
problems.push(`imap.host 未填写(可填 provider 预设:${PROVIDER_NAMES.join('/')})`);
|
|
45
|
+
if (smtp.host === undefined || smtp.host === '')
|
|
46
|
+
problems.push('smtp.host 未填写(同上)');
|
|
47
|
+
if (problems.length > 0) {
|
|
48
|
+
throw new Error(`dsh-email 未配置:${problems.join(';')}。请在 profile 的 cordis.patch.yml 中覆盖 tool-email 行并重启(见插件 README)`);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
user,
|
|
52
|
+
password,
|
|
53
|
+
imap: { ...imap, host: imap.host, port: imap.port, secure: imap.secure },
|
|
54
|
+
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,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function clampInt(value, fallback, min, max) {
|
|
62
|
+
const n = typeof value === 'number' ? Math.trunc(value) : fallback;
|
|
63
|
+
if (!Number.isFinite(n))
|
|
64
|
+
return fallback;
|
|
65
|
+
return Math.min(max, Math.max(min, n));
|
|
66
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type EmailConfig } from './config.js';
|
|
2
|
+
export declare const name = "tool-email";
|
|
3
|
+
export declare const inject: string[];
|
|
4
|
+
export type Config = EmailConfig;
|
|
5
|
+
export declare function apply(ctx: any, config?: Config): void;
|
|
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';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { clampInt, resolveEmailConfig } from './config.js';
|
|
2
|
+
import { MailClient, messageOf } from './mail-client.js';
|
|
3
|
+
export const name = 'tool-email';
|
|
4
|
+
export const inject = ['tools'];
|
|
5
|
+
const MAX_LIMIT = 100;
|
|
6
|
+
/** Loose-but-typed output schema; values below carry every declared field. */
|
|
7
|
+
const listSchema = {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
count: { type: 'integer' },
|
|
11
|
+
folder: { type: 'string' },
|
|
12
|
+
messages: {
|
|
13
|
+
type: 'array',
|
|
14
|
+
items: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
uid: { type: 'integer' },
|
|
18
|
+
date: { type: 'string' },
|
|
19
|
+
from: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
20
|
+
subject: { type: 'string' },
|
|
21
|
+
seen: { type: 'boolean' },
|
|
22
|
+
flagged: { type: 'boolean' },
|
|
23
|
+
size: { type: 'integer' },
|
|
24
|
+
hasAttachments: { type: 'boolean' },
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: true,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
additionalProperties: true,
|
|
31
|
+
};
|
|
32
|
+
const readSchema = {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
uid: { type: 'integer' },
|
|
36
|
+
folder: { type: 'string' },
|
|
37
|
+
date: { type: 'string' },
|
|
38
|
+
from: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
39
|
+
to: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
40
|
+
cc: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
41
|
+
subject: { type: 'string' },
|
|
42
|
+
text: { type: 'string' },
|
|
43
|
+
attachments: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
44
|
+
truncated: { type: 'boolean' },
|
|
45
|
+
},
|
|
46
|
+
additionalProperties: true,
|
|
47
|
+
};
|
|
48
|
+
const sendSchema = {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
messageId: { type: 'string' },
|
|
52
|
+
accepted: { type: 'array', items: { type: 'string' } },
|
|
53
|
+
rejected: { type: 'array', items: { type: 'string' } },
|
|
54
|
+
response: { type: 'string' },
|
|
55
|
+
},
|
|
56
|
+
additionalProperties: true,
|
|
57
|
+
};
|
|
58
|
+
function oneText(text) {
|
|
59
|
+
return [{ type: 'text', text }];
|
|
60
|
+
}
|
|
61
|
+
function describeMessage(message) {
|
|
62
|
+
const from = message.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知)';
|
|
63
|
+
const flags = [
|
|
64
|
+
message.seen ? '' : '未读',
|
|
65
|
+
message.flagged ? '已标星' : '',
|
|
66
|
+
message.hasAttachments ? '含附件' : '',
|
|
67
|
+
].filter(Boolean);
|
|
68
|
+
const parts = [`uid=${message.uid}`, from, message.date];
|
|
69
|
+
if (flags.length > 0)
|
|
70
|
+
parts.push(flags.join('、'));
|
|
71
|
+
return `${message.subject || '(无主题)'} [${parts.join(' | ')}]`;
|
|
72
|
+
}
|
|
73
|
+
function renderList(value) {
|
|
74
|
+
if (value.messages.length === 0) {
|
|
75
|
+
return oneText(`文件夹 "${value.folder}" 共 ${value.count} 封邮件,本次没有要列出的邮件。`);
|
|
76
|
+
}
|
|
77
|
+
const lines = value.messages.map((m, i) => `#${i + 1} ${describeMessage(m)}`);
|
|
78
|
+
return oneText(`文件夹 "${value.folder}" 共 ${value.count} 封邮件,最新 ${value.messages.length} 封:\n\n${lines.join('\n')}\n\n用 email_read 配合 uid 阅读全文。`);
|
|
79
|
+
}
|
|
80
|
+
function renderRead(value) {
|
|
81
|
+
const from = value.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知)';
|
|
82
|
+
const attach = value.attachments.length > 0
|
|
83
|
+
? `\n附件:${value.attachments.map(a => `${a.filename}(${a.contentType},${a.size} 字节)`).join(';')}`
|
|
84
|
+
: '';
|
|
85
|
+
return oneText(`主题:${value.subject || '(无主题)'}\n来自:${from}\n时间:${value.date || '(未知)'}${attach}\n\n${value.text}`);
|
|
86
|
+
}
|
|
87
|
+
function renderSearch(value) {
|
|
88
|
+
if (value.messages.length === 0) {
|
|
89
|
+
return oneText(`在文件夹 "${value.folder}" 中搜索 "${value.query}":共 ${value.count} 条匹配,本次没有列出。`);
|
|
90
|
+
}
|
|
91
|
+
const lines = value.messages.map((m, i) => `#${i + 1} ${describeMessage(m)}`);
|
|
92
|
+
return oneText(`在文件夹 "${value.folder}" 中搜索 "${value.query}":共 ${value.count} 条匹配,展示最新 ${value.messages.length} 条:\n\n${lines.join('\n')}`);
|
|
93
|
+
}
|
|
94
|
+
function renderSend(value) {
|
|
95
|
+
const rejected = value.rejected.length > 0 ? `;被拒:${value.rejected.join(', ')}` : '';
|
|
96
|
+
return oneText(`邮件已发送,messageId: ${value.messageId};成功送达:${value.accepted.join(', ')}${rejected};服务器响应:${value.response}`);
|
|
97
|
+
}
|
|
98
|
+
export function apply(ctx, config = {}) {
|
|
99
|
+
// Load-time nudge only: never break boot, tools report the details instead.
|
|
100
|
+
try {
|
|
101
|
+
resolveEmailConfig(config);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
ctx.logger?.warn?.(`[dsh-email] ${messageOf(error, '未配置邮箱账号')}`);
|
|
105
|
+
}
|
|
106
|
+
const resolve = () => resolveEmailConfig(config);
|
|
107
|
+
ctx.tools.register({
|
|
108
|
+
name: 'email_list',
|
|
109
|
+
description: 'List recent emails in a mailbox folder (newest first). Returns uid, date, sender, subject and flags without message bodies; use email_read with a uid to fetch the full text.',
|
|
110
|
+
parameters: {
|
|
111
|
+
folder: { type: 'string', description: `IMAP folder name, default "${resolveSafeFolder(config)}" (the configured inboxFolder)` },
|
|
112
|
+
limit: { type: 'integer', description: 'How many messages to return, 1-100, default 20' },
|
|
113
|
+
offset: { type: 'integer', description: 'Skip this many newest messages first, default 0' },
|
|
114
|
+
unreadOnly: { type: 'boolean', description: 'Only list unread messages, default false' },
|
|
115
|
+
},
|
|
116
|
+
output: {
|
|
117
|
+
schema: listSchema,
|
|
118
|
+
render: (_args, value) => renderList(value),
|
|
119
|
+
},
|
|
120
|
+
async execute(rawArgs) {
|
|
121
|
+
const args = rawArgs;
|
|
122
|
+
const cfg = resolve();
|
|
123
|
+
const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
|
|
124
|
+
const offset = clampInt(args.offset, 0, 0, 10000);
|
|
125
|
+
return await new MailClient(cfg).list(args.folder?.trim() || cfg.inboxFolder, limit, offset, args.unreadOnly === true);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
ctx.tools.register({
|
|
129
|
+
name: 'email_read',
|
|
130
|
+
description: 'Read one full email message by its uid (from email_list or email_search). Returns the plain-text body (HTML mail is converted; oversized bodies are truncated) plus attachment metadata.',
|
|
131
|
+
parameters: {
|
|
132
|
+
uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
|
|
133
|
+
folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the configured inboxFolder' },
|
|
134
|
+
},
|
|
135
|
+
output: {
|
|
136
|
+
schema: readSchema,
|
|
137
|
+
render: (_args, value) => renderRead(value),
|
|
138
|
+
},
|
|
139
|
+
async execute(rawArgs) {
|
|
140
|
+
const args = rawArgs;
|
|
141
|
+
const cfg = resolve();
|
|
142
|
+
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
143
|
+
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
144
|
+
}
|
|
145
|
+
return await new MailClient(cfg).read(args.uid, args.folder?.trim() || cfg.inboxFolder);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
ctx.tools.register({
|
|
149
|
+
name: 'email_search',
|
|
150
|
+
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.',
|
|
151
|
+
parameters: {
|
|
152
|
+
query: { type: 'string', required: true, description: 'Keyword to search for' },
|
|
153
|
+
folder: { type: 'string', description: 'IMAP folder to search in; defaults to the configured inboxFolder' },
|
|
154
|
+
limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
|
|
155
|
+
},
|
|
156
|
+
output: {
|
|
157
|
+
schema: listSchema,
|
|
158
|
+
render: (_args, value) => renderSearch(value),
|
|
159
|
+
},
|
|
160
|
+
async execute(rawArgs) {
|
|
161
|
+
const args = rawArgs;
|
|
162
|
+
const cfg = resolve();
|
|
163
|
+
if (typeof args.query !== 'string' || args.query.trim() === '')
|
|
164
|
+
throw new Error('query 不能为空');
|
|
165
|
+
const limit = clampInt(args.limit, 10, 1, MAX_LIMIT);
|
|
166
|
+
return await new MailClient(cfg).search(args.query.trim(), args.folder?.trim() || cfg.inboxFolder, limit);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
ctx.tools.register({
|
|
170
|
+
name: 'email_send',
|
|
171
|
+
description: 'Send an email from the configured account. Sending first asks the user for approval (recipient and subject are shown) unless sendApproval is disabled; never invent recipients or content without the user\'s instruction.',
|
|
172
|
+
parameters: {
|
|
173
|
+
to: { type: 'string', required: true, description: 'Recipient(s), comma-separated' },
|
|
174
|
+
subject: { type: 'string', required: true, description: 'Email subject' },
|
|
175
|
+
text: { type: 'string', description: 'Plain-text body' },
|
|
176
|
+
cc: { type: 'string', description: 'CC recipient(s), comma-separated' },
|
|
177
|
+
},
|
|
178
|
+
output: {
|
|
179
|
+
schema: sendSchema,
|
|
180
|
+
render: (_args, value) => renderSend(value),
|
|
181
|
+
},
|
|
182
|
+
async execute(rawArgs) {
|
|
183
|
+
const args = rawArgs;
|
|
184
|
+
const cfg = resolve();
|
|
185
|
+
if (typeof args.to !== 'string' || args.to.trim() === '')
|
|
186
|
+
throw new Error('to 不能为空');
|
|
187
|
+
if (typeof args.subject !== 'string' || args.subject.trim() === '')
|
|
188
|
+
throw new Error('subject 不能为空');
|
|
189
|
+
return await new MailClient(cfg).send(args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
// Approval gate: the user must confirm every send (recipient + subject).
|
|
193
|
+
// Runs before other listeners; degrades to "the tool fails at send time"
|
|
194
|
+
// only when the account itself is not configured yet.
|
|
195
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
196
|
+
if (exec?.name !== 'email_send')
|
|
197
|
+
return next();
|
|
198
|
+
if (config.sendApproval === false)
|
|
199
|
+
return next();
|
|
200
|
+
try {
|
|
201
|
+
resolve();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return next();
|
|
205
|
+
}
|
|
206
|
+
const args = (exec.args ?? {});
|
|
207
|
+
return { kind: 'ask', reason: `发送邮件给 ${args.to},主题「${args.subject}」` };
|
|
208
|
+
}, { prepend: true });
|
|
209
|
+
}
|
|
210
|
+
function resolveSafeFolder(config) {
|
|
211
|
+
return config.inboxFolder?.trim() || 'INBOX';
|
|
212
|
+
}
|
|
213
|
+
export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
|
|
214
|
+
export { resolveEmailConfig, clampInt } from './config.js';
|
|
215
|
+
export { stripHtml, truncateText, flattenAddresses, parseRawMessage } from './parse.js';
|
|
216
|
+
export { MailClient, MailError, messageOf } from './mail-client.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResolvedEmailConfig } from './config.js';
|
|
2
|
+
import type { EmailListResult, EmailReadResult, EmailSearchResult, EmailSendResult } from './types.js';
|
|
3
|
+
export declare class MailError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function messageOf(error: unknown, fallback: string): string;
|
|
7
|
+
/** One IMAP account; every operation opens its own short-lived connection. */
|
|
8
|
+
export declare class MailClient {
|
|
9
|
+
private readonly config;
|
|
10
|
+
constructor(config: ResolvedEmailConfig);
|
|
11
|
+
private createImap;
|
|
12
|
+
/** Open the mailbox read-only, run the operation, always close. */
|
|
13
|
+
private withImap;
|
|
14
|
+
private static fetchQuery;
|
|
15
|
+
list(folder: string, limit: number, offset: number, unreadOnly: boolean): Promise<EmailListResult>;
|
|
16
|
+
search(query: string, folder: string, limit: number): Promise<EmailSearchResult>;
|
|
17
|
+
private fetchListed;
|
|
18
|
+
read(uid: number, folder: string): Promise<EmailReadResult>;
|
|
19
|
+
send(to: string, subject: string, text: string | undefined, cc: string | undefined): Promise<EmailSendResult>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ImapFlow } from 'imapflow';
|
|
2
|
+
import nodemailer from 'nodemailer';
|
|
3
|
+
import { flattenAddresses } from './parse.js';
|
|
4
|
+
import { parseRawMessage } from './parse.js';
|
|
5
|
+
export class MailError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'MailError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function messageOf(error, fallback) {
|
|
12
|
+
return error instanceof Error && error.message !== '' ? error.message : fallback;
|
|
13
|
+
}
|
|
14
|
+
/** True when any bodyStructure node declares an attachment disposition. */
|
|
15
|
+
function structureHasAttachment(node) {
|
|
16
|
+
if (node === null || node === undefined || typeof node !== 'object')
|
|
17
|
+
return false;
|
|
18
|
+
if (node.disposition === 'attachment')
|
|
19
|
+
return true;
|
|
20
|
+
const children = Array.isArray(node.childNodes) ? node.childNodes : [];
|
|
21
|
+
return children.some(structureHasAttachment);
|
|
22
|
+
}
|
|
23
|
+
function toIso(date) {
|
|
24
|
+
return date instanceof Date ? date.toISOString() : '';
|
|
25
|
+
}
|
|
26
|
+
function listedFrom(envelope, size, hasAttachments) {
|
|
27
|
+
return {
|
|
28
|
+
uid: envelope.uid,
|
|
29
|
+
date: toIso(envelope.envelope?.date),
|
|
30
|
+
from: flattenAddresses(envelope.envelope?.from),
|
|
31
|
+
subject: envelope.envelope?.subject ?? '',
|
|
32
|
+
seen: envelope.flags?.has('\\Seen') === true,
|
|
33
|
+
flagged: envelope.flags?.has('\\Flagged') === true,
|
|
34
|
+
size: size ?? 0,
|
|
35
|
+
hasAttachments,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** One IMAP account; every operation opens its own short-lived connection. */
|
|
39
|
+
export class MailClient {
|
|
40
|
+
config;
|
|
41
|
+
constructor(config) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
}
|
|
44
|
+
createImap() {
|
|
45
|
+
return new ImapFlow({
|
|
46
|
+
host: this.config.imap.host,
|
|
47
|
+
port: this.config.imap.port,
|
|
48
|
+
secure: this.config.imap.secure,
|
|
49
|
+
auth: { user: this.config.user, pass: this.config.password },
|
|
50
|
+
logger: false,
|
|
51
|
+
connectionTimeout: this.config.imap.connectionTimeoutMs ?? 30000,
|
|
52
|
+
greetingTimeout: 30000,
|
|
53
|
+
socketTimeout: this.config.imap.socketTimeoutMs ?? 60000,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Open the mailbox read-only, run the operation, always close. */
|
|
57
|
+
async withImap(folder, run) {
|
|
58
|
+
const client = this.createImap();
|
|
59
|
+
try {
|
|
60
|
+
await client.connect();
|
|
61
|
+
await client.mailboxOpen(folder, { readOnly: true });
|
|
62
|
+
return await run(client);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const raw = messageOf(error, 'IMAP 操作失败');
|
|
66
|
+
if (raw.toLowerCase().includes('authentication') || raw.toLowerCase().includes('login')) {
|
|
67
|
+
throw new MailError(`邮箱登录失败:${raw}(请检查 user 与授权码)`);
|
|
68
|
+
}
|
|
69
|
+
if (raw.toLowerCase().includes('nonselect') || raw.toLowerCase().includes('does not exist')) {
|
|
70
|
+
throw new MailError(`找不到邮箱文件夹 "${folder}":${raw}`);
|
|
71
|
+
}
|
|
72
|
+
throw new MailError(raw);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
try {
|
|
76
|
+
await client.logout();
|
|
77
|
+
}
|
|
78
|
+
catch { /* already closed */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
static fetchQuery = { uid: true, envelope: true, flags: true, size: true, bodyStructure: true };
|
|
82
|
+
async list(folder, limit, offset, unreadOnly) {
|
|
83
|
+
return this.withImap(folder, async (client) => {
|
|
84
|
+
const mailbox = client.mailbox;
|
|
85
|
+
const total = mailbox === false ? 0 : mailbox.exists;
|
|
86
|
+
let scopeCount = total;
|
|
87
|
+
let uids = [];
|
|
88
|
+
if (unreadOnly) {
|
|
89
|
+
const found = await client.search({ seen: false }, { uid: true });
|
|
90
|
+
uids = found === false ? [] : found;
|
|
91
|
+
scopeCount = uids.length;
|
|
92
|
+
}
|
|
93
|
+
else if (total > 0) {
|
|
94
|
+
const start = Math.max(1, total - (limit + offset) + 1);
|
|
95
|
+
const fetched = await client.fetchAll(`${start}:*`, { uid: true }, { uid: true });
|
|
96
|
+
uids = fetched.map(message => message.uid);
|
|
97
|
+
}
|
|
98
|
+
// newest first, then the requested window
|
|
99
|
+
uids.reverse();
|
|
100
|
+
const window = uids.slice(offset, offset + limit);
|
|
101
|
+
const messages = await this.fetchListed(client, window);
|
|
102
|
+
return { count: scopeCount, folder, messages };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async search(query, folder, limit) {
|
|
106
|
+
return this.withImap(folder, async (client) => {
|
|
107
|
+
// No nested OR and no TEXT search: several servers (QQ among them)
|
|
108
|
+
// silently answer those with empty or match-everything results. Three
|
|
109
|
+
// independent searches unioned client-side behave well everywhere.
|
|
110
|
+
const found = await Promise.all([
|
|
111
|
+
client.search({ subject: query }, { uid: true }),
|
|
112
|
+
client.search({ from: query }, { uid: true }),
|
|
113
|
+
client.search({ to: query }, { uid: true }),
|
|
114
|
+
]);
|
|
115
|
+
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => a - b);
|
|
116
|
+
uids.reverse();
|
|
117
|
+
const messages = await this.fetchListed(client, uids.slice(0, limit));
|
|
118
|
+
return { query, count: uids.length, folder, messages };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async fetchListed(client, uids) {
|
|
122
|
+
if (uids.length === 0)
|
|
123
|
+
return [];
|
|
124
|
+
const fetched = await client.fetchAll(uids, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
|
|
125
|
+
return fetched.map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
|
|
126
|
+
}
|
|
127
|
+
async read(uid, folder) {
|
|
128
|
+
return this.withImap(folder, async (client) => {
|
|
129
|
+
const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
|
|
130
|
+
if (message === false || message.source === undefined) {
|
|
131
|
+
throw new MailError(`找不到 uid=${uid} 的邮件(可能已被删除,或不在文件夹 "${folder}";可用 email_list 重新获取 uid)`);
|
|
132
|
+
}
|
|
133
|
+
const body = await parseRawMessage(message.source, this.config.maxBodyChars);
|
|
134
|
+
return { uid, folder, ...body };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async send(to, subject, text, cc) {
|
|
138
|
+
const transporter = nodemailer.createTransport({
|
|
139
|
+
host: this.config.smtp.host,
|
|
140
|
+
port: this.config.smtp.port,
|
|
141
|
+
secure: this.config.smtp.secure,
|
|
142
|
+
auth: { user: this.config.user, pass: this.config.password },
|
|
143
|
+
connectionTimeout: 30000,
|
|
144
|
+
greetingTimeout: 10000,
|
|
145
|
+
socketTimeout: 60000,
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
const info = await transporter.sendMail({
|
|
149
|
+
from: this.config.user,
|
|
150
|
+
to,
|
|
151
|
+
cc,
|
|
152
|
+
subject,
|
|
153
|
+
text: text ?? '',
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
messageId: info.messageId,
|
|
157
|
+
accepted: info.accepted.map(String),
|
|
158
|
+
rejected: info.rejected.map(String),
|
|
159
|
+
response: info.response,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
const raw = messageOf(error, 'SMTP 发送失败');
|
|
164
|
+
throw new MailError(raw.toLowerCase().includes('auth') ? `SMTP 登录失败:${raw}(请检查 user 与授权码)` : raw);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
transporter.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
package/lib/parse.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AddressEntry, ReadMessageBody } from './types.js';
|
|
2
|
+
export declare function flattenAddresses(input: unknown): AddressEntry[];
|
|
3
|
+
/** Minimal, dependency-free HTML-to-text: block tags become newlines, tags are dropped, common entities decoded. */
|
|
4
|
+
export declare function stripHtml(html: string): string;
|
|
5
|
+
export declare function truncateText(text: string, maxChars: number): {
|
|
6
|
+
text: string;
|
|
7
|
+
truncated: boolean;
|
|
8
|
+
};
|
|
9
|
+
/** Parse a raw RFC822 message source into the read-result body. */
|
|
10
|
+
export declare function parseRawMessage(source: Buffer, maxBodyChars: number): Promise<ReadMessageBody>;
|
package/lib/parse.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { simpleParser } from 'mailparser';
|
|
2
|
+
export function flattenAddresses(input) {
|
|
3
|
+
if (input === null || input === undefined)
|
|
4
|
+
return [];
|
|
5
|
+
const list = Array.isArray(input) ? input : input.value;
|
|
6
|
+
if (!Array.isArray(list))
|
|
7
|
+
return [];
|
|
8
|
+
return list
|
|
9
|
+
.filter(entry => entry !== null && typeof entry === 'object')
|
|
10
|
+
.map(entry => {
|
|
11
|
+
const { name, address } = entry;
|
|
12
|
+
const out = {};
|
|
13
|
+
if (typeof name === 'string' && name !== '')
|
|
14
|
+
out.name = name;
|
|
15
|
+
if (typeof address === 'string' && address !== '')
|
|
16
|
+
out.address = address;
|
|
17
|
+
return out;
|
|
18
|
+
})
|
|
19
|
+
.filter(entry => entry.address !== undefined || entry.name !== undefined);
|
|
20
|
+
}
|
|
21
|
+
/** Minimal, dependency-free HTML-to-text: block tags become newlines, tags are dropped, common entities decoded. */
|
|
22
|
+
export function stripHtml(html) {
|
|
23
|
+
let out = html
|
|
24
|
+
.replace(/<(script|style|head|title)[\s\S]*?<\/\1\s*>/gi, ' ')
|
|
25
|
+
.replace(/<\/(p|div|tr|li|h[1-6]|table|blockquote|ul|ol|section|article|header|footer)[^>]*>/gi, '\n')
|
|
26
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
27
|
+
.replace(/<[^>]+>/g, ' ')
|
|
28
|
+
.replace(/ /gi, ' ')
|
|
29
|
+
.replace(/&/gi, '&')
|
|
30
|
+
.replace(/</gi, '<')
|
|
31
|
+
.replace(/>/gi, '>')
|
|
32
|
+
.replace(/"/gi, '"')
|
|
33
|
+
.replace(/'|'/gi, "'");
|
|
34
|
+
out = out.replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{3,}/g, '\n\n');
|
|
35
|
+
return out.trim();
|
|
36
|
+
}
|
|
37
|
+
export function truncateText(text, maxChars) {
|
|
38
|
+
if (text.length <= maxChars)
|
|
39
|
+
return { text, truncated: false };
|
|
40
|
+
const cut = text.slice(0, maxChars);
|
|
41
|
+
const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' '), 0);
|
|
42
|
+
return { text: cut.slice(0, lastBreak) + `\n\n…[正文过长,已截断,共 ${text.length} 字符]`, truncated: true };
|
|
43
|
+
}
|
|
44
|
+
/** Parse a raw RFC822 message source into the read-result body. */
|
|
45
|
+
export async function parseRawMessage(source, maxBodyChars) {
|
|
46
|
+
const parsed = await simpleParser(source);
|
|
47
|
+
let text = parsed.text ?? '';
|
|
48
|
+
if (text.trim() === '' && typeof parsed.html === 'string' && parsed.html.trim() !== '') {
|
|
49
|
+
text = stripHtml(parsed.html);
|
|
50
|
+
}
|
|
51
|
+
const limited = truncateText(text, maxBodyChars);
|
|
52
|
+
return {
|
|
53
|
+
date: parsed.date instanceof Date ? parsed.date.toISOString() : '',
|
|
54
|
+
from: flattenAddresses(parsed.from),
|
|
55
|
+
to: flattenAddresses(parsed.to),
|
|
56
|
+
cc: flattenAddresses(parsed.cc),
|
|
57
|
+
subject: parsed.subject ?? '',
|
|
58
|
+
text: limited.text,
|
|
59
|
+
attachments: (parsed.attachments ?? []).map(att => ({
|
|
60
|
+
filename: att.filename ?? '(unnamed)',
|
|
61
|
+
contentType: att.contentType,
|
|
62
|
+
size: att.size,
|
|
63
|
+
})),
|
|
64
|
+
truncated: limited.truncated,
|
|
65
|
+
};
|
|
66
|
+
}
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** One address entry as IMAP/MIME expose it. */
|
|
2
|
+
export interface AddressEntry {
|
|
3
|
+
name?: string;
|
|
4
|
+
address?: string;
|
|
5
|
+
}
|
|
6
|
+
/** One listed message: everything cheap to fetch without the body. */
|
|
7
|
+
export interface ListedMessage {
|
|
8
|
+
uid: number;
|
|
9
|
+
/** ISO 8601 string, or '' when unknown. */
|
|
10
|
+
date: string;
|
|
11
|
+
from: AddressEntry[];
|
|
12
|
+
subject: string;
|
|
13
|
+
seen: boolean;
|
|
14
|
+
flagged: boolean;
|
|
15
|
+
size: number;
|
|
16
|
+
hasAttachments: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** One fully read message body. */
|
|
19
|
+
export interface ReadMessageBody {
|
|
20
|
+
date: string;
|
|
21
|
+
from: AddressEntry[];
|
|
22
|
+
to: AddressEntry[];
|
|
23
|
+
cc: AddressEntry[];
|
|
24
|
+
subject: string;
|
|
25
|
+
/** Plain-text body; HTML mail is converted. Truncated at maxBodyChars. */
|
|
26
|
+
text: string;
|
|
27
|
+
attachments: Array<{
|
|
28
|
+
filename: string;
|
|
29
|
+
contentType: string;
|
|
30
|
+
size: number;
|
|
31
|
+
}>;
|
|
32
|
+
truncated: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface EmailListResult {
|
|
35
|
+
count: number;
|
|
36
|
+
folder: string;
|
|
37
|
+
messages: ListedMessage[];
|
|
38
|
+
}
|
|
39
|
+
export interface EmailReadResult extends ReadMessageBody {
|
|
40
|
+
uid: number;
|
|
41
|
+
folder: string;
|
|
42
|
+
}
|
|
43
|
+
export interface EmailSearchResult {
|
|
44
|
+
query: string;
|
|
45
|
+
count: number;
|
|
46
|
+
folder: string;
|
|
47
|
+
messages: ListedMessage[];
|
|
48
|
+
}
|
|
49
|
+
export interface EmailSendResult {
|
|
50
|
+
messageId: string;
|
|
51
|
+
accepted: string[];
|
|
52
|
+
rejected: string[];
|
|
53
|
+
response: string;
|
|
54
|
+
}
|
|
55
|
+
export interface EmailListArgs {
|
|
56
|
+
folder?: string;
|
|
57
|
+
limit?: number;
|
|
58
|
+
offset?: number;
|
|
59
|
+
unreadOnly?: boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface EmailReadArgs {
|
|
62
|
+
uid: number;
|
|
63
|
+
folder?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface EmailSearchArgs {
|
|
66
|
+
query: string;
|
|
67
|
+
folder?: string;
|
|
68
|
+
limit?: number;
|
|
69
|
+
}
|
|
70
|
+
export interface EmailSendArgs {
|
|
71
|
+
to: string;
|
|
72
|
+
subject: string;
|
|
73
|
+
text?: string;
|
|
74
|
+
cc?: string;
|
|
75
|
+
}
|
package/lib/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-email",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"prepare": "tsc",
|
|
22
|
+
"prepublishOnly": "pnpm run build",
|
|
23
|
+
"test": "pnpm run build && node --test \"test/*.test.mjs\""
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"dsh-plugin",
|
|
27
|
+
"deepseek-harness",
|
|
28
|
+
"dsh",
|
|
29
|
+
"email",
|
|
30
|
+
"imap",
|
|
31
|
+
"smtp"
|
|
32
|
+
],
|
|
33
|
+
"author": "stardustlc",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"imapflow": "^1.7.0",
|
|
45
|
+
"mailparser": "^3.9.15",
|
|
46
|
+
"nodemailer": "^9.0.5"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^26.2.0",
|
|
50
|
+
"@types/nodemailer": "^8.0.1",
|
|
51
|
+
"typescript": "^5.7.3"
|
|
52
|
+
}
|
|
53
|
+
}
|