PluginProbe
Mail Queue / 1.6.0
Mail Queue v1.6.0
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.0, at mail-queue.php

862 lines 35.3 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.0
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.0';
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 function wdm_wpma_is_pause_on_alert_active() {
116 return get_option('wdm_wpma_pause_on_alert_active') === '1';
117 }
118
119 function wdm_wpma_clear_pause_on_alert_state() {
120 delete_option('wdm_wpma_pause_on_alert_active');
121 }
122
123 function wdm_wpma_must_activate_pause_on_alert() {
124 $options = wdm_wpma_options();
125
126 if (!isset($options['enabled']) || $options['enabled'] !== '1') { return false; }
127 if (!isset($options['alert_enabled']) || $options['alert_enabled'] !== '1') { return false; }
128 if (!isset($options['pause_on_alert']) || $options['pause_on_alert'] !== '1') { return false; }
129 if (wdm_wpma_is_pause_on_alert_active()) { return false; }
130
131 return true;
132 }
133
134 function wdm_wpma_maybe_activate_pause_on_alert() {
135
136 if (!wdm_wpma_must_activate_pause_on_alert()) { return false; }
137
138 $settings = get_option('wdm_wpma_settings', array());
139 if (!is_array($settings)) { $settings = array(); }
140 $settings['enabled'] = 'paused';
141
142 update_option('wdm_wpma_settings', $settings, true);
143 update_option('wdm_wpma_pause_on_alert_active', '1', false);
144
145 wdm_wpma_push_queue_event([
146 'name' => 'autopause-activated',
147 ]);
148
149 wdm_wpma_refresh_settings();
150
151 return true;
152 }
153
154
155
156 $wdm_wpma_mailid = 0;
157 $wdm_wpma_options = wdm_wpma_get_settings(); // Get Settings
158
159
160
161
162
163 /* ***************************************************************
164 Overwrite wp_mail() if Plugin enabled and no Cron is running
165 **************************************************************** */
166 $wdm_wpma_pre_wp_mail_priority = 99999;
167
168 if (in_array($wdm_wpma_options['enabled'], ['1', 'paused'], /*strict*/true) && !wp_doing_cron()) {
169 // High priority: run late in the game to react to previous filters
170 add_filter('pre_wp_mail', 'wdm_wpma_prewpmail', $wdm_wpma_pre_wp_mail_priority, 2);
171 }
172
173 // pre WP Mail Filter
174 function wdm_wpma_prewpmail($return, $atts) {
175
176 global $wpdb;
177 $wdm_wpma_options = wdm_wpma_options();
178
179 if (!is_null($return)) {
180 // Another pre_wp_mail filter has already returned a value, so the mail is not added to the queue
181 return $return;
182 }
183
184 // Mail Variables
185 $to = $atts['to'];
186 $subject = $atts['subject'];
187 $message = $atts['message'];
188 $headers = $atts['headers'];
189 $attachments = $atts['attachments'];
190 $status = 'queue';
191
192 // Make sure that $headers always is an array
193 if ($headers) {
194 if (!is_array($headers)) {
195 $headers = explode( "\n", str_replace( "\r\n", "\n", $headers ) );
196 }
197 } else {
198 $headers = [];
199 }
200
201 // Loop through email headers
202 // - Instant Sending or Prio Mail? (the first X-Mail-Queue-Prio header wins)
203 // - Track if ContentType / From header is set
204 // Scan every header (no early break) so a Content-Type / From that sits
205 // after the X-Mail-Queue-Prio header is still detected;
206 // All X-Mail-Queue-Prio headers are stripped so they don't leak outbound.
207 $filtered_headers = $headers;
208 $hasContentTypeHeader = false;
209 $hasFromHeader = false;
210 $prio_status_found = false;
211 foreach($headers as $index => $val) {
212 $val = trim($val);
213 if (preg_match("#^X-Mail-Queue-Prio: +Instant *$#i",$val)) {
214 unset($filtered_headers[$index]);
215 if (!$prio_status_found) {
216 $status = 'instant';
217 $prio_status_found = true;
218 }
219 } else if (preg_match("#^X-Mail-Queue-Prio: +High *$#i",$val)) {
220 unset($filtered_headers[$index]);
221 if (!$prio_status_found) {
222 $status = 'high';
223 $prio_status_found = true;
224 }
225 } else if (preg_match('#^Content-Type:#i',$val)) {
226 $hasContentTypeHeader = true;
227 } else if (preg_match('#^From:#i',$val)) {
228 $hasFromHeader = true;
229 }
230 }
231 $headers = array_values($filtered_headers);
232
233 // For all emails that are stored in the queue to be sent later:
234 // Store custom filtered values in headers if available.
235 // Support the following hooks used in wp_mail:
236 // - wp_mail_content_type
237 // - wp_mail_charset
238 // - wp_mail_from
239 // - wp_mail_from_name
240 if ($status !== 'instant') {
241 if (!$hasContentTypeHeader) {
242 $contentType = apply_filters('wp_mail_content_type','text/plain');
243 if ( $contentType ) {
244 if (stripos($contentType,'multipart') === false) {
245 $charset = apply_filters('wp_mail_charset',get_bloginfo('charset'));
246 } else {
247 $charset = '';
248 }
249 $headers[] = 'Content-Type: '.$contentType.($charset ? '; charset="'.$charset.'"' : '');
250 }
251 }
252 if (!$hasFromHeader) {
253 $from_Email = apply_filters('wp_mail_from','');
254 if ($from_Email) {
255 $fromName = apply_filters('wp_mail_from_name','');
256 if ($fromName) {
257 $headers[] = 'From: '.$fromName.' <'.$from_Email.'>';
258 } else {
259 $headers[] = 'From: '.$from_Email;
260 }
261 }
262 }
263 }
264
265
266 // Write email in Queue
267 $tableName = wdm_wpma_table();
268 $data = array(
269 'timestamp'=> current_time('mysql',false),
270 'recipient'=> maybe_serialize($to),
271 'subject'=> $subject,
272 'message'=> $message,
273 'status' => $status,
274 'attachments' => ''
275 );
276 if (isset($headers) && $headers) { $data['headers'] = maybe_serialize($headers); }
277
278 // store attachments in /attachments/ Folder, to address them later
279 if (isset($attachments) && $attachments && $attachments != '') {
280
281 $subfolder = time().'-'.wp_generate_password(24,/*special_chars*/false,/*extra_special_chars*/false);
282 $foldercreated = wp_mkdir_p(plugin_dir_path(__FILE__).'attachments/'.$subfolder);
283 if (!$foldercreated) {
284 if ( defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ) {
285 error_log('[mail-queue] Could not create subfolder for email attachment');
286 }
287 $data['info'] = 'Error: Could not store attachments';
288 } else {
289 if (!is_array($attachments)) { $attachments = array($attachments); }
290 $newattachments = array();
291 global $wp_filesystem;
292 if ( ! is_a( $wp_filesystem, 'WP_Filesystem_Base') ){
293 include_once(ABSPATH . 'wp-admin/includes/file.php');
294 WP_Filesystem();
295 }
296 foreach($attachments as $item) {
297 $newfile = plugin_dir_path(__FILE__).'attachments/'.$subfolder.'/'.basename($item);
298 $wp_filesystem->copy($item,$newfile);
299 array_push($newattachments,$newfile);
300 }
301 $data['attachments'] = maybe_serialize($newattachments);
302 }
303 }
304 $inserted = $wpdb->insert($tableName,$data);
305
306 if ($inserted) {
307 wdm_wpma_set_current_mail_id( $wpdb->insert_id );
308 }
309
310 if ($status == 'instant') {
311 return null;
312 } else if ( !$inserted ) {
313 // No database entry, email cannot be send
314 return false;
315 } else {
316 // Fake Submit by returning 'True'
317 return true;
318 }
319
320 }
321
322
323
324 // show wp_mail() errors
325 function wdm_wpma_mail_failed( $wp_error ) {
326 global $wpdb;
327 $mailid = wdm_wpma_get_current_mail_id();
328 if ($mailid != 0) {
329 $tableName = wdm_wpma_table();
330 $wpMailFailedError = isset( $wp_error->errors ) && isset( $wp_error->errors['wp_mail_failed'][0] ) ? implode( '; ', $wp_error->errors['wp_mail_failed'] ) : 'Unknown';
331 $wpdb->update($tableName,array('timestamp'=>current_time('mysql',false),'status'=>'error', 'info'=>$wpMailFailedError),array('id'=>$mailid),array('%s', '%s', '%s'),'%d');
332 }
333 wdm_wpma_set_current_mail_id( 0 );
334 if ( defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ) {
335 error_log('[mail-queue] wp_mail_failed: '.print_r($wp_error, true));
336 }
337 }
338 add_action('wp_mail_failed','wdm_wpma_mail_failed',10,1);
339
340
341
342 // really send email function to send an email item immediately, without being added to the queue again
343 function wdm_wpma_really_send_mail($item, $args = null) {
344 global $wpdb, $wdm_wpma_pre_wp_mail_priority;
345 $wdm_wpma_options = wdm_wpma_options();
346
347 $currentstatus = isset($item['status']) ? $item['status'] : '';
348 // No recipient stored → fall back to the alert email and mark the subject.
349 // $recipient_fallback makes the success update below persist the changed subject
350 // to the DB row, so log and actually dispatched mail stay in sync.
351 $recipient_fallback = false;
352 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; }
353 if ($item['headers'] && $item['headers'] != '') { $headers = wdm_wpma_safe_unserialize($item['headers']); } else { $headers = ''; }
354 if ($item['attachments'] && $item['attachments'] != '') { $attachments = wdm_wpma_safe_unserialize($item['attachments']); } else { $attachments = ''; }
355
356 $tableName = wdm_wpma_table();
357
358 // Row is not in a sendable state (already sent/error/event, e.g. a stale
359 // double-submit) — refuse silently, never rewrite its status.
360 if ( !in_array($currentstatus, ['queue', 'high'], /*strict*/true) ) {
361 return false;
362 }
363
364 // Recipient column is non-empty but unusable (e.g. serialized empty array from a
365 // Bcc-only mail, or a corrupt value). Such a row can never be sent — fail it out,
366 // otherwise it clogs the front of the queue forever (the cron picker is
367 // oldest-first with LIMIT queue_amount, default 1).
368 if ( !$to ) {
369 $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');
370 wdm_wpma_delete_attachment_folder( $item['attachments'] );
371 return false;
372 }
373
374 wdm_wpma_set_current_mail_id( $item['id'] );
375
376 remove_filter('pre_wp_mail', 'wdm_wpma_prewpmail', $wdm_wpma_pre_wp_mail_priority);
377 $sendstatus = wp_mail($to,$item['subject'],$item['message'],$headers,$attachments); // Send the email for real
378 add_filter('pre_wp_mail', 'wdm_wpma_prewpmail', $wdm_wpma_pre_wp_mail_priority, 2);
379
380 wdm_wpma_set_current_mail_id( 0 );
381
382 if ($sendstatus) {
383 $infodata = array();
384 if (isset($args['send_mode']) && $args['send_mode']) {
385 $infodata['send_mode'] = $args['send_mode'];
386 } elseif ($currentstatus === 'high') {
387 $infodata['prio'] = 'high';
388 }
389 if ($recipient_fallback) {
390 $infodata['recipient_fallback'] = 'no recipient stored - mail was sent to the alert email address';
391 }
392 $info = $infodata ? json_encode($infodata) : '';
393 $update = array('timestamp'=>current_time('mysql',false),'status'=>'sent','info'=>$info);
394 if ($recipient_fallback) {
395 $update['subject'] = $item['subject']; // persist the 'ERROR // ' prefix the dispatched mail carries
396 }
397 $wpdb->update($tableName,$update,array('id'=>$item['id']),'%s','%d');
398 } else {
399 // wp_mail() can return false WITHOUT firing wp_mail_failed (e.g. another plugin
400 // short-circuits via pre_wp_mail). If wdm_wpma_mail_failed() already marked the
401 // row, the status is no longer queue/high and we must not overwrite its error
402 // message; otherwise fail the row out here so it can't clog the queue forever.
403 $dbstatus = $wpdb->get_var($wpdb->prepare("SELECT `status` FROM `$tableName` WHERE `id` = %d", $item['id']));
404 if ( in_array($dbstatus, ['queue', 'high'], /*strict*/true) ) {
405 $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');
406 }
407 }
408
409 // remove possible attachments from server after sending email
410 wdm_wpma_delete_attachment_folder( $item['attachments'] );
411
412 return $sendstatus;
413 }
414
415 // Deletes the per-mail attachment folder belonging to a queue/log row.
416 // $attachments_column = raw value of the `attachments` DB column (serialized array of absolute file paths).
417 // Used after a send attempt and when a row is deleted via bulk action.
418 function wdm_wpma_delete_attachment_folder( $attachments_column ) {
419 if ( ! $attachments_column || $attachments_column == '' ) { return; }
420 $attachments = wdm_wpma_safe_unserialize( $attachments_column );
421 if ( ! is_array( $attachments ) || empty( $attachments ) ) { return; }
422
423 // Safety net: only ever delete a subfolder inside the plugin's own attachments/
424 // directory, never the attachments/ directory itself or anything outside of it.
425 // realpath() resolves ../ segments and symlinks, so a crafted DB value like
426 // ".../attachments/../../x" cannot escape the base directory; it returns false
427 // for nonexistent paths, in which case there is nothing to delete anyway.
428 $basedir = realpath( plugin_dir_path(__FILE__).'attachments' );
429 $folder = realpath( dirname( $attachments[0] ) );
430 if ( $basedir === false || $folder === false ) { return; }
431 if ( $folder === $basedir || strpos( $folder.'/', trailingslashit($basedir) ) !== 0 ) { return; }
432
433 global $wp_filesystem;
434 if ( ! is_a( $wp_filesystem, 'WP_Filesystem_Base') ){
435 include_once(ABSPATH . 'wp-admin/includes/file.php');
436 WP_Filesystem();
437 }
438 $wp_filesystem->delete( $folder, true, 'd' );
439 }
440
441
442
443
444 /* ***************************************************************
445 CRON
446 **************************************************************** */
447
448 // Cross-process lock for the cron sender
449 // An expires_at value lets a crashed worker's stale
450 // lock be reclaimed instead of deadlocking the queue forever.
451 function wdm_wpma_try_lock_cron () {
452 $wdm_wpma_options = wdm_wpma_options();
453 $key = 'wdm_wpma_cron_lock';
454 $ttl = max(60, intval($wdm_wpma_options['queue_interval']) * 2);
455 $expires_at = time() + $ttl;
456
457 if (add_option($key, $expires_at, '', /*autoload*/false)) { return true; }
458
459 // Option exists — check whether the previous holder's lock has expired.
460 $existing = (int) get_option($key, 0);
461 if ($existing > 0 && $existing < time()) {
462 update_option($key, $expires_at, false);
463 return true;
464 }
465 return false;
466 }
467
468 function wdm_wpma_release_lock_cron () {
469 delete_option('wdm_wpma_cron_lock');
470 }
471
472 function wdm_wpma_search_mail_from_queue() {
473 // Ensure the global is populated (WP-CLI may have loaded the plugin in
474 // function scope), then bind to it — triggercount++ below relies on the
475 // global persisting across calls within the same PHP process.
476 wdm_wpma_options();
477 global $wdm_wpma_options;
478
479 // Only run if plugin is enabled or paused
480 if ( !in_array($wdm_wpma_options['enabled'], ['1', 'paused'], /*strict*/true) ) { return; }
481
482 // Triggercount to avoid multiple runs within the same PHP process
483 $wdm_wpma_options['triggercount']++;
484 if ($wdm_wpma_options['triggercount'] > 1) { return; }
485
486 // Cross-process lock: a second worker (e.g. a parallel cron tick) must back off.
487 // so we don't read the same rows twice and double-send them.
488 if (!wdm_wpma_try_lock_cron()) { return; }
489
490 try {
491 wdm_wpma_search_mail_from_queue_locked();
492 } finally {
493 wdm_wpma_release_lock_cron();
494 }
495 }
496
497 function wdm_wpma_search_mail_from_queue_locked() {
498 global $wpdb;
499 $wdm_wpma_options = wdm_wpma_options();
500
501 $tableName = wdm_wpma_table();
502
503 // Total Mails waiting in the Queue?
504 $mailjobsTotal = $wpdb->get_var( "SELECT COUNT(*) FROM `$tableName` WHERE `status` = 'queue' OR `status` = 'high'" );
505
506 // Mails to send
507 $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');
508 $mailsInQueue = is_array($mailjobs) ? count($mailjobs) : 0;
509
510 // Maybe alert admin and auto-pause if too many mails in the Queue.
511 if ($wdm_wpma_options['alert_enabled'] === '1' && $mailjobsTotal > intval($wdm_wpma_options['email_amount'])) {
512
513 // Pause sending other emails if option is active and not paused already
514 $must_trigger_auto_pause = wdm_wpma_must_activate_pause_on_alert();
515
516 // Last alerts older than 6 hours?
517 $alert_cutoff = gmdate('Y-m-d H:i:s', current_time('timestamp', false) - (6 * HOUR_IN_SECONDS));
518 $alerts = $wpdb->get_results($wpdb->prepare("SELECT * FROM `$tableName` WHERE `status` = 'alert' AND `timestamp` > %s ORDER BY `id` DESC", $alert_cutoff), 'ARRAY_A');
519
520 // If no recent alert exists, send one;
521 // In case new auto-pause is triggered, always send alert independent of existing recent alerts to inform about the pause
522 if (!$alerts || $must_trigger_auto_pause) {
523
524 $alertMessage = 'Hi,';
525 $alertMessage .= "\n\n";
526 $alertMessage .= 'this is an important message from your WordPress website '.esc_url(get_option('siteurl')).'.';
527 $alertMessage .= "\n";
528 $alertMessage .= "\n".'The Mail Queue Plugin has detected that your website tries to send more emails than expected (currently '.$mailjobsTotal.').';
529 $alertMessage .= "\n".'Please take a close look at the email queue, because it contains more messages than the specified limit.';
530 $alertMessage .= "\n";
531 if ($must_trigger_auto_pause) {
532 $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.';
533 $alertMessage .= "\n";
534 } elseif ($wdm_wpma_options['enabled'] === 'paused') {
535 if (wdm_wpma_is_pause_on_alert_active()) {
536 $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.';
537 $alertMessage .= "\n";
538 } else {
539 $alertMessage .= "\n".'Please note: The email sending is currently paused. If this is not intentional, please check the plugin settings.';
540 $alertMessage .= "\n";
541 }
542 }
543 $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.';
544 $alertMessage .= "\n\n";
545 $alertMessage .= "-- ";
546 $alertMessage .= "\n";
547 $alertMessage .= admin_url('admin.php?page=wdm_wpma_mail_queue');
548 $alertSubject = '🔴 WordPress Mail Queue Alert - '.esc_html(get_option('blogname'));
549 $data = array(
550 'timestamp'=> current_time('mysql',false),
551 'recipient'=> sanitize_email($wdm_wpma_options['email']),
552 'subject' => $alertSubject,
553 'message' => $alertMessage,
554 'status' => 'alert',
555 'info' => json_encode([
556 'in_queue' => strval( $mailjobsTotal ),
557 'email_amount' => intval($wdm_wpma_options['email_amount']),
558 'queue_amount' => intval($wdm_wpma_options['queue_amount']),
559 'queue_interval' => intval($wdm_wpma_options['queue_interval']),
560 ]),
561 );
562 $wpdb->insert($tableName,$data);
563 wp_mail($wdm_wpma_options['email'],$alertSubject,$alertMessage);
564 }
565
566 if ($must_trigger_auto_pause) {
567 wdm_wpma_maybe_activate_pause_on_alert();
568 }
569 }
570
571 // Alert might have triggered a pause (which refreshes the settings global),
572 // so re-fetch and check again before sending emails — the local copy from
573 // function entry would still hold the pre-pause 'enabled' value.
574 $wdm_wpma_options = wdm_wpma_options();
575 if ($wdm_wpma_options['enabled'] !== '1') { return; }
576
577 // Send Mails in Queue ($mailjobs is already limited to queue_amount by the SQL LIMIT)
578 if ($mailsInQueue > 0) {
579 foreach($mailjobs as $item) {
580 wdm_wpma_really_send_mail($item);
581 }
582 }
583
584 // Delete old logs
585 $clear_queue_cutoff = gmdate('Y-m-d H:i:s', current_time('timestamp', false) - (intval($wdm_wpma_options['clear_queue']) * HOUR_IN_SECONDS));
586 $wpdb->query($wpdb->prepare("DELETE FROM `$tableName` WHERE `status` != 'queue' AND `status` != 'high' AND `timestamp` < %s", $clear_queue_cutoff));
587
588 }
589 add_action('wp_mail_queue_hook','wdm_wpma_search_mail_from_queue');
590
591 // Custom Cron Interval
592 function wdm_wpma_cron_interval( $schedules ) {
593 $options = wdm_wpma_options();
594 $schedules['wdm_wpma_interval'] = array(
595 'interval' => $options['queue_interval'],
596 'display' => esc_html__('WP Mail Queue'), );
597 return $schedules;
598 }
599 add_filter('cron_schedules','wdm_wpma_cron_interval');
600
601 // Set, Remove, or Reschedule Cron.
602 // Reschedule when the stored interval no longer matches the configured one,
603 // otherwise a settings change would only take effect after disable+enable.
604 $scheduled_event = wp_get_scheduled_event('wp_mail_queue_hook');
605 $should_be_active = in_array($wdm_wpma_options['enabled'], ['1', 'paused'], true);
606 $configured_interval = intval($wdm_wpma_options['queue_interval']);
607 if ($scheduled_event && !$should_be_active) {
608 wp_unschedule_event($scheduled_event->timestamp,'wp_mail_queue_hook');
609 } else if (!$scheduled_event && $should_be_active) {
610 wp_schedule_event(time(),'wdm_wpma_interval','wp_mail_queue_hook');
611 } else if ($scheduled_event && $should_be_active && intval($scheduled_event->interval) !== $configured_interval) {
612 wp_unschedule_event($scheduled_event->timestamp,'wp_mail_queue_hook');
613 wp_schedule_event(time(),'wdm_wpma_interval','wp_mail_queue_hook');
614 }
615
616
617
618
619
620 /* ***************************************************************
621 Queue Events
622 **************************************************************** */
623
624 function wdm_wpma_push_queue_event ($args) {
625 global $wpdb;
626
627 $tableName = wdm_wpma_table();
628 $event_name = isset($args['name']) ? $args['name'] : '';
629 $event_data = isset($args['data']) ? $args['data'] : '';
630 if (!$event_name) { return false; }
631
632 $data = array(
633 'timestamp'=> current_time('mysql',false),
634 'status' => 'event',
635 'recipient'=> '',
636 'subject' => $event_name,
637 'message' => '',
638 'info' => $event_data ? (is_string($event_data) ? $event_data : json_encode($event_data)) : '',
639 );
640 $inserted = $wpdb->insert($tableName,$data);
641
642 return $inserted !== false;
643 }
644
645
646
647
648
649 /* ***************************************************************
650 WordPress Password Notification Emails
651 **************************************************************** */
652
653 function wdm_wpma_prioritize_password_reset_mail( $email, $key, $user_login, $user_data ) {
654 $wdm_wpma_options = wdm_wpma_options();
655
656 // If Mail Queue is paused send password reset emails instantly to prevent lockouts.
657 // Otherwise, send with high priority to make sure that password reset emails are sent before other queued emails.
658 $prio_header = isset( $wdm_wpma_options['enabled'] ) && $wdm_wpma_options['enabled'] === 'paused' ? 'X-Mail-Queue-Prio: Instant' : 'X-Mail-Queue-Prio: High';
659
660 if ( empty( $email['headers'] ) ) {
661 $email['headers'] = array( $prio_header );
662 } elseif ( is_array( $email['headers'] ) ) {
663 if ( ! in_array( $prio_header, $email['headers'], true ) ) {
664 $email['headers'][] = $prio_header;
665 }
666 } elseif ( stripos( $email['headers'], $prio_header ) === false ) {
667 $email['headers'] .= ( $email['headers'] ? "\r\n" : '' ) . $prio_header;
668 }
669
670 return $email;
671 }
672 add_filter('retrieve_password_notification_email', 'wdm_wpma_prioritize_password_reset_mail', 10, 4);
673
674
675
676
677
678 /* ***************************************************************
679 Install/Uninstall/Upgrade
680 **************************************************************** */
681
682
683 /* Delete plugin options and database table */
684 function wdm_wpma_uninstall () {
685 global $wpdb;
686
687 delete_option( 'wdm_wpma_settings' );
688 delete_option( 'wdm_wpma_version' );
689 delete_option( 'wdm_wpma_pause_on_alert_active' );
690 delete_option( 'wdm_wpma_cron_lock' );
691
692 $tableName = $wpdb->prefix.'mail_queue';
693 $wpdb->query( "DROP TABLE IF EXISTS $tableName" );
694 }
695
696 /* Delete Cron when Plugin deactivated */
697 function wdm_wpma_deactivate() {
698 wp_clear_scheduled_hook( 'wp_mail_queue_hook' );
699 }
700 register_deactivation_hook( __FILE__, 'wdm_wpma_deactivate' );
701
702 /* Create/Upgrade MySQL Table on Activation/Upgrade: https://codex.wordpress.org/Creating_Tables_with_Plugins */
703 function wdm_wpma_updateDatabaseTables() {
704 global $wpdb, $wdm_wpma_version;
705
706 $tableName = $wpdb->prefix.'mail_queue';
707
708 $charset_collate = $wpdb->get_charset_collate();
709
710 $sql = "CREATE TABLE $tableName (
711 id mediumint(9) NOT NULL AUTO_INCREMENT,
712 timestamp TIMESTAMP NOT NULL,
713 status varchar(55) DEFAULT '' NOT NULL,
714 recipient varchar(255) DEFAULT '' NOT NULL,
715 subject varchar(255) DEFAULT '' NOT NULL,
716 message mediumtext NOT NULL,
717 headers text NOT NULL,
718 attachments text NOT NULL,
719 info varchar(255) DEFAULT '' NOT NULL,
720 PRIMARY KEY (id),
721 KEY status_timestamp (status,timestamp),
722 KEY timestamp (timestamp)
723 ) $charset_collate;";
724
725 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
726 dbDelta( $sql );
727
728 update_option( 'wdm_wpma_version', $wdm_wpma_version, /*autoload*/true );
729 }
730
731 /* Update database and register hooks on activation */
732 function wdm_wpma_activate() {
733 wdm_wpma_updateDatabaseTables();
734 register_uninstall_hook( __FILE__, 'wdm_wpma_uninstall' );
735 }
736 register_activation_hook( __FILE__, 'wdm_wpma_activate' );
737
738 /* Upgrade routine: check for mismatching version numbers and run database update if necessary */
739 function wdm_wpma_check_update_db () {
740 global $wdm_wpma_version;
741 if ( get_option( 'wdm_wpma_version' ) !== $wdm_wpma_version ) {
742 wdm_wpma_updateDatabaseTables();
743 }
744 }
745 add_action( 'plugins_loaded', 'wdm_wpma_check_update_db', 10, 0 );
746
747
748
749
750 /* ***************************************************************
751 Options Page
752 **************************************************************** */
753 if (is_admin()) {
754 require_once( plugin_dir_path( __FILE__ ) . 'mail-queue-options.php' );
755 }
756
757
758
759
760 /* ***************************************************************
761 REST API
762 **************************************************************** */
763
764
765 function wdm_wpma_add_rest_endpoints () {
766 register_rest_route('wpma/v1', '/message/(?P<id>[\d]+)', array(
767 'methods' => 'GET',
768 'callback' => 'wdm_wpma_rest_get_message',
769 'permission_callback' => function () {
770 return current_user_can( 'manage_options' );
771 },
772 ));
773 }
774 add_action('rest_api_init', 'wdm_wpma_add_rest_endpoints', 10, 0);
775
776
777 function wdm_wpma_rest_get_message ($request) {
778 global $wpdb;
779 $tableName = wdm_wpma_table();
780 $id = intval($request['id']);
781 $row = $wpdb->get_row( $wpdb->prepare("SELECT * FROM `$tableName` WHERE `id` = %d", $id ), ARRAY_A );
782 // event/alert rows are queue bookkeeping, not real mails — the list-table UI offers
783 // no message toggle for them, so the endpoint treats them as not found as well.
784 if ( $row && in_array( $row['status'], array( 'event', 'alert' ), /*strict*/true ) ) {
785 $row = null;
786 }
787 if ($row) {
788 // Search for content-type header to detect html emails
789 $is_content_type_html = false;
790 $headers = wdm_wpma_safe_unserialize( $row['headers'] );
791 if (is_string($headers)) {
792 $headers = [ $headers ];
793 } else if (!is_array($headers)) {
794 $headers = [];
795 }
796 foreach ( $headers as $header ) {
797 if ( preg_match( '/content-type: ?text\/html/i', $header ) ) {
798 $is_content_type_html = true;
799 break;
800 }
801 }
802 return array(
803 'status' => 'ok',
804 'data' => array(
805 'html' => wdm_wpma_render_list_message($row['message'],$is_content_type_html),
806 ),
807 );
808 } else {
809 return new WP_Error( 'no_message', __( 'Message not found' ), array( 'status' => 404 ) );
810 }
811 }
812
813 function wdm_wpma_render_list_message ($message, $is_content_type_html) {
814 // Security: the message body is untrusted (captured from any wp_mail() caller,
815 // incl. unauthenticated flows). The esc_html() calls below are load-bearing — they
816 // render HTML mails as escaped source, never as live DOM. Any HTML preview
817 // must use a sandboxed <iframe srcdoc sandbox> (no allow-scripts), never raw output.
818 // Split html emails into parts and extract plain text preview
819 $parts = explode( '<body', $message );
820 $is_html = $is_content_type_html || count($parts) > 1;
821 if ($is_html) {
822 if (count($parts) > 1) {
823 $header = $parts[0];
824 $body = '<body'.$parts[1];
825 } else {
826 $header = '';
827 $body = $parts[0];
828 }
829 $parts = explode('</body>', $body);
830 if (count($parts) > 1) {
831 $body = $parts[0].'</body>';
832 $footer = $parts[1];
833 } else {
834 $body = $parts[0];
835 $footer = '';
836 }
837 if (!function_exists('convert_html_to_text')) {
838 require_once __DIR__.'/lib/html2text/html2text.php';
839 }
840 // ignore warnings when converting html containing non-converted HTML entities
841 $internal_errors = libxml_use_internal_errors(true);
842 $text = convert_html_to_text( $body );
843 libxml_use_internal_errors($internal_errors);
844 } else {
845 $text = $message;
846 $header = '';
847 $body = '';
848 $footer = '';
849 }
850 $html = '';
851 $html .= '<details class="wdm-wpma-email-source-meta" open><summary>Text</summary><pre class="wdm-wpma-email-plain-text">'.esc_html( $text ).'</pre></details>';
852 $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>' : '';
853 $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>' : '';
854 $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>' : '';
855 return $html;
856 }
857
858 function wdm_wpma_render_html_for_display ($html) {
859 $html = preg_replace( '/;base64,[^"\']+("|\')+/', ';base64, [...] $1', $html );
860 return $html;
861 }
862