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.
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.)
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.
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".
Every row in
pendinghas 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".
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.
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.
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.
A fingerprint mismatch is not the same as no context, and must be visible.
consume()must return three outcomes, not two:
- none — no WooCommerce email was in flight. Pass through silently; this is every password reset and every non-WC
wp_mail().- mismatch — a WC email was in flight, but its
to/subjectno longer match whatwp_mail()received. The values were captured atwoocommerce_mail_callback_params, beforeapply_filters('wp_mail', …)ran — so a plugin that rewrites the recipient or subject in awp_mailfilter (staging-mode redirectors, BCC archivers, some SMTP plugins) makes every handled mail mismatch, fall through, and send unthrottled. Collapsing this into "none" makes the plugin look installed and do nothing, with no log line to explain it. Log a warning naming the email id and which field diverged.- ok — matched; queue it.
The mismatch case is exactly the "silently defeated" failure the readme warns about; detection is what makes it diagnosable rather than a mystery MTA log.
Clear the was-queued flag on disabled/skipped emails too. woocommerce_email_disabled (class-wc-email.php:1150) and woocommerce_email_skipped (:1169) fire from send_notification() before send(), so the woocommerce_mail_callback probe never runs for them and never resets the flag. EmailLogger::log_non_send_outcome() also consults woocommerce_email_log_enabled on those paths (EmailLogger.php:246). So two same-id emails in one request — the first queued, the second skipped for want of a billing address — would leave the flag set with a matching id, and we would swallow WooCommerce's "not sent: no recipient" log line for the second. Hook both actions at priority 1 (before EmailLogger's 10) and clear only the queued flag there, not the whole context.
Known incompatibility. A plugin that replaces the woocommerce_mail_callback callable entirely bypasses wp_mail(), and therefore bypasses this plugin — mail sends synchronously and unthrottled. Nothing we can do at this layer. Document it.
pre_wp_mail is a filter chain, not an event. Honour the value you are handed.
public static function maybe_enqueue( $short_circuit, $atts ) {
$ctx = Context::consume( $atts['to'], $atts['subject'] ); // ALWAYS first
if ( null !== $short_circuit ) {
return $short_circuit; // someone already sent (true) or already failed (false)
}
…
}
WordPress runs pre_wp_mail callbacks in priority order and threads each return value into the next. A transactional-API mailer hooked below priority 10 returns true after it has delivered the message. Ignoring that and queueing anyway sends the mail twice — once immediately by them, once at our slot. Ignoring a false converts their failure into a reported success.
The consume() must still run first, or an early short-circuit leaves our context set and the next unrelated wp_mail() in the request inherits it — the stale-context bug this section exists to prevent.
wp_mail() is pluggable. A mailer that redefines the function never applies pre_wp_mail at all, and this plugin becomes silently, completely inert — no error, no log line, mail leaving unthrottled. On a host that rate-limits outbound mail an SMTP plugin is likely, not hypothetical. Detect it from admin_init and warn:
$defined_in = ( new ReflectionFunction( 'wp_mail' ) )->getFileName();
$is_core = wp_normalize_path( $defined_in ) === wp_normalize_path( ABSPATH . WPINC . '/pluggable.php' );
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 :1041WC_Emails::no_stock() — :1098, wp_mail() at :1128WC_Emails::backorder() — :1185, wp_mail() at :1214These 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.
woocommerce_email_sent fires at enqueue time — and core listenssend() 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. Match on the email id, not just the flag:
add_filter( 'woocommerce_email_log_enabled', array( __CLASS__, 'suppress' ), 10, 2 );
add_filter( 'woocommerce_email_log_add_order_note', array( __CLASS__, 'suppress' ), 10, 2 );
public static function suppress( $enabled, $email_id = '' ) {
if ( ! Context::was_queued() ) { return $enabled; }
if ( (string) $email_id !== Context::queued_context_id() ) { return $enabled; }
return false;
}
The flag alone leaks.
woocommerce_email_log_enabledis also applied fromEmailLogger::log_non_send_outcome()(EmailLogger.php:246), which runs onwoocommerce_email_disabledandwoocommerce_email_skipped. Both fire fromWC_Email::send_notification()beforesend()— so thewoocommerce_mail_callbackprobe never runs and never resets the flag.On a stock shop:
pending → processingfires the customer processing email (queued, flag set) and then the adminnew_orderemail. Admins routinely disable that one. We would silently swallow WooCommerce's "not sent: email type is disabled" log line for it.The order-note filter is correctly scoped even without this —
maybe_add_order_note()is only reachable fromhandle_woocommerce_email_sent(), which always ransend()— but scope both anyway.mark_queued()must therefore record the context id alongside the row id.
Inherit the privacy contract you suppressed. EmailLogger never writes raw recipient addresses or raw PHPMailer error strings into the transactional-emails log: it maps addresses through resolve_recipient() (username, or guest) and runs redact_emails() over error text, because that log is readable by anyone with manage_woocommerce. Having taken over writing the line, the worker owes the same. The plugin's own queue table storing the real address is fine and intended; the WC log is a different surface. Redact the order note's error text too — WooCommerce does.
Tag the note so it groups with WooCommerce's own: $order->add_order_note( $note, 0, false, array( 'note_group' => 'email_notification' ) ). Use the literal string; OrderNoteGroup lives in the Internal\ namespace and is not public API.
A single Notifier writes both on the real outcome:
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 workingThe notifier must be reachable from EVERY terminal state, not just the worker's happy paths. A row reaches
sent/failedfrom four places: worker success, worker max-attempts, worker corrupt-payload, and reaper max-reclaims. The last two are easy to miss — they are terminal states that never callwp_mail(). Miss either and the order carries no note andtransactional-emailscarries no line: the customer never got the email and the audit trail says nothing happened. That is the §3.1c "queue that lies about delivery" defect, inverted from a false positive into a silent hole. This is why the notifier is a standalone class both the worker and the reaper call, not a private worker method.Inherit the privacy contract.
EmailLoggermaps recipients throughresolve_recipient()(username, orguest) and runsredact_emails()over error text before writing totransactional-emails, because that log is readable by anyone withmanage_woocommerce. Having suppressed it, the notifier owes the same on both the log line and the order note's error text.
Guard the order note against a deleted order. order_id > 0 is not the same as "the order still exists". An order trashed and emptied between enqueue and send — twenty minutes is plenty — makes wc_get_order( $order_id ) return false, and false->add_order_note() is a fatal Error that kills the worker after the mail has already gone out. Under Action Scheduler that fatal also takes the rest of the batch with it, and the row is left in sending for the reaper to find and send again.
$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.
$email->id is not stableWC_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.
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.
$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: copy the attachments BEFORE the row becomes durable. An earlier revision of this plan said to INSERT first and rewrite the paths in a later UPDATE. That is wrong, and subtly so:
Between the
INSERTand theUPDATEthere is a durablependingrow whose payload points at the invoice plugin's temp paths. A PHP fatal in that window leaves it there permanently.rollback()cannot run after a fatal — which is exactly why §2.1 makes the reaper the safety net — and the reaper reschedules rows without re-validating attachments. It would faithfully resurrect a mail that can never send, burn its retry budget onwp_mail()failures, and bury it infailed.
The directory token is generated up front, so there is no chicken-and-egg to justify the wrong order. Inside the enqueue path:
$dir_token.uploads/studiou-wcmq-attachments/{$dir_token}/ and rewrite the payload's paths.json_encode the payload.INSERT the row with state='pending', attachment_dir = $dir_token, obtaining $row_id.as_schedule_single_action( $slot, …, array( (int) $row_id ), … ).If 2 or 3 fails: delete the directory, return false, send synchronously (§2). No row exists yet, so there is nothing to roll back. If 4 fails: delete the directory. If 5 fails: delete both.
The row is now never durable with paths we do not own, so the only fatal window left — between INSERT and as_schedule_single_action() — leaves a row the reaper can safely reschedule. The single cost is an orphaned directory when we bail before the INSERT; retention collects those.
Rollback cannot run after a fatal, so "directory with no surviving row" must be treated as garbage and collected. Retention owns this, not the reaper. The daily sweep's purge_orphan_attachment_dirs() deletes any token directory older than one day with no matching row — the one-day floor is what keeps it from racing an in-flight enqueue whose INSERT has not landed yet. The reaper (5-min cadence) deliberately does not touch directories: a shorter floor would risk that race, and reusing the one-day floor would gain nothing over retention. The cost is that a directory orphaned by a fatal between mkdir and INSERT — a rare fatal inside a millisecond window — can hold attachment PII on disk for up to ~a day. Accepted; documented in the readme's limitations. (An earlier draft said "the reaper and retention both" collect these — that was aspirational; retention alone does.)
Lifetime. Delete the directory when the row reaches sent, and when retention sweeps it. Do not delete it when the row reaches failed — failed rows are manually retriable from the admin (§11, Phase 8), and a retry that fires without its attachments sends a broken email that looks fine. Attachments for failed rows die with the row at retention_failed_days.
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.
wp_mail filter runs before pre_wp_mail, so the worker double-applies itwp_mail() opens with:
$atts = apply_filters( 'wp_mail', compact( 'to', 'subject', 'message', 'headers', 'attachments' ) );
$pre_wp_mail = apply_filters( 'pre_wp_mail', null, $atts );
The $atts we snapshot have therefore already been through every wp_mail filter on the site. When the worker later calls wp_mail() with that snapshot, they all run a second time — against their own prior output.
A plugin appending an unsubscribe footer appends it twice. A BCC-archive filter adds a duplicate Bcc:. A staging-mode filter that rewrites the recipient rewrites the already-rewritten address. Queued mail silently differs from synchronous mail, and nobody will connect it to this plugin.
wp_mail_from / wp_mail_from_name / wp_mail_content_type need no such treatment — they are idempotent, and §3.2 has already baked the resolved sender into the headers.
Detach the whole hook for the duration of the worker's send, and restore it in finally:
global $wp_filter;
$saved = $wp_filter['wp_mail'] ?? null;
if ( null !== $saved ) { unset( $wp_filter['wp_mail'] ); }
try {
return (bool) wp_mail( ... );
} finally {
Interceptor::$sending = false;
if ( null !== $saved ) { $wp_filter['wp_mail'] = $saved; }
}
remove_all_filters('wp_mail') is not an option — it would strip the callbacks for the rest of the request, and under Action Scheduler that means the rest of the batch. Restore, always.
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.
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.
$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.
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 columnsBIGINT UNSIGNED.MySQL evaluates a subtraction with any
UNSIGNEDoperand as unsigned. The moment an occupied row sits earlier than the candidate — which is the second mail of every bulk —q2.scheduled_at - c.candidateunderflows and MySQL raises error 1690,BIGINT UNSIGNED value is out of range(unlessNO_UNSIGNED_SUBTRACTIONhappens to be insql_mode, which is not the default and is not ours to assume).The query then fails,
Queue::enqueue()returnsfalse, 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.
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.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.
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 anUPDATEaffected-row count — leaves callers with no slot to schedule against. Writingfn( $slot ) => …and then referencing$sloton the next line does not work: an arrow function's parameter is scoped to the arrow function.$slotis simply undefined there,as_schedule_single_action( null, … )coerces to0, 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.
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:
as_unschedule_action() + re-schedule for each displaced row. Correct, and O(displaced) Action Scheduler writes per high-priority mail.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.
Two tables. Created and migrated by maybe_upgrade_db() on load (mu-plugins get no activation hook — see §9).
{$wpdb->prefix}studiou_wcmq_queueid 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_logid 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.
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.
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.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.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.
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.
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 corrupt → update_row_if_state('failed', last_error='corrupt payload',
claimed_at=NULL; WHERE state='sending');
if 1 row changed → Notifier::annotate(row, false, 'corrupt payload');
return. // terminal state — the order MUST get a note (§3.1c)
3. $this->last_error = null; // reset buffer BEFORE the send
4. Interceptor::$sending = true;
try { $ok = wp_mail( ... ); }
finally { Interceptor::$sending = false; }
$error = $this->last_error; $this->last_error = null; // consume
$attempts = (int) $row->attempts + 1; // computed, not written yet
5. Success → ONE state-guarded UPDATE: state='sent', sent_at=now, claimed_at=NULL,
last_error=NULL, attempts=$attempts; WHERE state='sending'
← check affected === 1. If not, the mail HAS gone out but the row
was deleted/reclaimed or the DB errored: log loudly, do NOT delete
the attachment dir, do NOT write a success note. The reaper may
resend it — keep it sendable.
then: delete attachment dir, Notifier::annotate(row, true, null).
Notifier never throws (it try/catches internally).
Failure → if attempts >= max_attempts:
update_row_if_state('failed', claimed_at=NULL, attempts=$attempts;
WHERE state='sending'); KEEP attachment dir (§3.3);
if 1 row changed → Notifier::annotate(row, false, $error);
else:
[ $slot, ] = Queue::with_slot_lock( now + backoff,
fn( $s ) => /* UPDATE state='pending', claimed_at=NULL,
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'.
Every terminal write is state-guarded on sending. Use UPDATE … WHERE id = %d AND state = 'sending', not a bare WHERE id. Between the claim and the commit the row can be deleted from the admin or reclaimed by a parallel reaper; a bare update reports success anyway ($wpdb->update() returns 0 changed rows as distinct from false, and a plain "did it error?" check treats 0 as fine). The worker would then delete attachments and write an order note for a row that no longer exists. Provide DB::update_row_if_state() and check for exactly one affected row before any post-commit bookkeeping. The same guard closes the reaper's infinite-loop bug — a re-slot whose UPDATE changed nothing must not schedule an action, or the row stays sending with unpersisted reclaims, and every sweep reschedules a fresh dead action forever without ever tripping max_reclaims.
attempts is folded into the commit, never written separately. A standalone UPDATE … SET attempts = attempts + 1 after wp_mail() returns but before the state commit widens exactly the window that "commit first, bookkeep second" exists to close: a fatal between the two leaves a row that has been attempted but not resolved. Because the worker holds the claim, nobody else writes that column, so computing $attempts = $row->attempts + 1 in PHP and setting it explicitly in the one terminal UPDATE is safe and removes the window entirely.
Decode defensively. §2 guards json_encode() returning false; the read side must be symmetric. json_decode() returns null on a truncated or corrupted LONGTEXT — a MySQL max_allowed_packet truncation, a botched migration, a partially-written UPDATE from a crashed enqueue. Feeding null into wp_mail( $payload['to'], … ) throws on array access. Fail the row loudly instead; a corrupt payload is not retriable, so do not burn attempts on it.
$row_id arrives from Action Scheduler as an int (it round-trips through json_decode of the action's args), so Worker::send( $row_id ) is safe. Rows read back from $wpdb are numeric strings — cast before handing them to any Action Scheduler function. See §8.1.
Retry timing. Backoff is $interval * ( 2 ** $attempts ), capped at one hour.
2 ** $attempts, not2 ^ $attempts. In PHP^is bitwise XOR, not exponentiation, and it binds looser than*— so$interval * 2 ^ $attemptsparses as($interval * 2) ^ $attemptsand yields a nonsense backoff (forinterval=12, attempts=3:24 ^ 3=27seconds instead of96). 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.
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 witharray( (int) $row_id )→"[123]". A$wpdbresult givesarray( "123" )→"[\"123\"]". The hashes differ,as_has_scheduled_action()returnsfalsefor a perfectly healthy row, and the reaper duplicates its action — two workers race the same row on every sweep. Cast every id that crosses into an Action Scheduler call.(a) and (b) are different faults and must not share a counter.
Only (a) is evidence that a worker died holding this row. (b) is a row that was never claimed and never attempted — its Action Scheduler action simply went missing. Charging (b) to
reclaimsmeans a queue that repeatedly loses actions (a pruned AS table, a failed retry re-schedule) eventually marks perfectly deliverable mailfailedwithlast_error = 'worker repeatedly crashed'— a diagnosis that is both false and unactionable.
recover_crashed()bumpsreclaimsand honoursmax_reclaims.repair_orphan()bumps nothing and simply re-slots. There is no runaway risk: the re-slot pushesscheduled_atinto the future, so the next sweep will not see the row until the grace window elapses again.
Every recovered row is re-slotted, not merely un-claimed. A row reclaimed with its original scheduled_at now lies in the past, so Action Scheduler fires it on the very next pass — and if the reaper recovers thirty rows at once, all thirty go out simultaneously. That is the burst this plugin exists to prevent, delivered by its own recovery path. Re-slot through the same locked allocator, and guard the schedule call exactly as enqueue and retry do:
[ $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'.
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:
Worker::send() — free, catches the common case immediately.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.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.
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.
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.
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, update_row_if_state
class-wcmq-settings.php option get/set, defaults, sanitization, RUNTIME_ALIAS_IDS
class-wcmq-state.php enabled/draining/disabled machine
class-wcmq-context.php woocommerce_mail_callback probe, consume (none/mismatch/ok)
class-wcmq-queue.php allocate_slot, with_slot_lock, enqueue, reschedule, attachments
class-wcmq-interceptor.php pre_wp_mail, payload snapshot, EmailLogger suppression
class-wcmq-notifier.php terminal-outcome order note + WC log line (see §3.1c)
class-wcmq-worker.php studiou_wcmq_send handler, claim, dispatch, retry, backoff
class-wcmq-reaper.php studiou_wcmq_reap — rescues stranded rows (own class)
class-wcmq-retention.php daily sweep of queue rows, log rows, attachment dirs
class-wcmq-admin.php menu, tabs, admin-post form handlers
views/
admin-page.php tab shell (state banner + counts + tab bar)
tab-settings.php
tab-queue.php plain wp-list-table markup, bulk retry/delete
tab-log.php plain wp-list-table markup, level filter, clear
assets/css/admin.css
assets/js/admin.js
languages/
studiou-wc-mail-queue.pot
studiou-wc-mail-queue-cs_CZ.po
studiou-wc-mail-queue-cs_CZ.mo (hand-compiled; not present in this repo)
loader/studiou-wc-mail-queue.php mu-plugins/ stub
.gitignore excludes the vendored WC tree
docs/
analyza-woocommerce-email-throttling.md
sample-wc-mail-throttle.php
Wiki/woocommerce-emailing.md distilled WC reference
plans/implement-plan-00.md (this file)
As-built deviations from the layout above, all deliberate:
class-wcmq-notifier.php is new and not in the original plan. It exists because a terminal-outcome note must be written from four call sites (§3.1c); a private worker method could not serve the reaper's max-reclaims path.class-wcmq-reaper.php is split out of retention. The reaper has three independent triggers (per-send, recurring, admin_init) and does not belong inside the daily sweep.class-wcmq-*-list-table.php. The queue and log views render <table class="wp-list-table"> inline. Less machinery, no untestable WP_List_Table subclass.admin-post.php; handlers still verify nonce + manage_woocommerce. This sidesteps output-buffer and JSON-envelope plumbing entirely. Phase 8's acceptance criteria (nonce, cap, correct counts) still hold.Naming follows the repo: constants STUDIOU_WCMQ_*, CSS prefix studiou-wcmq-, JS namespace studiouWcmq, nonce studiou-wcmq-nonce, error-log prefix STUDIOU WC MAIL:, text domain studiou-wc-mail-queue, AS group studiou-wcmq, AS hook studiou_wcmq_send.
Managers register their own hooks in their constructors. The main class only guards on WooCommerce, require_onces the includes, and instantiates. Nothing else.
Each phase ends in a state that can be verified by hand. Do not start a phase before its predecessor's acceptance criteria pass.
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.
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.
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).
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 — the line comes before any queue/pass-through decision, so it must appear even in this phase where nothing is queued; a WooCommerce password reset writes customer_reset_password; a wp-admin password reset and a low-stock alert produce no such line (status none, passed through silently). All still arrive normally. No mail is queued or lost in this phase. Verify the captured From: matches WooCommerce → Settings → Emails, not wordpress@. Verify the fingerprint rejects a stale context by temporarily setting one by hand and firing an unrelated wp_mail(). Add a wp_mail filter that rewrites the subject, complete an order, and confirm a warning log line naming the divergence — not silence.
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.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).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).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:
to drives the row through attempts to max_attempts and lands it in failed with last_error populated and its attachment directory intact.now + backoff — assert scheduled_at is at least interval from every other pending row (§4.2, §4.3).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).last_error = 'corrupt payload', does not consume an attempt, and still writes a failure order note — a terminal state must never leave the order silent (§3.1c).max_reclaims by repeated crashes lands in failed with a failure order note — the reaper's terminal path, easy to miss.sent, failed via max-attempts, failed via corrupt-payload, failed via max-reclaims — leaves exactly one note and one transactional-emails log line; the log line carries a username or guest, never a raw address, and any error text has addresses redacted.customer_partially_refunded_order — which requires the alias from §6, and will fail if the selector was built from get_emails() alone.sent or the commit reports 0 rows and the attachment dir is left intact for the reaper, and the worker does not fatal (§3.1c).UPDATE changes 0 rows, so it does not delete attachments or write a note for the vanished row.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.
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 identicalnow - Xshorthand with an already-seconds-valuedclaim_timeout, which is exactly what makes this trivial to copy wrong. Multiply byDAY_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.)created_at < now - (retention_log_days * DAY_IN_SECONDS)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:
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.failed rows remain (§3.3); a directory whose token matches no row is collected.sending row with a stale claimed_at is re-slotted to a future slot, not fired immediately, and its reclaims — not attempts — increments.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).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).studiou_wcmq_retention action and one studiou_wcmq_reap action.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.
WC()->mailer()->get_emails(), enable/disable control.Retry is a destructive action and needs a state guard, not just a confirm dialog.
sent row. Its attachment directory was deleted on send, so the resend arrives with missing files and the customer receives the email twice. Select-all on the "All" or "Sent" filter must be harmless. This is one misclick from mailing the whole customer list again.sending with claimed_at newer than claim_timeout — for either action. Retrying it delivers twice; deleting it pulls the attachment directory out from under a live wp_mail(). Past the claim timeout the worker is presumed dead (same rule as §8.1) and the row is fair game.pending row already owns an Action Scheduler action; reschedule() without as_unschedule_all_actions() leaves two actions for one row.reclaims as well as attempts. A manual retry is a fresh start; inheriting an accumulated crash budget means one more crash fails the row.Order links must come from WC_Order::get_edit_order_url(), never a hardcoded page=wc-orders URL — that admin page does not exist when order storage is legacy posts. Guard on the order still existing.
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.
Wrap every user-facing string. Generate the .pot and translate cs_CZ in full.
Compiling .mo is a hand-off, not a build step. The dev box has no php, no WP-CLI, and no msgfmt (see CLAUDE.md § Translations), so neither wp i18n make-mo languages/ nor the sibling plugins' php generate-mo.php fallback can run here. Deliver updated .po files and ask the user to compile — Poedit locally, or msgfmt on the server. Never hand-craft the binary.
A stale .mo serves the old strings silently, so an un-flagged .po edit presents as a translation that simply did not take effect.
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.
Per §12.
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:
max_execution_time.wp_studiou_wcmq_queue holds 100+ pending rows, scheduled_at ascending in exact interval steps.studiou-wcmq: pending count matches, spacing matches, nothing failed.state='sent'. Zero duplicates — the single most likely defect, caused by the default trigger not being cleanly bypassed or by a non-atomic claim.wp_mail_failed fires for nothing.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.
woocommerce_mail_callback does not exist.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.admin_init warning notice.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.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.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.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.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.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.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.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.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.allocate_slot($earliest) (§4.2). This is why the sample's counter was abandoned rather than hardened.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).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.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).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.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).failed without ever calling wp_mail(), so it is easy to leave them without a Notifier::annotate(). The order then records nothing about a mail the customer never received — the audit-trail hole inverted from the previous risk. The notifier is a shared class specifically so all four terminal paths reach it (§3.1c).UPDATE changes no row must not schedule an action; otherwise the row stays sending, reclaims never persists, and every 5-minute sweep schedules a fresh dead action without tripping max_reclaims (§8.1). Low probability (needs a DB error or a concurrent mutation) but unbounded if it hits.wp_mail filter that rewrites recipient/subject makes every handled mail fall through unthrottled and silent. consume() distinguishes none from mismatch and logs a warning on the latter (§3.1), so an inert plugin is diagnosable instead of a mystery.rate_per_minute below it. Put this in the readme; a misconfigured cap reproduces the original bug with extra steps.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.
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.