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

Initial commit, implement-plan-00.md add with documentations

Dalibor Votruba 4 дней назад
Родитель
Сommit
8dc45ea2f0

+ 194 - 0
studiou-wc-mail-queue/CLAUDE.md

@@ -0,0 +1,194 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Status: not yet implemented
+
+This directory currently contains **only a specification and design docs — no plugin code**. The first task here is to write the plugin from scratch.
+
+- [`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.
+
+### What the plugin must do
+
+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.
+
+Per `readme.md`, v1.0.0 ships as an **mu-plugin** with:
+
+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
+
+### Design decisions (locked — see the plan for rationale)
+
+**Interception: hybrid.** Short-circuit `wp_mail()` via the `pre_wp_mail` filter, but tag each call with the WooCommerce email's identity by probing the `woocommerce_mail_callback` filter (which passes the `WC_Email` instance). Mail whose context is not in the admin's handled-types list passes straight through. This gives both the spec's literal `wp_mail()` interception and its per-order-status selector.
+
+Do **not** key the context off `woocommerce_order_status_*_notification` hooks — one hook can fire several emails (`pending_to_processing` triggers both the customer's processing email and the admin's new-order email), so the hook name can't distinguish them.
+
+**Queue: custom table + Action Scheduler.** `{$wpdb->prefix}studiou_wcmq_queue` owns payload/state/attempts; Action Scheduler owns only the staggered wake-ups, one action per row. The admin queue view, log view, and retention sweep need a table we control — and AS prunes its own completed actions after 30 days.
+
+**Versions.** WP 6.8+ (tested 6.9.4), PHP 8.2+, `WC requires at least: 9.8`, `WC tested up to: 10.9.4`. The readme's "WooCommerce v.1.26.4" is a typo.
+
+### WooCommerce source is vendored
+
+`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.
+
+[`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.
+
+`WC_Email` lives at `includes/emails/class-wc-email.php`, **not** `includes/class-wc-email.php`.
+
+### Sharp edges
+
+Things that will silently produce a plausible-looking but wrong implementation. All are detailed in the plan:
+
+- **Timestamp columns must be signed `BIGINT`, never `BIGINT UNSIGNED`.** The slot allocator compares `scheduled_at` on both sides of a candidate; unsigned subtraction underflows into MySQL error 1690 the first time an occupied row precedes the candidate — i.e. mail #2 of every bulk. Enqueue then fails, the fall-through sends synchronously, and the plugin reproduces the exact burst it exists to prevent. Every empty-table test passes.
+- **Never return `true` from `pre_wp_mail` before the row *and* its Action Scheduler action are durably persisted.** A failed enqueue reported as a successful send is silent, permanent mail loss. Note `json_encode()` returns `false` without throwing on invalid UTF-8 — an exception-only guard misses it.
+- **Every `pending` row must have a scheduled Action Scheduler action.** That invariant, not rollback, is what prevents mail loss — rollback does not run after a PHP fatal. The reaper must repair both stranded `sending` rows *and* `pending` rows with no action, must re-slot them into a **future** slot rather than firing them all at once, and must be able to run when no `Worker::send()` can (give it its own recurring action).
+- **A monotonic slot ladder cannot survive retries.** One row backed off an hour drags every subsequent enqueue behind it. Allocate the *first free slot at or after a floor* instead. The interval must `ceil(60/rate)`, never `floor`. And every re-slot — enqueue, retry, reaper — takes the same lock, because the `UPDATE`/`INSERT` is what publishes the allocation. **The lock helper must return the slot**: `as_schedule_single_action(null, …)` coerces to timestamp `0`, which Action Scheduler runs *immediately*.
+- **Cast row ids to `int` before any Action Scheduler call.** `$wpdb` returns numeric strings; AS matches args by hashing `json_encode()`, so `["123"]` and `[123]` are different actions.
+- **Retention settings are days; timestamp columns are seconds.** Multiply by `DAY_IN_SECONDS` or a 7-day retention deletes sent mail after 7 seconds — silently, while reporting success.
+- **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`.
+- **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`.
+- **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.
+- **`woocommerce_email_sent` fires at enqueue time, not send time**, because we return `true` from `pre_wp_mail`. This is not hypothetical: WooCommerce's own `EmailLogger` (10.9) listens on it and **writes an order note** saying the mail was sent. Suppress it with `woocommerce_email_log_add_order_note` / `woocommerce_email_log_enabled` and write our own note when the mail actually leaves.
+- **`$email->id` is mutated at runtime** by `WC_Email_Customer_Refunded_Order`, which swaps between `customer_refunded_order` and `customer_partially_refunded_order`. Read the id at `woocommerce_mail_callback` time; never cache it per instance.
+
+### WooCommerce has its own deferred email queue — it is not a fix
+
+WC 10.8+ ships `DeferredEmailQueue` behind the **"Deferred emails"** feature toggle (Settings → Advanced → Features; off by default). It dispatches one Action Scheduler action per email via `WC()->queue()->add()` — an **async action with no timestamp**, so Action Scheduler runs them in batches as fast as it can. It decouples sending from the request but applies **no rate cap**. That is variant A from the analysis, and it does not solve the hosting rate-limit problem.
+
+It does not conflict with this plugin — it just chains two queues in series. Require it OFF, and warn from `admin_init` if it is on.
+
+### Emails that can never be queued
+
+`WC_Emails::low_stock()`, `no_stock()`, and `backorder()` call `wp_mail()` directly, bypassing `WC_Email::send()` (`includes/class-wc-emails.php:1041`, `:1128`, `:1214`). They carry no context and always pass through — correct behaviour for admin stock alerts, and they never appear in the settings selector because they aren't `WC_Email` subclasses.
+
+Build the handled-types selector from `WC()->mailer()->get_emails()`, never a hardcoded list: WC 10.9 has 21 `WC_Email` subclasses, including fulfillment, POS, and review-request emails. `customer_reset_password` is among them — selectable, but never default it on.
+
+**`get_emails()` is necessary but not sufficient.** `WC_Email_Customer_Refunded_Order::trigger()` swaps its own id to `customer_partially_refunded_order` on a partial refund, but the class that reports that id is registered only when the alpha `block_email_editor` feature is on (`class-wc-emails.php:326-328`). On a stock shop the selector can never offer it, so partial-refund emails silently bypass the queue no matter what the admin ticks. Union the dynamic list with a small `RUNTIME_ALIAS_IDS` table, and re-audit it on WooCommerce major upgrades.
+
+### 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.
+
+`interval = 12s` ⇒ 5 mails/min. A single shared slot counter across all handled email types keeps the rate limit **global**, not per-type.
+
+Notes carried over from the analysis, worth preserving:
+
+- Action Scheduler is provided **by WooCommerce**. Keep a fallback that sends immediately if `as_schedule_single_action()` is missing.
+- Duplicate sends happen if the default trigger isn't reliably detached. That detach is the correctness-critical line.
+- Store the slot counter with `update_option(..., false)` — autoload off.
+- Schedule Action Scheduler args as an **indexed** array (`array($order_id)`), not associative — associative args break on newer PHP.
+- Reliable pacing needs `DISABLE_WP_CRON` plus a system cron running `wp action-scheduler run` every minute. Without it the queue drains in bursts.
+
+## mu-plugin constraints (differ from every sibling plugin)
+
+The six sibling plugins in this repo are all regular plugins. An mu-plugin behaves differently in ways that break the conventions below if applied blindly:
+
+- **Subdirectories are not auto-loaded.** WordPress globs only `wp-content/mu-plugins/*.php` — top level, no recursion. A multi-file plugin needs a one-line loader stub dropped directly in `mu-plugins/` that `require`s the real entry point. Plan the install instructions around this.
+- **No activation/deactivation hooks.** `register_activation_hook()` never fires. Custom DB tables must be created on load. Use the `maybe_upgrade_db()` pattern from `studiou-wc-free-photo-product` — compare a stored `*_db_version` option against the version constant and run `dbDelta` on mismatch. It is idempotent.
+- **`Requires Plugins:` header is ignored.** Guard on `class_exists('WooCommerce')` at runtime instead.
+- **mu-plugins load before regular plugins.** WooCommerce (and therefore Action Scheduler) does not exist at mu-plugin load time. Register hooks on `init` or later, as the sample does. Do not call `as_*()` functions at file scope.
+
+## Repository layout
+
+This is a monorepo of independent WordPress/WooCommerce plugins for the studiou.cz shop, one directory each, sharing no code. There is **no build system, no dependency manager, no test framework, and no linter** — plugins are plain PHP loaded by WordPress and verified by hand in wp-admin.
+
+Siblings worth reading for precedent:
+
+- `studiou-wc-ord-print-statuses` — the closest architectural model. Manager-based structure, `UtilsLog` utility, HPOS handling, custom order statuses. Its `CLAUDE.md` is the most thorough in the repo.
+- `studiou-wc-free-photo-product` — custom DB table with automatic `dbDelta` schema upgrades; chunked AJAX; the `maybe_upgrade_db()` pattern.
+- `studiou-wc-product-cat-manage` — admin settings pages, tabbed UI loaded on demand via AJAX, batch processing, the cache-busted asset helper.
+
+### Git conventions
+
+Work happens on a branch named `<version>.<plugin-slug>` — this plugin's branch is `1.0.0.studiou-wc-mail-queue`. Tags follow `<version>.<plugin-slug>` (newer) or `<plugin-slug>.v.<version>` / `<plugin-slug>_v<version>` (older, inconsistent). Merges go to `master`.
+
+## Conventions shared across the plugins
+
+Match these unless the mu-plugin constraints above force otherwise.
+
+### Naming
+
+Directory name = plugin slug = text domain = main-file basename. So `studiou-wc-mail-queue/studiou-wc-mail-queue.php`, text domain `studiou-wc-mail-queue`. Classes live in `includes/class-*.php`, admin templates in `views/`, assets in `assets/css/` and `assets/js/`, translations in `languages/`.
+
+Pick a short constant/prefix abbreviation and use it everywhere — constants `STUDIOU_WCMQ_*`, CSS classes `studiou-wcmq-`, JS namespace `studiouWcmq`, nonce `studiou-wcmq-nonce`, error-log prefix `STUDIOU WC MAIL:`. Siblings use `STUDIOU_WCPCM_`, `STUDIOU_WCFPP_`, `STUDIOU_WC_OPS_`.
+
+### Bootstrap
+
+The main file, in order: plugin docblock header → `defined('ABSPATH') || exit;` (or `if (!defined('WPINC')) { die; }` — both are in use) → version and path constants → HPOS compatibility declaration → main class → hook registration.
+
+Declare HPOS compatibility from a **named** function (not a closure) so it can be `remove_action`-ed:
+
+```php
+function studiou_wc_mail_queue_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_wc_mail_queue_declare_hpos_compat');
+```
+
+Load the text domain on `plugins_loaded` **priority 5**, boot the plugin at default priority 10. This guarantees translations are available before any class instantiates.
+
+### Manager pattern
+
+The main class does three things: bail with an `admin_notices` warning if `class_exists('WooCommerce')` is false, `require_once` each `includes/class-*.php`, and instantiate one manager object per concern. **Managers register their own hooks in their constructors.** Nothing else lives in the main class.
+
+### Logging and admin notices
+
+Copy `includes/utils-log.php` from `studiou-wc-ord-print-statuses`. `UtilsLog::log()` writes to the error log gated on `WP_DEBUG`; `UtilsLog::message($msg, $type)` queues a dismissible admin notice in a per-user transient (300s TTL) so it survives the redirect after a form post. Note this plugin's spec calls for a *debug toggle in settings* rather than relying on `WP_DEBUG` — gate `log()` on the option instead.
+
+### AJAX handlers
+
+Every handler: verify nonce, check capability (`manage_woocommerce`), clean the output buffer before responding, wrap in try/catch, and reply through `wp_send_json_success()` / `wp_send_json_error()`.
+
+### Input and output
+
+Sanitize on the way in (`absint()`, `sanitize_text_field()`, `sanitize_key()`), escape on the way out (`esc_html()`, `esc_attr()`, `esc_url()`). Use `$wpdb->prepare()` for all SQL. Wrap every user-facing string in `__()` / `esc_html_e()` with the text domain.
+
+### Long-running operations
+
+Batch them (5–10 items per AJAX round-trip) with a progress bar, rather than risking `max_execution_time`. Destructive actions get a confirmation dialog.
+
+## Release process
+
+On every release, bump the version in all of these and keep them identical:
+
+1. `studiou-wc-mail-queue.php` — the `Version:` header **and** the `STUDIOU_WCMQ_VERSION` constant
+2. `CLAUDE.md` — the current-version line
+3. `readme.md` — the version line plus a new changelog entry
+4. `assets/js/*.js` — the `console.log` version banner, if one exists
+5. `languages/*.po` — the `Project-Id-Version` header
+
+**Bump the version whenever JS or CSS changes**, even for a one-line edit — it is the cache-busting mechanism.
+
+Caveat specific to this shop: **studiou.cz strips the `?ver=` query string**, so a version bump alone does not bust the browser cache there. Either tell the user to hard-refresh, or adopt the version-stamped-filename approach from `studiou-wc-product-cat-manage` (`get_cache_busted_asset_url()` copies `admin.js` to `admin-X.Y.Z.js` under `uploads/`, refreshes on source change, and cleans up stale copies; the copies are gitignored build artifacts — always edit the source file).
+
+## Translations
+
+Czech (`cs_CZ`) and English. Edit the `.po` files, then compile:
+
+```
+wp i18n make-mo languages/
+```
+
+`studiou-wc-free-photo-product/languages/generate-mo.php` is a dependency-free PHP fallback (`php generate-mo.php`) if WP-CLI is unavailable. Poedit or `msgfmt` also work. The `.mo` must be regenerated by hand — nothing does it automatically.
+
+## Verification
+
+There are no automated tests. Verify in a WordPress install:
+
+1. Enable `WP_DEBUG` and `WP_DEBUG_LOG`; watch `wp-content/debug.log`.
+2. Inspect the queue at **WooCommerce → Status → Scheduled Actions**, filtered to the plugin's action group — check pending/complete/failed and the spacing between scheduled times.
+3. On staging, bulk-change 100+ orders to Completed. Confirm every mail eventually sends, at the configured rate, with no duplicates and no request timeout.
+4. Hook `wp_mail_failed` to catch send errors; cross-check against the host's MTA log.

+ 439 - 0
studiou-wc-mail-queue/docs/Wiki/woocommerce-emailing.md

@@ -0,0 +1,439 @@
+# WooCommerce reference — emailing, order statuses, hooks
+
+Extracted from the vendored source at `docs/Wiki/woocommerce/`, **WooCommerce 10.9.4**.
+
+Every claim below was read from that tree. `file:line` references are relative to `docs/Wiki/woocommerce/`. Re-verify after a WooCommerce major upgrade — several things here are new in 10.8/10.9 and behave differently from what older tutorials and the project's own analysis document describe.
+
+Two path traps worth internalising up front:
+
+- `WC_Email` is at **`includes/emails/class-wc-email.php`**, not `includes/class-wc-email.php`.
+- `WC_Emails` (plural, the registry) is at **`includes/class-wc-emails.php`**.
+
+---
+
+# Part 1 — Emailing
+
+## 1.1 The full send pipeline
+
+```
+WC_Order::update_status('completed')                  includes/class-wc-order.php:401
+  └─ set_status()                                     :318   → records $this->status_transition
+       └─ save() → status_transition()                :432
+            └─ do_action('woocommerce_order_status_completed', $id, $order, $transition)
+                 │
+                 │  WC_Emails::init_transactional_emails() hooked one of these two:
+                 ├─ WC_Emails::send_transactional_email()          (default)
+                 └─ WC_Emails::queue_transactional_email()         ("Deferred emails" feature on)
+                      └─ DeferredEmailQueue::push() → shutdown → Action Scheduler
+                           └─ WC_Emails::send_queued_transactional_email()
+                 │
+                 └─ do_action_ref_array('woocommerce_order_status_completed_notification', $args)
+                      └─ WC_Email_Customer_Completed_Order::trigger($order_id, $order)
+                           ├─ setup_locale()
+                           ├─ $this->object / recipient / placeholders
+                           └─ send_notification()                  class-wc-email.php:1140
+                                ├─ !is_enabled()   → do_action('woocommerce_email_disabled')  → bail
+                                ├─ !get_recipient()→ do_action('woocommerce_email_skipped')   → bail
+                                └─ send( $to, get_subject(), get_content(), get_headers(), get_attachments() )
+                                     │                                       class-wc-email.php:1228
+                                     ├─ add_filter('wp_mail_from',         [$this,'get_from_address'])
+                                     ├─ add_filter('wp_mail_from_name',    [$this,'get_from_name'])
+                                     ├─ add_filter('wp_mail_content_type', [$this,'get_content_type'])
+                                     ├─ apply_filters('woocommerce_mail_content', style_inline($message))
+                                     ├─ apply_filters('woocommerce_mail_callback', 'wp_mail', $this)   :1234
+                                     ├─ apply_filters('woocommerce_mail_callback_params', [...], $this) :1235
+                                     ├─ $return = (bool) $mail_callback( ...$params )   ← wp_mail()
+                                     ├─ remove_filter('wp_mail_from', …)   ← detached immediately
+                                     └─ do_action('woocommerce_email_sent', $return, $this->id, $this)  :1253
+                                          └─ EmailLogger::handle_woocommerce_email_sent()
+```
+
+### Consequences that bite
+
+**The `wp_mail_from` / `wp_mail_from_name` / `wp_mail_content_type` filters exist only for the duration of the `wp_mail()` call.** Anything that defers the actual send past `send()` returning loses the shop's configured sender and falls back to `wordpress@{sitename}`. Resolve them while still inside `wp_mail()`.
+
+**`get_from_address( $from_email = '' )`** (`class-wc-email.php:1055`) ignores its argument entirely:
+
+```php
+public function get_from_address( $from_email = '' ) {
+    $from_email = apply_filters( 'woocommerce_email_from_address', get_option( 'woocommerce_email_from_address' ), $this, $from_email );
+    return sanitize_email( $from_email );
+}
+```
+
+So `apply_filters('wp_mail_from', 'anything')` while WooCommerce's filter is attached returns the shop's real sender.
+
+**`$headers` is a string, not an array.** `get_headers()` (`class-wc-email.php:684-719`) returns `\r\n`-delimited text containing `Content-Type`, `Reply-to`, and — only when the `email_improvements` feature is on — `Cc:` / `Bcc:`. It **never** contains a `From:` line.
+
+**The subject reaching `wp_mail()` has already been through `wp_specialchars_decode()`** (`:1235`).
+
+**`$this->id` is mutated at runtime by the refund email.** `WC_Email_Customer_Refunded_Order::set_email_strings()` (`class-wc-email-customer-refunded-order.php:195`) swaps its own `id` between `customer_refunded_order` and `customer_partially_refunded_order` depending on `$this->partial_refund`. Read `$email->id` at `woocommerce_mail_callback` time; never cache it per instance.
+
+## 1.2 Bootstrap — `WC_Emails::init_transactional_emails()`
+
+`includes/class-wc-emails.php:99-147`. Builds the list of "parent" hooks, then attaches one dispatcher to each:
+
+```php
+$email_actions = apply_filters( 'woocommerce_email_actions', array(
+    'woocommerce_low_stock',
+    'woocommerce_no_stock',
+    'woocommerce_product_on_backorder',
+    'woocommerce_order_status_pending_to_processing',
+    'woocommerce_order_status_pending_to_completed',
+    'woocommerce_order_status_processing_to_cancelled',
+    'woocommerce_order_status_pending_to_failed',
+    'woocommerce_order_status_pending_to_on-hold',
+    'woocommerce_order_status_failed_to_processing',
+    'woocommerce_order_status_failed_to_completed',
+    'woocommerce_order_status_failed_to_on-hold',
+    'woocommerce_order_status_cancelled_to_processing',
+    'woocommerce_order_status_cancelled_to_completed',
+    'woocommerce_order_status_cancelled_to_on-hold',
+    'woocommerce_order_status_on-hold_to_processing',
+    'woocommerce_order_status_on-hold_to_cancelled',
+    'woocommerce_order_status_on-hold_to_failed',
+    'woocommerce_order_status_completed',
+    'woocommerce_order_status_failed',
+    'woocommerce_order_fully_refunded',
+    'woocommerce_order_partially_refunded',
+    'woocommerce_send_review_request',
+    'woocommerce_new_customer_note',
+    'woocommerce_created_customer',
+    'woocommerce_payment_gateway_enabled',
+) );
+
+$defer_default = FeaturesUtil::feature_is_enabled( 'deferred_transactional_emails' );
+
+if ( apply_filters( 'woocommerce_defer_transactional_emails', $defer_default ) ) {
+    self::$deferred_queue = wc_get_container()->get( DeferredEmailQueue::class );
+    foreach ( $email_actions as $action ) {
+        add_action( $action, array( __CLASS__, 'queue_transactional_email' ), 10, 10 );
+    }
+} else {
+    foreach ( $email_actions as $action ) {
+        add_action( $action, array( __CLASS__, 'send_transactional_email' ), 10, 10 );
+    }
+}
+```
+
+Both dispatchers ultimately fire `do_action_ref_array( $filter . '_notification', $args )` — the `_notification` suffix is where the individual `WC_Email` classes attach their `trigger()`.
+
+So there are **two hook layers**, and they are not interchangeable:
+
+- `woocommerce_order_status_completed` — the "parent" hook. `WC_Emails` listens here.
+- `woocommerce_order_status_completed_notification` — the derived hook. `WC_Email_*::trigger()` listens here.
+
+`WC_Emails::init()` also wires the non-class emails directly (`:259-262`):
+
+```php
+add_action( 'woocommerce_low_stock_notification',          array( $this, 'low_stock' ) );
+add_action( 'woocommerce_no_stock_notification',           array( $this, 'no_stock' ) );
+add_action( 'woocommerce_product_on_backorder_notification', array( $this, 'backorder' ) );
+add_action( 'woocommerce_created_customer_notification',   array( $this, 'customer_new_account' ), 10, 3 );
+```
+
+And at the end of `init()`, `:273`:
+
+```php
+do_action( 'woocommerce_email', $this );   // $this = WC_Emails instance, ->emails is the class map
+```
+
+This is the canonical place to `remove_action()` a core email's `trigger` — the instances exist and their hooks are registered. Hook it at a late priority (99).
+
+## 1.3 The 21 `WC_Email` classes
+
+Everything in `includes/emails/`. `WC()->mailer()->get_emails()` (`class-wc-emails.php:344`) returns them keyed by class name; the registry itself is filterable via `woocommerce_email_classes` (`:336`).
+
+Format below: `id` — class — trigger hook(s).
+
+**Customer-facing, order status driven**
+
+- `customer_processing_order` — `WC_Email_Customer_Processing_Order` — `woocommerce_order_status_{pending,failed,cancelled,on-hold}_to_processing_notification`
+- `customer_completed_order` — `WC_Email_Customer_Completed_Order` — `woocommerce_order_status_completed_notification`
+- `customer_on_hold_order` — `WC_Email_Customer_On_Hold_Order` — `woocommerce_order_status_{pending,failed,cancelled}_to_on-hold_notification`
+- `customer_cancelled_order` — `WC_Email_Customer_Cancelled_Order` — `woocommerce_order_status_{processing,on-hold}_to_cancelled_notification`
+- `customer_failed_order` — `WC_Email_Customer_Failed_Order` — `woocommerce_order_status_failed_notification`
+- `customer_refunded_order` — `WC_Email_Customer_Refunded_Order` — `woocommerce_order_fully_refunded_notification` (`trigger_full`) and `woocommerce_order_partially_refunded_notification` (`trigger_partial`)
+- `customer_partially_refunded_order` — `WC_Email_Customer_Partially_Refunded_Order` — a thin subclass of the above that sets `partial_refund = true` and `remove_action`s the inherited `trigger_partial`. It exists so the partial variant gets its own settings row; the parent still does the sending and swaps `$this->id` at runtime.
+
+**Customer-facing, other**
+
+- `customer_invoice` — `WC_Email_Customer_Invoice` — **manual only.** `WC()->mailer()->customer_invoice($order)` from the order-edit *Order actions* box (`includes/admin/meta-boxes/class-wc-meta-box-order-actions.php:146`) and from REST (`src/Internal/Orders/OrderActionsRestController.php:669`).
+- `customer_new_account` — `WC_Email_Customer_New_Account` — `woocommerce_created_customer_notification`, routed through `WC_Emails::customer_new_account()` (`class-wc-emails.php:262`, `:519`)
+- `customer_note` — `WC_Email_Customer_Note` — `woocommerce_new_customer_note_notification`
+- `customer_reset_password` — `WC_Email_Customer_Reset_Password` — `woocommerce_reset_password_notification`
+- `customer_review_request` — `WC_Email_Customer_Review_Request` — `woocommerce_send_review_request_notification` (behind the `customer_review_request` feature)
+
+**Customer-facing, fulfillment (new)**
+
+- `customer_fulfillment_created` — `woocommerce_fulfillment_created_notification`
+- `customer_fulfillment_updated` — `woocommerce_fulfillment_updated_notification`
+- `customer_fulfillment_deleted` — `woocommerce_fulfillment_deleted_notification`
+
+**Customer-facing, POS (new)**
+
+- `customer_pos_completed_order` — `woocommerce_order_status_completed_notification` via `auto_trigger()`, plus `woocommerce_rest_order_actions_email_send`
+- `customer_pos_refunded_order` — `woocommerce_order_{fully,partially}_refunded_notification` via `auto_trigger()`, plus `woocommerce_rest_order_actions_email_send`
+
+**Admin-facing**
+
+- `new_order` — `WC_Email_New_Order` — nine hooks: `woocommerce_order_status_{pending,failed,cancelled}_to_{processing,completed,on-hold}_notification`
+- `cancelled_order` — `WC_Email_Cancelled_Order` — `woocommerce_order_status_{processing,on-hold}_to_cancelled_notification`
+- `failed_order` — `WC_Email_Failed_Order` — `woocommerce_order_status_{pending,on-hold}_to_failed_notification`
+- `admin_payment_gateway_enabled` — `woocommerce_payment_gateway_enabled_notification`
+
+> **A single `_notification` hook can fire more than one email.** `woocommerce_order_status_pending_to_processing_notification` triggers **both** `customer_processing_order` and the admin `new_order`. The hook name therefore cannot identify *which* email is being sent. If you need per-email granularity, read `$email->id` from `woocommerce_mail_callback`.
+
+## 1.4 Emails that never touch `WC_Email::send()`
+
+These call `wp_mail()` directly. No `woocommerce_mail_callback`, no `woocommerce_email_sent`, no `WC_Email` instance:
+
+- `WC_Emails::low_stock()` — `class-wc-emails.php:1007`, `wp_mail()` at `:1041`
+- `WC_Emails::no_stock()` — `:1098`, `wp_mail()` at `:1128`
+- `WC_Emails::backorder()` — `:1185`, `wp_mail()` at `:1214`
+- Email-editor preview send — `packages/email-editor/src/Engine/class-send-preview-email.php:173`
+
+The three stock alerts are admin notifications, filterable through `woocommerce_email_recipient_{low_stock,no_stock,backorder}`, `..._subject_...`, `..._content_...`, and gated by `woocommerce_should_send_{low,no}_stock_notification`. They are not `WC_Email` subclasses, so they never appear in `get_emails()` or the Emails settings screen.
+
+## 1.5 `DeferredEmailQueue` — new in 10.8.0
+
+`src/Internal/Email/DeferredEmailQueue.php`. Gated on the **"Deferred emails"** feature toggle (WooCommerce → Settings → Advanced → Features), declared at `src/Internal/Features/FeaturesController.php:432` with `'enabled_by_default' => false`.
+
+How it works: `push()` collects `(filter, args)` during the request, registers a `shutdown` hook at priority 100, and `dispatch()` schedules one Action Scheduler action per email:
+
+```php
+\WC()->queue()->add( 'woocommerce_send_queued_transactional_email', array( $filter, $args ), 'woocommerce-emails' );
+```
+
+- AS hook: `woocommerce_send_queued_transactional_email`
+- AS group: `woocommerce-emails`
+- Object args (`WC_Order`, `WC_Product`, `WC_Payment_Gateway`, `StockNotification`) are collapsed to `{type, id}` references and re-fetched on the worker side. `push()` returns `false` for anything it cannot represent, and the caller falls back to sending synchronously.
+- `init()` registers the AS handler **unconditionally**, so already-scheduled jobs still drain after the feature is switched off. Disabling mid-flight is safe.
+
+**What it does not do: rate limiting.** `WC()->queue()->add()` is an *async* action with no timestamp — Action Scheduler runs it as soon as it can, in batches (default ~25 per run). It decouples sending from the request and nothing more. This is exactly "variant A" from `docs/analyza-woocommerce-email-throttling.md`, now a first-class core feature. It does **not** solve a host-side outbound rate limit, and it must not be mistaken for a fix.
+
+It supersedes the legacy `WC_Background_Emailer` (`includes/class-wc-background-emailer.php`), which still ships.
+
+## 1.6 `EmailLogger` — new in 10.9.0
+
+`src/Internal/Email/EmailLogger.php`. Registers (`:45-49`):
+
+```php
+add_action( 'wp_mail_failed',           array( $this, 'capture_mail_error' ), 10, 1 );
+add_action( 'woocommerce_email_sent',   array( $this, 'handle_woocommerce_email_sent' ), 10, 3 );
+add_action( 'woocommerce_email_disabled', array( $this, 'handle_woocommerce_email_disabled' ), 10, 2 );
+add_action( 'woocommerce_email_skipped',  array( $this, 'handle_woocommerce_email_skipped' ), 10, 3 );
+```
+
+It writes to the WooCommerce logger under source `transactional-emails`, and — via `maybe_add_order_note()` (`:152`) — **adds an order note** recording each send attempt against the order.
+
+Two escape hatches, both new in 10.9.0:
+
+- `woocommerce_email_log_enabled` — `apply_filters(..., true, $email_id, $email)`. Return `false` to skip logging entirely.
+- `woocommerce_email_log_add_order_note` — `apply_filters(..., true, $email_id, $email, $order)`. Return `false` to suppress just the order note, keeping the log entry.
+
+> **Relevant to any plugin that short-circuits `pre_wp_mail`.** `woocommerce_email_sent` fires immediately after the mail callback returns, using that return value as `$success`. Short-circuit `wp_mail()` and return `true`, and `EmailLogger` records "sent" — and writes the order note — at *enqueue* time, not at actual send time. Suppress it with the two filters above and write your own note when the mail really leaves.
+
+## 1.7 Relevant options
+
+`woocommerce_email_from_address`, `woocommerce_email_from_name`, `woocommerce_email_reply_to_enabled`, `woocommerce_email_reply_to_address`, `woocommerce_email_reply_to_name`.
+
+Per-email settings live under `woocommerce_{$email_id}_settings`, read through `WC_Settings_API`. The `enabled` key is surfaced by `is_enabled()` (`class-wc-email.php:826`) and is filterable per email via `woocommerce_email_enabled_{$id}`.
+
+---
+
+# Part 2 — Order statuses
+
+## 2.1 Two enums, two spellings
+
+WooCommerce 10.x replaced the bare strings with enums. They differ only by the `wc-` prefix, and mixing them up is the classic bug.
+
+`src/Enums/OrderStatus.php` — **unprefixed**, what `$order->get_status()` returns and what `update_status()` accepts:
+
+```
+pending  processing  on-hold  completed  cancelled  refunded  failed
+trash  new  auto-draft  draft  checkout-draft
+```
+
+`src/Enums/OrderInternalStatus.php` — **`wc-` prefixed**, the registered post-status / DB value:
+
+```
+wc-pending  wc-processing  wc-on-hold  wc-completed  wc-cancelled  wc-refunded  wc-failed
+```
+
+`OrderStatus::PAYMENT_COMPLETE_STATUSES` is also defined (`:103`).
+
+## 2.2 Registration
+
+`WC_Post_Types::register_post_status()` — `includes/class-wc-post-types.php:594`, hooked to `init` priority **9**:
+
+```php
+$order_statuses = apply_filters( 'woocommerce_register_shop_order_post_statuses', array( /* the seven */ ) );
+foreach ( $order_statuses as $order_status => $values ) {
+    register_post_status( $order_status, $values );
+}
+```
+
+Registering a **custom** order status means two steps, and both are required:
+
+1. `register_post_status( 'wc-my-status', [...] )` on `init` — makes it a real post status. (Or filter `woocommerce_register_shop_order_post_statuses`.)
+2. `add_filter( 'wc_order_statuses', … )` — makes WooCommerce's admin dropdowns and reports aware of it.
+
+`wc_get_order_statuses()` (`includes/wc-order-functions.php:104`):
+
+```php
+function wc_get_order_statuses() {
+    $order_statuses = array(
+        OrderInternalStatus::PENDING    => _x( 'Pending payment', 'Order status', 'woocommerce' ),
+        OrderInternalStatus::PROCESSING => _x( 'Processing', 'Order status', 'woocommerce' ),
+        OrderInternalStatus::ON_HOLD    => _x( 'On hold', 'Order status', 'woocommerce' ),
+        OrderInternalStatus::COMPLETED  => _x( 'Completed', 'Order status', 'woocommerce' ),
+        OrderInternalStatus::CANCELLED  => _x( 'Cancelled', 'Order status', 'woocommerce' ),
+        OrderInternalStatus::REFUNDED   => _x( 'Refunded', 'Order status', 'woocommerce' ),
+        OrderInternalStatus::FAILED     => _x( 'Failed', 'Order status', 'woocommerce' ),
+    );
+    return apply_filters( 'wc_order_statuses', $order_statuses );
+}
+```
+
+Note the keys here are **prefixed** (`wc-completed`), while the transition hooks below use the **unprefixed** form (`completed`).
+
+## 2.3 Transition mechanics
+
+`WC_Order::update_status( $new_status, $note = '', $manual = false )` — `includes/class-wc-order.php:401`. Bails if the order has no ID. Calls `set_status()` (`:318`), which records a pending transition on the object, then `save()`, which calls `status_transition()` (`:432`).
+
+`status_transition()` fires, in order:
+
+```php
+do_action( 'woocommerce_order_status_' . $to, $order_id, $order, $status_transition );
+
+// ... adds the "Order status changed from X to Y." note, unless $note === false
+// ... skipped entirely when $from is draft / auto-draft / new / checkout-draft
+
+if ( ! empty( $status_transition['from'] ) ) {
+    do_action( 'woocommerce_order_status_' . $from . '_to_' . $to, $order_id, $order );
+    do_action( 'woocommerce_order_status_changed', $order_id, $from, $to, $order );
+}
+```
+
+So for a `processing → completed` transition you get, in sequence:
+
+1. `woocommerce_order_status_completed` — `( $order_id, $order, $status_transition )`
+2. `woocommerce_order_status_processing_to_completed` — `( $order_id, $order )`
+3. `woocommerce_order_status_changed` — `( $order_id, $from, $to, $order )`
+
+`$status_transition` is `array( 'from' => …, 'to' => …, 'note' => string|false, 'manual' => bool )`. Note the third argument is only passed to the first hook.
+
+An order created directly in a status (no `from`) fires **only** hook 1. Code that relies solely on `woocommerce_order_status_changed` will miss those.
+
+The whole body is wrapped in `try/catch` — an exception thrown by your hook is swallowed and logged, not propagated.
+
+## 2.4 Status helpers
+
+`includes/wc-order-functions.php`:
+
+- `wc_is_order_status( $maybe_status )` — `:123`, accepts the `wc-` prefixed form
+- `wc_get_is_paid_statuses()` — `:134`, defaults to `[processing, completed]`, filter `woocommerce_order_is_paid_statuses`
+- `wc_get_is_pending_statuses()` — `:151`, defaults to `[pending]`, filter `woocommerce_order_is_pending_statuses`
+- `wc_get_order_status_name( $status )` — `:169`, tolerates either spelling
+
+---
+
+# Part 3 — Hook reference
+
+## 3.1 Email hooks — `WC_Email` (`includes/emails/class-wc-email.php`)
+
+**Sending**
+
+- `woocommerce_mail_callback` — `(callable 'wp_mail', WC_Email $email)` — `:1234`. The only place the `WC_Email` instance is exposed immediately before `wp_mail()`. Return a different callable to replace the mailer entirely (which bypasses `wp_mail()` and everything hooked to it).
+- `woocommerce_mail_callback_params` — `(array [$to,$subject,$message,$headers,$attachments], WC_Email $email)` — `:1235`
+- `woocommerce_mail_content` — `(string $message)` — `:1233`, after inline styling
+- `woocommerce_mail_style_inline_callback` — `:900`
+- `woocommerce_email_sent` — action, `(bool $return, string $id, WC_Email $email)` — `:1253`
+- `woocommerce_email_disabled` — action, `(string $id, WC_Email $email)` — `:1150` *(10.9.0)*
+- `woocommerce_email_skipped` — action, `(string $reason, string $id, WC_Email $email)` — `:1169` *(10.9.0)*. Only reason today is `WC_Email::SKIP_REASON_NO_RECIPIENT` (`:40`).
+
+**Composition**
+
+- `woocommerce_email_headers` — `(string $header, string $id, $object, WC_Email $email)` — `:718`
+- `woocommerce_email_attachments` — `(array $attachments, string $id, $object, WC_Email $email)` — `:727`. Empty in core; invoice/packing-slip plugins push **file paths** here.
+- `woocommerce_email_content_type` — `:788`
+- `woocommerce_email_styles` — `:937`
+- `woocommerce_emogrifier` — action, `:953`
+- `woocommerce_email_subject_{$id}` — `:566`
+- `woocommerce_email_heading_{$id}` — `:613`
+- `woocommerce_email_preheader` — `:591`
+- `woocommerce_email_additional_content_{$id}` — `:548`
+- `woocommerce_email_format_string`, `..._find`, `..._replace` — `:402`, `:410` (placeholder substitution)
+
+**Addressing**
+
+- `woocommerce_email_from_address` — `(string $address, WC_Email $email, string $from_email)` — `:1056`
+- `woocommerce_email_from_name` — `:1045`
+- `woocommerce_email_recipient_{$id}` — `:631`
+- `woocommerce_email_cc_recipient_{$id}` — `:651`
+- `woocommerce_email_bcc_recipient_{$id}` — `:672`
+- `woocommerce_email_reply_to_enabled` / `..._address` / `..._name` — `:1073`, `:1111`, `:1092`
+
+**Config / misc**
+
+- `woocommerce_email_enabled_{$id}` — `(bool $enabled, $object, WC_Email $email)` — `:827`
+- `woocommerce_email_get_option`, `woocommerce_email_title`, `woocommerce_email_description` — `:818`, `:797`, `:806`
+- `woocommerce_email_groups`, `woocommerce_email_group_title` — `:476`, `:497`
+- `woocommerce_allow_switching_email_locale` / `..._restoring_...` / `woocommerce_email_setup_locale` / `woocommerce_email_restore_locale` — `:426`, `:446`, `:428`, `:448`
+- `woocommerce_is_email_preview` — `:743`
+- `woocommerce_template_directory`, `woocommerce_locate_core_template` — `:1458`, `:1475`
+- `woocommerce_email_settings_before` / `..._after` — actions, `:1582`, `:1595`
+
+## 3.2 Email hooks — `WC_Emails` (`includes/class-wc-emails.php`)
+
+- `woocommerce_email` — action, `(WC_Emails $this)` — `:273`. Fires at the end of `init()`, after every `WC_Email` instance has registered its `trigger()`. **The canonical detach point** — `remove_action()` here, at priority 99.
+- `woocommerce_email_classes` — `(array $emails)` — `:336`. Register or unregister email classes.
+- `woocommerce_email_actions` — `(array $actions)` — `:99`. The parent-hook list.
+- `woocommerce_defer_transactional_emails` — `(bool $defer)` — `:137`. Overrides the feature toggle.
+- `woocommerce_allow_send_queued_transactional_email` — `(bool, string $filter, array $args)` — `:183`
+- `woocommerce_email_header` / `woocommerce_email_footer` — actions, `:465`, `:475`
+- `woocommerce_email_order_meta_fields` / `..._meta_keys` — `:641`, `:650`
+- `woocommerce_email_customer_details_fields` — `:792`
+- Stock alerts: `woocommerce_should_send_low_stock_notification` `:1019`, `woocommerce_should_send_no_stock_notification` `:1110`; and `woocommerce_email_{recipient,subject,content}_{low_stock,no_stock,backorder}`
+
+## 3.3 Logging hooks — `EmailLogger` (`src/Internal/Email/EmailLogger.php`)
+
+- `woocommerce_email_log_enabled` — `(bool, string $id, WC_Email $email)`
+- `woocommerce_email_log_add_order_note` — `(bool, string $id, WC_Email $email, WC_Order $order)`
+
+## 3.4 Order status hooks
+
+- `wc_order_statuses` — `(array $statuses)` — prefixed keys. Admin dropdowns, reports.
+- `woocommerce_register_shop_order_post_statuses` — `(array $statuses)` — `register_post_status()` args.
+- `woocommerce_order_status_{$to}` — action, `( $order_id, $order, $status_transition )`
+- `woocommerce_order_status_{$from}_to_{$to}` — action, `( $order_id, $order )`
+- `woocommerce_order_status_changed` — action, `( $order_id, $from, $to, $order )`
+- `woocommerce_order_status_{$hook}_notification` — action, derived from each `$email_actions` entry. Arguments vary by email.
+- `woocommerce_order_is_paid_statuses`, `woocommerce_order_is_pending_statuses`
+
+## 3.5 Admin order-list search and filtering
+
+HPOS and the legacy CPT store use different hooks. Anything touching the Orders screen must handle both.
+
+- **HPOS**: `woocommerce_order_table_search_query_meta_keys` — `src/Internal/DataStores/Orders/OrdersTableSearchQuery.php:446`
+- **HPOS**: `woocommerce_shop_order_search_fields` — same file, `:439`
+- **Legacy CPT**: `woocommerce_shop_order_search_fields` — `includes/data-stores/class-wc-order-data-store-cpt.php:590`
+- **HPOS list-table query args**: `woocommerce_order_list_table_prepare_items_query_args` — `src/Internal/Admin/Orders/ListTable.php:418`
+- Bulk actions: `bulk_actions-woocommerce_page_wc-orders` (HPOS) vs `bulk_actions-edit-shop_order` (legacy); handlers on `handle_bulk_actions-…` respectively.
+
+---
+
+# Part 4 — Notes for `studiou-wc-mail-queue`
+
+Cross-references into `docs/plans/implement-plan-00.md`:
+
+1. **Context tagging** uses `woocommerce_mail_callback` (§1.1, §3.1) because it is the only pre-`wp_mail()` hook carrying the `WC_Email` instance. `_notification` hooks cannot identify the email (§1.3).
+2. **From address** must be resolved at enqueue time — the filters are detached the instant `wp_mail()` returns (§1.1).
+3. **`$headers` is a string** for WooCommerce emails and never contains `From:` (§1.1).
+4. **Attachments are file paths** supplied by third parties via `woocommerce_email_attachments` (§3.1) and may be temp files.
+5. **`EmailLogger` will record "sent" and write an order note at enqueue time** (§1.6). Suppress with `woocommerce_email_log_enabled` / `woocommerce_email_log_add_order_note` and emit our own note on real send.
+6. **"Deferred emails" must be off** (§1.5). It is not a rate limiter.
+7. **Stock alerts can never be queued** (§1.4) — correct behaviour, and they never appear in the settings selector.
+8. **Build the handled-types selector from `WC()->mailer()->get_emails()`** (§1.3), never a hardcoded list. `customer_reset_password` is in there — never default it on.
+9. **Read `$email->id` per call, never cache it** — the refund email mutates its own `id` (§1.1).

+ 225 - 0
studiou-wc-mail-queue/docs/analyza-woocommerce-email-throttling.md

@@ -0,0 +1,225 @@
+# Analýza: zahlcení wp_mail při hromadné změně stavu objednávek ve WooCommerce
+
+**Verze:** 1.0
+**Datum:** 29. 6. 2026
+**Oblast:** WordPress / WooCommerce, doručování transakčních e-mailů
+**Závažnost:** střední (provozní – část zákazníků nedostane notifikaci)
+
+---
+
+## 1. Shrnutí
+
+Při hromadné změně stavu velkého počtu objednávek (100+) na stav **„Dokončeno"**
+se WooCommerce pokouší odeslat zákaznickou notifikaci o dokončení objednávky pro
+každou objednávku. Všechna volání `wp_mail()` proběhnou **synchronně v rámci
+jediného HTTP requestu**, prakticky v jeden okamžik. Poskytovatel hostingu tento
+nárazový tok vyhodnotí jako potenciální zneužití (spam burst) a z bezpečnostních
+důvodů funkci odchozí pošty dočasně **zablokuje / rate-limituje**.
+
+Důsledkem je, že část zákazníků e-mail nedostane, request může spadnout do timeoutu
+a chování je nedeterministické (záleží, kdy limit hostingu „spadne").
+
+**Doporučené řešení:** odpojit synchronní odesílání a notifikace zařadit do
+**rate-limitované fronty** (Action Scheduler) s pevným odstupem mezi e-maily.
+Dlouhodobě navíc přejít na externí transakční SMTP/API službu.
+
+---
+
+## 2. Reprodukce a projevy
+
+| Aspekt | Pozorování |
+|---|---|
+| Spouštěč | Hromadná akce v adminu: *Objednávky → vybrat 100+ → Změnit stav na „Dokončeno"* |
+| Projev | Část e-mailů se neodešle; v logu hostingu/MTA chyby typu rate-limit / „too many connections" / dočasné odmítnutí |
+| Práh | Závisí na limitu hostingu (typicky desítky e-mailů za minutu na sdíleném hostingu) |
+| Reprodukovatelnost | Vysoká při dostatečném počtu objednávek v jedné dávce |
+
+---
+
+## 3. Analýza příčiny (root cause)
+
+### 3.1 Tok odeslání e-mailu ve WooCommerce
+
+Při změně stavu objednávky proběhne uvnitř jednoho requestu řetězec:
+
+```
+WC_Order::update_status('completed')
+   └─ status_transition()
+        └─ do_action('woocommerce_order_status_completed', $order_id, $order)
+             └─ WC_Emails::send_transactional_email()
+                  └─ do_action('woocommerce_order_status_completed_notification', …)
+                       └─ WC_Email_Customer_Completed_Order::trigger()
+                            └─ ->send()
+                                 └─ wp_mail()   ← skutečné odeslání
+```
+
+Při hromadné akci se tento řetězec provede **N-krát za sebou v témže PHP procesu**.
+Mezi jednotlivými `wp_mail()` není žádná prodleva ani dávkování.
+
+### 3.2 Kde vzniká problém
+
+1. **Synchronnost** – odeslání je svázané s requestem hromadné akce. 100+ e-mailů
+   odejde v rámci jednotek sekund.
+2. **Bez rate-limitu na straně aplikace** – WooCommerce ve výchozím stavu nijak
+   nereguluje tempo odesílání.
+3. **Bezpečnostní limit hostingu** – sdílené hostingy omezují počet odchozích zpráv
+   za jednotku času, aby zabránily zneužití účtu pro spam. Nárazový tok tento limit
+   překročí a hosting odesílání pozastaví.
+
+Příčina tedy **není** v hostingu jako takovém – ten se chová správně. Problém je,
+že aplikace generuje neregulovaný burst, který do bezpečnostního limitu naráží.
+
+---
+
+## 4. Dopady
+
+- **Funkční:** část zákazníků nedostane potvrzení o dokončení objednávky.
+- **Provozní:** nekonzistentní stav – nelze snadno určit, komu e-mail dorazil.
+- **UX/reputace:** chybějící notifikace = dotazy zákazníků, zhoršená důvěra.
+- **Deliverability:** opakované bursty mohou poškodit reputaci domény/IP.
+- **Výkon:** request hromadné akce může spadnout do timeoutu (PHP `max_execution_time`).
+
+---
+
+## 5. Možná řešení – srovnání
+
+| # | Řešení | Tvrdý strop/min | Odpojení od requestu | Náročnost | Poznámka |
+|---|---|:---:|:---:|:---:|---|
+| A | Filtr `woocommerce_defer_transactional_emails` | ✗ | ✓ | velmi nízká | Jednořádek; dávky AS ale stále mohou poslat ~25 najednou |
+| B | **Vlastní rate-limitovaná fronta (Action Scheduler)** | ✓ | ✓ | nízká–střední | **Doporučeno** – pevný odstup mezi e-maily |
+| C | Externí transakční SMTP/API (SES, Mailgun, Brevo…) | ~ | ✗* | střední | Obejde limit hostingu, zlepší deliverability; *bez fronty pořád burst |
+| D | Menší dávky ručně | ✗ | ✗ | nulová | Obejití, ne řešení; lidská chyba |
+| E | Hotový plugin třetí strany | dle pluginu | dle pluginu | nízká | Závislost navíc, různá kvalita |
+
+### 5.1 Varianta A – vestavěný defer (rychlá úleva)
+
+```php
+add_filter( 'woocommerce_defer_transactional_emails', '__return_true' );
+```
+
+WooCommerce e-maily zařadí jako asynchronní akce Action Scheduleru → odpojí je
+od requestu hromadné akce. **Omezení:** Action Scheduler zpracovává dávky (default
+~25 akcí na běh), takže při jednom běhu cronu může odejít několik desítek e-mailů
+téměř naráz. **Negarantuje** tedy strop „X e-mailů za minutu" a u přísného limitu
+hostingu nemusí stačit.
+
+### 5.2 Varianta B – rate-limitovaná fronta (doporučeno)
+
+Princip:
+
+1. Odpojí se výchozí synchronní trigger e-mailu „objednávka dokončena".
+2. Každá objednávka se místo toho zařadí do fronty s **vlastní časovou známkou**,
+   která navazuje na předchozí (např. +12 s ⇒ 5 e-mailů/min).
+3. Action Scheduler nikdy nezpracuje akci dříve, než nastane její čas → tempo
+   je dané **timestampy, ne frekvencí cronu**. To je tvrdý, deterministický strop.
+
+Counter dalšího slotu se sám „uzdravuje" (`max(now, next_slot)`), takže po vyprázdnění
+fronty se tempo resetuje na aktuální čas.
+
+Referenční implementace je v přiloženém mu-pluginu `wc-mail-throttle.php`.
+Tempo se ladí filtrem:
+
+```php
+add_filter( 'wcmt_interval_seconds', function () { return 12; } ); // 5/min
+```
+
+### 5.3 Varianta C – externí transakční služba
+
+Napojení na Amazon SES / Mailgun / Brevo / SendGrid přesune odchozí poštu **mimo**
+lokální `sendmail` hostingu, čímž odpadne jeho bezpečnostní blok a výrazně se zlepší
+doručitelnost. **Ale:** samotný přechod neřeší burst – každé `wp_mail()` při bulku
+proběhne dál synchronně (u SMTP dokonce pomaleji kvůli handshake). I providers mají
+rate-limity (často per-second). Proto se varianta C kombinuje s frontou (B).
+
+---
+
+## 6. Doporučení
+
+**Primárně:** nasadit variantu **B** (rate-limitovaná fronta) – řeší jádro problému
+deterministicky a bez závislosti na třetích stranách.
+
+**Sekundárně / dlouhodobě:** přejít na variantu **C** (externí transakční SMTP/API)
+pro lepší doručitelnost a odolnost, ponechat frontu z B jako regulátor tempa.
+
+Variantu A lze použít jako okamžitou úlevu, než se nasadí B.
+
+---
+
+## 7. Implementační poznámky
+
+### 7.1 Spolehlivý cron
+
+Frontu vyprazdňuje Action Scheduler nad WP-Cronem, který se spouští jen při návštěvě
+stránky. Pro spolehlivé tempo:
+
+1. Vypnout WP-Cron ve `wp-config.php`:
+   ```php
+   define( 'DISABLE_WP_CRON', true );
+   ```
+2. Nastavit systémový cron (každou minutu), např. přes WP-CLI:
+   ```
+   * * * * * cd /cesta/k/webu && wp action-scheduler run >/dev/null 2>&1
+   ```
+   nebo HTTP variantou:
+   ```
+   * * * * * wget -q -O - "https://web.tld/wp-cron.php?doing_wp_cron" >/dev/null 2>&1
+   ```
+
+Bez spolehlivého cronu throttling funguje, ale fronta se vyprazdňuje jen nárazově.
+
+### 7.2 Konfigurace tempa
+
+`interval = 12 s` ⇒ 5 e-mailů/min ⇒ 300/h. Hodnotu nastavit podle reálného limitu
+hostingu (vyžádat si od podpory přesné číslo odchozích zpráv za minutu/hodinu).
+
+### 7.3 Rozšíření na další stavy
+
+Stejnou logiku lze použít i pro „Zpracovává se", „Vráceno" apod. – v `detach_default`
+odpojit příslušnou e-mailovou třídu (`WC_Email_Customer_Processing_Order`, …) a přidat
+hook na její `_notification`. Sdílení jednoho slot counteru zajistí, že se rate-limit
+počítá **globálně** napříč typy e-mailů.
+
+---
+
+## 8. Monitoring a ověření
+
+| Co sledovat | Kde |
+|---|---|
+| Stav fronty (pending/complete/failed), rozestupy | WooCommerce → Status → Scheduled Actions, skupina `wcmt` |
+| Skutečné odeslání | Log MTA / hostingu, příp. log transakční služby |
+| Chyby `wp_mail` | Vlastní logování v `wp_mail_failed` hooku |
+| Timeout hromadné akce | PHP error log (`max_execution_time`) – po nasazení B by měl zmizet |
+
+Doporučený test: na staging spustit hromadnou změnu 100+ objednávek a ověřit, že
+e-maily ve frontě odcházejí v očekávaném tempu a všechny dojdou.
+
+---
+
+## 9. Rizika a edge cases
+
+- **Action Scheduler nedostupný** – plugin má fallback (pošle hned); při bulku by se
+  ale projevil původní problém. Ověřit, že WooCommerce (a tím AS) je aktivní.
+- **Duplicitní odeslání** – pokud by zůstal aktivní i výchozí trigger; proto je nutné
+  spolehlivé odpojení v `detach_default`.
+- **Velmi nízký interval** – příliš krátký odstup opět překročí limit hostingu;
+  hodnotu validovat proti reálnému limitu.
+- **Růst slot counteru** – ošetřeno přes `max(now, next)`; po vyprázdnění se resetuje.
+- **Vyšší PHP verze a předávání argumentů AS** – akce plánovat s indexovaným polem
+  argumentů (`array( $order_id )`), ne asociativním.
+
+---
+
+## 10. Závěr
+
+Problém vzniká neregulovaným nárazovým odesíláním transakčních e-mailů v rámci jednoho
+requestu, který naráží na bezpečnostní limit hostingu. Nejde o chybu hostingu, ale o
+chybějící regulaci tempa na straně aplikace. Nasazením rate-limitované fronty
+(varianta B) se odesílání odpojí od requestu a rozloží do deterministicky řízeného
+tempa; dlouhodobě je vhodné doplnit externí transakční službu (varianta C) pro
+doručitelnost a odolnost. Tím se chování stane stabilním a předvídatelným.
+
+---
+
+### Přílohy
+
+- `wc-mail-throttle.php` – referenční mu-plugin (rate-limitovaná fronta, varianta B)

+ 1060 - 0
studiou-wc-mail-queue/docs/plans/implement-plan-00.md

@@ -0,0 +1,1060 @@
+# Implementation plan — studiou-wc-mail-queue v1.0.0
+
+**Status:** not started. No plugin code exists yet.
+**Date:** 2026-07-09
+**Supersedes:** nothing. First plan.
+**Hooks verified against:** WooCommerce **10.9.4**. Every WooCommerce internal cited below was read from a full 10.9.4 source tree.
+
+**Inline `file:line` references are relative to a WooCommerce install root** (`wp-content/plugins/woocommerce/`), *not* to this repo. The tree they were read from lives at `docs/Wiki/woocommerce/` as a local working copy — it is **untracked by git and is not part of the repo**, so a fresh clone will not have it and the citations will not resolve locally. Do not treat its presence as guaranteed.
+
+The durable, in-repo distillation is [`../Wiki/woocommerce-emailing.md`](../Wiki/woocommerce-emailing.md). Consult that first. Re-verify against real WooCommerce source only when the vendored tree is present, and after any WooCommerce major upgrade.
+
+*(If the tree is meant to stay, either commit it or add an explicit `.gitignore` rule — right now it is neither tracked nor ignored, which is the worst of both.)*
+
+Read [`../analyza-woocommerce-email-throttling.md`](../analyza-woocommerce-email-throttling.md) before this document — it holds the root-cause analysis and the comparison of solution variants. [`../sample-wc-mail-throttle.php`](../sample-wc-mail-throttle.php) is a working proof of concept of the rate-limiting mechanism, not the deliverable.
+
+---
+
+## 1. Locked decisions
+
+Three questions were open when this plan was written. All are now resolved:
+
+**Interception point — hybrid `pre_wp_mail` + WooCommerce email context tag.**
+Short-circuit `wp_mail()` through the `pre_wp_mail` filter, but tag each call with the identity of the WooCommerce email that produced it. Mail whose context is not in the admin's handled-types list passes straight through and sends synchronously, exactly as today. This satisfies both the spec's literal "intercepts `wp_mail()`" and its "assign what type of email will be handled (order statuses)" setting, which the sample's trigger-detach approach could give us but a naive global interceptor could not.
+
+**Queue backend — custom table plus Action Scheduler as the dispatcher.**
+`{$wpdb->prefix}studiou_wcmq_queue` owns the payload, state, attempt count and timestamps. Action Scheduler owns only the staggered wake-ups: one scheduled action per queued row. This keeps the admin queue view, log view, retention sweep and clear-log operations under our control, and survives Action Scheduler pruning its own completed actions (default 30 days).
+
+**Target versions — match the sibling plugins.**
+WordPress 6.8+ (tested 6.9.4), PHP 8.2+, `WC requires at least: 9.8`, `WC tested up to: 10.9.4`. The readme's "WooCommerce v.1.26.4" is treated as a typo. (The vendored source is 10.9.4, newer than the 10.7.0 the siblings declare — use 10.9.4.)
+
+---
+
+## 2. What we are building
+
+Bulk-changing 100+ orders to Completed fires every `wp_mail()` synchronously inside one HTTP request. The shared host reads that burst as spam and rate-limits outbound mail, so an unpredictable share of customers never receive their notification, and the request may hit `max_execution_time`.
+
+The plugin decouples sending from the request and paces it behind a hard, deterministic per-minute cap.
+
+### Data flow
+
+```
+WC_Order::update_status('completed')
+  └─ do_action('woocommerce_order_status_completed')
+       └─ WC_Emails::send_transactional_email()
+            └─ do_action('woocommerce_order_status_completed_notification')
+                 └─ WC_Email_Customer_Completed_Order::trigger()
+                      └─ ->send()
+                           ├─ apply_filters('woocommerce_mail_callback', 'wp_mail', $this)
+                           │        ▲ Context::set($this->id, $email->object, $to, $subject)
+                           └─ wp_mail(...)
+                                └─ apply_filters('pre_wp_mail', null, $atts)
+                                         ▲ Interceptor::maybe_enqueue()  ← we intercept here
+                                         │
+                        ┌────────────────┴─────────────────┐
+                        │                                  │
+              context NOT handled                   context handled
+              or state != enabled                   → snapshot payload
+              or AS unavailable                     → Queue::enqueue()   [see below]
+                        │                                  │
+                        │                        ┌─────────┴──────────┐
+                        │                     success              failure
+                        │                        │                    │
+              → return null              → mark_queued($row_id)   → return null
+              → wp_mail proceeds         → return true ("sent")   → wp_mail proceeds
+                 (sends now)                                          (sends now)
+
+                                                              ⋮ (slot arrives)
+
+                            Action Scheduler → do_action('studiou_wcmq_send', $row_id)
+                                                    └─ Worker::send()
+                                                         ├─ claim row (pending → sending)
+                                                         ├─ wp_mail(...)   ← passes through, no context
+                                                         └─ mark sent / failed + retry
+```
+
+**Never return `true` before the mail is durably queued.** `Queue::enqueue()` must have both written the row *and* obtained a scheduled Action Scheduler action before the interceptor short-circuits. On any failure, log it and **return `null`** so `wp_mail()` proceeds and the mail sends synchronously.
+
+Returning `true` on a failed enqueue reports a successful send to WooCommerce for a mail that exists nowhere. It is silent, permanent mail loss, and it is the single worst failure this plugin can have — worse than the burst it exists to prevent.
+
+The guard must be a `try/catch` **plus explicit return-value checks**, because the three most likely failures do not throw:
+
+```php
+$json = json_encode( $payload );          // returns false on malformed UTF-8, does NOT throw
+if ( false === $json ) { return false; }  // ← or json_encode($payload, JSON_THROW_ON_ERROR)
+
+$ok = $wpdb->insert( ... );               // returns false on error, does NOT throw
+if ( false === $ok ) { return false; }
+
+$action_id = as_schedule_single_action( ... );   // returns 0 on failure, does NOT throw
+if ( ! $action_id ) { /* roll back */ return false; }
+```
+
+`json_encode()` is the sneaky one. A rendered order email is user-controlled content — a product name or customer address carrying a lone surrogate or invalid byte sequence makes `json_encode()` return `false` with no exception, `$wpdb->insert()` then writes an empty payload or fails, and the mail is gone. Use `JSON_THROW_ON_ERROR` (PHP 7.3+, and we require 8.2) so it joins the `catch`, or check `false ===` explicitly. Consider `JSON_INVALID_UTF8_SUBSTITUTE` — a mail with one replacement character beats no mail.
+
+`Queue::enqueue()` returns the new `$row_id` on success and `false` on **any** failure. `Interceptor` treats `false` as "not handled".
+
+### 2.1 The invariant that makes recovery possible
+
+> **Every row in `pending` has a scheduled Action Scheduler action.**
+
+This invariant, not the rollback, is what actually prevents mail loss — because a rollback cannot run after a PHP fatal or an OOM kill. §3.3 inserts the `pending` row *before* copying attachments and *before* scheduling the action, so there is a real window in which a fatal leaves a `pending` row with no action behind it. Nothing in Action Scheduler will ever run it, retention sweeps only `sent`/`failed`, and `draining → disabled` blocks on it forever.
+
+The reaper (§8.1) enforces the invariant by repairing violations, and it must therefore cover **`pending` rows with no action**, not just stranded `sending` rows. Once it does, a failed re-schedule anywhere — enqueue *or* retry — degrades to "recovered late" instead of "lost".
+
+### Rate limiting
+
+The hard cap comes from **timestamps, not cron frequency**. Each queued mail is assigned a send slot; Action Scheduler never runs an action before its scheduled time, so spacing is deterministic no matter how often cron fires.
+
+`interval = 12s` ⇒ 5 mails/min ⇒ 300/hour. A **single shared slot ladder across all handled email types** keeps the rate limit global, which is what the host enforces. Slot allocation is subtle once retries exist — see §4.
+
+---
+
+## 3. The interception design in detail
+
+This is the part with the sharp edges. Every subsection below is correctness-critical, and each one fails *silently* — the mail arrives, or appears to, and the defect surfaces as a support ticket weeks later.
+
+### 3.1 Identifying which email is sending — VERIFIED
+
+`pre_wp_mail` receives only `array('to','subject','message','headers','attachments')`. Nothing identifies the WooCommerce email class, so the per-order-status selector cannot be implemented at that layer alone.
+
+`WC_Email::send()` applies a filter that passes the email object itself. Verified at `includes/emails/class-wc-email.php:1228-1236` (note the path — it is `includes/emails/`, not `includes/`):
+
+```php
+public function send( $to, $subject, $message, $headers, $attachments ) {
+    add_filter( 'wp_mail_from',         array( $this, 'get_from_address' ) );
+    add_filter( 'wp_mail_from_name',    array( $this, 'get_from_name' ) );
+    add_filter( 'wp_mail_content_type', array( $this, 'get_content_type' ) );
+
+    $message              = apply_filters( 'woocommerce_mail_content', $this->style_inline( $message ) );
+    $mail_callback        = apply_filters( 'woocommerce_mail_callback', 'wp_mail', $this );          // ← line 1234
+    $mail_callback_params = apply_filters( 'woocommerce_mail_callback_params', array( $to, wp_specialchars_decode( $subject ), $message, $headers, $attachments ), $this );
+    $return               = (bool) $mail_callback( ...$mail_callback_params );
+
+    remove_filter( 'wp_mail_from',      array( $this, 'get_from_address' ) );   // ← detached before our send
+    // ...
+    do_action( 'woocommerce_email_sent', $return, (string) $this->id, $this );
+    return $return;
+}
+```
+
+Hook `woocommerce_mail_callback` purely as a probe and return `'wp_mail'` unchanged. Both filters fire *before* `wp_mail()`, so the context is set by the time `pre_wp_mail` runs.
+
+The probe must capture **four** things into a request-scoped static, not just the id:
+
+```php
+add_filter( 'woocommerce_mail_callback', function ( $callback, $email ) {
+    Context::set( array(
+        'id'       => $email->id,                                    // read per-call; see §3.1d
+        'order_id' => $email->object instanceof WC_Order ? $email->object->get_id() : 0,
+        'to'       => null,   // filled by the params probe below
+        'subject'  => null,
+    ) );
+    return $callback;
+}, 10, 2 );
+
+add_filter( 'woocommerce_mail_callback_params', function ( $params, $email ) {
+    Context::add_fingerprint( $params[0], $params[1] );   // $to, decoded $subject
+    return $params;
+}, 10, 2 );
+```
+
+`$email->object` is the only source of the order. `pre_wp_mail` never sees it, and `$atts` carries nothing that maps back to an order. Without capturing it here, the `order_id` column (§5), the worker's replacement order note (§3.1c) and the admin queue view's Order column are all permanently `0`. Guard the `instanceof` — `$email->object` is a `WC_Product` for stock emails, a `WP_User`-ish array for `customer_new_account`, and `null` for some.
+
+`pre_wp_mail` appears nowhere in the WooCommerce tree, so there is no contention for it.
+
+Do **not** key the context off the `woocommerce_order_status_*_notification` hooks. A single notification hook fires more than one email — `woocommerce_order_status_pending_to_processing_notification` triggers both `WC_Email_Customer_Processing_Order` and the admin-facing `WC_Email_New_Order` — so the hook name cannot distinguish them, and the admin needs to queue one without queueing the other.
+
+**Consume-once, plus a fingerprint.** Read the context in `pre_wp_mail` and clear it immediately, whether or not we queue. Additionally, capture `$to` and the decoded `$subject` from `woocommerce_mail_callback_params` and store them alongside the context; in `pre_wp_mail`, accept the context **only if** it fingerprint-matches `$atts['to']` and `$atts['subject']`.
+
+Without the fingerprint, a plugin that filters `woocommerce_mail_callback` to return its own mailer instead of `wp_mail` leaves our context set, `wp_mail()` never runs, and the *next* unrelated `wp_mail()` call in that request inherits the stale context — a password reset silently queued as if it were an order email.
+
+Note the subject reaching `pre_wp_mail` has already been through `wp_specialchars_decode()`. Fingerprint against the decoded form.
+
+**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.
+
+### 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:
+
+- `WC_Emails::low_stock()` — `includes/class-wc-emails.php:1007`, `wp_mail()` at `:1041`
+- `WC_Emails::no_stock()` — `:1098`, `wp_mail()` at `:1128`
+- `WC_Emails::backorder()` — `:1185`, `wp_mail()` at `:1214`
+
+These are low-volume admin stock alerts. Passing them through is the *correct* behaviour — a stock warning should not sit behind a 20-minute order backlog. They are not `WC_Email` subclasses, so they never appear in `WC()->mailer()->get_emails()` and the settings selector will not offer them. Consistent, but worth a line in the readme.
+
+Likewise the email-editor preview (`packages/email-editor/src/Engine/class-send-preview-email.php:173`) calls `wp_mail()` with no context, so admin preview sends stay instant. Correct.
+
+### 3.1c `woocommerce_email_sent` fires at enqueue time — and core listens
+
+`send()` fires `do_action('woocommerce_email_sent', $return, $this->id, $this)` (`class-wc-email.php:1253`) immediately after the mail callback returns. Because our `pre_wp_mail` returns `true`, `$return` is `true` and the action fires the moment we *queue*, not when we actually send.
+
+**This is not a hypothetical third-party concern.** WooCommerce 10.9 ships `EmailLogger` (`src/Internal/Email/EmailLogger.php:45-49`), which hooks `woocommerce_email_sent`, `woocommerce_email_disabled`, `woocommerce_email_skipped` and `wp_mail_failed`. On `woocommerce_email_sent` it writes a WC log entry under source `transactional-emails` **and adds an order note** via `maybe_add_order_note()` (`:152`).
+
+Left alone, every queued order gets an order note claiming the email was sent, up to `interval × queue_depth` seconds before it actually is. On a 100-order bulk that is 100 misleading notes, the last of them wrong by twenty minutes.
+
+Two core filters fix this, both added in 10.9.0:
+
+- `woocommerce_email_log_enabled` — `(bool, $email_id, $email)`. `false` skips logging entirely.
+- `woocommerce_email_log_add_order_note` — `(bool, $email_id, $email, $order)`. `false` suppresses only the note.
+
+**Key the suppression on "was *this* mail actually queued", never on "is this email type handled".** They are not the same predicate. A handled type still sends synchronously when the state is `draining`/`disabled`, when Action Scheduler is unavailable, or when `Queue::enqueue()` failed and we fell through (§2). In all those cases the mail really did leave during this request, `EmailLogger`'s note is correct and wanted — and no worker will ever run to write a replacement. Suppressing on type would silently delete the audit trail.
+
+The interceptor already knows. Have it set a consume-once flag when, and only when, it short-circuits:
+
+```php
+// In Interceptor, immediately before `return true`:
+Context::mark_queued( $row_id );
+
+// Ordering note: woocommerce_email_sent fires after wp_mail() returns,
+// i.e. after pre_wp_mail short-circuited — so the flag is always set by now.
+add_filter( 'woocommerce_email_log_add_order_note', function ( $enabled, $email_id, $email, $order ) {
+    return Context::was_queued() ? false : $enabled;
+}, 10, 4 );
+```
+
+**The `was_queued` flag and the context have different lifetimes. Do not clear them together.**
+
+The *context* is consumed once, inside `pre_wp_mail`. The *flag* must still be readable when `woocommerce_email_sent` fires — which is **after** `wp_mail()` returns, i.e. after `pre_wp_mail` has already run and cleared the context. Clearing the flag "immediately" alongside the context makes `was_queued()` always `false` at the moment both `EmailLogger` filters consult it, and the suppression never fires at all.
+
+Nor can the flag be consumed by the first filter that reads it: `EmailLogger` consults `woocommerce_email_log_enabled` first and `woocommerce_email_log_add_order_note` second, so a consume-once flag would suppress the log line and then let the order note through.
+
+Clear it on the **next send instead of the current one**:
+
+```php
+// woocommerce_mail_callback probe — a new WC_Email::send() is beginning.
+Context::set( ... );
+Context::clear_queued();     // reset before this send, not after it
+```
+
+plus a defensive clear on `shutdown`. Exactly one `wp_mail()` runs per `WC_Email::send()`, and `woocommerce_email_sent` fires before the next `send()` can begin, so the flag is always valid when read and always fresh when set. Order-independent, and no filter has to know it is the last reader.
+
+**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:
+
+```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 );
+```
+
+`Worker::send()` then emits, on the real outcome:
+
+- an order note (§8, and 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
+
+**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
+$order = $order_id ? wc_get_order( $order_id ) : false;
+if ( $order instanceof WC_Order ) {
+    $order->add_order_note( ... );
+}
+```
+
+More generally: once `wp_mail()` has returned `true`, **nothing after it may throw**. Mark the row `sent` first, then do the bookkeeping inside its own `try/catch`. A failure to annotate must never re-send an email.
+
+Other plugins listening on `woocommerce_email_sent` (delivery logs, CRM sync) will still fire early and cannot be fixed generically. Audit the shop's active plugins before rollout.
+
+### 3.1d `$email->id` is not stable
+
+`WC_Email_Customer_Refunded_Order::set_email_strings()` (`class-wc-email-customer-refunded-order.php:195`) reassigns its own `$this->id` on every trigger:
+
+```php
+$this->id = $this->partial_refund ? 'customer_partially_refunded_order' : 'customer_refunded_order';
+```
+
+Read `$email->id` inside the `woocommerce_mail_callback` probe, which runs after `set_email_strings()`. Never cache an id against an instance, and never build the handled-contexts map by iterating `get_emails()` once at boot and freezing each `->id`.
+
+### 3.2 The From address is not in the payload — VERIFIED
+
+`pre_wp_mail` runs at the very top of `wp_mail()`, **before** `apply_filters('wp_mail_from', ...)` and `apply_filters('wp_mail_from_name', ...)`. Meanwhile `WC_Email::send()` attaches those filters, calls the mailer, and detaches them again — see the `add_filter`/`remove_filter` pair in §3.1.
+
+So if we short-circuit and send later, the filters are long gone. The deferred mail goes out as `wordpress@studiou.cz` instead of the shop's configured sender: a deliverability and trust regression invisible to any smoke test that only asks "did the mail arrive".
+
+**Resolve the sender at enqueue time**, while WooCommerce's filters are still attached, and bake it into the stored headers:
+
+```php
+$from_addr = apply_filters( 'wp_mail_from',         'wordpress@' . $sitename );
+$from_name = apply_filters( 'wp_mail_from_name',    'WordPress' );
+$ctype     = apply_filters( 'wp_mail_content_type', 'text/plain' );
+```
+
+This works because `WC_Email::get_from_address( $from_email = '' )` (`class-wc-email.php:1055`) **ignores its argument** and returns `get_option('woocommerce_email_from_address')` through the `woocommerce_email_from_address` filter. Passing a throwaway default is safe; we get the shop's real sender back.
+
+**`$headers` is a string, not an array.** `WC_Email::get_headers()` (`class-wc-email.php:684-719`) builds and returns a `\r\n`-delimited string:
+
+```
+Content-Type: text/html
+Reply-to: Studio U <info@studiou.cz>
+```
+
+It emits `Content-Type` and `Reply-to`, plus `Cc:`/`Bcc:` when the `email_improvements` feature is on — and **never a `From:`**. So: store `$atts['headers']` verbatim (which carries Cc/Bcc for free), and prepend `From: "{$from_name}" <{$from_addr}>\r\n` only if no `From:` line is already present. Add `Content-Type` only if missing.
+
+Handle the array form too — `$atts['headers']` is whatever the caller passed, and non-WooCommerce mail may pass an array. Store as JSON either way; branch on `is_array()` when injecting.
+
+The stored payload must be **filter-independent**: everything needed to send correctly lives in the row.
+
+### 3.3 Attachments are file paths, not bytes
+
+`$atts['attachments']` holds absolute paths. `WC_Email::get_attachments()` (`class-wc-email.php:726`) returns `apply_filters('woocommerce_email_attachments', array(), …)` — empty in core, populated by invoice/packing-slip plugins. Those plugins typically write a PDF to a temp directory and delete it at end of request. By the time our worker runs, the path may be dead.
+
+At enqueue, copy each attachment into a plugin-owned directory and store the copied paths.
+
+**Name the directory from unguessable randomness, never from `row_id`.** These files are invoice and packing-slip PDFs: customer name, address, order contents, sometimes partial payment data. They live under `wp-content/uploads/`, which is **served directly by the web server**.
+
+An `index.php` stub only defeats directory listing. A `.htaccess` deny is **a no-op on Nginx**, and Nginx is standard on exactly the budget shared hosting this plugin is written for. With a sequential `{row_id}` path, anyone can walk `…/studiou-wcmq-attachments/1/`, `/2/`, `/3/` and harvest every customer invoice the queue has touched. Filename guessing is the only remaining barrier and PDF names are formulaic.
+
+```
+wp-content/uploads/studiou-wcmq-attachments/{32-hex}/invoice-123.pdf
+                                            ^ bin2hex( random_bytes( 16 ) )
+```
+
+Store the directory token in an `attachment_dir` column (§5). Ship `index.php` + `.htaccess` anyway (they cost nothing on Apache), and **document the Nginx `location` deny in the readme** — a random path is defence in depth, not a substitute for the server-level block.
+
+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:
+
+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 ), … )`.
+
+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.
+
+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.
+
+**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`.
+
+### 3.4 Re-entrancy
+
+The worker calls `wp_mail()` to actually send. That fires `pre_wp_mail` again.
+
+In practice the worker's call carries no context (it does not go through `WC_Email::send()`), so `Queue::handles(null)` returns false and it passes through. Relying on that alone is fragile. Add an explicit static `$sending` guard in the interceptor, set for the duration of the worker's `wp_mail()` call.
+
+**Clear it in a `finally`.** `wp_mail()` can throw — a PHPMailer exception escapes as `phpmailerException`, and any `pre_wp_mail`/`wp_mail_failed` listener in another plugin can throw arbitrarily. An inline set/clear pair leaks a stuck `true` for the rest of the request, which under Action Scheduler means the rest of the **batch**: every subsequent `Worker::send()` in that process sees the guard set, and if the guard is what gates enqueue, every subsequent handled mail in that process passes through unthrottled.
+
+```php
+Interceptor::$sending = true;
+try {
+    $ok = wp_mail( $to, $subject, $message, $headers, $attachments );
+} finally {
+    Interceptor::$sending = false;
+}
+```
+
+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.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.
+
+It is arguably the more correct semantics — the email describes the moment the status changed, not the moment the mail happened to leave the queue — but it is a behaviour change from stock WooCommerce and belongs in the readme's known limitations.
+
+---
+
+## 4. Slot allocation
+
+This section is the load-bearing one. Three separate bugs live here and they all end the same way: mail leaves faster than the cap, or the whole queue stalls behind one row.
+
+### 4.1 The interval must round *up*
+
+```php
+$interval = max( 1, (int) ceil( 60 / $rate ) );   // NOT floor()
+```
+
+`floor()` rounds the *spacing* down, which rounds the *rate* up. At `rate = 7`, `floor(60/7) = 8s` ⇒ 7.5 mails/min — above the configured cap, on a plugin whose entire purpose is not exceeding a cap. Every rate that does not divide 60 evenly overshoots. `ceil(60/7) = 9s` ⇒ 6.67/min, safely under.
+
+A rate limiter must err below the limit. Always `ceil`.
+
+Clamp `$rate` to `1..60` **before** the division. `$rate` comes from an option array, which is a mutable surface: a corrupted or hand-edited `rate_per_minute = 0` makes `60 / 0` a fatal `DivisionByZeroError` on PHP 8, on every request that sends mail.
+
+### 4.2 A monotonic ladder cannot survive retries
+
+The sample's counter — `slot = max(now, next); next = slot + interval` — is correct only while every slot is allocated in increasing order from *now*. Retries break that invariant.
+
+A retry backs off to `now + 1h`. If it takes a slot from the monotonic ladder, the ladder head jumps to `now + 1h + interval`, and **every subsequent enqueue is scheduled behind it** — a hundred fresh order confirmations queued an hour out because one mail bounced. If instead the retry is scheduled at `now + backoff` *outside* the ladder, it silently doubles up with whatever the ladder issues at that moment, and the cap is exceeded.
+
+The monotonic counter simply cannot express "reserve a slot an hour from now, and keep issuing slots starting now." Drop it. Allocate the **first free slot at or after a floor**:
+
+```php
+Queue::allocate_slot( int $earliest ): int
+```
+
+returns the smallest `t >= max(time(), $earliest)` such that no `pending`/`sending` row is already scheduled within `interval` of `t`.
+
+```sql
+SELECT c.candidate
+FROM (
+    SELECT GREATEST(%d, UNIX_TIMESTAMP()) AS candidate          -- $earliest
+    UNION ALL
+    SELECT q.scheduled_at + %d                                   -- $interval
+      FROM {prefix}studiou_wcmq_queue q
+     WHERE q.state IN ('pending','sending')
+       AND q.scheduled_at + %d >= GREATEST(%d, UNIX_TIMESTAMP())
+) AS c
+WHERE NOT EXISTS (
+    SELECT 1 FROM {prefix}studiou_wcmq_queue q2
+     WHERE q2.state IN ('pending','sending')
+       AND q2.scheduled_at >  c.candidate - %d                   -- $interval
+       AND q2.scheduled_at <  c.candidate + %d                   -- $interval
+)
+ORDER BY c.candidate ASC
+LIMIT 1
+```
+
+The candidate set is "the floor itself, plus one interval past each occupied slot at or after the floor" — the only places a gap can begin. The `NOT EXISTS` rejects candidates that collide. Needs the `(state, scheduled_at)` index from §5.
+
+> **Do not write the collision test as `ABS(q2.scheduled_at - c.candidate) < $interval`, and do not make the timestamp columns `BIGINT UNSIGNED`.**
+>
+> MySQL evaluates a subtraction with any `UNSIGNED` operand as unsigned. The moment an occupied row sits *earlier* than the candidate — which is the second mail of every bulk — `q2.scheduled_at - c.candidate` underflows and MySQL raises **error 1690, `BIGINT UNSIGNED value is out of range`** (unless `NO_UNSIGNED_SUBTRACTION` happens to be in `sql_mode`, which is not the default and is not ours to assume).
+>
+> The query then fails, `Queue::enqueue()` returns `false`, and §2's fall-through sends the mail synchronously. A 100-order bulk becomes 100 unthrottled synchronous sends. **The plugin reproduces the exact burst it exists to prevent, on its first real use, while every unit test on an empty table passes.**
+>
+> Fixed twice over: the range comparison above never subtracts two columns, and §5 declares all timestamp columns as signed `BIGINT`. Unix timestamps have no business being unsigned — the range buys nothing and costs this.
+
+- **New mail:** `allocate_slot( time() )` → the first free slot from now. A retry parked an hour out occupies exactly one slot an hour out and blocks nothing before it.
+- **Retry:** `allocate_slot( time() + backoff )` → respects the backoff *and* the cap. This resolves the contradiction the old §8 carried, where step 5 said "reschedule at now + backoff" while the sidenote said "a retry consumes a fresh slot from the ladder". Those were two mutually exclusive mechanisms; `allocate_slot($floor)` is the one that does both.
+
+**The query cannot return `NULL`.** The first `UNION` branch is unconditional, so the floor is always a candidate; and the largest candidate — `MAX(scheduled_at) + interval` — is by construction exactly `interval` from the furthest occupied slot and further from every other, so it always survives `NOT EXISTS`. Some candidate always wins. Keep a `?: max(time(), $earliest)` fallback as defence against a future edit to the query, but do not reason about it as a live path, and do not build behaviour on top of it.
+
+This is O(pending) per enqueue. Pending depth is bounded by the bulk size — a few hundred rows, one indexed scan. If a shop ever queues six figures, revisit; do not pre-optimise.
+
+### 4.3 The lock must cover the INSERT
+
+Two requests allocating concurrently will compute the *same* free slot: neither has inserted yet, so neither sees the other's row. Because the head is derived from the table rather than from a maintained option, **the `INSERT` is what publishes the allocation**. A lock that covers only the `SELECT` closes no race at all.
+
+Hold the advisory lock across allocate *and* insert:
+
+```php
+$locked = (int) $wpdb->get_var( $wpdb->prepare( "SELECT GET_LOCK(%s, 5)", 'studiou_wcmq_slot' ) );
+try {
+    $slot   = Queue::allocate_slot( $earliest );   // SELECT
+    $row_id = Queue::insert( $slot, $payload );    // INSERT  ← must be inside
+} finally {
+    if ( 1 === $locked ) {
+        $wpdb->query( $wpdb->prepare( "SELECT RELEASE_LOCK(%s)", 'studiou_wcmq_slot' ) );
+    }
+}
+```
+
+Attachment copying and `as_schedule_single_action()` (§3.3 steps 2–4) happen **outside** the lock — they are slow, and by then the slot is already published by the row.
+
+If `GET_LOCK` returns `0` (timeout) or `NULL` (error), log a warning and proceed unlocked rather than dropping the mail. Overshooting the rate limit is bad; losing a customer's order confirmation is worse. Release in `finally`, and only if we actually acquired — `RELEASE_LOCK` on a lock you do not hold returns `0` and, on some MySQL versions, warns.
+
+**Every re-slot takes the same lock.** `allocate_slot()` is not only called at enqueue. The retry path (§8) and the reaper (§8.1) both allocate a slot and then `UPDATE scheduled_at`, and that `UPDATE` is what publishes the allocation exactly as the `INSERT` does. Two concurrent retries, or a retry racing a reaper, will otherwise compute the same free slot and collide — silently exceeding the cap through the very code paths that exist to protect it.
+
+Expose one helper and route all three callers through it. **It must return the slot**, because every caller needs it *after* the lock is released, to pass to `as_schedule_single_action()`:
+
+```php
+/**
+ * @return array{0:int,1:mixed}  [ $slot, $publish_return ]
+ */
+Queue::with_slot_lock( int $earliest, callable $publish ): array {
+    // GET_LOCK → $slot = allocate_slot($earliest) → $result = $publish($slot)
+    //          → RELEASE_LOCK in finally → return [$slot, $result];
+}
+```
+
+Callers therefore look like:
+
+```php
+[ $slot, $row_id ] = Queue::with_slot_lock( $earliest, fn( $s ) => Queue::insert( $s, $payload ) );
+$action_id = as_schedule_single_action( $slot, 'studiou_wcmq_send', array( (int) $row_id ), 'studiou-wcmq' );
+```
+
+> A helper that returns only `$publish`'s result — a row id, or an `UPDATE` affected-row count — leaves callers with no slot to schedule against. Writing `fn( $slot ) => …` and then referencing `$slot` on the *next* line does not work: an arrow function's parameter is scoped to the arrow function. `$slot` is simply undefined there, `as_schedule_single_action( null, … )` coerces to `0`, and **Action Scheduler treats a timestamp of 0 as "run immediately"** — draining the entire queue as fast as it can, on the hot path *and* both recovery paths simultaneously. Silent, total defeat of the rate cap. Return the slot.
+
+`GET_LOCK` is per-connection and is not replicated. On a shop with a read/write split, ensure `$wpdb` is on the writer.
+
+### 4.4 Known limitation: no priority lanes in v1.0.0
+
+While a 100-order bulk is draining, a live customer's order-confirmation email is appended behind it. At 5/min that is a 20-minute delay on a transactional email the customer is actively waiting for.
+
+This is inherent to a single global ladder, and a global ladder is what the host's limit requires.
+
+**The `priority` column is reserved and entirely inert.** An earlier draft of this plan said to "have the worker order by `(priority, scheduled_at)`". That is not implementable in this architecture and the instruction was wrong: §8 schedules **one Action Scheduler action per row**, handing `Worker::send()` a fixed `$row_id`. The worker never selects rows and never orders anything — Action Scheduler decides what runs, purely from `scheduled_at`. There is no query for an `ORDER BY` to attach to.
+
+Making lanes actually work requires one of:
+
+- **Re-slot on insert.** When a high-priority mail arrives, allocate it the earliest free slot and *push back* the already-scheduled lower-priority rows — which means `as_unschedule_action()` + re-schedule for each displaced row. Correct, and O(displaced) Action Scheduler writes per high-priority mail.
+- **Switch to a pull dispatcher.** Replace per-row actions with a single recurring drain action that `SELECT`s the next due row `ORDER BY (priority, scheduled_at)`. This is where the `(priority, …)` index would earn its place — but it reintroduces the cron-frequency coupling that §2 exists to avoid, and the analysis document rejects for good reason.
+
+Neither is v1.0.0 work. Keep the column, default `10`, write `10` everywhere, index nothing on it, and **document the delay in the readme as a known limitation** — it is the single most likely support question after launch.
+
+---
+
+## 5. Database schema
+
+Two tables. Created and migrated by `maybe_upgrade_db()` on load (mu-plugins get no activation hook — see §9).
+
+### `{$wpdb->prefix}studiou_wcmq_queue`
+
+```sql
+id            BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT
+context       VARCHAR(100)     NOT NULL DEFAULT ''    -- WC_Email id, e.g. customer_completed_order
+order_id      BIGINT UNSIGNED  NOT NULL DEFAULT 0     -- best-effort, for the admin column; 0 if unknown
+recipient     VARCHAR(320)     NOT NULL DEFAULT ''    -- denormalised from payload for the list table
+subject       TEXT             NOT NULL
+payload       LONGTEXT         NOT NULL               -- JSON: to, subject, message, headers, attachments
+attachment_dir CHAR(32)        NULL                   -- random token, NOT row_id — see §3.3
+priority      SMALLINT         NOT NULL DEFAULT 10    -- RESERVED AND INERT in v1.0.0, see §4.4
+state         VARCHAR(20)      NOT NULL DEFAULT 'pending'  -- pending|sending|sent|failed
+attempts      TINYINT UNSIGNED NOT NULL DEFAULT 0     -- completed send attempts
+reclaims      TINYINT UNSIGNED NOT NULL DEFAULT 0     -- times the reaper rescued this row
+last_error    TEXT             NULL
+scheduled_at  BIGINT           NOT NULL DEFAULT 0     -- unix ts, the assigned slot   (SIGNED — see §4.2)
+claimed_at    BIGINT           NULL                   -- set on pending→sending; NULL otherwise
+sent_at       BIGINT           NULL
+created_at    BIGINT           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)
+```
+
+**All four timestamp columns are signed `BIGINT`, deliberately.** `allocate_slot()` compares `scheduled_at` against a candidate on both sides of it; with `BIGINT UNSIGNED` any expression that subtracts them underflows into MySQL error 1690 the first time an occupied row precedes the candidate. Unix timestamps gain nothing from the unsigned range. See the boxed warning in §4.2 — this is the single most damaging bug the plan has carried, and the column type is where it is prevented.
+
+`claimed_at` exists solely to make stranded `sending` rows recoverable — see §8.1. Without it, a worker that dies mid-send leaves the row in `sending` forever: retention only sweeps `sent`/`failed`, the state machine (§7) never reaches `disabled` while a `sending` row exists, and `allocate_slot()` counts it as an occupied slot permanently. One PHP fatal, and the queue is wedged for good with no admin path out short of direct SQL.
+
+**`attempts` and `reclaims` count different things and must not be conflated.**
+
+- `attempts` — send attempts that actually *completed* (`wp_mail()` returned). Incremented **after** the send, not at claim. Capped by `max_attempts`. This is "the mail server keeps rejecting this address."
+- `reclaims` — times the reaper rescued this row from a dead worker. Capped by `max_reclaims`. This is "the PHP process keeps dying while handling this row."
+
+Counting crashes as send attempts fails healthy mail. A worker killed by `max_execution_time` strands whichever row happened to be running when the cumulative batch budget expired — which is arbitrary, not a property of that row. Bump `attempts` at claim time and three unlucky timeouts permanently `failed` a customer's order confirmation that was never once actually attempted, with `last_error` empty because nothing ever failed.
+
+Counting them separately also keeps genuine poison-message handling: a row that reliably OOMs the worker exhausts `max_reclaims` and is failed with an honest error, rather than looping forever.
+
+The index is `(state, scheduled_at)`, **not** `(state, priority, scheduled_at)`. Nothing in v1.0.0 orders by `priority` — see §4.4.
+
+`payload` holds JSON, not `serialize()` — a serialized blob containing user-controlled strings is an unserialize-gadget surface, and JSON round-trips `$atts` losslessly. `headers` may legitimately be a string or an array; preserve whichever came in.
+
+Body size: an order email renders to roughly 40–60 KB of inline-styled HTML. 100 orders ≈ 5 MB in `LONGTEXT`. Acceptable, and bounded by retention.
+
+### `{$wpdb->prefix}studiou_wcmq_log`
+
+```sql
+id          BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT
+level       VARCHAR(10)      NOT NULL DEFAULT 'info'   -- debug|info|warning|error
+queue_id    BIGINT UNSIGNED  NULL
+message     TEXT             NOT NULL
+created_at  BIGINT           NOT NULL DEFAULT 0        -- signed, as in the queue table
+PRIMARY KEY (id)
+KEY created_at (created_at)
+KEY level      (level)
+```
+
+Timestamps are signed here too. Nothing subtracts them today, but the queue table's error-1690 bug (§4.2) came from exactly this kind of "it's a timestamp, it can't be negative" reasoning. Keep the two tables consistent so no future query can reintroduce it.
+
+`debug`-level rows are written only when the settings debug toggle is on.
+
+Clear-log is a **batched `DELETE`**, guarded by nonce + `manage_woocommerce` — not `TRUNCATE`.
+
+`TRUNCATE TABLE` requires the `DROP` privilege in MySQL. On locked-down shared hosting — which is the entire premise of this plugin — the site's DB user frequently lacks it, and Clear Log would throw instead of clearing. `DELETE FROM {table}` needs only `DELETE`. Batch it (`LIMIT 5000` in a loop) so a neglected log does not exceed `max_execution_time`, and note it does not reset `AUTO_INCREMENT`, which nothing here depends on.
+
+The same reasoning applies anywhere else the temptation arises: this plugin never issues DDL outside `dbDelta`.
+
+### Options
+
+- `studiou_wcmq_settings` — single array option, autoload **on** (read on every request by the interceptor)
+- `studiou_wcmq_db_version` — schema version, autoload **on**. `maybe_upgrade_db()` reads it on every request (§9), so autoloading off buys a dedicated `SELECT` per page load on any host without a persistent object cache — which is the target host. It is a short string; autoload it.
+- `studiou_wcmq_state` — `enabled` | `draining` | `disabled`, autoload on. **Defaults to `disabled`**: `get_option('studiou_wcmq_state', 'disabled')`, and any value not in the enum is coerced to `disabled`.
+
+  The default matters. An mu-plugin activates itself the moment the file lands — there is no activation step at which an admin confirms anything (§9). Defaulting to `enabled` means dropping the file silently starts intercepting mail on a live shop with an unconfigured rate, before anyone has asked the host what its real limit is. Defaulting to `disabled` makes install a no-op and forces an explicit opt-in on the settings page. On a plugin that can swallow customer email, fail closed.
+
+Everything else lives in the tables. Do not scatter individual options.
+
+---
+
+## 6. Settings
+
+Stored as one array in `studiou_wcmq_settings`. Sanitize on write, and defensively on read — an option array is a mutable surface.
+
+- `rate_per_minute` — int, **clamped to 1–60 on both write and read**, default 5. `interval = max(1, (int) ceil(60 / rate))` — `ceil`, and clamp before dividing. See §4.1; getting either wrong breaks the cap or fatals on PHP 8.
+- `handled_contexts` — array of `WC_Email` ids. Default `['customer_completed_order']` — the one the bug report is about. The settings page renders the list from `WC()->mailer()->get_emails()` (`includes/class-wc-emails.php:344`, returns `WC_Email` instances keyed by class name; store each `->id`, label with `->title`).
+
+  Building the list dynamically is not optional. WC 10.9 ships 21 `WC_Email` subclasses including ones the analysis never anticipated — `customer_fulfillment_created/updated/deleted`, `customer_pos_completed_order`, `customer_pos_refunded_order`, `customer_review_request` — and other plugins register their own. A hardcoded list would silently miss them.
+
+  Note `customer_reset_password` **is** a `WC_Email` and therefore selectable. Never default it on: queueing a password reset behind an order backlog is a support incident. WordPress core's own password reset does not route through `WC_Email` and is never queueable (§3.1b).
+
+- `max_attempts` — int, default 3. Completed send attempts before `failed`.
+- `max_reclaims` — int, default 5. Reaper rescues before `failed`. Deliberately higher than `max_attempts`: a crash is weaker evidence of a bad message than a rejection is. See §5.
+
+#### The selector cannot see every id the probe can capture
+
+`get_emails()` is necessary but **not sufficient**. `customer_partially_refunded_order` is the proof:
+
+- `WC_Email_Customer_Refunded_Order::trigger()` sets `$this->id = $partial_refund ? 'customer_partially_refunded_order' : 'customer_refunded_order'` (`class-wc-email-customer-refunded-order.php:195`). Our probe faithfully captures the partial id.
+- But `WC_Email_Customer_Partially_Refunded_Order` — the class that *reports* that id from `get_emails()` — is registered **only when the alpha `block_email_editor` feature is enabled** (`class-wc-emails.php:326-328`). It is off by default.
+
+So on a stock shop the selector offers `customer_refunded_order` and nothing else, the admin ticks it, a partial refund fires, the probe reports `customer_partially_refunded_order`, `Queue::handles()` returns `false` — and **partial-refund emails silently bypass the queue forever**, with no error and no way for the admin to opt in.
+
+Union the dynamic list with a small table of ids known to be produced at runtime but not always registered:
+
+```php
+const RUNTIME_ALIAS_IDS = array(
+    'customer_partially_refunded_order' => 'customer_refunded_order',  // id => parent shown in UI
+);
+```
+
+Render each alias as its own checkbox when `get_emails()` did not already supply it. Re-audit this constant on every WooCommerce major upgrade — it exists because WooCommerce mutates `$email->id` at trigger time, and nothing stops another email from doing the same.
+- `retention_sent_days` — int, default 7. 0 = keep forever.
+- `retention_failed_days` — int, default 30.
+- `retention_log_days` — int, default 14.
+- `claim_timeout` — int seconds, default 900. See §8.1; keep it well above `max_execution_time`.
+- `debug` — bool, default false. Gates `debug`-level log rows and `UtilsLog::log()`.
+
+The spec asks for a debug toggle rather than piggybacking on `WP_DEBUG`. Gate `UtilsLog::log()` on this option, not on `WP_DEBUG` — that is the one deliberate divergence from `studiou-wc-ord-print-statuses`'s copy of that utility.
+
+---
+
+## 7. Enable / disable state machine
+
+The spec says the feature is *"enable or disable (disabled after queue is empty)"*. Disabling must not strand mail that is already queued.
+
+```
+enabled   ──[admin clicks Disable]──▶  draining  ──[queue empty]──▶  disabled
+   ▲                                       │                            │
+   └───────────[admin clicks Enable]───────┴────────────────────────────┘
+```
+
+- `enabled` — interceptor queues handled contexts; worker drains.
+- `draining` — interceptor **passes everything through** (new mail sends synchronously); worker keeps draining what is already queued. The admin UI shows "Draining — N remaining".
+- `disabled` — interceptor passes everything through; nothing queued; worker idle.
+
+The `draining → disabled` transition is evaluated by the worker after each successful send, and by a lightweight `admin_init` check so a queue that emptied via retention or manual clearing still settles. Never transition to `disabled` while `state IN ('pending','sending')` rows remain.
+
+---
+
+## 8. Worker
+
+Hooked to the `studiou_wcmq_send` Action Scheduler action, group `studiou-wcmq`, one action per row, args as an **indexed** array `array($row_id)` — associative args break on newer PHP under Action Scheduler.
+
+```
+Worker::__construct()
+  add_action( 'wp_mail_failed', [$this,'capture'], 10, 1 );   // ONCE, never per send
+
+Worker::send( $row_id )
+  0. Run the reaper (§8.1).
+  1. Claim atomically:
+       UPDATE ... SET state='sending', claimed_at=UNIX_TIMESTAMP()
+       WHERE id=%d AND state='pending'
+     If affected_rows === 0 → another process already claimed it, or it is
+     already sent. Return silently. This is the duplicate-send guard; do not
+     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.
+  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.
+     Failure → if attempts >= max_attempts:
+                   state='failed', claimed_at=NULL, KEEP attachment dir (§3.3)
+               else:
+                   [ $slot, ] = Queue::with_slot_lock( now + backoff,
+                       fn( $s ) => /* UPDATE state='pending', claimed_at=NULL,
+                                      scheduled_at=$s */ );
+                   $action = as_schedule_single_action(
+                       $slot, 'studiou_wcmq_send', array( (int) $row_id ), 'studiou-wcmq' );
+                   if ( ! $action ) → log error; row stays 'pending' with no
+                                      action → the reaper repairs it (§2.1).
+                   log warning.
+  6. If state is 'draining' and no pending/sending rows remain → 'disabled'.
+```
+
+**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.
+
+**Retry timing.** Backoff is `$interval * ( 2 ** $attempts )`, capped at one hour.
+
+> `2 ** $attempts`, **not** `2 ^ $attempts`. In PHP `^` is bitwise XOR, not exponentiation, and it binds *looser* than `*` — so `$interval * 2 ^ $attempts` parses as `($interval * 2) ^ $attempts` and yields a nonsense backoff (for `interval=12, attempts=3`: `24 ^ 3` = `27` seconds instead of `96`). It never errors. It just quietly produces the wrong schedule.
+
+The retry is placed with `Queue::allocate_slot( now + backoff )` — §4.2 — which honours the backoff *as a floor* and still returns a slot that respects the rate cap. Do not schedule the retry at a bare `now + backoff`: that bypasses the ladder and can collide with whatever is already slotted there.
+
+**Re-slotting takes the lock and is guarded like enqueue.** The `UPDATE scheduled_at` publishes the allocation exactly as the `INSERT` does (§4.3), so it belongs inside `with_slot_lock()`. And `as_schedule_single_action()` can fail on retry just as at enqueue; because §2.1's invariant is enforced by the reaper, a failure here degrades to "repaired late" rather than "stranded forever" — but log it, because a shop hitting it repeatedly has a broken Action Scheduler.
+
+**Register the `wp_mail_failed` listener exactly once, at construction — never per send.** Under Action Scheduler many `Worker::send()` calls run in one PHP process. Adding a listener per send leaks one closure per row; and if the `remove_action` is skipped by an early `return` (the corrupt-payload bail above, or the affected-rows-zero bail), a `WP_Error` captured from row 3 is still in the buffer when row 4 succeeds — row 4 is then marked with row 3's error.
+
+Registering once and resetting the buffer *before* each send (step 3) makes both failure modes structurally impossible. This supersedes any per-send `add_action`/`remove_action` pairing.
+
+Note `wp_mail()` returns `false` without necessarily firing `wp_mail_failed`, and `EmailLogger` (§3.1c) also listens on `wp_mail_failed` — so treat `$ok === false` as failure regardless of whether an error was captured, and store `last_error` as nullable.
+
+### 8.1 The reaper — recovering stranded rows
+
+Two distinct rows get stranded, and the reaper must handle **both**.
+
+**(a) `sending` rows whose worker died.** A row claimed into `sending` has no path back. If the worker fatals, times out, or is OOM-killed mid-send, it stays `sending` forever: retention (Phase 7) sweeps only `sent`/`failed`, the state machine (§7) refuses `draining → disabled` while any `pending`/`sending` row remains, and `allocate_slot()` (§4.2) counts it as an occupied slot in perpetuity. Not hypothetical — a fatal inside `wp_mail()` is exactly what a mis-sized attachment or an SMTP plugin's uncaught exception produces.
+
+**(b) `pending` rows with no Action Scheduler action.** This violates the §2.1 invariant. It arises whenever a fatal lands in the window between `INSERT` (§3.3 step 1) and `as_schedule_single_action()` (step 4), or when scheduling fails on a retry (§8). Nothing will ever run these rows, and they block `draining → disabled` exactly as (a) does. A reaper that only covers `sending` leaves this class permanently unrecoverable — and it is the *more* likely of the two, because the enqueue window is on the hot path of every bulk.
+
+The reaper finds both classes:
+
+```php
+// (a) stale claims — bump reclaims, NOT attempts (see §5)
+SELECT id, reclaims FROM {prefix}studiou_wcmq_queue
+ WHERE state = 'sending'
+   AND claimed_at < UNIX_TIMESTAMP() - %d         // claim_timeout, default 900
+ LIMIT 200;
+
+// (b) pending rows that should already have run — §2.1 invariant violated
+SELECT id FROM {prefix}studiou_wcmq_queue
+ WHERE state = 'pending'
+   AND scheduled_at < UNIX_TIMESTAMP() - %d       // grace, default 300
+ LIMIT 200;
+```
+
+For each (b) row, confirm the action is genuinely gone before touching it:
+
+```php
+$row_id = (int) $row->id;      // ← CAST. $wpdb returns numeric strings.
+if ( ! as_has_scheduled_action( 'studiou_wcmq_send', array( $row_id ), 'studiou-wcmq' ) ) {
+    // re-slot and re-schedule
+}
+```
+
+> **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.
+
+**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
+[ $slot, ] = Queue::with_slot_lock( time(), fn( $s ) =>
+    /* UPDATE state='pending', claimed_at=NULL, reclaims=reclaims+1, scheduled_at=$s */ );
+
+$action = as_schedule_single_action( $slot, 'studiou_wcmq_send', array( $row_id ), 'studiou-wcmq' );
+if ( ! $action ) {
+    // log error; row stays 'pending' with no action; the next sweep retries it.
+}
+```
+
+An unguarded `as_schedule_single_action()` here re-creates precisely the orphan-`pending` row the reaper exists to repair — a recovery path that manufactures the fault it treats.
+
+Rows past `max_reclaims` (not `max_attempts`) go to `failed` with `last_error = 'worker repeatedly crashed'`.
+
+### 8.1a The reaper must be able to run when nothing else can
+
+Triggering the reaper only from `Worker::send()` is circular. Consider the wedge it exists to repair: every remaining row stranded in `sending`, zero `pending` rows, therefore **no `studiou_wcmq_send` action left to fire**, therefore no `Worker::send()`, therefore no reaper. The daily retention sweep does eventually rescue it — so the queue is not *permanently* wedged — but a shop can sit for up to 24 hours with every customer email frozen and no indication why.
+
+Give it three independent triggers:
+
+1. Top of every `Worker::send()` — free, catches the common case immediately.
+2. Its own recurring Action Scheduler action, **every 5 minutes**, registered with the same `as_next_scheduled_action()` + `$unique` guard as retention (Phase 7). Bounds the wedge at 5 minutes. The queries are two indexed `SELECT`s that normally return zero rows.
+3. `admin_init`, throttled by a 60-second transient — so an admin who notices the problem and loads wp-admin heals it immediately, without waiting for cron. This is also the trigger that works on a shop whose WP-Cron is broken, which is a plausible root cause of the wedge in the first place.
+
+Trigger 2 is the one that matters. Do not rely on the daily sweep.
+
+**Set `claim_timeout` well above `max_execution_time`** (default 900s vs a typical 60–300s). A timeout shorter than a real send reclaims a row whose original worker is still alive and about to succeed — and then it sends twice. The claim timeout trades a sliver of the duplicate-send guarantee for liveness; err long.
+
+Surface every reaper action as a `warning`-level log row. A shop reaping rows daily has a real problem worth seeing.
+
+**Action Scheduler availability.** It ships with WooCommerce. Because an mu-plugin loads before regular plugins, `as_schedule_single_action()` does not exist at file scope. Register everything on `init` (as the sample does), and check `function_exists('as_schedule_single_action')` before enqueueing. If absent — WooCommerce deactivated mid-flight — fall back to passing the mail through synchronously rather than queueing something nothing will ever drain.
+
+### 8.2 WooCommerce now has its own deferred email queue — turn it off
+
+**This is new since the analysis document was written and it changes the landscape.**
+
+WooCommerce 10.8.0 added `Automattic\WooCommerce\Internal\Email\DeferredEmailQueue` (`src/Internal/Email/DeferredEmailQueue.php`), gated behind a **user-visible feature toggle**: **WooCommerce → Settings → Advanced → Features → "Deferred emails"** (`deferred_transactional_emails`, `enabled_by_default => false`, `src/Internal/Features/FeaturesController.php:432`).
+
+When on, `WC_Emails::init_transactional_emails()` (`includes/class-wc-emails.php:129-147`) swaps `send_transactional_email` for `queue_transactional_email`. Emails are collected during the request and dispatched on `shutdown` as one Action Scheduler action each, hook `woocommerce_send_queued_transactional_email`, group `woocommerce-emails`, via `WC()->queue()->add()`.
+
+`WC()->queue()->add()` schedules an **async action with no timestamp** — Action Scheduler runs it as soon as it can, in batches. So this feature is **exactly variant A from the analysis**: it decouples sending from the request, but provides **no rate cap whatsoever**. A hundred queued emails still leave in a couple of AS batches, seconds apart. It does not solve our problem, and it must not be mistaken for a solution.
+
+It also does not *conflict* with us — with it on, the AS worker calls `WC_Email::send()`, `woocommerce_mail_callback` still fires, we still intercept, we still queue. But you get two queues in series: WooCommerce's AS action enqueues into our table, which schedules a second AS action. Double the AS table churn, an extra hop of latency, and a genuinely confusing debugging experience.
+
+**Require "Deferred emails" to be OFF.** Our queue supersedes it and does strictly more. Enforce it: on `admin_init`, if `apply_filters('woocommerce_defer_transactional_emails', FeaturesUtil::feature_is_enabled('deferred_transactional_emails'))` is true while our state is `enabled`, raise a persistent `admin_notices` warning linking to the Features screen. Do not silently flip the toggle for the user.
+
+Note `DeferredEmailQueue::init()` registers its AS handler **unconditionally**, so previously-scheduled jobs still drain after the feature is disabled. Turning it off mid-flight is safe.
+
+The legacy `WC_Background_Emailer` (`includes/class-wc-background-emailer.php`) still ships but is superseded by the above. Ignore it.
+
+---
+
+## 9. mu-plugin packaging
+
+All six sibling plugins are regular plugins. Three of their conventions break here.
+
+**Subdirectories are not auto-loaded.** WordPress globs only `wp-content/mu-plugins/*.php`, top level, no recursion. Ship a one-line stub that the user copies into `mu-plugins/` alongside the plugin directory:
+
+```php
+<?php
+// wp-content/mu-plugins/studiou-wc-mail-queue.php
+require_once __DIR__ . '/studiou-wc-mail-queue/studiou-wc-mail-queue.php';
+```
+
+Keep this stub in the repo at `loader/studiou-wc-mail-queue.php` and document the two-step copy in the readme. Getting this wrong produces a plugin that is silently, completely inert — with no error anywhere.
+
+**No activation hook.** `register_activation_hook()` never fires. Table creation runs on load via `maybe_upgrade_db()`: compare `studiou_wcmq_db_version` against `STUDIOU_WCMQ_VERSION`, run `dbDelta` on mismatch. It is idempotent. This is the pattern in `studiou-wc-free-photo-product`.
+
+**No `uninstall.php`, no deactivation hook.** Removal is manual. Document the two tables, three options, the Action Scheduler group and the attachments directory in the readme so a clean teardown is possible.
+
+**`Requires Plugins:` header is ignored** for mu-plugins. Guard on `class_exists('WooCommerce')` at runtime and surface an `admin_notices` warning, as every sibling does.
+
+---
+
+## 10. File layout
+
+```
+studiou-wc-mail-queue/
+  studiou-wc-mail-queue.php          main file: header, constants, HPOS, bootstrap
+  loader/
+    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-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
+  views/
+    admin-page.php                   tab shell
+    tab-settings.php
+    tab-queue.php
+    tab-log.php
+  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
+  docs/
+    analyza-woocommerce-email-throttling.md
+    sample-wc-mail-throttle.php
+    plans/implement-plan-00.md       (this file)
+```
+
+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.
+
+---
+
+## 11. Phased implementation
+
+Each phase ends in a state that can be verified by hand. Do not start a phase before its predecessor's acceptance criteria pass.
+
+### Phase 0 — Scaffold and bootstrap
+Main file with the plugin header, `defined('ABSPATH') || exit;`, the five constants, named-function HPOS declaration on `before_woocommerce_init`, `load_plugin_textdomain` on `plugins_loaded` priority 5, main class boot at priority 10, WooCommerce-missing notice. The `loader/` stub. Empty `includes/` classes wired up.
+
+*Accepts when:* the stub is copied into `mu-plugins/`, the plugin appears under **Plugins → Must-Use**, wp-admin loads with no notice or fatal, and deactivating WooCommerce shows the missing-WC warning instead of a crash.
+
+### Phase 1 — Schema
+`class-wcmq-db.php`: both tables via `dbDelta`, `maybe_upgrade_db()` on load, CRUD for queue and log rows. Attachments directory with `index.php` and `.htaccess`.
+
+*Accepts when:* both tables exist with the indexes from §5, bumping `STUDIOU_WCMQ_VERSION` re-runs `dbDelta` harmlessly, and a second page load does not.
+
+### Phase 2 — Settings
+`class-wcmq-settings.php` with defaults, sanitization on write and read. No UI yet — seed via `update_option()` and read back.
+
+*Accepts when:* a corrupted option array (wrong types, missing keys, `rate_per_minute = 9999`, `rate_per_minute = 0`, `rate_per_minute = -1`, `rate_per_minute = 'abc'`) still yields clamped, well-typed values and **never** reaches the `60 / $rate` division un-clamped (§4.1). A fresh install with no options at all reports state `disabled` (§5).
+
+### Phase 3 — Context and interceptor (highest risk)
+`woocommerce_mail_callback` is **verified present** in WC 10.9.4 (`includes/emails/class-wc-email.php:1234`), so this phase is no longer gated on a spike. Re-verify only after a WooCommerce major upgrade.
+
+`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()`.
+
+### 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).
+
+**Land the `EmailLogger` suppression in this phase, not Phase 5.** The moment `pre_wp_mail` returns `true`, `woocommerce_email_sent` fires with `$success = true` and `EmailLogger` writes a "sent" order note and log line (§3.1c). The suppression filters must be in place *in the same commit* as the flip. Otherwise this phase's own acceptance test — which drives ten real orders through the queue — stamps ten false "email sent" notes onto ten real orders, which is precisely the defect §3.1c exists to prevent. The *replacement* note (written on real send) belongs in Phase 5; the gap where neither note is written is confined to a dev phase in which nothing actually sends.
+
+*Accepts when:* completing 10 orders inserts 10 `pending` rows whose `scheduled_at` values are exactly `interval` apart, 10 actions appear under **WooCommerce → Status → Scheduled Actions** in group `studiou-wcmq`, `order_id` is populated on every row (§3.1), **no mail has been sent yet**, and **no order carries an email-sent note**.
+
+Also, specifically:
+- `rate_per_minute = 7` yields `interval = 9`, not `8` (§4.1). Assert on the arithmetic, not on a divides-evenly rate like 5 — that case passes under both `floor` and `ceil` and proves nothing.
+- **Enqueue at least two mails against a non-empty queue** and confirm no MySQL error 1690 (§4.2). A single enqueue into an empty table exercises none of the collision predicate; the bug appears on mail #2 of every bulk and on no unit test that forgets to seed a row.
+- Hand-insert a `pending` row scheduled an hour out, then enqueue new mail: the new mail must take a slot from **now**, not from behind the far-future row (§4.2).
+- Force `Queue::enqueue()` to fail three ways — a payload with invalid UTF-8 (`json_encode` returns `false`), a revoked `INSERT`, and a stubbed `as_schedule_single_action` returning `0`. Each must **send synchronously** and leave no row. Nothing is silently dropped (§2).
+
+### Phase 5 — Worker
+`class-wcmq-worker.php`: atomic claim, send, `wp_mail_failed` capture, retry with backoff, attachment cleanup. Suppress `EmailLogger`'s premature order note at enqueue (§3.1c) and write our own on real send.
+
+*Accepts when:* the 10 mails from Phase 4 arrive spaced by `interval`. The rendered body, `From:`, `Reply-To`, `Content-Type` and attachment set match a synchronously-sent control email **ignoring the headers that necessarily differ between any two sends** — `Date`, `Message-ID`, and any MIME `boundary`. Each order carries **exactly one** email-sent note, timestamped when the mail actually left, not at enqueue.
+
+Specifically:
+- An unroutable `to` drives the row through `attempts` to `max_attempts` and lands it in `failed` with `last_error` populated **and its attachment directory intact**.
+- 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 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).
+
+### Phase 6 — State machine
+`class-wcmq-state.php`. Enable/disable/drain per §7.
+
+*Accepts when:* clicking Disable mid-drain leaves the remaining rows draining while new orders send immediately, and the state settles to `disabled` only once the queue is empty.
+
+### Phase 7 — Retention
+`class-wcmq-retention.php`: a daily recurring sweep. Runs the reaper (§8.1) in the same pass. `0` means keep forever. Batch the deletes (500 rows per pass) so a year of neglect does not time out.
+
+**Every retention setting is in DAYS. Every timestamp column is in SECONDS.** Convert, every time:
+
+```sql
+DELETE FROM {prefix}studiou_wcmq_queue
+ WHERE state = 'sent'
+   AND sent_at < UNIX_TIMESTAMP() - ( %d * 86400 )    -- retention_sent_days * DAY_IN_SECONDS
+ LIMIT 500
+```
+
+> Written as `sent_at < now - retention_sent_days`, a 7-day retention deletes every sent row **seven seconds** after it is sent, along with its attachment directory. The queue empties itself continuously, the admin's Queue view is always blank, and the sweep reports success. The reaper snippets two blocks above use the identical `now - X` shorthand with an *already-seconds-valued* `claim_timeout`, which is exactly what makes this trivial to copy wrong. Multiply by `DAY_IN_SECONDS`.
+
+**Each rule also names its own timestamp column, and they are not the same column:**
+
+- `sent` rows → `sent_at < now - (retention_sent_days * DAY_IN_SECONDS)`
+- `failed` rows → **`created_at`**, not `sent_at`. A `failed` row has `sent_at IS NULL` by definition, so keying it off `sent_at` — the obvious parallel to the rule above — matches **nothing**, and failed rows accumulate forever while the sweep reports success. (`created_at` rather than a new `failed_at`: a row's whole retry lifetime is bounded by `max_attempts × 1h`, so creation time is within an hour of failure time, and it needs no extra column.)
+- log rows → `created_at < now - (retention_log_days * DAY_IN_SECONDS)`
+- attachment directories whose `attachment_dir` token matches no surviving row → delete (this also collects directories orphaned by a fatal, §3.3)
+
+**Register the recurring action exactly once.** Hooks are bound on `init` (§9), which runs on *every* request, so a bare `as_schedule_recurring_action()` there schedules a fresh action each time — thousands of duplicate daily sweeps within a day, each one deleting and each one reaping.
+
+```php
+if ( ! as_next_scheduled_action( 'studiou_wcmq_retention', array(), 'studiou-wcmq' ) ) {
+    as_schedule_recurring_action( time() + DAY_IN_SECONDS, DAY_IN_SECONDS,
+        'studiou_wcmq_retention', array(), 'studiou-wcmq', true );   // $unique = true
+}
+```
+
+Pass `$unique = true` *and* pre-check with `as_next_scheduled_action()` — the flag alone is a newer Action Scheduler addition and the pre-check also spares a DB write on every request.
+
+*Accepts when:*
+- A row sent **10 seconds ago** with `retention_sent_days = 7` **survives**. This is the day/second regression test; a row sent 8 days ago is deleted.
+- `failed` rows are actually swept — back-date `created_at`, leave `sent_at` NULL, confirm deletion.
+- `0` disables each rule independently.
+- Attachment directories for swept rows are gone from disk; those for surviving `failed` rows remain (§3.3); a directory whose token matches no row is collected.
+- A `sending` row with a stale `claimed_at` is re-slotted to a **future** slot, not fired immediately, and its `reclaims` — not `attempts` — increments.
+- A `pending` row whose AS action was deleted by hand is re-scheduled; a healthy `pending` row with a live action is **left alone** (this fails if `$row_id` is not cast to `int` before `as_has_scheduled_action()` — §8.1).
+- Wedge the queue: force every row to `sending` with a stale `claimed_at` and delete all `studiou_wcmq_send` actions. Within 5 minutes the recurring reaper must recover it **with no `Worker::send()` ever running** (§8.1a).
+- Loading wp-admin fifty times leaves exactly **one** scheduled `studiou_wcmq_retention` action and **one** `studiou_wcmq_reap` action.
+
+### Phase 8 — Admin UI
+`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".
+- **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.
+
+*Accepts when:* every destructive action is nonce-guarded and confirmed; a user without `manage_woocommerce` gets nothing from any endpoint; counts match direct SQL.
+
+### Phase 9 — i18n
+Wrap every user-facing string. Generate the `.pot`, translate `cs_CZ` in full, compile with `wp i18n make-mo languages/`.
+
+*Accepts when:* `WPLANG=cs_CZ` renders the whole admin page in Czech with no untranslated fragments, and the version bump has landed in the `.po` `Project-Id-Version` header.
+
+### Phase 10 — Load verification
+Per §12.
+
+---
+
+## 12. Verification
+
+There is no automated test harness in this repo. Verify in a real WordPress install with `WP_DEBUG` and `WP_DEBUG_LOG` on, watching `wp-content/debug.log`.
+
+**The load test is the acceptance test.** On staging, bulk-change 100+ orders to Completed and confirm:
+
+1. The bulk request returns promptly and does not approach `max_execution_time`.
+2. `wp_studiou_wcmq_queue` holds 100+ `pending` rows, `scheduled_at` ascending in exact `interval` steps.
+3. **WooCommerce → Status → Scheduled Actions**, group `studiou-wcmq`: pending count matches, spacing matches, nothing `failed`.
+4. Every mail eventually arrives. Count delivered against `state='sent'`. **Zero duplicates** — the single most likely defect, caused by the default trigger not being cleanly bypassed or by a non-atomic claim.
+5. Cross-check the host's MTA log for rate-limit rejections. There should be none. This is the whole point of the exercise.
+6. `wp_mail_failed` fires for nothing.
+7. A test order placed *during* the drain still receives its confirmation — late, per the §4 limitation, but received.
+
+**Reliable pacing needs reliable cron.** Action Scheduler rides WP-Cron, which only fires on page views. Before the load test:
+
+```php
+// wp-config.php
+define( 'DISABLE_WP_CRON', true );
+```
+```
+* * * * * cd /path/to/site && wp action-scheduler run >/dev/null 2>&1
+```
+
+Without this the queue drains in bursts on whatever page views happen to land, which will make a correct implementation look broken.
+
+**Before the load test, confirm "Deferred emails" is OFF** at WooCommerce → Settings → Advanced → Features (§8.2). With it on you will be watching two chained queues and misreading the result.
+
+```
+wp option get woocommerce_feature_deferred_transactional_emails_enabled
+```
+
+Note the **`_enabled` suffix**. `FeaturesController::feature_enable_option_name()` builds `woocommerce_feature_{$feature_id}_enabled` (`FeaturesController.php:1197-1204`), and the value is the string `yes` / `no`, not a boolean. Querying `woocommerce_feature_deferred_transactional_emails` reports "absent" whether the feature is on or off — a check that always passes, guarding nothing, causing exactly the misreading this step exists to prevent. (A feature may also override the name via its `option_key`; `deferred_transactional_emails` does not.)
+
+Expect the option to be absent or `no`, and the Scheduled Actions screen to show nothing in group `woocommerce-emails`.
+
+Also verify: WordPress password resets, low-stock alerts, and other non-handled mail still send synchronously and are never queued; a *WooCommerce* password reset is queued only if the admin explicitly ticked it; disabling mid-drain behaves per §7; a `GET_LOCK` timeout logs a warning and still sends.
+
+---
+
+## 13. Risks
+
+- ~~**`woocommerce_mail_callback` does not exist.**~~ **Resolved.** Verified in WC 10.9.4 at `includes/emails/class-wc-email.php:1234`, passing `$this`. Re-check on WooCommerce major upgrades; if it ever disappears, the fallback is the sample's trigger-detach approach, which costs the per-type selector.
+- **"Deferred emails" feature left on.** WooCommerce 10.8+ ships its own AS-based deferral with no rate cap (§8.2). Harmless but confusing in series with ours, and easy to mistake for a fix. Mitigated by the `admin_init` warning notice.
+- **A plugin replaces the `woocommerce_mail_callback` callable.** Bypasses `wp_mail()` entirely, so this plugin never sees the mail and it sends unthrottled. Undetectable from inside `pre_wp_mail`. Audit the shop's active plugins before rollout.
+- **Stale context leaking onto an unrelated `wp_mail()`.** Mitigated by consume-once plus the `to`+`subject` fingerprint (§3.1). Without the fingerprint this silently queues password resets.
+- **`woocommerce_email_sent` listeners fire early** (§3.1c). WooCommerce's own `EmailLogger` writes a premature order note on every queued mail — suppress via `woocommerce_email_log_add_order_note`. Third-party listeners cannot be fixed generically; audit them before rollout.
+- **`$email->id` mutates at runtime** on the refund email (§3.1d). Caching it produces silently mis-tagged queue rows.
+- **Silent mail loss on a failed enqueue.** Returning `true` from `pre_wp_mail` before the row and its AS action are durably persisted reports a successful send for a mail that exists nowhere. Strictly worse than the burst this plugin prevents. Guarded by §2: enqueue returns `false` on any failure and the interceptor falls through to a synchronous send. Explicitly tested in Phase 4.
+- **Duplicate sends.** The classic failure of this pattern. Guarded by the atomic claim in §8, by `pre_wp_mail` returning `true` (so WooCommerce never falls through to its own send), and by the re-entrancy flag. Explicitly tested in Phase 5 and step 4 of the load test. Note the stale-claim reaper (§8.1) deliberately trades a sliver of this guarantee for liveness — hence the long `claim_timeout`.
+- **The queue wedges permanently.** Two ways: a worker fatals mid-send and strands its row in `sending`; or a fatal between `INSERT` and `as_schedule_single_action()` strands a `pending` row with no action. Both block `draining → disabled`, occupy a slot forever, and are swept by nothing. Fixed by `claimed_at` + a reaper that covers **both** classes (§8.1), enforcing the §2.1 invariant. Rollback cannot help here — it does not run after a fatal.
+- **A deleted order fatals the worker after the mail has gone out.** `order_id > 0` does not mean the order exists; `wc_get_order()` returns `false` and `false->add_order_note()` is a fatal `Error` that takes down the rest of the Action Scheduler batch and leaves the row in `sending` to be reaped and **sent again** (§3.1c). Commit `sent` before any bookkeeping.
+- **The `transactional-emails` log records a delivery that never happened.** `EmailLogger` writes `status => 'sent'` at enqueue; if the deferred send later fails permanently, nothing reconciles it. Suppress both `EmailLogger` filters and re-emit from the worker on the real outcome (§3.1c).
+- **`BIGINT UNSIGNED` timestamps make `allocate_slot()` throw MySQL error 1690** the first time an occupied row precedes the candidate — i.e. on mail #2 of every bulk. Enqueue then fails, §2 falls through, and 100 orders become 100 unthrottled synchronous sends: the plugin reproduces the exact burst it exists to prevent, on first real use, with every empty-table test passing. Fixed by signed columns (§5) and a subtraction-free predicate (§4.2). **The highest-severity defect in this design.**
+- **The rate cap silently overshoots.** `floor(60/rate)` rounds the interval down and therefore the rate up (§4.1); a retry or reaper re-slot outside `with_slot_lock()` collides with a concurrent one (§4.3); a reaper that reclaims rows at their original past `scheduled_at` fires all of them at once (§8.1). Each independently defeats the plugin's only purpose while looking like it works.
+- **The recovery paths are the least-tested and most dangerous code.** Retry, reaper and enqueue-rollback all re-slot and re-schedule; every one of them must take the lock, allocate a *future* slot, and guard `as_schedule_single_action()`'s return. They run only when something has already gone wrong, so their bugs surface exclusively in production.
+- **`2 ^ $attempts` is XOR, not exponentiation** (§8), and binds looser than `*`. Produces a wrong backoff, never an error.
+- **`json_encode()` returns `false` without throwing** on invalid UTF-8 in a rendered order email (§2). An exception-only guard misses it and the mail is lost.
+- **One backed-off retry drags the whole queue an hour out.** The monotonic ladder from the sample cannot express a far-future reservation alongside near-term issuance. Fixed by `allocate_slot($earliest)` (§4.2). This is why the sample's counter was abandoned rather than hardened.
+- **Wrong From address on deferred mail.** §3.2. Silent, and invisible to any test that only checks arrival. Explicitly tested in Phase 3.
+- **Dead attachment paths.** §3.3. Manifests only for shops running an invoice plugin, so it will not appear on a clean staging box. Test with an attachment-producing plugin active, or synthesize one.
+- **The inert mu-plugin.** §9. A subdirectory mu-plugin without the top-level stub loads nothing and reports nothing. Phase 0's acceptance criterion exists solely to catch this.
+- **Slot allocation stranded in the future.** Not a risk any more, and for a different reason than earlier drafts claimed. There *is* no ladder head: `allocate_slot()` (§4.2) starts every search from a `max(now, $earliest)` floor and asks only whether candidates collide. It never computes `MAX(scheduled_at)`, so there is no counter to strand — a cleared queue, a far-future retry, and a stale option are all structurally incapable of dragging new mail forward. (Earlier revisions credited self-healing to `max()` over a derived head; that mechanism was removed and this bullet with it.)
+- **`with_slot_lock()` returning the wrong thing.** If the helper returns `$publish`'s result rather than the slot, callers reference an out-of-scope `$slot`, `as_schedule_single_action( null, … )` coerces the timestamp to `0`, and Action Scheduler drains the queue **immediately** — on enqueue, retry, and reaper alike. The rate cap is defeated in the one line that was written to enforce it. Return `[ $slot, $result ]` (§4.3).
+- **Customer PII served from `uploads/`.** Attachment copies are invoices. `.htaccess` is a no-op on Nginx, which is what the target hosting runs. Random 32-hex directory tokens (§3.3) plus a documented Nginx `location` deny; sequential `{row_id}` paths are enumerable.
+- **Partial-refund emails silently never queue.** `WC_Email_Customer_Refunded_Order` mutates `$this->id` to `customer_partially_refunded_order` at trigger time, but the class that *reports* that id is registered only under the alpha `block_email_editor` feature — so the settings selector can never offer it and `Queue::handles()` always returns `false` (§6).
+- **Retention deleting in seconds instead of days.** A days-valued setting subtracted from a seconds-valued timestamp deletes sent mail 7 seconds after send, silently, while reporting success (Phase 7).
+- **Queue table growth.** Bounded by retention (Phase 7). Note that the table holds rendered customer emails — retention is a GDPR control, not only a housekeeping one. Say so in the readme.
+- **`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).
+- **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.
+
+---
+
+## 14. Out of scope for v1.0.0
+
+External transactional SMTP/API (variant C in the analysis) — the queue is the prerequisite for it, and pairs with it later. Priority lanes (§4, column reserved). Multisite. Per-recipient throttling. `uninstall.php` (impossible for an mu-plugin).
+
+Retry of `failed` rows on a schedule — **manual Retry from the admin only**. Note this is precisely why §3.3 keeps the attachment directory alive for `failed` rows: a manual retry must be able to reconstruct the original mail. A future scheduled-retry feature inherits that constraint.
+
+---
+
+## 15. Release checklist
+
+Bump the version identically in: the `Version:` header, `STUDIOU_WCMQ_VERSION`, `CLAUDE.md`'s current-version line, `readme.md`'s version line plus a changelog entry, the `console.log` banner in `assets/js/admin.js`, and `languages/*.po`'s `Project-Id-Version`.
+
+Bump on **any** JS or CSS change — it is the cache-busting mechanism.
+
+Caveat for this shop: **studiou.cz strips the `?ver=` query string**, so a bump alone does not bust the browser cache there. Either tell the user to hard-refresh, or port `get_cache_busted_asset_url()` from `studiou-wc-product-cat-manage`, which copies `admin.js` to `admin-X.Y.Z.js` under `uploads/`, refreshes on source change, and cleans up stale copies. The versioned copies are gitignored build artifacts — always edit the source.
+
+Branch `1.0.0.studiou-wc-mail-queue`, tag `1.0.0.studiou-wc-mail-queue`, merge to `master`.

+ 87 - 0
studiou-wc-mail-queue/docs/sample-wc-mail-throttle.php

@@ -0,0 +1,87 @@
+<?php
+/**
+ * Plugin Name: WC Completed-Order Email Throttle
+ * Description: Posílá zákaznické e-maily o dokončení objednávky přes rate-limitovanou frontu (Action Scheduler), aby hromadná změna stavu nezahltila wp_mail / nepřekročila limit hostingu.
+ * Version:     1.0.0
+ * Requires Plugins: woocommerce
+ *
+ * Umístění: wp-content/mu-plugins/wc-mail-throttle.php  (mu-plugin = vždy aktivní)
+ * nebo jako běžný plugin do wp-content/plugins/.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+if ( ! class_exists( 'WC_Mail_Throttle' ) ) :
+
+class WC_Mail_Throttle {
+
+	const HOOK     = 'wcmt_send_completed_email';
+	const GROUP    = 'wcmt';
+	const SLOT_OPT = 'wcmt_next_slot';
+
+	/**
+	 * Odstup mezi jednotlivými e-maily v sekundách.
+	 * 12 s = max. 5 e-mailů / min. Uprav podle limitu svého hostingu.
+	 */
+	public static function interval() {
+		return (int) apply_filters( 'wcmt_interval_seconds', 12 );
+	}
+
+	public static function init() {
+		// 1) Odpojí standardní (synchronní) odeslání e-mailu o dokončení objednávky.
+		add_action( 'woocommerce_email', array( __CLASS__, 'detach_default' ), 99 );
+
+		// 2) Místo něj zařadí objednávku do fronty se staggered časem.
+		add_action( 'woocommerce_order_status_completed_notification', array( __CLASS__, 'enqueue' ), 10 );
+
+		// 3) Worker fronty – v naplánovaný čas e-mail skutečně odešle.
+		add_action( self::HOOK, array( __CLASS__, 'process' ) );
+	}
+
+	/** Sundá výchozí trigger z mailer objektu, aby se neposlal hned v rámci requestu. */
+	public static function detach_default( $wc_emails ) {
+		if ( isset( $wc_emails->emails['WC_Email_Customer_Completed_Order'] ) ) {
+			remove_action(
+				'woocommerce_order_status_completed_notification',
+				array( $wc_emails->emails['WC_Email_Customer_Completed_Order'], 'trigger' )
+			);
+		}
+	}
+
+	/** Zařadí objednávku do fronty; každý další e-mail dostane pozdější slot. */
+	public static function enqueue( $order_id ) {
+		$order_id = (int) $order_id;
+
+		// Fallback, kdyby Action Scheduler nebyl k dispozici – pošli hned.
+		if ( ! function_exists( 'as_schedule_single_action' ) ) {
+			self::process( $order_id );
+			return;
+		}
+
+		$now  = time();
+		$next = (int) get_option( self::SLOT_OPT, 0 );
+
+		// Slot = buď „teď", nebo navazuje na poslední naplánovaný (self-healing přes max()).
+		$slot = max( $now, $next );
+		update_option( self::SLOT_OPT, $slot + self::interval(), false ); // autoload = false
+
+		as_schedule_single_action( $slot, self::HOOK, array( $order_id ), self::GROUP );
+	}
+
+	/** Worker: znovu spustí standardní WooCommerce e-mail pro danou objednávku. */
+	public static function process( $order_id = 0 ) {
+		$order_id = (int) $order_id;
+		if ( ! $order_id ) {
+			return;
+		}
+
+		$emails = WC()->mailer()->get_emails();
+		if ( isset( $emails['WC_Email_Customer_Completed_Order'] ) ) {
+			$emails['WC_Email_Customer_Completed_Order']->trigger( $order_id );
+		}
+	}
+}
+
+add_action( 'init', array( 'WC_Mail_Throttle', 'init' ) );
+
+endif;

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

@@ -0,0 +1,9 @@
+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