dsh-email 0.17.2 → 0.18.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.
@@ -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
@@ -212,7 +213,11 @@ export declare class EmailPool {
212
213
  sendPrepared(accountName: string, to: string, subject: string, text: string, cc: string, attachments: Array<{
213
214
  filename: string;
214
215
  content: Buffer;
215
- }>, signal?: AbortSignal): Promise<EmailSendResult>;
216
+ }>, signal?: AbortSignal, reply?: {
217
+ uid: number;
218
+ folder: string;
219
+ uidValidity?: number;
220
+ }): Promise<EmailSendResult>;
216
221
  reply(accountName: string | undefined, folder: string, uid: number, mode: EmailReplyMode, text: string, forwardTo: string, cc: string | undefined, signal?: AbortSignal): Promise<EmailReplyResult>;
217
222
  }
218
223
  /** Stat every attachment path up front; total size must stay under the cap. */
@@ -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,19 +903,24 @@ 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);
875
921
  })
876
922
  .map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)))
877
- .sort((a, b) => b.uid - a.uid);
923
+ .sort((a, b) => Number(b.uid) - Number(a.uid));
878
924
  }
879
925
  /** Client-side scan of the tail of the mailbox, newest first. */
880
926
  async searchBodies(client, query, folder, limit, offset, since, until, signal) {
@@ -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();
@@ -935,7 +983,7 @@ export class EmailPool {
935
983
  signal?.throwIfAborted();
936
984
  return fetched
937
985
  .map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)))
938
- .sort((a, b) => b.uid - a.uid);
986
+ .sort((a, b) => Number(b.uid) - Number(a.uid));
939
987
  }
940
988
  async read(accountName, uid, folder, signal) {
941
989
  const name = this.resolveName(accountName);
@@ -967,6 +1015,7 @@ export class EmailPool {
967
1015
  account: name,
968
1016
  uid,
969
1017
  folder: folderName,
1018
+ uidValidity: this.uidValidityOf(client),
970
1019
  date: envelopeDate instanceof Date ? envelopeDate.toISOString() : '',
971
1020
  from: flattenAddresses(message.envelope.from),
972
1021
  to: flattenAddresses(message.envelope.to),
@@ -992,7 +1041,7 @@ export class EmailPool {
992
1041
  const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
993
1042
  signal?.throwIfAborted();
994
1043
  this.rememberRead(name, folderName, this.uidValidityOf(client), uid, { attachments: body.attachments, parts: collectAttachmentParts(full.bodyStructure) });
995
- return { account: name, uid, folder: folderName, ...body };
1044
+ return { account: name, uid, folder: folderName, uidValidity: this.uidValidityOf(client), ...body };
996
1045
  }, true, signal);
997
1046
  }
998
1047
  async mark(accountName, folder, uid, action, toFolder, signal) {
@@ -1143,11 +1192,25 @@ export class EmailPool {
1143
1192
  };
1144
1193
  }
1145
1194
  /** Send the attachment bytes the human reviewed, without re-reading mutable paths. */
1146
- async sendPrepared(accountName, to, subject, text, cc, attachments, signal) {
1195
+ async sendPrepared(accountName, to, subject, text, cc, attachments, signal, reply) {
1147
1196
  const name = this.resolveName(accountName), cfg = this.account(name);
1148
1197
  if (attachments.length > 10 || attachments.reduce((sum, file) => sum + file.content.length, 0) > Math.min(this.settings.maxAttachmentBytes, 20 * 1024 * 1024))
1149
1198
  throw new MailError('草稿附件超过大小或数量上限');
1150
- const info = await this.sendMail(name, cfg, { from: senderOf(cfg), to, cc, subject, text, attachments, disableFileAccess: true, disableUrlAccess: true }, signal);
1199
+ const thread = {};
1200
+ if (reply)
1201
+ await this.withImap(name, reply.folder || cfg.inboxFolder, async (client) => {
1202
+ if (reply.uidValidity !== undefined && reply.uidValidity !== this.uidValidityOf(client))
1203
+ throw new MailError('原文件夹 UIDVALIDITY 已变化,无法关联旧回复草稿;请重新打开邮件准备回复');
1204
+ const original = await client.fetchOne(reply.uid, { uid: true, headers: ['message-id', 'references'] }, { uid: true });
1205
+ if (!original || !original.headers)
1206
+ throw new MailError('原邮件不存在或缺少线程头,请重新打开邮件');
1207
+ const ids = extractMessageIds(original.headers);
1208
+ if (!ids.messageId)
1209
+ throw new MailError('原邮件缺少 Message-ID,无法安全关联回复线程');
1210
+ thread.inReplyTo = '<' + ids.messageId + '>';
1211
+ thread.references = [...(ids.references ?? '').split(' ').filter(Boolean), ids.messageId].map(id => '<' + id + '>');
1212
+ }, true, signal);
1213
+ const info = await this.sendMail(name, cfg, { from: senderOf(cfg), to, cc, subject, text, attachments, ...thread, disableFileAccess: true, disableUrlAccess: true }, signal);
1151
1214
  return { account: name, messageId: String(info.messageId ?? ''), accepted: Array.isArray(info.accepted) ? info.accepted.map(String) : [], rejected: Array.isArray(info.rejected) ? info.rejected.map(String) : [], response: String(info.response ?? '') };
1152
1215
  }
1153
1216
  async reply(accountName, folder, uid, mode, text, forwardTo, cc, signal) {
@@ -0,0 +1,50 @@
1
+ import { EmailPool } from './mail-client.js';
2
+ import { type MessageRef } from './agentmail.js';
3
+ import type { ResolvedEmailSettings } from './config.js';
4
+ import type { EmailFoldersResult, EmailReplyMode, EmailReplyResult, EmailMarkAction, EmailSearchOptions } from './types.js';
5
+ import type { downloadPublicBytes } from './public-download.js';
6
+ export interface ReplyReference {
7
+ uid: MessageRef;
8
+ folder: string;
9
+ uidValidity?: number;
10
+ }
11
+ export interface MailDependencies {
12
+ fetch?: typeof fetch;
13
+ download?: typeof downloadPublicBytes;
14
+ }
15
+ /** Route accounts independently. IMAP's numeric UIDs retain their original meaning. */
16
+ export declare class EmailService {
17
+ private settings;
18
+ private imap;
19
+ private api;
20
+ constructor(settings: ResolvedEmailSettings, dependencies?: MailDependencies);
21
+ get withImap(): EmailPool['withImap'];
22
+ set withImap(run: EmailPool['withImap']);
23
+ private name;
24
+ private usesApi;
25
+ private number;
26
+ private folder;
27
+ list(account: string | undefined, folder: string, limit: number, offset: number, unread: boolean, since?: Date, until?: Date, signal?: AbortSignal): Promise<import("./types.js").EmailListResult>;
28
+ read(account: string | undefined, uid: MessageRef, folder: string, signal?: AbortSignal): Promise<import("./types.js").EmailReadResult>;
29
+ search(account: string | undefined, query: string, folder: string, limit: number, offset: number, since?: Date, until?: Date, signal?: AbortSignal, options?: EmailSearchOptions): Promise<import("./types.js").EmailSearchResult>;
30
+ mark(account: string | undefined, folder: string, uid: MessageRef, action: EmailMarkAction, target?: string, signal?: AbortSignal): Promise<import("./types.js").EmailMarkResult>;
31
+ folders(account: string | undefined, subscribed: boolean, signal?: AbortSignal): Promise<EmailFoldersResult>;
32
+ downloadAttachment(account: string | undefined, folder: string, uid: MessageRef, index: number, workspace?: string, signal?: AbortSignal): Promise<import("./types.js").EmailAttachmentResult>;
33
+ send(account: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, paths: string[] | undefined, signal?: AbortSignal): Promise<import("./types.js").EmailSendResult>;
34
+ sendPrepared(account: string, to: string, subject: string, text: string, cc: string, attachments: Array<{
35
+ filename: string;
36
+ content: Buffer;
37
+ }>, signal?: AbortSignal, reply?: ReplyReference): Promise<import("./types.js").EmailSendResult>;
38
+ reply(account: string | undefined, folder: string, uid: MessageRef, mode: EmailReplyMode, text: string, to: string, cc: string | undefined, signal?: AbortSignal): Promise<EmailReplyResult>;
39
+ unseenUids(...args: Parameters<EmailPool['unseenUids']>): Promise<{
40
+ account: string;
41
+ folder: string;
42
+ uidValidity: number;
43
+ count: number;
44
+ uids: number[];
45
+ }>;
46
+ fetchByUids(...args: Parameters<EmailPool['fetchByUids']>): Promise<import("./types.js").ListedMessage[]>;
47
+ watchApi(account: string, folder: string, limit: number, scope: string, signal?: AbortSignal): Promise<import("./types.js").EmailWatchResult | undefined>;
48
+ startIdleSweep(): void;
49
+ dispose(): void;
50
+ }
@@ -0,0 +1,128 @@
1
+ import { EmailPool, buildReplyMessage, validateAttachmentPaths, } from './mail-client.js';
2
+ import { AgentMailClient } from './agentmail.js';
3
+ /** Route accounts independently. IMAP's numeric UIDs retain their original meaning. */
4
+ export class EmailService {
5
+ settings;
6
+ imap;
7
+ api;
8
+ constructor(settings, dependencies = {}) {
9
+ this.settings = settings;
10
+ this.imap = new EmailPool(settings);
11
+ this.api = new AgentMailClient(settings, dependencies.fetch, dependencies.download);
12
+ }
13
+ get withImap() {
14
+ return this.imap.withImap.bind(this.imap);
15
+ }
16
+ set withImap(run) {
17
+ this.imap.withImap = run;
18
+ }
19
+ name(account) {
20
+ const name = account || this.settings.defaultAccount;
21
+ if (!this.settings.accounts.has(name))
22
+ throw new Error('未知邮件账号');
23
+ return name;
24
+ }
25
+ usesApi(account) {
26
+ return (this.settings.accounts.get(this.name(account))?.transport === 'agentmail');
27
+ }
28
+ number(uid) {
29
+ if (typeof uid !== 'number' || !Number.isSafeInteger(uid) || uid <= 0)
30
+ throw new Error('IMAP uid 必须是正整数');
31
+ return uid;
32
+ }
33
+ folder(account, folder) {
34
+ return folder || this.settings.accounts.get(this.name(account)).inboxFolder;
35
+ }
36
+ list(account, folder, limit, offset, unread, since, until, signal) {
37
+ return this.usesApi(account)
38
+ ? this.api.list(this.name(account), this.folder(account, folder), limit, offset, unread, since, until, signal)
39
+ : this.imap.list(account, folder, limit, offset, unread, since, until, signal);
40
+ }
41
+ read(account, uid, folder, signal) {
42
+ return this.usesApi(account)
43
+ ? this.api.read(this.name(account), uid, this.folder(account, folder), signal)
44
+ : this.imap.read(account, this.number(uid), folder, signal);
45
+ }
46
+ search(account, query, folder, limit, offset, since, until, signal, options = {}) {
47
+ if (this.usesApi(account) && options.headerScanOffset)
48
+ throw new Error('AgentMail 搜索使用 offset 分页,不使用 IMAP headerScanOffset');
49
+ return this.usesApi(account)
50
+ ? this.api.search(this.name(account), query, this.folder(account, folder), limit, offset, since, until, signal)
51
+ : this.imap.search(account, query, folder, limit, offset, since, until, signal, options);
52
+ }
53
+ mark(account, folder, uid, action, target, signal) {
54
+ return this.usesApi(account)
55
+ ? this.api.mark(this.name(account), this.folder(account, folder), uid, action, target, signal)
56
+ : this.imap.mark(account, folder, this.number(uid), action, target, signal);
57
+ }
58
+ async folders(account, subscribed, signal) {
59
+ if (!this.usesApi(account))
60
+ return this.imap.folders(account, subscribed, signal);
61
+ signal?.throwIfAborted();
62
+ return {
63
+ account: this.name(account),
64
+ folders: [
65
+ ['INBOX', '\\Inbox'],
66
+ ['Sent', '\\Sent'],
67
+ ['Starred', '\\Flagged'],
68
+ ['Archive', '\\Archive'],
69
+ ['All', '\\All'],
70
+ ].map(([path, specialUse]) => ({
71
+ name: path,
72
+ path,
73
+ specialUse,
74
+ subscribed: true,
75
+ })),
76
+ };
77
+ }
78
+ downloadAttachment(account, folder, uid, index, workspace, signal) {
79
+ return this.usesApi(account)
80
+ ? this.api.downloadAttachment(this.name(account), folder, uid, index, workspace, signal)
81
+ : this.imap.downloadAttachment(account, folder, this.number(uid), index, workspace, signal);
82
+ }
83
+ async send(account, to, subject, text, cc, paths, signal) {
84
+ if (!this.usesApi(account))
85
+ return this.imap.send(account, to, subject, text, cc, paths, signal);
86
+ await validateAttachmentPaths(paths ?? [], Math.min(this.settings.maxAttachmentBytes, 4 * 1024 * 1024), signal);
87
+ return this.api.sendPaths(this.name(account), to, subject, text ?? '', cc ?? '', paths ?? [], signal);
88
+ }
89
+ sendPrepared(account, to, subject, text, cc, attachments, signal, reply) {
90
+ return this.usesApi(account)
91
+ ? this.api.send(this.name(account), to, subject, text, cc, attachments, signal, reply)
92
+ : this.imap.sendPrepared(account, to, subject, text, cc, attachments, signal, reply ? { ...reply, uid: this.number(reply.uid) } : undefined);
93
+ }
94
+ async reply(account, folder, uid, mode, text, to, cc, signal) {
95
+ if (!this.usesApi(account))
96
+ return this.imap.reply(account, folder, this.number(uid), mode, text, to, cc, signal);
97
+ const name = this.name(account), cfg = this.settings.accounts.get(name), original = await this.api.read(name, uid, this.folder(account, folder), signal), built = buildReplyMessage({ ...original, messageId: String(uid), references: '' }, mode, cfg.user, text, to), sent = await this.api.send(name, built.to, built.subject, built.text, cc ?? '', [], signal, mode === 'forward' ? undefined : { uid, folder });
98
+ return {
99
+ ...sent,
100
+ mode,
101
+ originalUid: uid,
102
+ to: built.to.split(','),
103
+ subject: built.subject,
104
+ };
105
+ }
106
+ unseenUids(...args) {
107
+ if (this.usesApi(args[0]))
108
+ throw new Error('AgentMail 使用独立的新邮件游标');
109
+ return this.imap.unseenUids(...args);
110
+ }
111
+ fetchByUids(...args) {
112
+ if (this.usesApi(args[0]))
113
+ throw new Error('AgentMail 不支持 IMAP 数字 UID 批量读取,请使用列表中的 message_id');
114
+ return this.imap.fetchByUids(...args);
115
+ }
116
+ async watchApi(account, folder, limit, scope, signal) {
117
+ return this.usesApi(account)
118
+ ? this.api.watch(this.name(account), this.folder(account, folder), limit, scope, signal)
119
+ : undefined;
120
+ }
121
+ startIdleSweep() {
122
+ this.imap.startIdleSweep();
123
+ }
124
+ dispose() {
125
+ this.imap.dispose();
126
+ this.api.clear();
127
+ }
128
+ }
@@ -0,0 +1,11 @@
1
+ import type { EmailRuntime } from './runtime.js';
2
+ import type { EmailDraftBackend } from './draft-web.js';
3
+ export declare const MAILBOX_ROUTE = "/api/dsh-email/mailbox";
4
+ export declare class EmailMailboxBackend {
5
+ private runtime;
6
+ private drafts;
7
+ constructor(runtime: Pick<EmailRuntime, 'getEffectiveSettings' | 'getPool'>, drafts: EmailDraftBackend);
8
+ action(body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
9
+ fetch(request: Request): Promise<Response>;
10
+ }
11
+ export declare function installEmailMailbox(ctx: any, backend: EmailMailboxBackend): void;
@@ -0,0 +1,219 @@
1
+ import { readFile, unlink } from 'node:fs/promises';
2
+ import { buildReplyMessage } from './mail-client.js';
3
+ import { draftFields } from './draft-store.js';
4
+ import { parseEmailDay } from './tool-contract.js';
5
+ import { clampInt } from './config.js';
6
+ export const MAILBOX_ROUTE = '/api/dsh-email/mailbox';
7
+ const json = (status, value) => new Response(JSON.stringify(value), {
8
+ status,
9
+ headers: {
10
+ 'content-type': 'application/json',
11
+ 'cache-control': 'no-store',
12
+ },
13
+ });
14
+ export class EmailMailboxBackend {
15
+ runtime;
16
+ drafts;
17
+ constructor(runtime, drafts) {
18
+ this.runtime = runtime;
19
+ this.drafts = drafts;
20
+ }
21
+ async action(body, signal) {
22
+ const settings = this.runtime.getEffectiveSettings(), account = typeof body.account === 'string' && body.account
23
+ ? body.account
24
+ : settings.defaultAccount, cfg = settings.accounts.get(account);
25
+ if (!cfg)
26
+ throw new Error('账号不存在,请重新选择');
27
+ if (body.action === 'accounts')
28
+ return {
29
+ accounts: [...settings.accounts].map(([name, c]) => ({
30
+ name,
31
+ user: c.user,
32
+ api: c.transport === 'agentmail',
33
+ })),
34
+ defaultAccount: settings.defaultAccount,
35
+ };
36
+ const pool = this.runtime.getPool(), folder = typeof body.folder === 'string' && body.folder
37
+ ? body.folder
38
+ : cfg.inboxFolder;
39
+ if (folder.length > 1024 || /[\r\n\0]/.test(folder))
40
+ throw new Error('无效文件夹');
41
+ if (body.action === 'folders')
42
+ return pool.folders(account, false, signal);
43
+ if (body.action === 'list') {
44
+ const limit = clampInt(body.limit, 20, 1, 100), offset = clampInt(body.offset, 0, 0, 10000);
45
+ const since = typeof body.since === 'string' && body.since
46
+ ? parseEmailDay(body.since, 'since')
47
+ : undefined, until = typeof body.until === 'string' && body.until
48
+ ? parseEmailDay(body.until, 'until', true)
49
+ : undefined;
50
+ const query = typeof body.query === 'string' ? body.query.trim() : '';
51
+ if (query.length > 500)
52
+ throw new Error('搜索词过长');
53
+ return query
54
+ ? pool.search(account, query, folder, limit, offset, since, until, signal, {
55
+ headerScanOffset: clampInt(body.headerScanOffset, 0, 0, 1_000_000_000),
56
+ })
57
+ : pool.list(account, folder, limit, offset, body.unreadOnly === true, since, until, signal);
58
+ }
59
+ const uid = body.uid;
60
+ if (!((typeof uid === 'number' && Number.isSafeInteger(uid) && uid > 0) ||
61
+ (typeof uid === 'string' &&
62
+ uid.length > 0 &&
63
+ uid.length <= 998 &&
64
+ !/[\r\n\0]/.test(uid))))
65
+ throw new Error('请从邮件列表重新选择邮件');
66
+ if (body.action === 'read')
67
+ return pool.read(account, uid, folder, signal);
68
+ if (body.action === 'mark') {
69
+ if (!['read', 'unread', 'star', 'unstar'].includes(String(body.mark)))
70
+ throw new Error('不支持的邮件状态操作');
71
+ return pool.mark(account, folder, uid, body.mark, undefined, signal);
72
+ }
73
+ if (body.action === 'replyDraft') {
74
+ const original = await pool.read(account, uid, folder, signal), mode = body.replyAll === true ? 'reply-all' : 'reply';
75
+ const built = buildReplyMessage({ ...original, messageId: '', references: '' }, mode, [cfg.user, cfg.authUser], '');
76
+ const created = await this.drafts.store.create(draftFields({
77
+ account,
78
+ to: built.to,
79
+ cc: '',
80
+ subject: built.subject,
81
+ text: built.text,
82
+ }), [], signal);
83
+ return this.drafts.store.change(created.id, created.revision, (draft) => {
84
+ draft.reply = {
85
+ uid,
86
+ folder,
87
+ ...(original.uidValidity
88
+ ? { uidValidity: original.uidValidity }
89
+ : {}),
90
+ };
91
+ }, signal);
92
+ }
93
+ throw new Error('不支持的邮箱操作');
94
+ }
95
+ async fetch(request) {
96
+ const fail = (status) => json(status, {
97
+ ok: false,
98
+ error: {
99
+ message: '邮件操作失败,请检查账号、连接或稍后重试 / Email action failed; check the account and connection',
100
+ },
101
+ });
102
+ const origin = request.headers.get('origin'), site = request.headers.get('sec-fetch-site');
103
+ if (request.method !== 'POST' ||
104
+ request.headers.get('x-dsh-email-mailbox') !== '1' ||
105
+ request.headers.get('content-type')?.split(';')[0] !==
106
+ 'application/json' ||
107
+ (site && !['same-origin', 'none'].includes(site)))
108
+ return fail(403);
109
+ // DSH's authenticated connection carrier reconstructs the URL internally;
110
+ // the original Host header identifies the browser-facing origin.
111
+ if (origin) {
112
+ try {
113
+ const source = new URL(origin);
114
+ if (!['http:', 'https:'].includes(source.protocol) ||
115
+ source.host !==
116
+ (request.headers.get('host') || new URL(request.url).host))
117
+ return fail(403);
118
+ }
119
+ catch {
120
+ return fail(403);
121
+ }
122
+ }
123
+ const reader = request.body?.getReader();
124
+ if (!reader)
125
+ return fail(400);
126
+ try {
127
+ const chunks = [];
128
+ let size = 0;
129
+ for (;;) {
130
+ const { value, done } = await reader.read();
131
+ if (done)
132
+ break;
133
+ size += value.length;
134
+ if (size > 16384) {
135
+ await reader.cancel();
136
+ return fail(413);
137
+ }
138
+ chunks.push(value);
139
+ }
140
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
141
+ if (!body || typeof body !== 'object' || Array.isArray(body))
142
+ return fail(400);
143
+ const signal = AbortSignal.any([
144
+ request.signal,
145
+ AbortSignal.timeout(120000),
146
+ ]);
147
+ if (body.action === 'download') {
148
+ // The authenticated file action owns only the copy created by this call.
149
+ const settings = this.runtime.getEffectiveSettings(), account = typeof body.account === 'string' && body.account
150
+ ? body.account
151
+ : settings.defaultAccount, cfg = settings.accounts.get(account), folder = typeof body.folder === 'string' && body.folder
152
+ ? body.folder
153
+ : (cfg?.inboxFolder ?? '');
154
+ if (!cfg ||
155
+ folder.length > 1024 ||
156
+ /[\r\n\0]/.test(folder) ||
157
+ !Number.isSafeInteger(body.index) ||
158
+ body.index < 0 ||
159
+ body.index > 999)
160
+ return fail(400);
161
+ if (!((typeof body.uid === 'number' &&
162
+ Number.isSafeInteger(body.uid) &&
163
+ body.uid > 0) ||
164
+ (typeof body.uid === 'string' &&
165
+ body.uid.length > 0 &&
166
+ body.uid.length <= 998 &&
167
+ !/[\r\n\0]/.test(body.uid))))
168
+ return fail(400);
169
+ const file = await this.runtime
170
+ .getPool()
171
+ .downloadAttachment(account, folder, body.uid, clampInt(body.index, 0, 0, 999), undefined, signal);
172
+ try {
173
+ const bytes = await readFile(file.path);
174
+ if (bytes.length >
175
+ this.runtime.getEffectiveSettings().maxAttachmentBytes)
176
+ return fail(413);
177
+ return new Response(new Uint8Array(bytes), {
178
+ headers: {
179
+ 'content-type': 'application/octet-stream',
180
+ 'content-disposition': "attachment; filename*=UTF-8''" +
181
+ encodeURIComponent(file.filename),
182
+ 'x-content-type-options': 'nosniff',
183
+ 'cache-control': 'no-store',
184
+ },
185
+ });
186
+ }
187
+ finally {
188
+ await unlink(file.path).catch(() => { });
189
+ }
190
+ }
191
+ return json(200, { ok: true, value: await this.action(body, signal) });
192
+ }
193
+ catch (error) {
194
+ let message = error instanceof Error ? error.message : '邮件操作失败';
195
+ try {
196
+ for (const cfg of this.runtime.getEffectiveSettings().accounts.values())
197
+ for (const key of [cfg.password, cfg.authPassword])
198
+ if (key)
199
+ message = message.split(key).join('[已隐藏]');
200
+ }
201
+ catch { }
202
+ return json(400, {
203
+ ok: false,
204
+ error: { message: message.slice(0, 1000) },
205
+ });
206
+ }
207
+ finally {
208
+ reader.releaseLock();
209
+ }
210
+ }
211
+ }
212
+ export function installEmailMailbox(ctx, backend) {
213
+ ctx.inject?.(['connection'], (host) => host.connection?.fetch?.register?.({
214
+ path: MAILBOX_ROUTE,
215
+ methods: ['POST'],
216
+ requestBody: 'buffered',
217
+ fetch: (request) => backend.fetch(request),
218
+ }));
219
+ }
@@ -0,0 +1,3 @@
1
+ export declare function publicAddress(address: string): boolean;
2
+ /** Download a signed public HTTPS URL without API credentials; pin the validated DNS answers. */
3
+ export declare function downloadPublicBytes(input: string, cap: number, signal?: AbortSignal, redirects?: number): Promise<Buffer>;