@lmcl/ailo-mcp-email 0.0.1
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 +45 -0
- package/dist/email-handler.js +486 -0
- package/dist/index.js +33 -0
- package/dist/mcp-server.js +194 -0
- package/package.json +35 -0
- package/src/email-handler.ts +565 -0
- package/src/index.ts +39 -0
- package/src/mcp-server.ts +209 -0
- package/src/types.d.ts +51 -0
- package/tsconfig.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @lmcl/ailo-mcp-email
|
|
2
|
+
|
|
3
|
+
Ailo 邮件通道 MCP:IMAP 收信 + SMTP 发信。
|
|
4
|
+
|
|
5
|
+
## 配置
|
|
6
|
+
|
|
7
|
+
| 变量 | 必填 | 说明 |
|
|
8
|
+
|------|------|------|
|
|
9
|
+
| IMAP_HOST | 是 | IMAP 服务器(如 imap.qq.com) |
|
|
10
|
+
| IMAP_USER | 是 | 邮箱账号 |
|
|
11
|
+
| IMAP_PASSWORD | 是 | 密码或授权码 |
|
|
12
|
+
| IMAP_PORT | 否 | 默认 993 |
|
|
13
|
+
| SMTP_HOST | 否 | 不填则从 IMAP 推测(如 smtp.qq.com) |
|
|
14
|
+
| SMTP_PORT | 否 | 默认 465 |
|
|
15
|
+
| SMTP_USER | 否 | 不填则用 IMAP_USER |
|
|
16
|
+
| SMTP_PASSWORD | 否 | 不填则用 IMAP_PASSWORD |
|
|
17
|
+
| TLS_REJECT_UNAUTHORIZED | 否 | 默认 true 验证证书;自签名可设为 false |
|
|
18
|
+
|
|
19
|
+
## 在 Aido 中添加
|
|
20
|
+
|
|
21
|
+
通过 `mcp_manage` 工具创建。**name 只能含字母、汉字、下划线**(无标点无数字),推荐纯英文尽量短:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
mcp_manage(action=create, name="email", command="npx", args=["@lmcl/ailo-mcp-email"], env={IMAP_HOST: "imap.qq.com", IMAP_USER: "xxx", IMAP_PASSWORD: "xxx"})
|
|
25
|
+
mcp_manage(action=start, name="email")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 本地开发
|
|
29
|
+
|
|
30
|
+
创建 `.env` 文件,填入配置后启动:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# .env 示例(必填:IMAP_HOST、IMAP_USER、IMAP_PASSWORD)
|
|
34
|
+
IMAP_HOST=imap.qq.com
|
|
35
|
+
IMAP_PORT=993
|
|
36
|
+
IMAP_USER=your@email.com
|
|
37
|
+
IMAP_PASSWORD=your_auth_code
|
|
38
|
+
# SMTP_HOST=...
|
|
39
|
+
# SMTP_PORT=465
|
|
40
|
+
# TLS_REJECT_UNAUTHORIZED=false # 自签名证书时
|
|
41
|
+
|
|
42
|
+
npm install
|
|
43
|
+
npm run build
|
|
44
|
+
npm start
|
|
45
|
+
```
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 邮件通道 Handler:IMAP 收信/读信/搜索/组织 + nodemailer 发信/回复/转发
|
|
3
|
+
*/
|
|
4
|
+
// @ts-nocheck
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import Imap from "imap";
|
|
8
|
+
import { simpleParser } from "mailparser";
|
|
9
|
+
import nodemailer from "nodemailer";
|
|
10
|
+
import { getWorkDir } from "@lmcl/ailo-mcp-sdk";
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
12
|
+
const parseHeader = require("imap/lib/Parser").parseHeader;
|
|
13
|
+
function p(fn) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
fn((err, result) => (err ? reject(err) : resolve(result)));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function parseHeaderSafe(buf) {
|
|
19
|
+
try {
|
|
20
|
+
return parseHeader(buf);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class EmailHandler {
|
|
27
|
+
config;
|
|
28
|
+
onMessageHandler = null;
|
|
29
|
+
imap = null;
|
|
30
|
+
transporter = null;
|
|
31
|
+
checkInterval = null;
|
|
32
|
+
lastUid = 0;
|
|
33
|
+
storage = null;
|
|
34
|
+
constructor(config) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
}
|
|
37
|
+
setDataProvider(storage) {
|
|
38
|
+
this.storage = storage;
|
|
39
|
+
}
|
|
40
|
+
setOnMessage(handler) {
|
|
41
|
+
this.onMessageHandler = handler;
|
|
42
|
+
}
|
|
43
|
+
getSmtpHost() {
|
|
44
|
+
if (this.config.smtpHost)
|
|
45
|
+
return this.config.smtpHost;
|
|
46
|
+
const m = this.config.imapHost.match(/^imap\.(.+)$/);
|
|
47
|
+
return m ? `smtp.${m[1]}` : this.config.imapHost.replace("imap", "smtp");
|
|
48
|
+
}
|
|
49
|
+
getSmtpPort() {
|
|
50
|
+
return this.config.smtpPort ?? 465;
|
|
51
|
+
}
|
|
52
|
+
getSmtpUser() {
|
|
53
|
+
return this.config.smtpUser ?? this.config.imapUser;
|
|
54
|
+
}
|
|
55
|
+
getSmtpPassword() {
|
|
56
|
+
return this.config.smtpPassword ?? this.config.imapPassword;
|
|
57
|
+
}
|
|
58
|
+
ensureTransporter() {
|
|
59
|
+
if (!this.transporter) {
|
|
60
|
+
this.transporter = nodemailer.createTransport({
|
|
61
|
+
host: this.getSmtpHost(),
|
|
62
|
+
port: this.getSmtpPort(),
|
|
63
|
+
secure: this.getSmtpPort() === 465,
|
|
64
|
+
auth: { user: this.getSmtpUser(), pass: this.getSmtpPassword() },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return this.transporter;
|
|
68
|
+
}
|
|
69
|
+
withImap(folder, fn) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
if (!this.imap) {
|
|
72
|
+
reject(new Error("IMAP 未连接"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.imap.openBox(folder, false, (err) => {
|
|
76
|
+
if (err) {
|
|
77
|
+
reject(err);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
fn(this.imap).then(resolve).catch(reject);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async send(opts) {
|
|
85
|
+
const t = this.ensureTransporter();
|
|
86
|
+
await t.sendMail({
|
|
87
|
+
from: this.config.imapUser,
|
|
88
|
+
to: opts.to,
|
|
89
|
+
cc: opts.cc,
|
|
90
|
+
bcc: opts.bcc,
|
|
91
|
+
subject: opts.subject ?? "(无主题)",
|
|
92
|
+
text: opts.body,
|
|
93
|
+
html: opts.html,
|
|
94
|
+
attachments: opts.attachments?.map((a) => ({
|
|
95
|
+
filename: a.filename,
|
|
96
|
+
content: a.content,
|
|
97
|
+
contentType: a.contentType,
|
|
98
|
+
})),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async reply(opts) {
|
|
102
|
+
const detail = await this.read({ uid: opts.uid, folder: opts.folder });
|
|
103
|
+
if (!detail)
|
|
104
|
+
throw new Error(`邮件 uid=${opts.uid} 不存在`);
|
|
105
|
+
const orig = await this.fetchRaw(opts.uid, opts.folder ?? "INBOX");
|
|
106
|
+
if (!orig)
|
|
107
|
+
throw new Error(`无法获取原始邮件`);
|
|
108
|
+
const inReplyTo = orig.messageId;
|
|
109
|
+
const refs = orig.references;
|
|
110
|
+
const references = Array.isArray(refs) ? refs.join(" ") : refs ?? inReplyTo ?? "";
|
|
111
|
+
const to = orig.from?.value?.[0]?.address ?? "";
|
|
112
|
+
const subj = (orig.subject ?? "").startsWith("Re:") ? orig.subject : `Re: ${orig.subject ?? ""}`;
|
|
113
|
+
const t = this.ensureTransporter();
|
|
114
|
+
await t.sendMail({
|
|
115
|
+
from: this.config.imapUser,
|
|
116
|
+
to,
|
|
117
|
+
subject: subj,
|
|
118
|
+
text: opts.body,
|
|
119
|
+
html: opts.html,
|
|
120
|
+
attachments: opts.attachments?.map((a) => ({
|
|
121
|
+
filename: a.filename,
|
|
122
|
+
content: a.content,
|
|
123
|
+
contentType: a.contentType,
|
|
124
|
+
})),
|
|
125
|
+
inReplyTo,
|
|
126
|
+
references,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async forward(opts) {
|
|
130
|
+
const raw = await this.fetchRaw(opts.uid, opts.folder ?? "INBOX");
|
|
131
|
+
if (!raw)
|
|
132
|
+
throw new Error(`邮件 uid=${opts.uid} 不存在`);
|
|
133
|
+
const subj = (raw.subject ?? "").startsWith("Fwd:") ? raw.subject : `Fwd: ${raw.subject ?? ""}`;
|
|
134
|
+
const fwdText = raw.text ?? raw.html ?? "";
|
|
135
|
+
const t = this.ensureTransporter();
|
|
136
|
+
await t.sendMail({
|
|
137
|
+
from: this.config.imapUser,
|
|
138
|
+
to: opts.to,
|
|
139
|
+
cc: opts.cc,
|
|
140
|
+
bcc: opts.bcc,
|
|
141
|
+
subject: subj,
|
|
142
|
+
text: opts.body ? `${opts.body}\n\n--- 转发内容 ---\n${fwdText}` : fwdText,
|
|
143
|
+
html: opts.body ? `<p>${opts.body.replace(/\n/g, "<br>")}</p><hr>${raw.html ?? ""}` : raw.html,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async list(opts) {
|
|
147
|
+
const folder = opts.folder ?? "INBOX";
|
|
148
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
|
|
149
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
150
|
+
return this.withImap(folder, async (imap) => {
|
|
151
|
+
const criteria = opts.unreadOnly ? ["UNSEEN"] : ["ALL"];
|
|
152
|
+
const uids = await p((cb) => imap.search(criteria, cb));
|
|
153
|
+
if (uids.length === 0)
|
|
154
|
+
return [];
|
|
155
|
+
const sorted = uids.sort((a, b) => b - a);
|
|
156
|
+
const slice = sorted.slice(offset, offset + limit);
|
|
157
|
+
if (slice.length === 0)
|
|
158
|
+
return [];
|
|
159
|
+
const items = [];
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const f = imap.fetch(slice, {
|
|
162
|
+
bodies: "HEADER.FIELDS (FROM TO SUBJECT DATE)",
|
|
163
|
+
struct: false,
|
|
164
|
+
});
|
|
165
|
+
f.on("message", (msg) => {
|
|
166
|
+
let buf = "";
|
|
167
|
+
let attrs = null;
|
|
168
|
+
msg.on("body", (stream) => {
|
|
169
|
+
stream.on("data", (ch) => {
|
|
170
|
+
buf += ch.toString();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
msg.once("attributes", (a) => {
|
|
174
|
+
attrs = a;
|
|
175
|
+
});
|
|
176
|
+
msg.on("end", () => {
|
|
177
|
+
if (attrs && buf) {
|
|
178
|
+
const h = parseHeaderSafe(buf);
|
|
179
|
+
items.push({
|
|
180
|
+
uid: attrs.uid,
|
|
181
|
+
from: String(h.from?.[0] ?? ""),
|
|
182
|
+
to: String(h.to?.[0] ?? ""),
|
|
183
|
+
subject: String(h.subject?.[0] ?? "(无主题)"),
|
|
184
|
+
date: String(h.date?.[0] ?? new Date()),
|
|
185
|
+
isRead: (attrs.flags ?? []).includes("\\Seen"),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
f.once("error", reject);
|
|
191
|
+
f.on("end", () => resolve(items.sort((a, b) => b.uid - a.uid)));
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async read(opts) {
|
|
196
|
+
const folder = opts.folder ?? "INBOX";
|
|
197
|
+
const raw = await this.fetchRaw(opts.uid, folder);
|
|
198
|
+
if (!raw)
|
|
199
|
+
return null;
|
|
200
|
+
const from = raw.from?.value?.[0];
|
|
201
|
+
const to = raw.to?.value?.map((v) => v.address).join(", ") ?? "";
|
|
202
|
+
const atts = raw.attachments ?? [];
|
|
203
|
+
return {
|
|
204
|
+
uid: opts.uid,
|
|
205
|
+
from: from ? `${from.name || ""} <${from.address}>`.trim() || (from.address ?? "") : "",
|
|
206
|
+
to,
|
|
207
|
+
subject: raw.subject ?? "(无主题)",
|
|
208
|
+
date: raw.date?.toISOString() ?? "",
|
|
209
|
+
text: raw.text ?? undefined,
|
|
210
|
+
html: raw.html ?? undefined,
|
|
211
|
+
attachments: atts.map((a) => ({
|
|
212
|
+
filename: a.filename ?? "attachment",
|
|
213
|
+
contentType: a.contentType ?? "application/octet-stream",
|
|
214
|
+
size: a.size ?? 0,
|
|
215
|
+
})),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async fetchRaw(uid, folder) {
|
|
219
|
+
return this.withImap(folder, (imap) => {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
let resolved = false;
|
|
222
|
+
const doResolve = (v) => {
|
|
223
|
+
if (!resolved) {
|
|
224
|
+
resolved = true;
|
|
225
|
+
resolve(v);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const f = imap.fetch(uid, { bodies: "" });
|
|
229
|
+
f.on("message", (msg) => {
|
|
230
|
+
msg.on("body", (stream) => {
|
|
231
|
+
void simpleParser(stream)
|
|
232
|
+
.then((mail) => doResolve(mail))
|
|
233
|
+
.catch((e) => {
|
|
234
|
+
if (!resolved) {
|
|
235
|
+
resolved = true;
|
|
236
|
+
reject(e);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
f.once("error", reject);
|
|
242
|
+
f.on("end", () => doResolve(null));
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
async search(opts) {
|
|
247
|
+
const folder = opts.folder ?? "INBOX";
|
|
248
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
|
|
249
|
+
return this.withImap(folder, async (imap) => {
|
|
250
|
+
const criteria = [];
|
|
251
|
+
if (opts.from)
|
|
252
|
+
criteria.push(["FROM", opts.from]);
|
|
253
|
+
if (opts.to)
|
|
254
|
+
criteria.push(["TO", opts.to]);
|
|
255
|
+
if (opts.subject)
|
|
256
|
+
criteria.push(["SUBJECT", opts.subject]);
|
|
257
|
+
if (opts.since)
|
|
258
|
+
criteria.push(["SINCE", opts.since]);
|
|
259
|
+
if (opts.until)
|
|
260
|
+
criteria.push(["BEFORE", opts.until]);
|
|
261
|
+
if (opts.query)
|
|
262
|
+
criteria.push(["BODY", opts.query]);
|
|
263
|
+
if (criteria.length === 0)
|
|
264
|
+
criteria.push("ALL");
|
|
265
|
+
const uids = await p((cb) => imap.search(criteria, cb));
|
|
266
|
+
const sorted = uids.sort((a, b) => b - a).slice(0, limit);
|
|
267
|
+
if (sorted.length === 0)
|
|
268
|
+
return [];
|
|
269
|
+
const items = [];
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const f = imap.fetch(sorted, {
|
|
272
|
+
bodies: "HEADER.FIELDS (FROM TO SUBJECT DATE)",
|
|
273
|
+
struct: false,
|
|
274
|
+
});
|
|
275
|
+
f.on("message", (msg) => {
|
|
276
|
+
let buf = "";
|
|
277
|
+
let attrs = null;
|
|
278
|
+
msg.on("body", (stream) => {
|
|
279
|
+
stream.on("data", (ch) => {
|
|
280
|
+
buf += ch.toString();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
msg.once("attributes", (a) => {
|
|
284
|
+
attrs = a;
|
|
285
|
+
});
|
|
286
|
+
msg.on("end", () => {
|
|
287
|
+
if (attrs && buf) {
|
|
288
|
+
const h = parseHeaderSafe(buf);
|
|
289
|
+
items.push({
|
|
290
|
+
uid: attrs.uid,
|
|
291
|
+
from: String(h.from?.[0] ?? ""),
|
|
292
|
+
to: String(h.to?.[0] ?? ""),
|
|
293
|
+
subject: String(h.subject?.[0] ?? "(无主题)"),
|
|
294
|
+
date: String(h.date?.[0] ?? new Date()),
|
|
295
|
+
isRead: (attrs.flags ?? []).includes("\\Seen"),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
f.once("error", reject);
|
|
301
|
+
f.on("end", () => resolve(items.sort((a, b) => b.uid - a.uid)));
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async markRead(opts) {
|
|
306
|
+
const uids = opts.uids.slice(0, 500);
|
|
307
|
+
if (uids.length === 0)
|
|
308
|
+
return;
|
|
309
|
+
const folder = opts.folder ?? "INBOX";
|
|
310
|
+
await this.withImap(folder, async (imap) => {
|
|
311
|
+
if (opts.read) {
|
|
312
|
+
await p((cb) => imap.addFlags(uids, "\\Seen", cb));
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
await p((cb) => imap.delFlags(uids, "\\Seen", cb));
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
async move(opts) {
|
|
320
|
+
const uids = opts.uids.slice(0, 500);
|
|
321
|
+
if (uids.length === 0)
|
|
322
|
+
return;
|
|
323
|
+
const fromFolder = opts.fromFolder ?? "INBOX";
|
|
324
|
+
await this.withImap(fromFolder, async (imap) => {
|
|
325
|
+
await p((cb) => imap.move(uids, opts.folder, cb));
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async delete(opts) {
|
|
329
|
+
const uids = opts.uids.slice(0, 500);
|
|
330
|
+
if (uids.length === 0)
|
|
331
|
+
return;
|
|
332
|
+
const folder = opts.folder ?? "INBOX";
|
|
333
|
+
await this.withImap(folder, async (imap) => {
|
|
334
|
+
await p((cb) => imap.addFlags(uids, "\\Deleted", cb));
|
|
335
|
+
await p((cb) => imap.expunge(cb));
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async getAttachment(opts) {
|
|
339
|
+
const folder = opts.folder ?? "INBOX";
|
|
340
|
+
const raw = (await this.fetchRaw(opts.uid, folder));
|
|
341
|
+
if (!raw?.attachments?.length)
|
|
342
|
+
return null;
|
|
343
|
+
const att = raw.attachments.find((a) => (a.filename ?? "") === opts.filename);
|
|
344
|
+
if (!att?.content)
|
|
345
|
+
return null;
|
|
346
|
+
return typeof att.content === "string"
|
|
347
|
+
? Buffer.from(att.content).toString("base64")
|
|
348
|
+
: att.content.toString("base64");
|
|
349
|
+
}
|
|
350
|
+
async sendText(to, text, subject) {
|
|
351
|
+
await this.send({ to, body: text, subject });
|
|
352
|
+
}
|
|
353
|
+
start() {
|
|
354
|
+
const imap = new Imap({
|
|
355
|
+
user: this.config.imapUser,
|
|
356
|
+
password: this.config.imapPassword,
|
|
357
|
+
host: this.config.imapHost,
|
|
358
|
+
port: this.config.imapPort,
|
|
359
|
+
tls: this.config.tls ?? true,
|
|
360
|
+
tlsOptions: { rejectUnauthorized: this.config.tlsRejectUnauthorized ?? true },
|
|
361
|
+
});
|
|
362
|
+
this.imap = imap;
|
|
363
|
+
const openInbox = (cb) => {
|
|
364
|
+
imap.openBox("INBOX", false, cb);
|
|
365
|
+
};
|
|
366
|
+
const fetchNew = () => {
|
|
367
|
+
if (!this.imap || !this.onMessageHandler)
|
|
368
|
+
return;
|
|
369
|
+
openInbox((err) => {
|
|
370
|
+
if (err) {
|
|
371
|
+
console.error("[email] IMAP openBox failed:", err);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
imap.search([["UID", `${this.lastUid + 1}:*`]], (searchErr, uids) => {
|
|
375
|
+
if (searchErr || !uids?.length)
|
|
376
|
+
return;
|
|
377
|
+
const fetch = imap.fetch(uids, { bodies: "" });
|
|
378
|
+
fetch.on("message", (msg) => {
|
|
379
|
+
let uid = 0;
|
|
380
|
+
msg.once("attributes", (attrs) => {
|
|
381
|
+
uid = attrs.uid;
|
|
382
|
+
});
|
|
383
|
+
msg.on("body", (stream) => {
|
|
384
|
+
void simpleParser(stream).then((parsed) => {
|
|
385
|
+
if (parsed && uid) {
|
|
386
|
+
if (uid > this.lastUid)
|
|
387
|
+
this.lastUid = uid;
|
|
388
|
+
this.emitMessage(parsed);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
fetch.on("end", () => {
|
|
394
|
+
this.storage?.setData("last_uid", String(this.lastUid)).catch(() => { });
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
imap.once("ready", () => {
|
|
400
|
+
console.error("[email] IMAP connected to", this.config.imapHost);
|
|
401
|
+
this.storage?.getData("last_uid").then((v) => {
|
|
402
|
+
if (v)
|
|
403
|
+
this.lastUid = parseInt(v, 10) || 0;
|
|
404
|
+
fetchNew();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
imap.once("error", (err) => console.error("[email] IMAP error:", err));
|
|
408
|
+
imap.once("end", () => console.error("[email] IMAP connection ended"));
|
|
409
|
+
imap.connect();
|
|
410
|
+
this.checkInterval = setInterval(fetchNew, 60 * 1000);
|
|
411
|
+
}
|
|
412
|
+
async emitMessage(parsed) {
|
|
413
|
+
if (!parsed || !this.onMessageHandler)
|
|
414
|
+
return;
|
|
415
|
+
const from = parsed.from?.value?.[0];
|
|
416
|
+
const fromAddr = from?.address ?? "unknown";
|
|
417
|
+
const fromName = from?.name ?? fromAddr;
|
|
418
|
+
const subject = parsed.subject ?? "(无主题)";
|
|
419
|
+
const text = parsed.text ?? parsed.html ?? "";
|
|
420
|
+
const date = parsed.date ? parsed.date.getTime() : Date.now();
|
|
421
|
+
const attachments = await this.saveAttachmentsToWorkdir(parsed);
|
|
422
|
+
const d = new Date(date);
|
|
423
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
424
|
+
const contextTags = [
|
|
425
|
+
{ desc: "类型", value: "私聊", core: true },
|
|
426
|
+
{ desc: "会话", value: fromAddr, core: true },
|
|
427
|
+
{ desc: "昵称", value: fromName, core: true },
|
|
428
|
+
{ desc: "用户", value: fromAddr, core: true },
|
|
429
|
+
{ desc: "时间", value: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`, core: false },
|
|
430
|
+
];
|
|
431
|
+
this.onMessageHandler({
|
|
432
|
+
text: `[主题: ${subject}]\n\n${text}`.trim(),
|
|
433
|
+
contextTags,
|
|
434
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
async saveAttachmentsToWorkdir(parsed) {
|
|
438
|
+
const rawAtts = parsed
|
|
439
|
+
.attachments;
|
|
440
|
+
if (!rawAtts?.length)
|
|
441
|
+
return [];
|
|
442
|
+
const workDir = getWorkDir();
|
|
443
|
+
const blobsDir = workDir ? path.join(workDir, "blobs") : null;
|
|
444
|
+
if (!blobsDir)
|
|
445
|
+
return [];
|
|
446
|
+
try {
|
|
447
|
+
await fs.promises.mkdir(blobsDir, { recursive: true });
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
const out = [];
|
|
453
|
+
const ts = Date.now();
|
|
454
|
+
for (let i = 0; i < rawAtts.length; i++) {
|
|
455
|
+
const a = rawAtts[i];
|
|
456
|
+
if (!a?.content || !Buffer.isBuffer(a.content))
|
|
457
|
+
continue;
|
|
458
|
+
const base = (a.filename ?? "attachment").replace(/[^a-zA-Z0-9._-]/g, "_") || "attachment";
|
|
459
|
+
const ext = path.extname(base) || "";
|
|
460
|
+
const stem = path.basename(base, ext) || "attachment";
|
|
461
|
+
const filename = `${stem}_${ts}_${i}${ext}`;
|
|
462
|
+
const outPath = path.join(blobsDir, filename);
|
|
463
|
+
try {
|
|
464
|
+
await fs.promises.writeFile(outPath, a.content);
|
|
465
|
+
const mime = a.contentType ?? "application/octet-stream";
|
|
466
|
+
const type = mime.startsWith("image/") ? "image" : mime.startsWith("audio/") ? "audio" : mime.startsWith("video/") ? "video" : "file";
|
|
467
|
+
out.push({ type, path: outPath, mime, name: a.filename ?? filename });
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// 落盘失败则跳过该附件
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
stop() {
|
|
476
|
+
if (this.checkInterval) {
|
|
477
|
+
clearInterval(this.checkInterval);
|
|
478
|
+
this.checkInterval = null;
|
|
479
|
+
}
|
|
480
|
+
if (this.imap) {
|
|
481
|
+
this.imap.end();
|
|
482
|
+
this.imap = null;
|
|
483
|
+
}
|
|
484
|
+
this.transporter = null;
|
|
485
|
+
}
|
|
486
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runMcpChannel } from "@lmcl/ailo-mcp-sdk";
|
|
3
|
+
import "dotenv/config";
|
|
4
|
+
import { EmailHandler } from "./email-handler.js";
|
|
5
|
+
import { createEmailMcpServer } from "./mcp-server.js";
|
|
6
|
+
const IMAP_HOST = process.env.IMAP_HOST ?? "";
|
|
7
|
+
const IMAP_USER = process.env.IMAP_USER ?? "";
|
|
8
|
+
const IMAP_PASSWORD = process.env.IMAP_PASSWORD ?? "";
|
|
9
|
+
const IMAP_PORT = parseInt(process.env.IMAP_PORT ?? "993", 10);
|
|
10
|
+
if (!IMAP_HOST || !IMAP_USER || !IMAP_PASSWORD) {
|
|
11
|
+
console.error("Missing IMAP_HOST, IMAP_USER or IMAP_PASSWORD");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const handler = new EmailHandler({
|
|
15
|
+
imapHost: IMAP_HOST,
|
|
16
|
+
imapPort: IMAP_PORT,
|
|
17
|
+
imapUser: IMAP_USER,
|
|
18
|
+
imapPassword: IMAP_PASSWORD,
|
|
19
|
+
smtpHost: process.env.SMTP_HOST || undefined,
|
|
20
|
+
smtpPort: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : undefined,
|
|
21
|
+
smtpUser: process.env.SMTP_USER || undefined,
|
|
22
|
+
smtpPassword: process.env.SMTP_PASSWORD || undefined,
|
|
23
|
+
tlsRejectUnauthorized: process.env.TLS_REJECT_UNAUTHORIZED !== "false",
|
|
24
|
+
});
|
|
25
|
+
function emailBuildChannelPrompt() {
|
|
26
|
+
return `邮件通道:chat_id 为发件人邮箱地址。回复时使用 email 工具(action=send),to 填对方邮箱。`;
|
|
27
|
+
}
|
|
28
|
+
const mcpServer = createEmailMcpServer(handler);
|
|
29
|
+
runMcpChannel({
|
|
30
|
+
handler,
|
|
31
|
+
buildChannelPrompt: emailBuildChannelPrompt,
|
|
32
|
+
mcpServer,
|
|
33
|
+
});
|