dsh-email 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -0
- package/README.md +5 -0
- package/lib/client.js +109 -22
- package/lib/config.d.ts +20 -0
- package/lib/config.js +16 -4
- package/lib/mail-client.d.ts +14 -4
- package/lib/mail-client.js +112 -48
- package/lib/runtime.js +12 -5
- package/lib/tool-contract.d.ts +3 -0
- package/lib/tool-contract.js +11 -2
- package/lib/tools.js +2 -1
- package/lib/types.d.ts +7 -0
- package/lib/web.d.ts +23 -0
- package/lib/web.js +62 -6
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -37,6 +37,7 @@ Example:
|
|
|
37
37
|
|
|
38
38
|
### Changelog
|
|
39
39
|
|
|
40
|
+
- **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).
|
|
40
41
|
- **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)).
|
|
41
42
|
- **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.
|
|
42
43
|
- **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.
|
|
@@ -161,6 +162,9 @@ The settings page's "Server presets" fold-out edits these presets visually, and
|
|
|
161
162
|
| `provider` | — | Preset name; auto-fills imap/smtp addresses. Explicitly written host/port/secure take precedence |
|
|
162
163
|
| `user` | required | Login email address |
|
|
163
164
|
| `password` | required* | Authorization code / app-specific password; *can also use the env var `DSH_EMAIL_PASSWORD` |
|
|
165
|
+
| `senderName` | — | Display name for the From header; the address itself stays `user` |
|
|
166
|
+
| `authUser` | = `user` | Login account. Alias / SMTP-relay setups: `user` is the address mail is sent from, this is the account IMAP/SMTP authenticates with |
|
|
167
|
+
| `authPassword` | = `password` | Password for `authUser`; only needed when the login account differs from `user` and has its own password |
|
|
164
168
|
| `imap.host/port/secure` | per preset | Incoming server (also `connectionTimeoutMs` / `socketTimeoutMs` for timeouts) |
|
|
165
169
|
| `smtp.host/port/secure` | per preset | Outgoing server |
|
|
166
170
|
| `inboxFolder` | `INBOX` | Default folder for read/send tools |
|
|
@@ -222,6 +226,7 @@ Microsoft has disabled username+password basic auth for Exchange Online: persona
|
|
|
222
226
|
## Known limitations
|
|
223
227
|
|
|
224
228
|
- **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.
|
|
225
230
|
- **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`).
|
|
226
231
|
- **Attachments**: inline images aren't downloadable separately yet; a failed attachment match errors instead of downloading the wrong file (safe default).
|
|
227
232
|
- **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,6 +45,7 @@ IMAP/SMTP email tools for DeepSeek Harness, with replies, forwarding, mailbox or
|
|
|
45
45
|
|
|
46
46
|
### 版本记录
|
|
47
47
|
|
|
48
|
+
- **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 之后的代码重写)。
|
|
48
49
|
- **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))。
|
|
49
50
|
- **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。
|
|
50
51
|
- **0.10.7(2026-09-11)**:复验官方 Harness 0.1.5-rc.1,更新整套同载与真实服务验证记录;运行时代码未变。
|
|
@@ -172,6 +173,9 @@ dsh plugin --profile web remove dsh-email
|
|
|
172
173
|
| `provider` | 无 | 预设名,自动填 imap/smtp 地址;显式写的 host/port/secure 优先 |
|
|
173
174
|
| `user` | 必填 | 登录邮箱地址 |
|
|
174
175
|
| `password` | 必填* | 授权码/应用专用密码;*也可用环境变量 `DSH_EMAIL_PASSWORD` |
|
|
176
|
+
| `senderName` | 无 | 发件显示名:只改收件人看到的名称,发件地址仍是 `user` |
|
|
177
|
+
| `authUser` | = `user` | 登录账号。别名 / SMTP 中继场景:`user` 是发件地址,这里填真正用于 IMAP/SMTP 认证的账号 |
|
|
178
|
+
| `authPassword` | = `password` | `authUser` 对应的密码;只有登录账号与 `user` 不同、且密码也不一样时才需要 |
|
|
175
179
|
| `imap.host/port/secure` | 按预设 | 收信服务器(另有 connectionTimeoutMs/socketTimeoutMs 可调超时) |
|
|
176
180
|
| `smtp.host/port/secure` | 按预设 | 发信服务器 |
|
|
177
181
|
| `inboxFolder` | `INBOX` | 收发工具默认使用的文件夹 |
|
|
@@ -233,6 +237,7 @@ dsh plugin --profile web remove dsh-email
|
|
|
233
237
|
## 已知限制
|
|
234
238
|
|
|
235
239
|
- **OAuth2 仅覆盖 Outlook / Exchange Online,且需自带应用 ID**:设备码登录已支持 IMAP 与 SMTP 双端,但插件**不内置任何第三方应用注册**,OAuth2 账号必须填自己的 `clientId`(免费注册,见上文「Outlook OAuth2」)。Google Workspace 等其它强制 OAuth 的环境仍不可用,只能用服务商的应用专用密码 / 授权码。
|
|
240
|
+
- **搜索的匹配数**:服务器命中会先用信封复核(见上文 `email_search`);复核通过时「共 N 条匹配」沿用服务器给出的条数,而列出的每一行都保证真的带关键词。
|
|
236
241
|
- **正文搜索**:服务器端只搜 subject / from / to / cc;多数服务器(如 QQ)的 IMAP `TEXT` / `HEADER` 搜索不可靠,无结果时回退到最近 `bodySearchLimit` 封的正文扫描(较慢,可用 `bodySearchFallback` 关闭)。
|
|
237
242
|
- **附件**:内嵌图片暂不支持单独下载;附件定位失败会直接报错而不是下载错误文件(安全默认)。
|
|
238
243
|
- **密码落盘**:设置页保存的授权码以明文写在本机 `settings.yaml`(secret 标记只保证它不进日志 / 导出 / 诊断,不做磁盘加密)。请勿把 `settings.yaml` 交给不信任的人。
|
package/lib/client.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// NOTE(维护者请看这里):这个文件就是插件网页端的**源码**,不经过 tsc —— 仓库里没有
|
|
2
|
+
// src/client.ts,`pnpm run build` 也不会重写它。改设置面板 / 鲸鱼娘弹窗请直接改这里,
|
|
3
|
+
// 改完用 `node --check lib/client.js` 自检(CI 里也会跑这一步)。
|
|
1
4
|
window.__ModuleLoader__.load({ id: "dsh-email", factory: (require) => {
|
|
2
5
|
var module = { exports: {} }; var exports = module.exports;
|
|
3
6
|
"use strict";
|
|
@@ -311,6 +314,15 @@ var UI = {
|
|
|
311
314
|
"card.passwordPlaceholder": "留空保持不变",
|
|
312
315
|
"card.passwordSaved": "已存有授权码:留空保持不变,清空后填内容即覆盖。",
|
|
313
316
|
"card.passwordHint": "留空即不写入 password 键。",
|
|
317
|
+
"card.senderNameLabel": "发件显示名(可选)",
|
|
318
|
+
"card.senderNamePlaceholder": "只影响收件人看到的名称",
|
|
319
|
+
"card.senderNameHint": "只改收件人看到的显示名,发件地址仍是上面的邮箱地址。",
|
|
320
|
+
"card.authUserLabel": "登录账号(与邮箱地址不同时填)",
|
|
321
|
+
"card.authUserPlaceholder": "留空 = 用上面的邮箱地址登录",
|
|
322
|
+
"card.authUserHint": "别名或中继场景:上面填信件发出的地址,这里填真正用于 IMAP/SMTP 登录的账号。留空即与邮箱地址相同。",
|
|
323
|
+
"card.authPasswordLabel": "登录账号的密码(可选)",
|
|
324
|
+
"card.authPasswordSaved": "已存有登录密码:留空保持不变,清空后填内容即覆盖。",
|
|
325
|
+
"card.authPasswordHint": "留空即沿用上面那栏的授权码 / 应用专用密码;只有登录账号与邮箱地址不同、且密码也不一样时才需要单独填。",
|
|
314
326
|
"card.inboxLabel": "收件文件夹(默认 INBOX)",
|
|
315
327
|
"card.autoSaveHint": "改动会自动保存。",
|
|
316
328
|
"card.autoSaveHintNoDefault": " 现在有多个账号但没有默认账号,必须先指定一个。",
|
|
@@ -480,6 +492,15 @@ var UI = {
|
|
|
480
492
|
"card.passwordPlaceholder": "Leave empty to keep it",
|
|
481
493
|
"card.passwordSaved": "A password is already stored: leave empty to keep it, or clear the field and type to overwrite.",
|
|
482
494
|
"card.passwordHint": "Leave it empty and the password key is not written.",
|
|
495
|
+
"card.senderNameLabel": "Sender display name (optional)",
|
|
496
|
+
"card.senderNamePlaceholder": "Only the name recipients see",
|
|
497
|
+
"card.senderNameHint": "Changes only the name recipients see; the address stays the one above.",
|
|
498
|
+
"card.authUserLabel": "Login user (when it differs from the address)",
|
|
499
|
+
"card.authUserPlaceholder": "Empty = log in with the address above",
|
|
500
|
+
"card.authUserHint": "Alias or relay: the address above is what mail is sent from, this is the account IMAP/SMTP authenticates. Empty means they are the same.",
|
|
501
|
+
"card.authPasswordLabel": "Password for the login user (optional)",
|
|
502
|
+
"card.authPasswordSaved": "A login password is stored: leave it empty to keep it, or type to replace it.",
|
|
503
|
+
"card.authPasswordHint": "Leave empty to reuse the authorization code / app password above; only needed when the login user has a password of its own.",
|
|
483
504
|
"card.inboxLabel": "Inbox folder (default INBOX)",
|
|
484
505
|
"card.autoSaveHint": "Changes save automatically.",
|
|
485
506
|
"card.autoSaveHintNoDefault": " There are several accounts but no default account yet; you must pick one first.",
|
|
@@ -737,6 +758,11 @@ function draftFromCard(card) {
|
|
|
737
758
|
provider: card.provider === undefined || card.provider === null ? "" : String(card.provider),
|
|
738
759
|
user: card.user === undefined || card.user === null ? "" : String(card.user),
|
|
739
760
|
password: "",
|
|
761
|
+
// 登录密码与 password 同一套三态契约:卡片永远拿不到明文,只有"是否已存"。
|
|
762
|
+
authPassword: "",
|
|
763
|
+
hasAuthPassword: card.hasAuthPassword === true,
|
|
764
|
+
senderName: card.senderName === undefined || card.senderName === null ? "" : String(card.senderName),
|
|
765
|
+
authUser: card.authUser === undefined || card.authUser === null ? "" : String(card.authUser),
|
|
740
766
|
clientId: card.clientId === undefined || card.clientId === null ? "" : String(card.clientId),
|
|
741
767
|
// authKind 是生效裁决(决定显示登录区还是密码框),authKindSetting 是用户钉住
|
|
742
768
|
// 了什么('' = 自动)。两者必须分开:都从裁决读的话,「自动」与「显式密码」在
|
|
@@ -834,6 +860,10 @@ function draftToInput(draft) {
|
|
|
834
860
|
};
|
|
835
861
|
if (draft.provider) input.provider = draft.provider;
|
|
836
862
|
if (!isOauthDraft(draft) && draft.password) input.password = draft.password;
|
|
863
|
+
// 显示名/登录名是普通三态字段(留空 = 清掉该键),密码与 password 同规:只有真打了字才写。
|
|
864
|
+
if (draft.senderName !== undefined) input.senderName = String(draft.senderName || "").trim();
|
|
865
|
+
if (!isOauthDraft(draft) && draft.authUser !== undefined) input.authUser = String(draft.authUser || "").trim();
|
|
866
|
+
if (!isOauthDraft(draft) && draft.authPassword) input.authPassword = draft.authPassword;
|
|
837
867
|
// Only an OAuth2 card models the application id; for any other account the
|
|
838
868
|
// field stays undefined, which the backend reads as「没说」and leaves the
|
|
839
869
|
// stored key alone. An empty string here is the user clearing it on purpose.
|
|
@@ -1699,6 +1729,11 @@ function AccountCardsEditor(props) {
|
|
|
1699
1729
|
h("label", null, t("card.addressLabel")),
|
|
1700
1730
|
fieldInput("text", draft.user, (v) => patchDraft(name, { user: v }), "you@example.com"),
|
|
1701
1731
|
]),
|
|
1732
|
+
h("div", { className: "dshe-field" }, [
|
|
1733
|
+
h("label", null, t("card.senderNameLabel")),
|
|
1734
|
+
fieldInput("text", draft.senderName, (v) => patchDraft(name, { senderName: v }), t("card.senderNamePlaceholder")),
|
|
1735
|
+
h("div", { className: "dshe-hint" }, t("card.senderNameHint")),
|
|
1736
|
+
]),
|
|
1702
1737
|
// 认证方式只在「可能是 OAuth2」的账号上出现:给 QQ/163 用户多一个
|
|
1703
1738
|
// 下拉框只是噪音。它是那条逃生舱的唯一面板入口——仍能用应用密码连
|
|
1704
1739
|
// Exchange Online 的租户(混合/本地部署、SMTP AUTH 未关)靠它才不用
|
|
@@ -1738,19 +1773,41 @@ function AccountCardsEditor(props) {
|
|
|
1738
1773
|
onCancel: () => cancelOauth(name),
|
|
1739
1774
|
onClientId: (v) => patchDraft(name, { clientId: v }),
|
|
1740
1775
|
})
|
|
1741
|
-
: h(
|
|
1742
|
-
h("
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1776
|
+
: h(React.Fragment, null, [
|
|
1777
|
+
h("div", { className: "dshe-field" }, [
|
|
1778
|
+
h("label", null, t("card.passwordLabel")),
|
|
1779
|
+
h("input", {
|
|
1780
|
+
type: "password",
|
|
1781
|
+
value: draft.password,
|
|
1782
|
+
disabled: busy !== "",
|
|
1783
|
+
placeholder: t("card.passwordPlaceholder"),
|
|
1784
|
+
onChange: (e) => patchDraft(name, { password: e.target.value }),
|
|
1785
|
+
}),
|
|
1786
|
+
h("div", { className: "dshe-hint" },
|
|
1787
|
+
view.hasPassword
|
|
1788
|
+
? t("card.passwordSaved")
|
|
1789
|
+
: t("card.passwordHint")),
|
|
1790
|
+
]),
|
|
1791
|
+
// 别名/中继:登录名与登录密码都只在密码类账号上有意义(OAuth2 用 token 登录)。
|
|
1792
|
+
h("div", { className: "dshe-field" }, [
|
|
1793
|
+
h("label", null, t("card.authUserLabel")),
|
|
1794
|
+
fieldInput("text", draft.authUser, (v) => patchDraft(name, { authUser: v }), t("card.authUserPlaceholder")),
|
|
1795
|
+
h("div", { className: "dshe-hint" }, t("card.authUserHint")),
|
|
1796
|
+
]),
|
|
1797
|
+
h("div", { className: "dshe-field" }, [
|
|
1798
|
+
h("label", null, t("card.authPasswordLabel")),
|
|
1799
|
+
h("input", {
|
|
1800
|
+
type: "password",
|
|
1801
|
+
value: draft.authPassword,
|
|
1802
|
+
disabled: busy !== "",
|
|
1803
|
+
placeholder: t("card.passwordPlaceholder"),
|
|
1804
|
+
onChange: (e) => patchDraft(name, { authPassword: e.target.value }),
|
|
1805
|
+
}),
|
|
1806
|
+
h("div", { className: "dshe-hint" },
|
|
1807
|
+
view.hasAuthPassword
|
|
1808
|
+
? t("card.authPasswordSaved")
|
|
1809
|
+
: t("card.authPasswordHint")),
|
|
1810
|
+
]),
|
|
1754
1811
|
]),
|
|
1755
1812
|
h("div", { className: "dshe-field" }, [
|
|
1756
1813
|
h("label", null, t("card.inboxLabel")),
|
|
@@ -2428,27 +2485,57 @@ function startWhaleWidget() {
|
|
|
2428
2485
|
hideTimer = setTimeout(closePopup, 12000);
|
|
2429
2486
|
};
|
|
2430
2487
|
|
|
2488
|
+
// 轮询策略:页面不可见时完全不打扰邮箱服务器;失败指数退避(上限 10 分钟),
|
|
2489
|
+
// 成功立刻回到 30s;快照(皮肤 / 账号列表)变化很慢,最多 10 分钟刷新一次。
|
|
2490
|
+
const POLL_MS = 30000;
|
|
2491
|
+
const SNAPSHOT_MS = 600000;
|
|
2492
|
+
const MAX_BACKOFF_MS = 600000;
|
|
2493
|
+
let failures = 0;
|
|
2494
|
+
let lastSnapshot = 0;
|
|
2495
|
+
let hasAccounts = true;
|
|
2496
|
+
|
|
2431
2497
|
const tick = async () => {
|
|
2498
|
+
if (document.hidden) return;
|
|
2432
2499
|
try {
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2500
|
+
if (!hasAccounts || Date.now() - lastSnapshot > SNAPSHOT_MS) {
|
|
2501
|
+
const snap = await api();
|
|
2502
|
+
lastSnapshot = Date.now();
|
|
2503
|
+
if (snap && snap.whale) {
|
|
2504
|
+
whaleUrl = snap.whale.url || "";
|
|
2505
|
+
whaleCredit = snap.whale.credit || "";
|
|
2506
|
+
}
|
|
2507
|
+
hasAccounts = !!(snap && snap.accounts && snap.accounts.length > 0);
|
|
2437
2508
|
}
|
|
2438
|
-
if (!
|
|
2509
|
+
if (!hasAccounts) return;
|
|
2439
2510
|
const value = await api("watch", { limit: 5 });
|
|
2440
2511
|
if (value && value.newCount > 0) showPopup(value);
|
|
2512
|
+
failures = 0;
|
|
2441
2513
|
} catch (e) {
|
|
2442
|
-
// Not configured or transient error: stay silent
|
|
2514
|
+
// Not configured or transient error: stay silent and back off.
|
|
2515
|
+
failures = Math.min(failures + 1, 8);
|
|
2443
2516
|
}
|
|
2444
2517
|
};
|
|
2445
2518
|
|
|
2446
|
-
|
|
2447
|
-
|
|
2519
|
+
const schedule = () => {
|
|
2520
|
+
clearTimeout(pollTimer);
|
|
2521
|
+
if (document.hidden) return; // 回到前台时 onVisibility 会立刻补一次
|
|
2522
|
+
const delay = failures === 0 ? POLL_MS : Math.min(POLL_MS * Math.pow(2, failures - 1), MAX_BACKOFF_MS);
|
|
2523
|
+
pollTimer = setTimeout(() => { void tick().then(schedule); }, delay);
|
|
2524
|
+
};
|
|
2525
|
+
|
|
2526
|
+
const onVisibility = () => {
|
|
2527
|
+
clearTimeout(pollTimer);
|
|
2528
|
+
if (document.hidden) return;
|
|
2529
|
+
failures = 0;
|
|
2530
|
+
void tick().then(schedule);
|
|
2531
|
+
};
|
|
2532
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
2533
|
+
void tick().then(schedule);
|
|
2448
2534
|
// 弹窗是命令式 DOM,没有 React 那层重渲染:语言切换后自己重画一次(开着的才重画)。
|
|
2449
2535
|
relocalize = () => { if (current !== null && root.childElementCount > 0) showPopup(current); };
|
|
2450
2536
|
return () => {
|
|
2451
|
-
|
|
2537
|
+
clearTimeout(pollTimer);
|
|
2538
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
2452
2539
|
if (hideTimer) clearTimeout(hideTimer);
|
|
2453
2540
|
relocalize = null;
|
|
2454
2541
|
root.remove();
|
package/lib/config.d.ts
CHANGED
|
@@ -58,6 +58,20 @@ export interface AccountConfig {
|
|
|
58
58
|
provider?: ProviderRef;
|
|
59
59
|
user?: string;
|
|
60
60
|
password?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Display name for the From header. The address stays `user` — recipients
|
|
63
|
+
* must see the mailbox that owns the mail, not the login.
|
|
64
|
+
*/
|
|
65
|
+
senderName?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Login handed to IMAP/SMTP when it differs from `user`: the alias case,
|
|
68
|
+
* where `user` is the address mail is sent *from* and the server only
|
|
69
|
+
* authenticates the real account, or a relay whose login is not a mailbox
|
|
70
|
+
* at all. Defaults to `user`.
|
|
71
|
+
*/
|
|
72
|
+
authUser?: string;
|
|
73
|
+
/** Password that goes with `authUser`. Defaults to `password`. */
|
|
74
|
+
authPassword?: string;
|
|
61
75
|
/**
|
|
62
76
|
* Public-client id used by the OAuth2 device-code flow. Only read for an
|
|
63
77
|
* OAuth2 account, where it overrides OUTLOOK_OAUTH2_CLIENT_ID.
|
|
@@ -141,6 +155,12 @@ export declare const EMAIL_PASSWORD_ENV = "DSH_EMAIL_PASSWORD";
|
|
|
141
155
|
/** Fully resolved, validated configuration for one account. */
|
|
142
156
|
export interface ResolvedEmailConfig {
|
|
143
157
|
user: string;
|
|
158
|
+
/** Display name for the From header, '' when the account does not set one. */
|
|
159
|
+
senderName: string;
|
|
160
|
+
/** Login actually handed to IMAP/SMTP (== user unless authUser is set). */
|
|
161
|
+
authUser: string;
|
|
162
|
+
/** Password for authUser (== password unless authPassword is set). */
|
|
163
|
+
authPassword: string;
|
|
144
164
|
/**
|
|
145
165
|
* The app password / 授权码. Empty for an OAuth2 account — that is the point:
|
|
146
166
|
* nothing is stored, the token store holds the credential instead.
|
package/lib/config.js
CHANGED
|
@@ -319,6 +319,12 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
|
|
|
319
319
|
// The settings form uses '' for an empty password. In single-account mode
|
|
320
320
|
// that explicitly selects the environment fallback; named accounts stay isolated.
|
|
321
321
|
const password = (acc.password ?? common.password) || (allowEnvPassword ? process.env[EMAIL_PASSWORD_ENV] ?? '' : '');
|
|
322
|
+
// An alias account sends from `user` but authenticates as somebody else, and a
|
|
323
|
+
// relay may use a different password than the mailbox it delivers for. Both
|
|
324
|
+
// default to the single-account pair so nothing changes for existing setups.
|
|
325
|
+
const authUser = (acc.authUser ?? common.authUser ?? '').trim() || user;
|
|
326
|
+
const authPassword = (acc.authPassword ?? common.authPassword) || password;
|
|
327
|
+
const senderName = (acc.senderName ?? common.senderName ?? '').trim();
|
|
322
328
|
const imap = {
|
|
323
329
|
host: acc.imap?.host ?? common.imap?.host ?? preset?.imap.host,
|
|
324
330
|
port: acc.imap?.port ?? common.imap?.port ?? preset?.imap.port,
|
|
@@ -347,8 +353,9 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
|
|
|
347
353
|
// An OAuth2 account has no password on purpose: its credential is the token
|
|
348
354
|
// in the OAuth2 store, and requiring a password would demand a secret
|
|
349
355
|
// Microsoft no longer accepts for Exchange Online.
|
|
350
|
-
if (!oauth2 &&
|
|
351
|
-
problems.push(`账号 "${name}" 的 password 未填写(单账号可用环境变量 ${EMAIL_PASSWORD_ENV})`);
|
|
356
|
+
if (!oauth2 && authPassword === '') {
|
|
357
|
+
problems.push(`账号 "${name}" 的 ${authUser === user ? 'password' : 'authPassword'} 未填写(单账号可用环境变量 ${EMAIL_PASSWORD_ENV})`);
|
|
358
|
+
}
|
|
352
359
|
if (imap.host === undefined || imap.host === '')
|
|
353
360
|
problems.push(`账号 "${name}" 的 imap.host 未填写(可填 provider 预设:${known.join('/')})`);
|
|
354
361
|
if (smtp.host === undefined || smtp.host === '')
|
|
@@ -359,8 +366,13 @@ function resolveAccount(name, common, acc, allowEnvPassword, providers, known) {
|
|
|
359
366
|
const clientId = (acc.clientId ?? common.clientId ?? '').trim();
|
|
360
367
|
return {
|
|
361
368
|
user,
|
|
362
|
-
|
|
363
|
-
//
|
|
369
|
+
senderName,
|
|
370
|
+
// OAuth2 logs in with the token's own account, so the alias login pair only
|
|
371
|
+
// exists for password accounts; and an OAuth2 account never carries a
|
|
372
|
+
// password at all — a stale one left in the YAML from before the provider
|
|
373
|
+
// changed must not travel into the pool.
|
|
374
|
+
authUser: oauth2 ? user : authUser,
|
|
375
|
+
authPassword: oauth2 ? '' : authPassword,
|
|
364
376
|
password: oauth2 ? '' : password,
|
|
365
377
|
authKind: oauth2 ? 'oauth2' : 'password',
|
|
366
378
|
...(clientId !== '' ? { clientId } : {}),
|
package/lib/mail-client.d.ts
CHANGED
|
@@ -46,12 +46,12 @@ export type SmtpAuth = {
|
|
|
46
46
|
* `accessToken` (imapflow then runs AUTHENTICATE XOAUTH2) and a password
|
|
47
47
|
* account with `pass`, exactly as before.
|
|
48
48
|
*/
|
|
49
|
-
export declare function imapAuthOf(cfg: Pick<ResolvedEmailConfig, '
|
|
49
|
+
export declare function imapAuthOf(cfg: Pick<ResolvedEmailConfig, 'authUser' | 'authPassword' | 'authKind'>, accessToken?: string): ImapAuth;
|
|
50
50
|
/**
|
|
51
51
|
* Nodemailer consumes an OAuth2 token through accessToken, not pass.
|
|
52
52
|
* Refresh remains owned by this plugin; no refresh credentials leave here.
|
|
53
53
|
*/
|
|
54
|
-
export declare function smtpAuthOf(cfg: Pick<ResolvedEmailConfig, '
|
|
54
|
+
export declare function smtpAuthOf(cfg: Pick<ResolvedEmailConfig, 'authUser' | 'authPassword' | 'authKind'>, accessToken?: string): SmtpAuth;
|
|
55
55
|
/** The message an OAuth2 account gets when the mailbox has to be logged into again. */
|
|
56
56
|
export declare const OAUTH2_RELOGIN_MESSAGE = "\u90AE\u7BB1\u767B\u5F55\u5931\u8D25\uFF1A\u8BF7\u5230\u8BBE\u7F6E\u9875\u91CD\u65B0\u767B\u5F55\uFF08Microsoft \u8D26\u53F7\u4F7F\u7528\u8BBE\u5907\u7801\u767B\u5F55\uFF0C\u4E0D\u4F7F\u7528\u6388\u6743\u7801\uFF09";
|
|
57
57
|
/**
|
|
@@ -106,7 +106,7 @@ export declare function extractMessageIds(source: Buffer): {
|
|
|
106
106
|
* be tested without a connection: recipients exclude the sending account,
|
|
107
107
|
* subject prefixes never stack, the original text is quoted underneath.
|
|
108
108
|
*/
|
|
109
|
-
export declare function buildReplyMessage(original: OriginalDigest, mode: EmailReplyMode, selfAddress: string, text: string, forwardTo?: string): BuiltReply;
|
|
109
|
+
export declare function buildReplyMessage(original: OriginalDigest, mode: EmailReplyMode, selfAddress: string | readonly string[], text: string, forwardTo?: string): BuiltReply;
|
|
110
110
|
/**
|
|
111
111
|
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
112
112
|
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
@@ -122,6 +122,16 @@ export declare class EmailPool {
|
|
|
122
122
|
resolveName(name?: string): string;
|
|
123
123
|
/** Serialize operations per account: one IMAP connection serves one op at a time. */
|
|
124
124
|
private enqueue;
|
|
125
|
+
private readonly readCache;
|
|
126
|
+
private readonly folderCache;
|
|
127
|
+
/** Remember a parsed attachment index so email_attachment can skip the refetch. */
|
|
128
|
+
private rememberRead;
|
|
129
|
+
/**
|
|
130
|
+
* The attachment index for one message: the cached one when email_read already
|
|
131
|
+
* produced it, otherwise a fresh parse of the full source plus its bodyStructure.
|
|
132
|
+
*/
|
|
133
|
+
private attachmentIndexOf;
|
|
134
|
+
private recallRead;
|
|
125
135
|
withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>, readOnly?: boolean, signal?: AbortSignal): Promise<T>;
|
|
126
136
|
private createImap;
|
|
127
137
|
/**
|
|
@@ -159,7 +169,7 @@ export declare class EmailPool {
|
|
|
159
169
|
*/
|
|
160
170
|
private sendMail;
|
|
161
171
|
list(accountName: string | undefined, folder: string, limit: number, offset: number, unreadOnly: boolean, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailListResult>;
|
|
162
|
-
search(accountName: string | undefined, query: string, folder: string, limit: number, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailSearchResult>;
|
|
172
|
+
search(accountName: string | undefined, query: string, folder: string, limit: number, offset: number, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailSearchResult>;
|
|
163
173
|
/**
|
|
164
174
|
* Confirm server-side hits against the mailbox itself: fetch the envelopes
|
|
165
175
|
* of the newest candidates — the same window the body-scan fallback looks at
|
package/lib/mail-client.js
CHANGED
|
@@ -41,8 +41,8 @@ export function redactCredentials(text) {
|
|
|
41
41
|
*/
|
|
42
42
|
export function imapAuthOf(cfg, accessToken) {
|
|
43
43
|
return cfg.authKind === 'oauth2'
|
|
44
|
-
? { user: cfg.
|
|
45
|
-
: { user: cfg.
|
|
44
|
+
? { user: cfg.authUser, accessToken: accessToken ?? '' }
|
|
45
|
+
: { user: cfg.authUser, pass: cfg.authPassword };
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
48
|
* Nodemailer consumes an OAuth2 token through accessToken, not pass.
|
|
@@ -50,8 +50,8 @@ export function imapAuthOf(cfg, accessToken) {
|
|
|
50
50
|
*/
|
|
51
51
|
export function smtpAuthOf(cfg, accessToken) {
|
|
52
52
|
return cfg.authKind === 'oauth2'
|
|
53
|
-
? { type: 'OAuth2', user: cfg.
|
|
54
|
-
: { user: cfg.
|
|
53
|
+
? { type: 'OAuth2', user: cfg.authUser, accessToken: accessToken ?? '' }
|
|
54
|
+
: { user: cfg.authUser, pass: cfg.authPassword };
|
|
55
55
|
}
|
|
56
56
|
/** The message an OAuth2 account gets when the mailbox has to be logged into again. */
|
|
57
57
|
export const OAUTH2_RELOGIN_MESSAGE = '邮箱登录失败:请到设置页重新登录(Microsoft 账号使用设备码登录,不使用授权码)';
|
|
@@ -118,6 +118,10 @@ 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
|
+
/** The From header: `user` is the visible address, `senderName` only labels it. */
|
|
122
|
+
function senderOf(cfg) {
|
|
123
|
+
return cfg.senderName === '' ? cfg.user : { name: cfg.senderName, address: cfg.user };
|
|
124
|
+
}
|
|
121
125
|
/** Case-insensitive match of a query against subject/from/body text. */
|
|
122
126
|
export function messageMatchesQuery(subject, fromText, body, query) {
|
|
123
127
|
const q = query.toLowerCase();
|
|
@@ -142,10 +146,11 @@ function formatAddress(entry) {
|
|
|
142
146
|
}
|
|
143
147
|
function dedupeAddresses(entries, exclude) {
|
|
144
148
|
const seen = new Set();
|
|
149
|
+
const excluded = new Set((Array.isArray(exclude) ? exclude : [exclude]).map(a => a.trim().toLowerCase()).filter(a => a !== ''));
|
|
145
150
|
const out = [];
|
|
146
151
|
for (const entry of entries) {
|
|
147
152
|
const addr = (entry.address ?? '').toLowerCase();
|
|
148
|
-
if (addr === '' || addr
|
|
153
|
+
if (addr === '' || excluded.has(addr) || seen.has(addr))
|
|
149
154
|
continue;
|
|
150
155
|
seen.add(addr);
|
|
151
156
|
out.push(entry);
|
|
@@ -164,7 +169,7 @@ const FORWARD_MAX_CHARS = 4000;
|
|
|
164
169
|
*/
|
|
165
170
|
export function buildReplyMessage(original, mode, selfAddress, text, forwardTo = '') {
|
|
166
171
|
const fromText = original.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知发件人)';
|
|
167
|
-
const self = selfAddress.
|
|
172
|
+
const self = (Array.isArray(selfAddress) ? selfAddress : [selfAddress]).filter(a => a.trim() !== '');
|
|
168
173
|
if (mode === 'forward') {
|
|
169
174
|
const to = forwardTo.trim();
|
|
170
175
|
if (to === '')
|
|
@@ -231,6 +236,11 @@ function listedFrom(envelope, size, hasAttachments) {
|
|
|
231
236
|
hasAttachments,
|
|
232
237
|
};
|
|
233
238
|
}
|
|
239
|
+
/** email_attachment reuses the MIME index email_read already parsed; keep a few. */
|
|
240
|
+
const READ_CACHE_MAX = 16;
|
|
241
|
+
const READ_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
242
|
+
/** Folder names change rarely; a short TTL keeps email_folders off the wire. */
|
|
243
|
+
const FOLDER_CACHE_TTL_MS = 60 * 1000;
|
|
234
244
|
/**
|
|
235
245
|
* One mailbox pool for the whole plugin: pooled IMAP connections per
|
|
236
246
|
* account plus pooled SMTP transporters, with idle sweep and error eviction.
|
|
@@ -265,6 +275,49 @@ export class EmailPool {
|
|
|
265
275
|
this.queues.set(name, next.then(() => undefined, () => undefined));
|
|
266
276
|
return next;
|
|
267
277
|
}
|
|
278
|
+
readCache = new Map();
|
|
279
|
+
folderCache = new Map();
|
|
280
|
+
/** 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;
|
|
283
|
+
this.readCache.delete(key);
|
|
284
|
+
this.readCache.set(key, { ...parsed, at: Date.now() });
|
|
285
|
+
while (this.readCache.size > READ_CACHE_MAX) {
|
|
286
|
+
const oldest = this.readCache.keys().next();
|
|
287
|
+
if (oldest.done === true)
|
|
288
|
+
break;
|
|
289
|
+
this.readCache.delete(oldest.value);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The attachment index for one message: the cached one when email_read already
|
|
294
|
+
* produced it, otherwise a fresh parse of the full source plus its bodyStructure.
|
|
295
|
+
*/
|
|
296
|
+
async attachmentIndexOf(client, account, folder, uid, signal) {
|
|
297
|
+
const cached = this.recallRead(account, folder, uid);
|
|
298
|
+
if (cached !== undefined)
|
|
299
|
+
return cached;
|
|
300
|
+
const message = await client.fetchOne(uid, { uid: true, bodyStructure: true, source: true }, { uid: true });
|
|
301
|
+
if (message === false || message.source === undefined) {
|
|
302
|
+
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
|
|
303
|
+
}
|
|
304
|
+
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
305
|
+
signal?.throwIfAborted();
|
|
306
|
+
const parsed = { attachments: body.attachments, parts: collectAttachmentParts(message.bodyStructure) };
|
|
307
|
+
this.rememberRead(account, folder, uid, parsed);
|
|
308
|
+
return parsed;
|
|
309
|
+
}
|
|
310
|
+
recallRead(account, folder, uid) {
|
|
311
|
+
const key = account + '\u0000' + folder + '\u0000' + uid;
|
|
312
|
+
const hit = this.readCache.get(key);
|
|
313
|
+
if (hit === undefined)
|
|
314
|
+
return undefined;
|
|
315
|
+
if (Date.now() - hit.at > READ_CACHE_TTL_MS) {
|
|
316
|
+
this.readCache.delete(key);
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return hit;
|
|
320
|
+
}
|
|
268
321
|
async withImap(accountName, folder, run, readOnly = true, signal) {
|
|
269
322
|
const name = this.resolveName(accountName);
|
|
270
323
|
const cfg = this.account(name);
|
|
@@ -532,6 +585,8 @@ export class EmailPool {
|
|
|
532
585
|
return this.withImap(name, folderName, async (client) => {
|
|
533
586
|
const mailbox = client.mailbox;
|
|
534
587
|
const total = mailbox === false ? 0 : mailbox.exists;
|
|
588
|
+
// imapflow types uidValidity as number | bigint; the wire format is 32-bit.
|
|
589
|
+
const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
|
|
535
590
|
let scopeCount = total;
|
|
536
591
|
let uids = [];
|
|
537
592
|
const hasDateFilter = since !== undefined || until !== undefined;
|
|
@@ -557,10 +612,10 @@ export class EmailPool {
|
|
|
557
612
|
uids.reverse();
|
|
558
613
|
const window = uids.slice(offset, offset + limit);
|
|
559
614
|
const messages = await this.fetchListed(client, window, signal);
|
|
560
|
-
return { account: name, count: scopeCount, folder: folderName, messages };
|
|
615
|
+
return { account: name, count: scopeCount, folder: folderName, uidValidity, messages };
|
|
561
616
|
}, true, signal);
|
|
562
617
|
}
|
|
563
|
-
async search(accountName, query, folder, limit, since, until, signal) {
|
|
618
|
+
async search(accountName, query, folder, limit, offset, since, until, signal) {
|
|
564
619
|
const name = this.resolveName(accountName);
|
|
565
620
|
const cfg = this.account(name);
|
|
566
621
|
const folderName = folder || cfg.inboxFolder;
|
|
@@ -586,20 +641,23 @@ export class EmailPool {
|
|
|
586
641
|
signal?.throwIfAborted();
|
|
587
642
|
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => b - a);
|
|
588
643
|
if (uids.length > 0) {
|
|
589
|
-
|
|
644
|
+
// The sample has to cover the requested page (offset + limit) — the same
|
|
645
|
+
// window the fallback scan looks at — so one FETCH serves both the
|
|
646
|
+
// verification and the rows that are handed out.
|
|
647
|
+
const confirmed = await this.searchHits(client, uids, query, offset + limit, signal);
|
|
590
648
|
if (confirmed.length > 0) {
|
|
591
649
|
// The server's list holds up, so its size is reported as the match
|
|
592
650
|
// count; only rows that were confirmed are ever handed out.
|
|
593
|
-
return { account: name, query, count: uids.length, folder: folderName, messages: confirmed.slice(
|
|
651
|
+
return { account: name, query, count: uids.length, folder: folderName, offset, messages: confirmed.slice(offset, offset + limit) };
|
|
594
652
|
}
|
|
595
653
|
}
|
|
596
654
|
// Nothing believable came back (empty answer, or hits that did not
|
|
597
655
|
// survive verification): scan the newest messages locally instead.
|
|
598
656
|
if (this.settings.bodySearchFallback) {
|
|
599
|
-
const messages = await this.searchBodies(client, query, folderName, limit, since, until, signal);
|
|
600
|
-
return { account: name, query, count: messages.length, folder: folderName, messages };
|
|
657
|
+
const messages = await this.searchBodies(client, query, folderName, limit, offset, since, until, signal);
|
|
658
|
+
return { account: name, query, count: messages.length, folder: folderName, offset, messages };
|
|
601
659
|
}
|
|
602
|
-
return { account: name, query, count: 0, folder: folderName, messages: [] };
|
|
660
|
+
return { account: name, query, count: 0, folder: folderName, offset, messages: [] };
|
|
603
661
|
}, true, signal);
|
|
604
662
|
}
|
|
605
663
|
/**
|
|
@@ -609,8 +667,8 @@ export class EmailPool {
|
|
|
609
667
|
* the four fields the server was asked about. No body is downloaded here,
|
|
610
668
|
* and uids the server made up simply return nothing.
|
|
611
669
|
*/
|
|
612
|
-
async searchHits(client, uids, query,
|
|
613
|
-
const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit,
|
|
670
|
+
async searchHits(client, uids, query, need, signal) {
|
|
671
|
+
const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, need)));
|
|
614
672
|
signal?.throwIfAborted();
|
|
615
673
|
const fetched = await client.fetchAll(sample, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
|
|
616
674
|
signal?.throwIfAborted();
|
|
@@ -624,7 +682,7 @@ export class EmailPool {
|
|
|
624
682
|
.sort((a, b) => b.uid - a.uid);
|
|
625
683
|
}
|
|
626
684
|
/** Client-side scan of the tail of the mailbox, newest first. */
|
|
627
|
-
async searchBodies(client, query, folder, limit, since, until, signal) {
|
|
685
|
+
async searchBodies(client, query, folder, limit, offset, since, until, signal) {
|
|
628
686
|
signal?.throwIfAborted();
|
|
629
687
|
const mailbox = client.mailbox;
|
|
630
688
|
const total = mailbox === false ? 0 : mailbox.exists;
|
|
@@ -635,7 +693,7 @@ export class EmailPool {
|
|
|
635
693
|
const out = [];
|
|
636
694
|
for (const message of [...fetched].reverse()) {
|
|
637
695
|
signal?.throwIfAborted();
|
|
638
|
-
if (out.length >= limit)
|
|
696
|
+
if (out.length >= offset + limit)
|
|
639
697
|
break;
|
|
640
698
|
const receivedAt = message.internalDate ?? message.envelope?.date;
|
|
641
699
|
if (since !== undefined && (receivedAt === undefined || receivedAt < since))
|
|
@@ -661,7 +719,7 @@ export class EmailPool {
|
|
|
661
719
|
out.push(listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
|
|
662
720
|
}
|
|
663
721
|
}
|
|
664
|
-
return out;
|
|
722
|
+
return out.slice(offset, offset + limit);
|
|
665
723
|
}
|
|
666
724
|
async fetchListed(client, uids, signal) {
|
|
667
725
|
signal?.throwIfAborted();
|
|
@@ -678,12 +736,13 @@ export class EmailPool {
|
|
|
678
736
|
const cfg = this.account(name);
|
|
679
737
|
const folderName = folder || cfg.inboxFolder;
|
|
680
738
|
return this.withImap(name, folderName, async (client) => {
|
|
681
|
-
const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
|
|
739
|
+
const message = await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
|
|
682
740
|
if (message === false || message.source === undefined) {
|
|
683
741
|
throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
|
|
684
742
|
}
|
|
685
743
|
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
686
744
|
signal?.throwIfAborted();
|
|
745
|
+
this.rememberRead(name, folderName, uid, { attachments: body.attachments, parts: collectAttachmentParts(message.bodyStructure) });
|
|
687
746
|
return { account: name, uid, folder: folderName, ...body };
|
|
688
747
|
}, true, signal);
|
|
689
748
|
}
|
|
@@ -746,17 +805,23 @@ export class EmailPool {
|
|
|
746
805
|
async folders(accountName, subscribedOnly, signal) {
|
|
747
806
|
const name = this.resolveName(accountName);
|
|
748
807
|
return this.withImap(name, null, async (client) => {
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
808
|
+
const cached = this.folderCache.get(name);
|
|
809
|
+
let rows;
|
|
810
|
+
if (cached !== undefined && Date.now() - cached.at < FOLDER_CACHE_TTL_MS) {
|
|
811
|
+
rows = cached.folders;
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
const list = await client.list();
|
|
815
|
+
signal?.throwIfAborted();
|
|
816
|
+
rows = list.map(row => ({
|
|
817
|
+
name: row.name ?? row.path,
|
|
818
|
+
path: row.path,
|
|
819
|
+
specialUse: row.specialUse ?? '',
|
|
820
|
+
subscribed: row.subscribed !== false,
|
|
821
|
+
}));
|
|
822
|
+
this.folderCache.set(name, { at: Date.now(), folders: rows });
|
|
823
|
+
}
|
|
824
|
+
return { account: name, folders: rows.filter(row => !subscribedOnly || row.subscribed !== false) };
|
|
760
825
|
}, true, signal);
|
|
761
826
|
}
|
|
762
827
|
async downloadAttachment(accountName, folder, uid, index, workspaceHint, signal) {
|
|
@@ -764,23 +829,19 @@ export class EmailPool {
|
|
|
764
829
|
const cfg = this.account(name);
|
|
765
830
|
const folderName = folder || cfg.inboxFolder;
|
|
766
831
|
return this.withImap(name, folderName, async (client) => {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
774
|
-
signal?.throwIfAborted();
|
|
775
|
-
const parts = collectAttachmentParts(message.bodyStructure);
|
|
776
|
-
if (body.attachments.length === 0)
|
|
832
|
+
// The mailparser list is authoritative for the index email_read showed and
|
|
833
|
+
// the bodyStructure walk supplies the IMAP part to download. email_read
|
|
834
|
+
// already produced both in the usual read-then-download flow, so reuse that
|
|
835
|
+
// instead of pulling the whole message — attachments included — again.
|
|
836
|
+
const { attachments, parts } = await this.attachmentIndexOf(client, name, folderName, uid, signal);
|
|
837
|
+
if (attachments.length === 0)
|
|
777
838
|
throw new MailError('该邮件没有附件');
|
|
778
|
-
if (
|
|
779
|
-
throw new MailError('附件序号 ' + index + ' 越界:共 ' +
|
|
839
|
+
if (attachments[index] === undefined) {
|
|
840
|
+
throw new MailError('附件序号 ' + index + ' 越界:共 ' + attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
|
|
780
841
|
}
|
|
781
|
-
const att = selectAttachmentPart(
|
|
842
|
+
const att = selectAttachmentPart(attachments, parts, index);
|
|
782
843
|
if (att === undefined) {
|
|
783
|
-
throw new MailError('附件 #' + index + '(' +
|
|
844
|
+
throw new MailError('附件 #' + index + '(' + attachments[index].filename + ')无法在邮件结构中定位(可能是内嵌图片,暂不支持下载)');
|
|
784
845
|
}
|
|
785
846
|
if (att.size > this.settings.maxAttachmentBytes) {
|
|
786
847
|
throw new MailError('附件 "' + att.filename + '" 大小 ' + att.size + ' 字节,超过上限 maxAttachmentBytes=' + this.settings.maxAttachmentBytes);
|
|
@@ -788,7 +849,7 @@ export class EmailPool {
|
|
|
788
849
|
const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
|
|
789
850
|
signal?.throwIfAborted();
|
|
790
851
|
const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes, signal);
|
|
791
|
-
const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ??
|
|
852
|
+
const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? attachments[index].filename);
|
|
792
853
|
// Default the destination to the session workspace so the model can
|
|
793
854
|
// read the file back; an explicit downloadDir always wins.
|
|
794
855
|
const dir = this.settings.downloadDirExplicit
|
|
@@ -809,7 +870,7 @@ export class EmailPool {
|
|
|
809
870
|
const cfg = this.account(name);
|
|
810
871
|
const attachments = await validateAttachmentPaths(attachmentPaths ?? [], this.settings.maxAttachmentBytes, signal);
|
|
811
872
|
const info = await this.sendMail(name, cfg, {
|
|
812
|
-
from: cfg
|
|
873
|
+
from: senderOf(cfg),
|
|
813
874
|
to,
|
|
814
875
|
cc,
|
|
815
876
|
subject,
|
|
@@ -838,10 +899,13 @@ export class EmailPool {
|
|
|
838
899
|
const ids = extractMessageIds(message.source);
|
|
839
900
|
const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
|
|
840
901
|
signal?.throwIfAborted();
|
|
841
|
-
return buildReplyMessage({ from: body.from, to: body.to, cc: body.cc, subject: body.subject, date: body.date, text: body.text, messageId: ids.messageId, references: ids.references }, mode,
|
|
902
|
+
return buildReplyMessage({ from: body.from, to: body.to, cc: body.cc, subject: body.subject, date: body.date, text: body.text, messageId: ids.messageId, references: ids.references }, mode,
|
|
903
|
+
// Both the visible address and the login are "me": a reply-all that
|
|
904
|
+
// keeps either of them would mail the sender his own message.
|
|
905
|
+
cfg.authUser === cfg.user ? cfg.user : [cfg.user, cfg.authUser], text, forwardTo);
|
|
842
906
|
}, true, signal);
|
|
843
907
|
const info = await this.sendMail(name, cfg, {
|
|
844
|
-
from: cfg
|
|
908
|
+
from: senderOf(cfg),
|
|
845
909
|
to: built.to,
|
|
846
910
|
cc,
|
|
847
911
|
subject: built.subject,
|
package/lib/runtime.js
CHANGED
|
@@ -65,19 +65,26 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
|
|
|
65
65
|
const capped = clampInt(limit, 20, 1, 100);
|
|
66
66
|
const result = await getPool().list(account, folder, 100, 0, true, undefined, undefined, signal);
|
|
67
67
|
const key = scope + '\u0000' + result.account + '\u0000' + result.folder;
|
|
68
|
-
const
|
|
69
|
-
const
|
|
68
|
+
const stored = watchCursors.get(key);
|
|
69
|
+
const uidValidity = typeof result.uidValidity === 'number' ? result.uidValidity : 0;
|
|
70
|
+
// A UIDVALIDITY change renumbers every message in the mailbox: keeping the
|
|
71
|
+
// old cursor would either report the whole folder as new or miss everything
|
|
72
|
+
// that renumbered below it. Re-seed the baseline instead and say so.
|
|
73
|
+
const reset = stored !== undefined && stored.uidValidity !== 0 && uidValidity !== 0 && stored.uidValidity !== uidValidity;
|
|
74
|
+
const isFirst = stored === undefined || reset;
|
|
75
|
+
const cursor = stored === undefined || reset ? 0 : stored.uid;
|
|
70
76
|
const fresh = result.messages.filter(message => message.uid > cursor);
|
|
71
77
|
if (result.messages.length > 0) {
|
|
72
|
-
watchCursors.set(key, Math.max(cursor, ...result.messages.map(message => message.uid)));
|
|
78
|
+
watchCursors.set(key, { uid: Math.max(cursor, ...result.messages.map(message => message.uid)), uidValidity });
|
|
73
79
|
}
|
|
74
80
|
else if (isFirst) {
|
|
75
|
-
watchCursors.set(key, 0);
|
|
81
|
+
watchCursors.set(key, { uid: 0, uidValidity });
|
|
76
82
|
}
|
|
77
83
|
return {
|
|
78
84
|
account: result.account,
|
|
79
85
|
folder: result.folder,
|
|
80
|
-
firstRun:
|
|
86
|
+
firstRun: stored === undefined,
|
|
87
|
+
...(reset ? { reset: true } : {}),
|
|
81
88
|
newCount: isFirst ? 0 : fresh.length,
|
|
82
89
|
messages: (isFirst ? [] : fresh).slice(0, capped),
|
|
83
90
|
totalUnread: result.count,
|
package/lib/tool-contract.d.ts
CHANGED
package/lib/tool-contract.js
CHANGED
|
@@ -199,7 +199,11 @@ export function renderSearch(value) {
|
|
|
199
199
|
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,本次没有列出。');
|
|
200
200
|
}
|
|
201
201
|
const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
|
|
202
|
-
|
|
202
|
+
const offset = value.offset ?? 0;
|
|
203
|
+
const window = offset > 0
|
|
204
|
+
? '跳过最新 ' + offset + ' 条后展示 ' + value.messages.length + ' 条'
|
|
205
|
+
: '展示最新 ' + value.messages.length + ' 条';
|
|
206
|
+
return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,' + window + ':\n\n' + lines.join('\n'));
|
|
203
207
|
}
|
|
204
208
|
export function renderSend(value) {
|
|
205
209
|
const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
|
|
@@ -229,6 +233,9 @@ export function renderReply(value) {
|
|
|
229
233
|
return oneText('账号 ' + value.account + ' 已' + REPLY_LABELS[value.mode] + ' uid=' + value.originalUid + ' 的邮件:收件人 ' + value.to.join(', ') + ',主题「' + value.subject + '」,messageId: ' + value.messageId + rejected);
|
|
230
234
|
}
|
|
231
235
|
export function renderWatch(value) {
|
|
236
|
+
if (value.reset === true) {
|
|
237
|
+
return oneText('账号 ' + value.account + ':文件夹 "' + value.folder + '" 的 UIDVALIDITY 已变化(服务器重新编号了邮件),已重新建立基线(当前未读 ' + value.totalUnread + ' 封)。这次不报告新邮件,之后照常。');
|
|
238
|
+
}
|
|
232
239
|
if (value.firstRun) {
|
|
233
240
|
return oneText('账号 ' + value.account + ':已建立新邮件监视基线(当前未读 ' + value.totalUnread + ' 封)。之后调用 email_watch 只会报告新到的邮件。');
|
|
234
241
|
}
|
|
@@ -269,6 +276,7 @@ export const watchSchema = {
|
|
|
269
276
|
account: { type: 'string' },
|
|
270
277
|
folder: { type: 'string' },
|
|
271
278
|
firstRun: { type: 'boolean' },
|
|
279
|
+
reset: { type: 'boolean' },
|
|
272
280
|
newCount: { type: 'integer' },
|
|
273
281
|
totalUnread: { type: 'integer' },
|
|
274
282
|
messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
|
|
@@ -279,7 +287,7 @@ export const descriptions = {
|
|
|
279
287
|
"email_list": 'List recent emails in a mailbox folder (newest first). Returns uid, date, sender, subject and flags without message bodies; use email_read with a uid to fetch the full text. Optional since/until (dates like 2026-08-01) filter by received date.',
|
|
280
288
|
"email_read": 'Read one full email message by its uid (from email_list or email_search). Returns the plain-text body (HTML mail is converted; oversized bodies are truncated) plus attachment metadata; use email_attachment to download one.',
|
|
281
289
|
"email_mark": 'Change an existing message: mark it read/unread, star/unstar it, or move it to another folder. Use after email_list/email_search when the user wants to tidy the mailbox (archive, clear unread, flag important mail). Moving uses the server MOVE/COPY so the uid changes; the new uid is reported when the server provides it.',
|
|
282
|
-
"email_search": 'Search emails by a keyword. The server first searches sender, recipients and subject; those hits are re-verified against the envelopes and, when none of them really carries the keyword (some servers answer every search with the same uids), recent messages are scanned locally including their body while bodySearchFallback is enabled. since/until still constrain both paths. Returns the same compact rows as email_list.',
|
|
290
|
+
"email_search": 'Search emails by a keyword. The server first searches sender, recipients and subject; those hits are re-verified against the envelopes and, when none of them really carries the keyword (some servers answer every search with the same uids), recent messages are scanned locally including their body while bodySearchFallback is enabled. offset skips the newest matches for paging; since/until still constrain both paths. Returns the same compact rows as email_list.',
|
|
283
291
|
"email_send": 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the dsh process cwd). Sending asks the user for approval (recipient, subject and attachment count are shown) unless sendApproval is disabled; in Full Access mode the approval policy never asks, so the send is refused with an explanation instead. Never invent recipients or content without the user\'s instruction.',
|
|
284
292
|
"email_reply": 'Reply to, reply-all to, or forward an existing message (mode: reply | reply-all | forward). Recipients come from the original message (your own address is excluded automatically), the subject gets a single Re:/Fwd: prefix, the original text is quoted underneath, and In-Reply-To/References headers keep mail clients threading correctly. mode=forward needs the to parameter. Like email_send, this asks the user for approval before sending. Never invent recipients or content without the user\'s instruction.',
|
|
285
293
|
"email_folders": 'List the mailbox folders of an account (INBOX, Sent, Trash, custom folders, ...). Use the returned path values as the folder argument of the other email tools.',
|
|
@@ -313,6 +321,7 @@ export const parameters = {
|
|
|
313
321
|
query: { type: 'string', required: true, description: 'Keyword to search for' },
|
|
314
322
|
folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
|
|
315
323
|
limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
|
|
324
|
+
offset: { type: 'integer', description: 'Skip this many newest matches first, default 0' },
|
|
316
325
|
since: { type: 'string', description: 'Only search messages received on or after this date, e.g. 2026-08-01 (optional)' },
|
|
317
326
|
until: { type: 'string', description: 'Only search messages received on or before this date, e.g. 2026-08-26 (optional)' },
|
|
318
327
|
account: { type: 'string', description: ACCOUNT_HINT },
|
package/lib/tools.js
CHANGED
|
@@ -74,9 +74,10 @@ export function buildEmailTools(runtime) {
|
|
|
74
74
|
if (typeof args.query !== 'string' || args.query.trim() === '')
|
|
75
75
|
throw new Error('query 不能为空');
|
|
76
76
|
const limit = clampInt(args.limit, 10, 1, MAX_LIMIT);
|
|
77
|
+
const offset = clampInt(args.offset, 0, 0, 10000);
|
|
77
78
|
const since = args.since?.trim() ? parseEmailDay(args.since, 'since') : undefined;
|
|
78
79
|
const until = args.until?.trim() ? parseEmailDay(args.until, 'until', true) : undefined;
|
|
79
|
-
return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, since, until, executionSignal(exec));
|
|
80
|
+
return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, offset, since, until, executionSignal(exec));
|
|
80
81
|
}
|
|
81
82
|
},
|
|
82
83
|
{
|
package/lib/types.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface EmailListResult {
|
|
|
39
39
|
account: string;
|
|
40
40
|
count: number;
|
|
41
41
|
folder: string;
|
|
42
|
+
/** The mailbox's UIDVALIDITY; 0 when the server did not report one. */
|
|
43
|
+
uidValidity: number;
|
|
42
44
|
messages: ListedMessage[];
|
|
43
45
|
}
|
|
44
46
|
export interface EmailReadResult extends ReadMessageBody {
|
|
@@ -51,6 +53,8 @@ export interface EmailSearchResult {
|
|
|
51
53
|
query: string;
|
|
52
54
|
count: number;
|
|
53
55
|
folder: string;
|
|
56
|
+
/** How many newest matches the caller skipped (0 on the first page). */
|
|
57
|
+
offset: number;
|
|
54
58
|
messages: ListedMessage[];
|
|
55
59
|
}
|
|
56
60
|
export interface EmailSendResult {
|
|
@@ -99,6 +103,7 @@ export interface EmailSearchArgs extends AccountArg {
|
|
|
99
103
|
query: string;
|
|
100
104
|
folder?: string;
|
|
101
105
|
limit?: number;
|
|
106
|
+
offset?: number;
|
|
102
107
|
since?: string;
|
|
103
108
|
until?: string;
|
|
104
109
|
}
|
|
@@ -175,6 +180,8 @@ export interface EmailWatchResult {
|
|
|
175
180
|
/** Unread messages never reported before (empty on firstRun). */
|
|
176
181
|
newCount: number;
|
|
177
182
|
messages: ListedMessage[];
|
|
183
|
+
/** True when a server-side UIDVALIDITY change forced a fresh baseline. */
|
|
184
|
+
reset?: boolean;
|
|
178
185
|
/** Total unread in the folder right now. */
|
|
179
186
|
totalUnread: number;
|
|
180
187
|
}
|
package/lib/web.d.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface AccountCardData {
|
|
|
40
40
|
* exactly the state that has to be fixed before login can start.
|
|
41
41
|
*/
|
|
42
42
|
clientId?: string;
|
|
43
|
+
/** Display name for the From header, when the account sets one. */
|
|
44
|
+
senderName?: string;
|
|
45
|
+
/** Login user when it differs from the visible address (`user`). */
|
|
46
|
+
authUser?: string;
|
|
47
|
+
/** Whether a login password separate from `password` is stored. */
|
|
48
|
+
hasAuthPassword?: boolean;
|
|
43
49
|
/** Login state of an OAuth2 account: none / a device code in flight / logged in. */
|
|
44
50
|
oauthState: OAuth2State;
|
|
45
51
|
/** The mailbox address the stored token belongs to (OAuth2 accounts only). */
|
|
@@ -77,6 +83,23 @@ export interface AccountCardInput {
|
|
|
77
83
|
* 第三方应用注册,所以这是 OAuth2 账号的必填项,而设置面板是用户唯一的常规入口。
|
|
78
84
|
*/
|
|
79
85
|
clientId?: string;
|
|
86
|
+
/**
|
|
87
|
+
* 发件显示名,三态契约同 clientId:undefined = 本卡片没提供(保留已存的
|
|
88
|
+
* senderName 键),'' = 明确清除,非空 = 写入。只改收件人看到的名称,发件地址
|
|
89
|
+
* 始终是 user。
|
|
90
|
+
*/
|
|
91
|
+
senderName?: string;
|
|
92
|
+
/**
|
|
93
|
+
* 登录账号(IMAP/SMTP 认证用),三态契约同上:undefined = 保留,'' = 清除(回到
|
|
94
|
+
* 用 user 登录),非空 = 写入。别名/中继场景下 user 是发件地址,它才是登录名。
|
|
95
|
+
*/
|
|
96
|
+
authUser?: string;
|
|
97
|
+
/**
|
|
98
|
+
* 登录账号自己的密码,三态契约与 password 完全相同(undefined = 保留已存的值,
|
|
99
|
+
* '' = 明确清除,非空 = 写入)。只有 authUser 与 user 不同、且密码也不一样时
|
|
100
|
+
* 才需要。
|
|
101
|
+
*/
|
|
102
|
+
authPassword?: string;
|
|
80
103
|
/**
|
|
81
104
|
* 认证方式覆盖,三态契约同上:undefined = 本卡片没提供(保留已存的 authKind 键),
|
|
82
105
|
* '' = 明确恢复「自动」(删掉该键,回到按 provider/主机派生),非空 = 钉住。
|
package/lib/web.js
CHANGED
|
@@ -87,6 +87,11 @@ function buildAccountCards(raw, defaultAccount, presets, tokens = () => ({ state
|
|
|
87
87
|
// back for the editor to prefill: an OAuth2 account without one cannot
|
|
88
88
|
// start a device-code login, and the card is where that gets fixed.
|
|
89
89
|
const clientId = typeof account.clientId === 'string' ? account.clientId.trim() : '';
|
|
90
|
+
// The display name and the login user are not secrets, so — like clientId —
|
|
91
|
+
// the card hands them back for the editor to prefill. A separate login
|
|
92
|
+
// password is a secret and only ever reported as a boolean.
|
|
93
|
+
const senderName = typeof account.senderName === 'string' ? account.senderName.trim() : '';
|
|
94
|
+
const authUser = typeof account.authUser === 'string' ? account.authUser.trim() : '';
|
|
90
95
|
const oauth = authKind === 'oauth2' ? tokens(name, user) : { state: 'none' };
|
|
91
96
|
list.push({
|
|
92
97
|
name,
|
|
@@ -97,6 +102,9 @@ function buildAccountCards(raw, defaultAccount, presets, tokens = () => ({ state
|
|
|
97
102
|
authKind,
|
|
98
103
|
...(pinned === 'oauth2' || pinned === 'password' ? { authKindDeclared: pinned } : {}),
|
|
99
104
|
...(clientId !== '' ? { clientId } : {}),
|
|
105
|
+
...(senderName !== '' ? { senderName } : {}),
|
|
106
|
+
...(authUser !== '' ? { authUser } : {}),
|
|
107
|
+
...(typeof account.authPassword === 'string' && account.authPassword !== '' ? { hasAuthPassword: true } : {}),
|
|
100
108
|
oauthState: oauth.state,
|
|
101
109
|
...(oauth.user !== undefined ? { oauthUser: oauth.user } : {}),
|
|
102
110
|
imap,
|
|
@@ -318,7 +326,7 @@ function persistedProvider(provider, customNames) {
|
|
|
318
326
|
* contract on AccountCardInput: the card omits the field whenever the editor has
|
|
319
327
|
* nothing to say about it, which must leave the stored secret untouched.
|
|
320
328
|
*/
|
|
321
|
-
function normalizeCardForYaml(card, customNames, inheritedPassword) {
|
|
329
|
+
function normalizeCardForYaml(card, customNames, inheritedPassword, inheritedAuthPassword) {
|
|
322
330
|
const out = {};
|
|
323
331
|
const provider = persistedProvider(card.provider, customNames);
|
|
324
332
|
if (provider !== undefined)
|
|
@@ -355,16 +363,40 @@ function normalizeCardForYaml(card, customNames, inheritedPassword) {
|
|
|
355
363
|
}
|
|
356
364
|
if (card.inboxFolder !== undefined)
|
|
357
365
|
out.inboxFolder = card.inboxFolder;
|
|
366
|
+
if (card.senderName !== undefined) {
|
|
367
|
+
const senderName = String(card.senderName).trim();
|
|
368
|
+
if (senderName !== '')
|
|
369
|
+
out.senderName = senderName;
|
|
370
|
+
}
|
|
371
|
+
if (card.authUser !== undefined) {
|
|
372
|
+
const authUser = String(card.authUser).trim();
|
|
373
|
+
if (authUser !== '')
|
|
374
|
+
out.authUser = authUser;
|
|
375
|
+
}
|
|
376
|
+
if (card.authPassword === undefined) {
|
|
377
|
+
// Same contract as password: a card that says nothing must not delete it.
|
|
378
|
+
if (inheritedAuthPassword !== undefined)
|
|
379
|
+
out.authPassword = inheritedAuthPassword;
|
|
380
|
+
}
|
|
381
|
+
else if (card.authPassword !== '') {
|
|
382
|
+
out.authPassword = String(card.authPassword);
|
|
383
|
+
}
|
|
358
384
|
return out;
|
|
359
385
|
}
|
|
360
|
-
/**
|
|
361
|
-
function
|
|
386
|
+
/** One secret key stored in the source YAML for one account, if it is there. */
|
|
387
|
+
function storedSecretOf(raw, name, key) {
|
|
362
388
|
const account = raw[name];
|
|
363
389
|
if (account === null || typeof account !== 'object' || Array.isArray(account))
|
|
364
390
|
return { present: false, value: undefined };
|
|
365
|
-
if (!Object.prototype.hasOwnProperty.call(account,
|
|
391
|
+
if (!Object.prototype.hasOwnProperty.call(account, key))
|
|
366
392
|
return { present: false, value: undefined };
|
|
367
|
-
return { present: true, value: account
|
|
393
|
+
return { present: true, value: account[key] };
|
|
394
|
+
}
|
|
395
|
+
function storedPasswordOf(raw, name) {
|
|
396
|
+
return storedSecretOf(raw, name, 'password');
|
|
397
|
+
}
|
|
398
|
+
function storedAuthPasswordOf(raw, name) {
|
|
399
|
+
return storedSecretOf(raw, name, 'authPassword');
|
|
368
400
|
}
|
|
369
401
|
/**
|
|
370
402
|
* Fallback writer: loses comments but keeps the semantics the cards describe.
|
|
@@ -390,6 +422,7 @@ function fallbackSerialize(cards, defaultAccount, source, customNames) {
|
|
|
390
422
|
for (const card of cards) {
|
|
391
423
|
const name = card.name;
|
|
392
424
|
let inherited;
|
|
425
|
+
let inheritedAuthPassword;
|
|
393
426
|
if (stored === undefined) {
|
|
394
427
|
// Nothing could be read back: a silent card may be losing a real secret.
|
|
395
428
|
if (card.password === undefined)
|
|
@@ -399,8 +432,11 @@ function fallbackSerialize(cards, defaultAccount, source, customNames) {
|
|
|
399
432
|
const { present, value } = storedPasswordOf(stored, name);
|
|
400
433
|
if (present)
|
|
401
434
|
inherited = value;
|
|
435
|
+
const auth = storedAuthPasswordOf(stored, name);
|
|
436
|
+
if (auth.present)
|
|
437
|
+
inheritedAuthPassword = auth.value;
|
|
402
438
|
}
|
|
403
|
-
raw[name] = normalizeCardForYaml(card, customNames, inherited);
|
|
439
|
+
raw[name] = normalizeCardForYaml(card, customNames, inherited, inheritedAuthPassword);
|
|
404
440
|
}
|
|
405
441
|
return {
|
|
406
442
|
accountsYaml: serializeAccountsYaml(raw, defaultAccount),
|
|
@@ -485,6 +521,26 @@ function serializeAccountsDraft(source, cards, defaultAccount, customNames) {
|
|
|
485
521
|
const nextProvider = persistedProvider(card.provider, customNames);
|
|
486
522
|
writeField(account, 'provider', nextProvider);
|
|
487
523
|
writeField(account, 'user', card.user);
|
|
524
|
+
// senderName / authUser are plain three-state fields (undefined = 保持原样,
|
|
525
|
+
// '' = 清除, 非空 = 写入) — writeField already implements exactly that.
|
|
526
|
+
// An undefined field means "this card says nothing" — writeField would read
|
|
527
|
+
// that as「delete」, so the guard has to live here, not inside it.
|
|
528
|
+
if (card.senderName !== undefined)
|
|
529
|
+
writeField(account, 'senderName', String(card.senderName).trim());
|
|
530
|
+
if (card.authUser !== undefined)
|
|
531
|
+
writeField(account, 'authUser', String(card.authUser).trim());
|
|
532
|
+
// authPassword is a secret and follows the password contract verbatim: the
|
|
533
|
+
// card never carries the plaintext, so an omitted field must leave the
|
|
534
|
+
// stored key — value, position and comment — untouched.
|
|
535
|
+
if (card.authPassword === undefined) {
|
|
536
|
+
// 未提供 = 保持原样:什么都不写。
|
|
537
|
+
}
|
|
538
|
+
else if (card.authPassword === '') {
|
|
539
|
+
account.delete('authPassword');
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
account.set('authPassword', String(card.authPassword));
|
|
543
|
+
}
|
|
488
544
|
// Password is three-state, unlike every other field: the card is never given
|
|
489
545
|
// the plaintext (snapshot exposes hasPassword only), so an omitted password
|
|
490
546
|
// means "the editor has nothing to say" and the stored key must survive
|