You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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同仓项目的典型问题,按以下顺序排查修复:

  • 部署配置问题
    1. 确认Heroku的启动命令配置正确。项目根目录package.json里的start脚本必须指向Express服务入口文件,比如入口文件是server.js,脚本要写为"start": "node server.js",不能写React本地开发命令react-scripts start。
    2. 确认后端服务正常启动。先直接访问部署后的域名根路径,如果根路径也返回404,说明服务根本没跑起来,直接看Heroku运行日志排查启动错误。常见启动失败原因:环境变量没在Heroku后台配置全、运行依赖漏装。

    注意:所有Express运行需要的依赖(nodemailer、googleapis、cors等)必须写到dependencies字段下,不要写到devDependencies,Heroku生产环境默认不会安装devDependencies下的包,会直接导致require报错、服务启动失败

  • 路由匹配问题
    1. 先给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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 08:57:21