dsh-email 0.17.2 → 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 +6 -0
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/docs/USAGE.en.md +11 -1
- package/docs/USAGE.md +9 -1
- package/lib/mail-client.d.ts +3 -2
- package/lib/mail-client.js +63 -15
- package/lib/tool-contract.js +19 -1
- package/lib/tools.js +4 -1
- package/lib/types.d.ts +27 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
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
|
+
|
|
7
13
|
## 0.17.2(2026-10-20)
|
|
8
14
|
|
|
9
15
|
- 移动邮件前核对 IMAP 的 MOVE / UIDPLUS 能力;两者均不支持时明确拒绝,不再触发可能清除其他已标记删除邮件的全局 EXPUNGE(#23)。
|
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
|
|
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,14 @@
|
|
|
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
|
+
|
|
7
15
|
`email_mark(action:"move")` 要求服务器支持 MOVE 或 UIDPLUS;两者均无时会拒绝移动,保持源邮件不变,避免全局 EXPUNGE 误删其他已标记删除的邮件。请改用邮箱原生客户端或升级服务器。
|
|
8
16
|
|
|
9
17
|
`email_send` 和 `email_draft` 的相对附件路径以当前会话工作区为基准,避免切换工作区后误读 DSH 启动目录的同名文件;绝对路径保持原样,没有会话上下文时仍使用启动目录。
|
|
@@ -18,7 +26,7 @@
|
|
|
18
26
|
|---|---|
|
|
19
27
|
| `email_list` | 列出文件夹里最新的邮件(未读过滤、分页、只看摘要不带正文) |
|
|
20
28
|
| `email_read` | 按 uid 读取一封邮件的全文(HTML 邮件自动转纯文本,超长截断) |
|
|
21
|
-
| `email_search` |
|
|
29
|
+
| `email_search` | 按关键词搜索主题/发件人/收件人/抄送,复核服务端候选,无可信结果时分批扫描信封(默认 5000 封);返回覆盖范围和更早窗口游标,可选正文兜底仍限于最近的小窗口 |
|
|
22
30
|
| `email_send` | 代发邮件(支持带附件)。**默认发信前会弹确认**,显示收件人、主题和附件数,由你批准后才发出 |
|
|
23
31
|
| `email_draft` | 准备本地草稿,不发送;用户在「设置 → 邮件 → 草稿」编辑、保存、检查预览后手动确认发送 |
|
|
24
32
|
| `email_folders` | 列出邮箱的文件夹(INBOX/已发送/垃圾邮件/自定义…),拿 path 喂给其他工具 |
|
package/lib/mail-client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ImapFlow } from 'imapflow';
|
|
2
2
|
import type { ResolvedEmailConfig, ResolvedEmailSettings } from './config.js';
|
|
3
|
-
import type { AddressEntry, EmailAttachmentMeta, EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailMarkAction, EmailMarkResult, EmailReadResult, EmailReplyMode, EmailReplyResult, EmailSearchResult, EmailSendResult, 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
|
package/lib/mail-client.js
CHANGED
|
@@ -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.
|
|
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
|
|
833
|
-
|
|
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
|
-
//
|
|
845
|
-
//
|
|
846
|
-
|
|
847
|
-
|
|
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();
|
package/lib/tool-contract.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
74
|
+
/** Local scans cannot claim a full mailbox/body match total. */
|
|
61
75
|
countKind?: 'scanned';
|
|
62
|
-
/**
|
|
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;
|