monorepo/native/wordpress/maple-carts-wp/includes/class-maple-carts-emails.php

633 lines
23 KiB
PHP

<?php
/**
* Email handling for Maple Carts.
*
* @package MapleCarts
*/
defined( 'ABSPATH' ) || exit;
/**
* Emails class.
*/
class Maple_Carts_Emails {
/**
* Single instance.
*
* @var Maple_Carts_Emails
*/
private static $instance = null;
/**
* Get instance.
*
* @return Maple_Carts_Emails
*/
public static function instance() {
if ( is_null( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
// Cron hook for sending emails.
add_action( 'maple_carts_send_emails', [ $this, 'process_pending_emails' ] );
// AJAX for preview email.
add_action( 'wp_ajax_maple_carts_preview_email', [ $this, 'ajax_preview_email' ] );
add_action( 'wp_ajax_maple_carts_send_test_email', [ $this, 'ajax_send_test_email' ] );
}
/**
* Process pending emails.
* Includes lock to prevent overlapping cron runs.
*/
public function process_pending_emails() {
// Skip if Mailjet mode is enabled - emails are handled by Mailjet automations.
if ( 'mailjet' === Maple_Carts::get_option( 'email_mode', 'builtin' ) ) {
return;
}
// Prevent overlapping cron runs.
if ( get_transient( 'maple_carts_sending_emails' ) ) {
return;
}
set_transient( 'maple_carts_sending_emails', 1, 300 ); // 5 minute lock.
$pending = Maple_Carts_DB::get_pending_emails();
foreach ( $pending as $email_data ) {
$this->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(
'<a href="%s" style="color:#999;">%s</a>',
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(
' | <a href="%s" style="color:#999;">%s</a>',
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 = '<table style="width:100%;border-collapse:collapse;margin:20px 0;">';
$html .= '<thead><tr style="background:#f7f7f7;">';
$html .= '<th style="padding:12px;text-align:left;border-bottom:2px solid #ddd;">' . __( 'Product', 'maple-carts' ) . '</th>';
$html .= '<th style="padding:12px;text-align:center;border-bottom:2px solid #ddd;">' . __( 'Qty', 'maple-carts' ) . '</th>';
$html .= '<th style="padding:12px;text-align:right;border-bottom:2px solid #ddd;">' . __( 'Price', 'maple-carts' ) . '</th>';
$html .= '</tr></thead><tbody>';
$total = 0;
foreach ( $cart_contents as $item ) {
$line_total = $item['price'] * $item['quantity'];
$total += $line_total;
$html .= '<tr>';
$html .= '<td style="padding:12px;border-bottom:1px solid #eee;">';
if ( ! empty( $item['image'] ) ) {
$html .= '<img src="' . esc_url( $item['image'] ) . '" alt="" style="width:50px;height:50px;object-fit:cover;margin-right:10px;vertical-align:middle;">';
}
$html .= '<span style="vertical-align:middle;">' . esc_html( $item['name'] ) . '</span>';
$html .= '</td>';
$html .= '<td style="padding:12px;text-align:center;border-bottom:1px solid #eee;">' . esc_html( $item['quantity'] ) . '</td>';
$html .= '<td style="padding:12px;text-align:right;border-bottom:1px solid #eee;">' . wc_price( $line_total, [ 'currency' => $currency ] ) . '</td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
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( '/<a[^>]+href=["\']([^"\']+)["\'][^>]*>([^<]+)<\/a>/i', '$2 ($1)', $text );
// Convert line breaks and paragraphs.
$text = preg_replace( '/<br\s*\/?>/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( '/<li[^>]*>/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 = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f4f4f4;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Oxygen-Sans,Ubuntu,Cantarell,\'Helvetica Neue\',sans-serif;">
<table role="presentation" style="width:100%;border-collapse:collapse;">
<tr>
<td style="padding:40px 20px;">
<table role="presentation" style="max-width:600px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.05);">
<tr>
<td style="padding:40px;">
{{content}}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>';
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' ) );
}
}
}