Письма с собственного домена для регистрации и восстановления пароля: сервисы, DNS и код

В этой статье разберём, как сайт отправляет пользователям автоматические письма от адреса вида no-reply@example.com: подтверждение email после регистрации, ссылку для сброса пароля и другие уведомления. Объясним термины (доменная почта, SMTP, транзакционные письма, Email Routing), сравним сервисы отправки, настроим DNS-записи SPF, DKIM и DMARC, напишем код на PHP через HTTP API и через SMTP, спроектируем токены для подтверждения и сброса пароля и соберём чек-лист запуска. В конце — отдельный практический блок для WordPress-сайта на примере ermilov-code.ru.

Во всех общих примерах используется нейтральный домен example.com и отправитель no-reply@example.com. Принципы (DNS, токены, безопасность) меняются редко, а тарифы и лимиты сервисов — часто. Поэтому все цифры по тарифам собраны в отдельном разделе с датой проверки: перед запуском сверьте их с официальными страницами.

Термины простыми словами

Доменная почта

Почта, у которой после @ стоит ваш домен, а не gmail.com или yandex.ru. Обычно её дают хостинг, Яндекс 360, Google Workspace, Mail.ru для бизнеса и подобные сервисы. Вы подключаете домен, добавляете DNS-записи — и можете заводить ящики для людей: читать входящие, отвечать, хранить переписку.

Почтовый ящик вида info@site.ru

Конкретный ящик внутри доменной почты: у него есть пароль, папка «Входящие», в него можно зайти через веб-интерфейс или почтовую программу. Ящик нужен для людей — чтобы читать и отвечать. Для того чтобы сайт отправлял письма от имени домена, отдельный ящик не обязателен (подробнее — в разделе про no-reply).

SMTP

SMTP (Simple Mail Transfer Protocol) — протокол, по которому письма передаются между серверами. Для разработчика SMTP — это способ отдать письмо сервису отправки: вы подключаетесь к серверу вроде smtp.example-provider.com на порт 587 или 465, логинитесь и передаёте письмо. Почти любая CMS и любой фреймворк умеют слать почту через SMTP «из коробки», поэтому это самый универсальный способ интеграции.

Транзакционные письма

Письма, которые отправляются в ответ на действие конкретного пользователя: регистрация, подтверждение email, сброс пароля, чек об оплате, уведомление о новом комментарии. Их противоположность — маркетинговые рассылки, которые уходят сразу многим получателям по решению владельца сайта. Для транзакционных писем важны скорость доставки и попадание во «Входящие»: письмо со ссылкой сброса пароля, пришедшее через час или в «Спам», равно неработающему сбросу пароля.

Сервисы транзакционных писем (Resend, Brevo и аналоги)

Компании, которые держат собственную почтовую инфраструктуру: серверы, IP-адреса с хорошей репутацией, обработку отказов (bounce), жалоб, очередей и повторных попыток. Вы подтверждаете у них свой домен, получаете API-ключ и отправляете письма через HTTP API или SMTP. Письмо уходит от вашего адреса no-reply@example.com, но физически его доставляют серверы сервиса. Этот тип сервисов ещё называют ESP (Email Service Provider) или «email-транспорт».

Email Routing

Пересылка входящей почты. Например, Cloudflare Email Routing принимает письма на support@example.com и пересылает их на ваш личный you@gmail.com. Ящика на домене при этом нет: сервис только принимает письмо и перенаправляет его. Отправлять письма от имени домена Email Routing не умеет — это отдельная задача. Cloudflare развивает для исходящей почты отдельный продукт (Email Sending для Workers), но это уже другой сервис с другими условиями.

Понятие Что делает Нужно ли для писем регистрации
Доменная почта Даёт людям ящики на вашем домене Нет, но удобна для приёма ответов
Ящик info@ Чтение и отправка писем человеком Нет
SMTP Протокол передачи писем Один из двух способов отдать письмо сервису
Транзакционное письмо Письмо в ответ на действие пользователя Это и есть наша задача
Сервис отправки (ESP) Доставляет письма от вашего домена Да, рекомендуемый вариант
Email Routing Пересылает входящие письма Нет, только для приёма ответов

Почему не нужен собственный почтовый сервер

Поднять Postfix на VPS технически несложно. Сложно добиться, чтобы Gmail, Яндекс и Mail.ru принимали от него письма во «Входящие». Для этого нужны чистый IP-адрес (у недорогих VPS адреса часто уже побывали в спам-листах), обратная DNS-запись (PTR) у хостера, корректные SPF, DKIM и DMARC, TLS, обработка отказов, мониторинг чёрных списков и постепенный «прогрев» репутации. Многие хостеры вообще блокируют исходящий порт 25.

Сервис отправки берёт это на себя. Сайту остаётся сформировать письмо и сделать один HTTP-запрос. Для небольшого проекта это надёжнее, быстрее в настройке и почти всегда дешевле, чем время на поддержку своего сервера.

Рекомендуемая архитектура

Пользователь ──> Сайт (форма) ──> Backend ──> Сервис отправки ──> Почтовый ящик
                                     │         (Resend, Brevo,       пользователя
                                     │          Postbox и т.п.)
                                     │
                                     ├─ проверяет данные и капчу
                                     ├─ создаёт токен, сохраняет его хеш в БД
                                     ├─ формирует письмо со ссылкой
                                     └─ отправляет его по API или SMTP,
                                        используя секретный ключ из .env

Ключевые правила этой схемы:

  • Письмо отправляет только сервер. API-ключ никогда не попадает в браузер, JavaScript или мобильное приложение: кто получил ключ, тот может рассылать письма от вашего домена.
  • Домен подтверждён в сервисе. Без этого сервис либо не разрешит отправку от no-reply@example.com, либо письма будут без подписи DKIM и уйдут в спам.
  • Отправка не должна ломать регистрацию. Если сервис временно недоступен, пользователь создан, а письмо можно запросить повторно. В нагруженных проектах отправку выносят в очередь (Redis, база данных, очереди фреймворка).

Какие сервисы подходят небольшому проекту

Ниже — общая характеристика сервисов, которая меняется медленно, и отдельно — цифры, которые меняются часто.

Resend

  • Преимущества: простой REST API, понятная документация, официальные SDK для популярных языков, есть SMTP, в панели видно каждое отправленное письмо и его статус, поддержка Idempotency-Key против двойной отправки.
  • Недостатки: на бесплатном тарифе есть дневной лимит; оплата только в долларах зарубежной картой; сервис ориентирован прежде всего на разработчиков — визуального конструктора рассылок меньше, чем у маркетинговых платформ.
  • Сложность подключения: низкая — домен, три-четыре DNS-записи, API-ключ, один POST-запрос.
  • Production: подходит; для стабильного объёма больше бесплатного лимита нужен платный тариф.

Brevo

  • Преимущества: бесплатный тариф без ограничения по сроку, транзакционные письма через API и SMTP доступны на всех тарифах, есть шаблоны писем, логи и вебхуки; можно совмещать с маркетинговыми рассылками.
  • Недостатки: дневной лимит бесплатного тарифа общий для транзакционных и маркетинговых писем; интерфейс перегружен маркетинговыми функциями; для SMTP используется отдельный SMTP-ключ, а не API-ключ, — частая причина ошибок авторизации.
  • Сложность подключения: низкая, но настроек больше, чем у Resend.
  • Production: подходит.

Yandex Cloud Postbox

  • Преимущества: российское облако, оплата в рублях, оплата по факту отправленных писем, бесплатный ежемесячный объём, SMTP и API, совместимый с Amazon SES v2.
  • Недостатки: нужен аккаунт Yandex Cloud с платёжным аккаунтом и сервисным аккаунтом; API совместим с Amazon SES и требует подписи запросов (удобнее через SDK или SMTP); тарифицируются все письма, принятые к отправке, даже недоставленные.
  • Сложность подключения: средняя из-за устройства облака (каталоги, роли, ключи).
  • Production: подходит.

Unisender Go

  • Преимущества: российский email-транспорт, оплата в рублях, Web API и SMTP, документация на русском.
  • Недостатки: бесплатный период ограничен по времени, дальше — фиксированная абонентская плата.
  • Сложность подключения: низкая.
  • Production: подходит.

Среди других известных вариантов — Postmark, Mailgun, Amazon SES, SendGrid, Mailjet. Принцип у всех одинаковый: подтверждение домена, DNS-записи, ключ, API или SMTP. Если вы уже живёте в экосистеме AWS или Cloudflare, логично посмотреть на их собственные сервисы отправки.

Тарифы и лимиты на сентябрь 2026 года

Цифры проверены по официальным страницам тарифов и документации 25 сентября 2026 года. Сервисы регулярно меняют условия — перед выбором откройте актуальные страницы.

Сервис Бесплатно Ближайший платный вариант Особенности лимитов
Resend 3 000 писем в месяц, не более 100 в день Pro — $20 в месяц за 50 000 писем, без дневного лимита API — 10 запросов в секунду на команду; дневная квота считается по UTC
Brevo 300 писем в день, без ограничения по сроку Платные тарифы по объёму писем — см. страницу тарифов Дневной лимит общий для транзакционных и маркетинговых писем
Yandex Cloud Postbox Первые 2 000 писем в месяц 80,32 ₽ за 1 000 писем (объём 2 001–10 000), дальше дешевле Оплачиваются все письма, принятые к отправке
Unisender Go 6 000 писем в месяц первые 2 месяца для новых пользователей 800 ₽ в месяц за 6 000 писем Оплата картой — предоплата; сверх лимита тарифицируется отдельно

Источники: тарифы Resend, лимиты API Resend, тарифы Brevo, тарифы Postbox, тарифы Unisender Go.

Для сайта регистраций на несколько десятков писем в день бесплатных лимитов любого из сервисов хватает с запасом. Следить стоит за дневным лимитом: всплеск регистраций или атака на форму сброса пароля может исчерпать его за минуты, и тогда до конца суток не уйдёт ни одно письмо.

Что учесть проекту в России

  • Оплата. Зарубежные сервисы принимают оплату картами, выпущенными за пределами России. Бесплатный тариф работает без оплаты, но при росте проекта это станет проблемой.
  • Персональные данные. Email пользователя — персональные данные. При отправке через зарубежный сервис адрес передаётся за границу, что по 152-ФЗ требует отдельных действий (уведомление Роскомнадзора о трансграничной передаче, отражение в политике конфиденциальности). Детали уточняйте у юриста; российские сервисы снимают часть вопросов.

DNS-записи: SPF, DKIM и DMARC

Почтовые сервисы получателя проверяют, имеет ли отправитель право писать от имени вашего домена. Для этого используются три записи в DNS домена. Добавляются они там, где управляются DNS: у регистратора, хостера или в Cloudflare.

Все значения ниже — примеры, показывающие формат. Реальные имена и значения записей берите из панели выбранного сервиса: у каждого свои селекторы DKIM, свои домены в SPF и свои требования.

SPF — какие серверы могут отправлять письма

SPF (Sender Policy Framework) — TXT-запись со списком серверов, которым разрешено отправлять почту от домена. Сервер получателя смотрит, с какого IP пришло письмо, и сверяет его с этим списком.

Тип:      TXT
Имя:      send.example.com   (или send — зависит от интерфейса DNS)
Значение: v=spf1 include:amazonses.com ~all

Важные детали:

  • SPF проверяется для адреса возврата (Return-Path, «конверт» письма), а не для адреса в поле «От кого». Поэтому многие сервисы, например Resend, просят добавить SPF не на корневой домен, а на поддомен вроде send.example.com: на нём живёт адрес возврата для отказов.
  • На одном имени может быть только одна SPF-запись. Если на корневом домене уже есть v=spf1 … от хостинга или доменной почты, новый include: нужно дописать в существующую запись, а не создавать вторую. Две SPF-записи дают ошибку permerror, и проверка не проходит вовсе.
  • Лимит — 10 DNS-запросов при проверке (каждый include, a, mx их расходует). Длинные цепочки include от нескольких сервисов легко его превышают.
  • Окончание ~all означает «остальным не доверять, но не отклонять жёстко», -all — «остальным отказать».

Без SPF: любой может отправить письмо, притворившись вашим доменом, а почтовые сервисы понижают доверие к письмам без подтверждённого отправителя.

DKIM — цифровая подпись письма

DKIM (DomainKeys Identified Mail) — подпись каждого письма закрытым ключом. Закрытый ключ хранится у сервиса отправки, открытый вы публикуете в DNS. Получатель проверяет подпись и убеждается, что письмо отправлено от имени домена и не изменено по дороге.

Тип:      TXT
Имя:      resend._domainkey.example.com
Значение: p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...  (длинный ключ из панели)

Часть перед ._domainkey называется селектором. Он позволяет держать несколько ключей: свой у каждого сервиса. Некоторые сервисы (например, Yandex Cloud Postbox или Amazon SES) вместо TXT просят CNAME-записи, указывающие на их ключи: так сервис сам может менять ключи. Если DNS у вас в Cloudflare, для таких CNAME отключайте проксирование (режим «DNS only»).

Без DKIM: письмо нельзя надёжно связать с вашим доменом; при пересылке SPF часто ломается, и подпись DKIM остаётся единственным доказательством подлинности. Gmail требует хотя бы SPF или DKIM от всех отправителей, а для отправителей больше 5 000 писем в день — оба и ещё DMARC (требования Gmail к отправителям, действуют с 1 февраля 2024 года).

DMARC — политика для писем, не прошедших проверку

DMARC связывает SPF и DKIM с адресом в поле «От кого» и говорит получателю, что делать с письмом, которое не прошло проверку. Письмо проходит DMARC, если прошёл SPF или DKIM и домен, для которого они прошли, совпадает с доменом отправителя (это называется выравниванием, alignment). В режиме по умолчанию достаточно совпадения основного домена: подпись для example.com и адрес возврата на send.example.com «выравниваются» с no-reply@example.com.

Тип:      TXT
Имя:      _dmarc.example.com
Значение: v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com
  • p=none — только наблюдать, письма не отклонять. Начинайте с этого режима.
  • p=quarantine — не прошедшие проверку письма класть в спам.
  • p=reject — отклонять такие письма.
  • rua= — адрес для ежедневных агрегированных отчётов: кто и сколько писем отправил от вашего домена.

Порядок внедрения: p=none и отчёты → несколько недель убеждаетесь, что все легальные источники почты (сайт, доменная почта, CRM) проходят проверку → p=quarantine → p=reject.

Без DMARC: злоумышленник может рассылать фишинг от no-reply@example.com, и получатели не знают, что такие письма нужно отклонять. Крупные почтовые сервисы также используют наличие DMARC как сигнал доверия.

Дополнительные записи

  • MX на поддомене отправки (например, send.example.com) — нужен сервису, чтобы принимать уведомления об отказах доставки. Он не влияет на MX корневого домена и на вашу обычную почту.
  • TXT для подтверждения владения (например, brevo-code:…) — некоторые сервисы просят его, чтобы убедиться, что домен ваш.

Пошаговая настройка для example.com

Покажем на примере Resend. У других сервисов названия пунктов меню отличаются, но шаги те же.

Шаг 1. Регистрация в сервисе

Создайте аккаунт. Сразу включите двухфакторную аутентификацию: доступ к аккаунту даёт возможность рассылать письма от вашего домена.

Шаг 2. Добавление домена

В разделе доменов добавьте example.com. Если сервис предлагает выбрать регион отправки, выберите ближайший к серверу сайта или к аудитории. Можно подтверждать и поддомен вроде mail.example.com — так репутация транзакционных писем отделяется от основной почты, но адрес отправителя тогда будет no-reply@mail.example.com.

Шаг 3. Добавление DNS-записей

Сервис покажет таблицу записей. Для Resend она выглядит так (значения — примеры):

Тип Имя Значение Назначение
MX send feedback-smtp.us-east-1.amazonses.com, приоритет 10 Приём отказов доставки
TXT send v=spf1 include:amazonses.com ~all SPF
TXT resend._domainkey p=MIGfMA0… DKIM
TXT _dmarc v=DMARC1; p=none; DMARC (добавляете сами)

Частая ошибка — вписать в поле «Имя» полный домен, когда панель DNS сама добавляет его в конец. Получается send.example.com.example.com. Если панель показывает домен рядом с полем, вводите только send.

Шаг 4. Подтверждение домена

Нажмите кнопку проверки в панели сервиса. DNS обновляется от нескольких минут до нескольких часов (зависит от TTL). Проверить записи можно самостоятельно:

dig +short TXT send.example.com
dig +short TXT resend._domainkey.example.com
dig +short TXT _dmarc.example.com
dig +short MX send.example.com

На Windows вместо dig используйте nslookup -type=TXT _dmarc.example.com.

Шаг 5. Создание API-ключа

Создайте ключ с минимальными правами: только отправка писем и, если сервис позволяет, только для домена example.com. Ключ показывается один раз — сразу сохраните его в менеджер паролей и в переменные окружения сервера. Для локальной разработки и продакшна заведите разные ключи: так утечка локального ключа не затронет прод, а ключ легко отозвать.

Шаг 6. Хранение ключа в .env

MAIL_FROM_ADDRESS=no-reply@example.com
MAIL_FROM_NAME="Example"
MAIL_REPLY_TO=support@example.com
MAIL_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
APP_URL=https://example.com

И сразу — в .gitignore:

.env
.env.*
!.env.example

В репозиторий кладут только .env.example с пустыми значениями, чтобы было видно, какие переменные нужны.

Почему API-ключ нельзя хранить в Git:

  • История Git хранит всё навсегда: удаление ключа следующим коммитом не убирает его из истории, клонов и форков.
  • Публичные репозитории автоматически сканируют боты в поисках ключей; утёкший ключ начинают использовать в течение минут.
  • С вашим ключом рассылают спам и фишинг от вашего домена. Итог — сгоревшая репутация домена, блокировка аккаунта в сервисе и письма пользователям, которые больше не доходят.
  • Даже приватный репозиторий видят все, у кого есть доступ, CI-системы и любые утечки резервных копий.

Если ключ всё же попал в Git — сначала отзовите его в панели сервиса и создайте новый, а уже потом чистите историю.

Шаг 7. Первое тестовое письмо

curl -X POST https://api.resend.com/emails \
  -H "Authorization: Bearer $MAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Example <no-reply@example.com>",
    "to": ["your-real-address@gmail.com"],
    "subject": "Тестовое письмо",
    "text": "Если вы это читаете, отправка работает."
  }'

В ответ придёт JSON с идентификатором письма ({"id": "…"}). Откройте письмо у получателя и посмотрите его заголовки («Показать оригинал» в Gmail, «Свойства письма» в Яндекс Почте). Там должно быть SPF: PASS, DKIM: PASS и DMARC: PASS. Отправьте тест на несколько почтовых сервисов (Gmail, Яндекс, Mail.ru, Outlook) и проверьте папку «Спам» в каждом.

Отправка письма из PHP

Вариант 1. HTTP API без библиотек

Прямой запрос через cURL. Библиотеки не нужны, код легко переносится на другой сервис: меняются URL, заголовок авторизации и названия полей.

<?php
// mailer.php

function send_email(string $to, string $subject, string $html, string $text): bool
{
    $apiKey = getenv('MAIL_API_KEY');
    if (!$apiKey) {
        error_log('MAIL_API_KEY is not set');
        return false;
    }

    $payload = [
        'from'     => sprintf('%s <%s>', getenv('MAIL_FROM_NAME'), getenv('MAIL_FROM_ADDRESS')),
        'to'       => [$to],
        'reply_to' => getenv('MAIL_REPLY_TO') ?: null,
        'subject'  => $subject,
        'html'     => $html,
        'text'     => $text,
    ];

    $ch = curl_init('https://api.resend.com/emails');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => json_encode(array_filter($payload), JSON_UNESCAPED_UNICODE),
    ]);

    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $error  = curl_error($ch);
    curl_close($ch);

    if ($body === false || $status < 200 || $status >= 300) {
        // В лог — статус и ответ сервиса, но не ключ и не текст письма со ссылкой.
        error_log("Email send failed: HTTP $status $error " . substr((string) $body, 0, 500));
        return false;
    }

    return true;
}

Для Brevo тот же запрос отправляется на https://api.brevo.com/v3/smtp/email с заголовком api-key: …, а поля называются sender, to (массив объектов с email), subject, htmlContent, textContent.

Переменные окружения задаются в конфигурации веб-сервера, PHP-FPM, Docker или загружаются из .env библиотекой вроде vlucas/phpdotenv; во фреймворках (Laravel, Symfony) это встроено. Сам файл .env должен лежать вне публичной папки сайта, чтобы его нельзя было скачать по URL.

Вариант 2. SMTP через PHPMailer

SMTP удобен, когда CMS или фреймворк уже умеют отправлять почту и нужно только указать сервер. Установка библиотеки:

composer require phpmailer/phpmailer
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

require __DIR__ . '/vendor/autoload.php';

function send_email_smtp(string $to, string $subject, string $html, string $text): bool
{
    $mail = new PHPMailer(true);

    try {
        $mail->isSMTP();
        $mail->Host       = getenv('SMTP_HOST');     // например, smtp.resend.com
        $mail->Port       = (int) getenv('SMTP_PORT'); // 587 (STARTTLS) или 465 (SMTPS)
        $mail->SMTPSecure = $mail->Port === 465
            ? PHPMailer::ENCRYPTION_SMTPS
            : PHPMailer::ENCRYPTION_STARTTLS;
        $mail->SMTPAuth   = true;
        $mail->Username   = getenv('SMTP_USER');
        $mail->Password   = getenv('SMTP_PASSWORD');
        $mail->Timeout    = 10;
        $mail->CharSet    = 'UTF-8';

        $mail->setFrom(getenv('MAIL_FROM_ADDRESS'), getenv('MAIL_FROM_NAME'));
        $mail->addReplyTo(getenv('MAIL_REPLY_TO'));
        $mail->addAddress($to);

        $mail->isHTML(true);
        $mail->Subject = $subject;
        $mail->Body    = $html;
        $mail->AltBody = $text;

        $mail->send();
        return true;
    } catch (Exception $e) {
        error_log('SMTP send failed: ' . $mail->ErrorInfo);
        return false;
    }
}

Параметры SMTP популярных сервисов на момент проверки:

Сервис Сервер Порты Логин / пароль
Resend smtp.resend.com 465, 2465 (SMTPS); 25, 587, 2587 (STARTTLS) resend / API-ключ
Brevo smtp-relay.brevo.com 587, 2525 (STARTTLS); 465 (SMTPS) SMTP-логин из панели / SMTP-ключ (не API-ключ)
Yandex Cloud Postbox postbox.cloud.yandex.net 587 (STARTTLS); 465 (SMTPS) Идентификатор API-ключа / секретная часть ключа

API или SMTP?

  • HTTP API — быстрее (один HTTPS-запрос вместо SMTP-диалога), сразу возвращает ID письма, даёт доступ к шаблонам, тегам и ключу идемпотентности. Хорош для своего кода.
  • SMTP — стандарт, который поддерживают WordPress, Laravel, Django, Bitrix и любые готовые системы. Переход на другой сервис — это смена четырёх настроек, код не меняется.

Если хостер блокирует исходящие SMTP-порты, помогают альтернативные порты (2525, 2587, 2465) или HTTP API: он идёт по обычному HTTPS на 443 порт.

Сценарий регистрации с подтверждением email

1. Пользователь отправляет форму регистрации.
2. Сервер проверяет данные и капчу, создаёт пользователя
   со статусом «email не подтверждён».
3. Сервер генерирует случайный токен, сохраняет в БД его хеш и срок жизни.
4. Пользователю уходит письмо со ссылкой
   https://example.com/account/verify/TOKEN
5. Пользователь открывает ссылку.
6. Сервер находит токен по хешу, проверяет срок и то, что он не использован,
   помечает email подтверждённым и удаляет токен.

Таблица токенов

Одна таблица подходит и для подтверждения email, и для сброса пароля: тип токена хранится в отдельной колонке.

CREATE TABLE user_tokens (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id     BIGINT UNSIGNED NOT NULL,
    type        VARCHAR(32)  NOT NULL,          -- 'verify_email' или 'reset_password'
    token_hash  CHAR(64)     NOT NULL,          -- SHA-256 от токена, не сам токен
    expires_at  DATETIME     NOT NULL,
    used_at     DATETIME     NULL,
    created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uniq_token_hash (token_hash),
    KEY idx_user_type (user_id, type)
);

Создание токена и отправка письма

<?php
function create_user_token(PDO $db, int $userId, string $type, int $ttlSeconds): string
{
    // Предыдущие неиспользованные токены этого типа больше не нужны.
    $db->prepare('DELETE FROM user_tokens WHERE user_id = ? AND type = ? AND used_at IS NULL')
       ->execute([$userId, $type]);

    $token = bin2hex(random_bytes(32)); // 256 бит случайности, 64 hex-символа

    $db->prepare(
        'INSERT INTO user_tokens (user_id, type, token_hash, expires_at)
         VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))'
    )->execute([$userId, $type, hash('sha256', $token), $ttlSeconds]);

    return $token; // Сам токен уходит только в письмо.
}

function send_verification_email(PDO $db, int $userId, string $email): void
{
    $token = create_user_token($db, $userId, 'verify_email', 24 * 3600);
    $url   = getenv('APP_URL') . '/account/verify/' . $token;

    $html = '<p>Подтвердите email, чтобы завершить регистрацию:</p>'
          . '<p><a href="' . htmlspecialchars($url) . '">Подтвердить email</a></p>'
          . '<p>Ссылка действует 24 часа. Если вы не регистрировались, просто удалите письмо.</p>';
    $text = "Подтвердите email: $url\nСсылка действует 24 часа.";

    send_email($email, 'Подтверждение email на Example', $html, $text);
}

Проверка ссылки

<?php
// Маршрут: GET /account/verify/{token}
function verify_email(PDO $db, string $token): bool
{
    if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
        return false;
    }

    $db->beginTransaction();

    $stmt = $db->prepare(
        'SELECT id, user_id FROM user_tokens
         WHERE token_hash = ? AND type = ? AND used_at IS NULL AND expires_at > NOW()
         FOR UPDATE'
    );
    $stmt->execute([hash('sha256', $token), 'verify_email']);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$row) {
        $db->rollBack();
        return false; // Показать «Ссылка недействительна или устарела» и кнопку повторной отправки.
    }

    $db->prepare('UPDATE users SET email_verified_at = NOW() WHERE id = ?')->execute([$row['user_id']]);
    $db->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = ?')->execute([$row['id']]);
    $db->commit();

    return true;
}

Поиск идёт по хешу, поэтому сравнивать строки через hash_equals() не нужно: атакующий не может подобрать хеш посимвольно по времени ответа, не зная исходного токена.

Нюанс: антивирусы и корпоративные почтовые фильтры иногда сами открывают ссылки из писем. Для подтверждения email это обычно безопасно — адрес подтверждён тем, что письмо дошло. А вот для сброса пароля переход по ссылке не должен ничего менять: токен используется только при отправке формы с новым паролем.

Сценарий восстановления пароля

1. Пользователь вводит email на странице «Забыли пароль?».
2. Сервер всегда отвечает одинаково: «Если аккаунт с таким email
   существует, мы отправили письмо».
3. Если пользователь найден — создаётся одноразовый токен на 30–60 минут
   и отправляется ссылка https://example.com/reset-password/TOKEN
4. GET по ссылке показывает форму нового пароля (токен только проверяется).
5. POST формы: токен проверяется ещё раз, пароль хешируется
   и сохраняется, токен помечается использованным,
   все сессии пользователя завершаются.
<?php
// POST /forgot-password
function request_password_reset(PDO $db, string $email): void
{
    $stmt = $db->prepare('SELECT id FROM users WHERE email = ?');
    $stmt->execute([mb_strtolower(trim($email))]);
    $userId = $stmt->fetchColumn();

    if ($userId) {
        $token = create_user_token($db, (int) $userId, 'reset_password', 3600);
        $url   = getenv('APP_URL') . '/reset-password/' . $token;
        send_email(
            $email,
            'Восстановление пароля на Example',
            '<p><a href="' . htmlspecialchars($url) . '">Задать новый пароль</a></p>'
            . '<p>Ссылка действует 1 час. Если вы не запрашивали сброс, ничего не делайте.</p>',
            "Задать новый пароль: $url\nСсылка действует 1 час."
        );
    }

    // Ответ одинаковый в обоих случаях.
}

// POST /reset-password/{token}
function reset_password(PDO $db, string $token, string $newPassword): bool
{
    if (!preg_match('/^[a-f0-9]{64}$/', $token) || mb_strlen($newPassword) < 10) {
        return false;
    }

    $db->beginTransaction();
    $stmt = $db->prepare(
        'SELECT id, user_id FROM user_tokens
         WHERE token_hash = ? AND type = ? AND used_at IS NULL AND expires_at > NOW()
         FOR UPDATE'
    );
    $stmt->execute([hash('sha256', $token), 'reset_password']);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$row) {
        $db->rollBack();
        return false;
    }

    $db->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
       ->execute([password_hash($newPassword, PASSWORD_DEFAULT), $row['user_id']]);
    $db->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = ?')->execute([$row['id']]);
    $db->prepare('DELETE FROM sessions WHERE user_id = ?')->execute([$row['user_id']]);
    $db->commit();

    return true;
}

Одинаковый ответ — это ещё и одинаковое время ответа. Если для существующего email сервер синхронно отправляет письмо (сотни миллисекунд), а для несуществующего отвечает мгновенно, разницу видно по таймингу. Решение — отправлять письмо через очередь или фоновую задачу, чтобы ответ формы не зависел от наличия пользователя.

Безопасность

Срок жизни токенов

  • Сброс пароля — 30–60 минут: ссылка даёт полный доступ к аккаунту.
  • Подтверждение email — от нескольких часов до нескольких суток.
  • Истёкшие токены периодически удаляйте из таблицы (cron).

Одноразовые токены

После успешного использования токен помечается использованным или удаляется. Новый запрос сброса аннулирует предыдущие токены. Смена пароля и email также аннулирует все активные токены сброса.

Хранение токенов в базе

В базе хранится только хеш токена (SHA-256). Если база утечёт через бэкап или SQL-инъекцию, по хешам нельзя сбросить пароли. Для токена из 256 случайных бит медленные хеши вроде bcrypt не нужны — перебрать такое пространство невозможно. Токен генерируется только криптографически стойким генератором (random_bytes()), а не rand(), uniqid() или хешем от email и времени.

Защита от перебора и rate limit

Угрозы здесь две: перебор токенов и паролей, а также использование формы как «пушки» для рассылки писем на чужие адреса (что заодно выжигает лимиты и репутацию домена). Нужны ограничения:

  • по IP: например, не больше 5 запросов сброса или регистраций за 15 минут;
  • по email: не больше 3 писем сброса на адрес за 15 минут и, например, 10 в сутки;
  • по попыткам входа и проверкам токенов;
  • капча на формах регистрации и восстановления.

Простой счётчик в фиксированном окне на Redis:

<?php
function too_many_attempts(Redis $redis, string $key, int $limit, int $windowSeconds): bool
{
    $bucket = 'rl:' . hash('sha256', $key) . ':' . intdiv(time(), $windowSeconds);
    $count  = $redis->incr($bucket);
    if ($count === 1) {
        $redis->expire($bucket, $windowSeconds);
    }
    return $count > $limit;
}

// В обработчике «Забыли пароль?»:
if (too_many_attempts($redis, 'reset-ip:' . $_SERVER['REMOTE_ADDR'], 5, 900)
    || too_many_attempts($redis, 'reset-email:' . mb_strtolower($email), 3, 900)) {
    http_response_code(429);
    exit('Слишком много запросов. Попробуйте позже.');
}

Ключи хешируются, чтобы не хранить email и IP в открытом виде. Лимиты на уровне приложения дополняют, но не заменяют ограничения на веб-сервере (limit_req в nginx) или в WAF.

Не раскрывайте, зарегистрирован ли email

Сообщение «Пользователь с таким email не найден» позволяет любому проверить, есть ли человек на вашем сайте. Для медицинского, финансового или просто личного сервиса это утечка сама по себе, а для атакующего — готовый список адресов для подбора паролей и фишинга. Поэтому:

  • восстановление пароля: всегда «Если аккаунт существует, письмо отправлено»;
  • регистрация на уже занятый email: тот же ответ «Проверьте почту», а владельцу адреса — письмо «Кто-то пытался зарегистрироваться с вашим адресом; если это вы — восстановите пароль»;
  • вход: одна ошибка «Неверный email или пароль» для обоих случаев.

Защита API-ключей

  • Ключ только в переменных окружения или менеджере секретов, не в коде и не в Git.
  • Минимальные права: только отправка, только нужный домен.
  • Отдельные ключи для локальной среды, тестового стенда и продакшна.
  • Ключи не пишутся в логи, отчёты об ошибках и ответы API.
  • При подозрении на утечку — отозвать и перевыпустить, затем проверить логи отправки в панели сервиса.
  • Включите уведомления сервиса о необычной активности и двухфакторную аутентификацию в аккаунте.

HTTPS

Все ссылки в письмах — только https://. По HTTP токен в URL виден любому посреднику в сети. Кроме того:

  • на страницах с токеном задайте Referrer-Policy: no-referrer, чтобы токен не утёк сторонним скриптам и сайтам через заголовок Referer;
  • не подключайте на этих страницах аналитику и сторонние скрипты;
  • не логируйте полный URL с токеном в журналах доступа приложения;
  • базовый URL для ссылок берите из конфигурации (APP_URL), а не из заголовка Host запроса: подменённый Host позволяет атакующему получить письмо со ссылкой на свой домен (атака Host header poisoning).

Почему не стоит использовать PHP mail() или свой SMTP-сервер

Функция mail() передаёт письмо локальной программе sendmail на сервере, а та отправляет его напрямую с IP сервера. Проблемы:

  • нет подписи DKIM, если её отдельно не настроить на сервере;
  • IP веб-сервера не указан в SPF домена и обычно не имеет PTR-записи;
  • репутация IP общего хостинга или VPS неизвестна: соседи могли рассылать спам;
  • mail() возвращает true, когда письмо принято локальной очередью, а не доставлено — об ошибках вы не узнаете;
  • нет логов доставки, статистики отказов и жалоб;
  • многие хостеры блокируют исходящий порт 25 или ограничивают объём.

Собственный SMTP-сервер решает часть этих вопросов, но добавляет постоянную работу: обновления безопасности, защиту от превращения в открытый релей, мониторинг чёрных списков, прогрев IP, обработку отказов. Имеет смысл, если есть веская причина: требования к хранению данных внутри своей инфраструктуры, огромные объёмы, где сервис становится дорогим, или отдельная команда эксплуатации.

Почему письма попадают в спам

  • Нет или сломан SPF. Две SPF-записи на одном имени, превышен лимит 10 DNS-запросов, сервис не включён в запись.
  • Нет DKIM. Домен не подтверждён в сервисе, или письма подписываются доменом сервиса, а не вашим — тогда проваливается выравнивание DMARC.
  • Нет DMARC. Для небольшого отправителя это не всегда причина спама, но это отрицательный сигнал, а для массовых отправителей Gmail это обязательное требование.
  • Репутация домена. Новый домен без истории отправок вызывает осторожность. Жалобы «Это спам» и письма на несуществующие адреса быстро портят репутацию. Помогают: подтверждение email при регистрации (без него база быстро наполняется опечатками и чужими адресами), капча и Postmaster-инструменты почтовых сервисов (Google Postmaster Tools, Яндекс Постмастер).
  • Содержание писем. Письмо, состоящее из одной картинки, отсутствие текстовой версии, «кричащие» заголовки, слова вроде «бесплатно» и «срочно», сокращатели ссылок. Транзакционное письмо должно быть коротким и простым: что произошло, ссылка, срок действия, что делать, если это были не вы.
  • Частота отправки. Резкий всплеск с нового домена (например, вы импортировали старую базу и разослали всем письма) выглядит как спам-атака. Объём растите постепенно, не смешивайте маркетинговые рассылки с транзакционными на одном адресе и поддомене.
  • Адрес отправителя. Отправка от @gmail.com или @yandex.ru через сторонний сервис гарантированно проваливает DMARC этих доменов. Отправляйте только от своего подтверждённого домена. Имя отправителя должно быть понятным: «Example», а не «noreply_system_12».
  • Ссылки в письмах. Ссылки должны вести на ваш же домен и по HTTPS. Текст ссылки не должен показывать один адрес, а вести на другой. Сокращатели и чужие трекинговые домены в транзакционных письмах лучше отключить.

Нужен ли настоящий почтовый ящик no-reply@example.com?

Для отправки — нет. Сервис транзакционных писем проверяет не существование ящика, а право на домен: вы подтвердили его DNS-записями, значит, можете отправлять от любого адреса на этом домене — no-reply@, notifications@, billing@. Ящик no-reply@example.com при этом может нигде не существовать.

Ящик или хотя бы пересылка нужны, если:

  • пользователи будут отвечать на письма, а вы не задаёте Reply-To — иначе ответы вернутся ошибкой «адресат не существует» или потеряются;
  • вы хотите получать отказы и автоответы, которые почтовые серверы иногда шлют на адрес «От кого»;
  • какой-то сторонний сервис требует подтвердить адрес отправителя письмом (некоторые сервисы помимо домена подтверждают конкретный адрес).

Более дружелюбная практика — не отталкивать пользователя адресом no-reply, а указывать Reply-To: support@example.com. Письмо приходит от автоматического адреса, но ответ попадает к людям.

Как принимать ответы пользователей

Разделите адреса по назначению:

no-reply@example.com  → только исходящие автоматические письма (сервис отправки)
support@example.com   → обычный ящик или пересылка, его читает человек

Варианты для support@example.com:

  • Доменная почта (хостинг, Яндекс 360, Google Workspace и т.п.) — полноценный ящик, из которого можно и отвечать от имени support@.
  • Email Routing / пересылка — входящие на support@ пересылаются в ваш личный ящик. Бесплатно, но отвечать вы будете с личного адреса, если не настроить отправку от support@ отдельно. У Cloudflare Email Routing есть условие: DNS домена должен обслуживаться Cloudflare.
  • Входящая почта сервиса отправки — некоторые сервисы (включая Resend) умеют принимать письма и передавать их в ваш backend вебхуком. Удобно для тикет-систем.

Входящие письма определяются MX-записями корневого домена (или поддомена, на который пишут). Записи сервиса отправки на send.example.com их не затрагивают, поэтому существующая доменная почта продолжает работать. Не ставьте на один домен MX-записи двух разных почтовых систем: письма будут случайно уходить то в одну, то в другую.

Сравнение вариантов

Критерий Собственный SMTP Resend Brevo Доменная почта
Сложность Высокая Низкая Низкая–средняя Низкая
Стоимость VPS + время администратора Бесплатно на старте, дальше по объёму Бесплатно на старте, дальше по объёму Часто входит в хостинг или стоит от небольшой суммы за ящик
Доставляемость Зависит от IP и вашей настройки Высокая при настроенном домене Высокая при настроенном домене Хорошая, но лимиты рассчитаны на людей
Обслуживание Постоянное Почти не нужно Почти не нужно Почти не нужно
API Нет, только SMTP REST API и SMTP REST API и SMTP Обычно только SMTP с паролем ящика
Для небольшого проекта Только при веской причине Подходит Подходит Для очень малых объёмов; риск блокировки ящика при всплесках

Отправка через SMTP обычного ящика доменной почты работает, но почтовые сервисы для людей ограничивают число писем в сутки и могут временно заблокировать ящик, если сочтут отправку подозрительной. Кроме того, пароль от рабочего ящика приходится хранить на сервере сайта.

Практический пример: WordPress-сайт ermilov-code.ru

Посмотрим, как общая схема ложится на реальный сайт. Это WordPress с личным кабинетом: регистрация создаёт пользователя, а WordPress отправляет письмо со ссылкой для установки пароля; восстановление пароля использует штатный механизм. Сроки жизни ключей, их хеширование и одноразовость WordPress обеспечивает сам (ключ сброса по умолчанию действует сутки, срок меняется фильтром password_reset_expiration). Задача — сделать так, чтобы wp_mail() отправлял письма через сервис с подтверждённым доменом, а не через mail() сервера.

Что уже есть в DNS

Проверка текущих записей показывает типичную картину для сайта на хостинге:

$ dig +short MX ermilov-code.ru
10 mail.ermilov-code.ru.
20 mail.ermilov-code.ru.

$ dig +short TXT ermilov-code.ru
"v=spf1 ip4:… a mx ~all"

$ dig +short TXT _dmarc.ermilov-code.ru
(пусто)

Отсюда выводы:

  • У домена уже есть почта хостинга (MX) — её не трогаем, она подойдёт для приёма ответов на support@ermilov-code.ru.
  • На корне уже есть SPF. Если выбранный сервис просит SPF для корня, дописываем его include: в эту же запись, а не создаём вторую. Если сервис использует свой поддомен (как Resend с send), корневая запись не меняется.
  • DMARC отсутствует — добавляем с p=none.
  • DNS управляется у хостинга, записи добавляются в его панели.

Выбор сервиса

Аудитория сайта русскоязычная, объём — единицы писем в день. Подойдёт любой сервис из обзора; с учётом оплаты в рублях и хранения данных практичны Yandex Cloud Postbox или Unisender Go, для старта без оплаты — бесплатные тарифы Resend или Brevo. Код ниже не зависит от выбора: WordPress отправляет почту через SMTP, меняются только четыре настройки.

DNS-записи

# Из панели сервиса (пример для сервиса с поддоменом send):
MX   send.ermilov-code.ru                10 feedback-smtp.…
TXT  send.ermilov-code.ru                "v=spf1 include:… ~all"
TXT  <селектор>._domainkey.ermilov-code.ru  "p=…"   (или CNAME — как укажет сервис)

# Добавляем сами:
TXT  _dmarc.ermilov-code.ru  "v=DMARC1; p=none; rua=mailto:dmarc@ermilov-code.ru"

Секреты в wp-config.php из окружения

Ключи не пишутся в код темы и не попадают в Git. В wp-config.php на сервере они читаются из переменных окружения (заданных в конфигурации PHP-FPM или веб-сервера):

define('MAIL_SMTP_HOST', getenv('MAIL_SMTP_HOST') ?: '');
define('MAIL_SMTP_PORT', (int) (getenv('MAIL_SMTP_PORT') ?: 587));
define('MAIL_SMTP_USER', getenv('MAIL_SMTP_USER') ?: '');
define('MAIL_SMTP_PASS', getenv('MAIL_SMTP_PASS') ?: '');
define('MAIL_FROM_ADDRESS', 'no-reply@ermilov-code.ru');
define('MAIL_FROM_NAME', 'ermilov-code.ru');
define('MAIL_REPLY_TO', 'support@ermilov-code.ru');

Подключение wp_mail() к SMTP

WordPress отправляет почту через встроенный PHPMailer, и перед каждой отправкой вызывает хук phpmailer_init. Достаточно небольшого модуля (в теме или mu-плагине):

<?php
add_action('phpmailer_init', function ($mailer) {
    if (!defined('MAIL_SMTP_HOST') || MAIL_SMTP_HOST === '') {
        return; // На локальной машине без ключей — штатное поведение.
    }

    $mailer->isSMTP();
    $mailer->Host       = MAIL_SMTP_HOST;
    $mailer->Port       = MAIL_SMTP_PORT;
    $mailer->SMTPSecure = MAIL_SMTP_PORT === 465 ? 'ssl' : 'tls';
    $mailer->SMTPAuth   = true;
    $mailer->Username   = MAIL_SMTP_USER;
    $mailer->Password   = MAIL_SMTP_PASS;
    $mailer->Timeout    = 10;

    if (!$mailer->getReplyToAddresses()) {
        $mailer->addReplyTo(MAIL_REPLY_TO);
    }
});

add_filter('wp_mail_from', fn () => MAIL_FROM_ADDRESS);
add_filter('wp_mail_from_name', fn () => MAIL_FROM_NAME);

add_action('wp_mail_failed', function (WP_Error $error) {
    error_log('wp_mail failed: ' . $error->get_error_message());
});

Тот же результат дают готовые SMTP-плагины. Преимущество своего модуля — пароль не хранится в базе данных в настройках плагина и не виден в админке.

Проверка на продакшне

  1. Зарегистрироваться с реальным тестовым адресом и получить письмо установки пароля.
  2. Открыть заголовки письма: SPF, DKIM и DMARC — pass, отправитель — no-reply@ermilov-code.ru.
  3. Запросить восстановление пароля, пройти по ссылке, задать пароль, войти.
  4. Ответить на письмо и убедиться, что ответ пришёл на support@ermilov-code.ru.
  5. Проверить папку «Спам» в Gmail, Яндекс Почте и Mail.ru.

Какой вариант выбрать

  • Маленький сайт (десятки писем в день): любой сервис транзакционных писем на бесплатном тарифе, подключённый через SMTP или API. Главное — подтверждённый домен и настроенные SPF, DKIM, DMARC. Выбирайте по удобству оплаты в будущем, требованиям к хранению персональных данных и тому, насколько понятна вам документация.
  • Растущий проект: платный тариф без дневного лимита, отправка через очередь с повторными попытками, вебхуки о доставке и отказах, отдельный поддомен или отдельный сервис для маркетинговых рассылок, мониторинг через Postmaster-инструменты. Держите отправку за своим небольшим интерфейсом (send_email()), чтобы смена сервиса занимала час.
  • Собственный SMTP имеет смысл при требованиях держать всю почту в своей инфраструктуре, при очень больших объёмах, где стоимость сервиса заметна, или при наличии людей, готовых обслуживать почтовую систему. Даже тогда многие отправляют исходящую почту через релей сервиса, оставляя себе только приём.

Чек-лист запуска

  • ☐ Домен добавлен и подтверждён в сервисе отправки.
  • ☐ SPF настроен, на каждом имени только одна SPF-запись.
  • ☐ DKIM настроен, в заголовках писем dkim=pass для вашего домена.
  • ☐ DMARC настроен (хотя бы p=none с адресом для отчётов).
  • ☐ API-ключ хранится в переменных окружения, не в Git; права ключа минимальны.
  • ☐ Тестовое письмо доставляется в Gmail, Яндекс Почту и Mail.ru.
  • ☐ Проверена папка «Спам» у каждого получателя.
  • ☐ Регистрация работает: письмо приходит, ссылка подтверждает email.
  • ☐ Восстановление пароля работает, ответ формы одинаков для любого email.
  • ☐ Токены имеют срок жизни, одноразовые, в базе хранятся хеши.
  • ☐ Настроен rate limit по IP и по email, на формах есть капча.
  • ☐ Ссылки в письмах — только HTTPS, базовый URL берётся из конфигурации.
  • ☐ Ответы на письма попадают в support@ через Reply-To.
  • ☐ Ошибки отправки пишутся в лог, известен дневной лимит тарифа.

Итог

Для большинства небольших сайтов не нужен собственный почтовый сервер — достаточно подтвердить домен в сервисе транзакционных писем и отправлять письма через API или SMTP. Остальная работа — на стороне вашего кода: случайные одноразовые токены с коротким сроком жизни, хеши в базе, ограничение частоты запросов, одинаковые ответы для любых email и ключи вне репозитория.