- .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 节点在国内。
234 lines
8.3 KiB
JavaScript
234 lines
8.3 KiB
JavaScript
#!/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, '&').replace(/</g, '<').replace(/>/g, '>') + '</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()
|