如何构建 Google Apps Script,把 Sheet 中的联系人导入 Brevo
自动把 Google Sheet 中的联系人推送到 Brevo。一个完整的 Apps Script,包含 API 密钥存储、按编辑触发、基于时间的触发器、自定义菜单和错误处理。无需服务器。
如果你的团队已经生活在 Google Sheet 里(销售线索、活动报名、合作伙伴联系人列表),把这些数据导入 Brevo 不必涉及导出 CSV 然后手动重新导入。Google Apps Script 让你把 Sheet 直接接到 Brevo 的 API。脚本运行在 Google 的基础设施内,所以没有任何东西需要托管、部署或照看。
本指南走过一个可工作的脚本:你 Sheet 中自定义的 Sync to Brevo 菜单项、自动每小时触发器、安全的 API 密钥存储、批处理,以及一点结构化日志,让你能知道发生了什么。
你需要什么
- 一个有联系人的 Google Sheet(每联系人一行,先是表头行)
- 一个 Brevo 账户和一个 API 密钥(Settings → SMTP & API → API Keys)
- 你想把联系人添加到的 Brevo 列表的数字 ID
就这些。无 npm,无 Python,无服务器。
Sheet 布局
本指南中的脚本期望一个表头行,后面跟每行一个联系人。列按表头名映射到 Brevo 属性:
| firstName | lastName | company | city | |
|---|---|---|---|---|
| [email protected] | Jane | Doe | Acme | Berlin |
| [email protected] | John | Smith | Globex | Paris |
email 是必需的,并且不区分大小写匹配。其他所有内容都作为联系人属性发送给 Brevo。自定义属性(任何标准属性如 FIRSTNAME、LASTNAME 之外的)需要先在你的 Brevo 账户中存在。在 Contacts → Settings → Contact attributes 中定义它们,或通过 Brevo API。
打开 Apps Script 编辑器
在你的 Sheet 中:Extensions → Apps Script。一个新标签打开,里面是空的 Code.gs。把内容替换为下面的脚本。
完整脚本
const BREVO_API_BASE = 'https://api.brevo.com/v3';const BREVO_LIST_ID = 42; // <- the Brevo list to import contacts intoconst SHEET_NAME = 'Contacts'; // <- name of the sheet tab to readconst BATCH_SIZE = 1000; // contacts per import call
/** * Adds a "Brevo" menu to the Sheet so users can run the sync from the UI. * Triggered automatically when the Sheet opens. */function onOpen() { SpreadsheetApp.getUi() .createMenu('Brevo') .addItem('Sync sheet to Brevo', 'syncSheetToBrevo') .addItem('Configure API key', 'configureApiKey') .addToUi();}
/** * Reads every contact row from the sheet, batches them, and sends each batch * to Brevo's import endpoint. Returns a summary string for logging. */function syncSheetToBrevo() { const apiKey = getApiKey_(); if (!apiKey) { SpreadsheetApp.getUi().alert( 'No Brevo API key configured. Run "Configure API key" first.' ); return; }
const contacts = readContactsFromSheet_(); if (contacts.length === 0) { SpreadsheetApp.getUi().alert('No contacts found in the sheet.'); return; }
const batches = chunk_(contacts, BATCH_SIZE); const results = [];
for (let i = 0; i < batches.length; i++) { const result = importBatchToBrevo_(apiKey, batches[i]); results.push(result); Logger.log( `Batch ${i + 1}/${batches.length}: ${result.ok ? 'ok' : 'FAILED'} ` + `(processId=${result.processId || '-'}, status=${result.status})` ); }
const summary = `Sent ${contacts.length} contacts in ${batches.length} batch(es). ` + `Successful: ${results.filter(r => r.ok).length}/${results.length}.`; Logger.log(summary); SpreadsheetApp.getActiveSpreadsheet().toast(summary, 'Brevo sync', 5); return summary;}
/** * Reads the active spreadsheet's "Contacts" tab into an array of * { email, attributes } objects shaped for Brevo's jsonBody. */function readContactsFromSheet_() { const sheet = SpreadsheetApp .getActiveSpreadsheet() .getSheetByName(SHEET_NAME); if (!sheet) { throw new Error(`Sheet tab "${SHEET_NAME}" not found`); }
const range = sheet.getDataRange().getValues(); if (range.length < 2) return [];
const headers = range[0].map(String); const emailColumn = headers.findIndex(h => h.toLowerCase() === 'email'); if (emailColumn === -1) { throw new Error('Sheet must have an "email" column'); }
const contacts = []; for (let i = 1; i < range.length; i++) { const row = range[i]; const email = String(row[emailColumn] || '').trim().toLowerCase(); if (!email || !email.includes('@')) continue; // skip invalid
const attributes = {}; for (let c = 0; c < headers.length; c++) { if (c === emailColumn) continue; const value = row[c]; if (value === '' || value === null) continue; // Brevo convention: ATTRIBUTES ARE UPPERCASE attributes[headers[c].toUpperCase()] = value; } contacts.push({ email, attributes }); } return contacts;}
/** * POSTs a batch of contacts to Brevo's import endpoint. * Returns { ok, status, processId, error }. */function importBatchToBrevo_(apiKey, contacts) { const payload = { jsonBody: contacts, listIds: [BREVO_LIST_ID], updateExistingContacts: true, emptyContactsAttributes: false, };
const response = UrlFetchApp.fetch(`${BREVO_API_BASE}/contacts/import`, { method: 'post', contentType: 'application/json', headers: { 'api-key': apiKey, 'accept': 'application/json', }, payload: JSON.stringify(payload), muteHttpExceptions: true, // we'll inspect status ourselves });
const status = response.getResponseCode(); const body = response.getContentText();
if (status === 202) { const json = JSON.parse(body); return { ok: true, status, processId: json.processId }; } return { ok: false, status, error: body };}
/** * Stores the Brevo API key in script properties, encrypted at rest by Google * and not visible in the source code or to viewers of the sheet. */function configureApiKey() { const ui = SpreadsheetApp.getUi(); const response = ui.prompt( 'Brevo API key', 'Paste your Brevo API key (xkeysib-...). It will be stored in Script Properties.', ui.ButtonSet.OK_CANCEL ); if (response.getSelectedButton() !== ui.Button.OK) return; const key = response.getResponseText().trim(); if (!key.startsWith('xkeysib-')) { ui.alert('That doesn\'t look like a Brevo API key (should start with xkeysib-).'); return; } PropertiesService.getScriptProperties().setProperty('BREVO_API_KEY', key); ui.alert('API key saved.');}
function getApiKey_() { return PropertiesService.getScriptProperties().getProperty('BREVO_API_KEY');}
function chunk_(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out;}就这些。保存它(⌘S / Ctrl+S),把项目命名为类似 “Brevo sync”,然后回到你的 Sheet。
第一次运行
重新加载 Sheet,新的 Brevo 菜单会出现在顶部。
- 点击 Brevo → Configure API key,粘贴你的
xkeysib-...密钥,点击 OK。 - 点击 Brevo → Sync sheet to Brevo。Google 会在第一次询问权限:
- “View and manage your spreadsheets”:读取行所必需
- “Connect to an external service”:调用 api.brevo.com 所必需
- 批准。脚本运行。右下角的绿色 toast 告诉你有多少个联系人发送了出去。
如果失败,点击 Extensions → Apps Script → View → Logs 查看 Brevo 返回的每批次状态码。最常见的失败是因缺少自定义属性而出现的 400,请见下面的故障排除部分。
按计划运行
在 Apps Script 编辑器中:Triggers(左侧栏的时钟图标)→ Add Trigger。
- Choose function:
syncSheetToBrevo - Event source:Time-driven
- Type:Hour timer(或 Day timer 用于每天一次的同步)
- Interval:每小时(或任何适合的)
保存。Google 会按这个频率永远运行该函数,无服务器、无 cron、无维护。
如果你希望每次单元格变更都触发同步,也可以使用 From spreadsheet → On edit。要小心,即便是装饰性编辑也会触发触发器,这在繁忙的 Sheet 上会很快耗尽 Apps Script 的每日配额。每小时时间触发器几乎总是正确答案。
需要了解的 Apps Script 配额
免费 Apps Script 层级有需要尊重的限制:
| 限制 | 值(免费层) |
|---|---|
| 每天总运行时间 | 90 分钟 |
| 单次执行时间 | 6 分钟 |
每天 UrlFetchApp 调用 | 20,000 |
UrlFetchApp 负载大小 | 50 MB |
UrlFetchApp 头大小 | 8 KB |
| 每用户每脚本触发器数 | 20 |
对于典型的联系人同步(几千个联系人,每小时一次),你远远没有接近任何限制。唯一需要注意的是 6 分钟单次执行:如果你曾经一次同步几十万个联系人,请把它们分成更小的块(上面的脚本已经通过 BATCH_SIZE 做到了这一点)。
异步处理导入
Brevo 的导入端点是异步的:你立即拿到 processId,实际导入在服务器端运行。对大多数 Sheet 同步来说,这没问题(fire and forget,每个批次完成时 Brevo 会发邮件汇总)。
如果你想阻塞直到导入真正完成,请轮询进程状态端点:
function waitForImport_(apiKey, processId, timeoutMs = 5 * 60 * 1000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const resp = UrlFetchApp.fetch(`${BREVO_API_BASE}/processes/${processId}`, { headers: { 'api-key': apiKey }, muteHttpExceptions: true, }); if (resp.getResponseCode() === 200) { const status = JSON.parse(resp.getContentText()).status; if (status === 'completed' || status === 'failed') return status; } Utilities.sleep(5000); // 5s between checks } return 'timeout';}Utilities.sleep 是 Apps Script 中阻塞等待的等价物。不要睡得太久,每次执行你总共有 6 分钟。
添加通知 webhook
比轮询更干净的模式是把你的 Apps Script 部署为 Web App,并把它的 URL 作为 notifyUrl 传递。Brevo 会在导入完成时向它 POST。
// Add to Code.gsfunction doPost(e) { const payload = JSON.parse(e.postData.contents); Logger.log(`Brevo import ${payload.processId} finished: ${payload.status}`); // optionally: write the result back to a "Sync log" tab in the sheet return ContentService.createTextOutput('ok');}部署:Deploy → New deployment → Web app,把 “Who has access” 设为 Anyone,复制结果 URL,并在你的导入负载中作为 notifyUrl 传递:
payload.notifyUrl = 'https://script.google.com/macros/s/AKfy.../exec';现在 Brevo 把结果发回你的 Sheet 自己的脚本,闭合循环而无需外部基础设施。
故障排除
带 error: "Attribute X not found" 的 400 Bad Request:你 Sheet 中的某列映射到 Brevo 不知道的属性。要么重命名 Sheet 列以匹配现有属性,要么在 Brevo 中创建该属性(Contacts → Settings → Contact attributes)。
401 Unauthorized:API 密钥错误或已过期。重新运行 Configure API key,从 Brevo 仪表板粘贴一个新密钥。
429 Too Many Requests:你正撞上 Brevo 的速率限制。导入端点允许每分钟约 30 次调用。如果你正在激进地批处理,请在循环中的批次之间添加 Utilities.sleep(2000)。
脚本静默地不按计划运行:在 Apps Script 编辑器中检查 Triggers。如果一个触发器反复失败,Google 会禁用它。点击进入触发器查看失败原因,通常是你可以重新授权的权限问题。
Brevo 菜单没有出现:onOpen 只在你(重新)从头打开 Sheet 时运行。重新加载浏览器标签。
权限弹窗一直回来:你可能编辑了脚本的作用域(添加了新的 Google 服务)。Apps Script 在所需权限发生变化时会再次提示授权。从编辑器运行任何函数一次以触发提示并批准。
这为什么胜过 Zapier 等
Apps Script 免费,生活在 Google 的基础设施内,并对 Sheet 的数据有直接访问权(没有逐行事件触发,没有每任务定价,除了 Google 的配额(对这类任务来说很慷慨)之外没有速率限制)。另一面:你承诺要写并维护一小段代码。对联系人同步来说,那是大约 100 行,并且基本上没有持续维护。
把这个与每天一次的触发器和销售团队已经在更新的表格搭配,你就有了一个零重复工作的 Brevo 联系人管道。