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