PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.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
← All changes | inc/emails/email-handler.php +41 -694 1.6.0 → 1.1.0 View file →
@@ -51,78 +51,33 @@
51 51 * @return void
52 52 * @since 1.0.0
53 53 */
54 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 ) {
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 + }
77 65 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 66 }
86 67
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 - }
68 + if ( empty( $form_id ) ) {
69 + $form_id = self::get_form_id_from_donation( $donation_id );
110 70 }
111 71
112 72 $notifications = self::get_form_notifications( $form_id );
113 73
114 74 if ( empty( $notifications ) ) {
115 - self::release_send_lock( $lock_key );
116 75 return;
117 76 }
118 77
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 );
78 + $campaign = get_post( $campaign_id );
79 + if ( ! $campaign ) {
125 80 return;
126 81 }
127 82
128 83 foreach ( $notifications as $notification ) {
@@ -153,9 +108,9 @@
153 108 continue;
154 109 }
155 110
156 111 foreach ( $recipients as $recipient ) {
157 - self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id, $event );
112 + self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id );
158 113 }
159 114 }
160 115 }
161 116
@@ -168,47 +123,8 @@
168 123 * @param int $form_id Form post ID.
169 124 * @return void
170 125 * @since 0.0.1
171 126 */
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 1.6.0
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 1.6.0
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 127 public static function send_donation_confirmation( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) {
212 128 self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_COMPLETED );
213 129 }
214 130
@@ -254,150 +170,10 @@
254 170 self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_REFUND_PROCESSED );
255 171 }
256 172
257 173 /**
258 - * Option recording that stored rows have had their keys back-filled.
174 + * Get email notifications from form post meta.
259 175 *
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 176 * @param int $form_id Form post ID.
401 177 * @return array<int, array<string, mixed>> Array of notification configs.
402 178 * @since 1.0.0
403 179 */
@@ -405,362 +181,55 @@
405 181 if ( empty( $form_id ) ) {
406 182 return [];
407 183 }
408 184
409 - $raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true );
410 - $notifications = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : null;
185 + $raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true );
411 186
412 - if ( ! is_array( $notifications ) ) {
413 - $notifications = [];
187 + if ( empty( $raw ) || ! is_string( $raw ) ) {
188 + return [];
414 189 }
415 190
416 - $defaults = Assets::get_instance()->get_default_email_notifications();
191 + $notifications = json_decode( $raw, true );
417 192
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'] ) ) {
193 + if ( ! is_array( $notifications ) ) {
538 194 return [];
539 195 }
540 196
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 197 return $notifications;
604 198 }
605 199
606 200 /**
607 - * Collapse rows that resolve to the same notification.
201 + * Look up form_id from the donations table.
608 202 *
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
203 + * @param int $donation_id Donation ID.
204 + * @return int Form ID, or 0 if not found.
205 + * @since 1.0.0
620 206 */
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 - }
207 + private static function get_form_id_from_donation( $donation_id ) {
208 + if ( empty( $donation_id ) ) {
209 + return 0;
646 210 }
647 211
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;
212 + $donation = Donations::get( $donation_id );
213 + if ( ! $donation || ! is_array( $donation ) ) {
214 + return 0;
662 215 }
663 216
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;
217 + return isset( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
685 218 }
686 219
687 220 /**
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 221 * Send email using notification settings.
752 222 *
753 223 * @param string $to_email Recipient email address.
754 224 * @param array<string, mixed> $notification Notification settings.
755 225 * @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.
226 + * @param \WP_Post $campaign Campaign post object.
757 227 * @param int $donation_id Optional donation ID.
758 - * @param string $event The event that triggered this email.
759 228 * @return bool True if email was sent successfully.
760 229 * @since 0.0.1
761 230 */
762 - private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0, $event = '' ) {
231 + private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0 ) {
763 232 if ( empty( $to_email ) || ! is_email( $to_email ) ) {
764 233 return false;
765 234 }
766 235
@@ -801,99 +270,10 @@
801 270
802 271 // Convert plain text to HTML if needed.
803 272 $email_body = self::format_email_body( $email_body );
804 273
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 274 // Send email.
895 - $sent = wp_mail( $to_email, $subject, $email_body, $headers, $attachments );
275 + $sent = wp_mail( $to_email, $subject, $email_body, $headers );
896 276
897 277 // Log email send attempt.
898 278 // 4th param is display name (not machine ID). Pre-release plugin (v0.0.1) with no
899 279 // external consumers of this hook, so no backward-compatibility concern.
@@ -907,9 +287,9 @@
907 287 * Process smart tags in email content.
908 288 *
909 289 * @param string $content Content with smart tags.
910 290 * @param array<string, mixed> $donation_data Donation data.
911 - * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
291 + * @param \WP_Post $campaign Campaign post object.
912 292 * @return string Processed content.
913 293 * @since 0.0.1
914 294 */
915 295 public static function process_smart_tags( $content, $donation_data, $campaign ) {
@@ -930,19 +310,8 @@
930 310 // Get date format - ensure string type.
931 311 $date_format = get_option( 'date_format' );
932 312 $date_format = is_string( $date_format ) ? $date_format : 'Y-m-d';
933 313
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 314 // Smart tags mapping.
946 315 $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : __( 'Donor', 'suredonation' );
947 316 $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
948 317 $transaction_id = isset( $donation_data['transaction_id'] ) && is_string( $donation_data['transaction_id'] ) ? $donation_data['transaction_id'] : '';
@@ -968,9 +337,9 @@
968 337 '{donor_name}' => esc_html( $donor_name ),
969 338 '{donor_email}' => esc_html( $donor_email ),
970 339 '{amount}' => esc_html( $formatted_amount ),
971 340 '{campaign_name}' => esc_html( $campaign_title ),
972 - '{donation_date}' => esc_html( (string) $donation_date ),
341 + '{donation_date}' => esc_html( (string) current_time( $date_format ) ),
973 342 '{transaction_id}' => esc_html( $transaction_id ),
974 343 '{site_title}' => esc_html( get_bloginfo( 'name' ) ),
975 344 '{admin_email}' => esc_html( Helper::get_string_value( $admin_email ) ),
976 345 '{site_url}' => esc_url( home_url() ),
@@ -988,41 +357,19 @@
988 357 '{refund_amount}' => isset( $donation_data['refund_amount'] ) && is_numeric( $donation_data['refund_amount'] )
989 358 ? esc_html( Payment_Helper::format_amount( (float) $donation_data['refund_amount'], $currency ) )
990 359 : '',
991 360 '{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 361 ];
996 362
997 363 // Apply filters to allow adding custom smart tags.
998 - $core_tags = $tags;
364 + $core_tags = array_keys( $tags );
999 365 $tags = apply_filters( 'suredonation_email_smart_tags', $tags, $donation_data, $campaign );
1000 366
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.
367 + // Sanitize any third-party tags added via the filter to prevent XSS in HTML emails.
1012 368 foreach ( $tags as $tag_key => $tag_value ) {
1013 - if ( isset( $core_tags[ $tag_key ] ) && $core_tags[ $tag_key ] === $tag_value ) {
1014 - continue;
369 + if ( ! in_array( $tag_key, $core_tags, true ) ) {
370 + $tags[ $tag_key ] = esc_html( (string) $tag_value );
1015 371 }
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 372 }
1026 373
1027 374 // Replace smart tags.
1028 375 return str_replace( array_keys( $tags ), array_values( $tags ), $content );