Documentation
M Cart Recovery
M Cart Recovery for WooCommerce asks a shopper for an email address before their first add to cart, keeps a copy of that cart in your own database, and emails reminders with a link that puts the exact cart back. This manual covers version 1.0.0 and is written for the administrator who has just installed it: what runs the moment you activate it, every setting and its shipped default, and the operations that can stop your sales, keep recording customer data after you believe you switched it off, delete your records or silently stop your email.
What it does and who it is for
The plugin blocks the first add-to-cart action of a visitor who has no email on file until an address is submitted, stores the resulting cart, marks it abandoned after a configurable period of inactivity, and sends up to three reminder emails containing a tokenised link that rebuilds the cart with the same variations and quantities and sends the shopper to checkout. Shoppers who already have an email on file, which includes every logged-in user, are recorded without ever seeing the dialog. Reports show tracked, abandoned and recovered carts plus recovered revenue. It is for stores that want abandoned-cart recovery without handing cart data to an outside service: everything is stored in two of your own database tables and sent through your own site's mail. WooCommerce must be installed and active; the plugin header declares Requires Plugins: woocommerce and the code stops with an admin notice if WooCommerce is not loaded.
Requirements, installing, activating
readme.txt states WordPress 7.0 or newer, WooCommerce 10.0 or newer and PHP 7.4 or newer. It contains no HTTPS recommendation; that line appears only in README.md, the developer file. Only the PHP requirement is enforced in code: activation on PHP older than 7.4 deactivates the plugin again and stops with the message "M Cart Recovery requires PHP 7.4 or newer." Nothing checks the WordPress or WooCommerce version at runtime.
- Install and activate WooCommerce first.
- Upload the m-cart-recovery directory to wp-content/plugins/, or install the ZIP through Plugins > Add New.
- Activate M Cart Recovery for WooCommerce in Plugins.
- WordPress redirects you once, automatically, to WooCommerce > Cart Recovery > Settings. The redirect fires on the next admin page load, uses a transient that lives for 30 seconds and requires the manage_woocommerce capability, so if you land somewhere else, open the page from the WooCommerce menu.
- Before anything else, read the next section: capture is already live on your storefront.
What happens the moment it is activated
Activation creates two tables using your database prefix, wp_mcr_carts and wp_mcr_email_log, writes mcr_db_version, adds the options mcr_settings and mcr_templates if they do not already exist, and sets a 30-second redirect transient. It schedules nothing. The two recurring jobs are created by MCR_Scheduler::ensure_scheduled() on init at priority 20, that is on the next request after activation, whether that request is an admin page or a storefront hit. The shipped default for the enabled setting is 1, so the storefront email gate is live from the first page load, in English, with the default text that ships in the code. The plugin includes only languages/m-cart-recovery.pot and no compiled Lithuanian or Polish translation, so both the dialog and the admin screens appear in English until you rewrite the storefront copy in Settings.
- Enable cart capture and recovery emails: on. The dialog markup renders on every storefront page and the script intercepts add-to-cart clicks.
- Three reminder emails: all enabled, with delays of 0, 1440 and 4320 minutes after a cart is marked abandoned.
- Mark abandoned after: 60 minutes.
- Retain recovery records: 90 days.
- Sender name: your site title. Sender email: the WordPress admin email.
- mcr_process_abandoned_carts: every five minutes, first run about a minute after the job is created.
- mcr_cleanup_old_data: once a day, first run about an hour after the job is created.
- Show optional marketing consent: on, but MailWizz sync is off (mailwizz_enabled is 0), so nothing is sent anywhere.
- Permanently delete plugin tables and settings on uninstall: off.
From the second you activate the plugin, no guest can add anything to your cart without typing an email address. There is no grace period and no preview mode. Activate on staging, or activate during quiet hours and be ready to clear the Enable cart capture and recovery emails box on the Settings tab you are redirected to.
Where the screens live in wp-admin
Everything sits on one page, WooCommerce > Cart Recovery (page slug m-cart-recovery), split into five tabs. Access requires the manage_woocommerce capability, which by default means Administrator and Shop manager. The Plugins list also gets a Settings link that opens the Settings tab directly. A badge in the header reads Recovery active or Recovery paused depending on the enabled setting, and it describes that setting only, not whether carts are being recorded.
- Reports: 30-day counters, a 14-day bar chart and a short explanation of how recovery works. This is the tab that opens by default.
- Abandoned carts: every stored cart record, filterable by status and searchable by email or name, 20 rows per page, with a Delete link per row.
- Email templates: the reminder sequence, one editor per email.
- Settings: the recovery engine, the storefront dialog copy and the uninstall data-removal switch.
- Integrations: MailWizz credentials, a Test connection button and the hook for other platforms.
Settings: Recovery engine
The first panel on the Settings tab controls timing, retention and the sender identity. Values outside the allowed range are clamped when you save, not rejected, so check what the field shows after saving. An invalid sender email silently reverts to the WordPress admin email.
- Enable cart capture and recovery emails. Default: on. When off, the dialog markup is not printed, the storefront CSS and JavaScript are not enqueued, no cart is marked abandoned and no reminder is sent. Carts are still recorded and the daily cleanup still runs. See the section on what this box does not stop.
- Mark abandoned after (minutes). Default: 60. Allowed: 5 to 43200 (30 days). The clock is the updated_at column of the cart record, which is rewritten on every request where one of the four cart hooks fires. One of them is woocommerce_cart_updated, which WooCommerce fires when it writes the cart to the session, so on most stores ordinary browsing pushes the abandonment moment forward. Do not read this figure as time since the shopper last changed the cart.
- Retain recovery records (days). Default: 90. Allowed: 7 to 3650. The cleanup job applies its own floor of 7 days regardless of what is stored.
- Sender name. Default: your site title.
- Sender email. Default: the WordPress admin email. Used verbatim in a From header on every reminder.
Settings: Email capture dialog
While capture is enabled, the dialog markup is printed in the footer of every storefront page, including for logged-in visitors and for anyone whose email is already known. What decides whether it ever opens is a single flag, mcrCapture.captured, written into the page by wp_localize_script when the page is rendered. It is true when the WooCommerce session, the customer record or the logged-in account already supplies an email, and then the script intercepts nothing. The script watches WooCommerce add-to-cart controls: classic and AJAX buttons, single-product forms, block product buttons and plain add-to-cart links. Closing the dialog cancels the pending add; the item is not added. Seven controls are editable below: six text fields and one checkbox. The eyebrow Cart protection, the field label Email address and the Close button label come from the translation file rather than from Settings, and only languages/m-cart-recovery.pot ships, so those three stay English whatever you write here.
- Title. Default: Save your cart.
- Message. Default: Enter your email to add this item and keep your cart available if you leave.
- Email placeholder. Default: you@example.com.
- Button. Default: Continue shopping.
- Show optional marketing consent. Default: on. Adds an unticked checkbox; only a ticked box counts as consent.
- Marketing consent label. Default: Send me occasional product news and offers.
- Privacy note. Default: We use your email to save this cart and send recovery reminders. Marketing emails are optional.
Because captured is baked into the HTML at render time, a full-page cache that stores a copy generated for a visitor who already had an email then serves captured=true to every guest who receives that cached page, and the gate silently does nothing for them. That costs you capture, not sales, and nothing in wp-admin reports it. Exclude storefront pages from full-page caching, or accept that capture is best effort and check the Tracked carts figure against your real traffic.
Settings: Data removal
One checkbox, Permanently delete plugin tables and settings when the plugin is uninstalled, shipped off. It changes nothing while the plugin is installed. It is read once, by uninstall.php, at the moment WordPress deletes the plugin. Leaving it off is what protects you during a reinstall.
Email templates: the reminder sequence
Three emails ship enabled. Each has an internal name, an enable switch, a delay in minutes (0 to 43200), a subject, a heading and an HTML message edited in a reduced WordPress editor. Delays are absolute, measured from the moment the cart is marked abandoned, not from the previous email, which is why the defaults read 0, 1440 and 4320: immediately, 24 hours later, 72 hours later. Disabled templates are skipped, and the next enabled one keeps its own delay. The bodies are filtered through wp_kses_post on save and again on send, so scripts and disallowed markup are stripped.
- First reminder, delay 0, subject: You left something in your cart at {site_name}.
- Second reminder, delay 1440 minutes, subject: Still thinking it over?
- Final reminder, delay 4320 minutes, subject: A final reminder about your saved cart.
- Placeholders available in subject, heading and message: {first_name}, {last_name}, {site_name}, {cart_items}, {cart_total}, {checkout_url}, {unsubscribe_url}.
- {first_name} falls back to the word "there" when no billing first name is known. {cart_items} renders an HTML table of products, quantities and line totals.
- Every email also carries an automatic footer with a Stop cart reminders link, whether or not you use {unsubscribe_url} in the body.
Because delays run from abandonment and the processing job advances exactly one template per cart per run, and because that job runs every five minutes, setting all three delays to the same number sends three emails to the same shopper about five minutes apart, all three inside roughly ten minutes. Keep the gaps meaningful before you save.
Integrations: MailWizz and other platforms
MailWizz sync is off by default. When enabled and configured with an API URL, an API key and a list UID, a shopper who ticks the marketing consent box is queued for a background sync that POSTs their email address to <api url>/lists/<list uid>/subscribers with an X-Api-Key header. Nothing is sent for shoppers who leave the box unticked, and recovery reminders themselves never depend on MailWizz. Test connection performs a read-only request for one subscriber and reports the HTTP status; the result of the last real sync is stored in the option mcr_mailwizz_status and shown under the fields. Any other platform can be wired up through the capture hook, which fires for every successful capture with the consent value as its second argument.
- Only the email address leaves the site from the storefront path. MCR_Capture::ajax_capture_email() calls queue_sync( $email, '', '' ), and MCR_MailWizz::sync() runs the request body through array_filter, which drops the empty FNAME and LNAME before the POST. No name is transmitted, because the capture dialog never asks for one.
- List UID is passed through sanitize_key on save: it is lowercased and everything outside a-z, 0-9, underscore and hyphen is stripped. A UID containing uppercase is stored altered, the field still looks plausible on screen, and every sync then fails while mcr_mailwizz_status shows an error you have no reason to connect to that field. Re-read the List UID field after every save.
- Test connection is not optional. It is the only thing that proves the URL, key and UID survived saving.
- The status line under the fields comes from the last real sync, not from the last test. A green test and a red status line at the same time means the credentials work and something about the payload or the list does not.
add_action(
'mcr_customer_email_captured',
function ( $email, $marketing_consent ) {
if ( ! $marketing_consent ) {
return;
}
// Queue your own provider sync here.
},
10,
2
);
The MailWizz API key is stored in plain text inside the mcr_settings option and is printed into the page HTML as a hidden field on the Settings tab as well as in the password field on Integrations. Anyone with manage_woocommerce, and anyone holding a database dump, can read it. Use a key scoped to that one list and replace it when a shop manager leaves.
Reports and the cart list
Every Reports tile filters on created_at within the last 30 days, that is the date the cart record was created, not the date of any activity on it. A cart created 40 days ago and abandoned this morning appears in no tile, including recovered revenue. Within that window: tracked carts is every record (a record only exists once an email is known), Currently abandoned is how many of those records are in status abandoned right now, Recovered counts status recovered, and the conversion rate is recovered divided by abandoned plus recovered. Recovered revenue sums cart_total on recovered records, and cart_total is overwritten with the order total when an order is matched. The subtitle counts email log rows with status sent in the same 30 days. The bar chart covers 14 days, again by creation date. Abandoned carts lists every record regardless of age, with its status, a two-item summary of contents, the total, the number of emails actually sent and the last activity time in your site timezone. An order is matched to a cart by the current session key first, and if that fails, to the most recently updated active or abandoned cart with the same billing email.
First-run configuration, in order
Do this without a break. The plugin is already live while you work, so the first step is the one that buys you time.
- On Settings, clear Enable cart capture and recovery emails and save. This takes the gate off your storefront at once. It does not stop the plugin recording carts; if you also want that stopped while you configure, deactivate the plugin instead and reactivate when you are done.
- Set Sender name and Sender email to a real mailbox on the store's own domain, one your SPF and DKIM records allow.
- Set Mark abandoned after. 60 minutes is a sensible start. Read it as time since the last recorded cart activity, which on most stores includes browsing.
- Set Retain recovery records to a period you can justify to a customer, and one you will not need to shorten later.
- Rewrite Title, Message, Email placeholder, Button and Privacy note in your customers' language, and remember that three fixed strings in the dialog stay English.
- Decide whether to show the marketing consent checkbox at all, and if you keep it, rewrite its label so it names what the shopper is agreeing to.
- Open Email templates, rewrite all three emails, save once, then reload the tab and confirm all three are still there.
- If you use MailWizz, fill in Integrations, save, re-read the List UID field, then press Test connection and wait for the success notice.
- Turn Enable cart capture and recovery emails back on and save.
- Test in a private browsing window with no WordPress session: add a product, submit a throwaway address, close the tab. Wait the abandonment delay plus five minutes, then check Abandoned carts for the record and the reminder.
- On a low-traffic store, disable WP-Cron's request-driven mode at server level and call wp-cron.php from a real cron every five minutes, otherwise reminders drift.
Danger: the email gate can stop every sale
While capture is enabled, a guest cannot add anything to the cart without submitting a valid email address. The script cancels the click, opens the dialog and only replays the original action after the server confirms the capture. If that request fails, the shopper is stuck: closing the dialog cancels the add, and clicking again just reopens it. Two failure modes are visible in the code. The nonce is generated when the page is rendered and verified when the form is submitted, so a page served from a full-page cache for long enough can carry a nonce WordPress no longer accepts. And the rate limiter counts submissions against $_SERVER['REMOTE_ADDR'] only, with no proxy header handling. Fifteen submissions inside five minutes, valid or not, and the sixteenth returns "Too many attempts. Please wait a few minutes." Each counted submission rewrites the counter with a fresh five-minute life, so the window rolls forward while submissions keep arriving; a rejected submission does not extend it, so the block does clear five minutes after the fifteenth counted attempt. Behind a CDN or reverse proxy that does not rewrite REMOTE_ADDR, every visitor shares one counter, which caps the whole store at fifteen captures per five minutes and blocks everyone else in between.
- Fastest fix, with admin access: WooCommerce > Cart Recovery > Settings, clear Enable cart capture and recovery emails, save. The dialog stops rendering immediately, add to cart works again and stored carts are kept.
- Without admin access, over WP-CLI: flip the setting inside the option without deactivating anything, or deactivate the plugin.
- Without WP-CLI: rename wp-content/plugins/m-cart-recovery over SFTP. WordPress deactivates a plugin whose file has disappeared. Tables and options survive the rename.
- If the cause was the rate limiter, wait before you flush anything. A rejected submission does not extend the counter, so the block clears by itself five minutes after the fifteenth counted attempt; reach for a flush only if submissions keep arriving and keep renewing it. The keys are mcr_rate_ plus a hash of the address, so you cannot target one: wp transient delete --all wipes every transient on the site, not only these counters, and on a site with a persistent object cache, Redis or Memcached, the counters are not in the database at all, so that command misses them entirely. wp cache flush does reach them, but it empties the whole object cache: WooCommerce transients, session data and anything else sharing that Redis database, including other sites on the same instance. On a busy store that is a cold-cache stampede on top of a checkout that is already broken. Then fix REMOTE_ADDR at the proxy or host level before re-enabling.
- If the cause was caching, exclude the storefront pages and admin-ajax.php from full-page caching, then retest in a private window.
wp option patch update mcr_settings enabled 0
wp plugin deactivate m-cart-recovery
wp cache flush
wp transient delete --all
This is the failure that costs money. There is no fallback path to the cart if the capture request fails: a broken nonce, a shared rate-limit counter, a JavaScript error or a blocked admin-ajax.php all mean nobody can buy anything, and nothing in wp-admin tells you it is happening. Watch your order rate for the first day after switching capture on.
Danger: the enable box does not stop capture
Unticking Enable cart capture and recovery emails stops three things: the dialog markup in the footer, the storefront CSS and JavaScript, and the five-minute processing run, which returns immediately. It stops nothing else. MCR_Capture::register() hooks woocommerce_add_to_cart, woocommerce_cart_item_removed, woocommerce_cart_item_restored, woocommerce_cart_updated and a shutdown save with no reference to the setting, and MCR_Capture::capture_current_cart() never checks it either. Every cart belonging to a shopper whose email is already known, which includes every logged-in user, keeps being written to wp_mcr_carts. Those rows land with status active, the processing job that would move them on is skipped, and MCR_Cart_Repository::cleanup() deletes only rows with status abandoned, completed, recovered or unsubscribed. A paused plugin therefore accumulates customer email addresses that nothing in it will ever remove, past whatever retention period you configured and told your customers about.
- What the box stops: the dialog, its CSS and JavaScript, the abandonment sweep and every reminder.
- What it does not stop: cart recording at shutdown, the wp_ajax_mcr_capture_email endpoint, restore links, unsubscribe links, order matching, checkout email prefill, the daily cleanup and the admin screens.
- The only real stop is deactivating the plugin. Deactivation unhooks everything and unschedules the jobs, while tables, options, settings, templates and every stored cart stay in place.
- Rows written while the plugin is paused keep status active indefinitely. The only things that ever remove an active row are a shopper emptying their WooCommerce cart, the Delete link on Abandoned carts, the WordPress personal-data eraser, and your own SQL.
wp plugin deactivate m-cart-recovery
wp db query "SELECT status, COUNT(*) FROM wp_mcr_carts GROUP BY status"
wp db query "DELETE l FROM wp_mcr_email_log l JOIN wp_mcr_carts c ON c.id = l.cart_id WHERE c.status = 'active'"
wp db query "DELETE FROM wp_mcr_carts WHERE status = 'active'"
If you unticked the box because of a privacy question, unticking did not answer it. Deactivate the plugin, count the active rows, then delete them. Substitute your own database prefix in the commands above, and take a dump first: deleting active rows also removes carts that live shoppers are still filling.
Danger: logged-in customers never see the dialog and are emailed anyway
MCR_Capture::get_customer_email() looks in the WooCommerce session first, then at WC()->customer->get_billing_email(), then at the logged-in user's account email. Any of the three is enough to record a cart. A logged-in customer therefore never sees the dialog, is never shown the privacy note, is never asked anything, and still gets a row in wp_mcr_carts keyed on their user ID, with marketing_consent 0, and a full three-email reminder sequence once the cart goes stale. The same applies to your own staff: every administrator and shop manager who leaves items in a test cart is recorded under their account address, which is why your own email turns up in Abandoned carts. The dialog markup is printed for these visitors too; the script simply never opens it, because the page was rendered with captured already true.
- Account holders are recorded from their account email with no dialog, no privacy note and no consent step of any kind.
- Their only opt-out is the Stop cart reminders link in the footer of a reminder, and it covers that one cart record, not the address.
- Marketing consent is never true for them, so nothing about them is sent to MailWizz. Only the local recording and the reminder emails apply.
- Test every change in a private window with no WordPress session. Testing while logged in both misses the dialog entirely and puts your own address in the table.
- The suggested privacy text this plugin contributes under Settings > Privacy opens with "When a shopper provides an email to save a cart", which does not describe this path. Rewrite it before you publish it.
- A logged-in shopper's cart record is keyed on their user ID rather than on the session, so clearing cookies does not split it: one user has one record.
Danger: restore links overwrite carts and identities
A restore link is your home URL with ?mcr_restore= and a 64-character token. Opening it empties whatever is in the visitor's current WooCommerce cart before rebuilding the saved one, then writes the saved email into the WooCommerce session and the customer object and saves it. The token never expires and is not bound to a device or browser: whoever holds the email holds a working link, and forwarding the email forwards the address along with the cart. The link stops working only once the record reaches status completed, recovered or unsubscribed, or is removed by retention, after which it shows "This saved cart is no longer available."
- Never open a restore link while logged in to wp-admin. The code empties your own cart and calls set_billing_email() and save() on the current customer object, which is yours. Test in a private window with no WordPress session.
- If you already did it, correct the billing email on your own account under Users or in My account > Addresses, and rebuild your own cart by hand.
- To stop restores from wiping live carts store-wide, return true from the mcr_restore_merge_cart filter. Saved items are then added to whatever the shopper already has.
- Restoring on a second device leaves the shopper with two live records. The restore writes the saved email into the new browser's session, the restored add-to-cart marks the cart dirty, and the shutdown save creates a fresh row under that browser's cart key, while mark_returned() puts the original row back to active. Both can go abandoned again, but they do not send the same thing: mark_returned() leaves emails_sent untouched and processing resumes at that index, so the original record carries on where it stopped while only the new row starts at the first reminder. Unsubscribing on one does nothing to the other. Because the manual's own point is that the token survives forwarding and is not device-bound, this is the ordinary case, not an edge case.
- Products that no longer exist or are out of stock are skipped silently. If none of them can be added, the shopper lands on the cart page with a notice instead of checkout.
- Restore and unsubscribe are handled on wp_loaded and template_redirect with no reference to the enabled setting. Pausing the plugin does not disarm links that are already in inboxes.
add_filter( 'mcr_restore_merge_cart', '__return_true' );
The unsubscribe link in the footer stops reminders for that one cart record, not for that email address. If the same shopper starts a new cart later, a new record is created and the sequence can run again. Treat this as a compliance question before you rely on it as an opt-out.
Danger: link scanners unsubscribe your shoppers
MCR_Recovery::handle_unsubscribe() acts on a plain GET of ?mcr_unsubscribe= with a 64-character token. There is no confirmation page, no POST and no nonce: fetching the URL is enough to set the record to unsubscribed. handle_restore() then refuses any record with that status, so the restore link in the same email dies at the same moment. Corporate mail security prefetches every URL in an inbound message: Outlook Safe Links, Proofpoint, Barracuda and most antivirus gateways do this by design. A business recipient can therefore be unsubscribed and have their saved cart killed before they open the message. The symptom is a recipient who receives the first reminder, receives nothing after it, and sees "This saved cart is no longer available" at the first click, which looks exactly like the mail-delivery failure described further on.
- No setting and no filter changes this. The honest position is to expect it and to stop reading one-click unsubscribe rates as shopper intent.
- To confirm it happened, compare the sent_at of the first log row with the record's updated_at. A gap of seconds points at a scanner, not a person.
- A restore token that a mail client wrapped across a line fails the 64-character pattern check, and the handler then does nothing at all: the visitor simply lands on your home page with no message. An unsubscribe token that gets wrapped fails differently and produces a hard 400 page reading "This unsubscribe link is invalid."
- A restore link can also die silently for a second reason. handle_restore() returns with no notice and no redirect when the record's stored cart_contents does not decode into an array, and that looks identical to the wrapped-token case: the home page loads and nothing happens. Check the token in the URL first; if it is a clean 64 characters, the cause is the cart_contents column, and the section on email that stops without telling you covers how to check it.
- Once retention has deleted a record, its unsubscribe link produces that same 400 for everyone, with no alternative route to opt out.
- When a shopper says they cannot unsubscribe, find their row on Abandoned carts and use Delete. That removes the record and its email log, and nothing further can be sent for it.
Danger: data that disappears without asking
Four separate paths delete cart records permanently. None of them asks for confirmation beyond a nonce, none writes an archive, and the plugin has no undo. A database backup is the only way back.
- The Delete link in the Abandoned carts list. One click removes the cart row and all its email log rows. There is no browser confirmation, only a nonce check, so a mis-click is final.
- The daily retention cleanup. It removes carts with status abandoned, completed, recovered or unsubscribed whose last update is older than Retain recovery records, together with their logs. It selects at most 500 ids per run and runs once a day, so the ceiling is 500 records per day in total, not 500 per batch. A store producing more than 500 expiring records a day never catches up, and personal data sits past the period you configured indefinitely. It runs even when Enable cart capture and recovery emails is off.
- Emptying a WooCommerce cart. When WooCommerce fires woocommerce_cart_emptied, an active record for that session is deleted along with its logs.
- The WordPress personal-data eraser under Tools > Erase Personal Data. It removes up to 100 cart records per pass for the requested address, with their logs.
wp db query "SELECT status, COUNT(*) AS rows_now FROM wp_mcr_carts GROUP BY status"
wp db query "SELECT COUNT(*) FROM wp_mcr_carts WHERE status IN ('abandoned','completed','recovered','unsubscribed') AND updated_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 90 DAY)"
Lowering Retain recovery records is destructive on a delay. Changing 90 to 7 does nothing visible until the daily job next runs, and then it starts deleting roughly three months of carts, email history and the reporting behind them, 500 records a day and no faster. Take a database dump before you lower that number, and run the second query above for several days afterwards: if the backlog is not falling, the cap is the reason and your stated retention period is not being met.
Danger: email that stops without telling you
Reminders go out through wp_mail with a From header built from Sender name and Sender email, applied to every message. If that address is not one your domain is authorised to send as, the mail is refused by the receiving server or filed as spam, and the plugin only records whatever wp_mail reports back. Failures are written to the email log table with status failed and the message "wp_mail() did not accept the message.", but no admin screen displays them: the only visible symptom is that the Emails column in Abandoned carts stays at zero while carts pile up. There is no send-test-email button anywhere in the plugin, so deliverability has to be proved with a real cart.
- Set Sender email to a mailbox that actually exists on the store's domain, and confirm SPF, DKIM and DMARC allow your host to send as it.
- If you run an SMTP plugin that must own the From header, remove the plugin's own header with the mcr_recovery_email_headers filter rather than fighting it.
- To read the failures, query the email log table, wp_mcr_email_log with your prefix, for rows where status is failed.
- Recovery emails are HTML only. There is no plain-text alternative part, which some spam filters weigh.
- A cart whose stored contents no longer decode to a non-empty array is skipped before wp_mail is reached, and nothing is logged at all. If a record sits at zero emails and the status never advances, check its cart_contents column. The same column also makes the restore link in any reminder already sent fail silently, as described in the section on link scanners.
add_filter(
'mcr_recovery_email_headers',
function ( $headers ) {
return array_values(
array_filter(
$headers,
function ( $header ) {
return 0 !== stripos( $header, 'From:' );
}
)
);
}
);
Danger: the email sequence can vanish on save
MCR_Admin::save_templates() writes MCR_Settings::sanitize_templates( $_POST['templates'] ) straight into the mcr_templates option. There is no merge against the shipped defaults and no minimum: whatever arrives in the POST becomes the entire sequence. The form carries three rich-text bodies plus fifteen smaller fields, which is a large POST. A host max_input_vars limit, a security module capping the request body or a proxy truncating it stores fewer templates than you edited, or an empty array. MCR_Admin::render_templates() renders only the templates already stored and offers no control to add one, so once the option holds an empty array the screen shows the placeholder help text and the Save button and nothing else, and reminders stop for good with no error anywhere.
- Check what is stored: wp option get mcr_templates --format=json. You should see three objects.
- If templates are missing or the array is empty, delete the option outright: wp option delete mcr_templates. MCR_Settings::get_templates() falls back to the shipped three-email sequence whenever the option is absent, so the defaults return at once, in English, with delays 0, 1440 and 4320.
- Rewrite the three emails, save once, then reload the tab and confirm all three came back before you walk away.
- If they truncate again, raise max_input_vars on the host and look at anything that filters or caps POST bodies.
- Keep your own copy of the three subjects, headings and bodies outside WordPress. The plugin has no export and no revision history.
wp option get mcr_templates --format=json
wp option delete mcr_templates
A truncated save is silent. The screen redirects with Changes saved. and shows only the templates that survived, so it looks correct. Reloading the tab after every save is the only check the plugin gives you.
Background processing and timing
The plugin uses Action Scheduler when WooCommerce provides it, in the group m-cart-recovery, and falls back to WP-Cron with its own five-minute interval registered as mcr_five_minutes. Both jobs are created on init at priority 20, on the first request after activation, so a site that receives no request at all schedules nothing. Two recurring jobs run: mcr_process_abandoned_carts every five minutes, which marks stale carts abandoned and sends due reminders, and mcr_cleanup_old_data once a day. A third hook, mcr_sync_marketing_contact, is queued on demand for MailWizz. Each processing run handles at most 20 due carts and advances exactly one template per cart, so a large backlog drains at roughly 240 carts an hour. Runs are serialised with a lock stored in the option mcr_processing_lock, which is discarded automatically if it is more than ten minutes old, so a crashed run unblocks itself on the following run.
Troubleshooting
The problems that actually occur, and the first thing to check for each.
- The dialog never appears. Capture is disabled; you are logged in, or your session or customer record already holds an email, and the page was therefore rendered with captured set to true; the page came from a cache generated for such a visitor; or your theme adds to cart with a control that does not match the selectors the script watches, in which case the gate is bypassed entirely.
- The dialog appears but submitting does nothing. Open the browser console and look at the admin-ajax.php response: 403 means the nonce failed, usually full-page caching; 429 is the rate limiter; 409 means the WooCommerce session was not available on that request; 400 means the address failed is_email.
- Your own address appears in Abandoned carts. You were logged in when you filled a test cart. Account emails are captured with no dialog. Delete the row and test in a private window.
- Carts never move to abandoned. The five-minute job is not running (check Action Scheduler, or your WP-Cron), Enable cart capture and recovery emails is off (the whole run is skipped), the record has no email attached, or its cart_contents is an empty array.
- Carts stay active far longer than the delay suggests. The abandonment clock is updated_at, and it is refreshed whenever WooCommerce writes the cart to the session, which on most stores happens while the shopper browses.
- Carts are abandoned but nothing is sent. Every template is disabled, the mcr_templates option is empty, the delay has not elapsed yet, the cart's stored contents are empty, or wp_mail is failing.
- The Email templates screen shows no editors. The mcr_templates option holds an empty array. Delete the option to restore the shipped sequence.
- The same shopper receives the sequence twice. A guest who clears cookies gets a new session key and therefore a new cart record with a fresh sequence, and so does a shopper who opens a restore link on a second device. Unsubscribe applies to a single record, not to an address.
- A shopper's cart keeps resetting the sequence. Any change to the cart contents resets the record to active and clears the counters by design, so the sequence starts again from the new abandonment.
- Records move to unsubscribed seconds after the first reminder. A corporate link scanner fetched the unsubscribe URL. The restore link in that email is now dead too.
- A restore link loads the home page and does nothing. Either the token failed the 64-character pattern check, usually because a mail client wrapped the URL, or the record's cart_contents no longer decodes into an array. Both return from handle_restore() with no notice and no redirect.
- Recovered revenue looks wrong. Matching uses the session key first, then the newest active or abandoned cart with the same billing email, so an order placed from another device can be credited to a different cart. The tiles also cover only records created in the last 30 days.
- A restore link reports the cart is no longer available. The record is completed, recovered or unsubscribed, or retention has already deleted it.
Privacy and data
Nothing leaves the site unless you enable MailWizz. With it enabled, only the email address of shoppers who ticked the consent box is POSTed to the API URL you configured; the empty first and last name fields are dropped from the request body before it is sent. Recovery emails obviously leave through your own mail transport. Everything else stays in two local tables. The plugin registers a personal-data exporter and eraser under Tools > Export Personal Data and Tools > Erase Personal Data, and adds suggested wording to the privacy policy guide under Settings > Privacy, which you should adapt to your jurisdiction rather than publish as shipped. That suggested wording describes only the shopper who provides an email through the dialog, so it does not cover logged-in customers, who are recorded without being asked.
- wp_mcr_carts stores: email, first and last name, user ID, marketing consent flag, cart contents as JSON, total, currency, status, a 64-character restore token, timestamps, the matched order ID, and a cart key that is an HMAC of either the user ID or the WooCommerce session id rather than a reversible identifier.
- wp_mcr_email_log stores: cart ID, template index, recipient, subject, status, error text and timestamp.
- No raw IP address is stored anywhere. The rate limiter turns the request address into an HMAC that becomes a transient key and is never written to the plugin tables.
- One cookie is set on successful capture: mcr_email_captured, value 1, one year, secure on HTTPS. No code in the plugin ever reads it. Whether the dialog opens is decided server side from the WooCommerce session, the customer record and the logged-in account.
- Records with status active are never removed by the retention job, only abandoned, completed, recovered and unsubscribed ones. If you leave capture disabled long-term, or your shoppers keep browsing, active rows stay indefinitely.
Uninstalling: what goes and what stays
Deactivating and deleting behave very differently, and the difference is one checkbox you had to tick beforehand.
- Deactivating unschedules mcr_process_abandoned_carts, mcr_cleanup_old_data and any queued mcr_sync_marketing_contact jobs, in both Action Scheduler and WP-Cron, and unhooks everything, including cart recording, restore links and unsubscribe links. Tables, options, settings, templates and every stored cart stay exactly where they are, and reactivating restores the schedule on the next request.
- Deleting the plugin in the Plugins list runs uninstall.php. It always unschedules the same jobs. It then reads the Permanently delete plugin tables and settings option and stops there if it is off.
- With that option on, uninstall drops the tables wp_mcr_carts and wp_mcr_email_log and deletes the options mcr_settings, mcr_templates, mcr_db_version, mcr_mailwizz_status and mcr_processing_lock. There is no export step and no second confirmation beyond WordPress's own delete prompt.
- With that option off, nothing is deleted. If you want the leftovers gone later, delete the two tables and the five options by hand.
- Either way, transients survive: the rate-limit counters keyed mcr_rate_, any mcr_admin_error_ message and a leftover mcr_activation_redirect are not removed by uninstall.php. They expire on their own.
- Either way, the mcr_email_captured cookie in visitors' browsers and the mcr_email and mcr_marketing_consent values inside WooCommerce sessions are not touched. They expire on their own too.
Tick Permanently delete plugin tables and settings only when you are certain you will never reinstall. Deletion runs the moment WordPress removes the plugin files, before you can change your mind, and it takes every cart record and the whole email history with it. Take a database dump first.
Automatic updates
The plugin checks majevski.com for new releases and offers them through the normal WordPress update screen — the same prompt, changelog popup and one-click install as any other plugin. Checks are cached, never slow a page down, and fail open: if majevski.com cannot be reached, the site simply carries on and tries again later. An update package is only ever accepted from majevski.com over HTTPS, and an older version is never offered. Versions before 1.1.0 predate the update channel, so they cannot see new releases — install 1.1.0 or newer manually once, and every later release arrives on its own.
Want something like this built?
Everything on this page was designed, built and is run by one person. If you need the same for your business, tell me what you have in mind.
Book a call opens in a new tab