WP Manifestindependent plugin directory
manifest / email / email-opt-in-out

Email Opt-In / Opt-Out Management Plugin self-updates

Records explicit per-campaign opt-in and opt-out decisions for recipients of an external email list, creating the WordPress user only when someone explicitly acts.

by JohnDee · github.com/johndeebdd/email-opt-in-out · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/johndeebdd/email-opt-in-out/archive/refs/heads/main.zip

Ships its own WordPress updater (Plugin Update Checker), so new versions show up under Dashboard → Updates.

Email Opt-In / Opt-Out Management (aiplugin5055)

Records explicit, per-campaign opt-in and opt-out decisions for recipients of an external email list — people who have never visited the site and have no WordPress account. Campaign mail goes out from Gmail by mail merge; the only signal WordPress ever receives is a click, so the link itself carries the recipient's identity.

The governing rule:

Receiving an email or failing to respond does not create a WordPress user. Explicitly opting in or explicitly opting out does.

Implements docs/PRD.md and docs/feature-breakdown.md, structured per docs/wordpress-plugin-architecture-guide.md.


Layout

aiplugin5055.php                 Main plugin file: requires and hook wiring only
src/
├── library/                     The shared action-code library — plain PHP, no
│   │                            WordPress. The one implementation of how a
│   │                            tracking code is built, parsed and linked, used
│   │                            by this plugin and loaded directly from disk by
│   │                            the Gmail Campaign Manager. See its README.md
│   ├── autoload.php             Entry point for callers outside WordPress
│   ├── selftest.php             `php src/library/selftest.php` — vectors and checks
│   └── src/
│       ├── ActionCode.php       build() / parse() / check value
│       ├── ActionUrls.php       Appending the code and the action parameter
│       ├── CampaignCode.php     Shape of a five-character campaign code
│       ├── EmailCodec.php       Reversible Base32-style email encoding
│       └── SiteSettings.php     Alphabet, secret and check-value length
├── aiplugin5055/
│   ├── Actions/
│   │   ├── ActionRecorder.php   Records an explicit opt-in / opt-out
│   │   └── CampaignState.php    Reads per-campaign state and summaries
│   ├── Admin/
│   │   ├── CampaignsScreen.php  Tools screen controller + write handlers
│   │   ├── CampaignsView.php    Tools screen markup
│   │   └── UserProfileSection.php  Per-user state on the user edit screen
│   ├── Campaigns/
│   │   ├── CampaignCodeGenerator.php  Unique code generation (shape: src/library/)
│   │   └── CampaignRepository.php     Campaign records (options, not a table)
│   ├── Codec/
│   │   ├── ActionCode.php       WordPress half of the code: settings + is_email
│   │   └── EmailCodec.php       Delegates to src/library/src/EmailCodec.php
│   ├── Frontend/
│   │   ├── ActionEndpoint.php   Public unsubscribe request handling
│   │   ├── ActionPageView.php   Unsubscribe page markup
│   │   ├── PageContentFilter.php  Unsubscribe UI on a configured WP page
│   │   └── SilentOptIn.php      Records opt-in on any URL carrying a code
│   ├── Meta/
│   │   └── MetaKeys.php         email_campaign_{CODE} keys and protection
│   ├── Rest/
│   │   ├── CampaignsController.php   Campaign CRUD over REST
│   │   ├── Permissions.php           Administrator-only authorization
│   │   ├── StateController.php       Recorded-state queries
│   │   └── TrackingCodeController.php Tracking code generation
│   ├── Support/
│   │   ├── RateLimiter.php      Per-IP limits for the public endpoints
│   │   ├── RejectedCodeLog.php  Ring buffer of rejected codes
│   │   ├── Settings.php         Site alphabet + secret (generated once)
│   │   └── Urls.php             Where the action links point on this site
│   └── Users/
│       └── UserProvisioner.php  Find-or-create, never duplicate
├── css/{admin,public}.css
└── js/
    ├── admin.js                 Admin entry module (wiring only)
    ├── frontend.js              Frontend entry module (wiring only)
    ├── services/TrackingCodeClient.js
    └── ui/{ActionForm,CopyButton,DeleteConfirmation,TrackingCodeGenerator}.js

No build step. JavaScript ships as ES modules with relative imports and is enqueued through the Script Modules API (WordPress 6.5+); the pages work without it.


The action code

A7K2Q  NBSWY3DPEBLW64TMMQ  X4M
└───┘  └────────────────┘  └─┘
  │            │            └── keyed check value (HMAC, 3 chars)
  │            └── the recipient's address, Base32-encoded through a
  │                site-specific shuffled alphabet
  └── campaign code (authorization + metadata key)
  • Decodes with no database lookup, because no recipient record exists.
  • URL-safe, single case, no O/0/I/1, no percent-encoding.
  • The check value detects truncated links and stops someone holding one valid code from forging codes for other addresses in the same campaign.

The alphabet and secret are generated once per site on first use and never rotate — codes already in the wild would stop decoding. Set the filter aiplugin5055_check_value_length to 0 to omit the check value (see PRD Section 19 for what that gives up).


Using it

1. Create a campaignTools → Email Campaigns. You supply a name; the plugin issues the five-character code.

2. Generate tracking codes — the same screen has a generator that takes a list of addresses and returns mail-merge CSV, or call the API:

curl -X POST https://example.com/wp-json/aiplugin5055/v1/tracking-codes \
  -H 'Content-Type: application/json' \
  -d '{"campaign_code":"A7K2Q","emails":["john@example.com"]}'

Generating a code creates no user and stores no address.

3. Mail merge — each row carries opt_in_url and opt_out_url. These URLs are personal data by construction: keep them out of analytics and shared logs.

4a. Recipients click the CTA. The code may ride on any URL of the site — a landing page, a post, the front page — as ?c=CODE. The visit itself is the opt-in: the plugin finds or creates the user, writes email_campaign_{CODE}, and serves the page the visitor asked for, unchanged. There is no form, no confirmation, no notice, and no login — the site says nothing about it. Re-visiting is a no-op while the address already stands at OPTED_IN; an address that has since unsubscribed opts in again. Opt-in has no page setting: opt_in_url defaults to the front page, and the aiplugin5055_opt_in_destination filter (or hand-building the URL) sends recipients anywhere else.

4b. Recipients click unsubscribe. That link still goes to a public page that states what will happen and does nothing until they submit the form. On submission the plugin writes the opt-out. Nobody is ever logged in.


REST API

All routes are under aiplugin5055/v1 and require an authenticated administrator (manage_options, filterable via aiplugin5055_admin_capability).

Method Route Purpose
POST /tracking-code One code + both action URLs
POST /tracking-codes Up to 2000 codes for a mail merge
GET /campaigns List, with opt-in / opt-out counts
POST /campaigns Create (name in, code out)
GET /campaigns/{code} One campaign
PUT/PATCH /campaigns/{code} Rename, or set active / disabled
DELETE /campaigns/{code}?force=true Delete the record only
GET /campaigns/{code}/recipients?state=OPTED_IN Who acted
GET /recipients/state?email=… One address, every campaign

Supplying campaign_code to create or update is an error: the plugin issues codes, and they are immutable because recorded metadata keys embed them.


Stored data

Authoritative record, one per campaign the user has acted on:

meta_key: email_campaign_A7K2Q
{
  "campaign_code": "A7K2Q",
  "campaign_name": "August 2026 launch",
  "state": "OPTED_OUT",
  "opt_in":  { "timestamp": "…", "first_timestamp": "…", "count": 2,
               "reason": "…", "source": "email_campaign", "details": {…} },
  "opt_out": { … },
  "history": [ { "action": "opt_in", "state": "OPTED_IN", … }, … ],
  "account_created_by": "email_action",
  "account_created_reason": "opt_out"
}

email_campaign_A7K2Q_state holds just OPTED_IN / OPTED_OUT so recipient queries are indexed rather than a full scan. Both keys are marked protected, so neither appears in the custom-fields UI. Campaign codes contain no underscore, so the two key shapes cannot collide.

Absence of an entry is NO_RECORD — never an opt-out. Deleting a campaign removes the campaign record only; every recorded decision survives it, and the code is never reissued.


Filters

Filter Default Purpose
aiplugin5055_admin_capability manage_options Capability for all admin surfaces
aiplugin5055_new_user_role site default role Role for created users (privileged roles are refused)
aiplugin5055_send_new_user_notification false Send WordPress's new-user email
aiplugin5055_check_value_length 3 Check-value characters; 0 disables
aiplugin5055_opt_in_destination home_url( '/' ) Where CTA links land; the code is appended to it
aiplugin5055_opt_out_slug email-unsubscribe Unsubscribe path segment
aiplugin5055_rate_limit 60 view / 15 submit / 20 opt_in_probe Requests per window per IP (the opt_in_probe bucket counts only rejected codes)
aiplugin5055_rate_limit_window 600 Window, seconds
aiplugin5055_client_ip REMOTE_ADDR Client IP behind a proxy

Actions: aiplugin5055_campaign_created, aiplugin5055_campaign_updated, aiplugin5055_campaign_deleted, aiplugin5055_action_recorded.


What an action code cannot do

Its entire authority is to set opt-in or opt-out metadata for one campaign on one address, creating that user if needed. It cannot log anyone in, issue a password or reset link, change a role, email address or any profile field, touch another campaign's metadata, reach another user's data, or accept an address supplied in the request in place of the encoded one.

Loading a URL that carries a code does record the opt-in — that is the point of the CTA link — so a mail scanner that follows one opts that recipient in. Opt-in is the reversible direction and the recipient's own unsubscribe always overrides it. Opt-out is not exposed that way: it still requires the confirming submission on the unsubscribe page, and the unsubscribe page never records an opt-in.

Changing rewrite slugs requires re-saving Settings → Permalinks.