| @@ -6,9 +6,12 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | namespace SureDonation\Inc\Emails; |
| 9 | 9 | |
| 10 | +use SureDonation\Inc\Database\Tables\Donations; | |
| 11 | +use SureDonation\Inc\FormEditor\Assets; | |
| 10 | 12 | use SureDonation\Inc\Helper; |
| 13 | +use SureDonation\Inc\Payments\Offline\Offline_Helper; | |
| 11 | 14 | use SureDonation\Inc\Payments\Payment_Helper; |
| 12 | 15 | |
| 13 | 16 | // Exit if accessed directly. |
| 14 | 17 | if ( ! defined( 'ABSPATH' ) ) { |
| @@ -17,124 +20,202 @@ | ||
| 17 | 20 | |
| 18 | 21 | /** |
| 19 | 22 | * Email_Handler class. |
| 20 | 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 | + * | |
| 21 | 28 | * @since 0.0.1 |
| 22 | 29 | */ |
| 23 | 30 | class Email_Handler { |
| 24 | 31 | /** |
| 25 | - * Option key for email notifications within consolidated options. | |
| 32 | + * Valid trigger event types. | |
| 26 | 33 | * |
| 27 | - * @since 0.0.1 | |
| 34 | + * @since 1.0.0 | |
| 28 | 35 | */ |
| 29 | - public const OPTION_KEY = 'email_notifications'; | |
| 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'; | |
| 30 | 40 | |
| 31 | 41 | /** |
| 32 | - * Send donation confirmation email to donor. | |
| 42 | + * Send email notifications matching a specific event. | |
| 33 | 43 | * |
| 44 | + * Only notifications whose trigger matches the event (or trigger 'all') are sent. | |
| 45 | + * | |
| 34 | 46 | * @param int $donation_id Donation ID. |
| 35 | 47 | * @param int $campaign_id Campaign ID. |
| 36 | 48 | * @param array<string, mixed> $donation_data Donation data array. |
| 37 | - * @return bool True if email was sent successfully. | |
| 38 | - * @since 0.0.1 | |
| 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 | |
| 39 | 53 | */ |
| 40 | - public static function send_donation_confirmation( $donation_id, $campaign_id, $donation_data ) { | |
| 41 | - $notification = self::get_notification( 'donation_receipt' ); | |
| 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 | + } | |
| 42 | 67 | |
| 43 | - if ( ! $notification ) { | |
| 44 | - return false; | |
| 68 | + if ( empty( $form_id ) ) { | |
| 69 | + $form_id = self::get_form_id_from_donation( $donation_id ); | |
| 45 | 70 | } |
| 46 | 71 | |
| 47 | - // Check if donor email is available. | |
| 48 | - $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : ''; | |
| 49 | - if ( empty( $donor_email ) || ! is_email( $donor_email ) ) { | |
| 50 | - return false; | |
| 72 | + $notifications = self::get_form_notifications( $form_id ); | |
| 73 | + | |
| 74 | + if ( empty( $notifications ) ) { | |
| 75 | + return; | |
| 51 | 76 | } |
| 52 | 77 | |
| 53 | - // Get campaign data. | |
| 54 | 78 | $campaign = get_post( $campaign_id ); |
| 55 | 79 | if ( ! $campaign ) { |
| 56 | - return false; | |
| 80 | + return; | |
| 57 | 81 | } |
| 58 | 82 | |
| 59 | - // Send donor receipt email. | |
| 60 | - $sent = self::send_email( | |
| 61 | - $donor_email, | |
| 62 | - $notification, | |
| 63 | - $donation_data, | |
| 64 | - $campaign, | |
| 65 | - $donation_id | |
| 66 | - ); | |
| 83 | + foreach ( $notifications as $notification ) { | |
| 84 | + if ( empty( $notification['status'] ) ) { | |
| 85 | + continue; | |
| 86 | + } | |
| 67 | 87 | |
| 68 | - // Also send admin notification if enabled. | |
| 69 | - self::send_admin_new_donation( $donation_id, $campaign_id, $donation_data ); | |
| 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 | + } | |
| 70 | 93 | |
| 71 | - return $sent; | |
| 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 | + } | |
| 72 | 115 | } |
| 73 | 116 | |
| 74 | 117 | /** |
| 75 | - * Send new donation notification to admin. | |
| 118 | + * Send donation confirmation emails. | |
| 76 | 119 | * |
| 77 | 120 | * @param int $donation_id Donation ID. |
| 78 | 121 | * @param int $campaign_id Campaign ID. |
| 79 | 122 | * @param array<string, mixed> $donation_data Donation data array. |
| 80 | - * @return bool True if email was sent successfully. | |
| 123 | + * @param int $form_id Form post ID. | |
| 124 | + * @return void | |
| 81 | 125 | * @since 0.0.1 |
| 82 | 126 | */ |
| 83 | - public static function send_admin_new_donation( $donation_id, $campaign_id, $donation_data ) { | |
| 84 | - $notification = self::get_notification( 'admin_new_donation' ); | |
| 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 | + } | |
| 85 | 130 | |
| 86 | - if ( ! $notification ) { | |
| 87 | - return false; | |
| 88 | - } | |
| 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 | + } | |
| 89 | 144 | |
| 90 | - // Get campaign data. | |
| 91 | - $campaign = get_post( $campaign_id ); | |
| 92 | - if ( ! $campaign ) { | |
| 93 | - return false; | |
| 94 | - } | |
| 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 | + } | |
| 95 | 158 | |
| 96 | - // Send to admin email. | |
| 97 | - $admin_email = get_option( 'admin_email' ); | |
| 98 | - $admin_email = is_string( $admin_email ) ? $admin_email : ''; | |
| 99 | - | |
| 100 | - return self::send_email( | |
| 101 | - $admin_email, | |
| 102 | - $notification, | |
| 103 | - $donation_data, | |
| 104 | - $campaign, | |
| 105 | - $donation_id | |
| 106 | - ); | |
| 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 ); | |
| 107 | 171 | } |
| 108 | 172 | |
| 109 | 173 | /** |
| 110 | - * Get notification settings by type. | |
| 174 | + * Get email notifications from form post meta. | |
| 111 | 175 | * |
| 112 | - * @param string $notification_type Notification type key. | |
| 113 | - * @return array<string, mixed>|false Notification settings or false if not found/disabled. | |
| 114 | - * @since 0.0.1 | |
| 176 | + * @param int $form_id Form post ID. | |
| 177 | + * @return array<int, array<string, mixed>> Array of notification configs. | |
| 178 | + * @since 1.0.0 | |
| 115 | 179 | */ |
| 116 | - private static function get_notification( $notification_type ) { | |
| 117 | - $notifications = Helper::get_suredonation_option( self::OPTION_KEY, [] ); | |
| 180 | + private static function get_form_notifications( $form_id ) { | |
| 181 | + if ( empty( $form_id ) ) { | |
| 182 | + return []; | |
| 183 | + } | |
| 118 | 184 | |
| 119 | - // Ensure notifications is an array before accessing offsets. | |
| 120 | - if ( ! is_array( $notifications ) || empty( $notifications[ $notification_type ] ) ) { | |
| 121 | - return false; | |
| 185 | + $raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true ); | |
| 186 | + | |
| 187 | + if ( empty( $raw ) || ! is_string( $raw ) ) { | |
| 188 | + return []; | |
| 122 | 189 | } |
| 123 | 190 | |
| 124 | - $notification = $notifications[ $notification_type ]; | |
| 191 | + $notifications = json_decode( $raw, true ); | |
| 125 | 192 | |
| 126 | - // Ensure notification is an array before accessing offsets. | |
| 127 | - if ( ! is_array( $notification ) ) { | |
| 128 | - return false; | |
| 193 | + if ( ! is_array( $notifications ) ) { | |
| 194 | + return []; | |
| 129 | 195 | } |
| 130 | 196 | |
| 131 | - // Check if enabled. | |
| 132 | - if ( empty( $notification['enabled'] ) ) { | |
| 133 | - return false; | |
| 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; | |
| 134 | 210 | } |
| 135 | 211 | |
| 136 | - return $notification; | |
| 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; | |
| 137 | 218 | } |
| 138 | 219 | |
| 139 | 220 | /** |
| 140 | 221 | * Send email using notification settings. |
| @@ -168,18 +249,23 @@ | ||
| 168 | 249 | ? $notification['reply_to'] |
| 169 | 250 | : ( is_string( $from_email ) ? $from_email : '' ); |
| 170 | 251 | |
| 171 | 252 | // Process smart tags in from fields. |
| 172 | - $from_name = self::process_smart_tags( is_string( $from_name ) ? $from_name : '', $donation_data, $campaign ); | |
| 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 ); | |
| 173 | 257 | |
| 174 | - // Ensure from_email and reply_to are strings. | |
| 175 | - $from_email = is_string( $from_email ) ? $from_email : ''; | |
| 176 | - $reply_to = is_string( $reply_to ) ? $reply_to : ''; | |
| 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; | |
| 177 | 263 | |
| 178 | 264 | // Set email headers. |
| 179 | 265 | $headers = [ |
| 180 | 266 | 'Content-Type: text/html; charset=UTF-8', |
| 181 | - sprintf( 'From: %s <%s>', $from_name, $from_email ), | |
| 267 | + sprintf( 'From: %s <%s>', (string) $from_name, $from_email ), | |
| 182 | 268 | sprintf( 'Reply-To: %s', $reply_to ), |
| 183 | 269 | ]; |
| 184 | 270 | |
| 185 | 271 | // Convert plain text to HTML if needed. |
| @@ -188,10 +274,12 @@ | ||
| 188 | 274 | // Send email. |
| 189 | 275 | $sent = wp_mail( $to_email, $subject, $email_body, $headers ); |
| 190 | 276 | |
| 191 | 277 | // Log email send attempt. |
| 192 | - $notification_id = isset( $notification['id'] ) && is_string( $notification['id'] ) ? $notification['id'] : ''; | |
| 193 | - do_action( 'suredonation_email_sent', $donation_id, $to_email, $sent, $notification_id ); | |
| 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 ); | |
| 194 | 282 | |
| 195 | 283 | return $sent; |
| 196 | 284 | } |
| 197 | 285 | |
| @@ -203,11 +291,12 @@ | ||
| 203 | 291 | * @param \WP_Post $campaign Campaign post object. |
| 204 | 292 | * @return string Processed content. |
| 205 | 293 | * @since 0.0.1 |
| 206 | 294 | */ |
| 207 | - private static function process_smart_tags( $content, $donation_data, $campaign ) { | |
| 295 | + public static function process_smart_tags( $content, $donation_data, $campaign ) { | |
| 208 | 296 | // Get currency symbol - ensure string type. |
| 209 | - $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD'; | |
| 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 : ''; | |
| 210 | 299 | |
| 211 | 300 | // Calculate total amount (base + fees) - ensure numeric types. |
| 212 | 301 | $amount_value = $donation_data['amount'] ?? 0; |
| 213 | 302 | $fees_covered_value = $donation_data['fees_covered'] ?? 0; |
| @@ -229,23 +318,59 @@ | ||
| 229 | 318 | if ( empty( $transaction_id ) && isset( $donation_data['id'] ) ) { |
| 230 | 319 | $transaction_id = is_scalar( $donation_data['id'] ) ? (string) $donation_data['id'] : ''; |
| 231 | 320 | } |
| 232 | 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 | + | |
| 233 | 336 | $tags = [ |
| 234 | - '{donor_name}' => $donor_name, | |
| 235 | - '{donor_email}' => $donor_email, | |
| 236 | - '{amount}' => $formatted_amount, | |
| 237 | - '{campaign_name}' => $campaign->post_title, | |
| 238 | - '{donation_date}' => current_time( $date_format ), | |
| 239 | - '{transaction_id}' => $transaction_id, | |
| 240 | - '{site_title}' => get_bloginfo( 'name' ), | |
| 241 | - '{admin_email}' => get_option( 'admin_email' ), | |
| 242 | - '{site_url}' => home_url(), | |
| 243 | - '{admin_url}' => admin_url( 'admin.php?page=suredonation' ), | |
| 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 ), | |
| 244 | 361 | ]; |
| 245 | 362 | |
| 246 | 363 | // Apply filters to allow adding custom smart tags. |
| 247 | - $tags = apply_filters( 'suredonation_email_smart_tags', $tags, $donation_data, $campaign ); | |
| 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 | + } | |
| 248 | 373 | |
| 249 | 374 | // Replace smart tags. |
| 250 | 375 | return str_replace( array_keys( $tags ), array_values( $tags ), $content ); |
| 251 | 376 | } |