2026-07-24 10:39:10 +08:00
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
|
|
const tls = require("node:tls");
|
|
|
|
|
|
|
|
|
|
|
|
function sendSmtpMail({ to, subject, approvalUrl, order, text, smtpHost, smtpPort, smtpUser, smtpPass }) {
|
|
|
|
|
|
if (!to || !smtpUser || !smtpPass || (!text && !approvalUrl)) return Promise.resolve(false);
|
|
|
|
|
|
const plain = text || [
|
|
|
|
|
|
"光湖小湖灯安全协议系统 · 授权请求",
|
|
|
|
|
|
"",
|
|
|
|
|
|
`人格体: ${order.persona.name} (${order.persona.pid})`,
|
|
|
|
|
|
`目标节点: ${order.target}`,
|
|
|
|
|
|
`授权范围: ${order.scope}`,
|
|
|
|
|
|
`登记动作: ${order.action}`,
|
|
|
|
|
|
`绑定资源: ${order.resource || "无"}`,
|
2026-07-29 23:37:26 +08:00
|
|
|
|
"有效期: 批准后三小时,仅限这台服务器与本申请范围",
|
2026-07-24 10:39:10 +08:00
|
|
|
|
"",
|
|
|
|
|
|
"打开以下链接查看工单并确认授权:",
|
|
|
|
|
|
approvalUrl,
|
|
|
|
|
|
"",
|
|
|
|
|
|
"如果不是你发起的操作,请不要点击。",
|
|
|
|
|
|
].join("\n");
|
|
|
|
|
|
const html = approvalUrl ? approvalEmailHtml({ approvalUrl, order }) : "";
|
|
|
|
|
|
const boundary = `guanghu-lake-lamp-${Date.now().toString(36)}`;
|
|
|
|
|
|
const body = html ? [
|
|
|
|
|
|
`--${boundary}`,
|
|
|
|
|
|
'Content-Type: text/plain; charset="utf-8"',
|
|
|
|
|
|
"Content-Transfer-Encoding: base64",
|
|
|
|
|
|
"",
|
|
|
|
|
|
encodeBase64(plain),
|
|
|
|
|
|
`--${boundary}`,
|
|
|
|
|
|
'Content-Type: text/html; charset="utf-8"',
|
|
|
|
|
|
"Content-Transfer-Encoding: base64",
|
|
|
|
|
|
"",
|
|
|
|
|
|
encodeBase64(html),
|
|
|
|
|
|
`--${boundary}--`,
|
|
|
|
|
|
].join("\r\n") : [
|
|
|
|
|
|
'Content-Type: text/plain; charset="utf-8"',
|
|
|
|
|
|
"Content-Transfer-Encoding: base64",
|
|
|
|
|
|
"",
|
|
|
|
|
|
encodeBase64(plain),
|
|
|
|
|
|
].join("\r\n");
|
|
|
|
|
|
const message = [
|
|
|
|
|
|
`From: =?UTF-8?B?${Buffer.from("光湖小湖灯").toString("base64")}?= <${smtpUser}>`,
|
|
|
|
|
|
`To: <${to}>`,
|
|
|
|
|
|
`Subject: =?UTF-8?B?${Buffer.from(subject).toString("base64")}?=`,
|
|
|
|
|
|
"MIME-Version: 1.0",
|
|
|
|
|
|
...(html ? [`Content-Type: multipart/alternative; boundary="${boundary}"`] : []),
|
|
|
|
|
|
"",
|
|
|
|
|
|
body,
|
|
|
|
|
|
".",
|
|
|
|
|
|
"",
|
|
|
|
|
|
].join("\r\n");
|
|
|
|
|
|
|
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
|
let settled = false;
|
|
|
|
|
|
const done = value => { if (!settled) { settled = true; resolve(value); } };
|
|
|
|
|
|
const socket = tls.connect({ host: smtpHost, port: smtpPort, servername: smtpHost, rejectUnauthorized: true });
|
|
|
|
|
|
let stage = 0;
|
|
|
|
|
|
let buffer = "";
|
|
|
|
|
|
socket.setTimeout(15000, () => { socket.destroy(); done(false); });
|
|
|
|
|
|
socket.on("error", () => done(false));
|
|
|
|
|
|
socket.on("data", data => {
|
|
|
|
|
|
buffer += data.toString();
|
|
|
|
|
|
const lines = buffer.split("\r\n");
|
|
|
|
|
|
buffer = lines.pop();
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
if (!/^\d{3}[ -]/.test(line) || line[3] === "-") continue;
|
|
|
|
|
|
const code = Number(line.slice(0, 3));
|
|
|
|
|
|
if (code >= 400) { socket.end(); done(false); return; }
|
|
|
|
|
|
if (stage === 0 && code === 220) { stage = 1; socket.write("EHLO guanghu-lake-lamp\r\n"); }
|
|
|
|
|
|
else if (stage === 1 && code === 250) { stage = 2; socket.write("AUTH LOGIN\r\n"); }
|
|
|
|
|
|
else if (stage === 2 && code === 334) { stage = 3; socket.write(`${Buffer.from(smtpUser).toString("base64")}\r\n`); }
|
|
|
|
|
|
else if (stage === 3 && code === 334) { stage = 4; socket.write(`${Buffer.from(smtpPass).toString("base64")}\r\n`); }
|
|
|
|
|
|
else if (stage === 4 && code === 235) { stage = 5; socket.write(`MAIL FROM:<${smtpUser}>\r\n`); }
|
|
|
|
|
|
else if (stage === 5 && code === 250) { stage = 6; socket.write(`RCPT TO:<${to}>\r\n`); }
|
|
|
|
|
|
else if (stage === 6 && code === 250) { stage = 7; socket.write("DATA\r\n"); }
|
|
|
|
|
|
else if (stage === 7 && code === 354) { stage = 8; socket.write(message); }
|
|
|
|
|
|
else if (stage === 8 && code === 250) { socket.write("QUIT\r\n"); socket.end(); done(true); }
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
socket.on("close", () => done(false));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function approvalEmailHtml({ approvalUrl, order }) {
|
|
|
|
|
|
const persona = escapeHtml(order.persona.name);
|
|
|
|
|
|
const personaId = escapeHtml(order.persona.pid);
|
|
|
|
|
|
const target = escapeHtml(order.target);
|
|
|
|
|
|
const scope = escapeHtml(order.scope);
|
|
|
|
|
|
const action = escapeHtml(order.action);
|
|
|
|
|
|
const allowedActions = escapeHtml((order.allowed_actions || [order.action]).join(" · "));
|
|
|
|
|
|
const description = escapeHtml(order.description || "未附加说明");
|
|
|
|
|
|
const resource = escapeHtml(order.resource || "无");
|
|
|
|
|
|
const link = escapeHtml(approvalUrl);
|
2026-07-29 23:37:26 +08:00
|
|
|
|
return `<!doctype html><html lang="zh-CN"><body style="margin:0;background:#07111b;color:#eaf6ff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:radial-gradient(circle at top left,#163653,#07111b 58%);padding:36px 16px"><tr><td align="center"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;background:#0b1c2a;border:1px solid #284a62;border-radius:24px;overflow:hidden"><tr><td style="padding:34px 38px 18px"><div style="color:#6ed8ff;font-size:12px;letter-spacing:3px;font-weight:700">LAKE LAMP · THREE-HOUR OPS SESSION</div><h1 style="margin:12px 0 8px;font-size:32px;line-height:1.25;color:#f3f9fd">一盏小湖灯,正在等你确认</h1><p style="margin:0;color:#9eb7c8;line-height:1.8">${persona}(${personaId})申请打开三小时受限运维会话。请核对申请者、服务器、能力范围和绑定资源,再决定是否开门。</p></td></tr><tr><td style="padding:16px 38px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#10283a;border:1px solid #29506a;border-radius:16px"><tr><td style="padding:22px 24px"><div style="font-size:20px;font-weight:750;color:#ffffff">${persona}</div><div style="color:#7899ad;font-size:13px;margin-top:2px">${personaId}</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;font-size:15px;line-height:2"><tr><td style="color:#82a0b3;width:96px">目标节点</td><td style="color:#e6f3fa;font-weight:650">${target}</td></tr><tr><td style="color:#82a0b3">授权范围</td><td style="color:#e6f3fa;font-weight:650">${scope}</td></tr><tr><td style="color:#82a0b3">进入动作</td><td style="color:#e6f3fa;font-weight:650">${action}</td></tr><tr><td style="color:#82a0b3;vertical-align:top">绑定资源</td><td style="color:#e6f3fa;word-break:break-all">${resource}</td></tr><tr><td style="color:#82a0b3;vertical-align:top">会话能力</td><td style="color:#e6f3fa">${allowedActions}</td></tr><tr><td style="color:#82a0b3;vertical-align:top">申请目的</td><td style="color:#e6f3fa">${description}</td></tr></table></td></tr></table></td></tr><tr><td style="padding:8px 38px 34px"><a href="${link}" style="display:block;text-align:center;background:#69d7ff;color:#052235;text-decoration:none;font-size:17px;font-weight:800;padding:16px 20px;border-radius:14px">打开三小时受限运维会话</a><p style="margin:18px 0 0;color:#8da7b8;font-size:13px;line-height:1.8">人格体持续执行上方已登记且绑定不变的任务时,会话会自动续期,最长不超过二十四小时。切换服务器、扩大范围、改变绑定资源或停止活动后过期才需重新授权。若这不是你发起的操作,请忽略本邮件。</p><p style="margin:14px 0 0;color:#587487;font-size:12px;word-break:break-all">按钮无法打开时:${link}</p></td></tr></table><p style="margin:18px 0 0;color:#587487;font-size:12px">光湖 · 小湖灯安全协议系统</p></td></tr></table></body></html>`;
|
2026-07-24 10:39:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function encodeBase64(value) { return Buffer.from(value).toString("base64").replace(/(.{76})/g, "$1\r\n"); }
|
|
|
|
|
|
function escapeHtml(value) { return String(value).replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); }
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = { sendSmtpMail };
|