PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.1 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
← All changes | inc/emails/email-handler.php +212 -87 0.0.1 → 1.1.0 View file →
@@ -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 }