Skip to main content

UnsubscribeManager

\Benecaster\Email\UnsubscribeManager

Class Free

Manages subscriber email opt-out preferences and unsubscribe links, and enforces those preferences at dispatch time by hooking benecaster_email_should_send.

Preferences are stored per show as JSON in the benecaster_email_preferences user meta key, under a show_{show_id} key holding broadcast_opted_out, broadcast_opted_out_at, episode_notifications_opted_out, episode_notifications_opted_out_at, all_opted_out and all_opted_out_at. A flag that was never saved reads as not opted out.

An unsubscribe link is one signed token, ?benecaster_unsub={token}, covering the listener, the show and the level. Nothing in the URL can be edited without breaking the signature, so a link works only for the person, show and level it was minted for. It never expires, and it keeps working after the WordPress salts are regenerated. Build one with build_unsubscribe_url() and read one with resolve_link(); nothing else can.

There are three opt-out levels, listed in SCOPES, and the difference is the whole design. broadcast suppresses non-transactional email such as broadcasts and digests, while transactional types continue. episode_notifications suppresses new-episode emails only, so leaving one does not stop announcements and leaving announcements does not stop new-episode mail. all suppresses every Benecaster email, billing included, except a type registered with 'unsubscribable' => false; it is the level a subscriber has to choose deliberately. Which level a type answers to is scope_for_type(); the map is Benecaster’s and not filterable.

Methods

Method Visibility Since Description
check_opt_out( bool $should_send, string $type, ?int $user_id, ?int $show_id ): bool Public — benecaster_email_should_send callback - the single enforcement point. A type registered with 'unsubscribable' => false in the benecaster_managed_email_types catalogue is checked first and always passes through. Otherwise returns false when the user has opted out at all, or when the type is non-transactional and they have opted out of the level scope_for_type() gives for it. Passes $should_send through unchanged when either id is null, so site-level mail with no subscriber attached is never suppressed here.
is_opted_out( int $user_id, int $show_id, string $type = 'broadcast' ): bool Public — Whether a user has opted out at the given level for a show. $type is one of SCOPES: broadcast, episode_notifications or all. The supported way for an add-on to drop opt-outs from an audience before handing it to the email queue or an ESP.
get_preferences( int $user_id, int $show_id ): array Public — The preference array for one user/show pair, or the default structure when nothing has been saved.
opt_out( ?int $user_id, int $show_id, string $type, string $method = 'api', string $token = '' ): void Public — Records an opt-out. $method is how it was initiated and is recorded by UnsubscribeLog - one_click_header, footer_link, subscriber_dashboard, admin_action, api. $user_id may be null when opting out via token without being logged in: user meta is skipped in that case but the hook still fires.
resubscribe( ?int $user_id, int $show_id, string $type, string $method = 'api' ): void Public — Clears an opt-out at the given level.
build_unsubscribe_url( int $user_id, int $show_id, string $type = 'broadcast' ): string Public — The signed unsubscribe URL for a subscriber and show, for the level $type (one of SCOPES). The only way to make one: the URL cannot be composed by hand. Pass scope_for_type() of the email's type so the link leaves the right level. Returns '' for an unknown level or when no signing key could be stored, so a caller sends no link rather than a broken one.
static scope_for_type( string $type ): string Public — The opt-out level a non-transactional email type answers to: episode_notifications for episode_notification, broadcast for every other type. The footer link and List-Unsubscribe header Benecaster adds are built for this level. Not filterable.
resolve_link( string $token ): ?array Public — Who, which show and which level an unsubscribe token is for, as [ 'user_id' => int, 'show_id' => int, 'scope' => string ]. Null when it is not a good link: a bad or edited signature, a token minted for another purpose, an unknown level, or a user who no longer exists. Every field comes from inside the signature. A custom branded opt-out page that replaces {{unsubscribe_url}} reads the incoming token with this, since it can no longer build or check the URL from a user and show.
opted_out_map( int $user_id, int $show_id ): array Public — One boolean per level in SCOPES, true where the user has opted out for the show. The shape the account page's Email preferences and the Subscribers screen both read.
suppression_reason( string $type, ?int $user_id, ?int $show_id ): ?string Public — Why this class would refuse a send: opted_out_all, opted_out_broadcast or opted_out_episode_notifications, or null when it would not. check_opt_out() is defined in terms of it, so the gate and the reason written to the email log always agree. Null does not mean the email was sent: another benecaster_email_should_send callback can still refuse it.
static is_transactional_type( string $type ): bool Public — Whether a type passes a broadcast opt-out, exactly as the send gate decides it: Benecaster's list plus benecaster_transactional_email_types. Public as benecaster_is_transactional_email_type(). Transactional is not the same as un-opt-out-able: an all opt-out still stops a transactional type.
inject_unsubscribe_url( array $tags, string $type, ?int $user_id, ?int $show_id ): array Public — Adds the unsubscribe URL to the merge-tag set for an outgoing email.
inject_list_unsubscribe_headers( array $headers, string $type, ?int $user_id, ?int $show_id ): array Public — Adds List-Unsubscribe and one-click headers to an outgoing email. Omitted for transactional types, for types registered 'unsubscribable' => false (a one-click button there would record a preference the type ignores), and when the email has no subscriber and show context.
register(): void Public — Registers the hook callbacks. Called during boot.

Constants

Name Value Description
SCOPES 'broadcast', 'episode_notifications', 'all' The opt-out levels. Each is a preference key and a log event type, so only Benecaster adds one.
QUERY_ARG 'benecaster_unsub' The one query argument an unsubscribe URL carries, holding the signed token.
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, price_change_notice, price_change_reminder, price_change_cancelled The email types that bypass a broadcast opt-out. Private; read it through the benecaster_transactional_email_types filter, which is applied on every check. Spans account, the migration sequence, plan/license notices, billing lifecycle (subscription_receipt, renewal_reminder, payment_failed, cancellation_confirmation, subscription_cancelled) and price changes (price_change_notice, price_change_reminder, price_change_cancelled).

Notes

A broadcast opt-out is a MARKETING preference. It means "stop sending me announcements", and it must never be read as consent to stop being told about the subscriber's own money.

payment_failed is the transactional type that costs the podcaster 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.

Four types look protected and are not. donation_thank_you, promote_grace_reminder, promote_grace_expired and manual_grant_expired are managed email types and appear in email settings, but they are not in TRANSACTIONAL_TYPES and a broadcast opt-out suppresses them.

Adding a type to the set does not exempt it from all. An all opt-out still stops every transactional type, billing included, and that is the level a subscriber has to choose deliberately.

'unsubscribable' => false is the only exemption that beats all. Benecaster registers it on guest_password_set, already_following and email_verification: each is a one-shot reply to something the person just did and their only route back into their own account. The flag is read from the benecaster_managed_email_types catalogue on every check, and absent means suppressible - the exemption has to be declared, never inferred. An add-on can register its own type with it, but it overrides a choice the subscriber deliberately made, so it belongs only on mail a person cannot do without.

Add-ons that only need to survive a broadcast opt-out add the type through the benecaster_transactional_email_types filter.