dsh-email 0.14.6 → 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. */
@@ -356,7 +356,7 @@ export class EmailPool {
356
356
  // 附件索引只需要 MIME 结构:bodyStructure 已经给出每个附件的 part/名称/大小,
357
357
  // 不必为了拿它先拉整封 source(第一次就下载附件的邮件也一样)。
358
358
  const message = await client.fetchOne(uid, { uid: true, bodyStructure: true }, { uid: true });
359
- if (message === false) {
359
+ if (message === false || message === undefined) {
360
360
  throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
361
361
  }
362
362
  signal?.throwIfAborted();
@@ -371,7 +371,7 @@ export class EmailPool {
371
371
  const full = message.source !== undefined
372
372
  ? message
373
373
  : await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
374
- if (full === false || full.source === undefined) {
374
+ if (full === false || full === undefined || full.source === undefined) {
375
375
  throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folder + '")');
376
376
  }
377
377
  const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
@@ -585,7 +585,7 @@ export class EmailPool {
585
585
  greetingTimeout: 10000,
586
586
  socketTimeout: 60000,
587
587
  getSocket: (options, callback) => {
588
- const socket = createConnection({ host: options.host ?? cfg.smtp.host, port: options.port ?? cfg.smtp.port });
588
+ const socket = createConnection({ host: options.host ?? cfg.smtp.host, port: Number(options.port ?? cfg.smtp.port) });
589
589
  onSocket(socket);
590
590
  let settled = false;
591
591
  const finish = (error, socketOptions) => {
@@ -715,6 +715,8 @@ export class EmailPool {
715
715
  async downloadPartText(client, uid, part, maxBytes, signal) {
716
716
  const dl = await client.download(uid, part.key, { uid: true, maxBytes });
717
717
  signal?.throwIfAborted();
718
+ if (dl.content === undefined)
719
+ throw new MailError('邮件正文分段已不存在;将尝试重新读取邮件');
718
720
  const buf = await collectStream(dl.content, maxBytes, signal);
719
721
  signal?.throwIfAborted();
720
722
  return buf.toString('utf8');
@@ -757,7 +759,7 @@ export class EmailPool {
757
759
  query.before = until;
758
760
  const found = await client.search(query, { uid: true });
759
761
  signal?.throwIfAborted();
760
- uids = found === false ? [] : found;
762
+ uids = Array.isArray(found) ? found : [];
761
763
  scopeCount = uids.length;
762
764
  }
763
765
  else if (total > 0) {
@@ -785,7 +787,7 @@ export class EmailPool {
785
787
  const uidValidity = mailbox === false ? 0 : Number(mailbox.uidValidity ?? 0);
786
788
  const found = await client.search({ seen: false }, { uid: true });
787
789
  signal?.throwIfAborted();
788
- const uids = (found === false ? [] : found).slice().sort((a, b) => b - a);
790
+ const uids = (Array.isArray(found) ? found : []).slice().sort((a, b) => b - a);
789
791
  return { account: name, folder: folderName, uidValidity, count: uids.length, uids };
790
792
  }, true, signal);
791
793
  }
@@ -822,7 +824,7 @@ export class EmailPool {
822
824
  client.search({ cc: query, ...dateRange }, { uid: true }),
823
825
  ]);
824
826
  signal?.throwIfAborted();
825
- const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => b - a);
827
+ const uids = [...new Set(found.flatMap(result => Array.isArray(result) ? result : []))].sort((a, b) => b - a);
826
828
  if (uids.length > 0) {
827
829
  // The sample has to cover the requested page (offset + limit) — the same
828
830
  // window the fallback scan looks at — so one FETCH serves both the
@@ -938,7 +940,7 @@ export class EmailPool {
938
940
  // 先只要信封与 MIME 结构,正文按 text/* 分段下载:带 20 MiB 附件的邮件
939
941
  // 只为看正文时不再整封拉下来,附件元数据直接复用 bodyStructure。
940
942
  const message = await client.fetchOne(uid, { uid: true, envelope: true, bodyStructure: true }, { uid: true });
941
- if (message === false) {
943
+ if (message === false || message === undefined) {
942
944
  throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
943
945
  }
944
946
  signal?.throwIfAborted();
@@ -979,7 +981,7 @@ export class EmailPool {
979
981
  const full = message.source !== undefined
980
982
  ? message
981
983
  : await client.fetchOne(uid, { uid: true, source: true, bodyStructure: true }, { uid: true });
982
- if (full === false || full.source === undefined) {
984
+ if (full === false || full === undefined || full.source === undefined) {
983
985
  throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
984
986
  }
985
987
  const body = await parseRawMessage(full.source, this.settings.maxBodyChars);
@@ -995,7 +997,7 @@ export class EmailPool {
995
997
  return this.withImap(name, folderName, async (client) => {
996
998
  const before = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
997
999
  signal?.throwIfAborted();
998
- if (before === false) {
1000
+ if (before === false || before === undefined) {
999
1001
  throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
1000
1002
  }
1001
1003
  let seen = before.flags?.has('\\Seen') === true;
@@ -1090,8 +1092,10 @@ export class EmailPool {
1090
1092
  }
1091
1093
  const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
1092
1094
  signal?.throwIfAborted();
1095
+ if (dl.content === undefined)
1096
+ throw new MailError('附件分段已不存在,请重新读取邮件后再下载');
1093
1097
  const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes, signal);
1094
- const safeName = sanitizeFilename(dl.meta.filename ?? att.filename ?? attachments[index].filename);
1098
+ const safeName = sanitizeFilename(dl.meta?.filename ?? att.filename ?? attachments[index].filename);
1095
1099
  // Default the destination to the session workspace so the model can
1096
1100
  // read the file back; an explicit downloadDir always wins.
1097
1101
  const dir = this.settings.downloadDirExplicit
@@ -1127,6 +1131,14 @@ export class EmailPool {
1127
1131
  response: typeof info.response === 'string' ? info.response : String(info.response ?? ''),
1128
1132
  };
1129
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
+ }
1130
1142
  async reply(accountName, folder, uid, mode, text, forwardTo, cc, signal) {
1131
1143
  const name = this.resolveName(accountName);
1132
1144
  const cfg = this.account(name);
@@ -1135,7 +1147,7 @@ export class EmailPool {
1135
1147
  // leaves a half-written mailbox state behind.
1136
1148
  const built = await this.withImap(name, folderName, async (client) => {
1137
1149
  const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
1138
- if (message === false || message.source === undefined) {
1150
+ if (message === false || message === undefined || message.source === undefined) {
1139
1151
  throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
1140
1152
  }
1141
1153
  const ids = extractMessageIds(message.source);
@@ -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
+ }>;