send_email( $email_data ); } delete_transient( 'maple_carts_sending_emails' ); } /** * Send a recovery email. * * @param object $email_data Email data from database. * @return bool */ public function send_email( $email_data ) { // Validate email address. if ( ! is_email( $email_data->email ) ) { Maple_Carts_DB::update_email_log( $email_data->id, 'failed', [ 'error_message' => 'Invalid email address' ] ); return false; } // Check if cart still qualifies. $cart = Maple_Carts_DB::get_cart_by_session( $email_data->session_id ); if ( ! $cart || 'abandoned' !== $cart->status || $cart->unsubscribed ) { Maple_Carts_DB::update_email_log( $email_data->id, 'cancelled' ); return false; } // Check for out-of-stock products. $cart_contents = maybe_unserialize( $email_data->cart_contents ); if ( $this->cart_has_out_of_stock( $cart_contents ) ) { Maple_Carts_DB::update_email_log( $email_data->id, 'cancelled', [ 'error_message' => 'Products out of stock' ] ); return false; } // Generate coupon if needed. $coupon_code = ''; if ( $email_data->include_coupon ) { $coupon_code = $this->generate_coupon( $email_data->coupon_type, $email_data->coupon_amount, $email_data->coupon_expires_days, $email_data->cart_id ); // Store coupon code in log. global $wpdb; $wpdb->update( $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE, [ 'coupon_code' => $coupon_code ], [ 'id' => $email_data->id ] ); } // Generate recovery URL with coupon. $recovery_url = Maple_Carts_Tracking::instance()->generate_recovery_url( $email_data->session_id, $coupon_code ); // Add UTM parameters for analytics attribution. $recovery_url = add_query_arg( [ 'utm_source' => 'maple_carts', 'utm_medium' => 'email', 'utm_campaign' => 'cart_recovery', 'utm_content' => 'email_' . $email_data->email_template_id, ], $recovery_url ); // Parse email content. $subject = $this->parse_template( $email_data->subject, $email_data, $coupon_code, $recovery_url ); $body = $this->parse_template( $email_data->body, $email_data, $coupon_code, $recovery_url ); // Send email. $sent = $this->dispatch_email( $email_data->email, $subject, $body ); if ( $sent ) { Maple_Carts_DB::update_email_log( $email_data->id, 'sent' ); /** * Fires after a recovery email is sent. * * @param object $email_data Email data. * @param string $subject Email subject. */ do_action( 'maple_carts_email_sent', $email_data, $subject ); return true; } else { Maple_Carts_DB::update_email_log( $email_data->id, 'failed', [ 'error_message' => 'Failed to send email' ] ); return false; } } /** * Parse email template with placeholders. * * @param string $template Template string. * @param object $email_data Email data. * @param string $coupon_code Coupon code. * @param string $recovery_url Recovery URL. * @return string */ private function parse_template( $template, $email_data, $coupon_code, $recovery_url ) { $cart_contents = maybe_unserialize( $email_data->cart_contents ); // Customer name fallback. $customer_name = $email_data->customer_name; if ( empty( $customer_name ) ) { $customer_name = __( 'there', 'maple-carts' ); } // Generate cart contents HTML. $cart_html = $this->generate_cart_html( $cart_contents, $email_data->currency ); // Generate product names list. $product_names = []; if ( is_array( $cart_contents ) ) { foreach ( $cart_contents as $item ) { $product_names[] = $item['name']; } } // Coupon display amount. $coupon_display = ''; if ( ! empty( $coupon_code ) ) { if ( 'percent' === $email_data->coupon_type ) { $coupon_display = $email_data->coupon_amount . '%'; } else { $coupon_display = wc_price( $email_data->coupon_amount ); } } // Unsubscribe link (using signed URL from Tracking class). $unsubscribe_url = Maple_Carts_Tracking::instance()->generate_unsubscribe_url( $email_data->session_id ); $unsubscribe_link = sprintf( '%s', esc_url( $unsubscribe_url ), __( 'Unsubscribe from these emails', 'maple-carts' ) ); // Delete data link for GDPR compliance (optional). $delete_data_link = ''; if ( 'yes' === Maple_Carts::get_option( 'show_delete_link', 'no' ) ) { $delete_data_url = Maple_Carts_Privacy::generate_deletion_url( $email_data->session_id ); $delete_data_link = sprintf( ' | %s', esc_url( $delete_data_url ), __( 'Delete my data', 'maple-carts' ) ); } // Replacements. $replacements = [ '{{customer_name}}' => esc_html( $customer_name ), '{{customer_email}}' => esc_html( $email_data->email ), '{{cart_contents}}' => $cart_html, '{{cart_total}}' => wc_price( $email_data->cart_total, [ 'currency' => $email_data->currency ] ), '{{product_names}}' => esc_html( implode( ', ', $product_names ) ), '{{recovery_url}}' => esc_url( $recovery_url ), '{{coupon_code}}' => esc_html( $coupon_code ), '{{coupon_amount}}' => $coupon_display, '{{coupon_expires}}' => $email_data->coupon_expires_days, '{{site_name}}' => esc_html( get_bloginfo( 'name' ) ), '{{site_url}}' => esc_url( home_url() ), '{{unsubscribe_link}}' => $unsubscribe_link, '{{delete_data_link}}' => $delete_data_link, '{{current_date}}' => date_i18n( get_option( 'date_format' ) ), ]; /** * Filter email template replacements. * * @param array $replacements Placeholder replacements. * @param object $email_data Email data. */ $replacements = apply_filters( 'maple_carts_email_replacements', $replacements, $email_data ); return str_replace( array_keys( $replacements ), array_values( $replacements ), $template ); } /** * Generate cart contents HTML. * * @param array $cart_contents Cart contents. * @param string $currency Currency code. * @return string */ private function generate_cart_html( $cart_contents, $currency = 'USD' ) { if ( empty( $cart_contents ) || ! is_array( $cart_contents ) ) { return ''; } $html = ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $total = 0; foreach ( $cart_contents as $item ) { $line_total = $item['price'] * $item['quantity']; $total += $line_total; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; } $html .= '
' . __( 'Product', 'maple-carts' ) . '' . __( 'Qty', 'maple-carts' ) . '' . __( 'Price', 'maple-carts' ) . '
'; if ( ! empty( $item['image'] ) ) { $html .= ''; } $html .= '' . esc_html( $item['name'] ) . ''; $html .= '' . esc_html( $item['quantity'] ) . '' . wc_price( $line_total, [ 'currency' => $currency ] ) . '
'; return $html; } /** * Check if cart has out of stock products. * * @param array $cart_contents Cart contents. * @return bool */ private function cart_has_out_of_stock( $cart_contents ) { if ( empty( $cart_contents ) || ! is_array( $cart_contents ) ) { return false; } foreach ( $cart_contents as $item ) { $product_id = ! empty( $item['variation_id'] ) ? $item['variation_id'] : $item['product_id']; $product = wc_get_product( $product_id ); if ( ! $product || ! $product->is_in_stock() ) { return true; } } return false; } /** * Generate a coupon code. * * @param string $type Coupon type (percent, fixed_cart). * @param float $amount Discount amount. * @param int $expires_days Days until expiry. * @param int $cart_id Cart ID for reference. * @return string */ public function generate_coupon( $type, $amount, $expires_days, $cart_id ) { // Check if cart already has a coupon (prevents double creation on cron misfire). $existing_coupon = $this->get_cart_coupon( $cart_id ); if ( $existing_coupon ) { return $existing_coupon; } $coupon_code = 'MAPLE-' . strtoupper( wp_generate_password( 8, false, false ) ); $coupon = new WC_Coupon(); $coupon->set_code( $coupon_code ); $coupon->set_discount_type( $type ); $coupon->set_amount( $amount ); $coupon->set_individual_use( true ); $coupon->set_usage_limit( 1 ); $coupon->set_usage_limit_per_user( 1 ); if ( $expires_days > 0 ) { $expiry = strtotime( "+{$expires_days} days" ); $coupon->set_date_expires( $expiry ); } $coupon->save(); // Mark as Maple Carts coupon for cleanup with creation timestamp. update_post_meta( $coupon->get_id(), '_maple_carts_coupon', '1' ); update_post_meta( $coupon->get_id(), '_maple_carts_cart_id', $cart_id ); update_post_meta( $coupon->get_id(), '_maple_carts_created_at', time() ); return $coupon_code; } /** * Get existing coupon for a cart (prevents double creation). * * @param int $cart_id Cart ID. * @return string|null Coupon code or null. */ private function get_cart_coupon( $cart_id ) { global $wpdb; // Check email log for existing coupon. $log_table = $wpdb->prefix . MAPLE_CARTS_EMAIL_LOG_TABLE; $coupon = $wpdb->get_var( $wpdb->prepare( "SELECT coupon_code FROM {$log_table} WHERE cart_id = %d AND coupon_code IS NOT NULL AND coupon_code != '' LIMIT 1", $cart_id ) ); if ( $coupon ) { // Verify coupon still exists in WooCommerce. $coupon_id = wc_get_coupon_id_by_code( $coupon ); if ( $coupon_id ) { return $coupon; } } return null; } /** * Dispatch email using wp_mail (works with any SMTP plugin). * * @param string $to Recipient email. * @param string $subject Email subject. * @param string $body Email body (HTML). * @return bool */ private function dispatch_email( $to, $subject, $body ) { // Sanitize header values to prevent header injection. $from_name = $this->sanitize_header_value( Maple_Carts::get_option( 'from_name', get_bloginfo( 'name' ) ) ); $from_email = $this->sanitize_header_value( Maple_Carts::get_option( 'from_email', get_option( 'admin_email' ) ) ); $reply_to = $this->sanitize_header_value( Maple_Carts::get_option( 'reply_to', get_option( 'admin_email' ) ) ); // Validate email addresses. if ( ! is_email( $from_email ) ) { $from_email = get_option( 'admin_email' ); } if ( ! is_email( $reply_to ) ) { $reply_to = get_option( 'admin_email' ); } // Wrap in basic HTML template. $html_body = $this->wrap_email_html( $body ); // Generate plain text alternative for better spam scores. $plain_body = $this->generate_plain_text( $body ); // Create multipart boundary. $boundary = 'maple_carts_' . md5( uniqid( time() ) ); $headers = [ "From: {$from_name} <{$from_email}>", "Reply-To: {$reply_to}", "Content-Type: multipart/alternative; boundary=\"{$boundary}\"", ]; // Build multipart message. $message = "--{$boundary}\r\n"; $message .= "Content-Type: text/plain; charset=UTF-8\r\n"; $message .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; $message .= $plain_body . "\r\n\r\n"; $message .= "--{$boundary}\r\n"; $message .= "Content-Type: text/html; charset=UTF-8\r\n"; $message .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; $message .= $html_body . "\r\n\r\n"; $message .= "--{$boundary}--"; /** * Filter the email before sending. * * @param array $email Email data. */ $email = apply_filters( 'maple_carts_before_send_email', [ 'to' => $to, 'subject' => $subject, 'body' => $message, 'headers' => $headers, ] ); // Use wp_mail which integrates with SMTP plugins. $sent = wp_mail( $email['to'], $email['subject'], $email['body'], $email['headers'] ); // Retry once on failure. if ( ! $sent ) { sleep( 1 ); $sent = wp_mail( $email['to'], $email['subject'], $email['body'], $email['headers'] ); } return $sent; } /** * Generate plain text version of HTML email. * * @param string $html HTML content. * @return string Plain text content. */ private function generate_plain_text( $html ) { // Convert common HTML elements. $text = $html; // Convert links to text format. $text = preg_replace( '/]+href=["\']([^"\']+)["\'][^>]*>([^<]+)<\/a>/i', '$2 ($1)', $text ); // Convert line breaks and paragraphs. $text = preg_replace( '//i', "\n", $text ); $text = preg_replace( '/<\/p>/i', "\n\n", $text ); $text = preg_replace( '/<\/div>/i', "\n", $text ); $text = preg_replace( '/<\/h[1-6]>/i', "\n\n", $text ); // Convert list items. $text = preg_replace( '/]*>/i', "• ", $text ); $text = preg_replace( '/<\/li>/i', "\n", $text ); // Strip remaining tags. $text = wp_strip_all_tags( $text ); // Decode HTML entities. $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ); // Clean up whitespace. $text = preg_replace( '/[ \t]+/', ' ', $text ); $text = preg_replace( '/\n{3,}/', "\n\n", $text ); $text = trim( $text ); return $text; } /** * Sanitize a value for use in email headers. * Prevents header injection attacks by removing newlines and control characters. * * @param string $value The value to sanitize. * @return string */ private function sanitize_header_value( $value ) { // Remove any newlines, carriage returns, and null bytes. $value = preg_replace( '/[\r\n\0]/', '', $value ); // Remove any remaining control characters. $value = preg_replace( '/[\x00-\x1F\x7F]/', '', $value ); return trim( $value ); } /** * Wrap email content in HTML template. * * @param string $content Email content. * @return string */ private function wrap_email_html( $content ) { $template = '
{{content}}
'; return str_replace( '{{content}}', wpautop( $content ), $template ); } /** * AJAX handler for email preview. */ public function ajax_preview_email() { check_ajax_referer( 'maple_carts_admin', 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( 'Permission denied' ); } $template_id = isset( $_POST['template_id'] ) ? absint( $_POST['template_id'] ) : 0; $subject = isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : ''; $body = isset( $_POST['body'] ) ? wp_kses_post( wp_unslash( $_POST['body'] ) ) : ''; // Create dummy data for preview. $dummy_data = (object) [ 'email' => 'customer@example.com', 'customer_name' => 'John Doe', 'cart_contents' => maybe_serialize( [ [ 'product_id' => 1, 'name' => 'Sample Product', 'price' => 29.99, 'quantity' => 2, 'image' => '', ], ] ), 'cart_total' => 59.98, 'currency' => get_woocommerce_currency(), 'session_id' => 'preview-session', 'include_coupon' => true, 'coupon_type' => 'percent', 'coupon_amount' => 10, 'coupon_expires_days' => 7, ]; $parsed_subject = $this->parse_template( $subject, $dummy_data, 'PREVIEW10', '#' ); $parsed_body = $this->parse_template( $body, $dummy_data, 'PREVIEW10', '#' ); $html_body = $this->wrap_email_html( $parsed_body ); wp_send_json_success( [ 'subject' => $parsed_subject, 'body' => $html_body, ] ); } /** * AJAX handler for sending test email. */ public function ajax_send_test_email() { check_ajax_referer( 'maple_carts_admin', 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( 'Permission denied' ); } $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; $subject = isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : ''; $body = isset( $_POST['body'] ) ? wp_kses_post( wp_unslash( $_POST['body'] ) ) : ''; if ( ! is_email( $email ) ) { wp_send_json_error( __( 'Invalid email address', 'maple-carts' ) ); } // Create dummy data. $dummy_data = (object) [ 'email' => $email, 'customer_name' => 'Test Customer', 'cart_contents' => maybe_serialize( [ [ 'product_id' => 1, 'name' => 'Test Product', 'price' => 49.99, 'quantity' => 1, 'image' => '', ], ] ), 'cart_total' => 49.99, 'currency' => get_woocommerce_currency(), 'session_id' => 'test-session', 'include_coupon' => true, 'coupon_type' => 'percent', 'coupon_amount' => 10, 'coupon_expires_days' => 7, ]; $parsed_subject = $this->parse_template( $subject, $dummy_data, 'TEST10OFF', '#' ); $parsed_body = $this->parse_template( $body, $dummy_data, 'TEST10OFF', '#' ); $sent = $this->dispatch_email( $email, '[TEST] ' . $parsed_subject, $parsed_body ); if ( $sent ) { wp_send_json_success( __( 'Test email sent successfully!', 'maple-carts' ) ); } else { wp_send_json_error( __( 'Failed to send test email. Check your email configuration.', 'maple-carts' ) ); } } }