dsh-email 0.17.2 → 0.18.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,407 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { mkdir, open, unlink } from 'node:fs/promises';
4
+ import { basename, join } from 'node:path';
5
+ import addressParser from 'nodemailer/lib/addressparser/index.js';
6
+ import { flattenAddresses, sanitizeFilename, stripHtml, truncateText, } from './parse.js';
7
+ import { downloadPublicBytes } from './public-download.js';
8
+ import { assertRecipientsAllowed, normalizeRecipients, } from './recipient-policy.js';
9
+ const labels = (m) => (Array.isArray(m.labels) ? m.labels : []);
10
+ const parsedAddresses = (value) => flattenAddresses(addressParser(value, { flatten: true }));
11
+ const listRow = (m) => ({
12
+ uid: String(m.message_id),
13
+ date: m.timestamp ?? '',
14
+ from: parsedAddresses(m.from ?? ''),
15
+ subject: m.subject ?? '',
16
+ seen: !labels(m).includes('unread'),
17
+ flagged: labels(m).includes('starred'),
18
+ size: Number(m.size) || 0,
19
+ hasAttachments: Array.isArray(m.attachments) && m.attachments.length > 0,
20
+ });
21
+ /** API transport. Endpoint hosts are fixed; redirects never carry credentials elsewhere. */
22
+ export class AgentMailClient {
23
+ settings;
24
+ fetcher;
25
+ downloader;
26
+ watches = new Map();
27
+ constructor(settings, fetcher = globalThis.fetch, downloader = downloadPublicBytes) {
28
+ this.settings = settings;
29
+ this.fetcher = fetcher;
30
+ this.downloader = downloader;
31
+ }
32
+ account(name) {
33
+ const c = this.settings.accounts.get(name);
34
+ if (c?.transport !== 'agentmail')
35
+ throw new Error('AgentMail 账号不存在');
36
+ return c;
37
+ }
38
+ async request(name, suffix = '', query = {}, body, signal, method = 'POST') {
39
+ const cfg = this.account(name);
40
+ if (!['https://api.agentmail.to', 'https://api.agentmail.eu'].includes(cfg.apiBase ?? ''))
41
+ throw new Error('AgentMail API 区域无效');
42
+ const url = new URL('/v0/inboxes/' + encodeURIComponent(cfg.user) + suffix, cfg.apiBase);
43
+ for (const [key, value] of Object.entries(query))
44
+ if (value !== undefined)
45
+ for (const item of Array.isArray(value) ? value : [value])
46
+ url.searchParams.append(key, String(item));
47
+ const serialized = body ? JSON.stringify(body) : undefined;
48
+ if (serialized && Buffer.byteLength(serialized) > 6_000_000)
49
+ throw new Error('AgentMail 单次发送内容超过 6 MB,请缩小附件或正文');
50
+ const res = await this.fetcher(url, {
51
+ method: body ? method : 'GET',
52
+ redirect: 'error',
53
+ signal: AbortSignal.any([
54
+ ...(signal ? [signal] : []),
55
+ AbortSignal.timeout(30000),
56
+ ]),
57
+ headers: {
58
+ authorization: 'Bearer ' + cfg.password,
59
+ ...(body
60
+ ? {
61
+ 'content-type': 'application/json',
62
+ 'Idempotency-Key': randomUUID(),
63
+ }
64
+ : {}),
65
+ },
66
+ body: serialized,
67
+ });
68
+ if (!res.ok) {
69
+ await res.body?.cancel();
70
+ throw new Error('AgentMail 请求失败(HTTP ' +
71
+ res.status +
72
+ ')' +
73
+ ({
74
+ 401: ':请检查 API Key 和区域',
75
+ 403: ':请检查账号权限、发信限制或邮箱状态',
76
+ 404: ':邮箱或邮件不存在',
77
+ 429: ':请求过于频繁,请稍后重试',
78
+ }[res.status] ?? ''));
79
+ }
80
+ let size = 0;
81
+ const chunks = [];
82
+ for await (const chunk of res.body) {
83
+ size += chunk.length;
84
+ if (size > 4 * 1024 * 1024)
85
+ throw new Error('AgentMail 响应过大');
86
+ chunks.push(chunk);
87
+ }
88
+ const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
89
+ if (result.inbox_id !== undefined &&
90
+ String(result.inbox_id).toLowerCase() !== cfg.user.toLowerCase())
91
+ throw new Error('AgentMail 返回了其他邮箱,已拒绝使用');
92
+ return result;
93
+ }
94
+ async test(name, signal) {
95
+ const value = await this.request(name, '', {}, undefined, signal);
96
+ if (String(value.inbox_id).toLowerCase() !==
97
+ this.account(name).user.toLowerCase())
98
+ throw new Error('AgentMail 返回了其他邮箱,已拒绝使用');
99
+ return value;
100
+ }
101
+ folderLabel(folder) {
102
+ return {
103
+ INBOX: 'received',
104
+ Sent: 'sent',
105
+ Starred: 'starred',
106
+ Archive: 'archived',
107
+ }[folder];
108
+ }
109
+ async list(name, folder, limit, offset, unread, since, until, signal, query) {
110
+ if (!['INBOX', 'Sent', 'Starred', 'Archive', 'All'].includes(folder))
111
+ throw new Error('AgentMail 文件夹可选 INBOX、Sent、Starred、Archive、All');
112
+ const rows = [];
113
+ let token;
114
+ let hasMore = false;
115
+ const seen = new Set(), tokens = new Set();
116
+ // Offset compatibility is bounded; API page counts are never presented as mailbox totals.
117
+ for (let page = 0; page < 201; page++) {
118
+ const label = this.folderLabel(folder);
119
+ const filters = [...(label ? [label] : []), ...(unread ? ['unread'] : [])];
120
+ const res = await this.request(name, query ? '/messages/search' : '/messages', {
121
+ limit: 100,
122
+ page_token: token,
123
+ q: query,
124
+ after: since?.toISOString(),
125
+ before: until?.toISOString(),
126
+ ...(!query && filters.length ? { labels: filters } : {}),
127
+ }, undefined, signal);
128
+ if (!Array.isArray(res.messages))
129
+ throw new Error('AgentMail 返回了无效邮件列表');
130
+ for (const row of res.messages) {
131
+ const id = row.message_id;
132
+ if (typeof id !== 'string' || !id || seen.has(id))
133
+ continue;
134
+ this.id(id);
135
+ if (row.inbox_id !== undefined &&
136
+ String(row.inbox_id).toLowerCase() !==
137
+ this.account(name).user.toLowerCase())
138
+ throw new Error('AgentMail 返回了其他邮箱,已拒绝使用');
139
+ seen.add(id);
140
+ if (query && label && !labels(row).includes(label))
141
+ continue;
142
+ if (unread && !labels(row).includes('unread'))
143
+ continue;
144
+ rows.push(row);
145
+ }
146
+ token =
147
+ typeof res.next_page_token === 'string' && res.next_page_token
148
+ ? res.next_page_token
149
+ : undefined;
150
+ hasMore = !!token || rows.length > offset + limit;
151
+ if (rows.length > offset + limit || !token)
152
+ break;
153
+ if (tokens.has(token))
154
+ throw new Error('AgentMail 分页游标重复,请重新加载');
155
+ tokens.add(token);
156
+ if (page === 200)
157
+ throw new Error('AgentMail 查询范围过大,请缩小日期或搜索条件');
158
+ }
159
+ return {
160
+ account: name,
161
+ folder,
162
+ uidValidity: 0,
163
+ count: rows.length,
164
+ countExact: !token,
165
+ hasMore,
166
+ messages: rows.slice(offset, offset + limit).map(listRow),
167
+ ...(query ? { order: 'relevance' } : {}),
168
+ };
169
+ }
170
+ async search(name, query, folder, limit, offset, since, until, signal) {
171
+ const r = await this.list(name, folder, limit, offset, false, since, until, signal, query);
172
+ return { ...r, query, offset, searchSource: 'api', order: 'relevance' };
173
+ }
174
+ id(uid) {
175
+ if (typeof uid !== 'string' ||
176
+ !uid ||
177
+ uid === '.' ||
178
+ uid === '..' ||
179
+ uid.length > 998 ||
180
+ /[\x00-\x1f\x7f]/.test(uid))
181
+ throw new Error('AgentMail uid 必须使用列表返回的字符串 message_id');
182
+ return uid;
183
+ }
184
+ async original(name, uid, signal) {
185
+ const id = this.id(uid), m = await this.request(name, '/messages/' + encodeURIComponent(id), {}, undefined, signal);
186
+ if (m.message_id !== id)
187
+ throw new Error('AgentMail 邮件标识不匹配');
188
+ return m;
189
+ }
190
+ async read(name, uid, folder, signal) {
191
+ const m = await this.original(name, uid, signal), body = truncateText(m.text ?? m.extracted_text ?? stripHtml(m.html ?? ''), this.settings.maxBodyChars);
192
+ return {
193
+ account: name,
194
+ uid: this.id(uid),
195
+ folder,
196
+ date: m.timestamp ?? '',
197
+ from: parsedAddresses(m.from ?? ''),
198
+ to: parsedAddresses((m.to ?? []).join(', ')),
199
+ cc: parsedAddresses((m.cc ?? []).join(', ')),
200
+ subject: m.subject ?? '',
201
+ text: body.text,
202
+ truncated: body.truncated,
203
+ attachments: (m.attachments ?? []).map((a) => ({
204
+ filename: a.filename ?? 'attachment',
205
+ contentType: a.content_type ?? 'application/octet-stream',
206
+ size: Number(a.size) || 0,
207
+ part: String(a.attachment_id),
208
+ })),
209
+ };
210
+ }
211
+ async mark(name, folder, uid, action, target, signal) {
212
+ const changes = {
213
+ read: { remove_labels: ['unread'] },
214
+ unread: { add_labels: ['unread'] },
215
+ star: { add_labels: ['starred'] },
216
+ unstar: { remove_labels: ['starred'] },
217
+ };
218
+ if (action === 'move') {
219
+ if (target !== 'Archive' || folder !== 'INBOX')
220
+ throw new Error('AgentMail 仅支持把 INBOX 归档到 Archive,不映射为 IMAP 移动或删除');
221
+ changes.move = { add_labels: ['archived'], remove_labels: ['received'] };
222
+ }
223
+ const r = await this.request(name, '/messages/' + encodeURIComponent(this.id(uid)), {}, changes[action], signal, 'PATCH');
224
+ return {
225
+ account: name,
226
+ folder,
227
+ uid,
228
+ action,
229
+ seen: !labels(r).includes('unread'),
230
+ flagged: labels(r).includes('starred'),
231
+ ...(action === 'move' ? { movedTo: 'Archive' } : {}),
232
+ };
233
+ }
234
+ async send(name, to, subject, text, cc, attachments, signal, reply) {
235
+ await assertRecipientsAllowed(to, cc, this.settings.recipientPolicies?.get(name));
236
+ const normalized = await normalizeRecipients(to, cc);
237
+ const toList = normalized
238
+ .filter((row) => row.field === 'to')
239
+ .map((row) => row.address), ccList = normalized
240
+ .filter((row) => row.field === 'cc')
241
+ .map((row) => row.address);
242
+ if (!toList.length)
243
+ throw new Error('请填写有效收件人');
244
+ if (attachments.length > 10 ||
245
+ attachments.reduce((n, a) => n + a.content.length, 0) >
246
+ Math.min(this.settings.maxAttachmentBytes, 4 * 1024 * 1024))
247
+ throw new Error('AgentMail 附件总量最多 4 MiB,且不超过设置上限');
248
+ const r = await this.request(name, reply
249
+ ? '/messages/' + encodeURIComponent(this.id(reply.uid)) + '/reply'
250
+ : '/messages/send', {}, {
251
+ to: toList,
252
+ ...(ccList.length ? { cc: ccList } : {}),
253
+ ...(!reply ? { subject } : {}),
254
+ text,
255
+ attachments: attachments.map((a) => ({
256
+ filename: a.filename,
257
+ content: a.content.toString('base64'),
258
+ })),
259
+ }, signal);
260
+ if (typeof r.message_id !== 'string')
261
+ throw new Error('AgentMail 未返回发送回执,请先核对已发送邮件再重试');
262
+ return {
263
+ account: name,
264
+ messageId: r.message_id,
265
+ accepted: normalized.map((row) => row.address),
266
+ rejected: [],
267
+ response: 'AgentMail 已接受提交;不表示收件方已投递成功',
268
+ };
269
+ }
270
+ async downloadAttachment(name, folder, uid, index, workspace, signal) {
271
+ const m = await this.original(name, uid, signal), attachment = m.attachments?.[index];
272
+ if (!attachment || typeof attachment.attachment_id !== 'string')
273
+ throw new Error('附件不存在,请重新打开邮件');
274
+ const meta = await this.request(name, '/messages/' +
275
+ encodeURIComponent(this.id(uid)) +
276
+ '/attachments/' +
277
+ encodeURIComponent(attachment.attachment_id), {}, undefined, signal);
278
+ const cap = Math.min(this.settings.maxAttachmentBytes, 20 * 1024 * 1024);
279
+ if (Number(meta.size) > cap)
280
+ throw new Error('附件超过下载大小上限(AgentMail 最多 20 MiB)');
281
+ const content = await this.downloader(String(meta.download_url), cap, signal);
282
+ if (content.length > cap)
283
+ throw new Error('附件超过下载大小上限(AgentMail 最多 20 MiB)');
284
+ const filename = sanitizeFilename(attachment.filename ?? meta.filename ?? 'attachment'), dir = this.settings.downloadDirExplicit
285
+ ? this.settings.downloadDir
286
+ : workspace
287
+ ? join(workspace, '.dsh-email-downloads')
288
+ : this.settings.downloadDir;
289
+ await mkdir(dir, { recursive: true });
290
+ const path = join(dir, randomUUID() + '-' + filename), handle = await open(path, 'wx', 0o600);
291
+ try {
292
+ signal?.throwIfAborted();
293
+ await handle.writeFile(content);
294
+ signal?.throwIfAborted();
295
+ }
296
+ catch (error) {
297
+ await handle.close();
298
+ await unlink(path).catch(() => { });
299
+ throw error;
300
+ }
301
+ finally {
302
+ await handle.close();
303
+ }
304
+ return {
305
+ account: name,
306
+ uid,
307
+ filename,
308
+ contentType: attachment.content_type ?? 'application/octet-stream',
309
+ size: content.length,
310
+ path,
311
+ };
312
+ }
313
+ async sendPaths(name, to, subject, text, cc, paths, signal) {
314
+ await assertRecipientsAllowed(to, cc, this.settings.recipientPolicies?.get(name));
315
+ const attachments = [];
316
+ let bytes = 0;
317
+ for (const path of paths) {
318
+ const chunks = [];
319
+ signal?.throwIfAborted();
320
+ for await (const chunk of createReadStream(path, { signal })) {
321
+ const data = Buffer.from(chunk);
322
+ bytes += data.length;
323
+ if (bytes > Math.min(this.settings.maxAttachmentBytes, 4 * 1024 * 1024))
324
+ throw new Error('AgentMail 附件总量最多 4 MiB');
325
+ chunks.push(data);
326
+ }
327
+ attachments.push({
328
+ filename: basename(path),
329
+ content: Buffer.concat(chunks),
330
+ });
331
+ }
332
+ return this.send(name, to, subject, text, cc, attachments, signal);
333
+ }
334
+ async watch(name, folder, limit, scope, signal) {
335
+ signal?.throwIfAborted();
336
+ const key = JSON.stringify([name, folder, scope]), old = this.watches.get(key);
337
+ if (old?.pending.length) {
338
+ const batch = old.pending.splice(0, limit);
339
+ return {
340
+ account: name,
341
+ folder,
342
+ firstRun: false,
343
+ newCount: batch.length,
344
+ messages: batch,
345
+ totalUnread: old.totalUnread,
346
+ totalUnreadExact: old.totalUnreadExact,
347
+ };
348
+ }
349
+ // Follow opaque API cursors directly, without re-reading earlier pages.
350
+ const label = this.folderLabel(folder), rows = [], ids = new Set(), tokens = new Set();
351
+ if (!['INBOX', 'Sent', 'Starred', 'Archive', 'All'].includes(folder))
352
+ throw new Error('AgentMail 文件夹无效');
353
+ let token;
354
+ for (let page = 0; page < 50; page++) {
355
+ const result = await this.request(name, '/messages', { limit: 100, page_token: token, labels: label }, undefined, signal);
356
+ if (!Array.isArray(result.messages))
357
+ throw new Error('AgentMail 返回了无效邮件列表');
358
+ for (const message of result.messages) {
359
+ const id = this.id(message.message_id);
360
+ if (message.inbox_id !== undefined &&
361
+ String(message.inbox_id).toLowerCase() !==
362
+ this.account(name).user.toLowerCase())
363
+ throw new Error('AgentMail 返回了其他邮箱,已拒绝使用');
364
+ if (!ids.has(id)) {
365
+ ids.add(id);
366
+ rows.push(listRow(message));
367
+ }
368
+ }
369
+ token =
370
+ typeof result.next_page_token === 'string' && result.next_page_token
371
+ ? result.next_page_token
372
+ : undefined;
373
+ if (!old || (old.anchor && ids.has(old.anchor)) || !token)
374
+ break;
375
+ if (tokens.has(token))
376
+ throw new Error('AgentMail 分页游标重复,请重新加载');
377
+ tokens.add(token);
378
+ }
379
+ signal?.throwIfAborted();
380
+ if (old && (old.anchor ? !ids.has(old.anchor) : !!token))
381
+ throw new Error('AgentMail 新邮件基线已超出窗口或被移除,请手动核对邮箱后重新启用提醒');
382
+ const totalUnread = rows.filter((m) => !m.seen).length, totalUnreadExact = !token, anchor = String(rows[0]?.uid ?? '');
383
+ const before = old?.anchor
384
+ ? rows.findIndex((m) => m.uid === old.anchor)
385
+ : rows.length;
386
+ const pending = old
387
+ ? rows
388
+ .slice(0, before)
389
+ .filter((m) => !m.seen)
390
+ .reverse()
391
+ : [];
392
+ this.watches.set(key, { anchor, pending, totalUnread, totalUnreadExact });
393
+ const batch = pending.splice(0, limit);
394
+ return {
395
+ account: name,
396
+ folder,
397
+ firstRun: !old,
398
+ newCount: batch.length,
399
+ messages: batch,
400
+ totalUnread,
401
+ totalUnreadExact,
402
+ };
403
+ }
404
+ clear() {
405
+ this.watches.clear();
406
+ }
407
+ }