This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This directory currently contains only a specification and design docs — no plugin code. The first task here is to write the plugin from scratch.
readme.md — the initial specification (feature list, target versions).docs/analyza-woocommerce-email-throttling.md — Czech-language root-cause analysis of the problem, comparison of five solution variants, and implementation notes. Read this first. Variant B (rate-limited Action Scheduler queue) is the chosen approach.docs/sample-wc-mail-throttle.php — reference implementation of variant B as a single-file mu-plugin. Working proof of concept, not the deliverable.docs/plans/implement-plan-00.md — the implementation plan. Resolves the two design questions below, specifies the schema, phases the build, and lists the sharp edges. Start here.Bulk-changing 100+ WooCommerce 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 some customers never get their notification. The fix is to decouple sending from the request and pace it behind a hard per-minute cap.
Per readme.md, v1.0.0 ships as an mu-plugin with:
Interception: hybrid. Short-circuit wp_mail() via the pre_wp_mail filter, but tag each call with the WooCommerce email's identity by probing the woocommerce_mail_callback filter (which passes the WC_Email instance). Mail whose context is not in the admin's handled-types list passes straight through. This gives both the spec's literal wp_mail() interception and its per-order-status selector.
Do not key the context off woocommerce_order_status_*_notification hooks — one hook can fire several emails (pending_to_processing triggers both the customer's processing email and the admin's new-order email), so the hook name can't distinguish them.
Queue: custom table + Action Scheduler. {$wpdb->prefix}studiou_wcmq_queue owns payload/state/attempts; Action Scheduler owns only the staggered wake-ups, one action per row. The admin queue view, log view, and retention sweep need a table we control — and AS prunes its own completed actions after 30 days.
Versions. WP 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 a typo.
docs/Wiki/woocommerce/ holds the full WooCommerce 10.9.4 tree. Every hook the design depends on was verified against it — grep there rather than guessing or reaching for training-data recall. Re-verify after a WooCommerce major upgrade.
It is a local working copy: untracked by git, and not ignored either. A fresh clone will not have it, and it has gone missing mid-project once already. file:line citations in the plan are relative to a WooCommerce install root, not to this repo. Treat the distilled reference below as the durable artefact; treat the tree as a convenience that may not be there.
docs/Wiki/woocommerce-emailing.md is a distilled reference extracted from that tree: the full send pipeline, all 21 WC_Email classes and their trigger hooks, order-status registration and the transition hook sequence, DeferredEmailQueue, EmailLogger, and a complete filter/action inventory with file:line refs. Read it before touching email or order-status code — it will save a grep session, and several behaviours changed in WC 10.8/10.9 in ways that older tutorials (and this project's own analysis doc) get wrong.
WC_Email lives at includes/emails/class-wc-email.php, not includes/class-wc-email.php.
Things that will silently produce a plausible-looking but wrong implementation. All are detailed in the plan:
BIGINT, never BIGINT UNSIGNED. The slot allocator compares scheduled_at on both sides of a candidate; unsigned subtraction underflows into MySQL error 1690 the first time an occupied row precedes the candidate — i.e. mail #2 of every bulk. Enqueue then fails, the fall-through sends synchronously, and the plugin reproduces the exact burst it exists to prevent. Every empty-table test passes.true from pre_wp_mail before the row and its Action Scheduler action are durably persisted. A failed enqueue reported as a successful send is silent, permanent mail loss. Note json_encode() returns false without throwing on invalid UTF-8 — an exception-only guard misses it.pending row must have a scheduled Action Scheduler action. That invariant, not rollback, is what prevents mail loss — rollback does not run after a PHP fatal. The reaper must repair both stranded sending rows and pending rows with no action, must re-slot them into a future slot rather than firing them all at once, and must be able to run when no Worker::send() can (give it its own recurring action).ceil(60/rate), never floor. And every re-slot — enqueue, retry, reaper — takes the same lock, because the UPDATE/INSERT is what publishes the allocation. The lock helper must return the slot: as_schedule_single_action(null, …) coerces to timestamp 0, which Action Scheduler runs immediately.int before any Action Scheduler call. $wpdb returns numeric strings; AS matches args by hashing json_encode(), so ["123"] and [123] are different actions.DAY_IN_SECONDS or a 7-day retention deletes sent mail after 7 seconds — silently, while reporting success.attempts after a completed send, never at claim; bump reclaims when the reaper rescues a row. Otherwise a worker killed by max_execution_time burns a healthy mail's retry budget without ever having attempted it.wp_mail() succeeds. order_id > 0 does not mean the order still exists; wc_get_order() returns false and false->add_order_note() is a fatal that kills the rest of the Action Scheduler batch after the mail went out, leaving the row to be reaped and sent again. Commit sent first, bookkeep second.pre_wp_mail runs before wp_mail_from is applied. WC_Email::send() attaches its From filters, calls wp_mail(), then detaches them (class-wc-email.php:1228-1245) — so by the time a deferred mail actually sends, they are gone and it goes out as wordpress@. Resolve the sender at enqueue time and bake it into the stored headers. Note $headers is a string for WooCommerce emails, carrying Content-Type and Reply-to but never From.woocommerce_mail_callback to a non-wp_mail mailer, our context is set but pre_wp_mail never fires — and the next unrelated wp_mail() inherits it. Consume the context once, and fingerprint it against to + subject.wp_mail() to send. Guard it, or mail is enqueued forever.UPDATE ... WHERE id=%d AND state='pending', then check affected rows). Never SELECT-then-UPDATE.woocommerce_email_sent fires at enqueue time, not send time, because we return true from pre_wp_mail. This is not hypothetical: WooCommerce's own EmailLogger (10.9) listens on it and writes an order note saying the mail was sent. Suppress it with woocommerce_email_log_add_order_note / woocommerce_email_log_enabled and write our own note when the mail actually leaves.$email->id is mutated at runtime by WC_Email_Customer_Refunded_Order, which swaps between customer_refunded_order and customer_partially_refunded_order. Read the id at woocommerce_mail_callback time; never cache it per instance.WC 10.8+ ships DeferredEmailQueue behind the "Deferred emails" feature toggle (Settings → Advanced → Features; off by default). It dispatches one Action Scheduler action per email via WC()->queue()->add() — an async action with no timestamp, so Action Scheduler runs them in batches as fast as it can. It decouples sending from the request but applies no rate cap. That is variant A from the analysis, and it does not solve the hosting rate-limit problem.
It does not conflict with this plugin — it just chains two queues in series. Require it OFF, and warn from admin_init if it is on.
WC_Emails::low_stock(), no_stock(), and backorder() call wp_mail() directly, bypassing WC_Email::send() (includes/class-wc-emails.php:1041, :1128, :1214). They carry no context and always pass through — correct behaviour for admin stock alerts, and they never appear in the settings selector because they aren't WC_Email subclasses.
Build the handled-types selector from WC()->mailer()->get_emails(), never a hardcoded list: WC 10.9 has 21 WC_Email subclasses, including fulfillment, POS, and review-request emails. customer_reset_password is among them — selectable, but never default it on.
get_emails() is necessary but not sufficient. WC_Email_Customer_Refunded_Order::trigger() swaps its own id to customer_partially_refunded_order on a partial refund, but the class that reports that id is registered only when the alpha block_email_editor feature is on (class-wc-emails.php:326-328). On a stock shop the selector can never offer it, so partial-refund emails silently bypass the queue no matter what the admin ticks. Union the dynamic list with a small RUNTIME_ALIAS_IDS table, and re-audit it on WooCommerce major upgrades.
The hard cap comes from timestamps, not cron frequency. Each queued mail gets a slot = max(now, last_slot), and last_slot advances by interval seconds. Action Scheduler never runs an action before its scheduled time, so the spacing is deterministic regardless of how often cron fires. The counter self-heals via max() — once the queue drains, pacing resets to now.
interval = 12s ⇒ 5 mails/min. A single shared slot counter across all handled email types keeps the rate limit global, not per-type.
Notes carried over from the analysis, worth preserving:
as_schedule_single_action() is missing.update_option(..., false) — autoload off.array($order_id)), not associative — associative args break on newer PHP.DISABLE_WP_CRON plus a system cron running wp action-scheduler run every minute. Without it the queue drains in bursts.The six sibling plugins in this repo are all regular plugins. An mu-plugin behaves differently in ways that break the conventions below if applied blindly:
wp-content/mu-plugins/*.php — top level, no recursion. A multi-file plugin needs a one-line loader stub dropped directly in mu-plugins/ that requires the real entry point. Plan the install instructions around this.register_activation_hook() never fires. Custom DB tables must be created on load. Use the maybe_upgrade_db() pattern from studiou-wc-free-photo-product — compare a stored *_db_version option against the version constant and run dbDelta on mismatch. It is idempotent.Requires Plugins: header is ignored. Guard on class_exists('WooCommerce') at runtime instead.init or later, as the sample does. Do not call as_*() functions at file scope.This is a monorepo of independent WordPress/WooCommerce plugins for the studiou.cz shop, one directory each, sharing no code. There is no build system, no dependency manager, no test framework, and no linter — plugins are plain PHP loaded by WordPress and verified by hand in wp-admin.
Siblings worth reading for precedent:
studiou-wc-ord-print-statuses — the closest architectural model. Manager-based structure, UtilsLog utility, HPOS handling, custom order statuses. Its CLAUDE.md is the most thorough in the repo.studiou-wc-free-photo-product — custom DB table with automatic dbDelta schema upgrades; chunked AJAX; the maybe_upgrade_db() pattern.studiou-wc-product-cat-manage — admin settings pages, tabbed UI loaded on demand via AJAX, batch processing, the cache-busted asset helper.Work happens on a branch named <version>.<plugin-slug> — this plugin's branch is 1.0.0.studiou-wc-mail-queue. Tags follow <version>.<plugin-slug> (newer) or <plugin-slug>.v.<version> / <plugin-slug>_v<version> (older, inconsistent). Merges go to master.
Match these unless the mu-plugin constraints above force otherwise.
Directory name = plugin slug = text domain = main-file basename. So studiou-wc-mail-queue/studiou-wc-mail-queue.php, text domain studiou-wc-mail-queue. Classes live in includes/class-*.php, admin templates in views/, assets in assets/css/ and assets/js/, translations in languages/.
Pick a short constant/prefix abbreviation and use it everywhere — constants STUDIOU_WCMQ_*, CSS classes studiou-wcmq-, JS namespace studiouWcmq, nonce studiou-wcmq-nonce, error-log prefix STUDIOU WC MAIL:. Siblings use STUDIOU_WCPCM_, STUDIOU_WCFPP_, STUDIOU_WC_OPS_.
The main file, in order: plugin docblock header → defined('ABSPATH') || exit; (or if (!defined('WPINC')) { die; } — both are in use) → version and path constants → HPOS compatibility declaration → main class → hook registration.
Declare HPOS compatibility from a named function (not a closure) so it can be remove_action-ed:
function studiou_wc_mail_queue_declare_hpos_compat() {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', STUDIOU_WCMQ_FILE, true);
}
}
add_action('before_woocommerce_init', 'studiou_wc_mail_queue_declare_hpos_compat');
Load the text domain on plugins_loaded priority 5, boot the plugin at default priority 10. This guarantees translations are available before any class instantiates.
The main class does three things: bail with an admin_notices warning if class_exists('WooCommerce') is false, require_once each includes/class-*.php, and instantiate one manager object per concern. Managers register their own hooks in their constructors. Nothing else lives in the main class.
Copy includes/utils-log.php from studiou-wc-ord-print-statuses. UtilsLog::log() writes to the error log gated on WP_DEBUG; UtilsLog::message($msg, $type) queues a dismissible admin notice in a per-user transient (300s TTL) so it survives the redirect after a form post. Note this plugin's spec calls for a debug toggle in settings rather than relying on WP_DEBUG — gate log() on the option instead.
Every handler: verify nonce, check capability (manage_woocommerce), clean the output buffer before responding, wrap in try/catch, and reply through wp_send_json_success() / wp_send_json_error().
Sanitize on the way in (absint(), sanitize_text_field(), sanitize_key()), escape on the way out (esc_html(), esc_attr(), esc_url()). Use $wpdb->prepare() for all SQL. Wrap every user-facing string in __() / esc_html_e() with the text domain.
Batch them (5–10 items per AJAX round-trip) with a progress bar, rather than risking max_execution_time. Destructive actions get a confirmation dialog.
On every release, bump the version in all of these and keep them identical:
studiou-wc-mail-queue.php — the Version: header and the STUDIOU_WCMQ_VERSION constantCLAUDE.md — the current-version linereadme.md — the version line plus a new changelog entryassets/js/*.js — the console.log version banner, if one existslanguages/*.po — the Project-Id-Version headerBump the version whenever JS or CSS changes, even for a one-line edit — it is the cache-busting mechanism.
Caveat specific to this shop: studiou.cz strips the ?ver= query string, so a version bump alone does not bust the browser cache there. Either tell the user to hard-refresh, or adopt the version-stamped-filename approach from studiou-wc-product-cat-manage (get_cache_busted_asset_url() copies admin.js to admin-X.Y.Z.js under uploads/, refreshes on source change, and cleans up stale copies; the copies are gitignored build artifacts — always edit the source file).
Czech (cs_CZ) and English. Edit the .po files, then compile:
wp i18n make-mo languages/
studiou-wc-free-photo-product/languages/generate-mo.php is a dependency-free PHP fallback (php generate-mo.php) if WP-CLI is unavailable. Poedit or msgfmt also work. The .mo must be regenerated by hand — nothing does it automatically.
There are no automated tests. Verify in a WordPress install:
WP_DEBUG and WP_DEBUG_LOG; watch wp-content/debug.log.wp_mail_failed to catch send errors; cross-check against the host's MTA log.