implement-plan-00.md 90 KB

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. 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 before this document — it holds the root-cause analysis and the comparison of solution variants. ../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:

$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/):

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:

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:

// 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:

// 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:

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.

$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:

$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:

$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 failedfailed 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.

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

$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:

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.

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:

$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():

/**
 * @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:

[ $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 SELECTs 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

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

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_stateenabled | 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:

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:

// (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:

$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:

[ $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 SELECTs 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
// 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_onces 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 truebut 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 sendsDate, 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:

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.

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_Tables, 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:

// 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.