= threshold` (i.e. * the threshold-th hit triggers the notification). * * @package AIOSEO\FourNotFour */ namespace AIOSEO\FourNotFour\Main\Front\Actions; // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } use AIOSEO\FourNotFour\Utils\Plugin; use AIOSEO\FourNotFour\Main\Front\Request; use AIOSEO\FourNotFour\Models\Log; /** * Class Email * * @since 4.0.0 * @package AIOSEO\FourNotFour\Front\Actions */ class Email extends Action { /** * Whether this action should fire for the current request. * * @since 4.0.0 * * @param Request $request Current request. * * @return bool */ protected function shouldRun( Request $request ): bool { if ( $request->isExcluded() ) { return false; } // Per-URL override beats the master toggle in both directions: // DISABLE silences alerts even with `email_enabled = true`, // ENABLE force-sends even with `email_enabled = false`. Without // a log row (or with GLOBAL on the row), fall back to the // master toggle. $log = $request->log(); $override = $log ? (int) $log->override_email : Log::OVERRIDE_GLOBAL; if ( Log::OVERRIDE_DISABLE === $override ) { return false; } if ( Log::OVERRIDE_ENABLE !== $override && ! $this->setting( 'notifications.email.enabled', false ) ) { return false; } // Standalone custom redirects suppress the alert too — the URL // was routed cleanly, so it's not a broken-link signal worth // emailing about. Mirrors the Log action's bail. $row = $request->redirect(); if ( null === $log && $row && 1 === (int) $row->is_active ) { return false; } $recipients = $this->recipients(); if ( empty( $recipients ) ) { return false; } // Threshold: only email once we have at least N hits. The Log // action runs before us, so by the time we get here the // `hits` column has already been bumped for this request. $threshold = max( 1, (int) $this->setting( 'notifications.email.threshold', 1 ) ); $hits = $log ? (int) $log->hits : 1; if ( $hits < $threshold ) { return false; } // Avoid spamming once the threshold has been crossed — only // send for the exact hit that hits the threshold (e.g. 5th // hit when threshold = 5). Otherwise every subsequent hit // would re-trigger. if ( $hits > $threshold && 1 !== $threshold ) { return false; } return true; } /** * Compose and send the notification. * * @since 4.0.0 * * @param Request $request Current request. * * @return void */ public function run( Request $request ): void { if ( ! $this->shouldRun( $request ) ) { return; } $recipients = $this->recipients(); // Decoded site name — used in both the subject and the body // greeting so a forwarded email is self-identifying without // the recipient needing to scroll up to the headers. $siteName = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); /* translators: %s: site name */ $subject = sprintf( __( '[%s] 404 error detected', '404-to-301' ), $siteName ); // Re-fetch the log so we can surface the running hit count. The // Log action upstream of us already bumped this for the current // request, so the number reflects every hit on this URL so far // (including this one). Falls back to 1 if the log row isn't // available for any reason. $log = $request->log(); $hits = $log ? (int) $log->hits : 1; // Deep link straight to the Logs admin page so the recipient // can review and triage from the email. We don't link to the // specific row because the logs list is filterable / paginated // client-side — landing on the list is the closest stable // target available today. $logsUrl = admin_url( 'admin.php?page=' . Plugin::PAGE_LOGS ); // Localised "when did this happen" stamp. `wp_date()` formats // using the site's configured timezone (Settings → General), // not the server's PHP default, so the time matches what the // admin sees elsewhere in WP. Format combines the site's // `date_format` and `time_format` options for consistency // with how core renders timestamps in the dashboard. $timestamp = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); $lines = [ sprintf( /* translators: %s: site name. */ __( 'A new 404 error has been detected on %s:', '404-to-301' ), $siteName ), '', sprintf( '%s: %s', __( 'URL', '404-to-301' ), $request->url() ), sprintf( /* translators: %d: total number of times this URL has 404'd. */ _n( '%d hit so far', '%d hits so far', $hits, '404-to-301' ), $hits ), sprintf( '%s: %s', __( 'Time', '404-to-301' ), $timestamp ), sprintf( '%s: %s', __( 'Referer', '404-to-301' ), $request->referer() ? $request->referer() : __( '(none)', '404-to-301' ) ), sprintf( '%s: %s', __( 'IP', '404-to-301' ), $request->ip() ? $request->ip() : __( '(masked)', '404-to-301' ) ), sprintf( '%s: %s', __( 'User-Agent', '404-to-301' ), $request->userAgent() ), sprintf( '%s: %s', __( 'Method', '404-to-301' ), $request->method() ), '', __( 'Review and manage 404 errors here:', '404-to-301' ), $logsUrl, ]; $body = implode( "\n", $lines ); /** * Filter the notification email recipient(s), subject and body. * * `recipient` is an array of validated email addresses since 4.0.0. * Callbacks that previously returned a string are still honoured * — `wp_mail()` accepts both forms — but new code should treat * the field as `string[]`. * * @since 4.0.0 * * @param array{recipient:string[],subject:string,body:string} $email Email payload. * @param Request $request Current request. */ $email = (array) apply_filters( '404_to_301_email_payload', [ 'recipient' => $recipients, 'subject' => $subject, 'body' => $body, ], $request ); wp_mail( $email['recipient'], $email['subject'], $email['body'] ); /** * Fires after the notification email has been sent. * * @since 4.0.0 * * @param array $email Sent payload. * @param Request $request Current request. */ do_action( '404_to_301_email_sent', $email, $request ); } /** * Resolve the configured recipient list. * * Accepts both shapes for resilience: an array is consumed as-is * and a string (legacy / third-party direct write) is split on the * usual separators and re-validated. The result is always a clean * array of valid addresses, possibly empty — when empty the * caller's `should_run()` short-circuits, matching the historical * "no valid recipient → skip the action" semantics. There is no * implicit fallback to `admin_email` here: clearing the field is * a deliberate way to silence notifications without flipping the * master toggle. * * @since 4.0.0 * * @return string[] */ private function recipients(): array { $raw = $this->setting( 'notifications.email.recipients', [] ); return \AIOSEO\FourNotFour\Utils\Sanitizer::emailList( $raw ); } }