Developers

How We Send Our Changelog Emails (Worked Example)

Published · Last updated

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 type registry – the extension point 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 what it registers; the companion plugin has the rest (the readme reader, the product map, the file paths).

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
  • enforces the frequency cap, picks the right reminder in an escalating sequence, and always attaches an unsubscribe
  • queues, sends, logs, and shows you the send history with opens and clicks

What it deliberately does not know is what to send or when. That’s your policy, and you supply it by registering a type. 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.

Registering a type

One call, on the sse_register_support_email_types action (or plugins_loaded). This is the whole shape of it:

add_action( 'sse_register_support_email_types', function () {
    sse_register_support_email_type( 'changelog', array(
        'label'        => 'Changelog (product update)',
        'mode'         => 'per_product',              // one email per product, or 'per_user'
        'stages'       => 1,                          // 1 body, or N for an escalating sequence
        'placeholders' => array(                      // these appear in the editor's helper panel
            '{changelog}'     => "Today's release: date, link, the first few items, and a count of what's left",
            '{changelog_url}' => 'Link to the full changelog page for the product',
            '{version}'       => 'The current version',
        ),
        'footer'       => "You're receiving this because you own {product_name}.",
        'admin_fields' => array( /* per-record options - see below */ ),
        'callbacks'    => array(
            'should_send'         => 'ssp_changelog_should_send',
            'content'             => 'ssp_changelog_content',
            'should_send_to_user' => 'ssp_changelog_should_send_to_user',
            'on_complete'         => 'ssp_changelog_on_complete',
        ),
    ) );
} );

Admins then create Support Emails of that type in wp-admin, and the editor adapts: it lists your placeholders, renders your options, and dispatches only your callbacks for records of your type. Two types on one site never cross-fire.

CallbackWhen it runsYou return / do
should_sendOnce per product, before owners are loadedtrue/false – is there news worth sending for this product? A cheap precondition (e.g. “did the version advance?”). Returning false skips the product entirely, before the expensive owner lookup. Optional; defaults to true.
contentPer product, then again per recipient at sendAn array { body, name } (and optionally subject) – fills your placeholders inside the admin’s body. Return the incoming value to leave the body untouched.
should_send_to_userOnce per owner, after the gate + per-day guardtrue/false – should this specific person get it? Optional; defaults to true.
on_sentOnce per email queued (real sends only)Per-recipient side effects.
on_completeOnce per product, after its whole fan-out finishedAdvance a per-product baseline here. See the warning below – this is not the same as on_sent.

Every callback receives the running value plus one argument: an SSE_Support_Context object.

Why the loop is products-first

Because should_send 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:

StepWhat happensCallback
1Is there news for this product right now? If not, skip the product (no owners loaded).should_send
2Build the subject and body for this product.content
3Resolve the product’s owners (direct buyers + bundle owners), and their purchase and licence data, in a handful of batched queries.–
4Drop owners already emailed this support email + product today (the per-day guard) or at their frequency cap, owners who’ve unsubscribed from product updates or are suppressed (the gate), and owners excluded by the GUI audience rules.–
5For each remaining owner, should this person get it?should_send_to_user
6Send one email to each surviving owner, and log it.on_sent
7Once the whole product is done, advance our stored “last version”.on_complete

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, step 1 says “yes” again and they get the next one.

The $context object

Every callback gets a single SSE_Support_Context $context. It tells your code which support email is running, which product (if any), who the recipient is, and what the admin chose. The fields you’ll use most:

FieldMeaning
$context->support_email_idWhich support email this is (the saved record id; 0 in an unsaved preview). Lets one site run several different support emails and have each callback know which one is firing.
$context->product_idThe single product this run is about (0 for a per_user type).
$context->subscriberThe 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_productThis 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, and meta (anything a plugin attached to that purchase – licence tier, renewal date).
$context->all_productsEvery product this recipient owns (for “owns X but not Y” logic).
$context->settingsThe per-record options the admin chose, resolved against your declared defaults. Read them with $context->setting( 'key' ).
$context->is_previewtrue during the admin preview. Your callbacks must be side-effect-free when this is set – only on_sent is guaranteed not to fire during a preview.

Helpers: $context->is_product_mode(), $context->owns( $id ), $context->bought_within( $id, $days ), $context->setting( $key, $fallback ), $context->owner_meta( $key ).

Because there’s a single $context, adding a new piece of information later never changes a callback signature.

Callback 1 – is there news? (should_send)

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 callback may run during a preview, and writing state there would silently consume the next real release.

function ssp_changelog_should_send( $should, $context ) {
    $slug = ssp_slug_for_product( $context->product_id ?? 0 );
    if ( ! $slug ) {
        return false;              // we have no changelog to quote - refuse rather than send an empty one
    }

    $current = ssp_current_version( $slug );
    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 );
        }
        return false;                     // first sight: baseline, email nobody
    }

    return version_compare( $current, $last, '>' );
}

Callback 2 – what does it say? (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} where they want the update notes; this callback replaces just those tokens. Core tokens like {first_name} and {unsubscribe_url} are filled per recipient by the engine after this runs, so we leave those in place.

Replace your own tokens unconditionally – even when there are no new entries – so a literal {changelog} can never ship in an email.

function ssp_changelog_content( $content, $context ) {
    $slug = ssp_slug_for_product( $context->product_id ?? 0 );
    if ( ! $slug ) {
        return $content;                             // not one of ours - leave the body untouched
    }

    $changes = ssp_changelog_since( $slug, get_option( 'sse_support_last_sent_version_' . $slug, '' ) );

    return array(
        'body' => str_replace(
            array( '{changelog}', '{changelog_url}',          '{version}' ),
            array( $changes,      ssp_changelog_url( $slug ), ssp_current_version( $slug ) ),
            $content['body'] ?? ''
        ),
        'name' => ssp_name_for_product( $context->product_id ),
    );
}

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.

Note

If you leave a placeholder unfilled, the engine refuses to send. It knows which tokens your type declared, and it checks the finished subject and body for them before anyone is emailed. A record whose type isn’t registered at all – because the plugin providing it is inactive – sends nothing and tells you why in the preview. The engine fails closed here on purpose: a literal {changelog} landing in a customer’s inbox can’t be taken back, and silence is recoverable where a bad blast isn’t.

How “send again tomorrow” works

The repeat behaviour is two simple mechanisms working together – there is no 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 → should_send. When the version advances it returns true again, and since it’s a new day the per-day guard allows the send.

Callback 3 – should this person get it? (should_send_to_user)

This is the per-customer gate, and it’s where our first genuinely interesting decision lives. Rather than hard-code the answer, we let the admin choose it – see the next section.

Letting the admin choose: your own options on the record

A type can declare admin_fields: options that appear in the Support Email editor, save on the record, and come back to your callbacks. This is what turns “one hard-coded audience” into “two Support Emails for the same products, targeting different people, with no extra code”.

Declare them in the registration:

'admin_fields' => array(
    'audience_scope' => array(
        'type'        => 'select',            // select | text | number | checkbox
        'label'       => 'Send to',
        'description' => 'All owners, or only those on an active licence.',
        'default'     => 'all',
        'options'     => array(
            array( 'value' => 'all',            'label' => 'All owners' ),
            array( 'value' => 'active_licence', 'label' => 'Active licence only' ),
        ),
    ),
),

That’s the whole integration. The engine renders the dropdown in the editor, validates what the admin picked against what you declared, stores it on the record, and hands it back:

function ssp_changelog_should_send_to_user( $send, $context ) {
    if ( 'active_licence' !== $context->setting( 'audience_scope', 'all' ) ) {
        return $send;
    }
    return ssp_has_active_licence( $context );
}

Three things worth knowing:

  • Preview honours it before you save. Change the dropdown, click Preview, and the recipient counts move. You see the audience each choice reaches before you commit to it.
  • Only what you declared can be stored. An option you didn’t declare is discarded; a value outside your list falls back to your default. That holds for the editor, for a config import, and for an AI assistant working over MCP – they all go through the same validation.
  • Deactivating your plugin doesn’t erase the admin’s settings. They stay on the record, waiting for the type to come back.

The active-licence gate, end to end

Here’s the real one. A customer’s licence counts as active if any of these is true:

  1. They bought or renewed within the last year (365 days).
  2. Their renewal date is still in the future.
  3. They hold a lifetime licence – which never expires, so they keep getting update emails for as long as they want them, and only ever stop by unsubscribing.
function ssp_has_active_licence( $context ) {
    $row = $context->matching_product;
    if ( ! $row ) {
        return true;                                   // nothing to judge them on - don't silently drop them
    }

    // 1. Bought or renewed within the last year.
    if ( null !== $row->days_since_last && $row->days_since_last <= 365 ) {
        return true;
    }

    $meta = $row->meta ?? array();

    // 2. A lifetime licence never expires.
    if ( 'lifetime' === ( $meta['license_tier'] ?? '' ) ) {
        return true;
    }

    // 3. A renewal date still in the future (it counts for the whole of its last day).
    if ( ! empty( $meta['renewal_date'] ) ) {
        $renewal = strtotime( $meta['renewal_date'] . ' 23:59:59 UTC' );
        return $renewal && $renewal >= time();
    }

    return false;
}

Where the licence data comes from. Super Speedy Emails knows nothing about licences – deliberately. The renewal date and tier are attached to the purchase by a separate plugin of ours when it processes an order, using the generic per-ownership meta any plugin can write. The engine just carries that meta through on the context, loaded in one batched query per run rather than one per recipient. On a product with 3,000 owners, the difference between those two designs is the difference between a daily cron that finishes and one that times out.

What about bundle customers? Someone who got the plugin inside a bundle usually has no licence record of their own. They pass on limb 1 while their bundle purchase is under a year old, and drop out afterwards. That’s the honest reading of “active” – we have no separate licence to consult for them – and it’s why the definition is an OR rather than a single field lookup.

Callback 4 – record what we sent (on_complete, not on_sent)

Best practice

Advance a per-product baseline in on_complete, which fires once after the product’s entire fan-out has finished – not in on_sent, which fires per recipient. Advancing in on_sent moves the baseline on recipient one of a 3,000-recipient run. If that run then dies partway through – a PHP timeout while queueing a big audience is the realistic case – should_send returns false afterwards, and everyone the run never reached is stranded until the next version bump, not merely until tomorrow. Advancing in on_complete means a mid-run death simply leaves the baseline alone: the next run re-covers the product, and the per-day guard stops anyone already queued being emailed twice.

function ssp_changelog_on_complete( $context ) {
    $slug = ssp_slug_for_product( $context->product_id ?? 0 );
    if ( $slug ) {
        update_option( 'sse_support_last_sent_version_' . $slug, ssp_current_version( $slug ) );
    }
}

What the engine gives you for free

Everything on this list is handled for you. It’s most of the reason a changelog-emails companion plugin is a few hundred lines rather than a project:

  • Audience resolution, including bundle owners, and GUI audience rules that narrow by purchase recency.
  • The suppression gate – product-update opt-outs, hard bounces and spam complaints, checked per recipient.
  • The per-day guard and an optional frequency cap (“at most 1 email every 7 days per person”).
  • Escalating reminder sequences – declare stages: 4 and the admin writes four reminders in the editor; the engine picks the right one by send count, and can restart the sequence after a quiet period.
  • Compliance – an unsubscribe link is always present, and the List-Unsubscribe header is set. If your body doesn’t place {unsubscribe_url} itself, the engine appends your footer plus the link.
  • Refusing to send a half-written email – unfilled placeholders and unregistered types are blocked, with the reason shown in the preview.
  • Preview that is the same code path as the send – the recipient counts, and the rendered subject and body, come from the planner the real run uses. Preview also works on unsaved edits, so what’s on screen is what you’re previewing.
  • Send history per support email, with delivered / opened / clicked / bounced / unsubscribed counts.
  • Test modes – rehearse the whole thing in log-only, or redirect every message to your own address.
  • Config export/import, so a support email you built on staging moves to live intact – type, options, reminders, frequency cap and all.
  • MCP control, so an AI assistant can list your support emails, see which types and options exist, and draft a new one as a draft for you to activate.

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:

  1. The product → plugin-slug map (ours derives itself; yours may not need to).
  2. The path to where your plugins’ readme.txt / {slug}.json files live (or define SSE_SUPPORT_ASSETS_DIR).
  3. The public changelog URL base.

Then, in Super Speedy Emails → Support Emails, click Add Support Email, pick the type, 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, choose who it goes to, 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 Preview to see the per-product reach (total owners / will receive / will not) and Preview content to see the actual email, before sending. Rehearse in log-only or redirect test mode first. Scheduled sending is paused by default, so nothing goes out automatically until you explicitly resume it.

Want a different kind of support email?

Nothing here is changelog-specific except our callbacks. Swap the readme reader for any source and the same engine sends a different email:

  • Security advisories – should_send checks a custom post type for new advisories tagged to a product; content renders the advisory.
  • Licence health digests – register a per_user type instead, so each person gets one email about everything they own rather than one per product, with a four-stage escalating reminder sequence the admin writes in the editor.
  • Onboarding tips – should_send_to_user checks days-since-purchase; content returns the tip for that day.

The engine – fan-out, audience rules, gate, guards, 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-click List-Unsubscribe header that Gmail and Apple Mail surface at the top of the message.
  • It’s a separate opt-out. Support emails use the product_support suppression 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_support opt-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.

Don’t use this if
  • The product_support category for critical mail – security advisories, billing failures, licence expiry and account or legal notices belong in transactional, which has no opt-out, so a customer who has silenced your changelog emails still gets them.
  • The product_support category to dodge a marketing opt-out – label marketing as marketing; use the correct category for the message rather than mislabelling it.

Further reading

the equivalent extension point for marketing campaign audiences
why this is product_support, not marketing

Comments

▲
▼
1/1