logger = $logger; } /** * Hooks. * * @return void */ public function init() { add_action( 'evlfl_event_logged', array( $this, 'on_event_logged' ), 10, 2 ); add_action( self::DIGEST_HOOK, array( $this, 'send_digest' ) ); add_action( self::OVERFLOW_HOOK, array( $this, 'send_digest' ) ); add_action( 'init', array( $this, 'sync_schedule' ), 20 ); } /** * Placeholders usable in the subject and body. * * @return array token (without braces) => description. */ public static function placeholders() { return array( 'site_name' => __( 'Site title', 'accesstrace-for-learndash' ), 'user_name' => __( 'Student display name', 'accesstrace-for-learndash' ), 'user_email' => __( 'Student email (only if enabled)', 'accesstrace-for-learndash' ), 'course_title' => __( 'Course title', 'accesstrace-for-learndash' ), 'action' => __( 'Enrolled / Unenrolled', 'accesstrace-for-learndash' ), 'source' => __( 'Source', 'accesstrace-for-learndash' ), 'trigger' => __( 'Trigger', 'accesstrace-for-learndash' ), 'status' => __( 'Valid / Invalid / Unknown', 'accesstrace-for-learndash' ), 'confidence' => __( 'Confidence', 'accesstrace-for-learndash' ), 'verification_reason' => __( 'Why the status was given', 'accesstrace-for-learndash' ), 'event_time' => __( 'Event time (site time)', 'accesstrace-for-learndash' ), 'log_url' => __( 'Link to the event in wp-admin', 'accesstrace-for-learndash' ), ); } /** * Built-in subject. * * @return string */ public static function default_subject() { return __( '[{site_name}] {action}: {user_name} / {course_title} ({status})', 'accesstrace-for-learndash' ); } /** * Built-in body. * * @return string */ public static function default_body() { return __( "Hello,\n\nAccessTrace logged a LearnDash enrollment change on {site_name}.\n\nUser: {user_name} ({user_email})\nCourse: {course_title}\nAction: {action}\nSource: {source} (trigger: {trigger})\nStatus: {status} - {verification_reason}\nWhen: {event_time}\n\nView details: {log_url}", 'accesstrace-for-learndash' ); } /** * Hook: an event was written to the log. * * @param int $id Row ID. * @param array $event Normalized event. * @return void */ public function on_event_logged( $id = 0, $event = array() ) { try { $id = absint( $id ); if ( $id < 1 || ! is_array( $event ) ) { return; } $cfg = EVLFL_Settings::all()['email']; if ( empty( $cfg['enabled'] ) || ! $this->matches( $cfg, $event, $id ) ) { return; } if ( 'immediate' !== $cfg['delivery'] ) { $this->queue( $id ); return; } if ( ! $this->take_rate_slot( (int) $cfg['rate_limit'] ) ) { $this->queue( $id ); if ( ! wp_next_scheduled( self::OVERFLOW_HOOK ) ) { wp_schedule_single_event( time() + HOUR_IN_SECONDS, self::OVERFLOW_HOOK ); } return; } $this->send( $cfg, $this->subject_for( $cfg, $event, $id ), $this->body_for( $cfg, $event, $id ) ); } catch ( \Throwable $e ) { $this->debug( $e ); } } /** * Send the queued events as one email (digest cron or rate-limit overflow). * * @return void */ public function send_digest() { try { $cfg = EVLFL_Settings::all()['email']; $pending = get_option( self::PENDING, array() ); $ids = isset( $pending['ids'] ) && is_array( $pending['ids'] ) ? array_map( 'absint', $pending['ids'] ) : array(); $dropped = isset( $pending['dropped'] ) ? absint( $pending['dropped'] ) : 0; if ( ! $ids ) { return; } delete_option( self::PENDING ); // Clear first so a slow send cannot be sent twice. if ( empty( $cfg['enabled'] ) ) { return; } $rows = $this->logger->get_by_ids( array_slice( $ids, 0, self::MAX_LINES ) ); $total = count( $ids ) + $dropped; $html = 'html' === $cfg['format']; $lines = array(); foreach ( $rows as $row ) { $v = $this->values( $cfg, $row, (int) $row['id'], false ); $lines[] = sprintf( '%1$s | %2$s | %3$s | %4$s | %5$s (%6$s)', $v['event_time'], $v['action'], $v['user_name'], $v['course_title'], $v['status'], $v['source'] ); } $intro = sprintf( /* translators: 1: number of events, 2: site title. */ _n( 'AccessTrace logged %1$s enrollment event on %2$s.', 'AccessTrace logged %1$s enrollment events on %2$s.', $total, 'accesstrace-for-learndash' ), number_format_i18n( $total ), $this->site_name() ); if ( $total > count( $lines ) ) { /* translators: %s: number of events not listed. */ $lines[] = sprintf( __( '...and %s more. Open the log for the full list.', 'accesstrace-for-learndash' ), number_format_i18n( $total - count( $lines ) ) ); } $footer = __( 'View the log:', 'accesstrace-for-learndash' ) . ' ' . $this->logs_url(); $body = $html ? '

' . esc_html( $intro ) . '

' . implode( '
', array_map( 'esc_html', $lines ) ) . '

' . esc_html__( 'View the log:', 'accesstrace-for-learndash' ) . ' ' . esc_html( $this->logs_url() ) . '

' : $intro . "\n\n" . implode( "\n", $lines ) . "\n\n" . $footer; /* translators: 1: site title, 2: number of events. */ $subject = sprintf( __( '[%1$s] AccessTrace summary: %2$s events', 'accesstrace-for-learndash' ), $this->site_name(), number_format_i18n( $total ) ); $this->send( $cfg, $subject, $body ); } catch ( \Throwable $e ) { $this->debug( $e ); } } /** * Send a test email to the first recipient using sample data and the saved settings. * * @return bool */ public function send_test() { try { $cfg = EVLFL_Settings::all()['email']; $sample = array( 'user_id' => get_current_user_id(), 'course_id' => 0, 'action' => EVLFL_Event::ACTION_UNENROLLED, 'source' => 'admin', 'event_trigger' => 'manual', 'status' => 'valid', 'confidence' => 'detected', 'verification_reason' => __( 'This is a test message with sample data.', 'accesstrace-for-learndash' ), 'event_time' => gmdate( 'Y-m-d H:i:s' ), ); $to = self::recipients( $cfg ); return $this->send( $cfg, '[TEST] ' . $this->subject_for( $cfg, $sample, 0 ), $this->body_for( $cfg, $sample, 0 ), array_slice( $to, 0, 1 ) ); } catch ( \Throwable $e ) { $this->debug( $e ); return false; } } /** * Keep the recurring digest schedule in line with the settings. * * @return void */ public function sync_schedule() { $cfg = EVLFL_Settings::all()['email']; $wanted = ( ! empty( $cfg['enabled'] ) && in_array( $cfg['delivery'], array( 'hourly', 'daily' ), true ) ) ? $cfg['delivery'] : ''; $current = wp_get_schedule( self::DIGEST_HOOK ); if ( (string) $current === $wanted ) { return; } wp_clear_scheduled_hook( self::DIGEST_HOOK ); if ( '' !== $wanted ) { wp_schedule_event( time() + HOUR_IN_SECONDS, $wanted, self::DIGEST_HOOK ); } } /** * Remove scheduled events (deactivation/uninstall). * * @return void */ public static function clear_schedules() { wp_clear_scheduled_hook( self::DIGEST_HOOK ); wp_clear_scheduled_hook( self::OVERFLOW_HOOK ); } /** * Recipients: configured addresses, else the site admin email. * * @param array $cfg Email settings. * @return string[] */ public static function recipients( array $cfg ) { $out = array(); foreach ( explode( ',', (string) $cfg['recipients'] ) as $address ) { $address = sanitize_email( trim( $address ) ); if ( is_email( $address ) ) { $out[] = $address; } } if ( ! $out ) { $admin = sanitize_email( (string) get_option( 'admin_email' ) ); if ( is_email( $admin ) ) { $out[] = $admin; } } return $out; } /** * Does the event match the configured triggers and sources? * * @param array $cfg Email settings. * @param array $event Event. * @param int $id Row ID. * @return bool */ private function matches( array $cfg, array $event, $id ) { $match = false; $on = (array) $cfg['notify_on']; if ( in_array( 'all', $on, true ) ) { $match = true; } elseif ( in_array( (string) $event['action'], $on, true ) ) { $match = true; // "enrolled" / "unenrolled" use the action value. } elseif ( in_array( (string) $event['status'], $on, true ) && in_array( $event['status'], array( 'invalid', 'unknown' ), true ) ) { $match = true; } $sources = (array) $cfg['sources']; if ( $match && $sources && ! in_array( (string) $event['source'], $sources, true ) ) { $match = false; } /** * Filters whether an admin email is sent for a logged event. * * @param bool $match Whether the settings say to send. * @param array $event Normalized event. * @param int $id Log row ID. */ return (bool) apply_filters( 'evlfl_should_notify', $match, $event, $id ); } /** * Add an event to the digest queue. * * @param int $id Row ID. * @return void */ private function queue( $id ) { $pending = get_option( self::PENDING, array() ); $pending = is_array( $pending ) ? $pending : array(); $ids = isset( $pending['ids'] ) && is_array( $pending['ids'] ) ? $pending['ids'] : array(); $dropped = isset( $pending['dropped'] ) ? absint( $pending['dropped'] ) : 0; if ( count( $ids ) >= self::MAX_PENDING ) { ++$dropped; } else { $ids[] = $id; } update_option( self::PENDING, array( 'ids' => $ids, 'dropped' => $dropped, ), false ); } /** * Count one immediate email against this hour's limit. * * @param int $limit Max emails per hour. * @return bool True when a slot was available. */ private function take_rate_slot( $limit ) { $key = 'evlfl_mail_rate_' . gmdate( 'YmdH' ); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return false; } set_transient( $key, $count + 1, 2 * HOUR_IN_SECONDS ); return true; } /** * Subject for one event. * * @param array $cfg Settings. * @param array $event Event. * @param int $id Row ID. * @return string */ private function subject_for( array $cfg, array $event, $id ) { $template = '' !== trim( (string) $cfg['subject'] ) ? (string) $cfg['subject'] : self::default_subject(); $subject = $this->fill( $template, $this->values( $cfg, $event, $id, false ) ); return trim( preg_replace( '/[\r\n]+/', ' ', wp_strip_all_tags( $subject ) ) ); } /** * Body for one event. * * @param array $cfg Settings. * @param array $event Event. * @param int $id Row ID. * @return string */ private function body_for( array $cfg, array $event, $id ) { $template = '' !== trim( (string) $cfg['body'] ) ? (string) $cfg['body'] : self::default_body(); if ( 'html' === $cfg['format'] ) { $values = $this->values( $cfg, $event, $id, true ); $values['log_url'] = '' . esc_html( $this->url_for( $id ) ) . ''; return nl2br( $this->fill( esc_html( $template ), $values ) ); } return $this->fill( $template, $this->values( $cfg, $event, $id, false ) ); } /** * Replace {tokens}. Unknown tokens stay as written. * * @param string $template Template. * @param array $values token => value (already safe for the output format). * @return string */ private function fill( $template, array $values ) { $map = array(); foreach ( $values as $token => $value ) { $map[ '{' . $token . '}' ] = $value; } return strtr( $template, $map ); } /** * Placeholder values. * * @param array $cfg Settings. * @param array $event Event row. * @param int $id Row ID (0 for a test). * @param bool $escape Escape each value for HTML. * @return array */ private function values( array $cfg, array $event, $id, $escape ) { $user = get_userdata( absint( $event['user_id'] ) ); $course = absint( $event['course_id'] ) > 0 ? get_post( absint( $event['course_id'] ) ) : null; $ts = strtotime( (string) $event['event_time'] . ' UTC' ); $deleted = static function ( $label, $n ) { /* translators: 1: "User" or "Course", 2: ID. */ return sprintf( __( '[Deleted %1$s #%2$d]', 'accesstrace-for-learndash' ), $label, $n ); }; $values = array( 'site_name' => $this->site_name(), 'user_name' => $user ? $user->display_name : $deleted( __( 'User', 'accesstrace-for-learndash' ), absint( $event['user_id'] ) ), 'user_email' => ( $user && ! empty( $cfg['include_user_email'] ) ) ? $user->user_email : __( '[hidden]', 'accesstrace-for-learndash' ), 'course_title' => $course ? $course->post_title : ( 0 === absint( $event['course_id'] ) ? __( 'Sample course', 'accesstrace-for-learndash' ) : $deleted( __( 'Course', 'accesstrace-for-learndash' ), absint( $event['course_id'] ) ) ), 'action' => EVLFL_Event::label( 'action', $event['action'] ), 'source' => EVLFL_Event::label( 'source', $event['source'] ), 'trigger' => EVLFL_Event::label( 'trigger', $event['event_trigger'] ), 'status' => EVLFL_Event::label( 'status', $event['status'] ), 'confidence' => EVLFL_Event::label( 'confidence', $event['confidence'] ), 'verification_reason' => isset( $event['verification_reason'] ) ? (string) $event['verification_reason'] : '', 'event_time' => false !== $ts ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $ts ) : '', 'log_url' => $this->url_for( $id ), ); foreach ( $values as $token => $value ) { $value = wp_strip_all_tags( (string) $value ); $values[ $token ] = $escape ? esc_html( $value ) : $value; } return $values; } /** * Admin URL of one event (or the list for a test). * * @param int $id Row ID. * @return string */ private function url_for( $id ) { $args = array( 'page' => EVLFL_Plugin::LOGS_PAGE ); if ( $id > 0 ) { $args['evlfl_event'] = absint( $id ); } return add_query_arg( $args, admin_url( 'admin.php' ) ); } /** * Admin URL of the log list. * * @return string */ private function logs_url() { return $this->url_for( 0 ); } /** * Site title as plain text. * * @return string */ private function site_name() { return wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); } /** * Send through wp_mail(). * * @param array $cfg Settings. * @param string $subject Subject. * @param string $body Body (HTML when the format is html). * @param array|null $to Override recipients. * @return bool */ private function send( array $cfg, $subject, $body, $to = null ) { $to = null === $to ? self::recipients( $cfg ) : $to; if ( ! $to ) { return false; } $headers = array(); if ( 'html' === $cfg['format'] ) { $headers[] = 'Content-Type: text/html; charset=UTF-8'; } if ( '' !== $cfg['from_email'] && is_email( $cfg['from_email'] ) ) { $name = '' !== $cfg['from_name'] ? $cfg['from_name'] : $this->site_name(); $headers[] = 'From: ' . str_replace( array( "\r", "\n" ), '', $name ) . ' <' . $cfg['from_email'] . '>'; } return (bool) wp_mail( $to, $subject, $body, $headers ); } /** * Debug-only logging. * * @param \Throwable $e Error. * @return void */ private function debug( $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'EVL notifier error: ' . get_class( $e ) . ': ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } }