# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Status **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: - **i18n catalogues are complete and the `.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. - No phase has passed its acceptance criteria in `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. - A packaging script (`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). ### Documentation - [`readme.md`](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`](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`](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`](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. ### Architecture at a glance ``` 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 `` 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. ### Design decisions (locked — see the plan for rationale) **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. ### WooCommerce source is vendored `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`](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`. ### Sharp edges Things that will silently produce a plausible-looking but wrong implementation. All are detailed in the plan: - **Timestamp columns must be signed `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. - **Never return `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. - **Every `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). - **A monotonic slot ladder cannot survive retries.** One row backed off an hour drags every subsequent enqueue behind it. Allocate the *first free slot at or after a floor* instead. The interval must `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*. - **Cast row ids to `int` before any Action Scheduler call.** `$wpdb` returns numeric strings; AS matches args by hashing `json_encode()`, so `["123"]` and `[123]` are different actions. - **Retention settings are days; timestamp columns are seconds.** Multiply by `DAY_IN_SECONDS` or a 7-day retention deletes sent mail after 7 seconds — silently, while reporting success. - **Count crashes and send-failures separately.** Bump `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. - **Nothing may throw after `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. - **Context can go stale.** If a plugin filters `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`. - **The `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. - **If you suppress `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. - **Attachments are file paths, not bytes.** Invoice plugins write to temp dirs that are gone by send time. Copy attachments into a plugin-owned directory at enqueue. - **Re-entrancy.** The worker calls `wp_mail()` to send. Guard it, or mail is enqueued forever. - **Duplicate sends.** Claim rows atomically (`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. ### WooCommerce has its own deferred email queue — it is not a fix 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. ### Emails that can never be queued `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. ### Rate-limiting mechanism 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: - Action Scheduler is provided **by WooCommerce**. Keep a fallback that sends immediately if `as_schedule_single_action()` is missing. - Duplicate sends happen if the default trigger isn't reliably detached. That detach is the correctness-critical line. - Store the slot counter with `update_option(..., false)` — autoload off. - Schedule Action Scheduler args as an **indexed** array (`array($order_id)`), not associative — associative args break on newer PHP. - Reliable pacing needs `DISABLE_WP_CRON` plus a system cron running `wp action-scheduler run` every minute. Without it the queue drains in bursts. ## mu-plugin constraints (differ from every sibling plugin) 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: - **Subdirectories are not auto-loaded.** WordPress globs only `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 `require`s the real entry point. Plan the install instructions around this. - **No activation/deactivation hooks.** `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. - **mu-plugins load before regular plugins.** WooCommerce (and therefore Action Scheduler) does not exist at mu-plugin load time. Register hooks on `init` or later, as the sample does. Do not call `as_*()` functions at file scope. ## Repository layout 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. ### Git conventions Work happens on a branch named `.` — this plugin's branch is `1.0.0.studiou-wc-mail-queue`. Tags follow `.` (newer) or `.v.` / `_v` (older, inconsistent). Merges go to `master`. ## Conventions shared across the plugins Match these unless the mu-plugin constraints above force otherwise. ### Naming 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_`. ### Bootstrap 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: ```php 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. ### Manager pattern 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. ### Logging and admin notices 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. ### AJAX handlers 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()`. ### Input and output 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. ### Long-running operations 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. ## Release process On every release, bump the version in all of these and keep them identical: 1. `studiou-wc-mail-queue.php` — the `Version:` header **and** the `STUDIOU_WCMQ_VERSION` constant 2. `CLAUDE.md` — the current-version line 3. `readme.md` — the version line plus a new changelog entry 4. `assets/js/*.js` — the `console.log` version banner, if one exists 5. `languages/*.po` — the `Project-Id-Version` header **Bump 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). ## Translations Czech (`cs_CZ`) and English. Catalogues live in `languages/`: - `studiou-wc-mail-queue.pot` — 95 strings, source of truth - `studiou-wc-mail-queue-cs_CZ.po` — complete Czech translation - `studiou-wc-mail-queue-cs_CZ.mo` — **missing; the user compiles this** ### mu-plugins need `load_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. ### This machine has no PHP toolchain — do not try to compile `.mo` Verified 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 anyway - `php languages/generate-mo.php` (the fallback in `studiou-wc-free-photo-product`) — no PHP - `msgfmt` — not installed **So: 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. ## Verification 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: 1. Enable `WP_DEBUG` and `WP_DEBUG_LOG`; watch `wp-content/debug.log`. 2. Inspect the queue at **WooCommerce → Status → Scheduled Actions**, filtered to the plugin's action group — check pending/complete/failed and the spacing between scheduled times. 3. On staging, bulk-change 100+ orders to Completed. Confirm every mail eventually sends, at the configured rate, with no duplicates and no request timeout. 4. Hook `wp_mail_failed` to catch send errors; cross-check against the host's MTA log.