Skip to content

Work that comes back every week? No human needed. More on AI and automation

Open Source

Magento 2 Order Confirmation Emails, Out of the Stone Age

A thin, brightly lit metal plate lying in front of three much thicker dark slabs that step back behind it.

magento2-module-sales-email replaces the Magento 2 order confirmation and shipment notification with two templates built for email clients, and puts the customer’s question above the shop’s paperwork.

It started as an email redesign for a shop I look after. The stock templates were the reason: they are a bit boring, and they could do with a modernisation. The work turned out to be reusable, so I made it a module rather than a theme override.

What core sends

The default order confirmation opens with four boxes: billing address, shipping address, payment method, shipping method. Under them sits the full line-item table. Every unmodified Magento shop sends essentially the same email.

Read it as the customer. They just paid, and they want to know whether it worked and what is coming. Both answers sit below a block of paperwork that exists for the shop’s records.

How to change the order confirmation template

Magento keeps the four templates as files in Magento_Sales: order_new.html and order_new_guest.html for the confirmation, shipment_new.html and shipment_new_guest.html for the notification. There are two routes to a template of your own, and they differ in where the copy lives.

In the admin, go to Marketing > Communications > Email Templates > Add New Template, choose the core template under Load template, edit the copy and save it. Then select it in Stores > Configuration > Sales > Sales Emails. Customers and guests have a setting each, so a copy selected in one of them leaves the other order type on the core template.

In a theme, put the file at <theme>/Magento_Sales/email/order_new.html, and the guest and shipment templates beside it. There is nothing to select, and the override sits in version control with the rest of the theme.

  • The template setting is per store view. A store view that holds an explicit template id keeps it, whatever the default becomes, until you set that setting back to “Use Default Value”.
  • Both routes freeze a copy of the core markup at the version you copied, so a later change in core does not reach it.

Either route lets you move the addresses below the items. The markup underneath is where the time goes: tables, styles that survive an inliner, and a test in the clients your customers actually read in.

What these templates send instead

Order confirmation, for customers and guests: logo and order number, a headline and a short intro, an info strip with the shipping method and the order date, then compact items with image, name, quantity and price, the totals, the invitation block, and only then the billing and shipping address, the payment method and the store contact footer.

Shipment notification, for customers and guests: a tracking strip with the number, the carrier and a “Track shipment” button, the items in that package with the shipped quantity, the invitation block, the delivery address.

The order confirmation, top to bottom. The addresses are still there, under the items and the totals. Lab data from a clean Mage-OS.
The shipment email answers one question, which is where the parcel is, and puts the button for it above everything else.

Bundle, downloadable and gift message items render. A product with no image gets a neutral placeholder cell rather than a broken image.

Why tables

An email client is not a browser. Flexbox and grid are not reliably available. Some clients strip the style element, and the @media block for narrow screens is in that element, so those clients get the desktop layout. Web fonts are out, SVG is out, images need absolute URLs. So these templates are tables, their styles are inlined from one stylesheet, and a few colours are repeated as bgcolor attributes for clients that ignore CSS.

Five colours live in PixelPerfect_SalesEmail::css/pp-sales-email.css: ink #2b2b2b, muted #6b6b6b, line #e3e3e3, tint #f6f6f4, accent #3a5a40. Ship the same file in your theme, or in a small shop module, to change them.

The exception is the handful of colours the templates repeat as bgcolor attributes for clients that ignore CSS, on the button cell, the shipment strip, the item placeholder and the page background. Those live in the templates, so changing them means copying the template too.

One editable slot, with a filter in front of it

Shops want to say something in a confirmation, usually an invitation to the newsletter. The invitation block is that slot: an ordinary CMS block, edited in the admin, chosen per store view and per email. It sits below the totals in the order email and below the items in the shipment email.

Anything an editor pastes into that block ends up in an email client, which makes it a security question. The block is sanitised before it is rendered:

  • Allowed tags: table tbody thead tr td th p a strong b em i br span div h1 h2 h3 ul ol li img hr. An unknown tag is unwrapped and its children stay.
  • Removed with their content: script style link svg form input button iframe object embed meta base textarea select noscript template math.
  • Allowed attributes: href src alt width height style align valign border cellpadding cellspacing role title bgcolor. Everything else is dropped, including onclick, class and id.
  • URL schemes in href and src: http, https, mailto, tel and relative URLs. A javascript: or data: URL is dropped.
  • Style values are dropped when they contain backslash escapes, comments, expression, behavior, javascript, vbscript, -moz-binding, @import or a url() with a non-http scheme.

The admin shows a warning when a saved block contains markup that will be removed, and that warning checks the raw content only. CMS directives such as {{widget}} and {{block}} are expanded at render time, so markup produced by a directive is sanitised then, silently.

Mutation testing found a hole in that filter before release: an entity-encoded java&Tab;script: link passed the scheme check. The value is decoded before the check now.

Working with Discount Lines

Neither module requires the other.

With pixelperfectat/magento2-module-discount-lines installed, the totals render one row per discount rule, with the coupon code in a monospace chip and “Gift” or “Free” in the amount cell for zero-value gift and free shipping lines. Small icons next to those rows follow pp_discount_lines/documents/email_icon, off by default.

Both modules installed: the totals carry one row per rule, and the coupon code keeps its own typeface so it is recognisable as something the customer typed.

Without it, the email shows Magento’s own discount row, unchanged, and core appends the sales rule’s description to that label. That is how an internal note reaches a customer, and the sister post explains how to get one discount line per cart rule.

Please try to break it

This module is new, and a browser test says little about it. It renders in a lab. Your customers read mail in Outlook, in a dark-mode iOS client, in a webmail that strips the style element.

Install it in a test environment, send yourself an order confirmation, open it in whatever your customers actually use, and paste something awkward into the invitation block. If a layout collapses or the sanitiser eats something it should have kept, that is worth an issue on GitHub. Screenshots of a broken client are the most useful thing you can send me.

Install

composer require pixelperfectat/magento2-module-sales-email
bin/magento setup:upgrade
bin/magento cache:flush

Setup creates the default invitation block pp_sales_email_invitation, and leaves an existing block with that identifier untouched. Then open Stores > Configuration > Sales > Modern Sales Emails, pick the CMS block under Invitation Block and choose the emails it appears in. The default block is installed but not selected, so no invitation shows until you choose one.

One switch per store view: pp_sales_email/general/enabled. Set to 0 for a store view, that store view sends the core templates again and no other store view is affected.

A store view that explicitly saved the core template id in Sales Emails keeps the core template until that setting is returned to “Use Default Value”.

Limits

  • Order confirmation and shipment only in 1.0. Invoice and credit memo emails are planned.
  • The narrow-screen layout depends on the client keeping the <style> element. A client that strips it shows the desktop layout.
  • Tracking links point at Magento’s own tracking popup, not the carrier’s page.
  • Real product images were not tested in the lab, only placeholder cells. Image URLs are resolved through the store emulation of the order’s store.
  • The invitation text is not translated per locale unless the CMS block is assigned per store view.
  • A block saved by an import or a direct resource-model save skips the admin warning. Its content is still sanitised at render.

Requirements and quality

PHP 8.3 with ext-intl, Magento 2.4 or Mage-OS. Released 1.0.0 on 2026-11-01, MIT licensed, on GitHub and Packagist. Built and tested in a clean Mage-OS 3.1.0 lab on PHP 8.5 with demo data only, so every screenshot in the repository shows placeholder data.

composer check runs in CI: composer validate, PHP lint, PHP_CodeSniffer, PHPStan, PHPUnit and Infection mutation testing. The mutation score is 100 / 100 (MSI / covered-code MSI), with the gate at 85 / 90, read from the CI run of 2026-11-01.

The module ships en_US and de_DE copy for its own strings only. Subtotal, Grand Total, Shipping & Handling and the order subject come from the language pack installed for the store, so the module does not carry a second copy of them.

Questions

How do I change the Magento 2 order confirmation email template?

Copy the core template and select your copy. In the admin: Marketing > Communications > Email Templates > Add New Template, choose the core template under Load template, edit it, save it, then select it in Stores > Configuration > Sales > Sales Emails, where customers and guests have a setting each. In a theme: put the file at `<theme>/Magento_Sales/email/order_new.html`. Both routes freeze a copy of the core markup, and the setting is per store view, so a store view holding an explicit template id keeps it until that setting goes back to "Use Default Value". With `magento2-module-sales-email` installed, its templates are already the default for `sales_email/order/template`, `sales_email/order/guest_template` and the two shipment equivalents, and they are overridden the same two ways under `PixelPerfect_SalesEmail`.

The new template is installed but my store still sends the old email. Why?

A store view that explicitly saved the core template id, meaning the dropdown shows the core template rather than "(Default)", keeps the core template. The module does not override an explicit choice. Set that setting back to "Use Default Value", or pick the modern template in the dropdown.

Why are transactional emails built from tables?

Because the receiving end is not a browser. Common email clients do not support flexbox or grid reliably, some strip the `style` element, others ignore parts of the CSS. These templates are therefore tables, they load no web fonts, they use no SVG, and they repeat a few colours as `bgcolor` attributes for clients that ignore the stylesheet.

Is a CMS block safe inside an email?

Only with a filter in front of it. The invitation block is an ordinary CMS block so editors can change it, and it is sanitised before it is rendered: `script`, `style`, `iframe`, `form`, `svg` and others are removed with their content, `onclick`, `class` and `id` are dropped, and in `href` and `src` only `http`, `https`, `mailto`, `tel` and relative URLs survive. The warning shown when saving checks the raw content only, so output of CMS directives is sanitised at render time instead.

Where Is Your Magento Project Right Now?

Tell me briefly what you are working on, where things stand and what has already been tried. I will be honest about whether I am the right developer for it.