PluginProbe
wpForo Forum / 3.1.2
wpForo Forum v3.1.2
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / EmailQueue.php

EmailQueue.php in wpForo Forum 3.1.2, at classes/EmailQueue.php

655 lines 17.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 // Exit if accessed directly
6 if( ! defined( 'ABSPATH' ) ) exit;
7
8 class EmailQueue {
9 const BATCH_SIZE = 20;
10 const MAX_ATTEMPTS = 3;
11 const CRON_HOOK = 'wpforo_process_email_queue';
12 const CLEANUP_HOOK = 'wpforo_cleanup_email_queue';
13 const CRON_INTERVAL = 30; // seconds
14 const STALE_THRESHOLD = 120; // 2 minutes - consider cron stalled
15 const CLEANUP_DAYS = 7;
16 const PROCESSING_LOCK = 'wpforo_email_queue_processing';
17 const LOCK_TIMEOUT = 300; // 5 minutes
18 const SELF_HEAL_LOCK = 'wpforo_email_queue_self_heal_check';
19 const SELF_HEAL_THROTTLE = 300; // 5 minutes between auto self-heal attempts
20
21 private $table;
22 private $table_exists = null;
23 private $last_mail_error = '';
24
25 public function __construct() {
26 $this->table = WPF()->db->prefix . 'wpforo_email_queue';
27 $this->init_hooks();
28 }
29
30 private function init_hooks() {
31 add_action( self::CRON_HOOK, [ $this, 'process_batch' ] );
32 add_action( self::CLEANUP_HOOK, [ $this, 'cleanup_old' ] );
33 add_action( 'wp_mail_failed', [ $this, 'capture_mail_error' ] );
34
35 // Auto self-heal for sites where WP-Cron is silently broken (page-cache
36 // stripping cron triggers, very low traffic, etc). Runs after WordPress
37 // finishes rendering the response, throttled to at most once per
38 // SELF_HEAL_THROTTLE per site. No-op on healthy sites.
39 add_action( 'shutdown', [ $this, 'maybe_self_heal' ], 100 );
40 }
41
42 /**
43 * Capture mail errors from wp_mail_failed action
44 * Works with native wp_mail and all SMTP plugins
45 */
46 public function capture_mail_error( $wp_error ): void {
47 if( is_wp_error( $wp_error ) ) {
48 $this->last_mail_error = $wp_error->get_error_message();
49 }
50 }
51
52 /**
53 * Check if the email_queue table exists
54 */
55 private function table_exists(): bool {
56 if( $this->table_exists === null ) {
57 $this->table_exists = (bool) WPF()->db->get_var(
58 WPF()->db->prepare( "SHOW TABLES LIKE %s", $this->table )
59 );
60 }
61 return $this->table_exists;
62 }
63
64 /**
65 * Contexts that should always be sent synchronously (user expects immediate delivery)
66 */
67 private function get_sync_contexts(): array {
68 return apply_filters( 'wpforo_email_sync_contexts', [
69 'password_reset',
70 'subscription_confirm',
71 'welcome',
72 'moderation_alert',
73 ] );
74 }
75
76 /**
77 * Check if WP Cron is healthy and capable of processing the queue
78 */
79 public function is_cron_healthy(): bool {
80 if( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
81 return false;
82 }
83
84 $last_run = (int) get_option( 'wpforo_email_queue_last_run', 0 );
85 $next_scheduled = wp_next_scheduled( self::CRON_HOOK );
86
87 if( $next_scheduled && $next_scheduled < ( time() - self::STALE_THRESHOLD * 5 ) ) {
88 if( $this->get_pending_count() > 0 ) {
89 return false;
90 }
91 }
92
93 if( $last_run > 0 && $last_run < ( time() - 600 ) ) {
94 if( $this->get_pending_count() > 0 ) {
95 return false;
96 }
97 }
98
99 return true;
100 }
101
102 /**
103 * Get cron status for admin display
104 */
105 public function get_cron_status(): array {
106 $last_run = (int) get_option( 'wpforo_email_queue_last_run', 0 );
107 $next_scheduled = wp_next_scheduled( self::CRON_HOOK );
108 $pending = $this->get_pending_count();
109 $is_healthy = $this->is_cron_healthy();
110
111 if( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {
112 $status = 'disabled';
113 $message = __( 'WP Cron is disabled - emails sent synchronously', 'wpforo' );
114 } elseif( ! $is_healthy ) {
115 $status = 'stalled';
116 $message = __( 'Cron appears stalled - emails sent synchronously', 'wpforo' );
117 } elseif( $last_run > ( time() - 300 ) ) {
118 $status = 'healthy';
119 $message = __( 'Cron is running normally', 'wpforo' );
120 } elseif( $pending === 0 ) {
121 $status = 'idle';
122 $message = __( 'Cron idle - no emails to send', 'wpforo' );
123 } else {
124 $status = 'unknown';
125 $message = __( 'Cron status unknown - no recent activity', 'wpforo' );
126 }
127
128 return [
129 'status' => $status,
130 'message' => $message,
131 'is_healthy' => $is_healthy,
132 'last_run' => $last_run,
133 'last_run_human' => $last_run ? human_time_diff( $last_run ) . ' ' . __( 'ago', 'wpforo' ) : __( 'Never', 'wpforo' ),
134 'next_scheduled' => $next_scheduled,
135 'pending_count' => $pending,
136 ];
137 }
138
139 /**
140 * Queue an email or send synchronously based on context and cron health
141 */
142 public function queue_or_send( string $email, string $subject, string $message, string $headers = '', string $context = 'general', int $related_id = 0 ): bool {
143 if( in_array( $context, $this->get_sync_contexts(), true ) ) {
144 return $this->send_sync( $email, $subject, $message, $headers );
145 }
146
147 if( ! wpforo_setting( 'email', 'async_notifications' ) ) {
148 return $this->send_sync( $email, $subject, $message, $headers );
149 }
150
151 if( ! $this->is_cron_healthy() ) {
152 $this->log_cron_fallback();
153 return $this->send_sync( $email, $subject, $message, $headers );
154 }
155
156 return $this->queue( $email, $subject, $message, $headers, $context, $related_id );
157 }
158
159 /**
160 * Add email to the queue
161 */
162 public function queue( string $email, string $subject, string $message, string $headers = '', string $context = 'general', int $related_id = 0 ): bool {
163 if( ! $this->table_exists() ) {
164 return $this->send_sync( $email, $subject, $message, $headers );
165 }
166
167 $now = current_time( 'mysql', true );
168 $boardid = 0;
169 if( isset( WPF()->board ) && method_exists( WPF()->board, 'get_current' ) ) {
170 $boardid = (int) WPF()->board->get_current( 'boardid' );
171 }
172
173 $result = WPF()->db->insert(
174 $this->table,
175 [
176 'email' => $email,
177 'subject' => $subject,
178 'message' => $message,
179 'headers' => $headers,
180 'priority' => 10,
181 'status' => 'pending',
182 'attempts' => 0,
183 'max_attempts' => self::MAX_ATTEMPTS,
184 'created_at' => $now,
185 'scheduled_at' => $now,
186 'context' => $context,
187 'related_id' => $related_id,
188 'boardid' => $boardid,
189 ],
190 [ '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%d' ]
191 );
192
193 if( $result ) {
194 $this->ensure_cron_scheduled();
195 return true;
196 }
197
198 return $this->send_sync( $email, $subject, $message, $headers );
199 }
200
201 /**
202 * Send email synchronously (fallback)
203 */
204 public function send_sync( string $email, string $subject, string $message, string $headers = '' ): bool {
205 if( defined( 'IS_GO2WPFORO' ) && IS_GO2WPFORO ) return false;
206
207 add_filter( 'wp_mail_content_type', 'wpforo_set_html_content_type', 999 );
208 $result = wp_mail( $email, $subject, $message, $headers ?: wpforo_mail_headers() );
209 remove_filter( 'wp_mail_content_type', 'wpforo_set_html_content_type' );
210
211 return $result;
212 }
213
214 /**
215 * Process a batch of pending emails
216 */
217 public function process_batch(): int {
218 if( ! $this->acquire_lock() ) {
219 return 0;
220 }
221
222 $processed = 0;
223
224 try {
225 $emails = $this->get_pending_emails( self::BATCH_SIZE );
226
227 if( empty( $emails ) ) {
228 $this->release_lock();
229 return 0;
230 }
231
232 $ids = wp_list_pluck( $emails, 'id' );
233 $this->mark_as_processing( $ids );
234
235 foreach( $emails as $email_row ) {
236 $success = $this->send_queued_email( $email_row );
237
238 if( $success ) {
239 $this->mark_as_sent( $email_row['id'] );
240 $processed++;
241 } else {
242 $this->handle_failed_email( $email_row );
243 }
244 }
245
246 update_option( 'wpforo_email_queue_last_run', time() );
247
248 if( $this->get_pending_count() > 0 ) {
249 $this->ensure_cron_scheduled();
250 }
251
252 } finally {
253 $this->release_lock();
254 }
255
256 return $processed;
257 }
258
259 /**
260 * Get pending emails ready to be sent
261 */
262 private function get_pending_emails( int $limit ): array {
263 if( ! $this->table_exists() ) return [];
264
265 $now = current_time( 'mysql', true );
266
267 $results = WPF()->db->get_results(
268 WPF()->db->prepare(
269 "SELECT * FROM `{$this->table}`
270 WHERE `status` = 'pending'
271 AND (`scheduled_at` <= %s OR `scheduled_at` IS NULL)
272 AND (`next_retry_at` IS NULL OR `next_retry_at` <= %s)
273 ORDER BY `priority` ASC, `created_at` ASC
274 LIMIT %d",
275 $now,
276 $now,
277 $limit
278 ),
279 ARRAY_A
280 );
281
282 return $results ?: [];
283 }
284
285 /**
286 * Send a single queued email
287 */
288 private function send_queued_email( array $email_row ): bool {
289 $this->last_mail_error = '';
290
291 add_filter( 'wp_mail_content_type', 'wpforo_set_html_content_type', 999 );
292 $result = wp_mail(
293 $email_row['email'],
294 $email_row['subject'],
295 $email_row['message'],
296 $email_row['headers'] ?: wpforo_mail_headers()
297 );
298 remove_filter( 'wp_mail_content_type', 'wpforo_set_html_content_type' );
299
300 return $result;
301 }
302
303 /**
304 * Mark emails as processing
305 */
306 private function mark_as_processing( array $ids ): void {
307 if( empty( $ids ) ) return;
308
309 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
310 WPF()->db->query(
311 WPF()->db->prepare(
312 "UPDATE `{$this->table}` SET `status` = 'processing' WHERE `id` IN ($placeholders)",
313 ...$ids
314 )
315 );
316 }
317
318 /**
319 * Mark email as sent
320 */
321 private function mark_as_sent( int $id ): void {
322 WPF()->db->update(
323 $this->table,
324 [
325 'status' => 'sent',
326 'processed_at' => current_time( 'mysql', true ),
327 ],
328 [ 'id' => $id ],
329 [ '%s', '%s' ],
330 [ '%d' ]
331 );
332 }
333
334 /**
335 * Handle failed email - retry or mark as failed
336 */
337 private function handle_failed_email( array $email_row ): void {
338 $attempts = (int) $email_row['attempts'] + 1;
339 $max_attempts = (int) $email_row['max_attempts'];
340
341 if( $attempts >= $max_attempts ) {
342 WPF()->db->update(
343 $this->table,
344 [
345 'status' => 'failed',
346 'attempts' => $attempts,
347 'processed_at' => current_time( 'mysql', true ),
348 'error_message' => $this->get_last_mail_error(),
349 ],
350 [ 'id' => $email_row['id'] ],
351 [ '%s', '%d', '%s', '%s' ],
352 [ '%d' ]
353 );
354 } else {
355 $delay = pow( 2, $attempts ) * 60;
356 $next_retry = gmdate( 'Y-m-d H:i:s', time() + $delay );
357
358 WPF()->db->update(
359 $this->table,
360 [
361 'status' => 'pending',
362 'attempts' => $attempts,
363 'next_retry_at' => $next_retry,
364 'error_message' => $this->get_last_mail_error(),
365 ],
366 [ 'id' => $email_row['id'] ],
367 [ '%s', '%d', '%s', '%s' ],
368 [ '%d' ]
369 );
370 }
371 }
372
373 /**
374 * Get last wp_mail error if available
375 * Uses wp_mail_failed hook (works with all SMTP plugins)
376 */
377 private function get_last_mail_error(): string {
378 if( ! empty( $this->last_mail_error ) ) {
379 $error = substr( $this->last_mail_error, 0, 500 );
380 $this->last_mail_error = '';
381 return $error;
382 }
383 return __( 'Unknown error', 'wpforo' );
384 }
385
386 /**
387 * Ensure cron job is scheduled
388 */
389 public function ensure_cron_scheduled(): void {
390 if( ! wp_next_scheduled( self::CRON_HOOK ) ) {
391 wp_schedule_single_event( time() + self::CRON_INTERVAL, self::CRON_HOOK );
392 }
393 }
394
395 /**
396 * Self-heal stalled queue when admin visits the page
397 */
398 public function maybe_process_stalled_queue(): int {
399 $next_scheduled = wp_next_scheduled( self::CRON_HOOK );
400
401 if( $next_scheduled && $next_scheduled < ( time() - self::STALE_THRESHOLD ) ) {
402 if( $this->get_pending_count() > 0 ) {
403 return $this->process_batch();
404 }
405 }
406
407 return 0;
408 }
409
410 /**
411 * Auto self-heal hooked to `shutdown` on every regular page request.
412 *
413 * Bridges the gap when WP-Cron is silently broken — page-cache plugins
414 * stripping cron triggers, very low traffic, or `DISABLE_WP_CRON` set
415 * without an external trigger. The check is heavily gated so it costs
416 * a single transient read on healthy sites:
417 *
418 * 1. Skips cron / AJAX / REST / XML-RPC / CLI — those are not user
419 * requests and self-healing on them would either be wasted or
420 * interfere with their normal flow.
421 * 2. Throttled by transient — at most one attempt per site per
422 * SELF_HEAL_THROTTLE seconds.
423 * 3. The actual processing is delegated to maybe_process_stalled_queue()
424 * which itself is a no-op unless the next scheduled cron is overdue
425 * AND pending emails exist.
426 */
427 public function maybe_self_heal(): void {
428 if( ! $this->table_exists() ) return;
429 if( wp_doing_cron() ) return;
430 if( wp_doing_ajax() ) return;
431 if( defined( 'WP_CLI' ) && WP_CLI ) return;
432 if( defined( 'REST_REQUEST' ) && REST_REQUEST ) return;
433 if( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) return;
434
435 if( get_transient( self::SELF_HEAL_LOCK ) ) return;
436 set_transient( self::SELF_HEAL_LOCK, 1, self::SELF_HEAL_THROTTLE );
437
438 $this->maybe_process_stalled_queue();
439 }
440
441 /**
442 * Clean up old sent emails
443 */
444 public function cleanup_old(): int {
445 if( ! $this->table_exists() ) return 0;
446
447 $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . self::CLEANUP_DAYS . ' days' ) );
448
449 $deleted = WPF()->db->query(
450 WPF()->db->prepare(
451 "DELETE FROM `{$this->table}` WHERE `status` = 'sent' AND `processed_at` < %s",
452 $cutoff
453 )
454 );
455
456 return (int) $deleted;
457 }
458
459 /**
460 * Get count of pending emails
461 */
462 public function get_pending_count(): int {
463 if( ! $this->table_exists() ) return 0;
464 return (int) WPF()->db->get_var(
465 "SELECT COUNT(*) FROM `{$this->table}` WHERE `status` IN ('pending', 'processing')"
466 );
467 }
468
469 /**
470 * Get count of failed emails
471 */
472 public function get_failed_count(): int {
473 if( ! $this->table_exists() ) return 0;
474 return (int) WPF()->db->get_var(
475 "SELECT COUNT(*) FROM `{$this->table}` WHERE `status` = 'failed'"
476 );
477 }
478
479 /**
480 * Get count of sent emails (today)
481 */
482 public function get_sent_today_count(): int {
483 if( ! $this->table_exists() ) return 0;
484 $today = gmdate( 'Y-m-d 00:00:00' );
485 return (int) WPF()->db->get_var(
486 WPF()->db->prepare(
487 "SELECT COUNT(*) FROM `{$this->table}` WHERE `status` = 'sent' AND `processed_at` >= %s",
488 $today
489 )
490 );
491 }
492
493 /**
494 * Get total sent count
495 */
496 public function get_total_sent_count(): int {
497 if( ! $this->table_exists() ) return 0;
498 return (int) WPF()->db->get_var(
499 "SELECT COUNT(*) FROM `{$this->table}` WHERE `status` = 'sent'"
500 );
501 }
502
503 /**
504 * Get queue statistics
505 */
506 public function get_stats(): array {
507 return [
508 'pending' => $this->get_pending_count(),
509 'failed' => $this->get_failed_count(),
510 'sent_today' => $this->get_sent_today_count(),
511 'sent_total' => $this->get_total_sent_count(),
512 'cron' => $this->get_cron_status(),
513 ];
514 }
515
516 /**
517 * Get paginated queue items for admin display
518 */
519 public function get_queue_items( string $status = 'pending', int $page = 1, int $per_page = 20 ): array {
520 if( ! $this->table_exists() ) {
521 return [ 'items' => [], 'total' => 0, 'page' => $page, 'per_page' => $per_page, 'total_pages' => 0 ];
522 }
523
524 $offset = ( $page - 1 ) * $per_page;
525
526 $items = WPF()->db->get_results(
527 WPF()->db->prepare(
528 "SELECT `id`, `email`, `subject`, `context`, `status`, `attempts`, `max_attempts`,
529 `created_at`, `processed_at`, `error_message`, `related_id`, `boardid`
530 FROM `{$this->table}`
531 WHERE `status` = %s
532 ORDER BY `created_at` DESC
533 LIMIT %d OFFSET %d",
534 $status,
535 $per_page,
536 $offset
537 ),
538 ARRAY_A
539 );
540
541 $total = (int) WPF()->db->get_var(
542 WPF()->db->prepare(
543 "SELECT COUNT(*) FROM `{$this->table}` WHERE `status` = %s",
544 $status
545 )
546 );
547
548 return [
549 'items' => $items ?: [],
550 'total' => $total,
551 'page' => $page,
552 'per_page' => $per_page,
553 'total_pages'=> ceil( $total / $per_page ),
554 ];
555 }
556
557 /**
558 * Retry failed emails
559 */
560 public function retry_failed( array $ids ): int {
561 if( empty( $ids ) || ! $this->table_exists() ) return 0;
562
563 $ids = array_map( 'intval', $ids );
564 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
565
566 $updated = WPF()->db->query(
567 WPF()->db->prepare(
568 "UPDATE `{$this->table}`
569 SET `status` = 'pending', `attempts` = 0, `next_retry_at` = NULL, `error_message` = NULL
570 WHERE `id` IN ($placeholders) AND `status` = 'failed'",
571 ...$ids
572 )
573 );
574
575 if( $updated ) {
576 $this->ensure_cron_scheduled();
577 }
578
579 return (int) $updated;
580 }
581
582 /**
583 * Delete queue items
584 */
585 public function delete_items( array $ids ): int {
586 if( empty( $ids ) || ! $this->table_exists() ) return 0;
587
588 $ids = array_map( 'intval', $ids );
589 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
590
591 return (int) WPF()->db->query(
592 WPF()->db->prepare(
593 "DELETE FROM `{$this->table}` WHERE `id` IN ($placeholders)",
594 ...$ids
595 )
596 );
597 }
598
599 /**
600 * Clear all sent emails
601 */
602 public function clear_sent_history(): int {
603 if( ! $this->table_exists() ) return 0;
604 return (int) WPF()->db->query(
605 "DELETE FROM `{$this->table}` WHERE `status` = 'sent'"
606 );
607 }
608
609 /**
610 * Acquire processing lock to prevent concurrent batch processing
611 */
612 private function acquire_lock(): bool {
613 $lock = get_transient( self::PROCESSING_LOCK );
614 if( $lock ) {
615 return false;
616 }
617 set_transient( self::PROCESSING_LOCK, time(), self::LOCK_TIMEOUT );
618 return true;
619 }
620
621 /**
622 * Release processing lock
623 */
624 private function release_lock(): void {
625 delete_transient( self::PROCESSING_LOCK );
626 }
627
628 /**
629 * Log when falling back to sync due to cron issues
630 */
631 private function log_cron_fallback(): void {
632 $count = (int) get_option( 'wpforo_email_queue_sync_fallback_count', 0 );
633 update_option( 'wpforo_email_queue_sync_fallback_count', $count + 1 );
634 }
635
636 /**
637 * Schedule daily cleanup cron
638 */
639 public function schedule_cleanup_cron(): void {
640 if( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) {
641 wp_schedule_event( strtotime( 'tomorrow 3:00am' ), 'daily', self::CLEANUP_HOOK );
642 }
643 }
644
645 /**
646 * Reset processing status for stuck emails (called on activation/upgrade)
647 */
648 public function reset_stuck_processing(): void {
649 if( ! $this->table_exists() ) return;
650 WPF()->db->query(
651 "UPDATE `{$this->table}` SET `status` = 'pending' WHERE `status` = 'processing'"
652 );
653 }
654 }
655