Перейти к содержимому
Поиск
Разработка

Symfony Mailer: письма из PHP, SMTP и вложения — мастерская и примеры

Собираем письмо, разбираем доставку и проверяем результат в локальном почтовом ящике. Понятные сценарии для PHP, живая мастерская и готовый проект с инструкциями для Windows и Linux.

Александр Некрасов
Symfony Mailer — письма из PHP: SMTP, шаблоны и вложения

Symfony Mailer помогает отправлять письма из PHP: подтверждения заказов, ответы поддержки, отчёты, уведомления и сообщения для личного кабинета. Можно собрать обычный текст и HTML, добавить вложение, направить ответ в нужную команду и обработать сбой SMTP без ручного конструирования почтовых заголовков.

В этой статье пройдём весь путь: от понятного сценария в интерфейсе до письма, которое действительно принял локальный SMTP-сервер. Затем разберём подключение своего провайдера на Linux VPS или выделенном сервере, использование в существующем PHP-приложении и особенности Windows-разработки. В архиве есть рабочий проект, проверяемый пример сервиса уведомлений и отдельный приватный CLI для контрольной отправки.

Два способа попробовать. На странице работает мини-мастерская с восемью ситуациями, предпросмотром и генератором PHP. Она ничего не отправляет. В скачанном проекте PHP действительно передаёт письма локальному Mailpit; адресаты ограничены доменами .test. Внешний SMTP подключается отдельно, на вашем сервере, с вашими учётными данными. Во время подготовки материала реальные внешние адресаты не использовались.

Что вы получите и где это пригодится

Письмо часто становится продолжением интерфейса. Пользователь оставил заявку и закрыл вкладку — подтверждение должно остаться у него в почте. Менеджер подготовил отчёт — клиенту нужен понятный результат и файл. Фоновая задача завершилась ночью — сотруднику достаточно одного уведомления, а не постоянной проверки панели. В этих случаях полезна небольшая серверная служба, которая умеет одинаково создавать, отправлять и диагностировать сообщения.

Ситуация Что отправляем Что важно предусмотреть
Регистрация Приветствие и следующий шаг Не смешивать письмо с подтверждением владения адресом
Интернет-магазин Номер заказа, сумму и статус Данные берём из сохранённого заказа
Поддержка Подтверждение заявки и контакт команды Разделяем From и Reply-To
Отчётность Пояснение и CSV/PDF-вложение Проверяем права на документ и размер
Фоновые задачи Результат импорта или экспорта Не отправляем одно письмо на каждую строку
Личный кабинет Уведомление об изменении Не раскрываем лишние персональные данные
Командная работа Дайджест событий Учитываем настройки и частоту уведомлений
Эксплуатация Служебное сообщение об ошибке Не включаем пароли и полный debug-лог

Готовый компонент особенно полезен, когда письма появляются в нескольких местах проекта. Вместо отдельных вызовов в каждом контроллере вы выделяете понятный сервис: «отправить подтверждение заказа» или «сообщить о готовом отчёте». Шаблоны, адрес отправителя и обработка ошибок перестают расходиться. Само решение о том, кому и когда писать, остаётся в бизнес-логике приложения.

Как устроена отправка: PHP, Mailer, SMTP и почтовый ящик

Браузер не должен знать пароль SMTP. Он обращается к вашему PHP-приложению, а приложение проверяет действие и получает данные из своей базы. Symfony Mime формирует сообщение с адресами, заголовками, текстом и вложениями. Symfony Mailer передаёт его выбранному транспорту. В нашем случае это SMTP: PHP устанавливает соединение с почтовым сервером и передаёт подготовленное письмо.

Дальше начинается другой участок пути. SMTP-сервер может принять сообщение, поставить его в собственную очередь, передать следующему серверу или позднее получить отказ. Поэтому «вызов send завершился без исключения» и «человек прочитал письмо» — разные события. Даже появление письма у провайдера ещё не доказывает попадание во «Входящие». Для реального сервиса нужны наблюдение за доставкой, обработка отказов и подходящий провайдер.

Mailpit сокращает эту цепочку для разработки. Он принимает SMTP и показывает сообщение в веб-интерфейсе, не доставляя его в интернет. Это удобно для проверки того, что создаёт PHP: какие адреса попали в заголовки, есть ли текстовая альтернатива, корректна ли кириллица и приложен ли нужный файл. Такой результат воспроизводим и не зависит от личного почтового ящика разработчика.

Источник: Документация Mailpit.

Symfony Mailer и стандартная mail(): в чём практическая разница

Встроенная функция mail() подходит для окружения, где системная отправка уже настроена и требования к письмам невелики. Однако по мере роста проекта приходится отдельно решать кодирование заголовков, multipart-структуру, вложения и диагностику. С Mailer вы работаете с объектом письма и транспортом: технические части собраны в одном API, а код остаётся ближе к смыслу сообщения.

Задача mail() Symfony Mailer
Простой текст Встроенная функция PHP Объект Email и text()
HTML и текст вместе Нужно корректно построить MIME Отдельные методы html() и text()
Вложение Ручная MIME-структура или дополнительный код attachFromPath() с именем и типом
SMTP-провайдер Зависит от системного почтового окружения Явный транспорт из DSN
Ошибка передачи Булев результат и окружение сервера Исключение транспортного уровня
Тестирование оформления Нужен организованный канал перехвата Удобно подключить Mailpit как SMTP

Переход на библиотеку сам по себе не исправляет настройки домена и не повышает репутацию отправителя. Его ценность — управляемое создание сообщения, понятная конфигурация и возможность проверять поведение. Успешный результат mail() также не является подтверждением доставки получателю. Это прямо оговаривает руководство PHP, поэтому одинаковую аккуратность нужно сохранять с обоими подходами.

Источник: PHP: mail() и смысл возвращаемого результата.

Мини-мастерская: восемь ситуаций, живой предпросмотр и PHP-код

Выберите приветствие, заказ, ответ поддержки, отчёт, простой текст, кириллицу, дайджест или отказ SMTP. Меняйте поля и сразу смотрите результат. Вкладка «Письмо» показывает оформление и адреса, «Текст» — альтернативную версию, «PHP-код» — самостоятельный пример, «SMTP / DSN» — настройку окружения. Результат можно скопировать и скачать.

Начните с отчёта: добавьте CSV, поменяйте тему и адрес Reply-To. Затем переключитесь на текстовый формат и посмотрите, как исчезает вызов HTML в PHP-коде. В примере с кириллицей есть строка, похожая на JavaScript: она остаётся видимым текстом. Попробуйте некорректный адрес — мастерская объяснит ошибку и остановит экспорт, сохранив последний корректный предпросмотр.

Открыть мастерскую на весь экран ↗Скачать PHP, Mailpit и исходники ↓

Мастерская показывает собственный шаблон письма, а не эмуляцию Outlook или Gmail. Она не проверяет DNS, пароль, доступность провайдера и доставку. При выборе внешнего SMTP меняется пример конфигурации. Фактическая отправка остаётся серверной задачей. Для работы формы не нужны ваши настоящие адреса и секреты.

Установка через Composer и совместимость PHP

Пакет можно использовать без Symfony Framework. Для нового проекта достаточно Composer, совместимой версии PHP и подключения автозагрузчика. Если с Composer ещё не работали, начните с руководства по установке, зависимостям и командам. Для повторения нашего проекта удобнее архив: в нём уже сохранён lock-файл.

composer require symfony/mailer
composer check-platform-reqs

В стенде на 7 ноября 2026 года закреплены Symfony Mailer и Mime 8.1.10, PHP 8.5.12, Composer 2.11.1 и Mailpit 1.33.0. Ветка Mailer 8.1 требует PHP не ниже 8.4.1. Наличие новой бета-ветки не означает, что её нужно ставить в рабочий проект. Выбирайте поддерживаемую стабильную версию, совместимую с вашим runtime, и храните composer.lock вместе с исходниками.

Это важно для Linux-хостинга: штатный PHP в Ubuntu 24.04 LTS относится к ветке 8.3. Он не подходит для закреплённого здесь Mailer 8.1. В контейнерном варианте нужный PHP уже задан. Для отдельного существующего приложения на PHP 8.3 можно выбрать поддерживаемую ветку Symfony Mailer 7.4, создать собственный lock-файл и проверить её отдельно. Обходить проверку платформы в production не нужно.

Источник: Версии Symfony Mailer и требования PHP.

Источник: Штатный PHP в Ubuntu 24.04.

Источник: Поддержка Symfony 7.4.

Запускаем рабочий PHP-стенд: Linux и Windows

Скачайте исходники и откройте терминал внутри папки mailer-project. Нужен Docker с Linux-контейнерами и Docker Compose v2. Команда соберёт PHP-приложение, установит зависимости из lock-файла и запустит Mailpit. Отдельно ставить PHP, Composer или SMTP-сервер на компьютер не требуется.

Linux, включая VPS и выделенный сервер

cd mailer-project
docker compose config --quiet
docker compose up -d --build --wait
docker compose ps
curl --fail http://127.0.0.1:8091/health.php

Ожидаемый результат — два healthy-контейнера и ответ ok. Форма доступна на http://127.0.0.1:8091/, ящик — на http://127.0.0.1:8027/. На Ubuntu Docker Engine и Compose устанавливайте по официальной инструкции для вашего дистрибутива. Проверенный контейнер PHP не зависит от версии системного PHP сервера.

Windows PowerShell

Set-Location C:\Projects\mailer-project
docker compose config --quiet
docker compose up -d --build --wait
docker compose ps
curl.exe --fail http://127.0.0.1:8091/health.php
Start-Process http://127.0.0.1:8091/
Start-Process http://127.0.0.1:8027/

Замените путь на папку, куда распаковали архив. Docker Desktop должен быть запущен и работать с Linux-контейнерами. Если порт уже занят, измените только левую, опубликованную часть привязки в Compose и используйте новый адрес. Внутренний SMTP-адрес mailpit:1025 от этого не меняется.

Если стенд запущен на удалённом Linux-сервере, адрес 127.0.0.1 относится к серверу. Для просмотра с компьютера используйте SSH-туннель. Команда подходит для Linux, macOS и Windows с OpenSSH; она не открывает ящик всему интернету. Терминал с туннелем должен оставаться открытым, пока вы проверяете письма.

ssh -N -L 8091:127.0.0.1:8091 -L 8027:127.0.0.1:8027 user@your-server

Источник: Установка Docker Engine и Compose на Ubuntu.

Что делает Compose и где лежат файлы

Сервис web запускает Apache с PHP, сервис mailpit принимает SMTP и хранит перехваченные сообщения в именованном томе. Наружу опубликованы только два HTTP-порта на loopback. SMTP-порт не публикуется на хост. Контейнеры находятся в отдельной bridge-сети; это отдельная сеть проекта, а не запрет исходящего интернет-трафика. За ограничение отправки отвечают фиксированный DSN и проверка адресатов в PHP.

Посмотреть полный compose.yaml
name: aen-mailer-20261107
services:
  web:
    build: .
    ports:
      - "127.0.0.1:8091:80"
    environment:
      MAILER_DSN: smtp://mailpit:1025
      MAILER_DEMO: "1"
    depends_on:
      mailpit:
        condition: service_healthy
    networks: [mail_lab]
    healthcheck:
      test: ["CMD", "php", "-r", "exit(file_get_contents('http://127.0.0.1/health.php') === 'ok' ? 0 : 1);"]
      interval: 5s
      timeout: 3s
      retries: 12
  mailpit:
    image: axllent/mailpit:v1.33.0
    ports:
      - "127.0.0.1:8027:8025"
    environment:
      MP_DATABASE: /data/mailpit.db
      MP_MAX_MESSAGES: "200"
    volumes:
      - mailpit_data:/data
    networks: [mail_lab]
    healthcheck:
      test: ["CMD", "/mailpit", "readyz"]
      interval: 5s
      timeout: 3s
      retries: 12
networks:
  mail_lab:
    # Dedicated bridge; only loopback HTTP ports are published.
    # The PHP demo allows only the fixed Mailpit DSN and .test recipients.
    driver: bridge
volumes:
  mailpit_data:

В public/ находится интерфейс и HTTP-обработчик. Код подготовки сообщения лежит в src/MailFactory.php, локальная отправка — в src/Sender.php, пример для бизнеса — в src/OrderNotifier.php. CSV хранится в attachments/report.csv. Корень веб-сервера указывает только на public, поэтому исходники классов, Composer-файлы и production CLI не выдаются как страницы.

Секреты не нужны для локального Mailpit. Значение DSN задано в Compose как учебный адрес без пароля. Для внешнего провайдера предусмотрен отдельный файл конфигурации за пределами webroot. Не смешивайте эти режимы: Mailpit нужен для разработки, а реальную доставку обеспечивает выбранный вами почтовый сервис.

Первое письмо: текст и явный SMTP-транспорт

У письма есть отправитель, получатель, тема и содержимое. У транспорта — адрес сервера и параметры соединения. Это две разные сущности, и такое разделение помогает при переносе проекта: содержание письма можно оставить прежним, заменив настройки SMTP. В учебном коде транспорт смотрит на Mailpit внутри сети Compose.

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

use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;

$dsn = getenv('MAILER_DSN');
if (!$dsn) throw new RuntimeException('SMTP не настроен');

$email = (new Email())
    ->from('studio@example.test')
    ->to('reader@example.test')
    ->subject('Ваш отчёт готов')
    ->text('Здравствуйте! Учебный отчёт подготовлен.');

(new Mailer(Transport::fromDsn($dsn)))->send($email);

Сохраните сгенерированный мастерской пример как send-example.php рядом с composer.json, пересоберите образ и выполните его внутри контейнера. Сервисное имя mailpit доступно контейнеру, но обычно не известно обычному PHP-процессу на хосте. Ошибка разрешения имени в такой ситуации говорит о месте запуска, а не о неправильном пароле.

docker compose up -d --build --wait
docker compose exec -T web php send-example.php

После обычного сценария в Mailpit появится новое письмо. Вкладка с заголовками поможет понять, как объект Email превратился в MIME-сообщение. Сравните это с текстом на странице: интерфейс показывает только представление, тогда как SMTP передаёт структурированное сообщение с техническими заголовками и частями тела.

Источник: Создание сообщений Symfony Mailer.

HTML и текстовая версия: два представления одного смысла

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

$subject = 'Заказ №1042 принят';
$body = "Здравствуйте!\nВаш заказ принят.\nСумма: 10 000 ₽.";
$safeSubject = htmlspecialchars($subject, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeBody = nl2br(htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));

$email->subject($subject)
    ->text($body)
    ->html('<h1>'.$safeSubject.'</h1><p>'.$safeBody.'</p>');

Текст пользователя нельзя просто вставить внутрь HTML. В проекте сначала выполняется экранирование, а затем переносы превращаются в <br>. Поэтому строка с угловыми скобками остаётся текстом. Это не редактор произвольной HTML-рассылки: для такого инструмента нужен отдельный подход к шаблонам, ограничениям разметки и предварительному просмотру.

CSS в почтовых клиентах поддерживается иначе, чем на обычном сайте. Bootstrap оформляет нашу мастерскую, но не подключается к полученному письму. Само письмо использует небольшой шаблон с inline-стилями. Перед реальной рассылкой проверяйте его в целевых клиентах, особенно длинные темы, переносы ссылок, тёмную тему и отключённые изображения. Локальный Mailpit подтверждает структуру, но не заменяет такие проверки.

Источник: Объект Email и части сообщения.

From, To, Reply-To, Cc и Bcc: кому что относится

From отвечает на вопрос «кто отправил письмо». To указывает основного получателя. Reply-To сообщает почтовой программе, куда направить ответ человека. Для формы обратной связи типичная схема такая: письмо отправляет ваш сервис со своего домена, а Reply-To содержит проверенный адрес автора обращения. Подставлять чужой пользовательский адрес в From обычно не нужно: это создаёт путаницу и проблемы с аутентификацией домена.

use Symfony\Component\Mime\Address;

$email
    ->from(new Address('notifications@example.test', 'Мой сервис'))
    ->to(new Address('reader@example.test'))
    ->replyTo(new Address('support@example.test'));

Cc показывает дополнительного получателя другим участникам. Bcc скрывает его в отображаемых заголовках сообщения. Но ни Cc, ни Bcc не заменяют проверку прав: если клиент не должен получать документ другого клиента, библиотека не может угадать это по адресу. Для индивидуальных уведомлений проще создавать отдельное сообщение с чётко определённым получателем.

В демонстрационном стенде From фиксирован, а To и Reply-To разрешены только в доменах .test. Это позволяет свободно экспериментировать без риска случайно написать реальному человеку. В вашем приложении адрес назначения должен определяться подтверждённой записью пользователя или заказом, а не произвольным значением POST-запроса.

Источник: Проверка и представление адресов в Symfony Mime.

Вложение: отчёт, документ или результат экспорта

Частый практический сценарий — отправить файл, который сервер уже сформировал. Например, приложение выгружает CSV по заявкам или готовит PDF-счёт. В письмо стоит добавить короткое объяснение: что за документ, за какой период и что с ним делать. Само имя файла должно быть понятным без открытия сообщения.

$email->attachFromPath(__DIR__.'/attachments/report.csv', 'report.csv', 'text/csv');

В нашем проекте путь выбран программой и не приходит из браузера. Пользователь может включить готовый CSV или отправить письмо без вложения. Это полезная граница: HTTP-поле со значением вроде «путь к файлу» не должно давать человеку возможность прикрепить произвольный файл сервера. Для загруженных документов сначала проверяют тип, размер, расположение и право пользователя на конкретный объект.

Большие документы не всегда удобно пересылать вложением. Лимиты действуют и у отправителя, и у получателя; передача также увеличивает время SMTP-запроса. Для крупных файлов можно отправить ссылку на защищённую загрузку с ограниченным сроком действия. Тогда сервер должен проверять доступ по этой ссылке, а само письмо не должно раскрывать внутренние пути хранилища.

В тестах архива сравниваются не только имя и MIME-тип CSV, но и точные байты вложения. Это важно: наличие кнопки «вложение» в интерфейсе ещё не подтверждает, что пользователь получил именно тот файл. В Mailpit откройте Attachments, скачайте report.csv и сравните его с исходником.

Проверка данных и защита заголовков

Валидация начинается до подключения к SMTP. Пустая тема, слишком длинный текст, неверный адрес и неизвестный тип вложения должны давать понятную ошибку формы. В нашем обработчике такие ошибки возвращают HTTP 422. CSRF-проверка отдельно возвращает 403, если запрос не относится к текущей форме. Это разные причины, и пользователю полезнее видеть конкретное исправление, чем универсальное «что-то пошло не так».

if (preg_match('/[\r\n\x00]/', $subject)) {
    throw new InvalidArgumentException('В теме нельзя использовать переносы строк.');
}
$recipient = new Address($recipientString);

Переносы строк допустимы в теле сообщения, но не должны превращаться в дополнительные заголовки через поле темы или адреса. В проекте для этих полей есть отдельная проверка. Объект Address проверяет синтаксическую корректность адреса. При этом синтаксис не доказывает существование ящика, принадлежность адреса человеку или разрешение на отправку — это отдельные правила продукта.

Не делайте публичный HTTP-метод, который принимает произвольные From, To, тело и SMTP-сервер без авторизации. Такой интерфейс быстро превращается в чужой почтовый шлюз. В реальном сервисе определите действие: «подтвердить заказ», «ответить на доступную оператору заявку», «отправить мой отчёт». Получателя и вложение выбирайте на сервере по этому действию, а частоту и повторные нажатия ограничивайте.

Если проверки сложнее нескольких полей, их можно вынести отдельно. Для этого пригодится Symfony Validator. Важно, чтобы валидация выполнялась на сервере даже при наличии HTML required и JavaScript-проверок: клиентский интерфейс помогает человеку, но не является границей доверия.

Ошибки SMTP: что показывать пользователю и что писать в журнал

Соединение может не установиться, сервер может отказать в авторизации или отклонить сообщение. Это не та же ошибка, что неверно заполненная форма. Транспортные исключения удобно обрабатывать отдельно: интерфейс получает короткий понятный результат, а журнал — техническое событие с идентификатором запроса. По этому коду можно сопоставить жалобу пользователя и запись на сервере.

use Symfony\Component\Mailer\Exception\TransportExceptionInterface;

try {
    $mailer->send($email);
} catch (TransportExceptionInterface $e) {
    error_log(json_encode([
        'event' => 'mail.transport_failed',
        'request_id' => $requestId,
        'class' => get_class($e),
    ]));
    // HTTP handler: return a clear status without credentials or SMTP transcript.
}

Не выводите в ответ весь exception message, DSN или SMTP debug. В них могут оказаться адреса, технические сведения и чувствительные параметры соединения. Для своего приложения удобно использовать Monolog с общим форматом журналов. В нашем небольшом стенде достаточно безопасного JSON-события через error_log.

Сценарий «Отказ SMTP» обращается к закрытому порту 1026 того же Mailpit. Это намеренно воспроизводимая ошибка: PHP возвращает 503, форма объясняет результат, новое письмо не появляется. Так можно проверить ветку обработки сбоя без остановки контейнеров, изменения пароля и обращения к внешнему серверу.

docker compose logs --tail 100 web

После сетевого сбоя иногда неизвестно, успел ли удалённый сервер принять письмо до разрыва соединения. Поэтому кнопка «повторить» не должна бесконечно отправлять одинаковые сообщения вслепую. Сначала проверьте журнал, состояние бизнес-операции и данные провайдера. Это особенно важно для счетов, подтверждений и уведомлений, которые человек воспринимает как отдельные события.

MAILER_DSN, getenv() и секреты окружения

DSN — строка настройки транспорта. В ней могут находиться схема, логин, пароль, хост, порт и дополнительные параметры. Для локального стенда это smtp://mailpit:1025. Для провайдера параметры будут другими, и их нужно брать из его документации. Пароль нельзя помещать в JavaScript, репозиторий, URL страницы или публично доступный файл.

$dsn = getenv('MAILER_DSN');
if (!is_string($dsn) || $dsn === '') {
    throw new RuntimeException('MAILER_DSN is not configured');
}
$transport = Transport::fromDsn($dsn);

Обычный PHP не читает .env автоматически. Файл должен загрузить ваш инструмент запуска, контейнер, фреймворк или отдельная библиотека. В Compose окружение передаётся процессу контейнера. Если используете phpdotenv, учитывайте выбранный способ загрузки: типичный immutable-режим работает с массивами окружения и не означает, что любое значение обязательно появится в getenv(). Подробности разобраны в статье о .env и переменных окружения.

Специальные символы логина и пароля кодируйте по отдельности через rawurlencode. Например, знак @ внутри пароля не должен ошибочно разделить пароль и хост. Не кодируйте весь DSN целиком: схема и разделители должны сохранить своё значение. При диагностике достаточно проверить наличие переменной и корректность её структуры, не печатая секрет в журнал.

Источник: PHP: getenv().

Источник: Способы загрузки phpdotenv.

TLS и почтовый провайдер: подключаемся осознанно

Для реальной передачи учётных данных SMTP нужно защищённое соединение. У провайдера могут быть STARTTLS на порту 587 или implicit TLS на 465. Номер порта сам по себе не является достаточной настройкой: согласуйте схему, режим TLS и требования провайдера. В примере ниже для SMTP явно требуется STARTTLS, а проверка сертификата остаётся включённой.

# Example only: use values from your provider
MAILER_DSN=smtp://USER:PASSWORD@smtp.example.com:587?require_tls=true

# If the provider specifies implicit TLS:
# MAILER_DSN=smtps://USER:PASSWORD@smtp.example.com:465

При ошибке сертификата проверьте имя сервера, время системы, CA-пакет и настройки окружения. Отключение проверки peer не является обычным способом починить рабочую почту. Наш приватный CLI дополнительно отклоняет настройки с отключённой проверкой и требует TLS для smtp-схемы. Локальный Mailpit без учётных данных — отдельный учебный контур, а не образец производственной передачи.

Если соединение не устанавливается на VPS, проверьте исходящие ограничения хостинга и выбранный порт. Некоторые инфраструктурные провайдеры ограничивают SMTP; настройки приложения не отменяют такое ограничение. Не меняйте случайно порт, пока не понятно, какой режим поддерживает почтовый сервис. Для API-провайдеров у Symfony существуют отдельные транспорты, которые устанавливаются дополнительными пакетами.

Источник: Параметры SMTP-транспорта.

Источник: TLS-поведение SMTP в проверенной версии.

Практический запуск на Linux VPS или выделенном сервере

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

Разместите содержимое проекта в /srv/aen-mailer. Секретную конфигурацию храните в /etc/aen-mailer/mailer.env, вне исходников и webroot. Команды ниже рассчитаны на администратора Linux-сервера с Docker. Первоначальное копирование шаблона выполняется один раз: уже заполненный секретный файл нельзя перезаписывать при каждом обновлении.

sudo install -d -m 0755 /srv/aen-mailer
# Разместите содержимое проекта в /srv/aen-mailer
cd /srv/aen-mailer
sudo install -d -m 0700 /etc/aen-mailer
sudo install -m 0600 config/production.env.example /etc/aen-mailer/mailer.env
sudoedit /etc/aen-mailer/mailer.env
sudo docker compose -f compose.production.yaml build
sudo docker compose -f compose.production.yaml run --rm mailer --check

Замените значения SMTP, From, Reply-To и контрольного получателя на свои. Параметр MAIL_ALLOWED_TO задаёт один разрешённый ящик, а MAIL_TO должен точно ему соответствовать. В режиме –check код проверит структуру настроек и создаст письмо, но не откроет сетевое соединение. Ожидаемый результат содержит фразу «no connection made and no email sent». Такой результат не проверяет реальный пароль и доступность провайдера.

name: aen-mailer-production
services:
  mailer:
    build: .
    user: "33:33"
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=1777
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    env_file:
      - /etc/aen-mailer/mailer.env
    entrypoint: ["php", "/var/www/app/bin/send-production.php"]
    command: ["--check"]

Когда конфигурация проверена и контрольный адрес принадлежит вам, можно выполнить одну реальную отправку. Эта команда имеет внешний эффект: почтовый провайдер получит сообщение. При подготовке статьи она не запускалась с внешними учётными данными; SMTP-проверки выполнялись только через локальный Mailpit. После своего запуска проверьте ящик, папку спама, заголовки и направление ответа.

sudo docker compose -f compose.production.yaml run --rm mailer --send

Код завершения 0 подтверждает передачу SMTP, но не чтение и не попадание во «Входящие». Если команда завершилась ошибкой, она покажет идентификатор запроса, а техническое событие попадёт в stderr без пароля. Не ставьте контрольную команду в cron: она специально создаёт одно и то же тестовое сообщение. Для регулярных уведомлений нужен ваш рабочий сценарий и учёт уже обработанных событий.

Подключаем к своему приложению: сервис подтверждения заказа

Для рабочего кода полезно отделить «собрать письмо о заказе» от «настроить SMTP». В архиве это делает OrderNotifier. Он принимает MailerInterface, адрес сервиса и адрес поддержки. Метод получает уже проверенные данные заказа: адрес владельца, номер и сумму в копейках. Контроллер не должен передавать туда произвольный адрес из формы, если пользователь не имеет права писать этому получателю.

use App\OrderNotifier;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;

$dsn = getenv('MAILER_DSN');
if (!$dsn) throw new RuntimeException('SMTP is not configured');
$notifier = new OrderNotifier(
    new Mailer(Transport::fromDsn($dsn)),
    new Address(getenv('MAIL_FROM')),
    new Address(getenv('MAIL_REPLY_TO')),
);

// $order получен из БД после проверки доступа и сохранения заказа.
$notifier->sendConfirmation(
    new Address($order->customerEmail),
    $order->number,
    $order->totalKopecks,
);
Полный класс OrderNotifier из исходников
<?php
declare(strict_types=1);
namespace App;

use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;

/** Reusable application service. Caller supplies an authorized order and its owner. */
final class OrderNotifier
{
    public function __construct(
        private readonly MailerInterface $mailer,
        private readonly Address $from,
        private readonly Address $replyTo,
    ) {}

    public function sendConfirmation(Address $customer, string $orderNumber, int $totalKopecks): void
    {
        if (!preg_match('/\A[A-Za-z0-9-]{1,40}\z/', $orderNumber) || $totalKopecks < 0) {
            throw new \InvalidArgumentException('Invalid order data');
        }
        $total = number_format($totalKopecks / 100, 2, ',', ' ');
        $subject = "Заказ {$orderNumber} принят";
        $text = "Ваш заказ {$orderNumber} принят.\nСумма: {$total} ₽.\nМы сообщим об изменении статуса.";
        $html = '<h1>'.htmlspecialchars($subject, ENT_QUOTES, 'UTF-8').'</h1><p>'.nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8')).'</p>';
        $email = (new Email())->from($this->from)->to($customer)->replyTo($this->replyTo)
            ->subject($subject)->text($text)->html($html);
        $email->getHeaders()->addTextHeader('X-Order-Reference', $orderNumber);
        $this->mailer->send($email);
    }
}

Такой сервис можно вызвать из HTTP-обработчика, консольной команды или worker. При этом бизнес-правила остаются едиными. В тесте вместо настоящего транспорта используется захватывающая реализация MailerInterface: проверяются тема, сумма, адрес ответа и отказ на неверных данных. Это дополняет проверку через Mailpit: один тест проверяет содержание бизнес-сообщения, другой — реальную передачу по SMTP.

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

Если приложение уже работает на PHP-FPM

Проверьте runtime именно того процесса, который выполняет код. PHP в SSH-терминале, cron и FPM-пуле могут отличаться по версии, расширениям и окружению. Для нашего lock-файла требуется PHP 8.4.1 или новее. Команда composer check-platform-reqs должна выполняться в целевом окружении. Наличие PHP 8.5 в Docker не обновляет PHP существующего сайта на хосте.

Корень сайта направляйте в public. Каталоги src и vendor, конфигурацию и секреты оставляйте выше. Переменная, экспортированная в одной SSH-сессии, не появляется автоматически у давно запущенного FPM. По умолчанию FPM очищает окружение через clear_env; передавайте нужные параметры адресно через управление сервисом, конфигурацию пула или принятый в проекте менеджер секретов. Не открывайте phpinfo в интернете ради проверки пароля.

Если вы осознанно создаёте отдельное приложение на штатном PHP 8.3 Ubuntu 24.04, используйте совместимую ветку Mailer 7.4. Пример установки расширений приведён ниже. Composer устанавливается по официальной инструкции с проверкой установщика. Команда require создаёт новый набор зависимостей для этого отдельного приложения, а не заменяет lock проверенного Docker-примера.

sudo apt update
sudo apt install php8.3-cli php8.3-fpm php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip php8.3-intl ca-certificates unzip
# Установите Composer по getcomposer.org/download/ с проверкой установщика.
# В каталоге отдельного приложения:
composer require symfony/mailer:^7.4
composer check-platform-reqs

После изменения конфигурации FPM сначала проверьте её командой вашей версии сервиса и затем выполните управляемый reload. Не заменяйте имена сокетов и служб случайным примером из другой системы: на сервере с панелью управления может быть несколько PHP-пулов. Для уже работающего сайта подключение Mailer должно быть небольшой проверяемой частью изменения, а не поводом перестраивать весь веб-сервер.

Источник: PHP-FPM: окружение и конфигурация пула.

Источник: Установка Composer.

SPF, DKIM, DMARC и граница ответственности Mailer

Для реальной доставки подтвердите домен отправителя у провайдера и настройте требуемые DNS-записи. SPF описывает разрешённых отправителей, DKIM связан с подписью сообщений, DMARC задаёт политику проверки доменного соответствия и отчётности. Конкретные значения зависят от провайдера и вашей зоны DNS: копировать чужой TXT-набор бессмысленно, а иногда вредно для уже работающей почты.

Mailer создаёт и передаёт сообщение, но не настраивает DNS и не управляет репутацией домена. У собственного почтового сервера добавляются вопросы обратного DNS, IP-репутации, очереди доставки и обслуживания. Поэтому для приложения часто проще использовать обслуживаемого SMTP-провайдера, сохраняя у себя логику уведомлений и контроль адресатов.

Проверьте From, Reply-To, заголовки аутентификации и фактическую доставку на своих контрольных ящиках. Для маркетинговых рассылок дополнительно нужны понятная подписка, отказ от уведомлений и соблюдение требований получающих сервисов. Транзакционное подтверждение заказа и рекламный дайджест решают разные задачи; не объединяйте их только потому, что технически оба сообщения отправляются одним методом.

Источник: Требования Gmail к отправителям.

Очереди, повторы и защита от дублей

Одно небольшое письмо можно отправить синхронно, если задержка допустима. Массовые уведомления, отчёты и нестабильный SMTP лучше вынести из HTTP-запроса. Пользователь получает ответ о сохранённом действии, а worker занимается отправкой. Для PHP естественным вариантом является Symfony Messenger с подходящим транспортом, ограниченными повторами и отдельным хранением неудачных сообщений.

В этом архиве очередь не реализована. Мы показываем серверный компонент отправки, локальную проверку и production CLI. Поэтому здесь нет фиктивного systemd-юнита для несуществующего worker. Когда добавите настоящий обработчик, его нужно запускать под менеджером процессов, ограничить время и память, организовать журнал и наблюдение за очередью. Конфигурация будет зависеть от выбранного транспорта и инфраструктуры.

Полезно хранить уникальный ключ уведомления: например, тип события, идентификатор заказа и версию. Это помогает не создавать одинаковую задачу после повторного HTTP-запроса. Но SMTP не даёт общего обещания exactly-once: сбой после передачи может оставить неопределённый результат. Для повторов нужны ограничение числа попыток, задержки и понятная процедура разбора спорных случаев.

На сайте есть материал о BullMQ и очередях Node.js. Он помогает понять задачи, прогресс и повторные попытки, но BullMQ не является PHP-адаптером этого примера. Выбирайте очередь под приложение и не добавляйте второй runtime только ради красивого названия технологии.

Источник: Symfony Messenger: workers, повторы и failed transport.

Как проверить результат и найти причину сбоя

Наблюдение Что проверить Ожидаемый результат
Форма не открывается docker compose ps и host-порт web healthy, правильный локальный адрес
422 при отправке Адрес .test, тема, длина, вложение Понятная ошибка конкретного поля
403 после долгого ожидания Обновление страницы и CSRF Новый токен формы
503 в режиме отказа Выбранный SMTP-сценарий Ожидаемая ошибка без нового письма
Письмо есть, HTML отличается HTML/Text и кодировка Обе версии передают один смысл
Вложение не то Имя, MIME и содержимое Байты совпадают с исходником
Внешний SMTP недоступен Провайдер, DNS, порт, TLS, ограничения VPS Проверка на реальном сервере отдельно

В архиве есть переносимые проверки на Python и Node.js. Они не требуют npm-зависимостей. SMTP-тесты создают учебные письма и не удаляют существующие сообщения. Генератор создаёт PHP-примеры, которые затем проверяются и исполняются в реальном контейнере. В обычном режиме они попадают в Mailpit, а пример отказа завершается ожидаемой ошибкой.

# Linux
node scripts/check-generator.mjs
python3 scripts/smoke.py
python3 scripts/acceptance.py
docker compose exec -T web php scripts/check-service.php
docker compose exec -T web composer check-platform-reqs
# Windows PowerShell
node scripts/check-generator.mjs
python scripts/smoke.py
python scripts/acceptance.py
docker compose exec -T web php scripts/check-service.php

Проверки разделены по смыслу. Предпросмотр подтверждает работу интерфейса. Генераторные тесты — корректность преобразования полей в код. Mailpit — фактическую передачу MIME, текста и вложения. Проверка production CLI без подключения — только локальную валидность конфигурации. Ни один из этих результатов сам по себе не подтверждает работу вашего внешнего SMTP, DNS или почтового клиента.

Обновление, журналы и сохранение писем

Обновляйте зависимости в рабочей копии, просматривайте изменения lock-файла и повторяйте тесты. На сервер переносите проверенный релиз. Composer install использует закреплённые версии, а update меняет набор зависимостей; случайное обновление на production усложняет воспроизведение ошибки. Базовые PHP-образы и системные сертификаты также требуют обслуживания.

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

docker compose logs --tail 100 web
docker compose stop
# Возобновить без удаления писем:
docker compose start

Mailpit хранит письма в именованном томе. Обычная остановка сохраняет их. Команда docker compose down удаляет контейнеры и сеть проекта, но без параметра -v оставляет том. Для этого учебного проекта не требуется глобальная очистка Docker. В архив не входят реальные .env, база Mailpit, vendor и Docker-образы; зависимости загружаются штатными инструментами.

Источник: Composer: lock-файл, install и update.

Исходники и следующий шаг

Начните с одной понятной задачи: подтверждение заказа, ответ на заявку или готовый отчёт. Проверьте письмо в Mailpit, подключите отдельный SMTP-канал и контрольный адрес, затем встроите сервис в приложение после успешного бизнес-действия. Когда отправок станет больше, добавьте очередь и наблюдение за результатами. Такой путь сохраняет понятность каждого этапа и позволяет использовать материал в реальном проекте.

Открыть мастерскую на весь экран ↗Скачать PHP, Mailpit и исходники ↓

Собственный код примеров распространяется по MIT; лицензии зависимостей перечислены в архиве. В README находятся команды для Windows и Linux, инструкция SSH-туннеля, серверные пути, production CLI, подключение к PHP-приложению и границы проведённых проверок. Мастерскую можно запускать отдельно через локальный HTTP-сервер и использовать как учебный конструктор.