Почта¶
Пространство имён Keel\Mail. Symfony Mailer, подключённый как провайдер: транспорт из DSN,
MailerInterface Symfony и поверх него Mailer Keel, который подставляет настроенного
отправителя, Reply-To и заголовки по умолчанию и умеет отправлять после ответа. Опциональная
зависимость.
composer require symfony/mailer # или: php bin/console keel:install mailer
// config/mail.php
return static fn(Env $env): MailConfig => new MailConfig(
dsn: $env->get('MAILER_DSN') ?? 'null://null',
from: 'My app <no-reply@example.com>', // для писем без заголовка From
replyTo: ['support@example.com'], // для писем без заголовка Reply-To
headers: ['X-Mailer' => 'My app'], // добавляются письмам, где заголовка нет
);
// config/services.php
return new ServicesConfig([MailerProvider::class]);
use Symfony\Component\Mime\Email;
final class RegistrationController
{
public function __construct(private readonly Users $users, private readonly Mailer $mailer, private readonly ViewRenderer $views) {}
#[Post('/register')]
public function register(#[FromBody] SignUp $form): array
{
$user = $this->users->create($form);
$this->mailer->defer(
new Email()
->to(new Address($user->email, $user->name))
->subject('Welcome')
->html($this->views->render('mail/welcome.twig', ['user' => $user]))
->text('Welcome, ' . $user->name),
);
return ['id' => $user->id];
}
}
Mailer::send() отправляет сразу и пропускает исключение транспорта наружу
(Symfony\Component\Mailer\Exception\TransportExceptionInterface). Mailer::defer() отдаёт
подготовленное письмо в DeferredTasks: его отправит
Kernel::terminate(), когда клиент уже получил ответ, так что запрос не ждёт обмена с SMTP, а
ошибка попадает в лог, а не клиенту. Отложенная почта не долговечна: падение процесса между
ответом и отправкой её теряет; очередь вне scope Keel. Оба метода принимают необязательный
Envelope.
Письмо — любой Email или Message из Symfony Mime: вложения, встроенные изображения,
приоритет, Bcc и остальное — всё Symfony. RawMessage (уже собранная MIME-строка) проходит
без подстановки умолчаний.
Транспорты¶
DSN — как в Symfony Mailer:
| DSN | Транспорт |
|---|---|
smtp://user:pass@mail.example.com:587 |
SMTP со STARTTLS; smtps:// для TLS на 465; ?verify_peer=0 и остальные опции как в Symfony |
sendmail://default |
локальный бинарник sendmail |
native://default |
настройки mail() из PHP |
null://null |
отбрасывает все письма (разработка без почтового сервера) |
memory://default |
хранит письма в MemoryTransport (тесты, см. ниже) |
mailgun+api://KEY:DOMAIN@default, ses+api://..., ... |
API-мост; каждый — отдельный пакет (symfony/mailgun-mailer, symfony/amazon-mailer, ...) и требует symfony/http-client |
failover(smtp://a smtp://b), roundrobin(...) |
несколько транспортов |
Транспорт создаётся один раз на воркер. SMTP-транспорт держит соединение между письмами и сам
переподключается после restart_threshold писем или ping_threshold секунд простоя, так что
worker mode не требует ничего особенного; опции DSN — Symfony.
Здоровье¶
MailerProvider регистрирует MailerCheck с тегом keel.health. Для SMTP проверка готовности
открывает сессию (соединение, EHLO, аутентификация) и закрывает её, сообщая задержку в ms;
остальные транспорты отмечаются up с "probed": false — без отправки их не проверить. Если
недоступный почтовый сервер не должен выводить приложение из готовности, зарегистрируйте
MailerCheck без тега в своём провайдере, загруженном после MailerProvider.
Тестирование¶
Направьте тестовое окружение на memory://default: MemoryTransport из контейнера собирает
каждый SentMessage — исходное письмо (getOriginalMessage()), конверт и собранный MIME-текст
(toString()).
$app = App::boot($projectDir, new Env(['APP_ENV' => 'test', 'MAILER_DSN' => 'memory://default']));
$client = TestClient::forApp($app);
$client->post('/register', ['email' => 'ann@example.com', 'name' => 'Ann'])->assertStatus(201);
$sent = $app->container->get(MemoryTransport::class)->sent();
self::assertCount(1, $sent);
self::assertSame('ann@example.com', $sent[0]->getEnvelope()->getRecipients()[0]->getAddress());
self::assertStringContainsString('Welcome', $sent[0]->toString());
Письма копятся всё время жизни процесса: clear() между тестами, делящими контейнер.
Юнит-тест собирает new Mailer(new Symfony\Component\Mailer\Mailer(new MemoryTransport()), new MailConfig('memory://default', from: ...))
напрямую; defer() требует DeferredTasks третьим аргументом и run() на нём.
Что регистрируется¶
Symfony\Component\Mailer\Transport\TransportInterface (из DSN, MemoryTransportFactory впереди
фабрик Symfony), Symfony\Component\Mailer\Mailer как MailerInterface, Keel\Mail\Mailer,
MemoryTransport, MailerCheck. Диспетчер событий Symfony не подключается: слушатели
MessageEvent полного фреймворка (подпись, шифрование, глобальные заголовки) заменены
умолчаниями MailConfig; шина сообщений не используется.