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

每日12点GMT触发的Pub/Sub云函数无法正常发送邮件

问题描述

部署了一个Pub/Sub Cloud Function,设定每日GMT时间0点自动执行,逻辑为:从Firestore读取访客列表,转换为Excel文件后,通过SendGrid API发送给Firestore中存储的收件人邮箱。
出现异常:

  • 当设置为10/30分钟短间隔触发,或手动触发时,函数正常运行,收件人能收到邮件
  • 每日0点定时触发时,云函数日志显示执行状态为OK,但收件人收不到邮件
    云函数与Firebase区域为us-central-1,时区配置为Europe/London(GMT),相关代码如下:
const { initializeApp, cert } = require('firebase-admin/app');
const { getFirestore, doc, deleteDoc, delete:deleteDocument } = require('firebase-admin/firestore');
const functions = require('firebase-functions');
const serviceAccount = require('./serviceAcc.json');
const sgMail = require("@sendgrid/mail");
const excelJS = require('exceljs');

initializeApp({
  credential: cert(serviceAccount)
});

const db = getFirestore();

let receiver = "";
let newVisitors = [];
let todaysDate;

async function deleteCollection(db, collectionPath, batchSize) {
  const collectionRef = db.collection(collectionPath);
  const query = collectionRef.orderBy('__name__').limit(batchSize);

  return new Promise((resolve, reject) => {
    deleteQueryBatch(db, query, resolve).catch(reject);
  });
}

async function deleteQueryBatch(db, query, resolve) {
  const snapshot = await query.get();


  const batchSize = snapshot.size;
  if (batchSize === 0) {
    // When there are no documents left, we are done

    if (newVisitors.length !== 0)
    {
        let workbook = new excelJS.Workbook();
        const worksheet = workbook.addWorksheet('Sheet1');
        worksheet.columns = [
            { header: "Name", key: "name", width:"30"},
            { header: "Type", key: "type", width:"20"},
            { header: "EnteringTime", key: "enteringTime", width:"40"},
            { header: "LeavingTime", key: "leavingTime", width:"40"},
        ]

        newVisitors.map((value,id) => {

            worksheet.addRow({
                name: value.name,
                type: value.type, 
                enteringTime: value.enteringTime,
                leavingTime: value.leavingTime,
            });
        })

        workbook.xlsx.writeBuffer().then((buffer) => {

            const msg = {
                to: receiver,
                from: "venuemanager@acostadancecentre.com",
                subject: "VISITORS LIST FOR TODAY",
                text: `PFA visitor list for ${todaysDate}`,
                html: `<h1>PFA visitor list for ${todaysDate}</h1>`,
                attachments: [
                    {
                    content: buffer.toString('base64'),
                    filename: 'visitors.xlsx',
                    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                    disposition: 'attachment',
                    },
                ],
            };

            sgMail.send(msg)
            .then((response) => {
                console.log("Email sent successfully");
                newVisitors = [];
            })
            .catch((error) => {
                console.log(`Error while sending email: ${error.message}`)
                newVisitors = [];
            });


        })
        .catch((error) => console.log(`Error while making buffer of excel: ${error.message}`))

    }


    resolve();
    return;
  }

  

  // Delete documents in a batch
  const batch = db.batch();
  snapshot.docs.forEach((doc) => {

    const abc = doc.data();
    todaysDate = abc.enteringTime.toDate().toDateString();
    const enteringTime = abc.enteringTime.toDate().toDateString() + " at " + abc.enteringTime.toDate(). toLocaleTimeString();


    let leavingTime;

    if (abc.isLeft) {
        leavingTime = abc.leavingTime.toDate().toDateString() + " at " + abc.leavingTime.toDate().toLocaleTimeString();
    } else {
        leavingTime = "NIL"; // or set it to a default value
    }

    const newVisitor = {
        name: abc.name,
        type: abc.type,
        enteringTime,
        // isLeft: abc.isLeft,
        leavingTime,
    };

    newVisitors.push(newVisitor);


    batch.delete(doc.ref);
  });
  await batch.commit();

  // Recurse on the next process tick, to avoid
  // exploding the stack.
  process.nextTick(() => {
    deleteQueryBatch(db, query, resolve);
  });
}

exports.emailTask = functions.pubsub.schedule("0 0 * * *").timeZone("Europe/London").onRun(async (context) => {

    sgMail.setApiKey('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'); // Replace with your SendGrid API key


    db.collection('RecipientEmail').get().then((snapshot) => {

        snapshot.forEach((doc) => {
        receiver = doc.data();
        console.log(receiver);
        });


        deleteCollection(db, "visitors", 10).then((res) => console.log(`successs`))
        .catch((error) => console.log(`faillllll: ${error.message()}`));

    })
    .catch((error) => console.log(`Error while fetching receipientEmail: ${error.message}`));


});
问题根源
  1. 异步操作未被正确等待:emailTask是async函数,但内部的Firestore查询、deleteCollection调用都用.then()链式处理,没有返回Promise也没有用await,导致Cloud Function在异步逻辑(比如Excel生成、邮件发送)完成前就提前终止。短间隔/手动触发时执行时间短,逻辑可能刚好跑完;但每日触发时数据量可能更大,异步操作还没完成就被函数生命周期中断。
  2. 全局变量引发的状态混乱:receiver、newVisitors、todaysDate都是全局变量,Cloud Function的实例复用机制可能导致上一次执行的残留值干扰当前执行,比如收件人信息错误、访客列表数据混乱。
  3. 邮件发送逻辑未纳入Promise链:deleteQueryBatch中生成Excel和发送邮件的.then()没有被包含在函数的Promise链里,这部分异步操作不被Cloud Function的生命周期追踪,函数可能在邮件发送前就结束。
  4. 收件人赋值错误:receiver = doc.data()将整个文档对象赋值给receiver,但SendGrid的to字段需要的是邮箱字符串,应该读取文档中对应的邮箱字段(比如doc.data().email,需根据实际字段名调整)。
修复方案

将所有异步操作改为await语法,移除全局变量,确保所有异步逻辑被函数生命周期追踪,修正收件人赋值:

const { initializeApp, cert } = require('firebase-admin/app');
const { getFirestore } = require('firebase-admin/firestore');
const functions = require('firebase-functions');
const serviceAccount = require('./serviceAcc.json');
const sgMail = require("@sendgrid/mail");
const excelJS = require('exceljs');

initializeApp({
  credential: cert(serviceAccount)
});

const db = getFirestore();

// 初始化SendGrid API Key,无需在每次触发时重复设置
sgMail.setApiKey('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');

async function deleteCollectionAndCollectVisitors(db, collectionPath, batchSize) {
  const visitors = [];
  let todaysDate = '';

  const deleteQueryBatch = async (query) => {
    const snapshot = await query.get();
    const batchSize = snapshot.size;

    if (batchSize === 0) {
      return { visitors, todaysDate };
    }

    const batch = db.batch();
    snapshot.docs.forEach(doc => {
      const data = doc.data();
      // 取第一个访客的日期作为当日日期
      if (!todaysDate) {
        todaysDate = data.enteringTime.toDate().toDateString();
      }

      const enteringTime = `${data.enteringTime.toDate().toDateString()} at ${data.enteringTime.toDate().toLocaleTimeString()}`;
      const leavingTime = data.isLeft 
        ? `${data.leavingTime.toDate().toDateString()} at ${data.leavingTime.toDate().toLocaleTimeString()}` 
        : "NIL";

      visitors.push({
        name: data.name,
        type: data.type,
        enteringTime,
        leavingTime
      });

      batch.delete(doc.ref);
    });

    await batch.commit();
    // 递归时等待下一批处理完成
    await deleteQueryBatch(query);
  };

  const collectionRef = db.collection(collectionPath);
  const query = collectionRef.orderBy('__name__').limit(batchSize);
  await deleteQueryBatch(query);

  return { visitors, todaysDate };
}

exports.emailTask = functions.pubsub.schedule("0 0 * * *").timeZone("Europe/London").onRun(async (context) => {
  try {
    // 获取收件人邮箱,这里假设文档中有email字段,根据实际调整
    const recipientSnapshot = await db.collection('RecipientEmail').get();
    let receiver = '';
    recipientSnapshot.forEach(doc => {
      // 读取邮箱字段,替换成实际字段名
      receiver = doc.data().email;
      console.log('收件人邮箱:', receiver);
    });

    if (!receiver) {
      console.log('未找到收件人邮箱');
      return;
    }

    // 删除访客集合并收集数据
    const { visitors, todaysDate } = await deleteCollectionAndCollectVisitors(db, "visitors", 10);

    if (visitors.length === 0) {
      console.log('今日无访客数据,无需发送邮件');
      return;
    }

    // 生成Excel
    const workbook = new excelJS.Workbook();
    const worksheet = workbook.addWorksheet('Sheet1');
    worksheet.columns = [
      { header: "Name", key: "name", width: 30 },
      { header: "Type", key: "type", width: 20 },
      { header: "EnteringTime", key: "enteringTime", width: 40 },
      { header: "LeavingTime", key: "leavingTime", width: 40 },
    ];

    visitors.forEach(visitor => {
      worksheet.addRow(visitor);
    });

    const buffer = await workbook.xlsx.writeBuffer();

    // 发送邮件
    const msg = {
      to: receiver,
      from: "venuemanager@acostadancecentre.com",
      subject: "VISITORS LIST FOR TODAY",
      text: `PFA visitor list for ${todaysDate}`,
      html: `<h1>PFA visitor list for ${todaysDate}</h1>`,
      attachments: [
        {
          content: buffer.toString('base64'),
          filename: 'visitors.xlsx',
          type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
          disposition: 'attachment',
        },
      ],
    };

    await sgMail.send(msg);
    console.log("邮件发送成功");

  } catch (error) {
    console.error('执行出错:', error.message);
    throw error; // 抛出错误让Cloud Function标记为失败,便于排查
  }
});
关键优化点
  • 移除所有全局变量,改用函数内部变量,避免实例复用导致的状态污染
  • 所有异步操作统一使用await,确保函数等待所有逻辑完成后再终止
  • 将访客数据收集、删除集合的逻辑封装为独立函数,返回明确的数据结构
  • 把邮件发送逻辑纳入主Promise链,确保被函数生命周期追踪
  • 添加完整的错误捕获,抛出错误让Cloud Function标记执行状态为失败,便于日志排查
  • 提前初始化SendGrid API Key,无需每次触发重复设置

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.02 23:47:34