Mail

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

AdapterWhat it needs
Postmarka server token
Resendan API key
SendGridan API key
Mailtrapan API token — and an inbox id, for the sandbox
Mailgunan API key, a sending domain, and a region
AmazonSesan SesV2Client-shaped object you construct
Smtpa 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 nextFailureScope::Message — stop
Authentication rejected, credential expiredSender address malformed
A 5xx from the providerA recipient address is invalid
429, rate limited, over quotaNo recipient, or no body
Connection refused, DNS or TLS failure, timeoutAttachment over a universal size limit
Account suspended or sending pausedProvider 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 sendingsend() 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 renderingView 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 AmazonSes uses for SES.