Приложение должно отправлять почту так же, как это делает почтовый клиент человека: через аутентифицированный SMTP submission на настоящий почтовый сервер, на порт 587 со STARTTLS или 465 с TLS с самого начала, используя отдельную учётную запись для отправки, учётные данные которой лежат в переменных окружения. Отправлять стоит из фоновой задачи, а не внутри веб-запроса, в строке From: указывать домен, которым вы управляете, с подписью DKIM для этого домена, и что-то делать с bounce, а не игнорировать их. Ничего сложного тут нет. Но пропустите любой из этих пунктов - и письма для сброса пароля окажутся в спаме, медленный почтовый сервер заставит страницу регистрации уходить в тайм-аут, а пароль SMTP, закоммиченный в репозиторий, станет чьей-то спам-операцией.
В этой статье - выбор протокола, код для Node, Python, Django и PHP, очереди, заголовки, которые стоит выставлять, и что вам сообщают bounce.
Транзакционная почта - не то же самое, что маркетинговая#
Транзакционное письмо вызвано действием самого получателя: подтверждение регистрации, сброс пароля, чек, уведомление, на которое он подписался. Его ждут, оно обычно срочное и уходит одному человеку за раз. Маркетинговую почту рассылают по списку, потому что так решил отправитель.
Это различие важно по трём причинам:
- Репутация общая. Если на рассылку жалуются, принимающие серверы начинают относиться к отправляющему домену и IP с подозрением, и письма для сброса пароля, отправленные оттуда же, страдают вместе с ней. Крупные отправители разделяют два потока - разные адреса отправки, часто отдельный поддомен вроде
mail.example.comдля маркетинга, - чтобы один не тянул на дно другой. - Правила разные. Gmail и Yahoo требуют от массовых отправителей маркетинговой почты отписку в один клик. Письму для сброса пароля ссылка для отписки не нужна, и её там быть не должно.
- Инструменты разные. Транзакционная почта - это несколько строк кода и почтовый сервер. Массовой рассылке нужны управление списками, обработка отписок, списки подавления и ограничение скорости, поэтому её и покупают как услугу.
Всё дальнейшее - о транзакционной почте.
Submission, а не порт 25#
Почта движется по SMTP двумя путями, и приложению следует пользоваться только одним из них.
| Порт | Название | Кто использует | Аутентификация |
|---|---|---|---|
25 | SMTP-релей | Почтовый сервер к почтовому серверу | Нет, проверяется через SPF, DKIM, репутацию |
587 | Submission | Клиенты и приложения к своему серверу | Обязательна, после STARTTLS |
465 | Submission поверх TLS | Клиенты и приложения к своему серверу | Обязательна, внутри TLS |
Порт 25 - это путь, по которому почтовый сервер доставляет письмо почтовому серверу получателя. Если бы ваше приложение пыталось доставлять почту так напрямую, ему пришлось бы искать MX каждого получателя, ставить письма в очередь и повторять попытки, когда серверы заняты, справляться с greylisting и отправлять с IP, у которого есть обратная DNS-запись и репутация, - иначе говоря, ему пришлось бы стать почтовым сервером. К тому же большинство хостинговых сетей всё равно блокируют исходящий 25 с серверов приложений, потому что именно так рассылают спам взломанные серверы. Подробно о блокировке - в статье порт 25 и блокировка исходящей почты.
Submission - другой путь. Приложение аутентифицируется на своём почтовом сервере - вашем или у провайдера - и передаёт ему письмо. Очереди, повторные попытки, подпись DKIM и доставку берёт на себя почтовый сервер. 465 и 587 одинаково хороши; правило одно - настройка TLS в коде должна соответствовать режиму порта, иначе соединение зависнет до тайм-аута.
Отдельная учётная запись для отправки#
Создайте почтовый ящик или учётные данные SMTP, которыми пользуется только приложение, например app@example.com или noreply@example.com. Не ящик конкретного человека и не учётную запись администратора.
- Учётные данные - в переменные окружения, никогда не в репозиторий. Утёкший пароль SMTP начинают использовать для рассылки спама в течение нескольких часов после пуша в публичный репозиторий, и первое, что вы об этом узнаете, - ваш домен в блок-листе. Где им место, описано в статье переменные окружения и секреты.
- Адрес `From:` должен быть таким, которым учётной записи разрешено пользоваться. Большинство почтовых серверов отказываются (или должны отказываться) разрешать аутентифицированной учётной записи отправлять от адреса, который ей не принадлежит. Если приложение отправляет от
orders@example.com, добавьте этот адрес учётной записи для отправки как алиас. - Для ответов используйте `Reply-To:`. Адрес
noreply@вполне годится дляFrom:, но если пользователи могут ответить на чек, укажите вReply-To:адрес поддержки, который кто-то читает, а не позволяйте их ответам исчезать. - Меняйте пароль, когда уходит кто-то, у кого был доступ, и немедленно, если подозреваете утечку. Отдельная учётная запись означает, что смена пароля ломает одну вещь, которую вы контролируете.
Отправка из кода#
Все четыре примера читают одни и те же переменные, так что конфигурация везде одинаковая:
SMTP_HOST=mail.example.comSMTP_PORT=465SMTP_SECURE=trueSMTP_USER=app@example.comSMTP_PASS=a-long-random-passwordMAIL_FROM="Example <app@example.com>"SMTP_SECURE - отдельная переменная, а не вывод из номера порта, потому что порт, который слушает ваш сервер, может быть нестандартным. true означает неявный TLS (режим 465), false - подключение открытым текстом с повышением через STARTTLS (режим 587).
Node.js с Nodemailer:
import nodemailer from "nodemailer";const transport = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT), secure: process.env.SMTP_SECURE === "true", // false = STARTTLS requireTLS: true, // refuse to send without TLS auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },});await transport.sendMail({ from: process.env.MAIL_FROM, to: user.email, subject: "Reset your password", text: `Use this link within an hour: ${link}`, html: `<p>Use <a href="${link}">this link</a> within an hour.</p>`,});Создайте транспорт один раз при запуске и используйте его повторно. transport.verify() при старте - дешёвый способ обнаружить неправильный пароль раньше, чем это сделает первый пользователь.
Python со стандартной библиотекой:
import os, smtplib, sslfrom email.message import EmailMessagefrom email.utils import make_msgid, formatdatemsg = EmailMessage()msg["From"] = os.environ["MAIL_FROM"]msg["To"] = to_addressmsg["Subject"] = "Reset your password"msg["Date"] = formatdate(localtime=True)msg["Message-ID"] = make_msgid(domain="example.com")msg.set_content(f"Use this link within an hour: {link}")ctx = ssl.create_default_context()host, port = os.environ["SMTP_HOST"], int(os.environ["SMTP_PORT"])if os.environ.get("SMTP_SECURE") == "true": server = smtplib.SMTP_SSL(host, port, context=ctx, timeout=15)else: server = smtplib.SMTP(host, port, timeout=15) server.starttls(context=ctx)with server: server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"]) server.send_message(msg)Django читает свои настройки, поэтому переменные переносятся напрямую:
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"EMAIL_HOST = os.environ["SMTP_HOST"]EMAIL_PORT = int(os.environ["SMTP_PORT"])EMAIL_USE_SSL = os.environ.get("SMTP_SECURE") == "true" # implicit TLSEMAIL_USE_TLS = not EMAIL_USE_SSL # STARTTLSEMAIL_HOST_USER = os.environ["SMTP_USER"]EMAIL_HOST_PASSWORD = os.environ["SMTP_PASS"]EMAIL_TIMEOUT = 15DEFAULT_FROM_EMAIL = os.environ["MAIL_FROM"]SERVER_EMAIL = DEFAULT_FROM_EMAILEMAIL_USE_SSL и EMAIL_USE_TLS взаимоисключающие; если задать оба, будет ошибка. SERVER_EMAIL - отправитель отчётов об ошибках для ADMINS, и если оставить его по умолчанию root@localhost, это частая причина того, что такие отчёты никогда не доходят.
PHP с Symfony Mailer (который Laravel тоже использует под капотом) принимает DSN: smtps:// для неявного TLS, smtp:// для соединения, повышаемого через STARTTLS:
MAILER_DSN=smtps://app%40example.com:a-long-random-password@mail.example.com:465@ в имени пользователя кодируется в URL как %40, и так же нужно кодировать любые специальные символы в пароле. В Laravel те же настройки живут в MAIL_MAILER=smtp, MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD и MAIL_FROM_ADDRESS; имя переменной, выбирающей режим TLS, менялось между версиями Laravel, поэтому сверьтесь с config/mail.php в своём проекте.
Отправляйте из очереди, а не из запроса#
SMTP-диалог занимает от сотни миллисекунд до многих секунд, а почтовый сервер, который перезапускается или тормозит, растягивает его до вашего тайм-аута. Если веб-запрос его ждёт, страница регистрации зависает, пользователь нажимает ещё раз, и появляются две учётные записи или два заказа.
Ставьте письмо в очередь внутри запроса и пусть его отправит исполнитель. Запрос сразу возвращается, исполнитель при сбое повторяет попытку с задержкой, а недоступность почтового сервера превращается в задержку почты, а не в недоступность вашего приложения. На Node это BullMQ; на Python - Celery или RQ; в Laravel - его система очередей; в Django - Celery или исполнитель задач на базе данных. Варианты, включая очередь в PostgreSQL без дополнительной службы, описаны в статье фоновые задачи на небольшом сервере, а очереди на протоколе Redis - в статье очереди задач на Valkey.
Два правила для исполнителя:
- Повторяйте временные сбои, а не постоянные. Ответ SMTP, начинающийся с
4(421,451), означает «попробуйте позже». Ответ на5(550,553) означает «нет», и повторные попытки ответа не изменят. - Делайте отправку идемпотентной. Если исполнитель упадёт после того, как сервер принял письмо, но до того, как задача отмечена выполненной, задача запустится снова. Храните отметку «отправлено» рядом с событием - токеном сброса пароля, номером заказа - и проверяйте её перед отправкой.
Заголовки, которые стоит выставлять#
Почтовые библиотеки выставляют минимум. Ещё несколько заголовков заставляют вашу почту вести себя лучше:
- `Message-ID` и `Date`: на практике обязательны; их отсутствие стоит баллов спама. Большинство библиотек и почтовых серверов добавляют их сами, но проверьте.
- `Auto-Submitted: auto-generated` (RFC 3834): сообщает серверу получателя, что письмо отправлено программой, чтобы автоответчики об отпуске на него не отвечали.
- Текстовая часть рядом с HTML. Письма только с HTML получают худший балл и менее доступны.
- `List-Unsubscribe` и `List-Unsubscribe-Post: List-Unsubscribe=One-Click`: для дайджестов уведомлений и всего, что похоже на маркетинг, но не для сброса пароля.
Держите шаблоны простыми. Транзакционное письмо, которое выглядит как рассылка - большая картинка в шапке, пиксели отслеживания, дюжина ссылок, - и фильтруется как рассылка.
Bounce и отправитель конверта#
У каждого письма есть отправитель конверта, который задаётся командой MAIL FROM и записывается как Return-Path:. Когда доставка не удаётся уже после того, как ваш сервер принял письмо - ящика не существует, сервер получателя его отклонил, - bounce приходит на этот адрес. Если он указывает на noreply@, который никто не читает, или на несуществующий ящик, вы так и не узнаете, что половина зарегистрировавшихся ввели адрес с ошибкой.
Что с ними делать:
- Жёсткие bounce (
5xx: пользователь неизвестен, домена не существует): прекратите отправлять на этот адрес. Отметьте это в записи пользователя и попросите его исправить адрес при следующем входе. Повторная отправка на мёртвые адреса - один из самых явных признаков отправителя, который не следит за своим списком. - Мягкие bounce (
4xx, ящик переполнен, временно недоступен): ваш почтовый сервер сам повторяет их по нескольку дней, прежде чем сдаться. Настоящим bounce это становится только после окончательной неудачи. - Куда они идут: в ящик, который читает приложение (по IMAP, из исполнителя), или в ящик, который проверяет человек. Приложения, отправляющие много, используют VERP - свой отправитель конверта для каждого получателя, например
bounces+anna=gmail.com@example.com, - чтобы bounce указывал адрес без разбора самого письма, а это кропотливое занятие.
Вашему почтовому серверу тоже нужно доверие при доставке. SPF, DKIM и DMARC для отправляющего домена разобраны в статье SPF, DKIM и DMARC простыми словами; без них даже аккуратный код попадает в спам.
Собственный почтовый сервер или сервис отправки#
Для транзакционной почты небольшого объёма - несколько сотен или несколько тысяч писем в день из одного приложения - собственного почтового сервера вполне достаточно, когда его DNS в порядке, а у его IP есть немного истории. Сервисы отрабатывают свою цену на больших объёмах, на маркетинговой почте и тогда, когда вам нужна аналитика доставки без того, чтобы строить её самим.
На RE:NODE линейка Mail Server работает на Stalwart, и приложение на тарифе Node, Python или C# может отправлять в него почту, как любой почтовый клиент: адрес сервера и порт submission из панели, отдельная учётная запись, созданная в веб-админке Stalwart, и учётные данные в переменных окружения на вкладке Startup приложения. Записи MX, SPF, DKIM и DMARC для домена добавляете вы. Если сеть получателя отказывается принимать почту с адреса вашего сервера на порту 25, Stalwart может передавать исходящую почту релею, заданному в его веб-админке, и код вашего приложения при этом совсем не меняется - когда это стоит делать, рассказано в статье SMTP-релеи для исходящей почты.
Проверка раньше пользователей#
swaks - «швейцарский нож» для SMTP - отправляет тестовое письмо ровно с теми настройками, что использует ваше приложение, и выводит весь диалог:
$ swaks --to you@example.net --from app@example.com \ --server mail.example.com:587 --tls \ --auth LOGIN --auth-user app@example.com --auth-password 'secret'Для порта с неявным TLS используйте --tls-on-connect вместо --tls. Ответ 235 означает, что аутентификация прошла; 250 после данных означает, что сервер принял письмо. Затем откройте доставленную копию и прочитайте её заголовки: Authentication-Results должен показывать, что SPF, DKIM и DMARC проходят для вашего домена. Повторите проверку на адреса Gmail и Outlook, потому что именно там большинство ваших пользователей.
FAQ#
Может ли приложение отправлять почту без почтового сервера?
Надёжно - нет. Доставлять напрямую через порт 25 значит реализовывать работу почтового сервера внутри приложения - поиск MX, очереди, повторные попытки, greylisting - с IP без почтовой репутации, а большинство хостинговых сетей и так блокируют этот порт на выход. Отправляйте через почтовый сервер, свой или провайдера, и пусть доставляет он.
Почему отправка зависает до тайм-аута?
Режим TLS не соответствует порту. Неявный TLS (secure: true, SMTP_SSL, EMAIL_USE_SSL, smtps://) на порту со STARTTLS ждёт рукопожатия, которое сервер никогда не начнёт, а обратная комбинация ждёт приветствия, которое сервер никогда не отправит. Приведите настройку в соответствие со слушателем.
Должны ли письма для сброса пароля приходить с адреса noreply?
Могут, но укажите в Reply-To: адрес, который кто-то читает, чтобы ответившие пользователи попадали к человеку. И убедитесь, что отправитель конверта указывает туда, где принимают bounce, иначе вы никогда не узнаете, какие адреса введены с ошибкой.
Нужна ли ссылка для отписки в транзакционных письмах?
В чисто транзакционных письмах, таких как чеки или сброс пароля, - нет. А вот дайджестам уведомлений, новостям о продукте и всему, чего пользователь вполне может не хотеть получать, она нужна, включая заголовки List-Unsubscribe для отписки в один клик.
Сколько писем можно отправлять с собственного почтового сервера?
Фиксированного числа на стороне сервера нет; ограничения задают получатели. Новый сервер должен начинать с малого и наращивать объём постепенно, потому что крупные провайдеры притормаживают и отправляют в спам внезапные объёмы с незнакомого им адреса. Транзакционные объёмы типичного приложения с запасом укладываются в возможности самостоятельно размещённого сервера.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.