Email Template Management at Scale: How I Built a Network-Wide System
November 9, 2025 • Bojan
November 9, 2025 • Bojan

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.
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.txt for the subject linecontent.html for the HTML bodyAdd 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:
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.
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:
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:
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.
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:
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.
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.
wp_cache_set('etm_templates', ...) for 1 hourwp_mail filters$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.
Before
After
If you are fighting email template drift in Multisite, stop editing per site. Centralize, discover, preview, ship.

I’m Bojan Josifoski - I’m a WordPress systems engineer who developed and maintained a proprietary WordPress-based framework used by U.S. financial institutions between 2016 and 2025.