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

如何在Node.js中使用服务账户访问Gmail API(无需刷新令牌)

改用服务账户访问Gmail API(替代过期的Refresh Token)

问题背景

我正在开发基于React.js和Node.js的项目,需要调用Gmail API读取邮件。此前实现的方案依赖OAuth2的refresh_token,但该令牌每周过期,仅适用于开发环境。现需要改用服务账户或其他无需刷新令牌的方式访问Gmail API,需要谷歌云控制台配置步骤及完整实现代码。

谷歌云控制台配置步骤

1. 创建服务账户

  • 登录谷歌云控制台,进入「IAM与Admin > 服务账户」
  • 点击「创建服务账户」,填写名称和ID后完成创建
  • 为服务账户添加权限角色:根据需求选择,比如「Gmail Reader」(仅读取)或「Gmail Modify」(读取+修改标记)
  • 生成密钥:进入服务账户详情页的「密钥」标签,点击「添加密钥 > 创建新密钥」,选择JSON格式,下载密钥文件并保存到项目目录(请勿提交到版本库)

2. 启用Gmail API

  • 在控制台搜索「Gmail API」,进入后点击「启用」

3. 域范围委派(仅Google Workspace账户)

服务账户需要模拟用户访问邮箱,仅适用于Google Workspace(原G Suite)账户:

  • 登录Google Workspace管理员控制台(admin.google.com)
  • 进入「安全 > API控制 > 域范围委派」
  • 点击「添加新的客户端ID」,输入服务账户的客户端ID(可在服务账户详情页找到)
  • 输入授权的API范围,例如:
    https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.modify
    
  • 保存配置

完整代码实现

首先安装依赖:

npm install googleapis

修改后的GmailService类:

import { google } from 'googleapis';
import fs from 'fs';
import cheerio from 'cheerio';
import puppeteer from 'puppeteer';

export class GmailService {
  private jwtClient: any;
  private readonly targetUserEmail = 'example@gmail.com'; // 要访问的目标邮箱

  constructor(private environmentService: any, private prismaService: any) {
    // 初始化服务账户JWT客户端
    const keyFilePath = './service-account-key.json'; // 替换为你的密钥文件路径
    const serviceAccountKeys = JSON.parse(fs.readFileSync(keyFilePath, 'utf8'));
    
    this.jwtClient = new google.auth.JWT(
      serviceAccountKeys.client_email,
      null,
      serviceAccountKeys.private_key,
      [
        'https://www.googleapis.com/auth/gmail.readonly',
        'https://www.googleapis.com/auth/gmail.modify'
      ], // 按需调整权限范围
      this.targetUserEmail // 模拟的目标用户邮箱
    );
  }

  async addEmail(pageToken = null) {
    try {
      // 完成服务账户认证
      await this.jwtClient.authorize();
      const gmailApi = google.gmail({ version: 'v1', auth: this.jwtClient });

      // 获取未读邮件列表
      const mailListResponse = await gmailApi.users.messages.list({
        userId: this.targetUserEmail,
        maxResults: 1,
        labelIds: ['UNREAD'],
        pageToken: pageToken
      });

      const mailList = mailListResponse.data;
      console.log('Fetched mail list:', mailList);

      if (!mailList.messages || mailList.messages.length === 0) {
        console.log('No unread emails found');
        return;
      }

      // 同步Gmail标签到本地数据库
      await this.syncGmailLabels();

      for (const mailItem of mailList.messages) {
        // 获取单封邮件详情
        const mailDetailResponse = await gmailApi.users.messages.get({
          userId: this.targetUserEmail,
          id: mailItem.id!
        });
        const mailData = mailDetailResponse.data;

        // 解析邮件时间
        const internalDate = mailData.internalDate;
        const emailTime = new Date(parseInt(internalDate!));
        const timeString = emailTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });

        // 解析发件人信息
        const senderHeader = mailData.payload?.headers?.find(header => header.name === 'From')?.value;
        if (!senderHeader) continue;
        
        const senderNameMatch = senderHeader.match(/(.+?)\s*<[^>]+>/);
        const senderName = senderNameMatch ? senderNameMatch[1] : senderHeader;
        const senderEmailMatch = senderHeader.match(/[^<\s]+@[^>\s]+/);
        const senderEmail = senderEmailMatch ? senderEmailMatch[0] : '';

        // 解析邮件主题
        const subject = mailData.payload?.headers?.find(header => header.name === 'Subject')?.value || '无主题';

        // 匹配本地分类
        const matchedCategory = await this.prismaService.categories.findFirst({
          where: {
            labelId: { in: mailData.labelIds || [] }
          }
        });

        // 标记邮件为已读
        await this.markEmailAsRead(mailItem.id!);

        if (matchedCategory) {
          // 提取HTML内容
          const htmlPart = mailData.payload?.parts?.find(part => 
            part.mimeType === 'text/html' && part.body?.size > 0
          );
          
          if (htmlPart) {
            const bodyContent = Buffer.from(htmlPart.body.data, 'base64').toString('utf8');
            const $ = cheerio.load('<!DOCTYPE html><html><head></head><body></body></html>');
            $('body').append(bodyContent);

            // 生成邮件截图
            let encodedImage = await this.generateEmailScreenshot(bodyContent);
            if (!encodedImage) {
              encodedImage = await this.generateEmailScreenshot($.html());
            }

            // 保存到数据库
            await this.prismaService.email.create({
              data: {
                fromEmail: senderEmail,
                content: $.html(),
                title: subject,
                image: encodedImage,
                senderName: senderName,
                categoryId: matchedCategory.id,
                time: timeString,
              },
            });
            console.log('Email record saved to database');
          }
        } else {
          console.log('No matching category found for email');
        }
      }

    } catch (err) {
      console.error('Error processing emails:', (err as Error).message);
    } finally {
      // 10分钟后再次检查邮件
      setTimeout(() => {
        console.log('Scheduling next email check');
        this.addEmail();
      }, 10 * 60 * 1000);
    }
  }

  async markEmailAsRead(emailId: string) {
    const gmailApi = google.gmail({ version: 'v1', auth: this.jwtClient });
    await gmailApi.users.messages.modify({
      userId: this.targetUserEmail,
      id: emailId,
      requestBody: {
        removeLabelIds: ['UNREAD']
      }
    });
    return true;
  }

  async getStoredEmails() {
    try {
      return await this.prismaService.email.findMany();
    } catch (error) {
      console.log('Error fetching stored emails:', error);
    }
  }

  async generateEmailScreenshot(content: string) {
    try {
      // 写入临时HTML文件
      await fs.promises.writeFile('temp-email.html', content);
      console.log('Temporary HTML file created');

      // 启动Puppeteer生成截图
      const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
      const page = await browser.newPage();
      await page.goto(`file://${process.cwd()}/temp-email.html`);
      await page.setViewport({ width: 300, height: 500 });

      const screenshotBase64 = await page.screenshot({ encoding: 'base64' });
      await browser.close();

      // 删除临时文件
      await fs.promises.unlink('temp-email.html');

      return `data:image/png;base64,${screenshotBase64}`;
    } catch (error) {
      console.log('Error generating screenshot:', error);
      return null;
    }
  }

  async syncGmailLabels() {
    try {
      const gmailApi = google.gmail({ version: 'v1', auth: this.jwtClient });
      const labelsResponse = await gmailApi.users.labels.list({
        userId: this.targetUserEmail
      });

      for (const label of labelsResponse.data.labels || []) {
        if (label.type === 'user') {
          const existingCategory = await this.prismaService.categories.findFirst({
            where: { labelId: label.id! }
          });

          if (existingCategory) {
            // 更新已有分类名称
            await this.prismaService.categories.update({
              where: { id: existingCategory.id },
              data: { categoryName: label.name! }
            });
          } else {
            // 创建新分类
            await this.prismaService.categories.create({
              data: {
                categoryName: label.name!,
                labelId: label.id!
              }
            });
          }
        }
      }
    } catch (error) {
      console.log('Error syncing Gmail labels:', (error as Error).message);
    }
  }
}

注意事项

  • 服务账户密钥文件需妥善保管,建议通过环境变量指定文件路径,避免硬编码或提交到版本库
  • 权限范围遵循最小化原则,仅授予业务所需的权限(如仅需读取则使用https://www.googleapis.com/auth/gmail.readonly)
  • 个人Gmail账户无法使用服务账户的域范围委派功能,此类场景建议将OAuth2应用类型设置为「桌面应用」,获取长期有效的refresh_token
  • 确保Google Workspace管理员已完成域范围委派,且服务账户已被授权访问目标用户邮箱

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.13 04:44:50