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/README.md +33 -10
- package/lib/config.d.ts +28 -8
- package/lib/config.js +80 -22
- package/lib/index.d.ts +3 -3
- package/lib/index.js +157 -68
- package/lib/mail-client.d.ts +36 -13
- package/lib/mail-client.js +286 -72
- package/lib/parse.d.ts +5 -0
- package/lib/parse.js +25 -6
- package/lib/types.d.ts +51 -9
- package/package.json +62 -53
package/lib/mail-client.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ImapFlow } from 'imapflow';
|
|
2
2
|
import nodemailer from 'nodemailer';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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:
|
|
47
|
-
port:
|
|
48
|
-
secure:
|
|
49
|
-
auth: { user:
|
|
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:
|
|
100
|
+
connectionTimeout: cfg.imap.connectionTimeoutMs ?? 30000,
|
|
52
101
|
greetingTimeout: 30000,
|
|
53
|
-
socketTimeout:
|
|
102
|
+
socketTimeout: cfg.imap.socketTimeoutMs ?? 60000,
|
|
54
103
|
});
|
|
55
104
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const client = this.createImap();
|
|
105
|
+
async imapRun(name, cfg, folder, run) {
|
|
106
|
+
let entry = this.imaps.get(name);
|
|
59
107
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
134
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
throw new MailError(raw.toLowerCase().includes('auth') ? `SMTP 登录失败:${raw}(请检查 user 与授权码)` : raw);
|
|
337
|
+
catch {
|
|
338
|
+
throw new MailError('附件路径不存在或不可读:' + path);
|
|
165
339
|
}
|
|
166
|
-
|
|
167
|
-
|
|
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) +
|
|
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
|
|
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:
|
|
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
|
|
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
|
}
|