Mailer

Note

Not part of core. Install it separately:

composer require kinetis/mailer

Mail sending via Symfony\Component\Mailer. A single DSN selects the transport — SMTP, or any of Symfony’s own API-based bridges (Sendgrid, Mailgun, Postmark, SES, …).

use Kinetis\Mailer\MailerFactory;
use Symfony\Component\Mime\Email;

$mailer = MailerFactory::fromConfig($config);

$email = (new Email())
    ->from('noreply@example.com')
    ->to('user@example.com')
    ->subject('Welcome!')
    ->text('Thanks for signing up.');

$mailer->send($email);

Configuring

MAILER_DSN=smtp://user:pass@smtp.example.com:587

Or, using an API-based transport instead — the scheme selects it:

MAILER_DSN=sendgrid+api://KEY@default

Whichever scheme you use, composer require the matching Symfony bridge package too (symfony/sendgrid-mailer, symfony/mailgun-mailer, symfony/postmark-mailer, symfony/amazon-mailer, …) — kinetis/mailer has no dispatch logic of its own here; Symfony\Component\Mailer\Transport discovers whichever bridge classes are actually installed.

Important

Only the API-based transports are non-blocking. SMTP opens a raw socket directly (stream_socket_client()), with no Fiber-yield point at all — sending over SMTP blocks the worker for as long as the send takes, the same way any other genuinely blocking call would. See Queueing mail below for the practical fix, regardless of which transport you choose.

Named connections

$transactional = MailerFactory::fromConfig($config, 'transactional');
MAILER_TRANSACTIONAL_DSN=sendgrid+api://KEY@default

Same convention as everywhere else in Kinetis (see Configuration): 'default' reads the plain MAILER_DSN above, and any other name reads MAILER_{NAME}_DSN instead.

Queueing mail

Sending mail from inside a request means the client waits for it — and if you’re on SMTP, that wait is a genuinely blocking one. The fix isn’t anything kinetis/mailer needs to provide: a queue job that constructor-injects MailerInterface already gets this for free, with no extra code in either package.

use Kinetis\Queue\Job;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

final readonly class SendWelcomeEmailJob implements Job
{
    public function __construct(
        public string $toEmail,
    ) {}

    public function handle(MailerInterface $mailer): void
    {
        $email = (new Email())
            ->from('noreply@example.com')
            ->to($this->toEmail)
            ->subject('Welcome!')
            ->text('Thanks for signing up.');

        $mailer->send($email);
    }
}
$queue->push(new SendWelcomeEmailJob($user->email));

Register MailerInterface once, in bootstrap.php, and any job’s handle() method can depend on it exactly like PingRepository or any other service:

use Kinetis\Mailer\MailerFactory;
use Symfony\Component\Mailer\MailerInterface;

return static function (AppScope $app, Config $config): void {
    $app->instance(MailerInterface::class, MailerFactory::fromConfig($config));
};

Now a slow SMTP send only occupies one queue worker’s own Fiber for one job, instead of the worker handling that HTTP request — the same reasoning behind queueing any other slow, non-critical-path work.

See also

  • Appendix: Revolt HTTP Client — the non-blocking HTTP client every API-based transport actually runs through.

  • Queue — job queues, retries, and named connections in full.

  • Configuration — the named-connection convention used above.