Kommo + Postmark: транзакционные письма из воронки с гарантией доставки

Postmark - транзакционный email-сервис с фокусом на скорость и надёжность доставки: среднее время доставки 2-3 секунды, delivery rate 98%+. В отличие от Mailchimp или MailerLite (bulk email), Postmark специализируется на одиночных транзакционных письмах: подтверждения, уведомления, счета, ссылки на документы. Для Kommo интеграция решает конкретные задачи: автоматически отправить письмо при смене этапа воронки и получать входящие ответы обратно в Kommo.

Postmark API использует заголовок X-Postmark-Server-Token: {SERVER_TOKEN}. Отправка письма: POST /email. Шаблоны: POST /email/withTemplate. Inbound (входящий email): Postmark парсит входящие письма и отправляет webhook на ваш endpoint.

Postmark Server - изолированная среда для одного типа писем. Транзакционные письма и bulk-рассылки должны быть в разных серверах - это фундаментальное правило Postmark для защиты репутации домена.

Архитектура использования с Kommo

Kommo: сделка -> этап "Договор подписан"
  -> Kommo webhook -> Ваш сервер

Ваш сервер
  -> Kommo API: получить email, имя, детали сделки
  -> Postmark: POST /email/withTemplate
     {to, template_alias: "deal-won-confirmation",
      template_model: {name, deal_id, amount}}
  -> Kommo: note "Email-подтверждение отправлен"

Клиент отвечает на письмо
  -> Postmark Inbound webhook -> Ваш сервер
  -> Kommo: note с текстом ответа

Реализация: исходящий email

import requests, os
from flask import Flask, request, jsonify

app = Flask(__name__)

POSTMARK_TOKEN = os.environ["POSTMARK_SERVER_TOKEN"]
POSTMARK_BASE  = "https://api.postmarkapp.com"
POSTMARK_HDR   = {
    "X-Postmark-Server-Token": POSTMARK_TOKEN,
    "Content-Type":            "application/json",
    "Accept":                  "application/json",
}
FROM_EMAIL     = os.environ["POSTMARK_FROM_EMAIL"]  # verified sender

KOMMO_SUBDOMAIN = os.environ["KOMMO_SUBDOMAIN"]
KOMMO_TOKEN     = os.environ["KOMMO_ACCESS_TOKEN"]

STAGE_EMAIL_MAP = {
    int(os.environ.get("STAGE_PROPOSAL", "0")):  ("proposal-sent",          "Коммерческое предложение отправлено"),
    int(os.environ.get("STAGE_SIGNED",   "0")):  ("deal-won-confirmation",  "Подтверждение сделки отправлено"),
    int(os.environ.get("STAGE_INVOICE",  "0")):  ("invoice-notification",   "Счёт отправлен на email"),
}

KOMMO_BASE = f"https://{KOMMO_SUBDOMAIN}.kommo.com/api/v4"
KOMMO_HDR  = {"Authorization": f"Bearer {KOMMO_TOKEN}", "Content-Type": "application/json"}

def get_lead_email_details(lead_id: int) -> tuple[str, str, dict]:
    r = requests.get(
        f"{KOMMO_BASE}/leads/{lead_id}",
        headers=KOMMO_HDR,
        params={"with": "contacts,custom_fields_values"},
    )
    lead     = r.json()
    contacts = lead.get("_embedded", {}).get("contacts", [])
    email = name = ""
    if contacts:
        rc = requests.get(
            f"{KOMMO_BASE}/contacts/{contacts[0]['id']}",
            headers=KOMMO_HDR,
            params={"with": "custom_fields_values"},
        )
        c = rc.json()
        name = c.get("name", "")
        for cf in c.get("custom_fields_values", []) or []:
            if cf.get("field_code") == "EMAIL":
                vals = cf.get("values", [])
                if vals:
                    email = vals[0].get("value", "")
                    break
    model = {
        "client_name": name,
        "deal_id":     str(lead_id),
        "deal_name":   lead.get("name", ""),
        "amount":      str(lead.get("price") or 0),
    }
    return email, name, model

def send_template_email(to: str, template_alias: str, model: dict, tag: str) -> str:
    r = requests.post(
        f"{POSTMARK_BASE}/email/withTemplate",
        headers=POSTMARK_HDR,
        json={
            "From":           FROM_EMAIL,
            "To":             to,
            "TemplateAlias":  template_alias,
            "TemplateModel":  model,
            "MessageStream":  "outbound",
            "Tag":            tag,
            "ReplyTo":        FROM_EMAIL,
            "Metadata": {
                "kommo_lead_id": str(model.get("deal_id", "")),
            },
        },
    )
    r.raise_for_status()
    return r.json().get("MessageID", "")

def add_note(lead_id: int, text: str):
    requests.post(
        f"{KOMMO_BASE}/notes",
        headers=KOMMO_HDR,
        json=[{
            "entity_id":   lead_id,
            "entity_type": "leads",
            "note_type":   "common",
            "params":      {"text": text},
        }],
    )

@app.route("/webhooks/kommo", methods=["POST"])
def kommo_webhook():
    data = request.json or {}
    for lead_data in data.get("leads", {}).get("status", []):
        lead_id    = lead_data.get("id")
        new_status = lead_data.get("status_id")
        stage_info = STAGE_EMAIL_MAP.get(new_status)
        if not stage_info:
            continue

        template_alias, note_text = stage_info
        email, name, model = get_lead_email_details(lead_id)
        if not email:
            continue

        msg_id = send_template_email(email, template_alias, model, tag=template_alias)
        add_note(lead_id, f"Postmark: {note_text}. MessageID: {msg_id}")

    return jsonify({"status": "ok"}), 200

Реализация: Postmark Inbound (входящие ответы)

@app.route("/webhooks/postmark/inbound", methods=["POST"])
def postmark_inbound():
    event    = request.json or {}
    from_email = event.get("FromFull", {}).get("Email", "")
    subject    = event.get("Subject", "")
    text_body  = event.get("TextBody", "") or event.get("StrippedTextReply", "")
    html_body  = event.get("HtmlBody", "")

    # Извлечь kommo_lead_id из headers или subject
    headers = {h.get("Name", ""): h.get("Value", "") for h in event.get("Headers", [])}
    lead_id = headers.get("X-Kommo-Lead-Id", "")

    # Fallback: найти по email отправителя
    if not lead_id:
        lead_id = find_lead_by_email(from_email)
        lead_id = str(lead_id) if lead_id else ""

    if not lead_id:
        return jsonify({"status": "no_lead_id"}), 200

    body_preview = (text_body or html_body)[:500].strip()
    add_note(
        int(lead_id),
        f"Входящий email от {from_email}.
Тема: {subject}

{body_preview}",
    )
    return jsonify({"status": "ok"}), 200

def find_lead_by_email(email: str) -> int | None:
    r = requests.get(
        f"{KOMMO_BASE}/contacts",
        headers=KOMMO_HDR,
        params={"query": email, "limit": 5},
    )
    contacts = r.json().get("_embedded", {}).get("contacts", []) or []
    if not contacts:
        return None
    r2 = requests.get(
        f"{KOMMO_BASE}/leads",
        headers=KOMMO_HDR,
        params={"filter[contact_id]": contacts[0]["id"], "limit": 1},
    )
    leads = r2.json().get("_embedded", {}).get("leads", []) or []
    return leads[0]["id"] if leads else None

Postmark Templates

Создайте шаблоны в Postmark Dashboard -> Templates. Пример шаблона deal-won-confirmation:

Subject: Ваша сделка подтверждена, {{client_name}}!

Уважаемый {{client_name}},

Рады сообщить, что сделка {{deal_name}} (ID: {{deal_id}}) оформлена.
Сумма: ${{amount}}.

С уважением,
Команда [Компания]

Template variables {{variable}} заполняются из TemplateModel в API-запросе.

Postmark Inbound: настройка

Postmark Dashboard -> Servers -> [Server] -> Settings -> Inbound:

  • Inbound domain: inbound.yourdomain.com (или используйте Postmark-provided)
  • Webhook URL: https://your-server.com/webhooks/postmark/inbound

MX-запись: inbound.yourdomain.com -> mx.postmarkapp.com (или настроить email forwarding если нет своего домена).

Для передачи lead_id в ответ клиента: добавьте кастомный заголовок X-Kommo-Lead-Id при отправке:

# В send_template_email - добавить Headers
json={
    ...
    "Headers": [
        {"Name": "X-Kommo-Lead-Id", "Value": str(model.get("deal_id", ""))},
    ],
}

При ответе клиент сохраняет заголовок (большинство email-клиентов) - Postmark Inbound его распарсит.

Postmark vs SendGrid vs AWS SES

PostmarkSendGridAWS SES
Delivery (транз.)98%+96%95%
Скорость2-3 сек5-10 сек3-5 сек
Цена (10k email)$15$19.95$1
Inbound parsingДаДа (дорого)Нет
ШаблоныДаДаНет

Postmark дороже SES, но delivery rate и скорость - лучшие в классе. Для транзакционных писем где важна гарантия доставки - Postmark оправдан.

Для кого актуально

B2B SaaS и сервисные компании, где каждое транзакционное письмо критично: подтверждение сделки, счёт, ссылка на договор. Особенно для компаний, уже столкнувшихся с попаданием в спам через общий email (Gmail SMTP, G Suite SMTP) - Postmark решает проблему репутации домена.

Для bulk email-маркетинга смотрите Kommo + MailerLite.

Часто задаваемые вопросы

Нужно ли верифицировать домен в Postmark?

Обязательно. Без верификации домена (DKIM + SPF) письма будут идти с пометкой “via postmarkapp.com” и иметь низкий deliverability. Верификация: Postmark Dashboard -> Sender Signatures -> Add Domain -> добавить DNS-записи. Занимает 5-10 минут.

Как Postmark обрабатывает bounces?

Postmark автоматически обрабатывает hard и soft bounces. При hard bounce (несуществующий email) Postmark деактивирует адрес и больше не отправляет на него. Bounce webhook - POST /webhooks event типа bounce - можно использовать для обновления контакта в Kommo (добавить тег “email_invalid”).

Как работает Message Streams в Postmark?

Message Streams разделяют транзакционные и broadcast (маркетинговые) письма в рамках одного сервера. MessageStream: "outbound" - транзакционные. "broadcast" - маркетинговые. Разные потоки = разные IP = разная репутация. Это критично: одна неудачная broadcast-кампания не влияет на доставку транзакционных писем.

Итог

Kommo + Postmark - транзакционный email из воронки:

  • X-Postmark-Server-Token заголовок, POST /email/withTemplate с TemplateAlias
  • Metadata kommo_lead_id для обратной корреляции
  • Inbound webhook: входящие ответы клиентов -> note в Kommo
  • X-Kommo-Lead-Id header в исходящих письмах для надёжной корреляции ответов
  • Message Streams: транзакционные и маркетинговые письма - разные потоки

Если нужна интеграция Postmark с Kommo или настройка транзакционных писем из воронки - опишите задачу команде Exceltic.dev.

Ещё статьи

Все →