Email Template Management at Scale: How I Built a Network-Wide System
The Problem: 50+ Hardcoded Emails Across 100+ Sites Hardcoding email templates across a WordPress Multisite is a trap. You tweak a subject line and now you are copy pasting into 100 places. No preview. No version control. Inconsistent branding. Hours burned for a single update. I wanted one source of truth. Change it once. Roll

The Problem: 50+ Hardcoded Emails Across 100+ Sites
Hardcoding email templates across a WordPress Multisite is a trap. You tweak a subject line and now you are copy pasting into 100 places. No preview. No version control. Inconsistent branding. Hours burned for a single update.
I wanted one source of truth. Change it once. Roll it out everywhere. Preview before sending. Safe to edit. Easy to ship with Git.
Here is the system.
The Architecture: Filesystem Discovery That Scales
Templates live in a predictable directory structure inside a plugin. No custom database schema. No mystery options table keys. Git controls history.
wp-content/plugins/email-template-manager/templates/
├── account/
│ ├── welcome/
│ │ ├── subject.txt
│ │ └── content.html
│ └── password-reset/
│ ├── subject.txt
│ └── content.html
├── order/
│ ├── confirmation/
│ └── shipped/
└── core/
├── password_reset_request/
└── new_user_user/
Each template has:
subject.txtfor the subject linecontent.htmlfor the HTML body
Add a folder, commit, deploy. The system discovers it automatically.
Scanner core:
class ETM_Template_Scanner {
public function scan_templates() {
$dir = ETM_PLUGIN_DIR . 'templates/';
$templates = [];
if (!is_dir($dir)) return $templates;
foreach (array_filter(glob($dir . '*'), 'is_dir') as $type_dir) {
$type = basename($type_dir);
if ($type === 'core') continue;
foreach (array_filter(glob($type_dir . '/*'), 'is_dir') as $tpl_dir) {
$slug = basename($tpl_dir);
$key = "{$type}/{$slug}";
$subj = "{$tpl_dir}/subject.txt";
$html = "{$tpl_dir}/content.html";
if (file_exists($subj) && file_exists($html)) {
$templates[$key] = [
'type' => $type,
'slug' => $slug,
'key' => $key,
'subject_file' => $subj,
'content_file' => $html,
'subject' => file_get_contents($subj),
'content' => file_get_contents($html),
'last_modified' => max(filemtime($subj), filemtime($html)),
];
}
}
}
return $templates;
}
}
Why this works:
- Discoverable by convention
- Versionable with Git
- Fast to deploy
- Zero custom schema to maintain
Variables: Real Data Without Hardcoding
Templates use simple tokens like {user_name}, {order_id}, {site_name}. At send time, the engine injects values and strips any unresolved tokens cleanly.
class ETM_Template_Processor {
public function process_template($template_key, $vars = []) {
$tpl = $this->get_template($template_key);
if (!$tpl) return null;
$vars['site_name'] = get_bloginfo('name');
$vars['site_url'] = home_url();
return [
'subject' => $this->replace($tpl['subject'], $vars),
'content' => $this->replace($tpl['content'], $vars),
];
}
private function replace($str, $vars) {
foreach ($vars as $k => $v) {
$str = str_replace('{' . $k . '}', $v, $str);
}
return preg_replace('/\{[^}]+\}/', '', $str);
}
}
This keeps templates readable and avoids PHP in HTML. Anyone can edit safely.
Network-Wide Control: Change Once, Apply Everywhere
All management happens in Network Admin. Capability is manage_network. Super admins see a single dashboard that lists every template grouped by type. Edit once. Save. Instantly live on any site that uses that template key.
class ETM_Network_Admin_Interface {
public function add_menu() {
add_submenu_page(
'settings.php',
'Email Templates',
'Email Templates',
'manage_network',
'etm-templates',
[$this, 'render_templates_page']
);
}
public function render_templates_page() {
$scanner = new ETM_Template_Scanner();
$templates = $scanner->scan_templates();
include ETM_PLUGIN_DIR . 'admin/views/templates-dashboard.php';
}
}
Benefits:
- One place to manage everything
- No per site drift
- Simple mental model
Editing Experience: Visual, HTML, Variables, Preview
Editors want to see what they are doing. Developers want raw HTML. Both are covered. There is a variable picker, a live preview, and a way to inject sample data per template type.
Preview flow:
- Choose a template
- Load sample variables for that type
- Process with the engine
- Show rendered HTML and subject
- Optional test email in the context of a specific site
add_action('wp_ajax_etm_preview_template', function() {
check_ajax_referer('etm_admin_nonce', 'nonce');
if (!current_user_can('manage_network')) wp_send_json_error('Access denied');
$key = sanitize_text_field($_POST['template_key']);
$vars = json_decode(stripslashes($_POST['variables']), true) ?: [];
$processor = new ETM_Template_Processor();
$out = $processor->process_template($key, $vars);
wp_send_json_success([
'subject' => $out['subject'],
'content' => $out['content'],
'html' => $out['content'],
]);
});
Test emails respect site context via switch_to_blog($site_id). This keeps logos, links, and names correct.
Hooking WordPress Core Emails
Core emails should not be special cases. They get the same treatment. Intercept, detect type, map variables, render with a core/... key if present. If a template is disabled, suppress the email cleanly.
class ETM_Core_Email_Manager {
public function init() {
add_filter('wp_mail', [$this, 'intercept'], 10, 1);
add_filter('retrieve_password_message', [$this, 'filter_password_reset'], 10, 4);
add_filter('wp_new_user_notification_email', [$this, 'filter_new_user'], 10, 3);
}
public function intercept($args) {
$type = $this->detect($args);
if (!$type) return $args;
$key = 'core/' . $type;
$tpl = $this->get_template($key);
if (!$tpl) return $args;
if ($this->is_email_disabled($type)) return false;
$vars = $this->extract_variables_from_args($args, $type);
$processor = new ETM_Template_Processor();
$out = $processor->process_template($key, $vars);
$args['subject'] = $out['subject'];
$args['message'] = $out['content'];
return $args;
}
}
Result:
- Core messages have the same branding as everything else
- One toggle can disable noisy emails
- Everything is observable and controllable
Safety Net: Backups and One-Click Restore
Every edit creates a JSON snapshot in uploads. Rollbacks are instant.
class ETM_Backup_Manager {
public function create_backup($key) {
$tpl = $this->get_template($key);
if (!$tpl) return false;
$dir = wp_upload_dir()['basedir'] . '/etm-backups/';
wp_mkdir_p($dir);
$file = $dir . $key . '-' . time() . '.json';
file_put_contents($file, json_encode([
'template_key' => $key,
'subject' => $tpl['subject'],
'content' => $tpl['content'],
'backup_time' => current_time('mysql'),
'backup_user' => get_current_user_id(),
], JSON_PRETTY_PRINT));
return $file;
}
}
Backups are sorted by time. You can review who changed what and when.
Portability: Export and Import
Templates are data. You should be able to move them like data.
class ETM_Export_Import {
public function export_templates($keys) {
$out = ['version' => '1.0', 'export_time' => current_time('mysql'), 'templates' => []];
foreach ($keys as $key) {
if ($tpl = $this->get_template($key)) {
$out['templates'][] = [
'key' => $key,
'type' => $tpl['type'],
'slug' => $tpl['slug'],
'subject' => $tpl['subject'],
'content' => $tpl['content'],
];
}
}
return json_encode($out, JSON_PRETTY_PRINT);
}
}
Import ensures directories exist, creates a backup, then writes files. Clean and predictable.
Performance: Cache What You Can, Invalidate When Needed
- Cache the scanned template map with
wp_cache_set('etm_templates', ...)for 1 hour - Invalidate when files change using a simple filemtime hash
- Lazy load content only when needed for preview or send
- Avoid repeated disk reads inside hot paths like
wp_mailfilters
$templates = wp_cache_get('etm_templates', 'etm');
if (false === $templates) {
$templates = (new ETM_Template_Scanner())->scan_templates();
wp_cache_set('etm_templates', $templates, 'etm', 3600);
}
File watching can be as simple as storing a hash of glob results and comparing on admin actions.
Results: Before vs After
Before
- 50 plus templates scattered across sites
- Manual edits that take hours
- No preview and constant mistakes
- Branding drift everywhere
- No backups and no history
After
- Centralized templates with a single source of truth
- One save updates any site that uses that key
- Live preview with sample data
- Core emails under control
- Automatic backups and quick restore
- Export and import for portability
- Full version control through Git
What Makes This Shareable
- It uses the filesystem, not a new database schema
- It respects Multisite and site context
- It treats core WordPress emails as first class citizens
- It gives non technical users a safe editor and preview
- It is shippable as a single plugin, controlled by Git
If you are fighting email template drift in Multisite, stop editing per site. Centralize, discover, preview, ship.
Bojan