settings = $settings; $this->builder = $builder ? $builder : new ReportBuilder( $summary, $settings ); } /** * The report builder. * * @return ReportBuilder */ public function builder() { return $this->builder; } /** * Builds and sends a site's report for its current period, outside the * delivery history; also used when the delivery tables are unavailable. * * @param Profile $site Site. * @param string[] $recipients Email addresses. * @param bool $test Whether this is a test send. * @return true|\WP_Error True only when every recipient's email was accepted. */ public function send( Profile $site, array $recipients, $test = false ) { $recipients = array_values( array_filter( $recipients, 'is_email' ) ); if ( ! $recipients ) { return new \WP_Error( 'turtlereport_no_recipients', __( 'No valid recipients.', 'turtlereport' ) ); } $period = Schedule::period( $site->email ); $prep = $this->builder->prepare( $site, $period ); $run = self::virtual_run( $site, $period, $prep ); $message = $this->compose( $site, $run, $test ); $failed = array(); try { foreach ( $recipients as $to ) { $ok = $this->deliver( $message, $to ); if ( is_wp_error( $ok ) ) { $failed[ $to ] = $ok->get_error_message(); } } } finally { $this->cleanup( $message ); } return self::result( count( $recipients ), $failed ); } /** * True when nothing failed, otherwise an error naming how many failed. * * @param int $total Recipients. * @param array $failed Address => error. * @return true|\WP_Error */ public static function result( $total, array $failed ) { if ( ! $failed ) { return true; } $first = reset( $failed ); if ( count( $failed ) === (int) $total ) { return new \WP_Error( 'turtlereport_mail_failed', $first, array( 'failed' => $failed ) ); } return new \WP_Error( 'turtlereport_mail_partial', sprintf( /* translators: 1: failed count, 2: total, 3: error message. */ __( '%1$d of %2$d emails failed: %3$s', 'turtlereport' ), count( $failed ), (int) $total, $first ), array( 'failed' => $failed ) ); } /** * A run-shaped array for a report built on the fly (not stored). * * @param Profile $site Site. * @param array $period Period. * @param array $prep Result of ReportBuilder::prepare(). * @return array */ public static function virtual_run( Profile $site, array $period, array $prep ) { return array( 'id' => 0, 'site_id' => $site->id, 'kind' => RunRepository::KIND_TEST, 'period_start' => $period['s'], 'period_end' => $period['e'], 'compare_start' => $period['ps'], 'compare_end' => $period['pe'], 'status' => RunRepository::PREPARING, 'readiness' => $prep['readiness']['level'], 'checks' => $prep['readiness'], 'insights' => $prep['insights'], 'notes' => '', 'snapshot' => array( 'data' => $prep['data'], 'full' => $prep['full'], ), 'meta' => array( 'frequency' => $site->email['frequency'], 'compare' => $site->email['compare'], ), 'reviewed_at' => 0, ); } /** * Builds the message for a run once, ready to send to each recipient. * * @param Profile $site Site. * @param array $run Run with snapshot. * @param bool $test Test send. * @return array{subject:string,html:string,text:string,headers:string[],attachments:string[],tmp:string,pdf_error:string,share_url:string} * @throws \Throwable After cleaning attachments if composition fails. */ public function compose( Profile $site, array $run, $test = false ) { $ctx = $this->builder->context( $site, $run, $test ); $attachments = array(); $tmp = ''; $pdf_error = ''; try { if ( ! empty( $site->delivery['attach_pdf'] ) ) { $pdf = $this->builder->pdf( $site, $run ); if ( is_wp_error( $pdf ) ) { $pdf_error = $pdf->get_error_message(); } elseif ( strlen( $pdf ) > self::MAX_ATTACHMENT ) { $pdf_error = __( 'The PDF was too large to attach.', 'turtlereport' ); } else { $tmp = self::temp_dir(); $file = '' !== $tmp ? $tmp . '/' . ReportBuilder::pdf_name( $site, $run ) : ''; if ( '' !== $file && self::write_attachment( $file, $pdf ) ) { $attachments[] = $file; } else { $pdf_error = __( 'The PDF couldn\'t be saved for attaching (the server\'s temporary folder isn\'t writable).', 'turtlereport' ); } } } $share_url = ! empty( $run['id'] ) ? ShareLinks::url( $site, (int) $run['id'] ) : ''; $share_expires = '' !== $share_url ? ShareLinks::expiry( $site ) : 0; $args = array_merge( $ctx, array( 'color' => $ctx['brand']['color'], 'logo' => $ctx['brand']['logo'], 'client_logo' => $ctx['brand']['client_logo'], 'footer' => $ctx['brand']['footer'], 'credit' => $ctx['brand']['credit'], 'brand' => $ctx['brand']['name'], 'url' => $ctx['report_url'], 'pdf_attached' => (bool) $attachments, 'pdf_error' => $pdf_error, 'share_url' => $share_url, 'share_expires' => $share_expires, ) ); $html = $this->render( $args ); $text = $this->plain_text( $args ); $subject = ( $test ? '[' . __( 'Test', 'turtlereport' ) . '] ' : '' ) . $site->display_name() . ' · ' . $ctx['label']; $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' => $text, 'headers' => $headers, 'attachments' => $attachments, 'tmp' => $tmp, 'pdf_error' => $pdf_error, 'share_url' => $share_url, ); } catch ( \Throwable $e ) { $this->cleanup( array( 'attachments' => $attachments, 'tmp' => $tmp, ) ); throw $e; } } /** * Sends a composed message to one address. * * @param array $message Result of compose(). * @param string $to Address. * @return true|\WP_Error True when the mail system accepted it. */ public function deliver( array $message, $to ) { if ( ! is_email( $to ) ) { return new \WP_Error( 'turtlereport_bad_address', __( 'This isn\'t a valid email address.', 'turtlereport' ), array( 'permanent' => true ) ); } $from_name = (string) $this->settings->get( 'email_from_name' ); $from_name = '' !== $from_name ? $from_name : $this->settings->brand_name(); $text = $message['text']; $set_name = static function () use ( $from_name ) { return $from_name; }; $set_alt = static function ( $mailer ) use ( $text ) { $mailer->AltBody = $text; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer property. }; $error = null; $on_error = static function ( $wp_error ) use ( &$error ) { $error = $wp_error; }; add_filter( 'wp_mail_from_name', $set_name ); add_action( 'phpmailer_init', $set_alt ); add_action( 'wp_mail_failed', $on_error ); try { $ok = wp_mail( $to, $message['subject'], $message['html'], $message['headers'], $message['attachments'] ); } catch ( \Throwable $e ) { $ok = false; $error = new \WP_Error( 'turtlereport_mail_exception', $e->getMessage() ); } remove_filter( 'wp_mail_from_name', $set_name ); remove_action( 'phpmailer_init', $set_alt ); remove_action( 'wp_mail_failed', $on_error ); if ( $ok ) { return true; } return $error instanceof \WP_Error ? new \WP_Error( 'turtlereport_mail_failed', self::clean_error( $error->get_error_message() ) ) : new \WP_Error( 'turtlereport_mail_failed', __( 'WordPress couldn\'t send the email. Check your site\'s mail setup (an SMTP plugin is recommended).', 'turtlereport' ) ); } /** * Shortens a mail error and strips anything that looks like a password. * * @param string $message Error. * @return string */ private static function clean_error( $message ) { $message = (string) preg_replace( '/(pass(word)?|auth|token)[^\s]*\s*[:=]\s*\S+/i', '$1=***', wp_strip_all_tags( (string) $message ) ); $message = trim( $message ); return '' !== $message ? substr( $message, 0, 500 ) : __( 'The mail system refused the message.', 'turtlereport' ); } /** * Deletes a composed message's temporary files. * * @param array $message Result of compose(). * @return void */ public function cleanup( array $message ) { foreach ( $message['attachments'] as $file ) { if ( is_file( $file ) ) { wp_delete_file( $file ); } } if ( '' !== $message['tmp'] && is_dir( $message['tmp'] ) ) { @rmdir( $message['tmp'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Empty temporary folder this plugin created. } } /** * A private temporary folder, so the attachment keeps a readable file name. * * @return string Path, or '' when none can be made. */ private static function temp_dir() { $base = untrailingslashit( get_temp_dir() ); $dir = $base . '/turtlereport-' . wp_generate_password( 16, false, false ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Exclusive owned temp directory; failure becomes an attachment error. if ( ! @mkdir( $dir, 0700 ) ) { return ''; } clearstatcache( true, $dir ); if ( is_link( $dir ) || false === fileperms( $dir ) || 0700 !== ( fileperms( $dir ) & 0777 ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Only the directory just created; fail closed if privacy cannot be verified. @rmdir( $dir ); return ''; } return $dir; } /** * Writes a private attachment without overwriting an existing path. * * @param string $file Owned private path. * @param string $pdf PDF bytes. * @return bool */ private static function write_attachment( $file, $pdf ) { $mask = umask( 0077 ); try { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- Exclusive file inside the verified owner-only directory; failure is reported by compose(). $handle = @fopen( $file, 'x+b' ); } finally { umask( $mask ); } if ( false === $handle ) { return false; } $ok = false; try { $stat = fstat( $handle ); if ( ! $stat || 0600 !== ( $stat['mode'] & 0777 ) ) { return false; } $length = strlen( $pdf ); $offset = 0; while ( $offset < $length ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.PHP.NoSilencedErrors.Discouraged -- Private temporary stream; short/failing writes are rejected. $written = @fwrite( $handle, substr( $pdf, $offset ) ); if ( false === $written || 0 === $written ) { return false; } $offset += $written; } $ok = fflush( $handle ); return $ok; } finally { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Owned temporary stream. fclose( $handle ); if ( ! $ok ) { wp_delete_file( $file ); } } } /** * HTML body. * * @param array $args Template data. * @return string */ private function render( array $args ) { ob_start(); View::render( 'email/report', $args ); return (string) ob_get_clean(); } /** * Plain-text alternative for mail clients that don't show HTML. * * @param array $args Template data. * @return string */ private function plain_text( array $args ) { $site = $args['site']; $period = $args['period']; $data = $args['data']; $lines = array( $site->display_name() . ' — ' . $args['label'], Format::range( $period['s'], $period['e'] ), '' ); foreach ( $args['problems'] as $problem ) { if ( in_array( $problem['level'], array( 'blocked', 'warning' ), true ) ) { $lines[] = '! ' . $problem['message']; } } if ( $args['problems'] ) { $lines[] = ''; } $rows = Comparison::rows( (array) $data, $site ); if ( $rows ) { $overall = Comparison::overall( $rows ); $cols = Comparison::columns( $period ); /* translators: 1: overall change, e.g. "−12%", 2: comparison period, 3: improved count, 4: declined count. */ $lines[] = sprintf( __( 'Overall change: %1$s vs %2$s (%3$d improved, %4$d declined)', 'turtlereport' ), $overall['text'], $cols[1], $overall['better'], $overall['worse'] ); $lines[] = ''; $section = ''; foreach ( $rows as $row ) { if ( $row['section'] !== $section ) { $section = $row['section']; $lines[] = $section; } /* translators: 1: measure, 2: value now, 3: comparison period, 4: value then, 5: change. */ $lines[] = sprintf( __( '%1$s: %2$s (%3$s: %4$s, %5$s)', 'turtlereport' ), ( $row['sub'] ? ' ' : '' ) . $row['label'], $row['cur'], $cols[1], $row['prev'], $row['change']['text'] ); } } $groups = Insights::grouped( $args['insights'] ); foreach ( Insights::headings() as $kind => $heading ) { if ( $groups[ $kind ] ) { $lines[] = ''; $lines[] = $heading; foreach ( $groups[ $kind ] as $line ) { $lines[] = '- ' . $line; } } } foreach ( array( $args['site_note'], $args['notes'] ) as $note ) { if ( '' !== $note ) { $lines[] = ''; $lines[] = $note; } } $lines[] = ''; if ( '' !== $args['share_url'] ) { /* translators: %s: URL of the PDF. */ $lines[] = sprintf( __( 'Download the PDF: %s', 'turtlereport' ), $args['share_url'] ); } if ( $args['pdf_attached'] ) { $lines[] = __( 'The full report is attached as a two-page PDF.', 'turtlereport' ); } return implode( "\n", $lines ); } /** * Title for the period an email covers: "Weekly report", "Last 2 weeks", * "September 2026" or "Q3 2026". Kept for extensions; reports now use * ReportBuilder::label(), which names the exact dates. * * @param string $frequency Interval. * @param array $period Period. * @return string */ public static function label( $frequency, array $period ) { $start = strtotime( $period['s'] . ' 12:00:00 UTC' ); switch ( $frequency ) { case Schedule::WEEKLY: return __( 'Weekly report', 'turtlereport' ); case Schedule::BIWEEKLY: return __( 'Last 2 weeks', 'turtlereport' ); case Schedule::QUARTERLY: /* translators: 1: quarter number (1-4), 2: year. */ return sprintf( __( 'Q%1$d %2$s', 'turtlereport' ), (int) ceil( (int) gmdate( 'n', $start ) / 3 ), gmdate( 'Y', $start ) ); default: return wp_date( 'F Y', $start, new \DateTimeZone( 'UTC' ) ); } } }