#!/usr/bin/env node /** * 零依赖 SMTP 发信 —— 只用 node 内置的 net / tls,不引任何 npm 包。 * * 用途:CNB 流水线部署完成后发通知邮件(成功 / 失败各一封)。 * 之所以手写而不是用现成的 SMTP 包:本流水线的构建镜像只装了 hugo / upx / * edgeone,没有任何 npm 依赖,发一封信不值得为它引一串传递依赖。 * * 两种加密方式都支持(按端口自动选): * - 465 隐式 TLS(smtp.qq.com 默认走这条,握手即加密) * - 587 STARTTLS(先明文连上再升级,需带 servername 否则证书校验会失败) * * 用法: * MAIL_SUBJECT='部署成功' MAIL_BODY_FILE=/tmp/mail.txt node scripts/send_mail.js * * 环境变量: * SMTP_HOST 默认 smtp.qq.com * SMTP_PORT 默认 465(465 隐式 TLS;填 587 会自动改走 STARTTLS) * SMTP_USER 发信账号(QQ 邮箱要求 From 与它完全一致,否则 553) * 未设时回落到 MAIL_USERNAME * SMTP_PASS 授权码,不是登录密码;未设时回落到 MAIL_PASSWORD * SMTP_FROM 默认 = SMTP_USER * SMTP_FROM_NAME 默认 优世界 * SMTP_TO 默认 = SMTP_USER * MAIL_SUBJECT 邮件主题 * MAIL_BODY_FILE 正文文件路径(推荐,免去 shell 里转义换行的麻烦) * MAIL_BODY 正文(未给 MAIL_BODY_FILE 时用它) * * 退出码:0 = 已发送或未配置(跳过);1 = 发送失败。 * 未配置时只告警不报错 —— 通知失败不该拖垮部署。 */ const net = require('net') const tls = require('tls') const fs = require('fs') const CFG = { host: process.env.SMTP_HOST || 'smtp.qq.com', port: parseInt(process.env.SMTP_PORT || '465', 10), // 变量名沿用仓库里既有的 MAIL_USERNAME / MAIL_PASSWORD(与旧 GitHub Actions 一致), // 这样密钥仓库不用为了迁 CNB 改名字。 user: process.env.SMTP_USER || process.env.MAIL_USERNAME || '', pass: process.env.SMTP_PASS || process.env.MAIL_PASSWORD || '', from: process.env.SMTP_FROM || process.env.SMTP_USER || process.env.MAIL_USERNAME || '', fromName: process.env.SMTP_FROM_NAME || '优世界', to: process.env.SMTP_TO || process.env.MAIL_USERNAME || '', timeout: parseInt(process.env.SMTP_TIMEOUT || '25000', 10) } function log(...a) { console.log('[mail]', ...a) } function fail(msg) { console.error('[mail] ❌ ' + msg) process.exit(1) } // ── 读取主题与正文 ──────────────────────────────────────────────── const subject = process.env.MAIL_SUBJECT || '(无主题)' let body = '' if (process.env.MAIL_BODY_FILE) { try { body = fs.readFileSync(process.env.MAIL_BODY_FILE, 'utf8') } catch (e) { fail('读不到正文文件 ' + process.env.MAIL_BODY_FILE + ':' + e.message) } } else { body = process.env.MAIL_BODY || '' } body = body.replace(/\r\n/g, '\n').replace(/\n+$/, '') // ── 未配置就跳过(不当作失败)────────────────────────────────────── if (!CFG.user || !CFG.pass) { log('⚠️ 未配置 SMTP_USER/SMTP_PASS(或 MAIL_USERNAME/MAIL_PASSWORD),跳过发信') log(' 想启用就在密钥仓库里加 MAIL_USERNAME + MAIL_PASSWORD 两项') log(' (QQ 邮箱的 MAIL_PASSWORD 是 SMTP 授权码,不是登录密码)') process.exit(0) } // ── SMTP 是按行说话的协议,得把「一轮响应」读全 ───────────────────── // 形如:250-first\r\n250-second\r\n250 last\r\n —— 第 4 个字符是 '-' 表示还有后续行。 function lineReader(socket) { let buf = '' const queue = [] const waiters = [] socket.on('data', d => { buf += d.toString('utf8') let i while ((i = buf.indexOf('\r\n')) !== -1) { const line = buf.slice(0, i) buf = buf.slice(i + 2) if (waiters.length) waiters.shift()(line) else queue.push(line) } }) return { next() { if (queue.length) return Promise.resolve(queue.shift()) return new Promise(r => waiters.push(r)) }, async reply() { const lines = [] for (;;) { const l = await this.next() lines.push(l) if (l.length < 4 || l[3] !== '-') break } return lines } } } // base64 正文按 76 字符折行,否则部分服务器拒收 function fold(line) { return line.replace(/(.{70,80})/g, '$1\r\n') } function buildMessage(to, subject, text) { const boundary = 'usj_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10) const enc = s => Buffer.from(s, 'utf8').toString('base64') const html = '
' +
    text.replace(/&/g, '&').replace(//g, '>') + '
' const head = [ 'From: =?UTF-8?B?' + enc(CFG.fromName) + '?= <' + CFG.from + '>', 'To: <' + to + '>', 'Subject: =?UTF-8?B?' + enc(subject) + '?=', 'Date: ' + new Date().toUTCString(), 'MIME-Version: 1.0', 'Content-Type: multipart/alternative; boundary="' + boundary + '"', '' ] const parts = [ '--' + boundary, 'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', fold(enc(text)), '--' + boundary, 'Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', fold(enc(html)), '--' + boundary + '--' ] return head.concat(parts).join('\r\n') } // ── 建连:465 走隐式 TLS,其余端口走明文再 STARTTLS ───────────────── function dial() { return new Promise((resolve, reject) => { const onErr = e => reject(e) if (CFG.port === 465) { const s = tls.connect({ host: CFG.host, port: CFG.port, servername: CFG.host }, () => { s.removeListener('error', onErr) resolve(s) }) s.once('error', onErr) } else { const s = net.connect({ host: CFG.host, port: CFG.port }, () => { s.removeListener('error', onErr) resolve(s) }) s.once('error', onErr) } }) } async function main() { if (!CFG.to || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(CFG.to)) fail('收件地址不合法:' + CFG.to) let socket = await dial() let done = false const timer = setTimeout(() => finish(new Error('SMTP 超时(' + CFG.timeout + 'ms)')), CFG.timeout) function finish(err) { if (done) return done = true clearTimeout(timer) try { socket.write('QUIT\r\n'); socket.end() } catch (e) { /* 忽略 */ } try { socket.destroy() } catch (e) { /* 忽略 */ } if (err) fail(err.message) log('✅ 已发送 → ' + CFG.to + ' | 主题:' + subject) process.exit(0) } let reader = lineReader(socket) async function cmd(text, expect) { socket.write(text + '\r\n') const lines = await reader.reply() const code = (lines[lines.length - 1] || '').slice(0, 3) if (expect && code !== expect) { throw new Error('SMTP `' + text.split(' ')[0] + '` 期望 ' + expect + ' 实际 ' + code + ':' + lines.join(' | ')) } return lines } socket.on('error', finish) try { await reader.reply() // 220 欢迎语 await cmd('EHLO ' + CFG.host, '250') if (CFG.port !== 465) { await cmd('STARTTLS', '220') socket.removeAllListeners('data') socket = await new Promise((res, rej) => { const t = tls.connect({ socket, servername: CFG.host }, () => res(t)) t.once('error', rej) }) socket.on('error', finish) reader = lineReader(socket) await cmd('EHLO ' + CFG.host, '250') } await cmd('AUTH LOGIN', '334') await cmd(Buffer.from(CFG.user, 'utf8').toString('base64'), '334') await cmd(Buffer.from(CFG.pass, 'utf8').toString('base64'), '235') await cmd('MAIL FROM:<' + CFG.from + '>', '250') await cmd('RCPT TO:<' + CFG.to + '>', '250') await cmd('DATA', '354') socket.write(buildMessage(CFG.to, subject, body) + '\r\n.\r\n') const lines = await reader.reply() const code = (lines[lines.length - 1] || '').slice(0, 3) if (code !== '250') throw new Error('投递失败:' + lines.join(' | ')) finish(null) } catch (e) { finish(e) } } main()