| 1 |
<?php |
| 2 |
/** |
| 3 |
* Email Handler - Sends donation-related emails |
| 4 |
* |
| 5 |
* @package SureDonation |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace SureDonation\Inc\Emails; |
| 9 |
|
| 10 |
use SureDonation\Inc\Database\Tables\Donations; |
| 11 |
use SureDonation\Inc\FormEditor\Assets; |
| 12 |
use SureDonation\Inc\Helper; |
| 13 |
use SureDonation\Inc\Payments\Offline\Offline_Helper; |
| 14 |
use SureDonation\Inc\Payments\Payment_Helper; |
| 15 |
|
| 16 |
// Exit if accessed directly. |
| 17 |
if ( ! defined( 'ABSPATH' ) ) { |
| 18 |
exit; |
| 19 |
} |
| 20 |
|
| 21 |
/** |
| 22 |
* Email_Handler class. |
| 23 |
* |
| 24 |
* Reads email notification config from per-form post meta |
| 25 |
* (_suredonation_form_email_notifications) and sends all enabled |
| 26 |
* notifications when a donation event occurs. |
| 27 |
* |
| 28 |
* @since 0.0.1 |
| 29 |
*/ |
| 30 |
class Email_Handler { |
| 31 |
/** |
| 32 |
* Valid trigger event types. |
| 33 |
* |
| 34 |
* @since 1.0.0 |
| 35 |
*/ |
| 36 |
public const EVENT_DONATION_COMPLETED = 'donation_completed'; |
| 37 |
public const EVENT_DONATION_PROCESSING = 'donation_processing'; |
| 38 |
public const EVENT_DONATION_FAILED = 'donation_failed'; |
| 39 |
public const EVENT_REFUND_PROCESSED = 'refund_processed'; |
| 40 |
|
| 41 |
/** |
| 42 |
* Send email notifications matching a specific event. |
| 43 |
* |
| 44 |
* Only notifications whose trigger matches the event (or trigger 'all') are sent. |
| 45 |
* |
| 46 |
* @param int $donation_id Donation ID. |
| 47 |
* @param int $campaign_id Campaign ID. |
| 48 |
* @param array<string, mixed> $donation_data Donation data array. |
| 49 |
* @param int $form_id Form post ID. |
| 50 |
* @param string $event The event that triggered this call. |
| 51 |
* @return void |
| 52 |
* @since 1.0.0 |
| 53 |
*/ |
| 54 |
public static function send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id = 0, $event = self::EVENT_DONATION_COMPLETED ) { |
| 55 |
// Prevent duplicate emails for the same donation + event (e.g. AJAX and webhook racing). |
| 56 |
// Note: get/set transient is non-atomic (TOCTOU), but the race window is microseconds |
| 57 |
// and the worst case is a duplicate email — not data corruption. wp_cache_add() would |
| 58 |
// only be atomic with an external object cache; most WP installs use DB transients |
| 59 |
// where it offers no real advantage. |
| 60 |
if ( $donation_id > 0 ) { |
| 61 |
$lock_key = 'suredonation_email_lock_' . $event . '_' . $donation_id; |
| 62 |
if ( get_transient( $lock_key ) ) { |
| 63 |
return; |
| 64 |
} |
| 65 |
set_transient( $lock_key, true, 60 ); |
| 66 |
} |
| 67 |
|
| 68 |
if ( empty( $form_id ) ) { |
| 69 |
$form_id = self::get_form_id_from_donation( $donation_id ); |
| 70 |
} |
| 71 |
|
| 72 |
$notifications = self::get_form_notifications( $form_id ); |
| 73 |
|
| 74 |
if ( empty( $notifications ) ) { |
| 75 |
return; |
| 76 |
} |
| 77 |
|
| 78 |
$campaign = get_post( $campaign_id ); |
| 79 |
if ( ! $campaign ) { |
| 80 |
return; |
| 81 |
} |
| 82 |
|
| 83 |
foreach ( $notifications as $notification ) { |
| 84 |
if ( empty( $notification['status'] ) ) { |
| 85 |
continue; |
| 86 |
} |
| 87 |
|
| 88 |
// Only send notifications whose trigger matches the current event. |
| 89 |
$trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : ''; |
| 90 |
if ( empty( $trigger ) || ( 'all' !== $trigger && $trigger !== $event ) ) { |
| 91 |
continue; |
| 92 |
} |
| 93 |
|
| 94 |
// Resolve email_to using smart tags. |
| 95 |
$email_to_raw = isset( $notification['email_to'] ) && is_string( $notification['email_to'] ) ? $notification['email_to'] : ''; |
| 96 |
$email_to = self::process_smart_tags( $email_to_raw, $donation_data, $campaign ); |
| 97 |
|
| 98 |
// Support comma-separated recipients. |
| 99 |
$recipients = array_map( 'trim', explode( ',', $email_to ) ); |
| 100 |
$recipients = array_filter( |
| 101 |
$recipients, |
| 102 |
static function ( string $email ): bool { |
| 103 |
return (bool) is_email( $email ); |
| 104 |
} |
| 105 |
); |
| 106 |
|
| 107 |
if ( empty( $recipients ) ) { |
| 108 |
continue; |
| 109 |
} |
| 110 |
|
| 111 |
foreach ( $recipients as $recipient ) { |
| 112 |
self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id ); |
| 113 |
} |
| 114 |
} |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Send donation confirmation emails. |
| 119 |
* |
| 120 |
* @param int $donation_id Donation ID. |
| 121 |
* @param int $campaign_id Campaign ID. |
| 122 |
* @param array<string, mixed> $donation_data Donation data array. |
| 123 |
* @param int $form_id Form post ID. |
| 124 |
* @return void |
| 125 |
* @since 0.0.1 |
| 126 |
*/ |
| 127 |
public static function send_donation_confirmation( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { |
| 128 |
self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_COMPLETED ); |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Send donation processing emails. |
| 133 |
* |
| 134 |
* @param int $donation_id Donation ID. |
| 135 |
* @param int $campaign_id Campaign ID. |
| 136 |
* @param array<string, mixed> $donation_data Donation data array. |
| 137 |
* @param int $form_id Form post ID. |
| 138 |
* @return void |
| 139 |
* @since 1.0.0 |
| 140 |
*/ |
| 141 |
public static function send_donation_processing( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { |
| 142 |
self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_PROCESSING ); |
| 143 |
} |
| 144 |
|
| 145 |
/** |
| 146 |
* Send donation failed emails. |
| 147 |
* |
| 148 |
* @param int $donation_id Donation ID. |
| 149 |
* @param int $campaign_id Campaign ID. |
| 150 |
* @param array<string, mixed> $donation_data Donation data array. |
| 151 |
* @param int $form_id Form post ID. |
| 152 |
* @return void |
| 153 |
* @since 1.0.0 |
| 154 |
*/ |
| 155 |
public static function send_donation_failed( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { |
| 156 |
self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_FAILED ); |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* Send refund processed emails. |
| 161 |
* |
| 162 |
* @param int $donation_id Donation ID. |
| 163 |
* @param int $campaign_id Campaign ID. |
| 164 |
* @param array<string, mixed> $donation_data Donation data array. |
| 165 |
* @param int $form_id Form post ID. |
| 166 |
* @return void |
| 167 |
* @since 1.0.0 |
| 168 |
*/ |
| 169 |
public static function send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { |
| 170 |
self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_REFUND_PROCESSED ); |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* Get email notifications from form post meta. |
| 175 |
* |
| 176 |
* @param int $form_id Form post ID. |
| 177 |
* @return array<int, array<string, mixed>> Array of notification configs. |
| 178 |
* @since 1.0.0 |
| 179 |
*/ |
| 180 |
private static function get_form_notifications( $form_id ) { |
| 181 |
if ( empty( $form_id ) ) { |
| 182 |
return []; |
| 183 |
} |
| 184 |
|
| 185 |
$raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true ); |
| 186 |
|
| 187 |
if ( empty( $raw ) || ! is_string( $raw ) ) { |
| 188 |
return []; |
| 189 |
} |
| 190 |
|
| 191 |
$notifications = json_decode( $raw, true ); |
| 192 |
|
| 193 |
if ( ! is_array( $notifications ) ) { |
| 194 |
return []; |
| 195 |
} |
| 196 |
|
| 197 |
return $notifications; |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Look up form_id from the donations table. |
| 202 |
* |
| 203 |
* @param int $donation_id Donation ID. |
| 204 |
* @return int Form ID, or 0 if not found. |
| 205 |
* @since 1.0.0 |
| 206 |
*/ |
| 207 |
private static function get_form_id_from_donation( $donation_id ) { |
| 208 |
if ( empty( $donation_id ) ) { |
| 209 |
return 0; |
| 210 |
} |
| 211 |
|
| 212 |
$donation = Donations::get( $donation_id ); |
| 213 |
if ( ! $donation || ! is_array( $donation ) ) { |
| 214 |
return 0; |
| 215 |
} |
| 216 |
|
| 217 |
return isset( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0; |
| 218 |
} |
| 219 |
|
| 220 |
/** |
| 221 |
* Send email using notification settings. |
| 222 |
* |
| 223 |
* @param string $to_email Recipient email address. |
| 224 |
* @param array<string, mixed> $notification Notification settings. |
| 225 |
* @param array<string, mixed> $donation_data Donation data for smart tags. |
| 226 |
* @param \WP_Post $campaign Campaign post object. |
| 227 |
* @param int $donation_id Optional donation ID. |
| 228 |
* @return bool True if email was sent successfully. |
| 229 |
* @since 0.0.1 |
| 230 |
*/ |
| 231 |
private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0 ) { |
| 232 |
if ( empty( $to_email ) || ! is_email( $to_email ) ) { |
| 233 |
return false; |
| 234 |
} |
| 235 |
|
| 236 |
// Prepare email data - ensure string types for process_smart_tags. |
| 237 |
$subject_raw = isset( $notification['subject'] ) && is_string( $notification['subject'] ) ? $notification['subject'] : ''; |
| 238 |
$email_body_raw = isset( $notification['email_body'] ) && is_string( $notification['email_body'] ) ? $notification['email_body'] : ''; |
| 239 |
$subject = self::process_smart_tags( $subject_raw, $donation_data, $campaign ); |
| 240 |
$email_body = self::process_smart_tags( $email_body_raw, $donation_data, $campaign ); |
| 241 |
|
| 242 |
// Get from name and email - ensure string types. |
| 243 |
$from_name_raw = isset( $notification['from_name'] ) && is_string( $notification['from_name'] ) ? $notification['from_name'] : ''; |
| 244 |
$from_name = ! empty( $from_name_raw ) ? $from_name_raw : get_bloginfo( 'name' ); |
| 245 |
$from_email = isset( $notification['from_email'] ) && is_string( $notification['from_email'] ) && ! empty( $notification['from_email'] ) |
| 246 |
? $notification['from_email'] |
| 247 |
: get_option( 'admin_email' ); |
| 248 |
$reply_to = isset( $notification['reply_to'] ) && is_string( $notification['reply_to'] ) && ! empty( $notification['reply_to'] ) |
| 249 |
? $notification['reply_to'] |
| 250 |
: ( is_string( $from_email ) ? $from_email : '' ); |
| 251 |
|
| 252 |
// Process smart tags in from fields. |
| 253 |
$from_name = self::process_smart_tags( is_string( $from_name ) ? $from_name : '', $donation_data, $campaign ); |
| 254 |
$from_email = self::process_smart_tags( is_string( $from_email ) ? $from_email : '', $donation_data, $campaign ); |
| 255 |
$reply_to = self::process_smart_tags( is_string( $reply_to ) ? $reply_to : '', $donation_data, $campaign ); |
| 256 |
$subject = str_replace( [ "\r", "\n" ], '', $subject ); |
| 257 |
|
| 258 |
// Sanitize header values: strip CRLF to prevent header injection, validate emails. |
| 259 |
$from_name = str_replace( [ "\r", "\n" ], '', $from_name ); |
| 260 |
$admin_email = get_option( 'admin_email' ); |
| 261 |
$from_email = is_email( $from_email ) ? (string) $from_email : ( is_string( $admin_email ) ? $admin_email : '' ); |
| 262 |
$reply_to = is_email( $reply_to ) ? (string) $reply_to : $from_email; |
| 263 |
|
| 264 |
// Set email headers. |
| 265 |
$headers = [ |
| 266 |
'Content-Type: text/html; charset=UTF-8', |
| 267 |
sprintf( 'From: %s <%s>', (string) $from_name, $from_email ), |
| 268 |
sprintf( 'Reply-To: %s', $reply_to ), |
| 269 |
]; |
| 270 |
|
| 271 |
// Convert plain text to HTML if needed. |
| 272 |
$email_body = self::format_email_body( $email_body ); |
| 273 |
|
| 274 |
// Send email. |
| 275 |
$sent = wp_mail( $to_email, $subject, $email_body, $headers ); |
| 276 |
|
| 277 |
// Log email send attempt. |
| 278 |
// 4th param is display name (not machine ID). Pre-release plugin (v0.0.1) with no |
| 279 |
// external consumers of this hook, so no backward-compatibility concern. |
| 280 |
$notification_name = isset( $notification['name'] ) && is_string( $notification['name'] ) ? $notification['name'] : ''; |
| 281 |
do_action( 'suredonation_email_sent', $donation_id, $to_email, $sent, $notification_name ); |
| 282 |
|
| 283 |
return $sent; |
| 284 |
} |
| 285 |
|
| 286 |
/** |
| 287 |
* Process smart tags in email content. |
| 288 |
* |
| 289 |
* @param string $content Content with smart tags. |
| 290 |
* @param array<string, mixed> $donation_data Donation data. |
| 291 |
* @param \WP_Post $campaign Campaign post object. |
| 292 |
* @return string Processed content. |
| 293 |
* @since 0.0.1 |
| 294 |
*/ |
| 295 |
public static function process_smart_tags( $content, $donation_data, $campaign ) { |
| 296 |
// Get currency symbol - ensure string type. |
| 297 |
$currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD'; |
| 298 |
$campaign_title = ( $campaign instanceof \WP_Post ) ? $campaign->post_title : ''; |
| 299 |
|
| 300 |
// Calculate total amount (base + fees) - ensure numeric types. |
| 301 |
$amount_value = $donation_data['amount'] ?? 0; |
| 302 |
$fees_covered_value = $donation_data['fees_covered'] ?? 0; |
| 303 |
$base_amount = is_numeric( $amount_value ) ? (float) $amount_value : 0.0; |
| 304 |
$fees_covered = is_numeric( $fees_covered_value ) ? (float) $fees_covered_value : 0.0; |
| 305 |
$total_amount = $base_amount + $fees_covered; |
| 306 |
|
| 307 |
// Format amounts with currency symbol. |
| 308 |
$formatted_amount = Payment_Helper::format_amount( $total_amount, $currency ); |
| 309 |
|
| 310 |
// Get date format - ensure string type. |
| 311 |
$date_format = get_option( 'date_format' ); |
| 312 |
$date_format = is_string( $date_format ) ? $date_format : 'Y-m-d'; |
| 313 |
|
| 314 |
// Smart tags mapping. |
| 315 |
$donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : __( 'Donor', 'suredonation' ); |
| 316 |
$donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : ''; |
| 317 |
$transaction_id = isset( $donation_data['transaction_id'] ) && is_string( $donation_data['transaction_id'] ) ? $donation_data['transaction_id'] : ''; |
| 318 |
if ( empty( $transaction_id ) && isset( $donation_data['id'] ) ) { |
| 319 |
$transaction_id = is_scalar( $donation_data['id'] ) ? (string) $donation_data['id'] : ''; |
| 320 |
} |
| 321 |
|
| 322 |
// Subscription smart tags. |
| 323 |
$subscription_id = isset( $donation_data['subscription_id'] ) && is_string( $donation_data['subscription_id'] ) ? $donation_data['subscription_id'] : ''; |
| 324 |
$admin_email = get_option( 'admin_email', '' ); |
| 325 |
|
| 326 |
// Payment method smart tags. |
| 327 |
$gateway = isset( $donation_data['gateway'] ) && is_string( $donation_data['gateway'] ) ? $donation_data['gateway'] : 'stripe'; |
| 328 |
$payment_method = Helper::get_payment_method_label( $gateway ); |
| 329 |
$payment_status = isset( $donation_data['payment_status'] ) && is_string( $donation_data['payment_status'] ) ? $donation_data['payment_status'] : ''; |
| 330 |
|
| 331 |
$offline_instructions = ''; |
| 332 |
if ( 'offline' === $gateway ) { |
| 333 |
$offline_instructions = Offline_Helper::get_offline_instructions(); |
| 334 |
} |
| 335 |
|
| 336 |
$tags = [ |
| 337 |
'{donor_name}' => esc_html( $donor_name ), |
| 338 |
'{donor_email}' => esc_html( $donor_email ), |
| 339 |
'{amount}' => esc_html( $formatted_amount ), |
| 340 |
'{campaign_name}' => esc_html( $campaign_title ), |
| 341 |
'{donation_date}' => esc_html( (string) current_time( $date_format ) ), |
| 342 |
'{transaction_id}' => esc_html( $transaction_id ), |
| 343 |
'{site_title}' => esc_html( get_bloginfo( 'name' ) ), |
| 344 |
'{admin_email}' => esc_html( Helper::get_string_value( $admin_email ) ), |
| 345 |
'{site_url}' => esc_url( home_url() ), |
| 346 |
'{admin_url}' => esc_url( admin_url( 'admin.php?page=suredonation' ) ), |
| 347 |
'{subscription_id}' => esc_html( $subscription_id ), |
| 348 |
'{subscription_interval}' => isset( $donation_data['subscription_interval'] ) && is_string( $donation_data['subscription_interval'] ) |
| 349 |
? esc_html( $donation_data['subscription_interval'] ) |
| 350 |
: '', |
| 351 |
'{payment_method}' => esc_html( $payment_method ), |
| 352 |
'{donation_amount}' => esc_html( Payment_Helper::format_amount( $base_amount, $currency ) ), |
| 353 |
'{donation_total}' => esc_html( $formatted_amount ), |
| 354 |
'{payment_status}' => Helper::render_payment_status_badge( $payment_status ), |
| 355 |
'{success_badge}' => Helper::render_success_badge(), |
| 356 |
'{donation_receipt}' => Helper::render_donation_receipt( $donation_data, $campaign_title ), |
| 357 |
'{refund_amount}' => isset( $donation_data['refund_amount'] ) && is_numeric( $donation_data['refund_amount'] ) |
| 358 |
? esc_html( Payment_Helper::format_amount( (float) $donation_data['refund_amount'], $currency ) ) |
| 359 |
: '', |
| 360 |
'{offline_instructions}' => wp_kses_post( $offline_instructions ), |
| 361 |
]; |
| 362 |
|
| 363 |
// Apply filters to allow adding custom smart tags. |
| 364 |
$core_tags = array_keys( $tags ); |
| 365 |
$tags = apply_filters( 'suredonation_email_smart_tags', $tags, $donation_data, $campaign ); |
| 366 |
|
| 367 |
// Sanitize any third-party tags added via the filter to prevent XSS in HTML emails. |
| 368 |
foreach ( $tags as $tag_key => $tag_value ) { |
| 369 |
if ( ! in_array( $tag_key, $core_tags, true ) ) { |
| 370 |
$tags[ $tag_key ] = esc_html( (string) $tag_value ); |
| 371 |
} |
| 372 |
} |
| 373 |
|
| 374 |
// Replace smart tags. |
| 375 |
return str_replace( array_keys( $tags ), array_values( $tags ), $content ); |
| 376 |
} |
| 377 |
|
| 378 |
/** |
| 379 |
* Format email body with HTML wrapper. |
| 380 |
* |
| 381 |
* @param string $body Email body content. |
| 382 |
* @return string Formatted HTML email. |
| 383 |
* @since 0.0.1 |
| 384 |
*/ |
| 385 |
private static function format_email_body( $body ) { |
| 386 |
$email_template = Email_Template::get_instance(); |
| 387 |
return $email_template->render( $body ); |
| 388 |
} |
| 389 |
} |
| 390 |
|