PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
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 trunk, at inc/emails/email-handler.php

1,043 lines 42.0 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
56 // webhook racing). Claimed immediately after the check rather than once
57 // the form and campaign are resolved: those lookups are a DB read, the
58 // notification merge and a get_post(), and holding the gap open across
59 // them lets both racers pass the check before either claims — which is
60 // exactly the pair this lock exists to separate, and they arrive together
61 // on essentially every Stripe donation.
62 //
63 // A claim that resolves nothing is released again at each early return
64 // below, so a caller that bailed does not swallow the retry that would
65 // have succeeded.
66 //
67 // get/set is still non-atomic (TOCTOU) and the worst case is a duplicate
68 // email, not data corruption. wp_cache_add() would only be atomic with an
69 // external object cache; most installs use DB transients.
70 $lock_key = $donation_id > 0 ? 'suredonation_email_lock_' . $event . '_' . $donation_id : '';
71
72 if ( '' !== $lock_key && get_transient( $lock_key ) ) {
73 return;
74 }
75
76 if ( '' !== $lock_key ) {
77 set_transient( $lock_key, true, 60 );
78
79 // Companion to the 60-second race lock, kept long enough to answer a
80 // different question: has this donation's email for this event been
81 // attempted at all? The webhook reconciler needs that to decide
82 // whether a donation the frontend already completed still owes the
83 // donor a receipt, and the race lock expires far too soon to say.
84 set_transient( self::sent_marker_key( $event, $donation_id ), true, WEEK_IN_SECONDS );
85 }
86
87 // One lookup covers both the form and the donation timestamp; callers
88 // build their own data array and rarely carry created_at.
89 $needs_form_id = empty( $form_id );
90 $needs_timestamp = empty( $donation_data['created_at'] );
91 // The submitted form fields live in the donation_data JSON column, which
92 // callers building their own array never carry. Resolved here, once, so
93 // the {form_fields} tag does not re-read the row for every tag pass.
94 $needs_fields = ! isset( $donation_data['fields'] );
95
96 if ( ( $needs_form_id || $needs_timestamp || $needs_fields ) && ! empty( $donation_id ) ) {
97 $donation = Donations::get( $donation_id );
98
99 if ( is_array( $donation ) ) {
100 if ( $needs_form_id && isset( $donation['form_id'] ) && is_scalar( $donation['form_id'] ) ) {
101 $form_id = absint( $donation['form_id'] );
102 }
103 if ( $needs_timestamp && isset( $donation['created_at'] ) && is_string( $donation['created_at'] ) ) {
104 $donation_data['created_at'] = $donation['created_at'];
105 }
106 if ( $needs_fields ) {
107 $donation_data['fields'] = self::extract_stored_fields( $donation );
108 }
109 }
110 }
111
112 $notifications = self::get_form_notifications( $form_id );
113
114 if ( empty( $notifications ) ) {
115 self::release_send_lock( $lock_key );
116 return;
117 }
118
119 // campaign_id 0 is a supported standalone form, not an error — see the
120 // note on Donations::add(). Everything downstream already tolerates a
121 // null campaign, so only a genuinely missing campaign should stop the send.
122 $campaign = $campaign_id > 0 ? get_post( $campaign_id ) : null;
123 if ( $campaign_id > 0 && ! $campaign ) {
124 self::release_send_lock( $lock_key );
125 return;
126 }
127
128 foreach ( $notifications as $notification ) {
129 if ( empty( $notification['status'] ) ) {
130 continue;
131 }
132
133 // Only send notifications whose trigger matches the current event.
134 $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : '';
135 if ( empty( $trigger ) || ( 'all' !== $trigger && $trigger !== $event ) ) {
136 continue;
137 }
138
139 // Resolve email_to using smart tags.
140 $email_to_raw = isset( $notification['email_to'] ) && is_string( $notification['email_to'] ) ? $notification['email_to'] : '';
141 $email_to = self::process_smart_tags( $email_to_raw, $donation_data, $campaign );
142
143 // Support comma-separated recipients.
144 $recipients = array_map( 'trim', explode( ',', $email_to ) );
145 $recipients = array_filter(
146 $recipients,
147 static function ( string $email ): bool {
148 return (bool) is_email( $email );
149 }
150 );
151
152 if ( empty( $recipients ) ) {
153 continue;
154 }
155
156 foreach ( $recipients as $recipient ) {
157 self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id, $event );
158 }
159 }
160 }
161
162 /**
163 * Send donation confirmation emails.
164 *
165 * @param int $donation_id Donation ID.
166 * @param int $campaign_id Campaign ID.
167 * @param array<string, mixed> $donation_data Donation data array.
168 * @param int $form_id Form post ID.
169 * @return void
170 * @since 0.0.1
171 */
172 /**
173 * Transient key recording that an email was attempted for a donation+event.
174 *
175 * @param string $event Email event.
176 * @param int $donation_id Donation ID.
177 * @return string
178 * @since x.x.x
179 */
180 public static function sent_marker_key( $event, $donation_id ) {
181 return 'suredonation_email_sent_' . (string) $event . '_' . absint( $donation_id );
182 }
183
184 /**
185 * Whether an email for this donation and event has already been attempted.
186 *
187 * Answers "does this donation still owe the donor a receipt?", which the
188 * PayPal webhook reconciler asks about a donation the frontend already
189 * marked completed. Deliberately not `receipt_sent`: that column is written
190 * only by Pro's PDF attachment path, so on free-only sites it is never set.
191 *
192 * @param int $donation_id Donation ID.
193 * @param string $event Email event; defaults to the donation receipt.
194 * @return bool
195 * @since x.x.x
196 */
197 public static function has_sent( $donation_id, $event = self::EVENT_DONATION_COMPLETED ) {
198 return (bool) get_transient( self::sent_marker_key( $event, $donation_id ) );
199 }
200
201 /**
202 * Send donation confirmation emails.
203 *
204 * @param int $donation_id Donation ID.
205 * @param int $campaign_id Campaign ID.
206 * @param array<string, mixed> $donation_data Donation data array.
207 * @param int $form_id Form post ID.
208 * @return void
209 * @since 1.0.0
210 */
211 public static function send_donation_confirmation( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
212 self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_COMPLETED );
213 }
214
215 /**
216 * Send donation processing emails.
217 *
218 * @param int $donation_id Donation ID.
219 * @param int $campaign_id Campaign ID.
220 * @param array<string, mixed> $donation_data Donation data array.
221 * @param int $form_id Form post ID.
222 * @return void
223 * @since 1.0.0
224 */
225 public static function send_donation_processing( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
226 self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_PROCESSING );
227 }
228
229 /**
230 * Send donation failed emails.
231 *
232 * @param int $donation_id Donation ID.
233 * @param int $campaign_id Campaign ID.
234 * @param array<string, mixed> $donation_data Donation data array.
235 * @param int $form_id Form post ID.
236 * @return void
237 * @since 1.0.0
238 */
239 public static function send_donation_failed( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
240 self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_FAILED );
241 }
242
243 /**
244 * Send refund processed emails.
245 *
246 * @param int $donation_id Donation ID.
247 * @param int $campaign_id Campaign ID.
248 * @param array<string, mixed> $donation_data Donation data array.
249 * @param int $form_id Form post ID.
250 * @return void
251 * @since 1.0.0
252 */
253 public static function send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
254 self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_REFUND_PROCESSED );
255 }
256
257 /**
258 * Option recording that stored rows have had their keys back-filled.
259 *
260 * @since 1.4.0
261 */
262 public const KEY_BACKFILL_OPTION = 'suredonation_notification_keys_backfilled';
263
264 /**
265 * Write resolved identities back to rows saved before keys existed.
266 *
267 * Resolution is otherwise re-derived on every send and never persisted, so a
268 * form saved before this release stays dependent on guesswork for the rest of
269 * its life — and stays exposed to whatever breaks the guess, whether that is
270 * a rename, a locale difference or an edited recipient. Doing it once, here,
271 * is what makes the fallback a migration rather than a permanent code path.
272 *
273 * Deliberately runs in admin context only: it writes, and the read path it
274 * repairs is reached during donor payment requests.
275 *
276 * Only identity is written: keys, and the triggers the old sanitizer
277 * overwrote. Deliberately not the dedupe — it discards one of two rows that
278 * resolve to the same key, and where both were customised that is an edit
279 * the admin cannot get back. In memory that loss lasts one request; on disk
280 * it is permanent. Collapsing duplicates costs nothing to redo per read, so
281 * it stays there.
282 *
283 * Missing defaults are not appended either. That is a read-time concern
284 * which depends on whether Pro is active, and baking today's answer into the
285 * row would strand the form the next time that changes.
286 *
287 * @return void
288 * @since 1.4.0
289 */
290 public static function backfill_notification_keys() {
291 $defaults = Assets::get_instance()->get_default_email_notifications();
292
293 // Keyed to the defaults that were available, not a bare "done" flag.
294 // Which rows can be identified depends on what is registered at the time:
295 // with Pro inactive or on an older build, its templates and their former
296 // names are simply absent, and its rows resolve to nothing. Recording the
297 // set means the pass runs again once that set changes — when Pro is
298 // activated or updated — instead of a one-time run deciding forever.
299 $signature = self::default_keys_signature( $defaults );
300
301 if ( get_option( self::KEY_BACKFILL_OPTION ) === $signature ) {
302 return;
303 }
304
305 global $wpdb;
306
307 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration over a meta key with no API equivalent.
308 $rows = $wpdb->get_results(
309 $wpdb->prepare(
310 "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value != ''",
311 Assets::EMAIL_NOTIFICATIONS_META_KEY
312 )
313 );
314
315 if ( is_array( $rows ) && ! empty( $rows ) ) {
316 foreach ( $rows as $row ) {
317 $stored = json_decode( (string) $row->meta_value, true );
318
319 if ( ! is_array( $stored ) || empty( $stored ) ) {
320 continue;
321 }
322
323 $resolved = self::add_identity_keys( $stored, $defaults );
324 $resolved = self::restore_rewritten_triggers( $resolved, $defaults );
325
326 // Only rows that were identified are written. A row that resolved
327 // to nothing keeps exactly what it had, including its status: the
328 // read path still parks it so it cannot mis-send, but parking it
329 // on disk would outlive the reason for it — a row unidentifiable
330 // today because Pro is a version behind would stay switched off
331 // after Pro caught up, with nothing to switch it back.
332 foreach ( $resolved as $index => $row_data ) {
333 if ( empty( $row_data['key'] ) ) {
334 $resolved[ $index ] = $stored[ $index ];
335 }
336 }
337
338 if ( $resolved === $stored ) {
339 continue;
340 }
341
342 update_post_meta(
343 (int) $row->post_id,
344 Assets::EMAIL_NOTIFICATIONS_META_KEY,
345 wp_slash( (string) wp_json_encode( $resolved ) )
346 );
347 }
348 }
349
350 update_option( self::KEY_BACKFILL_OPTION, $signature, false );
351 }
352
353 /**
354 * Signature of the default keys currently registered.
355 *
356 * @param array<int, array<string, mixed>> $defaults Default notifications.
357 * @return string Signature.
358 * @since 1.4.0
359 */
360 private static function default_keys_signature( $defaults ) {
361 $keys = [];
362
363 foreach ( $defaults as $default ) {
364 if ( ! empty( $default['key'] ) && is_string( $default['key'] ) ) {
365 $keys[] = $default['key'];
366 }
367 }
368
369 sort( $keys );
370
371 return md5( (string) wp_json_encode( $keys ) );
372 }
373
374 /**
375 * Give back a send lock claimed by a call that delivered nothing.
376 *
377 * @param string $lock_key Lock transient name, or '' when unlocked.
378 * @return void
379 * @since 1.4.0
380 */
381 private static function release_send_lock( $lock_key ) {
382 if ( '' !== $lock_key ) {
383 delete_transient( $lock_key );
384 }
385 }
386
387 /**
388 * Get email notifications for a form.
389 *
390 * Stored settings are authoritative, but they are only written when an admin
391 * opens the form's Email Notifications tab and saves. Until then the meta is
392 * empty, and reading it literally means a form sends nothing at all. Defaults
393 * fill the gaps so delivery never depends on having visited the editor.
394 *
395 * Resolved per read and not persisted: sending happens mid-payment, and a
396 * write there would be a side effect on a path that only needs to read. The
397 * resolution is persisted once by backfill_notification_keys(), which runs
398 * in admin context, so this stays a fallback rather than the normal path.
399 *
400 * @param int $form_id Form post ID.
401 * @return array<int, array<string, mixed>> Array of notification configs.
402 * @since 1.0.0
403 */
404 private static function get_form_notifications( $form_id ) {
405 if ( empty( $form_id ) ) {
406 return [];
407 }
408
409 $raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true );
410 $notifications = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : null;
411
412 if ( ! is_array( $notifications ) ) {
413 $notifications = [];
414 }
415
416 $defaults = Assets::get_instance()->get_default_email_notifications();
417
418 if ( empty( $notifications ) ) {
419 return $defaults;
420 }
421
422 $notifications = self::add_identity_keys( $notifications, $defaults );
423 $notifications = self::restore_rewritten_triggers( $notifications, $defaults );
424 $notifications = self::drop_duplicate_notifications( $notifications );
425
426 return self::add_missing_notifications( $notifications, $defaults );
427 }
428
429 /**
430 * Stamp a stable identity onto rows saved before keys existed.
431 *
432 * Older rows carry only `id` and `name`, and neither identifies anything on
433 * its own: `id` is reassigned whenever the editor re-seeds a set, and `name`
434 * is user-editable and translated — it resolves in the admin's locale when
435 * saved and the site's locale when an email is sent.
436 *
437 * So the fallback is `trigger` + `email_to`. Both are untranslated, neither
438 * changes when a notification is renamed, and the pair is unique across every
439 * default (`trigger` alone is not — donor and admin templates share one).
440 *
441 * @param array<int, array<string, mixed>> $notifications Stored notifications.
442 * @param array<int, array<string, mixed>> $defaults Default notifications.
443 * @return array<int, array<string, mixed>> Notifications with a `key` where one could be resolved.
444 * @since 1.4.0
445 */
446 private static function add_identity_keys( $notifications, $defaults ) {
447 $by_name = [];
448 $by_signature = [];
449
450 foreach ( $defaults as $default ) {
451 $key = isset( $default['key'] ) && is_string( $default['key'] ) ? $default['key'] : '';
452 if ( '' === $key ) {
453 continue;
454 }
455
456 $signature = self::notification_signature( $default );
457 if ( '' !== $signature && ! isset( $by_signature[ $signature ] ) ) {
458 $by_signature[ $signature ] = $key;
459 }
460
461 if ( isset( $default['name'] ) && is_string( $default['name'] ) ) {
462 $by_name[ $default['name'] ] = $key;
463 }
464 // A default that has been renamed carries the names it used to have,
465 // so rows stored under the old wording still resolve.
466 if ( isset( $default['legacy_names'] ) && is_array( $default['legacy_names'] ) ) {
467 foreach ( $default['legacy_names'] as $legacy ) {
468 if ( is_string( $legacy ) && ! isset( $by_name[ $legacy ] ) ) {
469 $by_name[ $legacy ] = $key;
470 }
471 }
472 }
473 }
474
475 foreach ( $notifications as $index => $notification ) {
476 if ( ! empty( $notification['key'] ) ) {
477 continue;
478 }
479
480 $name = isset( $notification['name'] ) && is_string( $notification['name'] ) ? $notification['name'] : '';
481
482 if ( '' !== $name && isset( $by_name[ $name ] ) ) {
483 $notifications[ $index ]['key'] = $by_name[ $name ];
484 continue;
485 }
486
487 // The name did not match, which a rename or a locale difference is
488 // enough to cause. Fall back to what the admin did not edit and
489 // gettext does not touch.
490 //
491 // Not `id`: the editor used to reassign ids when re-seeding, so a
492 // stored id lands on whichever default happens to hold it and a donor
493 // row can inherit an admin row's key — routing it to the wrong trigger
494 // and suppressing the default it was mistaken for. No key at all is
495 // recoverable; a confidently wrong one is not.
496 $signature = self::notification_signature( $notification );
497 if ( '' !== $signature && isset( $by_signature[ $signature ] ) ) {
498 $notifications[ $index ]['key'] = $by_signature[ $signature ];
499 continue;
500 }
501
502 // Unidentifiable, and 'all' means "fire on every event" — the value
503 // the old sanitizer fell back to. Leaving it enabled would send a
504 // recurring template to a one-time donor, so it is parked rather than
505 // guessed at.
506 //
507 // Parking here is in memory only, so the editor still shows the row
508 // switched on. backfill_notification_keys() is what reconciles the
509 // two: it persists the resolution, which restores the real trigger
510 // for rows that resolve and records the off state for those that do
511 // not, so the toggle stops disagreeing with what actually sends.
512 if ( 'all' === ( $notification['trigger'] ?? '' ) ) {
513 $notifications[ $index ]['status'] = false;
514 }
515 }
516
517 return $notifications;
518 }
519
520 /**
521 * Read the stored submitted form fields off a donation row.
522 *
523 * The donation_data column is shared JSON that comes back either decoded or
524 * still encoded depending on the caller, so both shapes are handled.
525 *
526 * @param array<string, mixed> $donation Donation row.
527 * @return array<mixed> Stored fields, or [] when the donation has none.
528 * @since 1.5.1
529 */
530 private static function extract_stored_fields( $donation ) {
531 $donation_data = $donation['donation_data'] ?? [];
532
533 if ( is_string( $donation_data ) && '' !== $donation_data ) {
534 $donation_data = json_decode( $donation_data, true );
535 }
536
537 if ( ! is_array( $donation_data ) || ! isset( $donation_data['fields'] ) || ! is_array( $donation_data['fields'] ) ) {
538 return [];
539 }
540
541 return $donation_data['fields'];
542 }
543
544 /**
545 * Identify a notification by the two fields that survive editing.
546 *
547 * `trigger` is a sanitized key and `email_to` is a smart tag, so neither is
548 * translated and neither changes when a notification is renamed. Together
549 * they are unique across every default; `trigger` on its own is not, because
550 * the donor and admin templates for an event share it.
551 *
552 * @param array<string, mixed> $notification Notification row.
553 * @return string Signature, or '' when the row cannot supply one.
554 * @since 1.4.0
555 */
556 private static function notification_signature( $notification ) {
557 $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : '';
558 $email_to = isset( $notification['email_to'] ) && is_string( $notification['email_to'] ) ? $notification['email_to'] : '';
559
560 if ( '' === $trigger || '' === $email_to ) {
561 return '';
562 }
563
564 return $trigger . '|' . $email_to;
565 }
566
567 /**
568 * Restore triggers rewritten by an older save.
569 *
570 * Saving a form while Pro was inactive rewrote its recurring triggers to
571 * 'all', so those notifications fire on every donation event. The editor
572 * repairs this when the tab is opened; doing it here as well means a form
573 * nobody edits stops mis-sending too.
574 *
575 * Only 'all' is corrected, and only against the row's resolved key. 'all' is
576 * the exact value the old sanitizer fell back to, so any other mismatch is
577 * treated as a deliberate choice and left alone.
578 *
579 * @param array<int, array<string, mixed>> $notifications Stored notifications.
580 * @param array<int, array<string, mixed>> $defaults Default notifications.
581 * @return array<int, array<string, mixed>> Notifications with triggers restored.
582 * @since 1.4.0
583 */
584 private static function restore_rewritten_triggers( $notifications, $defaults ) {
585 $by_key = [];
586 foreach ( $defaults as $default ) {
587 if ( ! empty( $default['key'] ) && is_string( $default['key'] ) && isset( $default['trigger'] ) ) {
588 $by_key[ $default['key'] ] = $default['trigger'];
589 }
590 }
591
592 foreach ( $notifications as $index => $notification ) {
593 $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
594 $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : '';
595
596 if ( 'all' !== $trigger || '' === $key || ! isset( $by_key[ $key ] ) ) {
597 continue;
598 }
599
600 $notifications[ $index ]['trigger'] = $by_key[ $key ];
601 }
602
603 return $notifications;
604 }
605
606 /**
607 * Collapse rows that resolve to the same notification.
608 *
609 * A form that went through the deactivate/reactivate cycle holds the admin's
610 * customised row alongside a pristine copy the editor appended when it failed
611 * to recognise the original. Restoring the trigger above makes the two exact
612 * twins, so without this the donor receives both.
613 *
614 * The customised row wins: it is the one carrying the admin's edits, and the
615 * pristine copy only exists because of the recognition bug.
616 *
617 * @param array<int, array<string, mixed>> $notifications Stored notifications.
618 * @return array<int, array<string, mixed>> Notifications with duplicates removed.
619 * @since 1.4.0
620 */
621 private static function drop_duplicate_notifications( $notifications ) {
622 $seen = [];
623 $kept = [];
624
625 foreach ( $notifications as $notification ) {
626 $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
627
628 // Without a resolved key there is nothing safe to compare on, so the
629 // row is kept as-is rather than guessed at.
630 if ( '' === $key ) {
631 $kept[] = $notification;
632 continue;
633 }
634
635 if ( ! isset( $seen[ $key ] ) ) {
636 $seen[ $key ] = count( $kept );
637 $kept[] = $notification;
638 continue;
639 }
640
641 // Prefer whichever copy the admin actually edited.
642 $existing = $kept[ $seen[ $key ] ];
643 if ( self::is_customised( $notification ) && ! self::is_customised( $existing ) ) {
644 $kept[ $seen[ $key ] ] = $notification;
645 }
646 }
647
648 return $kept;
649 }
650
651 /**
652 * Whether a stored row differs from the default it came from.
653 *
654 * @param array<string, mixed> $notification Stored notification.
655 * @return bool True when the row carries admin edits.
656 * @since 1.4.0
657 */
658 private static function is_customised( $notification ) {
659 $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
660 if ( '' === $key ) {
661 return false;
662 }
663
664 // Cached: this runs inside a payment request, twice per duplicate row, and
665 // rebuilding the array means ~40 __() calls plus an apply_filters pass
666 // each time. The defaults are constant for the life of the request.
667 static $defaults = null;
668 if ( null === $defaults ) {
669 $defaults = Assets::get_instance()->get_default_email_notifications();
670 }
671
672 foreach ( $defaults as $default ) {
673 if ( ( $default['key'] ?? '' ) !== $key ) {
674 continue;
675 }
676 foreach ( [ 'subject', 'email_body', 'email_to', 'from_name', 'from_email', 'reply_to', 'name' ] as $field ) {
677 if ( ( $notification[ $field ] ?? '' ) !== ( $default[ $field ] ?? '' ) ) {
678 return true;
679 }
680 }
681 return false;
682 }
683
684 return false;
685 }
686
687 /**
688 * Add defaults that the stored set does not already cover.
689 *
690 * Notifications cannot be added or deleted in the editor, so a default with
691 * no stored counterpart was never seeded — most often because Pro was
692 * activated after the form was last saved. Without this, those forms send
693 * nothing for the events Pro adds.
694 *
695 * Matching is on the stable key only. Falling back to `id` or `name` here
696 * would reintroduce the very ambiguity the key exists to remove: a locale
697 * difference used to make a present notification look absent (so it was
698 * added twice), while a reassigned id could collide with a different
699 * notification's default and make an absent one look present (so it was
700 * never sent at all).
701 *
702 * @param array<int, array<string, mixed>> $notifications Stored notifications.
703 * @param array<int, array<string, mixed>> $defaults Default notifications.
704 * @return array<int, array<string, mixed>> Stored notifications plus any missing defaults.
705 * @since 1.4.0
706 */
707 private static function add_missing_notifications( $notifications, $defaults ) {
708 $stored_keys = [];
709 $unresolved_slots = [];
710
711 foreach ( $notifications as $notification ) {
712 if ( ! empty( $notification['key'] ) && is_string( $notification['key'] ) ) {
713 $stored_keys[ $notification['key'] ] = true;
714 continue;
715 }
716
717 // A row we could not identify still occupies its slot. Appending the
718 // default that belongs there would leave two enabled rows on one
719 // trigger and send the donor two of every email — so the slot is
720 // recorded and the default withheld. Matched on the full signature,
721 // not just the trigger: the donor and admin templates for an event
722 // share a trigger, and withholding both because one row is
723 // unidentified would silence a notification that is working.
724 if ( ! empty( $notification['status'] ) ) {
725 $signature = self::notification_signature( $notification );
726 if ( '' !== $signature ) {
727 $unresolved_slots[ $signature ] = true;
728 }
729 }
730 }
731
732 foreach ( $defaults as $default ) {
733 $key = isset( $default['key'] ) && is_string( $default['key'] ) ? $default['key'] : '';
734
735 if ( '' === $key || isset( $stored_keys[ $key ] ) ) {
736 continue;
737 }
738
739 $signature = self::notification_signature( $default );
740 if ( '' !== $signature && isset( $unresolved_slots[ $signature ] ) ) {
741 continue;
742 }
743
744 $notifications[] = $default;
745 }
746
747 return $notifications;
748 }
749
750 /**
751 * Send email using notification settings.
752 *
753 * @param string $to_email Recipient email address.
754 * @param array<string, mixed> $notification Notification settings.
755 * @param array<string, mixed> $donation_data Donation data for smart tags.
756 * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
757 * @param int $donation_id Optional donation ID.
758 * @param string $event The event that triggered this email.
759 * @return bool True if email was sent successfully.
760 * @since 0.0.1
761 */
762 private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0, $event = '' ) {
763 if ( empty( $to_email ) || ! is_email( $to_email ) ) {
764 return false;
765 }
766
767 // Prepare email data - ensure string types for process_smart_tags.
768 $subject_raw = isset( $notification['subject'] ) && is_string( $notification['subject'] ) ? $notification['subject'] : '';
769 $email_body_raw = isset( $notification['email_body'] ) && is_string( $notification['email_body'] ) ? $notification['email_body'] : '';
770 $subject = self::process_smart_tags( $subject_raw, $donation_data, $campaign );
771 $email_body = self::process_smart_tags( $email_body_raw, $donation_data, $campaign );
772
773 // Get from name and email - ensure string types.
774 $from_name_raw = isset( $notification['from_name'] ) && is_string( $notification['from_name'] ) ? $notification['from_name'] : '';
775 $from_name = ! empty( $from_name_raw ) ? $from_name_raw : get_bloginfo( 'name' );
776 $from_email = isset( $notification['from_email'] ) && is_string( $notification['from_email'] ) && ! empty( $notification['from_email'] )
777 ? $notification['from_email']
778 : get_option( 'admin_email' );
779 $reply_to = isset( $notification['reply_to'] ) && is_string( $notification['reply_to'] ) && ! empty( $notification['reply_to'] )
780 ? $notification['reply_to']
781 : ( is_string( $from_email ) ? $from_email : '' );
782
783 // Process smart tags in from fields.
784 $from_name = self::process_smart_tags( is_string( $from_name ) ? $from_name : '', $donation_data, $campaign );
785 $from_email = self::process_smart_tags( is_string( $from_email ) ? $from_email : '', $donation_data, $campaign );
786 $reply_to = self::process_smart_tags( is_string( $reply_to ) ? $reply_to : '', $donation_data, $campaign );
787 $subject = str_replace( [ "\r", "\n" ], '', $subject );
788
789 // Sanitize header values: strip CRLF to prevent header injection, validate emails.
790 $from_name = str_replace( [ "\r", "\n" ], '', $from_name );
791 $admin_email = get_option( 'admin_email' );
792 $from_email = is_email( $from_email ) ? (string) $from_email : ( is_string( $admin_email ) ? $admin_email : '' );
793 $reply_to = is_email( $reply_to ) ? (string) $reply_to : $from_email;
794
795 // Set email headers.
796 $headers = [
797 'Content-Type: text/html; charset=UTF-8',
798 sprintf( 'From: %s <%s>', (string) $from_name, $from_email ),
799 sprintf( 'Reply-To: %s', $reply_to ),
800 ];
801
802 // Convert plain text to HTML if needed.
803 $email_body = self::format_email_body( $email_body );
804
805 /**
806 * Filter attachments for outgoing notification emails.
807 *
808 * Each entry must be an absolute path to a local, readable file
809 * (wp_mail() contract) inside the uploads directory. Non-string,
810 * non-existent and out-of-uploads entries are dropped before sending.
811 *
812 * A STRING KEY names the attachment for the recipient: wp_mail() passes
813 * it to PHPMailer as the display name, so the file can be stored under
814 * one name and delivered under another. Keys are run through
815 * sanitize_file_name(), given the real file's extension when they lack
816 * it, and dropped if nothing usable survives -- in which case the file's
817 * own name is used.
818 *
819 * The display name is best effort. Core has never documented the
820 * key-as-name behaviour, and a plugin that REPLACES pluggable wp_mail()
821 * (some API-based mailers do) may iterate values only and drop the key,
822 * delivering the file under its stored name instead.
823 *
824 * @param array<int|string, string> $attachments Attachment file paths, optionally keyed by display name. Default empty.
825 * @param array<string, mixed> $notification Notification settings.
826 * @param array<string, mixed> $donation_data Donation data.
827 * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
828 * @param int $donation_id Donation ID (0 when not available).
829 * @param string $event The event that triggered this email (e.g. 'donation_completed').
830 * @since 1.5.0
831 * @since 1.5.1 A string key names the attachment for the recipient.
832 */
833 $attachments = apply_filters( 'suredonation_email_attachments', [], $notification, $donation_data, $campaign, $donation_id, $event );
834
835 $upload_dir = wp_upload_dir();
836 $base_real = isset( $upload_dir['basedir'] ) && is_string( $upload_dir['basedir'] ) ? realpath( $upload_dir['basedir'] ) : false;
837 $uploads_dir = is_string( $base_real ) ? trailingslashit( wp_normalize_path( $base_real ) ) : '';
838
839 $attachments = is_array( $attachments ) ? array_filter(
840 $attachments,
841 static function ( $path ) use ( $uploads_dir ) {
842 if ( ! is_string( $path ) || '' === $path || ! file_exists( $path ) ) {
843 return false;
844 }
845
846 // Containment check: only files inside the uploads directory
847 // may be attached — a filtered-in traversal path or symlink
848 // must not exfiltrate arbitrary server files by email.
849 $real = realpath( $path );
850
851 if ( ! is_string( $real ) || '' === $uploads_dir ) {
852 return false;
853 }
854
855 return 0 === strpos( wp_normalize_path( $real ), $uploads_dir );
856 }
857 ) : [];
858
859 // Re-key rather than array_values(): a string key is the name the
860 // recipient sees, which is how a receipt stored under an unguessable
861 // filename arrives as something readable. Keys that do not survive
862 // sanitize_file_name() are dropped so the attachment falls back to the
863 // file's own name — never to attacker-shaped text in a mail header.
864 $named_attachments = [];
865
866 foreach ( $attachments as $key => $path ) {
867 $name = is_string( $key ) ? sanitize_file_name( $key ) : '';
868
869 // sanitize_file_name() neither requires nor preserves an extension, so
870 // a key like "Receipt for Ada" would be delivered with none at all --
871 // PHPMailer takes the content type from the PATH, leaving the reader a
872 // file their OS cannot open by double-clicking. Reconcile the two.
873 if ( '' !== $name ) {
874 $real_ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
875
876 if ( '' !== $real_ext && strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) ) !== $real_ext ) {
877 $name .= '.' . $real_ext;
878 }
879 }
880
881 // A name already claimed by an earlier attachment would silently
882 // replace it, losing a file that used to be sent. Keep both: the
883 // loser falls back to its own filename rather than disappearing.
884 if ( '' !== $name && ! isset( $named_attachments[ $name ] ) ) {
885 $named_attachments[ $name ] = $path;
886 continue;
887 }
888
889 $named_attachments[] = $path;
890 }
891
892 $attachments = $named_attachments;
893
894 // Send email.
895 $sent = wp_mail( $to_email, $subject, $email_body, $headers, $attachments );
896
897 // Log email send attempt.
898 // 4th param is display name (not machine ID). Pre-release plugin (v0.0.1) with no
899 // external consumers of this hook, so no backward-compatibility concern.
900 $notification_name = isset( $notification['name'] ) && is_string( $notification['name'] ) ? $notification['name'] : '';
901 do_action( 'suredonation_email_sent', $donation_id, $to_email, $sent, $notification_name );
902
903 return $sent;
904 }
905
906 /**
907 * Process smart tags in email content.
908 *
909 * @param string $content Content with smart tags.
910 * @param array<string, mixed> $donation_data Donation data.
911 * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
912 * @return string Processed content.
913 * @since 0.0.1
914 */
915 public static function process_smart_tags( $content, $donation_data, $campaign ) {
916 // Get currency symbol - ensure string type.
917 $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD';
918 $campaign_title = ( $campaign instanceof \WP_Post ) ? $campaign->post_title : '';
919
920 // Calculate total amount (base + fees) - ensure numeric types.
921 $amount_value = $donation_data['amount'] ?? 0;
922 $fees_covered_value = $donation_data['fees_covered'] ?? 0;
923 $base_amount = is_numeric( $amount_value ) ? (float) $amount_value : 0.0;
924 $fees_covered = is_numeric( $fees_covered_value ) ? (float) $fees_covered_value : 0.0;
925 $total_amount = $base_amount + $fees_covered;
926
927 // Format amounts with currency symbol.
928 $formatted_amount = Payment_Helper::format_amount( $total_amount, $currency );
929
930 // Get date format - ensure string type.
931 $date_format = get_option( 'date_format' );
932 $date_format = is_string( $date_format ) ? $date_format : 'Y-m-d';
933
934 // Prefer the donation's own timestamp. Falling back to "now" dates a
935 // receipt to when the email happened to be sent, which is wrong whenever
936 // that is not the moment of the donation — a delayed or redelivered
937 // gateway webhook, or a recurring charge whose template labels the field
938 // as the start date.
939 $created_at = isset( $donation_data['created_at'] ) && is_string( $donation_data['created_at'] ) ? $donation_data['created_at'] : '';
940 $created_stamp = '' !== $created_at ? strtotime( $created_at ) : false;
941 $donation_date = false !== $created_stamp
942 ? wp_date( $date_format, $created_stamp )
943 : current_time( $date_format );
944
945 // Smart tags mapping.
946 $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : __( 'Donor', 'suredonation' );
947 $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
948 $transaction_id = isset( $donation_data['transaction_id'] ) && is_string( $donation_data['transaction_id'] ) ? $donation_data['transaction_id'] : '';
949 if ( empty( $transaction_id ) && isset( $donation_data['id'] ) ) {
950 $transaction_id = is_scalar( $donation_data['id'] ) ? (string) $donation_data['id'] : '';
951 }
952
953 // Subscription smart tags.
954 $subscription_id = isset( $donation_data['subscription_id'] ) && is_string( $donation_data['subscription_id'] ) ? $donation_data['subscription_id'] : '';
955 $admin_email = get_option( 'admin_email', '' );
956
957 // Payment method smart tags.
958 $gateway = isset( $donation_data['gateway'] ) && is_string( $donation_data['gateway'] ) ? $donation_data['gateway'] : 'stripe';
959 $payment_method = Helper::get_payment_method_label( $gateway );
960 $payment_status = isset( $donation_data['payment_status'] ) && is_string( $donation_data['payment_status'] ) ? $donation_data['payment_status'] : '';
961
962 $offline_instructions = '';
963 if ( 'offline' === $gateway ) {
964 $offline_instructions = Offline_Helper::get_offline_instructions();
965 }
966
967 $tags = [
968 '{donor_name}' => esc_html( $donor_name ),
969 '{donor_email}' => esc_html( $donor_email ),
970 '{amount}' => esc_html( $formatted_amount ),
971 '{campaign_name}' => esc_html( $campaign_title ),
972 '{donation_date}' => esc_html( (string) $donation_date ),
973 '{transaction_id}' => esc_html( $transaction_id ),
974 '{site_title}' => esc_html( get_bloginfo( 'name' ) ),
975 '{admin_email}' => esc_html( Helper::get_string_value( $admin_email ) ),
976 '{site_url}' => esc_url( home_url() ),
977 '{admin_url}' => esc_url( admin_url( 'admin.php?page=suredonation' ) ),
978 '{subscription_id}' => esc_html( $subscription_id ),
979 '{subscription_interval}' => isset( $donation_data['subscription_interval'] ) && is_string( $donation_data['subscription_interval'] )
980 ? esc_html( $donation_data['subscription_interval'] )
981 : '',
982 '{payment_method}' => esc_html( $payment_method ),
983 '{donation_amount}' => esc_html( Payment_Helper::format_amount( $base_amount, $currency ) ),
984 '{donation_total}' => esc_html( $formatted_amount ),
985 '{payment_status}' => Helper::render_payment_status_badge( $payment_status ),
986 '{success_badge}' => Helper::render_success_badge(),
987 '{donation_receipt}' => Helper::render_donation_receipt( $donation_data, $campaign_title ),
988 '{refund_amount}' => isset( $donation_data['refund_amount'] ) && is_numeric( $donation_data['refund_amount'] )
989 ? esc_html( Payment_Helper::format_amount( (float) $donation_data['refund_amount'], $currency ) )
990 : '',
991 '{offline_instructions}' => wp_kses_post( $offline_instructions ),
992 '{form_fields}' => Helper::render_submitted_fields(
993 isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ? $donation_data['fields'] : []
994 ),
995 ];
996
997 // Apply filters to allow adding custom smart tags.
998 $core_tags = $tags;
999 $tags = apply_filters( 'suredonation_email_smart_tags', $tags, $donation_data, $campaign );
1000
1001 // Escape anything the filter introduced. Core tags deliberately carry
1002 // markup, so they are compared by value rather than by key — checking the
1003 // key alone let a callback overwrite an existing tag and slip raw HTML
1004 // into every email untouched.
1005 //
1006 // A consequence worth knowing: a callback that *appends* to a core tag
1007 // changes its value, so the result is escaped and any markup or bare `&`
1008 // the core tag carried is encoded a second time. Allowlisting the
1009 // markup-carrying tags would avoid that, but it would also reopen the
1010 // overwrite path above, so the escaping wins and the filter should
1011 // replace a tag outright rather than concatenate onto it.
1012 foreach ( $tags as $tag_key => $tag_value ) {
1013 if ( isset( $core_tags[ $tag_key ] ) && $core_tags[ $tag_key ] === $tag_value ) {
1014 continue;
1015 }
1016
1017 // A non-scalar cannot be rendered; casting one would emit "Array" or
1018 // fatal on an object, inside a payment webhook.
1019 if ( ! is_scalar( $tag_value ) ) {
1020 unset( $tags[ $tag_key ] );
1021 continue;
1022 }
1023
1024 $tags[ $tag_key ] = esc_html( (string) $tag_value );
1025 }
1026
1027 // Replace smart tags.
1028 return str_replace( array_keys( $tags ), array_values( $tags ), $content );
1029 }
1030
1031 /**
1032 * Format email body with HTML wrapper.
1033 *
1034 * @param string $body Email body content.
1035 * @return string Formatted HTML email.
1036 * @since 0.0.1
1037 */
1038 private static function format_email_body( $body ) {
1039 $email_template = Email_Template::get_instance();
1040 return $email_template->render( $body );
1041 }
1042 }
1043