|
|
@@ -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`.
|