[ '#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 .= '| '; $out .= ' |
| ' . esc_html( (string) $col ) . ' | '; } $out .= '
|---|
| ' . wp_kses_post( (string) $cell ) . ' | '; } $out .= '
' . esc_html( isset( $block['empty'] ) ? $block['empty'] : __( 'Nothing to report.', 'gemsecurity' ) ) . '
'; } $out .= '' . 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 '