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.
@@ -1,7 +1,8 @@
1
1
  import { ImapFlow } from 'imapflow';
2
2
  import nodemailer from 'nodemailer';
3
- import { flattenAddresses } from './parse.js';
4
- import { parseRawMessage } from './parse.js';
3
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { flattenAddresses, parseRawMessage, sanitizeFilename } from './parse.js';
5
6
  export class MailError extends Error {
6
7
  constructor(message) {
7
8
  super(message);
@@ -20,6 +21,25 @@ function structureHasAttachment(node) {
20
21
  const children = Array.isArray(node.childNodes) ? node.childNodes : [];
21
22
  return children.some(structureHasAttachment);
22
23
  }
24
+ /** Walk a bodyStructure tree collecting attachment parts (DFS, same order as mailparser). */
25
+ function collectAttachmentParts(node, out = []) {
26
+ if (node === null || node === undefined || typeof node !== 'object')
27
+ return out;
28
+ const isEmbedded = typeof node.type === 'string' && node.type.startsWith('message/rfc822');
29
+ if (node.part !== undefined && (node.disposition === 'attachment' || (isEmbedded && node.disposition !== 'inline'))) {
30
+ const filename = node.dispositionParameters?.filename ?? node.parameters?.name ?? 'part-' + node.part;
31
+ out.push({
32
+ part: String(node.part),
33
+ filename: String(filename),
34
+ contentType: typeof node.type === 'string' ? node.type : 'application/octet-stream',
35
+ size: typeof node.size === 'number' ? node.size : 0,
36
+ });
37
+ }
38
+ const children = Array.isArray(node.childNodes) ? node.childNodes : [];
39
+ for (const child of children)
40
+ collectAttachmentParts(child, out);
41
+ return out;
42
+ }
23
43
  function toIso(date) {
24
44
  return date instanceof Date ? date.toISOString() : '';
25
45
  }
@@ -35,52 +55,153 @@ function listedFrom(envelope, size, hasAttachments) {
35
55
  hasAttachments,
36
56
  };
37
57
  }
38
- /** One IMAP account; every operation opens its own short-lived connection. */
39
- export class MailClient {
40
- config;
41
- constructor(config) {
42
- this.config = config;
58
+ /**
59
+ * One mailbox pool for the whole plugin: pooled IMAP connections per
60
+ * account plus pooled SMTP transporters, with idle sweep and error eviction.
61
+ */
62
+ export class EmailPool {
63
+ settings;
64
+ imaps = new Map();
65
+ smtps = new Map();
66
+ queues = new Map();
67
+ idleTimer;
68
+ constructor(settings) {
69
+ this.settings = settings;
70
+ }
71
+ account(name) {
72
+ const cfg = this.settings.accounts.get(name);
73
+ if (cfg === undefined) {
74
+ throw new MailError('未知账号 "' + name + '",可用:' + [...this.settings.accounts.keys()].join('、'));
75
+ }
76
+ return cfg;
77
+ }
78
+ resolveName(name) {
79
+ return name?.trim() || this.settings.defaultAccount;
80
+ }
81
+ /** Serialize operations per account: one IMAP connection serves one op at a time. */
82
+ enqueue(name, task) {
83
+ const prev = this.queues.get(name) ?? Promise.resolve();
84
+ const next = prev.then(task, task);
85
+ this.queues.set(name, next.then(() => undefined, () => undefined));
86
+ return next;
43
87
  }
44
- createImap() {
88
+ async withImap(accountName, folder, run) {
89
+ const name = this.resolveName(accountName);
90
+ const cfg = this.account(name);
91
+ return this.enqueue(name, () => this.imapRun(name, cfg, folder, run));
92
+ }
93
+ createImap(cfg) {
45
94
  return new ImapFlow({
46
- host: this.config.imap.host,
47
- port: this.config.imap.port,
48
- secure: this.config.imap.secure,
49
- auth: { user: this.config.user, pass: this.config.password },
95
+ host: cfg.imap.host,
96
+ port: cfg.imap.port,
97
+ secure: cfg.imap.secure,
98
+ auth: { user: cfg.user, pass: cfg.password },
50
99
  logger: false,
51
- connectionTimeout: this.config.imap.connectionTimeoutMs ?? 30000,
100
+ connectionTimeout: cfg.imap.connectionTimeoutMs ?? 30000,
52
101
  greetingTimeout: 30000,
53
- socketTimeout: this.config.imap.socketTimeoutMs ?? 60000,
102
+ socketTimeout: cfg.imap.socketTimeoutMs ?? 60000,
54
103
  });
55
104
  }
56
- /** Open the mailbox read-only, run the operation, always close. */
57
- async withImap(folder, run) {
58
- const client = this.createImap();
105
+ async imapRun(name, cfg, folder, run) {
106
+ let entry = this.imaps.get(name);
59
107
  try {
60
- await client.connect();
61
- await client.mailboxOpen(folder, { readOnly: true });
62
- return await run(client);
63
- }
64
- catch (error) {
65
- const raw = messageOf(error, 'IMAP 操作失败');
66
- if (raw.toLowerCase().includes('authentication') || raw.toLowerCase().includes('login')) {
67
- throw new MailError(`邮箱登录失败:${raw}(请检查 user 与授权码)`);
108
+ if (entry === undefined || !entry.client.usable) {
109
+ if (entry !== undefined)
110
+ await this.evictImap(name);
111
+ const client = this.createImap(cfg);
112
+ await client.connect();
113
+ entry = { client, selected: null, lastUsed: Date.now(), inUse: 0 };
114
+ this.imaps.set(name, entry);
68
115
  }
69
- if (raw.toLowerCase().includes('nonselect') || raw.toLowerCase().includes('does not exist')) {
70
- throw new MailError(`找不到邮箱文件夹 "${folder}":${raw}`);
116
+ entry.lastUsed = Date.now();
117
+ entry.inUse += 1;
118
+ if (folder !== null && entry.selected !== folder) {
119
+ await entry.client.mailboxOpen(folder, { readOnly: true });
120
+ entry.selected = folder;
71
121
  }
72
- throw new MailError(raw);
122
+ const result = await run(entry.client);
123
+ entry.lastUsed = Date.now();
124
+ return result;
125
+ }
126
+ catch (error) {
127
+ await this.evictImap(name);
128
+ throw this.normalizeImapError(error, folder);
73
129
  }
74
130
  finally {
75
- try {
76
- await client.logout();
131
+ if (entry !== undefined)
132
+ entry.inUse = Math.max(0, entry.inUse - 1);
133
+ }
134
+ }
135
+ normalizeImapError(error, folder) {
136
+ const raw = messageOf(error, 'IMAP 操作失败');
137
+ const lower = raw.toLowerCase();
138
+ if (lower.includes('authentication') || lower.includes('login')) {
139
+ return new MailError('邮箱登录失败:' + raw + '(请检查 user 与授权码)');
140
+ }
141
+ if (lower.includes('nonselect') || lower.includes('does not exist') || lower.includes('nonexistent')) {
142
+ return new MailError('找不到邮箱文件夹 "' + (folder ?? '') + '":' + raw);
143
+ }
144
+ return new MailError(raw);
145
+ }
146
+ async evictImap(name) {
147
+ const entry = this.imaps.get(name);
148
+ if (entry === undefined)
149
+ return;
150
+ this.imaps.delete(name);
151
+ try {
152
+ await entry.client.logout();
153
+ }
154
+ catch { /* already closed */ }
155
+ }
156
+ /** Reap IMAP connections idle for longer than idleTimeoutMs. */
157
+ startIdleSweep() {
158
+ if (this.idleTimer !== undefined)
159
+ return;
160
+ const intervalMs = Math.max(5000, Math.min(this.settings.idleTimeoutMs / 2, 30000));
161
+ this.idleTimer = setInterval(() => {
162
+ const now = Date.now();
163
+ for (const [name, entry] of this.imaps) {
164
+ if (entry.inUse === 0 && now - entry.lastUsed > this.settings.idleTimeoutMs) {
165
+ void this.evictImap(name);
166
+ }
77
167
  }
78
- catch { /* already closed */ }
168
+ }, intervalMs);
169
+ this.idleTimer.unref();
170
+ }
171
+ dispose() {
172
+ if (this.idleTimer !== undefined)
173
+ clearInterval(this.idleTimer);
174
+ this.idleTimer = undefined;
175
+ for (const name of [...this.imaps.keys()])
176
+ void this.evictImap(name);
177
+ for (const transporter of this.smtps.values())
178
+ transporter.close();
179
+ this.smtps.clear();
180
+ }
181
+ transporter(name, cfg) {
182
+ let t = this.smtps.get(name);
183
+ if (t === undefined) {
184
+ t = nodemailer.createTransport({
185
+ pool: true,
186
+ host: cfg.smtp.host,
187
+ port: cfg.smtp.port,
188
+ secure: cfg.smtp.secure,
189
+ auth: { user: cfg.user, pass: cfg.password },
190
+ connectionTimeout: 30000,
191
+ greetingTimeout: 10000,
192
+ socketTimeout: 60000,
193
+ maxConnections: 2,
194
+ maxMessages: 50,
195
+ });
196
+ this.smtps.set(name, t);
79
197
  }
198
+ return t;
80
199
  }
81
- static fetchQuery = { uid: true, envelope: true, flags: true, size: true, bodyStructure: true };
82
- async list(folder, limit, offset, unreadOnly) {
83
- return this.withImap(folder, async (client) => {
200
+ async list(accountName, folder, limit, offset, unreadOnly) {
201
+ const name = this.resolveName(accountName);
202
+ const cfg = this.account(name);
203
+ const folderName = folder || cfg.inboxFolder;
204
+ return this.withImap(name, folderName, async (client) => {
84
205
  const mailbox = client.mailbox;
85
206
  const total = mailbox === false ? 0 : mailbox.exists;
86
207
  let scopeCount = total;
@@ -92,18 +213,20 @@ export class MailClient {
92
213
  }
93
214
  else if (total > 0) {
94
215
  const start = Math.max(1, total - (limit + offset) + 1);
95
- const fetched = await client.fetchAll(`${start}:*`, { uid: true }, { uid: true });
216
+ const fetched = await client.fetchAll(start + ':*', { uid: true }, { uid: true });
96
217
  uids = fetched.map(message => message.uid);
97
218
  }
98
- // newest first, then the requested window
99
219
  uids.reverse();
100
220
  const window = uids.slice(offset, offset + limit);
101
221
  const messages = await this.fetchListed(client, window);
102
- return { count: scopeCount, folder, messages };
222
+ return { account: name, count: scopeCount, folder: folderName, messages };
103
223
  });
104
224
  }
105
- async search(query, folder, limit) {
106
- return this.withImap(folder, async (client) => {
225
+ async search(accountName, query, folder, limit) {
226
+ const name = this.resolveName(accountName);
227
+ const cfg = this.account(name);
228
+ const folderName = folder || cfg.inboxFolder;
229
+ return this.withImap(name, folderName, async (client) => {
107
230
  // No nested OR and no TEXT search: several servers (QQ among them)
108
231
  // silently answer those with empty or match-everything results. Three
109
232
  // independent searches unioned client-side behave well everywhere.
@@ -115,7 +238,7 @@ export class MailClient {
115
238
  const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => a - b);
116
239
  uids.reverse();
117
240
  const messages = await this.fetchListed(client, uids.slice(0, limit));
118
- return { query, count: uids.length, folder, messages };
241
+ return { account: name, query, count: uids.length, folder: folderName, messages };
119
242
  });
120
243
  }
121
244
  async fetchListed(client, uids) {
@@ -124,47 +247,138 @@ export class MailClient {
124
247
  const fetched = await client.fetchAll(uids, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
125
248
  return fetched.map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)));
126
249
  }
127
- async read(uid, folder) {
128
- return this.withImap(folder, async (client) => {
250
+ async read(accountName, uid, folder) {
251
+ const name = this.resolveName(accountName);
252
+ const cfg = this.account(name);
253
+ const folderName = folder || cfg.inboxFolder;
254
+ return this.withImap(name, folderName, async (client) => {
129
255
  const message = await client.fetchOne(uid, { uid: true, source: true }, { uid: true });
130
256
  if (message === false || message.source === undefined) {
131
- throw new MailError(`找不到 uid=${uid} 的邮件(可能已被删除,或不在文件夹 "${folder}";可用 email_list 重新获取 uid)`);
257
+ throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '";可用 email_list 重新获取 uid)');
258
+ }
259
+ const body = await parseRawMessage(message.source, this.settings.maxBodyChars);
260
+ return { account: name, uid, folder: folderName, ...body };
261
+ });
262
+ }
263
+ async folders(accountName, subscribedOnly) {
264
+ const name = this.resolveName(accountName);
265
+ return this.withImap(name, null, async (client) => {
266
+ const list = await client.list();
267
+ const folders = list
268
+ .filter(row => !subscribedOnly || row.subscribed !== false)
269
+ .map(row => ({
270
+ name: row.name ?? row.path,
271
+ path: row.path,
272
+ specialUse: row.specialUse ?? '',
273
+ subscribed: row.subscribed !== false,
274
+ }));
275
+ return { account: name, folders };
276
+ });
277
+ }
278
+ async downloadAttachment(accountName, folder, uid, index) {
279
+ const name = this.resolveName(accountName);
280
+ const cfg = this.account(name);
281
+ const folderName = folder || cfg.inboxFolder;
282
+ return this.withImap(name, folderName, async (client) => {
283
+ const message = await client.fetchOne(uid, { uid: true, bodyStructure: true }, { uid: true });
284
+ if (message === false) {
285
+ throw new MailError('找不到 uid=' + uid + ' 的邮件(可能已被删除,或不在文件夹 "' + folderName + '")');
286
+ }
287
+ const attachments = collectAttachmentParts(message.bodyStructure);
288
+ if (attachments.length === 0)
289
+ throw new MailError('该邮件没有附件');
290
+ const att = attachments[index];
291
+ if (att === undefined) {
292
+ throw new MailError('附件序号 ' + index + ' 越界:共 ' + attachments.length + ' 个附件(序号从 0 开始,与 email_read 返回的 attachments 顺序一致)');
293
+ }
294
+ if (att.size > this.settings.maxAttachmentBytes) {
295
+ throw new MailError('附件 "' + att.filename + '" 大小 ' + att.size + ' 字节,超过上限 maxAttachmentBytes=' + this.settings.maxAttachmentBytes);
132
296
  }
133
- const body = await parseRawMessage(message.source, this.config.maxBodyChars);
134
- return { uid, folder, ...body };
297
+ const dl = await client.download(uid, att.part, { uid: true, maxBytes: this.settings.maxAttachmentBytes });
298
+ const buf = await collectStream(dl.content, this.settings.maxAttachmentBytes);
299
+ const safeName = sanitizeFilename(dl.meta.filename ?? att.filename);
300
+ const dir = this.settings.downloadDir;
301
+ await mkdir(dir, { recursive: true });
302
+ const dest = await uniquePath(join(dir, safeName));
303
+ await writeFile(dest, buf);
304
+ return { account: name, uid, filename: safeName, contentType: att.contentType, size: buf.length, path: dest };
135
305
  });
136
306
  }
137
- async send(to, subject, text, cc) {
138
- const transporter = nodemailer.createTransport({
139
- host: this.config.smtp.host,
140
- port: this.config.smtp.port,
141
- secure: this.config.smtp.secure,
142
- auth: { user: this.config.user, pass: this.config.password },
143
- connectionTimeout: 30000,
144
- greetingTimeout: 10000,
145
- socketTimeout: 60000,
307
+ async send(accountName, to, subject, text, cc, attachmentPaths) {
308
+ const name = this.resolveName(accountName);
309
+ const cfg = this.account(name);
310
+ const attachments = await validateAttachmentPaths(attachmentPaths ?? [], this.settings.maxAttachmentBytes);
311
+ const info = await this.transporter(name, cfg).sendMail({
312
+ from: cfg.user,
313
+ to,
314
+ cc,
315
+ subject,
316
+ text: text ?? '',
317
+ attachments,
146
318
  });
319
+ return {
320
+ account: name,
321
+ messageId: info.messageId,
322
+ accepted: info.accepted.map(String),
323
+ rejected: info.rejected.map(String),
324
+ response: info.response,
325
+ };
326
+ }
327
+ }
328
+ /** Stat every attachment path up front; total size must stay under the cap. */
329
+ export async function validateAttachmentPaths(paths, maxBytes) {
330
+ const out = [];
331
+ let total = 0;
332
+ for (const path of paths) {
333
+ let info;
147
334
  try {
148
- const info = await transporter.sendMail({
149
- from: this.config.user,
150
- to,
151
- cc,
152
- subject,
153
- text: text ?? '',
154
- });
155
- return {
156
- messageId: info.messageId,
157
- accepted: info.accepted.map(String),
158
- rejected: info.rejected.map(String),
159
- response: info.response,
160
- };
335
+ info = await stat(path);
161
336
  }
162
- catch (error) {
163
- const raw = messageOf(error, 'SMTP 发送失败');
164
- throw new MailError(raw.toLowerCase().includes('auth') ? `SMTP 登录失败:${raw}(请检查 user 与授权码)` : raw);
337
+ catch {
338
+ throw new MailError('附件路径不存在或不可读:' + path);
165
339
  }
166
- finally {
167
- transporter.close();
340
+ if (!info.isFile())
341
+ throw new MailError('附件路径不是文件:' + path);
342
+ total += info.size;
343
+ if (total > maxBytes) {
344
+ throw new MailError('附件总大小超过上限 maxAttachmentBytes=' + maxBytes + ' 字节');
345
+ }
346
+ out.push({ path });
347
+ }
348
+ return out;
349
+ }
350
+ /** Drain a download stream into a Buffer with a hard byte cap. */
351
+ async function collectStream(stream, maxBytes) {
352
+ const chunks = [];
353
+ let total = 0;
354
+ for await (const chunk of stream) {
355
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
356
+ total += buf.length;
357
+ if (total > maxBytes)
358
+ throw new MailError('附件超过上限 maxAttachmentBytes=' + maxBytes + ' 字节,下载中止');
359
+ chunks.push(buf);
360
+ }
361
+ return Buffer.concat(chunks);
362
+ }
363
+ /** Avoid overwriting: append -1, -2, ... before the extension. */
364
+ async function uniquePath(path) {
365
+ try {
366
+ await stat(path);
367
+ }
368
+ catch {
369
+ return path;
370
+ }
371
+ const dot = path.lastIndexOf('.');
372
+ const base = dot > 0 ? path.slice(0, dot) : path;
373
+ const ext = dot > 0 ? path.slice(dot) : '';
374
+ for (let i = 1; i < 1000; i++) {
375
+ const candidate = base + '-' + i + ext;
376
+ try {
377
+ await stat(candidate);
378
+ }
379
+ catch {
380
+ return candidate;
168
381
  }
169
382
  }
383
+ return base + '-' + Date.now() + ext;
170
384
  }
package/lib/parse.d.ts CHANGED
@@ -6,5 +6,10 @@ export declare function truncateText(text: string, maxChars: number): {
6
6
  text: string;
7
7
  truncated: boolean;
8
8
  };
9
+ /**
10
+ * Turn an untrusted attachment filename into a safe basename: no directory
11
+ * separators, no traversal, no control characters, bounded length.
12
+ */
13
+ export declare function sanitizeFilename(raw: unknown, fallback?: string): string;
9
14
  /** Parse a raw RFC822 message source into the read-result body. */
10
15
  export declare function parseRawMessage(source: Buffer, maxBodyChars: number): Promise<ReadMessageBody>;
package/lib/parse.js CHANGED
@@ -39,7 +39,24 @@ export function truncateText(text, maxChars) {
39
39
  return { text, truncated: false };
40
40
  const cut = text.slice(0, maxChars);
41
41
  const lastBreak = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' '), 0);
42
- return { text: cut.slice(0, lastBreak) + `\n\n…[正文过长,已截断,共 ${text.length} 字符]`, truncated: true };
42
+ return { text: cut.slice(0, lastBreak) + '\n\n…[正文过长,已截断,共 ' + text.length + ' 字符]', truncated: true };
43
+ }
44
+ /**
45
+ * Turn an untrusted attachment filename into a safe basename: no directory
46
+ * separators, no traversal, no control characters, bounded length.
47
+ */
48
+ export function sanitizeFilename(raw, fallback = 'attachment.bin') {
49
+ let name = String(raw ?? '').replace(/\\/g, '/').split('/').pop() ?? '';
50
+ // eslint-disable-next-line no-control-regex
51
+ name = name.replace(/[\u0000-\u001f\u007f]/g, '').trim();
52
+ if (name === '' || name === '.' || name === '..')
53
+ name = fallback;
54
+ if (name.length > 120) {
55
+ const dot = name.lastIndexOf('.');
56
+ const ext = dot > 0 && dot >= name.length - 12 ? name.slice(dot) : '';
57
+ name = name.slice(0, 120 - ext.length) + ext;
58
+ }
59
+ return name;
43
60
  }
44
61
  /** Parse a raw RFC822 message source into the read-result body. */
45
62
  export async function parseRawMessage(source, maxBodyChars) {
@@ -49,6 +66,12 @@ export async function parseRawMessage(source, maxBodyChars) {
49
66
  text = stripHtml(parsed.html);
50
67
  }
51
68
  const limited = truncateText(text, maxBodyChars);
69
+ const attachments = (parsed.attachments ?? []).map((att, index) => ({
70
+ filename: att.filename ?? '(unnamed)',
71
+ contentType: att.contentType,
72
+ size: att.size,
73
+ part: 'attachment-' + index,
74
+ }));
52
75
  return {
53
76
  date: parsed.date instanceof Date ? parsed.date.toISOString() : '',
54
77
  from: flattenAddresses(parsed.from),
@@ -56,11 +79,7 @@ export async function parseRawMessage(source, maxBodyChars) {
56
79
  cc: flattenAddresses(parsed.cc),
57
80
  subject: parsed.subject ?? '',
58
81
  text: limited.text,
59
- attachments: (parsed.attachments ?? []).map(att => ({
60
- filename: att.filename ?? '(unnamed)',
61
- contentType: att.contentType,
62
- size: att.size,
63
- })),
82
+ attachments,
64
83
  truncated: limited.truncated,
65
84
  };
66
85
  }
package/lib/types.d.ts CHANGED
@@ -15,6 +15,14 @@ export interface ListedMessage {
15
15
  size: number;
16
16
  hasAttachments: boolean;
17
17
  }
18
+ /** One attachment of a read message (metadata only). */
19
+ export interface EmailAttachmentMeta {
20
+ filename: string;
21
+ contentType: string;
22
+ size: number;
23
+ /** IMAP body part identifier used by email_attachment. */
24
+ part: string;
25
+ }
18
26
  /** One fully read message body. */
19
27
  export interface ReadMessageBody {
20
28
  date: string;
@@ -24,52 +32,86 @@ export interface ReadMessageBody {
24
32
  subject: string;
25
33
  /** Plain-text body; HTML mail is converted. Truncated at maxBodyChars. */
26
34
  text: string;
27
- attachments: Array<{
28
- filename: string;
29
- contentType: string;
30
- size: number;
31
- }>;
35
+ attachments: EmailAttachmentMeta[];
32
36
  truncated: boolean;
33
37
  }
34
38
  export interface EmailListResult {
39
+ account: string;
35
40
  count: number;
36
41
  folder: string;
37
42
  messages: ListedMessage[];
38
43
  }
39
44
  export interface EmailReadResult extends ReadMessageBody {
45
+ account: string;
40
46
  uid: number;
41
47
  folder: string;
42
48
  }
43
49
  export interface EmailSearchResult {
50
+ account: string;
44
51
  query: string;
45
52
  count: number;
46
53
  folder: string;
47
54
  messages: ListedMessage[];
48
55
  }
49
56
  export interface EmailSendResult {
57
+ account: string;
50
58
  messageId: string;
51
59
  accepted: string[];
52
60
  rejected: string[];
53
61
  response: string;
54
62
  }
55
- export interface EmailListArgs {
63
+ export interface EmailFolderRow {
64
+ name: string;
65
+ path: string;
66
+ specialUse: string;
67
+ subscribed: boolean;
68
+ }
69
+ export interface EmailFoldersResult {
70
+ account: string;
71
+ folders: EmailFolderRow[];
72
+ }
73
+ export interface EmailAttachmentResult {
74
+ account: string;
75
+ uid: number;
76
+ filename: string;
77
+ contentType: string;
78
+ size: number;
79
+ /** Absolute path the attachment was written to. */
80
+ path: string;
81
+ }
82
+ /** Every tool accepts an optional account selector. */
83
+ export interface AccountArg {
84
+ account?: string;
85
+ }
86
+ export interface EmailListArgs extends AccountArg {
56
87
  folder?: string;
57
88
  limit?: number;
58
89
  offset?: number;
59
90
  unreadOnly?: boolean;
60
91
  }
61
- export interface EmailReadArgs {
92
+ export interface EmailReadArgs extends AccountArg {
62
93
  uid: number;
63
94
  folder?: string;
64
95
  }
65
- export interface EmailSearchArgs {
96
+ export interface EmailSearchArgs extends AccountArg {
66
97
  query: string;
67
98
  folder?: string;
68
99
  limit?: number;
69
100
  }
70
- export interface EmailSendArgs {
101
+ export interface EmailSendArgs extends AccountArg {
71
102
  to: string;
72
103
  subject: string;
73
104
  text?: string;
74
105
  cc?: string;
106
+ /** Absolute paths (or paths relative to the dsh process cwd) to attach. */
107
+ attachments?: string[];
108
+ }
109
+ export interface EmailFoldersArgs extends AccountArg {
110
+ subscribedOnly?: boolean;
111
+ }
112
+ export interface EmailAttachmentArgs extends AccountArg {
113
+ uid: number;
114
+ /** 0-based index into the attachments of email_read. Default 0. */
115
+ index?: number;
116
+ folder?: string;
75
117
  }