dispatch(); } /** * Build and send the report. * * No-op when reports are disabled, the parent's classes aren't * loaded, or the recipient field sanitises to empty. * * @since 4.0.3 * * @return bool True when the email was queued, false otherwise. */ public function dispatch(): bool { if ( ! aioseo404To301()->options->reports->enabled ) { return false; } if ( ! class_exists( \AIOSEO\FourNotFour\Models\Log::class ) ) { return false; } $recipients = \AIOSEO\FourNotFour\Utils\Sanitizer::emailList( aioseo404To301()->options->reports->recipients ); if ( empty( $recipients ) ) { $fallback = sanitize_email( (string) get_option( 'admin_email' ) ); if ( '' !== $fallback ) { $recipients = [ $fallback ]; } } if ( empty( $recipients ) ) { return false; } $sinceId = (int) aioseo404To301()->internalOptions->internal->reportsLastSentId; $stats = ( new Stats() )->compute( $sinceId ); // Don't email an empty digest. Markers stay untouched so the // next tick keeps looking at the same window. if ( $stats['new_logs'] <= 0 && $sinceId > 0 ) { return false; } $frequency = (string) aioseo404To301()->options->reports->frequency; $lastAt = (string) aioseo404To301()->internalOptions->internal->reportsLastSentAt; $subject = $this->subject( $stats, $frequency ); $body = $this->body( $stats, $frequency, $lastAt ); $attachments = []; $tempCsv = ''; if ( aioseo404To301()->options->reports->attachCsv ) { $tempCsv = $this->writeCsv( $sinceId ); if ( '' !== $tempCsv ) { $attachments[] = $tempCsv; } } /** * Filter the report email payload immediately before dispatch. * * @since 4.0.3 * * @param array $email Email payload — recipient, subject, * body, headers, attachments. * @param array $stats Computed stats snapshot. * @param array $context Frequency + last-sent-at marker. */ $email = (array) apply_filters( '404_to_301_email_reports_email', [ 'recipient' => $recipients, 'subject' => $subject, 'body' => $body, 'headers' => [ 'Content-Type: text/html; charset=UTF-8' ], 'attachments' => $attachments, ], $stats, [ 'frequency' => $frequency, 'last_sent_at' => $lastAt, ] ); $sent = wp_mail( // `wp_mail()` accepts both shapes (string|array); the filter // might still hand us a scalar from older callbacks, so we // don't force-cast here. $email['recipient'] ?? $recipients, (string) ( $email['subject'] ?? $subject ), (string) ( $email['body'] ?? $body ), (array) ( $email['headers'] ?? [] ), (array) ( $email['attachments'] ?? [] ) ); // Clean up the temp CSV — `wp_mail()` reads it synchronously, // so we can drop it as soon as it returns. Skipping the unlink // on failure would leak files into the uploads dir on every // tick where SMTP is broken. if ( '' !== $tempCsv && file_exists( $tempCsv ) ) { wp_delete_file( $tempCsv ); } if ( $sent ) { aioseo404To301()->internalOptions->internal->reportsLastSentAt = current_time( 'mysql', true ); aioseo404To301()->internalOptions->internal->reportsLastSentId = (int) $stats['max_id']; /** * Fires after a report email has been queued through `wp_mail()`. * * @since 4.0.3 * * @param array $stats Stats snapshot included in the report. * @param array $email Final email payload. */ do_action( '404_to_301_email_reports_sent', $stats, $email ); } return (bool) $sent; } /** * Build the email subject. * * @since 4.0.3 * * @param array $stats Stats snapshot. * @param string $frequency Report cadence. * * @return string */ private function subject( array $stats, string $frequency ): string { $site = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); $new = (int) $stats['new_logs']; switch ( $frequency ) { case 'daily': /* translators: 1: site name, 2: new 404 count. */ $tpl = __( '[%1$s] Daily 404 report — %2$d new errors', '404-to-301' ); break; case 'monthly': /* translators: 1: site name, 2: new 404 count. */ $tpl = __( '[%1$s] Monthly 404 report — %2$d new errors', '404-to-301' ); break; case 'weekly': default: /* translators: 1: site name, 2: new 404 count. */ $tpl = __( '[%1$s] Weekly 404 report — %2$d new errors', '404-to-301' ); break; } return sprintf( $tpl, $site, $new ); } /** * Render the HTML email body. * * Inline styles only — most webmail clients still strip `