[ '#fdf2f2', '#f5c2c2', '#a01919' ], 'warning' => [ '#fdf6ec', '#f3d9ab', '#8a5a00' ], 'success' => [ '#f0faf4', '#b7e4c7', '#1b6b3a' ], 'info' => [ '#f1f5fd', '#c9d8f5', '#1f3f86' ], ]; /** * The site's display name, decoded, falling back to the host. * * @return string */ public static function site_name() { $name = trim( wp_specialchars_decode( (string) get_option( 'blogname' ), ENT_QUOTES ) ); return '' !== $name ? $name : self::site_host(); } /** The site's host, e.g. "shop.example.com". */ public static function site_host() { $host = wp_parse_url( home_url(), PHP_URL_HOST ); return $host ? (string) $host : 'wordpress'; } /** * Build a subject line: emoji, then an identity nobody can confuse with * another install, then the message itself. * * @param string $emoji * @param string $text * @return string */ public static function subject( $emoji, $text ) { $name = self::site_name(); $host = self::site_host(); // Don't print the host twice when the site is named after its domain. $identity = ( strcasecmp( $name, $host ) === 0 ) ? $host : $name . ' ยท ' . $host; /** * Filter the identity shown in every GemSecurity email subject. * * @param string $identity * @param string $name * @param string $host */ $identity = apply_filters( 'gemsecurity/email_identity', $identity, $name, $host ); $emoji = trim( (string) $emoji ); $parts = []; if ( '' !== $emoji ) { $parts[] = $emoji; } $parts[] = '[' . $identity . ']'; $parts[] = $text; return implode( ' ', $parts ); } /** * The one address GemSecurity notifies. * * Every alert, report and failure notice goes here. There used to be four * separate recipient settings plus a handful of places that hardcoded the * site admin address, so changing "where security email goes" meant finding * and changing all of them. * * @return string */ public static function recipient() { $to = trim( (string) Helper::get_setting( 'alert_to_email', '' ) ); if ( '' === $to || ! is_email( $to ) ) { $to = (string) get_option( 'admin_email' ); } /** * Filter the security notification recipient. * * @param string $to */ return (string) apply_filters( 'gemsecurity/notification_recipient', $to ); } /** * Compose and send a message. * * @param string $to Recipient. Empty or invalid falls back to * {@see recipient()}. * @param string $emoji Leading emoji for the subject. * @param string $title Subject text and the heading inside the email. * @param array $blocks Ordered content blocks (see render_block()). * @param array $args intro (string), footer (string). * @return bool */ public static function send( $to, $emoji, $title, array $blocks, array $args = [] ) { $to = trim( (string) $to ); if ( '' === $to || ! is_email( $to ) ) { $to = self::recipient(); } $origin = self::origin_block( $args ); if ( $origin ) { $blocks[] = $origin; } $subject = self::subject( $emoji, $title ); $html = self::render_html( $emoji, $title, $blocks, $args ); $text = self::render_text( $title, $blocks, $args ); $html_type = static function () { return 'text/html'; }; // Give clients that refuse HTML the same content as readable text. $alt_body = static function ( $phpmailer ) use ( $text ) { $phpmailer->AltBody = $text; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase }; add_filter( 'wp_mail_content_type', $html_type ); add_action( 'phpmailer_init', $alt_body ); $sent = wp_mail( $to, $subject, $html ); remove_action( 'phpmailer_init', $alt_body ); remove_filter( 'wp_mail_content_type', $html_type ); return (bool) $sent; } /** * "Where did this come from" table for the request that triggered the * email: its IP address, plus whatever the filter adds (GemSecurity Pro * adds the country). Left out for cron and command-line sends, where the * address would be the server's own, and when a caller passes * 'origin' => false. * * @param array $args Arguments given to send(). * @return array|null Table block, or null. */ private static function origin_block( array $args ) { if ( isset( $args['origin'] ) && false === $args['origin'] ) { return null; } if ( wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { return null; } $ip = Helper::get_ip_address(); if ( ! $ip || '0.0.0.0' === $ip ) { return null; } $rows = [ [ esc_html__( 'IP address', 'gemsecurity' ), esc_html( $ip ) ] ]; /** * Filter the rows of the "Request origin" table in alert emails. * * @param array $rows Each row is [ label, value ] (already escaped). * @param string $ip Requester's IP address. */ $rows = (array) apply_filters( 'gemsecurity/email_origin', $rows, $ip ); return [ 'type' => 'table', 'heading' => __( 'Request origin', 'gemsecurity' ), 'rows' => $rows, ]; } /* ------------------------------------------------------------------ */ /* Rendering */ /* ------------------------------------------------------------------ */ /** * @param string $emoji * @param string $title * @param array $blocks * @param array $args * @return string */ private static function render_html( $emoji, $title, array $blocks, array $args ) { $name = esc_html( self::site_name() ); $host = esc_html( self::site_host() ); $intro = isset( $args['intro'] ) ? $args['intro'] : ''; $footer = isset( $args['footer'] ) ? $args['footer'] : sprintf( /* translators: %s: site name. */ __( 'Sent by GemSecurity on %s. You are receiving this because you are listed as a security contact.', 'gemsecurity' ), self::site_name() ); $body = ''; foreach ( $blocks as $block ) { $body .= self::render_block( $block ); } // Shown in the inbox preview line, after the subject. $preheader = $intro ? wp_strip_all_tags( $intro ) : wp_strip_all_tags( $title ); $out = ''; $out .= ''; $out .= '' . esc_html( $title ) . ''; $out .= ''; $out .= '
' . esc_html( $preheader ) . '
'; $out .= ''; $out .= '
'; $out .= ''; // Header: which site this is about, stated before anything else. $out .= ''; // Title. $out .= ''; $out .= ''; $out .= ''; $out .= '
'; $out .= '
' . $name . '
'; $out .= '
' . $host . '
'; $out .= '
'; $out .= '

'; $out .= ( $emoji ? esc_html( $emoji ) . ' ' : '' ) . esc_html( $title ); $out .= '

'; if ( $intro ) { $out .= '

' . wp_kses_post( $intro ) . '

'; } $out .= '
' . $body . '
'; $out .= '

' . wp_kses_post( $footer ) . '

'; $out .= '

' . $host . '

'; $out .= '
'; return $out; } /** * One content block. * * Supported types: * - paragraph { text } * - notice { tone, text } * - stats { items: [ label => value ] } * - table { heading, columns[], rows[][] } * - list { heading, items[], empty } * - button { url, label } * * @param array $block * @return string */ private static function render_block( array $block ) { $type = isset( $block['type'] ) ? $block['type'] : 'paragraph'; switch ( $type ) { case 'notice': $tone = isset( $block['tone'], self::TONES[ $block['tone'] ] ) ? $block['tone'] : 'info'; list( $bg, $border, $color ) = self::TONES[ $tone ]; return '
' . wp_kses_post( $block['text'] ) . '
'; case 'stats': $cells = ''; foreach ( (array) $block['items'] as $label => $value ) { $cells .= '' . '
' . esc_html( (string) $value ) . '
' . '
' . esc_html( (string) $label ) . '
' . ''; } return '' . $cells . '
'; case 'table': $out = self::heading( $block ); $out .= ''; if ( ! empty( $block['columns'] ) ) { $out .= ''; foreach ( $block['columns'] as $col ) { $out .= ''; } $out .= ''; } foreach ( (array) $block['rows'] as $row ) { $out .= ''; foreach ( (array) $row as $cell ) { $out .= ''; } $out .= ''; } $out .= '
' . esc_html( (string) $col ) . '
' . wp_kses_post( (string) $cell ) . '
'; return $out; case 'list': $out = self::heading( $block ); $items = (array) $block['items']; if ( ! $items ) { return $out . '

' . esc_html( isset( $block['empty'] ) ? $block['empty'] : __( 'Nothing to report.', 'gemsecurity' ) ) . '

'; } $out .= ''; case 'button': return '

' . esc_html( $block['label'] ) . '

'; case 'paragraph': default: return '

' . wp_kses_post( $block['text'] ) . '

'; } } /** Section heading shared by table/list blocks. */ private static function heading( array $block ) { if ( empty( $block['heading'] ) ) { return ''; } return '

' . esc_html( $block['heading'] ) . '

'; } /** * Plain-text alternative built from the same blocks, so the fallback says * the same thing rather than being an empty or stripped-tag mess. * * @param string $title * @param array $blocks * @param array $args * @return string */ private static function render_text( $title, array $blocks, array $args ) { $lines = [ self::site_name() . ' (' . self::site_host() . ')', str_repeat( '=', 48 ), $title, ]; if ( ! empty( $args['intro'] ) ) { $lines[] = ''; $lines[] = wp_strip_all_tags( $args['intro'] ); } foreach ( $blocks as $block ) { $type = isset( $block['type'] ) ? $block['type'] : 'paragraph'; $lines[] = ''; switch ( $type ) { case 'stats': foreach ( (array) $block['items'] as $label => $value ) { $lines[] = sprintf( '%-24s %s', $label . ':', $value ); } break; case 'table': if ( ! empty( $block['heading'] ) ) { $lines[] = strtoupper( $block['heading'] ); } foreach ( (array) $block['rows'] as $row ) { $lines[] = '- ' . implode( ' | ', array_map( 'wp_strip_all_tags', (array) $row ) ); } break; case 'list': if ( ! empty( $block['heading'] ) ) { $lines[] = strtoupper( $block['heading'] ); } $items = (array) $block['items']; if ( ! $items ) { $lines[] = isset( $block['empty'] ) ? $block['empty'] : __( 'Nothing to report.', 'gemsecurity' ); break; } foreach ( $items as $item ) { $lines[] = '- ' . wp_strip_all_tags( (string) $item ); } break; case 'button': $lines[] = $block['label'] . ': ' . $block['url']; break; default: $lines[] = wp_strip_all_tags( $block['text'] ); } } $lines[] = ''; $lines[] = str_repeat( '-', 48 ); $lines[] = home_url( '/' ); return implode( "\n", $lines ); } }