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:
| Argument | Effect |
|---|---|
sendAlways | Bypass the white-list filter for this send. |
sendTest | Send even when email is inactive — for a config test. |
withoutTemplate | Send 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:
| Result | Meaning |
|---|---|
Sent | Handed off to the provider. |
InactiveSend | Email is turned off (EMAIL_ACTIVE). |
WhiteListFilter | The recipient is not on the white list. |
InvalidEmail | The address is malformed. |
NoEmails | No recipients were given. |
ProviderError | The 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:
| Key | Controls |
|---|---|
EMAIL_ACTIVE | The master switch — with it off, send() returns InactiveSend and nothing leaves. |
EMAIL_PROVIDER | Which transport to send through (see below). |
EMAIL_NAME / EMAIL_EMAIL | The default "from" name and address on every message. |
EMAIL_REPLY_TO | The default reply-to address. |
EMAIL_USE_WHITE_LIST | Restrict 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:
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):
EMAIL_PROVIDER = "SMTP"
SMTP_HOST = "smtp.example.com"
SMTP_PORT = 587
SMTP_SECURE = "tls"
SMTP_USERNAME = "postmaster@myapp.com"
SMTP_PASSWORD = "…"
SMTP_DEBUG = falseMandrill
Delivers through Mailchimp Transactional (Mandrill)'s HTTP API with a single key:
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:
EMAIL_PROVIDER = "Mailjet"
MAILJET_KEY = "…"
MAILJET_SECRET = "…"Mailgun
Delivers through the Mailgun API with a single key:
EMAIL_PROVIDER = "Mailgun"
MAILGUN_KEY = "…"SendGrid
Delivers through the SendGrid API with a single key:
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:
{
"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 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:
| Placeholder | Filled 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:
| Key | Sets |
|---|---|
EMAIL_TEMPLATE | Path to the HTML template file. |
EMAIL_LOGO / EMAIL_LOGO_HEIGHT | The logo image and its height. |
EMAIL_URL | Base 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(/* … */);
}