Bojan Josifoski < wp developer />

Email Template Management at Scale: How I Built a Network-Wide System

November 9, 2025 • Bojan

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:

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:


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:


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:

  1. Choose a template
  2. Load sample variables for that type
  3. Process with the engine
  4. Show rendered HTML and subject
  5. 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:


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

$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

After


What Makes This Shareable

If you are fighting email template drift in Multisite, stop editing per site. Centralize, discover, preview, ship.

About the Author

About the Author

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.

← Back to Blog