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 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("div", { className: "dshe-field" }, [
1742
- h("label", null, t("card.passwordLabel")),
1743
- h("input", {
1744
- type: "password",
1745
- value: draft.password,
1746
- disabled: busy !== "",
1747
- placeholder: t("card.passwordPlaceholder"),
1748
- onChange: (e) => patchDraft(name, { password: e.target.value }),
1749
- }),
1750
- h("div", { className: "dshe-hint" },
1751
- view.hasPassword
1752
- ? t("card.passwordSaved")
1753
- : t("card.passwordHint")),
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
- const snap = await api();
2434
- if (snap && snap.whale) {
2435
- whaleUrl = snap.whale.url || "";
2436
- whaleCredit = snap.whale.credit || "";
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 (!snap || !snap.accounts || snap.accounts.length === 0) return;
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, retry next tick.
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
- tick();
2447
- pollTimer = setInterval(tick, 30000);
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
- clearInterval(pollTimer);
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 && password === '')
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
- // An OAuth2 account never carries a password: a stale one left in the YAML
363
- // from before the provider changed must not travel into the pool.
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 } : {}),
@@ -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, 'user' | 'password' | 'authKind'>, accessToken?: string): ImapAuth;
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, 'user' | 'password' | 'authKind'>, accessToken?: string): SmtpAuth;
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
@@ -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.user, accessToken: accessToken ?? '' }
45
- : { user: cfg.user, pass: cfg.password };
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.user, accessToken: accessToken ?? '' }
54
- : { user: cfg.user, pass: cfg.password };
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 === exclude || seen.has(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.toLowerCase();
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
- const confirmed = await this.searchHits(client, uids, query, limit, signal);
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(0, limit) };
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, limit, signal) {
613
- const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, limit)));
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 list = await client.list();
750
- signal?.throwIfAborted();
751
- const folders = list
752
- .filter(row => !subscribedOnly || row.subscribed !== false)
753
- .map(row => ({
754
- name: row.name ?? row.path,
755
- path: row.path,
756
- specialUse: row.specialUse ?? '',
757
- subscribed: row.subscribed !== false,
758
- }));
759
- return { account: name, folders };
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
- const message = await client.fetchOne(uid, { uid: true, bodyStructure: true, source: true }, { uid: true });
768
- if (message === false || message.source === undefined) {
769
- throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '")');
770
- }
771
- // The mailparser list is authoritative for the index email_read showed;
772
- // the bodyStructure walk supplies the IMAP part to download.
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 (body.attachments[index] === undefined) {
779
- throw new MailError('附件序号 ' + index + ' 越界:共 ' + body.attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
839
+ if (attachments[index] === undefined) {
840
+ throw new MailError('附件序号 ' + index + ' 越界:共 ' + attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
780
841
  }
781
- const att = selectAttachmentPart(body.attachments, parts, index);
842
+ const att = selectAttachmentPart(attachments, parts, index);
782
843
  if (att === undefined) {
783
- throw new MailError('附件 #' + index + '(' + body.attachments[index].filename + ')无法在邮件结构中定位(可能是内嵌图片,暂不支持下载)');
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 ?? body.attachments[index].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.user,
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, cfg.user, text, forwardTo);
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.user,
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 isFirst = !watchCursors.has(key);
69
- const cursor = watchCursors.get(key) ?? 0;
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: isFirst,
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,
@@ -350,6 +350,9 @@ export declare const watchSchema: {
350
350
  firstRun: {
351
351
  type: string;
352
352
  };
353
+ reset: {
354
+ type: string;
355
+ };
353
356
  newCount: {
354
357
  type: string;
355
358
  };
@@ -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
- return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,展示最新 ' + value.messages.length + ' 条:\n\n' + lines.join('\n'));
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
- /** The `password` stored in the source YAML for one account, if the key is there. */
361
- function storedPasswordOf(raw, name) {
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, 'password'))
391
+ if (!Object.prototype.hasOwnProperty.call(account, key))
366
392
  return { present: false, value: undefined };
367
- return { present: true, value: account.password };
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "DeepSeek Harness 邮件插件:IMAP/SMTP 收发、搜索、回复转发、邮件整理与增量收件,支持多邮箱预设、发信审批及 Web 设置与新邮件通知。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",