@lmcl/ailo-mcp-email 0.0.2 → 0.0.5
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/package.json +4 -4
- package/src/email-handler.ts +566 -566
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmcl/ailo-mcp-email",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Ailo 邮件通道 MCP - IMAP 收信 + SMTP 发信",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"dev": "tsx src/index.ts"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@lmcl/ailo-mcp-sdk": "^0.0.
|
|
16
|
+
"@lmcl/ailo-mcp-sdk": "^0.0.3",
|
|
17
17
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
18
18
|
"dotenv": "^16.4.5",
|
|
19
|
-
"imap": "^0.8.
|
|
19
|
+
"imap": "^0.8.17",
|
|
20
20
|
"mailparser": "^3.6.6",
|
|
21
|
-
"nodemailer": "^
|
|
21
|
+
"nodemailer": "^8.0.1",
|
|
22
22
|
"zod": "^4.3.6"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
package/src/email-handler.ts
CHANGED
|
@@ -1,566 +1,566 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 邮件通道 Handler:IMAP 收信/读信/搜索/组织 + nodemailer 发信/回复/转发
|
|
3
|
-
*/
|
|
4
|
-
// @ts-nocheck
|
|
5
|
-
import { createRequire } from "module";
|
|
6
|
-
import fs from "fs";
|
|
7
|
-
import path from "path";
|
|
8
|
-
import Imap from "imap";
|
|
9
|
-
import { simpleParser } from "mailparser";
|
|
10
|
-
import nodemailer from "nodemailer";
|
|
11
|
-
import { getWorkDir, type BridgeHandler, type BridgeMessage, type ContextTag } from "@lmcl/ailo-mcp-sdk";
|
|
12
|
-
|
|
13
|
-
const require = createRequire(import.meta.url);
|
|
14
|
-
const parseHeader = (require("imap/lib/Parser") as { parseHeader: (s: string) => Record<string, string[]> }).parseHeader;
|
|
15
|
-
|
|
16
|
-
type ChannelStorage = {
|
|
17
|
-
getData(key: string): Promise<string | null>;
|
|
18
|
-
setData(key: string, value: string): Promise<void>;
|
|
19
|
-
deleteData(key: string): Promise<void>;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export type EmailConfig = {
|
|
23
|
-
imapHost: string;
|
|
24
|
-
imapPort: number;
|
|
25
|
-
imapUser: string;
|
|
26
|
-
imapPassword: string;
|
|
27
|
-
smtpHost?: string;
|
|
28
|
-
smtpPort?: number;
|
|
29
|
-
smtpUser?: string;
|
|
30
|
-
smtpPassword?: string;
|
|
31
|
-
tls?: boolean;
|
|
32
|
-
/** 是否验证 TLS 证书,默认 true。自签名证书可设为 false */
|
|
33
|
-
tlsRejectUnauthorized?: boolean;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export type EmailListItem = {
|
|
37
|
-
uid: number;
|
|
38
|
-
from: string;
|
|
39
|
-
to: string;
|
|
40
|
-
subject: string;
|
|
41
|
-
date: string;
|
|
42
|
-
isRead: boolean;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export type EmailDetail = {
|
|
46
|
-
uid: number;
|
|
47
|
-
from: string;
|
|
48
|
-
to: string;
|
|
49
|
-
subject: string;
|
|
50
|
-
date: string;
|
|
51
|
-
text?: string;
|
|
52
|
-
html?: string;
|
|
53
|
-
attachments: { filename: string; contentType: string; size: number }[];
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
function p<T>(fn: (cb: (err: Error | null, result?: T) => void) => void): Promise<T> {
|
|
57
|
-
return new Promise((resolve, reject) => {
|
|
58
|
-
fn((err, result) => (err ? reject(err) : resolve(result as T)));
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function parseHeaderSafe(buf: string): Record<string, string[]> {
|
|
63
|
-
try {
|
|
64
|
-
return parseHeader(buf);
|
|
65
|
-
} catch {
|
|
66
|
-
return {};
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export class EmailHandler implements BridgeHandler {
|
|
71
|
-
private config: EmailConfig;
|
|
72
|
-
private onMessageHandler: ((msg: BridgeMessage) => void | Promise<void>) | null = null;
|
|
73
|
-
private imap: Imap | null = null;
|
|
74
|
-
private transporter: nodemailer.Transporter | null = null;
|
|
75
|
-
private checkInterval: ReturnType<typeof setInterval> | null = null;
|
|
76
|
-
private lastUid = 0;
|
|
77
|
-
private storage: ChannelStorage | null = null;
|
|
78
|
-
|
|
79
|
-
constructor(config: EmailConfig) {
|
|
80
|
-
this.config = config;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
setDataProvider?(storage: ChannelStorage): void {
|
|
84
|
-
this.storage = storage;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
setOnMessage(handler: (msg: BridgeMessage) => void | Promise<void>): void {
|
|
88
|
-
this.onMessageHandler = handler;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
private getSmtpHost(): string {
|
|
92
|
-
if (this.config.smtpHost) return this.config.smtpHost;
|
|
93
|
-
const m = this.config.imapHost.match(/^imap\.(.+)$/);
|
|
94
|
-
return m ? `smtp.${m[1]}` : this.config.imapHost.replace("imap", "smtp");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
private getSmtpPort(): number {
|
|
98
|
-
return this.config.smtpPort ?? 465;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private getSmtpUser(): string {
|
|
102
|
-
return this.config.smtpUser ?? this.config.imapUser;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private getSmtpPassword(): string {
|
|
106
|
-
return this.config.smtpPassword ?? this.config.imapPassword;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
private ensureTransporter(): nodemailer.Transporter {
|
|
110
|
-
if (!this.transporter) {
|
|
111
|
-
this.transporter = nodemailer.createTransport({
|
|
112
|
-
host: this.getSmtpHost(),
|
|
113
|
-
port: this.getSmtpPort(),
|
|
114
|
-
secure: this.getSmtpPort() === 465,
|
|
115
|
-
auth: { user: this.getSmtpUser(), pass: this.getSmtpPassword() },
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
return this.transporter;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
private withImap<T>(folder: string, fn: (imap: Imap) => Promise<T>): Promise<T> {
|
|
122
|
-
return new Promise((resolve, reject) => {
|
|
123
|
-
if (!this.imap) {
|
|
124
|
-
reject(new Error("IMAP 未连接"));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
this.imap.openBox(folder, false, (err: Error | null) => {
|
|
128
|
-
if (err) {
|
|
129
|
-
reject(err);
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
fn(this.imap!).then(resolve).catch(reject);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async send(opts: {
|
|
138
|
-
to: string;
|
|
139
|
-
cc?: string;
|
|
140
|
-
bcc?: string;
|
|
141
|
-
subject?: string;
|
|
142
|
-
body: string;
|
|
143
|
-
html?: string;
|
|
144
|
-
attachments?: { filename: string; content: string; contentType?: string }[];
|
|
145
|
-
}): Promise<void> {
|
|
146
|
-
const t = this.ensureTransporter();
|
|
147
|
-
await t.sendMail({
|
|
148
|
-
from: this.config.imapUser,
|
|
149
|
-
to: opts.to,
|
|
150
|
-
cc: opts.cc,
|
|
151
|
-
bcc: opts.bcc,
|
|
152
|
-
subject: opts.subject ?? "(无主题)",
|
|
153
|
-
text: opts.body,
|
|
154
|
-
html: opts.html,
|
|
155
|
-
attachments: opts.attachments?.map((a) => ({
|
|
156
|
-
filename: a.filename,
|
|
157
|
-
content: a.content,
|
|
158
|
-
contentType: a.contentType,
|
|
159
|
-
})),
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async reply(opts: {
|
|
164
|
-
uid: number;
|
|
165
|
-
folder?: string;
|
|
166
|
-
body: string;
|
|
167
|
-
html?: string;
|
|
168
|
-
attachments?: { filename: string; content: string; contentType?: string }[];
|
|
169
|
-
}): Promise<void> {
|
|
170
|
-
const detail = await this.read({ uid: opts.uid, folder: opts.folder });
|
|
171
|
-
if (!detail) throw new Error(`邮件 uid=${opts.uid} 不存在`);
|
|
172
|
-
const orig = await this.fetchRaw(opts.uid, opts.folder ?? "INBOX");
|
|
173
|
-
if (!orig) throw new Error(`无法获取原始邮件`);
|
|
174
|
-
const inReplyTo = (orig as { messageId?: string }).messageId;
|
|
175
|
-
const refs = (orig as { references?: string[] | string }).references;
|
|
176
|
-
const references = Array.isArray(refs) ? refs.join(" ") : refs ?? inReplyTo ?? "";
|
|
177
|
-
const to = orig.from?.value?.[0]?.address ?? "";
|
|
178
|
-
const subj = (orig.subject ?? "").startsWith("Re:") ? orig.subject : `Re: ${orig.subject ?? ""}`;
|
|
179
|
-
const t = this.ensureTransporter();
|
|
180
|
-
await t.sendMail({
|
|
181
|
-
from: this.config.imapUser,
|
|
182
|
-
to,
|
|
183
|
-
subject: subj,
|
|
184
|
-
text: opts.body,
|
|
185
|
-
html: opts.html,
|
|
186
|
-
attachments: opts.attachments?.map((a) => ({
|
|
187
|
-
filename: a.filename,
|
|
188
|
-
content: a.content,
|
|
189
|
-
contentType: a.contentType,
|
|
190
|
-
})),
|
|
191
|
-
inReplyTo,
|
|
192
|
-
references,
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async forward(opts: {
|
|
197
|
-
uid: number;
|
|
198
|
-
folder?: string;
|
|
199
|
-
to: string;
|
|
200
|
-
cc?: string;
|
|
201
|
-
bcc?: string;
|
|
202
|
-
body?: string;
|
|
203
|
-
}): Promise<void> {
|
|
204
|
-
const raw = await this.fetchRaw(opts.uid, opts.folder ?? "INBOX");
|
|
205
|
-
if (!raw) throw new Error(`邮件 uid=${opts.uid} 不存在`);
|
|
206
|
-
const subj = (raw.subject ?? "").startsWith("Fwd:") ? raw.subject : `Fwd: ${raw.subject ?? ""}`;
|
|
207
|
-
const fwdText = raw.text ?? raw.html ?? "";
|
|
208
|
-
const t = this.ensureTransporter();
|
|
209
|
-
await t.sendMail({
|
|
210
|
-
from: this.config.imapUser,
|
|
211
|
-
to: opts.to,
|
|
212
|
-
cc: opts.cc,
|
|
213
|
-
bcc: opts.bcc,
|
|
214
|
-
subject: subj,
|
|
215
|
-
text: opts.body ? `${opts.body}\n\n--- 转发内容 ---\n${fwdText}` : fwdText,
|
|
216
|
-
html: opts.body ? `<p>${opts.body.replace(/\n/g, "<br>")}</p><hr>${raw.html ?? ""}` : raw.html,
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async list(opts: {
|
|
221
|
-
folder?: string;
|
|
222
|
-
limit?: number;
|
|
223
|
-
offset?: number;
|
|
224
|
-
unreadOnly?: boolean;
|
|
225
|
-
}): Promise<EmailListItem[]> {
|
|
226
|
-
const folder = opts.folder ?? "INBOX";
|
|
227
|
-
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
|
|
228
|
-
const offset = Math.max(0, opts.offset ?? 0);
|
|
229
|
-
return this.withImap(folder, async (imap) => {
|
|
230
|
-
const criteria: (string | string[])[] = opts.unreadOnly ? ["UNSEEN"] : ["ALL"];
|
|
231
|
-
const uids = await p<number[]>((cb) => imap.search(criteria as string[], cb));
|
|
232
|
-
if (uids.length === 0) return [];
|
|
233
|
-
const sorted = uids.sort((a, b) => b - a);
|
|
234
|
-
const slice = sorted.slice(offset, offset + limit);
|
|
235
|
-
if (slice.length === 0) return [];
|
|
236
|
-
const items: EmailListItem[] = [];
|
|
237
|
-
return new Promise((resolve, reject) => {
|
|
238
|
-
const f = imap.fetch(slice as unknown as Imap.MessageSource, {
|
|
239
|
-
bodies: "HEADER.FIELDS (FROM TO SUBJECT DATE)",
|
|
240
|
-
struct: false,
|
|
241
|
-
});
|
|
242
|
-
f.on("message", (msg) => {
|
|
243
|
-
let buf = "";
|
|
244
|
-
let attrs: { uid: number; flags?: string[] } | null = null;
|
|
245
|
-
msg.on("body", (stream: NodeJS.ReadableStream) => {
|
|
246
|
-
stream.on("data", (ch: Buffer) => {
|
|
247
|
-
buf += ch.toString();
|
|
248
|
-
});
|
|
249
|
-
});
|
|
250
|
-
msg.once("attributes", (a: { uid: number; flags?: string[] }) => {
|
|
251
|
-
attrs = a;
|
|
252
|
-
});
|
|
253
|
-
msg.on("end", () => {
|
|
254
|
-
if (attrs && buf) {
|
|
255
|
-
const h = parseHeaderSafe(buf);
|
|
256
|
-
items.push({
|
|
257
|
-
uid: attrs.uid,
|
|
258
|
-
from: String(h.from?.[0] ?? ""),
|
|
259
|
-
to: String(h.to?.[0] ?? ""),
|
|
260
|
-
subject: String(h.subject?.[0] ?? "(无主题)"),
|
|
261
|
-
date: String(h.date?.[0] ?? new Date()),
|
|
262
|
-
isRead: (attrs.flags ?? []).includes("\\Seen"),
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
f.once("error", reject);
|
|
268
|
-
f.on("end", () => resolve(items.sort((a, b) => b.uid - a.uid)));
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async read(opts: { uid: number; folder?: string }): Promise<EmailDetail | null> {
|
|
274
|
-
const folder = opts.folder ?? "INBOX";
|
|
275
|
-
const raw = await this.fetchRaw(opts.uid, folder);
|
|
276
|
-
if (!raw) return null;
|
|
277
|
-
const from = raw.from?.value?.[0];
|
|
278
|
-
const to = raw.to?.value?.map((v) => v.address).join(", ") ?? "";
|
|
279
|
-
const atts = (raw as { attachments?: Array<{ filename?: string; contentType?: string; size?: number }> }).attachments ?? [];
|
|
280
|
-
return {
|
|
281
|
-
uid: opts.uid,
|
|
282
|
-
from: from ? `${from.name || ""} <${from.address}>`.trim() || (from.address ?? "") : "",
|
|
283
|
-
to,
|
|
284
|
-
subject: raw.subject ?? "(无主题)",
|
|
285
|
-
date: raw.date?.toISOString() ?? "",
|
|
286
|
-
text: raw.text ?? undefined,
|
|
287
|
-
html: raw.html ?? undefined,
|
|
288
|
-
attachments: atts.map((a) => ({
|
|
289
|
-
filename: a.filename ?? "attachment",
|
|
290
|
-
contentType: a.contentType ?? "application/octet-stream",
|
|
291
|
-
size: a.size ?? 0,
|
|
292
|
-
})),
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
private async fetchRaw(uid: number, folder: string): Promise<unknown> {
|
|
297
|
-
return this.withImap(folder, (imap) => {
|
|
298
|
-
return new Promise((resolve, reject) => {
|
|
299
|
-
let resolved = false;
|
|
300
|
-
const doResolve = (v: unknown) => {
|
|
301
|
-
if (!resolved) {
|
|
302
|
-
resolved = true;
|
|
303
|
-
resolve(v);
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
const f = imap.fetch(uid as Imap.MessageSource, { bodies: "" });
|
|
307
|
-
f.on("message", (msg) => {
|
|
308
|
-
msg.on("body", (stream: NodeJS.ReadableStream) => {
|
|
309
|
-
void simpleParser(stream as Parameters<typeof simpleParser>[0])
|
|
310
|
-
.then((mail) => doResolve(mail))
|
|
311
|
-
.catch((e) => {
|
|
312
|
-
if (!resolved) {
|
|
313
|
-
resolved = true;
|
|
314
|
-
reject(e);
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
f.once("error", reject);
|
|
320
|
-
f.on("end", () => doResolve(null));
|
|
321
|
-
});
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
async search(opts: {
|
|
326
|
-
query?: string;
|
|
327
|
-
from?: string;
|
|
328
|
-
to?: string;
|
|
329
|
-
subject?: string;
|
|
330
|
-
since?: string;
|
|
331
|
-
until?: string;
|
|
332
|
-
folder?: string;
|
|
333
|
-
limit?: number;
|
|
334
|
-
}): Promise<EmailListItem[]> {
|
|
335
|
-
const folder = opts.folder ?? "INBOX";
|
|
336
|
-
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
|
|
337
|
-
return this.withImap(folder, async (imap) => {
|
|
338
|
-
const criteria: (string | string[])[] = [];
|
|
339
|
-
if (opts.from) criteria.push(["FROM", opts.from]);
|
|
340
|
-
if (opts.to) criteria.push(["TO", opts.to]);
|
|
341
|
-
if (opts.subject) criteria.push(["SUBJECT", opts.subject]);
|
|
342
|
-
if (opts.since) criteria.push(["SINCE", opts.since]);
|
|
343
|
-
if (opts.until) criteria.push(["BEFORE", opts.until]);
|
|
344
|
-
if (opts.query) criteria.push(["BODY", opts.query]);
|
|
345
|
-
if (criteria.length === 0) criteria.push("ALL");
|
|
346
|
-
const uids = await p<number[]>((cb) => imap.search(criteria as string[], cb));
|
|
347
|
-
const sorted = uids.sort((a, b) => b - a).slice(0, limit);
|
|
348
|
-
if (sorted.length === 0) return [];
|
|
349
|
-
const items: EmailListItem[] = [];
|
|
350
|
-
return new Promise((resolve, reject) => {
|
|
351
|
-
const f = imap.fetch(sorted as unknown as Imap.MessageSource, {
|
|
352
|
-
bodies: "HEADER.FIELDS (FROM TO SUBJECT DATE)",
|
|
353
|
-
struct: false,
|
|
354
|
-
});
|
|
355
|
-
f.on("message", (msg) => {
|
|
356
|
-
let buf = "";
|
|
357
|
-
let attrs: { uid: number; flags?: string[] } | null = null;
|
|
358
|
-
msg.on("body", (stream: NodeJS.ReadableStream) => {
|
|
359
|
-
stream.on("data", (ch: Buffer) => {
|
|
360
|
-
buf += ch.toString();
|
|
361
|
-
});
|
|
362
|
-
});
|
|
363
|
-
msg.once("attributes", (a: { uid: number; flags?: string[] }) => {
|
|
364
|
-
attrs = a;
|
|
365
|
-
});
|
|
366
|
-
msg.on("end", () => {
|
|
367
|
-
if (attrs && buf) {
|
|
368
|
-
const h = parseHeaderSafe(buf);
|
|
369
|
-
items.push({
|
|
370
|
-
uid: attrs.uid,
|
|
371
|
-
from: String(h.from?.[0] ?? ""),
|
|
372
|
-
to: String(h.to?.[0] ?? ""),
|
|
373
|
-
subject: String(h.subject?.[0] ?? "(无主题)"),
|
|
374
|
-
date: String(h.date?.[0] ?? new Date()),
|
|
375
|
-
isRead: (attrs.flags ?? []).includes("\\Seen"),
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
});
|
|
379
|
-
});
|
|
380
|
-
f.once("error", reject);
|
|
381
|
-
f.on("end", () => resolve(items.sort((a, b) => b.uid - a.uid)));
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
async markRead(opts: { uids: number[]; read: boolean; folder?: string }): Promise<void> {
|
|
387
|
-
const uids = opts.uids.slice(0, 500);
|
|
388
|
-
if (uids.length === 0) return;
|
|
389
|
-
const folder = opts.folder ?? "INBOX";
|
|
390
|
-
await this.withImap(folder, async (imap) => {
|
|
391
|
-
if (opts.read) {
|
|
392
|
-
await p((cb) => imap.addFlags(uids as unknown as Imap.MessageSource, "\\Seen", cb));
|
|
393
|
-
} else {
|
|
394
|
-
await p((cb) => imap.delFlags(uids as unknown as Imap.MessageSource, "\\Seen", cb));
|
|
395
|
-
}
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async move(opts: { uids: number[]; folder: string; fromFolder?: string }): Promise<void> {
|
|
400
|
-
const uids = opts.uids.slice(0, 500);
|
|
401
|
-
if (uids.length === 0) return;
|
|
402
|
-
const fromFolder = opts.fromFolder ?? "INBOX";
|
|
403
|
-
await this.withImap(fromFolder, async (imap) => {
|
|
404
|
-
await p((cb) => imap.move(uids as unknown as Imap.MessageSource, opts.folder, cb));
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
async delete(opts: { uids: number[]; folder?: string }): Promise<void> {
|
|
409
|
-
const uids = opts.uids.slice(0, 500);
|
|
410
|
-
if (uids.length === 0) return;
|
|
411
|
-
const folder = opts.folder ?? "INBOX";
|
|
412
|
-
await this.withImap(folder, async (imap) => {
|
|
413
|
-
await p((cb) => imap.addFlags(uids as unknown as Imap.MessageSource, "\\Deleted", cb));
|
|
414
|
-
await p((cb) => imap.expunge(cb));
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
async getAttachment(opts: { uid: number; folder?: string; filename: string }): Promise<string | null> {
|
|
419
|
-
const folder = opts.folder ?? "INBOX";
|
|
420
|
-
const raw = (await this.fetchRaw(opts.uid, folder)) as {
|
|
421
|
-
attachments?: Array<{ filename?: string; content?: Buffer | string }>;
|
|
422
|
-
} | null;
|
|
423
|
-
if (!raw?.attachments?.length) return null;
|
|
424
|
-
const att = raw.attachments.find((a) => (a.filename ?? "") === opts.filename);
|
|
425
|
-
if (!att?.content) return null;
|
|
426
|
-
return typeof att.content === "string"
|
|
427
|
-
? Buffer.from(att.content).toString("base64")
|
|
428
|
-
: att.content.toString("base64");
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
async sendText(to: string, text: string, subject?: string): Promise<void> {
|
|
432
|
-
await this.send({ to, body: text, subject });
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
start(): void {
|
|
436
|
-
const imap = new Imap({
|
|
437
|
-
user: this.config.imapUser,
|
|
438
|
-
password: this.config.imapPassword,
|
|
439
|
-
host: this.config.imapHost,
|
|
440
|
-
port: this.config.imapPort,
|
|
441
|
-
tls: this.config.tls ?? true,
|
|
442
|
-
tlsOptions: { rejectUnauthorized: this.config.tlsRejectUnauthorized ?? true },
|
|
443
|
-
});
|
|
444
|
-
this.imap = imap;
|
|
445
|
-
|
|
446
|
-
const openInbox = (cb: (err: Error | null) => void) => {
|
|
447
|
-
imap.openBox("INBOX", false, cb);
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
const fetchNew = () => {
|
|
451
|
-
if (!this.imap || !this.onMessageHandler) return;
|
|
452
|
-
openInbox((err) => {
|
|
453
|
-
if (err) {
|
|
454
|
-
console.error("[email] IMAP openBox failed:", err);
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
imap.search([["UID", `${this.lastUid + 1}:*`]], (searchErr, uids) => {
|
|
458
|
-
if (searchErr || !uids?.length) return;
|
|
459
|
-
const fetch = imap.fetch(uids, { bodies: "" });
|
|
460
|
-
fetch.on("message", (msg) => {
|
|
461
|
-
let uid = 0;
|
|
462
|
-
msg.once("attributes", (attrs: { uid: number }) => {
|
|
463
|
-
uid = attrs.uid;
|
|
464
|
-
});
|
|
465
|
-
msg.on("body", (stream: NodeJS.ReadableStream) => {
|
|
466
|
-
void simpleParser(stream as Parameters<typeof simpleParser>[0]).then((parsed) => {
|
|
467
|
-
if (parsed && uid) {
|
|
468
|
-
if (uid > this.lastUid) this.lastUid = uid;
|
|
469
|
-
this.emitMessage(parsed);
|
|
470
|
-
}
|
|
471
|
-
});
|
|
472
|
-
});
|
|
473
|
-
});
|
|
474
|
-
fetch.on("end", () => {
|
|
475
|
-
this.storage?.setData("last_uid", String(this.lastUid)).catch(() => {});
|
|
476
|
-
});
|
|
477
|
-
});
|
|
478
|
-
});
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
imap.once("ready", () => {
|
|
482
|
-
console.error("[email] IMAP connected to", this.config.imapHost);
|
|
483
|
-
this.storage?.getData("last_uid").then((v) => {
|
|
484
|
-
if (v) this.lastUid = parseInt(v, 10) || 0;
|
|
485
|
-
fetchNew();
|
|
486
|
-
});
|
|
487
|
-
});
|
|
488
|
-
imap.once("error", (err: Error) => console.error("[email] IMAP error:", err));
|
|
489
|
-
imap.once("end", () => console.error("[email] IMAP connection ended"));
|
|
490
|
-
imap.connect();
|
|
491
|
-
this.checkInterval = setInterval(fetchNew, 60 * 1000);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
private async emitMessage(parsed: Awaited<ReturnType<typeof simpleParser>>): Promise<void> {
|
|
495
|
-
if (!parsed || !this.onMessageHandler) return;
|
|
496
|
-
const from = parsed.from?.value?.[0];
|
|
497
|
-
const fromAddr = from?.address ?? "unknown";
|
|
498
|
-
const fromName = from?.name ?? fromAddr;
|
|
499
|
-
const subject = parsed.subject ?? "(无主题)";
|
|
500
|
-
const text = parsed.text ?? parsed.html ?? "";
|
|
501
|
-
const date = parsed.date ? parsed.date.getTime() : Date.now();
|
|
502
|
-
const attachments = await this.saveAttachmentsToWorkdir(parsed);
|
|
503
|
-
const d = new Date(date);
|
|
504
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
505
|
-
const contextTags: ContextTag[] = [
|
|
506
|
-
{ desc: "类型", value: "私聊", core: true },
|
|
507
|
-
{ desc: "会话", value: fromAddr, core: true },
|
|
508
|
-
{ desc: "昵称", value: fromName, core: true },
|
|
509
|
-
{ desc: "用户", value: fromAddr, core: true },
|
|
510
|
-
{ desc: "时间", value: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`, core: false },
|
|
511
|
-
];
|
|
512
|
-
this.onMessageHandler({
|
|
513
|
-
text: `[主题: ${subject}]\n\n${text}`.trim(),
|
|
514
|
-
contextTags,
|
|
515
|
-
attachments: attachments.length > 0 ? attachments : undefined,
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
private async saveAttachmentsToWorkdir(
|
|
520
|
-
parsed: Awaited<ReturnType<typeof simpleParser>>
|
|
521
|
-
): Promise<Array<{ type: string; path: string; mime?: string; name?: string }>> {
|
|
522
|
-
const rawAtts = (parsed as { attachments?: Array<{ content?: Buffer; filename?: string; contentType?: string }> })
|
|
523
|
-
.attachments;
|
|
524
|
-
if (!rawAtts?.length) return [];
|
|
525
|
-
const workDir = getWorkDir();
|
|
526
|
-
const blobsDir = workDir ? path.join(workDir, "blobs") : null;
|
|
527
|
-
if (!blobsDir) return [];
|
|
528
|
-
try {
|
|
529
|
-
await fs.promises.mkdir(blobsDir, { recursive: true });
|
|
530
|
-
} catch {
|
|
531
|
-
return [];
|
|
532
|
-
}
|
|
533
|
-
const out: Array<{ type: string; path: string; mime?: string; name?: string }> = [];
|
|
534
|
-
const ts = Date.now();
|
|
535
|
-
for (let i = 0; i < rawAtts.length; i++) {
|
|
536
|
-
const a = rawAtts[i];
|
|
537
|
-
if (!a?.content || !Buffer.isBuffer(a.content)) continue;
|
|
538
|
-
const base = (a.filename ?? "attachment").replace(/[^a-zA-Z0-9._-]/g, "_") || "attachment";
|
|
539
|
-
const ext = path.extname(base) || "";
|
|
540
|
-
const stem = path.basename(base, ext) || "attachment";
|
|
541
|
-
const filename = `${stem}_${ts}_${i}${ext}`;
|
|
542
|
-
const outPath = path.join(blobsDir, filename);
|
|
543
|
-
try {
|
|
544
|
-
await fs.promises.writeFile(outPath, a.content);
|
|
545
|
-
const mime = a.contentType ?? "application/octet-stream";
|
|
546
|
-
const type = mime.startsWith("image/") ? "image" : mime.startsWith("audio/") ? "audio" : mime.startsWith("video/") ? "video" : "file";
|
|
547
|
-
out.push({ type, path: outPath, mime, name: a.filename ?? filename });
|
|
548
|
-
} catch {
|
|
549
|
-
// 落盘失败则跳过该附件
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
return out;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
stop?(): void {
|
|
556
|
-
if (this.checkInterval) {
|
|
557
|
-
clearInterval(this.checkInterval);
|
|
558
|
-
this.checkInterval = null;
|
|
559
|
-
}
|
|
560
|
-
if (this.imap) {
|
|
561
|
-
this.imap.end();
|
|
562
|
-
this.imap = null;
|
|
563
|
-
}
|
|
564
|
-
this.transporter = null;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 邮件通道 Handler:IMAP 收信/读信/搜索/组织 + nodemailer 发信/回复/转发
|
|
3
|
+
*/
|
|
4
|
+
// @ts-nocheck
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import Imap from "imap";
|
|
9
|
+
import { simpleParser } from "mailparser";
|
|
10
|
+
import nodemailer from "nodemailer";
|
|
11
|
+
import { getWorkDir, type BridgeHandler, type BridgeMessage, type ContextTag } from "@lmcl/ailo-mcp-sdk";
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const parseHeader = (require("imap/lib/Parser") as { parseHeader: (s: string) => Record<string, string[]> }).parseHeader;
|
|
15
|
+
|
|
16
|
+
type ChannelStorage = {
|
|
17
|
+
getData(key: string): Promise<string | null>;
|
|
18
|
+
setData(key: string, value: string): Promise<void>;
|
|
19
|
+
deleteData(key: string): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type EmailConfig = {
|
|
23
|
+
imapHost: string;
|
|
24
|
+
imapPort: number;
|
|
25
|
+
imapUser: string;
|
|
26
|
+
imapPassword: string;
|
|
27
|
+
smtpHost?: string;
|
|
28
|
+
smtpPort?: number;
|
|
29
|
+
smtpUser?: string;
|
|
30
|
+
smtpPassword?: string;
|
|
31
|
+
tls?: boolean;
|
|
32
|
+
/** 是否验证 TLS 证书,默认 true。自签名证书可设为 false */
|
|
33
|
+
tlsRejectUnauthorized?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type EmailListItem = {
|
|
37
|
+
uid: number;
|
|
38
|
+
from: string;
|
|
39
|
+
to: string;
|
|
40
|
+
subject: string;
|
|
41
|
+
date: string;
|
|
42
|
+
isRead: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type EmailDetail = {
|
|
46
|
+
uid: number;
|
|
47
|
+
from: string;
|
|
48
|
+
to: string;
|
|
49
|
+
subject: string;
|
|
50
|
+
date: string;
|
|
51
|
+
text?: string;
|
|
52
|
+
html?: string;
|
|
53
|
+
attachments: { filename: string; contentType: string; size: number }[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function p<T>(fn: (cb: (err: Error | null, result?: T) => void) => void): Promise<T> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
fn((err, result) => (err ? reject(err) : resolve(result as T)));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseHeaderSafe(buf: string): Record<string, string[]> {
|
|
63
|
+
try {
|
|
64
|
+
return parseHeader(buf);
|
|
65
|
+
} catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class EmailHandler implements BridgeHandler {
|
|
71
|
+
private config: EmailConfig;
|
|
72
|
+
private onMessageHandler: ((msg: BridgeMessage) => void | Promise<void>) | null = null;
|
|
73
|
+
private imap: Imap | null = null;
|
|
74
|
+
private transporter: nodemailer.Transporter | null = null;
|
|
75
|
+
private checkInterval: ReturnType<typeof setInterval> | null = null;
|
|
76
|
+
private lastUid = 0;
|
|
77
|
+
private storage: ChannelStorage | null = null;
|
|
78
|
+
|
|
79
|
+
constructor(config: EmailConfig) {
|
|
80
|
+
this.config = config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setDataProvider?(storage: ChannelStorage): void {
|
|
84
|
+
this.storage = storage;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setOnMessage(handler: (msg: BridgeMessage) => void | Promise<void>): void {
|
|
88
|
+
this.onMessageHandler = handler;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private getSmtpHost(): string {
|
|
92
|
+
if (this.config.smtpHost) return this.config.smtpHost;
|
|
93
|
+
const m = this.config.imapHost.match(/^imap\.(.+)$/);
|
|
94
|
+
return m ? `smtp.${m[1]}` : this.config.imapHost.replace("imap", "smtp");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private getSmtpPort(): number {
|
|
98
|
+
return this.config.smtpPort ?? 465;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private getSmtpUser(): string {
|
|
102
|
+
return this.config.smtpUser ?? this.config.imapUser;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private getSmtpPassword(): string {
|
|
106
|
+
return this.config.smtpPassword ?? this.config.imapPassword;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private ensureTransporter(): nodemailer.Transporter {
|
|
110
|
+
if (!this.transporter) {
|
|
111
|
+
this.transporter = nodemailer.createTransport({
|
|
112
|
+
host: this.getSmtpHost(),
|
|
113
|
+
port: this.getSmtpPort(),
|
|
114
|
+
secure: this.getSmtpPort() === 465,
|
|
115
|
+
auth: { user: this.getSmtpUser(), pass: this.getSmtpPassword() },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return this.transporter;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private withImap<T>(folder: string, fn: (imap: Imap) => Promise<T>): Promise<T> {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
if (!this.imap) {
|
|
124
|
+
reject(new Error("IMAP 未连接"));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.imap.openBox(folder, false, (err: Error | null) => {
|
|
128
|
+
if (err) {
|
|
129
|
+
reject(err);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
fn(this.imap!).then(resolve).catch(reject);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async send(opts: {
|
|
138
|
+
to: string;
|
|
139
|
+
cc?: string;
|
|
140
|
+
bcc?: string;
|
|
141
|
+
subject?: string;
|
|
142
|
+
body: string;
|
|
143
|
+
html?: string;
|
|
144
|
+
attachments?: { filename: string; content: string; contentType?: string }[];
|
|
145
|
+
}): Promise<void> {
|
|
146
|
+
const t = this.ensureTransporter();
|
|
147
|
+
await t.sendMail({
|
|
148
|
+
from: this.config.imapUser,
|
|
149
|
+
to: opts.to,
|
|
150
|
+
cc: opts.cc,
|
|
151
|
+
bcc: opts.bcc,
|
|
152
|
+
subject: opts.subject ?? "(无主题)",
|
|
153
|
+
text: opts.body,
|
|
154
|
+
html: opts.html,
|
|
155
|
+
attachments: opts.attachments?.map((a) => ({
|
|
156
|
+
filename: a.filename,
|
|
157
|
+
content: a.content,
|
|
158
|
+
contentType: a.contentType,
|
|
159
|
+
})),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async reply(opts: {
|
|
164
|
+
uid: number;
|
|
165
|
+
folder?: string;
|
|
166
|
+
body: string;
|
|
167
|
+
html?: string;
|
|
168
|
+
attachments?: { filename: string; content: string; contentType?: string }[];
|
|
169
|
+
}): Promise<void> {
|
|
170
|
+
const detail = await this.read({ uid: opts.uid, folder: opts.folder });
|
|
171
|
+
if (!detail) throw new Error(`邮件 uid=${opts.uid} 不存在`);
|
|
172
|
+
const orig = await this.fetchRaw(opts.uid, opts.folder ?? "INBOX");
|
|
173
|
+
if (!orig) throw new Error(`无法获取原始邮件`);
|
|
174
|
+
const inReplyTo = (orig as { messageId?: string }).messageId;
|
|
175
|
+
const refs = (orig as { references?: string[] | string }).references;
|
|
176
|
+
const references = Array.isArray(refs) ? refs.join(" ") : refs ?? inReplyTo ?? "";
|
|
177
|
+
const to = orig.from?.value?.[0]?.address ?? "";
|
|
178
|
+
const subj = (orig.subject ?? "").startsWith("Re:") ? orig.subject : `Re: ${orig.subject ?? ""}`;
|
|
179
|
+
const t = this.ensureTransporter();
|
|
180
|
+
await t.sendMail({
|
|
181
|
+
from: this.config.imapUser,
|
|
182
|
+
to,
|
|
183
|
+
subject: subj,
|
|
184
|
+
text: opts.body,
|
|
185
|
+
html: opts.html,
|
|
186
|
+
attachments: opts.attachments?.map((a) => ({
|
|
187
|
+
filename: a.filename,
|
|
188
|
+
content: a.content,
|
|
189
|
+
contentType: a.contentType,
|
|
190
|
+
})),
|
|
191
|
+
inReplyTo,
|
|
192
|
+
references,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async forward(opts: {
|
|
197
|
+
uid: number;
|
|
198
|
+
folder?: string;
|
|
199
|
+
to: string;
|
|
200
|
+
cc?: string;
|
|
201
|
+
bcc?: string;
|
|
202
|
+
body?: string;
|
|
203
|
+
}): Promise<void> {
|
|
204
|
+
const raw = await this.fetchRaw(opts.uid, opts.folder ?? "INBOX");
|
|
205
|
+
if (!raw) throw new Error(`邮件 uid=${opts.uid} 不存在`);
|
|
206
|
+
const subj = (raw.subject ?? "").startsWith("Fwd:") ? raw.subject : `Fwd: ${raw.subject ?? ""}`;
|
|
207
|
+
const fwdText = raw.text ?? raw.html ?? "";
|
|
208
|
+
const t = this.ensureTransporter();
|
|
209
|
+
await t.sendMail({
|
|
210
|
+
from: this.config.imapUser,
|
|
211
|
+
to: opts.to,
|
|
212
|
+
cc: opts.cc,
|
|
213
|
+
bcc: opts.bcc,
|
|
214
|
+
subject: subj,
|
|
215
|
+
text: opts.body ? `${opts.body}\n\n--- 转发内容 ---\n${fwdText}` : fwdText,
|
|
216
|
+
html: opts.body ? `<p>${opts.body.replace(/\n/g, "<br>")}</p><hr>${raw.html ?? ""}` : raw.html,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async list(opts: {
|
|
221
|
+
folder?: string;
|
|
222
|
+
limit?: number;
|
|
223
|
+
offset?: number;
|
|
224
|
+
unreadOnly?: boolean;
|
|
225
|
+
}): Promise<EmailListItem[]> {
|
|
226
|
+
const folder = opts.folder ?? "INBOX";
|
|
227
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
|
|
228
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
229
|
+
return this.withImap(folder, async (imap) => {
|
|
230
|
+
const criteria: (string | string[])[] = opts.unreadOnly ? ["UNSEEN"] : ["ALL"];
|
|
231
|
+
const uids = await p<number[]>((cb) => imap.search(criteria as string[], cb));
|
|
232
|
+
if (uids.length === 0) return [];
|
|
233
|
+
const sorted = uids.sort((a, b) => b - a);
|
|
234
|
+
const slice = sorted.slice(offset, offset + limit);
|
|
235
|
+
if (slice.length === 0) return [];
|
|
236
|
+
const items: EmailListItem[] = [];
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
const f = imap.fetch(slice as unknown as Imap.MessageSource, {
|
|
239
|
+
bodies: "HEADER.FIELDS (FROM TO SUBJECT DATE)",
|
|
240
|
+
struct: false,
|
|
241
|
+
});
|
|
242
|
+
f.on("message", (msg) => {
|
|
243
|
+
let buf = "";
|
|
244
|
+
let attrs: { uid: number; flags?: string[] } | null = null;
|
|
245
|
+
msg.on("body", (stream: NodeJS.ReadableStream) => {
|
|
246
|
+
stream.on("data", (ch: Buffer) => {
|
|
247
|
+
buf += ch.toString();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
msg.once("attributes", (a: { uid: number; flags?: string[] }) => {
|
|
251
|
+
attrs = a;
|
|
252
|
+
});
|
|
253
|
+
msg.on("end", () => {
|
|
254
|
+
if (attrs && buf) {
|
|
255
|
+
const h = parseHeaderSafe(buf);
|
|
256
|
+
items.push({
|
|
257
|
+
uid: attrs.uid,
|
|
258
|
+
from: String(h.from?.[0] ?? ""),
|
|
259
|
+
to: String(h.to?.[0] ?? ""),
|
|
260
|
+
subject: String(h.subject?.[0] ?? "(无主题)"),
|
|
261
|
+
date: String(h.date?.[0] ?? new Date()),
|
|
262
|
+
isRead: (attrs.flags ?? []).includes("\\Seen"),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
f.once("error", reject);
|
|
268
|
+
f.on("end", () => resolve(items.sort((a, b) => b.uid - a.uid)));
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async read(opts: { uid: number; folder?: string }): Promise<EmailDetail | null> {
|
|
274
|
+
const folder = opts.folder ?? "INBOX";
|
|
275
|
+
const raw = await this.fetchRaw(opts.uid, folder);
|
|
276
|
+
if (!raw) return null;
|
|
277
|
+
const from = raw.from?.value?.[0];
|
|
278
|
+
const to = raw.to?.value?.map((v) => v.address).join(", ") ?? "";
|
|
279
|
+
const atts = (raw as { attachments?: Array<{ filename?: string; contentType?: string; size?: number }> }).attachments ?? [];
|
|
280
|
+
return {
|
|
281
|
+
uid: opts.uid,
|
|
282
|
+
from: from ? `${from.name || ""} <${from.address}>`.trim() || (from.address ?? "") : "",
|
|
283
|
+
to,
|
|
284
|
+
subject: raw.subject ?? "(无主题)",
|
|
285
|
+
date: raw.date?.toISOString() ?? "",
|
|
286
|
+
text: raw.text ?? undefined,
|
|
287
|
+
html: raw.html ?? undefined,
|
|
288
|
+
attachments: atts.map((a) => ({
|
|
289
|
+
filename: a.filename ?? "attachment",
|
|
290
|
+
contentType: a.contentType ?? "application/octet-stream",
|
|
291
|
+
size: a.size ?? 0,
|
|
292
|
+
})),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async fetchRaw(uid: number, folder: string): Promise<unknown> {
|
|
297
|
+
return this.withImap(folder, (imap) => {
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
let resolved = false;
|
|
300
|
+
const doResolve = (v: unknown) => {
|
|
301
|
+
if (!resolved) {
|
|
302
|
+
resolved = true;
|
|
303
|
+
resolve(v);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const f = imap.fetch(uid as Imap.MessageSource, { bodies: "" });
|
|
307
|
+
f.on("message", (msg) => {
|
|
308
|
+
msg.on("body", (stream: NodeJS.ReadableStream) => {
|
|
309
|
+
void simpleParser(stream as Parameters<typeof simpleParser>[0])
|
|
310
|
+
.then((mail) => doResolve(mail))
|
|
311
|
+
.catch((e) => {
|
|
312
|
+
if (!resolved) {
|
|
313
|
+
resolved = true;
|
|
314
|
+
reject(e);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
f.once("error", reject);
|
|
320
|
+
f.on("end", () => doResolve(null));
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async search(opts: {
|
|
326
|
+
query?: string;
|
|
327
|
+
from?: string;
|
|
328
|
+
to?: string;
|
|
329
|
+
subject?: string;
|
|
330
|
+
since?: string;
|
|
331
|
+
until?: string;
|
|
332
|
+
folder?: string;
|
|
333
|
+
limit?: number;
|
|
334
|
+
}): Promise<EmailListItem[]> {
|
|
335
|
+
const folder = opts.folder ?? "INBOX";
|
|
336
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
|
|
337
|
+
return this.withImap(folder, async (imap) => {
|
|
338
|
+
const criteria: (string | string[])[] = [];
|
|
339
|
+
if (opts.from) criteria.push(["FROM", opts.from]);
|
|
340
|
+
if (opts.to) criteria.push(["TO", opts.to]);
|
|
341
|
+
if (opts.subject) criteria.push(["SUBJECT", opts.subject]);
|
|
342
|
+
if (opts.since) criteria.push(["SINCE", opts.since]);
|
|
343
|
+
if (opts.until) criteria.push(["BEFORE", opts.until]);
|
|
344
|
+
if (opts.query) criteria.push(["BODY", opts.query]);
|
|
345
|
+
if (criteria.length === 0) criteria.push("ALL");
|
|
346
|
+
const uids = await p<number[]>((cb) => imap.search(criteria as string[], cb));
|
|
347
|
+
const sorted = uids.sort((a, b) => b - a).slice(0, limit);
|
|
348
|
+
if (sorted.length === 0) return [];
|
|
349
|
+
const items: EmailListItem[] = [];
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
const f = imap.fetch(sorted as unknown as Imap.MessageSource, {
|
|
352
|
+
bodies: "HEADER.FIELDS (FROM TO SUBJECT DATE)",
|
|
353
|
+
struct: false,
|
|
354
|
+
});
|
|
355
|
+
f.on("message", (msg) => {
|
|
356
|
+
let buf = "";
|
|
357
|
+
let attrs: { uid: number; flags?: string[] } | null = null;
|
|
358
|
+
msg.on("body", (stream: NodeJS.ReadableStream) => {
|
|
359
|
+
stream.on("data", (ch: Buffer) => {
|
|
360
|
+
buf += ch.toString();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
msg.once("attributes", (a: { uid: number; flags?: string[] }) => {
|
|
364
|
+
attrs = a;
|
|
365
|
+
});
|
|
366
|
+
msg.on("end", () => {
|
|
367
|
+
if (attrs && buf) {
|
|
368
|
+
const h = parseHeaderSafe(buf);
|
|
369
|
+
items.push({
|
|
370
|
+
uid: attrs.uid,
|
|
371
|
+
from: String(h.from?.[0] ?? ""),
|
|
372
|
+
to: String(h.to?.[0] ?? ""),
|
|
373
|
+
subject: String(h.subject?.[0] ?? "(无主题)"),
|
|
374
|
+
date: String(h.date?.[0] ?? new Date()),
|
|
375
|
+
isRead: (attrs.flags ?? []).includes("\\Seen"),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
f.once("error", reject);
|
|
381
|
+
f.on("end", () => resolve(items.sort((a, b) => b.uid - a.uid)));
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async markRead(opts: { uids: number[]; read: boolean; folder?: string }): Promise<void> {
|
|
387
|
+
const uids = opts.uids.slice(0, 500);
|
|
388
|
+
if (uids.length === 0) return;
|
|
389
|
+
const folder = opts.folder ?? "INBOX";
|
|
390
|
+
await this.withImap(folder, async (imap) => {
|
|
391
|
+
if (opts.read) {
|
|
392
|
+
await p((cb) => imap.addFlags(uids as unknown as Imap.MessageSource, "\\Seen", cb));
|
|
393
|
+
} else {
|
|
394
|
+
await p((cb) => imap.delFlags(uids as unknown as Imap.MessageSource, "\\Seen", cb));
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async move(opts: { uids: number[]; folder: string; fromFolder?: string }): Promise<void> {
|
|
400
|
+
const uids = opts.uids.slice(0, 500);
|
|
401
|
+
if (uids.length === 0) return;
|
|
402
|
+
const fromFolder = opts.fromFolder ?? "INBOX";
|
|
403
|
+
await this.withImap(fromFolder, async (imap) => {
|
|
404
|
+
await p((cb) => imap.move(uids as unknown as Imap.MessageSource, opts.folder, cb));
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async delete(opts: { uids: number[]; folder?: string }): Promise<void> {
|
|
409
|
+
const uids = opts.uids.slice(0, 500);
|
|
410
|
+
if (uids.length === 0) return;
|
|
411
|
+
const folder = opts.folder ?? "INBOX";
|
|
412
|
+
await this.withImap(folder, async (imap) => {
|
|
413
|
+
await p((cb) => imap.addFlags(uids as unknown as Imap.MessageSource, "\\Deleted", cb));
|
|
414
|
+
await p((cb) => imap.expunge(cb));
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async getAttachment(opts: { uid: number; folder?: string; filename: string }): Promise<string | null> {
|
|
419
|
+
const folder = opts.folder ?? "INBOX";
|
|
420
|
+
const raw = (await this.fetchRaw(opts.uid, folder)) as {
|
|
421
|
+
attachments?: Array<{ filename?: string; content?: Buffer | string }>;
|
|
422
|
+
} | null;
|
|
423
|
+
if (!raw?.attachments?.length) return null;
|
|
424
|
+
const att = raw.attachments.find((a) => (a.filename ?? "") === opts.filename);
|
|
425
|
+
if (!att?.content) return null;
|
|
426
|
+
return typeof att.content === "string"
|
|
427
|
+
? Buffer.from(att.content).toString("base64")
|
|
428
|
+
: att.content.toString("base64");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async sendText(to: string, text: string, subject?: string): Promise<void> {
|
|
432
|
+
await this.send({ to, body: text, subject });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
start(): void {
|
|
436
|
+
const imap = new Imap({
|
|
437
|
+
user: this.config.imapUser,
|
|
438
|
+
password: this.config.imapPassword,
|
|
439
|
+
host: this.config.imapHost,
|
|
440
|
+
port: this.config.imapPort,
|
|
441
|
+
tls: this.config.tls ?? true,
|
|
442
|
+
tlsOptions: { rejectUnauthorized: this.config.tlsRejectUnauthorized ?? true },
|
|
443
|
+
});
|
|
444
|
+
this.imap = imap;
|
|
445
|
+
|
|
446
|
+
const openInbox = (cb: (err: Error | null) => void) => {
|
|
447
|
+
imap.openBox("INBOX", false, cb);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const fetchNew = () => {
|
|
451
|
+
if (!this.imap || !this.onMessageHandler) return;
|
|
452
|
+
openInbox((err) => {
|
|
453
|
+
if (err) {
|
|
454
|
+
console.error("[email] IMAP openBox failed:", err);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
imap.search([["UID", `${this.lastUid + 1}:*`]], (searchErr, uids) => {
|
|
458
|
+
if (searchErr || !uids?.length) return;
|
|
459
|
+
const fetch = imap.fetch(uids, { bodies: "" });
|
|
460
|
+
fetch.on("message", (msg) => {
|
|
461
|
+
let uid = 0;
|
|
462
|
+
msg.once("attributes", (attrs: { uid: number }) => {
|
|
463
|
+
uid = attrs.uid;
|
|
464
|
+
});
|
|
465
|
+
msg.on("body", (stream: NodeJS.ReadableStream) => {
|
|
466
|
+
void simpleParser(stream as Parameters<typeof simpleParser>[0]).then((parsed) => {
|
|
467
|
+
if (parsed && uid) {
|
|
468
|
+
if (uid > this.lastUid) this.lastUid = uid;
|
|
469
|
+
this.emitMessage(parsed);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
fetch.on("end", () => {
|
|
475
|
+
this.storage?.setData("last_uid", String(this.lastUid)).catch(() => {});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
imap.once("ready", () => {
|
|
482
|
+
console.error("[email] IMAP connected to", this.config.imapHost);
|
|
483
|
+
this.storage?.getData("last_uid").then((v) => {
|
|
484
|
+
if (v) this.lastUid = parseInt(v, 10) || 0;
|
|
485
|
+
fetchNew();
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
imap.once("error", (err: Error) => console.error("[email] IMAP error:", err));
|
|
489
|
+
imap.once("end", () => console.error("[email] IMAP connection ended"));
|
|
490
|
+
imap.connect();
|
|
491
|
+
this.checkInterval = setInterval(fetchNew, 60 * 1000);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private async emitMessage(parsed: Awaited<ReturnType<typeof simpleParser>>): Promise<void> {
|
|
495
|
+
if (!parsed || !this.onMessageHandler) return;
|
|
496
|
+
const from = parsed.from?.value?.[0];
|
|
497
|
+
const fromAddr = from?.address ?? "unknown";
|
|
498
|
+
const fromName = from?.name ?? fromAddr;
|
|
499
|
+
const subject = parsed.subject ?? "(无主题)";
|
|
500
|
+
const text = parsed.text ?? parsed.html ?? "";
|
|
501
|
+
const date = parsed.date ? parsed.date.getTime() : Date.now();
|
|
502
|
+
const attachments = await this.saveAttachmentsToWorkdir(parsed);
|
|
503
|
+
const d = new Date(date);
|
|
504
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
505
|
+
const contextTags: ContextTag[] = [
|
|
506
|
+
{ desc: "类型", value: "私聊", core: true },
|
|
507
|
+
{ desc: "会话", value: fromAddr, core: true },
|
|
508
|
+
{ desc: "昵称", value: fromName, core: true },
|
|
509
|
+
{ desc: "用户", value: fromAddr, core: true },
|
|
510
|
+
{ desc: "时间", value: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`, core: false },
|
|
511
|
+
];
|
|
512
|
+
this.onMessageHandler({
|
|
513
|
+
text: `[主题: ${subject}]\n\n${text}`.trim(),
|
|
514
|
+
contextTags,
|
|
515
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async saveAttachmentsToWorkdir(
|
|
520
|
+
parsed: Awaited<ReturnType<typeof simpleParser>>
|
|
521
|
+
): Promise<Array<{ type: string; path: string; mime?: string; name?: string }>> {
|
|
522
|
+
const rawAtts = (parsed as { attachments?: Array<{ content?: Buffer; filename?: string; contentType?: string }> })
|
|
523
|
+
.attachments;
|
|
524
|
+
if (!rawAtts?.length) return [];
|
|
525
|
+
const workDir = getWorkDir();
|
|
526
|
+
const blobsDir = workDir ? path.join(workDir, "blobs") : null;
|
|
527
|
+
if (!blobsDir) return [];
|
|
528
|
+
try {
|
|
529
|
+
await fs.promises.mkdir(blobsDir, { recursive: true });
|
|
530
|
+
} catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
const out: Array<{ type: string; path: string; mime?: string; name?: string }> = [];
|
|
534
|
+
const ts = Date.now();
|
|
535
|
+
for (let i = 0; i < rawAtts.length; i++) {
|
|
536
|
+
const a = rawAtts[i];
|
|
537
|
+
if (!a?.content || !Buffer.isBuffer(a.content)) continue;
|
|
538
|
+
const base = (a.filename ?? "attachment").replace(/[^a-zA-Z0-9._-]/g, "_") || "attachment";
|
|
539
|
+
const ext = path.extname(base) || "";
|
|
540
|
+
const stem = path.basename(base, ext) || "attachment";
|
|
541
|
+
const filename = `${stem}_${ts}_${i}${ext}`;
|
|
542
|
+
const outPath = path.join(blobsDir, filename);
|
|
543
|
+
try {
|
|
544
|
+
await fs.promises.writeFile(outPath, a.content);
|
|
545
|
+
const mime = a.contentType ?? "application/octet-stream";
|
|
546
|
+
const type = mime.startsWith("image/") ? "image" : mime.startsWith("audio/") ? "audio" : mime.startsWith("video/") ? "video" : "file";
|
|
547
|
+
out.push({ type, path: outPath, mime, name: a.filename ?? filename });
|
|
548
|
+
} catch {
|
|
549
|
+
// 落盘失败则跳过该附件
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
stop?(): void {
|
|
556
|
+
if (this.checkInterval) {
|
|
557
|
+
clearInterval(this.checkInterval);
|
|
558
|
+
this.checkInterval = null;
|
|
559
|
+
}
|
|
560
|
+
if (this.imap) {
|
|
561
|
+
this.imap.end();
|
|
562
|
+
this.imap = null;
|
|
563
|
+
}
|
|
564
|
+
this.transporter = null;
|
|
565
|
+
}
|
|
566
|
+
}
|