Skip to content

Instantly share code, notes, and snippets.

@zhongruige
Created May 4, 2026 23:32
Show Gist options
  • Select an option

  • Save zhongruige/502723e37d2a6149a0ba344d0d8a9f29 to your computer and use it in GitHub Desktop.

Select an option

Save zhongruige/502723e37d2a6149a0ba344d0d8a9f29 to your computer and use it in GitHub Desktop.
2026-05-04T19-17-31_mailpoet

Testing Report — mailpoet

Run ID: 2026-05-04T19-17-31_mailpoet Generated: 2026-05-04T23:30:13.520Z Plugin version: 5.24.0 Sessions processed: 8 Sessions with errors: 1


Executive summary

Category Count
Problems 20
Questions 11
Improvements 13
Praises 18

Problem severity breakdown

Severity Count
critical 1
major 10
minor 9
trivial 0

Severity heatmap by area

Area Critical Major Minor Trivial Risk score
Export artifacts -- subscriber export and statistics export (F8, F9) 1 0 0 0 4
Export artifacts -- subscriber export scale (F8, a7) 0 1 0 0 3
Destructive operations — newsletter delete, cron body cleanup 0 1 0 0 3
Plugin lifecycle — uninstall 0 1 0 0 3
Newsletter scheduling 0 1 0 0 3
Archive shortcode / Newsletter listing 0 1 0 0 3
Subscribers / Newsletters listing bulk actions 0 1 0 0 3
Welcome wizard / sender configuration 0 1 0 0 3
Settings / Send With... (MTA configuration) 0 1 0 0 3
Settings / Advanced / Cron trigger mode indicator 0 1 0 0 3
Subscriber add form 0 1 0 0 3
Export artifacts -- subscriber export field selection (F8) 0 0 1 0 2
Newsletter bulk delete 0 0 1 0 2
Newsletter bulk delete — transaction consistency 0 0 1 0 2
Cron workers — SendingQueueBodyCleanup 0 0 1 0 2
Util/Cookies — SubscriberCookie legacy migration 0 0 1 0 2
Admin navigation / Form editor 0 0 1 0 2
Alpha block editor (new email editor) 0 0 1 0 2
Settings / Advanced / Cron trigger display 0 0 1 0 2
CSV import wizard 0 0 1 0 2
Export artifacts -- statistics export (F9) 0 0 0 0 0
Export artifacts -- recipient statistics export (F13) 0 0 0 0 0
Export artifacts -- file access protection (F8, F9) 0 0 0 0 0
Export artifacts -- subscriber export field defaults (F8) 0 0 0 0 0
Export artifacts -- filename uniqueness (a2) 0 0 0 0 0
Export artifacts -- lifecycle management (a4) 0 0 0 0 0
Export artifacts -- blast radius (a5) 0 0 0 0 0
Newsletter bulk delete — b6 capability gate UX 0 0 0 0 0
Newsletter delete — undo path 0 0 0 0 0
Newsletter bulk delete — transaction wrapping 0 0 0 0 0
Capability gates 0 0 0 0 0
Plugin deactivation — Action Scheduler cleanup 0 0 0 0 0
Subscribe form — email confirmation 0 0 0 0 0
Subscribe form — AJAX 400 response for expected errors 0 0 0 0 0
Util/Cookies — delete method 0 0 0 0 0
Subscribe form — inline feedback 0 0 0 0 0
Subscribe form — honeypot anti-spam 0 0 0 0 0
Subscribe form — XSS protection in inline JS 0 0 0 0 0
Frontend subscription form i18n 0 0 0 0 0
Date formatting locale awareness 0 0 0 0 0
Date/time formatting 0 0 0 0 0
JS translation pipeline 0 0 0 0 0
Arabic (RTL) translation quality 0 0 0 0 0
Alpha block editor REST errors 0 0 0 0 0
Classic editor block operations 0 0 0 0 0
Alpha block editor 0 0 0 0 0
Newsletter creation flow 0 0 0 0 0
Cron / SendingTaskSubscribersCleanup 0 0 0 0 0
Settings / MTA / SMTP credential lifecycle 0 0 0 0 0
Settings / Send With... (MTA) 0 0 0 0 0
Settings / Advanced / Cron trigger 0 0 0 0 0
Welcome wizard / sender validation 0 0 0 0 0
Settings / MTA / SMTP password field 0 0 0 0 0
Subscriber add form - invalid email validation 0 0 0 0 0
MailChimp import - API key error handling 0 0 0 0 0

Risk score = 4·critical + 3·major + 2·minor + 1·trivial

Top problems

1. [CRITICAL] Subscriber and statistics export files are directly downloadable without authentication from web-accessible uploads directory

  • Area: Export artifacts -- subscriber export and statistics export (F8, F9)
  • Persona affected: customer
  • Confidence: 1
  • Session: artifact-exports

Steps to reproduce:

    1. As admin, navigate to /wp-admin/admin.php?page=mailpoet-export
    1. Select 'Subscribers without a list', keep all default fields selected, click Export
    1. Note the download filename: MailPoet_export_.csv
    1. Open a new browser window (logged out / private browsing)
    1. Navigate to http://siteurl/wp-content/uploads/mailpoet/MailPoet_export_.csv
    1. File downloads immediately with full subscriber data (emails, IPs, subscription metadata)
    1. Same test with statistics export: WP-CLI eval or UI-triggered stats export places file at /wp-content/uploads/mailpoet/MailPoet_stats_export_.csv — HTTP 200 without auth

Expected: Export files should be protected by authentication, or delivered via a signed time-limited URL, or stored outside the webroot. Direct URL access by unauthenticated visitors should return 403 or redirect to login.

Actual: HTTP 200 with full file content returned to unauthenticated requests. Directory wp-content/uploads/mailpoet/ contains only index.php (PHP silence file) — no .htaccess rule denying direct access. Files persist for 1 day (subscriber) or 7 days (stats) before cleanup cron removes them.

Evidence: · · console

Notes: Source: Env.php:97 tempPath = wp_upload_dir()['basedir'] + '/mailpoet'. ExportFilesCleanup.php:16-17 shows 1-day retention for subscriber exports and 7-day retention for stats exports. During the retention window, any party who knows or obtains the filename (e.g. from server logs, browser history, social engineering) can download the full subscriber list. The 15-char random filename makes brute-force enumeration impractical but does not constitute access control. Mitigation: add Deny from all to wp-content/uploads/mailpoet/.htaccess and serve files via authenticated PHP streaming (similar to WooCommerce protected download approach).

2. [MAJOR] No uninstall.php and no register_uninstall_hook — all 40 wp_mailpoet_* tables and data persist permanently after plugin deletion

  • Area: Plugin lifecycle — uninstall
  • Persona affected: admin
  • Confidence: 0.95
  • Session: destructive-ops

Steps to reproduce:

  1. Deactivate MailPoet from wp-admin > Plugins
  2. Delete the MailPoet plugin (or simulate: check uninstall.php exists)
  3. Inspect database: SHOW TABLES LIKE 'wp_mailpoet%'
  4. Observe: all 40 wp_mailpoet_* tables remain
  5. Check wp_options for mailpoet entries — also remain
  6. Check get_option('uninstall_plugins') for mailpoet/mailpoet.php — not registered

Expected: Plugin deletion should invoke a cleanup routine (uninstall.php or registered uninstall hook) that removes all wp_mailpoet_* tables, plugin options, and Action Scheduler groups. At minimum, WordPress best practice requires one of these two mechanisms.

Actual: No uninstall.php file exists at plugin root. register_uninstall_hook is never called (confirmed via get_option('uninstall_plugins') returning empty for mailpoet/mailpoet.php). Plugin deletion via WordPress admin will silently leave all 40 custom database tables, options, and any remaining Action Scheduler entries permanently. This is a data hygiene violation per WordPress plugin best practices.

Evidence: · console

Notes: Confirmed via: (1) ls /path/to/mailpoet/uninstall.php → NOT FOUND, (2) grep -rn 'register_uninstall_hook' returns zero results in plugin source, (3) wp eval confirmed get_option('uninstall_plugins') has no mailpoet entry. This affects any admin who installs, evaluates, and removes MailPoet — their database is permanently cluttered with 40+ tables. Particularly problematic for agencies managing multiple client sites.

3. [MAJOR] Scheduling a newsletter with a past date/time accepts silently with no warning and immediately triggers sending

  • Area: Newsletter scheduling
  • Persona affected: admin
  • Confidence: 0.95
  • Session: newsletter-editor

Steps to reproduce:

  1. Create a newsletter using the classic editor with a valid template
  2. Click Next to proceed to the Send page
  3. Check the 'Schedule it' checkbox
  4. In the date picker, select today's date
  5. In the time dropdown, select a time earlier than the current time (e.g. 8:00 AM when it is 9:00 PM)
  6. Select a list with at least one subscriber
  7. Fill in the Sender name
  8. Click the Send button

Expected: The form should show a validation warning such as 'The scheduled time is in the past. Please select a future date and time.' and prevent submission until the user selects a future time.

Actual: No validation warning is shown. The newsletter is accepted and immediately transitions from 'scheduled' to 'sending' status. The admin sees 'The newsletter has been scheduled.' message with no indication that the past-time configuration caused immediate sending.

Evidence: · · console

Notes: WP-CLI confirmed newsletter status progression: draft → scheduled → sending immediately, triggered by setting a past date/time. No past-time validation exists on the client side (Parsley.js validator list does not include date-in-future constraint for the schedule date field). Users who accidentally set a wrong date/year could inadvertently send an email to their full subscriber list immediately and unexpectedly.

4. [MAJOR] [mailpoet_archive] shortcode fetches ALL sent newsletters without a LIMIT — unbounded SELECT will OOM on large sites

  • Area: Archive shortcode / Newsletter listing
  • Persona affected: admin
  • Confidence: 0.95
  • Session: scale-queries

Steps to reproduce:

    1. On a MailPoet installation with hundreds or thousands of sent newsletters, create a WordPress page.
    1. Add the shortcode [mailpoet_archive] with no attributes.
    1. Publish the page.
    1. Load the page as a frontend visitor.
    1. Observe: MailPoet calls NewslettersRepository::getArchives() with no limit parameter.

Expected: The shortcode should apply a default LIMIT (e.g., 100 or configurable) to the newsletter SELECT query to prevent unbounded result sets.

Actual: getArchives() at lib/Newsletter/NewslettersRepository.php:301-306 only applies setMaxResults() when $params['limit'] is a positive int. Shortcodes.php:189 sets 'limit' => null as default when no limit attribute is provided. The resulting query has no LIMIT clause — it fetches every sent newsletter into a PHP array, then iterates over all of them in a foreach loop at Shortcodes.php:166, each iteration potentially triggering an additional query via getLatestQueue().

Evidence: · console

Notes: empirical-confirmation-blocked: local Studio site has 0 sent newsletters so OOM cannot be reproduced empirically; source-evidence: lib/Newsletter/NewslettersRepository.php:301-306 — missing default LIMIT + lib/Config/Shortcodes.php:189 — null default for limit param. The [mailpoet_archive] shortcode is a public-facing frontend feature; any visitor can trigger this unbounded query simply by loading the page. On production sites with thousands of newsletters (common for high-volume senders), this will exhaust the 40-128MB PHP memory limit. scale-sensitive c2 fallback: source pattern filed as major Problem — lib/Newsletter/NewslettersRepository.php:301-306 unbounded SELECT with no default LIMIT when limit param is null.

5. [MAJOR] Welcome wizard 'Skip this step' link bypasses sender-name validation, silently saving blank sender name

  • Area: Welcome wizard / sender configuration
  • Persona affected: admin
  • Confidence: 0.95
  • Session: settings-sending

Steps to reproduce:

  1. Navigate to admin.php?page=mailpoet-welcome-wizard (with no version key in wp_mailpoet_settings)
  2. Clear the 'From Name' textbox (make it empty)
  3. Clear the 'From Address' textbox (make it empty)
  4. Click the 'Continue' button — validation fires ('This value is required.')
  5. Instead, click 'Skip this step' link below the Continue button
  6. Wizard advances to step 2 without saving sender info
  7. Query wp_mailpoet_settings for 'sender': value shows name='' (empty string), address=[email protected] (pre-filled default)

Expected: The 'Skip this step' link should either (a) also enforce sender validation so the sender name/email must be filled before advancing, or (b) clearly communicate to the admin that sender info has been left blank and emails will send without a From name, with a way to remedy before sending.

Actual: Clicking 'Skip this step' advances the wizard to step 2 with no validation error. The sender name remains blank in wp_mailpoet_settings (name=''). Subsequent email sends will use an empty From name, causing poorly-formed email headers and potential deliverability issues.

Evidence: · · console

Notes: The 'Continue' button correctly validates the required fields. However, the 'Skip this step' link bypasses this validation entirely. The DB confirms the blank sender name is persisted. The same blank value is visible in the Basics settings tab after wizard completion (screenshots/06-basics-empty-sender-name.png). Save-roundtrip verified: wizard skip -> sender.name='' stored -> Basics tab shows empty 'From Name' field.

6. [MAJOR] Duplicate email on subscriber add form causes silent redirect to subscriber list with no error feedback

  • Area: Subscriber add form
  • Persona affected: admin
  • Confidence: 0.95
  • Session: subscriber-import-mgmt

Steps to reproduce:

  1. Navigate to /wp-admin/admin.php?page=mailpoet-subscribers#/new
  2. Enter an email address that already exists in the subscribers table (e.g. [email protected])
  3. Click Save

Expected: An inline error message should appear explaining that this email is already subscribed, or the form should stay on the add page with a clear duplicate-email error

Actual: The browser receives a 400 Bad Request from admin-ajax.php; the UI silently redirects to the subscriber list page (#/) with no error message visible to the user. The admin has no way to know the save failed.

Evidence: · [console](sessions/subscriber-import-mgmt/console-logs.txt — [ERROR] Failed to load resource)

Notes: The 400 response is the server correctly rejecting the duplicate, but the client-side error handler silently redirects instead of surfacing the error. The invalid-email path correctly shows an error ('Your email address is invalid!'), making the silent-redirect behavior for duplicates inconsistent.

7. [MAJOR] No pre-operation snapshot or backup before newsletter bulk delete or email body purge by cron

  • Area: Destructive operations — newsletter delete, cron body cleanup
  • Persona affected: admin
  • Confidence: 0.9
  • Session: destructive-ops

Steps to reproduce:

  1. Attempt to find any backup/snapshot prompt or mechanism before executing bulk newsletter delete
  2. Review UI for any 'Create backup before deleting' option
  3. Review SendingQueueBodyCleanup source code for any backup step before nullifying rendered body

Expected: A pre-operation snapshot or explicit acknowledgment that data cannot be recovered should be provided before irreversible deletion. The cron body cleanup should log which sending queues it is purging.

Actual: No pre-operation backup mechanism exists in UI or source. NewsletterDeleteController.php.bulkDelete() executes deletion immediately. SendingQueueBodyCleanup.php nullifies rendered email body (newsletter_rendered_body) for all sends older than the retention period with no backup.

Evidence: · console

Notes: b2 AND-list anchor verdict: N — no pre-op snapshot for any destructive operation. b3 AND-list anchor verdict: N — no dry-run/preview mode. The body cleanup default (30 days) means sent emails lose their rendered body silently. Settings page mentions view-in-browser will 're-render from original template' — but if the template was later modified, the preview will show different content than what was sent.

8. [MAJOR] ListingRepository.getActionableIds() fetches ALL matching IDs without LIMIT for bulk 'select all' actions

  • Area: Subscribers / Newsletters listing bulk actions
  • Persona affected: admin
  • Confidence: 0.9
  • Session: scale-queries

Steps to reproduce:

    1. On a MailPoet installation with 100,000+ subscribers, go to MailPoet > Subscribers.
    1. Click 'Select All' to select all subscribers (not just the current page).
    1. Choose a bulk action (e.g., Delete, Add to list, Unsubscribe).
    1. Confirm the action.
    1. Observe: ListingRepository::getActionableIds() runs an unbounded SELECT fetching all matching IDs.

Expected: Bulk action ID fetching should paginate or stream IDs rather than loading all matching IDs into PHP memory at once.

Actual: lib/Listing/ListingRepository.php:42-53: when $ids = $definition->getSelection() is empty (user clicked 'select all' without checking individual rows), the code clones the query builder, applies constraints (filters, search, group), selects only the id column, and calls getScalarResult() with NO setMaxResults(). This loads all matching IDs into a PHP array. On a site with 100k subscribers and no active filters, this fetches 100k integer IDs into memory. On sites with 1M+ subscribers this will exhaust the PHP memory limit.

Evidence: · console

Notes: empirical-confirmation-blocked: local Studio site has 0 subscribers so OOM cannot be reproduced empirically; source-evidence: lib/Listing/ListingRepository.php:42-53 — no LIMIT applied when selection is empty. This affects any entity with a listing page (subscribers, newsletters). The 'Select All' UI element is visible on the subscribers listing page at admin.php?page=mailpoet-subscribers. scale-sensitive c2 fallback: source pattern filed as major Problem — lib/Listing/ListingRepository.php:42-53 unbounded SELECT fetching all IDs into PHP array when selection is empty.

9. [MAJOR] SMTP credentials (host, login, password) persist in wp_mailpoet_settings after switching to MailPoet Sending Service

  • Area: Settings / Send With... (MTA configuration)
  • Persona affected: admin
  • Confidence: 0.85
  • Session: settings-sending

Steps to reproduce:

  1. Navigate to admin.php?page=mailpoet-settings#/mta
  2. Click 'Configure' under the 'Other' (SMTP/host) option
  3. Select 'SMTP' from the Method dropdown
  4. Fill in SMTP Hostname, Port, Login, and Password with test credentials
  5. Click 'Activate' to save the SMTP configuration
  6. Navigate back to admin.php?page=mailpoet-settings#/mta
  7. Click on the 'MailPoet Sending Service' radio option — page redirects to #/premium
  8. Query wp_mailpoet_settings for 'mta' and 'mta_group': both still show SMTP credentials

Expected: When an admin switches from SMTP to MailPoet Sending Service (by selecting that option), the SMTP credentials (host, login, password) should be cleared from the database or at minimum the mta_group should switch to 'mailpoet', so that SMTP credentials are not unnecessarily retained in plaintext.

Actual: The DB mta field retains: password='supersecretpassword123', login='[email protected]', hostname='smtp.testexample.com', port='587'. The mta_group field remains 'smtp'. These credentials persist even after the admin navigates to the MailPoet Sending Service setup page.

Evidence: · console

Notes: The 'MailPoet Sending Service' selection immediately redirects to the premium/key page (#/premium) rather than issuing a save. The mta_group never gets updated to 'mailpoet'. The root issue is that the radio button selection for MailPoet Service navigates away without persisting the method change. The SMTP credentials remain in the mta JSON blob. This is a data hygiene problem — unnecessary credential retention — rather than an active credential exposure, since the UI won't use them if the user eventually sets up MailPoet Service correctly via key entry.

10. [MAJOR] Subscriber export loads 15000-row batches into PHP memory simultaneously, risking OOM on shared hosting at production scale

  • Area: Export artifacts -- subscriber export scale (F8, a7)
  • Persona affected: admin
  • Confidence: 0.8
  • Session: artifact-exports

Steps to reproduce:

    1. Seed 100k+ subscribers to the site
    1. Navigate to /wp-admin/admin.php?page=mailpoet-export
    1. Select the large segment, click Export
    1. Monitor PHP memory during export — each batch of 15000 subscribers is loaded into PHP memory as an array of associative rows
    1. On shared hosting with 64MB memory limit, expect OOM error partway through export

Expected: Export should use a streaming approach that keeps memory usage bounded regardless of subscriber count, or should use smaller batches with explicit memory management.

Actual: Source: Export.php:18 SUBSCRIBER_BATCH_SIZE = 15000. Each call to getSubscribersBatchBySegment() loads 15000 full subscriber rows (all selected fields) into PHP memory as an associative array. set_time_limit(0) prevents timeout but not OOM. 100k subscribers requires ~7 batch iterations; each batch may use 15-30MB depending on field widths. On 64MB PHP memory limit environments, the first or second batch may OOM.

Notes: scale-sensitive c2 a7: source pattern filed — lib/Subscribers/ImportExport/Export/Export.php:18 SUBSCRIBER_BATCH_SIZE=15000 with set_time_limit(0). Empirical OOM not triggered because local test site has only 5 subscribers. The XLSX writer (XLSXWriter) does stream rows to temp files, so final file assembly is not an additional memory concern. The batch-load-to-memory pattern is the primary risk. Mitigation: reduce SUBSCRIBER_BATCH_SIZE to 1000-5000 and/or use generator-based streaming with explicit memory_get_usage() checks. empirical-confirmation-blocked: batch too small on test site (5 rows); source-evidence: Export.php:18 — SUBSCRIBER_BATCH_SIZE = 15000 with set_time_limit(0).

Needs human review (confidence < 0.7)

  • [minor] Alpha block editor triggers multiple REST API 500 errors on initial page load which may prevent full editor initialization (confidence: 0.6, session: newsletter-editor)

Questions raised

  • [Export artifacts -- statistics export (F9)] Does the statistics export UI surface an explicit download link after the cron task completes, and does that link point to the web-accessible file URL (P1 vector) or to a protected authenticated download endpoint?
    • Why it matters: If the UI presents the export file URL directly to the admin as a clickable link, the URL is also the direct unauthenticated access URL (P1). The admin might share this link unknowingly, or it could appear in browser history accessible to others.
  • [Export artifacts -- recipient statistics export (F13)] Is the per-recipient statistics export (FILTER_RECIPIENT_ROWS) available on any tier of MailPoet, and does the a1 unauthenticated access vulnerability apply equally to recipient export files?
    • Why it matters: Recipient export would contain individual subscriber open/click tracking data (PII) in addition to email addresses. The free plugin ships an empty implementation, but if the premium plugin populates this, the same P1 vulnerability would apply with higher PII severity.
  • [Newsletter bulk delete — b6 capability gate UX] The unauthorized error message returned to non-admin users ('Sorry, but we couldn't connect to the MailPoet server. Please refresh the web page and try again.') is misleading — it implies a network/server error rather than a capability/permission denial. Is this intentional or a bug in the error message mapping?
    • Why it matters: A misleading error message makes debugging harder for developers and creates user confusion. A permission denial should be communicated as such.
  • [Subscribe form — email confirmation] Is the 'There was an error when sending a confirmation email' error a correct user-facing message when the underlying cause is server email misconfiguration? Should a more generic error be shown to the subscriber?
    • Why it matters: The error message is technically accurate but potentially alarming to the subscriber, who may not understand that the issue is on the site owner's side, not their own email address.
  • [Subscribe form — AJAX 400 response for expected errors] Is HTTP 400 the correct status code for application-level subscription errors like 'Please select a list.' or email-send failures? These are not malformed requests — they are expected application states.
    • Why it matters: HTTP 400 Bad Request implies the client sent a malformed request. Using it for business logic errors (no list configured, email send failure) may confuse developers integrating with the API and could cause confusion with CDN/proxy error caching.
  • [Frontend subscription form i18n] Do subscription form submit button label, placeholder text, and error/success messages translate to the site locale when MailPoet translation files are installed?
    • Why it matters: H23 predicts the frontend form remains in English. The .po files for de_DE and ar are present and contain form-related strings, but empirical verification of the frontend rendering was blocked by form data format issues in this session.
  • [Date formatting locale awareness] Does the system report (Help page) display dates in locale-appropriate format when the site locale is non-English?
    • Why it matters: H21 predicts English date formats. Source analysis shows date('Y-m-d H:i:s') is used — numeric format without month/day names — but empirical verification was blocked by SQLite incompatibility on the Help page.
  • [Alpha block editor REST errors] Are the REST API 500 errors on Alpha editor initial load a SQLite-specific issue, or do they also occur on MySQL production environments?
    • Why it matters: If the 500 errors only occur on SQLite (Studio test environment), they are an env issue and not a production bug. If they occur on MySQL, the block editor may be failing to load required data in production, silently degrading editor functionality.
  • [Classic editor block operations] Do delete and undo operations in the classic DnD editor produce inconsistent state (H9 second half)?
    • Why it matters: Only the duplicate operation was tested. H9 specifically calls out delete and undo as risk areas for container/spacing artifacts.
  • [Archive shortcode / Newsletter listing] Does the [mailpoet_archive] shortcode's foreach loop at Shortcodes.php:166 trigger N+1 queries via $newsletter->getLatestQueue(), or does the repository use join/eager loading to pre-fetch queues?
    • Why it matters: If getLatestQueue() fires an additional SQL query per newsletter, a site with 1000 newsletters fires 1001 queries on each page load containing [mailpoet_archive]. This could cause request timeouts even before hitting PHP memory limits.
  • [Settings / MTA / SMTP credential lifecycle] Is there a deliberate design decision to retain SMTP credentials when switching to MailPoet Sending Service, and if so, is there any data-at-rest protection (encryption) for the stored SMTP password?
    • Why it matters: The SMTP password is stored in plaintext in wp_mailpoet_settings. If the design intent is credential retention (e.g. to allow easy switch-back), at minimum the password should be encrypted at rest.

Suggested improvements

  • [Export artifacts -- file access protection (F8, F9)] Add a .htaccess file to wp-content/uploads/mailpoet/ that denies direct HTTP access to export files, and serve downloads via an authenticated WordPress endpoint (e.g., admin-post.php action with nonce + capability check) that streams the file and deletes it after delivery. (effort: medium) (impact: high)
    • Rationale: The current architecture (place file in uploads dir, return URL to admin) creates a 1-7 day window where any visitor with the URL can download subscriber PII. A protected download flow prevents this entirely.
  • [Export artifacts -- subscriber export field defaults (F8)] Change default field selection in subscriber export to exclude IP address fields (Subscription IP, Confirmation IP), or add a GDPR notice when IP fields are selected for export. (effort: low) (impact: medium)
    • Rationale: Data minimization principle: exports should not include PII fields by default unless explicitly selected. IP address data has distinct retention and processing requirements under GDPR.
  • [Newsletter bulk delete] Add a confirmation modal before 'Empty Trash' (permanent delete) that names how many newsletters will be permanently deleted and explicitly states the action cannot be undone. For 'Move to trash' bulk action, consider showing a brief toast/notification with an 'Undo' link (30-second window) instead of a blocking modal. (effort: low) (impact: medium)
    • Rationale: Permanent deletion of email campaigns is a high-stakes irreversible action. Even with a Trash mechanism, the permanent delete step warrants explicit confirmation. The scope information (N newsletters) helps admins verify they've selected the right items.
  • [Plugin lifecycle — uninstall] Add uninstall.php that (with explicit admin confirmation) drops all wp_mailpoet_* tables and removes plugin options. Alternatively, add a 'Remove all MailPoet data' option in Settings > Advanced that performs cleanup before the admin deletes the plugin. (effort: medium) (impact: high)
    • Rationale: WordPress plugin guidelines require cleanup on uninstall. The current behavior permanently clutters any database where MailPoet was trialed and removed.
  • [Util/Cookies — delete method] Add setcookie($name, '', time() - 3600, $path, $domain, $secure, $httponly) call in Cookies::delete() before the unset() to properly expire the cookie in the browser. (effort: low) (impact: low)
    • Rationale: Without the setcookie() call, delete() only removes the cookie from the current PHP request's superglobal. The browser retains the cookie and sends it on every subsequent request, defeating the purpose of deletion.
  • [Date/time formatting] Replace raw PHP date() calls in SystemReportCollector.php:167-176, TransactionalEmails.php:111, and DateConverter.php with wp_date() to ensure timezone consistency and future-proof locale awareness (effort: low) (impact: low)
    • Rationale: While the current numeric formats (Y-m-d H:i:s) do not produce locale-specific text like month names, wp_date() respects the WordPress timezone setting while date() uses the server's PHP timezone. This can cause timestamp discrepancies for admins in non-server timezones.
  • [Newsletter scheduling] Add client-side validation to the schedule date/time fields that prevents submission when the selected datetime is in the past, with a clear error message: 'The scheduled time is in the past. Please choose a future date and time.' (effort: low) (impact: high)
    • Rationale: Users who accidentally set a wrong year or select a past time have no feedback before the newsletter is sent to their full subscriber list. The current silent acceptance creates a high-stakes mistake scenario for email marketers.
  • [Alpha block editor] Add a persistent 'Alpha' indicator in the editor toolbar once inside the new email editor, so users are always aware they are in the experimental editor. (effort: low) (impact: medium)
    • Rationale: The entry dialog labels it Alpha, but once inside the editor there is no persistent indicator. Users may lose context after working in the editor for a while.
  • [Welcome wizard / sender configuration] Either disable the 'Skip this step' link when sender fields have been partially cleared (to prevent silent blank-name saves), or add an inline warning when the sender name is empty before advancing to the next wizard step. (effort: low) (impact: high)
    • Rationale: Blank sender names cause all MailPoet emails to have empty From headers, silently degrading deliverability. A simple guard or warning would catch most accidental skips.
  • [Settings / Send With... (MTA)] When the admin selects 'MailPoet Sending Service', before navigating to the key entry page, prompt: 'Would you like to clear your previously configured SMTP credentials?' — and clear them if confirmed. (effort: low) (impact: medium)
    • Rationale: Unnecessary credential retention is a data hygiene concern. Clearing SMTP credentials when the admin explicitly chooses a different method reduces attack surface and avoids confusion on return visits.
  • [Settings / Advanced / Cron trigger] Add a persistent admin notice or status badge on mailpoet-homepage and mailpoet-newsletters pages when sending is paused or disabled (cron_trigger.method='none' or similar). (effort: low) (impact: high)
    • Rationale: Admins can unknowingly queue emails that never send. A clear indicator on operational pages reduces the time-to-diagnosis significantly.
  • [CSV import wizard] Add a 'skipped' count to the import summary showing how many rows were rejected due to invalid email format, and a 'de-duplicated' count showing how many in-CSV duplicate rows were collapsed before import (effort: low) (impact: medium)
    • Rationale: Users importing from spreadsheets often have dirty data. Without knowing how many rows were skipped, they may assume all rows were imported and send to a smaller list than intended.
  • [Subscriber add form] Add real-time duplicate-email detection: when the email field loses focus (or on form submit), check if the email already exists and show an inline warning before attempting the server-side save (effort: medium) (impact: high)
    • Rationale: The current flow shows a misleading UX: the form appears to submit successfully (redirects to list) but the save silently failed. Client-side pre-check or a visible error on the save failure would prevent admin confusion.

What works well (praises)

  • [Export artifacts -- filename uniqueness (a2)] Both subscriber and statistics export filenames use Security::generateRandomString(15) — a 15-character cryptographically random string from a 62-character charset — making brute-force enumeration of filenames computationally infeasible.
    • Why: Many plugins use timestamp-based filenames (e.g. export-2026-05-04-1021.csv) that are guessable within a narrow time window. MailPoet's approach is significantly more secure. Note: this mitigates but does not eliminate the P1 concern, since the URL may appear in logs, browser history, or email.
  • [Export artifacts -- lifecycle management (a4)] ExportFilesCleanup cron worker automatically deletes export files after a retention period (1 day for subscriber exports, 7 days for stats exports), limiting the unauthenticated access exposure window.
    • Why: Many plugins never clean up export artifacts, leaving PII files on disk indefinitely. Having a cleanup mechanism demonstrates awareness of the lifecycle concern, even if the access protection gap (P1) remains the primary issue.
  • [Export artifacts -- blast radius (a5)] No export fires without explicit user action. StatisticsExport cron worker has AUTOMATIC_SCHEDULING=false; subscriber export is synchronous on user request; ExportFilesCleanup runs automatically but only cleans up, never generates.
    • Why: Correct-by-default: exports only happen when an admin explicitly requests them. No surprise artifact generation on activation, plugin install, or scheduled tasks.
  • [Newsletter delete — undo path] MailPoet implements a proper two-stage delete (Move to trash → Delete permanently) with a Restore option in the Trash view. This is a well-designed undo path that prevents accidental permanent data loss.
    • Why: Many email plugins immediately permanently delete campaigns. The soft-delete design is especially important for newsletters with statistics that represent months of data.
  • [Newsletter bulk delete — transaction wrapping] NewsletterDeleteController.php wraps the entire multi-table cascade in a Doctrine entity manager transaction with proper rollback on exception.
    • Why: Protecting a cascade across 10+ related tables with a transaction demonstrates good engineering hygiene. A partial failure on a table deletion will rollback all changes rather than leaving orphaned rows across statistics, links, and segment tables.
  • [Capability gates] The MailPoet API enforces the mailpoet_manage_emails capability at the API endpoint level via the $permissions declaration, not just in the UI. Editor users cannot bypass the UI to call destructive API endpoints.
    • Why: API-level capability enforcement prevents privilege escalation attacks and ensures that UI-level restrictions are backed by server-side checks.
  • [Plugin deactivation — Action Scheduler cleanup] MailPoet's deactivation hook properly calls actionSchedulerRunner->deactivate(), clearing all pending MailPoet cron jobs from the Action Scheduler queue.
    • Why: Pending cron actions that reference a deactivated plugin's code cause fatal PHP errors when they fire. This cleanup prevents orphaned scheduler entries from erroring after plugin removal.
  • [Subscribe form — inline feedback] Form error and success messages are displayed inline via the .mailpoet_message div without page reload. The AJAX response is parsed and the appropriate message shown/hidden.
    • Why: Inline feedback significantly improves UX for subscription forms embedded in content — users don't lose their context on the page.
  • [Subscribe form — honeypot anti-spam] The rendered form includes a honeypot field: Please leave this field empty
    • Why: Honeypot fields are an effective low-friction spam prevention technique. The field is visually hidden but accessible to screen readers, with appropriate aria labeling.
  • [Subscribe form — XSS protection in inline JS] SubscriptionFormBlock.php uses json_encode($forms, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) for the window.mailpoet_forms assignment, correctly using JSON_HEX_TAG to prevent script injection.
    • Why: JSON_HEX_TAG converts < and > to \u003C and \u003E respectively, which is the correct escaping approach for JSON data embedded in HTML script tags. The phpcs:ignore comment is justified.
  • [JS translation pipeline] MailPoet uses wp_set_script_translations() for ALL its admin JS bundles (commons, mailpoet, admin, settings, automation, etc.) and ships corresponding JSON translation files for both de_DE and ar
    • Why: This is the correct WordPress pattern for translating React-based admin UIs. The plugin has invested in a complete JS i18n pipeline — most admin strings translate correctly, including complex modal content, form field labels, and validation messages. Arabic RTL layout also works correctly out-of-the-box.
  • [Arabic (RTL) translation quality] Arabic locale fully translates MailPoet admin navigation, page headings, column headers, and button labels with proper RTL layout direction
    • Why: RTL support is often an afterthought; MailPoet demonstrates working RTL admin UI with document.dir='rtl' properly applied and CSS handling the layout correctly. Translation quality for Arabic appears comprehensive.
  • [Newsletter creation flow] The 'Choose editor version' dropdown clearly exposes the Alpha editor option with a confirmation dialog ('Note: Emails created here can't be opened in the legacy editor.') before committing
    • Why: This is a good UX pattern for introducing Alpha features — gating it behind a deliberate choice with clear consequences prevents accidental adoption and sets expectations. The warning about legacy editor incompatibility is accurate and actionable.
  • [Cron / SendingTaskSubscribersCleanup] The cleanup worker implements a dual safety mechanism: ROW_BATCH_SIZE=10000 rows per batch AND MAX_EXECUTION_TIME=30 seconds per run, plus a 100ms pause between iterations for I/O throttling.
    • Why: This is a well-designed approach to batched cleanup — it bounds both memory usage per iteration and total execution time, making the cron job safe to run on shared hosting without timeouts or runaway queries.
  • [Welcome wizard / sender validation] The 'Continue' button on Step 1 correctly validates both the sender name and sender email as required fields, showing 'This value is required.' inline validation messages for each empty field.
    • Why: Inline per-field validation that prevents form submission is the correct pattern for required sender info. The validation fires immediately on click without a server roundtrip, giving fast feedback.
  • [Settings / MTA / SMTP password field] The SMTP Password field uses type='password' in the rendered HTML, correctly masking the credential in the browser UI.
    • Why: Credential fields must use type='password' to prevent visual exposure and browser autofill history leakage. This is handled correctly.
  • [Subscriber add form - invalid email validation] The subscriber add form immediately rejects invalid email formats with a clear inline message 'Your email address is invalid!' on form submit, keeping the user on the form to correct their input
    • Why: Clear, actionable, inline validation is best-practice UX for form errors
  • [MailChimp import - API key error handling] Entering an invalid MailChimp API key and clicking Verify shows 'Invalid API Key.' — a clean, user-friendly message with no raw exception text, JSON blobs, or HTTP error codes exposed to the user
    • Why: The code catches exceptions (lib/API/JSON/v1/ImportExport.php:94-104) and translates them to a readable message. This is correct error handling for external API failures.

Coverage gaps

Session Status Turns Flows Notes
artifact-exports complete 12/12 5/5 artifact-filename-uniqueness probed: timestamp precision → N/A (Security::generateRandomString(15) used for both subscriber and stats export, no timestamp involved); collision risk → N (15-char random string from 62-char charset, astronomically unlikely). default blast radius probed: subscriber export → N (user-initiated only); statistics aggregate export → N (user-initiated or cron task on-demand only); recipient statistics export → N (AUTOMATIC_SCHEDULING=false on StatisticsExport cron worker). a5 = N for all surfaces. scale-sensitive c2 a7: subscriber export uses SUBSCRIBER_BATCH_SIZE=15000 batching with LIMIT/OFFSET — each batch loads 15000 subscriber rows into PHP memory simultaneously; on shared hosting with 64MB memory limit this batch size may cause OOM. Stats export aggregate is per-newsletter with no unbounded SELECT — scale concern exists in bulk action but is secondary. Recipient statistics export surface: empirical probe deferred — free plugin ships empty implementation behind premium-only filter, no exportable rows available. ExportFilesCleanup cron worker has AUTOMATIC_SCHEDULING=true (inherits from SimpleWorker) with DELETE_FILES_AFTER_X_DAYS=1 for subscriber exports and DELETE_STATS_FILES_AFTER_X_DAYS=7 for stats exports; files persist in web-accessible directory for up to 7 days with no authentication gate. scale-sensitive c2 fallback: source pattern filed as major Problem — lib/Subscribers/ImportExport/Export/Export.php:18 SUBSCRIBER_BATCH_SIZE=15000 with set_time_limit(0); each batch loads 15000 full subscriber rows (including all selected fields) into PHP memory simultaneously.
destructive-ops complete 12/12 6/7 default blast radius probed: newsletter bulk delete + Empty Trash → all related tables empty (wp_mailpoet_newsletter_links=0, wp_mailpoet_statistics_=0, wp_mailpoet_sending_queues=0) → Y cascade is clean. Deactivation: Action Scheduler entries cleared → Y. Missing uninstall.php confirmed: no cleanup on plugin deletion → 40 wp_mailpoet_ tables persist. Sending status data retention defaults to Never (task_subscribers cleanup disabled by default). Body cleanup defaults to 30 days (view-in-browser for old sends becomes unavailable). b6 API capability enforcement confirmed: editor receives 'unauthorized' on bulk delete AJAX call.
forms-subscribe-frontend complete 8/8 4/4 H18 popup dismissal was confirmed as a source-level defect (Cookies.delete() does not call setcookie() with past expiry) but the browser-side popup dismiss flow was not exercised due to budget constraints. H18 also clarified: the PHP Cookies::delete() bug primarily affects SubscriberCookie legacy migration (not popup suppression, which is JS-managed). H19 was confirmed safe for shortcode rendering; the inline JS risk in SubscriptionFormBlock.php is editor-context-only (admin). WP-CLI 'db query' unavailable (SQLite, no DB_HOST constant) — env warning.
i18n-surface complete 8/8 4/4 H21 probe (date formatting in system report) blocked by SQLite env limitation — MailPoet's Help page uses CREATE TEMPORARY TABLE AS SELECT which SQLite does not support. Source analysis of SystemReportCollector.php:167-176 shows date('Y-m-d H:i:s', ...) format which is numeric/locale-agnostic (no English month/day names). H23 (frontend form) partially blocked by unserialize errors from manually-created form data; form template selection page was used as a proxy showing partial English strings.
newsletter-editor complete 8/8 4/4 H9 (classic editor block operations): duplicate operation tested — content copied correctly, no inconsistent state found; delete and undo not tested due to budget. H10 (Alpha editor persistence): editor loaded via WP block editor (post.php), auto-save confirmed working on reload; REST 500 errors on initial page load investigated and documented as env-warning candidate. H11 (preview fidelity): preview modal opened and rendered newsletter content including duplicated block correctly; no visible rendering difference observed in a11y/screenshot comparison. H12 (schedule past time): confirmed bug — scheduling with past date+time accepted silently, no warning shown, newsletter transitions immediately to 'sending' status.
scale-queries complete 8/8 3/3 All four hypotheses probed via source analysis plus browser UI verification. H1 and H2 confirmed as source-pattern bugs; H3 refuted for server-side PHP memory but nuanced browser-side concern noted; H4 refuted due to 30-second time cap present. scale-sensitive c2 fallback: source pattern filed as major Problem — lib/Newsletter/NewslettersRepository.php:301-306 unbounded SELECT with no default LIMIT when limit param is null
settings-sending complete 8/8 4/4 Four hypotheses fully probed within budget. H5: wizard 'Skip this step' link bypasses sender-info validation, confirmed with DB evidence. H6: SMTP credentials (host, login, password) persist in wp_mailpoet_settings after navigating away to MailPoet Sending Service; the mta_group remains 'smtp'. H7: no visible mode indicator on mailpoet-homepage, mailpoet-newsletters, or mailpoet-subscribers when cron_trigger.method='none'. H8: the Advanced tab shows 'Action Scheduler' checked when DB has 'none' — UI round-trip failure for non-enum cron values. Moment.js deprecation warning in JS console noted as a minor code quality issue.
subscriber-import-mgmt complete 8/8 4/4 All three hypotheses probed within budget. H13 duplicate email scenario revealed a silent failure (redirect to list with no error) rather than inline validation. H14 import summary omits invalid-email and in-CSV-duplicate counts. H15 error message is user-friendly, not a raw exception.

Environment warnings

These are signals observed during the run that point at test-environment quirks (Studio + SQLite shim, WP-CLI Phar, WC stack interactions), NOT plugin defects. Apply extra scrutiny to findings in affected areas — some Problems may be false positives caused by the environment, and some real bugs may be masked.

Session Warning
artifact-exports PHP Warning from moment.js in MailPoet admin JS: 'Deprecation warning: value provided is not in a recognized RFC2822 or ISO format' — JavaScript library issue in compiled assets, not a PHP or server environment concern.
artifact-exports Recipient statistics export (F13, FILTER_RECIPIENT_ROWS) returned empty rows in free plugin — premium feature unavailable on Studio test environment. a3 probe for this surface is N/A, not a defect.
destructive-ops wp db query fails with 'Undefined constant DB_HOST' in SQLite environment — used wp eval workarounds for all DB state inspection. This is a Studio + SQLite + wp-cli interaction, not a plugin defect.
destructive-ops 15 browser console errors on initial MailPoet newsletters page load (HTTP 500 on load-scripts.php?chunk=hoverIntent,wp-dom-ready,wp-hooks) prevented React app from mounting on first load. App mounted correctly after page reload. These errors appear to be a Studio environment warmup issue, not a MailPoet defect.
destructive-ops WP-CLI plugin deactivation via browser click appeared not to work (plugin remained active after clicking Deactivate link) — deactivation succeeded via 'studio wp plugin deactivate mailpoet'. Root cause uncertain (possible nonce timeout or Studio proxy quirk).
forms-subscribe-frontend WP-CLI 'studio wp db query' failed with 'Undefined constant DB_HOST' — SQLite integration does not expose MySQL connection constants; db query commands are unavailable in this environment.
forms-subscribe-frontend Email sending failed with 'There was an error when sending a confirmation email' on subscribe form submission — Studio environment has no outbound email configured; subscriber was created (status: unconfirmed) but confirmation email was not sent. This is expected in Studio and not a plugin defect.
i18n-surface PHP Warning 'include(/wordpress/wp-content/languages/plugins/mailpoet-de_DE.l10n.php): Failed to open stream' on mailpoet-newsletters and mailpoet-subscribers pages — WP 6.9.4 tries to load pre-compiled .l10n.php translation cache which WP-CLI language install does not generate; falls back to .mo successfully but emits output before headers causing 'Cannot modify header information' cascade. This caused translation failures on newsletters and subscribers pages in de_DE locale. NOT a MailPoet plugin bug — WP core behavior change in 6.5+ that requires the .l10n.php file to be pre-generated.
i18n-surface Fatal error on mailpoet-help page: MailPoet's DataInconsistencyRepository uses 'CREATE TEMPORARY TABLE IF NOT EXISTS task_ids SELECT DISTINCT task_id FROM wp_mailpoet_scheduled_task_subscribers' — MySQL syntax not supported by SQLite shim. Caused H21 empirical probe to be blocked. This is a SQLite/MySQL compatibility issue in the test environment, not a plugin i18n defect.
i18n-surface Unserialize errors on frontend subscription form page: manually-created form via WP-CLI INSERT had JSON-encoded data in wrong format, causing 'unserialize(): Error at offset 0' warnings and preventing shortcode rendering. H23 frontend probe was thus inconclusive.
newsletter-editor REST API endpoints /wp/v2/settings, /wp/v2/pages, /wp/v2/global-styles/9, and /woocommerce-email-editor/v1/personalization_tags returned 500 in browser context on Alpha editor initial load but returned 200 via WP-CLI (authenticated) and via in-page fetch() with nonce. Likely a SQLite + session initialization race condition on initial page load. Not confirmed as a plugin defect — may be Studio + SQLite interaction affecting the authentication path for the first batch of REST requests from the block editor JS.
newsletter-editor wp_mailpoet_newsletters table in SQLite does not have a 'scheduled_at' column (confirmed 'no such column' error when using raw SQL), so newsletter schedule time could only be verified via status column ('scheduled' then 'sending') rather than exact timestamp. This is a SQLite schema migration difference from MySQL and limits schedule-time verification in this environment.
settings-sending Moment.js deprecation warning in JS console during MailPoet settings page load: 'Deprecation warning: value provided is not in a recognized RFC2822 or ISO format' — this appears to be triggered by MailPoet's own JS (commons.js) with an undefined timestamp value. May also manifest in production.
settings-sending SQLite shim (sqlite-database-integration) in use — ON DUPLICATE KEY UPDATE for wp_mailpoet_settings translates to INSERT OR REPLACE. Settings save behavior for the mta table was validated functionally but production MySQL may have slightly different serialization behavior.
subscriber-import-mgmt Recurring Moment.js deprecation warning in MailPoet JS bundle (commons.js?ver=5.24.0): 'value provided is not in a recognized RFC2822 or ISO format' — appears on every admin page load, not triggered by any specific test action. Likely a minor JS quality issue in the bundled library, not related to SQLite shim.

Invalid / failed session reports

recon

  • No report.json produced

Token usage & cost

Computed from Claude Code transcripts at ~/.claude/projects/<proj-hash>/. Rates from config/pricing.json. Window: 2026-05-04T19:17:31Z2026-05-04T23:30:07Z (with ±10min buffer for dispatch drift).

Estimated total cost for this run: $61.15

Category Cost % of total
Fresh input $0.02 0.0%
Output $4.70 7.7%
Cache-create (5m) $13.95 22.8%
Cache-create (1h) $1.42 2.3%
Cache-read $41.05 67.1%

Manager (main conversation)

Total: $4.14

Model Messages Input Output Cache-5m Cache-1h Cache-read Cost
claude-sonnet-4-6 97 122 23,460 0 236,102 7,914,706 $4.14

Subagents (11 invocations)

Total: $57.00

Model Messages Input Output Cache-5m Cache-1h Cache-read Cost
claude-sonnet-4-6 1445 1,509 206,727 2,144,396 0 116,137,593 $45.99
claude-opus-4-6 110 3,392 49,956 945,862 0 7,674,595 $11.01
Per-subagent breakdown (11 sessions)
Agent ID Type Models Cost
a0c9f86de88129c94 tester claude-sonnet-4-6 $3.24
a1a213d57bf281025 tester claude-sonnet-4-6 $3.41
a60f0c77c227e8364 tester claude-sonnet-4-6 $3.87
a6c8dec262fece66e tester claude-sonnet-4-6 $4.67
a71b151cf2c467e5b tester claude-sonnet-4-6 $5.54
a9d68c234613dff83 planner claude-opus-4-6 $7.13
abbd9f148c8bd6bde tester claude-sonnet-4-6 $9.24
abd09e09a599a82f4 tester claude-sonnet-4-6 $6.12
adf9fd368944acfbf tester claude-sonnet-4-6 $3.46
aecc7401668b3b0cf tester claude-sonnet-4-6 $6.43
afb7740917b615e45 planner claude-opus-4-6 $3.89

Recommended next steps

  1. Triage Export artifacts -- subscriber export and statistics export (F8, F9) first — highest risk score (4)
  2. Address 1 critical problem(s) before release
  3. Human review needed for 1 low-confidence finding(s)
  4. Follow up on 8 session(s) with incomplete coverage
  5. Investigate 1 session(s) that failed to produce valid reports
  6. Run 4 pending charter(s): magellan resume 2026-05-04T19-17-31_mailpoet
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment