# Рассылка писем контактам

**Сложность:** средний | **Скоупы:** 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`](/docs/mail/mailboxes).

### cURL

```bash
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`](/docs/mail/mailboxes/senders).

### cURL

```bash
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`](/docs/entities/contacts/search) отбирает контакты по фильтру. Условие `"!email": null` оставляет только тех, у кого адрес заполнен — без него скрипт будет перебирать контакты, которым некуда писать.

### cURL

```bash
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`](/docs/mail/messages/send) отправляет письмо. Обязательны `from`, `to`, `subject` и `body`.

### cURL

```bash
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`. Повторяйте с нарастающей задержкой, а не мгновенно — готовая функция повтора есть в [Лимитах и оптимизации](/docs/optimization).

```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
  }
}
```

Полный перечень кодов — [Ошибки](/docs/errors).

## Полный код

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

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

- [Отправить письмо](/docs/mail/messages/send)
- [Почтовые ящики](/docs/mail/mailboxes)
- [Поиск контактов](/docs/entities/contacts/search)
- [Синтаксис фильтрации](/docs/filtering)
