dsh-email 0.9.0 → 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.en.md CHANGED
@@ -26,7 +26,7 @@ Pure Node, **cross-platform** (one codebase for Windows / macOS / Linux), no she
26
26
 
27
27
  Once an account is configured, a "whale-girl courier" popup lives in the bottom-right corner of the main UI: it checks for new mail every 30 seconds and shows a card (sender + subject) when something arrives, auto-dismissing after 12 seconds. The popup shares the cursor logic with `email_watch` but keeps its own counter, so they never consume each other's mail.
28
28
 
29
- The popup artwork prefers the locally installed [dsh-deep-whale](https://github.com/Small-tailqwq/dsh-deep-whale) whale-girl skin assets (**never bundled** — read at runtime from your own installation): the artwork is a derivative of the original whale-girl character by [上善](https://www.pixiv.net/users/62155430) (skin by Small-tailqwq), published under CC BY-NC-SA 4.0 (Attribution-NonCommercial-ShareAlike); the popup shows the full attribution chain. A built-in fallback image is used when no skin is installed.
29
+ The popup artwork prefers the locally installed [dsh-deep-whale](https://github.com/Small-tailqwq/dsh-deep-whale) whale-girl skin assets (**never bundled** — read at runtime from your own installation): the artwork is a derivative of the original whale-girl character by [上善](https://www.pixiv.net/users/62155430) (skin by Small-tailqwq), published under CC BY-NC-SA 4.0 (Attribution-NonCommercial-ShareAlike); the popup shows the full attribution chain. Without the skin, a bundled community whale-girl artwork is used (copyright stays with the original author, personal non-commercial use only; removed on request via issue).
30
30
 
31
31
  Example:
32
32
 
@@ -42,7 +42,7 @@ Example:
42
42
 
43
43
  ## Compatibility
44
44
 
45
- Verified against `@deepseek-ai/dsh@0.1.2-alpha.2` on 2026-08-31. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
45
+ Verified against `@deepseek-ai/dsh@0.1.2-alpha.3` on 2026-09-01 (settings page / asset route / email_watch fully regression-tested). Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
46
46
 
47
47
  ## Installation
48
48
 
package/README.md CHANGED
@@ -29,12 +29,13 @@ 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
 
35
36
  配置好账号后,主界面右下角会出现「鲸鱼娘递信」小弹窗:每 30 秒检查一次新邮件,有新邮件时弹出卡片(发件人 + 主题),12 秒自动消失。弹窗与 `email_watch` 工具共用同一套游标逻辑但各自独立计数,互不抢占。
36
37
 
37
- 弹窗形象优先使用本地安装的 [dsh-deep-whale](https://github.com/Small-tailqwq/dsh-deep-whale) 鲸鱼娘皮肤素材(**不打包分发**,运行时从你自己的安装目录读取):该素材为一创 [上善](https://www.pixiv.net/users/62155430) 鲸鱼娘形象的衍生创作(二创 Small-tailqwq),以 CC BY-NC-SA 4.0(署名-非商业性使用-相同方式共享)发布,弹窗内附完整署名链。未安装皮肤时使用内置回退图。
38
+ 弹窗形象优先使用本地安装的 [dsh-deep-whale](https://github.com/Small-tailqwq/dsh-deep-whale) 鲸鱼娘皮肤素材(**不打包分发**,运行时从你自己的安装目录读取):该素材为一创 [上善](https://www.pixiv.net/users/62155430) 鲸鱼娘形象的衍生创作(二创 Small-tailqwq),以 CC BY-NC-SA 4.0(署名-非商业性使用-相同方式共享)发布,弹窗内附完整署名链。未安装皮肤时使用内置的社区鲸鱼娘形象(版权归原作者,仅供个人非商业使用;如有异议请提 Issue,会立即移除)。
38
39
 
39
40
  示例对话:
40
41
 
@@ -42,6 +43,8 @@ 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` 工具——标记已读/未读、加/取消星标、移动文件夹(归档/整理),补齐收发闭环;连接池按读/写模式分别管理邮箱打开状态。
47
+ - **0.9.1**:修复设置页空主机遮蔽 provider 预设(#3/#6);IMAP 连接超时不再杀死整个 DSH 进程(#4);暗色模式输入控件可见(#2);密码栏提示环境变量 `DSH_EMAIL_PASSWORD` 免明文方案(#5)。
45
48
  - **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
46
49
  - **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
47
50
  - **0.8.0/0.8.1**:`email_list` / `email_search` 新增 `since` / `until` 日期范围过滤;新增 `email_health` 自检(账号/连接/配置一键体检);适配 harness 0.1.2(清理已删除的客户端注入声明)。
@@ -50,7 +53,7 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
50
53
 
51
54
  ## 兼容性
52
55
 
53
- 在 `@deepseek-ai/dsh@0.1.2-alpha.2` 上验证(2026-08-31)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
56
+ 在 `@deepseek-ai/dsh@0.1.2-alpha.3` 上实测通过(2026-09-01,设置页/素材路由/email_watch 全回归)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
54
57
 
55
58
  ## 安装
56
59
 
Binary file
package/lib/client.js CHANGED
@@ -51,7 +51,7 @@ const EMPTY = {
51
51
  };
52
52
 
53
53
  const CSS = [
54
- ".dshe-settings{display:grid;gap:14px;max-width:900px;padding:8px 2px 32px;color:var(--dsw-alias-fg-primary,#26231f)}",
54
+ ".dshe-settings{display:grid;gap:14px;max-width:900px;padding:8px 2px 32px;color:var(--dsw-alias-fg-primary,#26231f);color-scheme:light dark}",
55
55
  ".dshe-header{display:grid;gap:4px;padding:8px 2px}",
56
56
  ".dshe-header h2{font-size:22px;letter-spacing:-.02em;margin:0}",
57
57
  ".dshe-header p{max-width:640px;margin:4px 0 0;color:var(--dsw-alias-fg-muted,#77736d);font-size:13px;line-height:1.55}",
@@ -60,7 +60,8 @@ const CSS = [
60
60
  ".dshe-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}",
61
61
  ".dshe-field{display:grid;gap:6px}",
62
62
  ".dshe-field label{font-size:12px;font-weight:600;color:var(--dsw-alias-fg-primary,#26231f)}",
63
- ".dshe-field input[type=text],.dshe-field input[type=password],.dshe-field input[type=number],.dshe-field select{width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--dsw-alias-border-subtle,#dedbd5);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#fff);color:inherit;font:inherit;font-size:13px}",
63
+ ".dshe-field input[type=text],.dshe-field input[type=password],.dshe-field input[type=number],.dshe-field select,.dshe-field textarea{width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--dsw-alias-border-subtle,#dedbd5);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-fg-primary,#26231f);font:inherit;font-size:13px}",
64
+ ".dshe-field select option{background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-fg-primary,#26231f)}",
64
65
  ".dshe-check{display:flex;gap:8px;align-items:center;font-size:13px}",
65
66
  ".dshe-actions{display:flex;gap:8px;flex-wrap:wrap}",
66
67
  ".dshe-btn{display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--dsw-alias-border-subtle,#dedbd5);background:var(--dsw-alias-bg-layer-1,#fff);color:inherit;font-size:13px;font-weight:600;cursor:pointer}",
@@ -73,7 +74,7 @@ const CSS = [
73
74
  ".dshe-details summary{font-size:12px;font-weight:600;cursor:pointer;color:var(--dsw-alias-fg-muted,#77736d)}",
74
75
  ".dshe-hint{font-size:12px;color:var(--dsw-alias-fg-muted,#77736d);line-height:1.5}",
75
76
  ".dshe-whale-root{position:fixed;right:20px;bottom:20px;z-index:2147483000;pointer-events:none}",
76
- ".dshe-whale-card{pointer-events:auto;position:relative;width:300px;border:1px solid var(--dsw-alias-border-subtle,#dedbd5);border-radius:14px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 10px 30px rgba(0,0,0,.18);overflow:hidden;animation:dshe-whale-in .35s ease-out;font-size:13px;color:var(--dsw-alias-fg-primary,#26231f)}",
77
+ ".dshe-whale-card{pointer-events:auto;position:relative;width:300px;border:1px solid var(--dsw-alias-border-subtle,#dedbd5);border-radius:14px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 10px 30px rgba(0,0,0,.18);overflow:hidden;animation:dshe-whale-in .35s ease-out;font-size:13px;color:var(--dsw-alias-fg-primary,#26231f);color-scheme:light dark}",
77
78
  "@keyframes dshe-whale-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}",
78
79
  ".dshe-whale-img{display:block;width:100%;height:120px;object-fit:cover;object-position:center top;background:#eaf3f8}",
79
80
  ".dshe-whale-body{padding:10px 12px 12px;display:grid;gap:6px}",
@@ -219,6 +220,7 @@ function EmailSettingsSection() {
219
220
  placeholder: "不是登录密码;QQ/163 在邮箱设置里生成授权码",
220
221
  onChange: (e) => update({ password: e.target.value }),
221
222
  }),
223
+ h("div", { className: "dshe-hint" }, "不想明文保存?此栏留空,改用环境变量 DSH_EMAIL_PASSWORD(仅单账号时生效)。"),
222
224
  ]),
223
225
  h("div", { className: "dshe-field" }, [
224
226
  h("label", null, "收件文件夹(默认 INBOX)"),
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,13 +114,13 @@ 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
- return new ImapFlow({
123
+ const client = new ImapFlow({
124
124
  host: cfg.imap.host,
125
125
  port: cfg.imap.port,
126
126
  secure: cfg.imap.secure,
@@ -130,8 +130,21 @@ export class EmailPool {
130
130
  greetingTimeout: 30000,
131
131
  socketTimeout: cfg.imap.socketTimeoutMs ?? 60000,
132
132
  });
133
+ // ImapFlow emits 'error' on socket timeouts/drops; without a listener Node
134
+ // escalates it to an uncaught exception and kills the whole DSH process
135
+ // (issue #4). Swallow it here and reap the dead connection when idle —
136
+ // in-flight calls fail through their own promise paths instead.
137
+ client.on('error', () => {
138
+ for (const [name, entry] of this.imaps) {
139
+ if (entry.client === client && entry.inUse === 0) {
140
+ void this.evictImap(name);
141
+ return;
142
+ }
143
+ }
144
+ });
145
+ return client;
133
146
  }
134
- async imapRun(name, cfg, folder, run) {
147
+ async imapRun(name, cfg, folder, readOnly, run) {
135
148
  let entry = this.imaps.get(name);
136
149
  try {
137
150
  if (entry === undefined || !entry.client.usable) {
@@ -139,14 +152,17 @@ export class EmailPool {
139
152
  await this.evictImap(name);
140
153
  const client = this.createImap(cfg);
141
154
  await client.connect();
142
- entry = { client, selected: null, lastUsed: Date.now(), inUse: 0 };
155
+ entry = { client, selected: null, selectedReadOnly: true, lastUsed: Date.now(), inUse: 0 };
143
156
  this.imaps.set(name, entry);
144
157
  }
145
158
  entry.lastUsed = Date.now();
146
159
  entry.inUse += 1;
147
- if (folder !== null && entry.selected !== folder) {
148
- 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 });
149
164
  entry.selected = folder;
165
+ entry.selectedReadOnly = readOnly;
150
166
  }
151
167
  const result = await run(entry.client);
152
168
  entry.lastUsed = Date.now();
@@ -342,6 +358,55 @@ export class EmailPool {
342
358
  return { account: name, uid, folder: folderName, ...body };
343
359
  });
344
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
+ }
345
410
  async folders(accountName, subscribedOnly) {
346
411
  const name = this.resolveName(accountName);
347
412
  return this.withImap(name, null, async (client) => {
package/lib/settings.js CHANGED
@@ -83,7 +83,9 @@ export function toEmailConfig(value, user) {
83
83
  if (user === null || user?.imap !== undefined) {
84
84
  const fields = user === null ? value.imap : (user.imap ?? {});
85
85
  const imap = {};
86
- if (fields.host !== undefined)
86
+ // Empty host means "use the provider preset" — never project it, or the
87
+ // preset gets shadowed by '' (issues #3 / #6).
88
+ if (fields.host !== undefined && value.imap.host !== '')
87
89
  imap.host = value.imap.host;
88
90
  if (fields.port !== undefined)
89
91
  imap.port = value.imap.port;
@@ -94,7 +96,7 @@ export function toEmailConfig(value, user) {
94
96
  if (user === null || user?.smtp !== undefined) {
95
97
  const fields = user === null ? value.smtp : (user.smtp ?? {});
96
98
  const smtp = {};
97
- if (fields.host !== undefined)
99
+ if (fields.host !== undefined && value.smtp.host !== '')
98
100
  smtp.host = value.smtp.host;
99
101
  if (fields.port !== undefined)
100
102
  smtp.port = value.smtp.port;
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/lib/web.js CHANGED
@@ -13,6 +13,8 @@ const SKIN_PACKAGES = [
13
13
  '@dsh-external/dsh-client-ui-skin-maid-atelier',
14
14
  ];
15
15
  const WHALE_CREDIT = '鲸鱼娘:一创 上善(pixiv 62155430)· 二创 Small-tailqwq / dsh-deep-whale · CC BY-NC-SA 4.0(非商业)';
16
+ /** Credit shown with the bundled fallback artwork (community fan character). */
17
+ const FALLBACK_CREDIT = '鲸鱼娘:社区同人形象,版权归原作者,仅供个人非商业使用;如有异议请提 Issue 移除';
16
18
  let whaleCache;
17
19
  function pickFromDir(dir) {
18
20
  let names = [];
@@ -36,10 +38,12 @@ function contentTypeOf(file) {
36
38
  return 'image/png';
37
39
  }
38
40
  /**
39
- * Locate the whale-girl artwork at runtime. It is never bundled: the skin art
40
- * is CC BY-NC-SA 4.0, so dsh-email only serves it from the user's own
41
- * installed dsh-deep-whale skin package (with the attribution chain shown in
42
- * the widget); otherwise the bundled MIT fallback is served.
41
+ * Locate the whale-girl artwork at runtime. Skin art is never bundled: it is
42
+ * CC BY-NC-SA 4.0, so dsh-email only serves it from the user's own installed
43
+ * dsh-deep-whale skin package (with the attribution chain shown in the
44
+ * widget). Without the skin, a bundled community whale-girl artwork is
45
+ * served with its own credit line (copyright stays with the original author;
46
+ * personal non-commercial use, removed on request via issue).
43
47
  */
44
48
  function findWhaleAsset() {
45
49
  if (whaleCache !== undefined)
@@ -60,7 +64,7 @@ function findWhaleAsset() {
60
64
  }
61
65
  const fallback = join(packageDir, 'assets', 'whale-fallback.png');
62
66
  if (existsSync(fallback)) {
63
- whaleCache = { file: fallback, contentType: 'image/png', skin: false, credit: '' };
67
+ whaleCache = { file: fallback, contentType: 'image/png', skin: false, credit: FALLBACK_CREDIT };
64
68
  return whaleCache;
65
69
  }
66
70
  return whaleCache;
@@ -130,6 +134,16 @@ export class EmailSettingsBackend {
130
134
  await pool.withImap(settings.defaultAccount, null, async () => 'connected');
131
135
  return { ok: true, ms: Date.now() - started };
132
136
  }
137
+ catch (error) {
138
+ // imapflow reports failed LOGIN as a bare "Command failed"; surface an
139
+ // actionable hint instead of the opaque message.
140
+ const raw = messageOf(error, 'unknown error');
141
+ const lower = raw.toLowerCase();
142
+ if (lower.includes('command failed') || lower.includes('authentication') || lower.includes('login')) {
143
+ throw new Error('邮箱登录失败:请检查邮箱地址与授权码(' + raw + ')');
144
+ }
145
+ throw error;
146
+ }
133
147
  finally {
134
148
  pool.dispose();
135
149
  }
@@ -231,8 +245,8 @@ export class EmailSettingsBackend {
231
245
  res.setHeader('Content-Type', asset.contentType);
232
246
  res.setHeader('Content-Length', String(bytes.length));
233
247
  res.setHeader('Cache-Control', 'no-store');
234
- if (asset.credit !== '')
235
- res.setHeader('X-Whale-Credit', asset.credit);
248
+ // No credit header: HTTP header values must be latin-1, and the widget
249
+ // already shows the credit from the snapshot JSON.
236
250
  res.writeHead(200);
237
251
  res.end(bytes);
238
252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.9.0",
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",