Files
blog/scripts/send_mail.js
T
zqlit bcc2236bde ci: 迁移到 CNB 流水线(.cnb.yml)+ 零依赖邮件通知
- .cnb.yml:6 个 stage 顺序执行(Hugo 构建 → 又拍云 sync → 又拍云 purge →
  多吉云刷新 → EdgeOne 部署 → 上报状态);push 与每日 09:00 定时任务
  共用同一组 stage(YAML 锚点),失败通知走 failStages
- 又拍云保持「非阻断但如实上报」:allowFailure 沿用原版 continue-on-error
  语义,国内线路直传失败不拖垮境外线路
- scripts/send_mail.js:零依赖 SMTP 发信(node net/tls,465 隐式 TLS +
  587 STARTTLS),变量名沿用 MAIL_USERNAME / MAIL_PASSWORD
- 密钥经 imports 从密钥仓库 zqlit/blog-secrets 注入(共 10 项)
- .gitignore:补 cnb-secrets.yml 本地粘贴草稿(含真实令牌,不入库)

原 GitHub Actions 里的 artifact 传递 / COS 中转 / 广州机 sync.sh 轮询
整条链在此不再需要 —— 其唯一根因是 runner 在境外,CNB 节点在国内。
2026-10-04 12:56:05 +08:00

234 lines
8.3 KiB
JavaScript
Raw 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.
#!/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 = '<pre style="font:13px/1.6 ui-monospace,Menlo,Consolas,monospace">' +
text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>'
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()