The email system consists of three focused classes that provide clear separation of concerns:
DnsResolver (including DnsResolver::getPtr() for reverse lookups). Locally-stored mail is read through a Gmail-style Mailbox Reader with a grant-based mailbox model — each address is its own mailbox, shareable among several staff users, with read/star state shared per mailbox; see Mailbox Reader.A clean, fluent API for email composition:
// Create from template
$message = EmailMessage::fromTemplate('activation_content', [
'act_code' => 'ABC123',
'resend' => false,
'recipient' => $user->export_as_array()
]);
$message->from('[email protected]', 'Admin')
->to('[email protected]', 'John Doe')
->subject('Activate Your Account');
// Create manually
$message = EmailMessage::create('[email protected]', 'Subject', 'Body content')
->from('[email protected]');Key Methods:
fromTemplate($name, $values) - Create from database templatecreate($to, $subject, $body) - Create simple messagefrom($email, $name) - Set senderto($email, $name) - Add recipientcc($email, $name) - Add CC recipientbcc($email, $name) - Add BCC recipientsubject($subject) - Set subjecthtml($content) - Set HTML bodytext($content) - Set plain text bodyattachment($path, $name) - Add attachmentheader($name, $value) - Add custom headerHandles all sending operations with service selection:
// Send a message
$sender = new EmailSender();
$result = $sender->send($message);
// Quick send (uses default template if HTML detected)
$result = EmailSender::quickSend(
'[email protected]',
'Subject',
'<p>HTML content</p>'
);
// Send from template
$result = EmailSender::sendTemplate(
'welcome_email',
'[email protected]',
['name' => 'John', 'recipient' => $user->export_as_array()]
);
// Batch send (uses provider's native batch API when available)
$recipients = ['[email protected]', '[email protected]'];
$result = $sender->sendBatch($message, $recipients);
// Returns: ['success' => bool, 'failed_recipients' => string[]]Service Selection:
email_service setting (mailgun/smtp)email_fallback_service settingFocused on template processing:
// Direct template processing (rarely needed - use EmailMessage instead)
$template = new EmailTemplate('activation_content');
$template->fill_template([
'act_code' => 'ABC123',
'resend' => false,
'recipient' => $user->export_as_array()
]);
// Get processed content
$subject = $template->getSubject();
$html = $template->getHtml();
$text = $template->getText();// For new code - use EmailMessage + EmailSender
$message = EmailMessage::fromTemplate('welcome_email', [
'user_name' => $user->get('usr_name'),
'activation_code' => $code,
'recipient' => $user->export_as_array()
]);
$message->from('[email protected]', 'Example Site')
->to($user->get('usr_email'), $user->get('usr_name'));
$sender = new EmailSender();
$success = $sender->send($message);// For simple emails
$success = EmailSender::quickSend(
$user->get('usr_email'),
'Welcome to our site!',
'<h1>Welcome!</h1><p>Thanks for joining us.</p>'
);// When you just need to send a template
$success = EmailSender::sendTemplate(
'password_reset',
$user->get('usr_email'),
[
'reset_link' => $reset_url,
'user_name' => $user->get('usr_name'),
'recipient' => $user->export_as_array()
]
);Templates support full conditional and variable processing:
Template Structure:
subject:Welcome to *company_name*, *recipient->usr_first_name*!
{~resend}
<h1>Welcome!</h1>
<p>Thanks for signing up on *company_name*! Please click this link to verify:</p>
{end}
{resend}
<p>Please click the following link to verify your email address:</p>
{end}
<p><a href="*web_dir*/activate?code=*act_code*">Activate Account</a></p>*variable_name**recipient->usr_first_name**date|Y-m-d**email_vars*Basic conditionals:
{variable_name}
Content if variable is truthy
{end}
{~variable_name}
Content if variable is falsy (NOT)
{end}Complex conditionals:
{recipient->usr_level >= 5}
<p>Admin content</p>
{end}
{template_name == "welcome"}
<p>Welcome-specific content</p>
{end}Variable operations:
{condition}
[counter=1]
[email_type="notification"]
Content here
{end}Loop over an array with {loop array_path as item_name} ... {end}:
{loop line_items as line}
- *line->product_name* x*line->quantity*
{end}The array_path follows the same dot/arrow resolution as variables (e.g.
order->items reaches $values['order']['items']). Inside the loop body
the loop variable is in scope as a regular value: *item_name*,
*item_name->property*, and conditionals like {item_name->is_gift} all
work.
Nesting: loops nest with each other and with conditionals in any order.
Each iteration runs the full loops -> conditionals -> variables pipeline
on its body, so an inner loop sees the outer loop's iteration variable,
and a conditional inside a loop sees the loop variable.
{loop groups as group}
*group->name*:
{loop group->members as m}
- *m->name* {m->is_admin}(admin){end}
{end}
{end}Edge cases (lenient): missing keys, non-array values, and empty arrays all render the loop body zero times with no error.
Caveats
_expand_loops runs before conditionals, so a loop cannot reference
a variable set inside a [var="..."] operation block — by the time
conditionals execute, the loop has already expanded.{loop ... } directive must not contain } inside it.{loop marker bypass the loop pre-pass entirely;
rendering behaviour is unchanged from pre-2026 templates.Three ways to set subject (priority order):
$message->subject('Custom Subject'); subject:Welcome to *company_name*!
<p>Email body...</p> subject:*subject*
<p>Email body...</p>Mailgun Configuration:
// Settings
mailgun_api_key = "key-abc123..."
mailgun_domain = "mg.example.com"
mailgun_eu_api_link = "https://api.eu.mailgun.net" // EU endpoint (optional)SMTP Configuration:
// Settings
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_username = "[email protected]"
smtp_password = "password"
smtp_encryption = "tls" // or "ssl"Service Selection:
// Primary service
email_service = "mailgun" // or "smtp"
// Fallback service
email_fallback_service = "smtp" // or "mailgun"
// Default template for HTML emails
default_email_template = "default_outer_template"Debug Mode:
email_debug_mode = "1" // Enable debug logging to debug_email_logs tableTest Mode:
email_test_mode = "1" // Redirect all emails to test address
email_test_redirect = "[email protected]"Web Interface:
/tests/email/Debug Logging:
// Enable in settings
email_debug_mode = "1"
// View logs
SELECT * FROM debug_email_logs ORDER BY del_timestamp DESC;Service Validation:
// Check service configuration
$validation = EmailSender::validateService('mailgun');
if (!$validation['valid']) {
foreach ($validation['errors'] as $error) {
echo "Error: $error\n";
}
}Template Testing:
// Test template without sending
$message = EmailMessage::fromTemplate('test_template', [
'variable' => 'value',
'recipient' => $user->export_as_array()
]);
echo "Subject: " . $message->getSubject() . "\n";
echo "HTML Length: " . strlen($message->getHtmlBody()) . "\n";
echo "Ready to send: " . ($message->getSubject() ? 'Yes' : 'No') . "\n";Automatic failover between email services:
// If Mailgun fails, automatically tries SMTP
$sender = new EmailSender();
$success = $sender->send($message);
// Check what actually happened
if ($success) {
// Email sent successfully (primary or fallback)
} else {
// Both services failed - email queued for retry
}Failed emails are automatically queued:
// Failed emails go to queued_email table
// Can be retried later with queue processing script$message = EmailMessage::create('[email protected]', 'Subject', 'Body')
->header('X-Custom-Header', 'value')
->attachment('/path/to/file.pdf', 'document.pdf')
->replyTo('[email protected]');Full access to template variables:
// All template variables work
$message = EmailMessage::fromTemplate('template', [
'recipient' => $user->export_as_array(), // User data
'act_code' => $activation_code, // Custom variables
'utm_source' => 'newsletter' // Tracking
]);
// Template can use:
// *recipient->usr_first_name*
// *act_code*
// *web_dir*
// *email_vars* (includes UTM tracking)$message = EmailMessage::fromTemplate('newsletter', [
'content' => $newsletter_content
]);
$recipients = [];
$users = new MultiUser(['usr_active' => 1]);
$users->load();
foreach ($users as $user) {
$recipients[] = $user->get('usr_email');
}
$sender = new EmailSender();
$result = $sender->sendBatch($message, $recipients);
// $result['success'] — true if all recipients succeeded
// $result['failed_recipients'] — array of email addresses that failed
// Failed recipients are automatically retried via the fallback provider,
// then queued for later retry if both providers fail.try {
$message = EmailMessage::fromTemplate('template_name', $values);
$sender = new EmailSender();
$success = $sender->send($message);
if (!$success) {
// Email queued for retry
error_log("Email queued due to service failure");
}
} catch (EmailTemplateError $e) {
// Template issue
error_log("Template error: " . $e->getMessage());
} catch (Exception $e) {
// Other issues
error_log("Email error: " . $e->getMessage());
}Always include recipient data when using templates:
// CORRECT - includes recipient data
$success = EmailSender::sendTemplate('welcome',
$user->get('usr_email'),
[
'activation_code' => $code,
'recipient' => $user->export_as_array() // Required for templates
]
);
// MISSING - may cause template variable errors
$success = EmailSender::sendTemplate('welcome',
$user->get('usr_email'),
['activation_code' => $code] // Missing recipient data
);The system automatically provides:
template_name - Derived from template filenameweb_dir - Site base URLemail_vars - UTM tracking parametersutm_source=email, utm_medium=email, etc.The receipt system (specs/receipts_refactor.md) uses two database-stored templates:
| Template name | Purpose | Recipient |
|---|---|---|
purchase_receipt_default | Default order receipt + per-registrant activation. One template, two render modes via {is_billing}. | Billing user always; per-registrant for event/bundle gift recipients. |
purchase_receipt_product_default | Per-product opt-in email. Sent at most once per (product, order). Falls back here when a product has pro_after_purchase_message or pro_emt_receipt_template_id set. | Billing user. |
purchase_receipt_product_default with any other template by setting pro_emt_receipt_template_id. If the override points at a missing or soft-deleted template the helper _resolve_receipt_template() falls back to the default — never crashes.Variables passed to purchase_receipt_default:
| Variable | Notes |
|---|---|
recipient | Recipient's user data (billing user or registrant) |
is_billing | True when sending to billing user; drives the price column and totals block |
order | Order data |
order_total | Used only when is_billing |
currency_symbol | |
line_items | Array — one entry per relevant line. Iterated via {loop line_items as line} |
coupon_codes_used | Only when is_billing and at least one coupon applied |
line_items entry: product_name, quantity, outcome (event/bundle/subscription/digital/plain), is_gift_to (set on gift lines for billing user), plus outcome-specific fields (event_name, event_list, digital_link, act_code, event_registrant_id, subscription_active). Gift lines for the billing user deliberately omit act_code and event_registrant_id so the activation token doesn't leak to the buyer.Variables passed to purchase_receipt_product_default: recipient (billing user), product_name, after_purchase_message (HTML, may be empty), order_item, order. There is no is_gift variable — per-product custom email always targets the billing user, so admins author one voice.
from() when different from defaultsThe email system provides:
includes/DnsAuthChecker.php is the one place to check whether a domain
publishes SPF, DKIM, and DMARC records. Use it — do not hand-roll
dns_get_record() TXT parsing. adm/admin_settings_email.php and the
utils/email_setup_check.php deep-dive tool both build on it, and the
inbound_email plugin's domain status badges do too.
> Record presence ≠ message verification. DnsAuthChecker is a DNS
> record check — it inspects domains we control for a sane outbound/setup
> config. It is not verification of an inbound message's connecting IP
> against a record, and must never be repurposed for inbound verdicts. The app
> no longer computes inbound SPF/DKIM/DMARC at all (it once hand-rolled a
> DKIM verifier that false-failed legitimate mail — removed). Per-inbound-message
> verdicts come from the message's Authentication-Results header, stamped by
> the verifying MTA (opendkim-verify + opendmarc) and read by the inbound_email
> plugin's AuthenticationResults/InboundEmailRouter — never from
> DnsAuthChecker. See plugins/inbound_email/docs/overview.md →
> Inbound authentication.
require_once(PathHelper::getIncludePath('includes/DnsAuthChecker.php'));
$spf = DnsAuthChecker::checkSPF('example.com'); // ['status'=>'pass|warn|fail', 'detail'=>…, 'record'=>…]
$all = DnsAuthChecker::quickCheck('example.com'); // ['spf'=>…, 'dkim'=>…, 'dmarc'=>…]Its lookups go through DnsResolver (the platform's single raw-DNS
chokepoint — see Validation › DNS Lookups),
so a resolver failure is handled cleanly and the checks are unit-testable via
DnsResolver::setBackend(). DnsAuthChecker's own public static API is
unchanged by that — callers and the EmailAuthChecker subclass are unaffected.
The email system uses a provider abstraction so that new email services can be added without modifying core code.
EmailServiceProvider — interface in includes/EmailServiceProvider.php that all outbound providers implementInboundEmailProvider — sibling interface in includes/InboundEmailProvider.php for inbound transports (Postfix, Mailgun webhook, etc.). A single provider class may implement both interfaces; the Inbound Email plugin discovers inbound providers via InboundProviderRegistry. See Inbound Email Plugin for the inbound side.includes/email_providers/ (e.g., MailgunProvider.php, SmtpProvider.php, SendGridProvider.php)EmailSender scans includes/email_providers/ for classes implementing EmailServiceProvider; InboundProviderRegistry walks the same directory for classes implementing InboundEmailProvider. No manual registration needed in either case.| Key | Label | Batch | Live API check | Notes |
|---|---|---|---|---|
mailgun | Mailgun | Native (recipient-variables, 500/chunk) | Yes (domain show) | EU region supported via mailgun_eu_api_link |
smtp | SMTP | Per-recipient loop via PHPMailer | Yes (connect + auth) | Generic SMTP, works with any provider that supports it |
sendgrid | SendGrid | Native (personalizations, 1000/chunk) | Yes (/v3/user/account) | Global or EU region via sendgrid_region; supports sandbox mode and per-message click-tracking toggle |
ses | Amazon SES | Per-recipient SendEmail loop (no native non-templated batch) | Yes (GetAccount) | AWS region selectable; static keys or IAM role auto-discovery; optional Configuration Set for engagement tracking |
postmark | Postmark | Native (sendEmailBatch, 500/chunk, per-recipient failure status) | Yes (getServer) | Server token (not Account token); message stream selection (transactional vs broadcast); per-message open and link tracking |
brevo | Brevo | Native (messageVersions, 1000/chunk) | Yes (/v3/account) | Single global endpoint; supports sandbox mode via X-Sib-Sandbox header |
resend | Resend | Native (batch->send, 100/chunk) | Yes (apiKeys->list) | Simplest config — single bearer token. Restricted/sending-only keys validate as "API Key Valid (Restricted)" |
mailjet | Mailjet | Native v3.1 Send API (50 messages/chunk, per-message status) | Yes (/v3/REST/myprofile) | Two-part credential (key + secret); supports sandbox mode |
Create a single file in includes/email_providers/ implementing EmailServiceProvider:
class SendGridProvider implements EmailServiceProvider {
public static function getKey(): string { return 'sendgrid'; }
public static function getLabel(): string { return 'SendGrid'; }
public static function getSettingsFields(): array { /* ... */ }
public static function validateConfiguration(): array { /* ... */ }
public function send(EmailMessage $message): bool { /* ... */ }
public function sendBatch(EmailMessage $message, array $recipients): array { /* ... */ }
}The provider automatically appears in the admin email settings dropdown and its configuration fields render dynamically. No other files need modification.
| Method | Purpose |
|---|---|
getKey() | Unique key stored in settings (e.g., 'mailgun') |
getLabel() | Human-readable name for admin UI |
getSettingsFields() | Array of setting field definitions for admin rendering |
validateConfiguration() | Check required settings are present; returns ['valid' => bool, 'errors' => []] |
send(EmailMessage) | Send a single message; return success/failure |
sendBatch(EmailMessage, array) | Send to multiple recipients; returns ['success' => bool, 'failed_recipients' => []]. Providers can optimize (e.g., Mailgun batch API) |
validateApiConnection() | (Optional) Live API check for admin validation panel |