Home › Blog › Article

WooCommerce Order Emails: Template Customization and Deliverability

WooCommerce Order Emails: Template Customization and Deliverability

Answer first: what to do and how to know it is correct

Copy WooCommerce email templates into a child theme, edit only the override files, register any new logic in a small site plugin, send through authenticated SMTP, and translate strings with the correct order context. A correct WooCommerce email customization is proven when a real test order produces the intended HTML and plain-text output, headers show SPF, DKIM and DMARC pass, the recipient language matches the order locale, and the original templates can be restored by deleting one override directory. Keep a staging copy, a mail log, and a rollback archive before touching production.

Preparation Process: baseline, staging and safe files

  1. Clone production to staging, confirm WooCommerce 10.x and WordPress 6.8/6.9 in 2026, then record PHP 8.2 or 8.3 and the active child theme version.
  2. Create a backup: tar -czf ~/backups/pre-email-2026.tar.gz wp-content/themes/your-child-theme wp-content/plugins/your-site-plugin wp-config.php.
  3. Install a mail log plugin or enable provider logs; capture one unmodified order email as the control sample.
  4. Create wp-content/mu-plugins/order-email-lab.php for temporary test hooks, never for permanent business logic.
  5. Confirm you can receive mail at a mailbox that exposes raw headers, such as a Gmail “Show original” view or a dedicated QA inbox.
  6. Write the rollback command in your deployment note: rm -rf wp-content/themes/your-child-theme/woocommerce/emails plus rm wp-content/mu-plugins/order-email-lab.php.

This preparation matters because WooCommerce loads templates through wc_get_template(), and a stale override can silently survive plugin upgrades. If your child theme is still being built, our WordPress Theme Development & Customization team normally places email overrides under version control before any SMTP work starts. Budget owners comparing implementation scope can also review WordPress Website Pricing: 2026 Vendor Comparison Guide before approving a staging sprint.

Template structure and overrides

WooCommerce 10.x stores default email templates in wp-content/plugins/woocommerce/templates/emails/. The common files are admin-new-order.php, customer-processing-order.php, customer-completed-order.php, customer-on-hold-order.php, customer-refunded-order.php, customer-note.php, email-header.php, email-footer.php, email-order-details.php, email-order-items.php, email-addresses.php, email-customer-details.php, and plain/ equivalents. Shared styling is generated by WC_Email::style_inline() from emails/email-styles.php; the header receives $email_heading, while every order template receives $order, $sent_to_admin, $plain_text and $email.

Create the override tree

Use the child theme path, not the plugin path. Run:

mkdir -p wp-content/themes/your-child-theme/woocommerce/emails/plain
cp wp-content/plugins/woocommerce/templates/emails/customer-processing-order.php 
   wp-content/themes/your-child-theme/woocommerce/emails/
cp wp-content/plugins/woocommerce/templates/emails/email-header.php 
   wp-content/themes/your-child-theme/woocommerce/emails/
cp wp-content/plugins/woocommerce/templates/emails/plain/customer-processing-order.php 
   wp-content/themes/your-child-theme/woocommerce/emails/plain/
wp option get woocommerce_email_footer_text

WooCommerce resolves your-child-theme/woocommerce/emails/... before the plugin directory. Edit the copied file, not the original. A minimal, copy-pasteable customization adds a delivery note only for local-pickup processing orders and keeps plain text in sync:

<?php
/**
 * Customer processing order override.
 * Expected vars: $order, $sent_to_admin, $plain_text, $email.
 */
defined( 'ABSPATH' ) || exit;

do_action( 'woocommerce_email_header', $email_heading, $email );

if ( $order instanceof WC_Order && $order->has_shipping_method( 'local_pickup' ) ) : ?>
<p><?php esc_html_e( 'Bring your order number and a photo ID to the pickup desk.', 'your-child-textdomain' ); ?></p>
<?php endif; ?>

<?php do_action( 'woocommerce_email_order_details', $order, $sent_to_admin, $plain_text, $email );
do_action( 'woocommerce_email_order_meta', $order, $sent_to_admin, $plain_text, $email );
do_action( 'woocommerce_email_customer_details', $order, $sent_to_admin, $plain_text, $email );
do_action( 'woocommerce_email_footer', $email );

For plain text, add the same conditional with esc_html_e() and no markup. Load translations with load_child_theme_textdomain( 'your-child-textdomain', get_stylesheet_directory() . '/languages' ) in functions.php. Use $order->get_billing_email() rather than $_POST, and always escape with esc_html__(), esc_html_e() or wp_kses_post().

Verify and roll back templates

Go to WooCommerce > Settings > Emails > Processing order, use the “Preview template” link, then place a real staging order. Inspect the source for your pickup sentence and confirm the plain-text part contains the same sentence. Turn on WC_EMAIL_DEVELOPER_MODE only on staging if you need preview without sending. Common pitfalls include editing plugins/woocommerce/templates directly, forgetting the plain/ file, using get_template_directory() in a child theme, and caching an old email-styles.php. Roll back by deleting wp-content/themes/your-child-theme/woocommerce/emails/; WooCommerce immediately falls back to plugin defaults. If a WooCommerce 10.x upgrade changes a hook signature, compare your override against the new plugin file with diff -u and reapply only your custom block. Broader deployment sequencing is covered in How to Publish a WordPress Site: 2026 Vendor Guide.

Get a Quote

Email + requirement + budget range — we reply with a quote within 24 hours.

Triggers and resending

Email classes live in wp-content/plugins/woocommerce/includes/emails/class-wc-email-*.php and are registered through WC_Emails::init_transactional_emails(). The important triggers are woocommerce_order_status_pending_to_processing, woocommerce_order_status_pending_to_on-hold, woocommerce_order_status_completed, woocommerce_order_status_processing_to_refunded, woocommerce_new_customer_note, and woocommerce_order_status_changed for admin notifications. “New order” is admin-facing; “Processing order” is customer-facing. Resending is available from the order screen: open WooCommerce > Orders > [order], choose “Resend order emails” in the order actions dropdown, select the email, and click the update arrow. Under the hood this calls WC_Meta_Box_Order_Actions::save() and then WC_Emails::send_transactional_email() on the selected WC_Email instance.

Step: add a guarded custom trigger

Do not call send() inside a template. Put a small plugin at wp-content/plugins/order-email-lab/order-email-lab.php and use the mailer API after the order is persisted:

<?php
/**
 * Plugin Name: Order Email Lab
 * Version: 1.0.0
 */
add_action( 'woocommerce_order_status_processing', function( $order_id, $order ) {
    if ( ! $order instanceof WC_Order ) {
        $order = wc_get_order( $order_id );
    }
    if ( ! $order || $order->get_meta( '_lab_notice_sent' ) ) {
        return;
    }
    $mailer = WC()->mailer();
    $mails  = $mailer->get_emails();
    if ( isset( $mails['WC_Email_Customer_Processing_Order'] ) ) {
        $mails['WC_Email_Customer_Processing_Order']->trigger( $order_id );
        $order->update_meta_data( '_lab_notice_sent', gmdate( 'c' ) );
        $order->save();
    }
}, 20, 2 );

The meta guard is the point: status transitions can fire more than once during payment gateway callbacks, imports, or WP-CLI recounts, and duplicate resends damage sender reputation. If you need a one-off bulk resend, use wp eval '$m=WC()->mailer()->get_emails(); $m["WC_Email_Customer_Completed_Order"]->trigger(1234);' with a dry-run list first, and throttle to one message per second.

Verify and roll back triggers

Check WooCommerce > Status > Logs for a woocommerce or mailer log entry, then confirm exactly one message in the QA inbox and one _lab_notice_sent row in wp_postmeta. Query with wp db query "SELECT post_id, meta_value FROM wp_postmeta WHERE meta_key='_lab_notice_sent' ORDER BY meta_id DESC LIMIT 5;". Pitfalls include hooking save_post instead of a status transition, triggering before $order has billing data, and leaving the resend action enabled for subscriptions. Roll back with wp plugin deactivate order-email-lab, delete the meta key with wp db query "DELETE FROM wp_postmeta WHERE meta_key='_lab_notice_sent';", and clear object cache via wp cache flush. Official hook references are on developer.wordpress.org.

SMTP and deliverability

WordPress uses wp_mail(), which wraps PHPMailer; WooCommerce does not bypass it. Default PHP mail() on shared hosting usually lacks alignment, so configure SMTP with a transactional provider, then publish SPF, DKIM and DMARC. In 2026, Gmail and Yahoo require SPF or DKIM alignment, a DMARC record for bulk senders, one-click unsubscribe for marketing mail, and spam complaint rates below 0.3%. Transactional order mail should stay on a separate sending domain or subdomain such as order.example.com, never share with newsletter traffic, and never include an unsubscribe header that implies marketing consent.

Step: configure constants and DNS

Add credentials to wp-config.php above /* That's all */ or inject them from environment variables; do not commit secrets:

define( 'SMTP_HOST', 'smtp.eu.mailprovider.example' );
define( 'SMTP_PORT', 587 );
define( 'SMTP_SECURE', 'tls' );
define( 'SMTP_USER', getenv( 'WC_SMTP_USER' ) );
define( 'SMTP_PASS', getenv( 'WC_SMTP_PASS' ) );

add_filter( 'phpmailer_init', function( $phpmailer ) {
    $phpmailer->isSMTP();
    $phpmailer->Host       = SMTP_HOST;
    $phpmailer->Port       = SMTP_PORT;
    $phpmailer->SMTPSecure = SMTP_SECURE;
    $phpmailer->SMTPAuth   = true;
    $phpmailer->Username   = SMTP_USER;
    $phpmailer->Password   = SMTP_PASS;
    $phpmailer->setFrom( 'orders@order.example.com', 'Example Store' );
    $phpmailer->addReplyTo( 'support@example.com', 'Example Support' );
    $phpmailer->CharSet    = 'UTF-8';
    $phpmailer->Encoding   = 'quoted-printable';
    return $phpmailer;
} );

add_filter( 'wp_mail_from_name', fn() => 'Example Store' );

Publish DNS records, replacing the selector and domain with your provider values:

order.example.com.        TXT  "v=spf1 include:_spf.mailprovider.example ~all"
mp2026._domainkey.order.example.com. TXT "v=DKIM1; k=rsa; p=REPLACE_WITH_PUBLIC_KEY"
_dmarc.order.example.com. TXT  "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; pct=100; adkim=s; aspf=s"
order.example.com.        CAA  0 issue "letsencrypt.org"

Validate with dig +short TXT order.example.com, dig +short TXT mp2026._domainkey.order.example.com, and openssl s_client -starttls smtp -connect smtp.eu.mailprovider.example:587 -servername smtp.eu.mailprovider.example. Set WooCommerce > Settings > Emails > Email sender options to the same From address; a mismatch between the WooCommerce From header and PHPMailer setFrom() is the most common alignment failure.

Verify and roll back SMTP

Send a test from the SMTP plugin or wp eval 'wp_mail("qa@example.org","SMTP check 2026","Body");', then read raw headers. You want spf=pass smtp.mailfrom=order.example.com, dkim=pass header.d=order.example.com, dmarc=pass header.from=order.example.com, and Received: from ... with ESMTPS. Also check that inline CSS survived, images use absolute HTTPS URLs, and the plain-text alternative exists. Pitfalls include port 465 with tls instead of ssl, a From address on the root domain while DKIM signs the subdomain, HTML-only mail, and a footer containing a tracking pixel. Roll back by commenting out the phpmailer_init block, restoring the previous From values in WooCommerce settings, and lowering DMARC from p=reject to p=none for 48 hours while you inspect aggregate reports. Keep the old DNS TTL at 300 seconds before the change so reverting propagates quickly. The WordPress email documentation explains the underlying wp_mail() behavior.

Multilingual emails

Multilingual emails

WooCommerce chooses the email locale from the order, not from the admin dashboard. Since WooCommerce 3.7+, WC_Email::setup_locale() calls switch_to_locale() using $order->get_customer_locale() when available, then restore_locale() after send. With WPML, Polylang or TranslatePress, store the language on the order at checkout; otherwise an admin resend in English can produce German text, or vice versa. Use __() with the woocommerce domain for core strings and your child theme domain for custom strings, and generate .mo files into wp-content/languages/woocommerce/woocommerce-de_DE.mo or wp-content/themes/your-child-theme/languages/de_DE.mo.

Step: persist and force the order locale

Capture the language during checkout, then make the mailer respect it even for manual resends:

add_action( 'woocommerce_checkout_create_order', function( $order, $data ) {
    $locale = determine_locale();
    $order->update_meta_data( '_customer_locale', $locale );
    $order->update_meta_data( '_email_lang', substr( $locale, 0, 2 ) );
}, 10, 2 );

add_filter( 'woocommerce_email_setup_locale', function( $locale, $email ) {
    $order = $email->object ?? null;
    if ( $order instanceof WC_Order && $order->get_meta( '_customer_locale' ) ) {
        return $order->get_meta( '_customer_locale' );
    }
    return $locale;
}, 10, 2 );

add_filter( 'woocommerce_email_restore_locale', function( $locale, $email ) {
    return determine_locale();
}, 10, 2 );

Compile translations with wp i18n make-pot wp-content/themes/your-child-theme wp-content/themes/your-child-theme/languages/your-child-textdomain.pot, translate with Poedit, then msgfmt de_DE.po -o de_DE.mo. For WPML, also map wpml_language_code to the order meta and enable “Send emails in customer language” in WooCommerce Multilingual & Multicurrency. Avoid get_locale() inside templates; it reports the switched locale only after setup_locale() runs, and direct template previews do not run that method.

Verify and roll back languages

Create two staging orders, one with de_DE and one with fr_FR stored in _customer_locale, resend both from an English admin session, and confirm subjects, headings, date formats and the pickup sentence match each order. Inspect Content-Type: text/html; charset=UTF-8 and Content-Transfer-Encoding: quoted-printable so accented characters are not mangled. Pitfalls include translating only HTML and not plain/, hardcoding “Order #” in email-header.php, and caching a rendered email in a full-page cache plugin. Roll back by removing the two filters, deleting _customer_locale meta with wp db query "DELETE FROM wp_postmeta WHERE meta_key='_customer_locale';", and renaming the custom .mo files. More implementation notes are indexed in our technical column.

Step-by-Step verification and rollback drill

Run this on staging, then repeat once on production during a low-traffic window. Keep the control sample open side by side.

  1. Place a local_pickup test order with a QA address and record the order ID.
  2. Confirm the override renders in HTML and plain text, with no PHP notices in wp-content/debug.log when WP_DEBUG_LOG is true.
  3. Resend “Processing order” once from the order actions dropdown and confirm exactly one delivery plus one _lab_notice_sent value.
  4. Read raw headers for SPF, DKIM, DMARC pass and aligned header.from; save the header block in the deployment ticket.
  5. Repeat with a second locale, then run wp cache flush and wp transient delete --all to rule out cached output.
  6. Execute the rollback commands, resend once, and verify the default WooCommerce 10.x template returns; then redeploy the reviewed override.

If any step fails, stop before the next one. A template error usually shows as a blank body; an SMTP error appears as WP_Error from wp_mail() or a PHPMailer exception in the log; a locale error appears as mixed-language subject and body. Rollback order matters: deactivate the lab plugin first, remove overrides second, revert SMTP constants third, and relax DMARC last. Restoring the pre-change tarball with tar -xzf ~/backups/pre-email-2026.tar.gz -C /var/www/html is the final safety net. Do not restore the database unless you also changed WooCommerce settings, because order meta is easier to delete surgically.

Frequently asked questions

Can I edit WooCommerce email templates inside the plugin folder?

No. Plugin upgrades overwrite them and you lose the diff. Copy only the files you change into your-child-theme/woocommerce/emails/, keep the plain/ pair, and compare with diff -u after each WooCommerce 10.x update.

Why did the customer receive two processing emails after one payment?

A gateway callback and a status transition both fired your trigger. Guard with order meta such as _lab_notice_sent, hook a specific transition at priority 20, and check the mail log before resending manually.

Do multilingual order emails need a separate SMTP domain?

No. Locale and authentication are independent. One aligned order.example.com sender works for all languages; only the switched locale, translation files and UTF-8 headers change.

Action checklist

  • Back up the child theme, site plugin and wp-config.php; save one control email with raw headers.
  • Override only the needed HTML and plain/ templates, using escaped order data and a child theme textdomain.
  • Guard every custom trigger with order meta, then verify one resend per order.
  • Enforce SMTP TLS, aligned From, SPF, DKIM and DMARC; confirm all three pass in raw headers.
  • Test two order locales, run the rollback drill, and document the exact commands in the release note.

Need a reviewed override set, SMTP alignment and multilingual QA before your next release? Our engineers handle WooCommerce email customization end to end. See all services or start with theme development.

References & further reading

Need WordPress Development?

Free proposal and quote within 24 hours — we serve clients worldwide.

QQ