Mail

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:

VariableMeaning
MAIL_HOSTThe SMTP relay hostname. Leave empty to use local sendmail only.
MAIL_PORTThe relay port. Derived from the encryption mode when unset.
MAIL_USERNAMEAUTH username. Empty disables authentication.
MAIL_PASSWORDAUTH password.
MAIL_ENCRYPTIONtls (STARTTLS, the default), ssl (implicit TLS), or none.
MAIL_FROM_ADDRESSThe default sender when a message does not call from().
MAIL_FROM_NAMEThe default sender display name.
MAIL_TIMEOUTSocket timeout in seconds (default 10).
MAIL_VERIFY_PEERVerify the relay's certificate (default true).
MAIL_ALLOW_FALLBACKFall back to sendmail when the relay is unreachable (default true).
MAIL_SENDMAIL_PATHAn 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 sendmail fallback 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_PEER for a relay you

control and understand.