如何构建 Google Apps Script,把 Sheet 中的联系人导入 Brevo

自动把 Google Sheet 中的联系人推送到 Brevo。一个完整的 Apps Script,包含 API 密钥存储、按编辑触发、基于时间的触发器、自定义菜单和错误处理。无需服务器。

Featured image for article: 如何构建 Google Apps Script,把 Sheet 中的联系人导入 Brevo

如果你的团队已经生活在 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 属性:

emailfirstNamelastNamecompanycity
[email protected]JaneDoeAcmeBerlin
[email protected]JohnSmithGlobexParis

email 是必需的,并且不区分大小写匹配。其他所有内容都作为联系人属性发送给 Brevo。自定义属性(任何标准属性如 FIRSTNAMELASTNAME 之外的)需要先在你的 Brevo 账户中存在。在 Contacts → Settings → Contact attributes 中定义它们,或通过 Brevo API。

打开 Apps Script 编辑器

在你的 Sheet 中:Extensions → Apps Script。一个新标签打开,里面是空的 Code.gs。把内容替换为下面的脚本。

完整脚本

Code.gs
const BREVO_API_BASE = 'https://api.brevo.com/v3';
const BREVO_LIST_ID = 42; // <- the Brevo list to import contacts into
const SHEET_NAME = 'Contacts'; // <- name of the sheet tab to read
const 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 菜单会出现在顶部。

  1. 点击 Brevo → Configure API key,粘贴你的 xkeysib-... 密钥,点击 OK。
  2. 点击 Brevo → Sync sheet to Brevo。Google 会在第一次询问权限:
    • “View and manage your spreadsheets”:读取行所必需
    • “Connect to an external service”:调用 api.brevo.com 所必需
  3. 批准。脚本运行。右下角的绿色 toast 告诉你有多少个联系人发送了出去。

如果失败,点击 Extensions → Apps Script → View → Logs 查看 Brevo 返回的每批次状态码。最常见的失败是因缺少自定义属性而出现的 400,请见下面的故障排除部分。

按计划运行

在 Apps Script 编辑器中:Triggers(左侧栏的时钟图标)→ Add Trigger

  • Choose functionsyncSheetToBrevo
  • 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.gs
function 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 联系人管道。

延伸阅读

Frequently Asked Questions

把 Google Sheet 同步到 Brevo 需要服务器或任何基础设施吗?
不需要。Google Apps Script 在 Google Sheets 内部运行。你写一个函数,保存项目,Google 会托管并运行它。脚本可以直接使用 UrlFetchApp 调用 Brevo 的 API。
我应该把 Brevo API 密钥存在哪里?
使用 PropertiesService.getScriptProperties(),这是一个由 Google 管理、作用域为 Apps Script 项目的键值存储。不要在源代码中硬编码密钥;Sheet 的协作者会看到它。
如何让它每天自动运行?
打开 Apps Script → Triggers → Add Trigger。选择 syncSheetToBrevo 函数,选择 'Time-driven',并设置每天/每小时的频率。Google 在免费层的配额是每天 90 分钟的总 Apps Script 执行时间,对联系人同步来说绰绰有余。
有行数限制吗?
Brevo 的导入端点接受约 10 MB 的内联 JSON。这大约相当于 30,000 到 50,000 个联系人,取决于每个联系人有多少属性。Apps Script 的 UrlFetchApp 可以发送 50 MB 的负载,所以瓶颈是 Brevo,而不是 Apps Script。对于更大的任务,请把行分批。
免费开始使用Brevo