技术栈:Next.js 15(App Router)+ nodemailer + Nodecrypto。相关代码:lib/mail.ts(138 行)、lib/access.ts(62 行)、lib/secret.ts(44 行)、app/api/admin/mail-test/route.ts(28 行)、lib/accounts.ts里的令牌签发与消费。
1. 为什么第三方发信服务出局
三家理由,任何一条单独成立都足以否掉:
- 境外依赖。请求要出境,收件人邮箱地址、验证链接里的令牌、站点域名一起出境。对一个"数据全在自己国内服务器上"的站,这不是延迟问题,是口径一致性——你没法一边在页脚写"自建部署",一边把用户邮箱交给第三方处理。
- 对端不可用会打断注册主流程。第三方服务宕机、域名 reputation 被拉黑、免费额度用完、API Key 被轮换,四种情况你都会在同一分钟内收到"注册收不到邮件"的反馈,而你自己一行代码都没改。
- 配置项变多意味着故障点变多。第三方需要:域名 SPF/DKIM 托管、回调地址、区域选择、SDK 版本。自建只需要:一个 host、一个端口、一个账号。
剩下的选项就是自建或者用国内云厂商的 SMTP 转发。无论哪种,代码这边只有一件事不同:参数必须由站长自己填,而且要能改完立刻生效。这条决定了下面所有的读取方式。
2. 主决定:没配 SMTP 也要能把站跑起来
这是整个模块最重要的一条,先说结论再给代码:
/** 未配置 SMTP 时不报错、不阻塞注册流程,验证链接改由服务端日志给出。 */exportfunctionmailEnabled():boolean{returnsmtpConfigured();}// lib/access.tsexportfunctionsmtpConfigured():boolean{returnBoolean(mailConfig().host);}判定只看一个字段:host。不要求 user/pass/from 齐全,因为
- 有些自建邮件服务器允许匿名中继(内网里常见);
- 有些服务商的密码就是登录口令、用户名可为空;
- 而"填了 host"是站长表达"我要开始发信了"的最短路径。
于是deliver()里的第一道检查是这样:
asyncfunctiondeliver(to:string,subject:string,text:string,html:string):Promise<MailResult>{constconfig=mailConfig();if(!config.host){console.warn(`[mail] 未配置 SMTP,跳过发信:${to}${subject}`);return{ok:false,error:"未配置 SMTP"};}// …}注意它返回{ ok: false }而不是抛异常。调用方(注册接口)拿到的仍然是"没发成功"的明确信号,因此可以走那条离线分支把链接打进日志;而流程不会因为它挂掉。如果这里抛异常,POST /api/account/signup就变 500,站长在配好 SMTP 之前连注册页都测不了——这恰恰是他最需要先测通注册流程的时候。
{ ok: boolean; error: string }这个返回类型贯穿全模块,比Result<T, E>或异常都更贴合用途:发信失败永远不是致命的,它只是一条需要转达给用户的信息。
3. 离线分支:链接一定进日志,但绝不默认回给调用方
/** * 未配置 SMTP 时的统一收尾:链接一定打进服务端日志(站长可人工送达)。 * 只有显式开了 `CNURL_MAIL_ECHO` 才同时回给调用方,供本机自测点链接用。 * 默认不回——否则公网实例忘了配 SMTP 时,任何人注册任意邮箱都能拿到可用令牌, * 邮箱验证形同绕过。这里不靠 Host / X-Forwarded-* 猜「是不是本机」: * standalone server 会给每个请求都补上转发包头头,推断既不可靠也偏不安全,宁可 fail closed。 */exportfunctionofflineMailBody(req:Request,kind:"verify"|"reset",token:string,):{message:string;verify_url?:string;reset_url?:string}{constpath=kind==="verify"?"verify":"reset";consturl=`${siteBase(req)}/${path}?token=${encodeURIComponent(token)}`;constlabel=kind==="verify"?"验证":"重置";console.info(`[mail] 未配置 SMTP,${label}链接(${kind==="verify"?"24 小时":"2 小时"}内有效):${url}`);constmessage=`${label}链接已输出到服务端日志(未配置 SMTP)`;if(!mailEchoEnabled())return{message};returnkind==="verify"?{message,verify_url:url}:{message,reset_url:url};}这段有三层,每层都在防不同的事故。
第一层:日志里一定有。docker logs cn-url | grep "\[mail\]"就能捞出链接,站长可以手工粘给自己完成验证。离线部署的第一原则是"不要因为没有邮件就断掉主流程",否则测试站永远起不来。
第二层:默认不回给浏览器。这是安全边界。如果无脑把verify_url放进响应,那么公网上一台忘了配 SMTP 的实例,任何人提交任意邮箱都能直接拿到可用令牌——邮箱验证这道关卡被完整绕过,账号即刻激活。这个洞不会因为"我打算上线前配好 SMTP"而消失,它只在上线前那段时间不存在,而那正好是最多人来试的窗口。
第三层:不靠请求头猜本机。这个想法很诱人——Host: localhost或x-forwarded-for: 127.0.0.1就当成自测环境放行。注释里说清了为什么不行:output: "standalone"的 server.js 会给每一个请求补上转发头,所以"有转发头"和"是不是本机"没有相关性;而Host头由客户端决定,攻击者想写什么就写什么。推断既不可靠又偏不安全,所以做成显式开关:
/** 未发信时把验证 / 重置链接回给调用方,只该在本机自测时开。 */exportfunctionmailEchoEnabled():boolean{returnflagValue(storedRaw("mail_echo"),process.env.CNURL_MAIL_ECHO)??false;}?? false是关键:没设、设了脏值、设成空串,全部落到false。这种"默认关闭"的写法只有在读取端硬编码才算数——写在文档里的"默认关闭"不算。
docker-compose.yml里对它的注释也保持了同一个方向:
# 仅本机自测用:未配 SMTP 时把验证/重置链接一并回给调用方,公网部署保持关闭CNURL_MAIL_ECHO:${CNURL_MAIL_ECHO:-}${VAR:-}展开成空串,而不是"未定义"——配合?? false恰好是关闭。这一条属于上线前必须检查的开关,和SMTP_PASS不要留在.env里同等优先级。
4. 三级回落:后台 > .env > 内置默认,而且"清空即回落"
所有参数读取都经过两个小工具:
functionstoredText(setting:string,env:string):string{return(settingsMap()[setting]??"").trim()||(process.env[env]??"").trim();}functionstoredRaw(setting:string):string{return(settingsMap()[setting]??"").trim();}storedText用于文本项(host / user / from),库里为空自动落到.env;storedRaw用于三态开关和密文项,必须能区分"库里没有这行"和"库里有一行但值是关",所以不做.env回落,交给flagValue(storedRaw(...), process.env.X)自己排优先级。