Email System Documentation
Overview
The email system consists of three focused classes that provide clear separation of concerns:
- EmailMessage: Fluent API for composing email messages
- EmailTemplate: Template processing (conditionals, variables)
- EmailSender: All sending logic with service selection and fallback
Architecture
EmailMessage Class
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 header
EmailSender Class
Handles 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:
- Primary service:
email_servicesetting (mailgun/smtp) - Fallback service:
email_fallback_servicesetting - Automatic fallback if primary fails
- Queue failed emails for retry
EmailTemplate Class
Focused 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();Development Patterns
Recommended Approach
// 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);Quick Send for Simple Cases
// For simple emails
$success = EmailSender::quickSend(
$user->get('usr_email'),
'Welcome to our site!',
'<h1>Welcome!</h1><p>Thanks for joining us.</p>'
);Template-based Sending
// 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()
]
);Template System
Template Processing
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 Syntax
- Variables:
*variable_name* - Object access:
*recipient->usr_first_name* - Pipe qualifiers:
*date|Y-m-d* - UTM tracking:
*email_vars*
Conditional Syntax
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}Iteration Syntax
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_loopsruns 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.- The
{loop ... }directive must not contain}inside it. - Templates without any
{loopmarker bypass the loop pre-pass entirely; rendering behaviour is unchanged from pre-2026 templates.
Subject Processing
Three ways to set subject (priority order):
- Direct assignment (highest priority):
$message->subject('Custom Subject');
- Template subject line:
subject:Welcome to *company_name*! <p>Email body...</p>
- Template variable:
subject:*subject* <p>Email body...</p>
Service Configuration
Email Services
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 and Testing
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]"Testing and Debugging
Email Testing System
Web Interface:
- URL:
/tests/email/ - Admin link: Admin Panel → Email Tools → Email System Testing
- ServiceTests: SMTP/Mailgun configuration validation
- TemplateTests: Template processing and variable replacement
- DeliveryTests: End-to-end sending simulation (test mode)
Debug Tools
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";Advanced Features
Service Fallback
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 Email Queue
Failed emails are automatically queued:
// Failed emails go to queued_email table
// Can be retried later with queue processing scriptCustom Headers and Attachments
$message = EmailMessage::create('[email protected]', 'Subject', 'Body')
->header('X-Custom-Header', 'value')
->attachment('/path/to/file.pdf', 'document.pdf')
->replyTo('[email protected]');Template Variable Integration
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)Batch Operations
$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.Error Handling
Exception Types
- EmailTemplateError: Template parsing/processing errors
- Exception: General email sending errors (service failures, validation)
Error Handling Patterns
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());
}Important Notes
Variable Requirements
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
);Default Variables
The system automatically provides:
template_name- Derived from template filenameweb_dir- Site base URLemail_vars- UTM tracking parameters- UTM defaults -
utm_source=email,utm_medium=email, etc.
Receipt Templates
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.
Service Selection
- Default from/sender addresses are used automatically
- Only set custom
from()when different from defaults - Service fallback happens automatically on failures
- Failed emails are queued for later retry
Summary
The email system provides:
- ✅ Modern fluent API - clean, readable code patterns
- ✅ Separation of concerns - template processing vs sending logic
- ✅ Service reliability - automatic fallback and retry
- ✅ Better testing - comprehensive test suite and debug tools
- ✅ Maintained performance - same template processing engine
- ✅ Template compatibility - all existing templates work unchanged
Email Service Provider Interface
The email system uses a provider abstraction so that new email services can be added without modifying core code.
Architecture
EmailServiceProvider— interface inincludes/EmailServiceProvider.phpthat all providers implement- Provider classes — live in
includes/email_providers/(e.g.,MailgunProvider.php,SmtpProvider.php,SendGridProvider.php) - Auto-discovery —
EmailSenderscansincludes/email_providers/for classes implementing the interface; no manual registration needed
Built-in Providers
| 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 |
Adding a New Provider
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.
Interface Methods
| 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 |