=== EffortLess Simple Emailing Tool ===
Contributors: domclic
Tags: email, newsletter, mailing list, campaigns, automation
Requires at least: 6.2
Tested up to: 7.0
Stable tag: 1.0.43
Requires PHP: 7.4
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Donate link: https://id7.dev/donate/?plugin=effortless-simple-emailing-tool

Subscriber management, campaigns, follow-up sequences, open/click tracking, and content restriction for WordPress.

== Description ==

EffortLess Simple Emailing Tool (ELSET) lets you manage email subscribers (WordPress users and external), send campaigns, build automated follow-up sequences, track opens and clicks, and restrict content to subscribers.

== Changelog ==

= 1.0.43 =
* Improvement: added Donate link header field, assets/ banner and icon images for the WordPress.org listing, and a dismissible "Support the development" donate notice on the Emailing dashboard screen (includes/class-donate-notice.php).

= 1.0.42 =
* Security: fixed the tracking/HMAC secret being empty on per-site multisite subsites (only the activation site received one), which made content-restriction cookies and click-tracking redirects forgeable there. The secret is now initialised per subsite at activation and when a new site is created, is lazily created on first use if still missing, and can never be returned as an empty key.
* Refactor: moved secret management into includes/secret.php (own responsibility, and unit-testable) and added a regression test suite for it.

= 1.0.41 =
* Security: every custom-table query now passes its table name through wp_prepare()'s %i identifier placeholder instead of concatenating it into the SQL string, resolving all Plugin Check "unescaped DB parameter / use wpdb::prepare()" findings across subscribers, lists, campaigns, sequences, queue, stats, tracking, activation, and uninstall.
* Compat: minimum WordPress version raised to 6.2 (the release that introduced the %i placeholder used above).
* Fix: removed the non-resolving domclic.com Plugin URI and Author URI header lines that Plugin Check flagged as invalid.

= 1.0.40 =
* Security: the tracking/cookie HMAC secret is no longer seeded from wp_salt( 'auth' ). It is now a standalone random string, so WordPress authentication salt material is never persisted in the elset_tracking_secret option. A one-time migration (admin_init) rotates any secret created by earlier versions to a salt-free value.
* Security: shortcode output that can contain form controls — the [elset_subscribe_form] markup and member content gated by [elset_restricted] — is now passed through wp_kses() with a form-aware allowlist, so callback return values are always escaped.
* Fix: bundled Chart.js updated from v4.4.4 to v4.5.1 (latest stable) on the Stats page.
* Fix: the admin (and network admin) top-level "Emailing" menu moved off the prominent position 25 (which collided with core Comments) to a low, non-competing position so it integrates as a utility.

= 1.0.39 =
* Fix: Chart.js is now bundled locally (assets/js/vendor/) instead of loaded from a CDN, resolving the Plugin Check "OffloadedContent" error — WordPress.org disallows plugins offloading scripts/styles to external servers. Removed the now-unneeded External Services disclosure from readme.txt.
* Fix: prefixed all global variables in uninstall.php ($elset_tables, $elset_table, $elset_options, $elset_opt), resolving PrefixAllGlobals warnings; also completed the option-cleanup list, which was missing several options added in later versions (confirm/subscribe page IDs, hourly/daily/send-delay limits, welcome email settings, network mode).
* Fix: added phpcs:ignore annotations for PluginCheck.Security.DirectDB.UnescapedDBParameter on internally-controlled table-name identifiers (never user input) across all direct-query call sites.

= 1.0.38 =
* Fix: text domain changed from "elset" to "effortless-simple-emailing-tool" to match the plugin slug, resolving the Plugin Check textdomain_mismatch error (and all per-string WordPress.WP.I18n.TextDomainMismatch errors) so WordPress.org's translation system can load translations for this plugin.
* Fix: removed the manual load_plugin_textdomain() call, discouraged since WP 4.6 for WordPress.org-hosted plugins (translations load automatically).
* Dev: excluded tests/, phpunit.xml, and composer.json/lock from the distributable zip.

= 1.0.37 =
* Fix: double opt-in confirm links now use the multisite-aware tracking secret, not a direct option read.
* Fix: deleting a list now pauses (and detaches) any sequence that targeted it, instead of leaving a dangling "Active" sequence.
* Fix: bounced subscribers can now reactivate by resubscribing via the public form.
* Perf: Subscribers/Lists/Campaigns admin tables now batch their list-membership and subscriber-count lookups instead of issuing one query per row.
* Refactor: consolidated 3 copy-pasted "fail pending queue rows" call sites, 3 copies of the zero-stats fallback array, duplicated admin notice markup, and duplicated tracking-token HMAC formulas into shared helpers.
* Fix: explicit hook priority so scheduled-campaign promotion always runs before queue batch processing, regardless of file load order.

= 1.0.36 =
* Fix: content-restriction cookie could be forged via document.cookie to bypass gated content — now HMAC-signed.
* Fix: deleting a subscriber no longer orphans their pending campaign/sequence queue rows.
* Fix: CSV-imported subscribers now fire the subscribe hook so list-triggered sequences enroll them.
* Fix: campaign scheduling now converts the datetime-local input to UTC, so scheduled sends fire at the intended local time on non-UTC sites.
* Fix: CSV import no longer fatal-errors on rows with more columns than the header.
* Fix: the Stats admin page is now reachable from the admin menu and enforces a capability check.
* Fix: "Send to New Subscribers" now refuses to run against a cancelled/non-sent campaign.
* Fix: an already-subscribed contact joining a new list now triggers that list's "on subscribe" sequence.
* Fix: moved inline `<script>` blocks on the Stats and Sequences admin pages to properly enqueued scripts.
* Add: External services disclosure for the Chart.js CDN load on the Stats page.

= 1.0.35 =
* Update: Confirmed compatibility with WordPress 7.0.

= 1.0.34 =
* Update: Tested up to WordPress 7.0.

= 1.0.33 =
* Fix: deleting a sequence or a sequence step now cancels pending queue rows for those steps before removing them, preventing the queue processor from silently failing deliveries to subscribers mid-sequence.

= 1.0.32 =
* Fix: do_action('elset_campaign_sent') now fires when a campaign is sent to a list with zero subscribers, ensuring the summary stats transient is invalidated in all paths.
* Fix: do_action('elset_subscribed') no longer fires when an already-subscribed user re-submits the signup form with single opt-in — prevents spurious sequence re-enrollment on every no-op resubmit.
* Fix: CSV export now clears the WordPress admin page output buffer before setting Content-Type headers, preventing admin HTML from being prepended to the download.

= 1.0.31 =
* Fix: unsubscribed/pending subscribers re-subscribing with single opt-in are now correctly promoted to 'subscribed'. Previously the status update was guarded by a $double_optin check, so they were silently left in their original state and never received campaigns.

= 1.0.30 =
* Fix: daily sending limit now correctly resets at UTC midnight to match UTC-stored occurred_at timestamps; reverts erroneous wp_date() usage introduced in 1.0.29.
* Fix: CSV export now fetches subscribers in 500-row pages instead of loading the entire table at once, preventing memory exhaustion on large lists.
* Fix: elset_summary_stats transient is now invalidated on subscribe, confirm, unsubscribe, and campaign-sent events so dashboard counts stay current.
* Fix: $zero_stats fallback in campaigns list now carries all 8 stat fields to match the full shape returned by elset_get_campaigns_stats_bulk().
* Fix: removed redundant phpcs:ignore NonceVerification comment on the CSV export action check (nonce is verified by check_admin_referer on the next line).
* Dev: added PHPUnit + Brain\Monkey test suite; 3 tests covering the above behavioural fixes.

= 1.0.29 =
* Performance: elset_get_open_rate_chart_data() now uses a single GROUP BY bulk query instead of one query per campaign (N+1 eliminated).
* Performance: elset_get_summary_stats() result is cached in a 5-minute transient to avoid repeated DB queries on every admin page load.
* Performance: Campaigns list view now uses elset_get_campaigns_stats_bulk() instead of one query per row (N+1 eliminated).
* Fix: "Send Again" restored for cancelled campaigns in elset_send_campaign_now() — the action is explicitly supported in the UI.
* Fix: daily sending limit now resets at calendar-day midnight in the site timezone (wp_date) instead of UTC midnight (gmdate).
* Security: subscribe form now includes a honeypot field; bots that fill it are silently rejected.
* Feature: Subscribers page now has an "Export CSV" button that downloads all subscribers as a CSV file.
* Feature: Welcome email — new Opt-in settings to send a customisable welcome email to new single-opt-in subscribers.

= 1.0.28 =
* Fix: unsubscribed re-subscribers now have their status reset to 'pending' before the double opt-in email is sent, so clicking confirm actually promotes them to 'subscribed' (previously the confirm handler silently did nothing).
* Performance: dashboard campaign table now uses elset_get_campaigns_stats_bulk() instead of one query per campaign row (N+1 eliminated).
* Performance: stats page campaign table no longer falls back to elset_query_stats_row() for campaigns with zero stats; uses a zero-value default array instead, eliminating N+1 for draft/scheduled campaigns.

= 1.0.27 =
* Refactor: elset_get_campaign_stats() and elset_get_sequence_step_stats() now delegate to a shared elset_query_stats_row() helper, eliminating ~40 lines of duplicated SQL.
* Refactor: new elset_get_summary_stats() helper centralises the subscriber count, list count, sent campaign count, and avg open rate queries — Dashboard and Stats pages both use it, so the numbers stay in sync automatically.
* Performance: Stats page campaign table no longer issues N per-campaign queries; uses elset_get_campaigns_stats_bulk() for a single GROUP BY query.
* Fix: unsubscribe redirect now uses wp_safe_redirect() instead of wp_redirect().
* Cleanup: elset_subscribe() no longer calls get_current_user_id() twice; result stored in a local variable.

= 1.0.26 =
* Security: click-tracking redirect now requires valid HMAC — an invalid token falls back to home_url() instead of following the embedded URL unconditionally (open redirect fix).
* Fix: cancelled campaigns are now blocked from being re-sent via elset_send_campaign_now() (only draft/scheduled allowed).
* Fix: unsubscribed users re-subscribing with double opt-in enabled now receive a confirmation email instead of being silently added back.
* Fix: daily sending limit now resets at calendar-day midnight (UTC) instead of using a rolling 24-hour window.
* Fix: CSV import now verifies the upload error code (UPLOAD_ERR_OK) before processing, and passes the raw tmp_name path to fopen() without mangling it through sanitize_text_field().
* Performance: dashboard avg open-rate now computed in one batched GROUP BY query instead of N per-campaign queries (elset_get_campaigns_stats_bulk()).

= 1.0.25 =
* Fix: click tracking now works for external links again — external links are proxied through WP home (original behaviour), same-domain links append params directly.
* Fix: mailto:, tel:, and other non-HTTP/HTTPS links are no longer rewritten by the click-tracker (previously they were broken by appended query params).
* Fix: [elset_confirmed] shortcode now verifies the tok HMAC before displaying subscriber name/email, preventing PII disclosure via crafted URLs.
* Fix: [elset_confirmed] no longer shows a false "confirmed" success message when accessed without a valid token; shortcode returns empty output on invalid/missing credentials.
* Fix: confirmation-page redirect now fires only when the subscriber's status is 'pending' — re-clicking an old link no longer redirects already-confirmed or unsubscribed users to the welcome page.
* Fix: a{color:#888888} CSS rule removed from email <style> block; rule was unscoped and turned all body links grey. Footer unsubscribe link already has an inline color style.
* Fix: wpautop() is now applied conditionally — skipped when the campaign body already contains block-level HTML, preventing corruption of HTML-formatted emails.
* Cleanup: {subscribe_url} placeholder no longer calls get_option() twice; redundant $title variable removed from elset_wrap_html_email().

= 1.0.24 =
* Fix: campaign/sequence email body now renders with proper HTML formatting — line breaks become paragraphs and <br /> tags via wpautop(), instead of being sent as raw plain text inside the HTML wrapper.

= 1.0.23 =
* Click tracking: tracked links in campaign/sequence emails now keep the original URL as the href (visible to subscribers and email clients) with tracking parameters appended after it, instead of replacing the link with a proxy URL.
* HTML email wrapper: converted to table-based layout with inline styles for full Gmail and Outlook compatibility; added lang attribute, IE meta, and bgcolor attributes on table cells.

= 1.0.22 =
* Settings → Content: new "Subscribe Page" dropdown — select the page that carries [elset_subscribe_form].
* New {subscribe_url} placeholder available in all campaign and sequence emails (resolves to the Subscribe Page URL, or home URL if none set).
* Campaign editor: placeholder hint updated to show {subscribe_url}.
* i18n: updated fr_FR, es_ES, zh_TW.

= 1.0.21 =
* Settings → Opt-in: confirmation email body now shows all available placeholders: {first_name}, {last_name}, {email}, {site_name}, {current_date}, {current_year}, {confirm_link}.
* Settings → Content: new "Confirmation Page" dropdown — subscribers are redirected there after clicking the confirm link.
* New shortcode [elset_confirmed]: place it on the confirmation page to show a personalised "Thank you, {name}!" message using the subscriber's name and email.
* i18n: updated fr_FR, es_ES, zh_TW for all new strings.

= 1.0.20 =
* i18n: regenerated POT from source; updated and completed fr_FR, es_ES, zh_TW translations — all new strings (rate controls, Settings tabs, pause/resume/cancel actions, dashboard copy) are now translated.

= 1.0.19 =
* Settings: renamed page title from "ELSET Settings" to "Emailing Settings".
* Settings: split into three tabs — Sending (identity + rate controls), Opt-in (double opt-in + confirmation email), Content (unsubscribe page + restricted message).

= 1.0.18 =
* Fix: campaigns stuck on "Sending…" after all emails were sent — the mark-sent hook was firing before the queue batch processor (hook priority order), so it never saw zero pending rows on the same run. Fixed by raising its priority to 20.

= 1.0.17 =
* Settings: new "Delay Between Emails" (0–60 s), "Hourly Sending Limit", and "Daily Sending Limit" controls to prevent SMTP provider blocks.
* Queue: batch processor now checks hourly and daily sent counts before each run and caps the batch size to the remaining quota.

= 1.0.16 =
* UI: campaign action buttons now show icon only; full label appears as tooltip on hover (both Campaigns list and Dashboard).

= 1.0.15 =
* Fix: "Send Again" on cancelled campaigns now does a full resend to all current list subscribers instead of only those who never received it — prevents campaign staying stuck in "Cancelled" when all prior recipients had already been sent to.

= 1.0.14 =
* Fix: re-entrancy guard in queue processor prevents recursive do_action explosion when multiple scheduled campaigns are due simultaneously.
* Fix: queue batch now also skips rows for cancelled campaigns (previously only skipped paused).
* Fix: elset_cancel_campaign now updates campaign status first so the queue filter stops it immediately, even if queue row cleanup is partial.
* Fix: elset_resume_campaign now checks the DB update return value before firing the queue.
* Fix: Send to New / Send Again no longer double-queues subscribers who already have a pending queue row.
* Fix: "Send to New" button removed from actively-sending campaigns (was incorrectly shown for status=sending).
* Fix: cancelled campaigns can now be deleted (previously only drafts could be deleted).
* Fix: ALTER TABLE ENUM migration only runs on genuine upgrades, not on every activation.

= 1.0.13 =
* Campaigns: "Send Again" button for cancelled campaigns — resends only to subscribers who have not yet received it.

= 1.0.12 =
* Campaigns: Pause, Resume, and Cancel Sending actions on Dashboard and Campaigns pages.
* Paused campaigns stop sending but keep all queue rows intact — Resume picks up exactly where it left off.
* Cancel Sending marks all unsent emails as cancelled and preserves already-sent stats.
* DB migration: adds 'paused' value to the campaigns status ENUM on existing installs.

= 1.0.11 =
* Fix: queue processor used $wpdb->prefix directly in elset_get_campaign_email_data, elset_get_sequence_email_data, and the mark-sent callback — fails on multisite global mode. Changed to use the multisite-aware table helpers.
* Fix: campaigns stuck in "Sending" — first batch now fires synchronously on Send Now instead of waiting for WP-Cron.

= 1.0.10 =
* Fix: translations not loading on multisite — textdomain was hooked to plugins_loaded which fires before the user locale is determined; moved to init.

= 1.0.9 =
* i18n: Added Spanish (es_ES), French (fr_FR), and Traditional Chinese/Taiwan (zh_TW) translations. POT template also included.

= 1.0.8 =
* Campaigns: "Send to New" button — resends a campaign only to subscribers who have not yet received it.
* Campaigns: "Send Test" button — sends a test copy to an email address you choose (pre-filled with admin email).

= 1.0.7 =
* Dashboard redesigned for ease of use: large icon cards, color-coded status badges, plain-language labels, descriptive section introductions, and friendly empty states with one-click CTAs.

= 1.0.6 =
* Rename network admin menu entry from "ELSET Emailing" to "Emailing".

= 1.0.5 =
* Fix: TEXT/LONGTEXT columns had DEFAULT '' causing table creation failure on MySQL 5.7 (root cause of "Failed to create list").
* Fix: Add Subscriber form always said "Subscriber added" even on failure; now checks for existing email and shows correct notice.

= 1.0.4 =
* Rename admin menu entry from "ELSET Emailing" to "Emailing".

= 1.0.3 =
* Fix: elset_page_dashboard undefined — critical error on Dashboard click.
* Fix: option name mismatches (queue_batch_size, confirm_subject/body, restricted_msg).
* Fix: elset_get_open_rate_chart_data() bypassed multisite table prefix.

= 1.0.2 =
* Multisite support: global (network-wide) and per-site subscriber modes.
* Network admin page to toggle mode and create/update tables.

= 1.0.1 =
* Added all admin pages: subscribers, lists, campaigns, sequences, stats dashboard, settings.
* Added scripts enqueue, admin CSS, subscribe form AJAX handler.

= 1.0.0 =
* Initial release.
