dsh-email 0.17.3 → 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.
@@ -920,7 +920,7 @@ export class EmailPool {
920
920
  return messageMatchesQuery(envelope?.subject ?? '', addressText, '', query);
921
921
  })
922
922
  .map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)))
923
- .sort((a, b) => b.uid - a.uid);
923
+ .sort((a, b) => Number(b.uid) - Number(a.uid));
924
924
  }
925
925
  /** Client-side scan of the tail of the mailbox, newest first. */
926
926
  async searchBodies(client, query, folder, limit, offset, since, until, signal) {
@@ -983,7 +983,7 @@ export class EmailPool {
983
983
  signal?.throwIfAborted();
984
984
  return fetched
985
985
  .map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)))
986
- .sort((a, b) => b.uid - a.uid);
986
+ .sort((a, b) => Number(b.uid) - Number(a.uid));
987
987
  }
988
988
  async read(accountName, uid, folder, signal) {
989
989
  const name = this.resolveName(accountName);
@@ -1015,6 +1015,7 @@ export class EmailPool {
1015
1015
  account: name,
1016
1016
  uid,
1017
1017
  folder: folderName,
1018
+ uidValidity: this.uidValidityOf(client),
1018
1019
  date: envelopeDate instanceof Date ? envelopeDate.toISOString() : '',
1019
1020
  from: flattenAddresses(message.envelope.from),
1020
1021
  to: flattenAddresses(message.envelope.to),
@@ -1040,7 +1041,7 @@ export class EmailPool {
1040
1041
  const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
1041
1042
  signal?.throwIfAborted();
1042
1043
  this.rememberRead(name, folderName, this.uidValidityOf(client), uid, { attachments: body.attachments, parts: collectAttachmentParts(full.bodyStructure) });
1043
- return { account: name, uid, folder: folderName, ...body };
1044
+ return { account: name, uid, folder: folderName, uidValidity: this.uidValidityOf(client), ...body };
1044
1045
  }, true, signal);
1045
1046
  }
1046
1047
  async mark(accountName, folder, uid, action, toFolder, signal) {
@@ -1191,11 +1192,25 @@ export class EmailPool {
1191
1192
  };
1192
1193
  }
1193
1194
  /** Send the attachment bytes the human reviewed, without re-reading mutable paths. */
1194
- async sendPrepared(accountName, to, subject, text, cc, attachments, signal) {
1195
+ async sendPrepared(accountName, to, subject, text, cc, attachments, signal, reply) {
1195
1196
  const name = this.resolveName(accountName), cfg = this.account(name);
1196
1197
  if (attachments.length > 10 || attachments.reduce((sum, file) => sum + file.content.length, 0) > Math.min(this.settings.maxAttachmentBytes, 20 * 1024 * 1024))
1197
1198
  throw new MailError('草稿附件超过大小或数量上限');
1198
- 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);
1199
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 ?? '') };
1200
1215
  }
1201
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>;
@@ -0,0 +1,77 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { get } from 'node:https';
3
+ import { isIP } from 'node:net';
4
+ export function publicAddress(address) {
5
+ const lower = address.toLowerCase();
6
+ if (isIP(lower) === 4) {
7
+ const [a, b] = lower.split('.').map(Number);
8
+ return !(a === 0 ||
9
+ a === 10 ||
10
+ a === 127 ||
11
+ a >= 224 ||
12
+ (a === 169 && b === 254) ||
13
+ (a === 172 && b >= 16 && b <= 31) ||
14
+ (a === 192 && (b === 168 || b === 0)) ||
15
+ (a === 100 && b >= 64 && b <= 127) ||
16
+ (a === 198 && (b === 18 || b === 19)));
17
+ }
18
+ // Global IPv6 unicast only. Mapped/compatible IPv4, loopback, local and multicast are rejected.
19
+ return (isIP(lower) === 6 && /^[23]/.test(lower) && !lower.startsWith('2001:db8:'));
20
+ }
21
+ /** Download a signed public HTTPS URL without API credentials; pin the validated DNS answers. */
22
+ export async function downloadPublicBytes(input, cap, signal, redirects = 0) {
23
+ const url = new URL(input);
24
+ if (url.protocol !== 'https:' ||
25
+ url.username ||
26
+ url.password ||
27
+ (url.port && url.port !== '443') ||
28
+ redirects > 2)
29
+ throw new Error('附件下载地址不受支持');
30
+ const host = url.hostname.replace(/^\[|\]$/g, ''), records = isIP(host)
31
+ ? [{ address: host, family: isIP(host) }]
32
+ : await lookup(host, { all: true });
33
+ if (!records.length ||
34
+ records.some((record) => !publicAddress(record.address)))
35
+ throw new Error('附件下载地址必须指向公网 HTTPS 服务');
36
+ const active = AbortSignal.any([
37
+ ...(signal ? [signal] : []),
38
+ AbortSignal.timeout(30000),
39
+ ]);
40
+ const res = await new Promise((resolve, reject) => {
41
+ const req = get(url, {
42
+ signal: active,
43
+ lookup: ((_host, options, cb) => {
44
+ if (options.all)
45
+ cb(null, records);
46
+ else
47
+ cb(null, records[0].address, records[0].family);
48
+ }),
49
+ }, resolve);
50
+ req.once('error', reject);
51
+ });
52
+ if ([301, 302, 303, 307, 308].includes(res.statusCode ?? 0) &&
53
+ res.headers.location) {
54
+ res.destroy();
55
+ return downloadPublicBytes(new URL(res.headers.location, url).href, cap, active, redirects + 1);
56
+ }
57
+ if (res.statusCode !== 200) {
58
+ res.destroy();
59
+ throw new Error('附件下载失败,请稍后重试');
60
+ }
61
+ const chunks = [];
62
+ let size = 0;
63
+ try {
64
+ for await (const chunk of res) {
65
+ active.throwIfAborted();
66
+ const bytes = Buffer.from(chunk);
67
+ size += bytes.length;
68
+ if (size > cap)
69
+ throw new Error('附件超过下载大小上限');
70
+ chunks.push(bytes);
71
+ }
72
+ }
73
+ finally {
74
+ res.destroy();
75
+ }
76
+ return Buffer.concat(chunks);
77
+ }
package/lib/runtime.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /** Live settings, account-pool ownership, and independent tool/web watch cursors. */
2
2
  import { type EmailConfig, type ResolvedEmailSettings } from './config.js';
3
- import { EmailPool } from './mail-client.js';
3
+ import { EmailService } from './mail-service.js';
4
4
  import { type EmailSettingsValue } from './settings.js';
5
5
  import type { EmailWatchResult } from './types.js';
6
- export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'sendPrepared' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose'>;
6
+ export type EmailClient = Pick<EmailService, 'list' | 'read' | 'mark' | 'search' | 'send' | 'sendPrepared' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose' | 'watchApi'>;
7
7
  export interface EmailSettingsScope {
8
8
  get(): unknown;
9
9
  namespace?: string;
package/lib/runtime.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Live settings, account-pool ownership, and independent tool/web watch cursors. */
2
2
  import { clampInt, mergeAccountDefaults, presetNamesIn, resolveEmailSettings, serializeAccountsYaml } from './config.js';
3
- import { EmailPool, messageOf } from './mail-client.js';
3
+ import { messageOf } from './mail-client.js';
4
+ import { EmailService } from './mail-service.js';
4
5
  import { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
5
6
  import { liveConfig } from './host-config.js';
6
7
  function fingerprintSettings(settings) {
@@ -18,7 +19,7 @@ function fingerprintSettings(settings) {
18
19
  });
19
20
  }
20
21
  /** Register live settings and own exactly one pool for their effective value. */
21
- export function createEmailRuntime(ctx, config, createPool = settings => new EmailPool(settings)) {
22
+ export function createEmailRuntime(ctx, config, createPool = settings => new EmailService(settings)) {
22
23
  config = liveConfig(config);
23
24
  const legacy = typeof ctx.settings.register === 'function';
24
25
  const namespace = legacy ? SETTINGS_NAMESPACE : ctx.fiber?.entry?.options.id ?? 'tool-email';
@@ -40,7 +41,7 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
40
41
  endpointDefaults = { imap: account.imap, smtp: account.smtp };
41
42
  }
42
43
  catch { /* A fresh installation remains editable before credentials exist. */ }
43
- const shared = Object.fromEntries(['provider', 'user', 'password', 'senderName', 'authUser', 'authPassword', 'clientId', 'authKind', 'imap', 'smtp', 'inboxFolder']
44
+ const shared = Object.fromEntries(['provider', 'user', 'password', 'apiKey', 'senderName', 'authUser', 'authPassword', 'clientId', 'authKind', 'imap', 'smtp', 'inboxFolder']
44
45
  .filter(key => config[key] !== undefined).map(key => [key, config[key]]));
45
46
  const cards = config.accounts && Object.keys(config.accounts).length > 0
46
47
  ? Object.fromEntries(Object.entries(config.accounts).map(([key, account]) => [key, mergeAccountDefaults(shared, account)]))
@@ -100,6 +101,9 @@ export function createEmailRuntime(ctx, config, createPool = settings => new Ema
100
101
  const pool = getPool();
101
102
  // 先只 SEARCH UNSEEN 拿 uid 列表与 totalUnread,再只为本轮要报告的最旧
102
103
  // limit 封取信封:网页弹窗每 30 秒轮询一次,不能因为未读多就整批 FETCH。
104
+ const apiWatch = await pool.watchApi?.(account, folder, capped, scope, signal);
105
+ if (apiWatch)
106
+ return apiWatch;
103
107
  const index = await pool.unseenUids(account, folder, signal);
104
108
  const key = scope + '\u0000' + index.account + '\u0000' + index.folder;
105
109
  const stored = watchCursors.get(key);
package/lib/settings.js CHANGED
@@ -214,7 +214,7 @@ export function validateSettingsValue(value, extraProviders = []) {
214
214
  parseRecipientPolicies(draft.trustedRecipientsYaml);
215
215
  }
216
216
  const provider = draft.provider;
217
- if (isSet(provider) && provider !== '' && !PROVIDER_NAMES.includes(provider) && !extraProviders.includes(provider)) {
217
+ if (isSet(provider) && provider !== '' && !['agentmail', 'agentmail-eu', ...PROVIDER_NAMES].includes(provider) && !extraProviders.includes(provider)) {
218
218
  const names = [...PROVIDER_NAMES, ...extraProviders];
219
219
  throw new Error('未知的邮箱服务商 ' + describeValue(provider) + ',可选:' + names.join('/') + '(或留空手填 IMAP/SMTP 主机)');
220
220
  }