dsh-mail-sender 0.1.2 → 0.1.4
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/cordis.patch.yml +3 -16
- package/lib/index.d.ts +30 -17
- package/lib/index.js +113 -68
- package/package.json +12 -7
- package/readme.md +54 -3
package/cordis.patch.yml
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
imap:
|
|
5
|
-
host: imap.example.com
|
|
6
|
-
port: 993
|
|
7
|
-
secure: true
|
|
8
|
-
user: ""
|
|
9
|
-
password: ""
|
|
10
|
-
smtp:
|
|
11
|
-
host: smtp.example.com
|
|
12
|
-
port: 465
|
|
13
|
-
secure: true
|
|
14
|
-
user: ""
|
|
15
|
-
password: ""
|
|
16
|
-
allowedAddresses: []
|
|
1
|
+
- insert:
|
|
2
|
+
- id: dsh-mail-sender
|
|
3
|
+
name: dsh-mail-sender
|
package/lib/index.d.ts
CHANGED
|
@@ -2,23 +2,36 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
2
2
|
import Schema from '@deepseek-ai/schemastery';
|
|
3
3
|
export declare const name = "dsh-mail-sender";
|
|
4
4
|
export declare const inject: string[];
|
|
5
|
+
/** schemastery `.volatile()` 字段解析后的稳定引用 */
|
|
6
|
+
interface Ref<T> {
|
|
7
|
+
get(): T;
|
|
8
|
+
}
|
|
5
9
|
export interface Config {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
secure: boolean;
|
|
17
|
-
user: string;
|
|
18
|
-
password: string;
|
|
19
|
-
};
|
|
20
|
-
/** 入站+出站合并白名单:只有名单内的地址可以给 agent 发信,agent 也只能回复这些地址 */
|
|
21
|
-
allowedAddresses: string[];
|
|
10
|
+
/** 机器人邮箱,同时用于 IMAP/SMTP 登录与回信 From */
|
|
11
|
+
email: Ref<string>;
|
|
12
|
+
/** 邮箱密码或授权码 */
|
|
13
|
+
password: Ref<string>;
|
|
14
|
+
imapHost: Ref<string>;
|
|
15
|
+
smtpHost: Ref<string>;
|
|
16
|
+
/** SMTP 端口,465 走 SSL,其余走 STARTTLS */
|
|
17
|
+
smtpPort: Ref<number>;
|
|
18
|
+
/** 入站+出站白名单 */
|
|
19
|
+
allowedAddresses: Ref<string[]>;
|
|
22
20
|
}
|
|
23
|
-
export declare const Config: Schema<
|
|
21
|
+
export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
|
|
22
|
+
email: Schema<string, string, "volatile-defined">;
|
|
23
|
+
password: Schema<string, string, "volatile-defined">;
|
|
24
|
+
imapHost: Schema<string, string, "volatile-defined">;
|
|
25
|
+
smtpHost: Schema<string, string, "volatile-defined">;
|
|
26
|
+
smtpPort: Schema<number, number, "volatile-defined">;
|
|
27
|
+
allowedAddresses: Schema<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
|
|
28
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
29
|
+
email: Schema<string, string, "volatile-defined">;
|
|
30
|
+
password: Schema<string, string, "volatile-defined">;
|
|
31
|
+
imapHost: Schema<string, string, "volatile-defined">;
|
|
32
|
+
smtpHost: Schema<string, string, "volatile-defined">;
|
|
33
|
+
smtpPort: Schema<number, number, "volatile-defined">;
|
|
34
|
+
allowedAddresses: Schema<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
|
|
35
|
+
}>>, "plain">;
|
|
24
36
|
export declare function apply(ctx: Context, config: Config): void;
|
|
37
|
+
export {};
|
package/lib/index.js
CHANGED
|
@@ -6,57 +6,77 @@ import Schema from '@deepseek-ai/schemastery';
|
|
|
6
6
|
export const name = 'dsh-mail-sender';
|
|
7
7
|
export const inject = ['agents'];
|
|
8
8
|
export const Config = Schema.object({
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
password: Schema.string().required().description('IMAP 密码/授权码'),
|
|
15
|
-
}),
|
|
16
|
-
smtp: Schema.object({
|
|
17
|
-
host: Schema.string().required().description('SMTP 服务器地址'),
|
|
18
|
-
port: Schema.number().default(465),
|
|
19
|
-
secure: Schema.boolean().default(true),
|
|
20
|
-
user: Schema.string().required().description('SMTP 登录账号'),
|
|
21
|
-
password: Schema.string().required().description('SMTP 密码/授权码'),
|
|
22
|
-
}),
|
|
9
|
+
email: Schema.string().volatile().default('').description('机器人邮箱,如 agent@qq.com'),
|
|
10
|
+
password: Schema.string().volatile().default('').description('邮箱密码或授权码'),
|
|
11
|
+
imapHost: Schema.string().volatile().default('').description('IMAP 服务器,如 imap.qq.com'),
|
|
12
|
+
smtpHost: Schema.string().volatile().default('').description('SMTP 服务器,如 smtp.qq.com'),
|
|
13
|
+
smtpPort: Schema.number().volatile().default(465).description('SMTP 端口,465 走 SSL,587 走 STARTTLS'),
|
|
23
14
|
allowedAddresses: Schema.array(Schema.string())
|
|
15
|
+
.volatile()
|
|
24
16
|
.default([])
|
|
25
17
|
.description('允许与 agent 通信的邮箱地址'),
|
|
26
18
|
});
|
|
19
|
+
function resolveServers(config) {
|
|
20
|
+
const smtpPort = config.smtpPort.get();
|
|
21
|
+
return {
|
|
22
|
+
email: config.email.get().trim(),
|
|
23
|
+
password: config.password.get(),
|
|
24
|
+
imapHost: config.imapHost.get().trim(),
|
|
25
|
+
imapPort: 993,
|
|
26
|
+
smtpHost: config.smtpHost.get().trim(),
|
|
27
|
+
smtpPort,
|
|
28
|
+
smtpSecure: smtpPort === 465,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
27
31
|
export function apply(ctx, config) {
|
|
28
|
-
const allowed = new Set(config.allowedAddresses.map(a => a.toLowerCase().trim()));
|
|
29
|
-
const smtp = nodemailer.createTransport({
|
|
30
|
-
host: config.smtp.host,
|
|
31
|
-
port: config.smtp.port,
|
|
32
|
-
secure: config.smtp.secure,
|
|
33
|
-
auth: { user: config.smtp.user, pass: config.smtp.password },
|
|
34
|
-
});
|
|
35
|
-
const imap = new ImapFlow({
|
|
36
|
-
host: config.imap.host,
|
|
37
|
-
port: config.imap.port,
|
|
38
|
-
secure: config.imap.secure,
|
|
39
|
-
auth: { user: config.imap.user, pass: config.imap.password },
|
|
40
|
-
logger: false,
|
|
41
|
-
});
|
|
42
32
|
// sessionId -> agent handle
|
|
43
33
|
const handles = new Map();
|
|
44
|
-
// sessionId ->
|
|
34
|
+
// sessionId -> 最近一封来信(用于回复)
|
|
45
35
|
const lastMail = new Map();
|
|
46
36
|
// `${sessionId}:${turn}` -> 该 turn 内累积的 assistant 文本
|
|
47
37
|
const turnBuffers = new Map();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
38
|
+
let client;
|
|
39
|
+
let lock;
|
|
40
|
+
let startedKey = '';
|
|
41
|
+
let smtp;
|
|
42
|
+
let smtpKey = '';
|
|
43
|
+
const stopImap = async () => {
|
|
44
|
+
startedKey = '';
|
|
45
|
+
try {
|
|
46
|
+
lock?.release();
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
lock = undefined;
|
|
50
|
+
const current = client;
|
|
51
|
+
client = undefined;
|
|
52
|
+
if (current)
|
|
53
|
+
await current.logout().catch(() => { });
|
|
54
|
+
};
|
|
55
|
+
const ensureStarted = async () => {
|
|
56
|
+
const servers = resolveServers(config);
|
|
57
|
+
const key = JSON.stringify(servers);
|
|
58
|
+
if (key === startedKey)
|
|
59
|
+
return;
|
|
60
|
+
await stopImap();
|
|
61
|
+
if (!servers.email || !servers.password || !servers.imapHost || !servers.smtpHost) {
|
|
62
|
+
console.warn('[mail-bot] 请在 设置 → 插件 → dsh-mail-sender 填写邮箱、密码与 IMAP/SMTP 服务器');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const next = new ImapFlow({
|
|
66
|
+
host: servers.imapHost,
|
|
67
|
+
port: servers.imapPort,
|
|
68
|
+
secure: true,
|
|
69
|
+
auth: { user: servers.email, pass: servers.password },
|
|
70
|
+
logger: false,
|
|
71
|
+
});
|
|
72
|
+
client = next;
|
|
73
|
+
startedKey = key;
|
|
74
|
+
next.on('exists', async () => {
|
|
52
75
|
try {
|
|
53
|
-
const mb =
|
|
76
|
+
const mb = next.mailbox;
|
|
54
77
|
if (mb === false)
|
|
55
78
|
return;
|
|
56
|
-
const
|
|
57
|
-
if (!seq)
|
|
58
|
-
return;
|
|
59
|
-
const fetched = await imap.fetchOne(seq, { source: true });
|
|
79
|
+
const fetched = await next.fetchOne(mb.exists, { source: true });
|
|
60
80
|
if (fetched === false || !fetched.source)
|
|
61
81
|
return;
|
|
62
82
|
const parsed = await simpleParser(fetched.source);
|
|
@@ -64,38 +84,65 @@ export function apply(ctx, config) {
|
|
|
64
84
|
if (!from)
|
|
65
85
|
return;
|
|
66
86
|
// 白名单:不在名单内直接丢弃
|
|
87
|
+
const allowed = new Set(config.allowedAddresses.get().map(a => a.toLowerCase().trim()));
|
|
67
88
|
if (!allowed.has(from))
|
|
68
89
|
return;
|
|
69
90
|
const sessionId = `mail-${from}`;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
lastMail.set(sessionId, {
|
|
92
|
+
from,
|
|
93
|
+
subject: parsed.subject || '(no subject)',
|
|
94
|
+
messageId: parsed.messageId,
|
|
95
|
+
});
|
|
96
|
+
// 同一发件人同一会话
|
|
74
97
|
let handle = handles.get(sessionId);
|
|
75
98
|
if (!handle) {
|
|
76
99
|
handle = await getOrCreateAgent(ctx, sessionId);
|
|
77
100
|
handles.set(sessionId, handle);
|
|
78
|
-
ctx.on('session/event', (session, event) => {
|
|
79
|
-
if (session?.header?.id !== sessionId)
|
|
80
|
-
return;
|
|
81
|
-
handleSessionEvent(sessionId, event);
|
|
82
|
-
});
|
|
83
101
|
}
|
|
84
|
-
|
|
85
|
-
content: [{ type: 'text', text:
|
|
102
|
+
handle.agent.followup(createUserMessage({
|
|
103
|
+
content: [{ type: 'text', text: parsed.text || '' }],
|
|
86
104
|
source: { kind: 'plugin', plugin: name },
|
|
87
|
-
});
|
|
88
|
-
handle.agent.followup(msg);
|
|
105
|
+
}));
|
|
89
106
|
}
|
|
90
107
|
catch (err) {
|
|
91
|
-
|
|
108
|
+
console.error('[mail-bot] 收信失败', err);
|
|
92
109
|
}
|
|
93
110
|
});
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
111
|
+
try {
|
|
112
|
+
await next.connect();
|
|
113
|
+
if (client !== next)
|
|
114
|
+
return void next.logout().catch(() => { });
|
|
115
|
+
lock = await next.getMailboxLock('INBOX');
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.error('[mail-bot] IMAP 连接失败', err);
|
|
119
|
+
if (client === next)
|
|
120
|
+
await stopImap();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const getSmtp = (servers) => {
|
|
124
|
+
const key = `${servers.smtpHost}:${servers.smtpPort}:${servers.smtpSecure}:${servers.email}`;
|
|
125
|
+
if (smtpKey !== key) {
|
|
126
|
+
smtp = nodemailer.createTransport({
|
|
127
|
+
host: servers.smtpHost,
|
|
128
|
+
port: servers.smtpPort,
|
|
129
|
+
secure: servers.smtpSecure,
|
|
130
|
+
requireTLS: !servers.smtpSecure,
|
|
131
|
+
auth: { user: servers.email, pass: servers.password },
|
|
132
|
+
});
|
|
133
|
+
smtpKey = key;
|
|
134
|
+
}
|
|
135
|
+
return smtp;
|
|
136
|
+
};
|
|
137
|
+
ctx.on('loader/volatile-update', () => { void ensureStarted(); });
|
|
138
|
+
ctx.on('session/event', (session, event) => {
|
|
139
|
+
const id = session?.header?.id;
|
|
140
|
+
if (typeof id === 'string' && handles.has(id))
|
|
141
|
+
handleSessionEvent(id, event);
|
|
142
|
+
});
|
|
143
|
+
ctx.effect(() => {
|
|
144
|
+
void ensureStarted();
|
|
145
|
+
return () => {
|
|
99
146
|
for (const h of handles.values()) {
|
|
100
147
|
try {
|
|
101
148
|
h.dispose();
|
|
@@ -105,12 +152,13 @@ export function apply(ctx, config) {
|
|
|
105
152
|
handles.clear();
|
|
106
153
|
turnBuffers.clear();
|
|
107
154
|
lastMail.clear();
|
|
108
|
-
|
|
155
|
+
void stopImap();
|
|
109
156
|
};
|
|
110
157
|
});
|
|
111
158
|
function handleSessionEvent(sessionId, event) {
|
|
112
159
|
const data = event?.data ?? {};
|
|
113
160
|
const turn = data.turn;
|
|
161
|
+
const key = `${sessionId}:${turn}`;
|
|
114
162
|
if (event.type === 'assistant/message') {
|
|
115
163
|
const text = (data.message?.content ?? [])
|
|
116
164
|
.filter((b) => b.type === 'text')
|
|
@@ -118,31 +166,28 @@ export function apply(ctx, config) {
|
|
|
118
166
|
.join('');
|
|
119
167
|
if (!text)
|
|
120
168
|
return;
|
|
121
|
-
|
|
122
|
-
const buf = turnBuffers.get(key) ?? [];
|
|
123
|
-
buf.push(text);
|
|
124
|
-
turnBuffers.set(key, buf);
|
|
169
|
+
turnBuffers.set(key, [...(turnBuffers.get(key) ?? []), text]);
|
|
125
170
|
return;
|
|
126
171
|
}
|
|
172
|
+
// 一轮完整结束后才回信;一个 turn 可能含多个 step
|
|
127
173
|
if (event.type === 'turn/end' && data.reason?.kind === 'completed') {
|
|
128
|
-
const
|
|
129
|
-
const parts = turnBuffers.get(key) ?? [];
|
|
174
|
+
const replyText = (turnBuffers.get(key) ?? []).join('\n\n').trim();
|
|
130
175
|
turnBuffers.delete(key);
|
|
131
|
-
const replyText = parts.join('\n\n').trim();
|
|
132
176
|
if (!replyText)
|
|
133
177
|
return;
|
|
134
178
|
const mail = lastMail.get(sessionId);
|
|
135
179
|
if (!mail)
|
|
136
180
|
return;
|
|
181
|
+
const servers = resolveServers(config);
|
|
137
182
|
const subject = mail.subject.startsWith('Re:') ? mail.subject : `Re: ${mail.subject}`;
|
|
138
|
-
|
|
139
|
-
from:
|
|
183
|
+
getSmtp(servers).sendMail({
|
|
184
|
+
from: servers.email,
|
|
140
185
|
to: mail.from,
|
|
141
186
|
subject,
|
|
142
187
|
text: replyText,
|
|
143
188
|
inReplyTo: mail.messageId,
|
|
144
189
|
references: mail.messageId,
|
|
145
|
-
}).catch(err =>
|
|
190
|
+
}).catch(err => console.error('[mail-bot] 回信失败', err));
|
|
146
191
|
}
|
|
147
192
|
}
|
|
148
193
|
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mail-sender",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "inbound IMAP mail is delivered to an agent, whose reply is sent back over SMTP.",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"main": "lib/index.js",
|
|
6
7
|
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
7
15
|
"files": ["lib", "cordis.patch.yml"],
|
|
16
|
+
"keywords": ["dsh-plugin", "deepseek-harness", "mail", "imap", "smtp"],
|
|
8
17
|
"dsh": {
|
|
9
|
-
"bundle": { "patch": "./cordis.patch.yml" }
|
|
10
|
-
"client": {
|
|
11
|
-
"inject": ["mail"],
|
|
12
|
-
"platform": "web"
|
|
13
|
-
}
|
|
18
|
+
"bundle": { "patch": "./cordis.patch.yml" }
|
|
14
19
|
},
|
|
15
20
|
"engines": {
|
|
16
21
|
"dsh": ">=0.2.1-rc.2 <0.3.0-0"
|
|
@@ -37,4 +42,4 @@
|
|
|
37
42
|
"@types/nodemailer": "^6.4.17",
|
|
38
43
|
"typescript": "^5.6.0"
|
|
39
44
|
}
|
|
40
|
-
}
|
|
45
|
+
}
|
package/readme.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# dsh-mail-sender
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness (DSH) 邮件通道插件:用户发邮件 → DSH 接收并交给 agent → agent 的回复自动用邮件发回去。
|
|
4
4
|
|
|
5
5
|
- **入站**:IMAP IDLE 实时监听收件箱
|
|
6
6
|
- **会话**:同一发件人 → 同一个 agent 会话(`sessionId = mail-<地址>`)
|
|
7
|
-
- **出站**:订阅 `session/event`,一轮 turn
|
|
8
|
-
- **白名单**:`allowedAddresses`
|
|
7
|
+
- **出站**:订阅 `session/event`,一轮 turn 正常结束后自动 SMTP 回信(`Re:` 主题 + `In-Reply-To`)
|
|
8
|
+
- **白名单**:`allowedAddresses` 同时控制入站与出站
|
|
9
|
+
- **配置界面**:Host 从导出的 `Config` schema 自动渲染,无需自写前端
|
|
9
10
|
|
|
10
11
|
## 工作原理
|
|
11
12
|
|
|
@@ -24,4 +25,54 @@ ctx.agents.create / resume (sessionId = `mail-${from}`)
|
|
|
24
25
|
│ turn/end (reason.kind === 'completed') → 取出本轮文本
|
|
25
26
|
▼
|
|
26
27
|
SMTP 回复 (Re: 原主题, In-Reply-To: 原 Message-ID)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
dsh plugin --profile web add dsh-mail-sender
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
安装后重启 `dsh web`,用 `dsh --profile web --dump-config` 确认插件行已进入配置树。
|
|
37
|
+
|
|
38
|
+
## 在设置页填写配置
|
|
39
|
+
|
|
40
|
+
打开 **Settings(设置)→ Plugins(插件)→ dsh-mail-sender**,表单由 Host 根据插件导出的 `Config` schema 自动生成。
|
|
41
|
+
|
|
42
|
+
> 该表单依赖两点,本插件已满足:
|
|
43
|
+
> 1. patch 行 `id` 与包名一致(`dsh-mail-sender`),设置表单按行 id 定位;
|
|
44
|
+
> 2. 每个可编辑字段声明为 `.volatile()`,DSH 才会把它投影成可编辑项并把改动写回 profile 的 `cordis.patch.yml`。
|
|
45
|
+
|
|
46
|
+
| 字段 | 说明 |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `email` | 机器人邮箱,如 `agent@qq.com`(同时用于 IMAP/SMTP 登录与回信 From) |
|
|
49
|
+
| `password` | 邮箱密码或授权码 |
|
|
50
|
+
| `imapHost` | IMAP 服务器,如 `imap.qq.com`(固定 993 + SSL) |
|
|
51
|
+
| `smtpHost` | SMTP 服务器,如 `smtp.qq.com` |
|
|
52
|
+
| `smtpPort` | 默认 `465`(SSL);填 `587` 时自动走 STARTTLS |
|
|
53
|
+
| `allowedAddresses` | 允许与 agent 通信的邮箱地址列表 |
|
|
54
|
+
|
|
55
|
+
服务器地址按你的邮箱服务商填写,插件不做猜测。
|
|
56
|
+
|
|
57
|
+
白名单同时控制两个方向:非名单内发件人的来信直接丢弃;agent 的回复目标就是来信人,因此也天然限定在名单内。
|
|
58
|
+
|
|
59
|
+
所有字段都是 `.volatile()`:保存后即时生效,IMAP 会按新配置自动重连,无需重启 host。
|
|
60
|
+
|
|
61
|
+
## 包结构
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
.
|
|
65
|
+
├── package.json # dsh.bundle 声明(无 dsh.client,纯 Host 插件)
|
|
66
|
+
├── cordis.patch.yml # 顶层 YAML 数组:插入插件行
|
|
67
|
+
├── src/index.ts
|
|
68
|
+
├── tsconfig.json
|
|
69
|
+
└── .npmrc # legacy-peer-deps=true(本地开发)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`cordis.patch.yml` 必须是**顶层数组**的 loader patch 条目:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
- insert:
|
|
76
|
+
- id: dsh-mail-sender
|
|
77
|
+
name: dsh-mail-sender
|
|
27
78
|
```
|