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.
- package/CHANGELOG.md +13 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/docs/AGENTMAIL.en.md +37 -0
- package/docs/AGENTMAIL.md +41 -0
- package/docs/USAGE.en.md +13 -1
- package/docs/USAGE.md +11 -1
- package/docs/VALIDATION.md +2 -0
- package/docs/validation/0.18.0.md +23 -0
- package/lib/agentmail.d.ts +33 -0
- package/lib/agentmail.js +407 -0
- package/lib/client.js +129 -31
- package/lib/config.d.ts +4 -0
- package/lib/config.js +12 -2
- package/lib/draft-store.d.ts +5 -0
- package/lib/draft-store.js +4 -0
- package/lib/draft-web.js +15 -6
- package/lib/host-config.js +1 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +12 -2
- package/lib/mail-client.d.ts +8 -3
- package/lib/mail-client.js +83 -20
- package/lib/mail-service.d.ts +50 -0
- package/lib/mail-service.js +128 -0
- package/lib/mailbox-web.d.ts +11 -0
- package/lib/mailbox-web.js +219 -0
- package/lib/public-download.d.ts +3 -0
- package/lib/public-download.js +77 -0
- package/lib/runtime.d.ts +2 -2
- package/lib/runtime.js +7 -3
- package/lib/settings.js +1 -1
- package/lib/tool-contract.d.ts +37 -8
- package/lib/tool-contract.js +48 -18
- package/lib/tools.js +14 -10
- package/lib/types.d.ts +44 -12
- package/lib/web.d.ts +9 -1
- package/lib/web.js +20 -2
- package/package.json +4 -3
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
|
|
@@ -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
|
|
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. */
|
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,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
|
|
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>;
|