logger = $logger; } /** * Register the cron hook and schedule. */ public function register(): void { add_action( self::CRON_HOOK, [ $this, 'process' ] ); if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time(), 'webcal_five_minutes', self::CRON_HOOK ); } add_filter( 'cron_schedules', [ $this, 'add_cron_schedule' ] ); } /** * Add a 5-minute cron schedule. * * @param array> $schedules Existing schedules. * @return array> */ public function add_cron_schedule( array $schedules ): array { if ( ! isset( $schedules['webcal_five_minutes'] ) ) { $schedules['webcal_five_minutes'] = [ 'interval' => 300, 'display' => __( 'Every 5 Minutes', 'agenticdaisy-calendar' ), ]; } return $schedules; } /** * Add an email to the queue. * * One option row per message, so an enqueue is a single insert and two * requests queueing at once cannot overwrite each other. * * @param string $to Recipient email address. * @param string $subject Email subject. * @param string $body Email body. */ public function enqueue( string $to, string $subject, string $body ): void { $this->store( [ 'to' => $to, 'subject' => $subject, 'body' => $body, 'queued' => time(), 'attempts' => 0, ] ); } /** * Send what is queued. * * Each message is claimed by deleting its row; only the run whose delete * removed it sends it, so overlapping cron runs never send twice, and a * run that dies part-way leaves the messages it had not reached queued. * A failed send is queued again, up to three attempts in all. */ public function process(): void { $this->adopt_legacy_queue(); global $wpdb; foreach ( $this->rows() as $name => $email ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- The delete is the claim; its row count says whether this run owns the message. if ( 1 !== $wpdb->delete( $wpdb->options, [ 'option_name' => $name ] ) ) { continue; } wp_cache_delete( $name, 'options' ); if ( wp_mail( $email['to'], $email['subject'], $email['body'] ) ) { continue; } $attempts = $email['attempts'] + 1; $this->logger->warning( sprintf( 'Failed to send queued email to %s (Attempt %d)', $email['to'], $attempts ) ); if ( $attempts < 3 ) { $email['attempts'] = $attempts; $this->store( $email ); } } } /** * The queued messages, oldest first. * * @return array */ public function get_queue(): array { return array_values( $this->rows() ); } /** * Get the number of emails in the queue. */ public function count(): int { return count( $this->get_queue() ); } /** * Write one message as its own row. * * @param array{to: string, subject: string, body: string, queued: int, attempts: int} $email Message. */ private function store( array $email ): void { add_option( self::ROW_PREFIX . bin2hex( random_bytes( 12 ) ), $email, '', false ); } /** * Queued rows by option name, oldest first. * * @return array */ private function rows(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Listing rows by prefix; the options API cannot. $results = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id ASC LIMIT 500", 'webcal\\_email\\_q\\_%' ), ARRAY_A ); $rows = []; foreach ( is_array( $results ) ? $results : [] as $row ) { if ( ! is_array( $row ) || ! is_string( $row['option_name'] ?? null ) || ! is_string( $row['option_value'] ?? null ) ) { continue; } $email = self::as_email( maybe_unserialize( $row['option_value'] ) ); if ( null !== $email ) { $rows[ $row['option_name'] ] = $email; } } return $rows; } /** * Move mail queued by a version that kept the queue in one option. */ private function adopt_legacy_queue(): void { $legacy = get_option( self::LEGACY_OPTION, [] ); if ( ! is_array( $legacy ) || [] === $legacy ) { return; } delete_option( self::LEGACY_OPTION ); foreach ( $legacy as $raw ) { $email = self::as_email( $raw ); if ( null !== $email ) { $this->store( $email ); } } } /** * A stored message, or null when the value is not one. * * @return array{to: string, subject: string, body: string, queued: int, attempts: int}|null */ private static function as_email( mixed $value ): ?array { if ( ! is_array( $value ) || ! is_string( $value['to'] ?? null ) || ! is_string( $value['subject'] ?? null ) || ! is_string( $value['body'] ?? null ) ) { return null; } return [ 'to' => $value['to'], 'subject' => $value['subject'], 'body' => $value['body'], 'queued' => is_numeric( $value['queued'] ?? null ) ? (int) $value['queued'] : time(), 'attempts' => is_numeric( $value['attempts'] ?? null ) ? (int) $value['attempts'] : 0, ]; } }