# SendForce Mail Relay — Full Changelog

> This is the **complete** release history with full detail. The short version
> shown on WordPress.org is in [readme.txt](readme.txt) and is truncated to
> the 5,000-word limit the plugin directory enforces. For older releases see
> below — every change is preserved here for reference and audit.
>
> The in-admin **"What's New"** tab inside the SendForce settings parses this
> file directly, so you can also browse the full history without leaving
> WordPress.

## = 1.0.34 =

* Compliance: **Display name finalized.** Set the WordPress.org listing display name to "SendForce Mail SMTP Relay - SMTP and Email API" — it no longer uses the discouraged "Free" / "WP" / "Plugin" terms or any third-party trademarks. The internal product brand and `Plugin Name` header remain "SendForce Mail Relay".
* Security: **MIME header-injection guard on the Amazon SES / Gmail raw-MIME path.** `build_raw_mime()` assembled RFC 2822 headers by concatenation, and `encode_header()` only neutralized non-ASCII values; the To/Cc/Bcc/Reply-To/From address headers were not sanitized at all. A component forwarding untrusted input into `wp_mail()` (e.g. a contact form) could therefore inject a CR/LF and add a `Bcc:`, an extra `To:`, or a forged body to the raw message SES/Gmail sends — a path that bypasses PHPMailer's normal CRLF protection. CR/LF/NUL are now stripped from every header-bound value (subject and from-name via `encode_header()`, addresses via a new `header_addr()` helper).
* Fix: **Encrypted credential storage could intermittently corrupt a stored secret.** `encrypt_password()` joined the 16-byte binary IV to the ciphertext with the literal delimiter `::`, and `decrypt_password()` split on it — so an IV that happened to contain the bytes `0x3A3A` (~1 in 4,400) split mid-IV and failed to decrypt, silently breaking that connection's authentication. The format is now `sf2:` + base64(IV . ciphertext) with the fixed-length IV sliced off by length, eliminating the collision. Credentials saved in the old format still decrypt (and migrate to the new format the next time they're saved), and decryption now fails closed — returning an empty string on a damaged/tampered value rather than leaking the raw ciphertext as if it were the password.
* Fix: **Duplicate send + Simulation Mode bypass with the queue enabled.** The one-shot test-send bypass in `SendForce_Queue::intercept_mail()` was evaluated before the "another filter already handled this send" guard. With an API connection, the API mailer had already delivered the email and returned a short-circuit value, but the bypass branch discarded it and returned null — so WordPress core fell through to PHPMailer and sent the email a second time. The same ordering let a test send slip past Simulation Mode. The handled-send guard now runs first, and the bypass flag is consumed up front so it can't leak to a later send in the same request.
* Fix: **Fallback-recovered email re-queued and re-sent.** With the queue enabled and an SMTP-primary connection plus a fallback, core's `wp_mail()` returns false even when the fallback connection actually delivered the email (it reports the primary's failure). `process_queue()` read only that boolean and re-queued the row, re-sending via the fallback on the next cron run. It now consults `SendForce_Mailer::$last_send_used_fallback` (reset before each send) and treats a fallback recovery as success.
* Note: These were surfaced by a multi-agent audit of the mailer/queue/router interaction. The audit also flagged a parallel set of Medium/Low items (a timezone-sensitive queue lease, routing-state restore under queue mode, multisite lifecycle, and uninstall cleanup) tracked for a later release.

## = 1.0.33 =

* Compliance: **WordPress.org Plugin Directory guideline fixes.** Shortened the plugin display name from the long keyword-stuffed listing title to just "SendForce Mail Relay" (the header `Plugin Name` already used this). Removed comparative and superlative wording from the description so it no longer compares the plugin to other plugins. Fixed three stale third-party policy links in the External Services disclosure — Elastic Email's privacy/terms (now point to their site, which has no stable deep-link) and Sinch's terms of service (now the `sinch.com/legal/` hub). Pointed the in-plugin "Leave a review" link at the full reviews page rather than a pre-filtered rating link.
* Fix: **Settings provider grid flashed all 24 providers on first load.** The grid's collapsed state (15 common providers shown + a "Show All Providers" toggle) was applied only by JavaScript on `$(document).ready()`, so before the script ran — most visible on first install, where the connection form auto-opens and assets aren't cached yet — all 24 providers rendered. The wrap now carries the `collapsed` class server-side, so the CSS hides the extra providers immediately on paint and there's no flash; the JavaScript toggle is unchanged.
* Security: **SSRF hardening on alert webhooks.** Previously the five user-supplied webhook channels (Slack, Discord, Microsoft Teams, Google Chat, and the generic Webhook) posted to whatever URL the admin entered with no destination validation. A compromised or careless admin account could point a webhook at internal infrastructure — a cloud provider's instance-metadata endpoint (`169.254.169.254`), a database admin panel on `127.0.0.1`, or a service on the private `10.x`/`172.16-31.x`/`192.168.x` network — and use SendForce as a blind request proxy to probe or exfiltrate from it. A new private helper `SendForce_Alerts::is_safe_webhook_url()` now runs before every webhook send: it validates the URL with `wp_http_validate_url()` (which enforces the http/https scheme and rejects credentialed URLs), resolves the host to its IP address(es) — IPv4 via `gethostbynamel()` and IPv6 via `dns_get_record()` — and rejects the request if **any** resolved address falls in a private, loopback, link-local, or reserved range (using PHP's `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE`). It fails closed: if a host can't be resolved, the send is refused rather than attempted. Telegram and Pushover are unaffected — they post to fixed provider hostnames, not user-entered URLs. Operators who genuinely need to reach an internal collector can re-enable private hosts with the new `sendforce_allow_private_webhook_hosts` filter.
* Fix: **RFC-compliant attachment filename encoding, plus a header-injection guard.** Attachment filenames were being placed verbatim into `Content-Disposition` headers in two places: the multipart/form-data builder used by Mailgun, and the raw RFC 2822 MIME builder used by Amazon SES and Gmail. Two problems resulted. (1) A filename containing non-ASCII bytes — common with WooCommerce PDF invoices, contact-form uploads, or LMS certificates named in accented or non-Latin scripts — arrived as mojibake or was rejected/silently dropped by the receiving provider. (2) A filename containing a CR/LF could inject arbitrary MIME headers into the request body. Both builders now route the filename through a sanitizer that strips CR/LF, provides a safe ASCII fallback in the legacy quoted `filename="..."`/`name="..."` parameters, and — when the name carries non-ASCII bytes — adds the extended `filename*=UTF-8''…` / `name*=UTF-8''…` parameters (RFC 5987 for multipart/form-data, RFC 2231 for raw MIME) that Gmail, Outlook and Apple Mail all honour, recovering the true UTF-8 name. Verified against ASCII names, accented names (résumé.pdf), embedded quotes, and CR/LF injection attempts.
* Performance: **Cached dashboard and chart statistics with automatic invalidation.** `SendForce_Admin::get_dashboard_stats()` ran two lifetime `COUNT(*)` queries on every single dashboard page load, and `get_daily_stats()` ran one `COUNT(*)` per day per status — up to 180 separate queries for the 90-day chart range — on every chart AJAX request. On sites with large log tables this made the dashboard sluggish. Both now cache their results in one-hour transients (`sendforce_dashboard_counts` and `sendforce_daily_stats_{7,30,90}`). The cache is invalidated immediately whenever the log table's row counts change — a new send is logged (`insert_log`), the retention cron prunes old rows (`cleanup_old_logs`), or a row/rows are deleted (`delete_log`, `bulk_delete`) — via a shared `SendForce_Logger::flush_stats_cache()` helper, so the figures are never stale after real activity. The flush deletes the transients directly rather than calling into the admin class, so ordinary front-end `wp_mail()` traffic doesn't pay the cost of autoloading the large admin class on every send.
* Internal: Added an `empty()` guard to `SendForce_Logger::bulk_delete()` so an empty ID list short-circuits to `0` instead of building a malformed `IN ()` SQL clause, and tidied a couple of scrambled method docblocks in the logger.

## = 1.0.32 =

* Compatibility: **Verified compatible with WordPress 7.0.** Bumped the readme.txt `Tested up to` header from 6.9 → 7.0 so the wp.org plugin directory's "may not be compatible with your version of WordPress" warning clears for sites already running the latest WordPress. No functional code changes were required — the plugin's existing APIs and hooks (action/filter signatures, `wp_mail` interception path, `wp_remote_post` usage, dbDelta migration logic) all continue to work as expected on 7.0.
* Maintenance: **Refreshed wp.org plugin directory tags.** Old tags: `smtp, wp smtp, email log, mailer, email api`. New tags: `email, email log, gmail, outlook, smtp`. The change drops two low-yield tags (`wp smtp`, `mailer`) and one broad tag (`email api`) in favour of two high-intent product-name tags (`gmail`, `outlook`) that match the OAuth integrations SendForce already ships. Users searching wp.org for "gmail smtp" or "outlook smtp" plugins are now more likely to find SendForce in the results — both terms have substantially higher search volume than the dropped tags.
* Note: No code changes between 1.0.31 and 1.0.32. This is a metadata-only release to refresh the wp.org listing and signal WordPress 7.0 compatibility.

## = 1.0.31 =

* Fix: **Email Log "Connection" filter dropped on pagination and export** (regression introduced in 1.0.30). When the new provider dropdown was used to narrow the log to, say, SendGrid-only entries, clicking page 2 of the results — or clicking "Export CSV" / "Export JSON" — silently dropped the `provider` query arg and reset the view back to all providers. The `array_filter()` arrays that build the `$sendforce_paginate_base` (passed to `paginate_links()`) and `$sendforce_export_base` (passed to the `admin-post.php` export handler) didn't include the new arg, so it was never round-tripped. Both arrays now carry `'provider' => $sendforce_provider`. Verified by paging through filtered results and confirming the exported CSV contains only the selected provider's rows.
* Polish: **Accessibility — `aria-label` on icon-only action buttons.** The "View details" button on each Email Log row and the "Retry" / "Delete" buttons on each Queue row were rendered as icon-only (`dashicons-visibility`, `dashicons-update`, `dashicons-trash`) with only a `title` attribute. Most screen readers don't announce `title` reliably (and on touch devices the tooltip never appears), so these buttons were effectively unlabelled for assistive tech. Added `aria-label` matching the title text, plus `aria-hidden="true"` on the decorative dashicon `<span>` so the icon doesn't get announced as "image" before the label.
* Polish: **Proper plural form on the Email Log bulk-action counter.** The "X item(s)" badge below the table used a hardcoded `sprintf( '%d item(s)', $sendforce_total )` — non-translatable, and the `(s)` parenthetical doesn't work in most languages anyway. Switched to `_n( '%d item', '%d items', $sendforce_total, 'sendforce-mail-relay' )` so translators can supply correct singular/plural forms and English reads naturally ("1 item" / "23 items" instead of "1 item(s)").
* Polish: **Settings → Developer Debug label** changed from `"Mirror SMTP & API debug output to debug.log"` to `"Mirror SMTP and API debug output to debug.log"`. A literal `&` in a translatable string is technically valid but several PO editors (and a few automatic translation services) trip over it; using the word "and" makes the string round-trip cleanly through every PO/MO toolchain.
* Polish: **Alerts tab SVG whitelist accepts `viewBox` (camelCase).** The `wp_kses_post()` allowed-attributes array for inline channel logos only listed lowercase `viewbox`. HTML5 parsers normalize to lowercase so most sources worked, but SVGs authored with the canonical SVG-spec camelCase `viewBox` could lose the attribute when passed through `wp_kses` — leading to a 0×0 render in strict environments. Added `'viewBox' => true` alongside the existing `'viewbox' => true` entry; both casings now survive sanitization.
* Internal: Renamed `$current_user` to `$sendforce_current_user` in `admin/partials/tabs/email-test-tab.php`. The bare `$current_user` name collides with WP's global `$current_user` if the template is ever included from a context where that global has been imported; consistent `sendforce_` prefixing across our own variables avoids that class of surprise.

## = 1.0.30 =

* New: **Filter Email Log by connection.** A new "Connection" dropdown is rendered in the Email Log filter bar (right after the date pickers). It lists every provider that has at least one entry in the log — Amazon SES, SendGrid, Microsoft 365, Mailgun, etc. — and narrows the table to just rows matching the picked provider. Useful when you run multiple connections side-by-side: "show me only emails WooCommerce sent through SendGrid this week" or "give me the failed SES sends from yesterday so I can debug the IAM credentials". Options are populated from connections that actually have log entries, so the dropdown never shows empty/never-used providers. Selecting "All connections" (the default) keeps the existing behaviour and includes pre-1.0.30 rows that don't have the new column populated.
* New: **"Copy diagnostic snapshot" button** on the Email Log detail modal. Click it once and the clipboard fills with a pre-formatted text bundle: plugin version, WordPress version, PHP version, log ID, created_at, status, provider, recipient, subject, error (multi-line, indented), headers (multi-line, indented), attachments, and the entire `debug_data` JSON (already redacted by the API mailer's `redact_headers()` / `redact_body()`). Paste it straight into a support ticket or a Slack message — no more manually copy-pasting six different fields out of the modal and re-formatting them.
* Internal: Added `provider` column (`varchar(50) NULL`, indexed) to `{$wpdb->prefix}sendforce_logs`. `dbDelta` handles the schema migration automatically on plugin upgrade (no separate migration step needed; the existing `sendforce_maybe_upgrade_db` hook in the bootstrap re-runs `dbDelta` whenever the stored db_version is behind). The logger populates the column on every new entry from the captured debug payload (where the API mailer records the provider name) or, if that's missing, by looking up the active default connection's provider. Existing rows from before 1.0.30 keep the column as NULL — they continue to render as expected and just don't match the new provider filter.
* Internal: New static method `SendForce_Logger::get_distinct_providers()` powers the dropdown by returning the distinct non-empty provider keys present in the log table. Cheap query thanks to the new index.

## = 1.0.29 =

* Fix: **Stray gray "pill" elements rendering on the What's New tab** (introduced in 1.0.28). On the in-admin Changelog accordion, floating empty `<code>` elements appeared down the left margin of the page as light-gray pill shapes against the WordPress admin's default `code, kbd { background: #f0f0f1 }` styling. Root cause: `sendforce_format_changelog_line()` converted backtick-delimited markdown to `<code>` tags via a regex, but didn't escape the captured group first — so any bullet that contained literal `<tag>` text inside backticks (very common in technical changelogs like ours, e.g. `` `[HTTP <code>] <body>` ``) ended up producing output like `<code>[HTTP <code>] <body></code>`. The browser saw the inner `<code>` as a real opening tag with no matching close and dragged it out to the top-level body, where it rendered as one of those gray pills.
* The fix is small: run `esc_html()` on the raw bullet text BEFORE applying the markdown regex rules. Now any `<`/`>` inside backtick code spans becomes `&lt;`/`&gt;` first, and the only HTML tags in the final output are the controlled `<strong>` and `<code>` that the regex adds. Tag balance verified at 163/163 across all 27 releases of CHANGELOG.md content (was 165/163 before — exactly the 2 stray opens that produced the visible pills).
* Affects 1.0.28 installs only. Earlier versions used a different parser path that didn't have this issue. 1.0.29 restores the expected clean layout.
* Fix: **WordPress 6.7+ "Translation loading triggered too early" notice**. Two `cron_schedules` filter callbacks (in `class-sendforce-mailer.php::add_cron_schedule()` and `class-sendforce-activator.php::register_cron_schedule()`) returned the schedule's display label via `__( 'Every 5 Minutes (SendForce Mail Relay)', 'sendforce-mail-relay' )`. The `cron_schedules` filter can fire very early in the request lifecycle — `wp_schedule_event()`, `wp_next_scheduled()`, and anything that calls `wp_get_schedules()` triggers it, including code in `plugins_loaded` and `setup_theme` — well before our textdomain loads at `init`. WP 6.7 added a `_doing_it_wrong` notice when this happens, which is what users on WP 6.7+ with `WP_DEBUG` enabled were seeing. Fix: both callbacks now use a literal English string for the schedule's `display` value. That field is only ever rendered by admin dev tools (WP Crontrol, `wp-cli cron list`, the schedules debug panel in some hosting environments) — never to end users — so leaving it untranslated is the right tradeoff.

## = 1.0.28 =

* New: **Test send now shows the full provider response inline** on the Email Test page. When a test fails the headline shows the short reason ("Failed to send email via Microsoft 365 API") and a new **Provider response** panel below it shows the raw HTTP response body returned by the provider — pretty-printed when it's JSON, with a one-click **Copy** button for pasting into a support ticket. Previously the full Graph / SES / Mailgun response was only visible by clicking into the Email Log entry; users debugging a misconfiguration now see every detail they need without leaving the test page. The detection logic recognises `[HTTP <code>] <body>`, bare JSON bodies, and bare `AADSTS<code>:` lines so it works across all 18 providers.
* New: **HTTPS warning on webhook URL fields** for the Slack, Discord, Microsoft Teams, Google Chat, and generic Webhook channels. As soon as you paste or type a URL starting with `http://` an inline red warning appears explaining that the alert message (site name, failed recipient address, error details — which can include API responses) would be sent over an unencrypted connection. Catches `http://` typos and homemade webhook receivers before the user saves a leaky config. The warning is informational only — the field still saves; it's nudging users toward HTTPS rather than enforcing it (some internal staging webhooks legitimately use http://).
* New: **"Welcome to v{X.Y.Z}" admin notice** shown to admins after the plugin updates to a new version, with a one-click button to the in-admin **What's New** tab. Stored per-user in user_meta so each admin sees the welcome on their first visit after the update. Auto-suppressed on the SendForce admin pages themselves (where the tab is already in front of you) and on fresh installs (the first user_meta read silently records the current version with no notice — there's no prior version to compare against, so no "welcome to a version you never upgraded from" surprise). Helps users discover what changed without reading the wp.org changelog page or hunting through the Changelog tab.
* New: **Failure-alert rate limiting** for the notification channels (Slack, Discord, Telegram, Teams, Pushover, Google Chat, Webhook). Default window: 5 minutes per channel. A burst of failed sends — for example WooCommerce trying to send 100 order emails through an OAuth-expired Microsoft 365 connection — no longer floods every enabled channel with 100 identical pings. Within the window the FIRST failure fires immediately; subsequent failures are counted and the suppressed count is rolled into a "(+N additional failure(s) were suppressed during the throttle window)" line appended to the next alert that goes out after the window expires. Users still see the volume; they just see it in one message instead of N. Filter `sendforce_alerts_throttle_seconds` lets site owners change the window length (default 300 seconds) or set it to 0 to disable throttling entirely and restore the previous one-alert-per-failure behaviour.
* Docs: **Added a full-detail `CHANGELOG.md`** alongside the existing `readme.txt`. WordPress.org enforces a 5,000-word cap on the `== Changelog ==` section of readme.txt — as the plugin matured past 25+ releases that cap forced older entries to be condensed to one-liners on the public plugin page. The new `CHANGELOG.md` carries the **complete** release history with every original bullet preserved, for anyone who wants the full story (reviewers, contributors, forkers, audit trails). The readme.txt Changelog section now stays under the 5,000-word cap while still showing the most recent 7 releases in full and one-liners for everything older — same release count, just compressed for old entries.
* In-admin: **The "What's New" tab now reads from `CHANGELOG.md` when present.** Falls back to readme.txt parsing when `CHANGELOG.md` is missing (e.g. if someone strips the file from a custom build). End result: the collapsible Changelog accordion inside SendForce → What's New always shows the fully detailed release history, regardless of how condensed the readme.txt version is.
* Parser refactor: `sendforce_parse_changelog()` now delegates to either `sendforce_parse_changelog_from_md()` (new) or `sendforce_parse_changelog_from_readme()` (existing). Both produce the same `{version, bullets}` shape so the rest of the tab code is unchanged. Cached per-request via a static so repeated calls are free.
* Resolves the "Changelog section is too long and was truncated. A maximum of 5,000 words is supported." warning the WordPress.org importer was showing on the plugin's public page.

## = 1.0.27 =

* Security (defense-in-depth): **Bulk-delete logs and queue retry/delete actions now require `manage_options` capability** in addition to the existing nonce check. The forms that issued these requests already live on admin-only pages, so a non-admin couldn't normally obtain a valid nonce — but explicit capability checks match WordPress.org Plugin Check expectations and protect against any future flow that might mint a nonce in a less-restricted context.
* Privacy: **Email Log now stores only the filename basenames of attachments** instead of full filesystem paths. Previously rows like `/var/www/example.com/wp-content/uploads/woocommerce_uploads/2026/invoice-12345.pdf` were stored verbatim — leaking the server directory layout, shared-hosting username paths, and customer order IDs from WooCommerce invoice filenames. From 1.0.27 the same row stores only `invoice-12345.pdf`. Existing rows are not rewritten; only new log entries get basename-only.

## = 1.0.26 =

**Sending — attachments and multipart_text across all 18 API providers.**

* New: **Attachments now work in API mode for every supported provider.** 1.0.25 added it for Amazon SES and Gmail; 1.0.26 closes the gap for the remaining 16: SendGrid, Brevo, Mailgun, Postmark, Mailjet, MailerSend, Mandrill, Pepipost / Netcore, SocketLabs, SMTP2GO, SMTP.com, SendLayer, SparkPost, Elastic Email, Resend, and Moosend. Plugin-attached files (WooCommerce PDF invoices, Contact Form 7 / WPForms / Gravity Forms uploads, LearnDash / LifterLMS certificates, anything passed to `wp_mail()`'s 5th argument) are now base64-encoded into each provider's native JSON schema and delivered through the API.
* New: **"Multi-Part Plain Text" setting now applies to API-mode sends across the board.** When the message is HTML and the toggle is on, the auto-derived `text/plain` alternative is included via each provider's native multipart fields. Improves deliverability — Gmail and other inbox providers favour multipart-alternative messages, and plain-text-only clients get a readable fallback. Previously this setting was silently ignored in API mode for the 16 providers covered here.
* Known limitation: Moosend's transactional API has a single `Content` field with no `IsContentHtml` companion for both parts, so it can't represent multipart_text — its attachments work, just not the dual-body part.
* Special-case: Mailgun's attachment endpoint requires `multipart/form-data` (not JSON like the other 17), so a new `build_multipart_form_data()` helper in the base class assembles the request body correctly while still capturing the response into the debug log.
* Internal: Two new base-class helpers — `prepare_attachments()` (base64 + MIME-type detection + safe file reads) and `get_plain_text_alt()` (HTML→plain conversion when multipart_text is on) — give each provider ~5 lines of mapping code instead of duplicating the same boilerplate 18 times.

**Email Queue — actual error messages + on-demand processing.**

* Fix: **Queued emails that fail now record the actual provider error** in the queue row's `error` column. Previously every failure showed a generic "Failed to send email." message, regardless of whether the cause was an expired OAuth token, an unverified sender domain, a wrong API key, or a network glitch. The queue processor now hooks `wp_mail_failed` during each send attempt and stores the real WP_Error message (e.g. `AADSTS70008: refresh token expired`, `MessageRejected: Email address is not verified`, `Domain not found`). Users on the Queue tab can finally see WHY a send failed without digging into the Email Log.
* New: **"Process Queue Now" button** on the Queue tab. Triggers the same processor that WP-Cron runs every 5 minutes, but on demand — useful right after fixing a connection or when testing the queue end-to-end. Disabled when there's nothing queued; only visible when the queue is actually enabled in Settings.
* New: **Queue-disabled info notice** on the Queue tab. Previously visiting the tab with the feature off showed an empty table with no explanation; now there's a clear "Queue is disabled — emails are sent immediately. Enable in Settings → Email Queue." banner with a deep link.
* New: **"Next automatic run" timestamp** shown above the queue list when the queue is enabled, formatted using the site's date/time format. Removes the mystery of "when will my queued emails actually go out".
* Fix: **Test sends bypass the queue.** With queue mode enabled, clicking "Send Test" used to queue the message and report immediate success — but the actual delivery wouldn't happen until the next cron run, which made the test page useless for verifying provider configuration. A new one-shot `SendForce_Queue::$bypass_once` flag tells the queue interceptor to skip the next wp_mail call, so test sends always go out synchronously regardless of queue mode.

**Admin — release history and roadmap in one place.**

* New: **"What's New" tab** combines the upcoming-features roadmap (formerly "What's Next") and the full release history into one navigation entry. Roadmap cards render at the top as before; the changelog sits below as a collapsible accordion.
* New: **Collapsible changelog accordion**, FAQ-style. Each release row shows version + change count; click to expand the bullets. The current installed version auto-expands; everything else stays collapsed by default — ~60% less vertical space than rendering everything flat. Uses native HTML `<details>`/`<summary>` so it's keyboard-accessible and works without JavaScript.
* Renamed: "What's Next" → "What's New". The old `?tab=upcoming` and `?tab=changelog` URLs both still resolve as back-compat aliases so any bookmarks or in-plugin links keep working.

## = 1.0.25 =

* New: **Email attachments now work with Amazon SES API mode** (and continue to work with Gmail API mode). Previously the SES mailer ignored the `$attachments` parameter completely — WooCommerce PDF invoices, contact-form file uploads, LMS certificates, and similar plugin-generated attachments would silently disappear when sent via SES. Now they're MIME-encoded and delivered correctly via `multipart/mixed`. Gmail API mode also routes through the new shared MIME builder so its attachment handling matches.
* New: **"Multi-Part Plain Text" setting now applies to SES and Gmail API modes**. When a connection is in API mode, an HTML email is now sent with both `text/html` and an auto-derived `text/plain` alternative (via `multipart/alternative`). Improves deliverability — Gmail and other inbox providers favour multipart-alternative messages, and plain-text-only mail clients (some IT environments, accessibility tools) get a readable fallback. Previously this setting was silently ignored in API mode.
* Fix: **Subjects and From-names containing non-ASCII characters** (Café, résumé, François, Müller, 주문, etc.) are now RFC 2047 base64-encoded (`=?UTF-8?B?…?=`) before being sent. Without this, strict MTAs would reject the message and lenient ones would render the headers as garbage.
* Refactor: SES and Gmail mailers now share a single `build_raw_mime()` helper in the base API mailer class. Same MIME format, same encoding rules, same attachment handling — verified against test fixtures covering single-part, multipart/alternative, multipart/mixed (with attachments), Bcc-in-headers (Gmail) vs Bcc-via-API (SES), and non-ASCII headers.

## = 1.0.24 =

* **Fix (critical for OAuth users): Microsoft 365 / Gmail "Connect with provider" no longer fails with `AADSTS50011` / redirect URI mismatch.** Root cause: the authorize URL was built with `add_query_arg()`, which does NOT URL-encode the values it inserts. The `&` characters inside our `redirect_uri` query value were left bare, so Microsoft / Google parsed `&tab=settings&sf_oauth=callback` as additional top-level query parameters and saw the `redirect_uri` truncated at the first `&`. The fix uses `http_build_query()` with `PHP_QUERY_RFC3986` so every value is properly percent-encoded. Affects every OAuth setup whose Web Redirect URI contains more than one query parameter (i.e. all current SendForce OAuth setups). The `get_redirect_uri()` hardening from 1.0.23 was real but addressed a different scenario; this is the actual cause of the AADSTS50011 reports.
* New: **OAuth consent now opens in a popup window** instead of replacing the settings page. Click "Connect with provider" → a centered 600×720 popup opens with the Microsoft / Google sign-in flow → after consent the popup closes itself and the settings page refreshes with a success notice. You no longer lose your unsaved settings or scroll position.
* Graceful fallback: if the browser blocks the popup (popup blocker on, or running in an embedded frame), SendForce falls back to the previous full-page redirect behaviour automatically — no error, no lost flow.
* The OAuth callback page is now a small branded "Completing connection…" splash screen with a spinner that signals the parent window via THREE independent channels and closes itself: localStorage event (primary, survives cross-origin opener severance under Cross-Origin-Opener-Policy), `postMessage` (fallback when `window.opener` survives), and parent-side popup-closed polling (final safety net — reloads the settings tab so the connection panel refreshes from the DB even if both signal channels fail). This eliminates the "popup closed but status didn't update" issue some users hit when the browser's COOP behaviour broke the opener relationship after returning from Microsoft / Google.
* UX: Replaced the remaining native browser `alert()` / `confirm()` dialogs in the OAuth flow ("Save the connection first", "Disconnect this account?", error toasts) with the styled `sfShowConfirmModal` already used elsewhere in the plugin. Consistent look across the admin UI.
* UX: **Save Connection** button now shows an inline spinner and a "Validating data. Please wait…" status message while the connection save is in flight — same pattern FluentSMTP uses. Makes it clear the click registered and the save is in progress, especially on slow networks where the previous "Saving…" text alone wasn't obvious enough.
* Fix: **Microsoft 365 send failures now show the actual Graph API error** instead of a generic "Failed to send email via Microsoft 365 API" message. The Outlook mailer was bypassing the base class's debug-capture helper, so the HTTP status and response body from `graph.microsoft.com` were never recorded. It now uses `make_request()` so the real error (`ErrorAccessDenied`, `MailboxNotEnabledForRESTAPI`, `AADSTS70008` token expired, etc.) is appended to the failure message and stored in the Email Log. Also captures token-endpoint failures from the OAuth refresh flow (previously silent).
* Setup help: **Rewrote the Gmail and Microsoft 365 OAuth setup instructions** in the connection editor to cover the steps users keep getting stuck on — the OAuth consent screen + Test users requirement for Gmail (without which Google returns `access_denied` "App is being tested"), the separate Authorised JavaScript origins field (origin-only, no path), and an explicit warning about Azure's confusing "Value vs Secret ID" two-column UI for client secrets (the most common AADSTS7000215 cause). Includes the format of valid Client IDs/Secrets so users can sanity-check what they pasted.
* Setup help: **Added a "JavaScript origin" copy field** for Gmail setups, alongside the existing "Web Redirect URI" field. Google's OAuth client form has two URL fields with different requirements; previously SendForce only documented one of them.
* Setup help: **Added client-side and server-side rejection of GUID-shaped secrets** for OAuth providers — pasting an Azure Secret ID into the Client Secret field is now blocked with a clear explanation, both as a live red warning under the field and as a hard rejection on save. Prevents the AADSTS7000215 "invalid client secret" loop where the user repeatedly pastes the wrong column from Azure.
* Fix: **Zoho ZeptoMail "Invalid API Token found" (TM_4001 / SERR_157)** when users paste the full `Zoho-enczapikey TOKEN` string from ZeptoMail's docs into the Send Mail Token field. The mailer now strips the `Zoho-enczapikey ` prefix automatically before constructing the Authorization header, so both pure-token and full-header paste styles work.
* Setup help: **Added a per-provider API-key hint** rendered inline under the API Key field (where supplied by the provider config). First use: ZeptoMail and Amazon SES fields now explain where to find their credentials, format gotchas, and the verified-domain / sandbox requirements.
* Fix: **Amazon SES send failures now show the actual SES API error** (same fix as Outlook). The SES mailer was bypassing the base class's debug-capture helper, so HTTP status and response body from `email.<region>.amazonaws.com` were never recorded. It now uses `make_request()` so real errors like `MessageRejected` (sandbox, unverified address), `MailFromDomainNotVerifiedException`, `SendingPausedException`, or `Throttling` appear in the failure message and the Email Log.
* Fix: **Gmail API send and token-fetch failures now surface the actual Google error**. Same root cause as Outlook/SES: the Gmail mailer bypassed `make_request()` for the send call, and silently swallowed token-endpoint errors during the OAuth refresh. Errors like `invalidArgument` (malformed raw email), `failedPrecondition` (delegation issue), `quotaExceeded`, or `invalid_grant` (refresh token revoked / 90-day expiry) are now surfaced. Also captures pre-flight failures (missing client_id / client_secret / refresh_token) with a clear explanation.
* Fix: **Mailgun API send failures now surface the actual Mailgun error**. The Mailgun mailer bypassed `make_request()` so errors like "Domain not found", 401 wrong-region, "freemail recipient unsupported", or "Forbidden" never showed in the UI. Switched to `make_request()` with `application/x-www-form-urlencoded` body so debug capture works while preserving Mailgun's required form-encoded body format.
* Fix: **Tolerate users pasting `Bearer TOKEN` (instead of just `TOKEN`)** into the API Key field for SendGrid, Resend, MailerSend, SMTP.com, and SendLayer. Provider docs typically show `Authorization: Bearer xxxxx` in their cURL examples, and users frequently copy the whole `Bearer xxxxx` string. The mailer now strips a leading `Bearer ` if present so both paste styles work. Same auto-strip already applies to ZeptoMail's `Zoho-enczapikey ` prefix; refactored to use a shared `strip_auth_prefix()` helper in the base API mailer class.
* Fix: **All API keys are now `trim()`ed automatically** before use. Catches the common case where a user copy-pastes a key from a provider dashboard and accidentally grabs trailing whitespace or a stray newline. Previously some providers (notably SendGrid) would return a generic 401 with no useful explanation when the auth header contained non-printable characters.

## = 1.0.23 =

* Fix: **Gmail / Microsoft 365 OAuth redirect URI is now constructed defensively** so security and caching plugins (e.g. Wordfence, LiteSpeed Cache) that filter `admin_url` cannot strip the query string from the path and produce a truncated `redirect_uri`. Resolves the intermittent `AADSTS50011` (redirect URI mismatch) errors some users hit when clicking "Connect with provider" for Microsoft 365. The final URL is unchanged — only the construction is now immune to third-party `admin_url` filters.
* i18n: Email Log "Next run" timestamp now uses the site's configured **date/time format** (Settings → General) instead of a hardcoded English style. Sites running in non-English locales will now see the cleanup schedule formatted naturally.
* Hardening: Expanded the `wp_kses` whitelist used to render channel logos on the Alerts tab — adds `xmlns:xlink`, `role`, `aria-label`, `width`, `height`, `fill-rule`, `clip-rule`, the `<g>` element, and a few missing path/circle attributes. No behaviour change for the bundled icons; future channel logos with these attributes will render instead of being silently stripped.
* Tweak: `ajax_cleanup_logs_now` handler now explicitly `return`s after each `wp_send_json_error()`. `wp_send_json_error` already terminates via `wp_die`, so behaviour is unchanged — purely a clarity improvement.

## = 1.0.21 =

* New: **"Clean Now" button on the Email Log**. Run the auto-clean immediately instead of waiting for the next scheduled cron — useful after lowering retention or before exporting logs. Backed by a new `sendforce_cleanup_logs_now` AJAX endpoint (capability + nonce protected) that reports how many rows were removed.
* New: **Auto-clean status row above the Email Log table** — at a glance shows the configured retention period, when the next automatic cleanup is scheduled, a deep-link to change the setting, and the "Clean Now" action.
* New: **"via rule: <label>" badge on Email Log entries** — when an email was redirected by a Smart Routing rule, the log row now shows which rule fired (read from the stored debug payload). Makes it trivial to confirm that routing is working as intended.
* New: **"Connection missing" warning** on routing rule cards. When a rule points at a connection that has been deleted, the rule card now shows an amber warning badge — previously such rules failed silently at runtime and emails fell through to the primary connection without explanation.
* New: **Real channel logos** on the Alerts tab — Slack, Discord, Microsoft Teams, Telegram, Google Chat, Pushover, and Webhook now render their actual brand SVG icons instead of two-letter initials. Inline-loaded and color-themed via currentColor for crisp rendering at any DPI.
* Improvement: **Test Routing tool parity**. The "Test Routing" tool now evaluates rules through the exact same matcher used by the live mailer (shared `rule_matches()` + new `build_test_context()` helper), eliminating any drift between what the tool predicts and what actually happens at send time. Side benefit: the test tool now correctly evaluates `time_between`, `weekday`, `to_pattern`, and `from_*` conditions (previously partly stubbed in a duplicate local matcher).
* Improvement: Routing-tab rule cards now render round-robin target lists ("Round-robin: SES, SendGrid") and a friendly "WordPress phpmail()" label for the phpmail pseudo-target — round-robin runtime was already supported, this surfaces it correctly in the UI.
* Improvement: A11y — channel icon buttons on the Alerts tab now expose an `aria-label` so screen readers announce the channel name instead of empty graphics.
* Fix: **Alert channels were occasionally wiped when saving the Summary Email form**. The settings sanitizer used to ignore the incoming `channels` payload and re-load whatever was in the DB; in races between the AJAX channel toggles and the Summary Email form save this could clobber channel state. The sanitizer now respects the incoming payload when present and only falls back to DB state when the form omits `channels`.
* Tweak: Summary Email body textarea reduced from 3 rows to 2 so the Alerts form fits more cleanly on smaller viewports.
* Internal: Email-log retention cleanup (`SendForce_Logger::cleanup_old_logs()`) now returns the number of rows deleted (was void) so callers like the new "Clean Now" handler can surface the count to the user.

## = 1.0.20 =

* Fix: Admin top navigation bar layout at narrow widths — resolved overlap with the WordPress admin bar on tablet/mobile, removed an oversized empty gap above the SendForce header, and prevented horizontal page scroll when tab labels would otherwise overflow on mid-size laptop screens.
* Tweak: Tab labels now collapse to icon-only mode below 1340px (was 1180px) so all tabs fit cleanly without scrolling on common laptop widths.
* Tweak: Logo gets proper left breathing room on mobile — no more flush-to-edge.

## = 1.0.19 =

* New: **Smart Routing Rules**. Send different emails through different connections based on rules you define. Conditions: recipient domain, recipient pattern (glob), source plugin (auto-detected for WooCommerce, Contact Form 7, WPForms, Gravity Forms, Ninja Forms, Fluent Forms, Forminator, Easy Digital Downloads, MemberPress, LearnDash, MailPoet, BuddyPress, bbPress and more), subject contains, time of day, weekday. Rules evaluated top-down — first match wins. If no rule matches, the email goes through your Primary connection (existing behaviour preserved).
* New "Routing" tab in the admin (sidebar submenu) with rule list, per-rule add / edit / delete, enable/disable toggle, and a built-in "Test Routing" tool that lets you paste a sample recipient + subject + source plugin and see exactly which rule (if any) would fire and which connection would be used.
* The mailer now always registers BOTH the API send filter and the SMTP `phpmailer_init` action and lets the (possibly routed) settings choose the path. This means a routing rule can freely switch between API and SMTP target connections — e.g. primary SMTP → routed to SES API, or primary API → routed to a different SMTP server.
* Storage: new `wp_sendforce_routes` table (added automatically on plugin upgrade via dbDelta). The routes table is now self-healing — if it's ever missing on an admin page load, it's recreated immediately.
* New: **inline rule editor**. Routing rules are now edited directly on the page as expandable cards — no modal popups. See all rules and conditions at a glance, edit any field inline, with an "Unsaved changes" indicator and Revert button per rule.
* New: **master "Enable Smart Routing" toggle** at the top of the Routing tab. Lets you pause all rules without deleting them — useful for diagnosing whether routing is involved in an issue.
* New: **`from_email` and `from_domain` condition types** — match by sender address, not just recipient. Useful when a single site sends from multiple identities (shop@, support@, noreply@).
* New: **matched rule shown in Email Log**. When a routing rule redirects an email, the log entry now displays a "via rule: <label>" badge under the subject so you can see exactly which rule fired for any given send.
* New: **starter presets** in the empty state — one-click rules for WooCommerce orders, @gmail.com recipients, and contact form submissions.
* New: **"What's Next" / Roadmap tab** in the admin — public roadmap of upcoming features, grouped by release.
* New: **styled confirm/alert dialogs** replace native browser pop-ups across the routing UI for a consistent look.
* Improvement: better keyboard support and focus management for routing dialogs (Esc to cancel, Enter to confirm).
* Improvement: navigation collapses to icon-only buttons on medium-width screens so all tabs fit cleanly without overflow.

## = 1.0.18 =

* Fixed a misleading "Connected" status on the Gmail / Microsoft 365 OAuth panel. The panel previously showed "Connected" (green) whenever the connection's password field had any value — including when the value was a leftover SMTP password from a previous SMTP-mode setup or a refresh token without matching Client ID / Secret. The check is now strict: green "Connected" only renders when the connection is in API mode AND a Client ID is present AND a Client Secret is stored AND a refresh token is stored.
* Added a new amber state — "Connection has a stored OAuth token but the Client ID or Client Secret is missing. Re-enter your OAuth credentials and click Reconnect." — for the partial-credentials edge case (only fires in API mode, so an SMTP password is never mistaken for an orphaned OAuth token).
* The OAuth panel now shows a friendly "Switch the connection to API mode above to use OAuth" hint when a Gmail / Microsoft 365 connection is still in SMTP mode.
* Polished the OAuth status indicator dot — larger (12px), stronger glow, properly aligned with the first line when the status text wraps to multiple lines, deeper text colours for better contrast.

## = 1.0.17 =

* Initialize the `oauth_email` field on newly created connections so the schema is consistent from the moment a connection is saved (was previously added only after the first OAuth completes — defensive code in JS handled the missing key, but the data shape was inconsistent).

## = 1.0.16 =

* New: **One-click "Connect with Google" and "Connect with Microsoft" OAuth buttons** for the Gmail and Microsoft 365 connections. Replaces the previous (very painful) workflow of generating refresh tokens manually via OAuth Playground or Azure CLI. The user pastes their Client ID + Client Secret once, clicks Connect, signs in, grants consent, and is done.
* The OAuth refresh token is captured automatically and stored encrypted in the connection. The connection panel shows "Connected as user@example.com" with a Disconnect button. No third-party OAuth proxy involved — bring-your-own-credentials model, every site retains full control of its own Google Cloud / Azure AD app.
* Microsoft 365: the API now supports **both delegated (authorization_code) and app-only (client_credentials) flows**, picked automatically based on whether a refresh token is stored. Existing Application-permission setups keep working unchanged.
* Setup help panel inline in the connection form: step-by-step instructions for creating the Google Cloud / Azure AD app, plus the exact redirect URI to register, with a click-to-select copy box.

## = 1.0.15 =

* Audited all 20 provider integrations (SendGrid, Mailgun, Brevo, SparkPost, Postmark, Amazon SES, Elastic Email, SMTP2GO, Pepipost/Netcore, SMTP.com, SocketLabs, Moosend, Microsoft 365, Gmail, SendLayer, Mailjet, MailerSend, Mandrill, Resend, Zoho ZeptoMail) against current provider documentation. 18 of 20 verified spec-compliant; 1 minor fix below.
* Fixed Moosend HTML content-type detection. The Moosend API treats an unset `IsContentHtml` flag inconsistently — now set explicitly in BOTH HTML and plain-text paths so the rendering matches what the user sent. Previously HTML emails sent via Moosend could render as plain text in some accounts.

## = 1.0.14 =

* Fixed an Email Queue bug where rows that entered "processing" state and then never completed (because the cron run timed out, the PHP process crashed, or the server restarted mid-batch) became orphaned forever. Each row claimed by the processor now carries a 10-minute lease (stored in `next_retry_at`); on the next cron run any rows whose lease has expired are automatically returned to the queue and retried.

## = 1.0.13 =

* Security: hardened the PHP-mail fallback path against header injection. Carriage-return / line-feed / null characters are now stripped from the To, Subject and every individual header line before being passed to mail(), preventing a malicious wp_mail filter from injecting BCC / CC / spoofed headers via embedded newlines.
* Security: validated attachment paths in the SMTP fallback path. Attachments are now only added when the resolved path lies inside a known-safe root (ABSPATH, WP_CONTENT_DIR, WP_PLUGIN_DIR, the uploads directory, or the system temp dir), preventing a path-traversal attack from a malicious upstream filter that could otherwise read arbitrary OS files.
* Security: sanitized the fallback-connection label on read with `sanitize_text_field()` so any HTML stored in the option (by any means) cannot leak into admin UI surfaces.
* Defense-in-depth: provider error messages echoed back into the test-email response are now run through `wp_strip_all_tags()` before being sent to the browser. The client already renders them via `.text()` so XSS was already blocked, but stripping tags server-side keeps the audit trail clean.

## = 1.0.12 =

* Renamed "Default Connection" to "Primary Connection" throughout the admin UI for clearer terminology (the connection-row badge, the General Settings section heading, helper text, and the About page). The internal storage key remains `default_connection` so existing user data is preserved without migration.
* Provider status dot on the Email Test page now reflects the connection STATUS (green when configured, red when not) rather than the provider's brand colour. Fixes the misleading red dot next to providers like Google and Mailgun whose brand happens to be red.
* Fixed the fallback-connection flow so `wp_mail()` correctly returns `true` when the primary fails but the fallback succeeds. The test-email UI now shows a clear "Test email sent via the fallback connection (X). The primary connection (Y) failed; the fallback recovered the send." message instead of misleading the user with the primary failure.
* Suppressed the "WARNING: SMTP connection may not have completed properly" message when the active send path was API or PHP-Mail (it never applied to those modes; it was firing incorrectly).

## = 1.0.11 =

* Fixed cluttered display on the WordPress admin Plugins page. The `Plugin Name:` header is now the clean brand name "SendForce Mail Relay" (was the long SEO title), and the `Description:` header is a single-line summary instead of the full provider list. The long SEO title remains on the WordPress.org directory listing page for search visibility.

## = 1.0.10 =

* Added a UI control for the **Developer Debug** setting in Settings → Queue & Logging. Previously the value was honored by the mailer code but had no admin toggle.
* Added a UI control for **Max Retry Attempts** (1–10) in the Email Queue section. Previously the queue read this value but there was no field to set it.
* Hardened settings sanitization: the From Email field is now rejected with an admin notice when it isn't a valid email (instead of being silently truncated). Batch Size and Max Retry Attempts are now clamped to their valid ranges server-side.
* Replaced every native <select> dropdown with a fully-styled custom component. The open menu is now properly themed in the SendForce brand teal (animated panel, brand-color selected state, scroll-on-overflow, custom checkmark on the active item) instead of falling back to the OS-native menu that browsers refuse to style. Original <select> elements are kept hidden in the DOM so form submission, "change" event listeners, and screen readers all keep working unchanged.
* Full keyboard support on the new dropdown: ArrowUp/ArrowDown to navigate, Enter to select, Escape to close, click-outside to dismiss.
* Default and Fallback connection selectors automatically refresh after adding or saving a connection (the styled UI gets the new option immediately, no page reload needed).
* Polished form-control styling: cleaner chevron arrow, brand-teal focus ring, hover state on the arrow icon, and disabled state for inputs and selects. Edit row buttons now use the brand teal on hover (was indigo).

## = 1.0.9 =

* Adjusted the WordPress.org listing title for directory search. The product brand remains "SendForce Mail Relay" everywhere users see it (page titles, About page, alerts, email subjects).

## = 1.0.8 =

* New feature: **Automatic fallback connection.** The fallback dropdown in Settings → Connections is now wired to real send logic. When the primary connection's send fails, SendForce immediately retries via the configured fallback connection — works across any combination of API → API, API → SMTP, SMTP → API, SMTP → SMTP, or any → PHP mail.
* The original failed attempt is still recorded in the Email Log; if the fallback succeeds, a second "sent" log row is created with a "Recovered via fallback connection" badge so you have a complete audit trail.
* Refactored connection resolution into a shared `merge_connection_into()` helper; reduces duplication and makes the fallback path use the exact same config-merge rules as the primary.
* Improved long-form Description in readme for better WordPress.org discoverability (added "Why SendForce", reorganized provider list by API vs SMTP, added "Works with all major WordPress plugins" section listing WooCommerce, Contact Form 7, WPForms, Gravity Forms, Elementor Forms, MemberPress, LearnDash, BuddyPress and more).

## = 1.0.7 =

* Adjusted the public plugin display name for WordPress.org directory search. Internal slug, code, classes, and admin sidebar label are unchanged.
* Rewrote the short description and `Description:` plugin header for better discoverability.
* Refreshed the readme `Tags` to the highest-volume search terms in this category (smtp, wp smtp, email log, mailer, email api).
* Polished the connection-row Edit / Delete buttons with dashicons, hover states, and proper destructive styling for the delete action.
* Added sub-menu entries (Dashboard, Settings, Email Log, Email Queue, Email Test, Alerts) under the SF Mail Relay sidebar item, plus a `submenu_file` filter so the active tab is highlighted correctly when deep-linked.

## = 1.0.6 =

* Shortened the WordPress admin sidebar label from "SendForce Mail Relay" to "SF Mail Relay" so it fits on a single line. The full plugin name is preserved everywhere else (page title, dashboard widget, alerts, email subjects).
* Added sub-menu entries under SF Mail Relay — Dashboard, Settings, Email Log, Email Queue, Email Test, Alerts — so each tab is reachable directly from the sidebar fly-out.

## = 1.0.5 =

* Removed an accidentally bundled macOS `.DS_Store` file from `assets/images/providers/`.
* Added explicit `phpcs:ignore` annotations with rationale to four `$wpdb` queries in `includes/class-sendforce-cli.php` and `includes/class-sendforce-sysinfo.php` where the table name is interpolated from `$wpdb->prefix` plus a hardcoded literal (no user input).
* Restructured the WP-CLI `log delete --before` query onto a single line so the existing `phpcs:ignore` directive correctly suppresses the `InterpolatedNotPrepared` warning on the prepared inner statement.

## = 1.0.4 =

* Updated bundled Chart.js from 4.4.0 to 4.5.0.
* Rewrote the External Services section in readme.txt to fully document every email provider, notification channel, and bundled library — including SendLayer, MailerSend, Mailjet, Mandrill, Resend, Zoho ZeptoMail, Microsoft 365 Graph, Gmail API, Telegram, Pushover, Slack, Discord, Microsoft Teams, Google Chat, and Custom Webhook — with verified privacy and terms-of-service URLs for each.
* Removed all inline `<style>` and `<script>` blocks from PHP output. Conflict-detection notice CSS and JS are now in dedicated assets/css/conflict-notice.css and assets/js/conflict-notice.js, properly enqueued via wp_enqueue_style/script. Alerts tab JS bootstrap data now uses wp_add_inline_script.
* Surface the actual provider HTTP status and response body in the failure error so users can diagnose API errors instead of seeing only a generic "Failed to send" message.
* Added a "Raw API request / response" panel to the Email Log detail modal — shows the captured (and secret-redacted) request/response from the last attempt.
* Show masked dots (••••••••••••••••) as the placeholder for SMTP Password and API Key fields when a value is already saved, so the field no longer looks empty.
* Hardened secret redaction: response bodies are now redacted (some providers echo the request payload back in errors); the redactor also handles malformed/non-JSON bodies via a regex fallback and includes `client_secret` in the secret-key list.
* Defused CSV formula injection on log export and added the explicit fputcsv escape argument for PHP 8.1+.
* Gated the on-load DB schema upgrade routine to admin / WP-CLI context only.
* Added confirmation prompts to the destructive WP-CLI commands `wp sendforce queue clear` and `wp sendforce log delete`.

## = 1.0.3 =

* Added WP-CLI commands: `wp sendforce test`, `wp sendforce queue process|stats|clear`, `wp sendforce log list|tail|delete|count`, `wp sendforce sysinfo`.
* Added developer hooks: `sendforce_before_send`, `sendforce_after_send`, `sendforce_log_entry` filter, `sendforce_api_request`, `sendforce_api_response`.
* Added "Developer debug" setting to mirror SMTP and API debug output to `debug.log` (requires WP_DEBUG_LOG).
* Added raw API request/response capture stored in a new `debug_data` log column (Authorization headers redacted).
* Added Email Log export as CSV or JSON, honoring active filters.
* Added "Copy Debug Info" button on the Email Test screen — generates a sanitized environment + settings snapshot with secrets stripped.
* Replaced stale "SMTP-Manager" User-Agent on Resend API requests with "SendForce-Mail-Relay".
* Added one-time DB upgrade routine to add the `debug_data` column on existing installs.

## = 1.0.1 =

* Fixed conflict detection banner not showing on all admin pages.
* Fixed test email branding from "SMTPFlow" to "SendForce Mail Relay".
* Added Microsoft 365 Graph API and Gmail API support.
* Added 5 new providers: SendLayer, Mailjet, MailerSend, Mandrill, Resend.
* Added Zoho ZeptoMail API support.
* Added 3 new notification channels: Microsoft Teams, Google Chat, Custom Webhook.
* Added SVG provider logos for all providers.
* Added collapsible provider grid with smooth animation.
* Fixed API mailer bugs: SendLayer field names, Pepipost endpoint, SparkPost CC, SES BCC, MailerSend Reply-To.
* Added CC/BCC/Reply-To support to Elastic Email, SMTP2GO, SMTP.com, SocketLabs, Moosend, Pepipost.
* Auto-set first connection as Default Connection.
* Bundled Chart.js locally (removed CDN dependency).
* Fixed all WordPress Plugin Check errors and warnings.
* Renamed main plugin file to sendforce-mail-relay.php.

## = 1.0.0 =

* Initial release.
* SMTP configuration with 18 provider presets and one-click setup.
* HTTP API sending support for 12 providers (SendGrid, Mailgun, SES, Brevo, SparkPost, Postmark, Elastic Email, SMTP2GO, Pepipost, SMTP.com, SocketLabs, Moosend).
* From name and email override for all outgoing WordPress emails.
* Full email logging with keyword search, status filter, and date range filter.
* Email queue with WP-Cron processing and exponential backoff retry.
* Dedicated Email Test page with live SMTP debug log.
* Encrypted password and API key storage (AES-256-CBC).
* Auto log cleanup with configurable retention period.
* Modern admin dashboard with connection status and 7-day send chart.
