|
|
4 ore fa | |
|---|---|---|
| .. | ||
| assets | 4 ore fa | |
| docs | 4 ore fa | |
| includes | 4 ore fa | |
| languages | 4 ore fa | |
| loader | 4 ore fa | |
| views | 4 ore fa | |
| .gitignore | 4 ore fa | |
| CLAUDE.md | 4 ore fa | |
| build-package.ps1 | 4 ore fa | |
| readme.md | 4 ore fa | |
| studiou-wc-mail-queue.php | 4 ore fa | |
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>
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.
WC_Email::send() is probed via woocommerce_mail_callback to learn which email is about to be sent.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.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.Anything you did not tick sends immediately, exactly as it does today.
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.
wp-content/mu-plugins/studiou-wc-mail-queue/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.
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.)
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.
// wp-config.php
define( 'DISABLE_WP_CRON', true );
* * * * * cd /path/to/site && wp action-scheduler run >/dev/null 2>&1
Attachment copies (invoice and packing-slip PDFs) are written to
wp-content/uploads/studiou-wcmq-attachments/<random-32-hex>/.
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:
location ~* /uploads/studiou-wcmq-attachments/ { deny all; }
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_passwordappears 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.
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:
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.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/.
WP_DEBUG / WP_DEBUG_LOG, and the plugin's own Verbose logging.studiou-wcmq — check pending counts and the spacing between scheduled times.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 https://www.gnu.org/licenses/gpl-2.0.html.
Contact: Dalibor Votruba <dvotruba@quadarax.com> · https://www.quadarax.com
Initial release.
pre_wp_mail interception with per-email-type selection via a woocommerce_mail_callback probe.pending rows left with no scheduled action.EmailLogger's premature "sent" order note for queued mail, and writes an accurate one on real send.