PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.0.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.0.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / emails / email-handler.php

email-handler.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.0.0, at inc/emails/email-handler.php

390 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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