Developers

Automated Support Emails — Developer Guide: Controlling Who Receives Email and What They Get

Published · Last updated

This is a complete, self-contained guide to Super Speedy Emails’ automated support-email system. It is written so a developer – or an AI assistant the developer hands it to – has everything needed to write the code that controls who receives a support email and what it contains, without reading the plugin source.

If you are an AI assistant: everything you need is on this page – the registration API, the callback signatures, the context object, what to return, where to put the code, and worked examples. You can write a correct implementation from this page alone.

What this system does

Super Speedy Emails (SSE) can email the people who own a WooCommerce product – for example, to tell owners of a plugin that a new version shipped. It is type-driven: SSE provides a generic engine that fans out over a product list, resolves owners (bundle owners included), applies the suppression gate and a per-day guard, then queues, sends and logs. You supply the policy by registering a type:

  1. Whether to send for a product at all (e.g. “did the version change?”).
  2. Who among the owners gets it.
  3. What it says.

Because these are separate, the same engine can send changelog notices, security advisories, renewal reminders, onboarding tips – anything – just by changing the answers.

A type is registered once, in code. Admins then create Support Emails of that type in wp-admin, and the engine dispatches only your callbacks for records of your type. Two types on the same site never interfere with each other.

How a Support Email is set up

The site admin creates a Support Email in Super Speedy Emails → Support Emails: a name, a type (chosen from those registered), a product checklist (the products it’s about), optional recency audience rules, a subject + body, a footer and an optional frequency cap. The product checklist is the fan-out axis – the engine sends one email per product, with that product in context.

You normally don’t write code to choose products; you write a type to control when, who and what – and you can give the admin options of your own (see admin_fields).

The mental model

Send now (or the daily cron) runs a Support Email. For EACH product on it:

  1. should_send            ── "Is there anything to send for this product now?"
     │                         (per product; false = stop, owners never loaded; default TRUE)
     ▼
  2. content                ── fill your placeholders in the admin's subject + body
     │
     ▼
  3. SSE resolves the product's owners (direct buyers + bundle owners) and their purchase
     │  and licence data, then removes: anyone already emailed this product today (per-day
     │  guard) or at their frequency cap, anyone unsubscribed/suppressed (gate), anyone
     │  excluded by the admin's recency audience rules
     ▼
  4. should_send_to_user    ── "Should THIS owner get it?"  (per owner; default TRUE)
     ▼
  5. SSE re-runs `content` for the recipient, personalises tokens, queues, sends, logs,
     │  and fires on_sent.
     ▼
  6. on_complete            ── once, after the whole product finished.

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

Registering a type

add_action( 'sse_register_support_email_types', function () {
    sse_register_support_email_type( 'my-type', array(
        'label'            => 'My email type',        // shown in the editor's type picker
        'mode'             => 'per_product',          // or 'per_user'
        'stages'           => 1,                      // N for an escalating reminder sequence
        'stage_reset_days' => 0,                      // restart the sequence after N quiet days
        'placeholders'     => array(                  // shown in the editor; checked before sending
            '{my_token}' => 'What this token contains',
        ),
        'freq_cap'         => array( 'max' => 0, 'days' => 0 ),   // default cap; 0 = none
        'footer'           => "You're receiving this because you own {product_name}.",
        'admin_fields'     => array( /* your own options - see below */ ),
        'callbacks'        => array(
            'should_send'         => 'my_should_send',
            'content'             => 'my_content',
            'should_send_to_user' => 'my_should_send_to_user',
            'on_sent'             => 'my_on_sent',
            'on_complete'         => 'my_on_complete',
        ),
    ) );
} );

Register on the sse_register_support_email_types action (fired on init, priority 5), or directly on plugins_loaded. Either is early enough for the daily run and the admin editor. Every callback is optional; omit one and the engine uses its default.

ArgumentMeaning
labelShown in the editor’s type picker.
modeper_product = one email per product to that product’s owners. per_user = one email per person, de-duplicated across everything they own (product_id is 0 and matching_product is null).
stages1 for a single body. N renders N subject+body fields in the editor (“Reminder 1 / 2 / … / Final”); the engine picks the one matching this person’s send count and stops at the last.
stage_reset_daysIf a person stops qualifying for this long, they restart at Reminder 1. 0 = never restart.
placeholders{token} => description. Listed in the editor, and checked before sending – see the fail-closed rule below.
freq_cap['max' => N, 'days' => N] – at most N emails per person per N days, for this record. The admin can override it.
footerThe “why you’re receiving this” line, prefilled into the editor and editable per record.
admin_fieldsOptions of your own that appear in the editor – see below.

The $context object (SSE_Support_Context)

PropertyMeaning
$context->support_email_idThe Support Email record id (0 in an unsaved preview). Lets one site run several support emails and have each callback know which is firing.
$context->product_idThe single product this run is about. 0 in per_user mode.
$context->nameThe product/support-email name.
$context->subscriberThe recipient row (->id, ->email, ->first_name, ->wp_user_id). Null in the per-product probe (steps 1 – 2); set per recipient (step 4 onward).
$context->matching_productThis recipient’s ownership of product_id (null in per_user mode): ->owner_id, ->variation_id, ->direct (false = got it in a bundle), ->first_purchased_at, ->last_purchased_at, ->days_since_first, ->days_since_last, ->meta.
$context->all_productsEvery product this recipient owns, same shape (for “owns X but not Y” logic).
$context->settingsThe per-record options the admin chose, resolved against your declared defaults.
$context->ruleThe full Support Email record.
$context->is_previewtrue during the admin Preview. Your callbacks must be side-effect-free when this is set – only on_sent and on_complete are guaranteed not to fire during a preview.

Helpers: $context->is_product_mode(), $context->setting( $key, $fallback ), $context->owner_meta( $key, $product_id = null ), $context->owns( $product_id ), $context->bought_within( $product_id, $days ), $context->owned_row( $product_id ), $context->wc_customer().

Where to put your code

A small must-use plugin, or a normal companion plugin (the changelog implementation ships as its own plugin, ssp-changelog-emails). A theme’s functions.php also works but a plugin is preferred – version-controlled and theme-independent.

<?php
/** Plugin Name: My Support Emails */
if ( ! defined( 'ABSPATH' ) ) exit;

// Only wire up if the engine is present.
if ( function_exists( 'sse_register_support_email_type' ) ) {
    add_action( 'sse_register_support_email_types', 'my_register_type' );
}

Callback 1 – WHEN: should_send (per product)

Runs once per product, before owners are loaded. Return true if this product has something to send right now; false to skip it entirely.

function my_should_send( $should, $context ) {
    // $should  bool                  running decision (DEFAULT TRUE)
    // $context SSE_Support_Context    $context->product_id is set; subscriber is null here
    return $should;   // return false to skip this product
}
  • Default is true – a Support Email with content sends unless your callback says otherwise.
  • Use it for “is there news?” logic: a version bump, a freshly published advisory, a maintenance window.
  • It’s also a cheap way to save work: returning false skips the product before its owners are ever loaded.
  • This is the right place for a safe baseline – but guard any state write with $context->is_preview so a Preview can’t consume a release (worked example below).
Common mistake

Writing state inside should_send without guarding on $context->is_preview. A Preview runs your callbacks too, so an unguarded update_option() there quietly consumes the next real release and nobody gets emailed. Wrap every state write in if ( ! $context->is_preview ).

Callback 2 – WHAT: content (per product, then per recipient)

Runs per product, then again per recipient at send. Returns { body, name } and optionally subject. The incoming $content already holds the editor’s values; return it unchanged to keep them.

The wording belongs to the admin. Rather than generating a body, fill your placeholders inside theirs:

function my_content( $content, $context ) {
    $body = (string) ( $content['body'] ?? '' );

    return array(
        'body' => str_replace( '{my_token}', my_render_for( $context->product_id ), $body ),
        'name' => get_the_title( $context->product_id ),   // human product name for the footer
    );
}
KeyRequiredMeaning
bodyyesThe email body. HTML is detected automatically; plain text is converted at send. An empty body sends nothing for that product.
nameoptionalA human product name used in the footer and {product_name}.
subjectoptionalReturn it to fill your own tokens in the subject line too. The subject is passed in as well, so you can str_replace on it.
Common mistake

Filling your placeholders only when you have something to say. Replace them unconditionally – even with an empty string. If one of your declared tokens is still in the finished subject or body, the engine treats it as a fault and refuses to send the record at all, reporting which token was left unfilled. That’s deliberate: a raw {changelog} in a customer’s inbox can’t be taken back, and silence is recoverable. The same applies if your plugin is inactive – a record whose type isn’t registered sends nothing.

Tokens replaced per recipient

The engine fills these itself, after your content callback runs – so leave them in place:

TokenReplaced with
{first_name}The recipient’s first name.
{email}The recipient’s email.
{product_name}The product name (your name return value).
{unsubscribe_url}The product-update unsubscribe link (a footer is appended automatically if you omit it).
{manage_url}Preference-centre link – the My Account tab if the customer has an account, else the standalone manage page with their token.
{site_name} / {site_url}Your site name / URL.

Double-brace {{token}} is also accepted. Any token neither you nor the engine recognises is left verbatim.

Callback 3 – WHO: should_send_to_user (per owner)

Runs once per owner, after the per-day guard, the gate and the admin’s recency rules. Return true to include this person, false to skip.

function my_should_send_to_user( $send, $context ) {
    // $send    bool                  running decision (DEFAULT TRUE)
    // $context SSE_Support_Context    ->subscriber, ->matching_product, ->all_products set
    return $send;   // return false to skip this individual
}
  • Default is true. You do not need to check unsubscribes/bounces – the gate already removed those.
  • Excluded people show up under Will NOT receive in the Preview, so you can verify the rule.
// Skip bundle-only owners (send only to direct buyers):
function my_should_send_to_user( $send, $context ) {
    if ( $context->matching_product && ! $context->matching_product->direct ) {
        return false;
    }
    return $send;
}

// Only customers who bought this product within the last year:
function my_should_send_to_user( $send, $context ) {
    return $context->bought_within( $context->product_id, 365 );
}

// Only customers whose licence renewal is still in the future (your own owner meta):
function my_should_send_to_user( $send, $context ) {
    $renewal = $context->owner_meta( 'renewal_date' );
    return $renewal && strtotime( $renewal . ' 23:59:59 UTC' ) >= time();
}

Callback 4 – your own options: admin_fields

Rather than hard-coding a decision like the ones above, you can let the admin make it. A type can declare options that appear in the Support Email editor, save on the record, and come back to your callbacks.

'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(          // select only; each { value, label }
            array( 'value' => 'all',            'label' => 'All owners' ),
            array( 'value' => 'active_licence', 'label' => 'Active licence only' ),
        ),
    ),
),

Read them in any callback:

function my_should_send_to_user( $send, $context ) {
    if ( 'active_licence' !== $context->setting( 'audience_scope', 'all' ) ) {
        return $send;
    }
    return my_licence_is_active( $context );
}
  • Always resolved. An option the admin never touched comes back as your declared default, so you never have to special-case a record created before you added the field.
  • Only what you declared can be stored. Undeclared keys are discarded and an out-of-range select value falls back to your default – on every write path: the editor, a config import, and an AI assistant working over MCP.
  • Preview honours it before saving. Change the dropdown, click Preview, and the recipient counts move. The admin sees the audience each choice reaches before committing.
  • Deactivating your plugin doesn’t wipe them. The admin’s stored values stay on the record, waiting for the type to come back.

Callback 5 – recording what you sent: on_sent and on_complete

on_sent fires once per successfully-queued email. on_complete fires once per product, after that product’s entire fan-out has finished. Both are real sends only, never during a Preview.

function my_on_complete( $context ) {
    // Product-level: no subscriber here.
    update_option( 'my_last_sent_' . $context->product_id, my_current_state( $context->product_id ) );
}
Best practice

Advance a per-product baseline in on_complete, not on_sent. on_sent fires on recipient one of the run. If the run then dies partway through – a PHP timeout while queueing a big audience is the realistic case – your should_send returns false afterwards and everyone the run never reached is stranded until your baseline next changes, rather than until tomorrow. on_complete only fires when the fan-out genuinely finished, so 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. Use on_sent for genuinely per-recipient side effects instead.

Per-ownership meta (licence data and anything else)

Any plugin can attach data to a customer’s purchase:

SSE_Product_Owners::set_owner_meta( $owner_id, 'renewal_date', '2027-03-14' );

It comes back on the context, batch-loaded in one query per run:

$renewal = $context->owner_meta( 'renewal_date' );              // for the product in context
$tier    = $context->owner_meta( 'license_tier', $other_id );   // for a specific product
$all     = $context->matching_product->meta;                    // the whole array

This is how licence-aware targeting works without SSE knowing anything about licences.

Repeat behaviour

There is no version/reference concept in core. Repeats are governed by:

  • Per-day guard (core): at most one email per (support email + product + customer) per calendar day. Re-running Send, or an overlapping cron, never double-sends the same day.
  • Frequency cap (the record): when the admin sets one, “at most N per M days” replaces the per-day guard.
  • “Send again when it changed” (your callback): should_send is the only thing that decides there’s news. When your stored “last sent” value advances, it returns true again, and on a new day the guard lets it through.
Did you know?

A customer who owns two products that both have news on the same day gets two separate emails, one per product. The loop runs each product independently, so the per-day guard is scoped to (support email + product + customer), not just the customer. If you’d rather send one email covering everything a person owns, register a per_user type instead.


Complete worked example A – changelog emails (what Super Speedy Plugins actually uses)

The real pattern behind “your plugin was updated” emails. Maps products to plugin slugs, reads the changelog, and uses the safe baseline (guarded against previews) so the first run emails nobody. It also gives the admin a choice of audience. (This is the ssp-changelog-emails companion plugin in miniature.)

<?php
/** Plugin Name: My Changelog Support Emails */
if ( ! defined( 'ABSPATH' ) ) exit;

function my_slug_for( $product_id ) {
    $map = array( 6018 => 'scalability-pro', 24899 => 'super-speedy-filters' );
    return $map[ $product_id ] ?? '';
}
function my_current_version( $slug ) { /* read readme.txt / {slug}.json → '6.23' */ }
function my_changelog_since( $slug, $last ) { /* → "- Faster warmup\n- New button" */ }

add_action( 'sse_register_support_email_types', function () {
    sse_register_support_email_type( 'my-changelog', array(
        'label'        => 'Changelog (product update)',
        'mode'         => 'per_product',
        'placeholders' => array(
            '{changelog}' => 'The release notes since the version we last emailed about',
            '{version}'   => 'The current version',
        ),
        'footer'       => "You're receiving this because you own {product_name}.",
        'admin_fields' => array(
            'audience_scope' => array(
                'type'    => 'select',
                'label'   => 'Send to',
                'default' => 'all',
                'options' => array(
                    array( 'value' => 'all',    'label' => 'All owners' ),
                    array( 'value' => 'direct', 'label' => 'Direct buyers only (not bundles)' ),
                ),
            ),
        ),
        'callbacks'    => array(
            'should_send'         => 'my_changelog_should_send',
            'content'             => 'my_changelog_content',
            'should_send_to_user' => 'my_changelog_should_send_to_user',
            'on_complete'         => 'my_changelog_on_complete',
        ),
    ) );
} );

// WHEN: only when the version advanced. Safe baseline on first sight - guard the write.
function my_changelog_should_send( $should, $context ) {
    $slug = my_slug_for( $context->product_id );
    if ( ! $slug ) return false;              // no changelog to quote → don't send an empty one

    $current = my_current_version( $slug );
    if ( '' === $current ) return false;

    $opt  = 'my_last_sent_' . $slug;
    $last = get_option( $opt, '' );
    if ( '' === $last ) {
        if ( ! $context->is_preview ) {       // NEVER write during a preview
            update_option( $opt, $current );  // first sight: baseline, email nobody
        }
        return false;
    }
    return version_compare( $current, $last, '>' );
}

// WHAT: fill our tokens inside the admin's body. Their wording, our data.
function my_changelog_content( $content, $context ) {
    $slug = my_slug_for( $context->product_id );
    if ( ! $slug ) return $content;

    $current = my_current_version( $slug );
    $changes = my_changelog_since( $slug, get_option( 'my_last_sent_' . $slug, '' ) );

    // Replace unconditionally - even when empty - so a literal {changelog} can never ship.
    return array(
        'body' => str_replace(
            array( '{changelog}', '{version}' ),
            array( $changes,      $current ),
            (string) ( $content['body'] ?? '' )
        ),
        'name' => get_the_title( $context->product_id ),
    );
}

// WHO: whatever the admin picked in the editor.
function my_changelog_should_send_to_user( $send, $context ) {
    if ( 'direct' === $context->setting( 'audience_scope', 'all' ) ) {
        return $context->matching_product && $context->matching_product->direct;
    }
    return $send;
}

// Advance the stored version once the product's whole fan-out is done.
function my_changelog_on_complete( $context ) {
    $slug = my_slug_for( $context->product_id );
    if ( $slug ) update_option( 'my_last_sent_' . $slug, my_current_version( $slug ) );
}

Why the baseline matters: on first deployment every plugin’s stored version is empty, so the first run records the current versions and sends nothing. From then on, only a real bump triggers a send – and because the write is skipped during Preview, rehearsing never silently eats a release.

Complete worked example B – security advisory to recent customers only

One product, only customers who bought it in the last year, with custom content. Trigger it by setting a flag by hand; clear it once the fan-out completes.

add_action( 'sse_register_support_email_types', function () {
    sse_register_support_email_type( 'my-advisory', array(
        'label'        => 'Security advisory',
        'mode'         => 'per_product',
        'placeholders' => array( '{advisory}' => 'The advisory text for this product' ),
        'footer'       => 'You own {product_name}, so we let you know about security issues affecting it.',
        'callbacks'    => array(
            'should_send'         => function ( $should, $context ) {
                return (bool) get_option( 'my_advisory_pending_' . $context->product_id );
            },
            'should_send_to_user' => function ( $send, $context ) {
                return $context->bought_within( $context->product_id, 365 );
            },
            'content'             => function ( $content, $context ) {
                $text = (string) get_option( 'my_advisory_text_' . $context->product_id, '' );
                return array(
                    'body'    => str_replace( '{advisory}', $text, (string) ( $content['body'] ?? '' ) ),
                    'subject' => str_replace( '{advisory}', $text, (string) ( $content['subject'] ?? '' ) ),
                    'name'    => get_the_title( $context->product_id ),
                );
            },
            'on_complete'         => function ( $context ) {
                delete_option( 'my_advisory_pending_' . $context->product_id );  // one-shot
            },
        ),
    ) );
} );

What SSE does for you (so you don’t write it)

  • Owner resolution, including bundle expansion – a bundle buyer counts as an owner of each component product.
  • Purchase and per-ownership meta, batch-loaded once per run rather than per recipient.
  • The recency audience rules the admin set on the Support Email (bought within / more than N days), applied before your per-user callback.
  • The suppression gate – unsubscribed-from-product-updates / hard-bounced / complained recipients are removed before your per-user callback runs.
  • The per-day guard and the frequency cap – no double-sends of the same product to the same person in a day, and no exceeding the record’s cap.
  • Escalating reminder sequences – declare stages and the admin authors each reminder; the engine picks the right one by send count and can restart it after a quiet period.
  • Refusing to send half-written email – unfilled placeholders and unregistered types are blocked, with the reason shown in the Preview.
  • Queuing, sending, retries, logging through the normal pipeline and your provider.
  • The unsubscribe footer + List-Unsubscribe header – every support email carries a working product-update opt-out (footer added automatically if your body omits {unsubscribe_url}).
  • Config export/import and MCP – your type and its options travel with the record, and an AI assistant can read and draft them.

Sending and testing

  • Send from the Send now button on the Support Email editor (per product, AJAX). Preview shows per product: total owners, will receive, will NOT receive (with reasons). Preview content renders the actual email for a real recipient. Both work on unsaved edits, so what’s on screen is what you’re previewing.
  • Scheduled sending is paused by default – resume it from the switch at the top of the Support Emails screen. The manual Send now button is unaffected by the pause.
  • Test safely with Send mode = Redirect (every email comes to you) or Log-only (recorded, not sent), under Settings → Sending.
  • WP-CLI (ad-hoc run of one saved record):
  wp eval '$r = SSE_Support_Rules::get( <record_id> ); print_r( SSE_Support_Emails::run_support_email( $r ) );'

Reference: the whole surface at a glance

NameKindArgsPurpose
sse_register_support_email_type( $key, $args )function–Declare a type.
sse_register_support_email_typesaction–Where to call it (fires on init, priority 5).
should_sendcallback$should, $contextWHEN (per product): is there anything to send? (default true)
contentcallback$content, $contextWHAT: returns { body, name }, optionally subject.
should_send_to_usercallback$send, $contextWHO (per owner): should this person get it? (default true)
on_sentcallback$contextPer queued email, real sends only.
on_completecallback$contextOnce per product, after its fan-out – advance a baseline here.

Every callback’s $context is an SSE_Support_Context (see the table above). The products a support email covers are the admin’s checklist on the record – there is no hook to override them from code.

Quick prompt for an AI assistant

“Using this guide, write me a plugin that registers a support-email type which emails owners of product 12345 whenever I publish a post in the ‘Releases’ category, using the post title as the subject and the post excerpt as the body, only to customers who bought within the last 2 years – with an editor option letting me switch that window between 1 and 2 years. Guard any state writes against $context->is_preview, and replace your declared placeholders unconditionally.”

Everything needed is above: the registration API, the WHEN/WHO/WHAT callbacks, admin_fields, the $context object and its helpers, the per-day guard, on_complete, and where to put the file.

Further reading

the shipping companion plugin, end to end.
the admin-facing how-to for the Support Emails page.
every public hook, with signatures.
customise the wrapper around your content.
how the separate product-update opt-out works.

Comments

▲
▼
1/1