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 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) — 邮件六件套 + Web 设置页
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
- 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.
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,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 route when a webServer service is present. */
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
- return () => dispose();
147
- }, 'dsh-email: web route');
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.8.2",
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
  ],