transport_result; } private AppointmentsRepository $appointments; private LocationsRepository $locations; public function __construct() { $this->appointments = new AppointmentsRepository(); $this->locations = new LocationsRepository(); } /** * @return array */ public static function template_labels(): array { return array( 'customer_confirmation' => __( 'Customer confirmation', 'abientot' ), 'customer_confirmed' => __( 'Customer booking confirmed', 'abientot' ), 'admin_new_booking' => __( 'Admin new booking', 'abientot' ), 'customer_cancellation' => __( 'Customer cancellation', 'abientot' ), 'customer_rescheduling' => __( 'Customer rescheduling', 'abientot' ), 'customer_completed' => __( 'Customer completed booking', 'abientot' ), ); } /** * @return array */ public static function template_subject_defaults(): array { return array( 'customer_confirmation' => __( 'Your booking with {business_name}', 'abientot' ), 'customer_confirmed' => __( 'Your booking is confirmed', 'abientot' ), 'admin_new_booking' => __( 'New booking: {service_name} with {customer_name}', 'abientot' ), 'customer_cancellation' => __( 'Your booking has been cancelled', 'abientot' ), 'customer_rescheduling' => __( 'Your booking has been rescheduled', 'abientot' ), 'customer_completed' => __( 'Thank you for your booking', 'abientot' ), ); } /** * @return array */ public static function template_body_defaults(): array { return array( 'customer_confirmation' => self::html_template( __( 'Booking request received', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Thank you for your booking request. Here are the details we received:', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}', __( 'Location', 'abientot' ) => '{location_name}', __( 'Status', 'abientot' ) => '{status}' ) ) . '

{cancellation_contact}

{rescheduling_contact}

' ), 'customer_confirmed' => self::html_template( __( 'Booking confirmed', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Your booking has been confirmed. We look forward to seeing you.', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}', __( 'Location', 'abientot' ) => '{location_name}', __( 'Status', 'abientot' ) => '{status}' ) ) . '

{cancellation_contact}

{rescheduling_contact}

' ), 'admin_new_booking' => self::html_template( __( 'New booking received', 'abientot' ), '

' . __( 'A new booking has been created.', 'abientot' ) . '

' . self::details_table( array( __( 'Customer', 'abientot' ) => '{customer_name}', __( 'Email', 'abientot' ) => '{customer_email}', __( 'Phone', 'abientot' ) => '{customer_phone}', __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}', __( 'Status', 'abientot' ) => '{status}', __( 'Payment', 'abientot' ) => '{payment_status}', __( 'Booking ID', 'abientot' ) => '{booking_id}' ) ) ), 'customer_cancellation' => self::html_template( __( 'Booking cancelled', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Your booking has been cancelled.', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}', __( 'Location', 'abientot' ) => '{location_name}' ) ) ), 'customer_rescheduling' => self::html_template( __( 'Booking rescheduled', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Your booking has been rescheduled. Your updated details are below.', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'New time', 'abientot' ) => '{start}', __( 'Location', 'abientot' ) => '{location_name}' ) ) ), 'customer_completed' => self::html_template( __( 'Thank you for your visit', 'abientot' ), '

' . __( 'Hello {customer_name},', 'abientot' ) . '

' . __( 'Thank you for visiting {business_name}. We hope to see you again.', 'abientot' ) . '

' . self::details_table( array( __( 'Service', 'abientot' ) => '{service_name}', __( 'When', 'abientot' ) => '{start}' ) ) ), ); } /** * Previous plain-text defaults used to upgrade only untouched saved templates. * * @return array */ public static function legacy_template_body_defaults(): array { return array( 'customer_confirmation' => "Hello {customer_name},\n\nYour booking request has been received.\n\nService: {service_name}\nWhen: {start}\nLocation: {location_name}\n\n{cancellation_contact}\n{rescheduling_contact}\n\nThank you,\n{business_name}", 'customer_confirmed' => "Hello {customer_name},\n\nYour booking has been confirmed.\n\nService: {service_name}\nWhen: {start}\nLocation: {location_name}\n\n{cancellation_contact}\n{rescheduling_contact}\n\nThank you,\n{business_name}", 'admin_new_booking' => "A new booking has been created.\n\nCustomer: {customer_name}\nEmail: {customer_email}\nPhone: {customer_phone}\nService: {service_name}\nWhen: {start}\nStatus: {status}\nPayment: {payment_status}\n\nBooking ID: {booking_id}", 'customer_cancellation' => "Hello {customer_name},\n\nYour booking has been cancelled.\n\nService: {service_name}\nWhen: {start}\n\n{business_name}", 'customer_rescheduling' => "Hello {customer_name},\n\nYour booking has been rescheduled.\n\nService: {service_name}\nNew time: {start}\nLocation: {location_name}\n\n{business_name}", 'customer_completed' => "Hello {customer_name},\n\nThank you for visiting {business_name}.\n\nService: {service_name}\nWhen: {start}", ); } private static function html_template( string $heading, string $content ): string { return '
{business_name}

' . $heading . '

' . $content . '
'; } /** * @param array $rows Detail labels and values. */ private static function details_table( array $rows ): string { $html = ''; foreach ( $rows as $label => $value ) { $html .= ''; } return $html . '
' . $label . '' . $value . '
'; } /** * Sends customer and admin creation notifications for a booking. */ public function send_booking_created( int $appointment_id ): void { $this->send_template_for_booking( 'customer_confirmation', $appointment_id ); $this->send_template_for_booking( 'admin_new_booking', $appointment_id ); } /** * Sends the customer notification matching a booking lifecycle event. */ public function send_booking_event( int $appointment_id, string $event, string $body_override = '' ): void { $map = array( 'confirmed' => 'customer_confirmed', 'cancelled' => 'customer_cancellation', 'rescheduled' => 'customer_rescheduling', 'completed' => 'customer_completed', ); if ( isset( $map[ $event ] ) ) { $this->send_template_for_booking( $map[ $event ], $appointment_id, $body_override ); } } /** * Returns the customer notification preview for a booking event. * * @return array{subject:string,body:string} */ public function preview_booking_event( int $appointment_id, string $event, string $start_utc = '', string $end_utc = '', string $body_override = '' ): array { $map = array( 'confirmed' => 'customer_confirmed', 'cancelled' => 'customer_cancellation', 'rescheduled' => 'customer_rescheduling', 'completed' => 'customer_completed', ); if ( ! isset( $map[ $event ] ) ) { return array( 'subject' => '', 'body' => '' ); } $booking = $this->appointments->find_for_admin( $appointment_id ); if ( ! $booking ) { return array( 'subject' => '', 'body' => '' ); } if ( '' !== $start_utc ) { $booking['start_utc'] = $start_utc; } if ( '' !== $end_utc ) { $booking['end_utc'] = $end_utc; } $template_key = $map[ $event ]; $variables = $this->variables_for_booking( $booking ); $body = '' !== $body_override ? $body_override : (string) $this->setting( 'email_' . $template_key . '_body' ); return array( 'subject' => $this->render_template( $this->setting( 'email_' . $template_key . '_subject' ), $variables ), 'body' => (string) apply_filters( 'abientot_email_booking_body', $this->render_template( $body, $variables ), $booking, $template_key ), ); } /** * Returns a rendered preview for settings UI. * * @return array{subject:string,body:string} */ public function preview( string $template_key, int $appointment_id = 0 ): array { $booking = $appointment_id > 0 ? $this->appointments->find_for_admin( $appointment_id ) : null; $variables = $booking ? $this->variables_for_booking( $booking ) : $this->sample_variables(); return array( 'subject' => $this->render_template( $this->setting( 'email_' . $template_key . '_subject' ), $variables ), 'body' => $this->render_template( $this->setting( 'email_' . $template_key . '_body' ), $variables ), ); } /** * Sends a sample test email. */ public function send_test( string $template_key, string $recipient, int $appointment_id = 0, ?string $subject_override = null, ?string $body_override = null ) { if ( ! is_email( $recipient ) || ! isset( self::template_labels()[ $template_key ] ) ) { return new WP_Error( 'abientot_test_email_invalid', __( 'Choose a valid template and recipient.', 'abientot' ) ); } $preview = $this->preview( $template_key, $appointment_id ); if ( null !== $subject_override || null !== $body_override ) { $booking = $appointment_id > 0 ? $this->appointments->find_for_admin( $appointment_id ) : null; $variables = $booking ? $this->variables_for_booking( $booking ) : $this->sample_variables(); if ( null !== $subject_override ) { $preview['subject'] = $this->render_template( $subject_override, $variables ); } if ( null !== $body_override ) { $preview['body'] = $this->render_template( $body_override, $variables ); } } $sent = $this->send( $recipient, sprintf( /* translators: %s: email subject. */ __( '[Test] %s', 'abientot' ), $preview['subject'] ), $preview['body'], array( 'appointment_id' => $appointment_id, 'template' => 'test', 'recipient_role' => 'test' ) ); return $sent ? true : new WP_Error( 'abientot_test_email_failed', __( 'WordPress could not send the test email.', 'abientot' ) ); } /** * Returns safe sample values for live admin template previews. * * @return array */ public function sample_template_variables(): array { return $this->sample_variables(); } /** Sends an explicitly authorized custom customer template; transport exceptions remain observable. */ public function send_custom_booking( int $appointment_id, string $subject, string $body, array $context = array() ): bool { $this->transport_result = null; $booking = $this->appointments->find_for_admin( $appointment_id ); if ( ! $booking || ! is_email( (string) $booking['customer_email'] ) ) { return false; } $variables = $this->variables_for_booking( $booking ); $template = in_array( $context['template'] ?? '', array( 'custom', 'reminder', 'service_preparation', 'service_followup' ), true ) ? $context['template'] : 'custom'; $rendered = ( $context['rendered'] ?? false ) === true; $renderedBody = $rendered ? $body : $this->render_template( $body, $variables ); if ( $template === 'reminder' ) { $renderedBody = (string) apply_filters( 'abientot_email_booking_body', $renderedBody, $booking, $template ); } return $this->send( (string) $booking['customer_email'], $rendered ? $subject : $this->render_template( $subject, $variables ), $renderedBody, array( 'appointment_id' => $appointment_id, 'template' => $template, 'recipient_role' => 'customer' ) ); } private function send_template_for_booking( string $template_key, int $appointment_id, string $body_override = '' ): void { try { $this->deliver_template_for_booking( $template_key, $appointment_id, $body_override ); } catch ( \Throwable $exception ) { // A transport/filter failure must never invite a duplicate booking retry. ( new \Abientot\Repositories\ActivityLogRepository() )->record_booking_event( $appointment_id, 'notification_failed', __( 'The booking was saved, but its notification could not be sent.', 'abientot' ), array( 'template' => $template_key ) ); } } /** Explicit manager callers authorize and acknowledge duplicate risk before invoking this. */ public function resend_booking_template( int $appointment_id, string $template_key ): bool { if ( \Abientot\Repositories\BookingLock::is_held() || ! isset( self::template_labels()[ $template_key ] ) ) { return false; } \Abientot\Support\TransactionGuard::prepare_write(); return $this->deliver_template_for_booking( $template_key, $appointment_id, '', true ); } private function deliver_template_for_booking( string $template_key, int $appointment_id, string $body_override = '', bool $retry = false ): bool { $settings = Settings::values(); if ( empty( $settings[ 'email_' . $template_key . '_enabled' ] ) ) { return false; } $booking = $this->appointments->find_for_admin( $appointment_id ); if ( ! $booking || ( $retry && ! is_email( (string) ( $booking['customer_email'] ?? '' ) ) ) ) { return false; } $recipient = 'admin_new_booking' === $template_key ? (string) $settings['admin_notification_email'] : (string) $booking['customer_email']; if ( ! is_email( $recipient ) ) { return false; } $variables = $this->variables_for_booking( $booking ); $subject = $this->render_template( (string) $settings[ 'email_' . $template_key . '_subject' ], $variables ); $body = $this->render_template( '' !== $body_override ? $body_override : (string) $settings[ 'email_' . $template_key . '_body' ], $variables ); $body = (string) apply_filters( 'abientot_email_booking_body', $body, $booking, $template_key ); if ( ! $this->send( $recipient, $subject, $body, array( 'appointment_id' => $appointment_id, 'template' => $template_key, 'recipient_role' => 'admin_new_booking' === $template_key ? 'admin' : 'customer' ) ) ) { throw new Exception( 'Delivery failed.' ); } return true; } private function send( string $recipient, string $subject, string $body, array $context = array() ): bool { $settings = Settings::values(); $from = is_email( (string) $settings['email_from_address'] ) ? (string) $settings['email_from_address'] : (string) get_option( 'admin_email' ); $name = '' !== trim( (string) $settings['email_from_name'] ) ? (string) $settings['email_from_name'] : (string) get_bloginfo( 'name' ); $reply_to = is_email( (string) $settings['email_reply_to_address'] ) ? (string) $settings['email_reply_to_address'] : $from; $headers = array( 'Content-Type: text/html; charset=UTF-8', 'From: ' . $this->clean_header_value( $name ) . ' <' . $from . '>', 'Reply-To: ' . $reply_to, ); $email = array( 'to' => $recipient, 'subject' => $this->plain_text( $subject ), 'body' => $this->with_credit( $this->html_body( $body ) ), 'headers' => $headers, ); $attempt = array( 'attempt_uuid' => wp_generate_uuid4(), 'appointment_id' => max( 0, (int) ( $context['appointment_id'] ?? 0 ) ), 'template' => (string) ( $context['template'] ?? 'custom' ), 'recipient_role' => (string) ( $context['recipient_role'] ?? 'customer' ), 'content_revision' => hash_hmac( 'sha256', wp_json_encode( array( $email['subject'], $email['body'] ) ), wp_salt( 'auth' ) ), 'occurred_at' => gmdate( 'Y-m-d H:i:s' ), ); try { $email = apply_filters( 'abientot_before_email_sent', $email ); if ( false === $email || ! is_array( $email ) ) { $this->observe_attempt( $attempt, 'suppressed' ); return false; } $attempt['content_revision'] = hash_hmac( 'sha256', wp_json_encode( array( $email['subject'], $email['body'] ) ), wp_salt( 'auth' ) ); $sent = wp_mail( (string) $email['to'], (string) $email['subject'], (string) $email['body'], (array) $email['headers'] ); $this->observe_attempt( $attempt, $sent ? 'accepted' : 'rejected' ); return $sent; } catch ( \Throwable $error ) { $this->observe_attempt( $attempt, 'exception' ); throw $error; } } /** Observational only: no listener may alter the transport or saved booking result. */ private function observe_attempt( array $attempt, string $result ): void { $this->transport_result = $result; global $wpdb; $suppressed = $wpdb->suppress_errors( true ); try { do_action( 'abientot_email_attempt', array_merge( $attempt, array( 'result' => $result ) ) ); } catch ( \Throwable $error ) { /* The attempt log is best effort, not a durable send intent. */ } finally { $wpdb->suppress_errors( $suppressed ); } } /** * @param array $booking Joined booking row. * @return array */ public function variables_for_booking( array $booking ): array { $settings = Settings::values(); $variables = array( 'business_name' => (string) $settings['business_name'], 'booking_id' => (string) $booking['id'], 'customer_name' => (string) $booking['customer_name'], 'customer_email' => (string) $booking['customer_email'], 'customer_phone' => (string) $booking['customer_phone'], 'service_name' => (string) $booking['service_name'], 'staff_name' => (string) $booking['staff_name'], 'location_name' => (string) $booking['location_name'], 'status' => (string) $booking['status_label'], 'start' => $this->format_booking_time( (string) $booking['start_utc'], (string) $booking['timezone'] ), 'end' => $this->format_booking_time( (string) $booking['end_utc'], (string) $booking['timezone'] ), 'price' => \Abientot\Support\Currency::format( (int) $booking['price_cents'], $booking['currency'] ), 'currency' => strtoupper( (string) $booking['currency'] ), 'payment_status' => ucwords( str_replace( '_', ' ', (string) $booking['payment_status'] ) ), 'customer_note' => (string) $booking['customer_note'], 'site_name' => (string) get_bloginfo( 'name' ), 'site_url' => home_url( '/' ), 'cancel_url' => '', 'reschedule_url' => '', 'cancellation_contact' => $this->contact_sentence( 'cancel' ), 'rescheduling_contact' => $this->contact_sentence( 'reschedule' ), ); /** * Filters email template variables. Future add-ons may add values. * * @param array $variables Template variables. * @param array $booking Joined booking row. */ return apply_filters( 'abientot_email_template_variables', $variables, $booking ); } /** * @return array */ private function sample_variables(): array { $settings = Settings::values(); return array( 'business_name' => (string) $settings['business_name'], 'booking_id' => '123', 'customer_name' => __( 'Sample Customer', 'abientot' ), 'customer_email' => 'customer@example.com', 'customer_phone' => '+1 555 0100', 'service_name' => __( 'Sample Service', 'abientot' ), 'staff_name' => __( 'Default Staff', 'abientot' ), 'location_name' => __( 'Main Location', 'abientot' ), 'status' => __( 'Pending', 'abientot' ), 'start' => \Abientot\Support\Locale::date( DateTimeHelper::datetime_display_format(), time() + DAY_IN_SECONDS, DateTimeHelper::business_timezone() ), 'end' => \Abientot\Support\Locale::date( DateTimeHelper::datetime_display_format(), time() + DAY_IN_SECONDS + HOUR_IN_SECONDS, DateTimeHelper::business_timezone() ), 'price' => 'USD 75.00', 'currency' => 'USD', 'payment_status' => __( 'Unpaid', 'abientot' ), 'customer_note' => __( 'Sample customer note.', 'abientot' ), 'site_name' => (string) get_bloginfo( 'name' ), 'site_url' => home_url( '/' ), 'cancel_url' => '', 'reschedule_url' => '', 'cancellation_contact' => $this->contact_sentence( 'cancel' ), 'rescheduling_contact' => $this->contact_sentence( 'reschedule' ), ); } private function contact_sentence( string $action ): string { $contact = $this->contact_details(); if ( 'cancel' === $action ) { return sprintf( /* translators: 1: phone number, 2: email address. */ __( 'To cancel this booking, please contact us by phone at %1$s or by email at %2$s.', 'abientot' ), $contact['phone'], $contact['email'] ); } return sprintf( /* translators: 1: phone number, 2: email address. */ __( 'To reschedule this booking, please contact us by phone at %1$s or by email at %2$s.', 'abientot' ), $contact['phone'], $contact['email'] ); } /** * @return array{phone:string,email:string} */ private function contact_details(): array { $settings = Settings::values(); $location = $this->locations->default_location(); $email = is_array( $location ) && is_email( (string) ( $location['email'] ?? '' ) ) ? (string) $location['email'] : (string) $settings['email_reply_to_address']; $phone = is_array( $location ) && '' !== trim( (string) ( $location['phone'] ?? '' ) ) ? (string) $location['phone'] : __( 'the business phone number', 'abientot' ); if ( ! is_email( $email ) ) { $email = (string) get_option( 'admin_email' ); } return array( 'phone' => $phone, 'email' => $email, ); } /** * @param array $variables Template variables. */ private function render_template( string $template, array $variables ): string { $replace = array(); foreach ( $variables as $key => $value ) { $replace[ '{' . $key . '}' ] = $value; } return strtr( $template, $replace ); } private function setting( string $key ): string { $settings = Settings::values(); return isset( $settings[ $key ] ) ? (string) $settings[ $key ] : ''; } private function format_booking_time( string $utc_datetime, string $timezone ): string { try { return DateTimeHelper::format_utc_datetime( $utc_datetime, $timezone ); } catch ( Exception $exception ) { return get_date_from_gmt( $utc_datetime, 'Y-m-d H:i' ); } } private function plain_text( string $value ): string { $value = wp_strip_all_tags( $value ); $value = str_replace( array( "\r\n", "\r" ), "\n", $value ); return trim( $value ); } private function html_body( string $value ): string { $value = str_replace( array( "\r\n", "\r" ), "\n", $value ); if ( $value === wp_strip_all_tags( $value ) ) { $value = wpautop( $value ); } return wp_kses_post( $value ); } /** * Adds the "Powered by Abientot" line only when the administrator turned it on in the * email settings. Templates saved by 1.0.x contain the line in their HTML: it is removed * first, so it never appears without that choice. */ private function with_credit( string $body ): string { $body = (string) preg_replace( '#
[^<]*
#i', '', $body ); if ( empty( Settings::values()['email_show_credit'] ) ) { return $body; } $credit = '
' . esc_html__( 'Powered by Abientot', 'abientot' ) . '
'; $trimmed = rtrim( $body ); // Inside the card of the default design; after the content for custom HTML. return str_ends_with( $trimmed, '' ) ? substr( $trimmed, 0, -12 ) . $credit . '' : $body . $credit; } private function clean_header_value( string $value ): string { return trim( str_replace( array( "\r", "\n" ), '', wp_strip_all_tags( $value ) ) ); } }