dsh-email 0.1.0 → 0.3.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,70 @@
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
+ import { EmailSettingsSchema, SETTINGS_NAMESPACE, toEmailConfig, toSettingsBase, validateSettingsValue } from './settings.js';
4
+ import { EmailSettingsBackend, installEmailSettingsWeb } from './web.js';
3
5
  export const name = 'tool-email';
4
- export const inject = ['tools'];
6
+ export const inject = ['settings', 'tools'];
5
7
  const MAX_LIMIT = 100;
6
- /** Loose-but-typed output schema; values below carry every declared field. */
8
+ const ACCOUNT_HINT = '账号名(配置了 accounts 多个账号时选择),省略时用 defaultAccount。可用账号见 email_folders 的报错或插件 README';
9
+ /**
10
+ * Compile the author DSL map into a raw JSON Schema object, exactly what
11
+ * defineTool stores as definition.parameters. The native wire request sends
12
+ * this value verbatim, so a raw DSL here would be rejected by the model API
13
+ * ("schema must be a JSON Schema of 'type: object'").
14
+ */
15
+ function compileParameters(spec) {
16
+ const properties = {};
17
+ const required = [];
18
+ for (const [key, prop] of Object.entries(spec)) {
19
+ if (prop?.required === true)
20
+ required.push(key);
21
+ const node = {};
22
+ if (typeof prop?.type === 'string')
23
+ node.type = prop.type;
24
+ if (typeof prop?.description === 'string')
25
+ node.description = prop.description;
26
+ if (prop?.type === 'array' && prop.items !== null && typeof prop.items === 'object') {
27
+ const items = { type: 'string' };
28
+ if (prop.items.type === 'object')
29
+ items.additionalProperties = true;
30
+ node.items = items;
31
+ }
32
+ properties[key] = node;
33
+ }
34
+ return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
35
+ }
36
+ const strArray = { type: 'array', items: { type: 'string' } };
37
+ const addrArray = { type: 'array', items: { type: 'object', additionalProperties: true } };
38
+ const messageShape = {
39
+ uid: { type: 'integer' },
40
+ date: { type: 'string' },
41
+ from: addrArray,
42
+ subject: { type: 'string' },
43
+ seen: { type: 'boolean' },
44
+ flagged: { type: 'boolean' },
45
+ size: { type: 'integer' },
46
+ hasAttachments: { type: 'boolean' },
47
+ };
7
48
  const listSchema = {
8
49
  type: 'object',
9
50
  properties: {
51
+ account: { type: 'string' },
10
52
  count: { type: 'integer' },
11
53
  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
- },
54
+ messages: { type: 'array', items: { type: 'object', properties: messageShape, additionalProperties: true } },
29
55
  },
30
56
  additionalProperties: true,
31
57
  };
32
58
  const readSchema = {
33
59
  type: 'object',
34
60
  properties: {
61
+ account: { type: 'string' },
35
62
  uid: { type: 'integer' },
36
63
  folder: { type: 'string' },
37
64
  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 } },
65
+ from: addrArray,
66
+ to: addrArray,
67
+ cc: addrArray,
41
68
  subject: { type: 'string' },
42
69
  text: { type: 'string' },
43
70
  attachments: { type: 'array', items: { type: 'object', additionalProperties: true } },
@@ -48,13 +75,46 @@ const readSchema = {
48
75
  const sendSchema = {
49
76
  type: 'object',
50
77
  properties: {
78
+ account: { type: 'string' },
51
79
  messageId: { type: 'string' },
52
- accepted: { type: 'array', items: { type: 'string' } },
53
- rejected: { type: 'array', items: { type: 'string' } },
80
+ accepted: strArray,
81
+ rejected: strArray,
54
82
  response: { type: 'string' },
55
83
  },
56
84
  additionalProperties: true,
57
85
  };
86
+ const foldersSchema = {
87
+ type: 'object',
88
+ properties: {
89
+ account: { type: 'string' },
90
+ folders: {
91
+ type: 'array',
92
+ items: {
93
+ type: 'object',
94
+ properties: {
95
+ name: { type: 'string' },
96
+ path: { type: 'string' },
97
+ specialUse: { type: 'string' },
98
+ subscribed: { type: 'boolean' },
99
+ },
100
+ additionalProperties: true,
101
+ },
102
+ },
103
+ },
104
+ additionalProperties: true,
105
+ };
106
+ const attachmentSchema = {
107
+ type: 'object',
108
+ properties: {
109
+ account: { type: 'string' },
110
+ uid: { type: 'integer' },
111
+ filename: { type: 'string' },
112
+ contentType: { type: 'string' },
113
+ size: { type: 'integer' },
114
+ path: { type: 'string' },
115
+ },
116
+ additionalProperties: true,
117
+ };
58
118
  function oneText(text) {
59
119
  return [{ type: 'text', text }];
60
120
  }
@@ -65,152 +125,237 @@ function describeMessage(message) {
65
125
  message.flagged ? '已标星' : '',
66
126
  message.hasAttachments ? '含附件' : '',
67
127
  ].filter(Boolean);
68
- const parts = [`uid=${message.uid}`, from, message.date];
128
+ const parts = ['uid=' + message.uid, from, message.date];
69
129
  if (flags.length > 0)
70
130
  parts.push(flags.join('、'));
71
- return `${message.subject || '(无主题)'} [${parts.join(' | ')}]`;
131
+ return (message.subject || '(无主题)') + ' [' + parts.join(' | ') + ']';
72
132
  }
73
133
  function renderList(value) {
74
134
  if (value.messages.length === 0) {
75
- return oneText(`文件夹 "${value.folder}" 共 ${value.count} 封邮件,本次没有要列出的邮件。`);
135
+ return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,本次没有要列出的邮件。');
76
136
  }
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 阅读全文。`);
137
+ const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
138
+ return oneText('账号 ' + value.account + ',文件夹 "' + value.folder + '" 共 ' + value.count + ' 封邮件,最新 ' + value.messages.length + ' 封:\n\n' + lines.join('\n') + '\n\n用 email_read 配合 uid 阅读全文。');
79
139
  }
80
140
  function renderRead(value) {
81
141
  const from = value.from.map(a => a.name ?? a.address).filter(Boolean).join(', ') || '(未知)';
82
142
  const attach = value.attachments.length > 0
83
- ? `\n附件:${value.attachments.map(a => `${a.filename}(${a.contentType},${a.size} 字节)`).join(';')}`
143
+ ? '\n附件:' + value.attachments.map((a, i) => '#' + i + ' ' + a.filename + '(' + a.contentType + ',' + a.size + ' 字节)').join(';') + '\n(用 email_attachment 配合 uid 与序号下载)'
84
144
  : '';
85
- return oneText(`主题:${value.subject || '(无主题)'}\n来自:${from}\n时间:${value.date || '(未知)'}${attach}\n\n${value.text}`);
145
+ return oneText('账号 ' + value.account + ',主题:' + (value.subject || '(无主题)') + '\n来自:' + from + '\n时间:' + (value.date || '(未知)') + attach + '\n\n' + value.text);
86
146
  }
87
147
  function renderSearch(value) {
88
148
  if (value.messages.length === 0) {
89
- return oneText(`在文件夹 "${value.folder}" 中搜索 "${value.query}":共 ${value.count} 条匹配,本次没有列出。`);
149
+ return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,本次没有列出。');
90
150
  }
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')}`);
151
+ const lines = value.messages.map((m, i) => '#' + (i + 1) + ' ' + describeMessage(m));
152
+ return oneText('账号 ' + value.account + ',在文件夹 "' + value.folder + '" 中搜索 "' + value.query + '":共 ' + value.count + ' 条匹配,展示最新 ' + value.messages.length + ' 条:\n\n' + lines.join('\n'));
93
153
  }
94
154
  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}`);
155
+ const rejected = value.rejected.length > 0 ? ';被拒:' + value.rejected.join(', ') : '';
156
+ return oneText('账号 ' + value.account + ' 邮件已发送,messageId: ' + value.messageId + ';成功送达:' + value.accepted.join(', ') + rejected + ';服务器响应:' + value.response);
157
+ }
158
+ function renderFolders(value) {
159
+ if (value.folders.length === 0)
160
+ return oneText('账号 ' + value.account + ':未列出任何文件夹。');
161
+ const lines = value.folders.map((f, i) => '#' + (i + 1) + ' ' + f.path + (f.specialUse !== '' ? ' [' + f.specialUse + ']' : '') + (f.subscribed ? '' : '(未订阅)'));
162
+ return oneText('账号 ' + value.account + ' 的文件夹(' + value.folders.length + ' 个):\n\n' + lines.join('\n') + '\n\n把 folder 参数填成其中的 path 即可。');
163
+ }
164
+ function renderAttachment(value) {
165
+ return oneText('账号 ' + value.account + ' 已下载附件 "' + value.filename + '"(' + value.contentType + ',' + value.size + ' 字节)到:\n' + value.path + '\n可用 read 工具读取该文件。');
166
+ }
167
+ function fingerprintSettings(settings) {
168
+ return JSON.stringify({
169
+ accounts: [...settings.accounts.entries()].map(([name, account]) => [name, account]),
170
+ defaultAccount: settings.defaultAccount,
171
+ sendApproval: settings.sendApproval,
172
+ maxBodyChars: settings.maxBodyChars,
173
+ downloadDir: settings.downloadDir,
174
+ maxAttachmentBytes: settings.maxAttachmentBytes,
175
+ idleTimeoutMs: settings.idleTimeoutMs,
176
+ });
97
177
  }
98
178
  export function apply(ctx, config = {}) {
179
+ // The settings namespace is the single source for the default account: the
180
+ // row config (cordis.patch.yml) is its base layer, the Web settings page
181
+ // writes the user layer, and resolveEmailSettings merges both at use time.
182
+ const settingsScope = ctx.settings.register(SETTINGS_NAMESPACE, EmailSettingsSchema, {
183
+ base: toSettingsBase(config),
184
+ applies: 'live',
185
+ validate: (value) => validateSettingsValue(value),
186
+ });
187
+ let pool = null;
188
+ let poolFingerprint = '';
189
+ const getPool = () => {
190
+ const value = settingsScope.get();
191
+ const effective = resolveEmailSettings({ ...config, ...toEmailConfig(value) });
192
+ const fp = fingerprintSettings(effective);
193
+ if (pool === null || fp !== poolFingerprint) {
194
+ pool?.dispose();
195
+ pool = new EmailPool(effective);
196
+ pool.startIdleSweep();
197
+ poolFingerprint = fp;
198
+ }
199
+ return pool;
200
+ };
99
201
  // Load-time nudge only: never break boot, tools report the details instead.
100
202
  try {
101
- resolveEmailConfig(config);
203
+ getPool();
102
204
  }
103
205
  catch (error) {
104
- ctx.logger?.warn?.(`[dsh-email] ${messageOf(error, '未配置邮箱账号')}`);
206
+ ctx.logger?.warn?.('[dsh-email] ' + messageOf(error, '未配置邮箱账号'));
105
207
  }
106
- const resolve = () => resolveEmailConfig(config);
208
+ ctx.effect(() => () => {
209
+ pool?.dispose();
210
+ pool = null;
211
+ });
212
+ installEmailSettingsWeb(ctx, new EmailSettingsBackend(ctx, settingsScope, config));
107
213
  ctx.tools.register({
108
214
  name: 'email_list',
109
215
  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
- parameters: {
111
- folder: { type: 'string', description: `IMAP folder name, default "${resolveSafeFolder(config)}" (the configured inboxFolder)` },
216
+ parameters: compileParameters({
217
+ folder: { type: 'string', description: 'IMAP folder path (see email_folders); defaults to the account inboxFolder' },
112
218
  limit: { type: 'integer', description: 'How many messages to return, 1-100, default 20' },
113
219
  offset: { type: 'integer', description: 'Skip this many newest messages first, default 0' },
114
220
  unreadOnly: { type: 'boolean', description: 'Only list unread messages, default false' },
115
- },
221
+ account: { type: 'string', description: ACCOUNT_HINT },
222
+ }),
116
223
  output: {
117
224
  schema: listSchema,
118
225
  render: (_args, value) => renderList(value),
119
226
  },
120
227
  async execute(rawArgs) {
121
228
  const args = rawArgs;
122
- const cfg = resolve();
123
229
  const limit = clampInt(args.limit, 20, 1, MAX_LIMIT);
124
230
  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);
231
+ return await getPool().list(args.account, args.folder?.trim() || '', limit, offset, args.unreadOnly === true);
126
232
  },
127
233
  });
128
234
  ctx.tools.register({
129
235
  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.',
131
- parameters: {
236
+ 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.',
237
+ parameters: compileParameters({
132
238
  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' },
134
- },
239
+ folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
240
+ account: { type: 'string', description: ACCOUNT_HINT },
241
+ }),
135
242
  output: {
136
243
  schema: readSchema,
137
244
  render: (_args, value) => renderRead(value),
138
245
  },
139
246
  async execute(rawArgs) {
140
247
  const args = rawArgs;
141
- const cfg = resolve();
142
248
  if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
143
249
  throw new Error('uid 必须是正整数(用 email_list 获取)');
144
250
  }
145
- return await new MailClient(cfg).read(args.uid, args.folder?.trim() || cfg.inboxFolder);
251
+ return await getPool().read(args.account, args.uid, args.folder?.trim() || '');
146
252
  },
147
253
  });
148
254
  ctx.tools.register({
149
255
  name: 'email_search',
150
256
  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
- parameters: {
257
+ parameters: compileParameters({
152
258
  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' },
259
+ folder: { type: 'string', description: 'IMAP folder to search in; defaults to the account inboxFolder' },
154
260
  limit: { type: 'integer', description: 'How many matches to return, 1-100, default 10' },
155
- },
261
+ account: { type: 'string', description: ACCOUNT_HINT },
262
+ }),
156
263
  output: {
157
264
  schema: listSchema,
158
265
  render: (_args, value) => renderSearch(value),
159
266
  },
160
267
  async execute(rawArgs) {
161
268
  const args = rawArgs;
162
- const cfg = resolve();
163
269
  if (typeof args.query !== 'string' || args.query.trim() === '')
164
270
  throw new Error('query 不能为空');
165
271
  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);
272
+ return await getPool().search(args.account, args.query.trim(), args.folder?.trim() || '', limit);
167
273
  },
168
274
  });
169
275
  ctx.tools.register({
170
276
  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.',
172
- parameters: {
277
+ 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.',
278
+ parameters: compileParameters({
173
279
  to: { type: 'string', required: true, description: 'Recipient(s), comma-separated' },
174
280
  subject: { type: 'string', required: true, description: 'Email subject' },
175
281
  text: { type: 'string', description: 'Plain-text body' },
176
282
  cc: { type: 'string', description: 'CC recipient(s), comma-separated' },
177
- },
283
+ attachments: { type: 'array', items: { type: 'string' }, description: 'File paths to attach (absolute, or relative to the dsh process cwd)' },
284
+ account: { type: 'string', description: ACCOUNT_HINT },
285
+ }),
178
286
  output: {
179
287
  schema: sendSchema,
180
288
  render: (_args, value) => renderSend(value),
181
289
  },
182
290
  async execute(rawArgs) {
183
291
  const args = rawArgs;
184
- const cfg = resolve();
185
292
  if (typeof args.to !== 'string' || args.to.trim() === '')
186
293
  throw new Error('to 不能为空');
187
294
  if (typeof args.subject !== 'string' || args.subject.trim() === '')
188
295
  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);
296
+ 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);
297
+ },
298
+ });
299
+ ctx.tools.register({
300
+ name: 'email_folders',
301
+ 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.',
302
+ parameters: compileParameters({
303
+ subscribedOnly: { type: 'boolean', description: 'Only subscribed folders, default false' },
304
+ account: { type: 'string', description: ACCOUNT_HINT },
305
+ }),
306
+ output: {
307
+ schema: foldersSchema,
308
+ render: (_args, value) => renderFolders(value),
309
+ },
310
+ async execute(rawArgs) {
311
+ const args = rawArgs;
312
+ return await getPool().folders(args.account, args.subscribedOnly === true);
313
+ },
314
+ });
315
+ ctx.tools.register({
316
+ name: 'email_attachment',
317
+ 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.',
318
+ parameters: compileParameters({
319
+ uid: { type: 'integer', required: true, description: 'Message uid from email_list or email_search' },
320
+ index: { type: 'integer', description: '0-based attachment index, as listed by email_read; default 0' },
321
+ folder: { type: 'string', description: 'IMAP folder the uid belongs to; defaults to the account inboxFolder' },
322
+ account: { type: 'string', description: ACCOUNT_HINT },
323
+ }),
324
+ output: {
325
+ schema: attachmentSchema,
326
+ render: (_args, value) => renderAttachment(value),
327
+ },
328
+ async execute(rawArgs) {
329
+ const args = rawArgs;
330
+ if (typeof args.uid !== 'number' || !Number.isInteger(args.uid) || args.uid <= 0) {
331
+ throw new Error('uid 必须是正整数(用 email_list 获取)');
332
+ }
333
+ const index = clampInt(args.index, 0, 0, 999);
334
+ return await getPool().downloadAttachment(args.account, args.folder?.trim() || '', args.uid, index);
190
335
  },
191
336
  });
192
337
  // 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.
338
+ // Runs before other listeners; degrades to the tool-time failure only
339
+ // when the account itself is not configured yet.
195
340
  ctx.on('tools/pre-execute', async (exec, next) => {
196
341
  if (exec?.name !== 'email_send')
197
342
  return next();
198
- if (config.sendApproval === false)
343
+ const value = settingsScope.get();
344
+ if (value.sendApproval === false)
199
345
  return next();
200
346
  try {
201
- resolve();
347
+ resolveEmailSettings({ ...config, ...toEmailConfig(value) });
202
348
  }
203
349
  catch {
204
- return next();
350
+ return next(); // unconfigured: let the tool report the actionable hint
205
351
  }
206
352
  const args = (exec.args ?? {});
207
- return { kind: 'ask', reason: `发送邮件给 ${args.to},主题「${args.subject}」` };
353
+ const attachCount = Array.isArray(args.attachments) ? args.attachments.length : 0;
354
+ const reason = '发送邮件给 ' + args.to + ',主题「' + args.subject + '」' + (attachCount > 0 ? ',附件 ' + attachCount + ' 个' : '');
355
+ return { kind: 'ask', reason };
208
356
  }, { prepend: true });
209
357
  }
210
- function resolveSafeFolder(config) {
211
- return config.inboxFolder?.trim() || 'INBOX';
212
- }
213
358
  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';
359
+ export { resolveEmailConfig, resolveEmailSettings, clampInt, defaultDownloadDir } from './config.js';
360
+ export { stripHtml, truncateText, flattenAddresses, sanitizeFilename, parseRawMessage } from './parse.js';
361
+ 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
+ }>>;