如何在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
相关产品推荐
相关产品推荐

