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