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