Sending email

Building messages, sending over SMTP, and the header-injection and TLS rules that are not optional.

PHP
<?php
use PhpOrbit\Mail\Mailer;
use PhpOrbit\Mail\Message;

final class WelcomeController implements Handler
{
    public function __construct(private readonly Mailer $mailer)
    {
    }

    public function handle(ServerRequest $request): Response
    {
        $this->mailer->send(
            Message::to('ada@example.test')
                ->subject('Welcome')
                ->text('Thanks for signing up.'),
        );

        return Response::redirect('/');
    }
}

That is the whole common case. The sender comes from configuration, so a message rarely needs one.

Configuration

.env
# array | smtp
# "array" keeps messages in memory instead of sending them.
MAIL_DRIVER=array

MAIL_HOST=smtp.example.test
# tls (STARTTLS, port 587) | ssl (implicit TLS, port 465) | none
MAIL_ENCRYPTION=tls
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=no-reply@example.test
MAIL_FROM_NAME=phporbit
MAIL_TIMEOUT=10
The default sends nothing

array collects messages in memory. A development machine that quietly starts delivering real mail to real people is a worse failure than one that sends none, so choosing smtp is something you write down.

Building a message

PHP
<?php
Message::to('Ada Lovelace <ada@example.test>')
    ->addCc('team@example.test')
    ->addBcc('audit@example.test')
    ->replyTo('support@example.test')
    ->subject('Your invoice')
    ->html('<p>Attached.</p>', 'Attached.')   // HTML with a plain-text alternative
    ->attach(Attachment::fromPath('/srv/app/storage/invoice.pdf'))
    ->header('X-Campaign', 'invoices');

Every call returns a copy, so a half-built message is safe to keep as a template and finish differently per recipient.

MessageStructure sent
text() onlyA single text/plain part
html() onlyA single text/html part
Bothmultipart/alternative
With attachmentsmultipart/mixed, nesting the above

Pass a plain-text alternative to html() where you can: some clients refuse HTML, and a message offering only an HTML part scores worse with spam filters. Date and Message-ID headers are added for you, for the same reason.

Two rules that are not configurable

Header injection is refused, not stripped

PHP
<?php
new Address('ada@example.test', "Ada\r\nBcc: victim@example.test");
// InvalidArgumentException: A display name may not contain CR, LF or NUL —
//   that is how header injection works.

Message::to('ada@example.test')->subject("Hi\r\nBcc: victim@example.test");
// InvalidArgumentException: Header values may not contain CR, LF or NUL.

Everything after a newline in a header becomes further headers of the sender's choosing — extra recipients, a forged From, a second body. Subjects and display names are frequently user-supplied, which is what makes this the defect to design against rather than document.

The same rule holds inside the transport: a line consisting of a single . ends the message, so a leading dot on any body line is doubled before it goes out.

Credentials require an encrypted connection

PHP
<?php
new SmtpSettings('mail.example.test', username: 'ada', password: 'hunter2', encryption: SmtpEncryption::None);
// InvalidArgumentException: Refusing to send credentials over an unencrypted
//   connection. SMTP AUTH base64-encodes the password, which is not encryption
//   — anyone on the path can read it.

Set MAIL_ALLOW_INSECURE_AUTH=true if the server really is on localhost. TLS certificate verification, separately, has no setting at all: an unverified connection cannot detect the interceptor it exists to stop, and a mail server's certificate is exactly what one would forge to collect the credentials sent moments later.

Testing

ArrayMailer is the mailer in tests. It validates messages exactly as the real one does, so a test still catches a missing sender or a malformed address.

PHP
<?php
$mailer = new ArrayMailer();

$application = Application::boot(static function (Blueprint $app) use ($mailer): void {
    $app->container->singleton(Mailer::class, static fn (): Mailer => $mailer);
    // ...
});

$application->handle(Requests::post('/register', 'email=ada@example.test'));

self::assertSame(1, $mailer->count());
self::assertTrue($mailer->sentTo('ada@example.test'));
self::assertStringContainsString('Welcome', (string) $mailer->last()?->subjectLine);
ArrayMailer is stateful — the one exception

Register it as scoped() rather than singleton() if the application reads back what it collected. As a singleton under a worker it accumulates every message the process has ever sent, and shows one request's mail to the next.

How the connection is managed

SmtpMailer opens and closes its connection inside send(). Keeping one open across requests would be faster, and would also mean a worker carrying a stateful socket — possibly mid-transaction after a failure — into the next user's request. Reconnecting costs a round trip and removes a class of bug; that is the trade this framework makes everywhere.

Reads are bounded by MAIL_TIMEOUT. A server that accepts a connection and then says nothing would otherwise hold the process for as long as it liked.

Every send is persisted

Both scaffolds wrap the driver-selected mailer in PersistingMailer, so Mailer::class in the container is never the bare driver. Every send() writes one row to mail_log — the full message, and the outcome — after the attempt resolves, then behaves exactly as before: it still throws MailFailed on the same failures, so calling code that already catches it needs no changes.

PHP
<?php
// app/bootstrap.php
$mailer = new PersistingMailer(MailerFactory::fromEnvironment($env), new MailLogRepository($database));

$app->container->singleton(Mailer::class, static fn (): Mailer => $mailer);
// Bound under its concrete type too: orbit mail:resend needs resend(), which
// the Mailer interface does not declare.
$app->container->singleton(PersistingMailer::class, static fn (): PersistingMailer => $mailer);
ColumnHolds
to_addresses, cc_addresses, bcc_addressesJSON arrays of header-value strings — the same form Address::parse() reads back
attachmentsJSON: filename, media type and base64-encoded contents — a resend attaches the identical bytes
statussent or failed — there is no "pending": sending is synchronous, so the row is written after the attempt resolves
errorThe server's reply, on failure — the same string a caller would see in MailFailed::getMessage()
attemptsStarts at 1; a resend increments it in place rather than inserting a new row

A validation failure — no recipients, no body, the things assertSendable() catches — is the caller's bug, not a delivery failure, and is deliberately not logged. Only what the Mailer interface's @throws MailFailed actually promises gets recorded.

Resending

orbit mail:list and orbit mail:resend — see The orbit CLI for every flag.

Shell
$ ./orbit mail:list --status=failed
4     failed  1    bob@example.test                   Reminder                                 2026-01-01T09:14:02+00:00

$ ./orbit mail:resend 4
Resent #4 to bob@example.test.

$ ./orbit mail:resend --failed
2 resent, 0 still failing.

Resending is refused for anything that is not currently failed — resending a message already marked sent would deliver it twice with no record that it happened, which defeats the reason the row exists. A resend that fails again updates the same row: the status and error move to the latest attempt, and attempts grows.

Still not a queue

Every resend is still a synchronous send() — a slow server slows the orbit mail:resend process the same way it would slow a request. What changed is that a failure is no longer lost: it is a row you can look at and a command you can run, not a retry loop the framework runs on its own.

Not built

  • Automatic retries. A failed send stays failed until orbit mail:resend is run by hand or from a cron entry you write — the framework does not retry on its own.
  • DKIM signing. Sign at the relay — most providers do it for you, and doing it here would mean managing private keys in the application.
  • Inline images (cid: references) and multipart/related.
  • Bounce handling. A MailFailed means the server refused the message; a delivery failure after that arrives by email or webhook, out of band.