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