Case Study: Renewal Reminders Built as a Support Email Type
This is a complete, shipping integration written up end to end: how renewal reminders for Super Speedy Plugins licences are sent through Super Speedy Emails. It is here as a worked case study, because the integration is small enough to read in one sitting and it uses every interesting part of the extension model.
If you are wondering what “extending Super Speedy Emails” actually involves, this article answers it with real code. The whole integration is one PHP file. It contains no cron job, no database table, no email templates, no mailer and no unsubscribe handling, because the engine provides all of those.
The companion article How We Send Our Changelog Emails is the same pattern applied to product updates, and the Automated Support Emails Developer Guide is the full API reference.
Per-recipient placeholder filling and the render-time placeholder check are available since 1.17.0.
The problem
A renewal reminder is a simple idea. A customer’s licence expires on a date. Thirty days before that date, email them and offer the renewal discount. Email them again on the day, and once more a couple of weeks after.
Written from scratch, that simple idea grows a lot of machinery: a daily cron job, a table to record what was sent so nobody gets the same email twice, templates, a mailer, an unsubscribe link that actually unsubscribes something, a suppression check so you do not email an address that has already bounced or complained, and a way to preview any of it without emailing a real customer.
None of that machinery is about renewals. It is the same for a changelog email, a security advisory, a warranty expiry, a course reminder, or anything else addressed to “the people who own this product”. Only three things are genuinely about renewals:
- Is this product one that renews at all?
- Is today the right day for this particular customer?
- What does the email say to them?
Super Speedy Emails is built around exactly that split. The engine owns the machinery. You answer the three questions. That is the whole model.
What the engine does for you
Everything in this list is handled for you, for any type you register:
- Resolving who owns each product, including customers who got it inside a bundle
- The suppression gate: product-update unsubscribes, bounces, spam complaints
- A per-day guard, so a run that fires twice cannot email the same person twice
- An optional frequency cap per record
- The unsubscribe link and the preference centre behind it
- Queueing, sending, retrying, and logging with opens and clicks
- The wp-admin editor where the copy is written, with a preview that renders a real recipient’s email and sends nothing
- Test modes, so a whole run can be rehearsed into the log with no email leaving the site
- A pause switch that defaults to paused, so nothing schedules itself into your customers’ inboxes by surprise
You write none of that. You write the answers to the three questions.
Registering the type
A type is registered once, in code, on the sse_register_support_email_types action. Here is the renewal reminder, trimmed to its shape:
sse_register_support_email_type( 'renewal-reminder', array(
'label' => 'Renewal reminder',
'mode' => 'per_product', // one email per product owned, not one per person
'stages' => 1,
'placeholders' => array(
'{renewal_date}' => 'Their expiry date, e.g. 14 June 2027',
'{days_to_expiry}' => 'Whole days from today to their expiry: 30, 0, -14',
'{renewal_status}' => 'The same thing as a phrase: "expires in 30 days"',
'{discount_summary}' => 'Their renewal offer in words, with the price',
'{changelog}' => 'What has shipped for this product recently',
'{changelog_url}' => 'Link to the public changelog page',
),
'footer' => "You're receiving this because you own {product_name}.",
'admin_fields' => array(
'offset_days' => array(
'type' => 'number',
'label' => 'Days from expiry',
'description' => 'Negative is before their expiry date, 0 is the day itself, positive is after.',
'default' => -30,
),
),
'freq_cap' => array( 'max' => 1, 'days' => 21 ),
'callbacks' => array(
'should_send' => array( __CLASS__, 'should_send' ),
'should_send_to_user' => array( __CLASS__, 'should_send_to_user' ),
'content' => array( __CLASS__, 'content' ),
),
) );
Two parts of that are worth pausing on.
placeholders are advertised to the person writing the email. They appear in the editor’s helper panel, so the copywriter can see what is available without reading any code. The engine also refuses to send a body that still contains one of your declared placeholders unfilled, so a literal {renewal_date} can never reach a customer.
admin_fields are options on the record itself. Declaring offset_days as a number means the engine renders the field in the editor, stores it, honours it in the preview, and hands it back to your callbacks. It also means the send schedule is not hard-coded in the plugin. A reminder at ninety days after expiry is a new record with its own copy, created in wp-admin by someone who does not write PHP.
Hard-coding the schedule, the audience scope or any other switch in your type. Declare it as an admin_fields entry instead: the engine renders it, stores it on the record, honours it in Preview and hands it back through $context->setting(). A new moment in the sequence is then a new record someone writes in wp-admin, not a release.
What the site admin does
In Super Speedy Emails → Support Emails, they add a Support Email, choose the type “Renewal reminder”, set “Days from expiry” to -30, tick every product that renews, and write the subject and body:
Subject: Your {product_name} licence {renewal_status}
Hi {first_name},
Your {product_name} licence {renewal_status}, on {renewal_date}.
{discount_summary}
Renew here: {renewal_url}
What has landed lately:
{changelog}
Then they do it twice more, with 0 and 14 in the field and different wording.
One record is one moment in the sequence, not one product. Every renewable product is ticked on all three records. The engine fans a record out over its product list and puts each product in context, so {product_name} is that customer’s product, and so are the price in {discount_summary} and the releases in {changelog}. Thirty products and three moments is three records, not ninety.
If one product deserves different wording, that is a fourth record with only that product ticked. Still no code.
Question 1: is there anything to send for this product?
should_send runs once per product, before any owner is looked up. It is the cheap early exit.
public static function should_send( $should, $context ) {
if ( ! $should ) {
return false;
}
return self::is_renewable( $context->product_id );
}
is_renewable() is a single indexed query asking whether the product or any of its variations carries our renewal_required flag. A free plugin, or one sold only as a lifetime licence, answers no and the engine stops there. It never resolves the audience, so the cost of having those products ticked is one query.
This callback must have no side effects, because the admin preview calls it too.
Question 2: should this person get it today?
should_send_to_user runs per owner, with that owner’s purchase history already loaded. This is where the actual policy lives, and it is worth reading in full because every line of it is a decision someone would otherwise get wrong.
public static function should_send_to_user( $send, $context ) {
if ( ! $send ) {
return false;
}
// 1. A direct purchase. Bundle-derived ownership does not renew on its own.
$row = $context->matching_product;
if ( ! $row || empty( $row->direct ) ) {
return false;
}
// 2. An expiry date to work from.
$renewal = self::renewal_date( $context );
if ( '' === $renewal ) {
return false;
}
// 3. Lifetime licences never expire, so they are never reminded.
if ( 'lifetime' === (string) $context->owner_meta( 'license_tier' ) ) {
return false;
}
// 4. Today, in UTC, is exactly this record's day for that date.
if ( ! self::is_send_day( $renewal, self::offset( $context ) ) ) {
return false;
}
// 5. A licence traded in for an upgrade has already been paid forward.
return ! self::licence_consumed( $context, $row );
}
Four things in there are the general lessons.
The context handed to your gate is batched: every owner’s purchase rows and their per-ownership meta are loaded in two queries for the whole audience, not two per recipient. A gate that looks like it queries per customer usually does not – and on a three thousand owner product that difference is the whole run.
The context arrives pre-loaded. $context->matching_product is this person’s ownership row for the product in hand, and $context->owner_meta( 'license_tier' ) reads data our own plugin attached to that row. Neither costs a query at this point.
Order the checks by cost. The date comparison eliminates almost everybody, so it runs before the upgrade-history check, which is the only expensive one.
Fail closed. Check 5 asks another plugin whether this licence was traded in for an upgrade. If that plugin is not active, the honest answer is “I cannot tell”, and the code returns false. The cost of a missed reminder is one email; the cost of the opposite is offering someone a discount on a licence they replaced months ago.
A gate that cannot answer must not send. If the data your policy depends on is unavailable – a companion plugin inactive, a file unreadable – return false. Silence is recoverable; an email offering the wrong customer the wrong thing is not.
Read the settings, do not hard-code them. self::offset( $context ) is $context->setting( 'offset_days', -30 ), clamped to something sane. The record decides, and the fallback keeps an unconfigured record behaving sensibly rather than silently never sending.
Question 3: what does it say?
content fills your placeholders inside the body the admin wrote. You do not build the email. You do not touch the parts you did not declare.
public static function content( $content, $context ) {
$body = (string) $content['body'];
$find = array( '{renewal_date}', '{renewal_status}', '{renewal_url}' );
$replace = array( $date, $status, $url );
return array( 'body' => str_replace( $find, $replace, $body ) );
}
Three rules make the difference between a type that behaves and one that surprises people.
Fill what you can, and leave the rest alone. Your callback runs once per recipient, with the copy the admin wrote and your tokens still in it, so a per-person value like their expiry date is filled with their own data every time. It also runs once per product with no recipient, to paint the admin’s plan and preview screens. When there is nobody in context there is genuinely nothing to fill for a per-person token, and leaving it is the right answer.
Never touch tokens you did not declare. {first_name}, {product_name} and {unsubscribe_url} are the engine’s, and it fills them per recipient after your callback runs. Anything neither of you recognises should come out the far end unchanged.
A token you never fill stops the send. Before an email goes out, the engine scans the finished text for the placeholders your type declared. If one is still sitting there, that product’s run halts on the spot: nobody gets a raw {renewal_date}, everyone reached before it got a complete email, and the run reports which token and which customer it went wrong on. Fix the cause and run it again; the people already emailed are skipped, and the rest carry on from where it stopped. This is why declaring a placeholder matters – it is what the safety net is made of.
Where the data comes from
The reminder needs to know each customer’s expiry date, and it needs to know it for the whole audience without a query per person.
Super Speedy Emails keeps an ownership row per customer per product, and any plugin can attach its own meta to that row. Our renewals plugin listens for sse_product_owner_upserted and writes the licence facts it knows onto the row as the order is ingested:
SSE_Product_Owners::set_owner_meta( $owner_id, 'renewal_date', $renewal );
SSE_Product_Owners::set_owner_meta( $owner_id, 'license_tier', $tier );
The engine then hands that meta back on the context, batched, and the same values feed the audience filters in the campaign builder. Write once, read everywhere.
That is the piece most people miss when they picture an integration. You do not have to teach the emailing system about licences. You attach your facts to its ownership rows and read them back when it asks you a question.
Testing an integration like this
Renewal reminders go out on one exact day per customer, which is an awkward thing to test. Two things make it straightforward.
Make “today” injectable. One filter is enough:
private static function today() {
return (string) apply_filters( 'ssp_renewals_emails_today', gmdate( 'Y-m-d' ) );
}
Let the engine send, and read what it actually sent. The tests for this integration create real WooCommerce orders, let the engine’s own ingest build the ownership rows, create a real Support Email record, and then fire the same daily cron hook the live site fires:
do_action( SSE_Support_Emails::CRON_HOOK );
Nothing in the tests picks a record or a product. The engine walks its active records itself, exactly as it does in production. The assertions then read the rows the engine wrote to its send log, which carry the real subject and body, and the record and product it credited them to. With sse_test_mode set to log_only, that whole run lands in the log and no email leaves the site.
The result is a suite that can fail for the right reason. Break the per-recipient handling in content, and it does not report a vague mismatch, it reports an email that says “your licence expires on .” with the date missing.
That is the whole argument for reading the send log rather than asserting against a mocked context. A mock agrees with whatever the code does; the log row is the email.
What it cost
The renewal integration is one file of about 450 lines, and roughly half of that is comments. It has no cron job, no database table, no templates, no mailer, no unsubscribe handling and no send log, because all of that lives in the engine and is shared with every other type on the site.
The engine, meanwhile, contains no renewal code at all. It has never heard of a licence. That is the point of the split: the emailing machinery stays general, your policy stays yours, and two types on the same site cannot interfere with each other.
Ideas this pattern fits
If you can answer the three questions for it, it is a type:
- Security advisories to the owners of one affected product
- Warranty or service-plan expiry, on the customer’s own date
- A “you have not downloaded your purchase yet” nudge
- Course or membership reminders driven by an enrolment date
- Dunning for a failed subscription payment
- Onboarding tips staged over the first month of ownership, using
stages
Start from the developer guide, copy the shape above, and give the copy to whoever writes your emails. They will not need you again to add the next reminder to the sequence.

Comments