Framework
Subsystems

Emails

Compose and send email through pluggable providers — with a queue for bulk delivery, localized content, a shared HTML template and a white list for safe testing.

Sending

Email::send() sends one message and returns an EmailResult. By default it wraps the message in the HTML template, honours the white list, and does nothing when email is disabled:

use Framework\Email\Email;
use Framework\Email\EmailResult;

$result = Email::send(
    toEmail: "user@example.com",
    subject: "Welcome",
    message: "Thanks for joining!",
);

if ($result === EmailResult::Sent) {
    // delivered
}

A few flags adjust that behaviour:

ArgumentEffect
sendAlwaysBypass the white-list filter for this send.
sendTestSend even when email is inactive — for a config test.
withoutTemplateSend the raw message without the HTML wrapper.

Send results

Rather than throw, a send returns an EmailResult — Sent on success, or the reason it did not go out, so callers can branch on it:

ResultMeaning
SentHanded off to the provider.
InactiveSendEmail is turned off (EMAIL_ACTIVE).
WhiteListFilterThe recipient is not on the white list.
InvalidEmailThe address is malformed.
NoEmailsNo recipients were given.
NoProviderNo EMAIL_PROVIDER is set, so there is nothing to send through.
ProviderErrorThe provider rejected the message.

Configuration

Email is configured entirely through environment values, which the build turns into typed Config getters. A few shared keys govern every send, whichever provider you use:

KeyControls
EMAIL_ACTIVEThe master switch — with it off, send() returns InactiveSend and nothing leaves.
EMAIL_PROVIDERWhich transport to send through (see below).
EMAIL_NAME / EMAIL_EMAILThe default "from" name and address on every message.
EMAIL_REPLY_TOThe default reply-to address.
EMAIL_USE_WHITE_LISTRestrict delivery to the white list.

Because these live in .env, each environment can use a different setup with no code changes — a real provider in production, and email switched off (or the white list on) locally:

.env
EMAIL_ACTIVE   = true
EMAIL_PROVIDER = "SMTP"
EMAIL_NAME     = "My App"
EMAIL_EMAIL    = "hi@myapp.com"
EMAIL_REPLY_TO = "support@myapp.com"

Providers

EMAIL_PROVIDER names an EmailProvider — one of the transports below. When you call Email::send(), it builds the message from the shared "from" identity and hands it to the matching transport: the API providers post to their HTTP API, while SMTP sends over a plain mail server. If the transport rejects the message, the send returns ProviderError.

There is no transport to fall back on. A send with EMAIL_PROVIDER unset returns NoProvider and nothing is built, so a setup that was never finished says so rather than reaching for a mail server nobody named.

Each provider reads its own credentials, so switching is just a matter of changing EMAIL_PROVIDER and filling in that provider's keys.

SMTP

Delivers over any SMTP server using PHPMailer, which is an optional dependency you add to your app:

composer require phpmailer/phpmailer

Point it at your server and credentials. SMTP_SECURE is the encryption — ssl or tls — and SMTP_DEBUG turns on verbose protocol logging to diagnose a failing send (the connection always uses SMTP auth and UTF-8):

.env
EMAIL_PROVIDER = "SMTP"
SMTP_HOST      = "smtp.example.com"
SMTP_PORT      = 587
SMTP_SECURE    = "tls"
SMTP_USERNAME  = "postmaster@myapp.com"
SMTP_PASSWORD  = "…"
SMTP_DEBUG     = false

Mandrill

Delivers through Mailchimp Transactional (Mandrill)'s HTTP API with a single key:

.env
EMAIL_PROVIDER = "Mandrill"
MANDRILL_KEY   = "…"

Mailjet

Delivers through the Mailjet API, which needs both an API key and secret. A contact MAILJET_LIST id is optional, for subscriber sync:

.env
EMAIL_PROVIDER = "Mailjet"
MAILJET_KEY    = "…"
MAILJET_SECRET = "…"

Mailgun

Delivers through the Mailgun API with a single key:

.env
EMAIL_PROVIDER = "Mailgun"
MAILGUN_KEY    = "…"

SendGrid

Delivers through the SendGrid API with a single key:

.env
EMAIL_PROVIDER = "SendGrid"
SEND_GRID_KEY  = "…"

Writing your own

The enum is not a list you edit. A transport is a class implementing EmailSender, and the build finds every one of them — in the framework and in your app — and writes the EmailProvider enum from their names. The class name is the case, so a Postmark class is EMAIL_PROVIDER = "Postmark".

Its keys are yours to name. Add them to the .env the way every other setting is added, and the build turns each one into a typed Config getter — an UPPER_SNAKE key becomes get plus its name in camel case:

.env
EMAIL_PROVIDER = "Postmark"
POSTMARK_KEY   = "…"
POSTMARK_URL   = "https://api.postmarkapp.com/email"

The sender then reads them back with Config::getPostmarkKey() and Config::getPostmarkUrl(), and answers whether the message was taken:

src/Provider/Postmark.php
namespace MyApp\Provider;

use Framework\Email\EmailSender;
use Framework\Provider\Curl;
use Framework\Provider\Type\CurlMethod;
use Framework\System\Config;

class Postmark implements EmailSender {

    public static function sendEmail(
        string $toEmail,
        string $fromEmail,
        string $fromName,
        string $replyTo,
        string $subject,
        string $body,
    ): bool {
        $response = Curl::execute(CurlMethod::POST, Config::getPostmarkUrl(), [
            "From"     => $fromEmail,
            "To"       => $toEmail,
            "ReplyTo"  => $replyTo,
            "Subject"  => $subject,
            "HtmlBody" => $body,
        ], [
            "X-Postmark-Server-Token" => Config::getPostmarkKey(),
        ], jsonBody: true);

        return is_array($response) && isset($response["MessageID"]);
    }
}

When the class is not what you want the config to say, name it with a Name constant and that is the case instead:

class AmazonSes implements EmailSender {

    public const Name = "SES";   // EMAIL_PROVIDER = "SES"
}

Run ./framework build and the provider is there. Nothing else changes: the white list, the template and the queue all sit in front of the sender.

Sending in a test

A test wants the send to run the whole way — white list, template, the lot — and stop before anything leaves. Write a sender that keeps what it is handed, and give it to Email::setSender(). It is handed over rather than named in the config, since the build only writes the providers it finds in the source:

use Framework\Email\Email;

Email::setSender(TestEmailSender::class);
Email::send("someone@myapp.com", "Hello", "A message");

Email::setSender();   // back to the one of the config

It differs from EMAIL_ACTIVE = false, which stops the send before anything is built and answers InactiveSend.

The queue

For bulk or deferred delivery, push messages onto the EmailQueue instead of sending inline, so a slow provider never blocks a request:

use Framework\Email\EmailQueue;

EmailQueue::add($content, "user@example.com");   // enqueue a message
EmailQueue::sendAll();                           // send everything pending
EmailQueue::deleteOld();                         // prune rows past EMAIL_DELETE_DAYS

Run sendAll() and deleteOld() on a schedule from your server's cron. First expose each as a console command:

use Framework\Discovery\Attr\ConsoleCommand;
use Framework\Email\EmailQueue;

class EmailCommands {
    #[ConsoleCommand("sendEmails")]
    public static function send(): void {
        EmailQueue::sendAll();
    }

    #[ConsoleCommand("cleanEmails")]
    public static function clean(): void {
        EmailQueue::deleteOld();
    }
}

Then add the crontab entries — one to flush the queue often, one to prune daily:

# crontab
* * * * *   cd /srv/app && ./framework sendEmails
0 3 * * *   cd /srv/app && ./framework cleanEmails

White list

The white list is a safety net for non-production environments. Turn it on with EMAIL_USE_WHITE_LIST, and send() delivers only to addresses you have added to the EmailWhiteList — every other recipient is skipped with a WhiteListFilter result. Staging can then run the real send path with no risk of emailing real customers.

Manage the list with EmailWhiteList::add(), edit() and remove(), and pass sendAlways: true on a message that must always go out — a password reset, say — to skip the filter for that one send.

Localized content

When the same email goes out again and again — a welcome, a receipt, a password reset — its copy belongs in the translation files, not in code. Each message lives per language under nls/emails, keyed by a code, with a subject and a message written as an array of paragraphs. [site] expands to your app name:

nls/emails/en.json
{
    "Welcome": {
        "subject": "Welcome to [site]",
        "message": [
            "Hi {{name}},",
            "Thanks for joining [site] — we are glad to have you.",
            "The [site] team"
        ]
    }
}

Add an es.json with the same keys, in the same order, for the Spanish copy — nlsCheck is what says whether they still match. At build time the EmailBuilder turns each key into a typed EmailCode — Welcome becomes EmailCode::Welcome — and a migration loads the copy into the database so it can be edited without a deploy. To send one, fetch its content in the recipient's language and hand it to sendContent():

use Framework\Email\Email;
use Framework\Email\EmailContent;
use Framework\System\EmailCode;

$content = EmailContent::get(EmailCode::Welcome, "en");
Email::sendContent($content, "user@example.com");

The stored copy renders through Mustache, so EmailContent::render() fills the message's {{placeholders}} — like {{name}} above — with per-send data, and EmailQueue::add() takes the very same content when you want it queued instead of sent inline.

The HTML template

Unless you pass withoutTemplate, every message is wrapped in one shared HTML template, so all mail carries the same branded shell. send() loads the file named by EMAIL_TEMPLATE, renders it with Mustache, and drops your message into it — you write only the body (plain text or HTML), and the template supplies the header, logo and footer around it.

You do not have to write one to start. The framework ships a working template at data/email.html — a centered, responsive shell with the logo above the message — and EMAIL_TEMPLATE points at it by default. The path is looked up in your app first and only then in the framework, so dropping your own data/email.html into the project replaces it without touching the configuration. Point EMAIL_TEMPLATE somewhere else to keep both.

The template is a normal HTML file with a handful of placeholders that send() fills in:

PlaceholderFilled with
{{message}}Your message — the body passed to send().
{{name}} / {{siteName}}The application name.
{{logo}} / {{logoHeight}}The logo file name (EMAIL_LOGO) and its height — the shipped template builds the src as {{files}}/{{logo}}.
{{url}}The email base url (EMAIL_URL).
{{files}}The app's files base url.
<!-- the EMAIL_TEMPLATE file -->
<table>
    <tr><td><img src="{{logo}}" height="{{logoHeight}}"></td></tr>
    <tr><td>{{{message}}}</td></tr>
    <tr><td>{{siteName}}</td></tr>
</table>

Note the triple braces around {{{message}}} — Mustache escapes with double braces, so a body that contains HTML is inserted raw with three. Its path and branding come from these keys:

KeySets
EMAIL_TEMPLATEPath to the HTML template file.
EMAIL_LOGO / EMAIL_LOGO_HEIGHTThe logo image and its height.
EMAIL_URLBase url used inside the email's links.

The localized content above renders through Mustache the same way, so a stored message can carry its own {{placeholders}} before it is dropped into this outer template.

Contact forms & captcha

For a public contact form, validate the reCAPTCHA on the incoming Request before sending — it checks the g-recaptcha-response field against EMAIL_RECAPTCHA_SECRET:

use Framework\Email\Email;

if (Email::isCaptchaValid($request)) {
    Email::send(/* … */);
}