dsh-email 0.4.0 → 0.5.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 +8 -6
- package/lib/config.d.ts +9 -1
- package/lib/config.js +3 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +31 -23
- package/lib/mail-client.d.ts +5 -1
- package/lib/mail-client.js +46 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,10 +12,10 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| `email_list` | 列出文件夹里最新的邮件(未读过滤、分页、只看摘要不带正文) |
|
|
14
14
|
| `email_read` | 按 uid 读取一封邮件的全文(HTML 邮件自动转纯文本,超长截断) |
|
|
15
|
-
| `email_search` |
|
|
15
|
+
| `email_search` | 按关键词搜索发件人/收件人/主题(服务器端);无结果时默认回退到最近 30 封的正文扫描 |
|
|
16
16
|
| `email_send` | 代发邮件(支持带附件)。**默认发信前会弹确认**,显示收件人、主题和附件数,由你批准后才发出 |
|
|
17
17
|
| `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
|
|
18
|
-
| `email_attachment` |
|
|
18
|
+
| `email_attachment` | 按序号下载邮件附件(默认存到会话工作区,模型可直接读取;大小受 maxAttachmentBytes 限制) |
|
|
19
19
|
|
|
20
20
|
示例对话:
|
|
21
21
|
|
|
@@ -103,9 +103,11 @@ dsh plugin --profile web add dsh-email
|
|
|
103
103
|
| `maxBodyChars` | `20000` | email_read 正文截断上限(1000–200000) |
|
|
104
104
|
| `accounts` | 无 | 具名账号表;账号级字段覆盖顶层简写 |
|
|
105
105
|
| `defaultAccount` | 单账号时自动 | 工具省略 account 参数时使用的账号(多账号必填) |
|
|
106
|
-
| `downloadDir` |
|
|
106
|
+
| `downloadDir` | 会话工作区下 .dsh-email-downloads(回退 $DSH_HOME/email-downloads) | email_attachment 的落盘目录;显式设置后固定 |
|
|
107
107
|
| `maxAttachmentBytes` | 20 MiB | 单个附件与附件总大小上限(1024–512 MiB) |
|
|
108
|
-
| `idleTimeoutMs` | `60000` | IMAP 空闲连接回收时间(连接复用,连续操作更快) |
|
|
108
|
+
| `idleTimeoutMs` | `60000` | IMAP 空闲连接回收时间(连接复用,连续操作更快) |
|
|
109
|
+
| `bodySearchFallback` | `true` | 服务器搜索无结果时,回退到客户端扫描最近邮件的正文 |
|
|
110
|
+
| `bodySearchLimit` | `30` | 正文回退扫描的邮件数量(5-200) |
|
|
109
111
|
|
|
110
112
|
## 第一步:拿到授权码
|
|
111
113
|
|
|
@@ -129,7 +131,7 @@ dsh plugin --profile web add dsh-email
|
|
|
129
131
|
- **多账号**:每个账号独立连接池;一个 `tool-email` 行可以配任意多个账号。设置页编辑的是默认账号;`accounts` 映射仍需写 cordis.patch.yml。
|
|
130
132
|
- **附件下载**:email_attachment 按 email_read 的附件列表定位(先按文件名、再按类型+大小匹配到 IMAP 部件,定位失败会报错而不是下载错文件);内嵌图片暂不支持下载;文件名会被清洗防路径穿越,已有同名文件自动加后缀,大小受 maxAttachmentBytes 限制。
|
|
131
133
|
- **不支持 OAuth2**:强制 OAuth 的企业环境(部分 M365/Google Workspace)暂不可用。
|
|
132
|
-
-
|
|
134
|
+
- 正文搜索走客户端回退:多数服务器(如 QQ)的 IMAP `TEXT`/`HEADER` 搜索不可靠,所以服务器端只搜主题/发件人/收件人;无结果时回退到最近 `bodySearchLimit` 封的正文扫描(较慢,可关 `bodySearchFallback`)。
|
|
133
135
|
- **密码落盘形式**:设置页保存的授权码以明文写在本机 `settings.yaml`(schema 标记 secret 只是保证它不进日志/导出/诊断,不做磁盘加密)。请勿把 settings.yaml 交给不信任的人。
|
|
134
136
|
- **设置页与插件集变更**:设置页保存后工具**立即生效**(live),无需重启;但升级/增删插件(组合树变化)仍需重启 `dsh web`。
|
|
135
137
|
|
|
@@ -138,7 +140,7 @@ dsh plugin --profile web add dsh-email
|
|
|
138
140
|
```sh
|
|
139
141
|
pnpm install
|
|
140
142
|
pnpm run build # tsc → lib/
|
|
141
|
-
pnpm test # 构建 + node --test(配置/解析/注册与审批门,
|
|
143
|
+
pnpm test # 构建 + node --test(配置/解析/注册与审批门,40 个用例,无需真实邮箱)
|
|
142
144
|
```
|
|
143
145
|
|
|
144
146
|
## 协议
|
package/lib/config.d.ts
CHANGED
|
@@ -29,8 +29,12 @@ export interface EmailConfig extends AccountConfig {
|
|
|
29
29
|
accounts?: Record<string, AccountConfig>;
|
|
30
30
|
/** Which account tools use when the call omits account. Required with 2+ accounts. */
|
|
31
31
|
defaultAccount?: string;
|
|
32
|
-
/** Directory email_attachment writes into. Default $DSH_HOME/email-downloads. */
|
|
32
|
+
/** Directory email_attachment writes into. Default: the session workspace's .dsh-email-downloads (falls back to $DSH_HOME/email-downloads). */
|
|
33
33
|
downloadDir?: string;
|
|
34
|
+
/** Client-side body scan when server search finds nothing. Default true. */
|
|
35
|
+
bodySearchFallback?: boolean;
|
|
36
|
+
/** How many recent messages the body-search fallback parses. Default 30. */
|
|
37
|
+
bodySearchLimit?: number;
|
|
34
38
|
/** Per-attachment and total-attachment byte cap. Default 20 MiB. */
|
|
35
39
|
maxAttachmentBytes?: number;
|
|
36
40
|
/** Unused IMAP connections close after this many ms. Default 60000. */
|
|
@@ -74,8 +78,12 @@ export interface ResolvedEmailSettings {
|
|
|
74
78
|
sendApproval: boolean;
|
|
75
79
|
maxBodyChars: number;
|
|
76
80
|
downloadDir: string;
|
|
81
|
+
/** Whether downloadDir was set explicitly (vs. the default). */
|
|
82
|
+
downloadDirExplicit: boolean;
|
|
77
83
|
maxAttachmentBytes: number;
|
|
78
84
|
idleTimeoutMs: number;
|
|
85
|
+
bodySearchFallback: boolean;
|
|
86
|
+
bodySearchLimit: number;
|
|
79
87
|
}
|
|
80
88
|
export declare function defaultDownloadDir(): string;
|
|
81
89
|
/**
|
package/lib/config.js
CHANGED
|
@@ -67,8 +67,11 @@ export function resolveEmailSettings(config) {
|
|
|
67
67
|
sendApproval: raw.sendApproval !== false,
|
|
68
68
|
maxBodyChars: clampInt(raw.maxBodyChars, 20000, 1000, 200000),
|
|
69
69
|
downloadDir: raw.downloadDir?.trim() || defaultDownloadDir(),
|
|
70
|
+
downloadDirExplicit: (raw.downloadDir?.trim() ?? '') !== '',
|
|
70
71
|
maxAttachmentBytes: clampInt(raw.maxAttachmentBytes, DEFAULT_MAX_ATTACHMENT_BYTES, 1024, 512 * 1024 * 1024),
|
|
71
72
|
idleTimeoutMs: clampInt(raw.idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS, 5000, 600000),
|
|
73
|
+
bodySearchFallback: raw.bodySearchFallback !== false,
|
|
74
|
+
bodySearchLimit: clampInt(raw.bodySearchLimit, 30, 5, 200),
|
|
72
75
|
};
|
|
73
76
|
}
|
|
74
77
|
/** Merge one account over the shared shorthand and validate it. */
|
package/lib/index.d.ts
CHANGED
|
@@ -6,6 +6,6 @@ export declare function apply(ctx: any, config?: Config): void;
|
|
|
6
6
|
export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
|
|
7
7
|
export { resolveEmailConfig, resolveEmailSettings, clampInt, defaultDownloadDir } from './config.js';
|
|
8
8
|
export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
|
|
9
|
-
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart } from './mail-client.js';
|
|
9
|
+
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart, messageMatchesQuery } from './mail-client.js';
|
|
10
10
|
export { SETTINGS_NAMESPACE, EmailSettingsSchema, toSettingsBase, toEmailConfig, validateSettingsValue } from './settings.js';
|
|
11
11
|
export { SETTINGS_ROUTE, EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
|
package/lib/index.js
CHANGED
|
@@ -164,20 +164,6 @@ function renderFolders(value) {
|
|
|
164
164
|
function renderAttachment(value) {
|
|
165
165
|
return oneText('账号 ' + value.account + ' 已下载附件 "' + value.filename + '"(' + value.contentType + ',' + value.size + ' 字节)到:\n' + value.path + '\n可用 read 工具读取该文件。');
|
|
166
166
|
}
|
|
167
|
-
/**
|
|
168
|
-
* Best-effort read of the session's effective approval policy. Duck-typed on
|
|
169
|
-
* the permission-presets seam; any drift or absence yields undefined and the
|
|
170
|
-
* gate falls back to the plain ask flow.
|
|
171
|
-
*/
|
|
172
|
-
function effectiveApprovalPolicy(ctx, exec) {
|
|
173
|
-
try {
|
|
174
|
-
const preset = ctx.get?.('permissionPresets')?.current?.(exec?.agent?.session?.events);
|
|
175
|
-
return typeof preset?.approval === 'string' ? preset.approval : undefined;
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
return undefined;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
167
|
function fingerprintSettings(settings) {
|
|
182
168
|
return JSON.stringify({
|
|
183
169
|
accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
|
|
@@ -185,7 +171,10 @@ function fingerprintSettings(settings) {
|
|
|
185
171
|
sendApproval: settings.sendApproval,
|
|
186
172
|
maxBodyChars: settings.maxBodyChars,
|
|
187
173
|
downloadDir: settings.downloadDir,
|
|
174
|
+
downloadDirExplicit: settings.downloadDirExplicit,
|
|
188
175
|
maxAttachmentBytes: settings.maxAttachmentBytes,
|
|
176
|
+
bodySearchFallback: settings.bodySearchFallback,
|
|
177
|
+
bodySearchLimit: settings.bodySearchLimit,
|
|
189
178
|
idleTimeoutMs: settings.idleTimeoutMs,
|
|
190
179
|
});
|
|
191
180
|
}
|
|
@@ -343,13 +332,14 @@ export function apply(ctx, config = {}) {
|
|
|
343
332
|
schema: attachmentSchema,
|
|
344
333
|
render: (_args, value) => renderAttachment(value),
|
|
345
334
|
},
|
|
346
|
-
async execute(rawArgs) {
|
|
335
|
+
async execute(rawArgs, exec) {
|
|
347
336
|
const args = rawArgs;
|
|
348
337
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
349
338
|
throw new Error('uid 必须是正整数(用 email_list 获取)');
|
|
350
339
|
}
|
|
351
340
|
const index = clampInt(args.index, 0, 0, 999);
|
|
352
|
-
|
|
341
|
+
const workspaceHint = typeof exec?.agent?.session?.header?.cwd === 'string' ? exec.agent.session.header.cwd : undefined;
|
|
342
|
+
return await getPool().downloadAttachment(args.account, args.folder?.trim() || '', args.uid, index, workspaceHint);
|
|
353
343
|
},
|
|
354
344
|
});
|
|
355
345
|
// Approval gate: the user must confirm every send (recipient + subject).
|
|
@@ -372,21 +362,39 @@ export function apply(ctx, config = {}) {
|
|
|
372
362
|
const args = (exec.args ?? {});
|
|
373
363
|
const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
|
|
374
364
|
const reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
365
|
+
// Gate-owned approval: we run the approval round-trip ourselves so the
|
|
366
|
+
// denial reason is always honest and actionable — including the Full
|
|
367
|
+
// Access case where the harness policy answers 'rejected' without ever
|
|
368
|
+
// showing a dialog.
|
|
369
|
+
const approval = ctx.get('approval');
|
|
370
|
+
if (approval === undefined) {
|
|
379
371
|
return {
|
|
380
372
|
kind: 'deny',
|
|
381
|
-
reason: 'email_send
|
|
373
|
+
reason: 'email_send 需要确认,但当前环境没有审批通道(如 headless)。如确定安全,可在配置中设置 sendApproval: false 后直接发送。',
|
|
382
374
|
};
|
|
383
375
|
}
|
|
384
|
-
|
|
376
|
+
const outcome = await approval.request({
|
|
377
|
+
agent: exec.agent,
|
|
378
|
+
toolName: exec.name,
|
|
379
|
+
callId: exec.callId,
|
|
380
|
+
reason,
|
|
381
|
+
signal: exec.signal,
|
|
382
|
+
});
|
|
383
|
+
if (outcome === 'allowed-once')
|
|
384
|
+
return next();
|
|
385
|
+
if (outcome === 'cancelled')
|
|
386
|
+
return { kind: 'deny', reason: '发信确认被取消,邮件未发送。' };
|
|
387
|
+
if (outcome === 'unavailable')
|
|
388
|
+
return { kind: 'deny', reason: '发信确认不可用(没有可用的审批界面),邮件未发送。' };
|
|
389
|
+
return {
|
|
390
|
+
kind: 'deny',
|
|
391
|
+
reason: '发信未获批准:要么你拒绝了,要么当前会话处于 Full Access(审批策略 never,不会弹框)。若在 Full Access:切到 Read Only / Write 再发,或关闭 sendApproval(自行承担风险)。',
|
|
392
|
+
};
|
|
385
393
|
}, { prepend: true });
|
|
386
394
|
}
|
|
387
395
|
export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
|
|
388
396
|
export { resolveEmailConfig, resolveEmailSettings, clampInt, defaultDownloadDir } from './config.js';
|
|
389
397
|
export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
|
|
390
|
-
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart } from './mail-client.js';
|
|
398
|
+
export { EmailPool, MailError, messageOf, validateAttachmentPaths, selectAttachmentPart, messageMatchesQuery } from './mail-client.js';
|
|
391
399
|
export { SETTINGS_NAMESPACE, EmailSettingsSchema, toSettingsBase, toEmailConfig, validateSettingsValue } from './settings.js';
|
|
392
400
|
export { SETTINGS_ROUTE, EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
|
package/lib/mail-client.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ interface AttachmentPart {
|
|
|
18
18
|
* the wrong part.
|
|
19
19
|
*/
|
|
20
20
|
export declare function selectAttachmentPart(readAttachments: EmailAttachmentMeta[], parts: AttachmentPart[], index: number): AttachmentPart | undefined;
|
|
21
|
+
/** Case-insensitive match of a query against subject/from/body text. */
|
|
22
|
+
export declare function messageMatchesQuery(subject: string, fromText: string, body: string, query: string): boolean;
|
|
21
23
|
/**
|
|
22
24
|
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
23
25
|
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
@@ -44,10 +46,12 @@ export declare class EmailPool {
|
|
|
44
46
|
private transporter;
|
|
45
47
|
list(accountName: string | undefined, folder: string, limit: number, offset: number, unreadOnly: boolean): Promise<EmailListResult>;
|
|
46
48
|
search(accountName: string | undefined, query: string, folder: string, limit: number): Promise<EmailSearchResult>;
|
|
49
|
+
/** Client-side scan of the tail of the mailbox, newest first. */
|
|
50
|
+
private searchBodies;
|
|
47
51
|
private fetchListed;
|
|
48
52
|
read(accountName: string | undefined, uid: number, folder: string): Promise<EmailReadResult>;
|
|
49
53
|
folders(accountName: string | undefined, subscribedOnly: boolean): Promise<EmailFoldersResult>;
|
|
50
|
-
downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number): Promise<EmailAttachmentResult>;
|
|
54
|
+
downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number, workspaceHint?: string): Promise<EmailAttachmentResult>;
|
|
51
55
|
send(accountName: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, attachmentPaths: string[] | undefined): Promise<EmailSendResult>;
|
|
52
56
|
}
|
|
53
57
|
/** Stat every attachment path up front; total size must stay under the cap. */
|
package/lib/mail-client.js
CHANGED
|
@@ -57,6 +57,13 @@ export function selectAttachmentPart(readAttachments, parts, index) {
|
|
|
57
57
|
const byTypeAndSize = parts.find(part => part.contentType === meta.contentType && Math.abs(part.size - meta.size) <= tolerance);
|
|
58
58
|
return byTypeAndSize;
|
|
59
59
|
}
|
|
60
|
+
/** Case-insensitive match of a query against subject/from/body text. */
|
|
61
|
+
export function messageMatchesQuery(subject, fromText, body, query) {
|
|
62
|
+
const q = query.toLowerCase();
|
|
63
|
+
return subject.toLowerCase().includes(q)
|
|
64
|
+
|| fromText.toLowerCase().includes(q)
|
|
65
|
+
|| body.toLowerCase().includes(q);
|
|
66
|
+
}
|
|
60
67
|
function toIso(date) {
|
|
61
68
|
return date instanceof Date ? date.toISOString() : '';
|
|
62
69
|
}
|
|
@@ -254,10 +261,41 @@ export class EmailPool {
|
|
|
254
261
|
]);
|
|
255
262
|
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => a - b);
|
|
256
263
|
uids.reverse();
|
|
264
|
+
if (uids.length === 0 && this.settings.bodySearchFallback) {
|
|
265
|
+
// Server-side search found nothing: fall back to a client-side scan of
|
|
266
|
+
// the most recent messages (subject/from/body), capped for time.
|
|
267
|
+
const messages = await this.searchBodies(client, query, folderName, limit);
|
|
268
|
+
return { account: name, query, count: messages.length, folder: folderName, messages };
|
|
269
|
+
}
|
|
257
270
|
const messages = await this.fetchListed(client, uids.slice(0, limit));
|
|
258
271
|
return { account: name, query, count: uids.length, folder: folderName, messages };
|
|
259
272
|
});
|
|
260
273
|
}
|
|
274
|
+
/** Client-side scan of the tail of the mailbox, newest first. */
|
|
275
|
+
async searchBodies(client, query, folder, limit) {
|
|
276
|
+
const mailbox = client.mailbox;
|
|
277
|
+
const total = mailbox === false ? 0 : mailbox.exists;
|
|
278
|
+
if (total === 0)
|
|
279
|
+
return [];
|
|
280
|
+
const start = Math.max(1, total - this.settings.bodySearchLimit + 1);
|
|
281
|
+
const fetched = await client.fetchAll(start + ':*', { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, source: true }, { uid: true });
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const message of [...fetched].reverse()) {
|
|
284
|
+
if (out.length >= limit)
|
|
285
|
+
break;
|
|
286
|
+
const subject = message.envelope?.subject ?? '';
|
|
287
|
+
const fromText = flattenAddresses(message.envelope?.from).map(a => (a.name ?? '') + ' ' + (a.address ?? '')).join(' ');
|
|
288
|
+
let body = '';
|
|
289
|
+
if (message.source !== undefined) {
|
|
290
|
+
const parsed = await parseRawMessage(message.source, 4096);
|
|
291
|
+
body = parsed.text;
|
|
292
|
+
}
|
|
293
|
+
if (messageMatchesQuery(subject, fromText, body, query)) {
|
|
294
|
+
out.push(listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
261
299
|
async fetchListed(client, uids) {
|
|
262
300
|
if (uids.length === 0)
|
|
263
301
|
return [];
|
|
@@ -292,7 +330,7 @@ export class EmailPool {
|
|
|
292
330
|
return { account: name, folders };
|
|
293
331
|
});
|
|
294
332
|
}
|
|
295
|
-
async downloadAttachment(accountName, folder, uid, index) {
|
|
333
|
+
async downloadAttachment(accountName, folder, uid, index, workspaceHint) {
|
|
296
334
|
const name = this.resolveName(accountName);
|
|
297
335
|
const cfg = this.account(name);
|
|
298
336
|
const folderName = folder || cfg.inboxFolder;
|
|
@@ -320,7 +358,13 @@ export class EmailPool {
|
|
|
320
358
|
const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
|
|
321
359
|
const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes);
|
|
322
360
|
const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? body.attachments[index].filename);
|
|
323
|
-
|
|
361
|
+
// Default the destination to the session workspace so the model can
|
|
362
|
+
// read the file back; an explicit downloadDir always wins.
|
|
363
|
+
const dir = this.settings.downloadDirExplicit
|
|
364
|
+
? this.settings.downloadDir
|
|
365
|
+
: (typeof workspaceHint === 'string' && workspaceHint !== ''
|
|
366
|
+
? join(workspaceHint, '.dsh-email-downloads')
|
|
367
|
+
: this.settings.downloadDir);
|
|
324
368
|
await mkdir(dir, { recursive: true });
|
|
325
369
|
const dest = await uniquePath(join(dir, safeName));
|
|
326
370
|
await writeFile(dest, buf);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|