dsh-email 0.12.0 → 0.13.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/CHANGELOG.md +36 -0
- package/README.en.md +5 -14
- package/README.md +5 -17
- package/lib/mail-client.d.ts +27 -1
- package/lib/mail-client.js +185 -17
- package/lib/parse.js +4 -2
- package/lib/runtime.d.ts +1 -1
- package/lib/runtime.js +24 -13
- package/lib/tool-contract.js +10 -1
- package/lib/tools.d.ts +1 -0
- package/lib/tools.js +13 -0
- package/lib/types.d.ts +5 -0
- package/package.json +4 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
> 完整历史(含详细改动说明)。README 只保留最近几个版本的一句话摘要。
|
|
4
|
+
|
|
5
|
+
## 中文版
|
|
6
|
+
|
|
7
|
+
- **0.13.0(2026-09-18)**:**修复**:①长正文截断会把整段正文丢成空(无断点时硬切,保留正文);②`email_watch` / 网页弹窗的游标会永久漏报一次超过 `limit` 条的新邮件(改为最旧优先分批、游标只推进到实际返回那批);③附件索引缓存未绑定 UIDVALIDITY,服务器重编号后可能按旧索引写出错误附件(缓存键并入 uidValidity)。**性能**:10 个工具声明 `timeoutMs`(search/watch 120s,其余 60s);watch/弹窗轮询只 FETCH 本轮要报的条数(此前每轮最多 FETCH 100 封未读信封);读信与正文搜索只下载 text/* 分段,不再把整封(含附件)拉下来。**口径**:搜索回退路径明确标注为扫描口径(「本页 N 条(仅扫描最近 X 封)」,不再冒充全文件夹匹配数)。**行为变化**:`email_read` 的 `attachments` 只列可下载的 `disposition=attachment`(内嵌图片不再列出);回退扫描改为逐封下载文本分段(省流量、往返略增)。测试 237 → 262 项。
|
|
8
|
+
- **0.12.0(2026-09-18)**:**新增发送别名**(`senderName` / `authUser` / `authPassword`):`user` 只作为发件地址与信箱身份,登录名与登录密码可以另填——Gmail / Workspace 的别名发信、以及「登录账号 ≠ From 地址」的 SMTP 中继不再被 `535 Username and Password not accepted` 拒绝,From 也能带显示名。设置页账号卡片新增「发件显示名 / 登录账号 / 登录账号的密码」三栏(与授权码同一套三态:留空保留已存值、清空即删除)。**新增** `email_search` 的 `offset`,命中多于一页时可翻页。**修复与优化**:文件夹 UIDVALIDITY 变化时重建 `email_watch` / 弹窗的增量基线(不再把重编号后的整箱当成新邮件);搜索的命中复核与结果列表合并为一次 FETCH;`email_attachment` 复用 `email_read` 已解析的 MIME 索引,不再把整封邮件(含附件)重下一遍;`email_folders` 结果缓存 60 秒;网页端新邮件弹窗在标签页不可见时暂停轮询、失败指数退避、皮肤快照降频。**工程**:CI 增加「`lib/` 与 `src/` 不允许漂移」和客户端 bundle 语法检查。测试 237 → 252 项。发送别名的方向来自 [@TianLanDaoRen](https://github.com/TianLanDaoRen) 的 [PR #8](https://github.com/STARDUSTLC666/dsh-email/pull/8)(本实现按 0.11.0 之后的代码重写)。
|
|
9
|
+
- **0.11.0(2026-09-18)**:合入 gurio-wine 的设置页四连([PR #11](https://github.com/STARDUSTLC666/dsh-email/pull/11)–[#14](https://github.com/STARDUSTLC666/dsh-email/pull/14)),并在评审后修掉其中若干问题。**新增**:①多账号卡片编辑器(增删改 / 改名 / 设默认 / 按账号单独测试连接,编辑即保存,不再需要点「保存并应用」)与服务器预设管理(`serverPresets`,自定义服务商端点,不含凭证);②Outlook / Exchange Online 的 OAuth2 设备码登录(IMAP 与 SMTP 双端,access token 自动刷新,密码认证账号完全不受影响);③设置面板文案中英双语,跟随宿主 Settings → General 的语言实时切换;④账号可显式钉住 `authKind`(自动 / oauth2 / password),给仍能用应用密码连 Exchange Online 的混合或本地租户留退路。**评审修复**:SMTP 的 OAuth2 认证形状原本一封也发不出去(nodemailer 的 `XOAuth2` 只读 `accessToken`、从不读 `pass`,实测报 `EAUTH`);保存面板不再无条件抹掉账号手写的 imap/smtp 端点(运行时解析以账号自己的值优先,原行为会把自建服务器账号静默改指预设,无 provider 的账号则直接失去连接信息);改名保留授权码与高级键,且不允许顶掉同名账号;设置路由增加 Host / Origin / Content-Type 同源校验(此前任意网页都能跨源改设置,DNS rebinding 还能读走含明文授权码的快照);响应不再回显解析后的账号映射(那是一份含明文密码、前端从不读取的副本);服务器原始报错经凭据脱敏后才展示(IMAP/SMTP 会回显被拒的认证串,其中含 access token);删除账号即清理其 token,未提交的保存不清;版本冲突自动重基,而不是拿旧 revision 反复重试。**不内置任何第三方 OAuth2 应用注册**:OAuth2 账号需自带 `clientId`,见下文「Outlook OAuth2」。测试 81 → 237 项。**修复 `email_search`**:QQ 这类服务器会对任意关键词返回同一批无关 UID,现在服务器命中会先用 envelope 复核(subject/from/to/cc),核实不到就回退本地正文扫描,不会再出现「不存在的关键词也匹配 40 条」([#15](https://github.com/STARDUSTLC666/dsh-email/issues/15))。
|
|
10
|
+
- **0.10.8(2026-09-16)**:合入 GUODnuli 的 [PR #9](https://github.com/STARDUSTLC666/dsh-email/pull/9),将设置页及新邮件弹窗的文字、边框引用改为官方主题变量,修复深色主题文字不可读;复验官方 Harness 0.1.5-rc.2 和 0.1.6-alpha.1。
|
|
11
|
+
- **0.10.7(2026-09-11)**:复验官方 Harness 0.1.5-rc.1,更新整套同载与真实服务验证记录;运行时代码未变。
|
|
12
|
+
- **0.10.6(2026-09-10)**:修复单账号设置页授权码留空时,空字符串遮蔽 `DSH_EMAIL_PASSWORD`,导致“测试连接”和保存后工具调用报未配置的问题;显式密码仍优先,多账号不会借用该环境变量。更新设置页工具数量、多账号说明,并补充真实 QQ 邮箱验证结果。
|
|
13
|
+
- **0.10.5(2026-09-08)**:补充官方 Harness 0.1.3-alpha.2 的安装、工具注册及 Web 设置接口验证,更新 Node 版本要求,明确 `email_health` 只检查配置;运行时代码与 0.10.4 相同。
|
|
14
|
+
- **0.10.4(2026-09-07)**:将 `mailparser` 最低版本提升到 `3.9.22` 并更新锁文件,使用 `html-to-text 10.0.1 → deepmerge-ts 8.0.2` 的修复链处理 [CVE-2026-40345](https://github.com/RebeccaStevens/deepmerge-ts/security/advisories/GHSA-ggr8-5vv4-36mx)。不依赖插件作为下游依赖安装时不生效的根级 `pnpm.overrides`;新增真实依赖链与 HTML 邮件解析回归测试。依赖告警不等于已证实邮件输入可触发该漏洞。
|
|
15
|
+
- **0.10.1**:补发制品——已发布的 0.10.0 打包时只含 `email_mark`,本版同时包含 `email_mark` 与 `email_reply`,代码与 0.10.0 的 main 一致。
|
|
16
|
+
- **0.10.0**:新增 `email_mark`(已读/未读/星标/移动文件夹,补齐收发闭环的整理侧)与 `email_reply`(回复/回复全部/转发,自动线程头+引文,走发信审批门);连接池按读/写模式分别管理邮箱打开状态。
|
|
17
|
+
- **0.9.1**:修复设置页空主机遮蔽 provider 预设(#3/#6);IMAP 连接超时不再杀死整个 DSH 进程(#4);暗色模式输入控件可见(#2);密码栏提示环境变量 `DSH_EMAIL_PASSWORD` 免明文方案(#5)。
|
|
18
|
+
- **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
|
|
19
|
+
- **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
|
|
20
|
+
- **0.8.0/0.8.1**:`email_list` / `email_search` 新增 `since` / `until` 日期范围过滤;新增 `email_health` 账号配置自检;适配 harness 0.1.2(清理已删除的客户端注入声明)。
|
|
21
|
+
- **0.6.2**:服务器端搜索补齐 `cc`,搜索范围真正覆盖主题 / 发件人 / 收件人 / 抄送;正文回退扫描也匹配 `to` / `cc`,单封解析失败不中断整批;列表强制 UID 降序「最新在前」;`email_send` 附件参数严格校验。
|
|
22
|
+
|
|
23
|
+
## English
|
|
24
|
+
|
|
25
|
+
- **0.13.0 (2026-09-18)**: **Fixes**: (1) long bodies could be truncated to nothing (now hard-cut with the body preserved); (2) the `email_watch` / popup cursor permanently skipped new mail beyond one `limit` batch (now oldest-first batching, cursor only advances over what was actually returned); (3) the attachment index cache ignored UIDVALIDITY, so a renumbered mailbox could download the wrong attachment (cache key now includes uidValidity). **Performance**: all ten tools declare `timeoutMs` (120s for search/watch, 60s otherwise); watch/popup polls FETCH only the rows they report (previously up to 100 unread envelopes per poll); reads and body search download text/* parts only instead of the whole message with its attachments. **Semantics**: the body-scan fallback now labels itself as such ("this page only, scanning the newest N messages") instead of claiming a folder-wide match count. **Behaviour change**: `email_read` now lists only downloadable `disposition=attachment` entries (inline images are no longer listed); the fallback scan fetches per message (fewer bytes, slightly more round trips). Tests 237 → 262.
|
|
26
|
+
- **0.12.0 (2026-09-18)**: **send-as alias** (`senderName` / `authUser` / `authPassword`): `user` is now only the From address and the mailbox identity, while the login user and password can differ — Gmail / Workspace aliases and SMTP relays where the login is not the From address no longer fail with `535 Username and Password not accepted`, and the From header can carry a display name. The account card gained three fields (same three-state contract as the authorization code: empty keeps the stored value, clearing deletes the key). **New**: `offset` for `email_search`, so results beyond the first page are reachable. **Fixes and optimisations**: a changed folder UIDVALIDITY re-seeds the `email_watch` / popup baseline instead of reporting the renumbered mailbox as new; search verification and the result rows share one FETCH; `email_attachment` reuses the MIME index `email_read` already parsed instead of downloading the whole message again; `email_folders` is cached for 60s; the web new-mail popup pauses while the tab is hidden, backs off on failures and refreshes its skin snapshot less often. **Engineering**: CI now rejects `lib/` drift against `src/` and syntax-checks the hand-written client bundle. Tests 237 → 252. The send-as direction came from [@TianLanDaoRen](https://github.com/TianLanDaoRen)'s [PR #8](https://github.com/STARDUSTLC666/dsh-email/pull/8) (this implementation is a rewrite on top of 0.11.0).
|
|
27
|
+
- **0.11.0 (2026-09-18)**: merge gurio-wine's four settings-page PRs ([#11](https://github.com/STARDUSTLC666/dsh-email/pull/11)–[#14](https://github.com/STARDUSTLC666/dsh-email/pull/14)) with post-review fixes. **Added**: ① visual multi-account card editor (add/edit/delete, rename, set-default, per-account connection test — edits auto-save; no "Save & Apply" button) and server-preset management (`serverPresets`, custom provider endpoints, no credentials); ② OAuth2 device-code login for Outlook / Exchange Online (IMAP and SMTP share one token; automatic refresh; password-auth accounts unaffected); ③ bilingual settings-panel copy that follows the host's Settings → General language in real time; ④ accounts can pin `authKind` (auto / oauth2 / password), giving hybrid or on-premises tenants that still accept app passwords an escape hatch. **Review fixes**: SMTP OAuth2 could never send (nodemailer's `XOAuth2` reads only `accessToken`, never `pass` — confirmed `EAUTH`); saving no longer unconditionally wipes account-level hand-written imap/smtp endpoints (runtime prefers the account's own host; the old behavior silently re-pointed custom-server accounts to presets, and accounts without a provider lost connection info entirely); rename preserves stored auth codes and advanced keys and refuses to overwrite an existing account name; settings routes now enforce Host / Origin / Content-Type same-origin checks (previously any web page could cross-origin-write settings; DNS rebinding could read snapshots containing plaintext auth codes); responses no longer echo the resolved account map (a plaintext-password copy the front end never reads); raw server errors are credential-scrubbed before display (IMAP/SMTP echo rejected auth strings containing access tokens); deleting an account cleans its tokens (uncommitted saves do not); version conflicts auto-rebase instead of retrying with a stale revision. **No third-party OAuth2 app registration is bundled**: OAuth2 accounts must supply their own `clientId` — see "Outlook OAuth2" below. Tests: 81 → 237. **`email_search` fix**: servers like QQ answer any keyword with the same unrelated uid list; hits are now re-verified against the envelopes (subject/from/to/cc) and fall back to the local body scan when none survive, so an impossible keyword no longer "matches" 40 messages ([#15](https://github.com/STARDUSTLC666/dsh-email/issues/15)).
|
|
28
|
+
- **0.10.8 (2026-09-16)**: integrate GUODnuli's [PR #9](https://github.com/STARDUSTLC666/dsh-email/pull/9), replacing nonexistent text and border variables in settings and notifications with official theme tokens; revalidate Harness 0.1.5-rc.2 and 0.1.6-alpha.1.
|
|
29
|
+
- **0.10.7 (2026-09-11)**: revalidate official Harness 0.1.5-rc.1 and refresh suite co-load and live-service evidence; runtime code is unchanged.
|
|
30
|
+
- **0.10.6 (2026-09-10)**: fix an empty authorization-code field shadowing `DSH_EMAIL_PASSWORD` in single-account connection tests and saved settings. Explicit passwords still take precedence; named accounts cannot borrow this environment variable. Refresh the settings tool count, multi-account guidance and real QQ mailbox validation notes.
|
|
31
|
+
- **0.10.5 (2026-09-08)**: document installation, tool registration and the Web settings endpoint in official Harness 0.1.3-alpha.2; update Node requirements and clarify that `email_health` checks configuration only. Runtime code is unchanged from 0.10.4.
|
|
32
|
+
- **0.10.4 (2026-09-07)**: raise the minimum `mailparser` version to `3.9.22` and update the lockfile to use the patched `html-to-text 10.0.1 → deepmerge-ts 8.0.2` dependency chain for [CVE-2026-40345](https://github.com/RebeccaStevens/deepmerge-ts/security/advisories/GHSA-ggr8-5vv4-36mx). This does not rely on root-only `pnpm.overrides`, which cannot fix consumers installing this plugin as a dependency. Add runtime dependency-chain and HTML-message parsing regression tests. An affected dependency is not proof that mail input can trigger this vulnerability.
|
|
33
|
+
- **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).
|
|
34
|
+
- **0.8.2**: `since` / `until` parameter descriptions unified to English, consistent with the other parameters, so multilingual agents read them correctly.
|
|
35
|
+
- **0.8.0/0.8.1**: `email_list` / `email_search` gained `since` / `until` date-range filters; new `email_health` account-configuration self-check; adapted to harness 0.1.2 (removed the deleted client-injection declaration).
|
|
36
|
+
- **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.en.md
CHANGED
|
@@ -37,19 +37,10 @@ Example:
|
|
|
37
37
|
|
|
38
38
|
### Changelog
|
|
39
39
|
|
|
40
|
-
- **0.
|
|
41
|
-
- **0.
|
|
42
|
-
- **0.
|
|
43
|
-
- **0.10.
|
|
44
|
-
- **0.10.6 (2026-09-10)**: fix an empty authorization-code field shadowing `DSH_EMAIL_PASSWORD` in single-account connection tests and saved settings. Explicit passwords still take precedence; named accounts cannot borrow this environment variable. Refresh the settings tool count, multi-account guidance and real QQ mailbox validation notes.
|
|
45
|
-
- **0.10.5 (2026-09-08)**: document installation, tool registration and the Web settings endpoint in official Harness 0.1.3-alpha.2; update Node requirements and clarify that `email_health` checks configuration only. Runtime code is unchanged from 0.10.4.
|
|
46
|
-
- **0.10.4 (2026-09-07)**: raise the minimum `mailparser` version to `3.9.22` and update the lockfile to use the patched `html-to-text 10.0.1 → deepmerge-ts 8.0.2` dependency chain for [CVE-2026-40345](https://github.com/RebeccaStevens/deepmerge-ts/security/advisories/GHSA-ggr8-5vv4-36mx). This does not rely on root-only `pnpm.overrides`, which cannot fix consumers installing this plugin as a dependency. Add runtime dependency-chain and HTML-message parsing regression tests. An affected dependency is not proof that mail input can trigger this vulnerability.
|
|
47
|
-
- **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).
|
|
48
|
-
- **0.8.2**: `since` / `until` parameter descriptions unified to English, consistent with the other parameters, so multilingual agents read them correctly.
|
|
49
|
-
- **0.8.0/0.8.1**: `email_list` / `email_search` gained `since` / `until` date-range filters; new `email_health` account-configuration self-check; adapted to harness 0.1.2 (removed the deleted client-injection declaration).
|
|
50
|
-
- **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.
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
- **0.13.0 (2026-09-18)**: fixes for bodies truncated to nothing, `email_watch` skipping new mail, and the attachment cache ignoring UIDVALIDITY; all ten tools declare a timeout; reads and body search download text parts only; the scan fallback labels its own semantics. 262 tests.
|
|
41
|
+
- **0.12.0 (2026-09-18)**: send-as alias (`senderName` / `authUser` / `authPassword`) and `offset` paging for `email_search`; QQ match-everything searches no longer trusted; popup polling pauses while the tab is hidden.
|
|
42
|
+
- **0.11.0 (2026-09-18)**: gurio-wine’s four settings-page PRs (card editor / OAuth2 device-code login / bilingual panel / pinned `authKind`) plus the review fixes for SMTP OAuth2 and same-origin settings routes.
|
|
43
|
+
- **0.10.8 and earlier**: see [CHANGELOG.md](CHANGELOG.md).
|
|
53
44
|
## Compatibility
|
|
54
45
|
|
|
55
46
|
Co-load verification was performed on 2026-09-16 with official source builds of Harness `0.1.5-rc.2` and `0.1.6-alpha.1`: all 18 components load alongside ModLens, with passing tool schemas, skill registration and offline read-only calls.
|
|
@@ -226,7 +217,7 @@ Microsoft has disabled username+password basic auth for Exchange Online: persona
|
|
|
226
217
|
## Known limitations
|
|
227
218
|
|
|
228
219
|
- **OAuth2 covers Outlook / Exchange Online only, and requires your own app ID**: device-code login supports both IMAP and SMTP, but the plugin **bundles no third-party app registration** — OAuth2 accounts must supply their own `clientId` (free to register; see "Outlook OAuth2" above). Other environments that mandate OAuth (e.g. Google Workspace) remain unusable; use the provider's app-specific password / authorization code instead.
|
|
229
|
-
- **Search match counts**: server hits are re-checked against the envelopes (see `email_search` above); when they hold up, "N matches" is the count the server reported while every listed row really carries the keyword.
|
|
220
|
+
- **Search match counts**: server hits are re-checked against the envelopes (see `email_search` above); when they hold up, "N matches" is the count the server reported while every listed row really carries the keyword. The local body-scan fallback only looked at the newest `bodySearchLimit` messages, so it renders "N rows on this page (only the newest N scanned)" instead of "N matches".
|
|
230
221
|
- **Body search**: the server side only searches subject / from / to / cc. Most servers (e.g. QQ) have unreliable IMAP `TEXT` / `HEADER` search, so with no results it falls back to a body scan of the most recent `bodySearchLimit` messages (slower; disable with `bodySearchFallback`).
|
|
231
222
|
- **Attachments**: inline images aren't downloadable separately yet; a failed attachment match errors instead of downloading the wrong file (safe default).
|
|
232
223
|
- **Password storage**: the authorization code saved in the settings page is written in plaintext to the local `settings.yaml` (the secret mark only keeps it out of logs / exports / diagnostics; no disk encryption). Don't hand `settings.yaml` to untrusted people.
|
package/README.md
CHANGED
|
@@ -45,22 +45,10 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
|
|
|
45
45
|
|
|
46
46
|
### 版本记录
|
|
47
47
|
|
|
48
|
-
- **0.
|
|
49
|
-
- **0.
|
|
50
|
-
- **0.
|
|
51
|
-
- **0.10.
|
|
52
|
-
- **0.10.6(2026-09-10)**:修复单账号设置页授权码留空时,空字符串遮蔽 `DSH_EMAIL_PASSWORD`,导致“测试连接”和保存后工具调用报未配置的问题;显式密码仍优先,多账号不会借用该环境变量。更新设置页工具数量、多账号说明,并补充真实 QQ 邮箱验证结果。
|
|
53
|
-
- **0.10.5(2026-09-08)**:补充官方 Harness 0.1.3-alpha.2 的安装、工具注册及 Web 设置接口验证,更新 Node 版本要求,明确 `email_health` 只检查配置;运行时代码与 0.10.4 相同。
|
|
54
|
-
- **0.10.4(2026-09-07)**:将 `mailparser` 最低版本提升到 `3.9.22` 并更新锁文件,使用 `html-to-text 10.0.1 → deepmerge-ts 8.0.2` 的修复链处理 [CVE-2026-40345](https://github.com/RebeccaStevens/deepmerge-ts/security/advisories/GHSA-ggr8-5vv4-36mx)。不依赖插件作为下游依赖安装时不生效的根级 `pnpm.overrides`;新增真实依赖链与 HTML 邮件解析回归测试。依赖告警不等于已证实邮件输入可触发该漏洞。
|
|
55
|
-
- **0.10.1**:补发制品——已发布的 0.10.0 打包时只含 `email_mark`,本版同时包含 `email_mark` 与 `email_reply`,代码与 0.10.0 的 main 一致。
|
|
56
|
-
- **0.10.0**:新增 `email_mark`(已读/未读/星标/移动文件夹,补齐收发闭环的整理侧)与 `email_reply`(回复/回复全部/转发,自动线程头+引文,走发信审批门);连接池按读/写模式分别管理邮箱打开状态。
|
|
57
|
-
- **0.9.1**:修复设置页空主机遮蔽 provider 预设(#3/#6);IMAP 连接超时不再杀死整个 DSH 进程(#4);暗色模式输入控件可见(#2);密码栏提示环境变量 `DSH_EMAIL_PASSWORD` 免明文方案(#5)。
|
|
58
|
-
- **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
|
|
59
|
-
- **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
|
|
60
|
-
- **0.8.0/0.8.1**:`email_list` / `email_search` 新增 `since` / `until` 日期范围过滤;新增 `email_health` 账号配置自检;适配 harness 0.1.2(清理已删除的客户端注入声明)。
|
|
61
|
-
- **0.6.2**:服务器端搜索补齐 `cc`,搜索范围真正覆盖主题 / 发件人 / 收件人 / 抄送;正文回退扫描也匹配 `to` / `cc`,单封解析失败不中断整批;列表强制 UID 降序「最新在前」;`email_send` 附件参数严格校验。
|
|
62
|
-
|
|
63
|
-
|
|
48
|
+
- **0.13.0(2026-09-18)**:修复长正文截断成空、`email_watch` 永久漏报新邮件、附件缓存跨 UIDVALIDITY 失效;10 个工具声明超时;读信/搜索只下正文分段;搜索回退标明扫描口径。测试 262 项。
|
|
49
|
+
- **0.12.0(2026-09-18)**:新增发送别名(`senderName` / `authUser` / `authPassword`)与 `email_search` 的 `offset` 翻页;修复 QQ 搜索假命中;弹窗轮询按页面可见性节流。
|
|
50
|
+
- **0.11.0(2026-09-18)**:合入 gurio-wine 的设置页四连(卡片编辑器 / OAuth2 设备码登录 / 双语面板 / `authKind` 钉住),并修掉评审发现的 SMTP OAuth2、设置路由同源校验等问题。
|
|
51
|
+
- **0.10.8 及更早**:见 [CHANGELOG.md](CHANGELOG.md)。
|
|
64
52
|
## 兼容性
|
|
65
53
|
|
|
66
54
|
2026-09-16 曾在官方源码构建的 Harness `0.1.5-rc.2` 和 `0.1.6-alpha.1` 上完成同载验证:18 个组件与 ModLens 同载,工具 schema、技能注册及离线只读调用检查通过。
|
|
@@ -237,7 +225,7 @@ dsh plugin --profile web remove dsh-email
|
|
|
237
225
|
## 已知限制
|
|
238
226
|
|
|
239
227
|
- **OAuth2 仅覆盖 Outlook / Exchange Online,且需自带应用 ID**:设备码登录已支持 IMAP 与 SMTP 双端,但插件**不内置任何第三方应用注册**,OAuth2 账号必须填自己的 `clientId`(免费注册,见上文「Outlook OAuth2」)。Google Workspace 等其它强制 OAuth 的环境仍不可用,只能用服务商的应用专用密码 / 授权码。
|
|
240
|
-
- **搜索的匹配数**:服务器命中会先用信封复核(见上文 `email_search`);复核通过时「共 N
|
|
228
|
+
- **搜索的匹配数**:服务器命中会先用信封复核(见上文 `email_search`);复核通过时「共 N 条匹配」沿用服务器给出的条数,而列出的每一行都保证真的带关键词。正文回退扫描只看了最近 `bodySearchLimit` 封,不知道全文件夹匹配数,因此渲染为「本页 N 条(仅扫描最近 N 封)」而不是「共 N 条」。
|
|
241
229
|
- **正文搜索**:服务器端只搜 subject / from / to / cc;多数服务器(如 QQ)的 IMAP `TEXT` / `HEADER` 搜索不可靠,无结果时回退到最近 `bodySearchLimit` 封的正文扫描(较慢,可用 `bodySearchFallback` 关闭)。
|
|
242
230
|
- **附件**:内嵌图片暂不支持单独下载;附件定位失败会直接报错而不是下载错误文件(安全默认)。
|
|
243
231
|
- **密码落盘**:设置页保存的授权码以明文写在本机 `settings.yaml`(secret 标记只保证它不进日志 / 导出 / 诊断,不做磁盘加密)。请勿把 `settings.yaml` 交给不信任的人。
|
package/lib/mail-client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ImapFlow } from 'imapflow';
|
|
2
2
|
import type { ResolvedEmailConfig, ResolvedEmailSettings } from './config.js';
|
|
3
|
-
import type { AddressEntry, EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailMarkAction, EmailMarkResult, EmailReadResult, EmailReplyMode, EmailReplyResult, EmailSearchResult, EmailSendResult } from './types.js';
|
|
3
|
+
import type { AddressEntry, EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailMarkAction, EmailMarkResult, EmailReadResult, EmailReplyMode, EmailReplyResult, EmailSearchResult, EmailSendResult, ListedMessage } from './types.js';
|
|
4
4
|
export declare class MailError extends Error {
|
|
5
5
|
constructor(message: string);
|
|
6
6
|
}
|
|
@@ -124,6 +124,8 @@ export declare class EmailPool {
|
|
|
124
124
|
private enqueue;
|
|
125
125
|
private readonly readCache;
|
|
126
126
|
private readonly folderCache;
|
|
127
|
+
/** UIDVALIDITY 变了以后同一 uid 可能指向另一封邮件,缓存键必须带上它。 */
|
|
128
|
+
private uidValidityOf;
|
|
127
129
|
/** Remember a parsed attachment index so email_attachment can skip the refetch. */
|
|
128
130
|
private rememberRead;
|
|
129
131
|
/**
|
|
@@ -168,7 +170,31 @@ export declare class EmailPool {
|
|
|
168
170
|
* single attempt they always had.
|
|
169
171
|
*/
|
|
170
172
|
private sendMail;
|
|
173
|
+
/**
|
|
174
|
+
* Download one MIME part through imapflow's decode pipeline: transfer
|
|
175
|
+
* encoding and charset are handled there, maxBytes caps what is fetched.
|
|
176
|
+
*/
|
|
177
|
+
private downloadPartText;
|
|
178
|
+
/**
|
|
179
|
+
* The message body without its attachments. undefined when the structure has
|
|
180
|
+
* no usable text part or the server refuses the part fetch, so the caller can
|
|
181
|
+
* fall back to the full-source path for that one message.
|
|
182
|
+
*/
|
|
183
|
+
private bodyTextFromParts;
|
|
171
184
|
list(accountName: string | undefined, folder: string, limit: number, offset: number, unreadOnly: boolean, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailListResult>;
|
|
185
|
+
/**
|
|
186
|
+
* The uid index behind email_watch: SEARCH UNSEEN only, no envelopes and no
|
|
187
|
+
* bodies. The caller decides which uids it actually needs to report.
|
|
188
|
+
*/
|
|
189
|
+
unseenUids(accountName: string | undefined, folder: string, signal?: AbortSignal): Promise<{
|
|
190
|
+
account: string;
|
|
191
|
+
folder: string;
|
|
192
|
+
uidValidity: number;
|
|
193
|
+
count: number;
|
|
194
|
+
uids: number[];
|
|
195
|
+
}>;
|
|
196
|
+
/** Fetch the envelopes for one uid batch: the rows email_watch will report. */
|
|
197
|
+
fetchByUids(accountName: string | undefined, folder: string, uids: number[], signal?: AbortSignal): Promise<ListedMessage[]>;
|
|
172
198
|
search(accountName: string | undefined, query: string, folder: string, limit: number, offset: number, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailSearchResult>;
|
|
173
199
|
/**
|
|
174
200
|
* Confirm server-side hits against the mailbox itself: fetch the envelopes
|
package/lib/mail-client.js
CHANGED
|
@@ -3,7 +3,7 @@ import nodemailer from 'nodemailer';
|
|
|
3
3
|
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { getFreshAccessToken, OAuth2Error } from './oauth2.js';
|
|
6
|
-
import { flattenAddresses, parseRawMessage, sanitizeFilename } from './parse.js';
|
|
6
|
+
import { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
|
|
7
7
|
export class MailError extends Error {
|
|
8
8
|
constructor(message) {
|
|
9
9
|
super(message);
|
|
@@ -118,6 +118,39 @@ export function selectAttachmentPart(readAttachments, parts, index) {
|
|
|
118
118
|
const byTypeAndSize = parts.find(part => part.contentType === meta.contentType && Math.abs(part.size - meta.size) <= tolerance);
|
|
119
119
|
return byTypeAndSize;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Leaf text/* parts that can carry the message body, attachment parts excluded.
|
|
123
|
+
* This is what email_read / the search fallback fetch instead of the whole
|
|
124
|
+
* source: a 20 MiB attachment must not be downloaded just to read the text.
|
|
125
|
+
*/
|
|
126
|
+
function collectTextParts(node, out = []) {
|
|
127
|
+
if (node === null || node === undefined || typeof node !== 'object')
|
|
128
|
+
return out;
|
|
129
|
+
// message/rfc822 整段是一封内嵌邮件(附件),它的正文分段不是本封的正文。
|
|
130
|
+
if (typeof node.type === 'string' && node.type.toLowerCase().startsWith('message/rfc822'))
|
|
131
|
+
return out;
|
|
132
|
+
const children = Array.isArray(node.childNodes) ? node.childNodes : [];
|
|
133
|
+
if (children.length === 0) {
|
|
134
|
+
const type = typeof node.type === 'string' ? node.type.toLowerCase() : '';
|
|
135
|
+
if (type.startsWith('text/') && node.disposition !== 'attachment') {
|
|
136
|
+
out.push({ key: node.part === undefined || node.part === null ? '1' : String(node.part), type });
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
for (const child of children)
|
|
141
|
+
collectTextParts(child, out);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** The body parts worth fetching: text/plain first, text/html as the fallback. */
|
|
145
|
+
function selectBodyParts(node) {
|
|
146
|
+
const parts = collectTextParts(node);
|
|
147
|
+
const plain = parts.find(part => part.type === 'text/plain');
|
|
148
|
+
const html = parts.find(part => part.type === 'text/html');
|
|
149
|
+
return {
|
|
150
|
+
...(plain !== undefined ? { plain } : {}),
|
|
151
|
+
...(html !== undefined ? { html } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
121
154
|
/** The From header: `user` is the visible address, `senderName` only labels it. */
|
|
122
155
|
function senderOf(cfg) {
|
|
123
156
|
return cfg.senderName === '' ? cfg.user : { name: cfg.senderName, address: cfg.user };
|
|
@@ -277,9 +310,14 @@ export class EmailPool {
|
|
|
277
310
|
}
|
|
278
311
|
readCache = new Map();
|
|
279
312
|
folderCache = new Map();
|
|
313
|
+
/** UIDVALIDITY 变了以后同一 uid 可能指向另一封邮件,缓存键必须带上它。 */
|
|
314
|
+
uidValidityOf(client) {
|
|
315
|
+
const mailbox = client.mailbox;
|
|
316
|
+
return mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
|
|
317
|
+
}
|
|
280
318
|
/** Remember a parsed attachment index so email_attachment can skip the refetch. */
|
|
281
|
-
rememberRead(account, folder, uid, parsed) {
|
|
282
|
-
const key = account + '\u0000' + folder + '\u0000' + uid;
|
|
319
|
+
rememberRead(account, folder, uidValidity, uid, parsed) {
|
|
320
|
+
const key = account + '\u0000' + folder + '\u0000' + uidValidity + '\u0000' + uid;
|
|
283
321
|
this.readCache.delete(key);
|
|
284
322
|
this.readCache.set(key, { ...parsed, at: Date.now() });
|
|
285
323
|
while (this.readCache.size > READ_CACHE_MAX) {
|
|
@@ -294,21 +332,39 @@ export class EmailPool {
|
|
|
294
332
|
* produced it, otherwise a fresh parse of the full source plus its bodyStructure.
|
|
295
333
|
*/
|
|
296
334
|
async attachmentIndexOf(client, account, folder, uid, signal) {
|
|
297
|
-
const
|
|
335
|
+
const uidValidity = this.uidValidityOf(client);
|
|
336
|
+
const cached = this.recallRead(account, folder, uidValidity, uid);
|
|
298
337
|
if (cached !== undefined)
|
|
299
338
|
return cached;
|
|
300
|
-
|
|
301
|
-
|
|
339
|
+
// 附件索引只需要 MIME 结构:bodyStructure 已经给出每个附件的 part/名称/大小,
|
|
340
|
+
// 不必为了拿它先拉整封 source(第一次就下载附件的邮件也一样)。
|
|
341
|
+
const message = await client.fetchOne(uid, { uid: true, bodyStructure: true }, { uid: true });
|
|
342
|
+
if (message === false) {
|
|
302
343
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
|
|
303
344
|
}
|
|
304
|
-
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
305
345
|
signal?.throwIfAborted();
|
|
306
|
-
|
|
307
|
-
|
|
346
|
+
if (message.bodyStructure !== undefined) {
|
|
347
|
+
const parts = collectAttachmentParts(message.bodyStructure);
|
|
348
|
+
const attachments = parts.map(part => ({ filename: part.filename, contentType: part.contentType, size: part.size, part: part.part }));
|
|
349
|
+
const parsed = { attachments, parts };
|
|
350
|
+
this.rememberRead(account, folder, uidValidity, uid, parsed);
|
|
351
|
+
return parsed;
|
|
352
|
+
}
|
|
353
|
+
// 服务器没给 bodyStructure:退回整封解析,行为和以前一样。
|
|
354
|
+
const full = message.source !== undefined
|
|
355
|
+
? message
|
|
356
|
+
: await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
|
|
357
|
+
if (full === false || full.source === undefined) {
|
|
358
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
|
|
359
|
+
}
|
|
360
|
+
const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
|
|
361
|
+
signal?.throwIfAborted();
|
|
362
|
+
const parsed = { attachments: body.attachments, parts: collectAttachmentParts(full.bodyStructure) };
|
|
363
|
+
this.rememberRead(account, folder, uidValidity, uid, parsed);
|
|
308
364
|
return parsed;
|
|
309
365
|
}
|
|
310
|
-
recallRead(account, folder, uid) {
|
|
311
|
-
const key = account + '\u0000' + folder + '\u0000' + uid;
|
|
366
|
+
recallRead(account, folder, uidValidity, uid) {
|
|
367
|
+
const key = account + '\u0000' + folder + '\u0000' + uidValidity + '\u0000' + uid;
|
|
312
368
|
const hit = this.readCache.get(key);
|
|
313
369
|
if (hit === undefined)
|
|
314
370
|
return undefined;
|
|
@@ -578,6 +634,33 @@ export class EmailPool {
|
|
|
578
634
|
}
|
|
579
635
|
}
|
|
580
636
|
}
|
|
637
|
+
/**
|
|
638
|
+
* Download one MIME part through imapflow's decode pipeline: transfer
|
|
639
|
+
* encoding and charset are handled there, maxBytes caps what is fetched.
|
|
640
|
+
*/
|
|
641
|
+
async downloadPartText(client, uid, part, maxBytes, signal) {
|
|
642
|
+
const dl = await client.download(uid, part.key, { uid: true, maxBytes });
|
|
643
|
+
signal?.throwIfAborted();
|
|
644
|
+
const buf = await collectStream(dl.content, maxBytes, signal);
|
|
645
|
+
signal?.throwIfAborted();
|
|
646
|
+
return buf.toString('utf8');
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* The message body without its attachments. undefined when the structure has
|
|
650
|
+
* no usable text part or the server refuses the part fetch, so the caller can
|
|
651
|
+
* fall back to the full-source path for that one message.
|
|
652
|
+
*/
|
|
653
|
+
async bodyTextFromParts(client, uid, structure, maxBytes, signal) {
|
|
654
|
+
const { plain, html } = selectBodyParts(structure);
|
|
655
|
+
if (plain !== undefined) {
|
|
656
|
+
const text = await this.downloadPartText(client, uid, plain, maxBytes, signal);
|
|
657
|
+
if (text.trim() !== '' || html === undefined)
|
|
658
|
+
return text;
|
|
659
|
+
}
|
|
660
|
+
if (html !== undefined)
|
|
661
|
+
return stripHtml(await this.downloadPartText(client, uid, html, maxBytes, signal));
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
581
664
|
async list(accountName, folder, limit, offset, unreadOnly, since, until, signal) {
|
|
582
665
|
const name = this.resolveName(accountName);
|
|
583
666
|
const cfg = this.account(name);
|
|
@@ -615,6 +698,32 @@ export class EmailPool {
|
|
|
615
698
|
return { account: name, count: scopeCount, folder: folderName, uidValidity, messages };
|
|
616
699
|
}, true, signal);
|
|
617
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* The uid index behind email_watch: SEARCH UNSEEN only, no envelopes and no
|
|
703
|
+
* bodies. The caller decides which uids it actually needs to report.
|
|
704
|
+
*/
|
|
705
|
+
async unseenUids(accountName, folder, signal) {
|
|
706
|
+
const name = this.resolveName(accountName);
|
|
707
|
+
const cfg = this.account(name);
|
|
708
|
+
const folderName = folder || cfg.inboxFolder;
|
|
709
|
+
return this.withImap(name, folderName, async (client) => {
|
|
710
|
+
const mailbox = client.mailbox;
|
|
711
|
+
const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
|
|
712
|
+
const found = await client.search({ seen: false }, { uid: true });
|
|
713
|
+
signal?.throwIfAborted();
|
|
714
|
+
const uids = (found === false ? [] : found).slice().sort((a, b) => b - a);
|
|
715
|
+
return { account: name, folder: folderName, uidValidity, count: uids.length, uids };
|
|
716
|
+
}, true, signal);
|
|
717
|
+
}
|
|
718
|
+
/** Fetch the envelopes for one uid batch: the rows email_watch will report. */
|
|
719
|
+
async fetchByUids(accountName, folder, uids, signal) {
|
|
720
|
+
if (uids.length === 0)
|
|
721
|
+
return [];
|
|
722
|
+
const name = this.resolveName(accountName);
|
|
723
|
+
const cfg = this.account(name);
|
|
724
|
+
const folderName = folder || cfg.inboxFolder;
|
|
725
|
+
return this.withImap(name, folderName, (client) => this.fetchListed(client, uids, signal), true, signal);
|
|
726
|
+
}
|
|
618
727
|
async search(accountName, query, folder, limit, offset, since, until, signal) {
|
|
619
728
|
const name = this.resolveName(accountName);
|
|
620
729
|
const cfg = this.account(name);
|
|
@@ -655,7 +764,12 @@ export class EmailPool {
|
|
|
655
764
|
// survive verification): scan the newest messages locally instead.
|
|
656
765
|
if (this.settings.bodySearchFallback) {
|
|
657
766
|
const messages = await this.searchBodies(client, query, folderName, limit, offset, since, until, signal);
|
|
658
|
-
|
|
767
|
+
// 本地扫描不知道全文件夹有多少匹配,count 只是本页条数:用 countKind
|
|
768
|
+
// 让渲染说「本页 N 条(仅扫描最近 bodySearchLimit 封)」,不能说「共 N 条」。
|
|
769
|
+
return {
|
|
770
|
+
account: name, query, count: messages.length, folder: folderName, offset, messages,
|
|
771
|
+
countKind: 'scanned', scannedLimit: this.settings.bodySearchLimit,
|
|
772
|
+
};
|
|
659
773
|
}
|
|
660
774
|
return { account: name, query, count: 0, folder: folderName, offset, messages: [] };
|
|
661
775
|
}, true, signal);
|
|
@@ -689,7 +803,8 @@ export class EmailPool {
|
|
|
689
803
|
if (total === 0)
|
|
690
804
|
return [];
|
|
691
805
|
const start = Math.max(1, total - this.settings.bodySearchLimit + 1);
|
|
692
|
-
|
|
806
|
+
// 只取信封与 MIME 结构;正文在下面按 text/* 分段下载,附件不进正文匹配。
|
|
807
|
+
const fetched = await client.fetchAll(start + ':*', { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, internalDate: true });
|
|
693
808
|
const out = [];
|
|
694
809
|
for (const message of [...fetched].reverse()) {
|
|
695
810
|
signal?.throwIfAborted();
|
|
@@ -705,6 +820,7 @@ export class EmailPool {
|
|
|
705
820
|
.map(flattenAddressText).join(' ');
|
|
706
821
|
let body = '';
|
|
707
822
|
if (message.source !== undefined) {
|
|
823
|
+
// 服务器多送了整封 source(测试/旧行为):直接解析,不必再多一次往返。
|
|
708
824
|
try {
|
|
709
825
|
const parsed = await parseRawMessage(message.source, 4096);
|
|
710
826
|
signal?.throwIfAborted();
|
|
@@ -715,6 +831,15 @@ export class EmailPool {
|
|
|
715
831
|
// 单封邮件解析失败不应中断整批回退扫描,继续用 subject/from/to/cc 匹配。
|
|
716
832
|
}
|
|
717
833
|
}
|
|
834
|
+
else if (message.bodyStructure !== undefined) {
|
|
835
|
+
try {
|
|
836
|
+
body = await this.bodyTextFromParts(client, message.uid, message.bodyStructure, 4096 * 4, signal) ?? '';
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
signal?.throwIfAborted();
|
|
840
|
+
// 单封邮件分段下载失败不应中断整批回退扫描,继续用 subject/from/to/cc 匹配。
|
|
841
|
+
}
|
|
842
|
+
}
|
|
718
843
|
if (messageMatchesQuery(subject, recipientSearchText, body, query)) {
|
|
719
844
|
out.push(listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
|
|
720
845
|
}
|
|
@@ -736,13 +861,56 @@ export class EmailPool {
|
|
|
736
861
|
const cfg = this.account(name);
|
|
737
862
|
const folderName = folder || cfg.inboxFolder;
|
|
738
863
|
return this.withImap(name, folderName, async (client) => {
|
|
739
|
-
|
|
740
|
-
|
|
864
|
+
// 先只要信封与 MIME 结构,正文按 text/* 分段下载:带 20 MiB 附件的邮件
|
|
865
|
+
// 只为看正文时不再整封拉下来,附件元数据直接复用 bodyStructure。
|
|
866
|
+
const message = await client.fetchOne(uid, { uid: true, envelope: true, bodyStructure: true }, { uid: true });
|
|
867
|
+
if (message === false) {
|
|
741
868
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
742
869
|
}
|
|
743
|
-
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
744
870
|
signal?.throwIfAborted();
|
|
745
|
-
|
|
871
|
+
if (message.source === undefined && message.envelope !== undefined && message.bodyStructure !== undefined) {
|
|
872
|
+
try {
|
|
873
|
+
const text = await this.bodyTextFromParts(client, uid, message.bodyStructure, this.settings.maxBodyChars * 4 + 4096, signal);
|
|
874
|
+
if (text !== undefined) {
|
|
875
|
+
const limited = truncateText(text, this.settings.maxBodyChars);
|
|
876
|
+
const parts = collectAttachmentParts(message.bodyStructure);
|
|
877
|
+
const attachments = parts.map(part => ({
|
|
878
|
+
filename: part.filename,
|
|
879
|
+
contentType: part.contentType,
|
|
880
|
+
size: part.size,
|
|
881
|
+
part: part.part,
|
|
882
|
+
}));
|
|
883
|
+
this.rememberRead(name, folderName, this.uidValidityOf(client), uid, { attachments, parts });
|
|
884
|
+
const envelopeDate = message.envelope.date;
|
|
885
|
+
return {
|
|
886
|
+
account: name,
|
|
887
|
+
uid,
|
|
888
|
+
folder: folderName,
|
|
889
|
+
date: envelopeDate instanceof Date ? envelopeDate.toISOString() : '',
|
|
890
|
+
from: flattenAddresses(message.envelope.from),
|
|
891
|
+
to: flattenAddresses(message.envelope.to),
|
|
892
|
+
cc: flattenAddresses(message.envelope.cc),
|
|
893
|
+
subject: message.envelope.subject ?? '',
|
|
894
|
+
text: limited.text,
|
|
895
|
+
attachments,
|
|
896
|
+
truncated: limited.truncated,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
signal?.throwIfAborted();
|
|
902
|
+
// 分段读取失败(服务器拒绝该分段或结构异常):这一封退回整封解析。
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const full = message.source !== undefined
|
|
906
|
+
? message
|
|
907
|
+
: await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
|
|
908
|
+
if (full === false || full.source === undefined) {
|
|
909
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
910
|
+
}
|
|
911
|
+
const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
|
|
912
|
+
signal?.throwIfAborted();
|
|
913
|
+
this.rememberRead(name, folderName, this.uidValidityOf(client), uid, { attachments: body.attachments, parts: collectAttachmentParts(full.bodyStructure) });
|
|
746
914
|
return { account: name, uid, folder: folderName, ...body };
|
|
747
915
|
}, true, signal);
|
|
748
916
|
}
|
package/lib/parse.js
CHANGED
|
@@ -38,8 +38,10 @@ export function truncateText(text, maxChars) {
|
|
|
38
38
|
if (text.length <= maxChars)
|
|
39
39
|
return { text, truncated: false };
|
|
40
40
|
const cut = text.slice(0, maxChars);
|
|
41
|
-
const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' ')
|
|
42
|
-
|
|
41
|
+
const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' '));
|
|
42
|
+
// 窗口内没有空格/换行时必须硬切:否则 lastBreak 为 0,正文会被整段丢成空串。
|
|
43
|
+
const head = lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
|
|
44
|
+
return { text: head + '\n\n…[正文过长,已截断,共 ' + text.length + ' 字符]', truncated: true };
|
|
43
45
|
}
|
|
44
46
|
/**
|
|
45
47
|
* Turn an untrusted attachment filename into a safe basename: no directory
|
package/lib/runtime.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type EmailConfig, type ResolvedEmailSettings } from './config.js';
|
|
|
3
3
|
import { EmailPool } from './mail-client.js';
|
|
4
4
|
import { type EmailSettingsValue } from './settings.js';
|
|
5
5
|
import type { EmailWatchResult } from './types.js';
|
|
6
|
-
export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'reply' | 'folders' | 'downloadAttachment' | 'startIdleSweep' | 'dispose'>;
|
|
6
|
+
export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose'>;
|
|
7
7
|
export interface EmailSettingsScope {
|
|
8
8
|
get(): unknown;
|
|
9
9
|
}
|
package/lib/runtime.js
CHANGED
|
@@ -63,31 +63,42 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
|
|
|
63
63
|
const watchCursors = new Map();
|
|
64
64
|
const watch = async (account, folder, limit, scope, signal) => {
|
|
65
65
|
const capped = clampInt(limit, 20, 1, 100);
|
|
66
|
-
const
|
|
67
|
-
|
|
66
|
+
const pool = getPool();
|
|
67
|
+
// 先只 SEARCH UNSEEN 拿 uid 列表与 totalUnread,再只为本轮要报告的最旧
|
|
68
|
+
// limit 封取信封:网页弹窗每 30 秒轮询一次,不能因为未读多就整批 FETCH。
|
|
69
|
+
const index = await pool.unseenUids(account, folder, signal);
|
|
70
|
+
const key = scope + '\u0000' + index.account + '\u0000' + index.folder;
|
|
68
71
|
const stored = watchCursors.get(key);
|
|
69
|
-
const uidValidity = typeof
|
|
72
|
+
const uidValidity = typeof index.uidValidity === 'number' ? index.uidValidity : 0;
|
|
70
73
|
// A UIDVALIDITY change renumbers every message in the mailbox: keeping the
|
|
71
74
|
// old cursor would either report the whole folder as new or miss everything
|
|
72
75
|
// that renumbered below it. Re-seed the baseline instead and say so.
|
|
73
76
|
const reset = stored !== undefined && stored.uidValidity !== 0 && uidValidity !== 0 && stored.uidValidity !== uidValidity;
|
|
74
77
|
const isFirst = stored === undefined || reset;
|
|
75
78
|
const cursor = stored === undefined || reset ? 0 : stored.uid;
|
|
76
|
-
const fresh =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
const fresh = isFirst ? [] : index.uids.filter(uid => uid > cursor);
|
|
80
|
+
// 后续每次只返回 fresh 中最旧的 limit 条,游标也只推进到这批的最大 uid:
|
|
81
|
+
// 窗口里更旧的新邮件留给下一轮,不能因为本次只返回 limit 条就被永久跳过。
|
|
82
|
+
const batch = fresh.slice(Math.max(0, fresh.length - capped));
|
|
83
|
+
const messages = batch.length > 0 ? await pool.fetchByUids(account, folder, batch, signal) : [];
|
|
84
|
+
if (isFirst) {
|
|
85
|
+
// 首次调用(或 UIDVALIDITY 重建)只落基线:游标取窗口最新一封,旧邮件不算新邮件。
|
|
86
|
+
watchCursors.set(key, {
|
|
87
|
+
uid: index.uids.length > 0 ? index.uids[0] : 0,
|
|
88
|
+
uidValidity,
|
|
89
|
+
});
|
|
79
90
|
}
|
|
80
|
-
else if (
|
|
81
|
-
watchCursors.set(key, { uid:
|
|
91
|
+
else if (batch.length > 0) {
|
|
92
|
+
watchCursors.set(key, { uid: Math.max(...batch), uidValidity });
|
|
82
93
|
}
|
|
83
94
|
return {
|
|
84
|
-
account:
|
|
85
|
-
folder:
|
|
95
|
+
account: index.account,
|
|
96
|
+
folder: index.folder,
|
|
86
97
|
firstRun: stored === undefined,
|
|
87
98
|
...(reset ? { reset: true } : {}),
|
|
88
|
-
newCount:
|
|
89
|
-
messages
|
|
90
|
-
totalUnread:
|
|
99
|
+
newCount: messages.length,
|
|
100
|
+
messages,
|
|
101
|
+
totalUnread: index.count,
|
|
91
102
|
};
|
|
92
103
|
};
|
|
93
104
|
const dispose = () => {
|
package/lib/tool-contract.js
CHANGED
|
@@ -195,7 +195,13 @@ export function renderRead(value) {
|
|
|
195
195
|
return oneText('账号 ' + value.account + ',主题:' + (value.subject || '(无主题)') + '\n来自:' + from + '\n时间:' + (value.date || '(未知)') + attach + '\n\n' + value.text);
|
|
196
196
|
}
|
|
197
197
|
export function renderSearch(value) {
|
|
198
|
+
const scanned = value.countKind === 'scanned';
|
|
199
|
+
// 回退扫描只看了最近 scannedLimit 封,不知道全文件夹匹配数:不能把本页条数说成「共 N 条」。
|
|
200
|
+
const scannedNote = '仅扫描最近 ' + (value.scannedLimit ?? 0) + ' 封,未统计全文件夹匹配数';
|
|
198
201
|
if (value.messages.length === 0) {
|
|
202
|
+
if (scanned) {
|
|
203
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":本页没有匹配(' + scannedNote + ')。');
|
|
204
|
+
}
|
|
199
205
|
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,本次没有列出。');
|
|
200
206
|
}
|
|
201
207
|
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
@@ -203,7 +209,10 @@ export function renderSearch(value) {
|
|
|
203
209
|
const window = offset > 0
|
|
204
210
|
? '跳过最新 ' + offset + ' 条后展示 ' + value.messages.length + ' 条'
|
|
205
211
|
: '展示最新 ' + value.messages.length + ' 条';
|
|
206
|
-
|
|
212
|
+
const summary = scanned
|
|
213
|
+
? '本页 ' + value.messages.length + ' 条(' + scannedNote + ')' + (offset > 0 ? ',已被 offset 跳过最新 ' + offset + ' 条' : '')
|
|
214
|
+
: '共 ' + value.count + ' 条匹配,' + window;
|
|
215
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":' + summary + ':\n\n' + lines.join('\n'));
|
|
207
216
|
}
|
|
208
217
|
export function renderSend(value) {
|
|
209
218
|
const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
|
package/lib/tools.d.ts
CHANGED
|
@@ -9,5 +9,6 @@ export interface EmailToolDefinition {
|
|
|
9
9
|
render(args: unknown, value: unknown): TextBlock[];
|
|
10
10
|
};
|
|
11
11
|
execute(args: unknown, exec?: unknown): Promise<unknown>;
|
|
12
|
+
timeoutMs?: number;
|
|
12
13
|
}
|
|
13
14
|
export declare function buildEmailTools(runtime: Pick<EmailRuntime, 'getPool' | 'getEffectiveSettings' | 'watch'>): EmailToolDefinition[];
|
package/lib/tools.js
CHANGED
|
@@ -2,6 +2,9 @@ import { clampInt, PROVIDER_PRESETS } from './config.js';
|
|
|
2
2
|
import { messageOf } from './mail-client.js';
|
|
3
3
|
import { NOT_LOGGED_IN_MESSAGE, oauth2StateOf } from './oauth2.js';
|
|
4
4
|
import { descriptions, parameters, MAX_LIMIT, MARK_ACTIONS, REPLY_MODES, executionSignal, normalizeAttachmentPaths, parseEmailDay, attachmentSchema, foldersSchema, listSchema, markSchema, readSchema, replySchema, sendSchema, watchSchema, renderAttachment, renderFolders, renderHealth, renderList, renderMark, renderRead, renderReply, renderSearch, renderSend, renderWatch, } from './tool-contract.js';
|
|
5
|
+
/** 单次工具调用的 deadline:普通查询 60s,正文扫描/watch 这类整批操作 120s。 */
|
|
6
|
+
const QUERY_TIMEOUT_MS = 60000;
|
|
7
|
+
const BATCH_TIMEOUT_MS = 120000;
|
|
5
8
|
export function buildEmailTools(runtime) {
|
|
6
9
|
const { getPool, getEffectiveSettings, watch: watchCore } = runtime;
|
|
7
10
|
return [
|
|
@@ -13,6 +16,7 @@ export function buildEmailTools(runtime) {
|
|
|
13
16
|
schema: listSchema,
|
|
14
17
|
render: (_args, value) => renderList(value),
|
|
15
18
|
},
|
|
19
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
16
20
|
async execute(rawArgs, exec) {
|
|
17
21
|
const args = rawArgs;
|
|
18
22
|
const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
|
|
@@ -30,6 +34,7 @@ export function buildEmailTools(runtime) {
|
|
|
30
34
|
schema: readSchema,
|
|
31
35
|
render: (_args, value) => renderRead(value),
|
|
32
36
|
},
|
|
37
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
33
38
|
async execute(rawArgs, exec) {
|
|
34
39
|
const args = rawArgs;
|
|
35
40
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
@@ -46,6 +51,7 @@ export function buildEmailTools(runtime) {
|
|
|
46
51
|
schema: markSchema,
|
|
47
52
|
render: (_args, value) => renderMark(value),
|
|
48
53
|
},
|
|
54
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
49
55
|
async execute(rawArgs, exec) {
|
|
50
56
|
const args = rawArgs;
|
|
51
57
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
@@ -69,6 +75,7 @@ export function buildEmailTools(runtime) {
|
|
|
69
75
|
schema: listSchema,
|
|
70
76
|
render: (_args, value) => renderSearch(value),
|
|
71
77
|
},
|
|
78
|
+
timeoutMs: BATCH_TIMEOUT_MS,
|
|
72
79
|
async execute(rawArgs, exec) {
|
|
73
80
|
const args = rawArgs;
|
|
74
81
|
if (typeof args.query !== 'string' || args.query.trim() === '')
|
|
@@ -88,6 +95,7 @@ export function buildEmailTools(runtime) {
|
|
|
88
95
|
schema: sendSchema,
|
|
89
96
|
render: (_args, value) => renderSend(value),
|
|
90
97
|
},
|
|
98
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
91
99
|
async execute(rawArgs, exec) {
|
|
92
100
|
const args = rawArgs;
|
|
93
101
|
if (typeof args.to !== 'string' || args.to.trim() === '')
|
|
@@ -102,6 +110,7 @@ export function buildEmailTools(runtime) {
|
|
|
102
110
|
description: descriptions.email_reply,
|
|
103
111
|
parameters: parameters.email_reply,
|
|
104
112
|
output: { schema: replySchema, render: (_args, value) => renderReply(value) },
|
|
113
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
105
114
|
async execute(rawArgs, exec) {
|
|
106
115
|
const args = rawArgs;
|
|
107
116
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
@@ -128,6 +137,7 @@ export function buildEmailTools(runtime) {
|
|
|
128
137
|
schema: foldersSchema,
|
|
129
138
|
render: (_args, value) => renderFolders(value),
|
|
130
139
|
},
|
|
140
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
131
141
|
async execute(rawArgs, exec) {
|
|
132
142
|
const args = rawArgs;
|
|
133
143
|
return await getPool().folders(args.account, args.subscribedOnly === true, executionSignal(exec));
|
|
@@ -138,6 +148,7 @@ export function buildEmailTools(runtime) {
|
|
|
138
148
|
description: descriptions.email_health,
|
|
139
149
|
parameters: parameters.email_health,
|
|
140
150
|
output: { schema: { type: 'object', additionalProperties: true }, render: renderHealth },
|
|
151
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
141
152
|
async execute(_rawArgs, exec) {
|
|
142
153
|
executionSignal(exec)?.throwIfAborted();
|
|
143
154
|
const checks = [];
|
|
@@ -182,6 +193,7 @@ export function buildEmailTools(runtime) {
|
|
|
182
193
|
schema: attachmentSchema,
|
|
183
194
|
render: (_args, value) => renderAttachment(value),
|
|
184
195
|
},
|
|
196
|
+
timeoutMs: QUERY_TIMEOUT_MS,
|
|
185
197
|
async execute(rawArgs, exec) {
|
|
186
198
|
const args = rawArgs;
|
|
187
199
|
if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
|
|
@@ -197,6 +209,7 @@ export function buildEmailTools(runtime) {
|
|
|
197
209
|
description: descriptions.email_watch,
|
|
198
210
|
parameters: parameters.email_watch,
|
|
199
211
|
output: { schema: watchSchema, render: (_args, value) => renderWatch(value) },
|
|
212
|
+
timeoutMs: BATCH_TIMEOUT_MS,
|
|
200
213
|
async execute(rawArgs, exec) {
|
|
201
214
|
const args = rawArgs;
|
|
202
215
|
const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
|
package/lib/types.d.ts
CHANGED
|
@@ -51,11 +51,16 @@ export interface EmailReadResult extends ReadMessageBody {
|
|
|
51
51
|
export interface EmailSearchResult {
|
|
52
52
|
account: string;
|
|
53
53
|
query: string;
|
|
54
|
+
/** Server path: the server's match count. Fallback scan: this page's row count (see countKind). */
|
|
54
55
|
count: number;
|
|
55
56
|
folder: string;
|
|
56
57
|
/** How many newest matches the caller skipped (0 on the first page). */
|
|
57
58
|
offset: number;
|
|
58
59
|
messages: ListedMessage[];
|
|
60
|
+
/** Set to 'scanned' when the local body scan produced this page, so count is not a total. */
|
|
61
|
+
countKind?: 'scanned';
|
|
62
|
+
/** How many newest messages the fallback scan looked at (set with countKind='scanned'). */
|
|
63
|
+
scannedLimit?: number;
|
|
59
64
|
}
|
|
60
65
|
export interface EmailSendResult {
|
|
61
66
|
account: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "DSH 邮件插件:IMAP/SMTP 收发搜索、回复转发、附件与整理,多账号卡片设置页 + Outlook OAuth2 + 发信审批。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"assets/whale-fallback.png",
|
|
20
20
|
"cordis.patch.yml",
|
|
21
21
|
"README.md",
|
|
22
|
-
"README.en.md"
|
|
22
|
+
"README.en.md",
|
|
23
|
+
"CHANGELOG.md"
|
|
23
24
|
],
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "tsc",
|