Most apps need to send the occasional email: a welcome note, a password reset, a receipt. Fast ships a small mailer for exactly that, reached through the mailer() helper (or the send_mail() shortcut). It has one job, done two ways. When you point it at an SMTP relay it delivers over that relay, upgrading the connection to TLS. When you do not, or when the relay is unreachable, it falls back to your operating system's sendmail binary. Either way your application code is identical; the mailer degrades on its own, with no code changes.
Because it is built on the same non-blocking sockets as the rest of Fast, sending mail inside a request handler parks that one fiber while it talks to the relay and lets the worker keep serving everyone else.
Sending a message
You compose a Message with a small fluent builder, then hand it to the mailer:
use Fast\Mail\Message;
$message = Message::create()
->to('ada@example.com', 'Ada Lovelace')
->subject('Welcome aboard')
->text('Thanks for signing up.')
->html('<p>Thanks for signing up.</p>');
send_mail($message);
send_mail() is a shortcut for mailer()->send($message). Use whichever reads better; they do the same thing.
The builder methods each return the message, so they chain in any order:
Message::create()
->from('no-reply@example.com', 'Example App') // optional if MAIL_FROM_ADDRESS is set
->to('ada@example.com')
->cc('team@example.com')
->bcc('audit@example.com') // stays out of the headers, still delivered
->replyTo('support@example.com')
->subject('Your receipt')
->text('Plain-text version.')
->html('<h1>Receipt</h1>')
->header('X-Entity-Ref', 'order-42'); // any extra header you need
Every address, name, subject, and custom header is checked for CR/LF before it goes near the wire, so a stray newline can never inject a header. Structural headers (like Subject or To) cannot be overridden through header().
Attachments
Attach a file from disk or from a string you already have in memory:
use Fast\Mail\Attachment;
Message::create()
->to('ada@example.com')
->subject('Your invoice')
->text('Invoice attached.')
->attach(Attachment::fromPath('/var/invoices/42.pdf'))
->attach(Attachment::fromString('notes.txt', 'thanks!', 'text/plain'));
fromPath() reads the file and derives the filename and MIME type for you; fromString() takes the filename, the raw bytes, and an optional MIME type. A message with both a text and an HTML body renders as multipart/alternative, and adding attachments wraps that in multipart/mixed, all built for you.
Configuration
The mailer reads its settings from the environment, so the same code sends through a relay in production and falls back to local sendmail in development without a change:
| Variable | Meaning |
|---|---|
MAIL_HOST | The SMTP relay hostname. Leave empty to use local sendmail only. |
MAIL_PORT | The relay port. Derived from the encryption mode when unset. |
MAIL_USERNAME | AUTH username. Empty disables authentication. |
MAIL_PASSWORD | AUTH password. |
MAIL_ENCRYPTION | tls (STARTTLS, the default), ssl (implicit TLS), or none. |
MAIL_FROM_ADDRESS | The default sender when a message does not call from(). |
MAIL_FROM_NAME | The default sender display name. |
MAIL_TIMEOUT | Socket timeout in seconds (default 10). |
MAIL_VERIFY_PEER | Verify the relay's certificate (default true). |
MAIL_ALLOW_FALLBACK | Fall back to sendmail when the relay is unreachable (default true). |
MAIL_SENDMAIL_PATH | An explicit sendmail path; otherwise the usual locations are searched. |
When MAIL_PORT is unset the port follows the encryption mode: 465 for ssl, 587 for tls, 25 for none. Authentication is refused over an unencrypted connection, so credentials are never sent in the clear.
If you would rather configure the mailer in code than through the environment, call use_mail() at boot with an explicit MailConfig:
use Fast\Mail\MailConfig;
use_mail(new MailConfig(
host: 'smtp.example.com',
username: 'apikey',
password: $secret,
encryption: 'tls',
fromAddress: 'no-reply@example.com',
fromName: 'Example App',
));
Whatever you pass wins; without it, mailer() builds the same service lazily from the MAIL_* values the first time you send.
How it degrades
The mailer always tries the SMTP relay first when MAIL_HOST is set. What happens next depends on why a send fails, and the distinction is deliberate:
- Transient failures — the relay is unreachable: the connection is refused, times
out, drops mid-conversation, or the TLS handshake fails. The relay never got the message, so falling back is safe. When MAIL_ALLOW_FALLBACK is on (the default) and a local sendmail is available, the mailer delivers through it and logs a warning.
- Permanent failures — the relay answered and rejected something: a bad address, a
policy 5xx, an authentication failure. The relay spoke, so retrying elsewhere would only send the same rejected mail (or a duplicate). These raise a MailException and do not fall back.
Once any recipient has been accepted by the relay, a later error is always treated as permanent, so a mid-delivery hiccup can never quietly send the whole message twice.
If MAIL_HOST is empty, there is nothing to fall back from: the mailer goes straight to sendmail.
Notes & limits
- Delivery is best-effort and synchronous within the sending fiber. For bulk or
retryable sending, dispatch a background job that calls the mailer, so a slow relay never blocks a request and failures retry with backoff.
- The
sendmailfallback hands every recipient to the binary explicitly (including
Bcc) with an envelope sender, so blind recipients are never dropped.
- SMTP over an unencrypted connection will not send credentials; if you need AUTH, use
tls or ssl.
- Peer verification is on by default. Only disable
MAIL_VERIFY_PEERfor a relay you
control and understand.