# 404 Emails

Connects a WordPress site to your [404 Emails](https://404emails.com)
workspace - in one click, with **Connect to 404 Emails** - through the
Generic Integration API (`/api/v1/*`). This plugin is a **thin client
only** - every rule about contacts, consent, subscription state,
suppression, groups/tags, and double opt-in lives on the 404 Emails
server. This plugin never reproduces any of that logic in PHP; it maps
WordPress data into a small, normalized shape and calls the existing
authenticated API.

Ships with two integration providers: **Event Tickets** (The Events
Calendar) and **WooCommerce**. See [Provider architecture](#provider-architecture)
for how a future provider (Contact Form 7, Gravity Forms, ...) would be
added without touching the API client at all.

## Requirements

| | Minimum |
|---|---|
| WordPress | 6.5 (for the bundled Czech translation's `.l10n.php` file) |
| PHP | 7.4 |
| Event Tickets (optional) | Any currently-supported release exposing `event_tickets_rsvp_attendee_created` and, for paid/free ticket sync, `tec_tickets_commerce_order_status_flag_{$flag}` (introduced in Event Tickets 5.1.9) |
| Event Tickets Plus (optional) | Required only for the in-form marketing-consent checkbox - see [Event Tickets support](#event-tickets-support) |
| WooCommerce (optional) | Any release exposing `woocommerce_register_additional_checkout_field()` (the Checkout Block field API) for the in-checkout consent checkbox - see [WooCommerce support](#woocommerce-support). Orders still sync without it, just without the checkbox. |

The connection/settings screen works with **no** Event Tickets dependency
at all - it only degrades gracefully (shows "Event Tickets is not
active") when that plugin isn't present, per the project's own "fail
gracefully" requirement. Multisite: settings, logs, and the retry queue
are all per-site (standard WordPress options/tables), not
network-wide - each site in a multisite network connects to its own 404 Emails workspace (or none at all) independently.

## Installation

1. Download the plugin from 404 Emails (**Settings → API keys → WordPress →
   Download 404-emails.zip** - built from this folder, without `tests/`
   and the Composer/PHPUnit files), or zip this folder yourself.
2. In WordPress: **Plugins → Add New Plugin → Upload Plugin**, choose the
   .zip, **Install Now**, then **Activate**.
3. Open **404 Emails** in the WordPress menu.

## Connecting

Click **Connect to 404 Emails**. WordPress sends you to 404 Emails, you
sign in, pick a workspace you own or administer and click **Allow**, and
you're back in WordPress, connected - no API key to copy. Under the hood
(`includes/class-connect.php`, and `lib/integrations/wordpress-connect.ts`
on the 404 Emails side) this is an OAuth-style hand-off with PKCE:

1. The plugin keeps a random `state` and a secret `verifier` for the
   current admin (a 15-minute transient) and sends the browser to
   `/connect/wordpress?return=…&state=…&challenge=…`, where `challenge` is
   base64url(SHA-256(verifier)).
2. After **Allow**, 404 Emails sends the browser back to this plugin's
   page with a one-time `code` and the `state`.
3. The plugin checks the `state`, then swaps `code` + `verifier`, server
   to server, at `POST /api/v1/connect/wordpress/token` for the site's own
   API key (`FORMS_SUBMIT` + `CONTACTS_WRITE`, named "WordPress - your-site").
   The verifier never leaves the WordPress server, so a code seen in a
   browser history or log is useless; codes work once, for 10 minutes.

**Disconnect** revokes the site's key on 404 Emails
(`POST /api/v1/connection/revoke`) and forgets it here; every other
setting is kept. Owners and admins can also disconnect a site from 404
Emails (**Settings → API keys → WordPress**).

The Connect button needs the WordPress admin on HTTPS (or a local
development address - `localhost`, `*.test`, `*.local`). Otherwise, or if
you prefer, use **Advanced: connect with an API key**:

1. In 404 Emails, go to **Settings → API keys** and create a key with the
   **Create and update contacts** and **View and submit signup forms**
   permissions. Copy it - it's shown **exactly once**.
2. In WordPress, paste the key (the platform URL is already filled in),
   then **Save settings** and **Test connection**.

Then choose which published 404 Emails form registrations go through,
under **Destination form** (the dropdown calls `GET /api/v1/forms` live).

To point a test site at another 404 Emails install, define
`EMAILS404_PLATFORM_URL` in `wp-config.php`.

## Making and editing forms

**404 Emails → Forms** lists every form with how many people signed up
(all time and this week). **New form** starts from one of 404 Emails'
ready-made forms (in your audience's language, in your brand colours) or a
blank one. The editor changes the heading, text, name fields, permission
text, double opt-in, groups, the button and colours, and the thank-you
message, with a live preview beside it. **Save and publish** puts it live;
**More design options** opens the same form in 404 Emails (fonts, field
order, tags). A **dashboard box** shows this week's sign-ups.

Editing needs the "Create and edit your signup forms" permission. Sites
connected with an earlier version are asked to reconnect once.

## Sign-ups from your other forms

Already using **Contact Form 7**, **WPForms** or **Elementor Pro** forms?
**404 Emails → Other forms** lists them. For each one, choose the 404 Emails
form people join and who is added:

- **Only people who tick a checkbox** on it - the checkbox wording is saved
  in 404 Emails as their consent, or
- **Everyone who sends it** - only for a form that is clearly a newsletter
  sign-up.

Only the email address and name are sent - never the message or other
fields. The chosen 404 Emails form's double opt-in, groups and welcome
emails apply. An unticked box sends nothing.

## Sign-up forms on event pages

For sites that show events with The Events Calendar and take sign-ups with an Elementor Pro form on each event page (often a separate copy of the form per event). **404 Emails → Other forms → Sign-up forms on event pages**: switch on the tick-box, set its wording and choose the 404 Emails form people join.

- The tick-box is added to the Elementor form on every single event page (`is_singular( 'tribe_events' )`) through `elementor/widget/render_content`, in Elementor's own acceptance-field markup, after the form's last tick-box (or before its button). Never ticked in advance. Nothing is saved in Elementor or in the events, so new events get it too.
- On `elementor_pro/forms/new_record`, a submission from an event page (Elementor's posted `queried_id`) is sent only when `form_fields[emails404_newsletter]` was ticked - email and name only, with the wording as `consentText`. Phone, billing and message fields are never sent.
- A form also connected on its own in the table below keeps that connection and isn't sent twice.

Code: `includes/class-event-page-forms.php`; tests: `tests/test-event-page-forms.php`.

## Forms on your site

Once connected, the plugin page lists your published 404 Emails forms.
There are three ways to show one - all of them print 404 Emails' own
`/embed.js` snippet (`includes/class-embed.php`), with exactly the options
**Installation options** gives in 404 Emails (a shared test,
`tests/fixtures/embed-cases.json`, keeps the two identical):

- **The "404 Emails form" block** (`blocks/form/` - plain JavaScript, no
  build step): pick a form and show it in the page, as a pop-up (opening
  on click, after a few seconds, on scroll or when leaving) or as a corner
  button. In-page forms are previewed live in the editor from the hosted
  form. The block is rendered on the server, so changes made in 404 Emails
  show up without editing the page.
- **The `[404emails]` shortcode**, for page builders and the classic editor:

  ```
  [404emails form="FORM_ID"]
  [404emails form="FORM_ID" mode="popup" trigger="delay" delay="8" label="Get 10% off"]
  [404emails form="FORM_ID" mode="floating" position="bottom-left" label="Join our list"]
  ```

  Options: `mode` (`inline`, `popup`, `floating`), `label`, `position`
  (`bottom-right`, `bottom-left`), `trigger` (`click`, `delay`, `scroll`,
  `exit`), `delay` (seconds, 1-120), `scroll` (%, 10-90), `frequency` (days
  before showing again, 0-365), `launcher="no"` (an automatic pop-up
  without its button).
- **Show a form on every page** (`includes/class-site-display.php`): one
  form as a pop-up or corner button across the whole site, printed in the
  footer - except on pages you list (and everything under them) and, by
  default, the WooCommerce cart, checkout and account pages.

The form itself - wording, consent, double opt-in, thank-you message - is
404 Emails' hosted form, so it behaves exactly as everywhere else. The
embed script follows the page's language (Czech pages get "Zavřít" and
"Odebírat novinky").

## Languages

The plugin's own screens are written in English and translated on
translate.wordpress.org, which WordPress downloads by itself (text domain
`404-emails`). The plugin doesn't ship or load translation files - the
WordPress.org review asks for exactly that. The Czech translation is kept in
`integrations/wordpress/translations/` (`404-emails-cs_CZ.po`, plus the
`404-emails.pot` template) to import on translate.wordpress.org once the
plugin is listed. What visitors see (the forms) comes from 404 Emails in the
workspace's language either way.

## API key storage: the honest tradeoff

The API key is stored as a normal WordPress option (`wp_options`,
`autoload = no` so it isn't loaded on every page request), exactly the
way most WordPress plugins store third-party API credentials (SMTP
passwords, payment-gateway secret keys, etc.). This plugin does **not**
invent a bespoke "encryption" scheme for it, because:

- A fake-encrypted secret whose decryption key lives in the same
  `wp-config.php`/database the secret itself is stored next to provides
  no real protection against anyone who already has that level of
  access - at that point the whole WordPress install (and every other
  plugin's own stored secrets) is already compromised, encryption or not.
- The realistic threat this plugin actually defends against - and does,
  rigorously - is **exposure to site visitors and accidental logging**:
  the key is never sent to the browser, never included in any enqueued
  script, never printed into rendered HTML (not even in a hidden field or
  HTML comment), and this plugin's own logging (`Logger`) is structurally
  incapable of recording it (see its class doc comment).

If your WordPress database itself is a concern (e.g. shared hosting with
untrusted other tenants), that is a hosting-level decision outside this
plugin's threat model - the same is true for every other credential
WordPress core and its ecosystem already store the same way.

## Event Tickets support

| Ticket type | Attendee-created signal | Notes |
|---|---|---|
| RSVP | `event_tickets_rsvp_attendee_created` | Fires for both "going" and "not going" - the plugin explicitly re-checks the attendee's RSVP status before syncing anything, since "not going" must never become a marketing contact. |
| Tickets Commerce - free ($0) | `tec_tickets_commerce_order_status_flag_attendee_generation` | A $0 order reaches the attendee-generation status immediately (no payment to wait for). |
| Tickets Commerce - paid | `tec_tickets_commerce_order_status_flag_attendee_generation` | The *same* hook - it only fires once the order has genuinely, safely completed (real payment confirmed), so free and paid tickets are correctly unified under one hook without any risk of syncing an abandoned/failed checkout. |
| Legacy PayPal ("TPP") / legacy WooCommerce ticket commerce | Not implemented | Tickets Commerce is the current, actively-maintained paid-ticket provider in modern Event Tickets. Registrations sold through a legacy commerce provider on an older install are not synced - a documented scope boundary, not a bug. |

Exact hook sources and reasoning are documented in the doc comment at the
top of `includes/providers/class-event-tickets-provider.php`.

### Setting up the newsletter tick-box

Works with the **free** Event Tickets (since plugin 1.4.1):

1. In **404 Emails → Settings**, choose the **Destination form** people join
   (its double opt-in, groups and welcome emails apply).
2. Under **Event Tickets**, tick **Enabled** and **Show checkbox**, check the
   wording (the standard wording follows the site's language), and save.
3. RSVP forms and the ticket checkout now show the tick-box under the email
   field, never ticked in advance.

Who gets what:

- **Ticked**: subscribed through the destination form, with the tick-box
  wording recorded as their consent.
- **Not ticked**: added as a contact only - no marketing emails, nothing
  fabricated.
- **"Not going" RSVPs**: never sent.

How it's added: Event Tickets renders its forms through its own template
system, which fires `tribe_template_after_include:{template}` after each
part - the plugin adds the box after `tickets/v2/rsvp/form/fields/email` and
`tickets/v2/commerce/checkout/purchaser-info/email` (the same hooks Event
Tickets uses itself). The RSVP form posts every field with the registration,
so the box arrives with it. The ticket checkout only sends the buyer's name
and email, so `assets/ticket-consent.js` tells the site when the box changes
(`admin-ajax.php`, with a nonce); the answer is kept against the visitor's
cart (Event Tickets' own cart cookie, read on the server) for two hours and
applied to the order's attendees when the order is approved
(`_tec_tc_order_hash`). Each attendee who ticked it is marked
`_emails404_marketing_consent = 1`.

**Event Tickets Plus** users who already have a newsletter checkbox in a
ticket's **Attendee Information** can paste that field's slug into **Event
Tickets Plus field** instead; the plugin's own box is then not added.

Before 1.4.1, ticket orders (Tickets Commerce) were never passed on: the
plugin checked for `Status_Interface` with `class_exists()`, which is false
for an interface. It now uses `interface_exists()`.

## WooCommerce support

Adds a marketing-consent checkbox to your store's **Checkout Block**
(WooCommerce's current default/recommended checkout - classic/shortcode
checkout is not supported this release, a documented limitation, not a
bug) and syncs consenting customers when their order is confirmed as
paid.

### Setting up the marketing-consent checkbox

1. Go to ****404 Emails** in the WordPress menu, find the **WooCommerce** section.
2. Check **Enabled**.
3. Set the **Marketing consent text** to whatever wording you want shown
   at checkout - kept visually and semantically separate from your
   store's own terms/privacy checkbox.
4. Choose which 404 Emails **Form** consenting customers should be
   sent to - this Form's own consent wording, double opt-in setting, and
   group/tag assignment all apply automatically; nothing about them is
   configured here.
5. Save. The checkbox appears automatically on your Checkout Block, **unchecked**.

### Why order completion, not checkout start

Two hooks are used together - `woocommerce_payment_complete` (fires once a
payment gateway confirms a charge) and `woocommerce_order_status_completed`
(a second trigger, registered because current official documentation
didn't give full certainty that `payment_complete` always fires for a
genuinely free/$0 order). Both funnel into the same sync logic, keyed by
order id, so a duplicate firing for the same order never produces a
duplicate side effect. Neither fires for an abandoned or failed checkout -
nothing is ever synced for those.

### If the customer doesn't check the consent box

**Nothing is sent to 404 Emails at all** - not even a contact-only
record. Event Tickets registrations work the same way (since 1.4.2):
only people who tick the newsletter box are sent.

## Data mapping

Only the fields the 404 Emails API actually needs, per attendee (Event Tickets) or order (WooCommerce):

| Field | Event Tickets source | WooCommerce source |
|---|---|---|
| `email` | The attendee's (or, if blank, the purchaser's) email - see `Event_Tickets_Provider::resolve_attendee_contact()`. | `$order->get_billing_email()`. |
| `firstName` / `lastName` | Split from the attendee's full name on the last space (Event Tickets core has no separate first/last name fields). | `$order->get_billing_first_name()` / `get_billing_last_name()`. |
| Marketing consent | Always `true` - only attendees who ticked this plugin's tick-box or the configured Event Tickets Plus field are sent at all. | The Checkout Block consent field's value, or `false` if not present/unchecked. |
| Source reference | `wordpress-event-tickets:event-{eventId}:attendee-{attendeeId}` - WordPress post IDs only, never a name or email. | `woocommerce:order-{orderId}` (`WooCommerce_Provider::build_source_reference()`) - not currently sent (the form-submission endpoint has no `sourceReference` field), retained as a documented, tested pure function for a possible future contact-only path. |
| Destination | The selected 404 Emails form, or the Contacts endpoint with marketing permission when no form is selected - see the doc comment on `Api_Client::upsert_contact()` for why. | The selected 404 Emails form, **only** when consent is true - no contacts-endpoint fallback (see [If the customer doesn't check the consent box](#if-the-customer-doesnt-check-the-consent-box) above). |

Never sent: ticket/order price, payment/billing details beyond
name+email, card data, shipping address, product line items, coupon
details, WordPress auth data, or any metadata beyond what's listed above.

## Idempotency

`wordpress:{provider}:{site_id}:{attendee_id}:{destination}` - see
`Idempotency::build_key()`. Deterministic: the same attendee, synced
twice (a duplicate hook firing, or a retried request), always produces
the same key, and 404 Emails' own idempotency system
(`IntegrationIdempotencyKey` / `FormSubmission.idempotencyKey`) is the
durable authority that actually deduplicates it - this plugin never tries
to second-guess that server-side.

## Failure handling and retries

A failed sync **never** blocks the underlying Event Tickets registration
- every hook handler returns normally regardless of outcome. Retryable
failures (timeout, HTTP 429, temporary 5xx) are queued in a small custom
table (`{prefix}emails404_retry_queue`) and retried with exponential backoff, up
to 5 attempts, via WP-Cron (hourly) or Action Scheduler when already
available on the site (e.g. via WooCommerce) - never both, and never a
new hard dependency. Non-retryable failures (invalid key, missing scope,
invalid form, a permanently bad email) are logged once and never retried.

## Logging

A small table (`{prefix}emails404_sync_log`), pruned to the last 30 days / 500
rows. Stores only: timestamp, provider, a safe internal reference
(WordPress post IDs, never a name or email), outcome, HTTP status, and a
safe error code. Never stores the API key, the Authorization header, the
raw request body, the raw 404 Emails response, cookies, or payment
data - see `Logger`'s own class doc comment.

## Provider architecture

```
WordPress hook (e.g. Event Tickets attendee created)
  -> Providers\Event_Tickets_Provider   (the only provider this plugin ships)
  -> Signup_Dto                          (platform-neutral: email, name, consent, source reference, destination, idempotency key)
  -> Sync_Service                        (decides which endpoint; logs; enqueues retries)
  -> Api_Client                          (knows only the 404 Emails /api/v1/* contract)
```

A future provider (Contact Form 7, Gravity Forms, WooCommerce, ...) would
implement `Integration_Provider_Interface`, map its own data into a
`Signup_Dto`, and call `Sync_Service::send()` - `Api_Client` never needs
to change, and never contains any provider-specific code today. Not
built this slice - see the main repository's `docs/product-roadmap.md`.

## Uninstalling

Deactivating the plugin only stops the retry-queue cron - settings, logs,
and the API key are preserved, so reactivating resumes cleanly. **Delete**
the plugin from the Plugins screen to remove everything this plugin ever
stored (options, the API key, both custom tables) - see `uninstall.php`.
This has no effect on the 404 Emails side; revoke the API key
separately there if it's no longer needed.

## Tests

See [`tests/README.md`](tests/README.md) for how to run the automated
PHPUnit suite and the explicit list of behaviors that instead require a
real WordPress install to verify (nonces, capabilities, hook firing,
rendered output) - see the main repository's
`docs/wordpress-integration.md#manual-verification-plan` for that
procedure.
