PluginProbe
Mail Queue / 1.6.1
Mail Queue v1.6.1
1.6.1 1.6.0 1.5.1 trunk 1.0 1.1 1.2 1.3 1.3.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.5.0
mail-queue / mail-queue.php

mail-queue.php in Mail Queue 1.6.1, at mail-queue.php

1,086 lines 48.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Plugin Name: Mail Queue
5 * Plugin URI: https://www.webdesign-muenchen.de/wordpress-plugin-mail-queue/
6 * Description: Take Control and improve Security of wp_mail(). Queue and log outgoing emails, and get alerted, if your website wants to send more emails than usual.
7 * Version: 1.6.1
8 * Requires at least: 5.9
9 * Requires PHP: 7.4
10 * Author: WDM
11 * Author URI: https://www.webdesign-muenchen.de
12 * License: GPLv3 or later
13 * License URI: https://www.gnu.org/licenses/gpl-3.0.html
14 */
15
16 if (!defined('ABSPATH')) { exit; }
17
18
19 /* ***************************************************************
20 PLUGIN VERSION
21 **************************************************************** */
22
23 $wdm_wpma_version = '1.6.1';
24
25
26
27
28
29 /* ***************************************************************
30 PLUGIN DEFAULT SETTINGS
31 **************************************************************** */
32 function wdm_wpma_get_settings() {
33 $defaults = array(
34 'enabled' => '0',
35 'alert_enabled' => '0',
36 'email' => get_option('admin_email'),
37 'email_amount' => '10',
38 'pause_on_alert' => '0',
39 'queue_amount' => '1',
40 'queue_interval' => '5',
41 'queue_interval_unit' => 'minutes',
42 'clear_queue' => '14',
43 'tableName' => 'mail_queue',
44 'triggercount' => 0,
45 );
46 $args = get_option('wdm_wpma_settings');
47 $options = wp_parse_args($args,$defaults);
48
49 if ($options['queue_interval_unit'] === 'seconds') {
50 $options['queue_interval'] = intval($options['queue_interval']);
51 if ($options['queue_interval'] < 10) { $options['queue_interval'] = 10; } // Minimum Interval 10 Seconds
52 } else {
53 $options['queue_interval'] = intval($options['queue_interval']) * 60;
54 }
55
56 $options['clear_queue'] = intval($options['clear_queue']) * 24;
57 return $options;
58 }
59
60
61 // Re-syncs the settings global from the DB. Required after any mid-request
62 // update_option('wdm_wpma_settings', …): wdm_wpma_options() only re-reads the DB
63 // when the global is unset, so without this call it would keep returning stale
64 // values. Callers that fetched settings before the refresh must re-call
65 // wdm_wpma_options() to see the new state (the accessor returns a value copy).
66 function wdm_wpma_refresh_settings() {
67 global $wdm_wpma_options;
68 $wdm_wpma_options = wdm_wpma_get_settings();
69 return $wdm_wpma_options;
70 }
71
72
73 // Always returns the settings array, repopulating the global if a
74 // function-scope include (e.g. WP-CLI) left it unset.
75 function wdm_wpma_options() {
76 global $wdm_wpma_options;
77 if ( !is_array($wdm_wpma_options) ) {
78 $wdm_wpma_options = wdm_wpma_get_settings();
79 }
80 return $wdm_wpma_options;
81 }
82
83
84 // Returns the fully-prefixed queue table name, sanitized for direct use in SQL
85 // (the name is config-derived, not user input; sanitizing in one place still
86 // closes the "table name concatenated into SQL" smell).
87 function wdm_wpma_table() {
88 global $wpdb;
89 $options = wdm_wpma_options();
90 return $wpdb->prefix.preg_replace('/[^A-Za-z0-9_]/', '', $options['tableName']);
91 }
92
93
94 // Track the DB row id of the mail currently handed to wp_mail(), so the
95 // wp_mail_failed handler can mark the correct row as 'error'. 0 = none in flight.
96 function wdm_wpma_set_current_mail_id ($id) {
97 global $wdm_wpma_mailid;
98 $wdm_wpma_mailid = intval($id);
99 }
100
101 function wdm_wpma_get_current_mail_id () {
102 global $wdm_wpma_mailid;
103 return isset($wdm_wpma_mailid) ? intval($wdm_wpma_mailid) : 0;
104 }
105
106
107 // Safer drop-in for maybe_unserialize(): refuses to instantiate any class.
108 // Use on DB columns we know to contain only scalars/arrays (recipient, headers, attachments).
109 function wdm_wpma_safe_unserialize ($value) {
110 if (!is_string($value) || !is_serialized($value)) { return $value; }
111 return @unserialize($value, ['allowed_classes' => false]);
112 }
113
114
115 // One-shot guard against replaying a state-changing admin request.
116 //
117 // The Log/Queue list forms submit via GET so that search/filter/pagination
118 // state stays in the URL (that is the WP_List_Table pattern). A side effect is
119 // that a dispatched bulk action (delete / resend / send now) also ends up as a
120 // bookmarkable URL: revisiting it — browser Back, history restore, link
121 // prefetch — would re-run the action while the nonce is still valid (nonces
122 // stay valid for hours), re-queueing or re-sending the same mails.
123 //
124 // This claims a *specific* action exactly once. The fingerprint covers the
125 // action name, the affected row ids and the nonce, so only the identical
126 // request is suppressed; a different selection (even under the same reusable
127 // nonce) is a different fingerprint and runs normally. Returns true the first
128 // time a fingerprint is claimed (caller proceeds), false on any repeat within
129 // the window (caller skips).
130 //
131 // The TTL spans the whole nonce lifetime (nonces verify for up to 24h): a
132 // replay is possible for exactly as long as its nonce still verifies, so a
133 // shorter window would only cover part of the threat. The flip side — a
134 // deliberately repeated identical action (same action, same ids, same still-
135 // valid nonce) is also suppressed and reports 0 processed rows — is accepted:
136 // only "resend the exact same selection again" is affected, and the zero-count
137 // notice makes it visible.
138 //
139 // The claim uses add_option() as the cross-process gate on the unique
140 // option_name key. (On current WP add_option() is INSERT..ON DUPLICATE KEY
141 // UPDATE, so two writers racing with *different* values can in theory both
142 // report success across a second boundary — converting the acquire to a plain
143 // INSERT IGNORE is a logged follow-up. The per-row 'sending' claim in the
144 // sender independently prevents double-sends regardless.) An expiring option
145 // row is used rather than a transient on purpose: under a persistent object
146 // cache a transient has no guaranteed lifetime and may be dropped before it
147 // expires, which would defeat a guard that must reliably fire within its
148 // window. On sites without an object cache the two behave identically.
149 //
150 // Each claim is its own option row (prefix wdm_wpma_claim_), self-expiring:
151 // an expired row is treated as free and reclaimed in place. A best-effort
152 // sweep of expired sibling rows keeps them from accumulating.
153 function wdm_wpma_claim_action_once ( $fingerprint, $ttl = DAY_IN_SECONDS ) {
154 global $wpdb;
155 $key = 'wdm_wpma_claim_' . md5( (string) $fingerprint );
156 $now = time();
157 $expires_at = $now + max( 1, intval( $ttl ) );
158
159 wdm_wpma_sweep_expired_claims();
160
161 // Fresh claim: atomic on the unique option_name key.
162 if ( add_option( $key, $expires_at, '', /*autoload*/false ) ) { return true; }
163
164 // Row exists — only reclaimable if the previous claim has expired. Compare-
165 // and-swap so two concurrent requests can't both reclaim it: exactly one
166 // UPDATE changes the row (the DB row-lock serializes them).
167 $reclaimed = $wpdb->query( $wpdb->prepare(
168 "UPDATE {$wpdb->options} SET option_value = %d WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
169 $expires_at, $key, $now
170 ) );
171 // Direct SQL bypasses the option cache; drop the stale entry so later reads
172 // (and add_option's notoptions flag) stay coherent under a persistent cache.
173 wp_cache_delete( $key, 'options' );
174
175 return $reclaimed === 1;
176 }
177
178 // Best-effort removal of expired replay-guard rows so they don't accumulate in
179 // wp_options. Cheap and bounded: only this plugin's own claim rows, only the
180 // expired ones.
181 function wdm_wpma_sweep_expired_claims () {
182 global $wpdb;
183 $wpdb->query( $wpdb->prepare(
184 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) < %d",
185 $wpdb->esc_like( 'wdm_wpma_claim_' ) . '%', time()
186 ) );
187 }
188
189
190 function wdm_wpma_is_pause_on_alert_active() {
191 return get_option('wdm_wpma_pause_on_alert_active') === '1';
192 }
193
194 function wdm_wpma_clear_pause_on_alert_state() {
195 delete_option('wdm_wpma_pause_on_alert_active');
196 }
197
198 function wdm_wpma_must_activate_pause_on_alert() {
199 $options = wdm_wpma_options();
200
201 if (!isset($options['enabled']) || $options['enabled'] !== '1') { return false; }
202 if (!isset($options['alert_enabled']) || $options['alert_enabled'] !== '1') { return false; }
203 if (!isset($options['pause_on_alert']) || $options['pause_on_alert'] !== '1') { return false; }
204 if (wdm_wpma_is_pause_on_alert_active()) { return false; }
205
206 return true;
207 }
208
209 function wdm_wpma_maybe_activate_pause_on_alert() {
210
211 if (!wdm_wpma_must_activate_pause_on_alert()) { return false; }
212
213 $settings = get_option('wdm_wpma_settings', array());
214 if (!is_array($settings)) { $settings = array(); }
215 $settings['enabled'] = 'paused';
216
217 update_option('wdm_wpma_settings', $settings, true);
218 update_option('wdm_wpma_pause_on_alert_active', '1', false);
219
220 wdm_wpma_push_queue_event([
221 'name' => 'autopause-activated',
222 ]);
223
224 wdm_wpma_refresh_settings();
225
226 return true;
227 }
228
229
230
231 $wdm_wpma_mailid = 0;
232 $wdm_wpma_options = wdm_wpma_get_settings(); // Get Settings
233
234
235
236
237
238 /* ***************************************************************
239 Overwrite wp_mail() if Plugin enabled
240 **************************************************************** */
241 $wdm_wpma_pre_wp_mail_priority = 99999;
242
243 // Intercept in EVERY context, WP-Cron included: a mail sent from a cron job
244 // (WooCommerce follow-ups, newsletters, or malware via wp_schedule_single_event)
245 // must be queued, logged and counted towards the alert like any other. The
246 // plugin's own sends stay exempt by bracketing wp_mail() with
247 // remove_filter/add_filter at each dispatch site, not by disarming the filter
248 // wholesale for the whole cron request.
249 if (in_array($wdm_wpma_options['enabled'], ['1', 'paused'], /*strict*/true)) {
250 // High priority: run late in the game to react to previous filters
251 add_filter('pre_wp_mail', 'wdm_wpma_prewpmail', $wdm_wpma_pre_wp_mail_priority, 2);
252 }
253
254 // pre WP Mail Filter
255 function wdm_wpma_prewpmail($return, $atts) {
256
257 global $wpdb;
258 $wdm_wpma_options = wdm_wpma_options();
259
260 if (!is_null($return)) {
261 // Another pre_wp_mail filter has already returned a value, so the mail is not added to the queue
262 return $return;
263 }
264
265 // Mail Variables
266 $to = $atts['to'];
267 $subject = $atts['subject'];
268 $message = $atts['message'];
269 $headers = $atts['headers'];
270 $attachments = $atts['attachments'];
271 $status = 'queue';
272
273 // Make sure that $headers always is an array
274 if ($headers) {
275 if (!is_array($headers)) {
276 $headers = explode( "\n", str_replace( "\r\n", "\n", $headers ) );
277 }
278 } else {
279 $headers = [];
280 }
281
282 // Loop through email headers
283 // - Instant Sending or Prio Mail? (the first X-Mail-Queue-Prio header wins)
284 // - Track if ContentType / From header is set
285 // Scan every header (no early break) so a Content-Type / From that sits
286 // after the X-Mail-Queue-Prio header is still detected;
287 // All X-Mail-Queue-Prio headers are stripped so they don't leak outbound.
288 $filtered_headers = $headers;
289 $hasContentTypeHeader = false;
290 $hasFromHeader = false;
291 $prio_status_found = false;
292 foreach($headers as $index => $val) {
293 $val = trim($val);
294 if (preg_match("#^X-Mail-Queue-Prio: +Instant *$#i",$val)) {
295 unset($filtered_headers[$index]);
296 if (!$prio_status_found) {
297 $status = 'instant';
298 $prio_status_found = true;
299 }
300 } else if (preg_match("#^X-Mail-Queue-Prio: +High *$#i",$val)) {
301 unset($filtered_headers[$index]);
302 if (!$prio_status_found) {
303 $status = 'high';
304 $prio_status_found = true;
305 }
306 } else if (preg_match('#^Content-Type:#i',$val)) {
307 $hasContentTypeHeader = true;
308 } else if (preg_match('#^From:#i',$val)) {
309 $hasFromHeader = true;
310 }
311 }
312 $headers = array_values($filtered_headers);
313
314 // For all emails that are stored in the queue to be sent later:
315 // Store custom filtered values in headers if available.
316 // Support the following hooks used in wp_mail:
317 // - wp_mail_content_type
318 // - wp_mail_charset
319 // - wp_mail_from
320 // - wp_mail_from_name
321 if ($status !== 'instant') {
322 if (!$hasContentTypeHeader) {
323 $contentType = apply_filters('wp_mail_content_type','text/plain');
324 if ( $contentType ) {
325 if (stripos($contentType,'multipart') === false) {
326 $charset = apply_filters('wp_mail_charset',get_bloginfo('charset'));
327 } else {
328 $charset = '';
329 }
330 $headers[] = 'Content-Type: '.$contentType.($charset ? '; charset="'.$charset.'"' : '');
331 }
332 }
333 if (!$hasFromHeader) {
334 $from_Email = apply_filters('wp_mail_from','');
335 if ($from_Email) {
336 $fromName = apply_filters('wp_mail_from_name','');
337 if ($fromName) {
338 $headers[] = 'From: '.$fromName.' <'.$from_Email.'>';
339 } else {
340 $headers[] = 'From: '.$from_Email;
341 }
342 }
343 }
344 }
345
346
347 // Write email in Queue
348 $tableName = wdm_wpma_table();
349 $data = array(
350 'timestamp'=> current_time('mysql',false),
351 'recipient'=> maybe_serialize($to),
352 'subject'=> $subject,
353 'message'=> $message,
354 'status' => $status,
355 'attachments' => ''
356 );
357 if (isset($headers) && $headers) { $data['headers'] = maybe_serialize($headers); }
358
359 // store attachments in /attachments/ Folder, to address them later
360 if (isset($attachments) && $attachments && $attachments != '') {
361
362 $subfolder = time().'-'.wp_generate_password(24,/*special_chars*/false,/*extra_special_chars*/false);
363 $foldercreated = wp_mkdir_p(plugin_dir_path(__FILE__).'attachments/'.$subfolder);
364 if (!$foldercreated) {
365 if ( defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ) {
366 error_log('[mail-queue] Could not create subfolder for email attachment');
367 }
368 $data['info'] = 'Error: Could not store attachments';
369 } else {
370 if (!is_array($attachments)) { $attachments = array($attachments); }
371 $newattachments = array();
372 $copy_failed = false;
373 global $wp_filesystem;
374 if ( ! is_a( $wp_filesystem, 'WP_Filesystem_Base') ){
375 include_once(ABSPATH . 'wp-admin/includes/file.php');
376 WP_Filesystem();
377 }
378 // WP_Filesystem() returns false if no method resolves, leaving $wp_filesystem
379 // null. This code runs inside whatever request sent the mail - typically a
380 // visitor's form submit - so calling ->copy() on null would fatal the whole
381 // page. Degrade instead: queue the mail without attachments.
382 if ( ! is_a( $wp_filesystem, 'WP_Filesystem_Base') ) {
383 $copy_failed = true;
384 if ( defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ) {
385 error_log('[mail-queue] WP_Filesystem unavailable - attachments not stored');
386 }
387 } else {
388 $usednames = array();
389 foreach($attachments as $item) {
390 // Two source files can share a basename (invoice.pdf from different
391 // dirs). WP_Filesystem::copy() defaults to $overwrite=false and
392 // RETURNS FALSE if the destination exists, so a collision would
393 // otherwise queue a row pointing at the FIRST file's bytes under the
394 // second file's name - the recipient silently gets the wrong file.
395 $name = basename($item);
396 if ( isset($usednames[$name]) ) {
397 $usednames[$name]++;
398 $ext = pathinfo($name, PATHINFO_EXTENSION);
399 $base = pathinfo($name, PATHINFO_FILENAME);
400 $name = $base.'-'.$usednames[$name].($ext !== '' ? '.'.$ext : '');
401 } else {
402 $usednames[$name] = 1;
403 }
404 $newfile = plugin_dir_path(__FILE__).'attachments/'.$subfolder.'/'.$name;
405 // Ignoring the return would queue a mail pointing at a file that was
406 // never written - the whole mail then errors out at send time.
407 if ( ! $wp_filesystem->copy($item,$newfile) ) {
408 $copy_failed = true;
409 if ( defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ) {
410 error_log('[mail-queue] Could not copy attachment: '.$item);
411 }
412 break;
413 }
414 array_push($newattachments,$newfile);
415 }
416 }
417 if ( $copy_failed ) {
418 // Partial copies are useless: send the mail without attachments rather
419 // than with a subset. Drop whatever was copied so no folder is orphaned.
420 if ( $newattachments ) {
421 wdm_wpma_delete_attachment_folder( maybe_serialize($newattachments) );
422 }
423 $data['attachments'] = '';
424 $data['info'] = 'Error: Could not store attachments';
425 } else {
426 $data['attachments'] = maybe_serialize($newattachments);
427 }
428 }
429 }
430 $inserted = $wpdb->insert($tableName,$data);
431
432 if ($inserted) {
433 wdm_wpma_set_current_mail_id( $wpdb->insert_id );
434 }
435
436 if ($status == 'instant') {
437 // Returning null lets wp_mail() carry on and really send this mail, so the
438 // id must STAY set: a wp_mail_failed during that send has to mark this row.
439 return null;
440 } else if ( !$inserted ) {
441 // No database entry, email cannot be send
442 wdm_wpma_set_current_mail_id( 0 );
443 return false;
444 } else {
445 // Row is queued and no wp_mail() follows in this request (we fake success
446 // below), so nothing is "in flight" anymore. Clear the id: leaving it set
447 // would let a later failing instant-send in the SAME request flip this
448 // perfectly fine queued row to 'error' via wdm_wpma_mail_failed().
449 wdm_wpma_set_current_mail_id( 0 );
450 // Fake Submit by returning 'True'
451 return true;
452 }
453
454 }
455
456
457
458 // show wp_mail() errors
459 function wdm_wpma_mail_failed( $wp_error ) {
460 global $wpdb;
461 $mailid = wdm_wpma_get_current_mail_id();
462 if ($mailid != 0) {
463 $tableName = wdm_wpma_table();
464 $wpMailFailedError = isset( $wp_error->errors ) && isset( $wp_error->errors['wp_mail_failed'][0] ) ? implode( '; ', $wp_error->errors['wp_mail_failed'] ) : 'Unknown';
465 $wpdb->update($tableName,array('timestamp'=>current_time('mysql',false),'status'=>'error', 'info'=>$wpMailFailedError),array('id'=>$mailid),array('%s', '%s', '%s'),'%d');
466 }
467 wdm_wpma_set_current_mail_id( 0 );
468 if ( defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ) {
469 error_log('[mail-queue] wp_mail_failed: '.print_r($wp_error, true));
470 }
471 }
472 add_action('wp_mail_failed','wdm_wpma_mail_failed',10,1);
473
474
475
476 // really send email function to send an email item immediately, without being added to the queue again
477 function wdm_wpma_really_send_mail($item, $args = null) {
478 global $wpdb, $wdm_wpma_pre_wp_mail_priority;
479 $wdm_wpma_options = wdm_wpma_options();
480
481 $currentstatus = isset($item['status']) ? $item['status'] : '';
482 // No recipient stored → fall back to the alert email and mark the subject.
483 // $recipient_fallback makes the success update below persist the changed subject
484 // to the DB row, so log and actually dispatched mail stay in sync.
485 $recipient_fallback = false;
486 if ($item['recipient'] && $item['recipient'] != '') { $to = wdm_wpma_safe_unserialize($item['recipient']); } else { $to = $wdm_wpma_options['email']; $item['subject'] = 'ERROR // '.$item['subject']; $recipient_fallback = true; }
487 if ($item['headers'] && $item['headers'] != '') { $headers = wdm_wpma_safe_unserialize($item['headers']); } else { $headers = ''; }
488 if ($item['attachments'] && $item['attachments'] != '') { $attachments = wdm_wpma_safe_unserialize($item['attachments']); } else { $attachments = ''; }
489
490 $tableName = wdm_wpma_table();
491
492 // Row is not in a sendable state (already sent/error/event/sending, e.g. a
493 // stale double-submit or a row another dispatcher is mid-sending) — refuse
494 // silently, never rewrite its status.
495 if ( !in_array($currentstatus, ['queue', 'high'], /*strict*/true) ) {
496 return false;
497 }
498
499 // Atomically CLAIM the row before doing anything that dispatches it. Both the
500 // cron picker and the "Send now" bulk action call this function; without a
501 // claim, two dispatchers that SELECTed the same still-'queue' row would both
502 // send it (admin Send-now racing a cron tick, or two cron ticks). This single
503 // conditional UPDATE flips the row queue/high -> 'sending' only while it is
504 // still unclaimed; whoever changed the row (rows_affected === 1) owns the send,
505 // everyone else backs off. 'sending' is a transient in-flight state, excluded
506 // from the cron picker/count ('queue'/'high' only), so a claimed row is never
507 // re-picked mid-send. The retention purge removes non-queue/high rows only
508 // past the retention window, so a mid-send row (fresh timestamp) is safe -
509 // and a crash-stranded 'sending' row gets cleaned up with the old logs.
510 $claimed = $wpdb->query( $wpdb->prepare(
511 "UPDATE `$tableName` SET `status` = 'sending' WHERE `id` = %d AND `status` IN ('queue','high')",
512 $item['id']
513 ) );
514 if ( $claimed !== 1 ) { return false; } // another dispatcher claimed it first
515
516 // Recipient column is non-empty but unusable (e.g. serialized empty array from a
517 // Bcc-only mail, or a corrupt value). Such a row can never be sent — fail it out,
518 // otherwise it clogs the front of the queue forever (the cron picker is
519 // oldest-first with LIMIT queue_amount, default 1).
520 if ( !$to ) {
521 $wpdb->update($tableName,array('timestamp'=>current_time('mysql',false),'status'=>'error','info'=>'No valid recipient - email cannot be sent'),array('id'=>$item['id']),'%s','%d');
522 wdm_wpma_delete_attachment_folder( $item['attachments'] );
523 return false;
524 }
525
526 wdm_wpma_set_current_mail_id( $item['id'] );
527
528 remove_filter('pre_wp_mail', 'wdm_wpma_prewpmail', $wdm_wpma_pre_wp_mail_priority);
529 $sendstatus = wp_mail($to,$item['subject'],$item['message'],$headers,$attachments); // Send the email for real
530 add_filter('pre_wp_mail', 'wdm_wpma_prewpmail', $wdm_wpma_pre_wp_mail_priority, 2);
531
532 wdm_wpma_set_current_mail_id( 0 );
533
534 if ($sendstatus) {
535 $infodata = array();
536 if (isset($args['send_mode']) && $args['send_mode']) {
537 $infodata['send_mode'] = $args['send_mode'];
538 } elseif ($currentstatus === 'high') {
539 $infodata['prio'] = 'high';
540 }
541 if ($recipient_fallback) {
542 $infodata['recipient_fallback'] = 'no recipient stored - mail was sent to the alert email address';
543 }
544 // Preserve what the interceptor recorded at queue time (e.g. 'Error:
545 // Could not store attachments') - overwriting it here would erase the
546 // only trace that the mail went out degraded.
547 if ( isset($item['info']) && $item['info'] !== '' ) {
548 $infodata['queued_info'] = $item['info'];
549 }
550 $info = $infodata ? json_encode($infodata) : '';
551 $update = array('timestamp'=>current_time('mysql',false),'status'=>'sent','info'=>$info);
552 if ($recipient_fallback) {
553 $update['subject'] = $item['subject']; // persist the 'ERROR // ' prefix the dispatched mail carries
554 }
555 $wpdb->update($tableName,$update,array('id'=>$item['id']),'%s','%d');
556 } else {
557 // wp_mail() can return false WITHOUT firing wp_mail_failed (e.g. another plugin
558 // short-circuits via pre_wp_mail). If wdm_wpma_mail_failed() already marked the
559 // row, its status is now 'error' and we must not overwrite its richer message;
560 // otherwise the row is still 'sending' (our claim) — fail it out here so it
561 // can't linger. (It can no longer be 'queue'/'high': we claimed it above.)
562 $dbstatus = $wpdb->get_var($wpdb->prepare("SELECT `status` FROM `$tableName` WHERE `id` = %d", $item['id']));
563 if ( $dbstatus === 'sending' ) {
564 $wpdb->update($tableName,array('timestamp'=>current_time('mysql',false),'status'=>'error','info'=>'wp_mail() failed without error details (possibly blocked by another plugin)'),array('id'=>$item['id']),'%s','%d');
565 }
566 }
567
568 // remove possible attachments from server after sending email
569 wdm_wpma_delete_attachment_folder( $item['attachments'] );
570
571 return $sendstatus;
572 }
573
574 // Deletes the per-mail attachment folder belonging to a queue/log row.
575 // $attachments_column = raw value of the `attachments` DB column (serialized array of absolute file paths).
576 // Used after a send attempt and when a row is deleted via bulk action.
577 function wdm_wpma_delete_attachment_folder( $attachments_column ) {
578 if ( ! $attachments_column || $attachments_column == '' ) { return; }
579 $attachments = wdm_wpma_safe_unserialize( $attachments_column );
580 if ( ! is_array( $attachments ) || empty( $attachments ) ) { return; }
581
582 // Safety net: only ever delete a subfolder inside the plugin's own attachments/
583 // directory, never the attachments/ directory itself or anything outside of it.
584 // realpath() resolves ../ segments and symlinks, so a crafted DB value like
585 // ".../attachments/../../x" cannot escape the base directory; it returns false
586 // for nonexistent paths, in which case there is nothing to delete anyway.
587 $basedir = realpath( plugin_dir_path(__FILE__).'attachments' );
588 $folder = realpath( dirname( $attachments[0] ) );
589 if ( $basedir === false || $folder === false ) { return; }
590 if ( $folder === $basedir || strpos( $folder.'/', trailingslashit($basedir) ) !== 0 ) { return; }
591
592 global $wp_filesystem;
593 if ( ! is_a( $wp_filesystem, 'WP_Filesystem_Base') ){
594 include_once(ABSPATH . 'wp-admin/includes/file.php');
595 WP_Filesystem();
596 }
597 // Same null-$wp_filesystem hazard as the intercept's copy block: WP_Filesystem()
598 // can return false. Leaving a folder behind is harmless; fataling here is not -
599 // this also runs from the queue sender and from bulk actions.
600 if ( ! is_a( $wp_filesystem, 'WP_Filesystem_Base') ) {
601 if ( defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ) {
602 error_log('[mail-queue] WP_Filesystem unavailable - attachment folder not deleted: '.$folder);
603 }
604 return;
605 }
606 $wp_filesystem->delete( $folder, true, 'd' );
607 }
608
609
610
611
612 /* ***************************************************************
613 CRON
614 **************************************************************** */
615
616 // Cross-process lock for the cron sender.
617 // An expires_at value lets a crashed worker's stale
618 // lock be reclaimed instead of deadlocking the queue forever.
619 // Returns the owned expires_at (truthy int) on success, or false if
620 // another worker holds a still-valid lock. The returned value is what
621 // wdm_wpma_release_lock_cron() uses to release *only its own* lock.
622 function wdm_wpma_try_lock_cron () {
623 global $wpdb;
624 $wdm_wpma_options = wdm_wpma_options();
625 $key = 'wdm_wpma_cron_lock';
626 $ttl = max(60, intval($wdm_wpma_options['queue_interval']) * 2);
627 $expires_at = time() + $ttl;
628
629 // Fresh acquire: add_option relies on the unique option_name key, so it's
630 // atomic across processes - exactly one worker can create the row.
631 if (add_option($key, $expires_at, '', /*autoload*/false)) { return $expires_at; }
632
633 // Option exists - try to reclaim it *only if* the previous holder's lock
634 // has expired. This must be atomic: a plain get_option -> compare ->
635 // update_option is check-then-act, so two workers hitting an expired lock
636 // in the same second would both win and double-send. A single conditional
637 // UPDATE (compare-and-swap) lets the DB pick exactly one winner - the row
638 // is changed only while it still carries the old (expired) expires_at, and
639 // whoever's UPDATE actually changed a row (rows_affected === 1) owns it.
640 $reclaimed = $wpdb->query( $wpdb->prepare(
641 "UPDATE {$wpdb->options} SET option_value = %d WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
642 $expires_at, $key, time()
643 ) );
644 // Direct SQL bypasses WP's option cache (persistent object cache stays
645 // stale otherwise: get_option would return the old value, add_option would
646 // trust a stale notoptions flag). Drop the cached entry so it re-reads.
647 wp_cache_delete($key, 'options');
648
649 // rows_affected === 1 => this worker changed the row => this worker owns it.
650 // 0 (row already advanced past our predicate) or false (query error) => lost.
651 if ($reclaimed === 1) { return $expires_at; }
652 return false;
653 }
654
655 // Release the lock, but only if this worker still owns it: delete the row
656 // solely while it still carries the expires_at we wrote. If a parallel worker
657 // reclaimed the lock in the meantime (writing a different expires_at), this
658 // matches nothing and correctly leaves that worker's lock intact.
659 function wdm_wpma_release_lock_cron ( $owned_expires_at ) {
660 global $wpdb;
661 $key = 'wdm_wpma_cron_lock';
662 if ( !$owned_expires_at ) { return; }
663
664 $wpdb->query( $wpdb->prepare(
665 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
666 $key, (string) $owned_expires_at
667 ) );
668 // Keep the option cache coherent with the row we just deleted (see note above).
669 wp_cache_delete($key, 'options');
670 }
671
672 function wdm_wpma_search_mail_from_queue() {
673 // Ensure the global is populated (WP-CLI may have loaded the plugin in
674 // function scope), then bind to it — triggercount++ below relies on the
675 // global persisting across calls within the same PHP process.
676 wdm_wpma_options();
677 global $wdm_wpma_options;
678
679 // Only run if plugin is enabled or paused
680 if ( !in_array($wdm_wpma_options['enabled'], ['1', 'paused'], /*strict*/true) ) { return; }
681
682 // Triggercount to avoid multiple runs within the same PHP process
683 $wdm_wpma_options['triggercount']++;
684 if ($wdm_wpma_options['triggercount'] > 1) { return; }
685
686 // Cross-process lock: a second worker (e.g. a parallel cron tick) must back off,
687 // so we don't read the same rows twice and double-send them. The lock returns
688 // the expires_at we own; release scopes its delete to that value so we never
689 // clear a lock a parallel worker reclaimed after ours expired.
690 $lock = wdm_wpma_try_lock_cron();
691 if ($lock === false) { return; }
692
693 try {
694 wdm_wpma_search_mail_from_queue_locked();
695 } finally {
696 wdm_wpma_release_lock_cron($lock);
697 }
698 }
699
700 function wdm_wpma_search_mail_from_queue_locked() {
701 global $wpdb, $wdm_wpma_pre_wp_mail_priority;
702 $wdm_wpma_options = wdm_wpma_options();
703
704 $tableName = wdm_wpma_table();
705
706 // Total Mails waiting in the Queue?
707 $mailjobsTotal = $wpdb->get_var( "SELECT COUNT(*) FROM `$tableName` WHERE `status` = 'queue' OR `status` = 'high'" );
708
709 // Mails to send
710 $mailjobs = $wpdb->get_results($wpdb->prepare("SELECT * FROM `$tableName` WHERE `status` = 'queue' OR `status` = 'high' ORDER BY `status` ASC, `id` LIMIT %d", intval($wdm_wpma_options['queue_amount'])),'ARRAY_A');
711 $mailsInQueue = is_array($mailjobs) ? count($mailjobs) : 0;
712
713 // Maybe alert admin and auto-pause if too many mails in the Queue.
714 if ($wdm_wpma_options['alert_enabled'] === '1' && $mailjobsTotal > intval($wdm_wpma_options['email_amount'])) {
715
716 // Pause sending other emails if option is active and not paused already
717 $must_trigger_auto_pause = wdm_wpma_must_activate_pause_on_alert();
718
719 // Last alerts older than 6 hours?
720 $alert_cutoff = gmdate('Y-m-d H:i:s', current_time('timestamp', false) - (6 * HOUR_IN_SECONDS));
721 $alerts = $wpdb->get_results($wpdb->prepare("SELECT * FROM `$tableName` WHERE `status` = 'alert' AND `timestamp` > %s ORDER BY `id` DESC", $alert_cutoff), 'ARRAY_A');
722
723 // If no recent alert exists, send one;
724 // In case new auto-pause is triggered, always send alert independent of existing recent alerts to inform about the pause
725 if (!$alerts || $must_trigger_auto_pause) {
726
727 $alertMessage = 'Hi,';
728 $alertMessage .= "\n\n";
729 $alertMessage .= 'this is an important message from your WordPress website '.esc_url(get_option('siteurl')).'.';
730 $alertMessage .= "\n";
731 $alertMessage .= "\n".'The Mail Queue Plugin has detected that your website tries to send more emails than expected (currently '.$mailjobsTotal.').';
732 $alertMessage .= "\n".'Please take a close look at the email queue, because it contains more messages than the specified limit.';
733 $alertMessage .= "\n";
734 if ($must_trigger_auto_pause) {
735 $alertMessage .= "\n".'Please note: The email sending has been paused automatically. It will remain paused until you re-enable it manually in the plugin settings.';
736 $alertMessage .= "\n";
737 } elseif ($wdm_wpma_options['enabled'] === 'paused') {
738 if (wdm_wpma_is_pause_on_alert_active()) {
739 $alertMessage .= "\n".'Please note: The email sending has been paused automatically due to a recent alert. It will remain paused until you re-enable it manually in the plugin settings.';
740 $alertMessage .= "\n";
741 } else {
742 $alertMessage .= "\n".'Please note: The email sending is currently paused. If this is not intentional, please check the plugin settings.';
743 $alertMessage .= "\n";
744 }
745 }
746 $alertMessage .= "\n".'In case this is the usual amount of emails, you can adjust the threshold for alerts in the settings of your Mail Queue Plugin.';
747 $alertMessage .= "\n\n";
748 $alertMessage .= "-- ";
749 $alertMessage .= "\n";
750 $alertMessage .= admin_url('admin.php?page=wdm_wpma_mail_queue');
751 $alertSubject = '🔴 WordPress Mail Queue Alert - '.esc_html(get_option('blogname'));
752 $data = array(
753 'timestamp'=> current_time('mysql',false),
754 'recipient'=> sanitize_email($wdm_wpma_options['email']),
755 'subject' => $alertSubject,
756 'message' => $alertMessage,
757 'status' => 'alert',
758 'info' => json_encode([
759 'in_queue' => strval( $mailjobsTotal ),
760 'email_amount' => intval($wdm_wpma_options['email_amount']),
761 'queue_amount' => intval($wdm_wpma_options['queue_amount']),
762 'queue_interval' => intval($wdm_wpma_options['queue_interval']),
763 ]),
764 );
765 $wpdb->insert($tableName,$data);
766 // Send the alert past our own interceptor. This bracket is load-bearing:
767 // the filter is active in cron context too, so without it the alert would
768 // queue itself behind the very congestion it warns about (and never go out
769 // at all once pause_on_alert has paused sending). The row above is the log
770 // entry ('alert'), not a queue entry - it is never dispatched.
771 remove_filter('pre_wp_mail', 'wdm_wpma_prewpmail', $wdm_wpma_pre_wp_mail_priority);
772 wp_mail($wdm_wpma_options['email'],$alertSubject,$alertMessage);
773 add_filter('pre_wp_mail', 'wdm_wpma_prewpmail', $wdm_wpma_pre_wp_mail_priority, 2);
774 }
775
776 if ($must_trigger_auto_pause) {
777 wdm_wpma_maybe_activate_pause_on_alert();
778 }
779 }
780
781 // Alert might have triggered a pause (which refreshes the settings global),
782 // so re-fetch and check again before sending emails — the local copy from
783 // function entry would still hold the pre-pause 'enabled' value.
784 $wdm_wpma_options = wdm_wpma_options();
785 if ($wdm_wpma_options['enabled'] !== '1') { return; }
786
787 // Send Mails in Queue ($mailjobs is already limited to queue_amount by the SQL LIMIT)
788 if ($mailsInQueue > 0) {
789 foreach($mailjobs as $item) {
790 wdm_wpma_really_send_mail($item);
791 }
792 }
793
794 // Delete old logs
795 $clear_queue_cutoff = gmdate('Y-m-d H:i:s', current_time('timestamp', false) - (intval($wdm_wpma_options['clear_queue']) * HOUR_IN_SECONDS));
796 $wpdb->query($wpdb->prepare("DELETE FROM `$tableName` WHERE `status` != 'queue' AND `status` != 'high' AND `timestamp` < %s", $clear_queue_cutoff));
797
798 }
799 add_action('wp_mail_queue_hook','wdm_wpma_search_mail_from_queue');
800
801 // Custom Cron Interval
802 function wdm_wpma_cron_interval( $schedules ) {
803 $options = wdm_wpma_options();
804 $schedules['wdm_wpma_interval'] = array(
805 'interval' => $options['queue_interval'],
806 'display' => esc_html__('WP Mail Queue'), );
807 return $schedules;
808 }
809 add_filter('cron_schedules','wdm_wpma_cron_interval');
810
811 // Set, Remove, or Reschedule Cron.
812 // Reschedule when the stored interval no longer matches the configured one,
813 // otherwise a settings change would only take effect after disable+enable.
814 $scheduled_event = wp_get_scheduled_event('wp_mail_queue_hook');
815 $should_be_active = in_array($wdm_wpma_options['enabled'], ['1', 'paused'], true);
816 $configured_interval = intval($wdm_wpma_options['queue_interval']);
817 if ($scheduled_event && !$should_be_active) {
818 wp_unschedule_event($scheduled_event->timestamp,'wp_mail_queue_hook');
819 } else if (!$scheduled_event && $should_be_active) {
820 wp_schedule_event(time(),'wdm_wpma_interval','wp_mail_queue_hook');
821 } else if ($scheduled_event && $should_be_active && intval($scheduled_event->interval) !== $configured_interval) {
822 wp_unschedule_event($scheduled_event->timestamp,'wp_mail_queue_hook');
823 wp_schedule_event(time(),'wdm_wpma_interval','wp_mail_queue_hook');
824 }
825
826
827
828
829
830 /* ***************************************************************
831 Queue Events
832 **************************************************************** */
833
834 function wdm_wpma_push_queue_event ($args) {
835 global $wpdb;
836
837 $tableName = wdm_wpma_table();
838 $event_name = isset($args['name']) ? $args['name'] : '';
839 $event_data = isset($args['data']) ? $args['data'] : '';
840 if (!$event_name) { return false; }
841
842 $data = array(
843 'timestamp'=> current_time('mysql',false),
844 'status' => 'event',
845 'recipient'=> '',
846 'subject' => $event_name,
847 'message' => '',
848 'info' => $event_data ? (is_string($event_data) ? $event_data : json_encode($event_data)) : '',
849 );
850 $inserted = $wpdb->insert($tableName,$data);
851
852 return $inserted !== false;
853 }
854
855
856
857
858
859 /* ***************************************************************
860 WordPress Password Notification Emails
861 **************************************************************** */
862
863 function wdm_wpma_prioritize_password_reset_mail( $email, $key, $user_login, $user_data ) {
864 $wdm_wpma_options = wdm_wpma_options();
865
866 // If Mail Queue is paused send password reset emails instantly to prevent lockouts.
867 // Otherwise, send with high priority to make sure that password reset emails are sent before other queued emails.
868 $prio_header = isset( $wdm_wpma_options['enabled'] ) && $wdm_wpma_options['enabled'] === 'paused' ? 'X-Mail-Queue-Prio: Instant' : 'X-Mail-Queue-Prio: High';
869
870 if ( empty( $email['headers'] ) ) {
871 $email['headers'] = array( $prio_header );
872 } elseif ( is_array( $email['headers'] ) ) {
873 if ( ! in_array( $prio_header, $email['headers'], true ) ) {
874 $email['headers'][] = $prio_header;
875 }
876 } elseif ( stripos( $email['headers'], $prio_header ) === false ) {
877 $email['headers'] .= ( $email['headers'] ? "\r\n" : '' ) . $prio_header;
878 }
879
880 return $email;
881 }
882 add_filter('retrieve_password_notification_email', 'wdm_wpma_prioritize_password_reset_mail', 10, 4);
883
884
885
886
887
888 /* ***************************************************************
889 Install/Uninstall/Upgrade
890 **************************************************************** */
891
892
893 /* Delete plugin options and database table */
894 function wdm_wpma_uninstall () {
895 global $wpdb;
896
897 // Resolve the table BEFORE deleting the settings: wdm_wpma_table() reads the
898 // tableName setting, so once wdm_wpma_settings is gone it would fall back to
899 // the default and we'd DROP wp_mail_queue - a table this install may not own -
900 // while leaving the configured one behind as an orphan.
901 $tableName = wdm_wpma_table();
902
903 delete_option( 'wdm_wpma_settings' );
904 delete_option( 'wdm_wpma_version' );
905 delete_option( 'wdm_wpma_pause_on_alert_active' );
906 delete_option( 'wdm_wpma_cron_lock' );
907 // Replay-guard rows (one self-expiring option per claimed action).
908 $wpdb->query( $wpdb->prepare(
909 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
910 $wpdb->esc_like( 'wdm_wpma_claim_' ) . '%'
911 ) );
912
913 $wpdb->query( "DROP TABLE IF EXISTS `$tableName`" );
914 }
915
916 /* Delete Cron when Plugin deactivated */
917 function wdm_wpma_deactivate() {
918 wp_clear_scheduled_hook( 'wp_mail_queue_hook' );
919 }
920 register_deactivation_hook( __FILE__, 'wdm_wpma_deactivate' );
921
922 /* Create/Upgrade MySQL Table on Activation/Upgrade: https://codex.wordpress.org/Creating_Tables_with_Plugins */
923 function wdm_wpma_updateDatabaseTables() {
924 global $wpdb, $wdm_wpma_version;
925
926 // Resolve the table the same way every read/write path does (honours the
927 // tableName setting). Hardcoding the default here would migrate/create
928 // wp_mail_queue while the plugin keeps using the configured table - the
929 // configured one would silently never receive schema changes.
930 $tableName = wdm_wpma_table();
931
932 $charset_collate = $wpdb->get_charset_collate();
933
934 $sql = "CREATE TABLE $tableName (
935 id mediumint(9) NOT NULL AUTO_INCREMENT,
936 timestamp TIMESTAMP NOT NULL,
937 status varchar(55) DEFAULT '' NOT NULL,
938 recipient text NOT NULL,
939 subject varchar(255) DEFAULT '' NOT NULL,
940 message mediumtext NOT NULL,
941 headers text NOT NULL,
942 attachments text NOT NULL,
943 info varchar(255) DEFAULT '' NOT NULL,
944 PRIMARY KEY (id),
945 KEY status_timestamp (status,timestamp),
946 KEY timestamp (timestamp)
947 ) $charset_collate;";
948
949 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
950 dbDelta( $sql );
951
952 update_option( 'wdm_wpma_version', $wdm_wpma_version, /*autoload*/true );
953 }
954
955 /* Update database and register hooks on activation */
956 function wdm_wpma_activate() {
957 wdm_wpma_updateDatabaseTables();
958 register_uninstall_hook( __FILE__, 'wdm_wpma_uninstall' );
959 }
960 register_activation_hook( __FILE__, 'wdm_wpma_activate' );
961
962 /* Upgrade routine: check for mismatching version numbers and run database update if necessary */
963 function wdm_wpma_check_update_db () {
964 global $wdm_wpma_version;
965 if ( get_option( 'wdm_wpma_version' ) !== $wdm_wpma_version ) {
966 wdm_wpma_updateDatabaseTables();
967 }
968 }
969 add_action( 'plugins_loaded', 'wdm_wpma_check_update_db', 10, 0 );
970
971
972
973
974 /* ***************************************************************
975 Options Page
976 **************************************************************** */
977 if (is_admin()) {
978 require_once( plugin_dir_path( __FILE__ ) . 'mail-queue-options.php' );
979 }
980
981
982
983
984 /* ***************************************************************
985 REST API
986 **************************************************************** */
987
988
989 function wdm_wpma_add_rest_endpoints () {
990 register_rest_route('wpma/v1', '/message/(?P<id>[\d]+)', array(
991 'methods' => 'GET',
992 'callback' => 'wdm_wpma_rest_get_message',
993 'permission_callback' => function () {
994 return current_user_can( 'manage_options' );
995 },
996 ));
997 }
998 add_action('rest_api_init', 'wdm_wpma_add_rest_endpoints', 10, 0);
999
1000
1001 function wdm_wpma_rest_get_message ($request) {
1002 global $wpdb;
1003 $tableName = wdm_wpma_table();
1004 $id = intval($request['id']);
1005 $row = $wpdb->get_row( $wpdb->prepare("SELECT * FROM `$tableName` WHERE `id` = %d", $id ), ARRAY_A );
1006 // event/alert rows are queue bookkeeping, not real mails — the list-table UI offers
1007 // no message toggle for them, so the endpoint treats them as not found as well.
1008 if ( $row && in_array( $row['status'], array( 'event', 'alert' ), /*strict*/true ) ) {
1009 $row = null;
1010 }
1011 if ($row) {
1012 // Search for content-type header to detect html emails
1013 $is_content_type_html = false;
1014 $headers = wdm_wpma_safe_unserialize( $row['headers'] );
1015 if (is_string($headers)) {
1016 $headers = [ $headers ];
1017 } else if (!is_array($headers)) {
1018 $headers = [];
1019 }
1020 foreach ( $headers as $header ) {
1021 if ( preg_match( '/content-type: ?text\/html/i', $header ) ) {
1022 $is_content_type_html = true;
1023 break;
1024 }
1025 }
1026 return array(
1027 'status' => 'ok',
1028 'data' => array(
1029 'html' => wdm_wpma_render_list_message($row['message'],$is_content_type_html),
1030 ),
1031 );
1032 } else {
1033 return new WP_Error( 'no_message', __( 'Message not found' ), array( 'status' => 404 ) );
1034 }
1035 }
1036
1037 function wdm_wpma_render_list_message ($message, $is_content_type_html) {
1038 // Security: the message body is untrusted (captured from any wp_mail() caller,
1039 // incl. unauthenticated flows). The esc_html() calls below are load-bearing — they
1040 // render HTML mails as escaped source, never as live DOM. Any HTML preview
1041 // must use a sandboxed <iframe srcdoc sandbox> (no allow-scripts), never raw output.
1042 // Split html emails into parts and extract plain text preview
1043 $parts = explode( '<body', $message );
1044 $is_html = $is_content_type_html || count($parts) > 1;
1045 if ($is_html) {
1046 if (count($parts) > 1) {
1047 $header = $parts[0];
1048 $body = '<body'.$parts[1];
1049 } else {
1050 $header = '';
1051 $body = $parts[0];
1052 }
1053 $parts = explode('</body>', $body);
1054 if (count($parts) > 1) {
1055 $body = $parts[0].'</body>';
1056 $footer = $parts[1];
1057 } else {
1058 $body = $parts[0];
1059 $footer = '';
1060 }
1061 if (!function_exists('convert_html_to_text')) {
1062 require_once __DIR__.'/lib/html2text/html2text.php';
1063 }
1064 // ignore warnings when converting html containing non-converted HTML entities
1065 $internal_errors = libxml_use_internal_errors(true);
1066 $text = convert_html_to_text( $body );
1067 libxml_use_internal_errors($internal_errors);
1068 } else {
1069 $text = $message;
1070 $header = '';
1071 $body = '';
1072 $footer = '';
1073 }
1074 $html = '';
1075 $html .= '<details class="wdm-wpma-email-source-meta" open><summary>Text</summary><pre class="wdm-wpma-email-plain-text">'.esc_html( $text ).'</pre></details>';
1076 $html .= $header ? '<details class="wdm-wpma-email-source-meta"><summary>HTML Header</summary><pre>'.esc_html( wdm_wpma_render_html_for_display($header) ).'</pre></details>' : '';
1077 $html .= $body ? '<details class="wdm-wpma-email-source-meta"><summary>HTML Body</summary><pre>'.esc_html( wdm_wpma_render_html_for_display($body) ).'</pre></details>' : '';
1078 $html .= $footer ? '<details class="wdm-wpma-email-source-meta"><summary>HTML Footer</summary><pre>'.esc_html( wdm_wpma_render_html_for_display($footer) ).'</pre></details>' : '';
1079 return $html;
1080 }
1081
1082 function wdm_wpma_render_html_for_display ($html) {
1083 $html = preg_replace( '/;base64,[^"\']+("|\')+/', ';base64, [...] $1', $html );
1084 return $html;
1085 }
1086