This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Current version: 1.0.0 — code written, never executed. Phases 0–9 of the plan are implemented (i18n catalogues included); Phase 10 (load verification) is outstanding. Nothing here has been run, linted, or seen a WordPress install (this box has no PHP — see § Verification).
Outstanding before release:
.mo is present. languages/ holds .pot, cs_CZ.po, and cs_CZ.mo (95 strings, validated for msgid parity, placeholder parity, and Czech's three plural forms). The user compiled the .mo by hand (this box has no msgfmt), so its sync with the .po cannot be re-verified here — regenerate it after any .po edit.docs/plans/implement-plan-00.md §11. Run them in order; Phase 4 and Phase 5 hold the tests that catch the design's worst failure modes.build-package.ps1) builds the distributable zip into D:\@StudioU. It has been run and produces a spec-compliant archive (forward-slash entries — not Compress-Archive, which writes backslashes on PS 5.1 and breaks install on the Linux host).readme.md — user-facing docs: install (the mu-plugin stub is easy to get wrong), required host config, settings, known limitations, changelog.docs/analyza-woocommerce-email-throttling.md — Czech-language root-cause analysis and comparison of five solution variants. Variant B (rate-limited Action Scheduler queue) is the chosen approach. Predates WC 10.8's own DeferredEmailQueue, which it does not mention and which is not a fix.docs/sample-wc-mail-throttle.php — proof of concept for variant B. Its monotonic slot counter was deliberately abandoned: it cannot survive retries. See plan §4.2.docs/plans/implement-plan-00.md — the implementation plan. Schema, phases, acceptance criteria, and every sharp edge with its rationale. The code follows it closely; when they disagree, the plan is the intent.woocommerce_mail_callback → Context (which WC_Email is sending? which order?)
pre_wp_mail → Interceptor (snapshot payload, resolve From, short-circuit)
→ Queue (allocate first-free slot under GET_LOCK, INSERT, schedule)
studiou_wcmq_send (AS) → Worker (claim atomically, send, retry with backoff)
studiou_wcmq_reap (AS) → Reaper (rescue stranded rows — every 5 min)
studiou_wcmq_retention (AS)→ Retention (sweep old rows, logs, orphaned attachments — daily)
Settings and State are static option accessors. DB owns both tables. Admin is loaded only under is_admin(). Notifier writes the order note + WC log line on every terminal outcome — it is a separate class precisely because four code paths reach a terminal state (worker success, worker max-attempts, worker corrupt-payload, reaper max-reclaims) and every one must record the outcome, or the order silently shows nothing about a mail the customer never got.
File layout deviates from plan §10 in three documented ways, all deliberate: (1) the two WP_List_Table subclasses the plan names were not written — the queue and log views render plain <table class="wp-list-table"> inline, which is less untestable surface; (2) there are no AJAX handlers — the admin uses admin-post.php form posts, which keep the nonce + manage_woocommerce checks but skip the output-buffer/JSON plumbing; (3) the reaper is its own class-wcmq-reaper.php rather than folded into retention. Don't treat these as missing files.
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, deliberately gitignored (.gitignore covers both docs/plans/woocommerce/ and docs/Wiki/woocommerce/ — it has appeared under both paths). 6,194 files; without the rule a git add -A commits the lot.
A fresh clone will not have it, and it has gone missing mid-project twice. 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.pre_wp_mail is a filter chain. Its first argument carries the previous callback's verdict. Ignoring a non-null $short_circuit and queueing anyway delivers the mail twice — another mailer already sent it. Consume the context first, then return $short_circuit untouched.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.was_queued flag leaks past send(). woocommerce_email_disabled and woocommerce_email_skipped fire from send_notification() before send(), so the probe never resets the flag for them — and EmailLogger consults woocommerce_email_log_enabled on those paths too. Scope the suppression by $email_id, not the flag alone.EmailLogger, you inherit its privacy contract. It maps recipients to usernames and redacts addresses out of error strings before writing to the transactional-emails log, which any shop manager can read. The queue table may hold the real address; that log may not.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 cap comes from timestamps, not cron frequency. Each queued mail is assigned the first free slot at or after max(now, earliest) — the smallest t with no other pending/sending row within interval of it (Queue::allocate_slot()). Action Scheduler never runs an action before its scheduled time, so the spacing is deterministic regardless of how often cron fires. There is no monotonic "next slot" counter: an earlier draft used one and it was abandoned because it cannot survive retries (a row backed off an hour would drag every subsequent enqueue behind it). Do not reintroduce it.
The cap is enforced against in-flight mail (pending + sending), not as a sliding window: a row's slot is freed the moment it reaches sent. During a bulk the queue is never empty so the spacing holds, but mail enqueued across separate requests, after the previous send already completed, can leave closer together than interval. This is by design (plan §4.2) and noted in the readme's limitations.
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. Catalogues live in languages/:
studiou-wc-mail-queue.pot — 95 strings, source of truthstudiou-wc-mail-queue-cs_CZ.po — complete Czech translationstudiou-wc-mail-queue-cs_CZ.mo — missing; the user compiles thisload_muplugin_textdomain(), not load_plugin_textdomain()load_plugin_textdomain() resolves its relative path against WP_PLUGIN_DIR. For an mu-plugin that points at wp-content/plugins/studiou-wc-mail-queue/languages/, which does not exist — the catalogue silently never loads and the UI stays English with no error. Use load_muplugin_textdomain(), which resolves against WPMU_PLUGIN_DIR.
The sibling plugins all use load_plugin_textdomain() and are all correct, because they are all regular plugins. Do not copy that line here.
.moVerified absent on this dev box: php, wp (WP-CLI), msgfmt, and a working python (the python/python3 on PATH are Windows Store alias stubs that print an install prompt and exit, so they are not interpreters). Only perl and git are real.
That rules out every compilation route the sibling plugins document:
wp i18n make-mo languages/ — no WP-CLI, and it needs PHP anywayphp languages/generate-mo.php (the fallback in studiou-wc-free-photo-product) — no PHPmsgfmt — not installedSo: edit the .po files only, and leave .mo compilation to the user. They compile manually (Poedit, or msgfmt on the server). When a change touches translatable strings, update the .po, say so explicitly, and tell the user the .mo still needs regenerating — do not claim the translation is live, and do not hand-craft a .mo binary.
Nothing regenerates .mo automatically. A stale .mo silently serves the old strings, so an un-flagged .po edit looks like a translation that simply did not work.
The same constraint applies to any PHP verification: this box cannot lint, parse, or run a line of this plugin. php -l is unavailable. Syntax errors will surface only in the user's WordPress install.
There are no automated tests, and nothing about this plugin can be executed on this machine — no PHP, so no php -l syntax check, no unit runner, no .mo compile (see § Translations). Every claim about runtime behaviour has to be verified by the user in a real WordPress install, or not claimed at all.
Report edits as "written, not yet run." Do not describe untested code as working.
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.