# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Status: not yet implemented 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`](readme.md) — the initial specification (feature list, target versions). - [`docs/analyza-woocommerce-email-throttling.md`](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`](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`](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. ### What the plugin must do 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: 0. Interception of outbound mail 1. Persistent mail queue with an automatic retention plan 2. Admin settings page: mails-per-minute, retention, debug toggle, which order-status emails are handled, enable/disable (disabling waits for the queue to drain), live queue state (pending/sent counts), log view with clear 3. Czech + English translations ### 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: 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`](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`. - **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`. - **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 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: - 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. 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. ## Verification There are no automated tests. 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.