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