prefix . 'luxmail_queue'; } public static function install() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created_at datetime NOT NULL, next_at datetime NOT NULL, attempts smallint(5) unsigned NOT NULL DEFAULT 0, status varchar(10) NOT NULL DEFAULT 'pending', log_id bigint(20) unsigned NOT NULL DEFAULT 0, last_error text NULL, state longtext NOT NULL, PRIMARY KEY (id), KEY next_at (status, next_at) ) {$charset};" ); } public static function add_schedule( $schedules ) { $schedules[ self::SCHEDULE ] = array( 'interval' => 5 * MINUTE_IN_SECONDS, 'display' => __( 'Every 5 minutes (SOHO SMTP)', 'soho-smtp' ), ); return $schedules; } public static function ensure_schedule() { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + MINUTE_IN_SECONDS, self::SCHEDULE, self::CRON_HOOK ); } } /** Wait before attempt n (1 = first retry). */ public static function delay_for( $attempt ) { $steps = array( 1 => 5 * MINUTE_IN_SECONDS, 2 => 30 * MINUTE_IN_SECONDS, 3 => 2 * HOUR_IN_SECONDS, 4 => 6 * HOUR_IN_SECONDS ); return isset( $steps[ $attempt ] ) ? $steps[ $attempt ] : 6 * HOUR_IN_SECONDS; } private static function dir() { $up = wp_upload_dir( null, false ); return trailingslashit( $up['basedir'] ) . 'soho-smtp'; } /** * Copy one file attachment into the protected queue folder. * * Only a real, readable, local file of a type WordPress itself accepts (so never .php, .exe and * the like) and of reasonable size is copied. The copy is stored as ".dat" inside this * plugin's own uploads folder, so nothing in it can be run by the web server. Returns the path * of the copy, or '' when the file is refused (the email is then sent right away instead). */ private static function copy_attachment( $source, $dir, $index ) { $source = (string) $source; if ( '' === $source || false !== strpos( $source, "\0" ) || false !== strpos( $source, '://' ) ) { return ''; } $real = realpath( $source ); if ( false === $real || ! is_file( $real ) || ! is_readable( $real ) ) { return ''; } $type = wp_check_filetype( wp_basename( $real ) ); if ( empty( $type['ext'] ) ) { return ''; } $size = filesize( $real ); if ( false === $size || $size > self::MAX_ATTACHMENT_BYTES ) { return ''; } $target = trailingslashit( $dir ) . (int) $index . '.dat'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_copy, WordPress.PHP.NoSilencedErrors.Discouraged -- checked above; the target is a fixed name inside our own protected folder. if ( ! @copy( $real, $target ) ) { return ''; } return $target; } private static function protect_dir( $dir ) { if ( ! wp_mkdir_p( $dir ) ) { return false; } // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- tiny guard files. // A plain empty page (not a PHP file) so the folder cannot be listed; no code is ever written here. if ( ! file_exists( $dir . '/index.html' ) ) { @file_put_contents( $dir . '/index.html', '' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } if ( ! file_exists( $dir . '/.htaccess' ) ) { @file_put_contents( $dir . '/.htaccess', "Require all denied\nDeny from all\n" ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged } // phpcs:enable return true; } /** * Store a built message. Returns the queue id, or 0 on failure. */ public static function enqueue( LuxMail_PHPMailer $m, $log_id, $attempts, $delay, $error ) { global $wpdb; $state = $m->luxmail_export(); $now = time(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- own queue table. $ok = $wpdb->insert( self::table(), array( 'created_at' => gmdate( 'Y-m-d H:i:s', $now ), 'next_at' => gmdate( 'Y-m-d H:i:s', $now + (int) $delay ), 'attempts' => (int) $attempts, 'status' => 'pending', 'log_id' => (int) $log_id, 'last_error' => $error, 'state' => '{}', ) ); if ( ! $ok ) { return 0; } $id = (int) $wpdb->insert_id; // Copy file attachments next to the queued message. $has_files = false; foreach ( $state['attachments'] as $a ) { if ( ! $a['is_string'] ) { $has_files = true; break; } } if ( $has_files ) { $dir = self::dir() . '/' . $id; if ( ! self::protect_dir( self::dir() ) || ! wp_mkdir_p( $dir ) ) { self::delete( $id ); return 0; } foreach ( $state['attachments'] as $i => $a ) { if ( $a['is_string'] ) { continue; } $target = self::copy_attachment( $a['path'], $dir, $i ); if ( '' === $target ) { self::delete( $id ); return 0; } // The stored copy has a neutral name; keep the original file name for the recipient. if ( '' === (string) $a['name'] ) { $state['attachments'][ $i ]['name'] = wp_basename( $a['path'] ); } $state['attachments'][ $i ]['path'] = $target; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( self::table(), array( 'state' => wp_json_encode( $state ) ), array( 'id' => $id ) ); self::ensure_schedule(); return $id; } /** * Ask WP-Cron to process the queue as soon as possible (non-blocking). */ public static function kick() { if ( ! wp_next_scheduled( self::CRON_HOOK . '_now' ) ) { wp_schedule_single_event( time(), self::CRON_HOOK . '_now' ); } if ( function_exists( 'spawn_cron' ) && ! ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) ) { spawn_cron(); } } public static function counts() { global $wpdb; $table = self::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( "SELECT status, COUNT(*) AS n, MIN(next_at) AS next_at FROM {$table} GROUP BY status" ); $out = array( 'pending' => 0, 'sending' => 0, 'next_at' => '' ); foreach ( (array) $rows as $r ) { if ( isset( $out[ $r->status ] ) ) { $out[ $r->status ] = (int) $r->n; } if ( 'pending' === $r->status ) { $out['next_at'] = $r->next_at; } } return $out; } /** * Send what is due. $force sends every pending message now (the "send now" button). * * @return array{sent: int, failed: int, retrying: int} */ public static function process( $force = false ) { global $wpdb; $stats = array( 'sent' => 0, 'failed' => 0, 'retrying' => 0 ); if ( get_transient( 'luxmail_queue_lock' ) ) { return $stats; } set_transient( 'luxmail_queue_lock', 1, 2 * MINUTE_IN_SECONDS ); $table = self::table(); $now = gmdate( 'Y-m-d H:i:s' ); // Messages left "sending" by a crashed run go back to the queue. // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = 'pending' WHERE status = 'sending' AND next_at < %s", gmdate( 'Y-m-d H:i:s', time() - 10 * MINUTE_IN_SECONDS ) ) ); $rows = $force ? $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE status = 'pending' ORDER BY id ASC LIMIT %d", self::BATCH ) ) : $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE status = 'pending' AND next_at <= %s ORDER BY id ASC LIMIT %d", $now, self::BATCH ) ); // phpcs:enable foreach ( (array) $rows as $row ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( $table, array( 'status' => 'sending', 'next_at' => $now ), array( 'id' => $row->id ) ); $state = json_decode( $row->state, true ); if ( ! is_array( $state ) || empty( $state['to'] ) && empty( $state['cc'] ) && empty( $state['bcc'] ) ) { self::delete( $row->id ); continue; } $m = LuxMail_Mailer::new_mailer(); try { $m->luxmail_import( $state ); $res = LuxMail_Mailer::attempt( $m ); } catch ( \Throwable $e ) { $res = array( 'ok' => false, 'error' => $e->getMessage(), 'debug' => '', 'via' => LuxMail_Mailer::via_label( 'primary' ), 'note' => '' ); } $attempts = (int) $row->attempts + 1; if ( $res['ok'] ) { LuxMail_Log::update( $row->log_id, array( 'status' => 'sent', 'error' => $res['note'], 'debug' => '', 'via' => $res['via'], 'attempts' => $attempts ) ); self::delete( $row->id ); ++$stats['sent']; continue; } $retry = LuxMail_Settings::get( 'retry_enabled' ) && $attempts < self::MAX_ATTEMPT; if ( $retry ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->update( $table, array( 'status' => 'pending', 'attempts' => $attempts, 'next_at' => gmdate( 'Y-m-d H:i:s', time() + self::delay_for( $attempts ) ), 'last_error' => $res['error'], ), array( 'id' => $row->id ) ); LuxMail_Log::update( $row->log_id, array( 'status' => 'retrying', 'error' => $res['error'], 'debug' => $res['debug'], 'via' => $res['via'], 'attempts' => $attempts ) ); ++$stats['retrying']; } else { LuxMail_Log::update( $row->log_id, array( 'status' => 'failed', 'error' => $res['error'], 'debug' => $res['debug'], 'via' => $res['via'], 'attempts' => $attempts ) ); $to = implode( ', ', array_map( static function ( $r ) { return $r[0]; }, (array) $state['to'] ) ); LuxMail_Alerts::delivery_failed( $res['error'], (string) $state['subject'], $to ); self::delete( $row->id ); ++$stats['failed']; } } delete_transient( 'luxmail_queue_lock' ); return $stats; } public static function delete( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->delete( self::table(), array( 'id' => (int) $id ) ); $dir = self::dir() . '/' . (int) $id; if ( is_dir( $dir ) ) { foreach ( (array) glob( $dir . '/*' ) as $f ) { wp_delete_file( $f ); } @rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } } /** Remove everything (uninstall). */ public static function destroy() { $dir = self::dir(); if ( is_dir( $dir ) ) { foreach ( (array) glob( $dir . '/*', GLOB_ONLYDIR ) as $d ) { foreach ( (array) glob( $d . '/*' ) as $f ) { wp_delete_file( $f ); } @rmdir( $d ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } foreach ( array( 'index.html', '.htaccess' ) as $f ) { wp_delete_file( $dir . '/' . $f ); } @rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } } }