Developers

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

Last updated July 7, 2026

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 filter names, their exact 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 filter-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 via WordPress filters:

  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.

How a Support Email is set up

The site admin creates a Support Email in Super Speedy Emails → Support Emails: a name, a product checklist (the products it’s about), optional recency audience rules, and a subject + body. 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 any code to choose products; you write filters to refine when, who and what.

The mental model

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

  1. sse_should_send_support_email   ── "Is there anything to send for this product now?"
     │                                  (per product; false = stop, owners never loaded; default TRUE)
     ▼
  2. sse_support_email_subject / sse_support_email_content   ── build the subject + body
     │
     ▼
  3. SSE resolves the product's owners (direct buyers + bundle owners), then removes:
     │  anyone already emailed this product today (per-day guard), anyone unsubscribed/
     │  suppressed (gate), anyone excluded by the admin's recency audience rules
     ▼
  4. sse_support_email_should_send_to_user  ── "Should THIS owner get it?"  (per owner; default TRUE)
     ▼
  5. SSE personalises tokens, queues, sends, logs, and fires sse_support_email_sent.

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

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 filter know which is firing.
$context->product_idThe single product this run is about. Always set — support emails are products-first.
$context->nameThe product/support-email name.
$context->subscriberThe recipient row (->id, ->email, ->first_name). Null in the per-product probe (steps 1–2); set per recipient (step 4 onward).
$context->matching_productThis recipient’s ownership 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_productsEvery product this recipient owns (for “owns X but not Y” logic).
$context->is_previewtrue during the admin Preview. Your filters must be side-effect-free when this is set — 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 ), $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;
// ... add_filter() calls from this guide go here ...

Filter 1 — WHEN: sse_should_send_support_email (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.

add_filter( 'sse_should_send_support_email', function ( $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
}, 10, 2 );
  • Default is true — a Support Email with content sends unless a filter says otherwise. (Contrast with older versions, where it defaulted to false.)
  • Use it for “is there news?” logic: a version bump, a freshly published advisory, a maintenance window.
  • 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).

Filter 2a — SUBJECT: sse_support_email_subject (optional)

Runs per product (and again per recipient at send). Returns the subject string. Optional — most implementations don’t need it: set the subject in the GUI with a {product_name} token and skip this filter.

add_filter( 'sse_support_email_subject', function ( $subject, $context ) {
    return $subject; // return a string to override the GUI subject
}, 10, 2 );

Filter 2b — BODY: sse_support_email_content (per product)

Runs per product (and again per recipient at send). Returns { body, name }. The incoming $content already holds the GUI defaults ([ 'body' => <gui body>, 'name' => <product label> ]); return it unchanged to keep them.

add_filter( 'sse_support_email_content', function ( $content, $context ) {
    return array(
        'body' => "Hi {first_name},
Here's what changed:
- Faster cache warmup
- New Recheck Licenses button",
        'name' => 'Scalability Pro', // human product name used in the footer
    );
}, 10, 2 );
KeyRequiredMeaning
bodyyesThe email body, plain text. Returning an empty body sends nothing for that product.
nameoptionalA human product name used in the footer.

Back-compat: a subject key returned here is honoured as a fallback if the subject filter returns empty — but prefer the GUI subject / the subject filter.

Tokens replaced per recipient

Include these in the body (or leave them to defaults); the engine fills them for each person:

TokenReplaced with
{first_name}The recipient’s first name (or “there”).
{email}The recipient’s email.
{product_name}The product name.
{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 enabled, else the standalone manage page (best for logged-in customers).
{site_name} / {site_url}Your site name / URL.

Filter 3 — WHO: sse_support_email_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. This is where per-customer business rules live, and it can override the GUI audience rules entirely.

add_filter( 'sse_support_email_should_send_to_user', function ( $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
}, 10, 2 );
  • 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 (changelog only to direct buyers):
add_filter( 'sse_support_email_should_send_to_user', function ( $send, $context ) {
    if ( $context->matching_product && ! $context->matching_product->direct ) {
        return false;
    }
    return $send;
}, 10, 2 );
// Only customers who bought this product within the last year:
add_filter( 'sse_support_email_should_send_to_user', function ( $send, $context ) {
    return $context->bought_within( $context->product_id, 365 );
}, 10, 2 );
// Only customers with an active licence (your own lookup):
add_filter( 'sse_support_email_should_send_to_user', function ( $send, $context ) {
    return my_licence_is_active( $context->subscriber->email );
}, 10, 2 );

Repeat behaviour & the sse_support_email_sent action

There is no version/reference concept in core. Two mechanisms govern repeats:

  • 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.
  • “Send again when it changed” (your filter): sse_should_send_support_email 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.

Advance your stored state only after a successful send, on the action — never inside should_send, so a mid-run failure can’t skip a release:

add_action( 'sse_support_email_sent', function ( $context ) {
    // Fires once per queued email, real sends only (never during preview).
    $slug = ssp_slug_for_product( $context->product_id );
    if ( $slug ) {
        update_option( 'my_last_sent_' . $slug, ssp_current_version( $slug ) );
    }
} );

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. (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
- New button" */ }
// WHEN: only when the version advanced. Safe baseline on first sight — guard the write.
add_filter( 'sse_should_send_support_email', function ( $should, $context ) {
    $slug = my_slug_for( $context->product_id );
    if ( ! $slug ) return $should;
    $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, '>' );
}, 10, 2 );
// WHAT: build the body from the changelog since the last emailed version. Subject is the
// GUI field "Updates available for {product_name}" — we don't override it.
add_filter( 'sse_support_email_content', function ( $content, $context ) {
    $slug = my_slug_for( $context->product_id );
    if ( ! $slug ) return $content;
    $current = my_current_version( $slug );
    $last    = get_option( 'my_last_sent_' . $slug, '' );
    $changes = my_changelog_since( $slug, $last );
    if ( '' === $current || '' === $changes ) return $content;
    return array(
        'body' => "Hi {first_name},
"
                . sprintf( "This plugin has been updated to v%s. Here's what changed:
", $current )
                . $changes,
        'name' => get_the_title( $context->product_id ),
    );
}, 10, 2 );
// Advance the stored version ONLY after a successful send.
add_action( 'sse_support_email_sent', function ( $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

Combines all three decision filters: send for one product, only to customers who bought it in the last year, with custom content. Trigger it by setting a flag by hand, then clear it after.

add_filter( 'sse_should_send_support_email', function ( $should, $context ) {
    if ( $context->product_id === 6018 ) {
        return (bool) get_option( 'my_advisory_pending_6018' );
    }
    return $should;
}, 10, 2 );
add_filter( 'sse_support_email_should_send_to_user', function ( $send, $context ) {
    if ( $context->product_id !== 6018 ) return $send;
    return $context->bought_within( 6018, 365 );
}, 10, 2 );
add_filter( 'sse_support_email_subject', function ( $subject, $context ) {
    return $context->product_id === 6018 ? 'Important security update for Scalability Pro' : $subject;
}, 10, 2 );
add_filter( 'sse_support_email_content', function ( $content, $context ) {
    if ( $context->product_id !== 6018 ) return $content;
    return array(
        'body' => "Hi {first_name},
Please update to 6.23 as soon as you can — it fixes a security issue...
",
        'name' => 'Scalability Pro',
    );
}, 10, 2 );
add_action( 'sse_support_email_sent', function ( $context ) {
    if ( $context->product_id === 6018 ) delete_option( 'my_advisory_pending_6018' ); // 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.
  • The recency audience rules the admin set on the Support Email (bought within / more than N days), applied before your per-user filter.
  • The suppression gate — unsubscribed-from-product-updates / hard-bounced / complained recipients are removed before your per-user filter runs.
  • The per-day guard — no double-sends of the same product to the same person in a day.
  • 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}).

Sending and testing

  • Send from the Send now button on the Support Email editor (per product, AJAX). It shows a Preview first: per product — total owners, will receive, will NOT receive (with reasons).
  • 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 ) );'

(SSE_Support_Emails::daily_check() runs all active records but only if the opt-in cron option is enabled.)

Reference: all hooks at a glance

HookTypeArgsPurpose
sse_should_send_support_emailfilter$should, $contextWHEN (per product): is there anything to send? (default true)
sse_support_email_subjectfilter$subject, $contextSUBJECT: optional override of the GUI subject.
sse_support_email_contentfilter$content, $contextBODY: returns { body, name }.
sse_support_email_should_send_to_userfilter$send, $contextWHO (per owner): should this person get it? (default true)
sse_support_email_sentaction$contextFires after a successful send (real sends only) — advance your state here.

Every callback’s $context is an SSE_Support_Context (see the table above). There is no sse_support_email_product_ids filter — products are the admin’s checklist on the record.

Quick prompt for an AI assistant

“Using this guide, write me a plugin that 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. Guard any state writes against $context->is_preview.”

Everything needed is above: the WHEN/WHO/WHAT filters, the $context object and its helpers, the per-day guard, the sse_support_email_sent action, and where to put the file.

  • How We Send Our Changelog Emails (Worked Example) — the shipping companion plugin, end to end.
  • Automated Support & Changelog Emails — the admin-facing how-to for the Support Emails page.
  • Editing Email Templates — customise the wrapper around your content.
  • Unsubscribes & the Preference Centre — how the separate product-update opt-out works.

Leave a Reply

Your email address will not be published. Required fields are marked *

×
1/1