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 CHANGED
@@ -1,16 +1,3 @@
1
- plugins:
2
- - name: dsh-mail-sender
3
- config:
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
- imap: {
7
- host: string;
8
- port: number;
9
- secure: boolean;
10
- user: string;
11
- password: string;
12
- };
13
- smtp: {
14
- host: string;
15
- port: number;
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<Config>;
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
- imap: Schema.object({
10
- host: Schema.string().required().description('IMAP 服务器地址'),
11
- port: Schema.number().default(993),
12
- secure: Schema.boolean().default(true),
13
- user: Schema.string().required().description('IMAP 登录账号'),
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
- ctx.effect(async () => {
49
- await imap.connect();
50
- const lock = await imap.getMailboxLock('INBOX');
51
- imap.on('exists', async () => {
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 = imap.mailbox;
76
+ const mb = next.mailbox;
54
77
  if (mb === false)
55
78
  return;
56
- const seq = mb.exists;
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
- const subject = parsed.subject || '(no subject)';
71
- const body = parsed.text || '';
72
- lastMail.set(sessionId, { from, subject, messageId: parsed.messageId });
73
- // 获取或创建 agent(同一发件人同一会话)
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
- const msg = createUserMessage({
85
- content: [{ type: 'text', text: body }],
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
- ctx.logger?.error('[mail-bot] fetch/parse failed', err);
108
+ console.error('[mail-bot] 收信失败', err);
92
109
  }
93
110
  });
94
- return async () => {
95
- try {
96
- lock.release();
97
- }
98
- catch { }
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
- await imap.logout();
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
- const key = `${sessionId}:${turn}`;
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 key = `${sessionId}:${turn}`;
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
- smtp.sendMail({
139
- from: config.smtp.user,
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 => ctx.logger?.error('[mail-bot] send reply failed', 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.2",
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
- 一个 DeepSeek Harness (DSH) 邮件通道插件:用户发邮件 → DSH 接收并交给 agent → agent 的回复自动用邮件发回去。
3
+ DeepSeek Harness (DSH) 邮件通道插件:用户发邮件 → DSH 接收并交给 agent → agent 的回复自动用邮件发回去。
4
4
 
5
5
  - **入站**:IMAP IDLE 实时监听收件箱
6
6
  - **会话**:同一发件人 → 同一个 agent 会话(`sessionId = mail-<地址>`)
7
- - **出站**:订阅 `session/event`,一轮 turn 结束后自动 SMTP 回复(`Re:` 主题 + `In-Reply-To`)
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
  ```