guanghu-ice-heart/server-tools/lake-lamp-authz/smtp-mailer.js

101 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 || "无"}`,
"有效期: 批准后三小时,仅限这台服务器与本申请范围",
"",
"打开以下链接查看工单并确认授权:",
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);
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>`;
}
function encodeBase64(value) { return Buffer.from(value).toString("base64").replace(/(.{76})/g, "$1\r\n"); }
function escapeHtml(value) { return String(value).replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]); }
module.exports = { sendSmtpMail };