How We Send Our Changelog Emails (Worked Example)
This article shows exactly how Super Speedy Plugins emails its own customers when a plugin they own gets an update. It’s a worked, shipping example of the Super Speedy Emails support-email filters — the extension points any developer can use to send “something happened to a product you own” emails.
The whole implementation lives in a small companion plugin, Super Speedy Plugins — Changelog Emails, which you can install and adapt. This article walks through the filters it uses; the companion plugin has the rest (the readme reader, the product map, the file paths).
Table of Contents
- The split: generic engine, your policy
- The support-email filters (and one action)
- Why the loop is products-first
- The $context object
- Filter 1 — is there news? (sse_should_send_support_email)
- Filter 2 — what does it say? (sse_support_email_content)
- Filter 3 — should this person get it? (sse_support_email_should_send_to_user)
- The action — record what we sent (sse_support_email_sent)
- Getting the full implementation
- Want a different kind of support email?
- Unsubscribes & compliance
- Related
The split: generic engine, your policy
Super Speedy Emails ships a generic support-email engine. It does all the heavy lifting that’s the same for everyone:
- resolves who owns each product (bundle owners included)
- applies the suppression gate (product-update unsubscribes, bounces, complaints)
- enforces the per-day guard so nobody gets the same product’s email twice in a day
- queues, sends, and logs every message
What it deliberately does not know is what to send or when. That’s your policy, and you supply it through a few filters. The engine asks; your code answers.
This is why the engine is vendor-neutral (it ships no changelog code at all) and why our changelog logic is a separate plugin you can read, copy, and change.
The support-email filters (and one action)
The admin part is just a Support Email: a name, a product list (the products this email is about), an optional subject + body, and optional audience rules. The product list is the fan-out axis — the engine sends one email per product, with that product in context. Everything changelog-specific is supplied by code, through these hooks. Every one receives the running value plus one argument: an SSE_Support_Context object (described below). These are the real hook names:
| Hook | Type | When it runs | You return / do |
|---|---|---|---|
sse_should_send_support_email |
filter | Once per product, before owners are loaded | true/false — is there news worth sending for this product? A cheap precondition (e.g. “did the version advance?”). Returning false skips the product entirely. Defaults to true. |
sse_support_email_subject |
filter | Per product (and per recipient at send) | The subject string. Optional — for changelog we don’t use it; we set the subject in the GUI to Updates available for {product_name} and let the {product_name} token do the work. |
sse_support_email_content |
filter | Per product (and again per recipient at send time) | An array { body, name } — the email body (the GUI body is the default). For changelog we keep the admin’s body and only fill placeholders inside it ({changelog}, {changelog_url}, {version}). Return the incoming value to keep the GUI body unchanged. |
sse_support_email_should_send_to_user |
filter | Once per owner, after the gate + per-day guard | true/false — should this specific person get it? For per-customer rules (licence, region, recency). Optional; defaults to true. |
sse_support_email_sent |
action | Once per email queued (real send only) | Side effects after a successful send — e.g. record the version we just emailed about. |
The two questions a normal send asks are who gets it (sse_support_email_should_send_to_user, plus the GUI audience rules) and what they get (the GUI subject/body, overridable by sse_support_email_subject / sse_support_email_content). sse_should_send_support_email isn’t a different question; it’s a per-product gate that lets the engine skip a product before the expensive owner lookup. On a day when nothing shipped, it returns false for every product and the engine does almost no work.
Why the loop is products-first
Because sse_should_send_support_email is a cheap per-product precondition, the engine evaluates products first and only resolves owners for the products that actually have news. The order matters: checking “is there an update?” 9 times is far cheaper than loading thousands of owners and then discovering there’s nothing to tell them.
For each product on the support email, the engine runs these steps in order:
| Step | What happens | Hook |
|---|---|---|
| 1 | Is there news for this product right now? If not, skip the product (no owners loaded). | sse_should_send_support_email |
| 2 | Build the subject and body for this product. | sse_support_email_subject, sse_support_email_content |
| 3 | Resolve the product’s owners (direct buyers + bundle owners). | — |
| 4 | Drop owners already emailed this support email + product today (the per-day guard), owners who’ve unsubscribed from product updates or are suppressed (the gate), and owners excluded by the GUI audience rules. | — |
| 5 | For each remaining owner, should this person get it? | sse_support_email_should_send_to_user |
| 6 | Send one email to each surviving owner, and log it. | — |
| 7 | After each successful send, advance our stored “last version”. | sse_support_email_sent |
The result is exactly what you want: one email per product per customer, and at most once per day. A customer who owns two plugins that both updated today gets two separate emails — two changelogs, two history rows — because each product runs through this loop independently. When a new version ships the next day, Filter 1 says “yes” again and they get the next one.
The $context object
Every hook gets a single SSE_Support_Context $context. It tells your code which support email is running, which product (if any), and who the recipient is. The fields you’ll use most:
| Field | Meaning |
|---|---|
$context->support_email_id |
Which support email this is (the saved record id; 0 in an unsaved preview). Lets one site run several different support emails and have each filter know which one is firing. |
$context->product_id |
The single product this run is about. Always set — support emails are products-first. |
$context->subscriber |
The recipient row (id, email, first_name, …). Null in the per-product probe at steps 1–2, set per recipient at steps 5–7. |
$context->matching_product |
This recipient’s purchase of product_id: variation_id, direct (false = got it in a bundle), first_purchased_at, last_purchased_at, days_since_first, days_since_last. |
$context->all_products |
Every product this recipient owns (for “owns X but not Y” logic). |
$context->is_preview |
true during the admin preview. Your filters must be side-effect-free when this is set (see below) — only the sse_support_email_sent action is guaranteed not to fire during a preview. |
Helpers: $context->is_product_mode(), $context->owns( $product_id ), $context->bought_within( $product_id, $days ).
Because there’s a single $context, adding a new piece of information later never changes a filter signature — and a filter can always tell which support email it’s being asked about via support_email_id.
Filter 1 — is there news? (sse_should_send_support_email)
Runs once per product. We map the product to a plugin slug, read its current version, and compare it to the last version we emailed about. The safe baseline: on first sight of a plugin we record the current version and send nobody — so deploying never blasts everyone the changelog they already have. The baseline write is guarded by is_preview — a filter may run during a preview, and writing state there would silently consume the next real release.
add_filter( 'sse_should_send_support_email', function ( $should, $context ) {
$slug = ssp_slug_for_product( $context->product_id ?? 0 ); // e.g. 6018 => 'scalability-pro'
if ( ! $slug ) {
return $should; // not one of ours — leave other implementations alone
}
$current = ssp_current_version( $slug ); // read the Stable tag / version
if ( '' === $current ) {
return false; // can't read a version → never send
}
$opt = 'sse_support_last_sent_version_' . $slug;
$last = get_option( $opt, '' );
if ( '' === $last ) {
if ( ! $context->is_preview ) { // never write state during a preview
update_option( $opt, $current ); // first sight: baseline, email nobody
}
return false;
}
return version_compare( $current, $last, '>' ); // send only when the version advanced
}, 10, 2 );
Filter 2 — what does it say? (sse_support_email_content)
The wording of the email belongs to the admin, in the Support Email editor — so we don’t generate the body, we fill placeholders inside it. The admin writes the body and drops {changelog} (and optionally {changelog_url} / {version}) where they want the update notes; this filter replaces just those tokens with the changelog entries since the last version. We don’t touch the subject here — that’s the GUI field Updates available for {product_name}, which the engine fills per product. Return { body, name }; the core tokens {first_name} / {unsubscribe_url} / etc. are filled per-recipient by the engine after this filter runs, so we leave those in place.
Replace our own tokens unconditionally — even when there are no new entries — so a literal {changelog} can never ship in an email.
add_filter( 'sse_support_email_content', function ( $content, $context ) {
$slug = ssp_slug_for_product( $context->product_id ?? 0 );
if ( ! $slug ) {
return $content; // not one of ours — leave the body untouched
}
$current = ssp_current_version( $slug );
$last = get_option( 'sse_support_last_sent_version_' . $slug, '' );
$changes = ( '' !== $current )
? ssp_changelog_since( $slug, $last ) // the readme entries since $last
: '';
// Fill ONLY our placeholders. The rest of the admin's body — and every core
// token like {first_name} — is left exactly as written.
$body = str_replace(
array( '{changelog}', '{changelog_url}', '{version}' ),
array( $changes, ssp_changelog_url( $slug ), $current ),
$content['body'] ?? ''
);
return array(
'body' => $body,
'name' => ssp_name_for_product( $context->product_id ),
);
}, 10, 2 );
The admin’s body might read simply:
Hi {first_name},
{product_name} just got an update. Here's what changed:
{changelog}
Full changelog: {changelog_url}
Because {changelog} is the only token we own in that body, the admin stays in full control of the surrounding copy, and a product with no new entries this run still produces clean text (the {changelog} line just collapses to empty).
How “send again tomorrow” works (no version field needed in core)
The repeat behaviour is two simple mechanisms working together — there is no reference/version concept in the core engine:
- Same day, don’t double-send → the engine’s per-day guard: at most one email per (support email + product + customer) per calendar day.
- A new version the next day → another email → Filter 1. When the version advances, it returns
trueagain, and since it’s a new day the per-day guard allows the send. The version logic lives entirely in your plugin; a customer who never writes a filter never encounters a “version”.
Filter 3 — should this person get it? (sse_support_email_should_send_to_user)
Optional. By default every owner who isn’t suppressed gets it, so a pass-through is fine. This is where per-customer policy goes. The $context gives you the recipient and their purchase, so you can be specific — for example, skip people who only got the plugin inside a bundle:
add_filter( 'sse_support_email_should_send_to_user', function ( $send, $context ) {
if ( $context->is_product_mode()
&& $context->matching_product
&& ! $context->matching_product->direct ) {
return false; // skip bundle-only owners for changelog emails
}
return $send;
}, 10, 2 );
The action — record what we sent (sse_support_email_sent)
Advance the stored “last version” only after a successful send (never inside Filter 1), so a failure mid-run can’t skip a release:
add_action( 'sse_support_email_sent', function ( $context ) {
$slug = ssp_slug_for_product( $context->product_id ?? 0 );
if ( $slug ) {
update_option( 'sse_support_last_sent_version_' . $slug, ssp_current_version( $slug ) );
}
} );
Getting the full implementation
The helper functions above (ssp_slug_for_product, ssp_current_version, ssp_changelog_since, …) — the product→slug map and the readme/JSON reader — are all in the companion plugin, Super Speedy Plugins — Changelog Emails. Install it, then adapt three things to your own setup:
- The product → plugin-slug map (your WooCommerce product IDs).
- The path to where your plugins’
readme.txt/{slug}.jsonfiles live (or defineSSE_SUPPORT_ASSETS_DIR). - The public changelog URL base.
Then, in Super Speedy Emails → Support Emails, click Add Support Email, give it a name, tick the products to monitor, set the subject to Updates available for {product_name}, write the body and drop {changelog} where you want the update notes to appear (the content filter fills it), and save it Active. On the first run each product’s current version is recorded as a baseline and nobody is emailed; after that, each version bump emails its owners once. Use the Preview button to see the per-product reach (total owners / will receive / will not) before sending, and rehearse in Log-only or Redirect test mode first.
Want a different kind of support email?
Nothing here is changelog-specific except your implementation of the filters. Swap the readme reader for any source and the same engine sends a different email:
- Security advisories — Filter 1 checks a custom post type for new advisories tagged to a product; the content filter renders the advisory.
- Renewal reminders — Filter 1 returns true when a licence is N days from expiry; the content filter builds the reminder.
- Onboarding tips —
should_send_to_userchecks days-since-purchase; the content filter returns the tip for that day.
The engine — products fan-out, audience rules, gate, per-day guard, queue, log — stays exactly the same.
Unsubscribes & compliance
Changelog and product-update emails are not purely transactional — they keep customers informed but carry a soft-marketing flavour, so they must be independently unsubscribable. Under GDPR (the right to object to legitimate-interest mail) and CAN-SPAM (anything with a commercial flavour needs a working opt-out), the answer is the same: give product-update emails their own unsubscribe, separate from your marketing newsletter.
Super Speedy Emails does this automatically — you don’t build anything:
- Every support email carries an unsubscribe. The engine appends a product-update unsubscribe footer to the body (only if your content didn’t already place
{unsubscribe_url}, so there’s never a double footer), and sets the one-clickList-Unsubscribeheader that Gmail and Apple Mail surface at the top of the message. - It’s a separate opt-out. Support emails use the
product_supportsuppression scope. Unsubscribing from your marketing newsletter does not stop product-update emails, and unsubscribing from product updates does not touch marketing, lists, or account mail. The global opt-out (and hard bounces / spam complaints) still stop everything. - It just works for the recipient. Clicking the link (or the one-click header button) records the
product_supportopt-out and shows a confirmation page linking to the full preference centre, so they can fine-tune instead of nuking everything.
Keep genuinely critical mail out of this channel. Security advisories, billing failures, licence-expiry and account/legal notices should be sent as transactional (no opt-out) — not product_support — so a customer who’s silenced your changelog emails still receives the things they can’t miss. Say so plainly in those emails. And never send marketing as product_support to dodge the marketing unsubscribe — use the right category for the message.
- Automated Support Emails — Developer Guide — the full filter reference
- Automated Support & Changelog Emails — the admin-facing how-to
- Which Email Category Should I Use? — why this is
product_support, not marketing