dsh-email 0.14.7 → 0.15.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.
@@ -0,0 +1,386 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, open, stat } from 'node:fs/promises';
4
+ import { basename, dirname, resolve } from 'node:path';
5
+ import { DraftError, DraftStore, draftFields } from './draft-store.js';
6
+ import { matchRecipients, parseRecipientPolicies, serializeRecipientPolicies } from './recipient-policy.js';
7
+ import { messageOf, redactCredentials } from './mail-client.js';
8
+ export const EMAIL_DRAFT_ROUTE = '/api/dsh-email/drafts';
9
+ const MAX_BYTES = 20 * 1024 * 1024;
10
+ const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
11
+ const json = (status, value) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
12
+ const editable = (draft) => { if (draft.state !== 'draft')
13
+ throw new DraftError('已提交或结果未确定的邮件不能再次发送;请先去邮箱核对,必要时复制为新草稿', 'draft-submitted'); };
14
+ const view = (draft) => ({ ...draft, attachments: draft.attachments.map(({ id, filename }) => ({ id, filename })) });
15
+ /** All mutations use the host's authenticated fetch carrier and an explicit UI action. */
16
+ export class EmailDraftBackend {
17
+ runtime;
18
+ settings;
19
+ options;
20
+ store;
21
+ plans = new Map();
22
+ constructor(runtime, settings, options = {}) {
23
+ this.runtime = runtime;
24
+ this.settings = settings;
25
+ this.options = options;
26
+ this.store = options.store ?? new DraftStore();
27
+ }
28
+ now() { return this.options.now?.() ?? Date.now(); }
29
+ prune() { for (const [id, plan] of this.plans)
30
+ if (this.now() - plan.created > 10 * 60 * 1000)
31
+ this.plans.delete(id); }
32
+ invalidate(id) { for (const [key, plan] of this.plans)
33
+ if (plan.draft.id === id)
34
+ this.plans.delete(key); }
35
+ connection(account) {
36
+ const settings = this.runtime.getEffectiveSettings(), name = account || settings.defaultAccount, config = settings.accounts.get(name);
37
+ if (!config)
38
+ throw new DraftError('发件账号不存在,请重新选择账号');
39
+ const value = this.runtime.getSettingsValue(), policies = parseRecipientPolicies(value.trustedRecipientsYaml);
40
+ return { name, config, cap: Math.min(settings.maxAttachmentBytes, MAX_BYTES), policy: policies.get(name), key: hash({ name, config, cap: settings.maxAttachmentBytes, policies: value.trustedRecipientsYaml ?? '', approval: value.sendApproval }) };
41
+ }
42
+ safe(error) {
43
+ let message = redactCredentials(messageOf(error, '操作失败,请稍后重试'));
44
+ try {
45
+ for (const account of this.runtime.getEffectiveSettings().accounts.values())
46
+ for (const secret of [account.password, account.authPassword])
47
+ if (secret && secret.length >= 4)
48
+ message = message.split(secret).join('[已隐藏]');
49
+ }
50
+ catch { /* Incomplete account errors are actionable. */ }
51
+ return message.slice(0, 1200);
52
+ }
53
+ async bytes(file, cap, signal) {
54
+ signal?.throwIfAborted();
55
+ let stream;
56
+ try {
57
+ const info = await stat(file.path);
58
+ if (!info.isFile() || info.size > cap)
59
+ throw new Error('size');
60
+ stream = createReadStream(file.path, { signal });
61
+ const parts = [];
62
+ let total = 0;
63
+ for await (const part of stream) {
64
+ signal?.throwIfAborted();
65
+ const chunk = Buffer.from(part);
66
+ total += chunk.length;
67
+ if (total > cap)
68
+ throw new Error('size');
69
+ parts.push(chunk);
70
+ }
71
+ return Buffer.concat(parts);
72
+ }
73
+ catch {
74
+ signal?.throwIfAborted();
75
+ throw new DraftError('附件「' + file.filename + '」不可读或超过草稿附件上限,请移除后重新添加');
76
+ }
77
+ finally {
78
+ stream?.destroy();
79
+ }
80
+ }
81
+ async attachments(draft, cap, signal) {
82
+ const result = [];
83
+ let used = 0;
84
+ for (const file of draft.attachments) {
85
+ const content = await this.bytes(file, cap - used, signal);
86
+ used += content.length;
87
+ result.push({ id: file.id, filename: file.filename, content });
88
+ }
89
+ return result;
90
+ }
91
+ async create(input, signal) {
92
+ const fields = draftFields(input);
93
+ if (!fields.account) {
94
+ try {
95
+ fields.account = this.runtime.getEffectiveSettings().defaultAccount;
96
+ }
97
+ catch { /* A local draft is useful even before the mailbox is configured. */ }
98
+ }
99
+ const paths = input.attachments ?? [];
100
+ if (!Array.isArray(paths) || paths.length > 10 || paths.some(file => typeof file !== 'string' || !file.trim()))
101
+ throw new DraftError('附件应为最多 10 个本地文件路径');
102
+ const files = paths.map(path => ({ id: randomUUID(), path: resolve(path), filename: basename(path), owned: false }));
103
+ if (files.length) {
104
+ const cap = fields.account ? this.connection(fields.account).cap : MAX_BYTES;
105
+ await this.attachments({ attachments: files }, cap, signal);
106
+ }
107
+ return this.store.create(fields, files, signal);
108
+ }
109
+ async recover(signal) {
110
+ for (const draft of await this.store.list())
111
+ if (draft.state === 'sending') {
112
+ try {
113
+ await this.store.sendLock(draft.id, async (current) => { await this.store.change(draft.id, draft.revision, value => { if (value.state === 'sending') {
114
+ value.state = 'uncertain';
115
+ value.error = '上次提交未留下完整回执;请先去邮箱核对,不会自动重发';
116
+ } }, current); }, signal);
117
+ }
118
+ catch (error) {
119
+ if (!(error instanceof DraftError) || !['draft-busy', 'draft-conflict', 'draft-missing'].includes(error.code))
120
+ throw error;
121
+ }
122
+ }
123
+ }
124
+ async action(body, signal) {
125
+ this.prune();
126
+ signal?.throwIfAborted();
127
+ if (body.action === 'list') {
128
+ await this.recover(signal);
129
+ const snapshot = await this.settings.snapshot();
130
+ return { drafts: (await this.store.list()).map(view), accounts: snapshot.accounts, revision: snapshot.settings.revision, writable: snapshot.writable };
131
+ }
132
+ if (body.action === 'rules') {
133
+ const snapshot = await this.settings.snapshot();
134
+ const policies = parseRecipientPolicies(snapshot.settings.value.trustedRecipientsYaml);
135
+ return { accounts: snapshot.accounts, revision: snapshot.settings.revision, writable: snapshot.writable, sendApproval: snapshot.settings.value.sendApproval !== false, policies: Object.fromEntries(policies) };
136
+ }
137
+ if (body.action === 'saveRules' || body.action === 'matchRules') {
138
+ if (typeof body.account !== 'string' || !body.account.trim())
139
+ throw new DraftError('请先选择账号');
140
+ const snapshot = await this.settings.snapshot();
141
+ if (!snapshot.accounts.includes(body.account))
142
+ throw new DraftError('账号已改变,请重新加载规则');
143
+ const single = parseRecipientPolicies(JSON.stringify({ [body.account]: body.policy })).get(body.account);
144
+ if (!single)
145
+ throw new DraftError('请填写完整的收件人规则');
146
+ if (body.action === 'matchRules')
147
+ return matchRecipients(String(body.to ?? ''), String(body.cc ?? ''), single);
148
+ const all = parseRecipientPolicies(snapshot.settings.value.trustedRecipientsYaml);
149
+ all.set(body.account, single);
150
+ await this.settings.saveRecipientRules(serializeRecipientPolicies(all), body.revision);
151
+ return this.action({ action: 'rules' }, signal);
152
+ }
153
+ if (body.action === 'create')
154
+ return view(await this.create({ ...body, attachments: [] }, signal)); // Browser attachments only come from its file chooser.
155
+ const id = typeof body.id === 'string' ? body.id : '', draft = await this.store.get(id);
156
+ if (body.action === 'get')
157
+ return view(draft);
158
+ if (body.action === 'cancelPreview') {
159
+ this.invalidate(id);
160
+ return { cancelled: true };
161
+ }
162
+ if (body.action === 'update') {
163
+ editable(draft);
164
+ const fields = draftFields(body), keep = body.attachments ?? draft.attachments.map(file => file.id);
165
+ if (!Array.isArray(keep) || keep.some(key => !draft.attachments.some(file => file.id === key)) || new Set(keep).size !== keep.length)
166
+ throw new DraftError('附件列表无效,请重新打开草稿');
167
+ const changed = await this.store.change(id, body.revision, value => { editable(value); Object.assign(value, fields); value.attachments = value.attachments.filter(file => keep.includes(file.id)); }, signal);
168
+ this.invalidate(id);
169
+ for (const file of draft.attachments)
170
+ if (!changed.attachments.some(kept => kept.id === file.id))
171
+ await this.store.removeOwnedFile(id, file);
172
+ return view(changed);
173
+ }
174
+ if (body.action === 'delete') {
175
+ if (body.confirmed !== true)
176
+ throw new DraftError('请先确认删除本地草稿与上传副本');
177
+ await this.store.remove(id, body.revision, signal);
178
+ this.invalidate(id);
179
+ return { removed: true };
180
+ }
181
+ if (body.action === 'copy') {
182
+ if (draft.revision !== body.revision)
183
+ throw new DraftError('草稿已改变,请重新打开后复制', 'draft-conflict');
184
+ if (draft.state === 'sending')
185
+ throw new DraftError('邮件还在提交,请结束后再复制', 'draft-busy');
186
+ if (body.confirmed !== true)
187
+ throw new DraftError('请先核对邮箱,再确认复制为新草稿');
188
+ const contents = await this.attachments(draft, MAX_BYTES, signal), created = await this.store.create(draftFields(draft), [], signal), files = [];
189
+ try {
190
+ for (const file of contents) {
191
+ const owned = this.store.uploadPath(created.id, file.filename), stored = { id: randomUUID(), ...owned, owned: true };
192
+ files.push(stored);
193
+ await this.writeUpload(owned.path, file.content, signal);
194
+ }
195
+ const changed = await this.store.change(created.id, created.revision, value => { value.attachments = files; }, signal);
196
+ return view(changed);
197
+ }
198
+ catch (error) {
199
+ for (const file of files)
200
+ await this.store.removeOwnedFile(created.id, file);
201
+ await this.store.remove(created.id, created.revision).catch(() => { });
202
+ throw error;
203
+ }
204
+ }
205
+ if (body.action === 'preview') {
206
+ editable(draft);
207
+ if (draft.revision !== body.revision)
208
+ throw new DraftError('草稿已改变,请保存后重新预览', 'draft-conflict');
209
+ if (!draft.subject)
210
+ throw new DraftError('请先填写并保存邮件主题');
211
+ const connection = this.connection(draft.account), matching = await matchRecipients(draft.to, draft.cc, connection.policy), attachments = await this.attachments(draft, connection.cap, signal);
212
+ if (connection.key !== this.connection(draft.account).key || (await this.store.get(id)).revision !== draft.revision)
213
+ throw new DraftError('草稿或账号已改变,请重新预览', 'draft-conflict');
214
+ this.invalidate(id);
215
+ if (this.plans.size >= 3)
216
+ this.plans.delete(this.plans.keys().next().value);
217
+ const plan = { id: randomUUID(), created: this.now(), key: connection.key, draft, attachments };
218
+ this.plans.set(plan.id, plan);
219
+ return { id: plan.id, draft: view(draft), from: { address: connection.config.user, name: connection.config.senderName }, matching, attachments: attachments.map(({ id, filename, content }) => ({ id, filename, size: content.length, sha256: createHash('sha256').update(content).digest('hex') })), expiresAt: plan.created + 10 * 60 * 1000 };
220
+ }
221
+ if (body.action === 'send') {
222
+ if (body.confirmed !== true)
223
+ throw new DraftError('请核对预览后明确确认发送');
224
+ const plan = typeof body.preview === 'string' ? this.plans.get(body.preview) : undefined;
225
+ if (!plan || plan.draft.id !== id)
226
+ throw new DraftError('预览已过期或已用过,请重新预览', 'draft-preview-expired');
227
+ return this.store.sendLock(id, async (current) => {
228
+ const before = await this.store.get(id);
229
+ editable(before);
230
+ if (before.revision !== plan.draft.revision || plan.key !== this.connection(before.account).key)
231
+ throw new DraftError('草稿、账号或规则已改变,请重新预览', 'draft-conflict');
232
+ const pool = this.runtime.getPool();
233
+ current.throwIfAborted();
234
+ const pending = await this.store.change(id, before.revision, value => { editable(value); value.state = 'sending'; value.attemptAt = new Date(this.now()).toISOString(); }, current);
235
+ this.invalidate(id);
236
+ let unchanged = false;
237
+ try {
238
+ unchanged = plan.key === this.connection(before.account).key;
239
+ }
240
+ catch { /* A now-incomplete account also invalidates this review. */ }
241
+ if (!unchanged) {
242
+ await this.store.change(id, pending.revision, value => { value.state = 'draft'; delete value.attemptAt; });
243
+ throw new DraftError('账号或规则已改变,邮件未提交;请重新预览', 'draft-conflict');
244
+ }
245
+ let receipt;
246
+ try {
247
+ receipt = await pool.sendPrepared(before.account, before.to, before.subject, before.text, before.cc, plan.attachments.map(({ filename, content }) => ({ filename, content })), current);
248
+ }
249
+ catch (error) {
250
+ const failed = await this.store.change(id, pending.revision, value => { value.state = 'uncertain'; value.error = '提交结果未确定;请先去邮箱核对,不会自动重发。' + this.safe(error); }).catch(() => null);
251
+ if (!failed)
252
+ throw new DraftError('提交结果未确定,且回执未能保存;请先去邮箱核对,不要直接重发', 'draft-storage');
253
+ return { draft: view(failed) };
254
+ }
255
+ // Once SMTP acknowledges, a disconnected browser must not prevent the receipt from being persisted.
256
+ try {
257
+ return { draft: view(await this.store.change(id, pending.revision, value => { value.state = 'sent'; value.receipt = receipt; })) };
258
+ }
259
+ catch {
260
+ throw new DraftError('SMTP 已返回回执,但本地保存失败;请先核对邮箱,不要再次发送', 'draft-storage');
261
+ }
262
+ }, signal);
263
+ }
264
+ throw new DraftError('不支持此草稿操作');
265
+ }
266
+ async writeUpload(path, content, signal) {
267
+ signal?.throwIfAborted();
268
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
269
+ const file = await open(path, 'wx', 0o600);
270
+ try {
271
+ await file.writeFile(content);
272
+ signal?.throwIfAborted();
273
+ await file.sync();
274
+ }
275
+ finally {
276
+ await file.close();
277
+ }
278
+ }
279
+ async upload(request, content, signal) {
280
+ const id = request.headers.get('x-dsh-draft-id') ?? '', revision = Number(request.headers.get('x-dsh-draft-revision')), draft = await this.store.get(id);
281
+ editable(draft);
282
+ if (draft.revision !== revision || draft.attachments.length >= 10)
283
+ throw new DraftError('草稿已改变或附件已达 10 个,请保存并重新打开后添加', 'draft-conflict');
284
+ let filename;
285
+ try {
286
+ filename = decodeURIComponent(request.headers.get('x-dsh-draft-filename') ?? '');
287
+ }
288
+ catch {
289
+ throw new DraftError('附件名称无效');
290
+ }
291
+ if (!filename || filename.length > 500)
292
+ throw new DraftError('附件名称为空或过长');
293
+ let existingSize = 0;
294
+ for (const file of draft.attachments) {
295
+ const info = await stat(file.path).catch(() => null);
296
+ if (info?.isFile())
297
+ existingSize += info.size;
298
+ }
299
+ const cap = (() => { try {
300
+ return this.connection(draft.account).cap;
301
+ }
302
+ catch {
303
+ return MAX_BYTES;
304
+ } })();
305
+ if (existingSize + content.length > cap)
306
+ throw new DraftError('草稿附件总大小最多 ' + Math.floor(cap / 1024 / 1024) + ' MiB');
307
+ const owned = { id: randomUUID(), ...this.store.uploadPath(id, filename), owned: true };
308
+ try {
309
+ await this.writeUpload(owned.path, content, signal);
310
+ const changed = await this.store.change(id, revision, value => { editable(value); value.attachments.push(owned); }, signal);
311
+ this.invalidate(id);
312
+ return view(changed);
313
+ }
314
+ catch (error) {
315
+ await this.store.removeOwnedFile(id, owned);
316
+ throw error;
317
+ }
318
+ }
319
+ async fetch(request) {
320
+ const site = request.headers.get('sec-fetch-site'), origin = request.headers.get('origin');
321
+ const fail = (status, error) => json(status, { ok: false, error: { code: error instanceof DraftError ? error.code : error?.code === 'SETTINGS_CONFLICT' ? 'settings-conflict' : 'draft-invalid', message: this.safe(error) } });
322
+ if (site && !['same-origin', 'none'].includes(site))
323
+ return fail(403, new DraftError('请从邮件草稿面板操作'));
324
+ if (origin) {
325
+ try {
326
+ const url = new URL(origin);
327
+ if (!['http:', 'https:'].includes(url.protocol) || url.host !== (request.headers.get('host') || new URL(request.url).host))
328
+ return fail(403, new DraftError('拒绝跨源操作'));
329
+ }
330
+ catch {
331
+ return fail(403, new DraftError('无效来源'));
332
+ }
333
+ }
334
+ try {
335
+ this.prune();
336
+ if (request.method === 'GET') {
337
+ const url = new URL(request.url), plan = this.plans.get(url.searchParams.get('preview') ?? ''), file = plan?.attachments.find(file => file.id === url.searchParams.get('attachment'));
338
+ if (!plan || !file || plan.key !== this.connection(plan.draft.account).key)
339
+ return fail(404, new DraftError('附件预览已失效,请重新预览'));
340
+ return new Response(new Uint8Array(file.content), { headers: { 'content-type': 'application/octet-stream', 'content-disposition': "attachment; filename*=UTF-8''" + encodeURIComponent(file.filename), 'x-content-type-options': 'nosniff', 'cache-control': 'no-store' } });
341
+ }
342
+ if (request.method !== 'POST')
343
+ return fail(405, new DraftError('请使用草稿面板支持的操作'));
344
+ const type = request.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
345
+ if (request.headers.get('x-dsh-email-draft') !== '1' || !['application/json', 'application/octet-stream'].includes(type ?? ''))
346
+ return fail(403, new DraftError('请从邮件草稿面板操作'));
347
+ const reader = request.body?.getReader();
348
+ if (!reader)
349
+ throw new DraftError('请求为空');
350
+ const limit = type === 'application/octet-stream' ? MAX_BYTES : 256 * 1024, chunks = [];
351
+ let size = 0;
352
+ try {
353
+ for (;;) {
354
+ const { done, value } = await reader.read();
355
+ if (done)
356
+ break;
357
+ size += value.length;
358
+ if (size > limit) {
359
+ await reader.cancel();
360
+ return fail(413, new DraftError('请求超过大小上限'));
361
+ }
362
+ chunks.push(value);
363
+ }
364
+ }
365
+ finally {
366
+ reader.releaseLock();
367
+ }
368
+ const content = Buffer.concat(chunks), signal = AbortSignal.any([request.signal, AbortSignal.timeout(120000)]);
369
+ if (type === 'application/octet-stream')
370
+ return json(200, { ok: true, value: await this.upload(request, content, signal) });
371
+ const body = JSON.parse(content.toString('utf8'));
372
+ if (!body || typeof body !== 'object' || Array.isArray(body))
373
+ throw new DraftError('请求必须为对象');
374
+ return json(200, { ok: true, value: await this.action(body, signal) });
375
+ }
376
+ catch (error) {
377
+ return fail(error instanceof DraftError && ['draft-conflict', 'draft-busy', 'draft-submitted'].includes(error.code) || error?.code === 'SETTINGS_CONFLICT' ? 409 : 400, error);
378
+ }
379
+ }
380
+ }
381
+ export function installEmailDrafts(ctx, backend) {
382
+ ctx.inject?.(['connection'], (host) => {
383
+ host.connection?.fetch?.register?.({ path: EMAIL_DRAFT_ROUTE, methods: ['POST', 'GET'], requestBody: 'buffered', fetch: (request) => backend.fetch(request) });
384
+ host.connection?.fetch?.register?.({ path: EMAIL_DRAFT_ROUTE + '/upload', methods: ['POST'], requestBody: 'streaming', fetch: (request) => backend.fetch(request) });
385
+ });
386
+ }
@@ -15,6 +15,7 @@ export const Config = z.object({
15
15
  maxBodyChars: z.number().volatile(), downloadDir: z.string().volatile(),
16
16
  accounts: z.dict(z.any()).role('secret').volatile(), defaultAccount: z.string().volatile(),
17
17
  accountsYaml: z.string().role('secret').volatile(), serverPresets: z.string().volatile(),
18
+ trustedRecipientsYaml: z.string().volatile(),
18
19
  maxAttachmentBytes: z.number().volatile(), bodySearchFallback: z.boolean().volatile(),
19
20
  bodySearchLimit: z.number().volatile(), idleTimeoutMs: z.number().volatile(),
20
21
  legacySettingsImported: z.boolean().volatile(),
package/lib/index.d.ts CHANGED
@@ -15,3 +15,5 @@ export { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase,
15
15
  export { parseEmailDay } from './tool-contract.js';
16
16
  export { EmailSettingsBackend, installEmailSettingsWeb, SETTINGS_ROUTE } from './web.js';
17
17
  export type { AccountCardData, AccountCardInput } from './web.js';
18
+ export { EmailDraftBackend, installEmailDrafts, EMAIL_DRAFT_ROUTE } from './draft-web.js';
19
+ export { DraftStore, DraftError } from './draft-store.js';
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
5
5
  import { liveConfig, Config as ConfigSchema } from './host-config.js';
6
6
  import { installLegacySettingsImport } from './legacy-settings.js';
7
7
  import { EmailSettingsSchema, SETTINGS_NAMESPACE } from './settings.js';
8
+ import { EmailDraftBackend, installEmailDrafts } from './draft-web.js';
8
9
  export const Config = ConfigSchema;
9
10
  export const name = 'tool-email';
10
11
  export const inject = ['settings', 'tools'];
@@ -21,7 +22,9 @@ export function apply(ctx, config = {}) {
21
22
  const backend = new EmailSettingsBackend(ctx, runtime.settingsScope, config);
22
23
  backend.watchImpl = runtime.watch;
23
24
  installEmailSettingsWeb(ctx, backend);
24
- for (const definition of buildEmailTools(runtime))
25
+ const drafts = new EmailDraftBackend(runtime, backend);
26
+ installEmailDrafts(ctx, drafts);
27
+ for (const definition of buildEmailTools(runtime, drafts))
25
28
  ctx.tools.register(definition);
26
29
  installSendApproval(ctx, runtime);
27
30
  }
@@ -32,3 +35,5 @@ export { flattenAddresses, parseRawMessage, sanitizeFilename, stripHtml, truncat
32
35
  export { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
33
36
  export { parseEmailDay } from './tool-contract.js';
34
37
  export { EmailSettingsBackend, installEmailSettingsWeb, SETTINGS_ROUTE } from './web.js';
38
+ export { EmailDraftBackend, installEmailDrafts, EMAIL_DRAFT_ROUTE } from './draft-web.js';
39
+ export { DraftStore, DraftError } from './draft-store.js';
@@ -208,6 +208,11 @@ export declare class EmailPool {
208
208
  folders(accountName: string | undefined, subscribedOnly: boolean, signal?: AbortSignal): Promise<EmailFoldersResult>;
209
209
  downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number, workspaceHint?: string, signal?: AbortSignal): Promise<EmailAttachmentResult>;
210
210
  send(accountName: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, attachmentPaths: string[] | undefined, signal?: AbortSignal): Promise<EmailSendResult>;
211
+ /** Send the attachment bytes the human reviewed, without re-reading mutable paths. */
212
+ sendPrepared(accountName: string, to: string, subject: string, text: string, cc: string, attachments: Array<{
213
+ filename: string;
214
+ content: Buffer;
215
+ }>, signal?: AbortSignal): Promise<EmailSendResult>;
211
216
  reply(accountName: string | undefined, folder: string, uid: number, mode: EmailReplyMode, text: string, forwardTo: string, cc: string | undefined, signal?: AbortSignal): Promise<EmailReplyResult>;
212
217
  }
213
218
  /** Stat every attachment path up front; total size must stay under the cap. */
@@ -1131,6 +1131,14 @@ export class EmailPool {
1131
1131
  response: typeof info.response === 'string' ? info.response : String(info.response ?? ''),
1132
1132
  };
1133
1133
  }
1134
+ /** Send the attachment bytes the human reviewed, without re-reading mutable paths. */
1135
+ async sendPrepared(accountName, to, subject, text, cc, attachments, signal) {
1136
+ const name = this.resolveName(accountName), cfg = this.account(name);
1137
+ if (attachments.length > 10 || attachments.reduce((sum, file) => sum + file.content.length, 0) > Math.min(this.settings.maxAttachmentBytes, 20 * 1024 * 1024))
1138
+ throw new MailError('草稿附件超过大小或数量上限');
1139
+ const info = await this.sendMail(name, cfg, { from: senderOf(cfg), to, cc, subject, text, attachments, disableFileAccess: true, disableUrlAccess: true }, signal);
1140
+ 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 ?? '') };
1141
+ }
1134
1142
  async reply(accountName, folder, uid, mode, text, forwardTo, cc, signal) {
1135
1143
  const name = this.resolveName(accountName);
1136
1144
  const cfg = this.account(name);
@@ -0,0 +1,26 @@
1
+ export interface RecipientPolicy {
2
+ skipApproval: boolean;
3
+ addresses: string[];
4
+ domains: string[];
5
+ }
6
+ export interface RecipientMatch {
7
+ field: 'to' | 'cc';
8
+ address: string;
9
+ matched: boolean;
10
+ rule: 'address' | 'domain' | '';
11
+ value: string;
12
+ }
13
+ export declare function canonicalDomain(value: string): string;
14
+ export declare function canonicalAddress(value: string): string;
15
+ /** The public Nodemailer JSON transport compiles the same recipient envelope without delivery. */
16
+ export declare function normalizeRecipients(to: string, cc?: string): Promise<Array<{
17
+ field: 'to' | 'cc';
18
+ address: string;
19
+ }>>;
20
+ export declare function parseRecipientPolicies(text?: string): Map<string, RecipientPolicy>;
21
+ export declare function serializeRecipientPolicies(policies: Map<string, RecipientPolicy>): string;
22
+ export declare function matchRecipients(to: string, cc: string, policy?: RecipientPolicy): Promise<{
23
+ rows: RecipientMatch[];
24
+ allTrusted: boolean;
25
+ skipsApproval: boolean;
26
+ }>;
@@ -0,0 +1,88 @@
1
+ import nodemailer from 'nodemailer';
2
+ import { domainToASCII } from 'node:url';
3
+ import { parse, stringify } from 'yaml';
4
+ const normalizer = nodemailer.createTransport({ jsonTransport: true, disableFileAccess: true, disableUrlAccess: true });
5
+ export function canonicalDomain(value) {
6
+ const domain = domainToASCII(value.trim().replace(/\.$/, '')).toLowerCase();
7
+ if (!domain || domain.length > 253 || domain.split('.').some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
8
+ throw new Error('请填写有效的完整域名,如 example.com;不支持通配符或网址');
9
+ return domain;
10
+ }
11
+ export function canonicalAddress(value) {
12
+ const address = value.trim(), at = address.lastIndexOf('@'), local = address.slice(0, at);
13
+ if (at <= 0 || Buffer.byteLength(local) > 64 || /[\r\n\u0000-\u001f\u007f]/.test(local)
14
+ || (!/^"(?:[^"\\]|\\.)*"$/.test(local) && (/[^\S\r\n]|[@<>()\[\],;:\\"]/.test(local) || local.startsWith('.') || local.endsWith('.') || local.includes('..'))))
15
+ throw new Error('请填写完整邮箱地址,如 name@example.com');
16
+ return local + '@' + canonicalDomain(address.slice(at + 1));
17
+ }
18
+ /** The public Nodemailer JSON transport compiles the same recipient envelope without delivery. */
19
+ export async function normalizeRecipients(to, cc = '') {
20
+ const rows = [];
21
+ for (const [field, value] of [['to', to], ['cc', cc]]) {
22
+ if (typeof value !== 'string' || value.length > 4096 || /[\r\n\u0000]/.test(value))
23
+ throw new Error('收件人字段不能含换行,且最多 4096 字符');
24
+ if (!value.trim()) {
25
+ if (field === 'to')
26
+ throw new Error('请填写收件人');
27
+ continue;
28
+ }
29
+ const info = await normalizer.sendMail({ from: 'preview@example.invalid', to: value, text: '' });
30
+ const envelope = info.envelope.to;
31
+ if (!Array.isArray(envelope) || !envelope.length)
32
+ throw new Error('收件人没有可投递的完整邮箱地址');
33
+ for (const raw of envelope) {
34
+ const address = canonicalAddress(String(raw));
35
+ if (!rows.some(row => row.field === field && row.address === address))
36
+ rows.push({ field, address });
37
+ }
38
+ }
39
+ if (rows.length > 100)
40
+ throw new Error('单封草稿最多 100 个 To/Cc 收件地址');
41
+ return rows;
42
+ }
43
+ const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ export function parseRecipientPolicies(text) {
45
+ if (!text?.trim())
46
+ return new Map();
47
+ if (Buffer.byteLength(text) > 64 * 1024)
48
+ throw new Error('可信收件人规则超过 64 KiB');
49
+ let data;
50
+ try {
51
+ data = parse(text, { maxAliasCount: 0, uniqueKeys: true });
52
+ }
53
+ catch {
54
+ throw new Error('可信收件人规则不是有效 YAML,请检查重复账号、缩进或引用');
55
+ }
56
+ if (!plain(data) || Object.keys(data).length > 50)
57
+ throw new Error('规则应按账号名填写,最多 50 个账号');
58
+ const policies = new Map();
59
+ for (const [account, raw] of Object.entries(data)) {
60
+ if (!account.trim() || account.length > 200 || !plain(raw) || Object.keys(raw).some(key => !['skipApproval', 'addresses', 'domains'].includes(key)))
61
+ throw new Error('账号规则只支持 skipApproval、addresses 与 domains');
62
+ if (raw.skipApproval !== undefined && typeof raw.skipApproval !== 'boolean')
63
+ throw new Error('skipApproval 必须是布尔值');
64
+ const list = (key, convert) => {
65
+ const values = raw[key] ?? [];
66
+ if (!Array.isArray(values) || values.length > 100 || values.some(value => typeof value !== 'string'))
67
+ throw new Error(key + ' 必须为字符串数组,最多 100 项');
68
+ return [...new Set(values.map(value => convert(value)))];
69
+ };
70
+ policies.set(account, { skipApproval: raw.skipApproval === true, addresses: list('addresses', canonicalAddress), domains: list('domains', canonicalDomain) });
71
+ }
72
+ return policies;
73
+ }
74
+ export function serializeRecipientPolicies(policies) {
75
+ return policies.size ? stringify(Object.fromEntries(policies)) : '';
76
+ }
77
+ export async function matchRecipients(to, cc, policy) {
78
+ const rows = (await normalizeRecipients(to, cc)).map(row => {
79
+ if (policy?.addresses.includes(row.address))
80
+ return { ...row, matched: true, rule: 'address', value: row.address };
81
+ const domain = row.address.slice(row.address.lastIndexOf('@') + 1);
82
+ if (policy?.domains.includes(domain))
83
+ return { ...row, matched: true, rule: 'domain', value: domain };
84
+ return { ...row, matched: false, rule: '', value: '' };
85
+ });
86
+ const allTrusted = rows.length > 0 && rows.every(row => row.matched);
87
+ return { rows, allTrusted, skipsApproval: policy?.skipApproval === true && allTrusted };
88
+ }
package/lib/runtime.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type EmailConfig, type ResolvedEmailSettings } from './config.js';
3
3
  import { EmailPool } from './mail-client.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' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose'>;
6
+ export type EmailClient = Pick<EmailPool, 'list' | 'read' | 'mark' | 'search' | 'send' | 'sendPrepared' | 'reply' | 'folders' | 'downloadAttachment' | 'unseenUids' | 'fetchByUids' | 'startIdleSweep' | 'dispose'>;
7
7
  export interface EmailSettingsScope {
8
8
  get(): unknown;
9
9
  namespace?: string;
package/lib/settings.d.ts CHANGED
@@ -12,6 +12,7 @@ export declare const EmailSettingsSchema: import("@deepseek-ai/schemastery").def
12
12
  password: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
13
13
  inboxFolder: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
14
14
  sendApproval: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
15
+ trustedRecipientsYaml: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
15
16
  maxBodyChars: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
16
17
  downloadDir: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
17
18
  accountsYaml: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
@@ -40,6 +41,7 @@ export declare const EmailSettingsSchema: import("@deepseek-ai/schemastery").def
40
41
  password: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
41
42
  inboxFolder: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
42
43
  sendApproval: import("@deepseek-ai/schemastery").default<boolean, boolean, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
44
+ trustedRecipientsYaml: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
43
45
  maxBodyChars: import("@deepseek-ai/schemastery").default<number, number, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
44
46
  downloadDir: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
45
47
  accountsYaml: import("@deepseek-ai/schemastery").default<string, string, Mode extends "volatile" | "volatile-defined" ? "volatile-defined" : "defined">;
@@ -69,6 +71,7 @@ export interface EmailSettingsValue {
69
71
  password: string;
70
72
  inboxFolder: string;
71
73
  sendApproval: boolean;
74
+ trustedRecipientsYaml?: string;
72
75
  maxBodyChars: number;
73
76
  downloadDir: string;
74
77
  accountsYaml: string;