Просмотр исходного кода

Implement studiou-wc-mail-queue v1.0.0

Rate-limited persistent queue for WooCommerce transactional mail, so a
bulk order-status change cannot exceed the host's outbound mail limit.

- pre_wp_mail interception with per-email-type selection via a
  woocommerce_mail_callback probe; unhandled mail passes straight through.
- First-free-slot allocator under an advisory lock — a hard, deterministic
  per-minute cap that survives retries (no monotonic ladder).
- Worker with atomic claim, exponential backoff, and a stale-claim reaper
  that also repairs pending rows left with no scheduled action.
- Notifier writes an order note + WC log line on every terminal outcome;
  suppresses EmailLogger's premature "sent" note for queued mail.
- enabled/draining/disabled state machine; retention sweep; admin screen
  (settings, live queue with guarded retry/delete, filterable log).
- mu-plugin packaging with loader stub; Czech + English catalogues.
- build-package.ps1 produces a spec-compliant distributable zip.

Not yet executed against a WordPress install; Phase 10 load tests pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dalibor Votruba 14 часов назад
Родитель
Сommit
9959f38186
28 измененных файлов с 5694 добавлено и 63 удалено
  1. 5 0
      studiou-wc-mail-queue/.gitignore
  2. 45 16
      studiou-wc-mail-queue/CLAUDE.md
  3. 109 0
      studiou-wc-mail-queue/assets/css/admin.css
  4. 59 0
      studiou-wc-mail-queue/assets/js/admin.js
  5. 269 0
      studiou-wc-mail-queue/build-package.ps1
  6. 172 38
      studiou-wc-mail-queue/docs/plans/implement-plan-00.md
  7. 513 0
      studiou-wc-mail-queue/includes/class-wcmq-admin.php
  8. 262 0
      studiou-wc-mail-queue/includes/class-wcmq-context.php
  9. 446 0
      studiou-wc-mail-queue/includes/class-wcmq-db.php
  10. 280 0
      studiou-wc-mail-queue/includes/class-wcmq-interceptor.php
  11. 150 0
      studiou-wc-mail-queue/includes/class-wcmq-notifier.php
  12. 445 0
      studiou-wc-mail-queue/includes/class-wcmq-queue.php
  13. 275 0
      studiou-wc-mail-queue/includes/class-wcmq-reaper.php
  14. 225 0
      studiou-wc-mail-queue/includes/class-wcmq-retention.php
  15. 264 0
      studiou-wc-mail-queue/includes/class-wcmq-settings.php
  16. 83 0
      studiou-wc-mail-queue/includes/class-wcmq-state.php
  17. 315 0
      studiou-wc-mail-queue/includes/class-wcmq-worker.php
  18. 103 0
      studiou-wc-mail-queue/includes/utils-log.php
  19. BIN
      studiou-wc-mail-queue/languages/studiou-wc-mail-queue-cs_CZ.mo
  20. 420 0
      studiou-wc-mail-queue/languages/studiou-wc-mail-queue-cs_CZ.po
  21. 418 0
      studiou-wc-mail-queue/languages/studiou-wc-mail-queue.pot
  22. 30 0
      studiou-wc-mail-queue/loader/studiou-wc-mail-queue.php
  23. 159 9
      studiou-wc-mail-queue/readme.md
  24. 194 0
      studiou-wc-mail-queue/studiou-wc-mail-queue.php
  25. 83 0
      studiou-wc-mail-queue/views/admin-page.php
  26. 92 0
      studiou-wc-mail-queue/views/tab-log.php
  27. 145 0
      studiou-wc-mail-queue/views/tab-queue.php
  28. 133 0
      studiou-wc-mail-queue/views/tab-settings.php

+ 5 - 0
studiou-wc-mail-queue/.gitignore

@@ -0,0 +1,5 @@
+# Vendored WooCommerce source. Kept locally so hooks can be verified against
+# real code rather than recalled from memory — see CLAUDE.md. Thousands of
+# files; never commit them. Without this rule `git add -A` sweeps the lot in.
+docs/plans/woocommerce/
+docs/Wiki/woocommerce/

+ 45 - 16
studiou-wc-mail-queue/CLAUDE.md

@@ -2,25 +2,37 @@
 
 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 
-## Status: not yet implemented
+## Status
 
-This directory currently contains **only a specification and design docs — no plugin code**. The first task here is to write the plugin from scratch.
+**Current version: 1.0.0** — code written, **never executed**. Phases 0–9 of the plan are implemented (i18n catalogues included); Phase 10 (load verification) is outstanding. Nothing here has been run, linted, or seen a WordPress install (this box has no PHP — see § Verification).
 
-- [`readme.md`](readme.md) — the initial specification (feature list, target versions).
-- [`docs/analyza-woocommerce-email-throttling.md`](docs/analyza-woocommerce-email-throttling.md) — Czech-language root-cause analysis of the problem, comparison of five solution variants, and implementation notes. **Read this first.** Variant B (rate-limited Action Scheduler queue) is the chosen approach.
-- [`docs/sample-wc-mail-throttle.php`](docs/sample-wc-mail-throttle.php) — reference implementation of variant B as a single-file mu-plugin. Working proof of concept, not the deliverable.
-- [`docs/plans/implement-plan-00.md`](docs/plans/implement-plan-00.md) — **the implementation plan.** Resolves the two design questions below, specifies the schema, phases the build, and lists the sharp edges. Start here.
+Outstanding before release:
 
-### What the plugin must do
+- **i18n catalogues are complete and the `.mo` is present.** `languages/` holds `.pot`, `cs_CZ.po`, and `cs_CZ.mo` (95 strings, validated for msgid parity, placeholder parity, and Czech's three plural forms). The user compiled the `.mo` by hand (this box has no `msgfmt`), so its sync with the `.po` cannot be re-verified here — regenerate it after any `.po` edit.
+- No phase has passed its acceptance criteria in `docs/plans/implement-plan-00.md` §11. Run them in order; Phase 4 and Phase 5 hold the tests that catch the design's worst failure modes.
+- A packaging script (`build-package.ps1`) builds the distributable zip into `D:\@StudioU`. It has been run and produces a spec-compliant archive (forward-slash entries — **not** `Compress-Archive`, which writes backslashes on PS 5.1 and breaks install on the Linux host).
 
-Bulk-changing 100+ WooCommerce orders to "Completed" fires every `wp_mail()` synchronously inside one HTTP request. Shared hosts read that burst as spam and rate-limit outbound mail, so some customers never get their notification. The fix is to decouple sending from the request and pace it behind a hard per-minute cap.
+### Documentation
 
-Per `readme.md`, v1.0.0 ships as an **mu-plugin** with:
+- [`readme.md`](readme.md) — user-facing docs: install (the mu-plugin stub is easy to get wrong), required host config, settings, known limitations, changelog.
+- [`docs/analyza-woocommerce-email-throttling.md`](docs/analyza-woocommerce-email-throttling.md) — Czech-language root-cause analysis and comparison of five solution variants. Variant B (rate-limited Action Scheduler queue) is the chosen approach. Predates WC 10.8's own `DeferredEmailQueue`, which it does not mention and which is *not* a fix.
+- [`docs/sample-wc-mail-throttle.php`](docs/sample-wc-mail-throttle.php) — proof of concept for variant B. Its monotonic slot counter was **deliberately abandoned**: it cannot survive retries. See plan §4.2.
+- [`docs/plans/implement-plan-00.md`](docs/plans/implement-plan-00.md) — **the implementation plan.** Schema, phases, acceptance criteria, and every sharp edge with its rationale. The code follows it closely; when they disagree, the plan is the intent.
 
-0. Interception of outbound mail
-1. Persistent mail queue with an automatic retention plan
-2. Admin settings page: mails-per-minute, retention, debug toggle, which order-status emails are handled, enable/disable (disabling waits for the queue to drain), live queue state (pending/sent counts), log view with clear
-3. Czech + English translations
+### Architecture at a glance
+
+```
+woocommerce_mail_callback  →  Context     (which WC_Email is sending? which order?)
+pre_wp_mail                →  Interceptor (snapshot payload, resolve From, short-circuit)
+                           →  Queue       (allocate first-free slot under GET_LOCK, INSERT, schedule)
+studiou_wcmq_send  (AS)    →  Worker      (claim atomically, send, retry with backoff)
+studiou_wcmq_reap  (AS)    →  Reaper      (rescue stranded rows — every 5 min)
+studiou_wcmq_retention (AS)→  Retention   (sweep old rows, logs, orphaned attachments — daily)
+```
+
+`Settings` and `State` are static option accessors. `DB` owns both tables. `Admin` is loaded only under `is_admin()`. `Notifier` writes the order note + WC log line on every terminal outcome — it is a separate class precisely because **four** code paths reach a terminal state (worker success, worker max-attempts, worker corrupt-payload, reaper max-reclaims) and every one must record the outcome, or the order silently shows nothing about a mail the customer never got.
+
+**File layout deviates from plan §10 in three documented ways**, all deliberate: (1) the two `WP_List_Table` subclasses the plan names were not written — the queue and log views render plain `<table class="wp-list-table">` inline, which is less untestable surface; (2) there are no AJAX handlers — the admin uses `admin-post.php` form posts, which keep the nonce + `manage_woocommerce` checks but skip the output-buffer/JSON plumbing; (3) the reaper is its own `class-wcmq-reaper.php` rather than folded into retention. Don't treat these as missing files.
 
 ### Design decisions (locked — see the plan for rationale)
 
@@ -36,7 +48,9 @@ Do **not** key the context off `woocommerce_order_status_*_notification` hooks 
 
 `docs/Wiki/woocommerce/` holds the full WooCommerce **10.9.4** tree. Every hook the design depends on was verified against it — grep there rather than guessing or reaching for training-data recall. Re-verify after a WooCommerce major upgrade.
 
-**It is a local working copy: untracked by git, and not ignored either.** A fresh clone will not have it, and it has gone missing mid-project once already. `file:line` citations in the plan are relative to a WooCommerce install root, not to this repo. Treat the distilled reference below as the durable artefact; treat the tree as a convenience that may not be there.
+**It is a local working copy, deliberately gitignored** (`.gitignore` covers both `docs/plans/woocommerce/` and `docs/Wiki/woocommerce/` — it has appeared under both paths). 6,194 files; without the rule a `git add -A` commits the lot.
+
+A fresh clone will not have it, and it has gone missing mid-project twice. `file:line` citations in the plan are relative to a WooCommerce install root, not to this repo. Treat the distilled reference below as the durable artefact; treat the tree as a convenience that may not be there.
 
 [`docs/Wiki/woocommerce-emailing.md`](docs/Wiki/woocommerce-emailing.md) is a distilled reference extracted from that tree: the full send pipeline, all 21 `WC_Email` classes and their trigger hooks, order-status registration and the transition hook sequence, `DeferredEmailQueue`, `EmailLogger`, and a complete filter/action inventory with `file:line` refs. **Read it before touching email or order-status code** — it will save a grep session, and several behaviours changed in WC 10.8/10.9 in ways that older tutorials (and this project's own analysis doc) get wrong.
 
@@ -55,7 +69,10 @@ Things that will silently produce a plausible-looking but wrong implementation.
 - **Count crashes and send-failures separately.** Bump `attempts` *after* a completed send, never at claim; bump `reclaims` when the reaper rescues a row. Otherwise a worker killed by `max_execution_time` burns a healthy mail's retry budget without ever having attempted it.
 - **Nothing may throw after `wp_mail()` succeeds.** `order_id > 0` does not mean the order still exists; `wc_get_order()` returns `false` and `false->add_order_note()` is a fatal that kills the rest of the Action Scheduler batch *after* the mail went out, leaving the row to be reaped and sent again. Commit `sent` first, bookkeep second.
 - **`pre_wp_mail` runs before `wp_mail_from` is applied.** `WC_Email::send()` attaches its From filters, calls `wp_mail()`, then detaches them (`class-wc-email.php:1228-1245`) — so by the time a deferred mail actually sends, they are gone and it goes out as `wordpress@`. Resolve the sender at enqueue time and bake it into the stored headers. Note `$headers` is a **string** for WooCommerce emails, carrying `Content-Type` and `Reply-to` but never `From`.
+- **`pre_wp_mail` is a filter chain.** Its first argument carries the previous callback's verdict. Ignoring a non-null `$short_circuit` and queueing anyway delivers the mail twice — another mailer already sent it. Consume the context *first*, then return `$short_circuit` untouched.
 - **Context can go stale.** If a plugin filters `woocommerce_mail_callback` to a non-`wp_mail` mailer, our context is set but `pre_wp_mail` never fires — and the next unrelated `wp_mail()` inherits it. Consume the context once, and fingerprint it against `to` + `subject`.
+- **The `was_queued` flag leaks past `send()`.** `woocommerce_email_disabled` and `woocommerce_email_skipped` fire from `send_notification()` *before* `send()`, so the probe never resets the flag for them — and `EmailLogger` consults `woocommerce_email_log_enabled` on those paths too. Scope the suppression by `$email_id`, not the flag alone.
+- **If you suppress `EmailLogger`, you inherit its privacy contract.** It maps recipients to usernames and redacts addresses out of error strings before writing to the `transactional-emails` log, which any shop manager can read. The queue table may hold the real address; that log may not.
 - **Attachments are file paths, not bytes.** Invoice plugins write to temp dirs that are gone by send time. Copy attachments into a plugin-owned directory at enqueue.
 - **Re-entrancy.** The worker calls `wp_mail()` to send. Guard it, or mail is enqueued forever.
 - **Duplicate sends.** Claim rows atomically (`UPDATE ... WHERE id=%d AND state='pending'`, then check affected rows). Never SELECT-then-UPDATE.
@@ -78,7 +95,9 @@ Build the handled-types selector from `WC()->mailer()->get_emails()`, never a ha
 
 ### Rate-limiting mechanism
 
-The hard cap comes from **timestamps, not cron frequency**. Each queued mail gets a slot = `max(now, last_slot)`, and `last_slot` advances by `interval` seconds. Action Scheduler never runs an action before its scheduled time, so the spacing is deterministic regardless of how often cron fires. The counter self-heals via `max()` — once the queue drains, pacing resets to now.
+The cap comes from **timestamps, not cron frequency**. Each queued mail is assigned the first free slot at or after `max(now, earliest)` — the smallest `t` with no other pending/sending row within `interval` of it (`Queue::allocate_slot()`). Action Scheduler never runs an action before its scheduled time, so the spacing is deterministic regardless of how often cron fires. There is **no monotonic "next slot" counter**: an earlier draft used one and it was abandoned because it cannot survive retries (a row backed off an hour would drag every subsequent enqueue behind it). Do not reintroduce it.
+
+The cap is enforced against **in-flight** mail (`pending` + `sending`), not as a sliding window: a row's slot is freed the moment it reaches `sent`. During a bulk the queue is never empty so the spacing holds, but mail enqueued across separate requests, after the previous send already completed, can leave closer together than `interval`. This is by design (plan §4.2) and noted in the readme's limitations.
 
 `interval = 12s` ⇒ 5 mails/min. A single shared slot counter across all handled email types keeps the rate limit **global**, not per-type.
 
@@ -176,7 +195,17 @@ Caveat specific to this shop: **studiou.cz strips the `?ver=` query string**, so
 
 ## Translations
 
-Czech (`cs_CZ`) and English.
+Czech (`cs_CZ`) and English. Catalogues live in `languages/`:
+
+- `studiou-wc-mail-queue.pot` — 95 strings, source of truth
+- `studiou-wc-mail-queue-cs_CZ.po` — complete Czech translation
+- `studiou-wc-mail-queue-cs_CZ.mo` — **missing; the user compiles this**
+
+### mu-plugins need `load_muplugin_textdomain()`, not `load_plugin_textdomain()`
+
+`load_plugin_textdomain()` resolves its relative path against `WP_PLUGIN_DIR`. For an mu-plugin that points at `wp-content/plugins/studiou-wc-mail-queue/languages/`, which does not exist — the catalogue silently never loads and the UI stays English with no error. Use `load_muplugin_textdomain()`, which resolves against `WPMU_PLUGIN_DIR`.
+
+The sibling plugins all use `load_plugin_textdomain()` and are all correct, because they are all regular plugins. Do not copy that line here.
 
 ### This machine has no PHP toolchain — do not try to compile `.mo`
 

+ 109 - 0
studiou-wc-mail-queue/assets/css/admin.css

@@ -0,0 +1,109 @@
+/*!
+ * Studiou WC Mail Queue — admin styles
+ *
+ * Copyright (C) 2026 QUADARAX. All rights reserved.
+ * Author:  Dalibor Votruba <dvotruba@quadarax.com>
+ * License: GPL-2.0-or-later
+ */
+
+.studiou-wcmq-status {
+	font-size: 14px;
+	margin: 12px 0 6px;
+}
+
+.studiou-wcmq-badge {
+	display: inline-block;
+	padding: 2px 8px;
+	border-radius: 3px;
+	font-weight: 600;
+	font-size: 12px;
+	text-transform: uppercase;
+	letter-spacing: .04em;
+	color: #fff;
+}
+
+.studiou-wcmq-badge-on {
+	background: #1e8e3e;
+}
+
+.studiou-wcmq-badge-draining {
+	background: #b26b00;
+}
+
+.studiou-wcmq-badge-off {
+	background: #757575;
+}
+
+.studiou-wcmq-counts {
+	display: flex;
+	gap: 24px;
+	margin: 0 0 16px;
+	padding: 0;
+	list-style: none;
+}
+
+.studiou-wcmq-counts li {
+	margin: 0;
+	color: #50575e;
+}
+
+.studiou-wcmq-counts strong {
+	font-size: 18px;
+	color: #1d2327;
+}
+
+.studiou-wcmq-counts .studiou-wcmq-has-failed strong {
+	color: #d63638;
+}
+
+.studiou-wcmq-contexts {
+	margin-bottom: 16px;
+}
+
+.studiou-wcmq-contexts label {
+	display: block;
+	margin: 4px 0;
+}
+
+.studiou-wcmq-contexts code {
+	color: #646970;
+	font-size: 11px;
+}
+
+.studiou-wcmq-state,
+.studiou-wcmq-level {
+	display: inline-block;
+	padding: 1px 6px;
+	border-radius: 3px;
+	font-size: 11px;
+	text-transform: uppercase;
+	background: #f0f0f1;
+	color: #3c434a;
+}
+
+.studiou-wcmq-state-sent {
+	background: #edfaef;
+	color: #1e8e3e;
+}
+
+.studiou-wcmq-state-failed,
+.studiou-wcmq-level-error {
+	background: #fcf0f1;
+	color: #d63638;
+}
+
+.studiou-wcmq-state-sending,
+.studiou-wcmq-level-warning {
+	background: #fcf9e8;
+	color: #b26b00;
+}
+
+.studiou-wcmq-error {
+	color: #d63638;
+	font-size: 12px;
+	word-break: break-word;
+}
+
+.studiou-wcmq-toggle-form .description {
+	margin-left: 8px;
+}

+ 59 - 0
studiou-wc-mail-queue/assets/js/admin.js

@@ -0,0 +1,59 @@
+/*!
+ * Studiou WC Mail Queue — admin script
+ *
+ * Copyright (C) 2026 QUADARAX. All rights reserved.
+ * Author:  Dalibor Votruba <dvotruba@quadarax.com>
+ * License: GPL-2.0-or-later
+ */
+( function ( $ ) {
+	'use strict';
+
+	console.log( 'STUDIOU WC MAIL: Admin script loaded v1.0.0' );
+
+	function safeInit( name, fn ) {
+		try {
+			fn();
+		} catch ( e ) {
+			console.error( 'STUDIOU WC MAIL: init failed for ' + name, e );
+		}
+	}
+
+	$( function () {
+
+		safeInit( 'checkAll', function () {
+			$( '#studiou-wcmq-check-all' ).on( 'change', function () {
+				$( '.studiou-wcmq-queue-form input[name="rows[]"]' ).prop( 'checked', this.checked );
+			} );
+		} );
+
+		safeInit( 'bulkConfirm', function () {
+			$( '.studiou-wcmq-queue-form' ).on( 'submit', function ( e ) {
+				var action = $( this ).find( 'select[name="wcmq_action"]' ).val();
+				var checked = $( this ).find( 'input[name="rows[]"]:checked' ).length;
+
+				if ( ! action || ! checked ) {
+					e.preventDefault();
+					return;
+				}
+				if ( 'delete' === action && ! window.confirm( studiouWcmq.confirmDelete ) ) {
+					e.preventDefault();
+					return;
+				}
+				// Retry re-delivers mail. It is destructive in the way that matters.
+				if ( 'retry' === action && ! window.confirm( studiouWcmq.confirmRetry ) ) {
+					e.preventDefault();
+				}
+			} );
+		} );
+
+		safeInit( 'clearLogConfirm', function () {
+			$( '.studiou-wcmq-clear-log-form' ).on( 'submit', function ( e ) {
+				if ( ! window.confirm( studiouWcmq.confirmClearLog ) ) {
+					e.preventDefault();
+				}
+			} );
+		} );
+
+	} );
+
+}( jQuery ) );

+ 269 - 0
studiou-wc-mail-queue/build-package.ps1

@@ -0,0 +1,269 @@
+<#
+.SYNOPSIS
+    Builds a distributable .zip of the Studiou WC Mail Queue plugin.
+
+.DESCRIPTION
+    Packages only the shippable runtime — PHP, views, assets, languages and the
+    user-facing readme. Development artefacts are excluded: the docs/ tree (which
+    includes a ~6,000-file vendored copy of WooCommerce), CLAUDE.md, this build
+    script, dotfiles and any previous zips.
+
+    Two layouts, because the plugin can run either way:
+
+      -Type MuPlugin  (default)  Extract the zip straight into wp-content/mu-plugins/.
+                                 The loader stub is placed at the archive root as
+                                 studiou-wc-mail-queue.php, beside the
+                                 studiou-wc-mail-queue/ directory — exactly the
+                                 two-part install the plugin requires. No manual
+                                 copying, so the "forgot the loader stub → silently
+                                 inert" trap cannot happen.
+
+      -Type Plugin               A conventional single-folder plugin zip, uploadable
+                                 via Plugins -> Add New -> Upload and installed to
+                                 wp-content/plugins/. The loader stub is not needed
+                                 there; the main file's own plugin header runs it.
+
+.PARAMETER OutputDir
+    Where the .zip is written. Default: D:\@StudioU
+
+.PARAMETER Type
+    MuPlugin (default) or Plugin. See above.
+
+.PARAMETER PluginDir
+    Source plugin directory. Default: the folder this script lives in.
+
+.EXAMPLE
+    .\build-package.ps1
+    Builds the mu-plugin package into D:\@StudioU.
+
+.EXAMPLE
+    .\build-package.ps1 -Type Plugin
+    Builds a conventional plugin zip instead.
+#>
+
+[CmdletBinding()]
+param(
+    [string] $OutputDir = 'D:\@StudioU',
+
+    [ValidateSet('MuPlugin', 'Plugin')]
+    [string] $Type = 'MuPlugin',
+
+    [string] $PluginDir
+)
+
+$ErrorActionPreference = 'Stop'
+
+# --- Resolve the plugin directory ------------------------------------------
+
+if ([string]::IsNullOrWhiteSpace($PluginDir)) {
+    if ($PSScriptRoot) {
+        $PluginDir = $PSScriptRoot
+    } elseif ($MyInvocation.MyCommand.Path) {
+        $PluginDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+    } else {
+        $PluginDir = (Get-Location).Path
+    }
+}
+$PluginDir = (Resolve-Path -LiteralPath $PluginDir).Path
+
+$Slug       = 'studiou-wc-mail-queue'
+$MainFile   = Join-Path $PluginDir "$Slug.php"
+$LoaderFile = Join-Path $PluginDir "loader\$Slug.php"
+$ScriptName = $MyInvocation.MyCommand.Name
+
+function Write-Step ($msg) { Write-Host "  $msg" -ForegroundColor Cyan }
+function Write-Ok   ($msg) { Write-Host "  $msg" -ForegroundColor Green }
+function Write-Note ($msg) { Write-Host "  $msg" -ForegroundColor DarkGray }
+
+Write-Host ""
+Write-Host "Studiou WC Mail Queue - package builder" -ForegroundColor White
+Write-Host "----------------------------------------"
+
+# --- Sanity checks ----------------------------------------------------------
+
+if (-not (Test-Path -LiteralPath $MainFile)) {
+    throw "Main plugin file not found: $MainFile`n(Is -PluginDir pointing at the plugin folder?)"
+}
+if ($Type -eq 'MuPlugin' -and -not (Test-Path -LiteralPath $LoaderFile)) {
+    throw "Loader stub not found: $LoaderFile`n(Required for a MuPlugin package.)"
+}
+
+# --- Read the version from the plugin header --------------------------------
+
+$header  = Get-Content -LiteralPath $MainFile -Raw
+$version = $null
+if ($header -match '(?m)^\s*\*\s*Version:\s*(.+?)\s*$') {
+    $version = $Matches[1].Trim()
+}
+if ([string]::IsNullOrWhiteSpace($version)) {
+    throw "Could not read 'Version:' from the plugin header in $MainFile"
+}
+Write-Step "Version:  $version"
+Write-Step "Type:     $Type"
+Write-Step "Source:   $PluginDir"
+
+# --- Warn if the compiled translation is missing ----------------------------
+
+$moFiles = @(Get-ChildItem -LiteralPath (Join-Path $PluginDir 'languages') -Filter '*.mo' -ErrorAction SilentlyContinue)
+if ($moFiles.Count -eq 0) {
+    Write-Warning "No compiled .mo in languages/. The admin UI renders in English until you compile it (Poedit / msgfmt)."
+}
+
+# --- Prepare output ---------------------------------------------------------
+
+if (-not (Test-Path -LiteralPath $OutputDir)) {
+    New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
+    Write-Note "Created output directory: $OutputDir"
+}
+
+if ($Type -eq 'Plugin') {
+    $zipName = "$Slug-$version.plugin.zip"
+} else {
+    $zipName = "$Slug-$version.zip"
+}
+$zipPath = Join-Path $OutputDir $zipName
+
+# --- Stage the files --------------------------------------------------------
+
+$staging = Join-Path ([System.IO.Path]::GetTempPath()) ("studiou-wcmq-build-" + [guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $staging | Out-Null
+
+try {
+    $pluginStage = Join-Path $staging $Slug
+
+    # Directories excluded from the copy. `loader` is excluded for MuPlugin
+    # because its stub is hoisted to the archive root; it is kept inside the
+    # folder for a conventional Plugin package.
+    $excludeDirs = @('docs', '.git', '.github', '.claude', '.vs', 'node_modules')
+    if ($Type -eq 'MuPlugin') { $excludeDirs += 'loader' }
+
+    # Files excluded by name / pattern.
+    $excludeFiles = @('*.zip', '*.ps1', '.gitignore', '.gitattributes', 'CLAUDE.md',
+                      'Thumbs.db', 'desktop.ini', '.DS_Store', $ScriptName)
+
+    Write-Step "Staging files (excluding dev artefacts)..."
+
+    # robocopy prunes excluded directories rather than copying then deleting, so
+    # the huge vendored WooCommerce tree under docs/ is never even traversed.
+    $roboArgs = @($PluginDir, $pluginStage, '/E',
+                  '/XD') + $excludeDirs + @('/XF') + $excludeFiles +
+                  @('/R:1', '/W:1', '/NFL', '/NDL', '/NJH', '/NJS', '/NP', '/NC', '/NS')
+
+    robocopy @roboArgs | Out-Null
+
+    # robocopy exit codes 0-7 are all success; 8+ is a real failure.
+    if ($LASTEXITCODE -ge 8) {
+        throw "robocopy failed with exit code $LASTEXITCODE while staging files."
+    }
+    $global:LASTEXITCODE = 0
+
+    if (-not (Test-Path -LiteralPath $MainFile.Replace($PluginDir, $pluginStage))) {
+        throw "Staging did not produce the main plugin file - aborting."
+    }
+
+    # For a mu-plugin, place the loader stub at the archive root so extraction
+    # into wp-content/mu-plugins/ needs no further steps.
+    if ($Type -eq 'MuPlugin') {
+        Copy-Item -LiteralPath $LoaderFile -Destination (Join-Path $staging "$Slug.php")
+        Write-Note "Loader stub placed at archive root."
+    }
+
+    # --- Compress -----------------------------------------------------------
+
+    if (Test-Path -LiteralPath $zipPath) {
+        Remove-Item -LiteralPath $zipPath -Force
+        Write-Note "Replaced existing $zipName"
+    }
+
+    Write-Step "Compressing..."
+
+    # NOT Compress-Archive: in Windows PowerShell 5.1 it writes entry paths with
+    # backslashes, which violates the ZIP spec (APPNOTE mandates forward slashes).
+    # On the Linux host that runs WordPress those extract as flat files with
+    # literal backslashes in their names, so the plugin never installs. Build the
+    # archive by hand and force forward-slash entry names.
+    Add-Type -AssemblyName System.IO.Compression
+    Add-Type -AssemblyName System.IO.Compression.FileSystem
+
+    $stagingRoot = $staging.TrimEnd('\')
+    $fs      = [System.IO.File]::Open($zipPath, [System.IO.FileMode]::Create)
+    $archive = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create)
+    try {
+        Get-ChildItem -LiteralPath $staging -Recurse -File | ForEach-Object {
+            $rel = $_.FullName.Substring($stagingRoot.Length + 1).Replace('\', '/')
+            [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
+                $archive, $_.FullName, $rel,
+                [System.IO.Compression.CompressionLevel]::Optimal) | Out-Null
+        }
+    }
+    finally {
+        $archive.Dispose()
+        $fs.Dispose()
+    }
+}
+finally {
+    if (Test-Path -LiteralPath $staging) {
+        Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
+    }
+}
+
+# --- Verify the archive -----------------------------------------------------
+
+Write-Step "Verifying..."
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+$zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath)
+try {
+    $entries   = $zip.Entries
+    $fileCount = ($entries | Where-Object { $_.Name -ne '' }).Count
+
+    # Guard against dev artefacts leaking in.
+    $leaked = $entries | Where-Object {
+        $_.FullName -match '(^|/)docs/' -or
+        $_.FullName -match '(^|/)\.git/' -or
+        $_.FullName -match '(^|/)woocommerce/' -or
+        $_.FullName -like  '*CLAUDE.md' -or
+        $_.FullName -like  '*.ps1'
+    }
+    if ($leaked) {
+        $names = ($leaked | Select-Object -First 5 | ForEach-Object { $_.FullName }) -join ', '
+        throw "Archive contains files that should have been excluded: $names"
+    }
+
+    # Confirm the entry point exists where install expects it.
+    if ($Type -eq 'MuPlugin') {
+        $needMain   = $entries | Where-Object { $_.FullName -eq "$Slug/$Slug.php" }
+        $needLoader = $entries | Where-Object { $_.FullName -eq "$Slug.php" }
+        if (-not $needMain)   { throw "Archive is missing $Slug/$Slug.php" }
+        if (-not $needLoader) { throw "Archive is missing the root loader stub $Slug.php" }
+    } else {
+        $needMain = $entries | Where-Object { $_.FullName -eq "$Slug/$Slug.php" }
+        if (-not $needMain) { throw "Archive is missing $Slug/$Slug.php" }
+    }
+}
+finally {
+    $zip.Dispose()
+}
+
+# --- Summary ----------------------------------------------------------------
+
+$sizeKB = [math]::Round((Get-Item -LiteralPath $zipPath).Length / 1KB, 1)
+
+Write-Host ""
+Write-Ok "Built $zipName"
+Write-Note "Path:  $zipPath"
+Write-Note "Size:  $sizeKB KB"
+Write-Note "Files: $fileCount"
+Write-Host ""
+
+if ($Type -eq 'MuPlugin') {
+    Write-Host "Install (mu-plugin):" -ForegroundColor White
+    Write-Note "Extract the zip directly into wp-content/mu-plugins/ so you get:"
+    Write-Note "    wp-content/mu-plugins/$Slug.php        (loader stub)"
+    Write-Note "    wp-content/mu-plugins/$Slug/           (plugin)"
+    Write-Note "Then: WooCommerce -> Mail Queue -> Enable. Ships disabled."
+} else {
+    Write-Host "Install (regular plugin):" -ForegroundColor White
+    Write-Note "wp-admin -> Plugins -> Add New -> Upload Plugin -> choose this zip, then Activate."
+    Write-Note "Then: WooCommerce -> Mail Queue -> Enable. Ships disabled."
+}
+Write-Host ""

+ 172 - 38
studiou-wc-mail-queue/docs/plans/implement-plan-00.md

@@ -171,8 +171,41 @@ Without the fingerprint, a plugin that filters `woocommerce_mail_callback` to re
 
 Note the subject reaching `pre_wp_mail` has already been through `wp_specialchars_decode()`. Fingerprint against the decoded form.
 
+> **A fingerprint *mismatch* is not the same as *no context*, and must be visible.** `consume()` must return three outcomes, not two:
+>
+> - **none** — no WooCommerce email was in flight. Pass through silently; this is every password reset and every non-WC `wp_mail()`.
+> - **mismatch** — a WC email *was* in flight, but its `to`/`subject` no longer match what `wp_mail()` received. The values were captured at `woocommerce_mail_callback_params`, *before* `apply_filters('wp_mail', …)` ran — so a plugin that rewrites the recipient or subject in a `wp_mail` filter (staging-mode redirectors, BCC archivers, some SMTP plugins) makes **every** handled mail mismatch, fall through, and send unthrottled. Collapsing this into "none" makes the plugin look installed and do nothing, with no log line to explain it. Log a **warning** naming the email id and which field diverged.
+> - **ok** — matched; queue it.
+>
+> The mismatch case is exactly the "silently defeated" failure the readme warns about; detection is what makes it diagnosable rather than a mystery MTA log.
+
+**Clear the was-queued flag on disabled/skipped emails too.** `woocommerce_email_disabled` (`class-wc-email.php:1150`) and `woocommerce_email_skipped` (`:1169`) fire from `send_notification()` **before** `send()`, so the `woocommerce_mail_callback` probe never runs for them and never resets the flag. `EmailLogger::log_non_send_outcome()` also consults `woocommerce_email_log_enabled` on those paths (`EmailLogger.php:246`). So two same-id emails in one request — the first queued, the second skipped for want of a billing address — would leave the flag set with a matching id, and we would swallow WooCommerce's "not sent: no recipient" log line for the second. Hook both actions at **priority 1** (before EmailLogger's 10) and clear only the queued flag there, not the whole context.
+
 **Known incompatibility.** A plugin that replaces the `woocommerce_mail_callback` callable entirely bypasses `wp_mail()`, and therefore bypasses this plugin — mail sends synchronously and unthrottled. Nothing we can do at this layer. Document it.
 
+**`pre_wp_mail` is a filter chain, not an event. Honour the value you are handed.**
+
+```php
+public static function maybe_enqueue( $short_circuit, $atts ) {
+    $ctx = Context::consume( $atts['to'], $atts['subject'] );   // ALWAYS first
+    if ( null !== $short_circuit ) {
+        return $short_circuit;    // someone already sent (true) or already failed (false)
+    }
+    …
+}
+```
+
+WordPress runs `pre_wp_mail` callbacks in priority order and threads each return value into the next. A transactional-API mailer hooked below priority 10 returns `true` after it has delivered the message. Ignoring that and queueing anyway sends the mail **twice** — once immediately by them, once at our slot. Ignoring a `false` converts their failure into a reported success.
+
+The `consume()` must still run first, or an early short-circuit leaves our context set and the next unrelated `wp_mail()` in the request inherits it — the stale-context bug this section exists to prevent.
+
+**`wp_mail()` is pluggable.** A mailer that *redefines* the function never applies `pre_wp_mail` at all, and this plugin becomes silently, completely inert — no error, no log line, mail leaving unthrottled. On a host that rate-limits outbound mail an SMTP plugin is likely, not hypothetical. Detect it from `admin_init` and warn:
+
+```php
+$defined_in = ( new ReflectionFunction( 'wp_mail' ) )->getFileName();
+$is_core    = wp_normalize_path( $defined_in ) === wp_normalize_path( ABSPATH . WPINC . '/pluggable.php' );
+```
+
 ### 3.1b Emails that have no context, by design
 
 Three WooCommerce emails call `wp_mail()` **directly**, never routing through `WC_Email::send()`, so they carry no context and always pass straight through:
@@ -231,18 +264,38 @@ plus a defensive clear on `shutdown`. Exactly one `wp_mail()` runs per `WC_Email
 
 **Suppress `woocommerce_email_log_enabled` too, on the same predicate.** An earlier draft called the early WC log line "harmless". It is not: `EmailLogger` writes `status => 'sent'` to the `transactional-emails` log at enqueue, and if the deferred send later fails permanently, **nothing ever reconciles it**. The log then asserts a successful delivery that never happened — which is precisely the log an operator reaches for when a customer says the mail never arrived. A queue that lies about delivery is worse than no log.
 
-Suppress both, and have the worker write both back at real send:
+Suppress both, and have the worker write both back at real send. **Match on the email id, not just the flag:**
 
 ```php
-add_filter( 'woocommerce_email_log_enabled',        fn( $e ) => Context::was_queued() ? false : $e, 10, 1 );
-add_filter( 'woocommerce_email_log_add_order_note', fn( $e ) => Context::was_queued() ? false : $e, 10, 1 );
+add_filter( 'woocommerce_email_log_enabled',        array( __CLASS__, 'suppress' ), 10, 2 );
+add_filter( 'woocommerce_email_log_add_order_note', array( __CLASS__, 'suppress' ), 10, 2 );
+
+public static function suppress( $enabled, $email_id = '' ) {
+    if ( ! Context::was_queued() ) { return $enabled; }
+    if ( (string) $email_id !== Context::queued_context_id() ) { return $enabled; }
+    return false;
+}
 ```
 
-`Worker::send()` then emits, on the real outcome:
+> The flag alone leaks. `woocommerce_email_log_enabled` is *also* applied from `EmailLogger::log_non_send_outcome()` (`EmailLogger.php:246`), which runs on `woocommerce_email_disabled` and `woocommerce_email_skipped`. Both fire from `WC_Email::send_notification()` **before** `send()` — so the `woocommerce_mail_callback` probe never runs and never resets the flag.
+>
+> On a stock shop: `pending → processing` fires the customer processing email (queued, flag set) and then the admin `new_order` email. Admins routinely disable that one. We would silently swallow WooCommerce's "not sent: email type is disabled" log line for it.
+>
+> The order-note filter is correctly scoped even without this — `maybe_add_order_note()` is only reachable from `handle_woocommerce_email_sent()`, which always ran `send()` — but scope both anyway. `mark_queued()` must therefore record the context id alongside the row id.
+
+**Inherit the privacy contract you suppressed.** `EmailLogger` never writes raw recipient addresses or raw PHPMailer error strings into the `transactional-emails` log: it maps addresses through `resolve_recipient()` (username, or `guest`) and runs `redact_emails()` over error text, because that log is readable by anyone with `manage_woocommerce`. Having taken over writing the line, the worker owes the same. The plugin's own queue table storing the real address is fine and intended; the WC log is a different surface. Redact the order note's error text too — WooCommerce does.
+
+Tag the note so it groups with WooCommerce's own: `$order->add_order_note( $note, 0, false, array( 'note_group' => 'email_notification' ) )`. Use the literal string; `OrderNoteGroup` lives in the `Internal\` namespace and is not public API.
 
-- an order note (§8, and see the deleted-order guard below)
+A single `Notifier` writes both on the real outcome:
+
+- an order note (see the deleted-order guard below)
 - a `wc_get_logger()->info()/error()` line to source `transactional-emails`, carrying the same `email_type` / `recipient` / `status` context shape `EmailLogger` uses, so existing log tooling keeps working
 
+> **The notifier must be reachable from EVERY terminal state, not just the worker's happy paths.** A row reaches `sent`/`failed` from four places: worker success, worker max-attempts, worker **corrupt-payload**, and reaper **max-reclaims**. The last two are easy to miss — they are terminal states that never call `wp_mail()`. Miss either and the order carries no note and `transactional-emails` carries no line: the customer never got the email and the audit trail says nothing happened. That is the §3.1c "queue that lies about delivery" defect, inverted from a false positive into a silent hole. This is why the notifier is a standalone class both the worker and the reaper call, not a private worker method.
+>
+> **Inherit the privacy contract.** `EmailLogger` maps recipients through `resolve_recipient()` (username, or `guest`) and runs `redact_emails()` over error text before writing to `transactional-emails`, because that log is readable by anyone with `manage_woocommerce`. Having suppressed it, the notifier owes the same on both the log line and the order note's error text.
+
 **Guard the order note against a deleted order.** `order_id > 0` is not the same as "the order still exists". An order trashed and emptied between enqueue and send — twenty minutes is plenty — makes `wc_get_order( $order_id )` return `false`, and `false->add_order_note()` is a fatal `Error` that kills the worker **after the mail has already gone out**. Under Action Scheduler that fatal also takes the rest of the batch with it, and the row is left in `sending` for the reaper to find and *send again*.
 
 ```php
@@ -314,16 +367,23 @@ Store the directory token in an `attachment_dir` column (§5). Ship `index.php`
 
 Delete the directory the moment the row reaches `sent`. The window during which the PII exists on disk should be the queue delay and not one second more.
 
-**Ordering.** The directory token is generated before the `INSERT`, so there is no chicken-and-egg — but the *row* must still exist before we start writing files we might have to clean up. Inside the enqueue path:
+**Ordering: copy the attachments BEFORE the row becomes durable.** An earlier revision of this plan said to `INSERT` first and rewrite the paths in a later `UPDATE`. That is wrong, and subtly so:
+
+> Between the `INSERT` and the `UPDATE` there is a durable `pending` row whose payload points at the invoice plugin's **temp** paths. A PHP fatal in that window leaves it there permanently. `rollback()` cannot run after a fatal — which is exactly why §2.1 makes the reaper the safety net — and the reaper reschedules rows without re-validating attachments. It would faithfully resurrect a mail that can never send, burn its retry budget on `wp_mail()` failures, and bury it in `failed`.
 
-1. Generate `$dir_token`. `INSERT` the row with `state='pending'`, `attachment_dir = $dir_token`, and the **raw** payload (original attachment paths), obtaining `$row_id`.
-2. Copy attachments into `uploads/studiou-wcmq-attachments/{$dir_token}/`.
-3. `UPDATE` the row's payload with the rewritten paths.
-4. `as_schedule_single_action( $slot, …, array( (int) $row_id ), … )`.
+The directory token is generated up front, so there is no chicken-and-egg to justify the wrong order. Inside the enqueue path:
 
-If step 2, 3 or 4 fails: delete the directory, delete the row, return `false`, let the mail send synchronously (§2). Do not leave a row pointing at paths that were never copied.
+1. Generate `$dir_token`.
+2. Copy attachments into `uploads/studiou-wcmq-attachments/{$dir_token}/` and rewrite the payload's paths.
+3. `json_encode` the payload.
+4. `INSERT` the row with `state='pending'`, `attachment_dir = $dir_token`, obtaining `$row_id`.
+5. `as_schedule_single_action( $slot, …, array( (int) $row_id ), … )`.
 
-Rollback cannot run after a fatal, so the reaper (§8.1) and retention both treat "directory with no surviving row" as garbage and delete it.
+If 2 or 3 fails: delete the directory, return `false`, send synchronously (§2). No row exists yet, so there is nothing to roll back. If 4 fails: delete the directory. If 5 fails: delete both.
+
+The row is now **never durable with paths we do not own**, so the only fatal window left — between `INSERT` and `as_schedule_single_action()` — leaves a row the reaper can safely reschedule. The single cost is an orphaned directory when we bail before the `INSERT`; retention collects those.
+
+Rollback cannot run after a fatal, so "directory with no surviving row" must be treated as garbage and collected. **Retention owns this, not the reaper.** The daily sweep's `purge_orphan_attachment_dirs()` deletes any token directory older than one day with no matching row — the one-day floor is what keeps it from racing an in-flight enqueue whose `INSERT` has not landed yet. The reaper (5-min cadence) deliberately does not touch directories: a shorter floor would risk that race, and reusing the one-day floor would gain nothing over retention. The cost is that a directory orphaned by a fatal *between* `mkdir` and `INSERT` — a rare fatal inside a millisecond window — can hold attachment PII on disk for up to ~a day. Accepted; documented in the readme's limitations. (An earlier draft said "the reaper and retention both" collect these — that was aspirational; retention alone does.)
 
 **Lifetime.** Delete the directory when the row reaches `sent`, and when retention sweeps it. **Do not delete it when the row reaches `failed`** — `failed` rows are manually retriable from the admin (§11, Phase 8), and a retry that fires without its attachments sends a broken email that looks fine. Attachments for `failed` rows die with the row at `retention_failed_days`.
 
@@ -346,6 +406,37 @@ try {
 
 Same discipline for the `wp_mail_failed` listener (§8) and for the `GET_LOCK` release (§4) — every one of them is a paired acquire/release across code that can throw.
 
+### 3.4a The `wp_mail` filter runs *before* `pre_wp_mail`, so the worker double-applies it
+
+`wp_mail()` opens with:
+
+```php
+$atts        = apply_filters( 'wp_mail', compact( 'to', 'subject', 'message', 'headers', 'attachments' ) );
+$pre_wp_mail = apply_filters( 'pre_wp_mail', null, $atts );
+```
+
+The `$atts` we snapshot have therefore **already been through every `wp_mail` filter on the site.** When the worker later calls `wp_mail()` with that snapshot, they all run a second time — against their own prior output.
+
+A plugin appending an unsubscribe footer appends it twice. A BCC-archive filter adds a duplicate `Bcc:`. A staging-mode filter that rewrites the recipient rewrites the already-rewritten address. Queued mail silently differs from synchronous mail, and nobody will connect it to this plugin.
+
+`wp_mail_from` / `wp_mail_from_name` / `wp_mail_content_type` need no such treatment — they are idempotent, and §3.2 has already baked the resolved sender into the headers.
+
+Detach the whole hook for the duration of the worker's send, and restore it in `finally`:
+
+```php
+global $wp_filter;
+$saved = $wp_filter['wp_mail'] ?? null;
+if ( null !== $saved ) { unset( $wp_filter['wp_mail'] ); }
+try {
+    return (bool) wp_mail( ... );
+} finally {
+    Interceptor::$sending = false;
+    if ( null !== $saved ) { $wp_filter['wp_mail'] = $saved; }
+}
+```
+
+`remove_all_filters('wp_mail')` is **not** an option — it would strip the callbacks for the rest of the request, and under Action Scheduler that means the rest of the batch. Restore, always.
+
 ### 3.5 Consequence: the queued body is a point-in-time snapshot
 
 Because we store the rendered HTML rather than an order ID, an order edited between enqueue and send will produce an email showing the **old** data. This is a deliberate trade of the trigger-detach approach's always-fresh rendering.
@@ -647,20 +738,28 @@ Worker::send( $row_id )
      replace it with a SELECT-then-UPDATE.
      NOTE: attempts is NOT incremented here. See §5.
   2. Load row. $payload = json_decode( $row->payload, true );
-     if ( ! is_array( $payload ) || JSON_ERROR_NONE !== json_last_error() )
-         → state='failed', last_error='corrupt payload', claimed_at=NULL, return.
+     if corrupt → update_row_if_state('failed', last_error='corrupt payload',
+                    claimed_at=NULL; WHERE state='sending');
+                  if 1 row changed → Notifier::annotate(row, false, 'corrupt payload');
+                  return.   // terminal state — the order MUST get a note (§3.1c)
   3. $this->last_error = null;            // reset buffer BEFORE the send
   4. Interceptor::$sending = true;
      try   { $ok = wp_mail( ... ); }
      finally { Interceptor::$sending = false; }
      $error = $this->last_error; $this->last_error = null;   // consume
-     UPDATE ... SET attempts = attempts + 1 WHERE id = %d    // AFTER the attempt
-  5. Success → state='sent', sent_at=now, claimed_at=NULL   ← commit FIRST
-               then, in its own try/catch: delete attachment dir,
-               write the order note + WC log line (§3.1c).
-               Nothing here may throw back into the send path.
+     $attempts = (int) $row->attempts + 1;                   // computed, not written yet
+  5. Success → ONE state-guarded UPDATE: state='sent', sent_at=now, claimed_at=NULL,
+                           last_error=NULL, attempts=$attempts; WHERE state='sending'
+               ← check affected === 1. If not, the mail HAS gone out but the row
+                 was deleted/reclaimed or the DB errored: log loudly, do NOT delete
+                 the attachment dir, do NOT write a success note. The reaper may
+                 resend it — keep it sendable.
+               then: delete attachment dir, Notifier::annotate(row, true, null).
+                 Notifier never throws (it try/catches internally).
      Failure → if attempts >= max_attempts:
-                   state='failed', claimed_at=NULL, KEEP attachment dir (§3.3)
+                   update_row_if_state('failed', claimed_at=NULL, attempts=$attempts;
+                     WHERE state='sending'); KEEP attachment dir (§3.3);
+                   if 1 row changed → Notifier::annotate(row, false, $error);
                else:
                    [ $slot, ] = Queue::with_slot_lock( now + backoff,
                        fn( $s ) => /* UPDATE state='pending', claimed_at=NULL,
@@ -673,6 +772,10 @@ Worker::send( $row_id )
   6. If state is 'draining' and no pending/sending rows remain → 'disabled'.
 ```
 
+**Every terminal write is state-guarded on `sending`.** Use `UPDATE … WHERE id = %d AND state = 'sending'`, not a bare `WHERE id`. Between the claim and the commit the row can be deleted from the admin or reclaimed by a parallel reaper; a bare update reports success anyway (`$wpdb->update()` returns 0 changed rows as distinct from `false`, and a plain "did it error?" check treats 0 as fine). The worker would then delete attachments and write an order note for a row that no longer exists. Provide `DB::update_row_if_state()` and check for exactly one affected row before any post-commit bookkeeping. The same guard closes the reaper's infinite-loop bug — a re-slot whose `UPDATE` changed nothing must **not** schedule an action, or the row stays `sending` with unpersisted `reclaims`, and every sweep reschedules a fresh dead action forever without ever tripping `max_reclaims`.
+
+**`attempts` is folded into the commit, never written separately.** A standalone `UPDATE … SET attempts = attempts + 1` after `wp_mail()` returns but before the state commit widens exactly the window that "commit first, bookkeep second" exists to close: a fatal between the two leaves a row that has been *attempted* but not *resolved*. Because the worker holds the claim, nobody else writes that column, so computing `$attempts = $row->attempts + 1` in PHP and setting it explicitly in the one terminal `UPDATE` is safe and removes the window entirely.
+
 **Decode defensively.** §2 guards `json_encode()` returning `false`; the read side must be symmetric. `json_decode()` returns `null` on a truncated or corrupted `LONGTEXT` — a MySQL `max_allowed_packet` truncation, a botched migration, a partially-written `UPDATE` from a crashed enqueue. Feeding `null` into `wp_mail( $payload['to'], … )` throws on array access. Fail the row loudly instead; a corrupt payload is not retriable, so do not burn attempts on it.
 
 **`$row_id` arrives from Action Scheduler as an int** (it round-trips through `json_decode` of the action's args), so `Worker::send( $row_id )` is safe. Rows read back from `$wpdb` are **numeric strings** — cast before handing them to any Action Scheduler function. See §8.1.
@@ -726,6 +829,12 @@ if ( ! as_has_scheduled_action( 'studiou_wcmq_send', array( $row_id ), 'studiou-
 
 > **The cast is load-bearing.** Action Scheduler matches actions by hashing `json_encode( $args )`. The action was scheduled with `array( (int) $row_id )` → `"[123]"`. A `$wpdb` result gives `array( "123" )` → `"[\"123\"]"`. The hashes differ, `as_has_scheduled_action()` returns `false` for a perfectly healthy row, and the reaper duplicates its action — two workers race the same row on every sweep. Cast every id that crosses into an Action Scheduler call.
 
+> **(a) and (b) are different faults and must not share a counter.**
+>
+> Only (a) is evidence that a worker died holding this row. (b) is a row that was **never claimed and never attempted** — its Action Scheduler action simply went missing. Charging (b) to `reclaims` means a queue that repeatedly loses actions (a pruned AS table, a failed retry re-schedule) eventually marks perfectly deliverable mail `failed` with `last_error = 'worker repeatedly crashed'` — a diagnosis that is both false and unactionable.
+>
+> `recover_crashed()` bumps `reclaims` and honours `max_reclaims`. `repair_orphan()` bumps nothing and simply re-slots. There is no runaway risk: the re-slot pushes `scheduled_at` into the future, so the next sweep will not see the row until the grace window elapses again.
+
 **Every recovered row is re-slotted, not merely un-claimed.** A row reclaimed with its original `scheduled_at` now lies in the *past*, so Action Scheduler fires it on the very next pass — and if the reaper recovers thirty rows at once, all thirty go out simultaneously. That is the burst this plugin exists to prevent, delivered by its own recovery path. Re-slot through the same locked allocator, and **guard the schedule call exactly as enqueue and retry do**:
 
 ```php
@@ -811,34 +920,44 @@ studiou-wc-mail-queue/
     studiou-wc-mail-queue.php        mu-plugins/ stub (see §9)
   includes/
     utils-log.php                    UtilsLog — adapted, gated on the debug setting
-    class-wcmq-db.php                schema, maybe_upgrade_db, queue+log CRUD
-    class-wcmq-settings.php          option get/set, defaults, sanitization
-    class-wcmq-context.php           woocommerce_mail_callback probe, consume-once
-    class-wcmq-interceptor.php       pre_wp_mail, payload snapshot, re-entrancy guard
-    class-wcmq-queue.php             slot ladder, GET_LOCK, enqueue, AS scheduling
-    class-wcmq-worker.php            studiou_wcmq_send handler, claim, retry, backoff
-    class-wcmq-retention.php         daily sweep of queue rows, log rows, attachment dirs
+    class-wcmq-db.php                schema, maybe_upgrade_db, queue+log CRUD, update_row_if_state
+    class-wcmq-settings.php          option get/set, defaults, sanitization, RUNTIME_ALIAS_IDS
     class-wcmq-state.php             enabled/draining/disabled machine
-    class-wcmq-admin.php             menu, tabs, form handlers, AJAX
-    class-wcmq-queue-list-table.php  WP_List_Table — queue
-    class-wcmq-log-list-table.php    WP_List_Table — log
+    class-wcmq-context.php           woocommerce_mail_callback probe, consume (none/mismatch/ok)
+    class-wcmq-queue.php             allocate_slot, with_slot_lock, enqueue, reschedule, attachments
+    class-wcmq-interceptor.php       pre_wp_mail, payload snapshot, EmailLogger suppression
+    class-wcmq-notifier.php          terminal-outcome order note + WC log line (see §3.1c)
+    class-wcmq-worker.php            studiou_wcmq_send handler, claim, dispatch, retry, backoff
+    class-wcmq-reaper.php            studiou_wcmq_reap — rescues stranded rows (own class)
+    class-wcmq-retention.php         daily sweep of queue rows, log rows, attachment dirs
+    class-wcmq-admin.php             menu, tabs, admin-post form handlers
   views/
-    admin-page.php                   tab shell
+    admin-page.php                   tab shell (state banner + counts + tab bar)
     tab-settings.php
-    tab-queue.php
-    tab-log.php
+    tab-queue.php                    plain wp-list-table markup, bulk retry/delete
+    tab-log.php                      plain wp-list-table markup, level filter, clear
   assets/css/admin.css
   assets/js/admin.js
   languages/
     studiou-wc-mail-queue.pot
     studiou-wc-mail-queue-cs_CZ.po
-    studiou-wc-mail-queue-cs_CZ.mo
+    studiou-wc-mail-queue-cs_CZ.mo   (hand-compiled; not present in this repo)
+  loader/studiou-wc-mail-queue.php   mu-plugins/ stub
+  .gitignore                         excludes the vendored WC tree
   docs/
     analyza-woocommerce-email-throttling.md
     sample-wc-mail-throttle.php
+    Wiki/woocommerce-emailing.md     distilled WC reference
     plans/implement-plan-00.md       (this file)
 ```
 
+**As-built deviations from the layout above, all deliberate:**
+
+- **`class-wcmq-notifier.php` is new** and not in the original plan. It exists because a terminal-outcome note must be written from four call sites (§3.1c); a private worker method could not serve the reaper's max-reclaims path.
+- **`class-wcmq-reaper.php` is split out** of retention. The reaper has three independent triggers (per-send, recurring, admin_init) and does not belong inside the daily sweep.
+- **No `class-wcmq-*-list-table.php`.** The queue and log views render `<table class="wp-list-table">` inline. Less machinery, no untestable `WP_List_Table` subclass.
+- **No AJAX handlers.** The admin posts to `admin-post.php`; handlers still verify nonce + `manage_woocommerce`. This sidesteps output-buffer and JSON-envelope plumbing entirely. Phase 8's acceptance criteria (nonce, cap, correct counts) still hold.
+
 Naming follows the repo: constants `STUDIOU_WCMQ_*`, CSS prefix `studiou-wcmq-`, JS namespace `studiouWcmq`, nonce `studiou-wcmq-nonce`, error-log prefix `STUDIOU WC MAIL:`, text domain `studiou-wc-mail-queue`, AS group `studiou-wcmq`, AS hook `studiou_wcmq_send`.
 
 Managers register their own hooks in their constructors. The main class only guards on WooCommerce, `require_once`s the includes, and instantiates. Nothing else.
@@ -869,7 +988,7 @@ Main file with the plugin header, `defined('ABSPATH') || exit;`, the five consta
 
 `class-wcmq-context.php` (probe, consume-once, `to`+`subject` fingerprint per §3.1) and `class-wcmq-interceptor.php` (`pre_wp_mail`, payload snapshot including resolved `From:` per §3.2, attachment copying per §3.3, re-entrancy guard per §3.4). Do **not** wire the queue yet — log the decision and return `null` so everything still sends normally.
 
-*Accepts when:* with `debug` on, completing a single order writes one log line naming `customer_completed_order`; a **WooCommerce** password reset writes `customer_reset_password`; a **wp-admin** password reset and a low-stock alert both write an empty context; all of them still arrive normally. **No mail is queued or lost in this phase.** Verify the captured `From:` matches WooCommerce → Settings → Emails, not `wordpress@`. Verify the fingerprint rejects a stale context by temporarily setting one by hand and firing an unrelated `wp_mail()`.
+*Accepts when:* with `debug` on, completing a single order writes one log line naming `customer_completed_order` — the line comes *before* any queue/pass-through decision, so it must appear even in this phase where nothing is queued; a **WooCommerce** password reset writes `customer_reset_password`; a **wp-admin** password reset and a low-stock alert produce no such line (status `none`, passed through silently). All still arrive normally. **No mail is queued or lost in this phase.** Verify the captured `From:` matches WooCommerce → Settings → Emails, not `wordpress@`. Verify the fingerprint rejects a stale context by temporarily setting one by hand and firing an unrelated `wp_mail()`. Add a `wp_mail` filter that rewrites the subject, complete an order, and confirm a **warning** log line naming the divergence — not silence.
 
 ### Phase 4 — Queue and slot allocation
 `class-wcmq-queue.php`: `allocate_slot($earliest)` per §4.2, `with_slot_lock()` **returning `[$slot, $result]`** per §4.3, attachment copy ordering per §3.3, then `as_schedule_single_action($slot, 'studiou_wcmq_send', array((int) $row_id), 'studiou-wcmq')` — note the `(int)` cast (§8.1). Flip the interceptor to return `true` — **but only on a successful enqueue** (§2).
@@ -894,9 +1013,12 @@ Specifically:
 - A retry lands on a slot that respects the rate cap, **not** at a bare `now + backoff` — assert `scheduled_at` is at least `interval` from every other pending row (§4.2, §4.3).
 - Firing `studiou_wcmq_send` twice for the same row sends exactly once.
 - `kill -9` mid-send leaves a `sending` row that the reaper returns to `pending` at a **future** slot after `claim_timeout`, bumping `reclaims` and **not** `attempts` (§5, §8.1).
-- A row corrupted to invalid JSON fails immediately with `last_error = 'corrupt payload'` and does **not** consume an attempt.
+- A row corrupted to invalid JSON fails immediately with `last_error = 'corrupt payload'`, does **not** consume an attempt, **and still writes a failure order note** — a terminal state must never leave the order silent (§3.1c).
+- A row driven past `max_reclaims` by repeated crashes lands in `failed` **with a failure order note** — the reaper's terminal path, easy to miss.
+- Every terminal state — `sent`, `failed` via max-attempts, `failed` via corrupt-payload, `failed` via max-reclaims — leaves exactly one note and one `transactional-emails` log line; the log line carries a **username or `guest`**, never a raw address, and any error text has addresses redacted.
 - A partial refund queues as `customer_partially_refunded_order` — which requires the alias from §6, and will fail if the selector was built from `get_emails()` alone.
-- Delete an order between enqueue and send: the mail still goes out, the row still reaches `sent`, and the worker does **not** fatal (§3.1c).
+- Delete an order between enqueue and send: the mail still goes out, the row reaches `sent` **or the commit reports 0 rows and the attachment dir is left intact for the reaper**, and the worker does **not** fatal (§3.1c).
+- Delete a row from the admin while a worker holds it: the worker's terminal `UPDATE` changes 0 rows, so it does **not** delete attachments or write a note for the vanished row.
 
 ### Phase 6 — State machine
 `class-wcmq-state.php`. Enable/disable/drain per §7.
@@ -949,7 +1071,16 @@ Pass `$unique = true` *and* pre-check with `as_next_scheduled_action()` — the
 `class-wcmq-admin.php`, two `WP_List_Table`s, three tab views. Submenu under **WooCommerce**, capability `manage_woocommerce`, following `Import_Manager` in `studiou-wc-ord-print-statuses`.
 
 - **Settings** — rate, retention, debug toggle, handled-contexts checkboxes rendered from `WC()->mailer()->get_emails()`, enable/disable control.
-- **Queue** — counts by state, list table (recipient, context, order, state, attempts, scheduled, sent), per-row Retry and Delete, bulk Retry/Delete, "Send next now".
+- **Queue** — counts by state, list table (recipient, context, order, state, attempts, scheduled, sent), per-row Retry and Delete, bulk Retry/Delete.
+
+  **Retry is a destructive action and needs a state guard, not just a confirm dialog.**
+
+  - **Never retry a `sent` row.** Its attachment directory was deleted on send, so the resend arrives with missing files *and* the customer receives the email twice. Select-all on the "All" or "Sent" filter must be harmless. This is one misclick from mailing the whole customer list again.
+  - **Never touch a row a worker is actively holding** — `sending` with `claimed_at` newer than `claim_timeout` — for *either* action. Retrying it delivers twice; deleting it pulls the attachment directory out from under a live `wp_mail()`. Past the claim timeout the worker is presumed dead (same rule as §8.1) and the row is fair game.
+  - **Unschedule before re-slotting.** A `pending` row already owns an Action Scheduler action; `reschedule()` without `as_unschedule_all_actions()` leaves two actions for one row.
+  - **Reset `reclaims` as well as `attempts`.** A manual retry is a fresh start; inheriting an accumulated crash budget means one more crash fails the row.
+
+  Order links must come from `WC_Order::get_edit_order_url()`, never a hardcoded `page=wc-orders` URL — that admin page does not exist when order storage is legacy posts. Guard on the order still existing.
 - **Log** — filterable by level, Clear Log behind a confirmation dialog.
 
 AJAX handlers: verify nonce, check `manage_woocommerce`, clean the output buffer, try/catch, reply via `wp_send_json_success()` / `wp_send_json_error()`. Sanitize in, escape out.
@@ -1041,6 +1172,9 @@ Also verify: WordPress password resets, low-stock alerts, and other non-handled
 - **`TRUNCATE` fails on restricted hosting.** It needs the `DROP` privilege, which the target shared hosts often withhold. Clear Log uses a batched `DELETE` (§5).
 - **`order_id` never populated.** `pre_wp_mail` cannot see the order; only `$email->object` in the `woocommerce_mail_callback` probe can (§3.1). Miss it and the admin Order column, the replacement order note, and any per-order debugging are all dead — while the schema looks correct.
 - **Order audit trail deleted rather than deferred.** Suppressing `EmailLogger`'s note by *email type* rather than by *was-this-mail-actually-queued* strips the note from handled-type mail that sent synchronously (draining, disabled, AS missing, enqueue failed), with no worker to write a replacement (§3.1c).
+- **A terminal state that writes no note.** The corrupt-payload and max-reclaims paths reach `failed` without ever calling `wp_mail()`, so it is easy to leave them without a `Notifier::annotate()`. The order then records nothing about a mail the customer never received — the audit-trail hole inverted from the previous risk. The notifier is a shared class specifically so all four terminal paths reach it (§3.1c).
+- **The reaper looping forever.** A re-slot whose state-guarded `UPDATE` changes no row must not schedule an action; otherwise the row stays `sending`, `reclaims` never persists, and every 5-minute sweep schedules a fresh dead action without tripping `max_reclaims` (§8.1). Low probability (needs a DB error or a concurrent mutation) but unbounded if it hits.
+- **Fingerprint mismatch mistaken for "no email in flight".** A `wp_mail` filter that rewrites recipient/subject makes every handled mail fall through unthrottled and silent. `consume()` distinguishes `none` from `mismatch` and logs a warning on the latter (§3.1), so an inert plugin is diagnosable instead of a mystery.
 - **Rate set above the host's real limit.** The plugin cannot discover the limit. Ask the host's support for the exact outbound messages-per-minute figure and set `rate_per_minute` below it. Put this in the readme; a misconfigured cap reproduces the original bug with extra steps.
 
 ---

+ 513 - 0
studiou-wc-mail-queue/includes/class-wcmq-admin.php

@@ -0,0 +1,513 @@
+<?php
+/**
+ * Admin screen: settings, queue and log tabs.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Admin
+ */
+class Studiou_WCMQ_Admin {
+
+	const PAGE     = 'studiou-wcmq';
+	const CAP      = 'manage_woocommerce';
+	const NONCE    = 'studiou-wcmq-nonce';
+	const PER_PAGE = 50;
+
+	/**
+	 * Constructor.
+	 */
+	public function __construct() {
+		add_action( 'admin_menu', array( $this, 'add_menu' ), 99 );
+		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
+		add_action( 'admin_notices', array( $this, 'maybe_warn_deferred_emails' ) );
+		add_action( 'admin_notices', array( $this, 'maybe_warn_pluggable_wp_mail' ) );
+
+		add_action( 'admin_post_studiou_wcmq_save_settings', array( $this, 'handle_save_settings' ) );
+		add_action( 'admin_post_studiou_wcmq_toggle_state', array( $this, 'handle_toggle_state' ) );
+		add_action( 'admin_post_studiou_wcmq_clear_log', array( $this, 'handle_clear_log' ) );
+		add_action( 'admin_post_studiou_wcmq_queue_action', array( $this, 'handle_queue_action' ) );
+	}
+
+	/**
+	 * Register the submenu under WooCommerce.
+	 */
+	public function add_menu() {
+		add_submenu_page(
+			'woocommerce',
+			__( 'Mail Queue', 'studiou-wc-mail-queue' ),
+			__( 'Mail Queue', 'studiou-wc-mail-queue' ),
+			self::CAP,
+			self::PAGE,
+			array( $this, 'render_page' )
+		);
+	}
+
+	/**
+	 * Enqueue admin assets on our page only.
+	 *
+	 * @param string $hook Current admin page hook.
+	 */
+	public function enqueue_assets( $hook ) {
+		if ( 'woocommerce_page_' . self::PAGE !== $hook ) {
+			return;
+		}
+		wp_enqueue_style(
+			'studiou-wcmq-admin',
+			STUDIOU_WCMQ_URL . 'assets/css/admin.css',
+			array(),
+			STUDIOU_WCMQ_VERSION
+		);
+		wp_enqueue_script(
+			'studiou-wcmq-admin',
+			STUDIOU_WCMQ_URL . 'assets/js/admin.js',
+			array( 'jquery' ),
+			STUDIOU_WCMQ_VERSION,
+			true
+		);
+		wp_localize_script(
+			'studiou-wcmq-admin',
+			'studiouWcmq',
+			array(
+				'confirmDelete'   => __( 'Delete the selected rows? This cannot be undone.', 'studiou-wc-mail-queue' ),
+				'confirmRetry'    => __( 'Re-queue the selected rows? Already-sent rows are skipped, but anything still pending or failed will be sent again.', 'studiou-wc-mail-queue' ),
+				'confirmClearLog' => __( 'Clear the entire log? This cannot be undone.', 'studiou-wc-mail-queue' ),
+			)
+		);
+	}
+
+	/**
+	 * Warn when WooCommerce's own "Deferred emails" feature is on.
+	 *
+	 * It moves mail off the request but applies no rate cap, and chained with
+	 * this plugin it produces two queues in series. Never flip it silently.
+	 */
+	public function maybe_warn_deferred_emails() {
+		if ( ! current_user_can( self::CAP ) ) {
+			return;
+		}
+		if ( ! Studiou_WCMQ_State::is_queueing() ) {
+			return;
+		}
+
+		$default = false;
+		if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
+			$default = (bool) \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'deferred_transactional_emails' );
+		}
+		/** This filter is documented in woocommerce/includes/class-wc-emails.php */
+		$deferred = (bool) apply_filters( 'woocommerce_defer_transactional_emails', $default );
+
+		if ( ! $deferred ) {
+			return;
+		}
+		?>
+		<div class="notice notice-warning">
+			<p>
+				<strong><?php esc_html_e( 'Studiou WC Mail Queue', 'studiou-wc-mail-queue' ); ?>:</strong>
+				<?php esc_html_e( 'WooCommerce\'s "Deferred emails" feature is enabled. It moves mail off the request but applies no rate limit, and chained with this plugin it queues every email twice. Turn it off under WooCommerce → Settings → Advanced → Features.', 'studiou-wc-mail-queue' ); ?>
+			</p>
+		</div>
+		<?php
+	}
+
+	/**
+	 * Warn when another plugin has replaced wp_mail() outright.
+	 *
+	 * `wp_mail()` is pluggable. A mailer that redefines it never applies
+	 * `pre_wp_mail` at all, so this plugin becomes silently, completely inert —
+	 * no error, no log line, mail leaving unthrottled. On a host that rate-limits
+	 * outbound mail an SMTP plugin is likely rather than hypothetical, so detect
+	 * it rather than documenting it and hoping.
+	 */
+	public function maybe_warn_pluggable_wp_mail() {
+		if ( ! current_user_can( self::CAP ) ) {
+			return;
+		}
+		if ( ! Studiou_WCMQ_State::is_queueing() ) {
+			return;
+		}
+
+		try {
+			$reflection = new ReflectionFunction( 'wp_mail' );
+			$defined_in = wp_normalize_path( (string) $reflection->getFileName() );
+		} catch ( Throwable $e ) {
+			return;
+		}
+
+		$core = wp_normalize_path( ABSPATH . WPINC . '/pluggable.php' );
+		if ( $defined_in === $core ) {
+			return;
+		}
+		?>
+		<div class="notice notice-error">
+			<p>
+				<strong><?php esc_html_e( 'Studiou WC Mail Queue', 'studiou-wc-mail-queue' ); ?>:</strong>
+				<?php
+				printf(
+					/* translators: %s: file path where wp_mail() is defined */
+					esc_html__( 'Another plugin has replaced the pluggable wp_mail() function (defined in %s). This plugin can only queue mail that passes through WordPress\'s own wp_mail(), so nothing is being throttled. Outbound mail is sending unthrottled.', 'studiou-wc-mail-queue' ),
+					'<code>' . esc_html( $defined_in ) . '</code>'
+				);
+				?>
+			</p>
+		</div>
+		<?php
+	}
+
+	/* ---------------------------------------------------------------------
+	 * Rendering
+	 * ------------------------------------------------------------------ */
+
+	/**
+	 * Render the tabbed page.
+	 */
+	public function render_page() {
+		if ( ! current_user_can( self::CAP ) ) {
+			wp_die( esc_html__( 'You do not have permission to access this page.', 'studiou-wc-mail-queue' ) );
+		}
+
+		$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		if ( ! in_array( $tab, array( 'settings', 'queue', 'log' ), true ) ) {
+			$tab = 'settings';
+		}
+
+		include STUDIOU_WCMQ_DIR . 'views/admin-page.php';
+	}
+
+	/**
+	 * URL of a tab on this page.
+	 *
+	 * @param string $tab  Tab slug.
+	 * @param array  $args Extra query args.
+	 * @return string
+	 */
+	public static function tab_url( $tab, array $args = array() ) {
+		$args = array_merge(
+			array(
+				'page' => self::PAGE,
+				'tab'  => $tab,
+			),
+			$args
+		);
+		return add_query_arg( $args, admin_url( 'admin.php' ) );
+	}
+
+	/**
+	 * Email types the admin may choose to queue.
+	 *
+	 * Built from WC()->mailer()->get_emails(), then unioned with the runtime
+	 * alias ids. get_emails() alone is not sufficient: see
+	 * Studiou_WCMQ_Settings::RUNTIME_ALIAS_IDS.
+	 *
+	 * @return array id => label
+	 */
+	public static function available_contexts() {
+		$out = array();
+
+		if ( function_exists( 'WC' ) && WC()->mailer() ) {
+			$emails = WC()->mailer()->get_emails();
+			if ( is_array( $emails ) ) {
+				foreach ( $emails as $email ) {
+					if ( ! is_object( $email ) || empty( $email->id ) ) {
+						continue;
+					}
+					$title              = isset( $email->title ) && $email->title ? $email->title : $email->id;
+					$out[ $email->id ] = $title;
+				}
+			}
+		}
+
+		foreach ( Studiou_WCMQ_Settings::RUNTIME_ALIAS_IDS as $alias_id => $parent_id ) {
+			if ( isset( $out[ $alias_id ] ) ) {
+				continue;
+			}
+			$parent_label = isset( $out[ $parent_id ] ) ? $out[ $parent_id ] : $parent_id;
+			/* translators: %s: parent email title */
+			$out[ $alias_id ] = sprintf( __( '%s (partial)', 'studiou-wc-mail-queue' ), $parent_label );
+		}
+
+		// Any id already selected but no longer offered stays visible, so an
+		// upgrade cannot silently drop a setting the admin cannot see.
+		foreach ( Studiou_WCMQ_Settings::handled_contexts() as $id ) {
+			if ( ! isset( $out[ $id ] ) ) {
+				$out[ $id ] = $id;
+			}
+		}
+
+		asort( $out );
+		return $out;
+	}
+
+	/* ---------------------------------------------------------------------
+	 * Handlers
+	 * ------------------------------------------------------------------ */
+
+	/**
+	 * Verify nonce + capability, or die.
+	 */
+	private function guard() {
+		if ( ! current_user_can( self::CAP ) ) {
+			wp_die( esc_html__( 'You do not have permission to do that.', 'studiou-wc-mail-queue' ) );
+		}
+		check_admin_referer( self::NONCE );
+	}
+
+	/**
+	 * Redirect back to a tab.
+	 *
+	 * @param string $tab Tab slug.
+	 */
+	private function redirect( $tab ) {
+		wp_safe_redirect( self::tab_url( $tab ) );
+		exit;
+	}
+
+	/**
+	 * Save the settings form.
+	 */
+	public function handle_save_settings() {
+		$this->guard();
+
+		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+		$raw = array(
+			'rate_per_minute'       => isset( $_POST['rate_per_minute'] ) ? wp_unslash( $_POST['rate_per_minute'] ) : '',
+			'handled_contexts'      => isset( $_POST['handled_contexts'] ) ? (array) wp_unslash( $_POST['handled_contexts'] ) : array(),
+			'retention_sent_days'   => isset( $_POST['retention_sent_days'] ) ? wp_unslash( $_POST['retention_sent_days'] ) : '',
+			'retention_failed_days' => isset( $_POST['retention_failed_days'] ) ? wp_unslash( $_POST['retention_failed_days'] ) : '',
+			'retention_log_days'    => isset( $_POST['retention_log_days'] ) ? wp_unslash( $_POST['retention_log_days'] ) : '',
+			'max_attempts'          => isset( $_POST['max_attempts'] ) ? wp_unslash( $_POST['max_attempts'] ) : '',
+			'max_reclaims'          => isset( $_POST['max_reclaims'] ) ? wp_unslash( $_POST['max_reclaims'] ) : '',
+			'claim_timeout'         => isset( $_POST['claim_timeout'] ) ? wp_unslash( $_POST['claim_timeout'] ) : '',
+			'debug'                 => ! empty( $_POST['debug'] ),
+		);
+		// phpcs:enable
+
+		Studiou_WCMQ_Settings::update( $raw );
+		Studiou_WCMQ_Utils_Log::message( __( 'Settings saved.', 'studiou-wc-mail-queue' ), 'success' );
+		$this->redirect( 'settings' );
+	}
+
+	/**
+	 * Enable or disable queueing.
+	 */
+	public function handle_toggle_state() {
+		$this->guard();
+
+		$want = isset( $_POST['wcmq_state'] ) ? sanitize_key( wp_unslash( $_POST['wcmq_state'] ) ) : '';
+
+		if ( Studiou_WCMQ_Settings::STATE_ENABLED === $want ) {
+			Studiou_WCMQ_State::enable();
+			Studiou_WCMQ_Utils_Log::message( __( 'Mail queue enabled.', 'studiou-wc-mail-queue' ), 'success' );
+		} else {
+			Studiou_WCMQ_State::disable();
+			if ( Studiou_WCMQ_Settings::STATE_DRAINING === Studiou_WCMQ_State::get() ) {
+				Studiou_WCMQ_Utils_Log::message(
+					__( 'Mail queue is draining. New mail sends immediately; queued mail will finish first.', 'studiou-wc-mail-queue' ),
+					'info'
+				);
+			} else {
+				Studiou_WCMQ_Utils_Log::message( __( 'Mail queue disabled.', 'studiou-wc-mail-queue' ), 'success' );
+			}
+		}
+
+		$this->redirect( 'settings' );
+	}
+
+	/**
+	 * Empty the log.
+	 */
+	public function handle_clear_log() {
+		$this->guard();
+		$n = Studiou_WCMQ_DB::clear_log();
+		/* translators: %d: number of rows */
+		Studiou_WCMQ_Utils_Log::message( sprintf( __( 'Log cleared (%d entries).', 'studiou-wc-mail-queue' ), $n ), 'success' );
+		$this->redirect( 'log' );
+	}
+
+	/**
+	 * Retry or delete queue rows.
+	 */
+	public function handle_queue_action() {
+		$this->guard();
+
+		$action = isset( $_POST['wcmq_action'] ) ? sanitize_key( wp_unslash( $_POST['wcmq_action'] ) ) : '';
+		$ids    = isset( $_POST['rows'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['rows'] ) ) : array();
+		$ids    = array_filter( $ids );
+
+		if ( empty( $ids ) || ! in_array( $action, array( 'retry', 'delete' ), true ) ) {
+			Studiou_WCMQ_Utils_Log::message( __( 'Nothing to do.', 'studiou-wc-mail-queue' ), 'info' );
+			$this->redirect( 'queue' );
+		}
+
+		$done         = 0;
+		$failed       = 0;
+		$skipped_live = 0;
+		$skipped_sent = 0;
+
+		$now           = time();
+		$claim_timeout = (int) Studiou_WCMQ_Settings::get( 'claim_timeout' );
+
+		foreach ( $ids as $row_id ) {
+			$row = Studiou_WCMQ_DB::get_row( $row_id );
+			if ( ! $row ) {
+				continue;
+			}
+
+			// A row a worker is actively holding must not be touched by either
+			// action: retrying it delivers the mail twice, and deleting it pulls
+			// the attachment directory out from under a wp_mail() that is still
+			// reading it. Once the claim is older than claim_timeout the worker is
+			// presumed dead (same rule the reaper uses) and the row is fair game.
+			if ( Studiou_WCMQ_DB::STATE_SENDING === $row->state ) {
+				$claimed_at = (int) $row->claimed_at;
+				if ( $claimed_at > 0 && ( $now - $claimed_at ) < $claim_timeout ) {
+					++$skipped_live;
+					continue;
+				}
+			}
+
+			if ( 'delete' === $action ) {
+				// Drop any scheduled action first, so Action Scheduler does not
+				// wake a worker for a row that no longer exists.
+				if ( function_exists( 'as_unschedule_all_actions' ) ) {
+					as_unschedule_all_actions( Studiou_WCMQ_Queue::HOOK, array( (int) $row_id ), Studiou_WCMQ_Queue::GROUP );
+				}
+				Studiou_WCMQ_Queue::delete_attachment_dir( $row->attachment_dir );
+				if ( Studiou_WCMQ_DB::delete_row( $row_id ) ) {
+					++$done;
+				} else {
+					++$failed;
+				}
+				continue;
+			}
+
+			// NEVER re-queue a row that already went out. Its attachment directory
+			// was deleted on send, so the resend would arrive with missing files —
+			// and the customer would receive the email a second time. Select-all on
+			// the "All" or "Sent" filter must be harmless.
+			if ( Studiou_WCMQ_DB::STATE_SENT === $row->state ) {
+				++$skipped_sent;
+				continue;
+			}
+
+			// Retry: a fresh attempt budget, a fresh crash budget, a fresh slot.
+			// Pass the state we observed as the guard: if a worker has claimed this
+			// row (pending -> sending) since we read it, reschedule() changes 0
+			// rows and returns false rather than flipping a mid-send row back to
+			// pending and double-delivering. reschedule() also drops any stale
+			// action itself, so no separate unschedule here.
+			$ok = Studiou_WCMQ_Queue::reschedule(
+				$row_id,
+				0,
+				array(
+					'attempts'   => 0,
+					'reclaims'   => 0,
+					'last_error' => null,
+				),
+				$row->state
+			);
+			if ( $ok ) {
+				++$done;
+			} else {
+				++$failed;
+			}
+		}
+
+		if ( 'delete' === $action ) {
+			/* translators: %d: number of rows */
+			Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows deleted.', 'studiou-wc-mail-queue' ), $done ), 'success' );
+		} else {
+			/* translators: %d: number of rows */
+			Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows re-queued.', 'studiou-wc-mail-queue' ), $done ), 'success' );
+		}
+		if ( $failed ) {
+			/* translators: %d: number of rows */
+			Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows could not be processed.', 'studiou-wc-mail-queue' ), $failed ), 'warning' );
+		}
+		if ( $skipped_live ) {
+			/* translators: %d: number of rows */
+			Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows skipped because they are currently sending.', 'studiou-wc-mail-queue' ), $skipped_live ), 'info' );
+		}
+		if ( $skipped_sent ) {
+			/* translators: %d: number of rows */
+			Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows skipped because they were already sent. Re-queueing them would deliver the email twice.', 'studiou-wc-mail-queue' ), $skipped_sent ), 'warning' );
+		}
+
+		Studiou_WCMQ_State::maybe_settle();
+		$this->redirect( 'queue' );
+	}
+
+	/* ---------------------------------------------------------------------
+	 * View helpers
+	 * ------------------------------------------------------------------ */
+
+	/**
+	 * Format a unix timestamp in site time.
+	 *
+	 * @param int|null $ts Timestamp.
+	 * @return string
+	 */
+	public static function format_time( $ts ) {
+		$ts = (int) $ts;
+		if ( $ts <= 0 ) {
+			return '&mdash;';
+		}
+		return esc_html( wp_date( 'Y-m-d H:i:s', $ts ) );
+	}
+
+	/**
+	 * Current page number from the query string.
+	 *
+	 * @return int
+	 */
+	public static function current_page() {
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$paged = isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 1;
+		return max( 1, $paged );
+	}
+
+	/**
+	 * Render simple prev/next pagination.
+	 *
+	 * @param string $tab   Tab slug.
+	 * @param int    $total Total rows.
+	 * @param int    $paged Current page.
+	 * @param array  $args  Extra query args.
+	 */
+	public static function render_pagination( $tab, $total, $paged, array $args = array() ) {
+		$pages = (int) ceil( $total / self::PER_PAGE );
+		if ( $pages < 2 ) {
+			return;
+		}
+		echo '<div class="tablenav"><div class="tablenav-pages">';
+		printf(
+			'<span class="displaying-num">%s</span> ',
+			esc_html( sprintf( /* translators: %d: number of items */ _n( '%d item', '%d items', $total, 'studiou-wc-mail-queue' ), $total ) )
+		);
+		if ( $paged > 1 ) {
+			printf(
+				'<a class="button" href="%s">&laquo; %s</a> ',
+				esc_url( self::tab_url( $tab, array_merge( $args, array( 'paged' => $paged - 1 ) ) ) ),
+				esc_html__( 'Previous', 'studiou-wc-mail-queue' )
+			);
+		}
+		printf(
+			'<span class="paging-input">%s</span> ',
+			esc_html( sprintf( /* translators: 1: current page, 2: total pages */ __( '%1$d of %2$d', 'studiou-wc-mail-queue' ), $paged, $pages ) )
+		);
+		if ( $paged < $pages ) {
+			printf(
+				'<a class="button" href="%s">%s &raquo;</a>',
+				esc_url( self::tab_url( $tab, array_merge( $args, array( 'paged' => $paged + 1 ) ) ) ),
+				esc_html__( 'Next', 'studiou-wc-mail-queue' )
+			);
+		}
+		echo '</div></div>';
+	}
+}

+ 262 - 0
studiou-wc-mail-queue/includes/class-wcmq-context.php

@@ -0,0 +1,262 @@
+<?php
+/**
+ * Request-scoped tag identifying which WooCommerce email is currently sending.
+ *
+ * `pre_wp_mail` receives only to/subject/message/headers/attachments — nothing
+ * that identifies the WC_Email class. `WC_Email::send()` applies
+ * `woocommerce_mail_callback` with $this as the second argument, immediately
+ * before calling wp_mail(). That is the only hook that exposes the instance.
+ *
+ * Verified against WooCommerce 10.9.4, includes/emails/class-wc-email.php:1234.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Context
+ */
+class Studiou_WCMQ_Context {
+
+	/**
+	 * Context for the send currently in progress.
+	 *
+	 * @var array|null
+	 */
+	private static $current = null;
+
+	/**
+	 * Row id of the mail queued by the current send, or 0.
+	 *
+	 * @var int
+	 */
+	private static $queued_row_id = 0;
+
+	/**
+	 * WC_Email id of the mail that was queued, or ''.
+	 *
+	 * The EmailLogger suppression filters must match on this. They fire for
+	 * emails that never reach send() at all — see suppress_email_log().
+	 *
+	 * @var string
+	 */
+	private static $queued_context_id = '';
+
+	/**
+	 * Register hooks.
+	 */
+	public static function init() {
+		add_filter( 'woocommerce_mail_callback', array( __CLASS__, 'probe_callback' ), 10, 2 );
+		add_filter( 'woocommerce_mail_callback_params', array( __CLASS__, 'probe_params' ), 10, 2 );
+
+		// These fire from send_notification() before send(), so they mean the
+		// current email is NOT the one we queued. Clear the flag before
+		// EmailLogger (priority 10) reads it, so its "not sent" log line for a
+		// disabled/skipped same-id email is not suppressed. See clear_queued().
+		add_action( 'woocommerce_email_disabled', array( __CLASS__, 'clear_queued' ), 1 );
+		add_action( 'woocommerce_email_skipped', array( __CLASS__, 'clear_queued' ), 1 );
+
+		add_action( 'shutdown', array( __CLASS__, 'clear_all' ) );
+	}
+
+	/**
+	 * Capture the email identity. Returns the callback untouched.
+	 *
+	 * Reading $email->id here (rather than caching it per instance) is required:
+	 * WC_Email_Customer_Refunded_Order::trigger() reassigns its own id to
+	 * 'customer_partially_refunded_order' before send() runs.
+	 *
+	 * $email->object is the only route to the order — pre_wp_mail cannot see it.
+	 *
+	 * @param callable $callback Mail callback ('wp_mail').
+	 * @param mixed    $email    WC_Email instance.
+	 * @return callable
+	 */
+	public static function probe_callback( $callback, $email = null ) {
+		// A new send is beginning: reset the was-queued flag for it. The flag is
+		// NOT cleared after use, because woocommerce_email_sent (which reads it)
+		// fires after wp_mail() has already returned.
+		self::$queued_row_id     = 0;
+		self::$queued_context_id = '';
+
+		$id       = '';
+		$order_id = 0;
+
+		if ( is_object( $email ) ) {
+			if ( isset( $email->id ) ) {
+				$id = (string) $email->id;
+			}
+			// $email->object is a WC_Product for stock mail, an id/array for some
+			// others, and null for a few. Only an order is useful to us.
+			if ( isset( $email->object ) && $email->object instanceof WC_Order ) {
+				$order_id = (int) $email->object->get_id();
+			}
+		}
+
+		self::$current = array(
+			'id'       => $id,
+			'order_id' => $order_id,
+			'to'       => null,
+			'subject'  => null,
+		);
+
+		return $callback;
+	}
+
+	/**
+	 * Capture the to/subject fingerprint.
+	 *
+	 * @param array $params [$to, $subject, $message, $headers, $attachments].
+	 * @param mixed $email  WC_Email instance.
+	 * @return array
+	 */
+	public static function probe_params( $params, $email = null ) {
+		unset( $email );
+		if ( is_array( self::$current ) && is_array( $params ) && isset( $params[0], $params[1] ) ) {
+			self::$current['to']      = $params[0];
+			self::$current['subject'] = $params[1];
+		}
+		return $params;
+	}
+
+	/**
+	 * Consume the context, once, if it belongs to this wp_mail() call.
+	 *
+	 * The fingerprint check matters: a plugin that filters
+	 * woocommerce_mail_callback to return its own mailer means wp_mail() never
+	 * runs, our context is never consumed, and the next unrelated wp_mail() in
+	 * the request would otherwise inherit it — silently queueing, say, a
+	 * password reset as if it were an order email.
+	 *
+	 * Distinguishes three outcomes, because they mean different things to the
+	 * caller and the second must be visible to an admin:
+	 *
+	 *   ['status' => 'none']     no WooCommerce email was in flight — pass through
+	 *                            silently, this is every password reset and every
+	 *                            non-WC wp_mail() on the site.
+	 *   ['status' => 'mismatch'] a WC email WAS in flight but the fingerprint
+	 *                            rejected it. That means a wp_mail filter rewrote
+	 *                            the recipient or subject between our capture (at
+	 *                            woocommerce_mail_callback_params) and now — a
+	 *                            staging redirector, a BCC archiver. The mail then
+	 *                            sends unthrottled, and the admin needs to know the
+	 *                            plugin is being defeated, not left guessing why it
+	 *                            "does nothing".
+	 *   ['status' => 'ok', ...]  matched; the context fields are merged in.
+	 *
+	 * @param mixed $to      wp_mail 'to'.
+	 * @param mixed $subject wp_mail 'subject'.
+	 * @return array
+	 */
+	public static function consume( $to, $subject ) {
+		$ctx           = self::$current;
+		self::$current = null;
+
+		// No WooCommerce email was mid-send, or the params probe never ran.
+		if ( ! is_array( $ctx ) || null === $ctx['to'] || null === $ctx['subject'] ) {
+			return array( 'status' => 'none' );
+		}
+
+		$to_match = self::normalise_to( $ctx['to'] ) === self::normalise_to( $to );
+		// The subject reaching wp_mail() has already been through
+		// wp_specialchars_decode(); we captured the same decoded value.
+		$subject_match = (string) $ctx['subject'] === (string) $subject;
+
+		if ( ! $to_match || ! $subject_match ) {
+			return array(
+				'status'  => 'mismatch',
+				'id'      => $ctx['id'],
+				'differs' => ( ! $to_match && ! $subject_match ) ? 'recipient and subject'
+					: ( ! $to_match ? 'recipient' : 'subject' ),
+			);
+		}
+
+		$ctx['status'] = 'ok';
+		return $ctx;
+	}
+
+	/**
+	 * Normalise a wp_mail recipient (string or array) for comparison.
+	 *
+	 * @param mixed $to Recipient.
+	 * @return string
+	 */
+	private static function normalise_to( $to ) {
+		if ( is_array( $to ) ) {
+			$to = implode( ',', $to );
+		}
+		return strtolower( trim( (string) $to ) );
+	}
+
+	/**
+	 * Record that the current send was queued rather than sent.
+	 *
+	 * @param int    $row_id     Queue row id.
+	 * @param string $context_id WC_Email id that was queued.
+	 */
+	public static function mark_queued( $row_id, $context_id ) {
+		self::$queued_row_id     = (int) $row_id;
+		self::$queued_context_id = (string) $context_id;
+	}
+
+	/**
+	 * Whether the current send was queued.
+	 *
+	 * Read by the EmailLogger suppression filters on woocommerce_email_sent.
+	 *
+	 * @return bool
+	 */
+	public static function was_queued() {
+		return self::$queued_row_id > 0;
+	}
+
+	/**
+	 * Queue row id of the current send, or 0.
+	 *
+	 * @return int
+	 */
+	public static function queued_row_id() {
+		return self::$queued_row_id;
+	}
+
+	/**
+	 * WC_Email id of the mail that was queued, or ''.
+	 *
+	 * @return string
+	 */
+	public static function queued_context_id() {
+		return self::$queued_context_id;
+	}
+
+	/**
+	 * Clear only the was-queued flag, leaving $current intact.
+	 *
+	 * Hooked to woocommerce_email_disabled / woocommerce_email_skipped, which
+	 * fire from WC_Email::send_notification() BEFORE send() — so the probe never
+	 * runs for them and never resets the flag. Without this, a second
+	 * same-id email in one request (e.g. two customer_completed_order sends, the
+	 * first queued, the second skipped for want of a billing address) would still
+	 * see was_queued() true with a matching id, and we would silently swallow
+	 * WooCommerce's "not sent" log line for the skipped one.
+	 *
+	 * @param mixed $arg1 First hook arg (email_id or reason); ignored.
+	 */
+	public static function clear_queued( $arg1 = null ) {
+		unset( $arg1 );
+		self::$queued_row_id     = 0;
+		self::$queued_context_id = '';
+	}
+
+	/**
+	 * Defensive end-of-request clear.
+	 */
+	public static function clear_all() {
+		self::$current           = null;
+		self::$queued_row_id     = 0;
+		self::$queued_context_id = '';
+	}
+}

+ 446 - 0
studiou-wc-mail-queue/includes/class-wcmq-db.php

@@ -0,0 +1,446 @@
+<?php
+/**
+ * Schema, migrations and row access.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_DB
+ */
+class Studiou_WCMQ_DB {
+
+	const DB_VERSION_OPTION = 'studiou_wcmq_db_version';
+	const DB_VERSION        = '1.0.0';
+
+	const STATE_PENDING = 'pending';
+	const STATE_SENDING = 'sending';
+	const STATE_SENT    = 'sent';
+	const STATE_FAILED  = 'failed';
+
+	/**
+	 * Queue table name.
+	 *
+	 * @return string
+	 */
+	public static function queue_table() {
+		global $wpdb;
+		return $wpdb->prefix . 'studiou_wcmq_queue';
+	}
+
+	/**
+	 * Log table name.
+	 *
+	 * @return string
+	 */
+	public static function log_table() {
+		global $wpdb;
+		return $wpdb->prefix . 'studiou_wcmq_log';
+	}
+
+	/**
+	 * Create/upgrade tables when the stored schema version lags the constant.
+	 *
+	 * mu-plugins get no activation hook, so this runs on load. dbDelta is
+	 * idempotent; the option check keeps it to one comparison per request.
+	 */
+	public static function maybe_upgrade_db() {
+		if ( get_option( self::DB_VERSION_OPTION ) === self::DB_VERSION ) {
+			return;
+		}
+		self::create_tables();
+		update_option( self::DB_VERSION_OPTION, self::DB_VERSION, true );
+	}
+
+	/**
+	 * Run dbDelta for both tables.
+	 *
+	 * All timestamp columns are SIGNED bigint. Unsigned subtraction underflows
+	 * into MySQL error 1690 inside the slot allocator's range comparison.
+	 */
+	private static function create_tables() {
+		global $wpdb;
+
+		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+		$charset_collate = $wpdb->get_charset_collate();
+		$queue           = self::queue_table();
+		$log             = self::log_table();
+
+		$sql_queue = "CREATE TABLE {$queue} (
+	id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+	context varchar(100) NOT NULL DEFAULT '',
+	order_id bigint(20) unsigned NOT NULL DEFAULT 0,
+	recipient varchar(320) NOT NULL DEFAULT '',
+	subject text NOT NULL,
+	payload longtext NOT NULL,
+	attachment_dir char(32) NULL,
+	priority smallint(6) NOT NULL DEFAULT 10,
+	state varchar(20) NOT NULL DEFAULT 'pending',
+	attempts tinyint(3) unsigned NOT NULL DEFAULT 0,
+	reclaims tinyint(3) unsigned NOT NULL DEFAULT 0,
+	last_error text NULL,
+	scheduled_at bigint(20) NOT NULL DEFAULT 0,
+	claimed_at bigint(20) NULL,
+	sent_at bigint(20) NULL,
+	created_at bigint(20) NOT NULL DEFAULT 0,
+	PRIMARY KEY  (id),
+	KEY state_sched (state,scheduled_at),
+	KEY state_claim (state,claimed_at),
+	KEY created_at (created_at),
+	KEY order_id (order_id)
+) {$charset_collate};";
+
+		$sql_log = "CREATE TABLE {$log} (
+	id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+	level varchar(10) NOT NULL DEFAULT 'info',
+	queue_id bigint(20) unsigned NULL,
+	message text NOT NULL,
+	created_at bigint(20) NOT NULL DEFAULT 0,
+	PRIMARY KEY  (id),
+	KEY created_at (created_at),
+	KEY level (level)
+) {$charset_collate};";
+
+		dbDelta( $sql_queue );
+		dbDelta( $sql_log );
+	}
+
+	/* ---------------------------------------------------------------------
+	 * Queue rows
+	 * ------------------------------------------------------------------ */
+
+	/**
+	 * Insert a queue row.
+	 *
+	 * @param array $data Column => value.
+	 * @return int Row id, or 0 on failure.
+	 */
+	public static function insert_row( array $data ) {
+		global $wpdb;
+		$ok = $wpdb->insert( self::queue_table(), $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+		if ( false === $ok ) {
+			return 0;
+		}
+		return (int) $wpdb->insert_id;
+	}
+
+	/**
+	 * Fetch one queue row.
+	 *
+	 * @param int $row_id Row id.
+	 * @return object|null
+	 */
+	public static function get_row( $row_id ) {
+		global $wpdb;
+		$table = self::queue_table();
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) $row_id ) );
+	}
+
+	/**
+	 * Update a queue row. Null values are written as SQL NULL by $wpdb.
+	 *
+	 * Returns $wpdb->update()'s raw result — rows CHANGED, or false on a DB
+	 * error. Callers must decide what they need, because the two are different
+	 * questions and conflating them hides real failures:
+	 *
+	 *   false === $r   a DB error occurred
+	 *   0    === $r    no row changed: the WHERE matched nothing, or the values
+	 *                  were already what we asked for (WordPress does not set
+	 *                  CLIENT_FOUND_ROWS, so matched-but-unchanged reads as 0)
+	 *   >0             rows changed
+	 *
+	 * Do not "fix" a caller by testing `> 0` on an update that may legitimately
+	 * be a no-op. Where a caller needs to know the row is still in the state it
+	 * expects, use update_row_if_state() instead.
+	 *
+	 * @param int   $row_id Row id.
+	 * @param array $data   Column => value.
+	 * @return int|false
+	 */
+	public static function update_row( $row_id, array $data ) {
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
+		return $wpdb->update( self::queue_table(), $data, array( 'id' => (int) $row_id ) );
+	}
+
+	/**
+	 * Update a queue row only while it is still in the expected state.
+	 *
+	 * The state is part of the WHERE clause, so a row deleted from the admin,
+	 * or reclaimed by the reaper, or already resolved by another worker, comes
+	 * back as 0 rows rather than as a silent success. Every terminal write in
+	 * the worker and the reaper uses this: the alternative is deleting a row's
+	 * attachments and writing its order note after the row has ceased to exist.
+	 *
+	 * All these callers change `state` (or `scheduled_at` to a future slot), so
+	 * a matched row always reports >= 1 changed. 0 unambiguously means "not in
+	 * the state I expected".
+	 *
+	 * @param int    $row_id         Row id.
+	 * @param array  $data           Column => value.
+	 * @param string $expected_state State the row must currently be in.
+	 * @return int|false Rows changed, or false on DB error.
+	 */
+	public static function update_row_if_state( $row_id, array $data, $expected_state ) {
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
+		return $wpdb->update(
+			self::queue_table(),
+			$data,
+			array(
+				'id'    => (int) $row_id,
+				'state' => (string) $expected_state,
+			)
+		);
+	}
+
+	/**
+	 * Atomically claim a pending row for sending.
+	 *
+	 * This is the duplicate-send guard. Never replace it with SELECT-then-UPDATE.
+	 *
+	 * It does NOT touch `attempts`. The worker folds `attempts` into the same
+	 * UPDATE that commits the outcome, so no row is ever "attempted but not
+	 * resolved". Crashes are counted separately, in `reclaims`, by the reaper.
+	 *
+	 * @param int $row_id Row id.
+	 * @return bool True when this process won the claim.
+	 */
+	public static function claim( $row_id ) {
+		global $wpdb;
+		$table = self::queue_table();
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		$affected = $wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$table} SET state = %s, claimed_at = %d WHERE id = %d AND state = %s",
+				self::STATE_SENDING,
+				time(),
+				(int) $row_id,
+				self::STATE_PENDING
+			)
+		);
+		return 1 === (int) $affected;
+	}
+
+	/**
+	 * Count rows still in flight.
+	 *
+	 * @return int
+	 */
+	public static function count_unfinished() {
+		global $wpdb;
+		$table = self::queue_table();
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		return (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$table} WHERE state IN (%s, %s)",
+				self::STATE_PENDING,
+				self::STATE_SENDING
+			)
+		);
+	}
+
+	/**
+	 * Row counts keyed by state.
+	 *
+	 * @return array
+	 */
+	public static function counts_by_state() {
+		global $wpdb;
+		$table = self::queue_table();
+		$out   = array(
+			self::STATE_PENDING => 0,
+			self::STATE_SENDING => 0,
+			self::STATE_SENT    => 0,
+			self::STATE_FAILED  => 0,
+		);
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		$rows = $wpdb->get_results( "SELECT state, COUNT(*) AS c FROM {$table} GROUP BY state" );
+		if ( is_array( $rows ) ) {
+			foreach ( $rows as $row ) {
+				$out[ $row->state ] = (int) $row->c;
+			}
+		}
+		return $out;
+	}
+
+	/**
+	 * Paginated queue rows for the admin view.
+	 *
+	 * @param string $state    State filter, or '' for all.
+	 * @param int    $per_page Rows per page.
+	 * @param int    $offset   Offset.
+	 * @return array
+	 */
+	public static function get_rows( $state, $per_page, $offset ) {
+		global $wpdb;
+		$table    = self::queue_table();
+		$per_page = max( 1, (int) $per_page );
+		$offset   = max( 0, (int) $offset );
+
+		if ( $state ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			return (array) $wpdb->get_results(
+				$wpdb->prepare(
+					"SELECT * FROM {$table} WHERE state = %s ORDER BY scheduled_at ASC, id ASC LIMIT %d OFFSET %d",
+					$state,
+					$per_page,
+					$offset
+				)
+			);
+		}
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		return (array) $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT * FROM {$table} ORDER BY scheduled_at ASC, id ASC LIMIT %d OFFSET %d",
+				$per_page,
+				$offset
+			)
+		);
+	}
+
+	/**
+	 * Count rows, optionally filtered by state.
+	 *
+	 * @param string $state State filter, or '' for all.
+	 * @return int
+	 */
+	public static function count_rows( $state = '' ) {
+		global $wpdb;
+		$table = self::queue_table();
+		if ( $state ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE state = %s", $state ) );
+		}
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
+	}
+
+	/**
+	 * Delete one queue row.
+	 *
+	 * @param int $row_id Row id.
+	 * @return bool
+	 */
+	public static function delete_row( $row_id ) {
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
+		return false !== $wpdb->delete( self::queue_table(), array( 'id' => (int) $row_id ), array( '%d' ) );
+	}
+
+	/* ---------------------------------------------------------------------
+	 * Log rows
+	 * ------------------------------------------------------------------ */
+
+	/**
+	 * Append a log row.
+	 *
+	 * `debug` rows are written only when the debug setting is on.
+	 *
+	 * @param string   $level    debug|info|warning|error.
+	 * @param string   $message  Message.
+	 * @param int|null $queue_id Related queue row.
+	 */
+	public static function log( $level, $message, $queue_id = null ) {
+		if ( ! in_array( $level, array( 'debug', 'info', 'warning', 'error' ), true ) ) {
+			$level = 'info';
+		}
+		if ( 'debug' === $level && ! Studiou_WCMQ_Settings::debug() ) {
+			return;
+		}
+
+		global $wpdb;
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
+		$wpdb->insert(
+			self::log_table(),
+			array(
+				'level'      => $level,
+				'queue_id'   => $queue_id ? (int) $queue_id : null,
+				'message'    => (string) $message,
+				'created_at' => time(),
+			)
+		);
+
+		if ( in_array( $level, array( 'warning', 'error' ), true ) ) {
+			Studiou_WCMQ_Utils_Log::log( strtoupper( $level ) . ': ' . $message );
+		}
+	}
+
+	/**
+	 * Paginated log rows.
+	 *
+	 * @param string $level    Level filter, or '' for all.
+	 * @param int    $per_page Rows per page.
+	 * @param int    $offset   Offset.
+	 * @return array
+	 */
+	public static function get_log_rows( $level, $per_page, $offset ) {
+		global $wpdb;
+		$table    = self::log_table();
+		$per_page = max( 1, (int) $per_page );
+		$offset   = max( 0, (int) $offset );
+
+		if ( $level ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			return (array) $wpdb->get_results(
+				$wpdb->prepare(
+					"SELECT * FROM {$table} WHERE level = %s ORDER BY id DESC LIMIT %d OFFSET %d",
+					$level,
+					$per_page,
+					$offset
+				)
+			);
+		}
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		return (array) $wpdb->get_results(
+			$wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d", $per_page, $offset )
+		);
+	}
+
+	/**
+	 * Count log rows.
+	 *
+	 * @param string $level Level filter, or '' for all.
+	 * @return int
+	 */
+	public static function count_log_rows( $level = '' ) {
+		global $wpdb;
+		$table = self::log_table();
+		if ( $level ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE level = %s", $level ) );
+		}
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
+	}
+
+	/**
+	 * Empty the log.
+	 *
+	 * Batched DELETE, never TRUNCATE: TRUNCATE requires the DROP privilege,
+	 * which the shared hosts this plugin targets frequently withhold.
+	 *
+	 * @return int Rows deleted.
+	 */
+	public static function clear_log() {
+		global $wpdb;
+		$table   = self::log_table();
+		$deleted = 0;
+		for ( $i = 0; $i < 200; $i++ ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			$n = $wpdb->query( "DELETE FROM {$table} LIMIT 5000" );
+			if ( ! $n ) {
+				break;
+			}
+			$deleted += (int) $n;
+		}
+		return $deleted;
+	}
+}

+ 280 - 0
studiou-wc-mail-queue/includes/class-wcmq-interceptor.php

@@ -0,0 +1,280 @@
+<?php
+/**
+ * pre_wp_mail interception.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Interceptor
+ */
+class Studiou_WCMQ_Interceptor {
+
+	/**
+	 * True while the worker is calling wp_mail() to actually send.
+	 *
+	 * Always cleared in a `finally`. A stuck `true` under Action Scheduler means
+	 * every later send in the batch bypasses the queue.
+	 *
+	 * @var bool
+	 */
+	public static $sending = false;
+
+	/**
+	 * Register hooks.
+	 */
+	public static function init() {
+		add_filter( 'pre_wp_mail', array( __CLASS__, 'maybe_enqueue' ), 10, 2 );
+
+		// WooCommerce 10.9's EmailLogger listens on woocommerce_email_sent, which
+		// fires with $success = true the moment we short-circuit wp_mail(). Left
+		// alone it writes an order note and a `transactional-emails` log line
+		// claiming the mail was sent, up to (interval x queue depth) early — and
+		// never reconciles it if the deferred send later fails permanently.
+		//
+		// Suppress on "was THIS mail queued", never on "is this email type
+		// handled": a handled type still sends synchronously while draining, or
+		// when Action Scheduler is missing, or when enqueue failed. In those
+		// cases the note is correct and no worker will write a replacement.
+		add_filter( 'woocommerce_email_log_enabled', array( __CLASS__, 'suppress_email_log' ), 10, 2 );
+		add_filter( 'woocommerce_email_log_add_order_note', array( __CLASS__, 'suppress_email_log' ), 10, 2 );
+	}
+
+	/**
+	 * Suppress EmailLogger for the one mail we queued — and nothing else.
+	 *
+	 * Matching on the flag alone is not enough. `woocommerce_email_log_enabled`
+	 * is also applied from EmailLogger::log_non_send_outcome(), which runs on
+	 * `woocommerce_email_disabled` and `woocommerce_email_skipped`. Both of those
+	 * fire from WC_Email::send_notification() *before* send() is ever called, so
+	 * the probe never runs and never resets the flag.
+	 *
+	 * Concretely, on a stock shop: pending -> processing fires the customer
+	 * processing email (queued, flag set) and then the admin new-order email. If
+	 * the admin disabled that one — routine — we would silently swallow
+	 * WooCommerce's "not sent: email type is disabled" log line for it.
+	 *
+	 * Match the email id too, so only the mail we actually queued is suppressed.
+	 *
+	 * @param bool   $enabled  Current value.
+	 * @param string $email_id WC_Email id this filter is being applied for.
+	 * @return bool
+	 */
+	public static function suppress_email_log( $enabled, $email_id = '' ) {
+		if ( ! Studiou_WCMQ_Context::was_queued() ) {
+			return $enabled;
+		}
+		if ( (string) $email_id !== Studiou_WCMQ_Context::queued_context_id() ) {
+			return $enabled;
+		}
+		return false;
+	}
+
+	/**
+	 * Short-circuit wp_mail() for handled WooCommerce email, or pass through.
+	 *
+	 * Returning true tells WordPress the mail was sent. We only ever do that
+	 * once the row and its Action Scheduler action are durably persisted.
+	 * Every failure path returns null so wp_mail() proceeds and the mail sends
+	 * synchronously — an unthrottled send beats a silently lost one.
+	 *
+	 * @param null|bool $short_circuit Null to continue.
+	 * @param array     $atts          wp_mail arguments.
+	 * @return null|bool
+	 */
+	public static function maybe_enqueue( $short_circuit, $atts ) {
+		if ( self::$sending ) {
+			return $short_circuit;
+		}
+
+		// Consume the context FIRST, unconditionally — before the $short_circuit
+		// check, before the $atts shape check. If any of those early-returns ran
+		// before consume(), an in-flight context would survive onto the next
+		// wp_mail() in the request, which is the stale-context bug §3.1 exists to
+		// prevent. This is why we read to/subject defensively here rather than
+		// gating on the isset() first.
+		$to      = is_array( $atts ) && isset( $atts['to'] ) ? $atts['to'] : null;
+		$subject = is_array( $atts ) && isset( $atts['subject'] ) ? $atts['subject'] : null;
+		$ctx     = Studiou_WCMQ_Context::consume( $to, $subject );
+
+		// WordPress threads each pre_wp_mail callback's return value into the
+		// next. A non-null value means another plugin — typically a transactional
+		// API mailer hooked at a lower priority — has ALREADY delivered (true) or
+		// already failed (false) this message. Queueing it now would send it a
+		// second time; returning true over their false would report a delivery
+		// that never happened. Pass their verdict through untouched.
+		if ( null !== $short_circuit ) {
+			return $short_circuit;
+		}
+
+		if ( ! is_array( $atts ) || ! isset( $atts['to'], $atts['subject'], $atts['message'] ) ) {
+			return $short_circuit;
+		}
+
+		// A WooCommerce email was in flight but its fingerprint did not match the
+		// mail wp_mail() actually received — a wp_mail filter rewrote the
+		// recipient or subject after we captured it. The mail is about to send
+		// unthrottled. This is the difference between "nothing to do" and "the
+		// plugin is being silently defeated", so make it visible.
+		if ( isset( $ctx['status'] ) && 'mismatch' === $ctx['status'] ) {
+			Studiou_WCMQ_DB::log(
+				'warning',
+				sprintf(
+					'Email "%s" was not queued: a wp_mail filter changed the %s after WooCommerce rendered it, so it could not be matched. It is sending unthrottled.',
+					isset( $ctx['id'] ) ? $ctx['id'] : '?',
+					isset( $ctx['differs'] ) ? $ctx['differs'] : 'message'
+				)
+			);
+			return $short_circuit;
+		}
+
+		if ( ! isset( $ctx['status'] ) || 'ok' !== $ctx['status'] || '' === $ctx['id'] ) {
+			return $short_circuit;
+		}
+
+		// From here we have a real, matched WooCommerce email. Log it before every
+		// decision so "why didn't this get queued?" is answerable from the log
+		// (Phase 3 acceptance): one line per handled email, naming its type.
+		Studiou_WCMQ_DB::log( 'debug', sprintf( 'Saw WooCommerce email "%s".', $ctx['id'] ) );
+
+		if ( ! Studiou_WCMQ_State::is_queueing() ) {
+			return $short_circuit;
+		}
+		if ( ! Studiou_WCMQ_Settings::handles( $ctx['id'] ) ) {
+			return $short_circuit;
+		}
+		if ( ! function_exists( 'as_schedule_single_action' ) ) {
+			Studiou_WCMQ_DB::log( 'warning', 'Action Scheduler unavailable; sending synchronously.' );
+			return $short_circuit;
+		}
+
+		$payload = self::snapshot( $atts );
+		if ( null === $payload ) {
+			return $short_circuit;
+		}
+
+		$row_id = Studiou_WCMQ_Queue::enqueue( $ctx, $payload );
+		if ( ! $row_id ) {
+			// Queue::enqueue() already logged and rolled back.
+			return $short_circuit;
+		}
+
+		Studiou_WCMQ_Context::mark_queued( $row_id, $ctx['id'] );
+
+		return true;
+	}
+
+	/**
+	 * Build a filter-independent snapshot of the mail.
+	 *
+	 * pre_wp_mail runs before wp_mail() applies wp_mail_from / wp_mail_from_name
+	 * / wp_mail_content_type, and WC_Email::send() detaches its own callbacks for
+	 * those the instant wp_mail() returns. A deferred send would therefore go out
+	 * as wordpress@{sitename}. Resolve them now, while WooCommerce's filters are
+	 * still attached, and bake the result into the stored headers.
+	 *
+	 * @param array $atts wp_mail arguments.
+	 * @return array|null
+	 */
+	private static function snapshot( array $atts ) {
+		$headers     = isset( $atts['headers'] ) ? $atts['headers'] : '';
+		$attachments = isset( $atts['attachments'] ) ? (array) $atts['attachments'] : array();
+
+		// Mirror wp_mail()'s own default so WooCommerce's filter receives what it
+		// expects. get_from_address() ignores the argument anyway.
+		$sitename = wp_parse_url( network_home_url(), PHP_URL_HOST );
+		$sitename = is_string( $sitename ) ? strtolower( $sitename ) : '';
+		if ( 0 === strpos( $sitename, 'www.' ) ) {
+			$sitename = substr( $sitename, 4 );
+		}
+		$default_from = 'wordpress@' . $sitename;
+
+		/** This filter is documented in wp-includes/pluggable.php */
+		$from_address = apply_filters( 'wp_mail_from', $default_from );
+		/** This filter is documented in wp-includes/pluggable.php */
+		$from_name = apply_filters( 'wp_mail_from_name', 'WordPress' );
+		/** This filter is documented in wp-includes/pluggable.php */
+		$content_type = apply_filters( 'wp_mail_content_type', 'text/plain' );
+
+		return array(
+			'to'          => $atts['to'],
+			'subject'     => $atts['subject'],
+			'message'     => $atts['message'],
+			'headers'     => self::inject_headers( $headers, $from_address, $from_name, $content_type ),
+			'attachments' => $attachments,
+		);
+	}
+
+	/**
+	 * Add From: (and Content-Type: if missing) to the captured headers.
+	 *
+	 * WC_Email::get_headers() returns a \r\n-delimited STRING carrying
+	 * Content-Type and Reply-to — and never a From. Non-WooCommerce callers may
+	 * pass an array, so handle both.
+	 *
+	 * @param string|array $headers      Original headers.
+	 * @param string       $from_address Resolved sender address.
+	 * @param string       $from_name    Resolved sender name.
+	 * @param string       $content_type Resolved content type.
+	 * @return string|array
+	 */
+	private static function inject_headers( $headers, $from_address, $from_name, $content_type ) {
+		$was_array = is_array( $headers );
+
+		if ( $was_array ) {
+			$lines = $headers;
+		} else {
+			// Split on line breaks, but NOT on a folded-header continuation: RFC
+			// 5322 allows a long header value to wrap onto a following line that
+			// begins with whitespace. Splitting there and trimming each line would
+			// turn the continuation into a standalone colon-less line, which
+			// wp_mail() then discards. WooCommerce's own get_headers() never
+			// folds, so this only bites a third-party woocommerce_email_headers
+			// filter — but preserve the fold rather than corrupt it.
+			$lines = preg_split( "/\r\n(?![ \t])|\r(?![ \t])|\n(?![ \t])/", (string) $headers );
+		}
+		$lines = is_array( $lines ) ? $lines : array();
+
+		$clean = array();
+		foreach ( $lines as $line ) {
+			// rtrim only: leading whitespace on a folded continuation is
+			// significant. An all-whitespace line is dropped.
+			$line = rtrim( (string) $line, "\r\n" );
+			if ( '' !== trim( $line ) ) {
+				$clean[] = $line;
+			}
+		}
+
+		$has_from         = false;
+		$has_content_type = false;
+		foreach ( $clean as $line ) {
+			$lower = strtolower( $line );
+			if ( 0 === strpos( $lower, 'from:' ) ) {
+				$has_from = true;
+			}
+			if ( 0 === strpos( $lower, 'content-type:' ) ) {
+				$has_content_type = true;
+			}
+		}
+
+		if ( ! $has_from && is_email( $from_address ) ) {
+			$name = trim( str_replace( array( '"', "\r", "\n" ), '', (string) $from_name ) );
+			if ( '' !== $name ) {
+				array_unshift( $clean, sprintf( 'From: "%s" <%s>', $name, $from_address ) );
+			} else {
+				array_unshift( $clean, sprintf( 'From: %s', $from_address ) );
+			}
+		}
+
+		if ( ! $has_content_type && is_string( $content_type ) && '' !== $content_type ) {
+			$clean[] = 'Content-Type: ' . $content_type;
+		}
+
+		return $was_array ? $clean : implode( "\r\n", $clean );
+	}
+}

+ 150 - 0
studiou-wc-mail-queue/includes/class-wcmq-notifier.php

@@ -0,0 +1,150 @@
+<?php
+/**
+ * Writes the order note and WC log line that EmailLogger was suppressed from
+ * writing at enqueue time.
+ *
+ * This lives apart from the worker because it must be reachable from EVERY
+ * terminal state, not just the two the worker owns. A queue row can reach
+ * `sent` or `failed` from four places:
+ *
+ *   Worker::on_success()            mail went out
+ *   Worker::on_failure()            max_attempts exhausted
+ *   Worker::send()                  payload was corrupt, unsendable
+ *   Reaper::recover_crashed()       max_reclaims exhausted
+ *
+ * Miss any of them and the order carries no note and `transactional-emails`
+ * carries no line: the customer never received the email and the shop's audit
+ * trail says nothing happened at all. That is the same defect as a queue which
+ * lies about delivery, inverted from a false positive into a silent hole.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Notifier
+ */
+class Studiou_WCMQ_Notifier {
+
+	const LOG_SOURCE = 'transactional-emails';
+
+	/**
+	 * Record the real outcome of a queued mail.
+	 *
+	 * Never throws: callers invoke this after the row has already been committed,
+	 * and a bookkeeping failure must not undo a send or re-open a resolved row.
+	 *
+	 * @param object      $row     Queue row.
+	 * @param bool        $success Whether the mail went out.
+	 * @param string|null $error   Error message, if any.
+	 */
+	public static function annotate( $row, $success, $error = null ) {
+		try {
+			self::write( $row, (bool) $success, self::redact_emails( (string) $error ) );
+		} catch ( Throwable $e ) {
+			Studiou_WCMQ_DB::log( 'warning', 'Could not record mail outcome: ' . $e->getMessage(), (int) $row->id );
+		}
+	}
+
+	/**
+	 * Order note + WC log line.
+	 *
+	 * @param object $row     Queue row.
+	 * @param bool   $success Outcome.
+	 * @param string $error   Redacted error message.
+	 */
+	private static function write( $row, $success, $error ) {
+		$order_id = (int) $row->order_id;
+
+		if ( $order_id > 0 ) {
+			// order_id > 0 does not mean the order still exists. An order trashed
+			// and emptied between enqueue and send makes wc_get_order() return
+			// false, and false->add_order_note() is a fatal Error.
+			$order = wc_get_order( $order_id );
+			if ( $order instanceof WC_Order ) {
+				if ( $success ) {
+					/* translators: 1: email type, 2: recipient */
+					$note = sprintf( __( 'Queued email "%1$s" sent to %2$s.', 'studiou-wc-mail-queue' ), $row->context, $row->recipient );
+				} else {
+					/* translators: 1: email type, 2: error message */
+					$note = sprintf( __( 'Queued email "%1$s" failed to send: %2$s', 'studiou-wc-mail-queue' ), $row->context, $error );
+				}
+
+				// Groups with WooCommerce's own email notes in the order-notes UI.
+				// The value is the literal behind OrderNoteGroup::EMAIL_NOTIFICATION;
+				// that class is in the Internal\ namespace, so never depend on it.
+				// The $meta_data parameter postdates our declared WC floor — PHP
+				// drops extra arguments to a userland method, so an older signature
+				// simply ignores it.
+				$order->add_order_note( $note, 0, false, array( 'note_group' => 'email_notification' ) );
+			}
+		}
+
+		if ( ! function_exists( 'wc_get_logger' ) ) {
+			return;
+		}
+
+		// We suppressed EmailLogger for this mail, so we inherit its privacy
+		// contract. The `transactional-emails` log is readable by anyone with
+		// manage_woocommerce, and WooCommerce deliberately never writes raw
+		// recipient addresses or raw PHPMailer error strings into it. The
+		// plugin's own queue table holds the real address; this log must not.
+		$context = array(
+			'source'     => self::LOG_SOURCE,
+			'email_type' => $row->context,
+			'status'     => $success ? 'sent' : 'failed',
+			'recipient'  => self::resolve_recipient( (string) $row->recipient ),
+		);
+		if ( $order_id > 0 ) {
+			$context['order'] = $order_id;
+		}
+
+		$logger = wc_get_logger();
+		if ( $success ) {
+			$logger->info( sprintf( 'Queued email "%s" sent.', $row->context ), $context );
+		} else {
+			$logger->error( sprintf( 'Queued email "%s" failed: %s', $row->context, $error ), $context );
+		}
+	}
+
+	/**
+	 * Map recipient addresses to usernames, or 'guest'.
+	 *
+	 * Mirrors EmailLogger::resolve_recipient().
+	 *
+	 * @param string $recipient Comma-separated addresses.
+	 * @return string
+	 */
+	public static function resolve_recipient( $recipient ) {
+		if ( '' === $recipient ) {
+			return 'guest';
+		}
+		$labels = array();
+		foreach ( explode( ',', $recipient ) as $address ) {
+			$user     = get_user_by( 'email', trim( $address ) );
+			$labels[] = $user instanceof WP_User ? $user->user_login : 'guest';
+		}
+		return implode( ', ', $labels );
+	}
+
+	/**
+	 * Strip email addresses out of a message.
+	 *
+	 * Mirrors EmailLogger::redact_emails(). PHPMailer error strings routinely
+	 * embed the recipient.
+	 *
+	 * @param string $message Message.
+	 * @return string
+	 */
+	public static function redact_emails( $message ) {
+		return (string) preg_replace(
+			'/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/',
+			'[redacted_email]',
+			$message
+		);
+	}
+}

+ 445 - 0
studiou-wc-mail-queue/includes/class-wcmq-queue.php

@@ -0,0 +1,445 @@
+<?php
+/**
+ * Slot allocation, enqueue, attachment custody.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Queue
+ */
+class Studiou_WCMQ_Queue {
+
+	const HOOK       = 'studiou_wcmq_send';
+	const GROUP      = 'studiou-wcmq';
+	const LOCK_NAME  = 'studiou_wcmq_slot';
+	const LOCK_WAIT  = 5;
+	const ATTACH_DIR = 'studiou-wcmq-attachments';
+
+	/**
+	 * Allocate a slot and publish it, under the advisory lock.
+	 *
+	 * Returns [ $slot, $publish_result ]. Callers need the slot AFTER the lock is
+	 * released, to hand to as_schedule_single_action(); a helper that returned
+	 * only $publish's result would leave them with no timestamp, and
+	 * as_schedule_single_action(null, ...) coerces to 0, which Action Scheduler
+	 * treats as "run immediately" — draining the whole queue at once.
+	 *
+	 * The publish callback must INSERT or UPDATE the row: with the slot derived
+	 * from the table, the write is what publishes the allocation. A lock that
+	 * spans only the SELECT closes no race.
+	 *
+	 * @param int      $earliest Floor timestamp.
+	 * @param callable $publish  fn( int $slot ): mixed.
+	 * @return array [ int $slot, mixed $result ]
+	 */
+	public static function with_slot_lock( $earliest, callable $publish ) {
+		global $wpdb;
+
+		// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
+		$locked = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', self::LOCK_NAME, self::LOCK_WAIT ) );
+
+		if ( 1 !== $locked ) {
+			// Overshooting the rate limit is bad; losing a customer's order
+			// confirmation is worse. Proceed unlocked and say so.
+			Studiou_WCMQ_DB::log( 'warning', 'Slot lock unavailable; allocating without it.' );
+		}
+
+		try {
+			$slot   = self::allocate_slot( (int) $earliest );
+			$result = call_user_func( $publish, $slot );
+			return array( $slot, $result );
+		} finally {
+			if ( 1 === $locked ) {
+				// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
+				$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', self::LOCK_NAME ) );
+			}
+		}
+	}
+
+	/**
+	 * First free slot at or after $earliest.
+	 *
+	 * The smallest t >= max(now, $earliest) such that no pending/sending row sits
+	 * within `interval` of t. A monotonic "next slot" counter cannot express this
+	 * once retries exist: a row backed off an hour would drag every subsequent
+	 * enqueue behind it.
+	 *
+	 * The collision test uses a two-sided range comparison rather than
+	 * ABS(scheduled_at - candidate). MySQL evaluates a subtraction with any
+	 * UNSIGNED operand as unsigned, so the moment an occupied row precedes the
+	 * candidate — mail #2 of every bulk — it underflows into error 1690. The
+	 * timestamp columns are signed for the same reason.
+	 *
+	 * @param int $earliest Floor timestamp.
+	 * @return int
+	 */
+	public static function allocate_slot( $earliest ) {
+		global $wpdb;
+
+		$table    = Studiou_WCMQ_DB::queue_table();
+		$interval = Studiou_WCMQ_Settings::interval();
+		$earliest = (int) $earliest;
+
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		$slot = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT c.candidate FROM (
+					SELECT GREATEST(%d, UNIX_TIMESTAMP()) AS candidate
+					UNION ALL
+					SELECT q.scheduled_at + %d
+					  FROM {$table} q
+					 WHERE q.state IN ('pending','sending')
+					   AND q.scheduled_at + %d >= GREATEST(%d, UNIX_TIMESTAMP())
+				) AS c
+				WHERE NOT EXISTS (
+					SELECT 1 FROM {$table} q2
+					 WHERE q2.state IN ('pending','sending')
+					   AND q2.scheduled_at >  c.candidate - %d
+					   AND q2.scheduled_at <  c.candidate + %d
+				)
+				ORDER BY c.candidate ASC
+				LIMIT 1",
+				$earliest,
+				$interval,
+				$interval,
+				$earliest,
+				$interval,
+				$interval
+			)
+		);
+		// phpcs:enable
+
+		// Cannot happen: the floor is always a candidate, and the largest
+		// candidate always survives NOT EXISTS. Defensive only.
+		if ( null === $slot ) {
+			return max( time(), $earliest );
+		}
+
+		return (int) $slot;
+	}
+
+	/**
+	 * Persist a mail and schedule its send.
+	 *
+	 * Returns the row id, or false on ANY failure. The interceptor treats false
+	 * as "not handled" and lets wp_mail() send synchronously.
+	 *
+	 * @param array $ctx     Context from the probe.
+	 * @param array $payload Mail snapshot.
+	 * @return int|false
+	 */
+	public static function enqueue( array $ctx, array $payload ) {
+		try {
+			$dir_token = bin2hex( random_bytes( 16 ) );
+		} catch ( Throwable $e ) {
+			Studiou_WCMQ_DB::log( 'error', 'Could not generate attachment token: ' . $e->getMessage() );
+			return false;
+		}
+
+		// Copy attachments BEFORE the row becomes durable.
+		//
+		// Attachments are file paths. Invoice plugins write them to temp dirs and
+		// delete them at end of request; by send time they are gone. If the row
+		// were inserted first and the paths rewritten afterwards, a PHP fatal in
+		// between would leave a permanent `pending` row whose payload points at
+		// files that no longer exist — and rollback() cannot run after a fatal.
+		// The reaper would then faithfully resurrect a mail that can never send.
+		//
+		// Copying first means the row is never durable with paths we do not own.
+		// The only cost is an orphaned directory if we bail before the INSERT,
+		// which the retention sweep collects.
+		$copied = self::copy_attachments( $dir_token, $payload['attachments'] );
+		if ( null === $copied ) {
+			self::delete_attachment_dir( $dir_token );
+			Studiou_WCMQ_DB::log( 'error', 'Could not copy mail attachments; sending synchronously.' );
+			return false;
+		}
+		$payload['attachments'] = $copied;
+
+		// wp_json_encode returns false on unencodable input (e.g. invalid UTF-8
+		// in a rendered order email) WITHOUT throwing. An exception-only guard
+		// would miss it and the mail would be lost.
+		$json = wp_json_encode( $payload );
+		if ( ! is_string( $json ) || '' === $json ) {
+			self::delete_attachment_dir( $dir_token );
+			Studiou_WCMQ_DB::log( 'error', 'Could not encode mail payload; sending synchronously.' );
+			return false;
+		}
+
+		$recipient = is_array( $payload['to'] ) ? implode( ',', $payload['to'] ) : (string) $payload['to'];
+		$now       = time();
+
+		$row = array(
+			'context'        => (string) $ctx['id'],
+			'order_id'       => (int) $ctx['order_id'],
+			'recipient'      => substr( $recipient, 0, 320 ),
+			'subject'        => (string) $payload['subject'],
+			'payload'        => $json,
+			'attachment_dir' => $dir_token,
+			'priority'       => 10,
+			'state'          => Studiou_WCMQ_DB::STATE_PENDING,
+			'attempts'       => 0,
+			'reclaims'       => 0,
+			'scheduled_at'   => 0,
+			'created_at'     => $now,
+		);
+
+		try {
+			list( $slot, $row_id ) = self::with_slot_lock(
+				$now,
+				function ( $slot ) use ( $row ) {
+					$row['scheduled_at'] = $slot;
+					return Studiou_WCMQ_DB::insert_row( $row );
+				}
+			);
+		} catch ( Throwable $e ) {
+			self::delete_attachment_dir( $dir_token );
+			Studiou_WCMQ_DB::log( 'error', 'Enqueue insert failed: ' . $e->getMessage() );
+			return false;
+		}
+
+		$row_id = (int) $row_id;
+		if ( ! $row_id ) {
+			self::delete_attachment_dir( $dir_token );
+			Studiou_WCMQ_DB::log( 'error', 'Could not insert queue row; sending synchronously.' );
+			return false;
+		}
+
+		// Scheduled last, and checked: as_schedule_single_action() returns 0 on
+		// failure without throwing. A fatal between the INSERT and here leaves a
+		// `pending` row with no action — but with a payload that is already
+		// self-contained, so the reaper can safely reschedule it.
+		$action_id = as_schedule_single_action( $slot, self::HOOK, array( (int) $row_id ), self::GROUP );
+		if ( ! $action_id ) {
+			self::rollback( $row_id, $dir_token );
+			Studiou_WCMQ_DB::log( 'error', 'Could not schedule send action; sending synchronously.' );
+			return false;
+		}
+
+		Studiou_WCMQ_DB::log(
+			'debug',
+			sprintf( 'Queued %s for %s at %d.', $ctx['id'], $recipient, $slot ),
+			$row_id
+		);
+
+		return $row_id;
+	}
+
+	/**
+	 * Put an existing row back into `pending` on a fresh future slot.
+	 *
+	 * Used by the admin Retry action. Takes the same lock as enqueue, because
+	 * the UPDATE publishes the allocation exactly as the INSERT does, and guards
+	 * the schedule call the same way.
+	 *
+	 * **$expected_state is mandatory and closes a double-send race.** The admin
+	 * reads the row, then calls this — and in that window a worker's Action
+	 * Scheduler action can fire and claim the row (`pending → sending`). An
+	 * unguarded UPDATE would then force the mid-send row back to `pending`: the
+	 * worker's own state-guarded commit fails (0 rows), so it keeps the
+	 * attachments and writes no note — but the mail already went out via
+	 * `wp_mail()`, and the fresh action we schedule here delivers a SECOND copy.
+	 * Guarding on the state the caller observed means a row a worker has since
+	 * claimed comes back as 0-changed → we do not schedule, and the worker's send
+	 * stands as the single delivery.
+	 *
+	 * @param int    $row_id         Row id.
+	 * @param int    $earliest       Floor timestamp, 0 for now.
+	 * @param array  $extra          Extra columns to set.
+	 * @param string $expected_state State the row must still be in (what the
+	 *                               caller observed at read time).
+	 * @return bool
+	 */
+	public static function reschedule( $row_id, $earliest, array $extra, $expected_state ) {
+		$row_id = (int) $row_id;
+		if ( ! $row_id || ! function_exists( 'as_schedule_single_action' ) ) {
+			return false;
+		}
+		$earliest = $earliest ? (int) $earliest : time();
+
+		list( $slot, $updated ) = self::with_slot_lock(
+			$earliest,
+			function ( $slot ) use ( $row_id, $extra, $expected_state ) {
+				$data = array_merge(
+					$extra,
+					array(
+						'state'        => Studiou_WCMQ_DB::STATE_PENDING,
+						'claimed_at'   => null,
+						'scheduled_at' => $slot,
+					)
+				);
+				return Studiou_WCMQ_DB::update_row_if_state( $row_id, $data, $expected_state );
+			}
+		);
+
+		// 0 (or false) means the row was no longer in $expected_state — most
+		// importantly, a worker claimed it out from under us. Do NOT schedule an
+		// action: the worker is mid-send and a fresh action would double-deliver.
+		// Report failure so the admin sees "couldn't process", not "re-queued".
+		if ( ! $updated ) {
+			Studiou_WCMQ_DB::log( 'error', 'Reschedule changed no row (state moved under us); not scheduling.', $row_id );
+			return false;
+		}
+
+		// The row is now ours in `pending`. Drop any stale action for it before
+		// adding the new one, so AS never holds two actions for one row (a row
+		// retried while still pending already owns an action at its old slot; a
+		// running action cannot be unscheduled, but that path is closed by the
+		// state guard above). Both fire → the claim guard makes it a single send,
+		// but unscheduling keeps it on the intended new slot.
+		if ( function_exists( 'as_unschedule_all_actions' ) ) {
+			as_unschedule_all_actions( self::HOOK, array( (int) $row_id ), self::GROUP );
+		}
+
+		$action_id = as_schedule_single_action( $slot, self::HOOK, array( (int) $row_id ), self::GROUP );
+		if ( ! $action_id ) {
+			Studiou_WCMQ_DB::log( 'error', 'Could not schedule action while rescheduling; reaper will repair.', $row_id );
+			return false;
+		}
+		return true;
+	}
+
+	/**
+	 * Undo a partial enqueue.
+	 *
+	 * Cannot run after a PHP fatal — that is what the reaper is for. This only
+	 * covers the failures we can actually catch.
+	 *
+	 * @param int    $row_id    Row id.
+	 * @param string $dir_token Attachment directory token.
+	 */
+	private static function rollback( $row_id, $dir_token ) {
+		self::delete_attachment_dir( $dir_token );
+		Studiou_WCMQ_DB::delete_row( $row_id );
+	}
+
+	/* ---------------------------------------------------------------------
+	 * Attachments
+	 * ------------------------------------------------------------------ */
+
+	/**
+	 * Base directory for attachment copies, created and guarded on first use.
+	 *
+	 * @return string Absolute path, or '' on failure.
+	 */
+	public static function attachments_basedir() {
+		$uploads = wp_upload_dir();
+		if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) {
+			return '';
+		}
+		$base = trailingslashit( $uploads['basedir'] ) . self::ATTACH_DIR;
+
+		if ( ! file_exists( $base ) && ! wp_mkdir_p( $base ) ) {
+			return '';
+		}
+
+		// Write the guards whenever they are missing, not only when we just
+		// created the directory — a backup restore or a security scanner can
+		// remove them while the directory survives, and an unguarded directory
+		// full of customer invoices is exactly what these prevent on Apache.
+		// Nginx ignores .htaccess entirely; there the random 32-hex directory
+		// token is the real protection, and the readme documents the location deny.
+		$index = $base . '/index.php';
+		if ( ! file_exists( $index ) ) {
+			@file_put_contents( $index, '<?php // Silence is golden.' ); // phpcs:ignore
+		}
+		$htaccess = $base . '/.htaccess';
+		if ( ! file_exists( $htaccess ) ) {
+			// Both syntaxes: Apache 2.4 dropped Order/Deny.
+			$rules = "<IfModule mod_authz_core.c>\n\tRequire all denied\n</IfModule>\n"
+				. "<IfModule !mod_authz_core.c>\n\tOrder deny,allow\n\tDeny from all\n</IfModule>\n";
+			@file_put_contents( $htaccess, $rules ); // phpcs:ignore
+		}
+
+		return $base;
+	}
+
+	/**
+	 * Copy attachments into this row's private directory.
+	 *
+	 * Array keys are preserved: wp_mail() treats a string key as the attachment
+	 * filename.
+	 *
+	 * @param string $dir_token   Directory token.
+	 * @param array  $attachments Original paths.
+	 * @return array|null Copied paths, or null on failure.
+	 */
+	private static function copy_attachments( $dir_token, $attachments ) {
+		$attachments = (array) $attachments;
+		if ( empty( $attachments ) ) {
+			return array();
+		}
+
+		$base = self::attachments_basedir();
+		if ( '' === $base ) {
+			return null;
+		}
+
+		$dir = $base . '/' . $dir_token;
+		if ( ! wp_mkdir_p( $dir ) ) {
+			return null;
+		}
+
+		$out = array();
+		foreach ( $attachments as $key => $path ) {
+			if ( ! is_string( $path ) || '' === $path || ! is_readable( $path ) ) {
+				return null;
+			}
+			$dest = $dir . '/' . wp_unique_filename( $dir, basename( $path ) );
+			if ( ! @copy( $path, $dest ) ) { // phpcs:ignore
+				return null;
+			}
+			$out[ $key ] = $dest;
+		}
+
+		return $out;
+	}
+
+	/**
+	 * Remove a row's attachment directory.
+	 *
+	 * @param string $dir_token Directory token.
+	 */
+	public static function delete_attachment_dir( $dir_token ) {
+		if ( ! is_string( $dir_token ) || ! preg_match( '/^[a-f0-9]{32}$/', $dir_token ) ) {
+			return;
+		}
+		$base = self::attachments_basedir();
+		if ( '' === $base ) {
+			return;
+		}
+		self::rmdir_recursive( $base . '/' . $dir_token );
+	}
+
+	/**
+	 * Delete a directory and its files, one level deep.
+	 *
+	 * @param string $dir Absolute path.
+	 */
+	public static function rmdir_recursive( $dir ) {
+		if ( ! is_dir( $dir ) ) {
+			return;
+		}
+		$items = @scandir( $dir ); // phpcs:ignore
+		if ( is_array( $items ) ) {
+			foreach ( $items as $item ) {
+				if ( '.' === $item || '..' === $item ) {
+					continue;
+				}
+				$path = $dir . '/' . $item;
+				if ( is_dir( $path ) ) {
+					self::rmdir_recursive( $path );
+				} else {
+					@unlink( $path ); // phpcs:ignore
+				}
+			}
+		}
+		@rmdir( $dir ); // phpcs:ignore
+	}
+}

+ 275 - 0
studiou-wc-mail-queue/includes/class-wcmq-reaper.php

@@ -0,0 +1,275 @@
+<?php
+/**
+ * Recovers stranded queue rows.
+ *
+ * Enforces the invariant that makes the whole design durable:
+ *
+ *   Every row in `pending` has a scheduled Action Scheduler action.
+ *
+ * That invariant, not the enqueue rollback, is what prevents mail loss —
+ * rollback cannot run after a PHP fatal.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Reaper
+ */
+class Studiou_WCMQ_Reaper {
+
+	const HOOK           = 'studiou_wcmq_reap';
+	const INTERVAL       = 300;
+	const ORPHAN_GRACE   = 300;
+	const BATCH          = 200;
+	const ADMIN_THROTTLE = 'studiou_wcmq_reap_throttle';
+
+	/**
+	 * Guard against running more than once per request.
+	 *
+	 * @var bool
+	 */
+	private static $ran_this_request = false;
+
+	/**
+	 * Register hooks.
+	 */
+	public static function init() {
+		add_action( self::HOOK, array( __CLASS__, 'run' ) );
+		add_action( 'admin_init', array( __CLASS__, 'maybe_run_from_admin' ) );
+	}
+
+	/**
+	 * Schedule the recurring sweep, exactly once.
+	 *
+	 * Called from `init`, which runs on every request — so a bare
+	 * as_schedule_recurring_action() here would register a fresh action each time.
+	 */
+	public static function schedule() {
+		if ( ! function_exists( 'as_next_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) {
+			return;
+		}
+		if ( as_next_scheduled_action( self::HOOK, array(), Studiou_WCMQ_Queue::GROUP ) ) {
+			return;
+		}
+		as_schedule_recurring_action(
+			time() + self::INTERVAL,
+			self::INTERVAL,
+			self::HOOK,
+			array(),
+			Studiou_WCMQ_Queue::GROUP,
+			true
+		);
+	}
+
+	/**
+	 * Heal from wp-admin, throttled.
+	 *
+	 * This is the trigger that still works when WP-Cron is broken — which is a
+	 * plausible root cause of the wedge in the first place.
+	 */
+	public static function maybe_run_from_admin() {
+		if ( get_transient( self::ADMIN_THROTTLE ) ) {
+			return;
+		}
+		set_transient( self::ADMIN_THROTTLE, 1, 60 );
+		self::run();
+	}
+
+	/**
+	 * Sweep both classes of stranded row.
+	 */
+	public static function run() {
+		if ( self::$ran_this_request ) {
+			return;
+		}
+		self::$ran_this_request = true;
+
+		if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_schedule_single_action' ) ) {
+			return;
+		}
+
+		global $wpdb;
+		$table   = Studiou_WCMQ_DB::queue_table();
+		$now     = time();
+		$timeout = (int) Studiou_WCMQ_Settings::get( 'claim_timeout' );
+
+		// (a) `sending` rows whose worker died. Without this, one PHP fatal wedges
+		// the queue forever: retention sweeps only sent/failed, the state machine
+		// never reaches `disabled`, and the slot stays occupied.
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		$stale = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT id, reclaims FROM {$table}
+				  WHERE state = %s AND claimed_at IS NOT NULL AND claimed_at < %d
+				  LIMIT %d",
+				Studiou_WCMQ_DB::STATE_SENDING,
+				$now - $timeout,
+				self::BATCH
+			)
+		);
+		if ( is_array( $stale ) ) {
+			foreach ( $stale as $row ) {
+				self::recover_crashed( (int) $row->id, (int) $row->reclaims );
+			}
+		}
+
+		// (b) `pending` rows with no action — the §2.1 invariant violated. Arises
+		// when a fatal lands between the INSERT and as_schedule_single_action(),
+		// or when a retry's re-schedule failed. More likely than (a), because the
+		// enqueue window is on the hot path of every bulk.
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+		$orphans = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT id FROM {$table}
+				  WHERE state = %s AND scheduled_at < %d
+				  LIMIT %d",
+				Studiou_WCMQ_DB::STATE_PENDING,
+				$now - self::ORPHAN_GRACE,
+				self::BATCH
+			)
+		);
+		if ( is_array( $orphans ) ) {
+			foreach ( $orphans as $row ) {
+				// The cast is load-bearing. Action Scheduler matches args by
+				// hashing json_encode($args); the action was scheduled with an int
+				// so its hash is of "[123]", while $wpdb hands back the string
+				// "123" whose hash is of "[\"123\"]". Without the cast this returns
+				// false for a perfectly healthy row and we duplicate its action.
+				$row_id = (int) $row->id;
+				if ( as_has_scheduled_action( Studiou_WCMQ_Queue::HOOK, array( $row_id ), Studiou_WCMQ_Queue::GROUP ) ) {
+					continue;
+				}
+				self::repair_orphan( $row_id );
+			}
+		}
+	}
+
+	/**
+	 * A `pending` row lost its Action Scheduler action. Give it a new one.
+	 *
+	 * This is NOT a crashed worker and must not touch `reclaims`. The row was
+	 * never claimed, never attempted, and nothing about it is suspect — the
+	 * action went missing (a fatal between INSERT and schedule, a failed retry
+	 * re-schedule, an admin pruning the AS tables). Charging it to the crash
+	 * budget would eventually fail perfectly deliverable mail with
+	 * "worker repeatedly crashed", which is both wrong and unactionable.
+	 *
+	 * There is no runaway risk: the re-slot moves scheduled_at into the future,
+	 * so the next sweep will not see the row until the grace window elapses again.
+	 *
+	 * @param int $row_id Row id.
+	 */
+	private static function repair_orphan( $row_id ) {
+		if ( ! self::reslot_and_schedule( $row_id, array(), Studiou_WCMQ_DB::STATE_PENDING ) ) {
+			return;
+		}
+		Studiou_WCMQ_DB::log( 'warning', 'Pending row had no scheduled action; rescheduled.', $row_id );
+	}
+
+	/**
+	 * A `sending` row whose worker died. Charge it to the crash budget.
+	 *
+	 * @param int $row_id   Row id.
+	 * @param int $reclaims Reclaims so far.
+	 */
+	private static function recover_crashed( $row_id, $reclaims ) {
+		$max_reclaims = (int) Studiou_WCMQ_Settings::get( 'max_reclaims' );
+		$reclaims     = $reclaims + 1;
+
+		// `reclaims`, not `attempts`. A worker killed by max_execution_time
+		// strands whichever row happened to be running when the cumulative batch
+		// budget expired — that is arbitrary, not a property of the row. Counting
+		// crashes as send attempts permanently fails healthy mail that was never
+		// once actually attempted.
+		if ( $reclaims >= $max_reclaims ) {
+			// Guarded on `sending`: if another sweep in a parallel process already
+			// handled this row, we must not double-count or re-fail it.
+			$affected = Studiou_WCMQ_DB::update_row_if_state(
+				$row_id,
+				array(
+					'state'      => Studiou_WCMQ_DB::STATE_FAILED,
+					'claimed_at' => null,
+					'reclaims'   => $reclaims,
+					'last_error' => 'worker repeatedly crashed while handling this mail',
+				),
+				Studiou_WCMQ_DB::STATE_SENDING
+			);
+			if ( 1 === (int) $affected ) {
+				// Terminal state — the order must carry a note, or it records
+				// nothing at all about a mail the customer never received.
+				$row = Studiou_WCMQ_DB::get_row( $row_id );
+				if ( $row ) {
+					Studiou_WCMQ_Notifier::annotate( $row, false, 'worker repeatedly crashed while handling this mail' );
+				}
+				Studiou_WCMQ_DB::log( 'error', sprintf( 'Row failed after %d crash recoveries.', $reclaims ), $row_id );
+			}
+			return;
+		}
+
+		if ( ! self::reslot_and_schedule( $row_id, array( 'reclaims' => $reclaims ), Studiou_WCMQ_DB::STATE_SENDING ) ) {
+			return;
+		}
+
+		Studiou_WCMQ_DB::log(
+			'warning',
+			sprintf( 'Recovered row from a dead worker; rescheduled. Crash recovery %d/%d.', $reclaims, $max_reclaims ),
+			$row_id
+		);
+	}
+
+	/**
+	 * Return a row to `pending` on a fresh FUTURE slot and schedule it.
+	 *
+	 * Re-slotting is not optional. A row restored at its original scheduled_at
+	 * now lies in the past, so Action Scheduler fires it on the next pass — and
+	 * thirty recovered rows would all go out at once. That is the burst this
+	 * plugin exists to prevent, delivered by its own recovery path.
+	 *
+	 * @param int    $row_id         Row id.
+	 * @param array  $extra          Extra columns to set (e.g. reclaims).
+	 * @param string $expected_state State the row must currently be in.
+	 * @return bool True when the row is pending with a live action.
+	 */
+	private static function reslot_and_schedule( $row_id, array $extra, $expected_state ) {
+		list( $slot, $affected ) = Studiou_WCMQ_Queue::with_slot_lock(
+			time(),
+			function ( $slot ) use ( $row_id, $extra, $expected_state ) {
+				$data = array_merge(
+					$extra,
+					array(
+						'state'        => Studiou_WCMQ_DB::STATE_PENDING,
+						'claimed_at'   => null,
+						'scheduled_at' => $slot,
+					)
+				);
+				return Studiou_WCMQ_DB::update_row_if_state( $row_id, $data, $expected_state );
+			}
+		);
+
+		// If the UPDATE changed no row — a DB error, or the row moved out from
+		// under us — do NOT schedule. Scheduling anyway is how the reaper loops
+		// forever: the row stays `sending` with a stale claimed_at and its
+		// reclaims never persists, so the next sweep computes reclaims=0+1 again,
+		// schedules another dead action, and never trips max_reclaims. Leave the
+		// row for the next sweep, which will re-read its true reclaims from the DB.
+		if ( 1 !== (int) $affected ) {
+			Studiou_WCMQ_DB::log( 'error', 'Reaper could not move row to pending; not scheduling. Next sweep will retry.', $row_id );
+			return false;
+		}
+
+		// Guarded exactly as enqueue and retry are. An unguarded call here would
+		// re-create the orphan `pending` row the reaper exists to repair.
+		$action_id = as_schedule_single_action( $slot, Studiou_WCMQ_Queue::HOOK, array( (int) $row_id ), Studiou_WCMQ_Queue::GROUP );
+		if ( ! $action_id ) {
+			Studiou_WCMQ_DB::log( 'error', 'Reaper could not reschedule; next sweep will retry.', $row_id );
+			return false;
+		}
+
+		return true;
+	}
+}

+ 225 - 0
studiou-wc-mail-queue/includes/class-wcmq-retention.php

@@ -0,0 +1,225 @@
+<?php
+/**
+ * Daily retention sweep.
+ *
+ * The queue table holds rendered customer emails and the attachment directories
+ * hold their invoices — retention here is a privacy control, not only
+ * housekeeping.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Retention
+ */
+class Studiou_WCMQ_Retention {
+
+	const HOOK      = 'studiou_wcmq_retention';
+	const BATCH     = 500;
+	const MAX_LOOPS = 200;
+
+	/**
+	 * Register hooks.
+	 */
+	public static function init() {
+		add_action( self::HOOK, array( __CLASS__, 'run' ) );
+	}
+
+	/**
+	 * Schedule the daily sweep, exactly once.
+	 */
+	public static function schedule() {
+		if ( ! function_exists( 'as_next_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) {
+			return;
+		}
+		if ( as_next_scheduled_action( self::HOOK, array(), Studiou_WCMQ_Queue::GROUP ) ) {
+			return;
+		}
+		as_schedule_recurring_action(
+			time() + DAY_IN_SECONDS,
+			DAY_IN_SECONDS,
+			self::HOOK,
+			array(),
+			Studiou_WCMQ_Queue::GROUP,
+			true
+		);
+	}
+
+	/**
+	 * Sweep old rows, log entries and orphaned attachment directories.
+	 */
+	public static function run() {
+		Studiou_WCMQ_Reaper::run();
+
+		$now = time();
+
+		// Every retention setting is in DAYS; every timestamp column is in
+		// SECONDS. Written as `sent_at < now - retention_sent_days`, a 7-day
+		// retention would delete sent rows seven SECONDS after sending, silently,
+		// while reporting success.
+		$sent_days   = (int) Studiou_WCMQ_Settings::get( 'retention_sent_days' );
+		$failed_days = (int) Studiou_WCMQ_Settings::get( 'retention_failed_days' );
+		$log_days    = (int) Studiou_WCMQ_Settings::get( 'retention_log_days' );
+
+		$deleted = 0;
+
+		if ( $sent_days > 0 ) {
+			$deleted += self::purge_queue_rows(
+				Studiou_WCMQ_DB::STATE_SENT,
+				'sent_at',
+				$now - ( $sent_days * DAY_IN_SECONDS )
+			);
+		}
+
+		if ( $failed_days > 0 ) {
+			// `created_at`, NOT `sent_at`. A failed row has sent_at IS NULL by
+			// definition, so the obvious parallel to the rule above would match
+			// nothing and failed rows would accumulate forever.
+			$deleted += self::purge_queue_rows(
+				Studiou_WCMQ_DB::STATE_FAILED,
+				'created_at',
+				$now - ( $failed_days * DAY_IN_SECONDS )
+			);
+		}
+
+		if ( $log_days > 0 ) {
+			self::purge_log_rows( $now - ( $log_days * DAY_IN_SECONDS ) );
+		}
+
+		self::purge_orphan_attachment_dirs();
+
+		if ( $deleted > 0 ) {
+			Studiou_WCMQ_DB::log( 'info', sprintf( 'Retention sweep removed %d queue rows.', $deleted ) );
+		}
+	}
+
+	/**
+	 * Delete queue rows in a state, older than a cutoff, in batches.
+	 *
+	 * Attachment directories are removed first: sent rows already had theirs
+	 * deleted at send, but failed rows keep theirs until now.
+	 *
+	 * @param string $state       Row state.
+	 * @param string $time_column Timestamp column to compare.
+	 * @param int    $cutoff      Unix timestamp.
+	 * @return int Rows deleted.
+	 */
+	private static function purge_queue_rows( $state, $time_column, $cutoff ) {
+		global $wpdb;
+
+		$table = Studiou_WCMQ_DB::queue_table();
+		$total = 0;
+
+		if ( ! in_array( $time_column, array( 'sent_at', 'created_at' ), true ) ) {
+			return 0;
+		}
+
+		for ( $i = 0; $i < self::MAX_LOOPS; $i++ ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			$rows = $wpdb->get_results(
+				$wpdb->prepare(
+					"SELECT id, attachment_dir FROM {$table}
+					  WHERE state = %s AND {$time_column} IS NOT NULL AND {$time_column} < %d
+					  LIMIT %d",
+					$state,
+					(int) $cutoff,
+					self::BATCH
+				)
+			);
+
+			if ( ! is_array( $rows ) || empty( $rows ) ) {
+				break;
+			}
+
+			$ids = array();
+			foreach ( $rows as $row ) {
+				Studiou_WCMQ_Queue::delete_attachment_dir( $row->attachment_dir );
+				$ids[] = (int) $row->id;
+			}
+
+			$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			$n = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids ) );
+
+			$total += (int) $n;
+
+			if ( count( $ids ) < self::BATCH ) {
+				break;
+			}
+		}
+
+		return $total;
+	}
+
+	/**
+	 * Delete old log rows in batches.
+	 *
+	 * @param int $cutoff Unix timestamp.
+	 */
+	private static function purge_log_rows( $cutoff ) {
+		global $wpdb;
+		$table = Studiou_WCMQ_DB::log_table();
+
+		for ( $i = 0; $i < self::MAX_LOOPS; $i++ ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			$n = $wpdb->query(
+				$wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %d LIMIT %d", (int) $cutoff, self::BATCH )
+			);
+			if ( ! $n ) {
+				break;
+			}
+		}
+	}
+
+	/**
+	 * Remove attachment directories whose token matches no surviving row.
+	 *
+	 * Collects directories orphaned by a fatal between mkdir and INSERT. Only
+	 * touches directories older than a day so an in-flight enqueue is never
+	 * raced.
+	 */
+	private static function purge_orphan_attachment_dirs() {
+		global $wpdb;
+
+		$base = Studiou_WCMQ_Queue::attachments_basedir();
+		if ( '' === $base || ! is_dir( $base ) ) {
+			return;
+		}
+
+		$entries = @scandir( $base ); // phpcs:ignore
+		if ( ! is_array( $entries ) ) {
+			return;
+		}
+
+		$table  = Studiou_WCMQ_DB::queue_table();
+		$cutoff = time() - DAY_IN_SECONDS;
+
+		foreach ( $entries as $entry ) {
+			if ( ! preg_match( '/^[a-f0-9]{32}$/', $entry ) ) {
+				continue;
+			}
+			$path = $base . '/' . $entry;
+			if ( ! is_dir( $path ) ) {
+				continue;
+			}
+			$mtime = @filemtime( $path ); // phpcs:ignore
+			if ( $mtime && $mtime > $cutoff ) {
+				continue;
+			}
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
+			$exists = (int) $wpdb->get_var(
+				$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE attachment_dir = %s", $entry )
+			);
+			if ( $exists ) {
+				continue;
+			}
+			Studiou_WCMQ_Queue::rmdir_recursive( $path );
+			Studiou_WCMQ_DB::log( 'debug', sprintf( 'Removed orphaned attachment directory %s.', $entry ) );
+		}
+	}
+}

+ 264 - 0
studiou-wc-mail-queue/includes/class-wcmq-settings.php

@@ -0,0 +1,264 @@
+<?php
+/**
+ * Settings and state option access.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Settings
+ */
+class Studiou_WCMQ_Settings {
+
+	const OPTION       = 'studiou_wcmq_settings';
+	const STATE_OPTION = 'studiou_wcmq_state';
+
+	const STATE_ENABLED  = 'enabled';
+	const STATE_DRAINING = 'draining';
+	const STATE_DISABLED = 'disabled';
+
+	/**
+	 * Email ids that WooCommerce produces at runtime but does not always
+	 * register in WC()->mailer()->get_emails().
+	 *
+	 * `WC_Email_Customer_Refunded_Order::trigger()` reassigns its own $this->id
+	 * to 'customer_partially_refunded_order' on a partial refund, but the class
+	 * that reports that id from get_emails() is only registered when the alpha
+	 * `block_email_editor` feature is on. Without this table the settings screen
+	 * can never offer the id, so partial-refund mail silently bypasses the queue.
+	 *
+	 * Re-audit on every WooCommerce major upgrade.
+	 *
+	 * id => id of the parent email whose checkbox it sits next to.
+	 */
+	const RUNTIME_ALIAS_IDS = array(
+		'customer_partially_refunded_order' => 'customer_refunded_order',
+	);
+
+	/**
+	 * Cached, sanitized settings for this request.
+	 *
+	 * @var array|null
+	 */
+	private static $cache = null;
+
+	/**
+	 * Default settings.
+	 *
+	 * @return array
+	 */
+	public static function defaults() {
+		return array(
+			'rate_per_minute'       => 5,
+			'handled_contexts'      => array( 'customer_completed_order' ),
+			'retention_sent_days'   => 7,
+			'retention_failed_days' => 30,
+			'retention_log_days'    => 14,
+			'max_attempts'          => 3,
+			'max_reclaims'          => 5,
+			'claim_timeout'         => 900,
+			'debug'                 => false,
+		);
+	}
+
+	/**
+	 * All settings, sanitized on read.
+	 *
+	 * An option array is a mutable surface; never trust what comes back.
+	 *
+	 * @return array
+	 */
+	public static function all() {
+		if ( null === self::$cache ) {
+			$raw         = get_option( self::OPTION, array() );
+			self::$cache = self::sanitize( is_array( $raw ) ? $raw : array() );
+		}
+		return self::$cache;
+	}
+
+	/**
+	 * One setting.
+	 *
+	 * @param string $key Setting key.
+	 * @return mixed
+	 */
+	public static function get( $key ) {
+		$all = self::all();
+		return isset( $all[ $key ] ) ? $all[ $key ] : null;
+	}
+
+	/**
+	 * Persist settings.
+	 *
+	 * @param array $raw Raw input.
+	 */
+	public static function update( array $raw ) {
+		$clean = self::sanitize( $raw );
+		update_option( self::OPTION, $clean, true );
+		self::$cache = $clean;
+	}
+
+	/**
+	 * Coerce anything into a valid settings array.
+	 *
+	 * @param array $raw Raw input.
+	 * @return array
+	 */
+	public static function sanitize( array $raw ) {
+		$d = self::defaults();
+
+		$contexts = array();
+		if ( isset( $raw['handled_contexts'] ) && is_array( $raw['handled_contexts'] ) ) {
+			foreach ( $raw['handled_contexts'] as $id ) {
+				$id = sanitize_key( (string) $id );
+				if ( '' !== $id ) {
+					$contexts[] = $id;
+				}
+			}
+			$contexts = array_values( array_unique( $contexts ) );
+		} else {
+			$contexts = $d['handled_contexts'];
+		}
+
+		return array(
+			'rate_per_minute'       => self::clamp_int( $raw, 'rate_per_minute', 1, 60, $d['rate_per_minute'] ),
+			'handled_contexts'      => $contexts,
+			'retention_sent_days'   => self::clamp_int( $raw, 'retention_sent_days', 0, 3650, $d['retention_sent_days'] ),
+			'retention_failed_days' => self::clamp_int( $raw, 'retention_failed_days', 0, 3650, $d['retention_failed_days'] ),
+			'retention_log_days'    => self::clamp_int( $raw, 'retention_log_days', 0, 3650, $d['retention_log_days'] ),
+			'max_attempts'          => self::clamp_int( $raw, 'max_attempts', 1, 10, $d['max_attempts'] ),
+			'max_reclaims'          => self::clamp_int( $raw, 'max_reclaims', 1, 20, $d['max_reclaims'] ),
+			'claim_timeout'         => self::clamp_int( $raw, 'claim_timeout', 60, 86400, $d['claim_timeout'] ),
+			'debug'                 => ! empty( $raw['debug'] ),
+		);
+	}
+
+	/**
+	 * Read an int from raw input and clamp it.
+	 *
+	 * Non-numeric input falls back to the default rather than to 0 — a 0 here
+	 * would reach the `60 / $rate` division.
+	 *
+	 * @param array  $raw     Raw input.
+	 * @param string $key     Key.
+	 * @param int    $min     Minimum.
+	 * @param int    $max     Maximum.
+	 * @param int    $default Fallback.
+	 * @return int
+	 */
+	private static function clamp_int( array $raw, $key, $min, $max, $default ) {
+		if ( ! isset( $raw[ $key ] ) || ! is_numeric( $raw[ $key ] ) ) {
+			return (int) $default;
+		}
+		$value = (int) $raw[ $key ];
+		if ( $value < $min ) {
+			return (int) $min;
+		}
+		if ( $value > $max ) {
+			return (int) $max;
+		}
+		return $value;
+	}
+
+	/**
+	 * Mails per minute, clamped to a sane range.
+	 *
+	 * @return int
+	 */
+	public static function rate() {
+		$rate = (int) self::get( 'rate_per_minute' );
+		if ( $rate < 1 ) {
+			$rate = 1;
+		}
+		if ( $rate > 60 ) {
+			$rate = 60;
+		}
+		return $rate;
+	}
+
+	/**
+	 * Seconds between sends.
+	 *
+	 * ceil(), never floor(). floor() rounds the spacing down, which rounds the
+	 * rate UP above the configured cap for every rate that does not divide 60.
+	 * The clamp in rate() also guarantees no division by zero.
+	 *
+	 * @return int
+	 */
+	public static function interval() {
+		return max( 1, (int) ceil( 60 / self::rate() ) );
+	}
+
+	/**
+	 * Whether the debug toggle is on.
+	 *
+	 * Read straight from the option to avoid recursing through all()/log().
+	 *
+	 * @return bool
+	 */
+	public static function debug() {
+		$raw = get_option( self::OPTION, array() );
+		return is_array( $raw ) && ! empty( $raw['debug'] );
+	}
+
+	/**
+	 * Handled email ids.
+	 *
+	 * @return array
+	 */
+	public static function handled_contexts() {
+		$contexts = self::get( 'handled_contexts' );
+		return is_array( $contexts ) ? $contexts : array();
+	}
+
+	/**
+	 * Whether this email id should be queued.
+	 *
+	 * @param string $context_id WC_Email id.
+	 * @return bool
+	 */
+	public static function handles( $context_id ) {
+		if ( ! is_string( $context_id ) || '' === $context_id ) {
+			return false;
+		}
+		return in_array( $context_id, self::handled_contexts(), true );
+	}
+
+	/* ---------------------------------------------------------------------
+	 * State
+	 * ------------------------------------------------------------------ */
+
+	/**
+	 * Current state, defaulting to disabled.
+	 *
+	 * An mu-plugin activates the moment its file lands — there is no activation
+	 * step at which an admin confirms anything. Dropping the file must not start
+	 * intercepting live mail at an unconfigured rate. Fail closed.
+	 *
+	 * @return string
+	 */
+	public static function state() {
+		$state = get_option( self::STATE_OPTION, self::STATE_DISABLED );
+		if ( ! in_array( $state, array( self::STATE_ENABLED, self::STATE_DRAINING, self::STATE_DISABLED ), true ) ) {
+			return self::STATE_DISABLED;
+		}
+		return $state;
+	}
+
+	/**
+	 * Persist state.
+	 *
+	 * @param string $state New state.
+	 */
+	public static function set_state( $state ) {
+		if ( ! in_array( $state, array( self::STATE_ENABLED, self::STATE_DRAINING, self::STATE_DISABLED ), true ) ) {
+			return;
+		}
+		update_option( self::STATE_OPTION, $state, true );
+	}
+}

+ 83 - 0
studiou-wc-mail-queue/includes/class-wcmq-state.php

@@ -0,0 +1,83 @@
+<?php
+/**
+ * enabled -> draining -> disabled state machine.
+ *
+ * Disabling must never strand mail that is already queued: the interceptor
+ * stops queueing immediately, but the worker keeps draining what is there.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_State
+ */
+class Studiou_WCMQ_State {
+
+	/**
+	 * Register hooks.
+	 */
+	public static function init() {
+		// A queue emptied by retention or by manual deletion still needs to settle.
+		add_action( 'admin_init', array( __CLASS__, 'maybe_settle' ) );
+	}
+
+	/**
+	 * Current state.
+	 *
+	 * @return string
+	 */
+	public static function get() {
+		return Studiou_WCMQ_Settings::state();
+	}
+
+	/**
+	 * Whether new mail should be queued right now.
+	 *
+	 * @return bool
+	 */
+	public static function is_queueing() {
+		return Studiou_WCMQ_Settings::STATE_ENABLED === self::get();
+	}
+
+	/**
+	 * Turn queueing on.
+	 */
+	public static function enable() {
+		Studiou_WCMQ_Settings::set_state( Studiou_WCMQ_Settings::STATE_ENABLED );
+		Studiou_WCMQ_DB::log( 'info', 'Queue enabled.' );
+	}
+
+	/**
+	 * Turn queueing off, draining first if anything is in flight.
+	 */
+	public static function disable() {
+		if ( Studiou_WCMQ_DB::count_unfinished() > 0 ) {
+			Studiou_WCMQ_Settings::set_state( Studiou_WCMQ_Settings::STATE_DRAINING );
+			Studiou_WCMQ_DB::log( 'info', 'Queue draining; new mail sends synchronously.' );
+			return;
+		}
+		Studiou_WCMQ_Settings::set_state( Studiou_WCMQ_Settings::STATE_DISABLED );
+		Studiou_WCMQ_DB::log( 'info', 'Queue disabled.' );
+	}
+
+	/**
+	 * Move draining -> disabled once nothing is left in flight.
+	 *
+	 * Called by the worker after each send and on admin_init.
+	 */
+	public static function maybe_settle() {
+		if ( Studiou_WCMQ_Settings::STATE_DRAINING !== self::get() ) {
+			return;
+		}
+		if ( Studiou_WCMQ_DB::count_unfinished() > 0 ) {
+			return;
+		}
+		Studiou_WCMQ_Settings::set_state( Studiou_WCMQ_Settings::STATE_DISABLED );
+		Studiou_WCMQ_DB::log( 'info', 'Queue drained; now disabled.' );
+	}
+}

+ 315 - 0
studiou-wc-mail-queue/includes/class-wcmq-worker.php

@@ -0,0 +1,315 @@
+<?php
+/**
+ * Action Scheduler worker: claims a row and sends it.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Worker
+ */
+class Studiou_WCMQ_Worker {
+
+	/**
+	 * Last WP_Error captured from wp_mail_failed, for the row in flight.
+	 *
+	 * @var string|null
+	 */
+	private $last_error = null;
+
+	/**
+	 * Constructor.
+	 *
+	 * The wp_mail_failed listener is registered ONCE here, never per send.
+	 * Under Action Scheduler many send() calls run in one PHP process; a
+	 * per-send add_action leaks a closure per row, and an early `return` that
+	 * skips the paired remove_action leaves a stale WP_Error in the buffer to be
+	 * misattributed to the next row. Resetting the buffer before each send (in
+	 * send(), step 3) makes both failure modes structurally impossible.
+	 */
+	public function __construct() {
+		add_action( Studiou_WCMQ_Queue::HOOK, array( $this, 'send' ), 10, 1 );
+		add_action( 'wp_mail_failed', array( $this, 'capture' ), 10, 1 );
+	}
+
+	/**
+	 * Capture a mail error.
+	 *
+	 * @param WP_Error $error Error.
+	 */
+	public function capture( $error ) {
+		if ( is_wp_error( $error ) ) {
+			$this->last_error = $error->get_error_message();
+		}
+	}
+
+	/**
+	 * Send one queued mail.
+	 *
+	 * @param int $row_id Queue row id.
+	 */
+	public function send( $row_id ) {
+		$row_id = (int) $row_id;
+		if ( ! $row_id ) {
+			return;
+		}
+
+		// The reaper runs before we claim OUR row, which looks unsafe: it hunts
+		// pending rows with no scheduled action, and our row is pending and about
+		// to run. It does not misfire, but only by a coincidence of Action
+		// Scheduler internals — as_has_scheduled_action() matches STATUS_RUNNING
+		// as well as STATUS_PENDING (packages/action-scheduler/functions.php:404),
+		// and AS calls log_execution() (which sets RUNNING) before invoking this
+		// hook (ActionScheduler_Abstract_QueueRunner.php:102). So our own row is
+		// seen as running, not as an orphan. If AS ever changes that ordering,
+		// this reaper call would duplicate the running row's action — revisit here.
+		Studiou_WCMQ_Reaper::run();
+
+		// Atomic claim. Zero affected rows means someone else has it, or it is
+		// already sent. This is the duplicate-send guard.
+		if ( ! Studiou_WCMQ_DB::claim( $row_id ) ) {
+			return;
+		}
+
+		$row = Studiou_WCMQ_DB::get_row( $row_id );
+		if ( ! $row ) {
+			return;
+		}
+
+		$payload = json_decode( (string) $row->payload, true );
+		if ( ! is_array( $payload ) || JSON_ERROR_NONE !== json_last_error()
+			|| ! isset( $payload['to'], $payload['subject'], $payload['message'] ) ) {
+			// Not retriable. Do not burn an attempt on it.
+			$affected = Studiou_WCMQ_DB::update_row_if_state(
+				$row_id,
+				array(
+					'state'      => Studiou_WCMQ_DB::STATE_FAILED,
+					'claimed_at' => null,
+					'last_error' => 'corrupt payload',
+				),
+				Studiou_WCMQ_DB::STATE_SENDING
+			);
+			Studiou_WCMQ_DB::log( 'error', 'Corrupt payload; row failed without sending.', $row_id );
+			// This is a terminal state. Without the note the order records nothing
+			// at all: the customer got no email and the audit trail is silent.
+			if ( 1 === (int) $affected ) {
+				Studiou_WCMQ_Notifier::annotate( $row, false, 'corrupt payload' );
+			}
+			Studiou_WCMQ_State::maybe_settle();
+			return;
+		}
+
+		$this->last_error = null;
+
+		$sent = false;
+		try {
+			$sent = $this->dispatch( $payload );
+		} catch ( Throwable $e ) {
+			$this->last_error = $e->getMessage();
+			$sent             = false;
+		}
+
+		$error            = $this->last_error;
+		$this->last_error = null;
+
+		// Completed send attempts, not crashes. Never bumped at claim, and folded
+		// into the same UPDATE that commits the outcome so there is no window in
+		// which the row has been attempted but not resolved. We hold the claim, so
+		// nobody else is writing this column.
+		$attempts = (int) $row->attempts + 1;
+
+		if ( $sent ) {
+			$this->on_success( $row, $row_id, $attempts );
+		} else {
+			$this->on_failure( $row, $row_id, $attempts, $error );
+		}
+
+		Studiou_WCMQ_State::maybe_settle();
+	}
+
+	/**
+	 * Call wp_mail() with the stored payload, exactly once.
+	 *
+	 * Two hooks have to be neutralised for the duration:
+	 *
+	 * 1. `pre_wp_mail` — our own interceptor, or the mail would be re-queued
+	 *    forever. Handled by the $sending flag.
+	 *
+	 * 2. `wp_mail` — WordPress applies this filter to $atts *before* it applies
+	 *    `pre_wp_mail`, so the payload we snapshotted at enqueue has already been
+	 *    through it. Calling wp_mail() again here would run every `wp_mail` filter
+	 *    on the site a second time, against its own prior output: an unsubscribe
+	 *    footer appended twice, a BCC-archive header duplicated. Queued mail would
+	 *    differ from synchronous mail in ways nobody would connect to this plugin.
+	 *
+	 * Detaching the whole `wp_mail` hook and restoring it in `finally` is the only
+	 * way to suppress third-party callbacks we do not control. `wp_mail_from` and
+	 * friends need no such treatment — they are idempotent, and the resolved
+	 * sender is already baked into the stored headers.
+	 *
+	 * @param array $payload Decoded mail payload.
+	 * @return bool
+	 */
+	private function dispatch( array $payload ) {
+		global $wp_filter;
+
+		$headers     = isset( $payload['headers'] ) ? $payload['headers'] : '';
+		$attachments = isset( $payload['attachments'] ) ? (array) $payload['attachments'] : array();
+
+		$saved_wp_mail = isset( $wp_filter['wp_mail'] ) ? $wp_filter['wp_mail'] : null;
+		if ( null !== $saved_wp_mail ) {
+			unset( $wp_filter['wp_mail'] );
+		}
+
+		Studiou_WCMQ_Interceptor::$sending = true;
+
+		try {
+			return (bool) wp_mail( $payload['to'], $payload['subject'], $payload['message'], $headers, $attachments );
+		} finally {
+			// Never leave either of these in place: under Action Scheduler many
+			// sends share one PHP process, and a stuck flag or a missing hook
+			// would corrupt every later mail in the batch.
+			Studiou_WCMQ_Interceptor::$sending = false;
+			if ( null !== $saved_wp_mail ) {
+				$wp_filter['wp_mail'] = $saved_wp_mail;
+			}
+		}
+	}
+
+	/**
+	 * Commit the send, then annotate.
+	 *
+	 * Order matters. Once wp_mail() has returned true, nothing may throw back
+	 * into the send path: a fatal here would leave the row in `sending` for the
+	 * reaper to rescue and send a second time.
+	 *
+	 * @param object $row      Row snapshot from before the send.
+	 * @param int    $row_id   Row id.
+	 * @param int    $attempts Attempts including this one.
+	 */
+	private function on_success( $row, $row_id, $attempts ) {
+		// State-guarded: if the row was deleted from the admin, or reclaimed by
+		// the reaper, between our claim and now, this reports 0 rather than
+		// silently "succeeding" and letting us delete attachments and write an
+		// order note for a row that no longer exists.
+		$affected = Studiou_WCMQ_DB::update_row_if_state(
+			$row_id,
+			array(
+				'state'      => Studiou_WCMQ_DB::STATE_SENT,
+				'sent_at'    => time(),
+				'claimed_at' => null,
+				'last_error' => null,
+				'attempts'   => (int) $attempts,
+			),
+			Studiou_WCMQ_DB::STATE_SENDING
+		);
+
+		if ( 1 !== (int) $affected ) {
+			// The mail went out but we could not record it. Either the DB errored
+			// (row stays `sending`, the reaper will reclaim and resend — a
+			// duplicate we cannot prevent from here) or the row vanished under us.
+			// Either way: do not delete the attachment directory, so a resend
+			// still carries its invoice, and write no note claiming success.
+			Studiou_WCMQ_DB::log(
+				'error',
+				'Mail was sent but the row could not be committed to `sent` (it may have been deleted or reclaimed). It may be re-sent.',
+				$row_id
+			);
+			return;
+		}
+
+		Studiou_WCMQ_Queue::delete_attachment_dir( $row->attachment_dir );
+		Studiou_WCMQ_Notifier::annotate( $row, true, null );
+
+		Studiou_WCMQ_DB::log(
+			'info',
+			sprintf( 'Sent %s to %s.', $row->context, $row->recipient ),
+			$row_id
+		);
+	}
+
+	/**
+	 * Retry with backoff, or give up.
+	 *
+	 * @param object      $row      Row snapshot.
+	 * @param int         $row_id   Row id.
+	 * @param int         $attempts Attempts after this one.
+	 * @param string|null $error    Captured error.
+	 */
+	private function on_failure( $row, $row_id, $attempts, $error ) {
+		$max_attempts = (int) Studiou_WCMQ_Settings::get( 'max_attempts' );
+
+		if ( $attempts >= $max_attempts ) {
+			$affected = Studiou_WCMQ_DB::update_row_if_state(
+				$row_id,
+				array(
+					'state'      => Studiou_WCMQ_DB::STATE_FAILED,
+					'claimed_at' => null,
+					'last_error' => $error,
+					'attempts'   => (int) $attempts,
+				),
+				Studiou_WCMQ_DB::STATE_SENDING
+			);
+			// The attachment directory is deliberately KEPT: failed rows are
+			// manually retriable from the admin, and a retry without its
+			// attachments sends a broken email that looks fine.
+			if ( 1 === (int) $affected ) {
+				Studiou_WCMQ_Notifier::annotate( $row, false, $error );
+			}
+			Studiou_WCMQ_DB::log(
+				'error',
+				sprintf( 'Send failed permanently after %d attempts: %s', $attempts, (string) $error ),
+				$row_id
+			);
+			return;
+		}
+
+		$interval = Studiou_WCMQ_Settings::interval();
+		// `**`, not `^`. In PHP `^` is bitwise XOR and binds looser than `*`.
+		$backoff = min( HOUR_IN_SECONDS, $interval * ( 2 ** $attempts ) );
+
+		list( $slot, $affected ) = Studiou_WCMQ_Queue::with_slot_lock(
+			time() + $backoff,
+			function ( $slot ) use ( $row_id, $error, $attempts ) {
+				return Studiou_WCMQ_DB::update_row_if_state(
+					$row_id,
+					array(
+						'state'        => Studiou_WCMQ_DB::STATE_PENDING,
+						'claimed_at'   => null,
+						'scheduled_at' => $slot,
+						'last_error'   => $error,
+						'attempts'     => (int) $attempts,
+					),
+					Studiou_WCMQ_DB::STATE_SENDING
+				);
+			}
+		);
+
+		if ( 1 !== (int) $affected ) {
+			// The row did not move back to `pending`. Scheduling an action for it
+			// would wake a worker that finds nothing to claim. The row is still
+			// `sending`, so the reaper will reclaim it after claim_timeout.
+			Studiou_WCMQ_DB::log( 'error', 'Could not return row to pending for retry; leaving it to the reaper.', $row_id );
+			return;
+		}
+
+		$action_id = as_schedule_single_action( $slot, Studiou_WCMQ_Queue::HOOK, array( (int) $row_id ), Studiou_WCMQ_Queue::GROUP );
+		if ( ! $action_id ) {
+			// The row is pending with no action — an invariant violation. The
+			// reaper repairs it, so this degrades to "recovered late", not "lost".
+			Studiou_WCMQ_DB::log( 'error', 'Could not schedule retry; reaper will repair.', $row_id );
+			return;
+		}
+
+		Studiou_WCMQ_DB::log(
+			'warning',
+			sprintf( 'Send failed (attempt %d/%d); retrying at %d. %s', $attempts, $max_attempts, $slot, (string) $error ),
+			$row_id
+		);
+	}
+}

+ 103 - 0
studiou-wc-mail-queue/includes/utils-log.php

@@ -0,0 +1,103 @@
+<?php
+/**
+ * error_log + admin-notice utility.
+ *
+ * Adapted from studiou-wc-ord-print-statuses, with one deliberate divergence:
+ * log() is gated on this plugin's own `debug` setting rather than WP_DEBUG,
+ * because the spec calls for a debug toggle on the settings page.
+ *
+ * The persistent, admin-visible log lives in the DB (Studiou_WCMQ_DB::log()).
+ * This class is only for the PHP error log and for transient admin notices.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class Studiou_WCMQ_Utils_Log
+ */
+class Studiou_WCMQ_Utils_Log {
+
+	const NOTICE_TRANSIENT_PREFIX = 'studiou_wcmq_notices_';
+	const NOTICE_TTL              = 300;
+
+	/**
+	 * Register hooks.
+	 */
+	public static function init() {
+		add_action( 'admin_notices', array( __CLASS__, 'render_notices' ) );
+	}
+
+	/**
+	 * Write to the PHP error log when the debug setting is on.
+	 *
+	 * @param mixed $message Message or structure.
+	 */
+	public static function log( $message ) {
+		if ( ! Studiou_WCMQ_Settings::debug() ) {
+			return;
+		}
+		if ( is_array( $message ) || is_object( $message ) ) {
+			$message = print_r( $message, true ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+		}
+		error_log( 'STUDIOU WC MAIL: ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+	}
+
+	/**
+	 * Queue a dismissible admin notice for the current user.
+	 *
+	 * Survives the redirect that follows form posts. Plain text only — it is
+	 * escaped with esc_html at render time.
+	 *
+	 * @param string $message Plain text.
+	 * @param string $type    info|success|warning|error.
+	 */
+	public static function message( $message, $type = 'info' ) {
+		$user_id = get_current_user_id();
+		if ( ! $user_id ) {
+			return;
+		}
+		if ( ! in_array( $type, array( 'info', 'success', 'warning', 'error' ), true ) ) {
+			$type = 'info';
+		}
+		$key     = self::NOTICE_TRANSIENT_PREFIX . $user_id;
+		$notices = get_transient( $key );
+		if ( ! is_array( $notices ) ) {
+			$notices = array();
+		}
+		$notices[] = array(
+			'message' => (string) $message,
+			'type'    => $type,
+		);
+		set_transient( $key, $notices, self::NOTICE_TTL );
+	}
+
+	/**
+	 * Render and clear queued notices.
+	 */
+	public static function render_notices() {
+		$user_id = get_current_user_id();
+		if ( ! $user_id ) {
+			return;
+		}
+		$key     = self::NOTICE_TRANSIENT_PREFIX . $user_id;
+		$notices = get_transient( $key );
+		if ( ! is_array( $notices ) || empty( $notices ) ) {
+			return;
+		}
+		delete_transient( $key );
+		foreach ( $notices as $notice ) {
+			$type    = isset( $notice['type'] ) ? $notice['type'] : 'info';
+			$message = isset( $notice['message'] ) ? $notice['message'] : '';
+			printf(
+				'<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
+				esc_attr( $type ),
+				esc_html( $message )
+			);
+		}
+	}
+}

BIN
studiou-wc-mail-queue/languages/studiou-wc-mail-queue-cs_CZ.mo


+ 420 - 0
studiou-wc-mail-queue/languages/studiou-wc-mail-queue-cs_CZ.po

@@ -0,0 +1,420 @@
+# Czech translation for QDR - Studiou WC Mail Queue.
+# Copyright (C) 2026 QUADARAX
+# This file is distributed under the GPL v2 or later.
+# Dalibor Votruba <dvotruba@quadarax.com>, 2026.
+msgid ""
+msgstr ""
+"Project-Id-Version: QDR - Studiou WC Mail Queue 1.0.0\n"
+"Report-Msgid-Bugs-To: dvotruba@quadarax.com\n"
+"Last-Translator: Dalibor Votruba <dvotruba@quadarax.com>\n"
+"Language-Team: Czech <dvotruba@quadarax.com>\n"
+"Language: cs_CZ\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"POT-Creation-Date: 2026-07-10T00:00:00+00:00\n"
+"PO-Revision-Date: 2026-07-10T00:00:00+00:00\n"
+"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
+"X-Domain: studiou-wc-mail-queue\n"
+
+#: studiou-wc-mail-queue.php:150
+msgid "Studiou WC Mail Queue requires WooCommerce to be installed and active."
+msgstr "Plugin Studiou WC Mail Queue vyžaduje nainstalované a aktivní WooCommerce."
+
+#: includes/class-wcmq-admin.php:40
+#: includes/class-wcmq-admin.php:41
+msgid "Mail Queue"
+msgstr "Fronta e-mailů"
+
+#: includes/class-wcmq-admin.php:74
+msgid "Delete the selected rows? This cannot be undone."
+msgstr "Smazat vybrané řádky? Tuto akci nelze vrátit zpět."
+
+#: includes/class-wcmq-admin.php:78
+msgid "Re-queue the selected rows? Already-sent rows are skipped, but anything still pending or failed will be sent again."
+msgstr "Zařadit vybrané řádky znovu do fronty? Již odeslané e-maily budou přeskočeny, ale vše čekající nebo neúspěšné bude odesláno znovu."
+
+#: includes/class-wcmq-admin.php:75
+msgid "Clear the entire log? This cannot be undone."
+msgstr "Vymazat celý log? Tuto akci nelze vrátit zpět."
+
+#: includes/class-wcmq-admin.php:107
+msgid "Studiou WC Mail Queue"
+msgstr "Studiou WC Mail Queue"
+
+#: includes/class-wcmq-admin.php:108
+msgid "WooCommerce's \"Deferred emails\" feature is enabled. It moves mail off the request but applies no rate limit, and chained with this plugin it queues every email twice. Turn it off under WooCommerce → Settings → Advanced → Features."
+msgstr "Ve WooCommerce je zapnutá funkce „Deferred emails“ (Odložené e-maily). Ta sice odpojí odesílání od requestu, ale nijak neomezuje tempo – a v kombinaci s tímto pluginem se každý e-mail zařadí do fronty dvakrát. Vypněte ji v WooCommerce → Nastavení → Pokročilé → Funkce."
+
+#. translators: %s: file path where wp_mail() is defined
+#: includes/class-wcmq-admin.php:154
+msgid "Another plugin has replaced the pluggable wp_mail() function (defined in %s). This plugin can only queue mail that passes through WordPress's own wp_mail(), so nothing is being throttled. Outbound mail is sending unthrottled."
+msgstr "Jiný plugin nahradil zásuvnou funkci wp_mail() (definována v %s). Tento plugin umí zařadit do fronty pouze e-maily procházející vlastní funkcí wp_mail() WordPressu, takže se tempo odesílání nijak neomezuje. Odchozí pošta odchází bez omezení."
+
+#: includes/class-wcmq-admin.php:123
+msgid "You do not have permission to access this page."
+msgstr "Nemáte oprávnění zobrazit tuto stránku."
+
+#. translators: %s: parent email title
+#: includes/class-wcmq-admin.php:183
+msgid "%s (partial)"
+msgstr "%s (částečné)"
+
+#: includes/class-wcmq-admin.php:207
+msgid "You do not have permission to do that."
+msgstr "Nemáte oprávnění provést tuto akci."
+
+#: includes/class-wcmq-admin.php:243
+msgid "Settings saved."
+msgstr "Nastavení bylo uloženo."
+
+#: includes/class-wcmq-admin.php:257
+msgid "Mail queue enabled."
+msgstr "Fronta e-mailů byla zapnuta."
+
+#: includes/class-wcmq-admin.php:262
+msgid "Mail queue is draining. New mail sends immediately; queued mail will finish first."
+msgstr "Fronta se dokončuje. Nové e-maily se odesílají okamžitě; e-maily již zařazené ve frontě budou ještě odeslány."
+
+#: includes/class-wcmq-admin.php:266
+msgid "Mail queue disabled."
+msgstr "Fronta e-mailů byla vypnuta."
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:280
+msgid "Log cleared (%d entries)."
+msgstr "Log byl vymazán (počet záznamů: %d)."
+
+#: includes/class-wcmq-admin.php:295
+msgid "Nothing to do."
+msgstr "Není co provést."
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:350
+msgid "%d rows deleted."
+msgstr "Smazáno řádků: %d."
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:353
+msgid "%d rows re-queued."
+msgstr "Znovu zařazeno do fronty: %d řádků."
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:357
+msgid "%d rows could not be processed."
+msgstr "Nepodařilo se zpracovat řádků: %d."
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:361
+msgid "%d rows skipped because they are currently sending."
+msgstr "Přeskočeno řádků, protože se právě odesílají: %d."
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:394
+msgid "%d rows skipped because they were already sent. Re-queueing them would deliver the email twice."
+msgstr "Přeskočeno řádků, protože už byly odeslány: %d. Opětovné zařazení do fronty by e-mail doručilo dvakrát."
+
+#. translators: %d: number of items
+#: includes/class-wcmq-admin.php:413
+msgid "%d item"
+msgid_plural "%d items"
+msgstr[0] "%d položka"
+msgstr[1] "%d položky"
+msgstr[2] "%d položek"
+
+#: includes/class-wcmq-admin.php:419
+msgid "Previous"
+msgstr "Předchozí"
+
+#. translators: 1: current page, 2: total pages
+#: includes/class-wcmq-admin.php:424
+msgid "%1$d of %2$d"
+msgstr "%1$d z %2$d"
+
+#: includes/class-wcmq-admin.php:430
+msgid "Next"
+msgstr "Další"
+
+#. translators: 1: email type, 2: recipient
+#: includes/class-wcmq-notifier.php:71
+msgid "Queued email \"%1$s\" sent to %2$s."
+msgstr "E-mail „%1$s“ z fronty byl odeslán na adresu %2$s."
+
+#. translators: 1: email type, 2: error message
+#: includes/class-wcmq-notifier.php:74
+msgid "Queued email \"%1$s\" failed to send: %2$s"
+msgstr "E-mail „%1$s“ z fronty se nepodařilo odeslat: %2$s"
+
+#: views/admin-page.php:15
+msgid "Settings"
+msgstr "Nastavení"
+
+#: views/admin-page.php:16
+msgid "Queue"
+msgstr "Fronta"
+
+#: views/admin-page.php:17
+msgid "Log"
+msgstr "Log"
+
+#: views/admin-page.php:21
+msgid "WooCommerce Mail Queue"
+msgstr "WooCommerce fronta e-mailů"
+
+#: views/admin-page.php:28
+msgid "Enabled"
+msgstr "Zapnuto"
+
+#. translators: 1: mails per minute, 2: seconds between mails
+#: views/admin-page.php:32
+msgid "Sending at most %1$d mail(s) per minute (one every %2$d seconds)."
+msgstr "Odesílá se nejvýše %1$d e-mailů za minutu (jeden každých %2$d sekund)."
+
+#: views/admin-page.php:39
+msgid "Draining"
+msgstr "Dokončuje se"
+
+#. translators: %d: rows remaining
+#: views/admin-page.php:43
+msgid "New mail sends immediately. %d queued mail(s) still to send."
+msgstr "Nové e-maily se odesílají okamžitě. Ve frontě zbývá odeslat e-mailů: %d."
+
+#: views/admin-page.php:49
+msgid "Disabled"
+msgstr "Vypnuto"
+
+#: views/admin-page.php:51
+msgid "All mail sends immediately, exactly as stock WooCommerce."
+msgstr "Všechny e-maily se odesílají okamžitě, přesně jako ve výchozím WooCommerce."
+
+#: views/admin-page.php:57
+msgid "pending"
+msgstr "čeká"
+
+#: views/admin-page.php:58
+msgid "sending"
+msgstr "odesílá se"
+
+#: views/admin-page.php:59
+msgid "sent"
+msgstr "odesláno"
+
+#: views/admin-page.php:61
+msgid "failed"
+msgstr "selhalo"
+
+#: views/tab-log.php:22
+#: views/tab-queue.php:22
+msgid "All"
+msgstr "Vše"
+
+#: views/tab-log.php:23
+msgid "Errors"
+msgstr "Chyby"
+
+#: views/tab-log.php:24
+msgid "Warnings"
+msgstr "Varování"
+
+#: views/tab-log.php:25
+msgid "Info"
+msgstr "Informace"
+
+#: views/tab-log.php:26
+#: views/tab-settings.php:116
+msgid "Debug"
+msgstr "Ladění"
+
+#: views/tab-log.php:47
+msgid "Clear log"
+msgstr "Vymazat log"
+
+#: views/tab-log.php:55
+#: views/tab-queue.php:61
+msgid "ID"
+msgstr "ID"
+
+#: views/tab-log.php:56
+msgid "Level"
+msgstr "Úroveň"
+
+#: views/tab-log.php:57
+msgid "Time"
+msgstr "Čas"
+
+#: views/tab-log.php:58
+msgid "Row"
+msgstr "Řádek"
+
+#: views/tab-log.php:59
+msgid "Message"
+msgstr "Zpráva"
+
+#: views/tab-log.php:64
+msgid "The log is empty."
+msgstr "Log je prázdný."
+
+#: views/tab-queue.php:23
+msgid "Pending"
+msgstr "Čeká"
+
+#: views/tab-queue.php:24
+msgid "Sending"
+msgstr "Odesílá se"
+
+#: views/tab-queue.php:25
+#: views/tab-queue.php:68
+msgid "Sent"
+msgstr "Odesláno"
+
+#: views/tab-queue.php:26
+msgid "Failed"
+msgstr "Selhalo"
+
+#: views/tab-queue.php:49
+msgid "Bulk actions"
+msgstr "Hromadné akce"
+
+#: views/tab-queue.php:50
+msgid "Retry now"
+msgstr "Zkusit znovu"
+
+#: views/tab-queue.php:51
+msgid "Delete"
+msgstr "Smazat"
+
+#: views/tab-queue.php:53
+msgid "Apply"
+msgstr "Provést"
+
+#: views/tab-queue.php:62
+msgid "Email type"
+msgstr "Typ e-mailu"
+
+#: views/tab-queue.php:63
+msgid "Order"
+msgstr "Objednávka"
+
+#: views/tab-queue.php:64
+msgid "Recipient"
+msgstr "Příjemce"
+
+#: views/tab-queue.php:65
+msgid "State"
+msgstr "Stav"
+
+#: views/tab-queue.php:66
+msgid "Tries"
+msgstr "Pokusy"
+
+#: views/tab-queue.php:67
+msgid "Scheduled"
+msgstr "Naplánováno"
+
+#: views/tab-queue.php:69
+msgid "Last error"
+msgstr "Poslední chyba"
+
+#: views/tab-queue.php:74
+msgid "The queue is empty."
+msgstr "Fronta je prázdná."
+
+#: views/tab-queue.php:104
+msgid "Order no longer exists"
+msgstr "Objednávka již neexistuje"
+
+#: views/tab-queue.php:104
+msgid "Crash recoveries"
+msgstr "Obnovení po pádu"
+
+#: views/tab-settings.php:25
+msgid "Disable mail queue"
+msgstr "Vypnout frontu e-mailů"
+
+#: views/tab-settings.php:26
+msgid "Enable mail queue"
+msgstr "Zapnout frontu e-mailů"
+
+#: views/tab-settings.php:30
+msgid "Disabling stops queueing new mail; anything already queued still sends at the configured rate."
+msgstr "Vypnutí zastaví zařazování nových e-mailů; e-maily již zařazené ve frontě se stále odešlou nastaveným tempem."
+
+#: views/tab-settings.php:41
+msgid "Rate limit"
+msgstr "Omezení tempa"
+
+#: views/tab-settings.php:45
+msgid "Mails per minute"
+msgstr "E-mailů za minutu"
+
+#: views/tab-settings.php:51
+msgid "Set this below your host's real outbound limit — ask their support for the exact number. A value above the real limit reproduces the original problem."
+msgstr "Nastavte hodnotu pod skutečný limit odchozí pošty vašeho hostingu – vyžádejte si od jejich podpory přesné číslo. Hodnota nad skutečným limitem původní problém znovu vyvolá."
+
+#: views/tab-settings.php:57
+msgid "Emails handled by the queue"
+msgstr "E-maily zpracovávané frontou"
+
+#: views/tab-settings.php:59
+msgid "Anything not ticked here sends immediately, exactly as it does today. Never queue password resets."
+msgstr "Vše nezaškrtnuté se odesílá okamžitě, přesně jako dosud. Nikdy nezařazujte do fronty obnovení hesla."
+
+#: views/tab-settings.php:72
+msgid "Retention"
+msgstr "Retence"
+
+#: views/tab-settings.php:74
+msgid "The queue stores rendered emails and copies of their attachments. Retention is a privacy control, not just housekeeping. 0 = keep forever."
+msgstr "Fronta ukládá vykreslené e-maily a kopie jejich příloh. Retence je nástrojem ochrany osobních údajů, nejen úklidu. 0 = uchovávat navždy."
+
+#: views/tab-settings.php:78
+msgid "Keep sent mail (days)"
+msgstr "Uchovat odeslané e-maily (dny)"
+
+#: views/tab-settings.php:82
+msgid "Keep failed mail (days)"
+msgstr "Uchovat neúspěšné e-maily (dny)"
+
+#: views/tab-settings.php:86
+msgid "Keep log entries (days)"
+msgstr "Uchovat záznamy logu (dny)"
+
+#: views/tab-settings.php:91
+msgid "Reliability"
+msgstr "Spolehlivost"
+
+#: views/tab-settings.php:94
+msgid "Max send attempts"
+msgstr "Maximální počet pokusů o odeslání"
+
+#: views/tab-settings.php:97
+msgid "Completed attempts where the mail server rejected the message."
+msgstr "Dokončené pokusy, při kterých poštovní server zprávu odmítl."
+
+#: views/tab-settings.php:101
+msgid "Max crash recoveries"
+msgstr "Maximální počet obnovení po pádu"
+
+#: views/tab-settings.php:104
+msgid "Times a mail may be rescued after the sending process died. Counted separately from send attempts, so a PHP timeout never burns a healthy mail's retry budget."
+msgstr "Kolikrát smí být e-mail zachráněn poté, co odesílající proces spadl. Počítá se odděleně od pokusů o odeslání, aby timeout PHP nespotřeboval limit pokusů u zdravého e-mailu."
+
+#: views/tab-settings.php:108
+msgid "Claim timeout (seconds)"
+msgstr "Timeout převzetí (sekundy)"
+
+#: views/tab-settings.php:111
+msgid "How long before a mail stuck \"sending\" is assumed dead and retried. Keep it well above PHP's max_execution_time — too short and a slow send is retried while still in progress, delivering twice."
+msgstr "Jak dlouho čekat, než se e-mail uvíznutý ve stavu „odesílá se“ považuje za mrtvý a zkusí se znovu. Nastavte výrazně nad hodnotu PHP max_execution_time – při příliš krátké hodnotě se pomalé odeslání zopakuje ještě během běhu a e-mail dorazí dvakrát."
+
+#: views/tab-settings.php:119
+msgid "Verbose logging"
+msgstr "Podrobné logování"
+
+#: views/tab-settings.php:123
+msgid "Record debug-level entries in the log and the PHP error log."
+msgstr "Zaznamenávat ladicí záznamy do logu a do PHP error logu."
+
+#: views/tab-settings.php:129
+msgid "Save settings"
+msgstr "Uložit nastavení"

+ 418 - 0
studiou-wc-mail-queue/languages/studiou-wc-mail-queue.pot

@@ -0,0 +1,418 @@
+# Copyright (C) 2026 QUADARAX
+# This file is distributed under the GPL v2 or later.
+# Dalibor Votruba <dvotruba@quadarax.com>, 2026.
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: QDR - Studiou WC Mail Queue 1.0.0\n"
+"Report-Msgid-Bugs-To: dvotruba@quadarax.com\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"POT-Creation-Date: 2026-07-10T00:00:00+00:00\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
+"X-Domain: studiou-wc-mail-queue\n"
+
+#: studiou-wc-mail-queue.php:150
+msgid "Studiou WC Mail Queue requires WooCommerce to be installed and active."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:40
+#: includes/class-wcmq-admin.php:41
+msgid "Mail Queue"
+msgstr ""
+
+#: includes/class-wcmq-admin.php:74
+msgid "Delete the selected rows? This cannot be undone."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:78
+msgid "Re-queue the selected rows? Already-sent rows are skipped, but anything still pending or failed will be sent again."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:75
+msgid "Clear the entire log? This cannot be undone."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:107
+msgid "Studiou WC Mail Queue"
+msgstr ""
+
+#: includes/class-wcmq-admin.php:108
+msgid "WooCommerce's \"Deferred emails\" feature is enabled. It moves mail off the request but applies no rate limit, and chained with this plugin it queues every email twice. Turn it off under WooCommerce → Settings → Advanced → Features."
+msgstr ""
+
+#. translators: %s: file path where wp_mail() is defined
+#: includes/class-wcmq-admin.php:154
+msgid "Another plugin has replaced the pluggable wp_mail() function (defined in %s). This plugin can only queue mail that passes through WordPress's own wp_mail(), so nothing is being throttled. Outbound mail is sending unthrottled."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:123
+msgid "You do not have permission to access this page."
+msgstr ""
+
+#. translators: %s: parent email title
+#: includes/class-wcmq-admin.php:183
+msgid "%s (partial)"
+msgstr ""
+
+#: includes/class-wcmq-admin.php:207
+msgid "You do not have permission to do that."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:243
+msgid "Settings saved."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:257
+msgid "Mail queue enabled."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:262
+msgid "Mail queue is draining. New mail sends immediately; queued mail will finish first."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:266
+msgid "Mail queue disabled."
+msgstr ""
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:280
+msgid "Log cleared (%d entries)."
+msgstr ""
+
+#: includes/class-wcmq-admin.php:295
+msgid "Nothing to do."
+msgstr ""
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:350
+msgid "%d rows deleted."
+msgstr ""
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:353
+msgid "%d rows re-queued."
+msgstr ""
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:357
+msgid "%d rows could not be processed."
+msgstr ""
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:361
+msgid "%d rows skipped because they are currently sending."
+msgstr ""
+
+#. translators: %d: number of rows
+#: includes/class-wcmq-admin.php:394
+msgid "%d rows skipped because they were already sent. Re-queueing them would deliver the email twice."
+msgstr ""
+
+#. translators: %d: number of items
+#: includes/class-wcmq-admin.php:413
+msgid "%d item"
+msgid_plural "%d items"
+msgstr[0] ""
+msgstr[1] ""
+
+#: includes/class-wcmq-admin.php:419
+msgid "Previous"
+msgstr ""
+
+#. translators: 1: current page, 2: total pages
+#: includes/class-wcmq-admin.php:424
+msgid "%1$d of %2$d"
+msgstr ""
+
+#: includes/class-wcmq-admin.php:430
+msgid "Next"
+msgstr ""
+
+#. translators: 1: email type, 2: recipient
+#: includes/class-wcmq-notifier.php:71
+msgid "Queued email \"%1$s\" sent to %2$s."
+msgstr ""
+
+#. translators: 1: email type, 2: error message
+#: includes/class-wcmq-notifier.php:74
+msgid "Queued email \"%1$s\" failed to send: %2$s"
+msgstr ""
+
+#: views/admin-page.php:15
+msgid "Settings"
+msgstr ""
+
+#: views/admin-page.php:16
+msgid "Queue"
+msgstr ""
+
+#: views/admin-page.php:17
+msgid "Log"
+msgstr ""
+
+#: views/admin-page.php:21
+msgid "WooCommerce Mail Queue"
+msgstr ""
+
+#: views/admin-page.php:28
+msgid "Enabled"
+msgstr ""
+
+#. translators: 1: mails per minute, 2: seconds between mails
+#: views/admin-page.php:32
+msgid "Sending at most %1$d mail(s) per minute (one every %2$d seconds)."
+msgstr ""
+
+#: views/admin-page.php:39
+msgid "Draining"
+msgstr ""
+
+#. translators: %d: rows remaining
+#: views/admin-page.php:43
+msgid "New mail sends immediately. %d queued mail(s) still to send."
+msgstr ""
+
+#: views/admin-page.php:49
+msgid "Disabled"
+msgstr ""
+
+#: views/admin-page.php:51
+msgid "All mail sends immediately, exactly as stock WooCommerce."
+msgstr ""
+
+#: views/admin-page.php:57
+msgid "pending"
+msgstr ""
+
+#: views/admin-page.php:58
+msgid "sending"
+msgstr ""
+
+#: views/admin-page.php:59
+msgid "sent"
+msgstr ""
+
+#: views/admin-page.php:61
+msgid "failed"
+msgstr ""
+
+#: views/tab-log.php:22
+#: views/tab-queue.php:22
+msgid "All"
+msgstr ""
+
+#: views/tab-log.php:23
+msgid "Errors"
+msgstr ""
+
+#: views/tab-log.php:24
+msgid "Warnings"
+msgstr ""
+
+#: views/tab-log.php:25
+msgid "Info"
+msgstr ""
+
+#: views/tab-log.php:26
+#: views/tab-settings.php:116
+msgid "Debug"
+msgstr ""
+
+#: views/tab-log.php:47
+msgid "Clear log"
+msgstr ""
+
+#: views/tab-log.php:55
+#: views/tab-queue.php:61
+msgid "ID"
+msgstr ""
+
+#: views/tab-log.php:56
+msgid "Level"
+msgstr ""
+
+#: views/tab-log.php:57
+msgid "Time"
+msgstr ""
+
+#: views/tab-log.php:58
+msgid "Row"
+msgstr ""
+
+#: views/tab-log.php:59
+msgid "Message"
+msgstr ""
+
+#: views/tab-log.php:64
+msgid "The log is empty."
+msgstr ""
+
+#: views/tab-queue.php:23
+msgid "Pending"
+msgstr ""
+
+#: views/tab-queue.php:24
+msgid "Sending"
+msgstr ""
+
+#: views/tab-queue.php:25
+#: views/tab-queue.php:68
+msgid "Sent"
+msgstr ""
+
+#: views/tab-queue.php:26
+msgid "Failed"
+msgstr ""
+
+#: views/tab-queue.php:49
+msgid "Bulk actions"
+msgstr ""
+
+#: views/tab-queue.php:50
+msgid "Retry now"
+msgstr ""
+
+#: views/tab-queue.php:51
+msgid "Delete"
+msgstr ""
+
+#: views/tab-queue.php:53
+msgid "Apply"
+msgstr ""
+
+#: views/tab-queue.php:62
+msgid "Email type"
+msgstr ""
+
+#: views/tab-queue.php:63
+msgid "Order"
+msgstr ""
+
+#: views/tab-queue.php:64
+msgid "Recipient"
+msgstr ""
+
+#: views/tab-queue.php:65
+msgid "State"
+msgstr ""
+
+#: views/tab-queue.php:66
+msgid "Tries"
+msgstr ""
+
+#: views/tab-queue.php:67
+msgid "Scheduled"
+msgstr ""
+
+#: views/tab-queue.php:69
+msgid "Last error"
+msgstr ""
+
+#: views/tab-queue.php:74
+msgid "The queue is empty."
+msgstr ""
+
+#: views/tab-queue.php:104
+msgid "Order no longer exists"
+msgstr ""
+
+#: views/tab-queue.php:104
+msgid "Crash recoveries"
+msgstr ""
+
+#: views/tab-settings.php:25
+msgid "Disable mail queue"
+msgstr ""
+
+#: views/tab-settings.php:26
+msgid "Enable mail queue"
+msgstr ""
+
+#: views/tab-settings.php:30
+msgid "Disabling stops queueing new mail; anything already queued still sends at the configured rate."
+msgstr ""
+
+#: views/tab-settings.php:41
+msgid "Rate limit"
+msgstr ""
+
+#: views/tab-settings.php:45
+msgid "Mails per minute"
+msgstr ""
+
+#: views/tab-settings.php:51
+msgid "Set this below your host's real outbound limit — ask their support for the exact number. A value above the real limit reproduces the original problem."
+msgstr ""
+
+#: views/tab-settings.php:57
+msgid "Emails handled by the queue"
+msgstr ""
+
+#: views/tab-settings.php:59
+msgid "Anything not ticked here sends immediately, exactly as it does today. Never queue password resets."
+msgstr ""
+
+#: views/tab-settings.php:72
+msgid "Retention"
+msgstr ""
+
+#: views/tab-settings.php:74
+msgid "The queue stores rendered emails and copies of their attachments. Retention is a privacy control, not just housekeeping. 0 = keep forever."
+msgstr ""
+
+#: views/tab-settings.php:78
+msgid "Keep sent mail (days)"
+msgstr ""
+
+#: views/tab-settings.php:82
+msgid "Keep failed mail (days)"
+msgstr ""
+
+#: views/tab-settings.php:86
+msgid "Keep log entries (days)"
+msgstr ""
+
+#: views/tab-settings.php:91
+msgid "Reliability"
+msgstr ""
+
+#: views/tab-settings.php:94
+msgid "Max send attempts"
+msgstr ""
+
+#: views/tab-settings.php:97
+msgid "Completed attempts where the mail server rejected the message."
+msgstr ""
+
+#: views/tab-settings.php:101
+msgid "Max crash recoveries"
+msgstr ""
+
+#: views/tab-settings.php:104
+msgid "Times a mail may be rescued after the sending process died. Counted separately from send attempts, so a PHP timeout never burns a healthy mail's retry budget."
+msgstr ""
+
+#: views/tab-settings.php:108
+msgid "Claim timeout (seconds)"
+msgstr ""
+
+#: views/tab-settings.php:111
+msgid "How long before a mail stuck \"sending\" is assumed dead and retried. Keep it well above PHP's max_execution_time — too short and a slow send is retried while still in progress, delivering twice."
+msgstr ""
+
+#: views/tab-settings.php:119
+msgid "Verbose logging"
+msgstr ""
+
+#: views/tab-settings.php:123
+msgid "Record debug-level entries in the log and the PHP error log."
+msgstr ""
+
+#: views/tab-settings.php:129
+msgid "Save settings"
+msgstr ""

+ 30 - 0
studiou-wc-mail-queue/loader/studiou-wc-mail-queue.php

@@ -0,0 +1,30 @@
+<?php
+/**
+ * Loader stub for the studiou-wc-mail-queue mu-plugin.
+ *
+ * WordPress globs only `wp-content/mu-plugins/*.php` — top level, no recursion.
+ * A plugin that lives in a subdirectory is therefore never loaded on its own.
+ *
+ * INSTALL
+ *   1. Copy the whole `studiou-wc-mail-queue/` directory to
+ *      `wp-content/mu-plugins/studiou-wc-mail-queue/`
+ *   2. Copy THIS file to `wp-content/mu-plugins/studiou-wc-mail-queue.php`
+ *      (top level, alongside the directory — not inside it).
+ *
+ * Without step 2 the plugin is completely inert and reports nothing.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+$studiou_wcmq_main = __DIR__ . '/studiou-wc-mail-queue/studiou-wc-mail-queue.php';
+
+if ( file_exists( $studiou_wcmq_main ) ) {
+	require_once $studiou_wcmq_main;
+}
+
+unset( $studiou_wcmq_main );

+ 159 - 9
studiou-wc-mail-queue/readme.md

@@ -1,9 +1,159 @@
-Initial specification:
-- read docs/analyza-woocommerce-email-throttling.md that describe problem and main motivation
-- read docs/sample-wc-mail-throttle.php for sample of simple solution
-- write mu-plugin (always active) in initial version 1.0.0 for Wordpress v.6.9.4 and WooCommerce v.1.26.4
-- main features:
-	0.intercepts wp-mail()
-	1.create persistent mail queue with automatic retention plan
-	2.create administration page where I can manage main queue settings (sent mail per minute, retention settings, enable/disable debug informations), assign what type of email will be handled (order statuses), enable or disable (disabled after queue is empty) this feature / current queue state - how many mails is pending, how many mails was sent, etc. / log view (with clear log)
-	3.make translations to Czech/English
+# QDR — Studiou WC Mail Queue
+
+**Version: 1.0.0**
+
+Sends WooCommerce transactional email through a persistent, rate-limited queue so a bulk order-status change cannot exceed the host's outbound mail limit.
+
+Requires WordPress 6.8+, PHP 8.2+, WooCommerce 9.8+ (tested against 10.9.4).
+
+Copyright © 2026 QUADARAX. Licensed under GPL v2 or later.
+Contact: Dalibor Votruba &lt;dvotruba@quadarax.com&gt;
+
+---
+
+## The problem
+
+Bulk-changing 100+ orders to **Completed** fires every `wp_mail()` synchronously inside one HTTP request. Shared hosts read that burst as spam and rate-limit outbound mail, so an unpredictable share of customers never receive their notification, and the request may hit `max_execution_time`.
+
+The host is behaving correctly. The application is generating an unregulated burst.
+
+Full analysis: [`docs/analyza-woocommerce-email-throttling.md`](docs/analyza-woocommerce-email-throttling.md).
+
+## How it works
+
+1. `WC_Email::send()` is probed via `woocommerce_mail_callback` to learn *which* email is about to be sent.
+2. `pre_wp_mail` intercepts the send. If the email type is one you ticked, the fully-rendered message is stored in a queue table and the send is short-circuited.
+3. Each queued mail is given a **send slot** — the first free timestamp at least `interval` seconds from every other queued mail. Action Scheduler never runs an action before its scheduled time, so the spacing is a hard cap, independent of how often cron fires.
+4. A worker sends each mail at its slot, retries with backoff on failure, and writes an order note when it genuinely leaves.
+
+Anything you did not tick sends immediately, exactly as it does today.
+
+---
+
+## Installation — read this part
+
+This is an **mu-plugin** (always active, no activation screen). WordPress loads only `wp-content/mu-plugins/*.php` — top level, **no recursion** — so a plugin in a subdirectory needs a loader stub.
+
+1. Copy the plugin directory to `wp-content/mu-plugins/studiou-wc-mail-queue/`
+2. Copy `loader/studiou-wc-mail-queue.php` to `wp-content/mu-plugins/studiou-wc-mail-queue.php`
+   (top level, **beside** the directory, not inside it)
+
+Miss step 2 and the plugin is completely inert, with no error anywhere.
+
+Then: **WooCommerce → Mail Queue → Enable mail queue.** It ships disabled — dropping the files in must not start intercepting live mail at an unconfigured rate.
+
+### Required: turn off WooCommerce's "Deferred emails"
+
+**WooCommerce → Settings → Advanced → Features → Deferred emails** must be **off**.
+
+That feature (WC 10.8+) moves mail off the request via Action Scheduler but applies **no rate limit** — it drains in batches as fast as it can. It does not solve this problem, and chained with this plugin it queues every email twice. The plugin shows an admin warning if it is on.
+
+```
+wp option get woocommerce_feature_deferred_transactional_emails_enabled
+```
+
+should be absent or `no`. (Note the `_enabled` suffix; without it the command reports "absent" either way.)
+
+### Required: a reliable cron
+
+Action Scheduler rides WP-Cron, which only fires on page views. Without a real cron the queue drains in bursts on whatever traffic happens to arrive, which makes a correct installation look broken.
+
+```php
+// wp-config.php
+define( 'DISABLE_WP_CRON', true );
+```
+
+```cron
+* * * * * cd /path/to/site && wp action-scheduler run >/dev/null 2>&1
+```
+
+### Recommended: block the attachment directory on Nginx
+
+Attachment copies (invoice and packing-slip PDFs) are written to
+`wp-content/uploads/studiou-wcmq-attachments/<random-32-hex>/`.
+
+They are deleted the moment the mail is sent, and the directory name is unguessable. The plugin also writes an `index.php` and a `.htaccess` deny — **but Nginx ignores `.htaccess`**. On Nginx, add:
+
+```nginx
+location ~* /uploads/studiou-wcmq-attachments/ { deny all; }
+```
+
+---
+
+## Settings
+
+**Mails per minute** — set this *below* your host's real outbound limit. Ask their support for the exact number. A value above the real limit reproduces the original bug with extra steps. Default 5 (one every 12 seconds, 300/hour).
+
+**Emails handled by the queue** — built from the emails WooCommerce actually has, so it stays honest as WooCommerce and other plugins add more. Default: *Completed order* only.
+
+> Never queue password resets. `customer_reset_password` appears in the list because it is a WooCommerce email, but a customer waiting on a reset link should not sit behind an order backlog.
+
+**Retention** — the queue stores rendered customer emails and copies of their attachments. Retention is a privacy control, not just housekeeping. `0` keeps forever.
+
+**Max send attempts** vs **Max crash recoveries** — deliberately separate. The first counts attempts where the mail server rejected the message; the second counts times the sending process died. A PHP timeout strands whichever mail happened to be running, which is arbitrary — counting that as a send attempt would permanently fail healthy mail that was never actually attempted.
+
+**Claim timeout** — how long a mail stuck in `sending` is presumed dead. Keep it well above `max_execution_time`. Too short and a slow send is retried while still in progress, delivering twice.
+
+---
+
+## Known limitations
+
+- **The rate limit is enforced against in-flight mail, not as a sliding window.** A mail's send slot is released the moment it is delivered, so the cap counts only mail still queued or sending. During the bulk this plugin exists for, the queue is never empty and the spacing holds exactly. But two mails enqueued in *separate* requests, where the first has already gone out before the second arrives, can leave closer together than the interval. This is deliberate — it lets the queue recover to real-time pacing the instant it drains — but it means "5 per minute" is a cap on concurrency-adjusted throughput, not a rolling 60-second window. If your host enforces a strict rolling window, set the rate with headroom.
+
+- **A live customer's order confirmation queues behind a bulk drain.** At 5/min, a 100-order bulk delays it by up to 20 minutes. This is inherent to a single global rate ladder, and a global ladder is what the host's limit requires. The `priority` column is reserved for a future fix and is currently inert.
+
+- **The queued email is a point-in-time snapshot.** The rendered HTML is stored at enqueue, so an order edited between enqueue and send produces an email showing the *old* data. Arguably the more correct semantics — the email describes the moment the status changed — but it differs from stock WooCommerce.
+
+- **`woocommerce_email_sent` fires at enqueue time**, because we tell WooCommerce the mail was sent. This plugin suppresses WooCommerce's own `EmailLogger` for queued mail and writes its own order note and log line when the mail really leaves. *Other* plugins listening on that hook (delivery logs, CRM sync) will still record the send early. Audit them before rollout.
+
+- **Admin stock alerts (low stock, no stock, backorder) can never be queued.** They call `wp_mail()` directly, bypassing `WC_Email::send()`. This is correct — a stock warning should not sit behind an order backlog.
+
+- **Two ways another mailer plugin can bypass this one entirely**, in which case mail sends unthrottled and nothing here reports a problem:
+
+  1. It **redefines `wp_mail()`**. The function is pluggable; a plugin that replaces it never runs the `pre_wp_mail` filter we hook. **This plugin detects that case and shows an admin error** naming the file that redefined it.
+  2. It replaces the `woocommerce_mail_callback` mailer, so `wp_mail()` is never called for WooCommerce email. Undetectable from here.
+
+  Given the shop is on a host that rate-limits outbound mail, an SMTP plugin being present is likely rather than hypothetical. Audit active mailer plugins before rollout.
+
+- **A transactional-API mailer hooked on `pre_wp_mail`** at a lower priority than 10 will deliver the mail before we see it. We honour its return value and do not queue — so no double delivery — but the mail is not throttled by us either.
+
+- **A crash mid-enqueue can leave an attachment directory on disk for up to a day.** If PHP fatals in the brief window between copying a mail's attachments and writing its queue row, the copied invoice PDFs are orphaned. The daily retention sweep collects any such directory (older than a day, with no matching row); the one-day floor avoids deleting a directory whose row is still being written. The directory name is unguessable random, so exposure is limited, but on a strict-privacy shop be aware the worst-case cleanup latency is ~24 hours.
+
+- **Removal is manual.** mu-plugins get no `uninstall.php`. To tear down: drop tables `{prefix}studiou_wcmq_queue` and `{prefix}studiou_wcmq_log`, delete options `studiou_wcmq_settings`, `studiou_wcmq_state`, `studiou_wcmq_db_version`, cancel the `studiou-wcmq` Action Scheduler group, and remove `uploads/studiou-wcmq-attachments/`.
+
+---
+
+## Verifying it works
+
+1. Enable `WP_DEBUG` / `WP_DEBUG_LOG`, and the plugin's own **Verbose logging**.
+2. **WooCommerce → Status → Scheduled Actions**, group `studiou-wcmq` — check pending counts and the spacing between scheduled times.
+3. On staging, bulk-change 100+ orders to Completed. Every mail should eventually arrive, spaced at the configured rate, with **no duplicates** and no request timeout.
+4. Cross-check the host's MTA log for rate-limit rejections. There should be none.
+5. Place a test order *during* the drain: it still receives its confirmation, late (see limitations).
+
+---
+
+## License
+
+Copyright © 2026 QUADARAX. All rights reserved.
+
+This program is free software; you can redistribute it and/or modify it under the terms of the **GNU General Public License, version 2 or later**, as published by the Free Software Foundation. See <https://www.gnu.org/licenses/gpl-2.0.html>.
+
+Contact: Dalibor Votruba &lt;dvotruba@quadarax.com&gt; · <https://www.quadarax.com>
+
+---
+
+## Changelog
+
+### 1.0.0
+
+Initial release.
+
+- `pre_wp_mail` interception with per-email-type selection via a `woocommerce_mail_callback` probe.
+- Persistent queue table with a first-free-slot rate limiter (hard per-minute cap, deterministic spacing).
+- Retry with exponential backoff; crash recovery via a stale-claim reaper that also repairs `pending` rows left with no scheduled action.
+- Enable / draining / disabled state machine — disabling never strands queued mail.
+- Retention sweep for sent rows, failed rows, log entries and orphaned attachment directories.
+- Admin screen: settings, live queue with retry/delete, filterable log with clear.
+- Suppresses WooCommerce `EmailLogger`'s premature "sent" order note for queued mail, and writes an accurate one on real send.
+- Czech and English localisation.

+ 194 - 0
studiou-wc-mail-queue/studiou-wc-mail-queue.php

@@ -0,0 +1,194 @@
+<?php
+/**
+ * Plugin Name: QDR - Studiou WC Mail Queue
+ * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-mail-queue
+ * Description: Intercepts outbound WooCommerce transactional mail and sends it through a persistent, rate-limited queue so bulk order-status changes cannot exceed the host's outbound mail limit.
+ * Version: 1.0.0
+ * Requires at least: 6.8
+ * Tested up to: 6.9.4
+ * Requires PHP: 8.2
+ * WC requires at least: 9.8
+ * WC tested up to: 10.9.4
+ * Author: QUADARAX
+ * Author URI: https://www.quadarax.com
+ * License: GPL v2 or later
+ * License URI: https://www.gnu.org/licenses/gpl-2.0.html
+ * Text Domain: studiou-wc-mail-queue
+ * Domain Path: /languages
+ *
+ * Copyright (C) 2026 QUADARAX. All rights reserved.
+ * Contact: Dalibor Votruba <dvotruba@quadarax.com>
+ *
+ * This is an mu-plugin. It lives in a subdirectory and is loaded by the stub in
+ * `loader/studiou-wc-mail-queue.php` — see that file for install instructions.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+define( 'STUDIOU_WCMQ_VERSION', '1.0.0' );
+define( 'STUDIOU_WCMQ_FILE', __FILE__ );
+define( 'STUDIOU_WCMQ_DIR', plugin_dir_path( __FILE__ ) );
+define( 'STUDIOU_WCMQ_URL', plugin_dir_url( __FILE__ ) );
+define( 'STUDIOU_WCMQ_BASENAME', plugin_basename( __FILE__ ) );
+
+/**
+ * Declare HPOS compatibility.
+ *
+ * Named (not a closure) so a test harness or another plugin can remove_action it.
+ */
+function studiou_wcmq_declare_hpos_compat() {
+	if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
+		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', STUDIOU_WCMQ_FILE, true );
+	}
+}
+add_action( 'before_woocommerce_init', 'studiou_wcmq_declare_hpos_compat' );
+
+/**
+ * Main plugin class.
+ *
+ * Its only jobs: guard on WooCommerce, load dependencies, instantiate managers.
+ * Managers register their own hooks in their constructors / init().
+ */
+final class Studiou_WC_Mail_Queue {
+
+	/**
+	 * Worker instance. Held so its hooks stay attached.
+	 *
+	 * @var Studiou_WCMQ_Worker|null
+	 */
+	private $worker = null;
+
+	/**
+	 * Admin instance.
+	 *
+	 * @var Studiou_WCMQ_Admin|null
+	 */
+	private $admin = null;
+
+	/**
+	 * Boot entry point.
+	 */
+	public static function init_plugin() {
+		$class = __CLASS__;
+		new $class();
+	}
+
+	/**
+	 * Constructor.
+	 */
+	public function __construct() {
+		if ( ! class_exists( 'WooCommerce' ) ) {
+			add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) );
+			return;
+		}
+
+		$this->load_dependencies();
+
+		Studiou_WCMQ_DB::maybe_upgrade_db();
+
+		Studiou_WCMQ_Utils_Log::init();
+		Studiou_WCMQ_Context::init();
+		Studiou_WCMQ_Interceptor::init();
+		Studiou_WCMQ_State::init();
+		Studiou_WCMQ_Reaper::init();
+		Studiou_WCMQ_Retention::init();
+
+		$this->worker = new Studiou_WCMQ_Worker();
+
+		if ( is_admin() ) {
+			$this->admin = new Studiou_WCMQ_Admin();
+		}
+
+		// Action Scheduler only exists once WooCommerce has loaded it. An mu-plugin
+		// runs before regular plugins, so nothing AS-dependent may happen at file
+		// scope or at plugins_loaded. Defer scheduling to `init`.
+		add_action( 'init', array( $this, 'schedule_recurring_actions' ), 20 );
+	}
+
+	/**
+	 * Load class files.
+	 */
+	private function load_dependencies() {
+		$files = array(
+			'utils-log.php',
+			'class-wcmq-settings.php',
+			'class-wcmq-db.php',
+			'class-wcmq-state.php',
+			'class-wcmq-context.php',
+			'class-wcmq-queue.php',
+			'class-wcmq-interceptor.php',
+			'class-wcmq-notifier.php',
+			'class-wcmq-worker.php',
+			'class-wcmq-reaper.php',
+			'class-wcmq-retention.php',
+		);
+
+		if ( is_admin() ) {
+			$files[] = 'class-wcmq-admin.php';
+		}
+
+		foreach ( $files as $file ) {
+			require_once STUDIOU_WCMQ_DIR . 'includes/' . $file;
+		}
+	}
+
+	/**
+	 * Register the recurring reaper + retention actions, exactly once each.
+	 *
+	 * Gated to admin / cron / WP-CLI. The `as_next_scheduled_action()` pre-check
+	 * inside each is what keeps this idempotent, but it is still two DB queries,
+	 * and running them on every front-end page view buys nothing: those contexts
+	 * are exactly the ones that never need to bootstrap a schedule.
+	 */
+	public function schedule_recurring_actions() {
+		if ( ! function_exists( 'as_schedule_recurring_action' ) ) {
+			return;
+		}
+		$should_schedule = is_admin() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI );
+		if ( ! $should_schedule ) {
+			return;
+		}
+		Studiou_WCMQ_Reaper::schedule();
+		Studiou_WCMQ_Retention::schedule();
+	}
+
+	/**
+	 * Admin notice shown when WooCommerce is absent.
+	 */
+	public function woocommerce_missing_notice() {
+		?>
+		<div class="notice notice-error">
+			<p><?php esc_html_e( 'Studiou WC Mail Queue requires WooCommerce to be installed and active.', 'studiou-wc-mail-queue' ); ?></p>
+		</div>
+		<?php
+	}
+}
+
+/**
+ * Load translations before anything instantiates.
+ *
+ * `load_plugin_textdomain()` resolves its relative path against WP_PLUGIN_DIR,
+ * so for an mu-plugin it looks in `wp-content/plugins/...` — a directory that
+ * does not exist here, and the catalogue silently never loads. The mu-plugin
+ * equivalent, `load_muplugin_textdomain()`, resolves against WPMU_PLUGIN_DIR.
+ *
+ * Both check WP_LANG_DIR/plugins/ first, so a translation shipped by the site
+ * still wins. The fallback keeps this working if the plugin is ever dropped into
+ * wp-content/plugins/ as a regular plugin instead.
+ */
+function studiou_wcmq_load_textdomain() {
+	$rel_path = basename( dirname( __FILE__ ) ) . '/languages';
+
+	if ( load_muplugin_textdomain( 'studiou-wc-mail-queue', $rel_path ) ) {
+		return;
+	}
+
+	load_plugin_textdomain( 'studiou-wc-mail-queue', false, $rel_path );
+}
+add_action( 'plugins_loaded', 'studiou_wcmq_load_textdomain', 5 );
+add_action( 'plugins_loaded', array( 'Studiou_WC_Mail_Queue', 'init_plugin' ), 10 );

+ 83 - 0
studiou-wc-mail-queue/views/admin-page.php

@@ -0,0 +1,83 @@
+<?php
+/**
+ * Admin page shell: header, state banner, tab bar.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ *
+ * @var string $tab Active tab slug.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+$wcmq_state  = Studiou_WCMQ_State::get();
+$wcmq_counts = Studiou_WCMQ_DB::counts_by_state();
+$wcmq_tabs   = array(
+	'settings' => __( 'Settings', 'studiou-wc-mail-queue' ),
+	'queue'    => __( 'Queue', 'studiou-wc-mail-queue' ),
+	'log'      => __( 'Log', 'studiou-wc-mail-queue' ),
+);
+?>
+<div class="wrap studiou-wcmq">
+	<h1><?php esc_html_e( 'WooCommerce Mail Queue', 'studiou-wc-mail-queue' ); ?></h1>
+
+	<p class="studiou-wcmq-status">
+		<?php
+		if ( Studiou_WCMQ_Settings::STATE_ENABLED === $wcmq_state ) {
+			printf(
+				'<span class="studiou-wcmq-badge studiou-wcmq-badge-on">%s</span> ',
+				esc_html__( 'Enabled', 'studiou-wc-mail-queue' )
+			);
+			printf(
+				/* translators: 1: mails per minute, 2: seconds between mails */
+				esc_html__( 'Sending at most %1$d mail(s) per minute (one every %2$d seconds).', 'studiou-wc-mail-queue' ),
+				(int) Studiou_WCMQ_Settings::rate(),
+				(int) Studiou_WCMQ_Settings::interval()
+			);
+		} elseif ( Studiou_WCMQ_Settings::STATE_DRAINING === $wcmq_state ) {
+			printf(
+				'<span class="studiou-wcmq-badge studiou-wcmq-badge-draining">%s</span> ',
+				esc_html__( 'Draining', 'studiou-wc-mail-queue' )
+			);
+			printf(
+				/* translators: %d: rows remaining */
+				esc_html__( 'New mail sends immediately. %d queued mail(s) still to send.', 'studiou-wc-mail-queue' ),
+				(int) ( $wcmq_counts['pending'] + $wcmq_counts['sending'] )
+			);
+		} else {
+			printf(
+				'<span class="studiou-wcmq-badge studiou-wcmq-badge-off">%s</span> ',
+				esc_html__( 'Disabled', 'studiou-wc-mail-queue' )
+			);
+			esc_html_e( 'All mail sends immediately, exactly as stock WooCommerce.', 'studiou-wc-mail-queue' );
+		}
+		?>
+	</p>
+
+	<ul class="studiou-wcmq-counts">
+		<li><strong><?php echo (int) $wcmq_counts['pending']; ?></strong> <?php esc_html_e( 'pending', 'studiou-wc-mail-queue' ); ?></li>
+		<li><strong><?php echo (int) $wcmq_counts['sending']; ?></strong> <?php esc_html_e( 'sending', 'studiou-wc-mail-queue' ); ?></li>
+		<li><strong><?php echo (int) $wcmq_counts['sent']; ?></strong> <?php esc_html_e( 'sent', 'studiou-wc-mail-queue' ); ?></li>
+		<li class="<?php echo $wcmq_counts['failed'] ? 'studiou-wcmq-has-failed' : ''; ?>">
+			<strong><?php echo (int) $wcmq_counts['failed']; ?></strong> <?php esc_html_e( 'failed', 'studiou-wc-mail-queue' ); ?>
+		</li>
+	</ul>
+
+	<h2 class="nav-tab-wrapper">
+		<?php foreach ( $wcmq_tabs as $wcmq_slug => $wcmq_label ) : ?>
+			<a href="<?php echo esc_url( Studiou_WCMQ_Admin::tab_url( $wcmq_slug ) ); ?>"
+				class="nav-tab <?php echo $tab === $wcmq_slug ? 'nav-tab-active' : ''; ?>">
+				<?php echo esc_html( $wcmq_label ); ?>
+			</a>
+		<?php endforeach; ?>
+	</h2>
+
+	<?php
+	$wcmq_partial = STUDIOU_WCMQ_DIR . 'views/tab-' . $tab . '.php';
+	if ( file_exists( $wcmq_partial ) ) {
+		include $wcmq_partial;
+	}
+	?>
+</div>

+ 92 - 0
studiou-wc-mail-queue/views/tab-log.php

@@ -0,0 +1,92 @@
+<?php
+/**
+ * Log tab.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+$wcmq_level = isset( $_GET['level'] ) ? sanitize_key( wp_unslash( $_GET['level'] ) ) : '';
+if ( ! in_array( $wcmq_level, array( 'debug', 'info', 'warning', 'error' ), true ) ) {
+	$wcmq_level = '';
+}
+
+$wcmq_paged  = Studiou_WCMQ_Admin::current_page();
+$wcmq_total  = Studiou_WCMQ_DB::count_log_rows( $wcmq_level );
+$wcmq_offset = ( $wcmq_paged - 1 ) * Studiou_WCMQ_Admin::PER_PAGE;
+$wcmq_rows   = Studiou_WCMQ_DB::get_log_rows( $wcmq_level, Studiou_WCMQ_Admin::PER_PAGE, $wcmq_offset );
+
+$wcmq_levels = array(
+	''        => __( 'All', 'studiou-wc-mail-queue' ),
+	'error'   => __( 'Errors', 'studiou-wc-mail-queue' ),
+	'warning' => __( 'Warnings', 'studiou-wc-mail-queue' ),
+	'info'    => __( 'Info', 'studiou-wc-mail-queue' ),
+	'debug'   => __( 'Debug', 'studiou-wc-mail-queue' ),
+);
+?>
+
+<ul class="subsubsub">
+	<?php foreach ( $wcmq_levels as $wcmq_slug => $wcmq_label ) : ?>
+		<li>
+			<a href="<?php echo esc_url( Studiou_WCMQ_Admin::tab_url( 'log', $wcmq_slug ? array( 'level' => $wcmq_slug ) : array() ) ); ?>"
+				class="<?php echo $wcmq_level === $wcmq_slug ? 'current' : ''; ?>">
+				<?php echo esc_html( $wcmq_label ); ?>
+			</a>
+		</li>
+	<?php endforeach; ?>
+</ul>
+<div class="clear"></div>
+
+<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="studiou-wcmq-clear-log-form">
+	<?php wp_nonce_field( Studiou_WCMQ_Admin::NONCE ); ?>
+	<input type="hidden" name="action" value="studiou_wcmq_clear_log" />
+	<p>
+		<button type="submit" class="button button-secondary" id="studiou-wcmq-clear-log">
+			<?php esc_html_e( 'Clear log', 'studiou-wc-mail-queue' ); ?>
+		</button>
+	</p>
+</form>
+
+<table class="wp-list-table widefat fixed striped">
+	<thead>
+		<tr>
+			<th style="width:60px"><?php esc_html_e( 'ID', 'studiou-wc-mail-queue' ); ?></th>
+			<th style="width:90px"><?php esc_html_e( 'Level', 'studiou-wc-mail-queue' ); ?></th>
+			<th style="width:170px"><?php esc_html_e( 'Time', 'studiou-wc-mail-queue' ); ?></th>
+			<th style="width:80px"><?php esc_html_e( 'Row', 'studiou-wc-mail-queue' ); ?></th>
+			<th><?php esc_html_e( 'Message', 'studiou-wc-mail-queue' ); ?></th>
+		</tr>
+	</thead>
+	<tbody>
+	<?php if ( empty( $wcmq_rows ) ) : ?>
+		<tr><td colspan="5"><?php esc_html_e( 'The log is empty.', 'studiou-wc-mail-queue' ); ?></td></tr>
+	<?php else : ?>
+		<?php foreach ( $wcmq_rows as $wcmq_row ) : ?>
+			<tr>
+				<td><?php echo (int) $wcmq_row->id; ?></td>
+				<td>
+					<span class="studiou-wcmq-level studiou-wcmq-level-<?php echo esc_attr( $wcmq_row->level ); ?>">
+						<?php echo esc_html( $wcmq_row->level ); ?>
+					</span>
+				</td>
+				<td><?php echo wp_kses_post( Studiou_WCMQ_Admin::format_time( $wcmq_row->created_at ) ); ?></td>
+				<td><?php echo $wcmq_row->queue_id ? (int) $wcmq_row->queue_id : '&mdash;'; ?></td>
+				<td><?php echo esc_html( $wcmq_row->message ); ?></td>
+			</tr>
+		<?php endforeach; ?>
+	<?php endif; ?>
+	</tbody>
+</table>
+
+<?php
+Studiou_WCMQ_Admin::render_pagination(
+	'log',
+	$wcmq_total,
+	$wcmq_paged,
+	$wcmq_level ? array( 'level' => $wcmq_level ) : array()
+);

+ 145 - 0
studiou-wc-mail-queue/views/tab-queue.php

@@ -0,0 +1,145 @@
+<?php
+/**
+ * Queue tab.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+$wcmq_filter = isset( $_GET['state'] ) ? sanitize_key( wp_unslash( $_GET['state'] ) ) : '';
+if ( ! in_array( $wcmq_filter, array( 'pending', 'sending', 'sent', 'failed' ), true ) ) {
+	$wcmq_filter = '';
+}
+
+$wcmq_paged  = Studiou_WCMQ_Admin::current_page();
+$wcmq_total  = Studiou_WCMQ_DB::count_rows( $wcmq_filter );
+$wcmq_offset = ( $wcmq_paged - 1 ) * Studiou_WCMQ_Admin::PER_PAGE;
+$wcmq_rows   = Studiou_WCMQ_DB::get_rows( $wcmq_filter, Studiou_WCMQ_Admin::PER_PAGE, $wcmq_offset );
+
+$wcmq_states = array(
+	''        => __( 'All', 'studiou-wc-mail-queue' ),
+	'pending' => __( 'Pending', 'studiou-wc-mail-queue' ),
+	'sending' => __( 'Sending', 'studiou-wc-mail-queue' ),
+	'sent'    => __( 'Sent', 'studiou-wc-mail-queue' ),
+	'failed'  => __( 'Failed', 'studiou-wc-mail-queue' ),
+);
+?>
+
+<ul class="subsubsub">
+	<?php foreach ( $wcmq_states as $wcmq_slug => $wcmq_label ) : ?>
+		<li>
+			<a href="<?php echo esc_url( Studiou_WCMQ_Admin::tab_url( 'queue', $wcmq_slug ? array( 'state' => $wcmq_slug ) : array() ) ); ?>"
+				class="<?php echo $wcmq_filter === $wcmq_slug ? 'current' : ''; ?>">
+				<?php echo esc_html( $wcmq_label ); ?>
+			</a>
+		</li>
+	<?php endforeach; ?>
+</ul>
+<div class="clear"></div>
+
+<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="studiou-wcmq-queue-form">
+	<?php wp_nonce_field( Studiou_WCMQ_Admin::NONCE ); ?>
+	<input type="hidden" name="action" value="studiou_wcmq_queue_action" />
+
+	<div class="tablenav top">
+		<div class="alignleft actions">
+			<select name="wcmq_action">
+				<option value=""><?php esc_html_e( 'Bulk actions', 'studiou-wc-mail-queue' ); ?></option>
+				<option value="retry"><?php esc_html_e( 'Retry now', 'studiou-wc-mail-queue' ); ?></option>
+				<option value="delete"><?php esc_html_e( 'Delete', 'studiou-wc-mail-queue' ); ?></option>
+			</select>
+			<button type="submit" class="button" id="studiou-wcmq-apply"><?php esc_html_e( 'Apply', 'studiou-wc-mail-queue' ); ?></button>
+		</div>
+	</div>
+
+	<table class="wp-list-table widefat fixed striped">
+		<thead>
+			<tr>
+				<td class="check-column"><input type="checkbox" id="studiou-wcmq-check-all" /></td>
+				<th><?php esc_html_e( 'ID', 'studiou-wc-mail-queue' ); ?></th>
+				<th><?php esc_html_e( 'Email type', 'studiou-wc-mail-queue' ); ?></th>
+				<th><?php esc_html_e( 'Order', 'studiou-wc-mail-queue' ); ?></th>
+				<th><?php esc_html_e( 'Recipient', 'studiou-wc-mail-queue' ); ?></th>
+				<th><?php esc_html_e( 'State', 'studiou-wc-mail-queue' ); ?></th>
+				<th><?php esc_html_e( 'Tries', 'studiou-wc-mail-queue' ); ?></th>
+				<th><?php esc_html_e( 'Scheduled', 'studiou-wc-mail-queue' ); ?></th>
+				<th><?php esc_html_e( 'Sent', 'studiou-wc-mail-queue' ); ?></th>
+				<th><?php esc_html_e( 'Last error', 'studiou-wc-mail-queue' ); ?></th>
+			</tr>
+		</thead>
+		<tbody>
+		<?php if ( empty( $wcmq_rows ) ) : ?>
+			<tr><td colspan="10"><?php esc_html_e( 'The queue is empty.', 'studiou-wc-mail-queue' ); ?></td></tr>
+		<?php else : ?>
+			<?php foreach ( $wcmq_rows as $wcmq_row ) : ?>
+				<tr>
+					<th scope="row" class="check-column">
+						<input type="checkbox" name="rows[]" value="<?php echo (int) $wcmq_row->id; ?>" />
+					</th>
+					<td><?php echo (int) $wcmq_row->id; ?></td>
+					<td><code><?php echo esc_html( $wcmq_row->context ); ?></code></td>
+					<td>
+						<?php
+						// Never hardcode the HPOS URL: `page=wc-orders` is not a
+						// registered admin page when order storage is legacy posts.
+						// WC_Order::get_edit_order_url() resolves both, and returns
+						// nothing useful for an order deleted since enqueue.
+						$wcmq_order_id  = (int) $wcmq_row->order_id;
+						$wcmq_edit_url  = '';
+						if ( $wcmq_order_id > 0 && function_exists( 'wc_get_order' ) ) {
+							$wcmq_order = wc_get_order( $wcmq_order_id );
+							if ( $wcmq_order instanceof WC_Order ) {
+								$wcmq_edit_url = $wcmq_order->get_edit_order_url();
+							}
+						}
+						if ( $wcmq_edit_url ) {
+							printf( '<a href="%s">#%d</a>', esc_url( $wcmq_edit_url ), $wcmq_order_id );
+						} elseif ( $wcmq_order_id > 0 ) {
+							// Order no longer exists.
+							printf( '<span title="%s">#%d</span>', esc_attr__( 'Order no longer exists', 'studiou-wc-mail-queue' ), $wcmq_order_id );
+						} else {
+							echo '&mdash;';
+						}
+						?>
+					</td>
+					<td><?php echo esc_html( $wcmq_row->recipient ); ?></td>
+					<td>
+						<span class="studiou-wcmq-state studiou-wcmq-state-<?php echo esc_attr( $wcmq_row->state ); ?>">
+							<?php echo esc_html( $wcmq_row->state ); ?>
+						</span>
+					</td>
+					<td>
+						<?php
+						echo (int) $wcmq_row->attempts;
+						if ( (int) $wcmq_row->reclaims > 0 ) {
+							printf(
+								' <abbr title="%s">(+%d)</abbr>',
+								esc_attr__( 'Crash recoveries', 'studiou-wc-mail-queue' ),
+								(int) $wcmq_row->reclaims
+							);
+						}
+						?>
+					</td>
+					<td><?php echo wp_kses_post( Studiou_WCMQ_Admin::format_time( $wcmq_row->scheduled_at ) ); ?></td>
+					<td><?php echo wp_kses_post( Studiou_WCMQ_Admin::format_time( $wcmq_row->sent_at ) ); ?></td>
+					<td class="studiou-wcmq-error"><?php echo esc_html( (string) $wcmq_row->last_error ); ?></td>
+				</tr>
+			<?php endforeach; ?>
+		<?php endif; ?>
+		</tbody>
+	</table>
+
+	<?php
+	Studiou_WCMQ_Admin::render_pagination(
+		'queue',
+		$wcmq_total,
+		$wcmq_paged,
+		$wcmq_filter ? array( 'state' => $wcmq_filter ) : array()
+	);
+	?>
+</form>

+ 133 - 0
studiou-wc-mail-queue/views/tab-settings.php

@@ -0,0 +1,133 @@
+<?php
+/**
+ * Settings tab.
+ *
+ * @package studiou-wc-mail-queue
+ * @copyright 2026 QUADARAX
+ * @author    Dalibor Votruba <dvotruba@quadarax.com>
+ * @license   GPL-2.0-or-later
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+$wcmq_settings   = Studiou_WCMQ_Settings::all();
+$wcmq_contexts   = Studiou_WCMQ_Admin::available_contexts();
+$wcmq_handled    = Studiou_WCMQ_Settings::handled_contexts();
+$wcmq_is_on      = Studiou_WCMQ_State::is_queueing();
+$wcmq_action_url = admin_url( 'admin-post.php' );
+?>
+
+<form method="post" action="<?php echo esc_url( $wcmq_action_url ); ?>" class="studiou-wcmq-toggle-form">
+	<?php wp_nonce_field( Studiou_WCMQ_Admin::NONCE ); ?>
+	<input type="hidden" name="action" value="studiou_wcmq_toggle_state" />
+	<input type="hidden" name="wcmq_state" value="<?php echo $wcmq_is_on ? 'disabled' : 'enabled'; ?>" />
+	<p>
+		<button type="submit" class="button button-primary">
+			<?php
+			echo $wcmq_is_on
+				? esc_html__( 'Disable mail queue', 'studiou-wc-mail-queue' )
+				: esc_html__( 'Enable mail queue', 'studiou-wc-mail-queue' );
+			?>
+		</button>
+		<span class="description">
+			<?php esc_html_e( 'Disabling stops queueing new mail; anything already queued still sends at the configured rate.', 'studiou-wc-mail-queue' ); ?>
+		</span>
+	</p>
+</form>
+
+<hr />
+
+<form method="post" action="<?php echo esc_url( $wcmq_action_url ); ?>">
+	<?php wp_nonce_field( Studiou_WCMQ_Admin::NONCE ); ?>
+	<input type="hidden" name="action" value="studiou_wcmq_save_settings" />
+
+	<h2><?php esc_html_e( 'Rate limit', 'studiou-wc-mail-queue' ); ?></h2>
+	<table class="form-table" role="presentation">
+		<tr>
+			<th scope="row">
+				<label for="rate_per_minute"><?php esc_html_e( 'Mails per minute', 'studiou-wc-mail-queue' ); ?></label>
+			</th>
+			<td>
+				<input type="number" min="1" max="60" step="1" id="rate_per_minute" name="rate_per_minute"
+					value="<?php echo esc_attr( $wcmq_settings['rate_per_minute'] ); ?>" class="small-text" />
+				<p class="description">
+					<?php esc_html_e( 'Set this below your host\'s real outbound limit — ask their support for the exact number. A value above the real limit reproduces the original problem.', 'studiou-wc-mail-queue' ); ?>
+				</p>
+			</td>
+		</tr>
+	</table>
+
+	<h2><?php esc_html_e( 'Emails handled by the queue', 'studiou-wc-mail-queue' ); ?></h2>
+	<p class="description">
+		<?php esc_html_e( 'Anything not ticked here sends immediately, exactly as it does today. Never queue password resets.', 'studiou-wc-mail-queue' ); ?>
+	</p>
+	<fieldset class="studiou-wcmq-contexts">
+		<?php foreach ( $wcmq_contexts as $wcmq_id => $wcmq_label ) : ?>
+			<label>
+				<input type="checkbox" name="handled_contexts[]" value="<?php echo esc_attr( $wcmq_id ); ?>"
+					<?php checked( in_array( $wcmq_id, $wcmq_handled, true ) ); ?> />
+				<?php echo esc_html( $wcmq_label ); ?>
+				<code><?php echo esc_html( $wcmq_id ); ?></code>
+			</label>
+		<?php endforeach; ?>
+	</fieldset>
+
+	<h2><?php esc_html_e( 'Retention', 'studiou-wc-mail-queue' ); ?></h2>
+	<p class="description">
+		<?php esc_html_e( 'The queue stores rendered emails and copies of their attachments. Retention is a privacy control, not just housekeeping. 0 = keep forever.', 'studiou-wc-mail-queue' ); ?>
+	</p>
+	<table class="form-table" role="presentation">
+		<tr>
+			<th scope="row"><label for="retention_sent_days"><?php esc_html_e( 'Keep sent mail (days)', 'studiou-wc-mail-queue' ); ?></label></th>
+			<td><input type="number" min="0" step="1" id="retention_sent_days" name="retention_sent_days" value="<?php echo esc_attr( $wcmq_settings['retention_sent_days'] ); ?>" class="small-text" /></td>
+		</tr>
+		<tr>
+			<th scope="row"><label for="retention_failed_days"><?php esc_html_e( 'Keep failed mail (days)', 'studiou-wc-mail-queue' ); ?></label></th>
+			<td><input type="number" min="0" step="1" id="retention_failed_days" name="retention_failed_days" value="<?php echo esc_attr( $wcmq_settings['retention_failed_days'] ); ?>" class="small-text" /></td>
+		</tr>
+		<tr>
+			<th scope="row"><label for="retention_log_days"><?php esc_html_e( 'Keep log entries (days)', 'studiou-wc-mail-queue' ); ?></label></th>
+			<td><input type="number" min="0" step="1" id="retention_log_days" name="retention_log_days" value="<?php echo esc_attr( $wcmq_settings['retention_log_days'] ); ?>" class="small-text" /></td>
+		</tr>
+	</table>
+
+	<h2><?php esc_html_e( 'Reliability', 'studiou-wc-mail-queue' ); ?></h2>
+	<table class="form-table" role="presentation">
+		<tr>
+			<th scope="row"><label for="max_attempts"><?php esc_html_e( 'Max send attempts', 'studiou-wc-mail-queue' ); ?></label></th>
+			<td>
+				<input type="number" min="1" max="10" step="1" id="max_attempts" name="max_attempts" value="<?php echo esc_attr( $wcmq_settings['max_attempts'] ); ?>" class="small-text" />
+				<p class="description"><?php esc_html_e( 'Completed attempts where the mail server rejected the message.', 'studiou-wc-mail-queue' ); ?></p>
+			</td>
+		</tr>
+		<tr>
+			<th scope="row"><label for="max_reclaims"><?php esc_html_e( 'Max crash recoveries', 'studiou-wc-mail-queue' ); ?></label></th>
+			<td>
+				<input type="number" min="1" max="20" step="1" id="max_reclaims" name="max_reclaims" value="<?php echo esc_attr( $wcmq_settings['max_reclaims'] ); ?>" class="small-text" />
+				<p class="description"><?php esc_html_e( 'Times a mail may be rescued after the sending process died. Counted separately from send attempts, so a PHP timeout never burns a healthy mail\'s retry budget.', 'studiou-wc-mail-queue' ); ?></p>
+			</td>
+		</tr>
+		<tr>
+			<th scope="row"><label for="claim_timeout"><?php esc_html_e( 'Claim timeout (seconds)', 'studiou-wc-mail-queue' ); ?></label></th>
+			<td>
+				<input type="number" min="60" max="86400" step="1" id="claim_timeout" name="claim_timeout" value="<?php echo esc_attr( $wcmq_settings['claim_timeout'] ); ?>" class="small-text" />
+				<p class="description"><?php esc_html_e( 'How long before a mail stuck "sending" is assumed dead and retried. Keep it well above PHP\'s max_execution_time — too short and a slow send is retried while still in progress, delivering twice.', 'studiou-wc-mail-queue' ); ?></p>
+			</td>
+		</tr>
+	</table>
+
+	<h2><?php esc_html_e( 'Debug', 'studiou-wc-mail-queue' ); ?></h2>
+	<table class="form-table" role="presentation">
+		<tr>
+			<th scope="row"><?php esc_html_e( 'Verbose logging', 'studiou-wc-mail-queue' ); ?></th>
+			<td>
+				<label>
+					<input type="checkbox" name="debug" value="1" <?php checked( ! empty( $wcmq_settings['debug'] ) ); ?> />
+					<?php esc_html_e( 'Record debug-level entries in the log and the PHP error log.', 'studiou-wc-mail-queue' ); ?>
+				</label>
+			</td>
+		</tr>
+	</table>
+
+	<?php submit_button( __( 'Save settings', 'studiou-wc-mail-queue' ) ); ?>
+</form>