Features

Mail

Tempest provides a convenient layer built on top of Symfony's excellent mailer component so that you can send emails with ease.

Getting started

Sending emails starts with picking an email transport. Tempest comes with built-in support for SMTP, Amazon SES, and Postmark; but it's trivial to add any other transport you'd like. We'll start with plain SMTP, and explain how to switch to other transports later.

By default, Tempest is configured to use SMTP mailing. You'll need to add these environment variables and the mailer will be ready for use:

MAIL_SMTP_HOST=mail.my_provider.com
MAIL_SMTP_PORT=587
MAIL_SMTP_USERNAME=my_username@my_provider.com
MAIL_SMTP_PASSWORD=my_password_123
MAIL_SENDER_NAME=Brent
MAIL_SENDER_EMAIL=brendt@stitcher.io

Sending an email is done via the Mailer, you can inject it anywhere you'd like:

use Tempest\Mail\Mailer;
use Tempest\Mail\GenericEmail;
 
final class UserEventHandlers
{
    public function __construct(
        private readonly Mailer $mailer,
    ) {}

    #[EventHandler]
    public function onCreated(UserCreated $userCreated): void
    {
        $this->mailer->send(new GenericEmail(
            subject: 'Welcome!',
            to: $userCreated->email,
            html: view(
                __DIR__ . '/mails/welcome.view.php', 
                user: $userCreated->user,
            ),
        ));

        $this->success('Done');
    }
}

Note that GenericEmail is a default email implementation that can be used on the fly, but a more scalable approach would be to make individual classes for every email:

use Tempest\Mail\Mailer;
use Tempest\Mail\GenericEmail;
 
final class UserEventHandlers
{
    public function __construct(
        private readonly Mailer $mailer,
    ) {}

    #[EventHandler]
    public function onCreated(UserCreated $userCreated): void
    {
        $this->mailer->send(new WelcomeEmail($userCreated->user));

        $this->success('Done');
    }
}

Here's what that WelcomeEmail would look like:

use Tempest\Mail\Email;
use Tempest\Mail\Envelope;
use Tempest\View\View;
use function Tempest\view;

final class WelcomeEmail implements Email
{
    public function __construct(
        private readonly User $user,
    ) {}

    public Envelope $envelope {
        get => new Envelope(
            subject: 'Welcome',
            to: $this->user->email,
        );
    }

    public string|View $html {
        get => view('welcome.view.php', user: $this->user);
    }
}

Note how Envelope contains all meta information about an email. Here you can specify the subject and receiver, but also headers, bcc, cc, and more.

Email content

In the previous examples, we assumed there to be a view attached to an email. Views are flexible since they can contain variable data like the user object, for example. In simple cases though, you might only want to send HTML without it being a view. In that case, you can pass in the HTML like so:

use Tempest\Mail\Email;

final class WelcomeEmail implements Email
{
    // …
    
    public string|View $html {
        get => <<<HTML
        <h1>Thanks for joining!</h1>
        HTML;
    }
}

Whenever an email is sent, Tempest will automatically provide a text-only version of that email as well, which will be used by text-only email clients. The text is generated based on your HTML template (by stripping all the HTML tags). However, you also have the option to manually specify the text-only contents of an email, by implementing HasTextContent:

use Tempest\Mail\Email;
use Tempest\View\View;
use Tempest\Mail\HasTextContent;

final class WelcomeEmail implements Email, HasTextContent
{
    // …
    
    public string|View|null $text = <<<TXT
    This is the text-only version of this email.
    TXT;
}

Note that you can also use a view to render your text-only content. This is especially useful when you have lots of dynamic parts in your text content. Keep in mind that these kinds of views should not contain any HTML:

use Tempest\Mail\Email;
use Tempest\View\View;
use Tempest\Mail\HasTextContent;

final class WelcomeEmail implements Email, HasTextContent
{
    // …
    
    public string|View|null $text = view('welcome-text.view.php', user: $this->user);
}
welcome-text.view.php
Hello {{ $user->name }}

Please visit this link to activate your account: {{ $user->activationLink }}.

See you soon!

Tempest

Attachments

If you want your email to have attachments, you can implement the HasAttachments interface:

use Tempest\Mail\Attachment;
use Tempest\Mail\Email;
use Tempest\Mail\HasAttachments;

final class WelcomeEmail implements Email, HasAttachments
{
    // …

    public array $attachments {
        get => [
            Attachment::fromFilesystem(__DIR__ . '/welcome.pdf')
        ];
    }
}

Creating attachments can be done in multiple ways:

  • By referencing a file directly on the filesystem (as shown in the previous example);
  • By using a storage drive: Attachment::fromStorage($s3Storage, '/welcome.pdf');
  • Or by manually passing a closure to a new attachment instance:
use Tempest\Mail\Attachment;

$attachment = new Attachment(function () {
    return Pdf::createFromTemplate('user-pdf.pdf', user: $this->user);
});

Other transports

As mentioned, Tempest has built-in support for SMTP, Amazon SES, and Postmark. It is however trivial to use a range of other transports as well. First let's talk about switching to one of the built-in transports.

The first step in using any transport is to install the transport-specific driver. You can find a list of all supported transports on Symfony's documentation. If we take Postmark as an example, you should install these two dependencies:

composer require symfony/postmark-mailer
composer require symfony/http-client

Next, create a new mail config file and return an instance of PostmarkConfig:

app/mail.config.php
use Tempest\Mail\Transports\Postmark\PostmarkConfig;
use function Tempest\env;

return new PostmarkConfig(
    key: env('MAIL_POSTMARK_TOKEN'),
);

Note that the Postmark token is the token associated with your Postmark account. A good practice is to also provide a default sender:

app/mail.config.php
use Tempest\Mail\EmailAddress;
use Tempest\Mail\Transports\Postmark\PostmarkConfig;
use function Tempest\env;

$defaultSender = null;

if (env('MAIL_SENDER_NAME') && env('MAIL_SENDER_EMAIL')) {
    $defaultSender = new EmailAddress(
        email: env('MAIL_SENDER_EMAIL'),
        name: env('MAIL_SENDER_NAME'),
    );
}

return new PostmarkConfig(
    key: env('MAIL_POSTMARK_TOKEN'),
    defaultSender: $defaultSender,
);

Finally, make sure that all environment variables are correctly set, and you're done! Tempest's mailer will now route your emails via Postmark.

Creating your own transports

While SMTP, Amazon SES, and Postmark are built in, there are a lot of other transports available as well. In order to use one of those, you must create a new config class, specifically for that transport. Here's an example of using Mailgun. First you require the Symfony driver:

composer require symfony/mailgun-mailer

Then you create a new config class, specifically for that transport:

final class MailgunConfig implements MailerConfig, ProvidesDefaultSender
{
    public string $transport = MailgunApiTransport::class;

    public function __construct(
        public readonly EmailAddress $defaultSender,
        #[SensitiveParameter]
        private readonly string $key,
        #[SensitiveParameter]
        private readonly string $domain,
    ) {}

    public function createTransport(): TransportInterface
    {
        return new MailgunTransportFactory()
            ->create(Dsn::fromString("mailgun+api://{$this->key}:{$this->domain}@default"));
    }
}

And finally, use it like so:

app/mail.config.php
return new MailgunConfig(
    defaultSender: $defaultSender,
    key: env('MAIL_MAILGUN_KEY'),
    domain: env('MAIL_MAILGUN_DOMAIN'),
);

Testing

Any test class extending from IntegrationTest will have the MailTester available:

public function test_welcome_mail()
{
    $this->mailer
        ->send(new WelcomeEmail($this->user))
        ->assertSentTo($this->user->email)
        ->assertAttached('welcome.pdf');
}

Note that mails sent within tests using the MailTester will never be actually sent. Read more about testing here.