Для AI-агентов: markdown этой страницы — /docs-content/recipes/mass-messaging.md индекс документации — /llms.txt
Рассылка писем контактам
Сложность: средний | Скоупы: crm, mail | Стек: cURL / JavaScript
Отправляем персонализированное письмо каждому контакту CRM, отобранному по фильтру. Письма уходят из почтового ящика, подключённого к порталу, поэтому ответы клиентов приходят в этот же ящик и попадают в Битрикс24.
Что понадобится
- API-ключ Вайбкод со скоупами
crmиmail - Почтовый ящик, подключённый к порталу Битрикс24
- Node.js 18 или новее
Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.
Как устроено решение
- Находим подключённый ящик и допустимый адрес отправителя.
- Выбираем контакты по фильтру, оставляя только тех, у кого заполнен адрес.
- Подставляем имя контакта в шаблон и отправляем письмо.
Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».
Шаг 1. Ящик и адрес отправителя
Список подключённых ящиков возвращает GET /v1/mail/mailboxes.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/mail/mailboxes"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/mail/mailboxes`, {
headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: mailboxes } = await res.json()
const mailboxId = mailboxes[0].id
{
"success": true,
"data": [
{ "id": 5, "name": "sales@example.com", "email": "sales@example.com", "senderName": "Отдел продаж" }
]
}
Адрес отправителя нельзя указать произвольно — он берётся из списка адресов ящика, который отдаёт GET /v1/mail/mailboxes/:id/senders.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/mail/mailboxes/5/senders"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/mail/mailboxes/${mailboxId}/senders`, {
headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data } = await res.json()
const sender = data.items[0].sender
{
"success": true,
"data": {
"items": [
{ "email": "sales@example.com", "name": "Отдел продаж", "sender": "Отдел продаж <sales@example.com>" }
]
}
}
В письмо подставляется готовая строка из поля sender — вместе с именем и угловыми скобками.
Шаг 2. Контакты с адресом
POST /v1/contacts/search отбирает контакты по фильтру. Условие "!email": null оставляет только тех, у кого адрес заполнен — без него скрипт будет перебирать контакты, которым некуда писать.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/contacts/search" \
-d '{
"filter": { "typeId": "CLIENT", "!email": null },
"select": ["id", "name", "lastName", "email"],
"limit": 50
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/contacts/search`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: { typeId: 'CLIENT', '!email': null },
select: ['id', 'name', 'lastName', 'email'],
limit: 50,
}),
})
const { data: contacts } = await res.json()
{
"success": true,
"data": [
{ "id": 17, "name": "Мария", "lastName": "Соколова", "email": "maria@example.com" }
],
"meta": { "total": 128, "hasMore": true, "durationMs": 412 }
}
Поле email приходит строкой. Если у контакта несколько адресов, в select попадает основной.
Сколько всего контактов подошло под фильтр, говорит meta.total, а признак «выдача не поместилась в одну страницу» — meta.hasMore. Обе величины лежат рядом с data, а не внутри него. Обходить страницы нужно по meta.hasMore: длина data, равная limit, последней страницы не исключает.
Шаг 3. Отправка письма
POST /v1/mail/messages отправляет письмо. Обязательны from, to, subject и body.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/mail/messages" \
-d '{
"from": "Отдел продаж <sales@example.com>",
"to": ["maria@example.com"],
"subject": "Новые условия сотрудничества",
"body": "Здравствуйте, Мария!\n\nМы обновили условия работы с ноября."
}'
JavaScript
function personalize(template, contact) {
const name = [contact.name, contact.lastName].filter(Boolean).join(' ') || 'коллеги'
return template.replaceAll('{{NAME}}', name)
}
await fetch(`${VIBE_URL}/v1/mail/messages`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: sender,
to: [contact.email],
subject: 'Новые условия сотрудничества',
body: personalize(TEMPLATE, contact),
}),
})
Ответ подтверждает отправку и перечисляет получателей.
{ "success": true, "data": { "success": true, "to": ["maria@example.com"] } }
Каждое письмо отправляется отдельным вызовом. Массового режима с одним запросом на всех получателей нет, и это к лучшему: подстановка имени у каждого своя, а один недоставленный адрес не срывает всю рассылку.
Ограничения
Адрес отправителя из списка. Адрес отправителя обязан быть одним из тех, что вернул GET /v1/mail/mailboxes/:id/senders. Произвольная строка в поле from письмо не отправит.
Письмо от лица сотрудника. Письма уходят от имени реального сотрудника или отдела, а не от системы. Клиент увидит адрес из шага 1 и ответит на него — предупредите владельца ящика до запуска.
Журнал против повторов. Скрипт ведёт журнал отправленных адресов в файле и пропускает тех, кто в нём уже есть. Без такого журнала обрыв на середине означает, что повторный запуск пришлёт письмо второй раз всем, кто его уже получил. Журнал привязан к конкретной рассылке — для следующей заведите новый файл, иначе она никому не уйдёт.
Пауза между письмами. Ставьте паузу между отправками. Ограничение здесь не в скорости API, а в том, что почтовые провайдеры получателей считают резкий поток одинаковых писем с одного адреса нежелательной почтой, и адрес попадает в чёрные списки.
Согласие получателей. Отправляйте только тем, кто согласился получать письма. Отбор typeId: "CLIENT" — это тип записи в CRM, а не подтверждённое согласие на рассылку.
Проверка на себе. Перед первым боевым запуском отправьте письмо на собственный адрес и посмотрите, как выглядит подстановка имени.
Очередь портала. Портал выполняет ограниченное число запросов к API одновременно, остальные ждут в очереди. Предел задаётся настройками платформы и на конкретное число полагаться нельзя. Цикл, который шлёт запросы подряд, рано или поздно получит 429. Код QUEUE_OVERFLOW означает, что очередь переполнена и запрос отклонён сразу, QUEUE_TIMEOUT — что запрос прождал места дольше 30 секунд и до Битрикс24 не дошёл, поэтому повторить его безопасно. Рекомендованная пауза приходит в заголовке Retry-After и дублируется в error.retryAfter. Повторяйте с нарастающей задержкой, а не мгновенно — готовая функция повтора есть в Лимитах и оптимизации.
{
"success": false,
"error": {
"code": "QUEUE_OVERFLOW",
"message": "Portal queue overloaded — 128 Bitrix24 calls already pending",
"userMessage": "Слишком много одновременных запросов к Bitrix24 — повторите через несколько секунд.",
"hint": "Honor the Retry-After header. Use exponential backoff with jitter for repeated failures.",
"retryAfter": 3
}
}
Полный перечень кодов — Ошибки.
Полный код
// mail-blast.js — персонализированная рассылка контактам CRM
import { readFileSync, appendFileSync } from 'node:fs'
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')
const MAILBOX_ID = 5
const DELAY_MS = 2000
const PAGE = 50
// Журнал уже отправленных адресов. Письма уходят живым людям, поэтому запуск
// после обрыва обязан продолжить с места остановки, а не разослать всё заново.
const SENT_LOG = process.env.SENT_LOG ?? './sent.log'
function loadSent() {
try {
return new Set(readFileSync(SENT_LOG, 'utf8').split('\n').filter(Boolean))
} catch {
return new Set()
}
}
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
const SUBJECT = 'Новые условия сотрудничества'
const TEMPLATE = `Здравствуйте, {{NAME}}!
Мы обновили условия работы. Новые тарифы действуют с первого числа следующего месяца.
С уважением, отдел продаж`
const sleep = ms => new Promise(r => setTimeout(r, ms))
// Любой отказ 429 (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) означает,
// что запрос до Битрикс24 не дошёл, — повторить его безопасно, письмо не уйдёт дважды. Сколько ждать,
// говорит заголовок Retry-After, а при его отсутствии поле error.retryAfter.
// К паузе добавляется случайная доля
// секунды, чтобы параллельные копии скрипта не пошли на повтор одновременно.
const MAX_RETRIES = 5
async function apiCall(url, init = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, init)
const body = await res.json().catch(() => null)
if (res.status === 429 && attempt < MAX_RETRIES) {
// Рекомендованную паузу берём как есть: умножать её на номер попытки
// нельзя — из Retry-After: 3 на пятой попытке вышло бы 48 секунд.
// Экспонента нужна только когда сервер паузу не назвал.
const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
// Потолок в 60 с: рекомендованную паузу мы уважаем, но неожиданно
// большое значение от промежуточного прокси не должно вешать цикл.
const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
const wait = Math.min(base, 60) + Math.random()
console.warn(`Очередь портала занята, повтор через ${Math.round(wait)} с`)
await new Promise(resolve => setTimeout(resolve, wait * 1000))
continue
}
if (!body?.success) throw new Error(body?.error?.message ?? `запрос отклонён (${res.status})`)
return body
}
}
async function resolveSender() {
const body = await apiCall(`${VIBE_URL}/v1/mail/mailboxes/${MAILBOX_ID}/senders`, { headers })
const sender = body.data.items?.[0]?.sender
if (!sender) throw new Error(`у ящика ${MAILBOX_ID} нет ни одного подтверждённого отправителя`)
return sender
}
async function fetchContacts() {
const all = []
for (let offset = 0; ; offset += PAGE) {
const body = await apiCall(`${VIBE_URL}/v1/contacts/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: { typeId: 'CLIENT', '!email': null },
select: ['id', 'name', 'lastName', 'email'],
limit: PAGE,
offset,
}),
})
all.push(...body.data)
if (!body.meta?.hasMore) return all
}
}
function personalize(template, contact) {
const name = [contact.name, contact.lastName].filter(Boolean).join(' ') || 'коллеги'
return template.replaceAll('{{NAME}}', name)
}
async function sendTo(sender, contact) {
await apiCall(`${VIBE_URL}/v1/mail/messages`, {
method: 'POST',
headers,
body: JSON.stringify({
from: sender,
to: [contact.email],
subject: SUBJECT,
body: personalize(TEMPLATE, contact),
}),
})
}
async function main() {
const [sender, contacts] = await Promise.all([resolveSender(), fetchContacts()])
console.log(`Отправитель: ${sender}`)
console.log(`Получателей: ${contacts.length}`)
const alreadySent = loadSent()
let sent = 0
let skipped = 0
const failed = []
for (const contact of contacts) {
if (alreadySent.has(contact.email)) {
skipped++
continue
}
try {
await sendTo(sender, contact)
appendFileSync(SENT_LOG, contact.email + '\n')
sent++
console.log(` ${contact.email} — отправлено`)
} catch (error) {
failed.push({ email: contact.email, reason: error.message })
console.log(` ${contact.email} — ошибка: ${error.message}`)
}
await sleep(DELAY_MS)
}
console.log(`Отправлено: ${sent}, пропущено ранее отправленных: ${skipped}, с ошибкой: ${failed.length}`)
for (const f of failed) console.log(` ${f.email}: ${f.reason}`)
}
main().catch(console.error)