dsh-email 0.8.2 → 0.9.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 +8 -0
- package/README.md +9 -1
- package/assets/whale-fallback.png +0 -0
- package/lib/client.js +104 -0
- package/lib/index.js +71 -1
- package/lib/types.d.ts +16 -0
- package/lib/web.d.ts +18 -1
- package/lib/web.js +118 -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. A built-in fallback image is used when no skin is installed.
|
|
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.
|
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(署名-非商业性使用-相同方式共享)发布,弹窗内附完整署名链。未安装皮肤时使用内置回退图。
|
|
31
38
|
|
|
32
39
|
示例对话:
|
|
33
40
|
|
|
@@ -35,6 +42,7 @@ Email tools for DeepSeek Harness: list, read, search and send mail through stand
|
|
|
35
42
|
|
|
36
43
|
### 版本记录
|
|
37
44
|
|
|
45
|
+
- **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
|
|
38
46
|
- **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
|
|
39
47
|
- **0.8.0/0.8.1**:`email_list` / `email_search` 新增 `since` / `until` 日期范围过滤;新增 `email_health` 自检(账号/连接/配置一键体检);适配 harness 0.1.2(清理已删除的客户端注入声明)。
|
|
40
48
|
- **0.6.2**:服务器端搜索补齐 `cc`,搜索范围真正覆盖主题 / 发件人 / 收件人 / 抄送;正文回退扫描也匹配 `to` / `cc`,单封解析失败不中断整批;列表强制 UID 降序「最新在前」;`email_send` 附件参数严格校验。
|
|
@@ -181,4 +189,4 @@ MIT。这是一个社区插件,与 DeepSeek 官方无关;`@deepseek-ai/*`
|
|
|
181
189
|
|
|
182
190
|
- [dsh-slack](https://github.com/STARDUSTLC666/dsh-slack) — Slack 通知/收件箱
|
|
183
191
|
- [dsh-dingtalk](https://github.com/STARDUSTLC666/dsh-dingtalk) — 钉钉群通知(零依赖)
|
|
184
|
-
- [dsh-email](https://github.com/STARDUSTLC666/dsh-email) —
|
|
192
|
+
- [dsh-email](https://github.com/STARDUSTLC666/dsh-email) — 邮件八件套 + Web 设置页 + 新邮件弹窗
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -72,6 +72,15 @@ const CSS = [
|
|
|
72
72
|
".dshe-alert.info{background:rgba(11,108,159,.08);color:#0b5c86}",
|
|
73
73
|
".dshe-details summary{font-size:12px;font-weight:600;cursor:pointer;color:var(--dsw-alias-fg-muted,#77736d)}",
|
|
74
74
|
".dshe-hint{font-size:12px;color:var(--dsw-alias-fg-muted,#77736d);line-height:1.5}",
|
|
75
|
+
".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
|
+
"@keyframes dshe-whale-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}",
|
|
78
|
+
".dshe-whale-img{display:block;width:100%;height:120px;object-fit:cover;object-position:center top;background:#eaf3f8}",
|
|
79
|
+
".dshe-whale-body{padding:10px 12px 12px;display:grid;gap:6px}",
|
|
80
|
+
".dshe-whale-title{font-weight:700;font-size:13px}",
|
|
81
|
+
".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}",
|
|
82
|
+
".dshe-whale-credit{font-size:10px;color:var(--dsw-alias-fg-muted,#77736d);opacity:.8;line-height:1.4}",
|
|
83
|
+
".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
84
|
].join("\n");
|
|
76
85
|
|
|
77
86
|
function fieldInput(type, value, onChange, placeholder) {
|
|
@@ -296,6 +305,99 @@ function EmailSettingsSection() {
|
|
|
296
305
|
]);
|
|
297
306
|
}
|
|
298
307
|
|
|
308
|
+
// Whale-girl courier popup: polls the same-origin watch route every 30s.
|
|
309
|
+
// The first poll seeds the baseline silently; later polls pop a card only
|
|
310
|
+
// when newCount > 0. The artwork URL/credit come from the settings snapshot
|
|
311
|
+
// (skin artwork is served from the user's local dsh-deep-whale install with
|
|
312
|
+
// its CC BY-NC-SA attribution chain; otherwise a built-in fallback is used).
|
|
313
|
+
function startWhaleWidget() {
|
|
314
|
+
if (document.querySelector("#dsh-email-whale-root")) return () => {};
|
|
315
|
+
const root = document.createElement("div");
|
|
316
|
+
root.id = "dsh-email-whale-root";
|
|
317
|
+
root.className = "dshe-whale-root";
|
|
318
|
+
document.body.appendChild(root);
|
|
319
|
+
let pollTimer = 0;
|
|
320
|
+
let hideTimer = 0;
|
|
321
|
+
let whaleUrl = "";
|
|
322
|
+
let whaleCredit = "";
|
|
323
|
+
|
|
324
|
+
const closePopup = () => {
|
|
325
|
+
if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0; }
|
|
326
|
+
root.replaceChildren();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const showPopup = (value) => {
|
|
330
|
+
closePopup();
|
|
331
|
+
const card = document.createElement("div");
|
|
332
|
+
card.className = "dshe-whale-card";
|
|
333
|
+
if (whaleUrl) {
|
|
334
|
+
const img = document.createElement("img");
|
|
335
|
+
img.className = "dshe-whale-img";
|
|
336
|
+
img.src = whaleUrl;
|
|
337
|
+
img.alt = "";
|
|
338
|
+
img.onerror = () => { img.remove(); };
|
|
339
|
+
card.appendChild(img);
|
|
340
|
+
}
|
|
341
|
+
const body = document.createElement("div");
|
|
342
|
+
body.className = "dshe-whale-body";
|
|
343
|
+
const title = document.createElement("div");
|
|
344
|
+
title.className = "dshe-whale-title";
|
|
345
|
+
title.textContent = "鲸鱼娘递信:有 " + value.newCount + " 封新邮件";
|
|
346
|
+
body.appendChild(title);
|
|
347
|
+
(value.messages || []).slice(0, 3).forEach((m) => {
|
|
348
|
+
const row = document.createElement("div");
|
|
349
|
+
row.className = "dshe-whale-item";
|
|
350
|
+
const who = (m.from || []).map((a) => a.name || a.address).filter(Boolean).join(", ") || "(未知)";
|
|
351
|
+
row.textContent = who + " · " + (m.subject || "(无主题)");
|
|
352
|
+
body.appendChild(row);
|
|
353
|
+
});
|
|
354
|
+
if (value.newCount > 3) {
|
|
355
|
+
const more = document.createElement("div");
|
|
356
|
+
more.className = "dshe-whale-item";
|
|
357
|
+
more.textContent = "…其余 " + (value.newCount - 3) + " 封,用 email_watch / email_read 查看";
|
|
358
|
+
body.appendChild(more);
|
|
359
|
+
}
|
|
360
|
+
if (whaleCredit) {
|
|
361
|
+
const credit = document.createElement("div");
|
|
362
|
+
credit.className = "dshe-whale-credit";
|
|
363
|
+
credit.textContent = whaleCredit;
|
|
364
|
+
body.appendChild(credit);
|
|
365
|
+
}
|
|
366
|
+
card.appendChild(body);
|
|
367
|
+
const closeBtn = document.createElement("button");
|
|
368
|
+
closeBtn.className = "dshe-whale-close";
|
|
369
|
+
closeBtn.textContent = "\u2715";
|
|
370
|
+
closeBtn.setAttribute("aria-label", "关闭");
|
|
371
|
+
closeBtn.onclick = closePopup;
|
|
372
|
+
card.appendChild(closeBtn);
|
|
373
|
+
root.appendChild(card);
|
|
374
|
+
hideTimer = setTimeout(closePopup, 12000);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const tick = async () => {
|
|
378
|
+
try {
|
|
379
|
+
const snap = await api();
|
|
380
|
+
if (snap && snap.whale) {
|
|
381
|
+
whaleUrl = snap.whale.url || "";
|
|
382
|
+
whaleCredit = snap.whale.credit || "";
|
|
383
|
+
}
|
|
384
|
+
if (!snap || !snap.accounts || snap.accounts.length === 0) return;
|
|
385
|
+
const value = await api("watch", { limit: 5 });
|
|
386
|
+
if (value && value.newCount > 0) showPopup(value);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
// Not configured or transient error: stay silent, retry next tick.
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
tick();
|
|
393
|
+
pollTimer = setInterval(tick, 30000);
|
|
394
|
+
return () => {
|
|
395
|
+
clearInterval(pollTimer);
|
|
396
|
+
if (hideTimer) clearTimeout(hideTimer);
|
|
397
|
+
root.remove();
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
299
401
|
const inject = ["slots"];
|
|
300
402
|
|
|
301
403
|
function apply(ctx) {
|
|
@@ -310,6 +412,8 @@ function apply(ctx) {
|
|
|
310
412
|
return () => { style.remove(); };
|
|
311
413
|
}, "dsh-email: styles");
|
|
312
414
|
|
|
415
|
+
ctx.effect(() => startWhaleWidget(), "dsh-email: whale courier");
|
|
416
|
+
|
|
313
417
|
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
314
418
|
name: "settings.section",
|
|
315
419
|
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/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,70 @@
|
|
|
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
|
+
let whaleCache;
|
|
17
|
+
function pickFromDir(dir) {
|
|
18
|
+
let names = [];
|
|
19
|
+
try {
|
|
20
|
+
names = readdirSync(dir);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const images = names.filter(name => /\.(webp|png|jpe?g)$/i.test(name));
|
|
26
|
+
if (images.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const preferred = images.find(name => /^light-hero/i.test(name)) ?? images.find(name => /^light-active/i.test(name));
|
|
29
|
+
return join(dir, preferred ?? images[0]);
|
|
30
|
+
}
|
|
31
|
+
function contentTypeOf(file) {
|
|
32
|
+
if (/\.webp$/i.test(file))
|
|
33
|
+
return 'image/webp';
|
|
34
|
+
if (/\.jpe?g$/i.test(file))
|
|
35
|
+
return 'image/jpeg';
|
|
36
|
+
return 'image/png';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
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.
|
|
43
|
+
*/
|
|
44
|
+
function findWhaleAsset() {
|
|
45
|
+
if (whaleCache !== undefined)
|
|
46
|
+
return whaleCache;
|
|
47
|
+
whaleCache = null;
|
|
48
|
+
const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
49
|
+
const roots = [process.cwd(), dirname(packageDir)];
|
|
50
|
+
for (const root of roots) {
|
|
51
|
+
for (const pkg of SKIN_PACKAGES) {
|
|
52
|
+
for (const base of [join(root, 'node_modules', pkg, 'assets'), join(root, pkg, 'assets')]) {
|
|
53
|
+
const file = pickFromDir(base);
|
|
54
|
+
if (file !== null) {
|
|
55
|
+
whaleCache = { file, contentType: contentTypeOf(file), skin: true, credit: WHALE_CREDIT };
|
|
56
|
+
return whaleCache;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const fallback = join(packageDir, 'assets', 'whale-fallback.png');
|
|
62
|
+
if (existsSync(fallback)) {
|
|
63
|
+
whaleCache = { file: fallback, contentType: 'image/png', skin: false, credit: '' };
|
|
64
|
+
return whaleCache;
|
|
65
|
+
}
|
|
66
|
+
return whaleCache;
|
|
67
|
+
}
|
|
6
68
|
/**
|
|
7
69
|
* Browser-facing backend: snapshot the settings namespace, save it with
|
|
8
70
|
* optimistic concurrency, and test a draft account over a live IMAP login.
|
|
@@ -16,6 +78,8 @@ export class EmailSettingsBackend {
|
|
|
16
78
|
this.scope = scope;
|
|
17
79
|
this.rowConfig = rowConfig;
|
|
18
80
|
}
|
|
81
|
+
/** Wired by apply(): the email_watch core; 'web' keeps its own cursor scope. */
|
|
82
|
+
watchImpl;
|
|
19
83
|
userSection() {
|
|
20
84
|
const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
|
|
21
85
|
return descriptor?.user;
|
|
@@ -27,6 +91,7 @@ export class EmailSettingsBackend {
|
|
|
27
91
|
async snapshot() {
|
|
28
92
|
const descriptor = (this.ctx.settings.describe?.() ?? []).find((row) => row.ns === SETTINGS_NAMESPACE);
|
|
29
93
|
const value = this.scope.get();
|
|
94
|
+
const whale = findWhaleAsset();
|
|
30
95
|
return {
|
|
31
96
|
settings: {
|
|
32
97
|
value,
|
|
@@ -35,6 +100,9 @@ export class EmailSettingsBackend {
|
|
|
35
100
|
},
|
|
36
101
|
writable: this.ctx.settings.writable !== false,
|
|
37
102
|
accounts: [...(this.effectiveAccounts().keys())],
|
|
103
|
+
whale: whale === null
|
|
104
|
+
? { url: '', skin: false, credit: '' }
|
|
105
|
+
: { url: WHALE_ASSET_ROUTE, skin: whale.skin, credit: whale.credit },
|
|
38
106
|
};
|
|
39
107
|
}
|
|
40
108
|
effectiveAccounts() {
|
|
@@ -121,6 +189,14 @@ export class EmailSettingsBackend {
|
|
|
121
189
|
else if (body?.action === 'test') {
|
|
122
190
|
this.responseJson(res, 200, { ok: true, value: await this.test(body.value) });
|
|
123
191
|
}
|
|
192
|
+
else if (body?.action === 'watch') {
|
|
193
|
+
if (typeof this.watchImpl !== 'function')
|
|
194
|
+
throw new Error('email_watch 未就绪');
|
|
195
|
+
const account = typeof body.account === 'string' ? body.account : '';
|
|
196
|
+
const folder = typeof body.folder === 'string' ? body.folder : '';
|
|
197
|
+
const limit = Number.isSafeInteger(body.limit) ? body.limit : 5;
|
|
198
|
+
this.responseJson(res, 200, { ok: true, value: await this.watchImpl(account, folder, limit, 'web') });
|
|
199
|
+
}
|
|
124
200
|
else {
|
|
125
201
|
this.responseJson(res, 400, { ok: false, error: { code: 'invalid-request', message: 'unsupported action' } });
|
|
126
202
|
}
|
|
@@ -133,8 +209,39 @@ export class EmailSettingsBackend {
|
|
|
133
209
|
});
|
|
134
210
|
}
|
|
135
211
|
}
|
|
212
|
+
/** GET-only localhost route serving the whale-girl courier image. */
|
|
213
|
+
handleAsset(req, res) {
|
|
214
|
+
const remote = String(req.socket?.remoteAddress ?? '');
|
|
215
|
+
if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') {
|
|
216
|
+
this.responseJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-email asset route is localhost-only' } });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (req.method !== 'GET') {
|
|
220
|
+
res.setHeader('Allow', 'GET');
|
|
221
|
+
this.responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const asset = findWhaleAsset();
|
|
225
|
+
if (asset === null) {
|
|
226
|
+
this.responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'no whale asset available' } });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const bytes = readFileSync(asset.file);
|
|
231
|
+
res.setHeader('Content-Type', asset.contentType);
|
|
232
|
+
res.setHeader('Content-Length', String(bytes.length));
|
|
233
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
234
|
+
if (asset.credit !== '')
|
|
235
|
+
res.setHeader('X-Whale-Credit', asset.credit);
|
|
236
|
+
res.writeHead(200);
|
|
237
|
+
res.end(bytes);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
this.responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: messageOf(error, 'unknown error') } });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
136
243
|
}
|
|
137
|
-
/** Mount the same-origin
|
|
244
|
+
/** Mount the same-origin routes when a webServer service is present. */
|
|
138
245
|
export function installEmailSettingsWeb(ctx, backend) {
|
|
139
246
|
ctx.inject(['webServer'], (webCtx) => {
|
|
140
247
|
webCtx.effect(() => {
|
|
@@ -143,7 +250,15 @@ export function installEmailSettingsWeb(ctx, backend) {
|
|
|
143
250
|
path: SETTINGS_ROUTE,
|
|
144
251
|
handler: (req, res) => backend.handle(req, res),
|
|
145
252
|
});
|
|
146
|
-
|
|
147
|
-
|
|
253
|
+
const disposeAsset = webCtx.webServer.register({
|
|
254
|
+
kind: 'exact',
|
|
255
|
+
path: WHALE_ASSET_ROUTE,
|
|
256
|
+
handler: (req, res) => backend.handleAsset(req, res),
|
|
257
|
+
});
|
|
258
|
+
return () => {
|
|
259
|
+
dispose();
|
|
260
|
+
disposeAsset();
|
|
261
|
+
};
|
|
262
|
+
}, 'dsh-email: web routes');
|
|
148
263
|
});
|
|
149
264
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -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
|
],
|