dsh-email 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.en.md +12 -20
- package/README.md +12 -23
- package/lib/client.js +21 -9
- package/lib/config.d.ts +18 -14
- package/lib/config.js +18 -14
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/mail-client.d.ts +27 -1
- package/lib/mail-client.js +185 -17
- package/lib/oauth2.d.ts +5 -4
- package/lib/oauth2.js +5 -4
- 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/lib/web.d.ts +12 -4
- package/lib/web.js +5 -3
- package/package.json +4 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
> 完整历史(含详细改动说明)。README 只保留最近几个版本的一句话摘要。
|
|
4
|
+
|
|
5
|
+
## 中文版
|
|
6
|
+
|
|
7
|
+
- **0.13.1(2026-09-19)**:**新增**:内置一份公共客户端注册(`15dcd5aa-…`,贡献者 [gurio-wine](https://github.com/gurio-wine) 在 [PR #13](https://github.com/STARDUSTLC666/dsh-email/pull/13) 注册,并授权本项目内置使用,特此致谢)——Outlook / Exchange Online 的 OAuth2 登录**开箱即用**,不再需要每个用户自己注册应用。设置页卡片会显示当前生效的应用 ID:留空即用内置的社区应用,填入自己的 `clientId` 即覆盖(账号级仍可覆盖顶层简写)。**文档**:README 说明内置应用来自谁、代价是什么、如何换成自己的;并说明 token 与签发它的应用 ID 绑定,将来替换内置注册需要这些账号重新登录一次。测试 262 → 264 项。
|
|
8
|
+
- **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 项。
|
|
9
|
+
- **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 之后的代码重写)。
|
|
10
|
+
- **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))。
|
|
11
|
+
- **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。
|
|
12
|
+
- **0.10.7(2026-09-11)**:复验官方 Harness 0.1.5-rc.1,更新整套同载与真实服务验证记录;运行时代码未变。
|
|
13
|
+
- **0.10.6(2026-09-10)**:修复单账号设置页授权码留空时,空字符串遮蔽 `DSH_EMAIL_PASSWORD`,导致“测试连接”和保存后工具调用报未配置的问题;显式密码仍优先,多账号不会借用该环境变量。更新设置页工具数量、多账号说明,并补充真实 QQ 邮箱验证结果。
|
|
14
|
+
- **0.10.5(2026-09-08)**:补充官方 Harness 0.1.3-alpha.2 的安装、工具注册及 Web 设置接口验证,更新 Node 版本要求,明确 `email_health` 只检查配置;运行时代码与 0.10.4 相同。
|
|
15
|
+
- **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 邮件解析回归测试。依赖告警不等于已证实邮件输入可触发该漏洞。
|
|
16
|
+
- **0.10.1**:补发制品——已发布的 0.10.0 打包时只含 `email_mark`,本版同时包含 `email_mark` 与 `email_reply`,代码与 0.10.0 的 main 一致。
|
|
17
|
+
- **0.10.0**:新增 `email_mark`(已读/未读/星标/移动文件夹,补齐收发闭环的整理侧)与 `email_reply`(回复/回复全部/转发,自动线程头+引文,走发信审批门);连接池按读/写模式分别管理邮箱打开状态。
|
|
18
|
+
- **0.9.1**:修复设置页空主机遮蔽 provider 预设(#3/#6);IMAP 连接超时不再杀死整个 DSH 进程(#4);暗色模式输入控件可见(#2);密码栏提示环境变量 `DSH_EMAIL_PASSWORD` 免明文方案(#5)。
|
|
19
|
+
- **0.9.0**:新增 `email_watch` 增量新邮件检查工具(游标式,适合定时提醒);Web 端新增「鲸鱼娘递信」新邮件弹窗(本地皮肤素材运行时读取 + 内置回退图)。
|
|
20
|
+
- **0.8.2**:`since` / `until` 参数描述与其余参数统一为英文,方便多语言 agent 理解。
|
|
21
|
+
- **0.8.0/0.8.1**:`email_list` / `email_search` 新增 `since` / `until` 日期范围过滤;新增 `email_health` 账号配置自检;适配 harness 0.1.2(清理已删除的客户端注入声明)。
|
|
22
|
+
- **0.6.2**:服务器端搜索补齐 `cc`,搜索范围真正覆盖主题 / 发件人 / 收件人 / 抄送;正文回退扫描也匹配 `to` / `cc`,单封解析失败不中断整批;列表强制 UID 降序「最新在前」;`email_send` 附件参数严格校验。
|
|
23
|
+
|
|
24
|
+
## English
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
- **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).
|
|
28
|
+
- **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)).
|
|
29
|
+
- **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.
|
|
30
|
+
- **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.
|
|
31
|
+
- **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.
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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).
|
|
35
|
+
- **0.8.2**: `since` / `until` parameter descriptions unified to English, consistent with the other parameters, so multilingual agents read them correctly.
|
|
36
|
+
- **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).
|
|
37
|
+
- **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,11 @@ Example:
|
|
|
37
37
|
|
|
38
38
|
### Changelog
|
|
39
39
|
|
|
40
|
-
- **0.
|
|
41
|
-
- **0.
|
|
42
|
-
- **0.
|
|
43
|
-
- **0.
|
|
44
|
-
- **0.10.
|
|
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.1 (2026-09-19)**: ships a community public-client registration (thanks [gurio-wine](https://github.com/gurio-wine)), so Outlook / Exchange Online works out of the box; supply your own `clientId` to override it — the card shows which application is in effect. 264 tests.
|
|
41
|
+
- **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.
|
|
42
|
+
- **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.
|
|
43
|
+
- **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.
|
|
44
|
+
- **0.10.8 and earlier**: see [CHANGELOG.md](CHANGELOG.md).
|
|
53
45
|
## Compatibility
|
|
54
46
|
|
|
55
47
|
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.
|
|
@@ -172,7 +164,7 @@ The settings page's "Server presets" fold-out edits these presets visually, and
|
|
|
172
164
|
| `maxBodyChars` | `20000` | Body truncation limit for `email_read` (1000–200000) |
|
|
173
165
|
| `accounts` | — | Named account map; account-level fields override top-level shorthand |
|
|
174
166
|
| `accountsYaml` | — | YAML text of the account map, written by the settings-page card editor; overrides `accounts` when non-empty |
|
|
175
|
-
| `clientId` |
|
|
167
|
+
| `clientId` | built-in community app (below) | Application (client) ID for OAuth2 accounts: empty uses the plugin's built-in public client, a value overrides it (account-level also overrides the top-level shorthand) |
|
|
176
168
|
| `authKind` | derived from provider | Authentication method override: `oauth2` / `password`. When omitted, derived from provider and IMAP host; hybrid or on-premises tenants that still accept app passwords for Exchange Online can pin `password`. The card's "Authentication method" selector writes this key |
|
|
177
169
|
| `serverPresets` | — | YAML text of custom provider presets (key = preset name, value has optional `label` + `imap`/`smtp`); endpoints only, no credentials. The settings-page dropdown lists preset names and pre-fills endpoints into account cards; editing a preset does not reconnect established connections |
|
|
178
170
|
| `defaultAccount` | auto (single account) | Account used when the `account` argument is omitted (required for multi-account) |
|
|
@@ -202,9 +194,9 @@ Every provider requires an authorization code / app-specific password instead of
|
|
|
202
194
|
|
|
203
195
|
Microsoft has disabled username+password basic auth for Exchange Online: personal outlook.com accounts and most tenants now require OAuth2. This plugin supports the device-code flow — IMAP and SMTP share a single token with automatic refresh.
|
|
204
196
|
|
|
205
|
-
**
|
|
197
|
+
**Where the bundled application ID comes from**: the device-code flow needs an app registration, and making every user register one is a wall nobody should have to climb — so the plugin ships one: `15dcd5aa-00dd-487f-82d7-1d2b2c299e14`, registered by contributor [gurio-wine](https://github.com/gurio-wine) in [PR #13](https://github.com/STARDUSTLC666/dsh-email/pull/13) and used here with their permission — thank you. The cost is stated plainly: the Microsoft consent screen names **their** application (enterprise security teams may refuse it), and the sign-in logs and telemetry land in **their** tenant, including your UPN. If they ever delete the app, every account that did not bring its own id stops signing in at once, with no better error than "clientId may be wrong". **Registering your own (free, ~10 minutes) keeps you independent — paste it into the card to override the built-in value; leaving the field empty keeps using the bundled one.**
|
|
206
198
|
|
|
207
|
-
**
|
|
199
|
+
**Using your own application instead (optional, free, ~10 minutes)**:
|
|
208
200
|
|
|
209
201
|
1. Open the [Entra admin center](https://entra.microsoft.com/) → **App registrations** → **New registration**.
|
|
210
202
|
2. Under **Supported account types**, select "Accounts in any organizational directory and personal Microsoft accounts" — this determines whether personal outlook.com accounts can sign in. Choosing incorrectly yields `AADSTS700016` or `AADSTS50020`.
|
|
@@ -213,20 +205,20 @@ Microsoft has disabled username+password basic auth for Exchange Online: persona
|
|
|
213
205
|
5. In the left sidebar, go to **Authentication** → scroll to the bottom → set **Allow public client flows** to **Yes** and save. Without this, login fails with an `AADSTS700028`-style "device-code flow not enabled" error.
|
|
214
206
|
6. In the left sidebar, go to **API permissions** → Add a permission → Microsoft Graph → **Delegated permissions** → check `IMAP.AccessAsUser.All`, `SMTP.Send`, and `offline_access` (the last one is essential for obtaining a refresh token — without it, every expiry forces a fresh login). Personal tenants generally need no admin consent; enterprise tenants may require an admin to click "Grant admin consent" once.
|
|
215
207
|
|
|
216
|
-
**Filling it into the plugin**: Settings → Mail (dsh-email) → the account card's "Application (client) ID" field; or in YAML (account-level `clientId`, which can also be set at the top level as a default for all accounts).
|
|
208
|
+
**Filling it into the plugin (overrides the built-in value)**: Settings → Mail (dsh-email) → the account card's "Application (client) ID" field (the card shows which application is in effect; empty keeps the bundled community app); or in YAML (account-level `clientId`, which can also be set at the top level as a default for all accounts).
|
|
217
209
|
|
|
218
210
|
**Login flow**: click "Sign in to Microsoft account" on the card → the panel shows a `microsoft.com/devicelogin` link and a code → open the link in a browser, enter the code, and complete authorization → the panel polls until it shows "Signed in: your@email". Both receiving and sending then use this token.
|
|
219
211
|
|
|
220
212
|
**Caveats**:
|
|
221
213
|
|
|
222
214
|
- Enterprise tenants may additionally require an admin to enable **IMAP** and **SMTP AUTH** for the mailbox in the Exchange admin center. The typical symptom of SMTP AUTH being off: receiving works fine, sending is rejected.
|
|
223
|
-
- The token is bound to the application ID that issued it: changing `clientId` is treated as "switched apps" and requires re-login (this is intentional — it prevents using the old app's credentials against the new one).
|
|
215
|
+
- The token is bound to the application ID that issued it: changing `clientId` is treated as "switched apps" and requires re-login (this is intentional — it prevents using the old app's credentials against the new one). The bundled app is shared by every account that names none: a future release that replaces it with the project's own registration will ask those accounts to sign in once more.
|
|
224
216
|
- If your tenant is hybrid or on-premises and SMTP AUTH is still enabled, app passwords work: select "Password / authorization code" in the card's "Authentication method" selector — no OAuth2 needed.
|
|
225
217
|
|
|
226
218
|
## Known limitations
|
|
227
219
|
|
|
228
|
-
- **OAuth2 covers Outlook / Exchange Online only, and
|
|
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
|
+
- **OAuth2 covers Outlook / Exchange Online only, and works out of the box (your own app ID optional)**: device-code login supports both IMAP and SMTP and defaults to the bundled community application (see "Outlook OAuth2" above), so no registration is needed to sign in; if your organisation refuses third-party apps, paste your own `clientId` into the card to override it. Other environments that mandate OAuth (e.g. Google Workspace) remain unusable; use the provider's app-specific password / authorization code instead.
|
|
221
|
+
- **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
222
|
- **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
223
|
- **Attachments**: inline images aren't downloadable separately yet; a failed attachment match errors instead of downloading the wrong file (safe default).
|
|
232
224
|
- **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,11 @@ 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.
|
|
52
|
-
- **0.10.
|
|
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.1(2026-09-19)**:内置一份社区公共客户端注册(感谢 [gurio-wine](https://github.com/gurio-wine)),Outlook / Exchange Online 的 OAuth2 登录开箱即用;想用自己的应用仍可填 `clientId` 覆盖,设置页会显示当前生效的是哪个应用。测试 264 项。
|
|
49
|
+
- **0.13.0(2026-09-18)**:修复长正文截断成空、`email_watch` 永久漏报新邮件、附件缓存跨 UIDVALIDITY 失效;10 个工具声明超时;读信/搜索只下正文分段;搜索回退标明扫描口径。测试 262 项。
|
|
50
|
+
- **0.12.0(2026-09-18)**:新增发送别名(`senderName` / `authUser` / `authPassword`)与 `email_search` 的 `offset` 翻页;修复 QQ 搜索假命中;弹窗轮询按页面可见性节流。
|
|
51
|
+
- **0.11.0(2026-09-18)**:合入 gurio-wine 的设置页四连(卡片编辑器 / OAuth2 设备码登录 / 双语面板 / `authKind` 钉住),并修掉评审发现的 SMTP OAuth2、设置路由同源校验等问题。
|
|
52
|
+
- **0.10.8 及更早**:见 [CHANGELOG.md](CHANGELOG.md)。
|
|
64
53
|
## 兼容性
|
|
65
54
|
|
|
66
55
|
2026-09-16 曾在官方源码构建的 Harness `0.1.5-rc.2` 和 `0.1.6-alpha.1` 上完成同载验证:18 个组件与 ModLens 同载,工具 schema、技能注册及离线只读调用检查通过。
|
|
@@ -183,7 +172,7 @@ dsh plugin --profile web remove dsh-email
|
|
|
183
172
|
| `maxBodyChars` | `20000` | email_read 正文截断上限(1000–200000) |
|
|
184
173
|
| `accounts` | 无 | 具名账号表;账号级字段覆盖顶层简写 |
|
|
185
174
|
| `accountsYaml` | 无 | 账号映射的 YAML 文本,由设置页的卡片编辑器写入;非空时覆盖 accounts |
|
|
186
|
-
| `clientId` |
|
|
175
|
+
| `clientId` | 内置社区应用(见下) | OAuth2 账号的应用(客户端)ID:留空即用插件内置的公共客户端,填了则覆盖内置值(账号级也可覆盖顶层简写) |
|
|
187
176
|
| `authKind` | 按 provider 派生 | 认证方式覆盖,取值 `oauth2` / `password`。缺省时按 provider 与 IMAP 主机派生;仍能用应用密码连 Exchange Online 的混合或本地租户可钉 `password`。设置页卡片的「认证方式」选择器即写此键 |
|
|
188
177
|
| `serverPresets` | 无 | 自定义服务商预设的 YAML 文本(键=预设名,值含 `label?`/`imap`/`smtp`);只存端点、不含凭证,设置页下拉会列出预设名并把端点预填进账号卡片,改预设不会重连已建立的连接 |
|
|
189
178
|
| `defaultAccount` | 单账号时自动 | 工具省略 account 参数时使用的账号(多账号必填) |
|
|
@@ -213,9 +202,9 @@ dsh plugin --profile web remove dsh-email
|
|
|
213
202
|
|
|
214
203
|
微软已经对 Exchange Online 关闭了用户名+密码的 basic auth:个人 outlook.com 与绝大多数租户现在只能用 OAuth2。本插件支持设备码(device code)流程,IMAP 与 SMTP 双端共用同一份 token,过期自动刷新。
|
|
215
204
|
|
|
216
|
-
|
|
205
|
+
**内置的应用 ID 是哪来的**:设备码登录必须先有一个"应用注册",而让每个用户自己注册一次实在太麻烦——所以插件内置了一份:`15dcd5aa-00dd-487f-82d7-1d2b2c299e14`,由贡献者 [gurio-wine](https://github.com/gurio-wine) 在 [PR #13](https://github.com/STARDUSTLC666/dsh-email/pull/13) 注册,并授权本项目内置使用,在此致谢。代价也要说清楚:微软同意屏上显示的是**他的应用名**(企业安全团队可能因此拒绝授权),登录日志与 telemetry 会归到**他的租户**(含你的 UPN);哪天他删掉这个应用,所有没填自己 ID 的账号会同时登不上,报错还只是一句"clientId 可能填错了"。**想完全自主就注册一个自己的(免费,约 10 分钟)填进卡片覆盖内置值;留空则一直用内置的。**
|
|
217
206
|
|
|
218
|
-
|
|
207
|
+
**换成自己的应用(可选,免费,约 10 分钟)**:
|
|
219
208
|
|
|
220
209
|
1. 打开 [Entra 管理中心](https://entra.microsoft.com/) → **应用注册(App registrations)** → **新注册**。
|
|
221
210
|
2. **受支持的账户类型**选「任何组织目录中的账户 **以及** 个人 Microsoft 账户」——这一项决定了个人 outlook.com 能不能登录,选错会报 `AADSTS700016` 或 `AADSTS50020`。
|
|
@@ -224,20 +213,20 @@ dsh plugin --profile web remove dsh-email
|
|
|
224
213
|
5. 左侧 **身份验证** → 页面最下方 **允许公共客户端流** 设为 **是** 并保存。不开这一项,登录会报 `AADSTS700028` 之类的"未开启设备码流"错误。
|
|
225
214
|
6. 左侧 **API 权限** → 添加权限 → Microsoft Graph → **委托的权限**,勾上 `IMAP.AccessAsUser.All`、`SMTP.Send`、`offline_access`(最后这个是拿到 refresh token 的关键,少了它每次过期都要重新登录)。个人租户一般无需管理员同意;企业租户可能需要管理员点一次「授予同意」。
|
|
226
215
|
|
|
227
|
-
|
|
216
|
+
**填进插件(覆盖内置值)**:设置页 → 邮件 (dsh-email) → 该账号卡片的「应用(客户端)ID」栏(卡片会显示当前生效的是哪个应用;留空即继续用内置的社区应用);或者写在 YAML 里(账号级 `clientId`,也可写在顶层作为所有账号的默认)。
|
|
228
217
|
|
|
229
218
|
**登录**:卡片上点「登录 Microsoft 账号」→ 面板给出一个 `microsoft.com/devicelogin` 链接和一段代码 → 在浏览器打开链接、输入代码、完成授权 → 面板轮询到成功后即显示「已登录:你的地址」。之后收信与发信都用这份 token。
|
|
230
219
|
|
|
231
220
|
**注意事项**:
|
|
232
221
|
|
|
233
222
|
- 企业租户可能还需要管理员在 Exchange 管理中心开启该邮箱的 **IMAP** 与 **SMTP AUTH**。没开 SMTP AUTH 时的典型症状是:收信一切正常,发信被拒。
|
|
234
|
-
- token 与签发它的应用 ID 绑定:换了 `clientId` 会被判为"换了应用"
|
|
223
|
+
- token 与签发它的应用 ID 绑定:换了 `clientId` 会被判为"换了应用",需要重新登录(这是有意的,避免拿旧应用的凭据去撞新应用)。内置应用是所有没填 `clientId` 的账号共用的一份:将来某个版本把它换成项目自己的注册时,这些账号也会需要重新登录一次。
|
|
235
224
|
- 如果你的租户是混合或本地部署、SMTP AUTH 仍然开着,用应用密码也能连:在卡片的「认证方式」里选「密码 / 授权码」即可,不必走 OAuth2。
|
|
236
225
|
|
|
237
226
|
## 已知限制
|
|
238
227
|
|
|
239
|
-
- **OAuth2 仅覆盖 Outlook / Exchange Online
|
|
240
|
-
- **搜索的匹配数**:服务器命中会先用信封复核(见上文 `email_search`);复核通过时「共 N
|
|
228
|
+
- **OAuth2 仅覆盖 Outlook / Exchange Online(开箱即用,也可自带应用 ID)**:设备码登录已支持 IMAP 与 SMTP 双端,默认使用插件内置的社区应用(见上文「Outlook OAuth2」),无需自己注册即可登录;企业策略不接受第三方应用时,在卡片里填自己的 `clientId` 覆盖。Google Workspace 等其它强制 OAuth 的环境仍不可用,只能用服务商的应用专用密码 / 授权码。
|
|
229
|
+
- **搜索的匹配数**:服务器命中会先用信封复核(见上文 `email_search`);复核通过时「共 N 条匹配」沿用服务器给出的条数,而列出的每一行都保证真的带关键词。正文回退扫描只看了最近 `bodySearchLimit` 封,不知道全文件夹匹配数,因此渲染为「本页 N 条(仅扫描最近 N 封)」而不是「共 N 条」。
|
|
241
230
|
- **正文搜索**:服务器端只搜 subject / from / to / cc;多数服务器(如 QQ)的 IMAP `TEXT` / `HEADER` 搜索不可靠,无结果时回退到最近 `bodySearchLimit` 封的正文扫描(较慢,可用 `bodySearchFallback` 关闭)。
|
|
242
231
|
- **附件**:内嵌图片暂不支持单独下载;附件定位失败会直接报错而不是下载错误文件(安全默认)。
|
|
243
232
|
- **密码落盘**:设置页保存的授权码以明文写在本机 `settings.yaml`(secret 标记只保证它不进日志 / 导出 / 诊断,不做磁盘加密)。请勿把 `settings.yaml` 交给不信任的人。
|
package/lib/client.js
CHANGED
|
@@ -332,8 +332,9 @@ var UI = {
|
|
|
332
332
|
// OAuth2 登录区
|
|
333
333
|
"oauth.clientIdLabel": "应用(客户端)ID",
|
|
334
334
|
"oauth.clientIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
335
|
-
"oauth.clientIdHint": "
|
|
336
|
-
"oauth.clientIdMissing": "
|
|
335
|
+
"oauth.clientIdHint": "留空即用插件内置的社区应用(免费、开箱即用):微软同意屏上显示的是贡献者的应用名,登录日志也归到对方租户。想换成自己的 Azure(Entra)公共客户端 ID(需勾选 IMAP/SMTP 委托权限并开启设备码流)就填进来,改动它需要重新登录。",
|
|
336
|
+
"oauth.clientIdMissing": "当前构建没有内置应用(客户端)ID,设备码登录无法开始:按 README 的「Outlook OAuth2」一节注册一个,填进来即可。",
|
|
337
|
+
"oauth.clientIdBuiltIn": "将使用插件内置的社区应用:{id}(贡献者 gurio-wine 注册并授权本项目内置使用)。企业策略不接受第三方应用时,请换成自己的。",
|
|
337
338
|
"oauth.passwordless": "这个服务商使用 OAuth2 授权,不需要密码{label}。",
|
|
338
339
|
"oauth.passwordlessLabel": "({label})",
|
|
339
340
|
"oauth.signIn.outlook": "登录 Microsoft 账号",
|
|
@@ -510,8 +511,9 @@ var UI = {
|
|
|
510
511
|
// OAuth2 登录区
|
|
511
512
|
"oauth.clientIdLabel": "Application (client) ID",
|
|
512
513
|
"oauth.clientIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
513
|
-
"oauth.clientIdHint": "
|
|
514
|
-
"oauth.clientIdMissing": "
|
|
514
|
+
"oauth.clientIdHint": "Leave this empty to log in with the plugin's built-in community application (free, nothing to set up): the Microsoft consent screen names the contributor's app and the sign-in logs land in their tenant. Paste your own Azure (Entra) public client ID here (with the IMAP/SMTP delegated permissions and the device-code flow enabled) to use that instead; changing it requires signing in again.",
|
|
515
|
+
"oauth.clientIdMissing": "This build carries no built-in application (client) ID, so the device-code login cannot start: register one as described in the README's「Outlook OAuth2」section and paste it here.",
|
|
516
|
+
"oauth.clientIdBuiltIn": "Signing in through the plugin's built-in community application: {id} (registered by contributor gurio-wine, used with permission). Use your own application if third-party apps are not allowed.",
|
|
515
517
|
"oauth.passwordless": "This provider authorizes over OAuth2, so no password is needed{label}.",
|
|
516
518
|
"oauth.passwordlessLabel": " ({label})",
|
|
517
519
|
"oauth.signIn.outlook": "Sign in with Microsoft",
|
|
@@ -764,6 +766,11 @@ function draftFromCard(card) {
|
|
|
764
766
|
senderName: card.senderName === undefined || card.senderName === null ? "" : String(card.senderName),
|
|
765
767
|
authUser: card.authUser === undefined || card.authUser === null ? "" : String(card.authUser),
|
|
766
768
|
clientId: card.clientId === undefined || card.clientId === null ? "" : String(card.clientId),
|
|
769
|
+
// 卡片只在账号自己的 clientId 为空时报内置值:它是「留空会用哪个应用」的答案,
|
|
770
|
+
// 不是账号的字段,所以既不进 YAML,也不参与三态写入。
|
|
771
|
+
defaultClientId: card.oauthDefaultClientId === undefined || card.oauthDefaultClientId === null
|
|
772
|
+
? ""
|
|
773
|
+
: String(card.oauthDefaultClientId),
|
|
767
774
|
// authKind 是生效裁决(决定显示登录区还是密码框),authKindSetting 是用户钉住
|
|
768
775
|
// 了什么('' = 自动)。两者必须分开:都从裁决读的话,「自动」与「显式密码」在
|
|
769
776
|
// 编辑器里长得一样,那条给混合/本地租户留的退路就没法从面板操作。
|
|
@@ -1180,9 +1187,11 @@ function OauthLoginPanel(props) {
|
|
|
1180
1187
|
const disabled = props.disabled === true;
|
|
1181
1188
|
const label = props.providerLabel || draft.provider || "OAuth2";
|
|
1182
1189
|
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1190
|
+
// 「用哪个应用登录」是账号自己的配置项,面板是用户唯一的常规入口。它不是机密
|
|
1191
|
+
// (公共客户端 ID 每次都随请求发出),因此可以直接回填、直接编辑。留空不是
|
|
1192
|
+
// 「没配置」:插件自带一份社区注册,所以这里必须说清生效的到底是哪个应用 ——
|
|
1193
|
+
// 同意屏上写的名字不是账号主人自己的,这件事不该等点了登录才发现。
|
|
1194
|
+
const builtInClientId = String(draft.defaultClientId || "").trim();
|
|
1186
1195
|
const clientIdField = () => h("div", { className: "dshe-field dshe-oauth-client" }, [
|
|
1187
1196
|
h("label", null, t("oauth.clientIdLabel")),
|
|
1188
1197
|
h("input", {
|
|
@@ -1190,10 +1199,13 @@ function OauthLoginPanel(props) {
|
|
|
1190
1199
|
value: String(draft.clientId || ""),
|
|
1191
1200
|
disabled: disabled,
|
|
1192
1201
|
spellcheck: "false",
|
|
1193
|
-
placeholder: t("oauth.clientIdPlaceholder"),
|
|
1202
|
+
placeholder: builtInClientId !== "" ? builtInClientId : t("oauth.clientIdPlaceholder"),
|
|
1194
1203
|
onChange: (e) => { if (props.onClientId) props.onClientId(e.target.value); },
|
|
1195
1204
|
}),
|
|
1196
1205
|
h("div", { className: "dshe-hint" }, t("oauth.clientIdHint")),
|
|
1206
|
+
String(draft.clientId || "").trim() === "" && builtInClientId !== ""
|
|
1207
|
+
? h("div", { className: "dshe-hint dshe-oauth-builtin" }, t("oauth.clientIdBuiltIn", { id: builtInClientId }))
|
|
1208
|
+
: null,
|
|
1197
1209
|
]);
|
|
1198
1210
|
|
|
1199
1211
|
if (state === "pending") {
|
|
@@ -1263,7 +1275,7 @@ function OauthLoginPanel(props) {
|
|
|
1263
1275
|
h("div", { className: "dshe-oauth-head" },
|
|
1264
1276
|
t("oauth.passwordless", { label: label ? t("oauth.passwordlessLabel", { label }) : "" })),
|
|
1265
1277
|
clientIdField(),
|
|
1266
|
-
String(draft.clientId || "").trim() === ""
|
|
1278
|
+
String(draft.clientId || "").trim() === "" && builtInClientId === ""
|
|
1267
1279
|
? h("div", { className: "dshe-alert warn" }, t("oauth.clientIdMissing"))
|
|
1268
1280
|
: null,
|
|
1269
1281
|
h("div", { className: "dshe-oauth-actions" }, [
|
package/lib/config.d.ts
CHANGED
|
@@ -18,23 +18,27 @@ export declare const OUTLOOK_PROVIDER = "outlook";
|
|
|
18
18
|
/** The Exchange Online IMAP host. Any account dialling it is an OAuth2 account. */
|
|
19
19
|
export declare const OUTLOOK_IMAP_HOST = "outlook.office365.com";
|
|
20
20
|
/**
|
|
21
|
-
* The built-in
|
|
21
|
+
* The built-in client id for the device-code flow: a community registration.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
23
|
+
* Requiring every Outlook user to register an application of their own puts a
|
|
24
|
+
* setup wall in front of the one provider where OAuth2 cannot be avoided, so
|
|
25
|
+
* the plugin ships one. The id below is the public-client registration
|
|
26
|
+
* contributed by gurio-wine (PR #13) and used with their permission; the README
|
|
27
|
+
* credits them and states the two things that follow from using somebody
|
|
28
|
+
* else's application: the consent screen names *their* app, and the sign-in
|
|
29
|
+
* logs land in *their* tenant along with the user's UPN.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
31
|
+
* An account that wants neither — an enterprise that refuses third-party apps,
|
|
32
|
+
* or a day when this registration is gone — sets its own `clientId` (a free
|
|
33
|
+
* Entra public-client registration) and that value wins over this constant.
|
|
34
|
+
* The settings card shows which application is in effect either way, so nobody
|
|
35
|
+
* consents to an app they were not told about.
|
|
36
|
+
*
|
|
37
|
+
* Replacing this constant is the whole change a maintainer makes to ship the
|
|
38
|
+
* project's own application instead. Tokens are bound to the id that issued
|
|
39
|
+
* them, so such a release asks every built-in account to log in one more time.
|
|
36
40
|
*/
|
|
37
|
-
export declare const OUTLOOK_OAUTH2_CLIENT_ID = "";
|
|
41
|
+
export declare const OUTLOOK_OAUTH2_CLIENT_ID = "15dcd5aa-00dd-487f-82d7-1d2b2c299e14";
|
|
38
42
|
/**
|
|
39
43
|
* How an account proves who it is. `password` covers every existing provider
|
|
40
44
|
* (an app password / 授权码) and is the default, so nothing about them changes.
|
package/lib/config.js
CHANGED
|
@@ -13,23 +13,27 @@ export const OUTLOOK_PROVIDER = 'outlook';
|
|
|
13
13
|
/** The Exchange Online IMAP host. Any account dialling it is an OAuth2 account. */
|
|
14
14
|
export const OUTLOOK_IMAP_HOST = 'outlook.office365.com';
|
|
15
15
|
/**
|
|
16
|
-
* The built-in
|
|
16
|
+
* The built-in client id for the device-code flow: a community registration.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
18
|
+
* Requiring every Outlook user to register an application of their own puts a
|
|
19
|
+
* setup wall in front of the one provider where OAuth2 cannot be avoided, so
|
|
20
|
+
* the plugin ships one. The id below is the public-client registration
|
|
21
|
+
* contributed by gurio-wine (PR #13) and used with their permission; the README
|
|
22
|
+
* credits them and states the two things that follow from using somebody
|
|
23
|
+
* else's application: the consent screen names *their* app, and the sign-in
|
|
24
|
+
* logs land in *their* tenant along with the user's UPN.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* An account that wants neither — an enterprise that refuses third-party apps,
|
|
27
|
+
* or a day when this registration is gone — sets its own `clientId` (a free
|
|
28
|
+
* Entra public-client registration) and that value wins over this constant.
|
|
29
|
+
* The settings card shows which application is in effect either way, so nobody
|
|
30
|
+
* consents to an app they were not told about.
|
|
31
|
+
*
|
|
32
|
+
* Replacing this constant is the whole change a maintainer makes to ship the
|
|
33
|
+
* project's own application instead. Tokens are bound to the id that issued
|
|
34
|
+
* them, so such a release asks every built-in account to log in one more time.
|
|
31
35
|
*/
|
|
32
|
-
export const OUTLOOK_OAUTH2_CLIENT_ID = '';
|
|
36
|
+
export const OUTLOOK_OAUTH2_CLIENT_ID = '15dcd5aa-00dd-487f-82d7-1d2b2c299e14';
|
|
33
37
|
export const PROVIDER_PRESETS = {
|
|
34
38
|
qq: { imap: { host: 'imap.qq.com', port: 993, secure: true }, smtp: { host: 'smtp.qq.com', port: 465, secure: true } },
|
|
35
39
|
'163': { imap: { host: 'imap.163.com', port: 993, secure: true }, smtp: { host: 'smtp.163.com', port: 465, secure: true } },
|
package/lib/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type Config = EmailConfig;
|
|
|
6
6
|
export declare function apply(ctx: any, config?: Config): void;
|
|
7
7
|
export { clampInt, defaultDownloadDir, EMAIL_PASSWORD_ENV, isOAuth2Account, OUTLOOK_OAUTH2_CLIENT_ID, OUTLOOK_PROVIDER, parseAccountsYaml, parseServerPresets, presetNamesIn, providerNames, PROVIDER_NAMES, resolveEmailConfig, resolveEmailSettings, serializeAccountsYaml } from './config.js';
|
|
8
8
|
export type { AuthKind, ResolvedEmailConfig } from './config.js';
|
|
9
|
-
export { ACCESS_TOKEN_MARGIN_MS, classifyOAuthFailure, clearTokenFor, DEVICE_CODE_URL, getFreshAccessToken, mapAadstsMessage, NO_CLIENT_ID_MESSAGE, NOT_LOGGED_IN_MESSAGE, oauth2StateOf, oauth2TokenFile, OAUTH2_SCOPES, OAuth2Error, pollDeviceFlow, readTokenStore, startDeviceFlow, TOKEN_URL, writeTokenStore, } from './oauth2.js';
|
|
9
|
+
export { ACCESS_TOKEN_MARGIN_MS, classifyOAuthFailure, clearTokenFor, clientIdOf, DEVICE_CODE_URL, getFreshAccessToken, mapAadstsMessage, NO_CLIENT_ID_MESSAGE, NOT_LOGGED_IN_MESSAGE, oauth2StateOf, oauth2TokenFile, OAUTH2_SCOPES, OAuth2Error, pollDeviceFlow, readTokenStore, startDeviceFlow, TOKEN_URL, writeTokenStore, } from './oauth2.js';
|
|
10
10
|
export type { DeviceFlowStart, OAuth2PollResult, OAuth2State, OAuth2TokenEntry, OAuth2TokenStore } from './oauth2.js';
|
|
11
11
|
export { buildReplyMessage, EmailPool, extractMessageIds, imapAuthOf, looksLikeAuthFailure, MailError, messageMatchesQuery, messageOf, OAUTH2_RELOGIN_MESSAGE, redactCredentials, selectAttachmentPart, smtpAuthOf, validateAttachmentPaths } from './mail-client.js';
|
|
12
12
|
export { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
|
package/lib/index.js
CHANGED
|
@@ -15,7 +15,7 @@ export function apply(ctx, config = {}) {
|
|
|
15
15
|
installSendApproval(ctx, runtime);
|
|
16
16
|
}
|
|
17
17
|
export { clampInt, defaultDownloadDir, EMAIL_PASSWORD_ENV, isOAuth2Account, OUTLOOK_OAUTH2_CLIENT_ID, OUTLOOK_PROVIDER, parseAccountsYaml, parseServerPresets, presetNamesIn, providerNames, PROVIDER_NAMES, resolveEmailConfig, resolveEmailSettings, serializeAccountsYaml } from './config.js';
|
|
18
|
-
export { ACCESS_TOKEN_MARGIN_MS, classifyOAuthFailure, clearTokenFor, DEVICE_CODE_URL, getFreshAccessToken, mapAadstsMessage, NO_CLIENT_ID_MESSAGE, NOT_LOGGED_IN_MESSAGE, oauth2StateOf, oauth2TokenFile, OAUTH2_SCOPES, OAuth2Error, pollDeviceFlow, readTokenStore, startDeviceFlow, TOKEN_URL, writeTokenStore, } from './oauth2.js';
|
|
18
|
+
export { ACCESS_TOKEN_MARGIN_MS, classifyOAuthFailure, clearTokenFor, clientIdOf, DEVICE_CODE_URL, getFreshAccessToken, mapAadstsMessage, NO_CLIENT_ID_MESSAGE, NOT_LOGGED_IN_MESSAGE, oauth2StateOf, oauth2TokenFile, OAUTH2_SCOPES, OAuth2Error, pollDeviceFlow, readTokenStore, startDeviceFlow, TOKEN_URL, writeTokenStore, } from './oauth2.js';
|
|
19
19
|
export { buildReplyMessage, EmailPool, extractMessageIds, imapAuthOf, looksLikeAuthFailure, MailError, messageMatchesQuery, messageOf, OAUTH2_RELOGIN_MESSAGE, redactCredentials, selectAttachmentPart, smtpAuthOf, validateAttachmentPaths } from './mail-client.js';
|
|
20
20
|
export { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncateText } from './parse.js';
|
|
21
21
|
export { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
|
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/oauth2.d.ts
CHANGED
|
@@ -17,11 +17,12 @@ export declare const OAUTH2_REQUEST_TIMEOUT_MS = 15000;
|
|
|
17
17
|
/** The one message every「no token yet」path reports, so the fix is always the same. */
|
|
18
18
|
export declare const NOT_LOGGED_IN_MESSAGE = "\u5C1A\u672A\u767B\u5F55\uFF1A\u8BF7\u5148\u5728\u8BBE\u7F6E\u9875\u5B8C\u6210\u8BBE\u5907\u7801\u767B\u5F55";
|
|
19
19
|
/**
|
|
20
|
-
* Reported when an OAuth2 account has
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Reported when an OAuth2 account has nothing to log in through. The packaged
|
|
21
|
+
* build carries a community registration (OUTLOOK_OAUTH2_CLIENT_ID), so this
|
|
22
|
+
* only surfaces in a build that blanks it, or on an account whose own id was
|
|
23
|
+
* cleared while the built-in one is gone — it still says what to type.
|
|
23
24
|
*/
|
|
24
|
-
export declare const NO_CLIENT_ID_MESSAGE = "\u5C1A\u672A\u914D\u7F6E OAuth2 \u5E94\u7528\uFF1A\u8BF7\u5728\u8BBE\u7F6E\u9875\u8BE5\u8D26\u53F7\u7684\u300C\u5E94\u7528\uFF08\u5BA2\u6237\u7AEF\uFF09ID\u300D\u91CC\u586B\u5165\
|
|
25
|
+
export declare const NO_CLIENT_ID_MESSAGE = "\u5C1A\u672A\u914D\u7F6E OAuth2 \u5E94\u7528\uFF1A\u5F53\u524D\u6784\u5EFA\u6CA1\u6709\u5185\u7F6E\u516C\u5171\u5BA2\u6237\u7AEF ID\uFF0C\u8BF7\u5728\u8BBE\u7F6E\u9875\u8BE5\u8D26\u53F7\u7684\u300C\u5E94\u7528\uFF08\u5BA2\u6237\u7AEF\uFF09ID\u300D\u91CC\u586B\u5165\u4E00\u4E2A\uFF08\u514D\u8D39\u6CE8\u518C\uFF0C\u6B65\u9AA4\u89C1 README \u7684\u300COutlook OAuth2\u300D\u4E00\u8282\uFF09\uFF0C\u5426\u5219\u65E0\u6CD5\u5F00\u59CB\u8BBE\u5907\u7801\u767B\u5F55";
|
|
25
26
|
/** Where the refresh/access tokens live. Kept out of the settings namespace on purpose. */
|
|
26
27
|
export declare function oauth2TokenFile(): string;
|
|
27
28
|
export interface OAuth2TokenEntry {
|
package/lib/oauth2.js
CHANGED
|
@@ -44,11 +44,12 @@ export const OAUTH2_REQUEST_TIMEOUT_MS = 15000;
|
|
|
44
44
|
/** The one message every「no token yet」path reports, so the fix is always the same. */
|
|
45
45
|
export const NOT_LOGGED_IN_MESSAGE = '尚未登录:请先在设置页完成设备码登录';
|
|
46
46
|
/**
|
|
47
|
-
* Reported when an OAuth2 account has
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* Reported when an OAuth2 account has nothing to log in through. The packaged
|
|
48
|
+
* build carries a community registration (OUTLOOK_OAUTH2_CLIENT_ID), so this
|
|
49
|
+
* only surfaces in a build that blanks it, or on an account whose own id was
|
|
50
|
+
* cleared while the built-in one is gone — it still says what to type.
|
|
50
51
|
*/
|
|
51
|
-
export const NO_CLIENT_ID_MESSAGE = '尚未配置 OAuth2
|
|
52
|
+
export const NO_CLIENT_ID_MESSAGE = '尚未配置 OAuth2 应用:当前构建没有内置公共客户端 ID,请在设置页该账号的「应用(客户端)ID」里填入一个(免费注册,步骤见 README 的「Outlook OAuth2」一节),否则无法开始设备码登录';
|
|
52
53
|
/** Where the refresh/access tokens live. Kept out of the settings namespace on purpose. */
|
|
53
54
|
export function oauth2TokenFile() {
|
|
54
55
|
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
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/lib/web.d.ts
CHANGED
|
@@ -36,10 +36,18 @@ export interface AccountCardData {
|
|
|
36
36
|
* The application (client) id this account logs in through. Unlike a password
|
|
37
37
|
* this is not a secret — a public-client id travels in every device-code
|
|
38
38
|
* request — so the card carries the value itself and the editor can prefill
|
|
39
|
-
* it. Omitted when the account
|
|
40
|
-
*
|
|
39
|
+
* it. Omitted when the account names none, which means the built-in
|
|
40
|
+
* registration below is the application that will be used.
|
|
41
41
|
*/
|
|
42
42
|
clientId?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The application the login falls back to when the account names none: the
|
|
45
|
+
* community registration the plugin ships. Carried on the card so the editor
|
|
46
|
+
* can show which app the consent screen is about to name, instead of leaving
|
|
47
|
+
* the user to guess — and so「no id of its own」stops looking like an error
|
|
48
|
+
* that has to be fixed before a login can even start.
|
|
49
|
+
*/
|
|
50
|
+
oauthDefaultClientId?: string;
|
|
43
51
|
/** Display name for the From header, when the account sets one. */
|
|
44
52
|
senderName?: string;
|
|
45
53
|
/** Login user when it differs from the visible address (`user`). */
|
|
@@ -79,8 +87,8 @@ export interface AccountCardInput {
|
|
|
79
87
|
password?: string | number | boolean;
|
|
80
88
|
/**
|
|
81
89
|
* OAuth2 应用(客户端)ID,三态契约与 password 相同:undefined = 本卡片没提供
|
|
82
|
-
* (保留 YAML 里已存的 clientId 键),'' =
|
|
83
|
-
*
|
|
90
|
+
* (保留 YAML 里已存的 clientId 键),'' = 明确清除(回到内置的社区应用),非空 =
|
|
91
|
+
* 写入。留空不等于不能用:内置注册就是默认值,这一栏是把默认值换成自己的应用。
|
|
84
92
|
*/
|
|
85
93
|
clientId?: string;
|
|
86
94
|
/**
|
package/lib/web.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, join } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { isMap, parseDocument } from 'yaml';
|
|
5
5
|
import { SETTINGS_NAMESPACE, toEmailConfig, validateSettingsValue } from './settings.js';
|
|
6
|
-
import { isOAuth2Account, parseAccountsYaml, parseServerPresets, presetNamesIn, PROVIDER_PRESETS, resolveEmailSettings, serializeAccountsYaml, } from './config.js';
|
|
6
|
+
import { isOAuth2Account, OUTLOOK_OAUTH2_CLIENT_ID, parseAccountsYaml, parseServerPresets, presetNamesIn, PROVIDER_PRESETS, resolveEmailSettings, serializeAccountsYaml, } from './config.js';
|
|
7
7
|
import { clearTokenFor, getFreshAccessToken, oauth2StateOf, pollDeviceFlow, startDeviceFlow, } from './oauth2.js';
|
|
8
8
|
import { EmailPool, messageOf, redactCredentials } from './mail-client.js';
|
|
9
9
|
/** Same-origin route the browser settings section talks to. */
|
|
@@ -84,9 +84,10 @@ function buildAccountCards(raw, defaultAccount, presets, tokens = () => ({ state
|
|
|
84
84
|
: isOAuth2Account(providerName, imap.host) ? 'oauth2' : 'password';
|
|
85
85
|
const user = typeof account.user === 'string' ? account.user : '';
|
|
86
86
|
// A public-client id is not a secret, so unlike the password it is handed
|
|
87
|
-
// back for the editor to prefill: an
|
|
88
|
-
//
|
|
87
|
+
// back for the editor to prefill: an account that names no application logs
|
|
88
|
+
// in with the built-in one, and the card has to say which that is.
|
|
89
89
|
const clientId = typeof account.clientId === 'string' ? account.clientId.trim() : '';
|
|
90
|
+
const oauthDefaultClientId = authKind === 'oauth2' && clientId === '' ? OUTLOOK_OAUTH2_CLIENT_ID : '';
|
|
90
91
|
// The display name and the login user are not secrets, so — like clientId —
|
|
91
92
|
// the card hands them back for the editor to prefill. A separate login
|
|
92
93
|
// password is a secret and only ever reported as a boolean.
|
|
@@ -102,6 +103,7 @@ function buildAccountCards(raw, defaultAccount, presets, tokens = () => ({ state
|
|
|
102
103
|
authKind,
|
|
103
104
|
...(pinned === 'oauth2' || pinned === 'password' ? { authKindDeclared: pinned } : {}),
|
|
104
105
|
...(clientId !== '' ? { clientId } : {}),
|
|
106
|
+
...(oauthDefaultClientId !== '' ? { oauthDefaultClientId } : {}),
|
|
105
107
|
...(senderName !== '' ? { senderName } : {}),
|
|
106
108
|
...(authUser !== '' ? { authUser } : {}),
|
|
107
109
|
...(typeof account.authPassword === 'string' && account.authPassword !== '' ? { hasAuthPassword: true } : {}),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.13.1",
|
|
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",
|