码桶
发现社区成员的开源项目
index.js23 KB
/**
* 插件:邮箱助手(SMTP 发件 + IMAP 收件提示 + 智能助手收发)
* --------------------------------------------------
* 用途:
* 1) 配置 SMTP / IMAP 服务器后,机器人即可帮你「发邮件」「收邮件」。
* 2) 收到新邮件时自动在微信里提示(可选 AI 摘要)。
* 3) 接入智能助手 function calling:直接用自然语言让 AI 发邮件、看最近邮件、读某封邮件。
*
* 在微信里使用(指令):
* 邮箱 / 邮件 → 查看使用说明与配置状态
* 邮件 发送 收件人|主题|正文 → 发送一封邮件(分隔符 | 或 |)
* 邮件 收取 [数量] → 拉取最近若干封邮件(默认 5)
* 邮件 最新 → 查看最新一封邮件
*
* 配置(机器人 → 插件设置 → 邮箱助手):
* SMTP(发件):smtp_host / smtp_port / smtp_secure / smtp_user / smtp_pass / smtp_from
* IMAP(收件):imap_host / imap_port / imap_user / imap_pass(账号密码留空则复用 SMTP)
* 收到新邮件提示:notify(开关)+ ai_summary(AI 摘要开关)+ poll_interval(轮询秒数)
*
* 注意:多数邮箱(QQ / 163 / Gmail 等)需使用「授权码 / 应用专用密码」而非登录密码。
*/
const db = require('../../lib/db');
const ILink = require('../../lib/ilink');
const nodemailer = require('nodemailer');
const { ImapFlow } = require('imapflow');
const { simpleParser } = require('mailparser');
// ==================== 建表(记录每个机器人已读到的最大 UID,用于新邮件检测) ====================
let tablesReady = false;
async function ensureTables() {
if (tablesReady) return;
try {
await db.exec(`CREATE TABLE IF NOT EXISTS mail_state (
bot_id INTEGER PRIMARY KEY,
last_uid INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)`);
tablesReady = true;
} catch (e) {
console.error('[mail] 建表失败:', e.message);
}
}
// ==================== 配置读取 ====================
async function loadConfig(botId) {
const rows = await db.rows(
'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
[botId, 'mail']
);
const c = {};
rows.forEach(r => { c[r.config_key] = r.config_value; });
const smtpHost = (c.smtp_host || '').trim();
const smtpUser = (c.smtp_user || '').trim();
const smtpPass = (c.smtp_pass || '').trim();
const smtpPort = parseInt(c.smtp_port, 10) || 465;
const smtpSecure = c.smtp_secure !== undefined ? c.smtp_secure === '1' : (smtpPort === 465);
const imapHost = (c.imap_host || '').trim();
const imapUser = (c.imap_user || '').trim() || smtpUser;
const imapPass = (c.imap_pass || '').trim() || smtpPass;
const imapPort = parseInt(c.imap_port, 10) || 993;
return {
smtp: { host: smtpHost, port: smtpPort, secure: smtpSecure, user: smtpUser, pass: smtpPass, from: (c.smtp_from || '').trim() || smtpUser },
imap: { host: imapHost, port: imapPort, secure: true, user: imapUser, pass: imapPass },
notify: c.notify === '1',
aiSummary: c.ai_summary === '1',
pollInterval: Math.max(30, parseInt(c.poll_interval, 10) || 120),
hasSmtp: !!(smtpHost && smtpUser && smtpPass),
hasImap: !!(imapHost && imapUser && imapPass),
};
}
// ==================== 发件(SMTP / nodemailer) ====================
async function sendMail(botId, { to, subject, text, html }) {
const cfg = await loadConfig(botId);
if (!cfg.hasSmtp) throw new Error('尚未配置 SMTP 发件服务器,请在插件设置中填写 smtp_host / smtp_user / smtp_pass');
to = String(to || '').trim();
if (!to) throw new Error('缺少收件人');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to.split(/[,;]/)[0].trim())) throw new Error('收件人邮箱格式不正确:' + to);
// QQ/163 等邮箱 SMTP 要求发件人必须与授权账号一致 → 自动修正 from 中的邮箱部分
let fromAddr = cfg.smtp.from || cfg.smtp.user;
const fromEmailMatch = fromAddr.match(/<([^>@]+@[^>]+)>/) || fromAddr.match(/^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/);
if (fromEmailMatch && fromEmailMatch[1].toLowerCase() !== cfg.smtp.user.toLowerCase()) {
const displayName = fromAddr.match(/^([^<]*)\s*</);
fromAddr = displayName && displayName[1].trim() ? `${displayName[1].trim()} <${cfg.smtp.user}>` : cfg.smtp.user;
}
const transporter = nodemailer.createTransport({
host: cfg.smtp.host,
port: cfg.smtp.port,
secure: cfg.smtp.secure,
auth: { user: cfg.smtp.user, pass: cfg.smtp.pass },
});
const info = await transporter.sendMail({
from: fromAddr,
to,
subject: String(subject || '(无主题)'),
text: text != null ? String(text) : undefined,
html: html != null ? String(html) : undefined,
});
return { messageId: info.messageId, accepted: info.accepted, to, subject };
}
// ==================== 收件(IMAP / imapflow) ====================
/** 建立 IMAP 连接(调用方负责 logout) */
async function imapConnect(cfg) {
const client = new ImapFlow({
host: cfg.imap.host,
port: cfg.imap.port,
secure: cfg.imap.secure,
auth: { user: cfg.imap.user, pass: cfg.imap.pass },
logger: false,
});
await client.connect();
return client;
}
/** 把一封邮件的原始 source 解析成简洁对象 */
async function parseMail(source, envelope, uid) {
let parsed = {};
try { parsed = await simpleParser(source); } catch (_) { /* ignore */ }
const fromAddr = (envelope && envelope.from && envelope.from[0]) || {};
return {
uid,
subject: (envelope && envelope.subject) || parsed.subject || '(无主题)',
from: fromAddr.name ? `${fromAddr.name} <${fromAddr.address}>` : (fromAddr.address || (parsed.from && parsed.from.text) || '未知'),
fromAddress: fromAddr.address || '',
date: (envelope && envelope.date) || parsed.date || null,
text: (parsed.text || '').trim(),
};
}
/** 拉取最近 N 封邮件(返回按时间倒序) */
async function fetchRecent(botId, limit = 5) {
const cfg = await loadConfig(botId);
if (!cfg.hasImap) throw new Error('尚未配置 IMAP 收件服务器,请在插件设置中填写 imap_host(账号密码可复用 SMTP)');
limit = Math.max(1, Math.min(20, parseInt(limit, 10) || 5));
const client = await imapConnect(cfg);
const out = [];
const lock = await client.getMailboxLock('INBOX');
try {
const total = client.mailbox.exists || 0;
if (total > 0) {
const start = Math.max(1, total - limit + 1);
for await (const msg of client.fetch(`${start}:*`, { envelope: true, source: true, uid: true })) {
out.push(await parseMail(msg.source, msg.envelope, msg.uid));
}
}
} finally {
lock.release();
}
await client.logout().catch(() => {});
return out.reverse(); // 最新在前
}
// ==================== peer 解析与发送(复用 push 的思路) ====================
async function defaultPeer(botId) {
const row = await db.row(
"SELECT peer_id FROM messages WHERE bot_id=? AND direction='in' AND peer_id IS NOT NULL AND peer_id != '' ORDER BY id DESC LIMIT 1",
[botId]
);
return row ? row.peer_id : '';
}
async function resolveCtx(botId, peerId) {
if (peerId) {
const lastIn = await db.row(
"SELECT context_token FROM messages WHERE bot_id=? AND peer_id=? AND direction='in' AND context_token IS NOT NULL AND context_token != '' ORDER BY id DESC LIMIT 1",
[botId, peerId]
);
if (lastIn && lastIn.context_token) return lastIn.context_token;
}
const latestIn = await db.row(
"SELECT context_token FROM messages WHERE bot_id=? AND direction='in' AND context_token IS NOT NULL AND context_token != '' ORDER BY id DESC LIMIT 1",
[botId]
);
if (latestIn && latestIn.context_token) return latestIn.context_token;
const bot = await db.row('SELECT context_token FROM bots WHERE id=?', [botId]);
return bot && bot.context_token ? bot.context_token : null;
}
async function sendToPeer(botId, peerId, text) {
const ctxToken = await resolveCtx(botId, peerId);
if (!ctxToken) throw new Error('无法解析 context_token,请确保该会话近期有消息往来');
const bot = await db.row('SELECT bot_token, base_url FROM bots WHERE id=?', [botId]);
if (!bot) throw new Error('机器人不存在');
const il = new ILink({ bot_token: bot.bot_token, base_url: bot.base_url });
const resp = await il.sendMessage(peerId, ctxToken, String(text));
if ((resp.ret ?? -1) !== 0) throw new Error('发送失败: ' + JSON.stringify(resp));
const textStr = String(text);
await db.exec(
'INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, created_at) VALUES (?,?,?,?,?,?,?)',
[botId, 'out', peerId, textStr, 'text', ctxToken, Math.floor(Date.now() / 1000)]
);
}
// ==================== 新邮件提示调度器 ====================
let schedulerStarted = false;
const lastPollAt = new Map(); // botId -> ms
function startScheduler() {
if (schedulerStarted || global.__mailSchedulerStarted) return;
schedulerStarted = true;
global.__mailSchedulerStarted = true;
setInterval(pollAllBots, 30000).unref();
console.log('[mail] 新邮件提示调度器已启动');
}
async function pollAllBots() {
try {
await ensureTables();
// 只轮询已启用本插件的机器人
const bots = await db.rows("SELECT bot_id FROM plugins WHERE market_id='mail' AND enabled=1");
const now = Date.now();
// 清理已停用/删除 bot 的 lastPollAt 条目(防内存泄漏)
const activeBotIds = new Set(bots.map(b => b.bot_id));
for (const botId of lastPollAt.keys()) {
if (!activeBotIds.has(botId)) lastPollAt.delete(botId);
}
for (const b of bots) {
const botId = b.bot_id;
try {
const cfg = await loadConfig(botId);
if (!cfg.notify || !cfg.hasImap) continue;
const last = lastPollAt.get(botId) || 0;
if (now - last < cfg.pollInterval * 1000) continue;
lastPollAt.set(botId, now);
await pollBot(botId, cfg);
} catch (e) {
console.error('[mail] 机器人', botId, '轮询失败:', e.message);
}
}
} catch (e) {
console.error('[mail] 调度错误:', e.message);
}
}
async function pollBot(botId, cfg) {
const state = await db.row('SELECT last_uid FROM mail_state WHERE bot_id=?', [botId]);
const lastUid = state ? state.last_uid : 0;
const client = await imapConnect(cfg);
const lock = await client.getMailboxLock('INBOX');
const fresh = [];
let maxUid = lastUid;
try {
const total = client.mailbox.exists || 0;
if (total > 0) {
// 首次运行:只记录当前最大 UID,不推送历史邮件,避免刷屏
if (lastUid === 0) {
for await (const msg of client.fetch('1:*', { uid: true })) {
if (msg.uid > maxUid) maxUid = msg.uid;
}
} else {
for await (const msg of client.fetch({ uid: `${lastUid + 1}:*` }, { envelope: true, source: true, uid: true })) {
if (msg.uid <= lastUid) continue;
if (msg.uid > maxUid) maxUid = msg.uid;
fresh.push(await parseMail(msg.source, msg.envelope, msg.uid));
}
}
}
} finally {
lock.release();
}
await client.logout().catch(() => {});
const now = Math.floor(Date.now() / 1000);
await db.exec(
'INSERT INTO mail_state (bot_id, last_uid, updated_at) VALUES (?,?,?) ON CONFLICT(bot_id) DO UPDATE SET last_uid=excluded.last_uid, updated_at=excluded.updated_at',
[botId, maxUid, now]
);
if (!fresh.length) return;
const peer = await defaultPeer(botId);
if (!peer) return;
for (const m of fresh.sort((a, b) => a.uid - b.uid)) {
let body = m.text;
if (cfg.aiSummary && body) {
body = await aiSummarize(botId, m);
} else if (body && body.length > 400) {
body = body.slice(0, 400) + '…';
}
const dateStr = m.date ? new Date(m.date).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
const lines = [
'[Mail] 收到新邮件',
'发件人:' + m.from,
'主题:' + m.subject,
dateStr ? '时间:' + dateStr : '',
'',
body || '(无正文)',
].filter(Boolean);
try { await sendToPeer(botId, peer, lines.join('\n')); } catch (e) {
console.error('[mail] 推送新邮件失败:', e.message);
}
}
}
/** 用智能助手对邮件做摘要,失败回退原文截断 */
async function aiSummarize(botId, mail) {
try {
const pluginsLib = require('../../lib/plugins');
const bot = await db.row('SELECT user_id FROM bots WHERE id=?', [botId]);
const prompt = `请用中文简明总结下面这封邮件的核心内容(3 句以内,突出要点和需要处理的事项):\n\n主题:${mail.subject}\n发件人:${mail.from}\n正文:\n${(mail.text || '').slice(0, 3000)}`;
const text = await pluginsLib.callAssistant({ botId, userId: bot ? bot.user_id : null, prompt });
if (text && text.trim()) return '[Summary] 摘要:' + text.trim();
} catch (e) {
console.error('[mail] AI 摘要失败,回退原文:', e.message);
}
const t = mail.text || '';
return t.length > 400 ? t.slice(0, 400) + '…' : t;
}
// ==================== 智能助手 AI 工具(function calling) ====================
const aiTools = [
{
type: 'function',
function: {
name: 'send_email',
description: '发送一封电子邮件。当用户说“发邮件给XX”“给XX发个邮件”“帮我写封邮件发出去”等意图时调用。',
parameters: {
type: 'object',
properties: {
to: { type: 'string', description: '收件人邮箱地址,多个用逗号分隔' },
subject: { type: 'string', description: '邮件主题' },
body: { type: 'string', description: '邮件正文(纯文本)' },
},
required: ['to', 'subject', 'body'],
},
},
},
{
type: 'function',
function: {
name: 'list_recent_emails',
description: '查看收件箱最近的邮件列表(主题、发件人、时间)。当用户说“看看我的邮件”“最近有什么邮件”“收件箱”时调用。',
parameters: {
type: 'object',
properties: {
limit: { type: 'integer', description: '要查看的邮件数量,默认 5,最多 20' },
},
},
},
},
{
type: 'function',
function: {
name: 'read_email',
description: '读取某封邮件的完整正文。当用户说“读第X封邮件”“看看第一封邮件写了什么”时调用;index 为 list_recent_emails 列表中的序号(从 1 开始)。',
parameters: {
type: 'object',
properties: {
index: { type: 'integer', description: '邮件序号,从 1 开始(1=最新一封)' },
},
required: ['index'],
},
},
},
];
async function handleAiTool(name, args, ctx) {
const botId = ctx.bot.id;
switch (name) {
case 'send_email': {
try {
const r = await sendMail(botId, { to: args.to, subject: args.subject, text: args.body });
return `[OK] 邮件已发送给 ${r.to},主题「${r.subject}」。`;
} catch (e) {
return '发送邮件失败:' + e.message;
}
}
case 'list_recent_emails': {
try {
const list = await fetchRecent(botId, args.limit || 5);
if (!list.length) return '收件箱是空的。';
const lines = list.map((m, i) => {
const ds = m.date ? new Date(m.date).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
return `${i + 1}. 【${m.subject}】 来自 ${m.from}${ds ? ' · ' + ds : ''}`;
});
return '[Mail] 最近邮件:\n' + lines.join('\n') + '\n\n可说“读第 N 封邮件”查看正文。';
} catch (e) {
return '获取邮件失败:' + e.message;
}
}
case 'read_email': {
try {
const idx = Math.max(1, parseInt(args.index, 10) || 1);
const list = await fetchRecent(botId, Math.max(idx, 5));
const m = list[idx - 1];
if (!m) return `没有第 ${idx} 封邮件。`;
let body = m.text || '(无正文)';
if (body.length > 1500) body = body.slice(0, 1500) + '\n…(内容较长已截断)';
const ds = m.date ? new Date(m.date).toLocaleString('zh-CN') : '';
return `[Mail] 《${m.subject}》\n发件人:${m.from}${ds ? '\n时间:' + ds : ''}\n\n${body}`;
} catch (e) {
return '读取邮件失败:' + e.message;
}
}
default:
return `未知邮件工具:${name}`;
}
}
// ==================== 插件入口 ====================
const PREFIX_RE = /^(邮箱|邮件|email|mail)\s*/i;
module.exports = {
meta: {
id: 'mail',
name: '邮箱助手',
version: '1.0.0',
author: '奶狗',
category: '消息处理',
description: '配置 SMTP/IMAP 后,机器人可帮你收发邮件:智能助手支持自然语言「发邮件/看邮件/读邮件」,收到新邮件时自动在微信提示(可选 AI 摘要)。',
entry: 'mail/index.js',
commandPrefix: ['邮箱', '邮件', 'email', 'mail'],
// 用通用表单渲染 SMTP / IMAP 配置
settingsSchema: [
{ key: 'smtp_host', label: 'SMTP 发件服务器', type: 'text', placeholder: '如 smtp.qq.com', help: '发送邮件用的服务器地址' },
{ key: 'smtp_port', label: 'SMTP 端口', type: 'number', placeholder: '465(SSL)或 587' },
{ key: 'smtp_secure', label: 'SMTP 使用 SSL(465 端口开启)', type: 'switch' },
{ key: 'smtp_user', label: '邮箱账号', type: 'text', placeholder: '[email protected]' },
{ key: 'smtp_pass', label: '邮箱密码 / 授权码', type: 'password', help: 'QQ/163/Gmail 等需使用「授权码 / 应用专用密码」' },
{ key: 'smtp_from', label: '发件人显示(可选)', type: 'text', placeholder: '奶狗Bot <[email protected]>' },
{ key: 'imap_host', label: 'IMAP 收件服务器', type: 'text', placeholder: '如 imap.qq.com', help: '收取邮件用的服务器地址' },
{ key: 'imap_port', label: 'IMAP 端口', type: 'number', placeholder: '993(默认)' },
{ key: 'imap_user', label: 'IMAP 账号(留空复用 SMTP)', type: 'text', placeholder: '默认同邮箱账号' },
{ key: 'imap_pass', label: 'IMAP 密码 / 授权码(留空复用 SMTP)', type: 'password' },
{ key: 'notify', label: '收到新邮件时在微信提示', type: 'switch' },
{ key: 'ai_summary', label: '新邮件用智能助手 AI 摘要', type: 'switch' },
{ key: 'poll_interval', label: '收件轮询间隔(秒,最小 30,默认 120)', type: 'number', placeholder: '120' },
],
},
// 供智能助手对接
aiTools,
handleAiTool,
// 供外部复用
ensureTables, startScheduler, sendMail, fetchRecent, loadConfig,
async onMessage(msg, ctx) {
await ensureTables();
startScheduler();
const text = (msg.content || '').trim();
if (!text) return false;
const m = text.match(PREFIX_RE);
if (!m) return false;
const rest = text.slice(m[0].length).trim();
const botId = ctx.bot.id;
try {
// ── 发送邮件:邮件 发送 收件人|主题|正文 ──
let sm = rest.match(/^(发送|发|send)\s+([\s\S]+)$/i);
if (sm) {
const parts = sm[2].split(/\s*[||]\s*/);
if (parts.length < 3) {
await ctx.sendText('格式:邮件 发送 收件人|主题|正文\n例如:邮件 发送 [email protected]|周报|本周工作已完成');
return true;
}
const [to, subject, ...bodyArr] = parts;
const r = await sendMail(botId, { to: to.trim(), subject: subject.trim(), text: bodyArr.join('|').trim() });
await ctx.sendText(`[OK] 已发送给 ${r.to}\n主题:${r.subject}`);
return true;
}
// ── 收取 / 最新 ──
let rm = rest.match(/^(收取|收件|查看|列表|list)\s*(\d+)?$/i);
if (rm) {
const n = rm[2] ? parseInt(rm[2], 10) : 5;
const list = await fetchRecent(botId, n);
if (!list.length) { await ctx.sendText('收件箱是空的。'); return true; }
const lines = list.map((mm, i) => {
const ds = mm.date ? new Date(mm.date).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
return `${i + 1}. 【${mm.subject}】 ${mm.from}${ds ? ' · ' + ds : ''}`;
});
await ctx.sendText('[Mail] 最近邮件:\n' + lines.join('\n'));
return true;
}
if (/^(最新|latest)$/i.test(rest)) {
const list = await fetchRecent(botId, 1);
if (!list.length) { await ctx.sendText('收件箱是空的。'); return true; }
const mm = list[0];
let body = mm.text || '(无正文)';
if (body.length > 1200) body = body.slice(0, 1200) + '\n…(已截断)';
const ds = mm.date ? new Date(mm.date).toLocaleString('zh-CN') : '';
await ctx.sendText(`[Mail] 《${mm.subject}》\n发件人:${mm.from}${ds ? '\n时间:' + ds : ''}\n\n${body}`);
return true;
}
// ── 状态 / 帮助 ──
if (/^(状态|status)$/i.test(rest)) {
const cfg = await loadConfig(botId);
await ctx.sendText(
'[Mail] 邮箱助手状态:\n' +
'发件(SMTP):' + (cfg.hasSmtp ? '已配置 ' + cfg.smtp.host : '未配置') + '\n' +
'收件(IMAP):' + (cfg.hasImap ? '已配置 ' + cfg.imap.host : '未配置') + '\n' +
'新邮件提示:' + (cfg.notify ? '开启' : '关闭') + (cfg.aiSummary ? '(含 AI 摘要)' : '') + '\n' +
'轮询间隔:' + cfg.pollInterval + ' 秒'
);
return true;
}
// 帮助
await ctx.sendText(
'[Mail] 邮箱助手 使用说明:\n' +
' 邮件 发送 收件人|主题|正文 → 发送邮件\n' +
' 邮件 收取 [数量] → 拉取最近邮件(默认 5)\n' +
' 邮件 最新 → 查看最新一封\n' +
' 邮件 状态 → 查看配置状态\n\n' +
'也可直接对智能助手说:“发邮件给 [email protected] 说明天开会” / “看看我最近的邮件”。\n' +
'配置:机器人 → 插件设置 → 邮箱助手,填写 SMTP/IMAP。多数邮箱需用「授权码」。'
);
return true;
} catch (e) {
console.error('[mail] onMessage 出错:', e.message);
try { await ctx.sendText('[邮箱] 操作失败:' + e.message); } catch (_) {}
return true;
}
},
};
// 模块加载时自动启动 IMAP 新邮件轮询(不依赖用户主动触发邮箱指令)
startScheduler();