dsh-email 0.9.1 → 0.10.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
@@ -29,6 +29,7 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
29
29
  | `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
30
30
  | `email_attachment` | 按序号下载邮件附件(默认存到会话工作区,模型可直接读取;大小受 maxAttachmentBytes 限制) |
31
31
  | `email_watch` | 增量检查新邮件:首次调用建立基线,之后每次只报告比上次多出来的未读邮件,适合定时任务做新邮件提醒 |
32
+ | `email_mark` | 修改邮件状态:标记已读/未读、加/取消星标,或移动到别的文件夹(归档、丢回收站),收发闭环的「收完之后」那一半 |
32
33
 
33
34
  ### 新邮件提醒(Web 端)
34
35
 
@@ -42,6 +43,7 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
42
43
 
43
44
  ### 版本记录
44
45
 
46
+ - **0.10.0**:新增 `email_mark` 工具——标记已读/未读、加/取消星标、移动文件夹(归档/整理),补齐收发闭环;连接池按读/写模式分别管理邮箱打开状态。
45
47
  - **0.9.1**:修复设置页空主机遮蔽 provider 预设(#3/#6);IMAP 连接超时不再杀死整个 DSH 进程(#4);暗色模式输入控件可见(#2);密码栏提示环境变量 `DSH_EMAIL_PASSWORD` 免明文方案(#5)。
46
48
  - **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
47
49
  - **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
package/lib/index.js CHANGED
@@ -137,6 +137,21 @@ const attachmentSchema = {
137
137
  },
138
138
  additionalProperties: true,
139
139
  };
140
+ const markSchema = {
141
+ type: 'object',
142
+ properties: {
143
+ account: { type: 'string' },
144
+ uid: { type: 'integer' },
145
+ folder: { type: 'string' },
146
+ action: { type: 'string' },
147
+ seen: { type: 'boolean' },
148
+ flagged: { type: 'boolean' },
149
+ movedTo: { type: 'string' },
150
+ movedUid: { type: 'integer' },
151
+ },
152
+ additionalProperties: true,
153
+ };
154
+ const MARK_ACTIONS = ['read', 'unread', 'star', 'unstar', 'move'];
140
155
  function oneText(text) {
141
156
  return [{ type: 'text', text }];
142
157
  }
@@ -194,6 +209,23 @@ function renderFolders(value) {
194
209
  function renderAttachment(value) {
195
210
  return oneText('账号 ' + value.account + ' 已下载附件 "' + value.filename + '"(' + value.contentType + ',' + value.size + ' 字节)到:\n' + value.path + '\n可用 read 工具读取该文件。');
196
211
  }
212
+ const MARK_LABELS = {
213
+ read: '标记为已读',
214
+ unread: '标记为未读',
215
+ star: '加星标',
216
+ unstar: '取消星标',
217
+ move: '移动',
218
+ };
219
+ function renderMark(value) {
220
+ let text = '账号 ' + value.account + ':文件夹 "' + value.folder + '" 中 uid=' + value.uid + ' 已' + (MARK_LABELS[value.action] ?? value.action);
221
+ if (value.action === 'move') {
222
+ text += '到 "' + (value.movedTo ?? '') + '"' + (typeof value.movedUid === 'number' ? '(新 uid=' + value.movedUid + ')' : '');
223
+ }
224
+ else {
225
+ text += '(当前:' + (value.seen ? '已读' : '未读') + (value.flagged ? '、已标星' : '') + ')';
226
+ }
227
+ return oneText(text);
228
+ }
197
229
  function fingerprintSettings(settings) {
198
230
  return JSON.stringify({
199
231
  accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
@@ -293,6 +325,35 @@ export function apply(ctx, config = {}) {
293
325
  return await getPool().read(args.account, args.uid, args.folder?.trim() || '');
294
326
  },
295
327
  });
328
+ ctx.tools.register({
329
+ name: 'email_mark',
330
+ description: 'Change an existing message: mark it read/unread, star/unstar it, or move it to another folder. Use after email_list/email_search when the user wants to tidy the mailbox (archive, clear unread, flag important mail). Moving uses the server MOVE/COPY so the uid changes; the new uid is reported when the server provides it.',
331
+ parameters: compileParameters({
332
+ uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
333
+ action: { type: 'string', required: true, description: 'What to do: read, unread, star, unstar, or move' },
334
+ toFolder: { type: 'string', description: 'Destination folder path for action=move (see email_folders for valid paths)' },
335
+ folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
336
+ account: { type: 'string', description: ACCOUNT_HINT },
337
+ }),
338
+ output: {
339
+ schema: markSchema,
340
+ render: (_args, value) => renderMark(value),
341
+ },
342
+ async execute(rawArgs) {
343
+ const args = rawArgs;
344
+ if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
345
+ throw new Error('uid 必须是正整数(用 email_list 获取)');
346
+ }
347
+ const action = (typeof args.action === 'string' ? args.action.trim().toLowerCase() : '');
348
+ if (!MARK_ACTIONS.includes(action)) {
349
+ throw new Error('action 必须是 ' + MARK_ACTIONS.join('、') + ' 之一');
350
+ }
351
+ if (action === 'move' && (typeof args.toFolder !== 'string' || args.toFolder.trim() === '')) {
352
+ throw new Error('action=move 时需要 toFolder 参数(用 email_folders 查看可用文件夹)');
353
+ }
354
+ return await getPool().mark(args.account, args.folder?.trim() || '', args.uid, action, args.toFolder);
355
+ },
356
+ });
296
357
  ctx.tools.register({
297
358
  name: 'email_search',
298
359
  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.',
@@ -1,6 +1,6 @@
1
1
  import { ImapFlow } from 'imapflow';
2
2
  import type { ResolvedEmailConfig, ResolvedEmailSettings } from './config.js';
3
- import type { EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailReadResult, EmailSearchResult, EmailSendResult } from './types.js';
3
+ import type { EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailMarkAction, EmailMarkResult, EmailReadResult, EmailSearchResult, EmailSendResult } from './types.js';
4
4
  export declare class MailError extends Error {
5
5
  constructor(message: string);
6
6
  }
@@ -35,7 +35,7 @@ export declare class EmailPool {
35
35
  resolveName(name?: string): string;
36
36
  /** Serialize operations per account: one IMAP connection serves one op at a time. */
37
37
  private enqueue;
38
- withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>): Promise<T>;
38
+ withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>, readOnly?: boolean): Promise<T>;
39
39
  private createImap;
40
40
  private imapRun;
41
41
  private normalizeImapError;
@@ -50,6 +50,7 @@ export declare class EmailPool {
50
50
  private searchBodies;
51
51
  private fetchListed;
52
52
  read(accountName: string | undefined, uid: number, folder: string): Promise<EmailReadResult>;
53
+ mark(accountName: string | undefined, folder: string, uid: number, action: EmailMarkAction, toFolder?: string): Promise<EmailMarkResult>;
53
54
  folders(accountName: string | undefined, subscribedOnly: boolean): Promise<EmailFoldersResult>;
54
55
  downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number, workspaceHint?: string): Promise<EmailAttachmentResult>;
55
56
  send(accountName: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, attachmentPaths: string[] | undefined): Promise<EmailSendResult>;
@@ -114,10 +114,10 @@ export class EmailPool {
114
114
  this.queues.set(name, next.then(() => undefined, () => undefined));
115
115
  return next;
116
116
  }
117
- async withImap(accountName, folder, run) {
117
+ async withImap(accountName, folder, run, readOnly = true) {
118
118
  const name = this.resolveName(accountName);
119
119
  const cfg = this.account(name);
120
- return this.enqueue(name, () => this.imapRun(name, cfg, folder, run));
120
+ return this.enqueue(name, () => this.imapRun(name, cfg, folder, readOnly, run));
121
121
  }
122
122
  createImap(cfg) {
123
123
  const client = new ImapFlow({
@@ -144,7 +144,7 @@ export class EmailPool {
144
144
  });
145
145
  return client;
146
146
  }
147
- async imapRun(name, cfg, folder, run) {
147
+ async imapRun(name, cfg, folder, readOnly, run) {
148
148
  let entry = this.imaps.get(name);
149
149
  try {
150
150
  if (entry === undefined || !entry.client.usable) {
@@ -152,14 +152,17 @@ export class EmailPool {
152
152
  await this.evictImap(name);
153
153
  const client = this.createImap(cfg);
154
154
  await client.connect();
155
- entry = { client, selected: null, lastUsed: Date.now(), inUse: 0 };
155
+ entry = { client, selected: null, selectedReadOnly: true, lastUsed: Date.now(), inUse: 0 };
156
156
  this.imaps.set(name, entry);
157
157
  }
158
158
  entry.lastUsed = Date.now();
159
159
  entry.inUse += 1;
160
- if (folder !== null && entry.selected !== folder) {
161
- await entry.client.mailboxOpen(folder, { readOnly: true });
160
+ // Reopen when the folder changes or when the caller needs a different
161
+ // access mode (email_mark writes flags / moves messages).
162
+ if (folder !== null && (entry.selected !== folder || entry.selectedReadOnly !== readOnly)) {
163
+ await entry.client.mailboxOpen(folder, { readOnly });
162
164
  entry.selected = folder;
165
+ entry.selectedReadOnly = readOnly;
163
166
  }
164
167
  const result = await run(entry.client);
165
168
  entry.lastUsed = Date.now();
@@ -355,6 +358,55 @@ export class EmailPool {
355
358
  return { account: name, uid, folder: folderName, ...body };
356
359
  });
357
360
  }
361
+ async mark(accountName, folder, uid, action, toFolder) {
362
+ const name = this.resolveName(accountName);
363
+ const cfg = this.account(name);
364
+ const folderName = folder || cfg.inboxFolder;
365
+ return this.withImap(name, folderName, async (client) => {
366
+ const before = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
367
+ if (before === false) {
368
+ throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
369
+ }
370
+ let seen = before.flags?.has('\\Seen') === true;
371
+ let flagged = before.flags?.has('\\Flagged') === true;
372
+ if (action === 'read' && !seen) {
373
+ await client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
374
+ seen = true;
375
+ }
376
+ else if (action === 'unread' && seen) {
377
+ await client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
378
+ seen = false;
379
+ }
380
+ else if (action === 'star' && !flagged) {
381
+ await client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
382
+ flagged = true;
383
+ }
384
+ else if (action === 'unstar' && flagged) {
385
+ await client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
386
+ flagged = false;
387
+ }
388
+ else if (action === 'move') {
389
+ const target = (toFolder ?? '').trim();
390
+ if (target === '')
391
+ throw new MailError('move 操作需要 toFolder 参数(用 email_folders 查看可用文件夹)');
392
+ if (target === folderName)
393
+ throw new MailError('邮件已在文件夹 "' + folderName + '" 中,无需移动');
394
+ const folders = await client.list();
395
+ if (!folders.some(row => row.path === target)) {
396
+ throw new MailError('找不到目标文件夹 "' + target + '",可用:' + folders.map(row => row.path).join('、'));
397
+ }
398
+ const moved = await client.messageMove(uid, target, { uid: true });
399
+ if (moved === false)
400
+ throw new MailError('移动 uid=' + uid + ' 到 "' + target + '" 失败(服务器拒绝了 MOVE/COPY)');
401
+ const result = { account: name, uid, folder: folderName, action, seen, flagged, movedTo: target };
402
+ const destUid = moved?.destinationUid;
403
+ if (typeof destUid === 'number')
404
+ result.movedUid = destUid;
405
+ return result;
406
+ }
407
+ return { account: name, uid, folder: folderName, action, seen, flagged };
408
+ }, false);
409
+ }
358
410
  async folders(accountName, subscribedOnly) {
359
411
  const name = this.resolveName(accountName);
360
412
  return this.withImap(name, null, async (client) => {
package/lib/types.d.ts CHANGED
@@ -124,6 +124,26 @@ export interface EmailWatchArgs extends AccountArg {
124
124
  /** Max number of new messages to return per call, default 20. */
125
125
  limit?: number;
126
126
  }
127
+ export type EmailMarkAction = 'read' | 'unread' | 'star' | 'unstar' | 'move';
128
+ export interface EmailMarkArgs extends AccountArg {
129
+ uid: number;
130
+ action: EmailMarkAction;
131
+ /** Target folder path; only used (and required) when action is 'move'. */
132
+ toFolder?: string;
133
+ folder?: string;
134
+ }
135
+ export interface EmailMarkResult {
136
+ account: string;
137
+ uid: number;
138
+ folder: string;
139
+ action: EmailMarkAction;
140
+ seen: boolean;
141
+ flagged: boolean;
142
+ /** Set after a successful move: the destination folder path. */
143
+ movedTo?: string;
144
+ /** Set after a successful move: uid in the destination when the server reports it. */
145
+ movedUid?: number;
146
+ }
127
147
  export interface EmailWatchResult {
128
148
  account: string;
129
149
  folder: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",