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.
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.
| Property | Value |
|---|---|
| Current version | 3.1.1 |
| Requires WordPress | 5.8 or newer |
| Tested up to | WordPress 6.9 |
| Requires PHP | 7.4 or newer |
| License | GPL v2 or later |
| Text domain | flexa-unsubscribe |
| REST namespace | flexa-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 callback | Priority | Responsibility |
|---|---|---|
flexa_tech_su_block_unsubscribed_emails | 5 | Strips 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_link | 10 | Appends 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
- Upload the
flexa-unsubscribefolder towp-content/plugins/(or install the.zipfrom the Plugins screen). - Activate the plugin through the WordPress “Plugins” menu.
- 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.
- 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
| Feature | What it does |
|---|---|
| Auto-append unsubscribe footer | Adds a signed unsubscribe button to single-recipient outgoing email. Footer heading text, button label and button colour are all admin-configurable. |
| Hard recipient blocking | Refuses to send to addresses on the unsubscribe list. Each blocked attempt records subject, from-name/email and headers. |
| Exclude keywords | Comma-separated subject keywords that bypass both blocking and auto-append - for transactional mail. |
| Re-subscribe flow | Opted-out users get a signed re-subscribe link and a confirmation page; the row is marked re-subscribed rather than deleted. |
| Reason capture | The public unsubscribe page can ask why; reasons are admin-managed, sortable, and feed the analytics “by reason” chart. |
| Themable public pages | Colours, typography and every copy string on the unsubscribe / re-subscribe / error pages are editable, with a live preview in the admin. |
| CSV export | Nonce-protected exports of the unsubscribes, blocked and re-subscribed tables. |
| Internationalization | Fully 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.
| Field | Type | Default | Notes |
|---|---|---|---|
enable_auto_append | boolean | false | Append the unsubscribe footer to single-recipient mail. |
exclude_keywords | string | Order, Password, Invoice | Comma-separated, max 500 chars. Case-insensitive subject match. |
enable_blocking | boolean | true | Hard-stop outbound mail to unsubscribed addresses. |
append_heading_text | string | “No longer want to receive these emails?” | Line above the button. Max 200 chars, translatable default. |
append_button_text | string | “Unsubscribe” | Button label. Max 100 chars, translatable default. |
append_button_color | string | #dc3545 | Button 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
| Token | Default |
|---|---|
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
| Token | Default | Allowed |
|---|---|---|
font_family | sans-serif | sans-serif · Arial, sans-serif · Helvetica, sans-serif · Georgia, serif · ‘Times New Roman’, serif · ‘Courier New’, monospace |
font_size | 14px | Number + px / em / rem / % |
Text content
| Token | Default copy | Sanitiser |
|---|---|---|
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.
Security model
Unsubscribe links (HMAC)
Every link carries a token derived from the recipient’s address and the site’s AUTH_KEY. There is no per-link database row - the token is the proof, verified with a constant-time hash_equals() comparison.
$token = hash_hmac('sha256', $email, AUTH_KEY);
add_query_arg([
'flexa_action' => 'unsubscribe', // or 'resubscribe'
'email' => urlencode($email),
'token' => $token,
], home_url('/'));At the public read site $_GET['email'] and $_GET['token'] are unslashed and sanitised (sanitize_email / sanitize_text_field) before the HMAC is recomputed and compared. The HMAC token is the CSRF protection for these intentionally public links, so a WordPress nonce is deliberately not used here.
Rotating salts
Because the token depends only on the email and AUTH_KEY, changing the WordPress secret keys invalidates all previously-sent links at once. Plan key rotation accordingly.
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:
| Param | Default | Bounds |
|---|---|---|
page | 1 | min 1 |
per_page | 20 | min 1, max 100 |
order_by | first enum value | per-endpoint column allowlist |
order | DESC | ASC | DESC (case-insensitive) |
{
"items": [ /* rows */ ],
"total": 137,
"page": 1,
"per_page": 20
}Lists & records
| Method | Route | Purpose |
|---|---|---|
| GET | /unsubscribes | Paginated unsubscribe list. order_by: unsubscribed_at | email | reason. |
| DELETE | /unsubscribes/{id} | Delete one unsubscribe row. |
| POST | /unsubscribes/import | CSV import. multipart/form-data with field file. Skips emails already on the list. |
| GET | /blocked | Paginated blocked-attempt log. order_by: blocked_at | email | subject | from_email. |
| DELETE | /blocked/{id} | Delete one blocked-log row. |
| POST | /blocked/clear | Empty the entire blocked-log table. |
| GET | /resubscribed | Paginated re-subscribed list. order_by: resubscribed_at | email | unsubscribed_at. |
Reasons
| Method | Route | Body / params |
|---|---|---|
| GET | /reasons | All reasons, sorted (not paginated). |
| POST | /reasons | reason_text (text), sort_order (int) |
| PUT | /reasons/{id} | reason_text, sort_order |
| DELETE | /reasons/{id} | Delete one reason. |
| POST | /reasons/reorder | order: array of reason IDs in new order |
| POST | /reason | Public feedback submit - HMAC-verified, not capability-gated. |
Analytics & settings
| Method | Route | Params |
|---|---|---|
| GET | /analytics/summary | - |
| GET | /analytics/by-date | period (sanitised key) |
| GET | /analytics/by-reason | limit (positive int) |
| GET PUT | /settings/general | See Settings · General. |
| GET PUT | /settings/appearance | See 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:
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:
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.
| Column | Required | Notes |
|---|---|---|
Email | Yes | Validated with sanitize_email + is_email. Invalid rows are reported back in failed[]. |
Reason | No | Free text. Passed through sanitize_text_field. |
Date | No | Parsed 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 IGNOREon theemailUNIQUE key. Existing rows are never modified - their originalunsubscribed_atandreasonstand. Counted underskipped_duplicatein the response. - Limits: 2 MiB file size, 10,000 rows per import, first 100 row-level errors returned in
failed[](thefailed_countfield keeps climbing past 100). - Streaming: rows are read with
fgetcsvone at a time; the full file is never materialized in PHP memory.
{
"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:
| Table | Key columns |
|---|---|
wp_flexa_unsubscribes | id, email (unique), token, reason, unsubscribed_at, resubscribed_at (nullable - set on re-subscribe). |
wp_flexa_blocked_emails | id, email, subject, from_email, from_name, headers, blocked_at. |
wp_flexa_unsubscribe_reasons | id, 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}andplugin_row_metafilters - 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/importaccepts 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 IGNOREon theemailUNIQUE 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-su→flexa-unsubscribe(legacy bookmarks 404). Removed the legacyflexa_get_analytics_dataAJAX endpoint. Declares Requires PHP 7.4.
v2.0.2
- Pagination for large lists.
v2.0.1
- Menu refinements.
v2.0.0
- Analytics page introduced.