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 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` | 按关键词搜索发件人/收件人/主题(服务器端 IMAP SEARCH,不搜正文) |
15
+ | `email_search` | 按关键词搜索发件人/收件人/主题(服务器端);无结果时默认回退到最近 30 封的正文扫描 |
16
16
  | `email_send` | 代发邮件(支持带附件)。**默认发信前会弹确认**,显示收件人、主题和附件数,由你批准后才发出 |
17
17
  | `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
18
- | `email_attachment` | 按序号下载邮件附件到本地文件(大小受 maxAttachmentBytes 限制) |
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` | `$DSH_HOME/email-downloads` | email_attachment 的落盘目录 |
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
- - 正文搜索不提供:多数服务器(如 QQ)的 IMAP `TEXT`/`HEADER` 搜索要么全量匹配要么不支持,所以只搜主题/发件人/收件人;正文搜索列入后续版本(需客户端下载解析,较慢)。
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(配置/解析/注册与审批门,37 个用例,无需真实邮箱)
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
- return await getPool().downloadAttachment(args.account, args.folder?.trim() || '', args.uid, index);
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
- // Full Access (approval policy 'never') rejects asks before any answerer
376
- // runs, so surface the actionable explanation instead of a misleading
377
- // "the user rejected" failure.
378
- if (effectiveApprovalPolicy(ctx, exec) === 'never') {
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 需要确认,但当前会话处于 Full Access(审批策略 never),不会弹出确认框。两条路:① 把访问模式切回 Read Only 或 Write 再发;② 在设置页关闭「发信前确认」(sendApproval: false),自行承担风险。',
373
+ reason: 'email_send 需要确认,但当前环境没有审批通道(如 headless)。如确定安全,可在配置中设置 sendApproval: false 后直接发送。',
382
374
  };
383
375
  }
384
- return { kind: 'ask', reason };
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';
@@ -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. */
@@ -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
- const dir = this.settings.downloadDir;
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.4.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",