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 +42 -569 1.5.0 → 1.1.0 View file →
@@ -51,64 +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 66 }
79 67
80 - // One lookup covers both the form and the donation timestamp; callers
81 - // build their own data array and rarely carry created_at.
82 - $needs_form_id = empty( $form_id );
83 - $needs_timestamp = empty( $donation_data['created_at'] );
84 -
85 - if ( ( $needs_form_id || $needs_timestamp ) && ! empty( $donation_id ) ) {
86 - $donation = Donations::get( $donation_id );
87 -
88 - if ( is_array( $donation ) ) {
89 - if ( $needs_form_id && isset( $donation['form_id'] ) && is_scalar( $donation['form_id'] ) ) {
90 - $form_id = absint( $donation['form_id'] );
91 - }
92 - if ( $needs_timestamp && isset( $donation['created_at'] ) && is_string( $donation['created_at'] ) ) {
93 - $donation_data['created_at'] = $donation['created_at'];
94 - }
95 - }
68 + if ( empty( $form_id ) ) {
69 + $form_id = self::get_form_id_from_donation( $donation_id );
96 70 }
97 71
98 72 $notifications = self::get_form_notifications( $form_id );
99 73
100 74 if ( empty( $notifications ) ) {
101 - self::release_send_lock( $lock_key );
102 75 return;
103 76 }
104 77
105 - // campaign_id 0 is a supported standalone form, not an error — see the
106 - // note on Donations::add(). Everything downstream already tolerates a
107 - // null campaign, so only a genuinely missing campaign should stop the send.
108 - $campaign = $campaign_id > 0 ? get_post( $campaign_id ) : null;
109 - if ( $campaign_id > 0 && ! $campaign ) {
110 - self::release_send_lock( $lock_key );
78 + $campaign = get_post( $campaign_id );
79 + if ( ! $campaign ) {
111 80 return;
112 81 }
113 82
114 83 foreach ( $notifications as $notification ) {
@@ -139,9 +108,9 @@
139 108 continue;
140 109 }
141 110
142 111 foreach ( $recipients as $recipient ) {
143 - self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id, $event );
112 + self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id );
144 113 }
145 114 }
146 115 }
147 116
@@ -201,150 +170,10 @@
201 170 self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_REFUND_PROCESSED );
202 171 }
203 172
204 173 /**
205 - * Option recording that stored rows have had their keys back-filled.
174 + * Get email notifications from form post meta.
206 175 *
207 - * @since 1.4.0
208 - */
209 - public const KEY_BACKFILL_OPTION = 'suredonation_notification_keys_backfilled';
210 -
211 - /**
212 - * Write resolved identities back to rows saved before keys existed.
213 - *
214 - * Resolution is otherwise re-derived on every send and never persisted, so a
215 - * form saved before this release stays dependent on guesswork for the rest of
216 - * its life — and stays exposed to whatever breaks the guess, whether that is
217 - * a rename, a locale difference or an edited recipient. Doing it once, here,
218 - * is what makes the fallback a migration rather than a permanent code path.
219 - *
220 - * Deliberately runs in admin context only: it writes, and the read path it
221 - * repairs is reached during donor payment requests.
222 - *
223 - * Only identity is written: keys, and the triggers the old sanitizer
224 - * overwrote. Deliberately not the dedupe — it discards one of two rows that
225 - * resolve to the same key, and where both were customised that is an edit
226 - * the admin cannot get back. In memory that loss lasts one request; on disk
227 - * it is permanent. Collapsing duplicates costs nothing to redo per read, so
228 - * it stays there.
229 - *
230 - * Missing defaults are not appended either. That is a read-time concern
231 - * which depends on whether Pro is active, and baking today's answer into the
232 - * row would strand the form the next time that changes.
233 - *
234 - * @return void
235 - * @since 1.4.0
236 - */
237 - public static function backfill_notification_keys() {
238 - $defaults = Assets::get_instance()->get_default_email_notifications();
239 -
240 - // Keyed to the defaults that were available, not a bare "done" flag.
241 - // Which rows can be identified depends on what is registered at the time:
242 - // with Pro inactive or on an older build, its templates and their former
243 - // names are simply absent, and its rows resolve to nothing. Recording the
244 - // set means the pass runs again once that set changes — when Pro is
245 - // activated or updated — instead of a one-time run deciding forever.
246 - $signature = self::default_keys_signature( $defaults );
247 -
248 - if ( get_option( self::KEY_BACKFILL_OPTION ) === $signature ) {
249 - return;
250 - }
251 -
252 - global $wpdb;
253 -
254 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration over a meta key with no API equivalent.
255 - $rows = $wpdb->get_results(
256 - $wpdb->prepare(
257 - "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value != ''",
258 - Assets::EMAIL_NOTIFICATIONS_META_KEY
259 - )
260 - );
261 -
262 - if ( is_array( $rows ) && ! empty( $rows ) ) {
263 - foreach ( $rows as $row ) {
264 - $stored = json_decode( (string) $row->meta_value, true );
265 -
266 - if ( ! is_array( $stored ) || empty( $stored ) ) {
267 - continue;
268 - }
269 -
270 - $resolved = self::add_identity_keys( $stored, $defaults );
271 - $resolved = self::restore_rewritten_triggers( $resolved, $defaults );
272 -
273 - // Only rows that were identified are written. A row that resolved
274 - // to nothing keeps exactly what it had, including its status: the
275 - // read path still parks it so it cannot mis-send, but parking it
276 - // on disk would outlive the reason for it — a row unidentifiable
277 - // today because Pro is a version behind would stay switched off
278 - // after Pro caught up, with nothing to switch it back.
279 - foreach ( $resolved as $index => $row_data ) {
280 - if ( empty( $row_data['key'] ) ) {
281 - $resolved[ $index ] = $stored[ $index ];
282 - }
283 - }
284 -
285 - if ( $resolved === $stored ) {
286 - continue;
287 - }
288 -
289 - update_post_meta(
290 - (int) $row->post_id,
291 - Assets::EMAIL_NOTIFICATIONS_META_KEY,
292 - wp_slash( (string) wp_json_encode( $resolved ) )
293 - );
294 - }
295 - }
296 -
297 - update_option( self::KEY_BACKFILL_OPTION, $signature, false );
298 - }
299 -
300 - /**
301 - * Signature of the default keys currently registered.
302 - *
303 - * @param array<int, array<string, mixed>> $defaults Default notifications.
304 - * @return string Signature.
305 - * @since 1.4.0
306 - */
307 - private static function default_keys_signature( $defaults ) {
308 - $keys = [];
309 -
310 - foreach ( $defaults as $default ) {
311 - if ( ! empty( $default['key'] ) && is_string( $default['key'] ) ) {
312 - $keys[] = $default['key'];
313 - }
314 - }
315 -
316 - sort( $keys );
317 -
318 - return md5( (string) wp_json_encode( $keys ) );
319 - }
320 -
321 - /**
322 - * Give back a send lock claimed by a call that delivered nothing.
323 - *
324 - * @param string $lock_key Lock transient name, or '' when unlocked.
325 - * @return void
326 - * @since 1.4.0
327 - */
328 - private static function release_send_lock( $lock_key ) {
329 - if ( '' !== $lock_key ) {
330 - delete_transient( $lock_key );
331 - }
332 - }
333 -
334 - /**
335 - * Get email notifications for a form.
336 - *
337 - * Stored settings are authoritative, but they are only written when an admin
338 - * opens the form's Email Notifications tab and saves. Until then the meta is
339 - * empty, and reading it literally means a form sends nothing at all. Defaults
340 - * fill the gaps so delivery never depends on having visited the editor.
341 - *
342 - * Resolved per read and not persisted: sending happens mid-payment, and a
343 - * write there would be a side effect on a path that only needs to read. The
344 - * resolution is persisted once by backfill_notification_keys(), which runs
345 - * in admin context, so this stays a fallback rather than the normal path.
346 - *
347 176 * @param int $form_id Form post ID.
348 177 * @return array<int, array<string, mixed>> Array of notification configs.
349 178 * @since 1.0.0
350 179 */
@@ -352,338 +181,55 @@
352 181 if ( empty( $form_id ) ) {
353 182 return [];
354 183 }
355 184
356 - $raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true );
357 - $notifications = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : null;
185 + $raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true );
358 186
359 - if ( ! is_array( $notifications ) ) {
360 - $notifications = [];
187 + if ( empty( $raw ) || ! is_string( $raw ) ) {
188 + return [];
361 189 }
362 190
363 - $defaults = Assets::get_instance()->get_default_email_notifications();
191 + $notifications = json_decode( $raw, true );
364 192
365 - if ( empty( $notifications ) ) {
366 - return $defaults;
193 + if ( ! is_array( $notifications ) ) {
194 + return [];
367 195 }
368 196
369 - $notifications = self::add_identity_keys( $notifications, $defaults );
370 - $notifications = self::restore_rewritten_triggers( $notifications, $defaults );
371 - $notifications = self::drop_duplicate_notifications( $notifications );
372 -
373 - return self::add_missing_notifications( $notifications, $defaults );
374 - }
375 -
376 - /**
377 - * Stamp a stable identity onto rows saved before keys existed.
378 - *
379 - * Older rows carry only `id` and `name`, and neither identifies anything on
380 - * its own: `id` is reassigned whenever the editor re-seeds a set, and `name`
381 - * is user-editable and translated — it resolves in the admin's locale when
382 - * saved and the site's locale when an email is sent.
383 - *
384 - * So the fallback is `trigger` + `email_to`. Both are untranslated, neither
385 - * changes when a notification is renamed, and the pair is unique across every
386 - * default (`trigger` alone is not — donor and admin templates share one).
387 - *
388 - * @param array<int, array<string, mixed>> $notifications Stored notifications.
389 - * @param array<int, array<string, mixed>> $defaults Default notifications.
390 - * @return array<int, array<string, mixed>> Notifications with a `key` where one could be resolved.
391 - * @since 1.4.0
392 - */
393 - private static function add_identity_keys( $notifications, $defaults ) {
394 - $by_name = [];
395 - $by_signature = [];
396 -
397 - foreach ( $defaults as $default ) {
398 - $key = isset( $default['key'] ) && is_string( $default['key'] ) ? $default['key'] : '';
399 - if ( '' === $key ) {
400 - continue;
401 - }
402 -
403 - $signature = self::notification_signature( $default );
404 - if ( '' !== $signature && ! isset( $by_signature[ $signature ] ) ) {
405 - $by_signature[ $signature ] = $key;
406 - }
407 -
408 - if ( isset( $default['name'] ) && is_string( $default['name'] ) ) {
409 - $by_name[ $default['name'] ] = $key;
410 - }
411 - // A default that has been renamed carries the names it used to have,
412 - // so rows stored under the old wording still resolve.
413 - if ( isset( $default['legacy_names'] ) && is_array( $default['legacy_names'] ) ) {
414 - foreach ( $default['legacy_names'] as $legacy ) {
415 - if ( is_string( $legacy ) && ! isset( $by_name[ $legacy ] ) ) {
416 - $by_name[ $legacy ] = $key;
417 - }
418 - }
419 - }
420 - }
421 -
422 - foreach ( $notifications as $index => $notification ) {
423 - if ( ! empty( $notification['key'] ) ) {
424 - continue;
425 - }
426 -
427 - $name = isset( $notification['name'] ) && is_string( $notification['name'] ) ? $notification['name'] : '';
428 -
429 - if ( '' !== $name && isset( $by_name[ $name ] ) ) {
430 - $notifications[ $index ]['key'] = $by_name[ $name ];
431 - continue;
432 - }
433 -
434 - // The name did not match, which a rename or a locale difference is
435 - // enough to cause. Fall back to what the admin did not edit and
436 - // gettext does not touch.
437 - //
438 - // Not `id`: the editor used to reassign ids when re-seeding, so a
439 - // stored id lands on whichever default happens to hold it and a donor
440 - // row can inherit an admin row's key — routing it to the wrong trigger
441 - // and suppressing the default it was mistaken for. No key at all is
442 - // recoverable; a confidently wrong one is not.
443 - $signature = self::notification_signature( $notification );
444 - if ( '' !== $signature && isset( $by_signature[ $signature ] ) ) {
445 - $notifications[ $index ]['key'] = $by_signature[ $signature ];
446 - continue;
447 - }
448 -
449 - // Unidentifiable, and 'all' means "fire on every event" — the value
450 - // the old sanitizer fell back to. Leaving it enabled would send a
451 - // recurring template to a one-time donor, so it is parked rather than
452 - // guessed at.
453 - //
454 - // Parking here is in memory only, so the editor still shows the row
455 - // switched on. backfill_notification_keys() is what reconciles the
456 - // two: it persists the resolution, which restores the real trigger
457 - // for rows that resolve and records the off state for those that do
458 - // not, so the toggle stops disagreeing with what actually sends.
459 - if ( 'all' === ( $notification['trigger'] ?? '' ) ) {
460 - $notifications[ $index ]['status'] = false;
461 - }
462 - }
463 -
464 197 return $notifications;
465 198 }
466 199
467 200 /**
468 - * Identify a notification by the two fields that survive editing.
201 + * Look up form_id from the donations table.
469 202 *
470 - * `trigger` is a sanitized key and `email_to` is a smart tag, so neither is
471 - * translated and neither changes when a notification is renamed. Together
472 - * they are unique across every default; `trigger` on its own is not, because
473 - * the donor and admin templates for an event share it.
474 - *
475 - * @param array<string, mixed> $notification Notification row.
476 - * @return string Signature, or '' when the row cannot supply one.
477 - * @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
478 206 */
479 - private static function notification_signature( $notification ) {
480 - $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : '';
481 - $email_to = isset( $notification['email_to'] ) && is_string( $notification['email_to'] ) ? $notification['email_to'] : '';
482 -
483 - if ( '' === $trigger || '' === $email_to ) {
484 - return '';
207 + private static function get_form_id_from_donation( $donation_id ) {
208 + if ( empty( $donation_id ) ) {
209 + return 0;
485 210 }
486 211
487 - return $trigger . '|' . $email_to;
488 - }
489 -
490 - /**
491 - * Restore triggers rewritten by an older save.
492 - *
493 - * Saving a form while Pro was inactive rewrote its recurring triggers to
494 - * 'all', so those notifications fire on every donation event. The editor
495 - * repairs this when the tab is opened; doing it here as well means a form
496 - * nobody edits stops mis-sending too.
497 - *
498 - * Only 'all' is corrected, and only against the row's resolved key. 'all' is
499 - * the exact value the old sanitizer fell back to, so any other mismatch is
500 - * treated as a deliberate choice and left alone.
501 - *
502 - * @param array<int, array<string, mixed>> $notifications Stored notifications.
503 - * @param array<int, array<string, mixed>> $defaults Default notifications.
504 - * @return array<int, array<string, mixed>> Notifications with triggers restored.
505 - * @since 1.4.0
506 - */
507 - private static function restore_rewritten_triggers( $notifications, $defaults ) {
508 - $by_key = [];
509 - foreach ( $defaults as $default ) {
510 - if ( ! empty( $default['key'] ) && is_string( $default['key'] ) && isset( $default['trigger'] ) ) {
511 - $by_key[ $default['key'] ] = $default['trigger'];
512 - }
212 + $donation = Donations::get( $donation_id );
213 + if ( ! $donation || ! is_array( $donation ) ) {
214 + return 0;
513 215 }
514 216
515 - foreach ( $notifications as $index => $notification ) {
516 - $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
517 - $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : '';
518 -
519 - if ( 'all' !== $trigger || '' === $key || ! isset( $by_key[ $key ] ) ) {
520 - continue;
521 - }
522 -
523 - $notifications[ $index ]['trigger'] = $by_key[ $key ];
524 - }
525 -
526 - return $notifications;
217 + return isset( $donation['form_id'] ) ? absint( $donation['form_id'] ) : 0;
527 218 }
528 219
529 220 /**
530 - * Collapse rows that resolve to the same notification.
531 - *
532 - * A form that went through the deactivate/reactivate cycle holds the admin's
533 - * customised row alongside a pristine copy the editor appended when it failed
534 - * to recognise the original. Restoring the trigger above makes the two exact
535 - * twins, so without this the donor receives both.
536 - *
537 - * The customised row wins: it is the one carrying the admin's edits, and the
538 - * pristine copy only exists because of the recognition bug.
539 - *
540 - * @param array<int, array<string, mixed>> $notifications Stored notifications.
541 - * @return array<int, array<string, mixed>> Notifications with duplicates removed.
542 - * @since 1.4.0
543 - */
544 - private static function drop_duplicate_notifications( $notifications ) {
545 - $seen = [];
546 - $kept = [];
547 -
548 - foreach ( $notifications as $notification ) {
549 - $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
550 -
551 - // Without a resolved key there is nothing safe to compare on, so the
552 - // row is kept as-is rather than guessed at.
553 - if ( '' === $key ) {
554 - $kept[] = $notification;
555 - continue;
556 - }
557 -
558 - if ( ! isset( $seen[ $key ] ) ) {
559 - $seen[ $key ] = count( $kept );
560 - $kept[] = $notification;
561 - continue;
562 - }
563 -
564 - // Prefer whichever copy the admin actually edited.
565 - $existing = $kept[ $seen[ $key ] ];
566 - if ( self::is_customised( $notification ) && ! self::is_customised( $existing ) ) {
567 - $kept[ $seen[ $key ] ] = $notification;
568 - }
569 - }
570 -
571 - return $kept;
572 - }
573 -
574 - /**
575 - * Whether a stored row differs from the default it came from.
576 - *
577 - * @param array<string, mixed> $notification Stored notification.
578 - * @return bool True when the row carries admin edits.
579 - * @since 1.4.0
580 - */
581 - private static function is_customised( $notification ) {
582 - $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : '';
583 - if ( '' === $key ) {
584 - return false;
585 - }
586 -
587 - // Cached: this runs inside a payment request, twice per duplicate row, and
588 - // rebuilding the array means ~40 __() calls plus an apply_filters pass
589 - // each time. The defaults are constant for the life of the request.
590 - static $defaults = null;
591 - if ( null === $defaults ) {
592 - $defaults = Assets::get_instance()->get_default_email_notifications();
593 - }
594 -
595 - foreach ( $defaults as $default ) {
596 - if ( ( $default['key'] ?? '' ) !== $key ) {
597 - continue;
598 - }
599 - foreach ( [ 'subject', 'email_body', 'email_to', 'from_name', 'from_email', 'reply_to', 'name' ] as $field ) {
600 - if ( ( $notification[ $field ] ?? '' ) !== ( $default[ $field ] ?? '' ) ) {
601 - return true;
602 - }
603 - }
604 - return false;
605 - }
606 -
607 - return false;
608 - }
609 -
610 - /**
611 - * Add defaults that the stored set does not already cover.
612 - *
613 - * Notifications cannot be added or deleted in the editor, so a default with
614 - * no stored counterpart was never seeded — most often because Pro was
615 - * activated after the form was last saved. Without this, those forms send
616 - * nothing for the events Pro adds.
617 - *
618 - * Matching is on the stable key only. Falling back to `id` or `name` here
619 - * would reintroduce the very ambiguity the key exists to remove: a locale
620 - * difference used to make a present notification look absent (so it was
621 - * added twice), while a reassigned id could collide with a different
622 - * notification's default and make an absent one look present (so it was
623 - * never sent at all).
624 - *
625 - * @param array<int, array<string, mixed>> $notifications Stored notifications.
626 - * @param array<int, array<string, mixed>> $defaults Default notifications.
627 - * @return array<int, array<string, mixed>> Stored notifications plus any missing defaults.
628 - * @since 1.4.0
629 - */
630 - private static function add_missing_notifications( $notifications, $defaults ) {
631 - $stored_keys = [];
632 - $unresolved_slots = [];
633 -
634 - foreach ( $notifications as $notification ) {
635 - if ( ! empty( $notification['key'] ) && is_string( $notification['key'] ) ) {
636 - $stored_keys[ $notification['key'] ] = true;
637 - continue;
638 - }
639 -
640 - // A row we could not identify still occupies its slot. Appending the
641 - // default that belongs there would leave two enabled rows on one
642 - // trigger and send the donor two of every email — so the slot is
643 - // recorded and the default withheld. Matched on the full signature,
644 - // not just the trigger: the donor and admin templates for an event
645 - // share a trigger, and withholding both because one row is
646 - // unidentified would silence a notification that is working.
647 - if ( ! empty( $notification['status'] ) ) {
648 - $signature = self::notification_signature( $notification );
649 - if ( '' !== $signature ) {
650 - $unresolved_slots[ $signature ] = true;
651 - }
652 - }
653 - }
654 -
655 - foreach ( $defaults as $default ) {
656 - $key = isset( $default['key'] ) && is_string( $default['key'] ) ? $default['key'] : '';
657 -
658 - if ( '' === $key || isset( $stored_keys[ $key ] ) ) {
659 - continue;
660 - }
661 -
662 - $signature = self::notification_signature( $default );
663 - if ( '' !== $signature && isset( $unresolved_slots[ $signature ] ) ) {
664 - continue;
665 - }
666 -
667 - $notifications[] = $default;
668 - }
669 -
670 - return $notifications;
671 - }
672 -
673 - /**
674 221 * Send email using notification settings.
675 222 *
676 223 * @param string $to_email Recipient email address.
677 224 * @param array<string, mixed> $notification Notification settings.
678 225 * @param array<string, mixed> $donation_data Donation data for smart tags.
679 - * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
226 + * @param \WP_Post $campaign Campaign post object.
680 227 * @param int $donation_id Optional donation ID.
681 - * @param string $event The event that triggered this email.
682 228 * @return bool True if email was sent successfully.
683 229 * @since 0.0.1
684 230 */
685 - 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 ) {
686 232 if ( empty( $to_email ) || ! is_email( $to_email ) ) {
687 233 return false;
688 234 }
689 235
@@ -724,53 +270,10 @@
724 270
725 271 // Convert plain text to HTML if needed.
726 272 $email_body = self::format_email_body( $email_body );
727 273
728 - /**
729 - * Filter attachments for outgoing notification emails.
730 - *
731 - * Each entry must be an absolute path to a local, readable file
732 - * (wp_mail() contract) inside the uploads directory. Non-string,
733 - * non-existent and out-of-uploads entries are dropped before sending.
734 - *
735 - * @param array<int, string> $attachments Attachment file paths. Default empty.
736 - * @param array<string, mixed> $notification Notification settings.
737 - * @param array<string, mixed> $donation_data Donation data.
738 - * @param \WP_Post $campaign Campaign post object.
739 - * @param int $donation_id Donation ID (0 when not available).
740 - * @param string $event The event that triggered this email (e.g. 'donation_completed').
741 - * @since 1.5.0
742 - */
743 - $attachments = apply_filters( 'suredonation_email_attachments', [], $notification, $donation_data, $campaign, $donation_id, $event );
744 -
745 - $upload_dir = wp_upload_dir();
746 - $base_real = isset( $upload_dir['basedir'] ) && is_string( $upload_dir['basedir'] ) ? realpath( $upload_dir['basedir'] ) : false;
747 - $uploads_dir = is_string( $base_real ) ? trailingslashit( wp_normalize_path( $base_real ) ) : '';
748 -
749 - $attachments = is_array( $attachments ) ? array_values(
750 - array_filter(
751 - $attachments,
752 - static function ( $path ) use ( $uploads_dir ) {
753 - if ( ! is_string( $path ) || '' === $path || ! file_exists( $path ) ) {
754 - return false;
755 - }
756 -
757 - // Containment check: only files inside the uploads directory
758 - // may be attached — a filtered-in traversal path or symlink
759 - // must not exfiltrate arbitrary server files by email.
760 - $real = realpath( $path );
761 -
762 - if ( ! is_string( $real ) || '' === $uploads_dir ) {
763 - return false;
764 - }
765 -
766 - return 0 === strpos( wp_normalize_path( $real ), $uploads_dir );
767 - }
768 - )
769 - ) : [];
770 -
771 274 // Send email.
772 - $sent = wp_mail( $to_email, $subject, $email_body, $headers, $attachments );
275 + $sent = wp_mail( $to_email, $subject, $email_body, $headers );
773 276
774 277 // Log email send attempt.
775 278 // 4th param is display name (not machine ID). Pre-release plugin (v0.0.1) with no
776 279 // external consumers of this hook, so no backward-compatibility concern.
@@ -784,9 +287,9 @@
784 287 * Process smart tags in email content.
785 288 *
786 289 * @param string $content Content with smart tags.
787 290 * @param array<string, mixed> $donation_data Donation data.
788 - * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form.
291 + * @param \WP_Post $campaign Campaign post object.
789 292 * @return string Processed content.
790 293 * @since 0.0.1
791 294 */
792 295 public static function process_smart_tags( $content, $donation_data, $campaign ) {
@@ -807,19 +310,8 @@
807 310 // Get date format - ensure string type.
808 311 $date_format = get_option( 'date_format' );
809 312 $date_format = is_string( $date_format ) ? $date_format : 'Y-m-d';
810 313
811 - // Prefer the donation's own timestamp. Falling back to "now" dates a
812 - // receipt to when the email happened to be sent, which is wrong whenever
813 - // that is not the moment of the donation — a delayed or redelivered
814 - // gateway webhook, or a recurring charge whose template labels the field
815 - // as the start date.
816 - $created_at = isset( $donation_data['created_at'] ) && is_string( $donation_data['created_at'] ) ? $donation_data['created_at'] : '';
817 - $created_stamp = '' !== $created_at ? strtotime( $created_at ) : false;
818 - $donation_date = false !== $created_stamp
819 - ? wp_date( $date_format, $created_stamp )
820 - : current_time( $date_format );
821 -
822 314 // Smart tags mapping.
823 315 $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : __( 'Donor', 'suredonation' );
824 316 $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : '';
825 317 $transaction_id = isset( $donation_data['transaction_id'] ) && is_string( $donation_data['transaction_id'] ) ? $donation_data['transaction_id'] : '';
@@ -845,9 +337,9 @@
845 337 '{donor_name}' => esc_html( $donor_name ),
846 338 '{donor_email}' => esc_html( $donor_email ),
847 339 '{amount}' => esc_html( $formatted_amount ),
848 340 '{campaign_name}' => esc_html( $campaign_title ),
849 - '{donation_date}' => esc_html( (string) $donation_date ),
341 + '{donation_date}' => esc_html( (string) current_time( $date_format ) ),
850 342 '{transaction_id}' => esc_html( $transaction_id ),
851 343 '{site_title}' => esc_html( get_bloginfo( 'name' ) ),
852 344 '{admin_email}' => esc_html( Helper::get_string_value( $admin_email ) ),
853 345 '{site_url}' => esc_url( home_url() ),
@@ -868,35 +360,16 @@
868 360 '{offline_instructions}' => wp_kses_post( $offline_instructions ),
869 361 ];
870 362
871 363 // Apply filters to allow adding custom smart tags.
872 - $core_tags = $tags;
364 + $core_tags = array_keys( $tags );
873 365 $tags = apply_filters( 'suredonation_email_smart_tags', $tags, $donation_data, $campaign );
874 366
875 - // Escape anything the filter introduced. Core tags deliberately carry
876 - // markup, so they are compared by value rather than by key — checking the
877 - // key alone let a callback overwrite an existing tag and slip raw HTML
878 - // into every email untouched.
879 - //
880 - // A consequence worth knowing: a callback that *appends* to a core tag
881 - // changes its value, so the result is escaped and any markup or bare `&`
882 - // the core tag carried is encoded a second time. Allowlisting the
883 - // markup-carrying tags would avoid that, but it would also reopen the
884 - // overwrite path above, so the escaping wins and the filter should
885 - // 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.
886 368 foreach ( $tags as $tag_key => $tag_value ) {
887 - if ( isset( $core_tags[ $tag_key ] ) && $core_tags[ $tag_key ] === $tag_value ) {
888 - continue;
369 + if ( ! in_array( $tag_key, $core_tags, true ) ) {
370 + $tags[ $tag_key ] = esc_html( (string) $tag_value );
889 371 }
890 -
891 - // A non-scalar cannot be rendered; casting one would emit "Array" or
892 - // fatal on an object, inside a payment webhook.
893 - if ( ! is_scalar( $tag_value ) ) {
894 - unset( $tags[ $tag_key ] );
895 - continue;
896 - }
897 -
898 - $tags[ $tag_key ] = esc_html( (string) $tag_value );
899 372 }
900 373
901 374 // Replace smart tags.
902 375 return str_replace( array_keys( $tags ), array_values( $tags ), $content );