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 EmailResultSent 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.
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 the default sends over a plain SMTP server. If the transport rejects the message, the send returns ProviderError.

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

SMTP

The default transport 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  = "…"

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 for the Spanish copy. At build time the EmailBuilder turns each key into a typed EmailCodeWelcome 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(/* … */);
}