dsh-email 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,43 +1,41 @@
1
- import { clampInt, resolveEmailConfig } from './config.js';
2
- import { MailClient, messageOf } from './mail-client.js';
1
+ import { clampInt, resolveEmailSettings } from './config.js';
2
+ import { EmailPool, messageOf } from './mail-client.js';
3
3
  export const name = 'tool-email';
4
4
  export const inject = ['tools'];
5
5
  const MAX_LIMIT = 100;
6
- /** Loose-but-typed output schema; values below carry every declared field. */
6
+ const ACCOUNT_HINT = '账号名(配置了 accounts 多个账号时选择),省略时用 defaultAccount。可用账号见 email_folders 的报错或插件 README';
7
+ const strArray = { type: 'array', items: { type: 'string' } };
8
+ const addrArray = { type: 'array', items: { type: 'object', additionalProperties: true } };
9
+ const messageShape = {
10
+ uid: { type: 'integer' },
11
+ date: { type: 'string' },
12
+ from: addrArray,
13
+ subject: { type: 'string' },
14
+ seen: { type: 'boolean' },
15
+ flagged: { type: 'boolean' },
16
+ size: { type: 'integer' },
17
+ hasAttachments: { type: 'boolean' },
18
+ };
7
19
  const listSchema = {
8
20
  type: 'object',
9
21
  properties: {
22
+ account: { type: 'string' },
10
23
  count: { type: 'integer' },
11
24
  folder: { type: 'string' },
12
- messages: {
13
- type: 'array',
14
- items: {
15
- type: 'object',
16
- properties: {
17
- uid: { type: 'integer' },
18
- date: { type: 'string' },
19
- from: { type: 'array', items: { type: 'object', additionalProperties: true } },
20
- subject: { type: 'string' },
21
- seen: { type: 'boolean' },
22
- flagged: { type: 'boolean' },
23
- size: { type: 'integer' },
24
- hasAttachments: { type: 'boolean' },
25
- },
26
- additionalProperties: true,
27
- },
28
- },
25
+ messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
29
26
  },
30
27
  additionalProperties: true,
31
28
  };
32
29
  const readSchema = {
33
30
  type: 'object',
34
31
  properties: {
32
+ account: { type: 'string' },
35
33
  uid: { type: 'integer' },
36
34
  folder: { type: 'string' },
37
35
  date: { type: 'string' },
38
- from: { type: 'array', items: { type: 'object', additionalProperties: true } },
39
- to: { type: 'array', items: { type: 'object', additionalProperties: true } },
40
- cc: { type: 'array', items: { type: 'object', additionalProperties: true } },
36
+ from: addrArray,
37
+ to: addrArray,
38
+ cc: addrArray,
41
39
  subject: { type: 'string' },
42
40
  text: { type: 'string' },
43
41
  attachments: { type: 'array', items: { type: 'object', additionalProperties: true } },
@@ -48,13 +46,46 @@ const readSchema = {
48
46
  const sendSchema = {
49
47
  type: 'object',
50
48
  properties: {
49
+ account: { type: 'string' },
51
50
  messageId: { type: 'string' },
52
- accepted: { type: 'array', items: { type: 'string' } },
53
- rejected: { type: 'array', items: { type: 'string' } },
51
+ accepted: strArray,
52
+ rejected: strArray,
54
53
  response: { type: 'string' },
55
54
  },
56
55
  additionalProperties: true,
57
56
  };
57
+ const foldersSchema = {
58
+ type: 'object',
59
+ properties: {
60
+ account: { type: 'string' },
61
+ folders: {
62
+ type: 'array',
63
+ items: {
64
+ type: 'object',
65
+ properties: {
66
+ name: { type: 'string' },
67
+ path: { type: 'string' },
68
+ specialUse: { type: 'string' },
69
+ subscribed: { type: 'boolean' },
70
+ },
71
+ additionalProperties: true,
72
+ },
73
+ },
74
+ },
75
+ additionalProperties: true,
76
+ };
77
+ const attachmentSchema = {
78
+ type: 'object',
79
+ properties: {
80
+ account: { type: 'string' },
81
+ uid: { type: 'integer' },
82
+ filename: { type: 'string' },
83
+ contentType: { type: 'string' },
84
+ size: { type: 'integer' },
85
+ path: { type: 'string' },
86
+ },
87
+ additionalProperties: true,
88
+ };
58
89
  function oneText(text) {
59
90
  return [{ type: 'text', text }];
60
91
  }
@@ -65,53 +96,78 @@ function describeMessage(message) {
65
96
  message.flagged ? '已标星' : '',
66
97
  message.hasAttachments ? '含附件' : '',
67
98
  ].filter(Boolean);
68
- const parts = [`uid=${message.uid}`, from, message.date];
99
+ const parts = ['uid=' + message.uid, from, message.date];
69
100
  if (flags.length > 0)
70
101
  parts.push(flags.join('、'));
71
- return `${message.subject || '(无主题)'} [${parts.join(' | ')}]`;
102
+ return (message.subject || '(无主题)') + ' [' + parts.join(' | ') + ']';
72
103
  }
73
104
  function renderList(value) {
74
105
  if (value.messages.length === 0) {
75
- return oneText(`文件夹 "${value.folder}" 共 ${value.count} 封邮件,本次没有要列出的邮件。`);
106
+ return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,本次没有要列出的邮件。');
76
107
  }
77
- const lines = value.messages.map((m, i) => `#${i + 1} ${describeMessage(m)}`);
78
- return oneText(`文件夹 "${value.folder}" 共 ${value.count} 封邮件,最新 ${value.messages.length} 封:\n\n${lines.join('\n')}\n\n用 email_read 配合 uid 阅读全文。`);
108
+ const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
109
+ return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,最新 ' + value.messages.length + ' 封:\n\n' + lines.join('\n') + '\n\n用 email_read 配合 uid 阅读全文。');
79
110
  }
80
111
  function renderRead(value) {
81
112
  const from = value.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知)';
82
113
  const attach = value.attachments.length > 0
83
- ? `\n附件:${value.attachments.map(a => `${a.filename}(${a.contentType},${a.size} 字节)`).join(';')}`
114
+ ? '\n附件:' + value.attachments.map((a, i) => '#' + i + ' ' + a.filename + '(' + a.contentType + ',' + a.size + ' 字节)').join(';') + '\n(用 email_attachment 配合 uid 与序号下载)'
84
115
  : '';
85
- return oneText(`主题:${value.subject || '(无主题)'}\n来自:${from}\n时间:${value.date || '(未知)'}${attach}\n\n${value.text}`);
116
+ return oneText('账号 ' + value.account + ',主题:' + (value.subject || '(无主题)') + '\n来自:' + from + '\n时间:' + (value.date || '(未知)') + attach + '\n\n' + value.text);
86
117
  }
87
118
  function renderSearch(value) {
88
119
  if (value.messages.length === 0) {
89
- return oneText(`在文件夹 "${value.folder}" 中搜索 "${value.query}":共 ${value.count} 条匹配,本次没有列出。`);
120
+ return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,本次没有列出。');
90
121
  }
91
- const lines = value.messages.map((m, i) => `#${i + 1} ${describeMessage(m)}`);
92
- return oneText(`在文件夹 "${value.folder}" 中搜索 "${value.query}":共 ${value.count} 条匹配,展示最新 ${value.messages.length} 条:\n\n${lines.join('\n')}`);
122
+ const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
123
+ return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,展示最新 ' + value.messages.length + ' 条:\n\n' + lines.join('\n'));
93
124
  }
94
125
  function renderSend(value) {
95
- const rejected = value.rejected.length > 0 ? `;被拒:${value.rejected.join(', ')}` : '';
96
- return oneText(`邮件已发送,messageId: ${value.messageId};成功送达:${value.accepted.join(', ')}${rejected};服务器响应:${value.response}`);
126
+ const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
127
+ return oneText('账号 ' + value.account + ' 邮件已发送,messageId: ' + value.messageId + ';成功送达:' + value.accepted.join(', ') + rejected + ';服务器响应:' + value.response);
128
+ }
129
+ function renderFolders(value) {
130
+ if (value.folders.length === 0)
131
+ return oneText('账号 ' + value.account + ':未列出任何文件夹。');
132
+ const lines = value.folders.map((f, i) => '#' + (i + 1) + ' ' + f.path + (f.specialUse !== '' ? ' [' + f.specialUse + ']' : '') + (f.subscribed ? '' : '(未订阅)'));
133
+ return oneText('账号 ' + value.account + ' 的文件夹(' + value.folders.length + ' 个):\n\n' + lines.join('\n') + '\n\n把 folder 参数填成其中的 path 即可。');
134
+ }
135
+ function renderAttachment(value) {
136
+ return oneText('账号 ' + value.account + ' 已下载附件 "' + value.filename + '"(' + value.contentType + ',' + value.size + ' 字节)到:\n' + value.path + '\n可用 read 工具读取该文件。');
97
137
  }
98
138
  export function apply(ctx, config = {}) {
99
- // Load-time nudge only: never break boot, tools report the details instead.
139
+ let settingsError = '';
140
+ let settings = null;
100
141
  try {
101
- resolveEmailConfig(config);
142
+ settings = resolveEmailSettings(config);
102
143
  }
103
144
  catch (error) {
104
- ctx.logger?.warn?.(`[dsh-email] ${messageOf(error, '未配置邮箱账号')}`);
145
+ settingsError = messageOf(error, 'dsh-email 配置无效');
146
+ ctx.logger?.warn?.('[dsh-email] ' + settingsError);
105
147
  }
106
- const resolve = () => resolveEmailConfig(config);
148
+ let pool = null;
149
+ const getPool = () => {
150
+ if (settingsError !== '')
151
+ throw new Error(settingsError);
152
+ if (pool === null) {
153
+ pool = new EmailPool(settings);
154
+ pool.startIdleSweep();
155
+ }
156
+ return pool;
157
+ };
158
+ ctx.effect(() => () => {
159
+ pool?.dispose();
160
+ pool = null;
161
+ });
107
162
  ctx.tools.register({
108
163
  name: 'email_list',
109
164
  description: 'List recent emails in a mailbox folder (newest first). Returns uid, date, sender, subject and flags without message bodies; use email_read with a uid to fetch the full text.',
110
165
  parameters: {
111
- folder: { type: 'string', description: `IMAP folder name, default "${resolveSafeFolder(config)}" (the configured inboxFolder)` },
166
+ folder: { type: 'string', description: 'IMAP folder path (see email_folders); defaults to the account inboxFolder' },
112
167
  limit: { type: 'integer', description: 'How many messages to return, 1-100, default 20' },
113
168
  offset: { type: 'integer', description: 'Skip this many newest messages first, default 0' },
114
169
  unreadOnly: { type: 'boolean', description: 'Only list unread messages, default false' },
170
+ account: { type: 'string', description: ACCOUNT_HINT },
115
171
  },
116
172
  output: {
117
173
  schema: listSchema,
@@ -119,18 +175,18 @@ export function apply(ctx, config = {}) {
119
175
  },
120
176
  async execute(rawArgs) {
121
177
  const args = rawArgs;
122
- const cfg = resolve();
123
178
  const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
124
179
  const offset = clampInt(args.offset, 0, 0, 10000);
125
- return await new MailClient(cfg).list(args.folder?.trim() || cfg.inboxFolder, limit, offset, args.unreadOnly === true);
180
+ return await getPool().list(args.account, args.folder?.trim() || '', limit, offset, args.unreadOnly === true);
126
181
  },
127
182
  });
128
183
  ctx.tools.register({
129
184
  name: 'email_read',
130
- description: 'Read one full email message by its uid (from email_list or email_search). Returns the plain-text body (HTML mail is converted; oversized bodies are truncated) plus attachment metadata.',
185
+ description: 'Read one full email message by its uid (from email_list or email_search). Returns the plain-text body (HTML mail is converted; oversized bodies are truncated) plus attachment metadata; use email_attachment to download one.',
131
186
  parameters: {
132
187
  uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
133
- folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the configured inboxFolder' },
188
+ folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
189
+ account: { type: 'string', description: ACCOUNT_HINT },
134
190
  },
135
191
  output: {
136
192
  schema: readSchema,
@@ -138,11 +194,10 @@ export function apply(ctx, config = {}) {
138
194
  },
139
195
  async execute(rawArgs) {
140
196
  const args = rawArgs;
141
- const cfg = resolve();
142
197
  if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
143
198
  throw new Error('uid 必须是正整数(用 email_list 获取)');
144
199
  }
145
- return await new MailClient(cfg).read(args.uid, args.folder?.trim() || cfg.inboxFolder);
200
+ return await getPool().read(args.account, args.uid, args.folder?.trim() || '');
146
201
  },
147
202
  });
148
203
  ctx.tools.register({
@@ -150,8 +205,9 @@ export function apply(ctx, config = {}) {
150
205
  description: 'Search emails by a keyword matched against sender, recipient and subject (server-side IMAP SEARCH). Body search is not supported by every server and is not attempted; returns the same compact rows as email_list.',
151
206
  parameters: {
152
207
  query: { type: 'string', required: true, description: 'Keyword to search for' },
153
- folder: { type: 'string', description: 'IMAP folder to search in; defaults to the configured inboxFolder' },
208
+ folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
154
209
  limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
210
+ account: { type: 'string', description: ACCOUNT_HINT },
155
211
  },
156
212
  output: {
157
213
  schema: listSchema,
@@ -159,21 +215,22 @@ export function apply(ctx, config = {}) {
159
215
  },
160
216
  async execute(rawArgs) {
161
217
  const args = rawArgs;
162
- const cfg = resolve();
163
218
  if (typeof args.query !== 'string' || args.query.trim() === '')
164
219
  throw new Error('query 不能为空');
165
220
  const limit = clampInt(args.limit, 10, 1, MAX_LIMIT);
166
- return await new MailClient(cfg).search(args.query.trim(), args.folder?.trim() || cfg.inboxFolder, limit);
221
+ return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit);
167
222
  },
168
223
  });
169
224
  ctx.tools.register({
170
225
  name: 'email_send',
171
- description: 'Send an email from the configured account. Sending first asks the user for approval (recipient and subject are shown) unless sendApproval is disabled; never invent recipients or content without the user\'s instruction.',
226
+ description: 'Send an email from a configured account, optionally with file attachments (absolute paths, or relative to the dsh process cwd). Sending first asks the user for approval (recipient, subject and attachment count are shown) unless sendApproval is disabled; never invent recipients or content without the user\'s instruction.',
172
227
  parameters: {
173
228
  to: { type: 'string', required: true, description: 'Recipient(s), comma-separated' },
174
229
  subject: { type: 'string', required: true, description: 'Email subject' },
175
230
  text: { type: 'string', description: 'Plain-text body' },
176
231
  cc: { type: 'string', description: 'CC recipient(s), comma-separated' },
232
+ attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the dsh process cwd)' },
233
+ account: { type: 'string', description: ACCOUNT_HINT },
177
234
  },
178
235
  output: {
179
236
  schema: sendSchema,
@@ -181,36 +238,68 @@ export function apply(ctx, config = {}) {
181
238
  },
182
239
  async execute(rawArgs) {
183
240
  const args = rawArgs;
184
- const cfg = resolve();
185
241
  if (typeof args.to !== 'string' || args.to.trim() === '')
186
242
  throw new Error('to 不能为空');
187
243
  if (typeof args.subject !== 'string' || args.subject.trim() === '')
188
244
  throw new Error('subject 不能为空');
189
- return await new MailClient(cfg).send(args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined);
245
+ return await getPool().send(args.account, args.to.trim(), args.subject.trim(), typeof args.text === 'string' ? args.text : undefined, typeof args.cc === 'string' && args.cc.trim() !== '' ? args.cc.trim() : undefined, Array.isArray(args.attachments) ? args.attachments : undefined);
246
+ },
247
+ });
248
+ ctx.tools.register({
249
+ name: 'email_folders',
250
+ description: 'List the mailbox folders of an account (INBOX, Sent, Trash, custom folders, ...). Use the returned path values as the folder argument of the other email tools.',
251
+ parameters: {
252
+ subscribedOnly: { type: 'boolean', description: 'Only subscribed folders, default false' },
253
+ account: { type: 'string', description: ACCOUNT_HINT },
254
+ },
255
+ output: {
256
+ schema: foldersSchema,
257
+ render: (_args, value) => renderFolders(value),
258
+ },
259
+ async execute(rawArgs) {
260
+ const args = rawArgs;
261
+ return await getPool().folders(args.account, args.subscribedOnly === true);
262
+ },
263
+ });
264
+ ctx.tools.register({
265
+ name: 'email_attachment',
266
+ description: 'Download one attachment of a message to a local file (size capped by maxAttachmentBytes). The index matches the attachments array of email_read. Returns the absolute path of the written file.',
267
+ parameters: {
268
+ uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
269
+ index: { type: 'integer', description: '0-based attachment index, as listed by email_read; default 0' },
270
+ folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
271
+ account: { type: 'string', description: ACCOUNT_HINT },
272
+ },
273
+ output: {
274
+ schema: attachmentSchema,
275
+ render: (_args, value) => renderAttachment(value),
276
+ },
277
+ async execute(rawArgs) {
278
+ const args = rawArgs;
279
+ if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
280
+ throw new Error('uid 必须是正整数(用 email_list 获取)');
281
+ }
282
+ const index = clampInt(args.index, 0, 0, 999);
283
+ return await getPool().downloadAttachment(args.account, args.folder?.trim() || '', args.uid, index);
190
284
  },
191
285
  });
192
286
  // Approval gate: the user must confirm every send (recipient + subject).
193
- // Runs before other listeners; degrades to "the tool fails at send time"
194
- // only when the account itself is not configured yet.
287
+ // Runs before other listeners; degrades to the tool-time failure only
288
+ // when the account itself is not configured yet.
195
289
  ctx.on('tools/pre-execute', async (exec, next) => {
196
290
  if (exec?.name !== 'email_send')
197
291
  return next();
198
292
  if (config.sendApproval === false)
199
293
  return next();
200
- try {
201
- resolve();
202
- }
203
- catch {
294
+ if (settingsError !== '')
204
295
  return next();
205
- }
206
296
  const args = (exec.args ?? {});
207
- return { kind: 'ask', reason: `发送邮件给 ${args.to},主题「${args.subject}」` };
297
+ const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
298
+ const reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
299
+ return { kind: 'ask', reason };
208
300
  }, { prepend: true });
209
301
  }
210
- function resolveSafeFolder(config) {
211
- return config.inboxFolder?.trim() || 'INBOX';
212
- }
213
302
  export { PROVIDER_NAMES, EMAIL_PASSWORD_ENV } from './config.js';
214
- export { resolveEmailConfig, clampInt } from './config.js';
215
- export { stripHtml, truncateText, flattenAddresses, parseRawMessage } from './parse.js';
216
- export { MailClient, MailError, messageOf } from './mail-client.js';
303
+ export { resolveEmailConfig, resolveEmailSettings, clampInt, defaultDownloadDir } from './config.js';
304
+ export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
305
+ export { EmailPool, MailError, messageOf, validateAttachmentPaths } from './mail-client.js';
@@ -1,20 +1,43 @@
1
- import type { ResolvedEmailConfig } from './config.js';
2
- import type { EmailListResult, EmailReadResult, EmailSearchResult, EmailSendResult } from './types.js';
1
+ import { ImapFlow } from 'imapflow';
2
+ import type { ResolvedEmailConfig, ResolvedEmailSettings } from './config.js';
3
+ import type { EmailAttachmentResult, EmailFoldersResult, EmailListResult, EmailReadResult, EmailSearchResult, EmailSendResult } from './types.js';
3
4
  export declare class MailError extends Error {
4
5
  constructor(message: string);
5
6
  }
6
7
  export declare function messageOf(error: unknown, fallback: string): string;
7
- /** One IMAP account; every operation opens its own short-lived connection. */
8
- export declare class MailClient {
9
- private readonly config;
10
- constructor(config: ResolvedEmailConfig);
8
+ /**
9
+ * One mailbox pool for the whole plugin: pooled IMAP connections per
10
+ * account plus pooled SMTP transporters, with idle sweep and error eviction.
11
+ */
12
+ export declare class EmailPool {
13
+ private readonly settings;
14
+ private readonly imaps;
15
+ private readonly smtps;
16
+ private readonly queues;
17
+ private idleTimer;
18
+ constructor(settings: ResolvedEmailSettings);
19
+ account(name: string): ResolvedEmailConfig;
20
+ resolveName(name?: string): string;
21
+ /** Serialize operations per account: one IMAP connection serves one op at a time. */
22
+ private enqueue;
23
+ withImap<T>(accountName: string | undefined, folder: string | null, run: (client: ImapFlow) => Promise<T>): Promise<T>;
11
24
  private createImap;
12
- /** Open the mailbox read-only, run the operation, always close. */
13
- private withImap;
14
- private static fetchQuery;
15
- list(folder: string, limit: number, offset: number, unreadOnly: boolean): Promise<EmailListResult>;
16
- search(query: string, folder: string, limit: number): Promise<EmailSearchResult>;
25
+ private imapRun;
26
+ private normalizeImapError;
27
+ private evictImap;
28
+ /** Reap IMAP connections idle for longer than idleTimeoutMs. */
29
+ startIdleSweep(): void;
30
+ dispose(): void;
31
+ private transporter;
32
+ list(accountName: string | undefined, folder: string, limit: number, offset: number, unreadOnly: boolean): Promise<EmailListResult>;
33
+ search(accountName: string | undefined, query: string, folder: string, limit: number): Promise<EmailSearchResult>;
17
34
  private fetchListed;
18
- read(uid: number, folder: string): Promise<EmailReadResult>;
19
- send(to: string, subject: string, text: string | undefined, cc: string | undefined): Promise<EmailSendResult>;
35
+ read(accountName: string | undefined, uid: number, folder: string): Promise<EmailReadResult>;
36
+ folders(accountName: string | undefined, subscribedOnly: boolean): Promise<EmailFoldersResult>;
37
+ downloadAttachment(accountName: string | undefined, folder: string, uid: number, index: number): Promise<EmailAttachmentResult>;
38
+ send(accountName: string | undefined, to: string, subject: string, text: string | undefined, cc: string | undefined, attachmentPaths: string[] | undefined): Promise<EmailSendResult>;
20
39
  }
40
+ /** Stat every attachment path up front; total size must stay under the cap. */
41
+ export declare function validateAttachmentPaths(paths: string[], maxBytes: number): Promise<Array<{
42
+ path: string;
43
+ }>>;