dsh-email 0.8.2 → 0.9.1

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
@@ -20,6 +20,13 @@ Pure Node, **cross-platform** (one codebase for Windows / macOS / Linux), no she
20
20
  | `email_send` | Send mail on your behalf (attachments supported). **Prompts for confirmation before sending by default**, showing recipients, subject and attachment count; only sends after you approve |
21
21
  | `email_folders` | List the mailbox folders (INBOX/Sent/Junk/custom…); feed the `path` to other tools |
22
22
  | `email_attachment` | Download an attachment by index (saved to the session workspace by default so the model can read it directly; size capped by `maxAttachmentBytes`) |
23
+ | `email_watch` | Incremental new-mail check: the first call seeds a baseline, every later call reports only unread mail newer than the last check — ideal for scheduled new-mail notifications |
24
+
25
+ ### New-mail notifications (web UI)
26
+
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
+
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).
23
30
 
24
31
  Example:
25
32
 
@@ -27,6 +34,7 @@ Example:
27
34
 
28
35
  ### Changelog
29
36
 
37
+ - **0.9.0**: new `email_watch` incremental new-mail tool (cursor-based, ideal for scheduled notifications); new "whale-girl courier" new-mail popup in the web UI (local skin artwork read at runtime + built-in fallback).
30
38
  - **0.8.2**: `since` / `until` parameter descriptions unified to English, consistent with the other parameters, so multilingual agents read them correctly.
31
39
  - **0.8.0/0.8.1**: `email_list` / `email_search` gained `since` / `until` date-range filters; new `email_health` self-check (account/connection/config in one call); adapted to harness 0.1.2 (removed the deleted client-injection declaration).
32
40
  - **0.6.2**: server-side search covers `cc` (subject / sender / recipients / CC); the body fallback scan also matches `to` / `cc` and one malformed message no longer aborts the batch; lists are UID-descending (newest first); `email_send` strictly validates attachment paths.
@@ -34,7 +42,7 @@ Example:
34
42
 
35
43
  ## Compatibility
36
44
 
37
- 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.
38
46
 
39
47
  ## Installation
40
48
 
package/README.md CHANGED
@@ -28,6 +28,13 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
28
28
  | `email_send` | 代发邮件(支持带附件)。**默认发信前会弹确认**,显示收件人、主题和附件数,由你批准后才发出 |
29
29
  | `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
30
30
  | `email_attachment` | 按序号下载邮件附件(默认存到会话工作区,模型可直接读取;大小受 maxAttachmentBytes 限制) |
31
+ | `email_watch` | 增量检查新邮件:首次调用建立基线,之后每次只报告比上次多出来的未读邮件,适合定时任务做新邮件提醒 |
32
+
33
+ ### 新邮件提醒(Web 端)
34
+
35
+ 配置好账号后,主界面右下角会出现「鲸鱼娘递信」小弹窗:每 30 秒检查一次新邮件,有新邮件时弹出卡片(发件人 + 主题),12 秒自动消失。弹窗与 `email_watch` 工具共用同一套游标逻辑但各自独立计数,互不抢占。
36
+
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(署名-非商业性使用-相同方式共享)发布,弹窗内附完整署名链。未安装皮肤时使用内置的社区鲸鱼娘形象(版权归原作者,仅供个人非商业使用;如有异议请提 Issue,会立即移除)。
31
38
 
32
39
  示例对话:
33
40
 
@@ -35,6 +42,8 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
35
42
 
36
43
  ### 版本记录
37
44
 
45
+ - **0.9.1**:修复设置页空主机遮蔽 provider 预设(#3/#6);IMAP 连接超时不再杀死整个 DSH 进程(#4);暗色模式输入控件可见(#2);密码栏提示环境变量 `DSH_EMAIL_PASSWORD` 免明文方案(#5)。
46
+ - **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
38
47
  - **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
39
48
  - **0.8.0/0.8.1**:`email_list` / `email_search` 新增 `since` / `until` 日期范围过滤;新增 `email_health` 自检(账号/连接/配置一键体检);适配 harness 0.1.2(清理已删除的客户端注入声明)。
40
49
  - **0.6.2**:服务器端搜索补齐 `cc`,搜索范围真正覆盖主题 / 发件人 / 收件人 / 抄送;正文回退扫描也匹配 `to` / `cc`,单封解析失败不中断整批;列表强制 UID 降序「最新在前」;`email_send` 附件参数严格校验。
@@ -42,7 +51,7 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
42
51
 
43
52
  ## 兼容性
44
53
 
45
- 在 `@deepseek-ai/dsh@0.1.2-alpha.2` 上验证(2026-08-31)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
54
+ 在 `@deepseek-ai/dsh@0.1.2-alpha.3` 上实测通过(2026-09-01,设置页/素材路由/email_watch 全回归)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
46
55
 
47
56
  ## 安装
48
57
 
@@ -181,4 +190,4 @@ MIT。这是一个社区插件,与 DeepSeek 官方无关;`@deepseek-ai/*`
181
190
 
182
191
  - [dsh-slack](https://github.com/STARDUSTLC666/dsh-slack) — Slack 通知/收件箱
183
192
  - [dsh-dingtalk](https://github.com/STARDUSTLC666/dsh-dingtalk) — 钉钉群通知(零依赖)
184
- - [dsh-email](https://github.com/STARDUSTLC666/dsh-email) — 邮件六件套 + Web 设置页
193
+ - [dsh-email](https://github.com/STARDUSTLC666/dsh-email) — 邮件八件套 + Web 设置页 + 新邮件弹窗
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}",
@@ -72,6 +73,15 @@ const CSS = [
72
73
  ".dshe-alert.info{background:rgba(11,108,159,.08);color:#0b5c86}",
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}",
76
+ ".dshe-whale-root{position:fixed;right:20px;bottom:20px;z-index:2147483000;pointer-events:none}",
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}",
78
+ "@keyframes dshe-whale-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}",
79
+ ".dshe-whale-img{display:block;width:100%;height:120px;object-fit:cover;object-position:center top;background:#eaf3f8}",
80
+ ".dshe-whale-body{padding:10px 12px 12px;display:grid;gap:6px}",
81
+ ".dshe-whale-title{font-weight:700;font-size:13px}",
82
+ ".dshe-whale-item{color:var(--dsw-alias-fg-muted,#77736d);font-size:12px;line-height:1.45;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
83
+ ".dshe-whale-credit{font-size:10px;color:var(--dsw-alias-fg-muted,#77736d);opacity:.8;line-height:1.4}",
84
+ ".dshe-whale-close{position:absolute;top:6px;right:6px;width:22px;height:22px;border-radius:50%;border:none;background:rgba(0,0,0,.35);color:#fff;cursor:pointer;font-size:12px;line-height:1}",
75
85
  ].join("\n");
76
86
 
77
87
  function fieldInput(type, value, onChange, placeholder) {
@@ -210,6 +220,7 @@ function EmailSettingsSection() {
210
220
  placeholder: "不是登录密码;QQ/163 在邮箱设置里生成授权码",
211
221
  onChange: (e) => update({ password: e.target.value }),
212
222
  }),
223
+ h("div", { className: "dshe-hint" }, "不想明文保存?此栏留空,改用环境变量 DSH_EMAIL_PASSWORD(仅单账号时生效)。"),
213
224
  ]),
214
225
  h("div", { className: "dshe-field" }, [
215
226
  h("label", null, "收件文件夹(默认 INBOX)"),
@@ -296,6 +307,99 @@ function EmailSettingsSection() {
296
307
  ]);
297
308
  }
298
309
 
310
+ // Whale-girl courier popup: polls the same-origin watch route every 30s.
311
+ // The first poll seeds the baseline silently; later polls pop a card only
312
+ // when newCount > 0. The artwork URL/credit come from the settings snapshot
313
+ // (skin artwork is served from the user's local dsh-deep-whale install with
314
+ // its CC BY-NC-SA attribution chain; otherwise a built-in fallback is used).
315
+ function startWhaleWidget() {
316
+ if (document.querySelector("#dsh-email-whale-root")) return () => {};
317
+ const root = document.createElement("div");
318
+ root.id = "dsh-email-whale-root";
319
+ root.className = "dshe-whale-root";
320
+ document.body.appendChild(root);
321
+ let pollTimer = 0;
322
+ let hideTimer = 0;
323
+ let whaleUrl = "";
324
+ let whaleCredit = "";
325
+
326
+ const closePopup = () => {
327
+ if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0; }
328
+ root.replaceChildren();
329
+ };
330
+
331
+ const showPopup = (value) => {
332
+ closePopup();
333
+ const card = document.createElement("div");
334
+ card.className = "dshe-whale-card";
335
+ if (whaleUrl) {
336
+ const img = document.createElement("img");
337
+ img.className = "dshe-whale-img";
338
+ img.src = whaleUrl;
339
+ img.alt = "";
340
+ img.onerror = () => { img.remove(); };
341
+ card.appendChild(img);
342
+ }
343
+ const body = document.createElement("div");
344
+ body.className = "dshe-whale-body";
345
+ const title = document.createElement("div");
346
+ title.className = "dshe-whale-title";
347
+ title.textContent = "鲸鱼娘递信:有 " + value.newCount + " 封新邮件";
348
+ body.appendChild(title);
349
+ (value.messages || []).slice(0, 3).forEach((m) => {
350
+ const row = document.createElement("div");
351
+ row.className = "dshe-whale-item";
352
+ const who = (m.from || []).map((a) => a.name || a.address).filter(Boolean).join(", ") || "(未知)";
353
+ row.textContent = who + " · " + (m.subject || "(无主题)");
354
+ body.appendChild(row);
355
+ });
356
+ if (value.newCount > 3) {
357
+ const more = document.createElement("div");
358
+ more.className = "dshe-whale-item";
359
+ more.textContent = "…其余 " + (value.newCount - 3) + " 封,用 email_watch / email_read 查看";
360
+ body.appendChild(more);
361
+ }
362
+ if (whaleCredit) {
363
+ const credit = document.createElement("div");
364
+ credit.className = "dshe-whale-credit";
365
+ credit.textContent = whaleCredit;
366
+ body.appendChild(credit);
367
+ }
368
+ card.appendChild(body);
369
+ const closeBtn = document.createElement("button");
370
+ closeBtn.className = "dshe-whale-close";
371
+ closeBtn.textContent = "\u2715";
372
+ closeBtn.setAttribute("aria-label", "关闭");
373
+ closeBtn.onclick = closePopup;
374
+ card.appendChild(closeBtn);
375
+ root.appendChild(card);
376
+ hideTimer = setTimeout(closePopup, 12000);
377
+ };
378
+
379
+ const tick = async () => {
380
+ try {
381
+ const snap = await api();
382
+ if (snap && snap.whale) {
383
+ whaleUrl = snap.whale.url || "";
384
+ whaleCredit = snap.whale.credit || "";
385
+ }
386
+ if (!snap || !snap.accounts || snap.accounts.length === 0) return;
387
+ const value = await api("watch", { limit: 5 });
388
+ if (value && value.newCount > 0) showPopup(value);
389
+ } catch (e) {
390
+ // Not configured or transient error: stay silent, retry next tick.
391
+ }
392
+ };
393
+
394
+ tick();
395
+ pollTimer = setInterval(tick, 30000);
396
+ return () => {
397
+ clearInterval(pollTimer);
398
+ if (hideTimer) clearTimeout(hideTimer);
399
+ root.remove();
400
+ };
401
+ }
402
+
299
403
  const inject = ["slots"];
300
404
 
301
405
  function apply(ctx) {
@@ -310,6 +414,8 @@ function apply(ctx) {
310
414
  return () => { style.remove(); };
311
415
  }, "dsh-email: styles");
312
416
 
417
+ ctx.effect(() => startWhaleWidget(), "dsh-email: whale courier");
418
+
313
419
  ctx.slots.inject("settings.section", () => ctx.slots.register({
314
420
  name: "settings.section",
315
421
  id: "dsh-email",
package/lib/index.js CHANGED
@@ -246,7 +246,8 @@ export function apply(ctx, config = {}) {
246
246
  pool?.dispose();
247
247
  pool = null;
248
248
  });
249
- installEmailSettingsWeb(ctx, new EmailSettingsBackend(ctx, settingsScope, config));
249
+ const backend = new EmailSettingsBackend(ctx, settingsScope, config);
250
+ installEmailSettingsWeb(ctx, backend);
250
251
  ctx.tools.register({
251
252
  name: 'email_list',
252
253
  description: 'List recent emails in a mailbox folder (newest first). Returns uid, date, sender, subject and flags without message bodies; use email_read with a uid to fetch the full text. Optional since/until (dates like 2026-08-01) filter by received date.',
@@ -417,6 +418,75 @@ export function apply(ctx, config = {}) {
417
418
  return await getPool().downloadAttachment(args.account, args.folder?.trim() || '', args.uid, index, workspaceHint);
418
419
  },
419
420
  });
421
+ // Incremental watch: per scope+account+folder we remember the highest uid
422
+ // already reported. The first call seeds the baseline (firstRun=true,
423
+ // nothing counts as new), later calls report only unseen unread messages.
424
+ // The 'tool' and 'web' scopes keep independent cursors, so the browser
425
+ // popup and the email_watch tool never consume each other's new mail. The
426
+ // same core is exposed to the browser widget via action: watch.
427
+ const watchCursors = new Map();
428
+ const watchCore = async (account, folder, limit, scope) => {
429
+ const capped = clampInt(limit, 20, 1, MAX_LIMIT);
430
+ const result = await getPool().list(account, folder, MAX_LIMIT, 0, true);
431
+ const key = scope + '\u0000' + result.account + '\u0000' + result.folder;
432
+ const isFirst = !watchCursors.has(key);
433
+ const cursor = watchCursors.get(key) ?? 0;
434
+ const fresh = result.messages.filter(m => m.uid > cursor);
435
+ if (result.messages.length > 0) {
436
+ watchCursors.set(key, Math.max(cursor, ...result.messages.map(m => m.uid)));
437
+ }
438
+ else if (isFirst) {
439
+ watchCursors.set(key, 0);
440
+ }
441
+ return {
442
+ account: result.account,
443
+ folder: result.folder,
444
+ firstRun: isFirst,
445
+ newCount: isFirst ? 0 : fresh.length,
446
+ messages: (isFirst ? [] : fresh).slice(0, capped),
447
+ totalUnread: result.count,
448
+ };
449
+ };
450
+ backend.watchImpl = watchCore;
451
+ function renderWatch(value) {
452
+ if (value.firstRun) {
453
+ return oneText('账号 ' + value.account + ':已建立新邮件监视基线(当前未读 ' + value.totalUnread + ' 封)。之后调用 email_watch 只会报告新到的邮件。');
454
+ }
455
+ if (value.newCount === 0) {
456
+ return oneText('账号 ' + value.account + ':没有新邮件(当前未读 ' + value.totalUnread + ' 封)。');
457
+ }
458
+ const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
459
+ return oneText('账号 ' + value.account + ' 有 ' + value.newCount + ' 封新邮件:\n\n' + lines.join('\n') + '\n\n用 email_read 配合 uid 阅读全文。');
460
+ }
461
+ ctx.tools.register({
462
+ name: 'email_watch',
463
+ description: 'Check for NEW unread emails since the last check (cursor-based). The first call per account+folder sets the baseline and reports nothing as new; every later call returns only unseen unread messages. Call this periodically (e.g. via a scheduled task) to notify the user about new mail. Returns newCount, the new messages, and totalUnread.',
464
+ parameters: compileParameters({
465
+ folder: { type: 'string', description: 'IMAP folder path (see email_folders); defaults to the account inboxFolder' },
466
+ limit: { type: 'integer', description: 'Max number of new messages to return, 1-100, default 20' },
467
+ account: { type: 'string', description: ACCOUNT_HINT },
468
+ }),
469
+ output: {
470
+ schema: {
471
+ type: 'object',
472
+ properties: {
473
+ account: { type: 'string' },
474
+ folder: { type: 'string' },
475
+ firstRun: { type: 'boolean' },
476
+ newCount: { type: 'integer' },
477
+ totalUnread: { type: 'integer' },
478
+ messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
479
+ },
480
+ additionalProperties: true,
481
+ },
482
+ render: (_args, value) => renderWatch(value),
483
+ },
484
+ async execute(rawArgs) {
485
+ const args = rawArgs;
486
+ const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
487
+ return await watchCore(args.account?.trim() || '', args.folder?.trim() || '', limit, 'tool');
488
+ },
489
+ });
420
490
  // Approval gate: the user must confirm every send (recipient + subject).
421
491
  // Runs before other listeners; degrades to the tool-time failure only
422
492
  // when the account itself is not configured yet.
@@ -120,7 +120,7 @@ export class EmailPool {
120
120
  return this.enqueue(name, () => this.imapRun(name, cfg, folder, 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,6 +130,19 @@ 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
147
  async imapRun(name, cfg, folder, run) {
135
148
  let entry = this.imaps.get(name);
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
@@ -119,3 +119,19 @@ export interface EmailAttachmentArgs extends AccountArg {
119
119
  index?: number;
120
120
  folder?: string;
121
121
  }
122
+ export interface EmailWatchArgs extends AccountArg {
123
+ folder?: string;
124
+ /** Max number of new messages to return per call, default 20. */
125
+ limit?: number;
126
+ }
127
+ export interface EmailWatchResult {
128
+ account: string;
129
+ folder: string;
130
+ /** True on the first call for this account+folder: it sets the baseline. */
131
+ firstRun: boolean;
132
+ /** Unread messages never reported before (empty on firstRun). */
133
+ newCount: number;
134
+ messages: ListedMessage[];
135
+ /** Total unread in the folder right now. */
136
+ totalUnread: number;
137
+ }
package/lib/web.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { type EmailSettingsValue } from './settings.js';
2
2
  import { type EmailConfig } from './config.js';
3
+ import type { EmailWatchResult } from './types.js';
3
4
  /** Same-origin route the browser settings section talks to. */
4
5
  export declare const SETTINGS_ROUTE = "/_dsh/dsh-email/settings";
6
+ /** Same-origin route serving the whale-girl courier image to the widget. */
7
+ export declare const WHALE_ASSET_ROUTE = "/_dsh/dsh-email/assets/whale";
5
8
  /**
6
9
  * Browser-facing backend: snapshot the settings namespace, save it with
7
10
  * optimistic concurrency, and test a draft account over a live IMAP login.
@@ -11,6 +14,8 @@ export declare class EmailSettingsBackend {
11
14
  private readonly scope;
12
15
  private readonly rowConfig;
13
16
  constructor(ctx: any, scope: any, rowConfig: EmailConfig);
17
+ /** Wired by apply(): the email_watch core; 'web' keeps its own cursor scope. */
18
+ watchImpl?: (account: string, folder: string, limit: number, scope: string) => Promise<EmailWatchResult>;
14
19
  private userSection;
15
20
  /** Effective config for the stored value (row + user-set fields only). */
16
21
  private effectiveStored;
@@ -22,6 +27,11 @@ export declare class EmailSettingsBackend {
22
27
  };
23
28
  writable: boolean;
24
29
  accounts: string[];
30
+ whale: {
31
+ url: string;
32
+ skin: boolean;
33
+ credit: string;
34
+ };
25
35
  }>;
26
36
  private effectiveAccounts;
27
37
  save(value: EmailSettingsValue, expectedRevision: number): Promise<{
@@ -32,6 +42,11 @@ export declare class EmailSettingsBackend {
32
42
  };
33
43
  writable: boolean;
34
44
  accounts: string[];
45
+ whale: {
46
+ url: string;
47
+ skin: boolean;
48
+ credit: string;
49
+ };
35
50
  }>;
36
51
  test(value: EmailSettingsValue): Promise<{
37
52
  ok: boolean;
@@ -39,6 +54,8 @@ export declare class EmailSettingsBackend {
39
54
  }>;
40
55
  responseJson(res: any, status: number, body: unknown): void;
41
56
  handle(req: any, res: any): Promise<void>;
57
+ /** GET-only localhost route serving the whale-girl courier image. */
58
+ handleAsset(req: any, res: any): void;
42
59
  }
43
- /** Mount the same-origin route when a webServer service is present. */
60
+ /** Mount the same-origin routes when a webServer service is present. */
44
61
  export declare function installEmailSettingsWeb(ctx: any, backend: EmailSettingsBackend): void;
package/lib/web.js CHANGED
@@ -1,8 +1,74 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import { SETTINGS_NAMESPACE, toEmailConfig, validateSettingsValue } from './settings.js';
2
5
  import { resolveEmailSettings } from './config.js';
3
6
  import { EmailPool, messageOf } from './mail-client.js';
4
7
  /** Same-origin route the browser settings section talks to. */
5
8
  export const SETTINGS_ROUTE = '/_dsh/dsh-email/settings';
9
+ /** Same-origin route serving the whale-girl courier image to the widget. */
10
+ export const WHALE_ASSET_ROUTE = '/_dsh/dsh-email/assets/whale';
11
+ const SKIN_PACKAGES = [
12
+ '@dsh-external/dsh-client-ui-skin-orca-link',
13
+ '@dsh-external/dsh-client-ui-skin-maid-atelier',
14
+ ];
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 移除';
18
+ let whaleCache;
19
+ function pickFromDir(dir) {
20
+ let names = [];
21
+ try {
22
+ names = readdirSync(dir);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ const images = names.filter(name => /\.(webp|png|jpe?g)$/i.test(name));
28
+ if (images.length === 0)
29
+ return null;
30
+ const preferred = images.find(name => /^light-hero/i.test(name)) ?? images.find(name => /^light-active/i.test(name));
31
+ return join(dir, preferred ?? images[0]);
32
+ }
33
+ function contentTypeOf(file) {
34
+ if (/\.webp$/i.test(file))
35
+ return 'image/webp';
36
+ if (/\.jpe?g$/i.test(file))
37
+ return 'image/jpeg';
38
+ return 'image/png';
39
+ }
40
+ /**
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).
47
+ */
48
+ function findWhaleAsset() {
49
+ if (whaleCache !== undefined)
50
+ return whaleCache;
51
+ whaleCache = null;
52
+ const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
53
+ const roots = [process.cwd(), dirname(packageDir)];
54
+ for (const root of roots) {
55
+ for (const pkg of SKIN_PACKAGES) {
56
+ for (const base of [join(root, 'node_modules', pkg, 'assets'), join(root, pkg, 'assets')]) {
57
+ const file = pickFromDir(base);
58
+ if (file !== null) {
59
+ whaleCache = { file, contentType: contentTypeOf(file), skin: true, credit: WHALE_CREDIT };
60
+ return whaleCache;
61
+ }
62
+ }
63
+ }
64
+ }
65
+ const fallback = join(packageDir, 'assets', 'whale-fallback.png');
66
+ if (existsSync(fallback)) {
67
+ whaleCache = { file: fallback, contentType: 'image/png', skin: false, credit: FALLBACK_CREDIT };
68
+ return whaleCache;
69
+ }
70
+ return whaleCache;
71
+ }
6
72
  /**
7
73
  * Browser-facing backend: snapshot the settings namespace, save it with
8
74
  * optimistic concurrency, and test a draft account over a live IMAP login.
@@ -16,6 +82,8 @@ export class EmailSettingsBackend {
16
82
  this.scope = scope;
17
83
  this.rowConfig = rowConfig;
18
84
  }
85
+ /** Wired by apply(): the email_watch core; 'web' keeps its own cursor scope. */
86
+ watchImpl;
19
87
  userSection() {
20
88
  const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
21
89
  return descriptor?.user;
@@ -27,6 +95,7 @@ export class EmailSettingsBackend {
27
95
  async snapshot() {
28
96
  const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
29
97
  const value = this.scope.get();
98
+ const whale = findWhaleAsset();
30
99
  return {
31
100
  settings: {
32
101
  value,
@@ -35,6 +104,9 @@ export class EmailSettingsBackend {
35
104
  },
36
105
  writable: this.ctx.settings.writable !== false,
37
106
  accounts: [...(this.effectiveAccounts().keys())],
107
+ whale: whale === null
108
+ ? { url: '', skin: false, credit: '' }
109
+ : { url: WHALE_ASSET_ROUTE, skin: whale.skin, credit: whale.credit },
38
110
  };
39
111
  }
40
112
  effectiveAccounts() {
@@ -62,6 +134,16 @@ export class EmailSettingsBackend {
62
134
  await pool.withImap(settings.defaultAccount, null, async () => 'connected');
63
135
  return { ok: true, ms: Date.now() - started };
64
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
+ }
65
147
  finally {
66
148
  pool.dispose();
67
149
  }
@@ -121,6 +203,14 @@ export class EmailSettingsBackend {
121
203
  else if (body?.action === 'test') {
122
204
  this.responseJson(res, 200, { ok: true, value: await this.test(body.value) });
123
205
  }
206
+ else if (body?.action === 'watch') {
207
+ if (typeof this.watchImpl !== 'function')
208
+ throw new Error('email_watch 未就绪');
209
+ const account = typeof body.account === 'string' ? body.account : '';
210
+ const folder = typeof body.folder === 'string' ? body.folder : '';
211
+ const limit = Number.isSafeInteger(body.limit) ? body.limit : 5;
212
+ this.responseJson(res, 200, { ok: true, value: await this.watchImpl(account, folder, limit, 'web') });
213
+ }
124
214
  else {
125
215
  this.responseJson(res, 400, { ok: false, error: { code: 'invalid-request', message: 'unsupported action' } });
126
216
  }
@@ -133,8 +223,39 @@ export class EmailSettingsBackend {
133
223
  });
134
224
  }
135
225
  }
226
+ /** GET-only localhost route serving the whale-girl courier image. */
227
+ handleAsset(req, res) {
228
+ const remote = String(req.socket?.remoteAddress ?? '');
229
+ if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') {
230
+ this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-email asset route is localhost-only' } });
231
+ return;
232
+ }
233
+ if (req.method !== 'GET') {
234
+ res.setHeader('Allow', 'GET');
235
+ this.responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } });
236
+ return;
237
+ }
238
+ const asset = findWhaleAsset();
239
+ if (asset === null) {
240
+ this.responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'no whale asset available' } });
241
+ return;
242
+ }
243
+ try {
244
+ const bytes = readFileSync(asset.file);
245
+ res.setHeader('Content-Type', asset.contentType);
246
+ res.setHeader('Content-Length', String(bytes.length));
247
+ res.setHeader('Cache-Control', 'no-store');
248
+ // No credit header: HTTP header values must be latin-1, and the widget
249
+ // already shows the credit from the snapshot JSON.
250
+ res.writeHead(200);
251
+ res.end(bytes);
252
+ }
253
+ catch (error) {
254
+ this.responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: messageOf(error, 'unknown error') } });
255
+ }
256
+ }
136
257
  }
137
- /** Mount the same-origin route when a webServer service is present. */
258
+ /** Mount the same-origin routes when a webServer service is present. */
138
259
  export function installEmailSettingsWeb(ctx, backend) {
139
260
  ctx.inject(['webServer'], (webCtx) => {
140
261
  webCtx.effect(() => {
@@ -143,7 +264,15 @@ export function installEmailSettingsWeb(ctx, backend) {
143
264
  path: SETTINGS_ROUTE,
144
265
  handler: (req, res) => backend.handle(req, res),
145
266
  });
146
- return () => dispose();
147
- }, 'dsh-email: web route');
267
+ const disposeAsset = webCtx.webServer.register({
268
+ kind: 'exact',
269
+ path: WHALE_ASSET_ROUTE,
270
+ handler: (req, res) => backend.handleAsset(req, res),
271
+ });
272
+ return () => {
273
+ dispose();
274
+ disposeAsset();
275
+ };
276
+ }, 'dsh-email: web routes');
148
277
  });
149
278
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
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",
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "files": [
18
18
  "lib",
19
+ "assets/whale-fallback.png",
19
20
  "cordis.patch.yml",
20
21
  "README.md"
21
22
  ],