booking
/
includes
/
page-setup-wizard
/
step-review-setup
/
class-wpbc-setup-wizard-summary-email.php
class-wpbc-setup-wizard-summary-email.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-review-setup/class-wpbc-setup-wizard-summary-email.php
| 1 | <?php |
| 2 | /** |
| 3 | * Review-page Setup Wizard summary email. |
| 4 | * |
| 5 | * @package Booking Calendar |
| 6 | */ |
| 7 | |
| 8 | if ( ! defined( 'ABSPATH' ) ) { |
| 9 | exit; |
| 10 | } |
| 11 | |
| 12 | /** |
| 13 | * Send one consented, retry-safe setup summary when Review Setup first opens. |
| 14 | * |
| 15 | * The legacy Setup Wizard sent selected onboarding values to Booking Calendar |
| 16 | * after its booking-type step. The progressive wizard waits until Review Setup |
| 17 | * so the message can contain the same complete, server-authorized plan shown to |
| 18 | * the administrator. A compact-content fingerprint prevents duplicate sends |
| 19 | * for 15 minutes, while changed content may be sent immediately. |
| 20 | */ |
| 21 | final class WPBC_Setup_Wizard_Summary_Email { |
| 22 | |
| 23 | /** Maximum Days Off records expanded in one feedback email. */ |
| 24 | const MAX_EXPANDED_DAYS_OFF_ITEMS = 5; |
| 25 | |
| 26 | /** Number of seconds an unchanged sent fingerprint remains suppressed. */ |
| 27 | const SENT_DEDUPLICATION_TTL = 1 * MINUTE_IN_SECONDS; |
| 28 | |
| 29 | /** Email address used by the released Setup Wizard feedback workflow. */ |
| 30 | const RECIPIENT = '[email protected]'; |
| 31 | |
| 32 | /** Lock namespace separated from the terminal Review save operation. */ |
| 33 | const LOCK_STEP_ID = 'review_setup_email'; |
| 34 | |
| 35 | /** @var WPBC_Setup_Wizard_Email_State_Store */ |
| 36 | private $email_state_store; |
| 37 | |
| 38 | /** @var WPBC_Setup_Wizard_Operation_Lock */ |
| 39 | private $operation_lock; |
| 40 | |
| 41 | /** @var callable|null */ |
| 42 | private $mail_callback; |
| 43 | |
| 44 | /** |
| 45 | * Build the sender from narrow persistence, locking, and delivery services. |
| 46 | * |
| 47 | * @param WPBC_Setup_Wizard_Email_State_Store $email_state_store Summary-email checkpoint store. |
| 48 | * @param WPBC_Setup_Wizard_Operation_Lock $operation_lock Context-scoped operation lock. |
| 49 | * @param callable|null $mail_callback Optional test delivery callback. It receives recipient, subject, message, and headers. |
| 50 | */ |
| 51 | public function __construct( WPBC_Setup_Wizard_Email_State_Store $email_state_store, WPBC_Setup_Wizard_Operation_Lock $operation_lock, $mail_callback = null ) { |
| 52 | $this->email_state_store = $email_state_store; |
| 53 | $this->operation_lock = $operation_lock; |
| 54 | $this->mail_callback = is_callable( $mail_callback ) ? $mail_callback : null; |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * Send the current reviewed setup once when consent and environment allow it. |
| 59 | * |
| 60 | * A `pending` checkpoint is written before `wp_mail()` is called. A concurrent |
| 61 | * request cannot repeat that operation while its lock is active. If a request |
| 62 | * stops before recording the result, a later Review opening may retry after |
| 63 | * the short operation lock expires. A `sent` fingerprint is suppressed for |
| 64 | * 15 minutes; confirmed `failed` attempts may retry on the next opening. |
| 65 | * |
| 66 | * @param array<string,mixed> $review_context Authorized context rendered by Review Setup. |
| 67 | * |
| 68 | * @return array<string,mixed>|WP_Error Current or updated checkpoint, or a safe persistence/lock error. |
| 69 | */ |
| 70 | public function maybe_send( array $review_context ) { |
| 71 | $checkpoint = $this->email_state_store->load(); |
| 72 | if ( ! $this->is_requested( $checkpoint ) ) { |
| 73 | return $checkpoint; |
| 74 | } |
| 75 | |
| 76 | $fingerprint = $this->get_summary_fingerprint( $review_context ); |
| 77 | $email_state = isset( $checkpoint['email'] ) && is_array( $checkpoint['email'] ) ? $checkpoint['email'] : array(); |
| 78 | if ( $this->is_completed_attempt( $email_state, $fingerprint ) ) { |
| 79 | return $checkpoint; |
| 80 | } |
| 81 | |
| 82 | $operation_id = 'summary_email_' . substr( $fingerprint, 0, 32 ); |
| 83 | $lock_token = $this->operation_lock->acquire( |
| 84 | $this->email_state_store->get_lock_scope(), |
| 85 | self::LOCK_STEP_ID, |
| 86 | $operation_id |
| 87 | ); |
| 88 | if ( is_wp_error( $lock_token ) ) { |
| 89 | return $lock_token; |
| 90 | } |
| 91 | |
| 92 | try { |
| 93 | $checkpoint = $this->email_state_store->load(); |
| 94 | $email_state = isset( $checkpoint['email'] ) && is_array( $checkpoint['email'] ) ? $checkpoint['email'] : array(); |
| 95 | if ( ! $this->is_requested( $checkpoint ) || $this->is_completed_attempt( $email_state, $fingerprint ) ) { |
| 96 | return $checkpoint; |
| 97 | } |
| 98 | |
| 99 | $attempted_at = current_time( 'mysql', true ); |
| 100 | $pending = $this->email_state_store->update_email_state( |
| 101 | array( |
| 102 | 'requested' => true, |
| 103 | 'recipient' => self::RECIPIENT, |
| 104 | 'status' => 'pending', |
| 105 | 'operation_id' => $operation_id, |
| 106 | 'summary_fingerprint' => $fingerprint, |
| 107 | 'attempted_at' => $attempted_at, |
| 108 | 'sent_at' => '', |
| 109 | 'last_error' => '', |
| 110 | ), |
| 111 | isset( $checkpoint['revision'] ) ? absint( $checkpoint['revision'] ) : 0 |
| 112 | ); |
| 113 | if ( is_wp_error( $pending ) ) { |
| 114 | return $pending; |
| 115 | } |
| 116 | |
| 117 | try { |
| 118 | $is_sent = $this->send_mail( $review_context ); |
| 119 | } catch ( Throwable $mail_error ) { |
| 120 | $is_sent = false; |
| 121 | } |
| 122 | $result = $this->email_state_store->update_email_state( |
| 123 | array( |
| 124 | 'requested' => true, |
| 125 | 'recipient' => self::RECIPIENT, |
| 126 | 'status' => $is_sent ? 'sent' : 'failed', |
| 127 | 'operation_id' => $operation_id, |
| 128 | 'summary_fingerprint' => $fingerprint, |
| 129 | 'attempted_at' => $attempted_at, |
| 130 | 'sent_at' => $is_sent ? current_time( 'mysql', true ) : '', |
| 131 | 'last_error' => $is_sent ? '' : __( 'WordPress could not hand the optional setup summary to the configured feedback.', 'booking' ), |
| 132 | ), |
| 133 | isset( $pending['revision'] ) ? absint( $pending['revision'] ) : 0 |
| 134 | ); |
| 135 | |
| 136 | return is_wp_error( $result ) ? $pending : $result; |
| 137 | } finally { |
| 138 | $this->operation_lock->release( $this->email_state_store->get_lock_scope(), self::LOCK_STEP_ID, $lock_token ); |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Format a compact email projection of the authorized Review Setup context. |
| 144 | * |
| 145 | * @param array<string,mixed> $review_context Authorized Review Setup presentation context. |
| 146 | * |
| 147 | * @return string Plain-text UTF-8 email body. |
| 148 | */ |
| 149 | public static function format_message( array $review_context ) { |
| 150 | $lines = self::get_content_lines( $review_context ); |
| 151 | |
| 152 | $plugin_version = function_exists( 'wpbc_feedback_01_get_version' ) |
| 153 | ? wpbc_feedback_01_get_version() |
| 154 | : ( defined( 'WP_BK_VERSION_NUM' ) ? WP_BK_VERSION_NUM : __( 'Unknown', 'booking' ) ); |
| 155 | |
| 156 | $lines[] = ''; |
| 157 | $lines[] = sprintf( __( 'Site: %s', 'booking' ), esc_url_raw( home_url( '/' ) ) ); |
| 158 | $lines[] = sprintf( __( 'Booking Calendar version: %s', 'booking' ), self::sanitize_line( $plugin_version ) ); |
| 159 | $lines[] = sprintf( __( 'Generated: %s', 'booking' ), wp_date( 'Y-m-d H:i:s T' ) ); |
| 160 | $how_old_info_arr = function_exists( 'wpbc_get_info__about_how_old' ) ? wpbc_get_info__about_how_old() : false; |
| 161 | if ( ! empty( $how_old_info_arr ) ) { |
| 162 | $lines[] = sprintf( |
| 163 | __( 'First booking: %1$s (%2$d days ago)', 'booking' ), |
| 164 | self::sanitize_line( isset( $how_old_info_arr['date_echo'] ) ? $how_old_info_arr['date_echo'] : '' ), |
| 165 | isset( $how_old_info_arr['days'] ) ? absint( $how_old_info_arr['days'] ) : 0 |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | return implode( "\n", $lines ); |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Build stable feedback content without volatile delivery metadata. |
| 174 | * |
| 175 | * Free-text descriptions are deliberately excluded from feedback email. Long |
| 176 | * Days Off collections remain summarized by the row total instead of listing |
| 177 | * every configured date period. |
| 178 | * |
| 179 | * @param array<string,mixed> $review_context Authorized Review Setup context. |
| 180 | * |
| 181 | * @return string[] Stable plain-text content lines. |
| 182 | */ |
| 183 | private static function get_content_lines( array $review_context ) { |
| 184 | $journey = isset( $review_context['journey'] ) && is_array( $review_context['journey'] ) ? $review_context['journey'] : array(); |
| 185 | $groups = isset( $review_context['groups'] ) && is_array( $review_context['groups'] ) ? $review_context['groups'] : array(); |
| 186 | $feedback_profile = isset( $review_context['feedback_profile'] ) && is_array( $review_context['feedback_profile'] ) ? $review_context['feedback_profile'] : array(); |
| 187 | $lines = array( |
| 188 | __( 'Booking Calendar Setup', 'booking' ), |
| 189 | str_repeat( '=', 32 ), |
| 190 | '', |
| 191 | __( 'Selected customer journey', 'booking' ), |
| 192 | '- ' . self::sanitize_line( isset( $journey['title'] ) ? $journey['title'] : __( 'Not configured', 'booking' ) ), |
| 193 | ); |
| 194 | |
| 195 | if ( ! empty( $feedback_profile ) ) { |
| 196 | $lines[] = ''; |
| 197 | $lines[] = __( 'Setup feedback profile', 'booking' ); |
| 198 | $lines[] = str_repeat( '-', 24 ); |
| 199 | self::append_review_row_lines( $lines, $feedback_profile ); |
| 200 | } |
| 201 | |
| 202 | foreach ( $groups as $group ) { |
| 203 | if ( ! is_array( $group ) ) { |
| 204 | continue; |
| 205 | } |
| 206 | $lines[] = ''; |
| 207 | $lines[] = self::sanitize_line( isset( $group['label'] ) ? $group['label'] : __( 'Setup details', 'booking' ) ); |
| 208 | $lines[] = str_repeat( '-', 24 ); |
| 209 | |
| 210 | foreach ( isset( $group['rows'] ) && is_array( $group['rows'] ) ? $group['rows'] : array() as $row ) { |
| 211 | if ( ! is_array( $row ) ) { |
| 212 | continue; |
| 213 | } |
| 214 | self::append_review_row_lines( $lines, $row ); |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | return $lines; |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Append one normalized Review record to the plain-text message. |
| 223 | * |
| 224 | * @param string[] $lines Message lines passed by reference. |
| 225 | * @param array<string,mixed> $row Authorized Review or feedback-profile row. |
| 226 | * |
| 227 | * @return void |
| 228 | */ |
| 229 | private static function append_review_row_lines( array &$lines, array $row ) { |
| 230 | $row_label = self::sanitize_line( isset( $row['label'] ) ? $row['label'] : '' ); |
| 231 | $row_summary = self::sanitize_line( isset( $row['summary'] ) ? $row['summary'] : '' ); |
| 232 | $lines[] = sprintf( '%1$s: %2$s', $row_label, $row_summary ); |
| 233 | |
| 234 | foreach ( isset( $row['details'] ) && is_array( $row['details'] ) ? $row['details'] : array() as $detail ) { |
| 235 | if ( is_array( $detail ) && isset( $detail['label'], $detail['value'] ) && empty( $detail['feedback_hidden'] ) && ! self::is_description_label( $detail['label'] ) ) { |
| 236 | $feedback_value = isset( $detail['feedback_value'] ) ? $detail['feedback_value'] : $detail['value']; |
| 237 | $lines[] = sprintf( ' - %1$s: %2$s', self::sanitize_line( $detail['label'] ), self::sanitize_line( $feedback_value ) ); |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | $items = isset( $row['items'] ) && is_array( $row['items'] ) ? $row['items'] : array(); |
| 242 | if ( 'days_off' === ( isset( $row['step_id'] ) ? $row['step_id'] : '' ) && self::MAX_EXPANDED_DAYS_OFF_ITEMS < count( $items ) ) { |
| 243 | return; |
| 244 | } |
| 245 | |
| 246 | foreach ( $items as $item ) { |
| 247 | if ( is_array( $item ) && isset( $item['label'], $item['value'] ) && empty( $item['feedback_hidden'] ) ) { |
| 248 | $feedback_value = isset( $item['feedback_value'] ) ? $item['feedback_value'] : $item['value']; |
| 249 | $lines[] = sprintf( ' * %1$s: %2$s', self::sanitize_line( $item['label'] ), self::sanitize_line( $feedback_value ) ); |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Detect direct description fields excluded from feedback email. |
| 256 | * |
| 257 | * @param mixed $label Authorized Review detail label. |
| 258 | * |
| 259 | * @return bool True when the label identifies free-text description content. |
| 260 | */ |
| 261 | private static function is_description_label( $label ) { |
| 262 | $label = self::sanitize_line( $label ); |
| 263 | |
| 264 | return in_array( |
| 265 | $label, |
| 266 | array( |
| 267 | self::sanitize_line( __( 'Description', 'booking' ) ), |
| 268 | self::sanitize_line( __( 'Template description', 'booking' ) ), |
| 269 | ), |
| 270 | true |
| 271 | ); |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * Determine whether the current checkpoint explicitly requests sharing. |
| 276 | * |
| 277 | * @param array<string,mixed> $checkpoint Current normalized checkpoint. |
| 278 | * |
| 279 | * @return bool True only for an eligible Review step with explicit consent. |
| 280 | */ |
| 281 | private function is_requested( array $checkpoint ) { |
| 282 | if ( ! WPBC_Setup_Wizard_Environment_Policy::allows_summary_email() || 'review_setup' !== ( isset( $checkpoint['current_step'] ) ? $checkpoint['current_step'] : '' ) ) { |
| 283 | return false; |
| 284 | } |
| 285 | |
| 286 | $business_values = isset( $checkpoint['values']['business_details'] ) && is_array( $checkpoint['values']['business_details'] ) |
| 287 | ? $checkpoint['values']['business_details'] |
| 288 | : array(); |
| 289 | |
| 290 | return ! empty( $business_values['personalization_consent'] ) |
| 291 | && ! empty( $business_values['booking_email'] ) |
| 292 | && false !== is_email( $business_values['booking_email'] ); |
| 293 | } |
| 294 | |
| 295 | /** |
| 296 | * Detect a recently completed attempt for one exact compact email. |
| 297 | * |
| 298 | * @param array<string,mixed> $email_state Summary-email checkpoint metadata. |
| 299 | * @param string $fingerprint Current Review summary fingerprint. |
| 300 | * |
| 301 | * @return bool True during the 15-minute sent-state suppression window. |
| 302 | */ |
| 303 | private function is_completed_attempt( array $email_state, $fingerprint ) { |
| 304 | if ( |
| 305 | ! isset( $email_state['summary_fingerprint'], $email_state['status'], $email_state['sent_at'] ) |
| 306 | || ! hash_equals( (string) $email_state['summary_fingerprint'], (string) $fingerprint ) |
| 307 | || 'sent' !== $email_state['status'] |
| 308 | ) { |
| 309 | return false; |
| 310 | } |
| 311 | |
| 312 | $sent_timestamp = strtotime( (string) $email_state['sent_at'] . ' UTC' ); |
| 313 | $current_timestamp = strtotime( current_time( 'mysql', true ) . ' UTC' ); |
| 314 | if ( false === $sent_timestamp || false === $current_timestamp ) { |
| 315 | return false; |
| 316 | } |
| 317 | |
| 318 | $sent_age = $current_timestamp - $sent_timestamp; |
| 319 | |
| 320 | return 0 <= $sent_age && self::SENT_DEDUPLICATION_TTL > $sent_age; |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Hash the stable compact content that will be shared by feedback email. |
| 325 | * |
| 326 | * @param array<string,mixed> $review_context Authorized Review Setup context. |
| 327 | * |
| 328 | * @return string SHA-256 fingerprint. |
| 329 | */ |
| 330 | private function get_summary_fingerprint( array $review_context ) { |
| 331 | return hash( 'sha256', wp_json_encode( self::get_content_lines( $review_context ) ) ); |
| 332 | } |
| 333 | |
| 334 | /** |
| 335 | * Deliver one plain-text summary through the normal WordPress mail pipeline. |
| 336 | * |
| 337 | * @param array<string,mixed> $review_context Authorized Review Setup context. |
| 338 | * |
| 339 | * @return bool True when WordPress accepted the message for delivery. |
| 340 | */ |
| 341 | private function send_mail( array $review_context ) { |
| 342 | $is_allowed = apply_filters( 'wpbc_email_api_is_allow_send', true, 'setup_wizard_summary', array() ); |
| 343 | if ( ! $is_allowed ) { |
| 344 | return false; |
| 345 | } |
| 346 | |
| 347 | $subject = 'Booking Calendar | Setup'; |
| 348 | $message = self::format_message( $review_context ); |
| 349 | $headers = array( 'Content-Type: text/plain; charset=UTF-8' ); |
| 350 | |
| 351 | if ( function_exists( 'get_option' ) && function_exists( 'wp_get_current_user' ) ) { |
| 352 | $current_user = wp_get_current_user(); |
| 353 | $from_email = sanitize_email( (string) get_option( 'admin_email', '' ) ); |
| 354 | $from_name = is_object( $current_user ) && isset( $current_user->display_name ) |
| 355 | ? sanitize_text_field( (string) $current_user->display_name ) |
| 356 | : ''; |
| 357 | |
| 358 | if ( '' !== $from_email && '' !== $from_name ) { |
| 359 | $headers[] = sprintf( 'From: %1$s <%2$s>', $from_name, $from_email ); |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | if ( null !== $this->mail_callback ) { |
| 364 | return (bool) call_user_func( $this->mail_callback, self::RECIPIENT, $subject, $message, $headers ); |
| 365 | } |
| 366 | |
| 367 | $result_sent = @wp_mail( self::RECIPIENT, $subject, $message, $headers ); |
| 368 | |
| 369 | return (bool) $result_sent; |
| 370 | } |
| 371 | |
| 372 | /** |
| 373 | * Collapse untrusted scalar presentation text to one safe email line. |
| 374 | * |
| 375 | * @param mixed $text Scalar presentation text. |
| 376 | * |
| 377 | * @return string Sanitized single-line text. |
| 378 | */ |
| 379 | private static function sanitize_line( $text ) { |
| 380 | return sanitize_text_field( is_scalar( $text ) ? (string) $text : '' ); |
| 381 | } |
| 382 | } |
| 383 |