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

Nodemailer定时任务随机发送重复邮件问题求助

问题分析与解决方案

核心原因:异步竞态条件

你的代码存在异步操作未同步导致的竞态问题:

  • 定时任务每2分钟执行一次,每次查询SEND_FLAG = 'N' AND STATUS ='NOT_SENT'的记录
  • 处理邮件时(尤其是生成PDF的分支),generatePOPDF、transporter.sendMail、runProcedureMYSQL都是异步操作,这些操作完成前,下一次定时任务已经触发
  • 此时数据库中对应记录的SEND_FLAG和STATUS还未更新,导致同一条记录被重复查询、重复发送

另外,每次定时任务都重新创建transporter实例虽然不是重复发送的直接原因,但会造成资源浪费,建议复用。

具体修复步骤

1. 先标记记录为“发送中”,避免重复查询

修改查询逻辑,先将待发送的记录锁定并标记为发送状态,防止下一次定时任务读取到未处理的记录:

let query = `SELECT * FROM EMAIL_NOTIFICATIONS_V WHERE LANGUAGE = 'EN' AND SEND_FLAG = 'N' AND STATUS ='NOT_SENT' FOR UPDATE`;
let result = await runQueryMYSQL(query);

// 先批量更新状态为SENDING
if (result.length > 0) {
  const notificationIds = result.map(item => item.NOTIFICATION_ID).join(',');
  await runQueryMYSQL(`UPDATE EMAIL_NOTIFICATIONS SET STATUS = 'SENDING' WHERE NOTIFICATION_ID IN (${notificationIds})`);
}

这里用FOR UPDATE行级锁,确保其他事务无法修改这些记录,同时立即更新状态为SENDING,后续定时任务不会再查询到它们。

2. 重构异步代码为顺序执行

将.then()改为await,确保每一条邮件的发送和数据库更新完成后,再处理下一条:

// 复用transporter,不要每次创建
const transporter = nodeMailer.createTransport({
  service: 'gmail.com',
  auth: {
    user: constantVariables.CREDENTIAL.EMAIL_NOTIFICATION.user,
    pass: constantVariables.CREDENTIAL.EMAIL_NOTIFICATION.password
  }
});

for (let i = 0; i < result.length; i++) {
  const item = result[i];
  let mailOptions;

  if (item.EMAIL_CATEGORY_CODE === 'PO_APPROVED_EXT' && item.REFERENCE_ID !== null) {
    const buffer = await generatePOPDF(item.REFERENCE_ID, item.LANGUAGE);
    mailOptions = {
      from: item.FROM_EMAIL_ADDRESS,
      to: item.TO_EMAIL_ADDRESS,
      cc: item.CC_EMAIL_ADDRESS || '',
      subject: item.EMAIL_SUBJECT,
      html: item.EMAIL_BODY,
      attachments: [{
        filename: `PO-${item.REFERENCE_ID}.pdf`,
        content: buffer,
        contentType: 'application/pdf',
        contentDisposition: `attachment; filename = &quot;PO-${item.REFERENCE_ID}.pdf&quot;`
      }]
    };
  } else {
    mailOptions = {
      from: item.FROM_EMAIL_ADDRESS,
      to: item.TO_EMAIL_ADDRESS,
      cc: item.CC_EMAIL_ADDRESS || '',
      subject: item.EMAIL_SUBJECT,
      html: item.EMAIL_BODY,
    };
  }

  // 等待邮件发送完成
  try {
    await transporter.sendMail(mailOptions);
    await runProcedureMYSQL("EMAIL_NOTIFICATIONS_SP", [
      item.NOTIFICATION_ID,
      null, null, null, null, null, null, null, null, null, null, null,
      'Y', 'Submitted', null, null,
      item.FROM_USER_ID, item.FROM_USER_ID, 'InsertOrUpdateEmailNotification'
    ]);
    // 更新最终状态为SENT
    await runQueryMYSQL(`UPDATE EMAIL_NOTIFICATIONS SET STATUS = 'SENT' WHERE NOTIFICATION_ID = ${item.NOTIFICATION_ID}`);
  } catch (error) {
    await runProcedureMYSQL("EMAIL_NOTIFICATIONS_SP", [
      item.NOTIFICATION_ID,
      null, null, null, null, null, null, null, null, null, null, null,
      'N', error.message, null, null,
      item.FROM_USER_ID, item.FROM_USER_ID, 'InsertOrUpdateEmailNotification'
    ]);
    // 更新状态为FAILED,方便后续重试
    await runQueryMYSQL(`UPDATE EMAIL_NOTIFICATIONS SET STATUS = 'FAILED' WHERE NOTIFICATION_ID = ${item.NOTIFICATION_ID}`);
  }
}

3. 确保定时任务不会重叠执行

添加执行锁,防止上一次任务未完成时,下一次任务重复触发:

let isJobRunning = false;

const emailScheduler = () => {
  let jobs = CronJob.from({
    cronTime: `*/${schedulerTime} * * * *`,
    onTick: async function () {
      if (isJobRunning) return;
      isJobRunning = true;
      try {
        // 原来的处理逻辑(包含上面的查询、更新、邮件发送代码)
      } finally {
        isJobRunning = false;
      }
    },
    start: false,
    timeZone: env.TIME_ZONE
  })
  jobs.start()
}

验证SMTP服务器是否存在问题

如果修复代码后仍有重复发送,可以通过以下方式排查:

  • 在transporter.sendMail前添加日志,记录每一次发送的NOTIFICATION_ID,对比重复发送的记录是否是同一ID
  • 测试单次发送邮件,看SMTP是否会返回多次成功响应
  • 检查Gmail的发送记录,确认是否是代码重复调用导致的发送,而非SMTP重复投递

内容的提问来源于stack exchange,提问作者Arslan

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.16 16:08:15