Flexa Unsubscribe

WordPress plugin · v3.1.1

Flexa Unsubscribe

Adds secure, HMAC-signed unsubscribe links to outgoing WordPress email, blocks mail to recipients who opted out, and gives them a customizable opt-out / re-subscribe page - managed from a React admin.

Requires WordPress 5.8+Tested up to 6.9Requires PHP 7.4GPL v27 bundled locales

Introduction

Overview

Flexa Unsubscribe intercepts every email WordPress sends through wp_mail() and adds a one-click, cryptographically signed unsubscribe link. Once a recipient opts out, the plugin can hard-block any future mail to that address and log every blocked attempt for audit. Recipients can also re-subscribe themselves.

All admin screens - Dashboard, Unsubscribes, Blocked Emails, Re-subscribed, Reasons, Settings and Appearance - are a single-page React app (Vite + TypeScript + shadcn/ui + Tailwind v4) talking to a namespaced REST API. The public-facing unsubscribe and re-subscribe pages are fully themable from the Appearance screen.

PropertyValue
Current version3.1.1
Requires WordPress5.8 or newer
Tested up toWordPress 6.9
Requires PHP7.4 or newer
LicenseGPL v2 or later
Text domainflexa-unsubscribe
REST namespaceflexa-unsubscribe/v1

Architecture

How it works

The plugin hooks the wp_mail filter twice. Order matters: blocking runs first so a fully-blocked send is dropped before any footer work happens.

Filter callbackPriorityResponsibility
flexa_tech_su_block_unsubscribed_emails5Strips recipients on the unsubscribe list. If every recipient is blocked, the send is cancelled and the attempt is logged to the blocked-emails table.
flexa_auto_append_unsubscribe_link10Appends the unsubscribe footer (heading + button) to single-recipient emails, converting plain text to HTML when needed and de-duplicating via an HTML marker.

Both callbacks honour the exclude keywords list: if the subject contains any configured keyword (case-insensitive), that email skips both blocking and auto-append - the escape hatch for transactional mail (orders, password resets, invoices).

On the front end the plugin watches the home URL for a flexa_action query parameter (unsubscribe or resubscribe) plus an email and HMAC token, then renders the themed public page.

Getting started

Installation

  1. Upload the flexa-unsubscribe folder to wp-content/plugins/ (or install the .zip from the Plugins screen).
  2. Activate the plugin through the WordPress “Plugins” menu.
  3. On activation the plugin provisions three database tables and seeds the reasons table with three default opt-out reasons. A versioned schema upgrade then runs once per request whenever the stored schema version is behind the code.
  4. Open Unsubscribe → Settings and enable auto-append and/or blocking as needed.

AUTH_KEY is required

Unsubscribe tokens are signed with the AUTH_KEY salt from wp-config.php (WordPress always defines it). Rotating AUTH_KEY invalidates every unsubscribe and re-subscribe link already sent - recipients would have to use a freshly generated link.

Capabilities

Features

FeatureWhat it does
Auto-append unsubscribe footerAdds a signed unsubscribe button to single-recipient outgoing email. Footer heading text, button label and button colour are all admin-configurable.
Hard recipient blockingRefuses to send to addresses on the unsubscribe list. Each blocked attempt records subject, from-name/email and headers.
Exclude keywordsComma-separated subject keywords that bypass both blocking and auto-append - for transactional mail.
Re-subscribe flowOpted-out users get a signed re-subscribe link and a confirmation page; the row is marked re-subscribed rather than deleted.
Reason captureThe public unsubscribe page can ask why; reasons are admin-managed, sortable, and feed the analytics “by reason” chart.
Themable public pagesColours, typography and every copy string on the unsubscribe / re-subscribe / error pages are editable, with a live preview in the admin.
CSV exportNonce-protected exports of the unsubscribes, blocked and re-subscribed tables.
InternationalizationFully translatable, with seven locales bundled (PHP + JS string translations).

Configuration

Settings · General

Persisted as flexa_tech_su_* options and exposed at GET|PUT /settings/general. Booleans travel the wire as real JSON true/false but persist as '1'/'' strings for back-compat with legacy comparisons.

FieldTypeDefaultNotes
enable_auto_appendbooleanfalseAppend the unsubscribe footer to single-recipient mail.
exclude_keywordsstringOrder, Password, InvoiceComma-separated, max 500 chars. Case-insensitive subject match.
enable_blockingbooleantrueHard-stop outbound mail to unsubscribed addresses.
append_heading_textstring“No longer want to receive these emails?”Line above the button. Max 200 chars, translatable default.
append_button_textstring“Unsubscribe”Button label. Max 100 chars, translatable default.
append_button_colorstring#dc3545Button background. Normalised to a lowercase 6-char hex.

Configuration

Settings · Appearance

Themes the public unsubscribe / re-subscribe / error pages. Exposed at GET|PUT /settings/appearance. Colours are normalised to lowercase 6-char hex; font_family is an enum; font_size must match ^\d+(\.\d+)?(px|em|rem|%)$.

Colours

TokenDefault
bg_color#f0f2f5
box_bg_color#ffffff
text_color#333333
heading_color#495057
button_bg_color#00aad0
button_text_color#ffffff
button_hover_color#0099bb

Typography

TokenDefaultAllowed
font_familysans-serifsans-serif · Arial, sans-serif · Helvetica, sans-serif · Georgia, serif · ‘Times New Roman’, serif · ‘Courier New’, monospace
font_size14pxNumber + px / em / rem / %

Text content

TokenDefault copySanitiser
title_text“Unsubscribed”text
success_message“Email {email} is removed.”wp_kses_post
button_text“Submit Feedback”text
thank_you_title“Thank you for your feedback!”text
thank_you_message“We greatly appreciate your input…”text
home_link_text“Back to Home Page”text
error_title“We’re Sorry, This Link Is No Longer Valid”text
error_message“The unsubscribe link… invalid or has expired.”wp_kses_post
resubscribe_title“Successfully Re-subscribed!”text
resubscribe_message“You have been successfully re-subscribed…”text

HTML-allowed fields

success_message and error_message render unescaped on the public page, so a safe HTML subset is permitted via wp_kses_post (links, <br>, <strong> …). Scripts are stripped. The {email} token in success_message is replaced with the recipient address.

Developer reference

REST API reference

All routes are registered under /wp-json/flexa-unsubscribe/v1/. Every route uses a permission_callback of current_user_can('manage_options') - the sole exception is POST /reason, which is the public feedback submission and authenticates with the same HMAC scheme as the unsubscribe links. Authenticated requests need the WordPress auth cookie plus the X-WP-Nonce header from wp_create_nonce('wp_rest').

Shared list query parameters

The list endpoints (/unsubscribes, /blocked, /resubscribed) share a paging contract and return a uniform envelope:

ParamDefaultBounds
page1min 1
per_page20min 1, max 100
order_byfirst enum valueper-endpoint column allowlist
orderDESCASC | DESC (case-insensitive)
List response envelope
{
  "items":    [ /* rows */ ],
  "total":    137,
  "page":     1,
  "per_page": 20
}

Lists & records

MethodRoutePurpose
GET/unsubscribesPaginated unsubscribe list. order_by: unsubscribed_at | email | reason.
DELETE/unsubscribes/{id}Delete one unsubscribe row.
POST/unsubscribes/importCSV import. multipart/form-data with field file. Skips emails already on the list.
GET/blockedPaginated blocked-attempt log. order_by: blocked_at | email | subject | from_email.
DELETE/blocked/{id}Delete one blocked-log row.
POST/blocked/clearEmpty the entire blocked-log table.
GET/resubscribedPaginated re-subscribed list. order_by: resubscribed_at | email | unsubscribed_at.

Reasons

MethodRouteBody / params
GET/reasonsAll reasons, sorted (not paginated).
POST/reasonsreason_text (text), sort_order (int)
PUT/reasons/{id}reason_text, sort_order
DELETE/reasons/{id}Delete one reason.
POST/reasons/reorderorder: array of reason IDs in new order
POST/reasonPublic feedback submit - HMAC-verified, not capability-gated.

Analytics & settings

MethodRouteParams
GET/analytics/summary-
GET/analytics/by-dateperiod (sanitised key)
GET/analytics/by-reasonlimit (positive int)
GET PUT/settings/generalSee Settings · General.
GET PUT/settings/appearanceSee Settings · Appearance.

Extensibility

Hooks & filters

The supported developer extension point is a single filter that lets you augment the config object handed to the React admin app:

includes/Helpers/Helper.php
add_filter('flexa_unsubscribe_js_config', function (array $config) {
    // $config carries the REST root, nonce and CSV-export nonces
    // consumed by the admin SPA. Extend it for custom UI add-ons.
    return $config;
});

The two wp_mail filter callbacks (flexa_tech_su_block_unsubscribed_emails at priority 5 and flexa_auto_append_unsubscribe_link at priority 10) are internal but documented under How it works so integrators understand ordering against their own wp_mail hooks.

Localization

Internationalization

Text domain flexa-unsubscribe. Both PHP strings (.mo) and the React admin’s JS strings (script-translation .json consumed via wp_set_script_translations) are localised. Seven locales ship in the box:

fr_FR - Frenchde_DE - Germansv_SE - Swedishit_IT - Italianzh_CN - Simplified Chineseja - Japanesear - Arabic

Regenerate the catalog with wp i18n make-pot and wp i18n make-json --no-purge --pretty-print. The JS translation files are keyed by the md5 of the built admin bundle, so re-running make-json after a rebuild is required for the admin UI to stay translated.

Default copy must stay byte-identical

The translatable defaults for the footer text and the appearance copy are duplicated between the read path (includes/core.php / templates) and the REST controller. They must remain byte-identical so both sides resolve the same translation entry.

Data

CSV export & import

Export

Each list screen offers a CSV download handled via admin-post.php. There are three exports - unsubscribes, blocked emails and re-subscribed - each guarded by its own nonce (flexa_export_csv, flexa_export_blocked_csv, flexa_export_resubscribed_csv) verified with check_admin_referer(). Streaming is keyset-paginated on the primary key so memory stays flat regardless of total row count.

Exports contain PII

CSV exports include recipient email addresses (and, for blocked attempts, message subjects and from-headers). Treat the files as sensitive personal data - restrict who can run the export and where the files are stored.

Import

The Unsubscribes screen accepts a CSV upload via POST /unsubscribes/import. The endpoint takes a multipart/form-data body with field file, requires manage_options plus the standard X-WP-Nonce header, and returns a per-row outcome summary.

ColumnRequiredNotes
EmailYesValidated with sanitize_email + is_email. Invalid rows are reported back in failed[].
ReasonNoFree text. Passed through sanitize_text_field.
DateNoParsed with strtotime and stored as MySQL DATETIME. Missing or unparseable values fall back to current_time('mysql').

A header row is auto-detected (case-insensitive matching on Email / Reason / Date with a few aliases). Headerless files are accepted too: if the first cell parses as an email, columns are read by position.

Dedup & safety

  • Skip-on-duplicate: implemented as INSERT IGNORE on the email UNIQUE key. Existing rows are never modified - their original unsubscribed_at and reason stand. Counted under skipped_duplicate in the response.
  • Limits: 2 MiB file size, 10,000 rows per import, first 100 row-level errors returned in failed[] (the failed_count field keeps climbing past 100).
  • Streaming: rows are read with fgetcsv one at a time; the full file is never materialized in PHP memory.
Example response
{
  "imported":          138,
  "skipped_duplicate": 12,
  "failed_count":      2,
  "failed": [
    { "row": 7,   "email": "not-an-email", "error": "Invalid email address." },
    { "row": 142, "email": "",             "error": "Missing email." }
  ],
  "total":             152
}

Reference

Database schema

Three tables are provisioned on activation:

TableKey columns
wp_flexa_unsubscribesid, email (unique), token, reason, unsubscribed_at, resubscribed_at (nullable - set on re-subscribe).
wp_flexa_blocked_emailsid, email, subject, from_email, from_name, headers, blocked_at.
wp_flexa_unsubscribe_reasonsid, reason_text, sort_order, created_at (seeded with 3 defaults).

A re-subscribe does not delete the unsubscribe row; it stamps resubscribed_at, which is what separates the “Unsubscribes” and “Re-subscribed” admin lists. The “Blocked Emails” list is an independent audit log of send attempts that were stopped - not the opt-out list itself.

History

Changelog

v3.1.1

  • New: Plugins-list row now shows Settings and Documentation action links (next to Deactivate) plus a View documentation link in the row's meta line. Both target the public doc site at https://unsubscribe-doc.flexacommerce.com/.
  • Hooks: wired via the standard WordPress plugin_action_links_{basename} and plugin_row_meta filters - no menu position changes, no admin-screen changes.
  • i18n: 3 new translatable strings ("Settings", "Documentation", "View documentation").

v3.1.0

  • New: CSV import on the Unsubscribes screen. New REST endpoint POST /unsubscribes/import accepts a multipart upload and returns a per-row imported / skipped / failed summary.
  • Schema: The import shape matches the existing CSV export (Email, Reason, Date) so an export from one site re-imports cleanly on another. Header row is auto-detected; headerless files work too.
  • Dedup: Skip-on-duplicate via INSERT IGNORE on the email UNIQUE key. Existing rows are never overwritten - the import is idempotent.
  • Safety: 2 MiB file size cap, 10,000 row cap, manage_options + nonce required.
  • i18n: 29 new strings (8 PHP, 21 admin UI) translated across all seven bundled locales.

v3.0.3

  • New: Customizable unsubscribe email footer - the footer heading, button label and button colour are now editable from Settings → General (previously hard-coded).
  • i18n: New footer strings translated across all seven bundled locales.

v3.0.2

  • Security: Sanitise $_GET['email'] / $_GET['token'] at the public read site, with the HMAC token documented as the CSRF layer.
  • Compatibility: Replace inline <style>/<script> in the public templates with the WordPress enqueue API.
  • Docs: Fix the source-repository URL in readme.txt.

v3.0.0

  • Complete admin rewrite - the seven admin pages are now a React SPA (Vite + TypeScript + shadcn/ui + Tailwind v4).
  • New: REST API under /wp-json/flexa-unsubscribe/v1/ covering every screen.
  • New: Dashboard charts, live Appearance preview, client search + server sort/pagination, bookmarkable URL table state.
  • Security: CSV export handlers now verify nonces via check_admin_referer().
  • Change: Menu label “Unsubscribe” at position 60; slug changed flexa-suflexa-unsubscribe (legacy bookmarks 404). Removed the legacy flexa_get_analytics_data AJAX endpoint. Declares Requires PHP 7.4.

v2.0.2

  • Pagination for large lists.

v2.0.1

  • Menu refinements.

v2.0.0

  • Analytics page introduced.