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 +9 -1
- package/README.md +11 -2
- package/assets/whale-fallback.png +0 -0
- package/lib/client.js +108 -2
- package/lib/index.js +71 -1
- package/lib/mail-client.js +14 -1
- package/lib/settings.js +4 -2
- package/lib/types.d.ts +16 -0
- package/lib/web.d.ts +18 -1
- package/lib/web.js +132 -3
- package/package.json +2 -1
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.
|
|
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.
|
|
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) —
|
|
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:
|
|
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
|
-
|
|
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.
|
package/lib/mail-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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.
|
|
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
|
],
|