Heroku部署Nodemailer OAuth2发邮件报POST 404错误
问题现象
- 本地开发环境下,React + Nodemailer + OAuth2 实现的站点联系表单功能完全正常,本地请求地址为
http://localhost:3001/send - 项目部署到Heroku生产环境后,提交表单触发POST请求返回
POST 404 not found错误,检索公开技术资料未找到有效解决方案
相关代码
服务端代码
const express = require("express"); const nodemailer = require("nodemailer"); const app = express(); const { google } = require("googleapis"); const OAuth2 = google.auth.OAuth2; const cors = require("cors"); require('dotenv').config() const PASSWORD = process.env.password; const EMAIL = process.env.email; const TOKEN = process.env.OAUTH_REFRESH_TOKEN; const SECRET = process.env.OAUTH_CLIENT_SECRET; const CLIENT = process.env.OAUTH_CLIENTID; const oauthClient = new OAuth2( CLIENT, SECRET, "https://developers.google.com/oauthplayground" ) oauthClient.setCredentials({ refresh_token: TOKEN }); const accessToken = oauthClient.getAccessToken() // 中间件 app.use(express.json()); app.use(cors()); const port = process.env.PORT || 3001; app.listen(port, () => { console.log(`Server is running on port: ${port}`) }); let transporter = nodemailer.createTransport({ service: "gmail", auth: { type: "OAuth2", user: EMAIL, pass: PASSWORD, clientId: CLIENT, clientSecret: SECRET, refreshToken: TOKEN, accessToken: accessToken }, }); transporter.verify((err, success) => { err ? console.log(err) : console.log(`=== Server is ready to take messages: ${success} ===`); }); app.post("/send", function (req, res) { let mailOptions = { from: `${req.body.mailerState.email}`, to: EMAIL, subject: `${req.body.mailerState.subject} from ${req.body.mailerState.name}`, text: `You received this email from ${req.body.mailerState.name} @ ${req.body.mailerState.email} They wrote: \n${req.body.mailerState.message}`, }; transporter.sendMail(mailOptions, function (err, data) { if (err) { console.log("Error " + err, "data " + data); res.json({ status: "fail" }); } else { console.log("Email sent successfully"); res.json({ status: "success" }); } }); });
前端Fetch请求代码
const submitEmail = async (e) => { e.preventDefault(); console.log({ mailerState }); const response = await fetch("https://<myapp>.herokuapp.com/send", { method: "POST", headers: { "Content-type": "application/json", }, body: JSON.stringify({ mailerState }), }) .then((res) => res.json()) .then(async (res) => { const resData = await res; console.log(resData); if (resData.status === "success") { alert("Message sent"); } else if (resData.status === "fail") { alert("Message failed to send.") } }) .then(() => { setMailerState({ email: "", name: "", subject: "", message: "", }); }); };
解决方案
这个404是Heroku部署React+Express同仓项目的典型问题,按以下顺序排查修复:
- 部署配置问题
- 确认Heroku的启动命令配置正确。项目根目录
package.json里的start脚本必须指向Express服务入口文件,比如入口文件是server.js,脚本要写为"start": "node server.js",不能写React本地开发命令react-scripts start。 - 确认后端服务正常启动。先直接访问部署后的域名根路径,如果根路径也返回404,说明服务根本没跑起来,直接看Heroku运行日志排查启动错误。常见启动失败原因:环境变量没在Heroku后台配置全、运行依赖漏装。
注意:所有Express运行需要的依赖(nodemailer、googleapis、cors等)必须写到
dependencies字段下,不要写到devDependencies,Heroku生产环境默认不会安装devDependencies下的包,会直接导致require报错、服务启动失败 - 确认Heroku的启动命令配置正确。项目根目录
- 路由匹配问题
- 先给Express加个根路径测试路由,部署后先访问根路径验证服务存活:
如果根路径能正常返回内容,但app.get("/", (req, res) => { res.send("Server running") })/send报404,先检查路由拼写是否正确,有没有多写少写前缀。
2. 如果项目配置了Express静态资源托管、前端路由兜底,必须把/send接口的定义放在这些配置的前面。注意:Express路由匹配是按代码书写顺序从上到下执行的,如果静态资源托管、前端兜底路由写在接口前面,请求会被优先拦截,找不到对应静态文件就直接返回404,根本不会走到接口逻辑
正确写法参考:// 接口路由写在最前面 app.post("/send", function (req, res) { // 原有发信逻辑 }) // 后面再写静态资源托管 app.use(express.static(path.join(__dirname, "build"))) // 最后写前端路由兜底 app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "build", "index.html")) }) - 请求地址问题
不要硬编码Heroku域名,生产环境直接用相对路径/send作为fetch请求地址即可,避免域名拼写错误、http/https混用导致的异常。本地开发时在React项目的package.json里加"proxy": "http://localhost:3001",就可以同时兼容本地和生产环境,不需要手动切换请求地址。 - 附带修复原有代码隐患
现有代码里oauthClient.getAccessToken()是异步方法,同步调用拿不到有效token,会导致后续发信失败,调整为发信前异步获取token即可:app.post("/send", async function (req, res) { try { const accessTokenRes = await oauthClient.getAccessToken() const transporter = nodemailer.createTransport({ service: "gmail", auth: { type: "OAuth2", user: EMAIL, clientId: CLIENT, clientSecret: SECRET, refreshToken: TOKEN, accessToken: accessTokenRes.token }, }); const mailOptions = { from: `${req.body.mailerState.email}`, to: EMAIL, subject: `${req.body.mailerState.subject} from ${req.body.mailerState.name}`, text: `You received this email from ${req.body.mailerState.name} @ ${req.body.mailerState.email} They wrote: \n${req.body.mailerState.message}`, }; await transporter.sendMail(mailOptions) res.json({ status: "success" }); } catch (err) { console.log("Send error: ", err); res.json({ status: "fail" }); } });
内容的提问来源于stack exchange,提问作者Christopher Wolfe
相关产品推荐
相关产品推荐

