0, 'leads_drop' => 1, 'no_clicks' => 2, 'clicks_drop' => 3, 'sessions_drop' => 4, 'page_drop' => 5, 'rankings_drop' => 6, ); /** * Sites. * * @var ProfileRepository */ private $sites; /** * Client Monitor. * * @var Monitor */ private $monitor; /** * Settings. * * @var Settings */ private $settings; /** * Mailer used for report emails. * * @var ReportMailer */ private $mailer; /** * Constructor. * * @param ProfileRepository $sites Sites. * @param Monitor $monitor Client Monitor. * @param Settings $settings Settings. * @param ReportMailer $mailer Mailer. */ public function __construct( ProfileRepository $sites, Monitor $monitor, Settings $settings, ReportMailer $mailer ) { $this->sites = $sites; $this->monitor = $monitor; $this->settings = $settings; $this->mailer = $mailer; } /** * Whether it is switched on (and the Client Monitor it reads from). * * @return bool */ public function enabled() { return (bool) $this->settings->get( 'brief_enabled' ) && $this->monitor->enabled(); } /** * Who gets it: the addresses in Settings, else the site admin email. * * @return string[] */ public function recipients() { $list = array_values( array_unique( array_map( 'strtolower', array_filter( (array) $this->settings->get( 'brief_emails' ), 'is_email' ) ) ) ); return $list ? $list : array( (string) get_option( 'admin_email' ) ); } /** * The latest send time at or before $now (the chosen weekday and hour, site time zone). * * @param int $now Unix time. * @return int */ public function last_slot( $now ) { $tz = wp_timezone(); $local = ( new \DateTimeImmutable( '@' . (int) $now ) )->setTimezone( $tz ); $day = min( 7, max( 1, (int) $this->settings->get( 'brief_weekday' ) ) ); $hour = min( 23, max( 0, (int) $this->settings->get( 'brief_hour' ) ) ); $back = ( (int) $local->format( 'N' ) - $day + 7 ) % 7; // Move the date before setting the hour: on a DST change day the hour may not exist. $slot = $local->modify( '-' . $back . ' days' )->setTime( $hour, 0 ); if ( $slot->getTimestamp() > $now ) { $slot = $slot->modify( '-7 days' )->setTime( $hour, 0 ); } return $slot->getTimestamp(); } /** * The next send time after $now. * * @param int|null $now Unix time. * @return int */ public function next_slot( $now = null ) { $now = null === $now ? time() : (int) $now; $last = $this->last_slot( $now ); $sent = (int) get_option( self::SENT, 0 ); // This week's slot has passed but it hasn't gone yet: the hourly job sends it next. if ( $sent < $last && $sent > 0 ) { return $last; } $hour = min( 23, max( 0, (int) $this->settings->get( 'brief_hour' ) ) ); return ( new \DateTimeImmutable( '@' . $last ) )->setTimezone( wp_timezone() )->modify( '+7 days' )->setTime( $hour, 0 )->getTimestamp(); } /** * Starts the clock when it is switched on, so the first brief goes at the * next chosen time rather than straight away for a time already past. * * @param int|null $now Unix time. * @return void */ public function start( $now = null ) { update_option( self::SENT, null === $now ? time() : (int) $now, false ); } /** * Sends this week's brief if it is due. Called by the hourly job. * * @param int|null $now Unix time. * @return bool Whether at least one message was accepted; normal skips return false. * @throws \Throwable When an attempted send or its outcome storage fails. */ public function maybe_send( $now = null ) { $now = null === $now ? time() : (int) $now; if ( ! $this->enabled() ) { return false; } $lock = OptionLock::acquire( 'turtlereport_brief_lock', self::LOCK_TTL ); if ( null === $lock ) { return false; } $claimed = false; try { wp_cache_delete( self::SENT, 'options' ); $sent = (int) get_option( self::SENT, 0 ); if ( ! $sent ) { $this->start( $now ); return false; } if ( $sent >= $this->last_slot( $now ) ) { return false; } // Claim before mail: an interrupted attempt must not trigger duplicate mail. if ( ! update_option( self::SENT, $now, false ) ) { return false; } $claimed = true; $this->record_outcome( 'attempted', $now ); $result = $this->send( $this->recipients() ); $sent = $result['sent']; $failed = count( $result['failed'] ); $this->record_outcome( $failed ? ( $sent ? 'partial' : 'rejected' ) : 'accepted', $now, $sent, $failed ); return $result['sent'] > 0; } catch ( \Throwable $e ) { if ( $claimed ) { // Some mail may already have been accepted. Preserve the claim; never retry blindly. update_option( self::OUTCOME, array( 'time' => $now, 'status' => 'unknown', 'accepted' => null, 'rejected' => null, ), false ); } throw $e; } finally { $lock->release(); } } /** * Whether the last scheduled attempt stopped before recording its outcome * (a fatal error, timeout or killed process). Some mail may have gone out. * * @param int|null $now Unix time. * @return bool */ public function interrupted( $now = null ) { $now = null === $now ? time() : (int) $now; $outcome = $this->last_outcome(); return 'attempted' === $outcome['status'] && $outcome['time'] < $now - self::LOCK_TTL; } /** * Last attempt, preserved through ordinary disabled/not-due/busy skips. * * @return array{time:int,status:string,accepted:int|null,rejected:int|null} */ public function last_outcome() { $value = get_option( self::OUTCOME, array() ); $value = is_array( $value ) ? $value : array(); return array( 'time' => isset( $value['time'] ) ? max( 0, (int) $value['time'] ) : 0, 'status' => isset( $value['status'] ) && in_array( $value['status'], array( 'attempted', 'accepted', 'rejected', 'partial', 'unknown' ), true ) ? $value['status'] : '', 'accepted' => isset( $value['accepted'] ) ? max( 0, (int) $value['accepted'] ) : null, 'rejected' => isset( $value['rejected'] ) ? max( 0, (int) $value['rejected'] ) : null, ); } /** * Persists only safe state and counts before/after an external side effect. * * @param string $status Attempt state. * @param int $now Attempt start. * @param int|null $accepted Accepted count when known. * @param int|null $rejected Rejected count when known. * @return void * @throws \RuntimeException When the outcome cannot be persisted. */ private function record_outcome( $status, $now, $accepted = null, $rejected = null ) { $value = array( 'time' => $now, 'status' => $status, 'accepted' => $accepted, 'rejected' => $rejected, ); if ( ! update_option( self::OUTCOME, $value, false ) && get_option( self::OUTCOME ) !== $value ) { throw new \RuntimeException( 'Weekly brief outcome storage failed.' ); } } /** * What the brief says, grouped so it reads in half a minute: the verdict, * then each client that needs attention with its most important issues, * then the clients with nothing open. * * @return array{total:int,attention:int,issues:int,top:array[],groups:array[],quiet:string[],unavailable:string[],period:array,checked:int,monitor_url:string,lead:string} */ public function build() { $all = array_values( $this->sites->all() ); $payload = $this->monitor->payload( $all ); $active = array_values( array_filter( $payload['alerts'], static function ( $a ) { return AlertStore::ACTIVE === $a['state']; } ) ); usort( $active, array( __CLASS__, 'compare' ) ); $open = array(); foreach ( $active as $a ) { $open[ $a['site'] ] = isset( $open[ $a['site'] ] ) ? $open[ $a['site'] ] + 1 : 1; } // The most important issue of each client first, so three clients aren't crowded out by one. $top = array(); $seen = array(); foreach ( array( true, false ) as $first_only ) { foreach ( $active as $a ) { if ( count( $top ) >= self::TOP ) { break 2; } $key = $a['site'] . '|' . $a['id']; if ( isset( $seen[ $key ] ) || ( $first_only && isset( $seen[ $a['site'] ] ) ) ) { continue; } $seen[ $key ] = true; $seen[ $a['site'] ] = true; $top[] = array( 'site' => (string) $a['site'], 'client' => (string) $a['siteName'], 'title' => (string) $a['title'], 'summary' => self::short_summary( (string) $a['summary'] ), 'basis' => (string) $a['basis'], 'severity' => 'high' === $a['severity'] ? 'high' : 'medium', 'priority' => ! empty( $a['priority']['level'] ) ? (string) $a['priority']['label'] : '', 'action' => ! empty( $a['next'] ) ? (string) $a['next'] : ( ! empty( $a['checks'] ) ? (string) $a['checks'][0] : '' ), 'url' => (string) $a['reportUrl'], ); } } // The same items, one block per client, in the order they first appear. $groups = array(); foreach ( $top as $t ) { if ( ! isset( $groups[ $t['site'] ] ) ) { $groups[ $t['site'] ] = array( 'client' => $t['client'], 'url' => $t['url'], 'open' => $open[ $t['site'] ], 'items' => array(), ); } $groups[ $t['site'] ]['items'][] = $t; } foreach ( $groups as $id => $g ) { $groups[ $id ]['more'] = max( 0, $g['open'] - count( $g['items'] ) ); } $quiet = array(); $unavailable = array(); $records = array_column( $payload['sites'], null, 'id' ); foreach ( $all as $site ) { if ( ! Monitor::checkable( $site ) ) { continue; } $record = isset( $records[ $site->id ] ) ? $records[ $site->id ] : array( 'checked' => 0, 'errors' => array(), ); if ( ! $record['checked'] || $record['errors'] ) { $unavailable[] = $site->display_name(); continue; } if ( ! isset( $open[ $site->id ] ) ) { $quiet[] = $site->display_name(); } } natcasesort( $quiet ); natcasesort( $unavailable ); return array( 'total' => count( $all ), 'attention' => count( $open ), 'issues' => count( $active ), 'top' => $top, 'groups' => array_values( $groups ), 'quiet' => array_values( $quiet ), 'unavailable' => array_values( $unavailable ), 'period' => Periods::last_days( 28 ), 'checked' => (int) $payload['checked'], 'monitor_url' => AdminLinks::url( AdminLinks::OVERVIEW ), 'lead' => $top ? $top[0]['client'] : '', ); } /** * An issue's numbers; its actual measurement period is shown separately. * * @param string $summary Summary. * @return string */ public static function short_summary( $summary ) { $tail = __( ', last 28 days vs the 28 days before.', 'turtlereport' ); $s = trim( (string) $summary ); if ( '' !== $tail && substr( $s, -strlen( $tail ) ) === $tail ) { $s = substr( $s, 0, -strlen( $tail ) ) . '.'; } return $s; } /** * A short date range, e.g. "17 Sep – 14 Oct". * * @param string $s Start (Y-m-d). * @param string $e End (Y-m-d). * @return string */ public static function short_range( $s, $e ) { $utc = new \DateTimeZone( 'UTC' ); $day = static function ( $ymd ) use ( $utc ) { return wp_date( 'j M', (int) strtotime( $ymd . ' 12:00:00 UTC' ), $utc ); }; return $day( $s ) . ' – ' . $day( $e ); } /** * The one-line verdict at the top of the brief and in the inbox preview. * * @param array $data Result of build(). * @return string */ public static function verdict( array $data ) { if ( ! $data['attention'] ) { if ( ! empty( $data['unavailable'] ) ) { return __( 'Some client checks are incomplete. Review the Client Monitor before drawing conclusions.', 'turtlereport' ); } /* translators: %d: number of checked clients. */ return sprintf( _n( 'No active issues recorded for %d client.', 'No active issues recorded for %d clients.', $data['total'], 'turtlereport' ), $data['total'] ); } return sprintf( /* translators: 1: clients that need attention, 2: all clients, 3: client to start with. */ _n( '%1$d of %2$d clients needs attention. Start with %3$s.', '%1$d of %2$d clients need attention. Start with %3$s.', $data['attention'], 'turtlereport' ), $data['attention'], $data['total'], $data['lead'] ); } /** * Most important first: the client's own priorities, then business impact, then severity. * * @param array $a Presented alert. * @param array $b Presented alert. * @return int */ public static function compare( array $a, array $b ) { $la = isset( $a['priority']['level'] ) ? (int) $a['priority']['level'] : 0; $lb = isset( $b['priority']['level'] ) ? (int) $b['priority']['level'] : 0; $ia = isset( self::IMPACT[ $a['type'] ] ) ? self::IMPACT[ $a['type'] ] : 9; $ib = isset( self::IMPACT[ $b['type'] ] ) ? self::IMPACT[ $b['type'] ] : 9; foreach ( array( $lb <=> $la, $ia <=> $ib, ( 'high' === $b['severity'] ) <=> ( 'high' === $a['severity'] ) ) as $order ) { if ( 0 !== $order ) { return $order; } } return strnatcasecmp( (string) $a['siteName'], (string) $b['siteName'] ); } /** * The email. * * @param array $data Result of build(). * @param bool $test Test send. * @return array Message for ReportMailer::deliver(). */ public function compose( array $data, $test = false ) { $args = array( 'data' => $data, 'test' => (bool) $test, 'brand' => $this->settings->brand_name(), 'color' => $this->settings->brand_color(), 'logo' => $this->settings->logo_url(), 'footer' => (string) $this->settings->get( 'footer_text' ), 'credit' => $this->settings->show_credit(), 'week' => wp_date( 'j M Y' ), ); ob_start(); View::render( 'email/brief', $args ); $html = (string) ob_get_clean(); $subject = ( $test ? '[' . __( 'Test', 'turtlereport' ) . '] ' : '' ) . ( $data['attention'] /* translators: 1: number of clients, 2: client to start with. */ ? sprintf( _n( 'Weekly summary: %1$d client needs attention · start with %2$s', 'Weekly summary: %1$d clients need attention · start with %2$s', $data['attention'], 'turtlereport' ), $data['attention'], $data['lead'] ) : ( ! empty( $data['unavailable'] ) ? __( 'Weekly summary: client checks need attention', 'turtlereport' ) /* translators: %d: number of clients. */ : sprintf( _n( 'Weekly summary: no active issues for %d client', 'Weekly summary: no active issues for %d clients', $data['total'], 'turtlereport' ), $data['total'] ) ) ); $headers = array( 'Content-Type: text/html; charset=UTF-8' ); $reply_to = (string) $this->settings->get( 'email_reply_to' ); if ( is_email( $reply_to ) ) { $headers[] = 'Reply-To: ' . $reply_to; } return array( 'subject' => $subject, 'html' => $html, 'text' => self::plain( $data ), 'headers' => $headers, 'attachments' => array(), 'tmp' => '', ); } /** * Builds and sends it. * * @param string[] $to Addresses. * @param bool $test Test send. * @return array{sent:int,failed:string[],errors:array} */ public function send( array $to, $test = false ) { $message = $this->compose( $this->build(), $test ); $sent = 0; $failed = array(); $errors = array(); foreach ( $to as $address ) { $ok = $this->mailer->deliver( $message, (string) $address ); if ( true === $ok ) { ++$sent; } else { $failed[] = (string) $address; $errors[ (string) $address ] = $ok->get_error_message(); } } return array( 'sent' => $sent, 'failed' => $failed, 'errors' => $errors, ); } /** * Plain-text version. * * @param array $data Result of build(). * @return string */ public static function plain( array $data ) { $lines = array( self::verdict( $data ), __( 'From the latest Client Monitor checks', 'turtlereport' ), ); foreach ( $data['groups'] as $g ) { $lines[] = ''; /* translators: %d: number of open issues. */ $lines[] = strtoupper( $g['client'] ) . ' · ' . sprintf( _n( '%d open issue', '%d open issues', $g['open'], 'turtlereport' ), $g['open'] ); foreach ( $g['items'] as $t ) { $lines[] = '- ' . ( 'high' === $t['severity'] ? __( 'High', 'turtlereport' ) : __( 'Medium', 'turtlereport' ) ) . ': ' . $t['title'] . ( '' !== $t['priority'] ? ' [' . $t['priority'] . ']' : '' ); $lines[] = ' ' . $t['summary']; if ( ! empty( $t['basis'] ) ) { $lines[] = ' ' . $t['basis']; } if ( '' !== $t['action'] ) { /* translators: %s: recommended next step. */ $lines[] = ' ' . sprintf( __( 'Next step: %s', 'turtlereport' ), $t['action'] ); } } $lines[] = ' ' . $g['url']; } if ( $data['quiet'] ) { $lines[] = ''; /* translators: %s: client names. */ $lines[] = sprintf( __( 'No active issues: %s', 'turtlereport' ), implode( ', ', $data['quiet'] ) ); } if ( ! empty( $data['unavailable'] ) ) { /* translators: %s: names of clients whose checks failed or have not run. */ $lines[] = sprintf( __( 'Checks incomplete: %s. Earlier issues may still be listed.', 'turtlereport' ), implode( ', ', $data['unavailable'] ) ); } $lines[] = ''; /* translators: %s: Client Monitor address. */ $lines[] = sprintf( __( 'Open the Client Monitor: %s', 'turtlereport' ), $data['monitor_url'] ); return implode( "\n", $lines ); } }