Для 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. Находим подключённый ящик и допустимый адрес отправителя.
  2. Выбираем контакты по фильтру, оставляя только тех, у кого заполнен адрес.
  3. Подставляем имя контакта в шаблон и отправляем письмо.

Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».

Шаг 1. Ящик и адрес отправителя

Список подключённых ящиков возвращает GET /v1/mail/mailboxes.

cURL

Terminal
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/mail/mailboxes"

JavaScript

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
JSON
{
  "success": true,
  "data": [
    { "id": 5, "name": "sales@example.com", "email": "sales@example.com", "senderName": "Отдел продаж" }
  ]
}

Адрес отправителя нельзя указать произвольно — он берётся из списка адресов ящика, который отдаёт GET /v1/mail/mailboxes/:id/senders.

cURL

Terminal
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/mail/mailboxes/5/senders"

JavaScript

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
JSON
{
  "success": true,
  "data": {
    "items": [
      { "email": "sales@example.com", "name": "Отдел продаж", "sender": "Отдел продаж <sales@example.com>" }
    ]
  }
}

В письмо подставляется готовая строка из поля sender — вместе с именем и угловыми скобками.

Шаг 2. Контакты с адресом

POST /v1/contacts/search отбирает контакты по фильтру. Условие "!email": null оставляет только тех, у кого адрес заполнен — без него скрипт будет перебирать контакты, которым некуда писать.

cURL

Terminal
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

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()
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

Terminal
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

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),
  }),
})

Ответ подтверждает отправку и перечисляет получателей.

JSON
{ "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. Повторяйте с нарастающей задержкой, а не мгновенно — готовая функция повтора есть в Лимитах и оптимизации.

JSON
{
  "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
  }
}

Полный перечень кодов — Ошибки.

Полный код

javascript
// 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)

Смотрите также