Skip to content
MAGENTO EXTENSIONS

Magento 2 Follow-Up Emails After Purchase: What Core Does and What It Doesn't

Magento 2 sends no delayed follow-up emails after purchase. What core does, what a DIY cron queue needs, and where a per-SKU module fits.

Shane Blandford
Shane Blandford · FOUNDERPUBLISHED · 9 MIN READ

Magento sends an email when something happens to an order: it is placed, invoiced, shipped, refunded, or an admin adds a comment. It does not send anything later. There is no setting for "email the customer three days after shipment," and no queue that holds an email until a date.

Before going further, it helps to know which follow-up you mean, because the answer is different for each. We build and sell the OCM Labs Product Messaging module for Magento 2, which handles one kind and deliberately not the other. Below is where the line sits, what the custom code looks like, and where the module stops.

What follow-up emails does Magento send out of the box?

Only event emails, sent at the moment of the event. Magento Open Source ships senders for the order, invoice, shipment, and credit memo, plus a comment email for each. They go out when the order is saved, or from a cron job if you turn on asynchronous sending, but either way they describe something that just happened.

Two core cron jobs do send email on a schedule, and neither is a purchase follow-up. The product alert job sends price-drop and back-in-stock emails to customers who subscribed to a product. The newsletter job sends newsletters you have queued. Reports > Marketing > Abandoned Carts lists abandoned carts but emails nobody.

Adobe Commerce adds Email Reminder Rules under Marketing > Communications. They remind customers about abandoned carts and wish lists, and each one is built around a cart price rule, so they are a promotion tool rather than a post-purchase one. Magento Open Source does not have them.

Which kind of follow-up email do you need?

Most people searching for a Magento follow-up email want marketing automation: review requests, win-back emails, cross-sells, abandoned cart sequences. Core Magento does not send those on a delay. Neither does our module. Be clear on which group yours falls in before you pick a tool.

Transactional follow-ups are about the thing the customer bought. They would make no sense sent to anyone else:

  • A warranty registration reminder a few days after the drill ships
  • Setup tips or a quick-start guide for a product that is easy to get wrong
  • The instruction manual
  • "The battery ships separately and will arrive in a second box"
  • Care instructions for a cast iron pan or a leather jacket

Marketing follow-ups try to get another purchase or some other action that benefits the store:

  • "How did we do? Leave a review"
  • "It's been 60 days, here's 10% off"
  • "Customers who bought this also bought..."
  • Cart and browse abandonment

The second group carries obligations the first does not. You need consent rules for each country you sell into, an unsubscribe link that works, a suppression list so unsubscribed people stay unsubscribed, and usually send-time and frequency controls. That is what your email marketing platform is built for. Sync orders to it and build the flows there. Rebuilding consent and suppression handling inside Magento is a poor use of a developer.

Transactional follow-ups are different. They depend on order data that lives in Magento (which SKU, which shipment, which store view), and they often get lost in a marketing platform, where they sit under the same unsubscribe as the promotions. Unsubscribing from promotions should not stop someone getting the manual for the saw they bought.

What does a DIY delayed follow-up need in core Magento?

A queue and a cron job. An observer writes a row when the trigger happens, a cron job sends rows once they are due, and every row records what happened. For the observer side, including why shipment events are harder than they look, see our post on how to attach a PDF manual to Magento order emails. The delayed part is new.

The queue is a table you declare in db_schema.xml, with at least order_id, template_id, scheduled_at, status, attempts, sent_at, and error. Add a unique key on whatever identifies "this follow-up for this order," so a second save cannot insert a second row.

The cron job is registered in crontab.xml:

APP/CODE/ACME/FOLLOWUP/ETC/CRONTAB.XML
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="default">
        <job name="acme_followup_send_due" instance="Acme\FollowUp\Cron\SendDue" method="execute">
            <schedule>*/5 * * * *</schedule>
        </job>
    </group>
</config>

And the class it calls picks up due rows and sends them through TransportBuilder:

APP/CODE/ACME/FOLLOWUP/CRON/SENDDUE.PHP
<?php
declare(strict_types=1);

namespace Acme\FollowUp\Cron;

use Magento\Framework\App\Area;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Mail\Template\TransportBuilder;
use Magento\Sales\Api\OrderRepositoryInterface;
use Psr\Log\LoggerInterface;

class SendDue
{
    public function __construct(
        private readonly ResourceConnection $resource,
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly TransportBuilder $transportBuilder,
        private readonly LoggerInterface $logger
    ) {
    }

    public function execute(): void
    {
        $db = $this->resource->getConnection();
        $table = $this->resource->getTableName('acme_followup_queue');

        $rows = $db->fetchAll(
            $db->select()
                ->from($table)
                ->where('status = ?', 'pending')
                ->where('scheduled_at <= ?', gmdate('Y-m-d H:i:s'))
                ->limit(50)
        );

        foreach ($rows as $row) {
            try {
                $order = $this->orderRepository->get((int) $row['order_id']);
                $storeId = (int) $order->getStoreId();

                $this->transportBuilder
                    ->setTemplateIdentifier($row['template_id'])
                    ->setTemplateOptions(['area' => Area::AREA_FRONTEND, 'store' => $storeId])
                    ->setTemplateVars(['order' => $order])
                    ->setFromByScope('general', $storeId)
                    ->addTo($order->getCustomerEmail())
                    ->getTransport()
                    ->sendMessage();

                $db->update(
                    $table,
                    ['status' => 'sent', 'sent_at' => gmdate('Y-m-d H:i:s')],
                    ['id = ?' => $row['id']]
                );
            } catch (\Throwable $e) {
                $this->logger->error('Follow-up send failed', ['id' => $row['id'], 'error' => $e->getMessage()]);
                $db->update(
                    $table,
                    ['status' => 'failed', 'error' => $e->getMessage()],
                    ['id = ?' => $row['id']]
                );
            }
        }
    }
}

Magento stores datetimes in UTC, hence gmdate(). The template ID is one you declare in etc/email_templates.xml, and the template itself uses the normal directive syntax covered in our reference on Magento email template variables. The code uses no mail-layer internals, so it runs the same on 2.4.7's laminas-mail and the Symfony Mime layer in 2.4.8 and later.

What the skeleton leaves out

Which trigger. Order placed fires before anything ships. Invoice fires on payment. For "a few days after the product arrives," shipment is the closest event Magento has.

Split shipments. If the drill ships in the second box, a shipment observer has to check that shipment's items, not the order's, or the reminder goes out three days after a box that did not contain it.

Dedupe. A re-saved shipment or a second invoice fires the event again. The unique key handles it, as long as the insert ignores the duplicate rather than throwing inside the shipment save.

Cancellations. An order refunded on day two should not get a warranty reminder on day three. Check the order's state at send time.

Retries. The skeleton marks a failure as final. A mail relay hiccup at 2 AM deserves a second attempt, with a backoff and a cap.

Interrupted runs. If PHP dies between sendMessage() and the status update, the row is still pending and the customer gets the email twice. Claim rows before sending them, and decide what a half-finished row means.

Staging. A database copied from production to staging, with this cron running, emails real customers. You need a switch that redirects everything to a test inbox.

A log people can read. The queue table is your record. Someone in customer service will want to see it without opening a database client.

Can Magento send a follow-up X days after delivery?

No, because Magento does not know when a package is delivered. A shipment stores tracking numbers, and core has no event or field for a confirmed delivery. Carrier tracking lookups show status on request but do not write it back to the order. The closest you get in core is "N days after the shipment was created." If you need true post-delivery timing, you need a carrier webhook or a shipping platform that writes delivery status back, and that is a separate integration.

How does the module handle delayed follow-ups?

Each rule in the module lists SKUs, an email template, a trigger event, and a send delay in minutes. A warranty reminder three days after shipment looks like this:

  • SKU patterns: DRILL-*, with Match Child SKUs on so configurable children count
  • Trigger Event: Shipment Created
  • Send Delay: 4320 (three days)
  • Email Template: your "Did you register your warranty?" template

The observer writes a queue row with a scheduled time of now plus the delay, and a per-minute cron job sends it when due. No extra cron job is needed for delays, which can be up to a year. The full behavior is in how triggers and send delays work.

The gaps from the list above map like this:

  • Split shipments: a Shipment Created rule looks only at the items in that shipment, so the reminder follows the box that held the drill.
  • Dedupe: the queue has a unique key on rule and order. One email per rule per order, whatever gets re-saved.
  • Cancellations: if the order is cancelled or fully refunded while the email is waiting, it is not sent, and the log row shows it as Cancelled.
  • Retries: failed sends retry with exponential backoff, 5 minutes then 10 with the defaults, before a row is marked Failed.
  • Scoping: each rule can be limited to store views and customer groups, and a config switch excludes guest orders entirely.
  • Email Log: every row is listed under Sales > Product Messaging > Email Log as Pending, Sent, Failed, or Cancelled, with attempts and errors, and you can resend or cancel from the grid.
  • Staging: Test Mode diverts every queued email to one address, and the log keeps the original recipient. See Test Mode and the other module settings.
  • Send Test: a button on the rule form sends the current, unsaved version to any address against the most recent matching order, without touching the queue.

What won't the module do?

It will not replace your email marketing platform. There is no unsubscribe link and no suppression list, so keep rule templates transactional. Put a discount code in a three-day reminder and it becomes a marketing email, with the compliance work that comes with it.

There is no open or click tracking. Matching is a SKU list, with wildcards, and nothing else: no category, attribute, or order-total conditions, and no "only if they haven't registered yet." Triggers are Order Placed, Invoice Created, and Shipment Created, with a delay after any of them. Nothing triggers on delivery, a review, or a date. Cron has to run.

The module is $250 for Magento Open Source and $500 for Adobe Commerce, supports 2.4.8 to 2.4.9 on PHP 8.2 to 8.5, and runs on Luma and Hyva. Our other modules are on the Magento extensions page.

FAQ

Does Magento 2 have a built-in follow-up email feature?+
No. Magento Open Source sends emails when an order is placed, invoiced, shipped, or refunded, and nothing on a delay afterward. Adobe Commerce adds Email Reminder Rules for abandoned carts and wish lists, which are built around cart price rules rather than completed orders.
Can I send a review request email from Magento?+
Not from core, and not from our module. A review request is a marketing email: it needs an unsubscribe and should respect consent. Send it from your email marketing platform using order data synced from Magento.
How do I send an email a few days after an order ships?+
With custom code, queue a row from a shipment observer and send it from a cron job when it comes due. With the Product Messaging module, create a rule with the Shipment Created trigger and a send delay, such as 4320 minutes for three days.
Why do my delayed emails never send?+
Almost always because cron is not running. Delayed emails sit in a queue until a cron job picks them up. Check that bin/magento cron:run is scheduled on the server.
Can a follow-up email be sent only for certain products?+
Yes. Custom code needs a SKU check in the observer. In the module, each rule has its own SKU list, so a drill and a saw can have different follow-ups, and an order containing both gets both.
Will these follow-ups go to guest customers?+
In the module, yes by default, at the order's email address. A config switch turns guest emails off. In custom code, use getCustomerEmail() on the order, which is set for guests too.
Shane Blandford

Shane Blandford

FOUNDER

Founder of Orange Collar Media, a Denver ecommerce agency behind 800+ Magento and Shopify builds. In Magento since version 1.x - building, rescuing, and supporting revenue-critical stores since 2008.

All posts by Shane Blandford→

KEEP READING

Related from the Journal.

Your store can sell more. Let's find out how much.

Start a project