dsh-email 0.17.1 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.17.3(2026-10-21)
8
+
9
+ - 修复 #25 的搜索兜底窗口问题:服务端无可信命中或 SEARCH 失败时,先按每批 500 封扫描信封,默认最多 5000 封,不受最近 30 封正文窗口限制;信封命中时不下载正文或附件。
10
+ - 新增 `headerScanLimit`(最多 20000)与 `headerScanOffset`,返回检索来源、扫描数量、覆盖状态及下一窗口,零命中不再被描述为完整邮箱无匹配。
11
+ - 信封与服务端候选均核对接收日期;明确 UID 到达序与发件 Date 头排序不同,正文兜底仍保持原有小窗口。
12
+
13
+ ## 0.17.2(2026-10-20)
14
+
15
+ - 移动邮件前核对 IMAP 的 MOVE / UIDPLUS 能力;两者均不支持时明确拒绝,不再触发可能清除其他已标记删除邮件的全局 EXPUNGE(#23)。
16
+
7
17
  ## 0.17.1(2026-10-18)
8
18
 
9
19
  - 完成 [#19](https://github.com/STARDUSTLC666/dsh-email/issues/19) 的发信策略说明:全局确认开关关闭时,账号页显示中英文影响范围,明确所有账号的模型发信、回复和转发都不再逐次确认;只读设置不能编辑该开关。
package/README.en.md CHANGED
@@ -17,6 +17,7 @@ See the [0.16.3 validation record](docs/validation/0.16.3.md) for DSH `0.2.1-alp
17
17
 
18
18
  - Read, send, reply and forward using IMAP and SMTP.
19
19
  - Manage attachments, search results and mailbox organization.
20
+ - Scan headers in batches when server search is unreliable, report coverage and continue into older mail.
20
21
  - Configure multiple accounts, Outlook OAuth2, encrypted Windows login tokens and send approval.
21
22
  - Turn new-mail popups off in settings; the preference survives restarts.
22
23
  - Prepare and edit local drafts, review To/Cc, content and attachment bytes, then confirm sending.
package/README.md CHANGED
@@ -17,6 +17,7 @@ DSH `0.2.1-alpha.5` 的本轮检查与未验收范围见 [0.16.3 验收记录](d
17
17
 
18
18
  - 通过 IMAP / SMTP 收发、回复和转发邮件。
19
19
  - 管理附件、搜索结果与邮箱整理操作。
20
+ - 搜索无可信服务端结果时,分批检查邮件信封,并说明覆盖范围;可继续查更早邮件。
20
21
  - 多账号设置,支持 Outlook OAuth2、Windows 登录令牌加密和发信审批。
21
22
  - 新邮件弹窗可在设置中关闭,保存后重启仍生效。
22
23
  - 准备和编辑本地草稿,核对发件人、To/Cc、正文与附件预览后再确认发送。
package/docs/USAGE.en.md CHANGED
@@ -16,7 +16,7 @@ See the [draft workbench](DRAFTS.en.md) for editable drafts and per-account trus
16
16
  |---|---|
17
17
  | `email_list` | List the newest mail in a folder (unread filter, pagination, summaries only, no body) |
18
18
  | `email_read` | Read one message's full text by uid (HTML auto-converted to plain text, oversized bodies truncated) |
19
- | `email_search` | Search subject/sender/recipient/CC by keyword (server-side subject/from/to/cc; hits are re-checked against the envelopes, so "match-everything" servers such as QQ are rejected); when nothing believable is returned, it falls back to a body scan of the most recent 30 messages by default (including to/cc) |
19
+ | `email_search` | Search subject/sender/recipient/CC by keyword, verify server candidates and fall back to a batched header scan (default 5000 messages). Returns coverage and an older-window cursor; optional body search remains limited to recent messages. |
20
20
  | `email_send` | Send mail on your behalf (attachments supported). **Prompts for confirmation before sending by default**, showing recipients, subject and attachment count; only sends after you approve |
21
21
  | `email_draft` | Prepare a local draft without sending; edit, save, inspect the preview and manually confirm in Settings → Email → Drafts |
22
22
  | `email_folders` | List the mailbox folders (INBOX/Sent/Junk/custom…); feed the `path` to other tools |
@@ -26,6 +26,16 @@ See the [draft workbench](DRAFTS.en.md) for editable drafts and per-account trus
26
26
  | `email_mark` | Mark messages as read/unread, add/remove stars, or move messages to another folder |
27
27
  | `email_reply` | Reply, reply-all or forward with thread headers and quoted content; uses the same send-approval gate |
28
28
 
29
+ ### Search coverage and older messages
30
+
31
+ When server SEARCH returns no verified matches or fails, `email_search` checks local subject, From, To and Cc headers in batches of 500. The default window is the newest 5000 messages; `headerScanLimit` accepts 1–20000. Header matches do not download message bodies or attachments. This handles empty SEARCH responses without relying on the small body-search window, including mail pushed back by imported messages.
32
+
33
+ Inspect `searchSource`, `serverMatchCount`, `serverSearchFailed`, `headerScan` and `bodyScan`. `headerScan` reports the mailbox size, headers actually scanned, matches in that window and whether the scan covered the folder. Continue with its `nextScanOffset` as `headerScanOffset`, keeping the same query, folder, date filters and window size. `offset` separately pages matching rows within a window; reset it to 0 when moving to another window. These are live mailbox windows, not a frozen snapshot: restart from offset 0 after arrivals or deletions if consistency matters.
34
+
35
+ If headers do not match, the optional body fallback still scans only the recent `bodySearchLimit` window (default 30). Disabling `bodySearchFallback` disables body scans only; header fallback stays available. An explicit older header window skips body fallback. A zero-result page does not establish that the whole mailbox, especially all message bodies, has no match.
36
+
37
+ Results follow descending mailbox UID (arrival order), not the sender's Date header or sorted INTERNALDATE. Imported old messages can appear first. `since`/`until` filter the server's received date (INTERNALDATE), falling back to the envelope Date only if unavailable.
38
+
29
39
  ### New-mail notifications (web UI)
30
40
 
31
41
  Once an account is configured, a "whale-girl courier" popup lives in the bottom-right corner of the main UI: it checks for new mail every 30 seconds and shows a card (sender + subject) when something arrives, auto-dismissing after 12 seconds. The popup shares the cursor logic with `email_watch` but keeps its own counter, so they never consume each other's mail.
package/docs/USAGE.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  ## 本次改进
6
6
 
7
+ `email_search` 先复核服务端候选;QQ 等服务器返回空集、报错或无可信命中时,分批扫描信封(From / To / Cc / Subject),默认最近 5000 封、每批 500 封,不下载正文或附件。可用 `headerScanLimit` 调整到 1–20000;按 `headerScan.nextScanOffset` 设置 `headerScanOffset` 继续查更早的信封。`offset` 仍是当前结果窗口内跳过的匹配数,两种 offset 不同。
8
+
9
+ 返回 `searchSource`、`serverMatchCount`、`serverSearchFailed`、`headerScan`(实际扫描数、文件夹总数、命中数、是否完整、下一窗口)和可选 `bodyScan`。信封无匹配且从最新窗口开始时,才按 `bodySearchFallback` / `bodySearchLimit` 检查最近正文。`bodySearchFallback:false` 只关闭正文扫描;部分扫描或正文窗口零命中不能断言邮箱里不存在目标邮件。
10
+
11
+ 切换到下一信封窗口时保留查询词、文件夹、日期条件与窗口大小,并把匹配 `offset` 归零。窗口对应实时邮箱,不是冻结快照;期间新增或删除邮件后,需要严格一致结果时请从 `headerScanOffset:0` 重新开始。
12
+
13
+ 结果按 UID 到达序倒排,不按发件 Date 头或 INTERNALDATE 排序;批量导入的旧邮件可能排在前面。`since` / `until` 使用接收 INTERNALDATE 过滤,缺失时退到 Date 头。
14
+
15
+ `email_mark(action:"move")` 要求服务器支持 MOVE 或 UIDPLUS;两者均无时会拒绝移动,保持源邮件不变,避免全局 EXPUNGE 误删其他已标记删除的邮件。请改用邮箱原生客户端或升级服务器。
16
+
7
17
  `email_send` 和 `email_draft` 的相对附件路径以当前会话工作区为基准,避免切换工作区后误读 DSH 启动目录的同名文件;绝对路径保持原样,没有会话上下文时仍使用启动目录。
8
18
 
9
19
  在「收件人规则」页按账号设置禁止地址与域名;配置文本的字段为 `denyAddresses` / `denyDomains`。禁止规则覆盖 To、Cc,即使关闭发信确认也生效。域名按完整域名匹配,不自动包含子域名。移除被禁止的收件人并重新预览后才能确认发送;只有全部 To/Cc 匹配且主动开启的可信规则才会跳过 `email_send` 插件确认,宿主权限仍生效。
@@ -16,7 +26,7 @@
16
26
  |---|---|
17
27
  | `email_list` | 列出文件夹里最新的邮件(未读过滤、分页、只看摘要不带正文) |
18
28
  | `email_read` | 按 uid 读取一封邮件的全文(HTML 邮件自动转纯文本,超长截断) |
19
- | `email_search` | 按关键词搜索主题/发件人/收件人/抄送(服务器端 subject/from/to/cc;命中会先用信封复核,QQ 这种"什么都匹配"的响应会被判无效);复核或服务器都没给出可信结果时,默认回退到最近 30 封的正文扫描(含 to/cc) |
29
+ | `email_search` | 按关键词搜索主题/发件人/收件人/抄送,复核服务端候选,无可信结果时分批扫描信封(默认 5000 封);返回覆盖范围和更早窗口游标,可选正文兜底仍限于最近的小窗口 |
20
30
  | `email_send` | 代发邮件(支持带附件)。**默认发信前会弹确认**,显示收件人、主题和附件数,由你批准后才发出 |
21
31
  | `email_draft` | 准备本地草稿,不发送;用户在「设置 → 邮件 → 草稿」编辑、保存、检查预览后手动确认发送 |
22
32
  | `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
@@ -1,6 +1,6 @@
1
1
  import { ImapFlow } from 'imapflow';
2
2
  import type { ResolvedEmailConfig, ResolvedEmailSettings } from './config.js';
3
- import type { AddressEntry, EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailMarkAction, EmailMarkResult, EmailReadResult, EmailReplyMode, EmailReplyResult, EmailSearchResult, EmailSendResult, ListedMessage } from './types.js';
3
+ import type { AddressEntry, EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailMarkAction, EmailMarkResult, EmailReadResult, EmailReplyMode, EmailReplyResult, EmailSearchResult, EmailSearchOptions, EmailSendResult, ListedMessage } from './types.js';
4
4
  export declare class MailError extends Error {
5
5
  constructor(message: string);
6
6
  }
@@ -191,7 +191,8 @@ export declare class EmailPool {
191
191
  }>;
192
192
  /** Fetch the envelopes for one uid batch: the rows email_watch will report. */
193
193
  fetchByUids(accountName: string | undefined, folder: string, uids: number[], signal?: AbortSignal): Promise<ListedMessage[]>;
194
- search(accountName: string | undefined, query: string, folder: string, limit: number, offset: number, since?: Date, until?: Date, signal?: AbortSignal): Promise<EmailSearchResult>;
194
+ search(accountName: string | undefined, query: string, folder: string, limit: number, offset: number, since?: Date, until?: Date, signal?: AbortSignal, options?: EmailSearchOptions): Promise<EmailSearchResult>;
195
+ private searchHeaders;
195
196
  /**
196
197
  * Confirm server-side hits against the mailbox itself: fetch the envelopes
197
198
  * of the newest candidates — the same window the body-scan fallback looks at
@@ -805,7 +805,7 @@ export class EmailPool {
805
805
  const folderName = folder || cfg.inboxFolder;
806
806
  return this.withImap(name, folderName, (client) => this.fetchListed(client, uids, signal), true, signal);
807
807
  }
808
- async search(accountName, query, folder, limit, offset, since, until, signal) {
808
+ async search(accountName, query, folder, limit, offset, since, until, signal, options = {}) {
809
809
  const name = this.resolveName(accountName);
810
810
  const cfg = this.account(name);
811
811
  const folderName = folder || cfg.inboxFolder;
@@ -822,39 +822,80 @@ export class EmailPool {
822
822
  dateRange.since = since;
823
823
  if (until !== undefined)
824
824
  dateRange.before = until;
825
- const found = await Promise.all([
825
+ const found = await Promise.allSettled([
826
826
  client.search({ subject: query, ...dateRange }, { uid: true }),
827
827
  client.search({ from: query, ...dateRange }, { uid: true }),
828
828
  client.search({ to: query, ...dateRange }, { uid: true }),
829
829
  client.search({ cc: query, ...dateRange }, { uid: true }),
830
830
  ]);
831
831
  signal?.throwIfAborted();
832
- const uids = [...new Set(found.flatMap(result => Array.isArray(result) ? result : []))].sort((a, b) => b - a);
833
- if (uids.length > 0) {
832
+ const serverSearchFailed = found.some(result => result.status === 'rejected');
833
+ const uids = [...new Set(found.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : []))].sort((a, b) => b - a);
834
+ if (uids.length > 0 && !serverSearchFailed && !options.headerScanOffset) {
834
835
  // The sample has to cover the requested page (offset + limit) — the same
835
836
  // window the fallback scan looks at — so one FETCH serves both the
836
837
  // verification and the rows that are handed out.
837
- const confirmed = await this.searchHits(client, uids, query, offset + limit, signal);
838
+ const confirmed = await this.searchHits(client, uids, query, offset + limit, signal, since, until);
838
839
  if (confirmed.length > 0) {
839
840
  // The server's list holds up, so its size is reported as the match
840
841
  // count; only rows that were confirmed are ever handed out.
841
- return { account: name, query, count: uids.length, folder: folderName, offset, messages: confirmed.slice(offset, offset + limit) };
842
+ return { account: name, query, count: uids.length, folder: folderName, offset, messages: confirmed.slice(offset, offset + limit), searchSource: 'server', serverMatchCount: uids.length };
842
843
  }
843
844
  }
844
- // Nothing believable came back (empty answer, or hits that did not
845
- // survive verification): scan the newest messages locally instead.
846
- if (this.settings.bodySearchFallback) {
847
- const messages = await this.searchBodies(client, query, folderName, limit, offset, since, until, signal);
845
+ // Header search is cheap and independent of the much smaller body scan.
846
+ // Never turn a silent empty SEARCH into a claim about the whole mailbox.
847
+ const headers = await this.searchHeaders(client, query, options, since, until, signal);
848
+ const coverage = { headerScan: headers.scan, serverMatchCount: uids.length, serverSearchFailed };
849
+ if (headers.uids.length > 0) {
850
+ const messages = await this.fetchListed(client, headers.uids.slice(offset, offset + limit), signal);
851
+ return { account: name, query, count: headers.uids.length, folder: folderName, offset, messages,
852
+ countKind: 'scanned', scannedLimit: headers.scan.scanned, searchSource: 'local-headers', ...coverage };
853
+ }
854
+ if (this.settings.bodySearchFallback && !options.headerScanOffset) {
855
+ const { messages, scanned } = await this.searchBodies(client, query, folderName, limit, offset, since, until, signal);
848
856
  // 本地扫描不知道全文件夹有多少匹配,count 只是本页条数:用 countKind
849
857
  // 让渲染说「本页 N 条(仅扫描最近 bodySearchLimit 封)」,不能说「共 N 条」。
850
858
  return {
851
859
  account: name, query, count: messages.length, folder: folderName, offset, messages,
852
860
  countKind: 'scanned', scannedLimit: this.settings.bodySearchLimit,
861
+ searchSource: 'local-body', bodyScan: { scanned, scanLimit: this.settings.bodySearchLimit }, ...coverage,
853
862
  };
854
863
  }
855
- return { account: name, query, count: 0, folder: folderName, offset, messages: [] };
864
+ return { account: name, query, count: 0, folder: folderName, offset, messages: [], countKind: 'scanned', scannedLimit: headers.scan.scanned, searchSource: 'local-headers', ...coverage };
856
865
  }, true, signal);
857
866
  }
867
+ async searchHeaders(client, query, options, since, until, signal) {
868
+ const integer = (value, fallback, max) => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(max, Math.trunc(value))) : fallback;
869
+ const scanLimit = Math.max(1, integer(options.headerScanLimit, 5000, 20000));
870
+ const scanOffset = integer(options.headerScanOffset, 0, 1_000_000_000);
871
+ const mailboxTotal = client.mailbox === false ? 0 : client.mailbox.exists;
872
+ const end = Math.max(0, mailboxTotal - scanOffset), start = Math.max(1, end - scanLimit + 1);
873
+ const matches = new Set();
874
+ let scanned = 0;
875
+ for (let last = end; last >= start; last -= 500) {
876
+ signal?.throwIfAborted();
877
+ const first = Math.max(start, last - 499);
878
+ const rows = await client.fetchAll(first + ':' + last, { uid: true, envelope: true, internalDate: true });
879
+ signal?.throwIfAborted();
880
+ scanned += rows.length;
881
+ for (const message of rows) {
882
+ const date = message.internalDate ?? message.envelope?.date;
883
+ if (since !== undefined && (date === undefined || date < since))
884
+ continue;
885
+ if (until !== undefined && (date === undefined || date >= until))
886
+ continue;
887
+ const addresses = [message.envelope?.from, message.envelope?.to, message.envelope?.cc].map(flattenAddressText).join(' ');
888
+ if (messageMatchesQuery(message.envelope?.subject ?? '', addresses, '', query))
889
+ matches.add(message.uid);
890
+ }
891
+ }
892
+ const currentTotal = client.mailbox === false ? 0 : client.mailbox.exists;
893
+ return { uids: [...matches].sort((a, b) => b - a), scan: {
894
+ mailboxTotal, scanned, matched: matches.size, scanOffset, scanLimit,
895
+ complete: scanOffset === 0 && start === 1 && scanned === mailboxTotal && currentTotal === mailboxTotal,
896
+ ...(start > 1 && end > 0 ? { nextScanOffset: scanOffset + (end - start + 1) } : {}),
897
+ } };
898
+ }
858
899
  /**
859
900
  * Confirm server-side hits against the mailbox itself: fetch the envelopes
860
901
  * of the newest candidates — the same window the body-scan fallback looks at
@@ -862,13 +903,18 @@ export class EmailPool {
862
903
  * the four fields the server was asked about. No body is downloaded here,
863
904
  * and uids the server made up simply return nothing.
864
905
  */
865
- async searchHits(client, uids, query, need, signal) {
906
+ async searchHits(client, uids, query, need, signal, since, until) {
866
907
  const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, need)));
867
908
  signal?.throwIfAborted();
868
- const fetched = await client.fetchAll(sample, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
909
+ const fetched = await client.fetchAll(sample, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, internalDate: true }, { uid: true });
869
910
  signal?.throwIfAborted();
870
911
  return fetched
871
912
  .filter(message => {
913
+ const date = message.internalDate ?? message.envelope?.date;
914
+ if (since !== undefined && (date === undefined || date < since))
915
+ return false;
916
+ if (until !== undefined && (date === undefined || date >= until))
917
+ return false;
872
918
  const envelope = message.envelope;
873
919
  const addressText = [envelope?.from, envelope?.to, envelope?.cc].map(flattenAddressText).join(' ');
874
920
  return messageMatchesQuery(envelope?.subject ?? '', addressText, '', query);
@@ -882,15 +928,17 @@ export class EmailPool {
882
928
  const mailbox = client.mailbox;
883
929
  const total = mailbox === false ? 0 : mailbox.exists;
884
930
  if (total === 0)
885
- return [];
931
+ return { messages: [], scanned: 0 };
886
932
  const start = Math.max(1, total - this.settings.bodySearchLimit + 1);
887
933
  // 只取信封与 MIME 结构;正文在下面按 text/* 分段下载,附件不进正文匹配。
888
934
  const fetched = await client.fetchAll(start + ':*', { uid: true, envelope: true, flags: true, size: true, bodyStructure: true, internalDate: true });
889
935
  const out = [];
936
+ let scanned = 0;
890
937
  for (const message of [...fetched].reverse()) {
891
938
  signal?.throwIfAborted();
892
939
  if (out.length >= offset + limit)
893
940
  break;
941
+ scanned++;
894
942
  const receivedAt = message.internalDate ?? message.envelope?.date;
895
943
  if (since !== undefined && (receivedAt === undefined || receivedAt < since))
896
944
  continue;
@@ -925,7 +973,7 @@ export class EmailPool {
925
973
  out.push(listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
926
974
  }
927
975
  }
928
- return out.slice(offset, offset + limit);
976
+ return { messages: out.slice(offset, offset + limit), scanned };
929
977
  }
930
978
  async fetchListed(client, uids, signal) {
931
979
  signal?.throwIfAborted();
@@ -1033,6 +1081,12 @@ export class EmailPool {
1033
1081
  throw new MailError('move 操作需要 toFolder 参数(用 email_folders 查看可用文件夹)');
1034
1082
  if (target === folderName)
1035
1083
  throw new MailError('邮件已在文件夹 "' + folderName + '" 中,无需移动');
1084
+ // ImapFlow falls back to COPY + STORE + EXPUNGE without MOVE. Only
1085
+ // UIDPLUS can scope that expunge to this UID; plain EXPUNGE also deletes
1086
+ // unrelated mail that another client marked \\Deleted.
1087
+ if (!client.capabilities?.has('MOVE') && !client.capabilities?.has('UIDPLUS')) {
1088
+ throw new MailError('服务器同时缺少 MOVE 和 UIDPLUS,无法安全移动邮件;操作已取消,源邮件未改动。请用邮箱原生客户端处理或升级服务器。');
1089
+ }
1036
1090
  const folders = await client.list();
1037
1091
  signal?.throwIfAborted();
1038
1092
  if (!folders.some(row => row.path === target)) {
@@ -195,6 +195,22 @@ export function renderRead(value) {
195
195
  return oneText('账号 ' + value.account + ',主题:' + (value.subject || '(无主题)') + '\n来自:' + from + '\n时间:' + (value.date || '(未知)') + attach + '\n\n' + value.text);
196
196
  }
197
197
  export function renderSearch(value) {
198
+ if (value.headerScan) {
199
+ const scan = value.headerScan;
200
+ const window = scan.scanOffset === 0 ? '最近 ' + scan.scanned : '跳过最新 ' + scan.scanOffset + ' 封后的 ' + scan.scanned;
201
+ const completeness = scan.complete ? '完整信封扫描' : '未覆盖全文件夹';
202
+ const continuation = scan.nextScanOffset === undefined ? '' : ';继续更早信封可用 headerScanOffset=' + scan.nextScanOffset + '、headerScanLimit=' + scan.scanLimit;
203
+ const body = value.bodyScan ? ';正文只检查 ' + value.bodyScan.scanned + ' 封(上限 ' + value.bodyScan.scanLimit + '),正文未覆盖全文件夹' : ';未扫描正文';
204
+ const server = value.serverSearchFailed ? '服务端部分搜索失败' : '服务端候选 ' + (value.serverMatchCount ?? 0) + ' 条';
205
+ const note = server + ';已扫描' + window + ' 封的信封(文件夹共 ' + scan.mailboxTotal + ' 封,' + completeness + ',窗口内信封命中 ' + scan.matched + ' 条)' + continuation + body;
206
+ const summary = (value.messages.length ? '本页 ' + value.messages.length + ' 条' : '本页未命中,不能据此断言整个邮箱没有匹配') + (value.offset > 0 ? ',已跳过最新 ' + value.offset + ' 条匹配' : '');
207
+ const rows = value.messages.map((message, i) => '#' + (i + 1) + ' ' + describeMessage(message));
208
+ return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":' + summary + '。' + note + (rows.length ? '\n\n' + rows.join('\n') : ''));
209
+ }
210
+ if (value.searchSource === 'server') {
211
+ const rows = value.messages.map((message, i) => '#' + (i + 1) + ' ' + describeMessage(message));
212
+ return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":服务端报告 ' + value.count + ' 个候选;已核实本页 ' + value.messages.length + ' 条(跳过最新 ' + value.offset + ' 条)。' + (rows.length ? '\n\n' + rows.join('\n') : ''));
213
+ }
198
214
  const scanned = value.countKind === 'scanned';
199
215
  // 回退扫描只看了最近 scannedLimit 封,不知道全文件夹匹配数:不能把本页条数说成「共 N 条」。
200
216
  const scannedNote = '仅扫描最近 ' + (value.scannedLimit ?? 0) + ' 封,未统计全文件夹匹配数';
@@ -296,7 +312,7 @@ export const descriptions = {
296
312
  "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.',
297
313
  "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.',
298
314
  "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.',
299
- "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.',
315
+ "email_search": 'Search sender, recipients and subject; verify server candidates against envelopes and arrival dates. If server SEARCH is empty, fails, or has no confirmed matches, scan local headers in batches without downloading bodies/attachments (default newest 5000, max 20000). headerScanOffset/Limit select an older header window; use headerScan.nextScanOffset to continue. offset pages MATCHES within that window. If no headers match and headerScanOffset=0, optional body fallback scans only bodySearchLimit recent arrivals. Inspect searchSource/headerScan/bodyScan: partial or zero results never prove the whole mailbox has no match. Results use descending mailbox UID arrival order, not Date-header or INTERNALDATE sorting. since/until filter INTERNALDATE (Date header only if unavailable).',
300
316
  "email_send": 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the current session workspace; process cwd is the fallback without a session). Saved blocked-address/domain rules reject To/Cc recipients even when sendApproval is off. Plugin confirmation is required by default, unless sendApproval is disabled or every To/Cc recipient matches saved trust rules with skipApproval enabled. Host permissions still apply. Full Access refuses a send that still requires plugin confirmation; use email_draft for human review and explicit sending. Never invent recipients or content without the user\'s instruction.',
301
317
  "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. Plugin confirmation is required unless sendApproval is disabled; trusted-recipient skipping only applies to email_send. Blocked recipients still stop delivery before SMTP and host permissions remain in effect. Never invent recipients or content without the user\'s instruction.',
302
318
  "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.',
@@ -331,6 +347,8 @@ export const parameters = {
331
347
  folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
332
348
  limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
333
349
  offset: { type: 'integer', description: 'Skip this many newest matches first, default 0' },
350
+ headerScanLimit: { type: 'integer', description: 'Local header fallback window size, 1-20000; default 5000. Headers only, no bodies or attachments.' },
351
+ headerScanOffset: { type: 'integer', description: 'Skip this many newest arrivals for the local header window; default 0. Continue with headerScan.nextScanOffset. Independent of match offset; a nonzero value skips body fallback.' },
334
352
  since: { type: 'string', description: 'Only search messages received on or after this date, e.g. 2026-08-01 (optional)' },
335
353
  until: { type: 'string', description: 'Only search messages received on or before this date, e.g. 2026-08-26 (optional)' },
336
354
  account: { type: 'string', description: ACCOUNT_HINT },
package/lib/tools.js CHANGED
@@ -99,7 +99,10 @@ export function buildEmailTools(runtime, drafts) {
99
99
  const offset = clampInt(args.offset, 0, 0, 10000);
100
100
  const since = args.since?.trim() ? parseEmailDay(args.since, 'since') : undefined;
101
101
  const until = args.until?.trim() ? parseEmailDay(args.until, 'until', true) : undefined;
102
- return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, offset, since, until, executionSignal(exec));
102
+ return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit, offset, since, until, executionSignal(exec), {
103
+ headerScanLimit: clampInt(args.headerScanLimit, 5000, 1, 20000),
104
+ headerScanOffset: clampInt(args.headerScanOffset, 0, 0, 1_000_000_000),
105
+ });
103
106
  }
104
107
  },
105
108
  {
package/lib/types.d.ts CHANGED
@@ -48,19 +48,41 @@ export interface EmailReadResult extends ReadMessageBody {
48
48
  uid: number;
49
49
  folder: string;
50
50
  }
51
+ export interface EmailSearchOptions {
52
+ /** Local header window; independent of result offset and bodySearchLimit. */
53
+ headerScanLimit?: number;
54
+ headerScanOffset?: number;
55
+ }
56
+ export interface EmailHeaderScan {
57
+ mailboxTotal: number;
58
+ scanned: number;
59
+ matched: number;
60
+ scanOffset: number;
61
+ scanLimit: number;
62
+ complete: boolean;
63
+ nextScanOffset?: number;
64
+ }
51
65
  export interface EmailSearchResult {
52
66
  account: string;
53
67
  query: string;
54
- /** Server path: the server's match count. Fallback scan: this page's row count (see countKind). */
68
+ /** Server candidates, local header-window matches, or body-fallback page rows; inspect searchSource and coverage. */
55
69
  count: number;
56
70
  folder: string;
57
71
  /** How many newest matches the caller skipped (0 on the first page). */
58
72
  offset: number;
59
73
  messages: ListedMessage[];
60
- /** Set to 'scanned' when the local body scan produced this page, so count is not a total. */
74
+ /** Local scans cannot claim a full mailbox/body match total. */
61
75
  countKind?: 'scanned';
62
- /** How many newest messages the fallback scan looked at (set with countKind='scanned'). */
76
+ /** Header rows actually scanned, or the configured body window for legacy renderers. */
63
77
  scannedLimit?: number;
78
+ searchSource?: 'server' | 'local-headers' | 'local-body';
79
+ serverMatchCount?: number;
80
+ serverSearchFailed?: boolean;
81
+ headerScan?: EmailHeaderScan;
82
+ bodyScan?: {
83
+ scanned: number;
84
+ scanLimit: number;
85
+ };
64
86
  }
65
87
  export interface EmailSendResult {
66
88
  account: string;
@@ -111,6 +133,8 @@ export interface EmailSearchArgs extends AccountArg {
111
133
  offset?: number;
112
134
  since?: string;
113
135
  until?: string;
136
+ headerScanLimit?: number;
137
+ headerScanOffset?: number;
114
138
  }
115
139
  export interface EmailSendArgs extends AccountArg {
116
140
  to: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-email",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "在 DSH 中收发、搜索和整理邮件,支持多个邮箱账号。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",