Requires Clarity 1.6.0 or newer. The skeleton pins
monad/clarity: ^1.0, which also resolves to releases without this service —
run composer update monad/clarity if Services\Mail is missing.
Mail is an abstract contract every mailer
(Monad\Clarity\Services\MailAdapters\*) implements: one send()
method taking a provider-agnostic Message and returning a
SentMessage. Switching provider is a change to one constructor call and
nothing else.
use Monad\Clarity\Services\HttpClient;
use Monad\Clarity\Services\Mail\{Address, Message};
use Monad\Clarity\Services\MailAdapters\Postmark;
$mail = new Postmark($serverToken, new HttpClient());
$sent = $mail->send(new Message(
from: new Address('hello@example.com', 'Example'),
to: [new Address($user->email)],
subject: 'Reset your password',
text: "Reset your password: {$url}",
));
$sent->mailer; // 'postmark'
$sent->providerMessageId; // the provider's own id, where it returns one
The seven mailers
| Adapter | What it needs |
|---|---|
Postmark | a server token |
Resend | an API key |
SendGrid | an API key |
Mailtrap | an API token — and an inbox id, for the sandbox |
Mailgun | an API key, a sending domain, and a region |
AmazonSes | an SesV2Client-shaped object you construct |
Smtp | a host, port, credentials and an encryption mode |
Every adapter takes what it actually needs
Unlike LLM and Checkout,
whose facades fix (string $apiKey, HttpClient $httpClient) for every
implementation, Mail declares no constructor at all. It could
not honestly declare one: Smtp has no API key and no HttpClient —
it speaks to a socket — and AmazonSes has no credential of its own, only the
client you hand it. A shared two-argument base would have meant an SMTP adapter keeping
its password in a property named $apiKey.
use Monad\Clarity\Services\MailAdapters\{AmazonSes, Mailgun, Mailtrap, Smtp};
use Monad\Clarity\Services\Mail\SmtpEncryption;
new Mailgun($apiKey, 'mg.example.com', $http, Mailgun::REGION_EU);
Mailtrap::sending($apiToken, $http); // real recipients
Mailtrap::sandbox($apiToken, $inboxId, $http); // nothing leaves the sandbox
new AmazonSes($sesV2Client); // any object with sendEmail(array $args)
new Smtp(
host: 'smtp.example.com',
port: 587,
username: $user,
password: $password,
encryption: SmtpEncryption::StartTls, // the default
);
Mailtrap's sandbox is a named constructor rather than a flag, because a
sandbox: true left false in a staging config sends real mail to real people
and looks like nothing at all in review.
No aws/aws-sdk-php dependency is added.
AmazonSes accepts any object exposing sendEmail(array $args) — the
real Aws\SesV2Client method shape — so the genuine SDK needs no translation and
a test needs only a plain fake. It is the same arrangement
Files uses for S3.
Building a message
new Message(
Address $from,
array $to, // list<Address>, at least one
string $subject,
?string $text = null, // text, html, or both — at least one
?string $html = null,
array $cc = [],
array $bcc = [],
?Address $replyTo = null,
array $headers = [], // extra headers; structural ones are refused
array $attachments = [], // list<Attachment>
array $tags = [], // list<string>, for provider-side reporting
)
Everything is validated at construction, so a malformed recipient fails in the code that
built it rather than as a provider's 422 several layers away.
One Message is one email: three to addresses
means three recipients who can see each other, not three separate sends. Build three
messages for three private emails — which is also the only way to give each its own body.
Render HTML through View and pass the result. Mail takes strings and has no opinion about where your templates live:
use Monad\Clarity\Services\Mail\{Address, Attachment, Message};
use Monad\Clarity\Services\View;
$message = new Message(
from: new Address('billing@example.com', 'Example Billing'),
to: [new Address($user->email, $user->name)],
subject: 'Your receipt',
text: View::render('Emails/Receipt.txt', ['order' => $order]),
html: View::render('Emails/Receipt', ['order' => $order]),
attachments: [
new Attachment('receipt.pdf', 'application/pdf', $pdfBytes),
Attachment::inline('logo.png', 'image/png', $logoBytes, 'logo'), // <img src="cid:logo">
],
);
Attachments hold bytes, never a path. An adapter that read a path at send time would put a filesystem in the middle of every provider integration and turn a missing file into a failure a pool cannot classify.
Several mailers, in priority order
A mail outage is not like other outages. It silently breaks password reset, email verification and receipts — the paths a user cannot route around, and the ones nobody notices are down until support asks why signups stopped. So Mail offers what LLM deliberately does not: automatic failover across providers. Two language models given one prompt return different answers, and failing over changes what your product said. Two mailers are interchangeable — a delivered email is a delivered email.
use Monad\Clarity\Services\Mail\MailerPool;
$mail = new MailerPool([$postmark, $resend, $smtp]);
$sent = $mail->send($message);
$sent->mailer; // 'resend' — who actually took it
$sent->failedOver(); // true — worth alerting on
$sent->attempts; // every mailer tried, ending with the one that succeeded
There is no "enable multi-mailer" setting. Whether failover is on is
simply which object your config/mail.php returns — a single adapter, or a
pool. Both have the type Services\Mail, so nothing downstream changes, and
switching is a one-line edit. Priority is array order: the list reads top
to bottom in the order it will be tried.
A pool is a mailer, so a pool can hold another pool if you want tiers. It does not refuse two members with the same name — a primary and a standby account at one provider is a legitimate pool, and exactly what you configure when a sending domain is rate-limited.
Failover keys on whose fault it is, not on the status code
The obvious rule — retry on 5xx, give up on 4xx — is wrong in both directions, and this is the one thing worth reading twice.
A 401 fails over. Bad or expired credentials on one provider
are precisely when the next one should take the message, because the whole point
of a standby is that it holds a different credential. A pool that gave up there would fail
exactly when it was configured to help.
A malformed recipient does not. That message is invalid at all seven providers, so failing it over buys seven round trips, seven timeouts' worth of latency, and a final error naming the last mailer tried rather than the real fault.
So every failure carries a FailureScope, and the pool reads nothing else:
FailureScope::Mailer — try the next | FailureScope::Message — stop |
|---|---|
| Authentication rejected, credential expired | Sender address malformed |
A 5xx from the provider | A recipient address is invalid |
429, rate limited, over quota | No recipient, or no body |
| Connection refused, DNS or TLS failure, timeout | Attachment over a universal size limit |
| Account suspended or sending paused | Provider rejects the payload as malformed |
Anything unrecognised is treated as the mailer's fault. Guessing that way wrongly costs one wasted round trip; guessing the other way wrongly costs a message that is never sent.
A pool can send twice
The guarantee is at-least-once, not exactly-once. If a provider accepts a message and the connection then times out before its acknowledgement is read, the pool cannot tell "never sent" from "sent, acknowledgement lost". It moves on, and the recipient may receive the message twice. No cross-provider idempotency key exists — each provider mints its own id and none will honour another's.
So: send an invoice, or a one-time code that invalidates the previous one, through a single adapter and handle the failure yourself. A pool is for messages where a duplicate is a mild annoyance and a silent non-delivery is a real failure — which is most transactional mail, and all of the mail that matters at 3am.
Watch the trail, or failover is invisible
Clarity keeps no delivery table. SentMessage is the only record that failover
happened, so a pool quietly falling through to its third mailer for a week is an outage
that looks like nothing at all until the third one fails too.
if ($sent->failedOver()) {
foreach ($sent->failures() as $attempt) {
$logger->warning('mail failover', [
'passed_over' => $attempt->mailer,
'reason' => $attempt->reason(),
'delivered_by'=> $sent->mailer,
]);
}
}
attempts always ends with the mailer that succeeded, so
count($sent->attempts) is the number of mailers tried and a single-adapter
send returns exactly one.
Bcc never reaches the message
Blind recipients travel in the SMTP envelope and nowhere else. Clarity
emits no Bcc: header, on any path, ever — a Bcc header in a
transmitted message discloses every blind recipient to every other recipient, and it is the
one failure of this service that is both silent and unrecoverable: by the time anyone
notices, the disclosure has happened.
The same rule closes the front door. Bcc — and every other structural header —
is refused as an application-supplied extra:
new Message(
// ...
headers: ['Bcc' => 'someone@example.com'], // InvalidArgumentException
);
Header injection is refused, not sanitised
Headers are separated by CRLF, so one unescaped newline in a display name or a subject
turns one header into two — and the second belongs entirely to whoever supplied it: a
Bcc of their choosing, a rewritten Reply-To, a forged
From. Since a subject routinely carries a username and a display name is
routinely user-supplied, every header-bound value is checked at construction:
new Address('someone@example.com', "Someone\r\nBcc: attacker@example.net"); // InvalidArgumentException
Refused rather than stripped, deliberately. Silently removing the newline would send something you did not write.
SMTP
Smtp speaks the protocol directly — one connection per message, commands and
responses in lockstep.
Check your egress before you trust it. This is the first Clarity component needing a port other than 443, and on a host with a restrictive egress policy the traffic is simply dropped. The symptom is a connect timeout classified as a mailer fault, which inside a pool reads as a provider outage rather than a firewall.
STARTTLS is required by default and never silently skipped: a relay that does
not advertise it raises, because a stripped advertisement is indistinguishable from an
interception. The opt-out is named at the call site, so it cannot be typed by accident:
use Monad\Clarity\Services\Mail\SmtpEncryption;
SmtpEncryption::StartTls // default — port 587, upgraded after connecting
SmtpEncryption::ImplicitTls // port 465, TLS from the first byte
SmtpEncryption::None // a local relay such as Mailpit, and nothing else
AUTH PLAIN and AUTH LOGIN only, over TLS. CRAM-MD5 is
not implemented: it exists to protect a password on an unencrypted link, which is a worse
answer to a problem TLS has already solved.
If a relay refuses any recipient, the whole message is abandoned and
DATA is never sent. Delivering to the addresses that were accepted looks
kinder and is worse — a pool failing the message over would then send it to them twice.
Configuring it
config/mail.php returns the mailer your application uses. One adapter, or a
pool — the file is the only place the choice appears:
<?php
use Monad\Clarity\Services\HttpClient;
use Monad\Clarity\Services\Mail\MailerPool;
use Monad\Clarity\Services\MailAdapters\{Postmark, Resend};
$http = new HttpClient();
return new MailerPool([
new Postmark($_ENV['POSTMARK_TOKEN'], $http),
new Resend($_ENV['RESEND_KEY'], $http),
]);
Send one message through each member directly before trusting a pool. A pool exists to reach its later members when the earlier ones fail, so a standby whose credentials and egress have never been exercised is a standby you have no evidence about.
Each adapter takes its own $timeoutSeconds, defaulting to 30, and a pool tries
its members in series — so five mailers that all time out is a 150-second worst case on a
request path. Lower each one's timeout rather than accept the sum.
What's deliberately not here
No queue or asynchronous sending — send() blocks until the
provider answers; sending off the request path is scheduling work, and
Scheduler already does that.
No retry within a single mailer: failover ships first, and a backoff would
widen the double-send window and put a sleep on the request path.
No inbound mail, bounce handling or webhook parsing — every provider signs
and shapes its events differently, and none of it is needed to send.
No delivery table: SentMessage describes the send fully, and
what you record about your own mail is your own concern.
No template rendering — View renders,
Mail takes strings. And no mitosis command: Mail owns no
state to install.
Next steps
- View — rendering the HTML body you pass to Mail.
- Session — the password-reset and verification tokens Mail exists to deliver.
- HttpClient — the client the six API mailers send through.
- Files — the same injected-client arrangement
AmazonSesuses for SES.