crystoline/xpsr-mail

Email for XPSR applications, on Symfony Mailer. One DSN says where email goes; bodies are views; and with crystoline/xpsr-queue, email can be sent from a worker instead of during the request.

composer require crystoline/xpsr-mail

Add the provider to app.registry in config/app.php:

Crystoline\Xpsr\Mail\MailServiceRegistry::class,

and create config/mail.php:

return [
    'dsn' => env('MAIL_DSN', 'log://default'),
    'from' => ['address' => env('MAIL_FROM', 'hello@example.com'), 'name' => env('APP_NAME', 'XPSR')],
    // 'queue' => 'mail',   // the transport Mailer::queue() uses; without it, queue.default
];

Check the settings with php xpsr mail:test you@example.com.

Sending

use Crystoline\Xpsr\Mail\Mailer;
use Symfony\Component\Mime\Email;

$mailer->send(
    (new Email())
        ->to('ada@example.com')
        ->subject('Your invoice')
        ->text('Your invoice is attached.')
        ->attach($pdf, 'invoice.pdf', 'application/pdf'),
);

Messages are Symfony Mime Email objects: to(), cc(), bcc(), replyTo(), priority(), attach(), embed(), and the rest. A message without a From gets mail.from.

Views

compose() renders a view as the HTML body, with the view layer's usual escaping:

$email = $mailer->compose('emails.welcome', ['user' => $user])   // views/emails/welcome.twig
    ->to($user->email)
    ->subject('Welcome');
$mailer->send($email);

The text body is the HTML as readable text: tags removed, <style> and <script> dropped, and links written as text (url). For a text body of your own, name its view too: compose('emails.welcome', $data, 'emails.welcome_text').

Queued email

With crystoline/xpsr-queue installed and its provider in app.registry, queue() sends from a worker, so a slow mail server does not slow the request:

$mailer->queue($email);
$mailer->queue($email, delay: '10 minutes');
$mailer->queue($email, queue: 'bulk');      // a transport from config/queue.php

The email is complete when it is queued: views are rendered first, and the worker only sends it. Attach content (attach($bytes, ...)) rather than file paths for queued email, because the worker may run where the file is not.

Transports

MAIL_DSN Email goes Needs
log://default (the default) to the application log, not sent —
array://default into memory, for tests —
smtp://user:pass@smtp.example.com:587 to an SMTP server; smtps:// for TLS from the start —
sendmail://default to the local sendmail binary —
null://null nowhere —
failover(smtp://a smtp://b), roundrobin(...) through several transports —
ses+smtp://..., mailgun+api://..., postmark+api://..., and others to a provider its Symfony bridge, such as symfony/mailgun-mailer

Characters such as @ or / in a username or password must be URL-encoded. The Symfony Mailer documentation lists every provider and its DSN.

Testing

Set MAIL_DSN=array://default in phpunit.xml, then read what was sent:

use Crystoline\Xpsr\Mail\Transport\ArrayTransport;

$this->postJson('/users', ['email' => 'ada@example.com']);

$sent = $this->application()->get(ArrayTransport::class)->emails();
self::assertCount(1, $sent);
self::assertSame('Welcome', $sent[0]->getSubject());
self::assertSame('ada@example.com', $sent[0]->getTo()[0]->getAddress());

messages() also gives each message's envelope, and clear() empties it. Queued email reaches it when the queue runs: with QUEUE_DSN=sync://, at once.

Services

Service Provides
Crystoline\Xpsr\Mail\Mailer compose(), send(), and queue(). Shared.
Symfony\Component\Mailer\MailerInterface Symfony's mailer, which sends at once.
Symfony\Component\Mailer\Transport\TransportInterface The transport mail.dsn names.
Crystoline\Xpsr\Mail\Transport\ArrayTransport The email sent through array://.

Requires PHP 8.4+.