diff --git a/collectors/emails.php b/collectors/emails.php new file mode 100644 index 00000000..8dd109e5 --- /dev/null +++ b/collectors/emails.php @@ -0,0 +1,202 @@ + + */ +class QM_Collector_Emails extends QM_DataCollector { + + public $id = 'emails'; + + public function get_storage(): QM_Data { + return new QM_Data_Emails(); + } + + /** + * @return void + */ + public function set_up() { + parent::set_up(); + add_filter( 'wp_mail', array( $this, 'filter_wp_mail' ), 9999 ); + add_filter( 'pre_wp_mail', array( $this, 'filter_pre_wp_mail' ), 9999, 2 ); + add_action( 'wp_mail_failed', array( $this, 'action_wp_mail_failed' ) ); + } + + /** + * @return void + */ + public function tear_down() { + remove_filter( 'wp_mail', array( $this, 'filter_wp_mail' ), 9999 ); + remove_filter( 'pre_wp_mail', array( $this, 'filter_pre_wp_mail' ), 9999 ); + remove_action( 'wp_mail_failed', array( $this, 'action_wp_mail_failed' ) ); + + parent::tear_down(); + } + + /** + * @return array + */ + public function get_concerned_actions() { + return array( + 'phpmailer_init', + 'wp_mail_succeeded', + 'wp_mail_failed', + ); + } + + /** + * @return array + */ + public function get_concerned_filters() { + return array( + 'pre_wp_mail', + 'wp_mail', + 'wp_mail_from', + 'wp_mail_from_name', + 'wp_mail_content_type', + 'wp_mail_charset', + ); + } + + /** + * Other attributes of wp_mail() are changed, + * so use the attributes that aren't changed + * to generate identifying hash. + * + * @param array> $atts + * @return string + */ + protected function hash( $atts ) { + $to = $atts['to']; + + if ( ! is_array( $to ) ) { + $to = explode( ',', $to ); + $to = array_map( 'trim', $to ); + } + + $data = array( + 'to' => $to, + 'subject' => $atts['subject'], + 'message' => $atts['message'], + ); + + $datastring = json_encode( $data ); + + if ( ! is_string( $datastring ) ) { + $datastring = print_r( $data, true ); + } + + return wp_hash( $datastring ); + } + + /** + * @param array> $atts + * @return array> + */ + public function filter_wp_mail( $atts ) { + $atts = wp_parse_args( $atts, array( + 'to' => array(), + 'subject' => '', + 'message' => '', + 'headers' => array(), + 'attachments' => array(), + ) ); + + if ( ! is_array( $atts['to'] ) ) { + $atts['to'] = explode( ',', $atts['to'] ); + } + + if ( ! is_array( $atts['attachments'] ) ) { + $atts['attachments'] = explode( "\n", str_replace( "\r\n", "\n", $atts['attachments'] ) ); + } + + $hash = $this->hash( $atts ); + $trace = new QM_Backtrace( array( + 'ignore_hook' => array( + current_filter() => true, + ), + ) ); + + $this->data->emails[ $hash ] = array( + 'atts' => $atts, + 'error' => null, + 'filtered_trace' => $trace->get_filtered_trace(), + ); + + return $atts; + } + + /** + * @param null|bool $preempt + * @param array> $atts + * @return null|bool + */ + public function filter_pre_wp_mail( $preempt, $atts ) { + if ( is_null( $preempt ) ) { + return null; + } + + $hash = $this->hash( $atts ); + + $this->data->preempted[] = $hash; + $this->data->emails[ $hash ]['error'] = new WP_Error( 'pre_wp_mail', 'Preempted sending email.' ); + + return $preempt; + } + + /** + * @param WP_Error $error + * @return void + */ + public function action_wp_mail_failed( $error ) { + $atts = $error->get_error_data( 'wp_mail_failed' ); + $hash = $this->hash( $atts ); + + $this->data->failed[] = $hash; + $this->data->emails[ $hash ]['error'] = $error; + } + + /** + * @return void + */ + public function process() { + $this->data->counts = array( + 'preempted' => 0, + 'failed' => 0, + 'succeeded' => 0, + 'total' => 0, + ); + + if ( ! is_array( $this->data->preempted ) ) { + $this->data->preempted = array(); + } + + if ( ! is_array( $this->data->failed ) ) { + $this->data->failed = array(); + } + + foreach ( $this->data->emails as $hash => $email ) { + $this->data->counts['total']++; + + if ( in_array( $hash, $this->data->preempted ) ) { + $this->data->counts['preempted']++; + } else if ( in_array( $hash, $this->data->failed ) ) { + $this->data->counts['failed']++; + } else { + $this->data->counts['succeeded']++; + } + } + } + +} + +# Load early to catch early emails +QM_Collectors::add( new QM_Collector_Emails() ); diff --git a/data/emails.php b/data/emails.php new file mode 100644 index 00000000..33076578 --- /dev/null +++ b/data/emails.php @@ -0,0 +1,28 @@ +> + */ + public $emails; + + /** + * @var array + */ + public $preempted; + + /** + * @var array + */ + public $failed; + + /** + * @var array + */ + public $counts; +} diff --git a/output/html/emails.php b/output/html/emails.php new file mode 100644 index 00000000..ef81cc58 --- /dev/null +++ b/output/html/emails.php @@ -0,0 +1,256 @@ + + */ + public function get_type_labels() { + return array( + /* translators: %s: Total number of emails */ + 'total' => _x( 'Total: %s', 'Emails', 'query-monitor' ), + 'plural' => __( 'Emails', 'query-monitor' ), + /* translators: %s: Total number of emails */ + 'count' => _x( 'Emails (%s)', 'Emails', 'query-monitor' ), + ); + } + + /** + * @return void + */ + public function output() { + /** @var QM_Data_Emails $data */ + $data = $this->collector->get_data(); + + if ( empty( $data->emails ) ) { + $this->before_non_tabular_output(); + + $notice = __( 'No emails.', 'query-monitor' ); + echo $this->build_notice( $notice ); // WPCS: XSS ok. + + $this->after_non_tabular_output(); + + return; + } + + $this->before_tabular_output(); + + echo ''; + echo ''; + echo '' . esc_html__( 'To', 'query-monitor' ) . ''; + echo '' . esc_html__( 'Subject', 'query-monitor' ) . ''; + echo '' . esc_html__( 'Caller', 'query-monitor' ) . ''; + echo '' . esc_html__( 'Headers', 'query-monitor' ) . ''; + echo '' . esc_html__( 'Attachments', 'query-monitor' ) . ''; + echo ''; + echo ''; + + echo ''; + + foreach ( $data->emails as $hash => $row ) { + $is_error = false; + $stack = array(); + $css = ''; + $to = $row['atts']['to']; + + if ( is_array( $to ) ) { + $to = implode( PHP_EOL, $to ); + } + + $filtered_trace = $row['filtered_trace']; + + foreach ( $filtered_trace as $frame ) { + $stack[] = self::output_filename( $frame['display'], $frame['calling_file'], $frame['calling_line'] ); + } + + $is_error = in_array( $hash, $data->preempted ) || in_array( $hash, $data->failed ); + + if ( $is_error ) { + $css = 'qm-warn'; + } + + if ( ! is_array( $row['atts']['headers'] ) ) { + $row['atts']['headers'] = array(); + } + + printf( // WPCS: XSS ok. + '', + esc_attr( $css ) + ); + + printf( + '%s', + nl2br( esc_html( $to ) ) + ); + + echo ''; + + echo esc_html( $row['atts']['subject'] ); + + if ( is_wp_error( $row['error'] ) ) { + $icon = QueryMonitor::icon( 'warning' ); + echo '
' . $icon; // WPCS: XSS ok. + echo esc_html( __( 'Failed sending:', 'query-monitor' ) . ' ' . $row['error']->get_error_message() ); + } + + echo ''; + + $caller = array_shift( $stack ); + + echo ''; + + if ( ! empty( $stack ) ) { + echo self::build_toggler(); // WPCS: XSS ok; + } + + echo '
    '; + + echo "
  1. {$caller}
  2. "; // WPCS: XSS ok. + + if ( ! empty( $stack ) ) { + echo '
  3. ' . implode( '
  4. ', $stack ) . '
  5. '; // WPCS: XSS ok. + } + + echo '
'; + + $is_empty = empty( $row['atts']['headers'] ); + + echo ''; + if ( $is_empty ) { + echo '—'; + } else { + self::output_inner( $row['atts']['headers'] ); + } + echo ''; + + $is_empty = empty( $row['atts']['attachments'] ); + + echo ''; + if ( $is_empty ) { + echo '—'; + } else { + self::output_inner( $row['atts']['attachments'] ); + } + echo ''; + + echo ''; + } + + echo ''; + + echo ''; + printf( + '%s %s %s', + sprintf( + /* translators: %s: Total number of emails */ + esc_html_x( 'Total: %s', 'Total emails', 'query-monitor' ), + '' . esc_html( number_format_i18n( $data->counts['total'] ) ) . '' + ), + sprintf( + /* translators: %s: Total number of emails preempted */ + esc_html_x( 'Preempted: %s', 'Preempted emails', 'query-monitor' ), + '' . esc_html( number_format_i18n( $data->counts['preempted'] ) ) . '' + ), + sprintf( + /* translators: %s: Total number of emails failed */ + esc_html_x( 'Failed: %s', 'Failed emails', 'query-monitor' ), + '' . esc_html( number_format_i18n( $data->counts['failed'] ) ) . '' + ) + ); + echo ''; + + $this->after_tabular_output(); + } + + /** + * @param array $class + * @return array + */ + public function admin_class( array $class ) { + /** @var QM_Data_Emails */ + $data = $this->collector->get_data(); + + if ( ! empty( $data->preempted ) || ! empty( $data->failed ) ) { + $class[] = 'qm-error'; + } + + return $class; + + } + + /** + * @param array $menu + * @return array + */ + public function admin_menu( array $menu ) { + /** @var QM_Data_Emails */ + $data = $this->collector->get_data(); + + $type_label = $this->get_type_labels(); + $label = sprintf( + $type_label['count'], + number_format_i18n( $data->counts['total'] ) + ); + + $args = array( + 'title' => esc_html( $label ), + 'id' => esc_attr( "query-monitor-{$this->collector->id}" ), + 'href' => esc_attr( '#' . $this->collector->id() ), + ); + + if ( ! empty( $data->preempted ) || ! empty( $data->failed ) ) { + $args['meta']['classname'] = 'qm-error'; + } + + $id = $this->collector->id(); + $menu[ $id ] = $this->menu( $args ); + + return $menu; + + } + +} + +/** + * @param array $output + * @param QM_Collectors $collectors + * @return array + */ +function register_qm_output_html_emails( array $output, QM_Collectors $collectors ) { + $collector = QM_Collectors::get( 'emails' ); + if ( $collector ) { + $output['emails'] = new QM_Output_Html_Emails( $collector ); + } + return $output; +} + +add_filter( 'qm/outputter/html', 'register_qm_output_html_emails', 110, 2 );