Skip to main content

Customizing Email Templates

Benecaster’s default email templates are minimal by design — they work without configuration. When you want to match your brand, change the copy, or reroute sends to a third-party service, every part of the email pipeline is customizable via PHP filters.

Translating an email into another language is a different task from customizing its template. This page covers changing what a template contains; see Translating Benecaster for translating the text it already contains.


Theme Overrides

Copy any template file into your theme (or child theme) to override it. Benecaster checks child theme first, then parent theme, then its own default, then any directories add-ons have registered.

Override path: {theme}/benecaster/emails/{template}.php

The full search order. When the subscriber has a locale, every locale-specific location is checked before any unlocalised one:

  1. {child-theme}/benecaster/emails/{locale}/{template}.php
  2. {parent-theme}/benecaster/emails/{locale}/{template}.php
  3. Benecaster’s templates/emails/{locale}/{template}.php
  4. {add-on directory}/{locale}/{template}.php, for each registered directory in registration order
  5. {child-theme}/benecaster/emails/{template}.php
  6. {parent-theme}/benecaster/emails/{template}.php
  7. Benecaster’s templates/emails/{template}.php
  8. {add-on directory}/{template}.php, for each registered directory in registration order

The first file that exists wins. Two consequences follow. Your theme wins over every template file, including one an add-on ships, so any email you see can be overridden from your theme, unless an email editor has stored content for it (below). And an add-on cannot replace one of Benecaster’s own templates — Benecaster’s default is found before any add-on directory is searched. This is the opposite of front-end templates, where Template Overrides checks add-on directories before the plugin default.

Content stored by an email editor beats your theme’s file. An add-on such as the Email Editor can store a design for an email through benecaster_email_template_body and benecaster_email_template_wrapper; for that email, the stored content is used instead of any benecaster/emails/ override in your theme. Your theme’s file still applies to every email nothing is stored for. Benecaster itself stores nothing, so this only matters once such an add-on is active, and with the Email Editor, only once you save a design or branding: switching it on changes no email. A saved design keeps what was saved, so it doesn’t pick up later changes to Benecaster’s own wording. A stored wrapper with no {{unsubscribe_url}} still gets the unsubscribe link, added at the end of the email, on any email that has one, as it does the business address.

Start from the shipped file, and keep its name. The originals live in templates/emails/ inside the plugin. Copy the one you want rather than writing a new file from scratch — it already has the merge tags and markup wired up — and do not rename it. The override is matched on filename alone, so welcome.php overrides the welcome email and welcome-custom.php overrides nothing and fails silently.

The table below is the complete set; there are no email templates that cannot be overridden this way.

Template file What it controls
base.php The outer HTML shell — doctype, <html>, <body>, and the container <table> that wraps all content. The header (logo, accent color, show name) and footer (unsubscribe link, show name, copyright) are rendered inside this file, not as separate partials — override base.php to change any of them. The body slot is injected here.
welcome.php Body of the welcome email
token-reset.php Body of the token reset email
tier-change.php Body of the tier change email
donation-thank-you.php Body of the donation thank-you email sent after a listener logs a donation reference
subscription-receipt.php Body of the billing receipt email sent on each successful payment
renewal-reminder.php Body of the renewal reminder email sent N days before the billing period ends
payment-failed.php Body of the payment failure email sent once per Stripe retry cycle
cancellation-confirmation.php Body of the cancellation confirmation email sent immediately when a subscriber requests cancellation
subscription-cancelled.php Body of the subscription cancelled email sent at terminal cancellation (billing period ended or all retries exhausted)
promote-grace-reminder.php Grace period reminder sent 6–8 days before a promoted subscriber’s feed token expires
promote-grace-expired.php Expiry notice sent when a promoted subscriber’s grace period ends and their token is revoked
manual-grant-expired.php Sent to subscribers whose admin-comped access expires — dispatched by the hourly manual-grant expiry cron
guest-password-set.php Body of the set-your-password email sent when an account is created for someone rather than by them: bulk enrollment, a subscriber added by hand, or a completed donation. Template variable: $reset_url — a single-use password-reset link
already-following.php Body of the confirmation sent when someone submits the follower signup form with an address that already follows the show. Template variables: $login_url (single-use signed sign-in link), $account_url (plain account page URL), $feed_url (the follower’s existing feed URL) and the five deep links $apple_podcasts_url, $overcast_url, $pocket_casts_url, $castro_url and $podcast_addict_url. The feed URL and links are empty when the URL can’t be recovered, and the shipped template then skips its URL block. A theme override copied from an older version has no URL block and keeps sending the sign-in link alone until you copy the current template again
confirm-follower-signup.php Body of the double opt-in email sent before a new follower’s token is created — the link both confirms the address and completes signup. Template variables: $confirm_url — single-use signed link that confirms the signup and mints the token; $marketing_consent_text — the marketing box’s wording when it was ticked, otherwise empty; $marketing_confirm_url — the second button’s link, which follows the show and confirms that answer, while $confirm_url only follows. An edited copy needs both, with {{marketing_consent_text}} and {{marketing_confirm_url}} in the Email Editor, or nobody can confirm their answer
verify-email.php Body of the verification email sent to a subscriber’s new address when they change it on the account page. Template variable: $verify_url — a single-use link that promotes the new address; the account keeps the old address until it’s clicked
price-change-notice.php Body of the notice sent when a built-in-membership tier or buy-up price change with Move is saved
price-change-reminder.php Body of the optional reminder sent before a subscriber’s first renewal at a changed price
price-change-cancelled.php Body of the confirmation sent when a pending price change is cancelled before it takes effect
cadence-switch-scheduled.php Body of the confirmation sent when a subscriber schedules a self-service switch to another billing period of their tier
tier-switch-scheduled.php Body of the confirmation sent when a subscriber schedules a move down, or to a tier at the same price (not sent for an upgrade)
free-tier-switch-scheduled.php Body of the confirmation sent when a subscriber chooses the show’s free plan. It quotes no price, deliberately
tax-address-needed.php Body of the email sent to a built-in-membership subscriber when Stripe Tax can no longer work out the tax for their billing address, asking them to check it on the account page
tax-threshold-nudge.php Body of the email sent to the site’s admin email address, not a subscriber, when a show’s built-in membership sales pass US$1,000 in a calendar year

manual-grant-expired.php is dispatched by the hourly manual-grant expiry WP-Cron task. On low-traffic sites it may arrive later than expected — see Cron isn’t firing on time →.

Variables Available in Templates

Every template has access to all resolved merge tags. The specific variables available depend on the email type — see Email Merge Tags for the full list. Common global variables always set:

$subscriber_name   // Subscriber's display name
$subscriber_email  // Subscriber's email address
$show_title        // Show display name
$show_url          // Show's public URL: the show's own page
$account_url       // Subscriber's account page; empty if the account page has been deleted
$site_name         // WordPress site name
$unsubscribe_url   // HMAC-signed unsubscribe URL

Link the account page through $account_url, never $show_url. The show page has no plan, payment or billing controls on it. Every your account page link and every Update payment method, Manage subscription and Update your billing address button in the shipped templates uses $account_url; Subscribe and Resubscribe buttons use $show_url (the two Promote grace emails are the exception, see below). A theme override copied from an older template may send these links to the show page; switch them to $account_url, and guard for the empty case as the shipped templates do.

Type-specific variables in welcome.php:

$feed_url            // Subscriber's private RSS feed URL
$tier_name           // Subscriber's tier display name
$apple_podcasts_url  // Deep link for Apple Podcasts
$overcast_url        // Deep link for Overcast
$pocket_casts_url    // Deep link for Pocket Casts
$castro_url          // Deep link for Castro
$podcast_addict_url  // Deep link for Podcast Addict (podcastaddict://subscribe?url=...)
$marketing_consent_text  // A ticked marketing box's wording, for a logged-out free-tier signup; otherwise empty
$marketing_confirm_url   // The link that says yes to it; otherwise empty

An edited Welcome email needs both marketing values, or someone who signed up for a free tier while logged out can’t confirm their answer. With {{marketing_consent_text}} and {{marketing_confirm_url}} in the Email Editor, it’s the same.

The five *_url deep-link variables use non-http(s) schemes. A bare esc_url( $url ) strips a podcast://, overcast://, pktc://, castro:// or podcastaddict:// URL to an empty string, leaving the button’s href blank. The shipped templates escape them with esc_url( $url, \Benecaster\Feed\AppDeepLinks::PROTOCOLS ) — pass the same second argument in any override, or its buttons silently do nothing. A theme override copied before this fix still has empty buttons; the fix is that one-line change per button.

Type-specific variables in token-reset.php:

$feed_url            // New feed URL after reset
$apple_podcasts_url  // Deep link for new URL
$overcast_url
$pocket_casts_url
$castro_url
$podcast_addict_url  // Deep link for Podcast Addict
$custom_message      // Admin's optional message; empty string if not provided
$show_support_email  // Where the subscriber can write: the show's Author Email, or the site admin email

The shipped template ends with a Why did this happen? note built from $show_title and $show_support_email, and leaves out the contact sentence when the address is empty. Guard for the empty case the same way in an override. A theme override copied from an older version has neither the note nor the variable until you copy the current template again.

Same rule as welcome.php above: escape these five with esc_url( $url, \Benecaster\Feed\AppDeepLinks::PROTOCOLS ), not a bare esc_url().

Type-specific variables in tier-change.php:

$old_tier_name  // Previous tier display name
$new_tier_name  // New tier display name

Type-specific variables in donation-thank-you.php:

$show_name         // Show display name
$donation_amount   // Formatted amount with ISO 4217 currency code, e.g. "USD 12.50";
                   // empty string when the donor did not provide an amount
$donation_platform // Human-friendly platform label, e.g. "Ko-fi", "PayPal", "Buy Me a Coffee";
                   // falls back to the raw platform slug when the label is not recognised
$site_name         // WordPress site name
$donor_name        // Donor's name; empty string when not collected — always guard against empty
$donor_message     // Message from the donor; empty string when not collected
$donation_date     // Date of the donation, formatted per WordPress date settings

The donation_thank_you email is only sent when the donor provides an email address during submission. It is skipped silently for anonymous donations. Always guard against empty $donor_name and $donor_message in templates — Stripe donations populate these when the donor supplies them; anonymous or link-mode donations do not.

Type-specific variables in subscription-receipt.php:

$amount_formatted    // Invoice amount: currency code, then the amount at that currency's
                     // own precision, e.g. "USD 9.00", "JPY 1,500", "KWD 5.250"
$invoice_number      // Stripe invoice number, e.g. "INV-0001"
$invoice_hosted_url  // Link to the Stripe-hosted invoice page
$invoice_pdf_url     // Link to download the invoice PDF
$tier_name           // Subscriber's tier display name
$period_end_formatted // Formatted next renewal date
$line_items          // What the payment covers: one item per line, "Description — USD 9.00",
                     // joined by "\n"; empty string when the invoice carries no lines
$subtotal_formatted  // Invoice subtotal; empty string when no tax was charged
$tax_formatted       // Tax on the invoice; empty string when no tax was charged
$tax_inclusive       // '1' when the tax is included in the prices, otherwise empty
$total_formatted     // Invoice total. $amount_formatted stays the amount actually paid
$line_items_incomplete // '1' when $line_items is not every item (Stripe could not be asked
                     // for the rest), otherwise empty; the default template then adds a note

Every one of these is a string, and each is also a merge tag of the same name ({{line_items}}, {{tax_inclusive}} and so on). To print the items, split $line_items on newlines, then split each line on \Benecaster\Membership\InvoiceBreakdown::LINE_SEPARATOR (' — '):

// In a theme override of subscription-receipt.php: one row per item.
foreach ( array_filter( explode( "\n", (string) $line_items ) ) as $item ) {
    [ $what, $amount ] = array_pad( explode( \Benecaster\Membership\InvoiceBreakdown::LINE_SEPARATOR, $item, 2 ), 2, '' );
    printf( '<tr><td>%s</td><td>%s</td></tr>', esc_html( $what ), esc_html( $amount ) );
}

When $tax_inclusive is '1', print the total and the tax it includes, not Subtotal + Tax = Total. With tax included in the prices the subtotal already contains the tax, so a sum would not add up. The shipped template shows Total and Includes tax in that case. A theme override copied before these variables existed keeps working; it just doesn’t show the breakdown until you copy the current template again. Stripe’s hosted invoice, linked from the email, remains the tax receipt.

Type-specific variables in renewal-reminder.php:

$tier_name           // Subscriber's tier display name
$renewal_date        // Formatted renewal date
$days_until_renewal  // Integer: number of days until the billing period ends
$amount_formatted    // Renewal amount, same shape as the receipt's ("USD 9.00",
                     // "JPY 1,500", "KWD 5.250"); empty string when no price can be found.
                     // With Calculate tax automatically on, Stripe's upcoming-invoice total
$billing_interval    // 'month' or 'year'
$subtotal_formatted  // Upcoming subtotal; empty string when there is no tax
$tax_formatted       // Upcoming tax; empty string when there is none
$tax_inclusive       // '1' when the tax is included in the prices, otherwise empty
$amount_before_tax   // '1' when Calculate tax automatically is on but Stripe could not be asked,
                     // so $amount_formatted is the stored, pre-tax price; otherwise empty

With Calculate tax automatically off (Benecaster → Settings → Payments), the four tax variables are empty and $amount_formatted is a prediction from the stored tier and buy-up prices. Each is also a merge tag of the same name. When $amount_before_tax is '1', label the amount as before tax, as the shipped template does, so the subscriber isn’t quoted a figure that leaves the tax out.

Type-specific variables in payment-failed.php:

$tier_name           // Subscriber's tier display name

Type-specific variables in cancellation-confirmation.php:

$tier_name           // Subscriber's tier display name
$period_end_formatted // Formatted date when access ends
$resume_url          // URL to resume the subscription — filterable via benecaster_cancellation_confirmation_resume_url

Type-specific variables in subscription-cancelled.php:

$tier_name           // Subscriber's tier display name
$period_end_formatted // Formatted date when access ends (if cancel_at_period_end was set)
$keeps_purchases     // '1' when the subscriber bought episodes outright and keeps their
                     // private feed for them; '' when the private feed stops
$removed_by_podcaster // '1' when you had already removed the subscriber; '' otherwise

It must also branch on $removed_by_podcaster. When it is '1', the shipped template says they will not be charged again and their access had already ended, and has no resubscribe line or button, because a removal blocks a resubscribe.

A theme override of subscription-cancelled.php must branch on $keeps_purchases. When it is '1', the shipped template tells the subscriber their private feed keeps working, with the episodes they bought and the show’s public episodes, and labels the date Plan ended: instead of Access ended:. An override that ignores it tells a purchaser their feed is about to stop when it is not. The email is never sent to a subscriber who still holds another active plan on the show.

Type-specific variables in tax-address-needed.php: none. It uses only the standard globals. Its Update your billing address button links $account_url, the subscriber’s account page, whose Billing section has the Billing address form. It is always sent, with no per-show switch.

All billing lifecycle templates also receive the standard global variables ($subscriber_name, $show_title, $show_url, $account_url, $site_name). $unsubscribe_url is always empty on them: they are transactional, so they carry no unsubscribe link.

Type-specific variables in price-change-notice.php, price-change-reminder.php, and price-change-cancelled.php:

$item_name    // Tier or buy-up name the change applies to
$old_price    // Current price, formatted with currency
$new_price    // New price the subscriber is moving to, formatted with currency
$effective_date // Date the new price takes effect (formatted)
$notice_days  // Days of notice configured for this change
$timing_sentence // Pre-built sentence describing the notice window — see below

Print $timing_sentence. Do not build your own sentence from $notice_days. The notice’s timing wording branches by how many days of notice were configured — 0 days reads “at your next renewal, on {date}”, 1 day reads “1 day’s notice”, and 2 or more reads “n days’ notice … your first renewal at the new price is on {date}”. $timing_sentence already carries the correct branch; a template built from $notice_days directly would print something like “after 0 days” for the 0-day case, which is wrong and reads as a bug.

Type-specific variables in cadence-switch-scheduled.php:

$from_label      // The billing period the subscriber is on now, e.g. "Monthly"
$to_label        // The billing period they are switching to, e.g. "Annual"
$price_formatted // What they pay from the switch date, formatted with currency
$switch_date     // Formatted date the switch takes effect — the subscriber's next renewal
$later_formatted // A later price on the new billing period, formatted with currency — empty string unless a price change on it lands after the switch
$later_date      // Formatted date of the first charge at $later_formatted — empty string when $later_formatted is

$later_formatted and $later_date are set only when the podcaster has already scheduled a price change on the billing period the subscriber is switching to, and it takes effect after the switch lands — this email is then the subscriber’s notice of that change, so check for an empty $later_formatted before printing anything about a later price.

tier-switch-scheduled.php receives the same six variables, with $from_label and $to_label holding plan names (for example “Silver” and “Gold”) rather than billing periods.

Type-specific variables in free-tier-switch-scheduled.php:

$from_label  // The paid plan the subscriber is on now, e.g. "Gold"
$to_label    // The free plan they are moving to, e.g. "Free"
$switch_date // Formatted date the move takes effect: the end of the period they have already paid for

There is no price variable. Keep it that way in an override: a price line such as “you will be billed 0.00 each period” reads as a billing that continues, to somebody who has just stopped paying. The shipped template also tells the subscriber they keep the same feed address and that extras they pay for end on the same date; keep both.

Type-specific variables in tax-threshold-nudge.php:

$sales_lines   // This calendar year's net sales for the show, after tax and refunds,
               // one currency per line, e.g. "USD 1,200.00"
$year          // The four-digit calendar year, in the site's timezone
$settings_url  // Link to Benecaster → Settings → Payments in wp-admin

Plus $show_title and $site_name. Each is also a merge tag of the same name.

This one goes to the site’s admin email address, not to a subscriber, so there is no subscriber and no unsubscribe link. It is sent at most once a year for each show, the first time the show’s built-in membership sales pass US$1,000, or about the same in other currencies, and never while Stripe Tax is active in the show’s Stripe account or while the show is in test mode. It is transactional. To stop it, return false from benecaster_email_should_send_tax_threshold_nudge:

add_filter( 'benecaster_email_should_send_tax_threshold_nudge', '__return_false' );

Keep an override a prompt, not advice. The shipped email says the figure was passed and that it may be worth talking to an accountant. Don’t add a country, a state or anything that reads as an obligation.

Type-specific variables in manual-grant-expired.php:

$tier_name  // Subscriber's tier display name — may be empty when the tier has since been deleted

All standard globals are also available ($subscriber_name, $show_title, $show_url, $account_url, $site_name, $unsubscribe_url). This template does not receive a $feed_url — the subscriber’s token has already been revoked before the email is dispatched.

Type-specific variables in promote-grace-reminder.php and promote-grace-expired.php:

$tier_name             // The subscriber's original native tier name
$grace_period_end_date // Formatted expiry date
$target_plugin_name    // Name of the destination membership plugin
$signup_url            // The destination plugin's signup page for the subscriber's level,
                       // on this site; '' when none can be found

Available as merge tags: {{tier_name}}, {{grace_period_end_date}}, {{target_plugin_name}}, {{signup_url}}.

These two buttons go to $signup_url, not the show page. The membership plugin’s own checkout is the only place a promoted subscriber can add payment details, so the reminder’s Re-enter payment details button and the expiry email’s Resubscribe button both link it. When $signup_url is empty, the shipped templates link $show_url instead and label the button Visit the show page, so it doesn’t promise a form that isn’t there. Do the same in an override.

The header and footer are rendered inside base.php from a $wrapper_args array, filtered via benecaster_email_wrapper_args. Default keys:

$wrapper_args['logo_url']        // Header logo URL
$wrapper_args['show_name']       // Show name in header/footer
$wrapper_args['accent_color']    // Hex colour for header background
$wrapper_args['footer_text']     // Footer text
$wrapper_args['is_admin_email']  // bool — admin emails omit the unsubscribe link

Use this filter for branding changes — logo, accent color, footer text. Reach for a base.php override only when you need to change the wrapper’s structural markup, since an override means maintaining a copy of that file against future updates.

The Email Editor add-on takes over header and footer rendering when active, so both this filter and any base.php override are bypassed while it is running.


Key Filters

When Your Code Runs: Queued vs. Sent

Benecaster does not send an email in the request that triggers it. It writes the email to a queue, and the actual wp_mail() call happens later, from the benecaster_process_email_queue WP-Cron hook, in a different PHP request. Almost every filter on this page — benecaster_email_headers included — runs at queue time, not at send time.

That gap catches people out in one specific way, so it is worth stating plainly: anything you stash in a static property, a global, or any other request-scoped variable while an email is being queued will not be there when that email is sent. The request it lived in has ended. The failure is silent — mail goes out normally and your data is simply missing, which looks like a problem with your mail service rather than a timing problem.

If you need to run code in the same request as the send — most often to hand Benecaster’s per-email context to a mail plugin’s own outgoing-message filter — hook the benecaster_email_queue_before_send action instead. It fires immediately before each queued email’s wp_mail() call, with the message, the email type, the show and the recipient. See benecaster_email_queue_before_send.

benecaster_mail() is the exception to all of this: it sends immediately, so there is no gap and no cron hop.

Gate Whether an Email Sends

// Suppress all emails of one type:
add_filter( 'benecaster_email_should_send_welcome', function( bool $send, ?int $user_id, ?int $show_id ): bool {
    return false; // suppress welcome; send your own
}, 10, 3 );

Shared filter: benecaster_email_should_send( $should_send, $email_type, $user_id, $show_id ) — fires for every email type.

Important: Never suppress transactional emails (welcome, token_reset) for compliance reasons. Only suppress broadcast or marketing type emails.

This filter is not the same mechanism as the per-show Welcome / Feed URL reset / Tier change switches on Show Settings → Subscription — don’t confuse the two. Those switches are checked before the email is even built, at the point the underlying event fires, so a switched-off email never reaches this filter (or benecaster_email_should_send) at all — there is nothing here to intercept. The welcome switch does not stop a follower’s welcome — a follower has no billing relationship behind which a podcaster could deliver their feed URL another way, so it’s their only route to it (operator ruling). If you need to suppress a follower’s welcome specifically — something the show-level switch cannot do — return false from benecaster_email_should_send_welcome instead. A developer who does this must deliver the feed URL to the follower some other way themselves — the plaintext token is available as the fifth parameter of the benecaster_token_generated action at the moment it fires, and nowhere else afterward.

Change the Feed URL in the Welcome Email

The feed URL placed in the welcome email passes through benecaster_token_url before embedding. Benecaster already has a built-in pretty-permalink format — turn on Use pretty feed URLs at Settings → Permalinks rather than filtering for it. Reach for this filter only for some other URL shape entirely:

add_filter( 'benecaster_token_url', function( string $url, string $token, int $show_id ): string {
    return home_url( '/listen/' . $token );
}, 10, 3 );

If the built-in switch is on, $url arrives already in the pretty form (/podcast/{feed-slug}/feed/{token}/), not the query-string form — a callback written to str_replace( '?token=', …, $url ) or similar finds nothing to replace and silently does nothing. Write your filter to work from $token and $show_id directly, as the example above does, rather than assuming what shape $url arrives in.

Customize Merge Tags per Type

// Add a custom merge tag to welcome emails only:
add_filter( 'benecaster_email_merge_tags_welcome', function( array $tags, ?int $user_id, ?int $show_id ): array {
    $tags['podcast_network_name'] = get_option( 'my_network_name', '' );
    return $tags;
}, 10, 3 );

Shared variant: benecaster_email_merge_tags( $tags, $email_type, $user_id, $show_id ) — fires for every email type. See Email Merge Tags for the full tag catalog.

Tier Change Email

// Suppress tier change email for specific scenarios:
add_filter( 'benecaster_email_should_send_tier_change', function( bool $send, ?int $user_id, ?int $show_id ): bool {
    // return false to suppress
    return $send;
}, 10, 3 );
// Add a custom tag to tier change emails:
add_filter( 'benecaster_email_merge_tags_tier_change', function( array $tags, ?int $user_id, ?int $show_id ): array {
    $tags['support_url'] = 'https://example.com/support';
    return $tags;
}, 10, 3 );
add_filter( 'benecaster_email_wrapper_args', function( array $args, string $email_type, int $show_id ): array {
    $args['logo_url']     = 'https://example.com/custom-email-logo.png';
    $args['accent_color'] = '#1a2e3f';
    return $args;
}, 10, 3 );

Does not fire when the Email Editor add-on is active.

Change the Headers on an Outgoing Email

add_filter( 'benecaster_email_headers', function( array $headers, string $type, ?int $user_id, ?int $show_id ): array {
    if ( 'welcome' === $type ) {
        $headers[] = 'Reply-To: hello@example.com';
    }
    return $headers;
}, 10, 4 );

Per-type variant: benecaster_email_headers_{$type} — same filter for one type only, with $type dropped from the arguments.

On the queued path this filter runs when the email is queued, not when it is sent. Benecaster queues outbound mail and wp_mail() is called later, from cron, in a different PHP request. (benecaster_mail() is the exception — it sends immediately, so the filter runs in your own request there, with the type custom.) The headers you return are stored on the queue row and applied at send time — so do not try to pass state from this filter to a mailer-side filter (phpmailer_init, WP Mail SMTP’s body filters) through a static property or a global. Nothing survives the gap between the two requests. Put what you need into the headers themselves, or hook benecaster_email_queue_before_send, which fires in the sending request — see When Your Code Runs above.

From: set through this filter is dropped. Use benecaster_email_from_name and benecaster_email_from_address instead — those are the supported path, and they are what the Email Editor and the email log read. A message with two From lines is malformed and lands differently in every mail server, so the queue takes the address from its own columns and wins.

The Unsubscribe Headers

Non-transactional Benecaster emails carry RFC 8058 List-Unsubscribe and List-Unsubscribe-Post headers. Gmail, Yahoo and Apple Mail read these and render a native Unsubscribe control next to your name at the top of the message, separate from any link in your footer.

It matters beyond appearances. Gmail and Yahoo have required one-click unsubscribe from bulk senders since 2024, and mail that does not offer it is more likely to be filtered. The unsubscribe URL is HMAC-signed, so the control cannot be used to opt somebody out who did not click it.

Transactional emails carry no unsubscribe headers, and that is deliberate — do not describe this as “all emails”. The protected set is listed under Protecting Transactional Emails from Opt-out Suppression below. Emails with no subscriber context — admin notifications, anything sent without a user and a show — carry none either, and neither do the three types registered with 'unsubscribable' => false (guest_password_set, already_following, email_verification), which are not on that list. A one-click button on one of those would record a preference that is then ignored for that type.

Registering a Custom Email Type

Add-ons that introduce new email types should register them via benecaster_managed_email_types so podcasters can find and edit them:

add_filter( 'benecaster_managed_email_types', function( array $types ): array {
    $types[] = [
        'type'        => 'my_addon_delivery_email',
        'label'       => __( 'Custom Delivery Notice', 'my-addon' ),
        'description' => __( 'Sent when a custom delivery event occurs.', 'my-addon' ),
        'add_on'      => 'my-addon',
    ];
    return $types;
} );

The entry is appended to a list, not keyed by the type string — the slug goes in the type key. Writing $types['my_type'] = [ … ] instead produces an entry every reader of the filter will skip.

Registering is a catalogue entry, not wiring. The filter carries a type, label, description and add-on slug — no subject and no template — so registering a type does not by itself make an email send, render or route. Sending is benecaster_send_email(). The one key that changes behavior is 'unsubscribable' => false, which exempts the type from a subscriber’s opt-out — see Protecting Transactional Emails from Opt-out Suppression below. What registration buys you is that the type is discoverable and editable rather than invisible.

default_template_path is a template name, not a file path. If your type has a template, set it to the same string you pass as $template when sending: my-addon-delivery, never emails/my-addon-delivery.php. Benecaster resolves it through the child theme, the parent theme, its own templates and every directory registered with EmailRenderer::add_template_dir(), so an add-on shipping its own template sets the name and registers its directory. Benecaster itself never reads the key; the Email Editor add-on does, to open the template for editing.

Choose your type string against what is already registered, and check at runtime: apply_filters( 'benecaster_managed_email_types', [] ) returns Benecaster’s own set plus every active add-on. A collision is silent.

Benecaster’s own manual_grant_expired type is registered this way:

  • Label: Manual Grant Expired
  • Description: Sent to subscribers whose admin-comped access expires — dispatched by the hourly manual-grant expiry cron when the grant’s expiry datetime has passed.
  • Template: templates/emails/manual-grant-expired.php; theme override at [theme]/benecaster/emails/manual-grant-expired.php
  • Merge tags: Standard globals (subscriber_name, show_title, show_url, site_name) plus tier_name (may be empty when the tier has since been deleted)

Once registered, also add the type to benecaster_transactional_email_types if it should bypass broadcast opt-out suppression — see below.


Sending Email From an Add-on

Two public functions, and which one you want depends on whether a subscriber should be able to opt out. If the add-on’s emails use templates of its own, register the directory they live in first.

Shipping templates with your add-on

Register the directory on benecaster_boot, against the container’s EmailRenderer — that is the instance Benecaster sends with:

add_action( 'benecaster_boot', function ( $container ) {
    $container->make( \Benecaster\Email\EmailRenderer::class )
        ->add_template_dir( MY_ADDON_DIR . 'templates/emails' );
} );

The directory is the template root itself: templates/emails/delivery-notice.php is the template delivery-notice, and a translated copy goes in a {locale}/ subdirectory beside it (templates/emails/fr_FR/delivery-notice.php). Where it sits in the lookup is shown in the search order under Theme Overrides.

Give every template a name Benecaster does not use. Benecaster’s own templates are found before yours, so an add-on file called welcome.php or base.php is never loaded and nothing reports it. Prefix the names with your add-on’s slug.

A podcaster can still override your templates from their theme, at {theme}/benecaster/emails/{template}.php, exactly as they override Benecaster’s. Document your template names so they can.

Registration does not check that the directory exists — a missing one simply never matches. Registering the same directory twice has no effect, with or without a trailing slash. This is a separate call from benecaster_register_template_directory(), which registers front-end templates; an add-on that ships both registers each directory with its own function. See Ship your own email templates from an add-on for a worked example.

benecaster_send_email() — dispatch a registered type

$queue_id = benecaster_send_email(
    $user_id,
    'my_addon_delivery_email',
    __( 'Your delivery is on its way', 'my-addon' ),
    'my-addon/delivery-notice',
    [ 'show_id' => $show_id ]
);

This is the supported way to perform a send, and it is a pass-through rather than a reimplementation: the should_send gate and unsubscribe suppression, merge tags, template resolution, subscriber locale, every subject/body/from/header filter and both send actions all run exactly as they do for Benecaster’s own emails.

Registering a type does not make it send — the two are separate steps and you need both. Registration makes the type editable in the Email Editor; this function dispatches it.

Do not resolve EmailManager from the container. That was the only route before this function existed, and it made an internal class part of the published surface. The class is not stable; the function is.

Pass show_id in the context whenever you have one. Unsubscribe enforcement needs both the user and the show — omit the show and a subscriber who opted out of that show still gets the mail.

The return value is a queue row ID, not a delivery result. The queue sends later on cron. A 0 means a filter suppressed the send — most often an opt-out, which is not an error and must not be retried.

benecaster_mail() — one-off mail with the podcast’s identity

benecaster_mail(
    $show_id,
    $recipient_email,
    __( 'Your export is ready', 'my-addon' ),
    '<p>' . esc_html__( 'Here it is.', 'my-addon' ) . '</p>'
);

A raw wp_mail() call from add-on code sends as wordpress@yoursite.com. Benecaster’s sender overrides are not bound as global wp_mail_from / wp_mail_from_name / wp_mail_content_type filters — nothing registers those. Identity is computed inside the dispatch pipeline. That leak is the entire reason this function exists, and it applies to any wp_mail() you have already written in add-on code.

It does not queue, log, gate or suppress. No should_send gate runs, so an unsubscribed recipient is not filtered out, and nothing is written to the email log. Anything sent to subscribers in bulk, or anything a subscriber can opt out of, belongs in benecaster_send_email() — register the type and let the queue do its job. benecaster_mail() is for operator alerts, reports and genuine one-offs.

$show_id is filter context, not a per-show sender setting — there is no such setting. The from-name defaults to the site’s blogname; passing the show lets you vary identity per show through benecaster_email_from_name. A From: header you supply is dropped, so the message never goes out with two.


Broadcast Audience Selection

Benecaster resolves audience slugs to WordPress user ID lists when dispatching broadcast emails. Four built-in slugs are available:

Slug Who it includes
'paying' Paying subscribers only — excludes followers and free-tier bridge members
'followers' Direct followers only
'all' Every active token holder for the show: paying + free-tier bridge members + followers
'tier:SLUG' Active token holders on one tier, e.g. 'tier:gold'

Per-tier sends need no code. Pass 'tier:gold' and Benecaster resolves it. If you have a filter callback that hand-rolls tier: handling, delete it — it duplicates Benecaster’s resolution and shadows it.

The slug must match one of the show’s tiers exactly, and the match is case-sensitive. The show’s tiers are the ones in its Tier Mapping on a membership-plugin show, or under Memberships → Tiers on a show using built-in membership. 'tier:Gold' does not find gold. An unconfigured or wrong-cased slug resolves to an empty list rather than erroring, so a typo sends to nobody and looks indistinguishable from a tier nobody has joined. To tell the two apart, check the slug against benecaster_get_show_tiers(). A show’s public tier is a valid slug, but nobody holds a token on it, so it resolves empty.

'tier:follower' does not reach your followers. Follower tokens carry the tier slug follower, and no tier is configured with that slug on a normal install, in Tier Mapping or among a built-in show’s tiers, so it resolves empty. Use 'followers'.

To resolve an audience in your own code, call benecaster_find_audience_user_ids( $show_id, $audience ). It returns the WordPress user IDs the slug reaches and needs nothing but WordPress and Benecaster loaded — useful for a dry-run count before anyone presses Send, an export, or handing the list to an ESP yourself.

An empty result is not distinguishable from an error. A tier nobody holds, a slug the show has not configured, and a typo all come back as an empty list. Do not report “nobody is on that tier” on the strength of that alone.

Unknown slugs resolve to an empty array before the benecaster_broadcast_audience_user_ids filter fires — add-ons can fully define a custom audience this way. Reach for a callback when you want something Benecaster has no opinion about, such as an engagement or recency segment:

add_filter( 'benecaster_broadcast_audience_user_ids', function (
    array  $user_ids,
    string $audience,
    int    $show_id
): array {
    // Guard first. Without this you rewrite every audience at once.
    if ( 'joined-last-30-days' !== $audience ) {
        return $user_ids;
    }

    return my_recent_joiner_ids( $show_id, 30 );
}, 10, 3 );

Guard on $audience as the first line of the callback. The filter runs on every audience resolution, built-ins included, so a callback that rewrites the list unconditionally changes 'paying', 'followers', 'all' and every 'tier:' send at once — which is rarely what anyone means and is easy not to notice until a broadcast reaches the wrong people.

That same property is useful on purpose: one callback can drop opt-outs from every audience before the list reaches the email queue or an ESP. Pair it with \Benecaster\Email\UnsubscribeManager::is_opted_out().

Resolving an audience from inside a callback is safe. Asking for the audience you are currently resolving returns Benecaster’s own answer instead of firing the filter again, so it cannot recurse. A different audience still filters normally — a callback for 'my-segment' may legitimately ask for 'paying'.

See the enumerate-broadcast-audience-recipients recipe for a full working example.


Protecting Transactional Emails from Opt-out Suppression

By default, all email types are subject to opt-out suppression when a subscriber has opted out of broadcast emails — except for a protected set of transactional types. The shipped protected set is UnsubscribeManager::TRANSACTIONAL_TYPES:

welcome, token_reset, tier_change,
migration_welcome, migration_reminder, migration_final, migration_complete, migration_expired,
plan_bumped, threshold_warning, threshold_crossed, downgrade_available,
subscription_receipt, renewal_reminder, payment_failed,
cancellation_confirmation, subscription_cancelled,
tax_address_needed, tax_threshold_nudge,
price_change_notice, price_change_reminder, price_change_cancelled,
cadence_switch_scheduled, tier_switch_scheduled, free_tier_switch_scheduled,
promote_grace_reminder, promote_grace_expired, manual_grant_expired, donation_thank_you

The billing lifecycle types are protected because a broadcast opt-out is a marketing preference. It means “stop sending me announcements”, and it is never consent to stop being told about the subscriber’s own money or access. The grace-period reminders, the notice that comped access has ended and a donation’s thank-you are protected for the same reason. payment_failed is the one that costs you money directly — a subscriber who is never told their card failed does not fix it, and churns on a dunning cycle they could not see.

A broadcast opt-out is not the only opt-out. An all opt-out still suppresses every type, billing included, except a type registered with 'unsubscribable' => false (see below). That is the level a subscriber has to choose deliberately, and adding a type to the protected set does not exempt it from all.

If your add-on introduces a custom email type that should bypass broadcast opt-out suppression, register it via the benecaster_transactional_email_types filter:

add_filter( 'benecaster_transactional_email_types', function( array $types ): array {
    $types[] = 'my_addon_delivery_email';
    return $types;
} );

Types added here are still suppressed when the subscriber has opted out of all emails. To survive that too, register the type with 'unsubscribable' => false instead — see the next section.


Unsubscribe Endpoint

Benecaster provides a built-in unsubscribe endpoint at:

?benecaster_unsub={token}

The token is signed, and it carries everything the endpoint needs: the listener, the show and the level (broadcast, episode_notifications or all). Nothing else goes in the URL, and an edited token opens an Invalid Link page. A link never expires, and it keeps working after a security plugin regenerates the WordPress salts.

A {{unsubscribe_url}} merge tag is filled in on emails a subscriber can opt out of — never on a protected type or one registered 'unsubscribable' => false, so those carry no footer unsubscribe link, matching the headers. You can replace the default URL with a custom branded opt-out page via the benecaster_email_merge_tags filter — see add-custom-merge-tags-to-emails. A custom page can’t build the link from a user and show. Pass the token from {{unsubscribe_url}} through to this endpoint, or read it with UnsubscribeManager::resolve_link() and record the opt-out yourself.

guest_password_set, already_following, email_verification and feed_link are registered with 'unsubscribable' => false, and a subscriber’s opt-out never stops them. UnsubscribeManager::check_opt_out() reads that flag from the benecaster_managed_email_types catalogue before it looks at anything else, and a type carrying it is delivered through both a broadcast and an all opt-out. Each is a one-shot reply to something the person just did, and their only route back into their own account: the password for an account created for them, the response to a signup form they submitted, the link that completes an address change they asked for, and the feed URL their podcaster re-sent with Email link. The person is not stuck with the mail either — the opt-out is still recorded and still silences every other type.

To give your own add-on’s type the same protection, register it with 'unsubscribable' => false. Adding it to benecaster_transactional_email_types only survives a broadcast opt-out; an all opt-out still stops it. The flag has to be declared: a type registered without it stays fully suppressible. Reserve it for mail a person cannot do without, because it overrides a choice the subscriber deliberately made. benecaster_email_should_send still runs for these types like any other, so your own code can still suppress them.

To gate unsubscribe processing (for fraud detection or rate limiting only — never to prevent legitimate opt-outs):

add_filter( 'benecaster_email_unsubscribe_allowed', function( bool $allowed, ?int $user_id, int $show_id, string $type, string $token ): bool {
    // return false only for fraud/abuse prevention
    return $allowed;
}, 10, 5 );

See Unsubscribe Handling for the subscriber-facing flow.


REST Endpoints

For programmatic configuration (integration tests, headless setups, or custom admin UIs):

Endpoint Purpose
GET /benecaster/v1/email-settings Read current email settings including emails_per_hour and queue depth
POST /benecaster/v1/email-settings Update email settings — accepts emails_per_hour (int, 10–2000)

Both require manage_options capability and a valid WP REST nonce.

See Also

Need this built rather than just documented? See our services →