send_digest(); // Schedule next event. $this->schedule_next_event(); } /** * Calculate the next send timestamp in UTC based on settings. * * @param array|null $email_cfg Optional email config override. * @param DateTimeImmutable|null $from_dt Optional reference datetime. * @return int Unix timestamp. */ public function calculate_next_timestamp( $email_cfg = null, $from_dt = null ) { if ( null === $email_cfg ) { $settings = ocap_get_settings(); $email_cfg = isset( $settings['email'] ) ? $settings['email'] : array(); } $tz = wp_timezone(); if ( null === $from_dt ) { $now = new DateTimeImmutable( 'now', $tz ); } else { $now = $from_dt->setTimezone( $tz ); } $frequency = isset( $email_cfg['frequency'] ) ? $email_cfg['frequency'] : 'quarterly'; $send_day = isset( $email_cfg['send_day'] ) ? (int) $email_cfg['send_day'] : 1; $send_time = ! empty( $email_cfg['send_time'] ) ? $email_cfg['send_time'] : '09:00'; $time_parts = explode( ':', $send_time ); $hour = isset( $time_parts[0] ) ? (int) $time_parts[0] : 9; $minute = isset( $time_parts[1] ) ? (int) $time_parts[1] : 0; if ( 'weekly' === $frequency ) { // Day of week: 1 for Monday through 7 for Sunday. $send_day = max( 1, min( 7, $send_day ) ); $cur_w = (int) $now->format( 'N' ); // 1 (Mon) to 7 (Sun). $candidate = $now->setTime( $hour, $minute, 0 ); if ( $cur_w === $send_day && $candidate > $now ) { // Today at send_time. $next_dt = $candidate; } else { $days_ahead = ( $send_day - $cur_w + 7 ) % 7; if ( 0 === $days_ahead ) { $days_ahead = 7; } $next_dt = $now->modify( "+{$days_ahead} days" )->setTime( $hour, $minute, 0 ); } } elseif ( 'monthly' === $frequency ) { // send_day: 1 to 28. $send_day = max( 1, min( 28, $send_day ) ); $candidate = $now->setDate( (int) $now->format( 'Y' ), (int) $now->format( 'm' ), $send_day )->setTime( $hour, $minute, 0 ); if ( $candidate > $now ) { $next_dt = $candidate; } else { $next_dt = $candidate->modify( '+1 month' ); } } else { // Quarterly: every 3 months. $send_day = max( 1, min( 28, $send_day ) ); $candidate = $now->setDate( (int) $now->format( 'Y' ), (int) $now->format( 'm' ), $send_day )->setTime( $hour, $minute, 0 ); if ( $candidate > $now ) { $next_dt = $candidate; } else { $next_dt = $candidate->modify( '+3 months' ); } } return $next_dt->getTimestamp(); } /** * Schedule the next digest run. */ public function schedule_next_event() { $this->clear_scheduled_event(); $settings = ocap_get_settings(); $email_cfg = isset( $settings['email'] ) ? $settings['email'] : array(); if ( empty( $email_cfg['enabled'] ) ) { return; } $next_ts = $this->calculate_next_timestamp( $email_cfg ); wp_schedule_single_event( $next_ts, self::CRON_HOOK ); } /** * Clear scheduled cron event. */ public function clear_scheduled_event() { wp_clear_scheduled_hook( self::CRON_HOOK ); wp_clear_scheduled_hook( 'sct_send_digest' ); } /** * Get formatted string of next scheduled send date. * * @return string Formatted date or 'Not scheduled'. */ public function get_next_scheduled_date() { $next_ts = wp_next_scheduled( self::CRON_HOOK ); if ( ! $next_ts ) { $next_ts = wp_next_scheduled( 'sct_send_digest' ); } if ( ! $next_ts ) { return __( 'Not scheduled (digest is disabled or not set up)', 'contentcortex' ); } $tz = wp_timezone(); $dt = new DateTimeImmutable( '@' . $next_ts ); $dt_site = $dt->setTimezone( $tz ); $date_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); return $dt_site->format( $date_format ) . ' (' . $tz->getName() . ')'; } /** * Build template data array. * * @param array|null $custom_settings Optional settings override (e.g. from test form). * @return array */ public function build_template_data( $custom_settings = null ) { $settings = ocap_get_settings(); $email_cfg = isset( $settings['email'] ) ? $settings['email'] : array(); if ( is_array( $custom_settings ) ) { $email_cfg = array_merge( $email_cfg, $custom_settings ); } $max_items = isset( $email_cfg['max_items'] ) ? max( 1, (int) $email_cfg['max_items'] ) : 25; // Query stale items. $query = new OCAP_Query( array( 'view' => 'stale', 'per_page' => $max_items, 'paged' => 1, 'orderby' => 'days', 'order' => 'DESC', ) ); $items = $query->get_results(); $total_count = $query->get_total_items(); $by_type_raw = $query->get_counts_by_type(); // Convert by_type to human labels. $by_type = array(); foreach ( $by_type_raw as $pt => $cnt ) { $pt_obj = get_post_type_object( $pt ); $label = $pt_obj ? $pt_obj->labels->name : ucfirst( $pt ); $by_type[ $label ] = $cnt; } // Calculate quarter e.g. "Q4 2026". $tz = wp_timezone(); $now = new DateTimeImmutable( 'now', $tz ); $month = (int) $now->format( 'n' ); $q_num = (int) ceil( $month / 3 ); $quarter = sprintf( 'Q%d %s', $q_num, $now->format( 'Y' ) ); $site_name = get_bloginfo( 'name' ); $site_url = home_url(); $report_url = ocap_get_report_url(); $date_format = get_option( 'date_format' ); $send_date = $now->format( $date_format ); $placeholders = array( '{site_name}' => esc_html( $site_name ), '{site_url}' => esc_url( $site_url ), '{stale_count}' => number_format_i18n( $total_count ), '{report_url}' => esc_url( $report_url ), '{date}' => esc_html( $send_date ), '{quarter}' => esc_html( $quarter ), ); // Subject placeholders (plain text). $subject_placeholders = array( '{site_name}' => $site_name, '{site_url}' => $site_url, '{stale_count}' => (string) $total_count, '{report_url}' => $report_url, '{date}' => $send_date, '{quarter}' => $quarter, ); $raw_heading = ! empty( $email_cfg['heading'] ) ? $email_cfg['heading'] : __( 'Content that needs your attention', 'contentcortex' ); $raw_intro = ! empty( $email_cfg['intro'] ) ? $email_cfg['intro'] : __( 'These items have not been updated within their review window.', 'contentcortex' ); $raw_footer = ! empty( $email_cfg['footer'] ) ? $email_cfg['footer'] : __( 'Sent by ContentCortex from {site_name}.', 'contentcortex' ); $raw_subject = ! empty( $email_cfg['subject'] ) ? $email_cfg['subject'] : __( 'Content refresh report: {stale_count} outdated items on {site_name}', 'contentcortex' ); $heading = strtr( esc_html( $raw_heading ), $placeholders ); $intro = wp_kses_post( strtr( $raw_intro, $placeholders ) ); $footer = wp_kses_post( strtr( $raw_footer, $placeholders ) ); $subject = strtr( sanitize_text_field( $raw_subject ), $subject_placeholders ); $logo_id = isset( $email_cfg['logo_id'] ) ? absint( $email_cfg['logo_id'] ) : 0; $logo_url = ''; if ( $logo_id > 0 ) { $logo_src = wp_get_attachment_image_src( $logo_id, 'medium' ); if ( $logo_src ) { $logo_url = $logo_src[0]; } } $brand_color = ! empty( $email_cfg['brand_color'] ) ? sanitize_hex_color( $email_cfg['brand_color'] ) : '#2563eb'; if ( empty( $brand_color ) ) { $brand_color = '#2563eb'; } $button_text = ! empty( $email_cfg['button_text'] ) ? sanitize_text_field( $email_cfg['button_text'] ) : __( 'Open full report', 'contentcortex' ); $columns = isset( $email_cfg['show_columns'] ) && is_array( $email_cfg['show_columns'] ) ? $email_cfg['show_columns'] : array( 'type', 'days', 'author' ); $formatted_items = array(); foreach ( $items as $item ) { $formatted_items[] = array( 'title' => $item['title'], 'edit_url' => $item['edit_url'], 'view_url' => $item['view_url'], 'type' => $item['post_type_label'], 'days' => $item['days_since_update'], 'limit' => $item['day_limit'], 'author' => $item['author_name'], 'severity' => $item['severity'], ); } $data = array( 'site_name' => $site_name, 'heading' => $heading, 'intro' => $intro, 'footer' => $footer, 'subject' => $subject, 'logo_url' => $logo_url, 'brand_color' => $brand_color, 'button_text' => $button_text, 'report_url' => $report_url, 'total' => $total_count, 'shown' => count( $formatted_items ), 'columns' => $columns, 'items' => $formatted_items, 'by_type' => $by_type, ); return apply_filters( 'ocap_email_data', $data ); } /** * Locate template file, checking theme override first. * * @param string $template_name Relative template file name. * @return string File path. */ public function locate_template( $template_name ) { // Check child theme then parent theme: {theme}/contentcortex/email/{template_name}. $theme_file = locate_template( array( 'contentcortex/email/' . $template_name, 'contentcortex/' . $template_name, 'stale-content-tracker/email/' . $template_name, 'stale-content-tracker/' . $template_name, ) ); if ( $theme_file ) { return $theme_file; } return OCAP_PLUGIN_DIR . 'templates/email/' . $template_name; } /** * Render HTML digest email. * * @param array $data Template data. * @return string Rendered HTML. */ public function render_html( $data ) { $template_path = $this->locate_template( 'digest.php' ); ob_start(); if ( file_exists( $template_path ) ) { include $template_path; } $html = ob_get_clean(); return apply_filters( 'ocap_email_html', $html, $data ); } /** * Render plain text digest email fallback. * * @param array $data Template data. * @return string Rendered plain text. */ public function render_plain( $data ) { $template_path = $this->locate_template( 'digest-plain.php' ); ob_start(); if ( file_exists( $template_path ) ) { include $template_path; } $plain = ob_get_clean(); return apply_filters( 'ocap_email_plain', $plain, $data ); } /** * Hook callback to set text/html content type. * * @return string */ public function set_html_content_type() { return 'text/html'; } /** * Hook callback to configure PHPMailer AltBody for multipart email. * * @param \PHPMailer\PHPMailer\PHPMailer $phpmailer PHPMailer instance. */ public function set_phpmailer_alt_body( $phpmailer ) { if ( ! empty( $this->current_alt_body ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- External library PHPMailer property. $phpmailer->AltBody = $this->current_alt_body; } } /** * Send the digest email. * * @param array|null $recipients_override Optional recipients list. * @param array|null $settings_override Optional settings override. * @return bool True if sent, false otherwise. */ public function send_digest( $recipients_override = null, $settings_override = null ) { $settings = ocap_get_settings(); $email_cfg = isset( $settings['email'] ) ? $settings['email'] : array(); if ( is_array( $settings_override ) ) { $email_cfg = array_merge( $email_cfg, $settings_override ); } $data = $this->build_template_data( $settings_override ); // Check only_if_stale flag. if ( ! empty( $email_cfg['only_if_stale'] ) && 0 === $data['total'] && null === $recipients_override ) { // Skip sending if no stale items (except for explicit test sends). return false; } // Determine recipients. if ( ! empty( $recipients_override ) ) { $recipients = (array) $recipients_override; } else { $recipients = isset( $email_cfg['recipients'] ) ? (array) $email_cfg['recipients'] : array(); } $recipients = array_filter( array_map( 'sanitize_email', $recipients ) ); $recipients = apply_filters( 'ocap_email_recipients', $recipients ); if ( empty( $recipients ) ) { return false; } $subject = apply_filters( 'ocap_email_subject', $data['subject'], $data ); $html = $this->render_html( $data ); $plain = $this->render_plain( $data ); $this->current_alt_body = $plain; // Attach hooks. add_filter( 'wp_mail_content_type', array( $this, 'set_html_content_type' ) ); add_action( 'phpmailer_init', array( $this, 'set_phpmailer_alt_body' ) ); $sent = wp_mail( $recipients, $subject, $html ); // Clean up hooks. remove_filter( 'wp_mail_content_type', array( $this, 'set_html_content_type' ) ); remove_action( 'phpmailer_init', array( $this, 'set_phpmailer_alt_body' ) ); $this->current_alt_body = ''; return (bool) $sent; } /** * AJAX endpoint to render live HTML preview. */ public function ajax_preview_email() { if ( ! check_ajax_referer( 'ocap_preview_nonce', 'security', false ) && ! check_ajax_referer( 'sct_preview_nonce', 'security', false ) ) { wp_die( -1 ); } if ( ! current_user_can( 'manage_options' ) ) { wp_die( -1 ); } // Optional overrides passed from form. $overrides = array(); if ( isset( $_POST['heading'] ) ) { $overrides['heading'] = sanitize_text_field( wp_unslash( $_POST['heading'] ) ); } if ( isset( $_POST['intro'] ) ) { $overrides['intro'] = wp_kses_post( wp_unslash( $_POST['intro'] ) ); } if ( isset( $_POST['footer'] ) ) { $overrides['footer'] = wp_kses_post( wp_unslash( $_POST['footer'] ) ); } if ( isset( $_POST['button_text'] ) ) { $overrides['button_text'] = sanitize_text_field( wp_unslash( $_POST['button_text'] ) ); } if ( isset( $_POST['brand_color'] ) ) { $overrides['brand_color'] = sanitize_hex_color( wp_unslash( $_POST['brand_color'] ) ); } if ( isset( $_POST['logo_id'] ) ) { $overrides['logo_id'] = absint( $_POST['logo_id'] ); } if ( isset( $_POST['show_columns'] ) && is_array( $_POST['show_columns'] ) ) { $overrides['show_columns'] = array_map( 'sanitize_key', $_POST['show_columns'] ); } $data = $this->build_template_data( $overrides ); $html = $this->render_html( $data ); header( 'Content-Type: text/html; charset=utf-8' ); echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped exit; } /** * AJAX endpoint to send a test email to the current user. */ public function ajax_send_test_email() { if ( ! check_ajax_referer( 'ocap_test_email_nonce', 'security', false ) && ! check_ajax_referer( 'sct_test_email_nonce', 'security', false ) ) { wp_send_json_error( array( 'message' => __( 'Security check failed.', 'contentcortex' ) ) ); } if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'contentcortex' ) ) ); } $current_user = wp_get_current_user(); if ( ! $current_user || ! is_email( $current_user->user_email ) ) { wp_send_json_error( array( 'message' => __( 'Could not find current user email.', 'contentcortex' ) ) ); } // Optional overrides passed from form. $overrides = array(); if ( isset( $_POST['heading'] ) ) { $overrides['heading'] = sanitize_text_field( wp_unslash( $_POST['heading'] ) ); } if ( isset( $_POST['intro'] ) ) { $overrides['intro'] = wp_kses_post( wp_unslash( $_POST['intro'] ) ); } if ( isset( $_POST['footer'] ) ) { $overrides['footer'] = wp_kses_post( wp_unslash( $_POST['footer'] ) ); } if ( isset( $_POST['button_text'] ) ) { $overrides['button_text'] = sanitize_text_field( wp_unslash( $_POST['button_text'] ) ); } if ( isset( $_POST['brand_color'] ) ) { $overrides['brand_color'] = sanitize_hex_color( wp_unslash( $_POST['brand_color'] ) ); } if ( isset( $_POST['logo_id'] ) ) { $overrides['logo_id'] = absint( $_POST['logo_id'] ); } if ( isset( $_POST['show_columns'] ) && is_array( $_POST['show_columns'] ) ) { $overrides['show_columns'] = array_map( 'sanitize_key', $_POST['show_columns'] ); } $sent = $this->send_digest( array( $current_user->user_email ), $overrides ); if ( $sent ) { wp_send_json_success( array( 'message' => sprintf( /* translators: %s: recipient email address */ __( 'Test email successfully sent to %s!', 'contentcortex' ), esc_html( $current_user->user_email ) ), ) ); } else { wp_send_json_error( array( 'message' => sprintf( /* translators: %s: recipient email address */ __( 'Failed to send test email to %s. Please check your WordPress mail configuration.', 'contentcortex' ), esc_html( $current_user->user_email ) ), ) ); } } }