| @@ -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 ); |