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 节点在国内。
This commit is contained in:
1 parent
e0218b42bb
commit
bcc2236bde
3 files changed
+512
-2
No files matched your search
@@ -0,0 +1,275 @@
|
|||||||
|
# =====================================================================
|
||||||
|
# CNB 流水线 | 由 .github/workflows/deploy.yml 迁移(2026-10-04)
|
||||||
|
# =====================================================================
|
||||||
|
# 为什么迁过来反而更简单:
|
||||||
|
# 1. 构建节点在国内(腾讯云)→ 又拍云「境内直传」恢复。
|
||||||
|
# 原版因 runner 在境外(Gitea 在境外 VPS),直传又拍云恒失败被 if:false
|
||||||
|
# 停用,改用「COS 中转 + 广州机 sync.sh 每分钟轮询」兜底。CNB 让这条
|
||||||
|
# 整链失去存在理由(见 deploy.yml 第 290 行那段注释的「复活条件」)。
|
||||||
|
# 2. 不需要 artifact 传递:同一 pipeline 的 stages 顺序执行、共享工作目录,
|
||||||
|
# public/ 天然传给后续 stage —— 连带 upload/download-artifact 在 Gitea 上
|
||||||
|
# 那两个坑(v4 不支持 / v3 finalize 500)一起消失。
|
||||||
|
# 3. 不需要 github.server_url 那 8 处兼容分支。
|
||||||
|
# 4. 未迁「图片优化 + 反向 commit」:原版每轮都白跑(15 张老图处理不掉),
|
||||||
|
# 还会把优化结果反写回仓库。如需保留,照搬 scripts/optimize_images.js。
|
||||||
|
# 5. 通知只保留邮件:原版三套冗余(TG / 飞书 / 邮件),现收敛成邮件一套。
|
||||||
|
# 成功 → stages 最后一个任务;失败 → failStages。
|
||||||
|
# 发信由 scripts/send_mail.js 零依赖手写(net/tls 直连 SMTP,465 隐式 TLS),
|
||||||
|
# 对应原版的 dawidd6/action-send-mail,两者都走 QQ 邮箱。
|
||||||
|
# 注意原版**失败通知没有邮件**(只有 TG/飞书),失败邮件是这次补上的。
|
||||||
|
# 6. 又拍云保持「非阻断但如实上报」:原版是 continue-on-error: true,
|
||||||
|
# 国内线路直传失败不得拖垮境外线路。这里用 allowFailure + .ci_status
|
||||||
|
# 落盘记录,邮件里逐条如实显示,不做假绿。
|
||||||
|
#
|
||||||
|
# ⚠️ 落地前必做
|
||||||
|
# 1. 建一个 CNB「密钥仓库」(新建仓库时类型选「密钥仓库」;它禁止 clone、
|
||||||
|
# 只能在 Web 界面编辑,所以必须手动建)。命名 zqlit/blog-secrets,
|
||||||
|
# 在 Web 界面新建 secrets.yml,内容照抄本仓库根目录的 cnb-secrets.yml
|
||||||
|
# (该文件已 gitignore,只作粘贴草稿,绝不入库)。需注入 10 项:
|
||||||
|
# UPYUN_SERVICE / UPYUN_OPERATOR / UPYUN_PASSWORD
|
||||||
|
# DOGECLOUD_ACCESS_KEY / DOGECLOUD_SECRET_KEY / CDN_URL_LIST
|
||||||
|
# EDGEONE_API_TOKEN / RSS_API_TOKEN
|
||||||
|
# MAIL_USERNAME / MAIL_PASSWORD ← QQ 邮箱 + SMTP 授权码
|
||||||
|
# ⚠️ 又拍云服务名在旧 Gitea 里存的 secret 名是 UPYUN_BUCKET,
|
||||||
|
# 迁到 CNB 必须改叫 UPYUN_SERVICE(imports 注入的就是变量名本身)。
|
||||||
|
# 2. 密钥仓库若与本仓库不在同一组织,定时任务可能被跨仓管控拦下,
|
||||||
|
# 需在「组织设置 → 仓库管控」放行。首跑留意 cron 那条有没有报 imports 403。
|
||||||
|
# 3. 首次跑重点观察两个未知数:
|
||||||
|
# a) 又拍云 v0 接口从 CNB 出口的真实表现(本方案的立论基础)
|
||||||
|
# b) EdgeOne 从国内上传 ~372MB 的耗时(原为境外同区上传)
|
||||||
|
# 建议首跑把这两步暂时注释掉,只验「Hugo 构建」跑通,再逐步放开。
|
||||||
|
# 4. 邮件默认 smtp.qq.com:465;要换服务商就加 env(SMTP_HOST / SMTP_PORT /
|
||||||
|
# SMTP_FROM_NAME),脚本自带 QQ 的默认值。
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# 公用 stage 片段:push 与定时任务共用(顶层以 . 开头的 key 不会被当成分支名)
|
||||||
|
.common-stages: &common-stages
|
||||||
|
- name: Hugo 构建
|
||||||
|
timeout: 15m
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
# 用一份落盘的状态文件在各 stage 之间传话(原版靠 needs.*.outputs),
|
||||||
|
# 邮件通知最后统一读它。同一 pipeline 的 stages 共享工作目录。
|
||||||
|
CI_STATUS="$PWD/.ci_status"
|
||||||
|
: > "$CI_STATUS"
|
||||||
|
|
||||||
|
hugo version
|
||||||
|
python3 scripts/add_draft_to_hidden.py
|
||||||
|
rm -rf public resources/_gen
|
||||||
|
hugo --minify --gc
|
||||||
|
BUILD_HASH=$(find public -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')
|
||||||
|
echo "$BUILD_HASH" > public/.build_hash
|
||||||
|
echo "$BUILD_HASH" > public/build_hash.txt
|
||||||
|
echo "$BUILD_HASH" > .build_hash.out
|
||||||
|
echo "build=ok" >> "$CI_STATUS"
|
||||||
|
echo "✅ 构建完成 hash=$(printf '%.12s' "$BUILD_HASH") 文件数=$(find public -type f | wc -l | tr -d ' ')"
|
||||||
|
|
||||||
|
- name: 同步到又拍云(境内源站)
|
||||||
|
timeout: 30m
|
||||||
|
# 对应原版的 continue-on-error: true —— 国内线路直传失败不该拖垮境外线路,
|
||||||
|
# 但失败必须被如实记录(写进 .ci_status,邮件里会显示 ❌)。
|
||||||
|
allowFailure: true
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
CI_STATUS="$PWD/.ci_status"
|
||||||
|
ST=ok
|
||||||
|
record() {
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then ST=fail; fi
|
||||||
|
echo "upyun_sync=$ST" >> "$CI_STATUS"
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
trap record EXIT
|
||||||
|
|
||||||
|
# 原版这里是 if:false(境外直传必挂)。CNB 节点在国内,直接恢复。
|
||||||
|
# 首次仍保守用 -w 5 且不加 --strong,稳定后再考虑调高并发。
|
||||||
|
upx login "$UPYUN_SERVICE" "$UPYUN_OPERATOR" "$UPYUN_PASSWORD"
|
||||||
|
upx sync public/ / -w 5 --delete
|
||||||
|
upx put public/.build_hash /__build_hash || true
|
||||||
|
echo "✅ 又拍云同步完成"
|
||||||
|
|
||||||
|
- name: 刷新又拍云 CDN
|
||||||
|
allowFailure: true
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
CI_STATUS="$PWD/.ci_status"
|
||||||
|
ST=ok
|
||||||
|
record() {
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then ST=fail; fi
|
||||||
|
echo "upyun_purge=$ST" >> "$CI_STATUS"
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
trap record EXIT
|
||||||
|
|
||||||
|
# ★ 必须刷固定入口页:只刷首页会让 sitemap.xml / rss.xml / archives.html /
|
||||||
|
# posts.html 一直发 CDN 上的旧副本(2026-10-01 实测复现:源站 22812B、
|
||||||
|
# CDN 仍是 7/24 的 22592B,手动 purge 后立刻变新)。
|
||||||
|
{ echo "https://usj.cc/"
|
||||||
|
for p in sitemap.xml rss.xml archives.html posts.html comment.html links.html; do
|
||||||
|
echo "https://usj.cc/$p"
|
||||||
|
done
|
||||||
|
} > /tmp/purge.txt
|
||||||
|
upx purge --list /tmp/purge.txt
|
||||||
|
|
||||||
|
- name: 刷新多吉云 CDN
|
||||||
|
allowFailure: true
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
CI_STATUS="$PWD/.ci_status"
|
||||||
|
ST=ok
|
||||||
|
record() {
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then ST=fail; fi
|
||||||
|
echo "dogecloud=$ST" >> "$CI_STATUS"
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
trap record EXIT
|
||||||
|
|
||||||
|
# 复用仓库里的 scripts/refresh_cdn.js(零外部依赖,仅用 node 内置模块)
|
||||||
|
node scripts/refresh_cdn.js
|
||||||
|
|
||||||
|
- name: 部署 EdgeOne(境外线路)
|
||||||
|
timeout: 20m
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
CI_STATUS="$PWD/.ci_status"
|
||||||
|
ST=ok
|
||||||
|
record() {
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then ST=fail; fi
|
||||||
|
echo "edgeone=$ST" >> "$CI_STATUS"
|
||||||
|
exit "$rc"
|
||||||
|
}
|
||||||
|
trap record EXIT
|
||||||
|
|
||||||
|
test -n "$EDGEONE_API_TOKEN" || { echo "❌ 缺 EDGEONE_API_TOKEN"; exit 1; }
|
||||||
|
npx edgeone pages deploy ./public -n hugo-blog -t "$EDGEONE_API_TOKEN" --area overseas
|
||||||
|
echo "✅ EdgeOne 部署完成"
|
||||||
|
|
||||||
|
- name: 上报部署状态
|
||||||
|
allowFailure: true
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
H=$(cat .build_hash.out)
|
||||||
|
curl -s --max-time 20 -X POST \
|
||||||
|
"https://api.200181.xyz/api/deploy-status?token=${RSS_API_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"hash\":\"$H\",\"phase\":\"built\"}" || true
|
||||||
|
echo "ℹ️ 已上报 built: $(printf '%.12s' "$H")"
|
||||||
|
|
||||||
|
# ── 成功通知(放在 stages 末尾)────────────────────────────────────
|
||||||
|
# 任一前置 stage 真失败会直接跳 failStages,这个 stage 就不会跑,
|
||||||
|
# 所以「成功邮件」只在真的全绿(含又拍云)时发出。
|
||||||
|
- name: 发送邮件通知(成功)
|
||||||
|
allowFailure: true
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
CI_STATUS="$PWD/.ci_status"
|
||||||
|
st() { case "${1:-}" in ok) printf '✅ 成功';; fail) printf '❌ 失败';; *) printf '⏭️ 未执行';; esac; }
|
||||||
|
stv() { sed -n "s/^$1=//p" "$CI_STATUS" 2>/dev/null | tail -1; }
|
||||||
|
H=$(cat .build_hash.out 2>/dev/null || echo unknown)
|
||||||
|
if [ "${CNB_IS_CRONEVENT:-false}" = "true" ]; then TRIGGER=定时任务; else TRIGGER=代码推送; fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '🎉 部署成功\n'
|
||||||
|
printf '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
|
||||||
|
printf '📦 项目信息\n'
|
||||||
|
printf ' 项目: %s\n' "${CNB_REPO_SLUG:-zqlit/blog}"
|
||||||
|
printf ' 分支: %s\n' "${CNB_BRANCH:-main}"
|
||||||
|
printf ' Commit: %s\n' "${CNB_COMMIT_SHORT:-unknown}"
|
||||||
|
printf ' ⏰ 时间: %s\n' "$(date +'%Y-%m-%d %H:%M:%S')"
|
||||||
|
printf ' 🚀 触发: %s\n\n' "$TRIGGER"
|
||||||
|
printf '📊 构建统计\n'
|
||||||
|
printf ' 文件总数: %s\n' "$(find public -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
printf ' 构建哈希: %s\n\n' "$(printf '%.12s' "$H")"
|
||||||
|
printf '📌 各线路状态\n'
|
||||||
|
printf ' ☁️ 又拍云源站(境内直传) %s\n' "$(st "$(stv upyun_sync)")"
|
||||||
|
printf ' 🔄 又拍云 CDN 刷新 %s\n' "$(st "$(stv upyun_purge)")"
|
||||||
|
printf ' 🔄 多吉云 CDN %s\n' "$(st "$(stv dogecloud)")"
|
||||||
|
printf ' 🌐 EdgeOne Pages(境外) %s\n\n' "$(st "$(stv edgeone)")"
|
||||||
|
printf '🔗 构建日志\n %s\n\n' "${CNB_BUILD_WEB_URL:-(无)}"
|
||||||
|
printf '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'
|
||||||
|
} > /tmp/mail_body.txt
|
||||||
|
|
||||||
|
MAIL_SUBJECT="🎉 部署成功 - ${CNB_REPO_SLUG:-blog} - $(date +'%Y-%m-%d %H:%M')" \
|
||||||
|
MAIL_BODY_FILE=/tmp/mail_body.txt \
|
||||||
|
node scripts/send_mail.js
|
||||||
|
|
||||||
|
# 失败通知:stages 里任一**阻断型** stage 失败时执行
|
||||||
|
.common-fail-stages: &common-fail-stages
|
||||||
|
- name: 发送邮件通知(失败)
|
||||||
|
allowFailure: true
|
||||||
|
script: |
|
||||||
|
set -eu
|
||||||
|
CI_STATUS="$PWD/.ci_status"
|
||||||
|
st() { case "${1:-}" in ok) printf '✅ 成功';; fail) printf '❌ 失败';; *) printf '⏭️ 未执行';; esac; }
|
||||||
|
stv() { sed -n "s/^$1=//p" "$CI_STATUS" 2>/dev/null | tail -1; }
|
||||||
|
if [ "${CNB_IS_CRONEVENT:-false}" = "true" ]; then TRIGGER=定时任务; else TRIGGER=代码推送; fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '💥 部署失败\n'
|
||||||
|
printf '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
|
||||||
|
printf '📦 项目信息\n'
|
||||||
|
printf ' 项目: %s\n' "${CNB_REPO_SLUG:-zqlit/blog}"
|
||||||
|
printf ' 分支: %s\n' "${CNB_BRANCH:-main}"
|
||||||
|
printf ' Commit: %s\n' "${CNB_COMMIT_SHORT:-unknown}"
|
||||||
|
printf ' ⏰ 时间: %s\n' "$(date +'%Y-%m-%d %H:%M:%S')"
|
||||||
|
printf ' 🚀 触发: %s\n\n' "$TRIGGER"
|
||||||
|
printf '⚠️ 失败阶段\n %s\n\n' "${CNB_BUILD_FAILED_STAGE_NAME:-未知}"
|
||||||
|
printf '📋 失败原因\n'
|
||||||
|
printf '%s\n\n' "${CNB_BUILD_FAILED_MSG:-(未提供)}"
|
||||||
|
printf '📌 已执行到的步骤\n'
|
||||||
|
printf ' ☁️ 又拍云源站(境内直传) %s\n' "$(st "$(stv upyun_sync)")"
|
||||||
|
printf ' 🔄 又拍云 CDN 刷新 %s\n' "$(st "$(stv upyun_purge)")"
|
||||||
|
printf ' 🔄 多吉云 CDN %s\n' "$(st "$(stv dogecloud)")"
|
||||||
|
printf ' 🌐 EdgeOne Pages(境外) %s\n\n' "$(st "$(stv edgeone)")"
|
||||||
|
printf '🔗 构建日志\n %s\n\n' "${CNB_BUILD_WEB_URL:-(无)}"
|
||||||
|
printf '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'
|
||||||
|
} > /tmp/mail_body.txt
|
||||||
|
|
||||||
|
MAIL_SUBJECT="💥 部署失败 - ${CNB_REPO_SLUG:-blog} - $(date +'%Y-%m-%d %H:%M')" \
|
||||||
|
MAIL_BODY_FILE=/tmp/mail_body.txt \
|
||||||
|
node scripts/send_mail.js
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 触发 1:推送到 main
|
||||||
|
# =====================================================================
|
||||||
|
main:
|
||||||
|
push:
|
||||||
|
- name: 构建并发布(境内外双线路)
|
||||||
|
runner:
|
||||||
|
cpus: 4 # 4 核 8G;160 核时/月 → 约 240 次
|
||||||
|
docker:
|
||||||
|
build:
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
by: [package.json]
|
||||||
|
versionBy: [package-lock.json]
|
||||||
|
imports:
|
||||||
|
- https://cnb.cool/zqlit/blog-secrets/-/blob/main/secrets.yml
|
||||||
|
lock: # 对应原 concurrency.cancel-in-progress
|
||||||
|
key: deploy-main
|
||||||
|
cancel-in-progress: true
|
||||||
|
stages: *common-stages
|
||||||
|
failStages: *common-fail-stages
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# 触发 2:定时构建(原 schedule '0 1 * * *' UTC = 北京 09:00;
|
||||||
|
# CNB 的 cron 时区就是 Asia/Shanghai,故直接写 09:00)
|
||||||
|
# =====================================================================
|
||||||
|
"crontab: 0 9 * * *":
|
||||||
|
- name: 定时构建(每天 09:00)
|
||||||
|
runner:
|
||||||
|
cpus: 4
|
||||||
|
docker:
|
||||||
|
build:
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
by: [package.json]
|
||||||
|
versionBy: [package-lock.json]
|
||||||
|
imports:
|
||||||
|
- https://cnb.cool/zqlit/blog-secrets/-/blob/main/secrets.yml
|
||||||
|
lock:
|
||||||
|
key: deploy-main
|
||||||
|
cancel-in-progress: true
|
||||||
|
stages: *common-stages
|
||||||
|
failStages: *common-fail-stages
|
||||||
+4
-2
@@ -5,11 +5,13 @@ node_modules/
|
|||||||
|
|
||||||
.elog.env
|
.elog.env
|
||||||
|
|
||||||
write\.env
|
|
||||||
|
|
||||||
/public
|
/public
|
||||||
# Claude Code 临时 worktree(曾被误当 submodule 提交)
|
# Claude Code 临时 worktree(曾被误当 submodule 提交)
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.workbuddy-backup/
|
.workbuddy-backup/
|
||||||
# WorkBuddy 工作数据(记忆/日志/会话),不入库
|
# WorkBuddy 工作数据(记忆/日志/会话),不入库
|
||||||
.workbuddy/
|
.workbuddy/
|
||||||
|
# CNB 构建时各 stage 之间传状态用的临时文件
|
||||||
|
.ci_status
|
||||||
|
# CNB 密钥仓库的本地草稿(含真实令牌,仅供往 Web 界面粘贴,绝不入库)
|
||||||
|
cnb-secrets.yml
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
#!/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()
|
||||||
Reference in new issue
Block a user