=== SSI Newsletter ===
Contributors: deoboy
Tags: newsletter, subscribe, email, opt-in, recaptcha
Requires at least: 6.0
Tested up to: 6.8
Requires PHP: 7.4
Stable tag: 2.1.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

A newsletter subscription bar with a subscriber database, double opt-in, one-click unsubscribe and optional server-side HubSpot forwarding.

== Description ==

Adds a responsive newsletter bar to any page, post or template through the `[ssi_newsletter]` shortcode. Subscribers are stored in a dedicated table and can be searched, filtered, exported and managed from the WordPress admin.

**Features**

* Mobile-first, accessible bar. No framework, no build step, no jQuery.
* Double opt-in with signed, expiring confirmation links.
* One-click unsubscribe, including the `List-Unsubscribe` headers Gmail and Yahoo require of bulk senders.
* Subscriber table with search, status filters, bulk actions and CSV export.
* Two email notifications, both overridable from a theme.
* Honeypot, timing and same-origin checks, plus per-IP rate limiting.
* Optional Google reCAPTCHA v3, verified server-side, with low-scoring submissions held for review rather than rejected.
* Configurable IP storage: anonymised, full, or not at all.
* Optional HubSpot forms forwarding, performed server-side.

**Shortcode attributes**

`[ssi_newsletter heading="Monthly specials" subtext="Be the first to know." mobile_kicker="Newsletter" mobile_heading="Monthly specials" name_placeholder="Full Name" email_placeholder="your@email.com" button_label="Send" mobile_button_label="Join" heading_tag="h2" class=""]`

**Templates**

Copy any file from `templates/` into `your-theme/ssi-newsletter/` to override it.

**Filters**

* `ssi_nl_capability` — capability required to manage subscribers.
* `ssi_nl_pre_subscribe` — return a `WP_Error` to reject a submission. Use this for CAPTCHA.
* `ssi_nl_looks_automated` — override the bot verdict.
* `ssi_nl_is_rate_limited` — override the rate-limit verdict.
* `ssi_nl_client_ip`, `ssi_nl_allowed_hosts`
* `ssi_nl_recaptcha_verdict` — override the CAPTCHA verdict, e.g. to add a middle band.
* `ssi_nl_hubspot_payload`, `ssi_nl_hubspot_endpoint`
* `ssi_nl_email_template_path`, `ssi_nl_email_template_vars`
* `ssi_nl_force_logging`

**Actions**

* `ssi_nl_subscriber_created`
* `ssi_nl_subscriber_activated`
* `ssi_nl_subscriber_unsubscribed`
* `ssi_nl_subscriber_flagged`

== Installation ==

1. Upload the `ssi-newsletter` folder to `/wp-content/plugins/`.
2. Activate the plugin.
3. Visit **Newsletter → Settings** and review the notification, sender and privacy options.
4. Add `[ssi_newsletter]` to a page, post, block or template.

== Frequently Asked Questions ==

= Does deleting the plugin delete my subscribers? =

Only if you tick **Delete data on uninstall** first. Deactivating never removes anything.

= Why is double opt-in recommended? =

Without it, anyone can add any address to your list, and your welcome email is sent to a person who never asked for it. Repeated at scale that is a mail-bombing vector and it damages your sending reputation.

= Do I need the HubSpot integration? =

No. Leave the portal ID empty and nothing is sent anywhere.

== Upgrade Notice ==

= 2.1.0 =
Adds optional Google reCAPTCHA v3. Schema change: a `captcha_score` column and a `spam` status. Runs automatically on upgrade.

= 2.0.0 =
Security release. Fixes a silent schema failure, CSV formula injection in the export, and a duplicate-subscription race that could report success without saving. Adds double opt-in and unsubscribe. HubSpot submissions now happen server-side. The markup and CSS class names have changed: custom CSS targeting `.newsletter-bar` will need updating.

== Changelog ==

= 2.1.0 =

* Added: optional Google reCAPTCHA v3, configured with a site key and secret key on the settings screen.
* The token is minted at submit time, not on page load, because a v3 token expires two minutes after it is issued.
* Verification is server-side only. The secret key never reaches the browser and is never written to the log.
* The returned `action` and `hostname` are both checked, so a token minted on another form, or on another site using the same key, cannot be replayed against the subscribe endpoint.
* Submissions scoring below the threshold are saved with a new `spam` status and their score, and no email of any kind is sent for them. They never reach the active list, they never resubscribe an opted-out address, and the visitor sees the same response everybody else does — so a bot learns nothing from being caught, and a false positive can be released from the admin instead of being silently destroyed.
* Only a definitively forged token is rejected. A missing token — which is what an ad blocker that eats google.com produces, and is indistinguishable from a bot omitting it — is quarantined instead, as is `timeout-or-duplicate`, which Google returns for both an expired token and a replayed one. An unreachable or misconfigured verifier is treated as an outage and, by default, accepted and logged: failing closed would let one Google outage stop every subscription on the site. That is a setting.
* Releasing a single row from the admin runs the full activation, so a recovered false positive receives their welcome email and reaches any connected CRM. A bulk release does not, because five hundred rows would mean five hundred messages in one request.
* Added: a `captcha_score` column, shown in the subscriber table and included in the CSV export, so a threshold can be chosen from real traffic rather than guessed. The default threshold of 0.50 can be set to 0.00 to record scores without holding anything.
* Added: a Spam view, a "Hold for review" row and bulk action, and release by marking a row active.
* Added: an option to hide the reCAPTCHA badge, which substitutes the attribution notice Google's terms require in its place.
* Added: `ssi_nl_recaptcha_verdict` filter and `ssi_nl_subscriber_flagged` action.
* Note: reCAPTCHA is an anti-automation control. It does not affect injection or cross-site scripting exposure, and it is layered on top of the honeypot, timing, same-origin, rate-limit and cooldown checks rather than replacing any of them.

= 2.0.0 =

**Security**

* Fixed: the `UNIQUE` index on a `varchar(255)` email column is 1020 bytes under `utf8mb4` and exceeds the 767-byte InnoDB index limit on MySQL 5.6, and on 5.7 tables using the `COMPACT` row format. `dbDelta()` failed silently and the table was never created. The column is now `varchar(100)`, matching WordPress core. Existing tables that hold a longer address keep their width and raise an admin notice instead of truncating a subscriber.
* Fixed: CSV formula injection in the subscriber export. Cells beginning with `=`, `+`, `-`, `@`, a tab or a carriage return are now prefixed so Excel, Numbers and LibreOffice treat them as text.
* Fixed: a race between the duplicate check and the insert. Two simultaneous submissions of the same address tripped the unique key, the row was not saved, and the visitor was still told the subscription succeeded. The insert is now attempted first and the outcome is read from the database.
* Fixed: HubSpot submissions were made from the browser, exposing the portal ID and form GUID and letting anyone post arbitrary data. They now run on the server, after validation, and a HubSpot failure can no longer affect the visitor.
* Added: per-IP rate limiting over a short and a long window, and a per-address cooldown so the form cannot be used to repeatedly mail a third party.
* Added: honeypot field, submission timing check and a same-origin check.
* Added: signed HMAC tokens for confirmation and unsubscribe links. A `GET` never changes state, so mail scanners and link prefetchers cannot unsubscribe anyone.
* Added: the subscribe endpoint returns an identical response for every accepted request, so it cannot be used to discover whether an address is on the list.
* Changed: proxy headers such as `X-Forwarded-For` are ignored unless the site owner confirms the site is behind a proxy. Previously a visitor could forge their own IP.
* Changed: IP addresses are anonymised by default and can be disabled entirely.
* Changed: the `From` name is stripped of characters that would allow header injection.

**Features**

* Added: double opt-in, off by default, with a seven-day confirmation link.
* Added: unsubscribe, with a confirmation step for humans and `List-Unsubscribe` / `List-Unsubscribe-Post` headers for one-click clients.
* Added: subscriber statuses (pending, active, unsubscribed), status filters, bulk actions and row actions.
* Added: settings for sender name and address, CC recipients, company phone, logo, and a confirmation page.
* Added: `uninstall.php`. Data is removed only when the site owner opts in.
* Added: text domain, `/languages` path and translator comments throughout.
* Added: multisite-aware activation and uninstall.

**Design**

* Rewritten front-end. On desktop the name field, divider, email field and send button sit inside a single white pill on a dark navy bar; on mobile the bar becomes a card with a compact form.
* All colours are CSS custom properties on the `.ssi-nl` root, so a child theme can retheme the bar without touching the stylesheet.
* Respects `prefers-reduced-motion` and `forced-colors`.
* Markup is now scoped to `.ssi-nl`. The old `.newsletter-bar` class names and the `#contact-form-newletter`, `#fullNamen`, `#emailn` IDs no longer exist.

**Internals**

* Split the single 667-line class into focused classes under `includes/`.
* All database access is confined to `SSI_NL_Repository`, and every query is prepared.
* The REST endpoint declares an argument schema with sanitize and validate callbacks.

= 1.0.5 =
* Initial release.
