PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
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

383 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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