| @@ -74,8 +74,15 @@ | ||
| 74 | 74 | } |
| 75 | 75 | |
| 76 | 76 | if ( '' !== $lock_key ) { |
| 77 | 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 ); | |
| 78 | 85 | } |
| 79 | 86 | |
| 80 | 87 | // One lookup covers both the form and the donation timestamp; callers |
| 81 | 88 | // build their own data array and rarely carry created_at. |
| @@ -80,10 +87,14 @@ | ||
| 80 | 87 | // One lookup covers both the form and the donation timestamp; callers |
| 81 | 88 | // build their own data array and rarely carry created_at. |
| 82 | 89 | $needs_form_id = empty( $form_id ); |
| 83 | 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'] ); | |
| 84 | 95 | |
| 85 | - if ( ( $needs_form_id || $needs_timestamp ) && ! empty( $donation_id ) ) { | |
| 96 | + if ( ( $needs_form_id || $needs_timestamp || $needs_fields ) && ! empty( $donation_id ) ) { | |
| 86 | 97 | $donation = Donations::get( $donation_id ); |
| 87 | 98 | |
| 88 | 99 | if ( is_array( $donation ) ) { |
| 89 | 100 | if ( $needs_form_id && isset( $donation['form_id'] ) && is_scalar( $donation['form_id'] ) ) { |
| @@ -91,8 +102,11 @@ | ||
| 91 | 102 | } |
| 92 | 103 | if ( $needs_timestamp && isset( $donation['created_at'] ) && is_string( $donation['created_at'] ) ) { |
| 93 | 104 | $donation_data['created_at'] = $donation['created_at']; |
| 94 | 105 | } |
| 106 | + if ( $needs_fields ) { | |
| 107 | + $donation_data['fields'] = self::extract_stored_fields( $donation ); | |
| 108 | + } | |
| 95 | 109 | } |
| 96 | 110 | } |
| 97 | 111 | |
| 98 | 112 | $notifications = self::get_form_notifications( $form_id ); |
| @@ -154,8 +168,47 @@ | ||
| 154 | 168 | * @param int $form_id Form post ID. |
| 155 | 169 | * @return void |
| 156 | 170 | * @since 0.0.1 |
| 157 | 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 | + */ | |
| 158 | 211 | public static function send_donation_confirmation( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { |
| 159 | 212 | self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_COMPLETED ); |
| 160 | 213 | } |
| 161 | 214 | |
| @@ -464,8 +517,32 @@ | ||
| 464 | 517 | return $notifications; |
| 465 | 518 | } |
| 466 | 519 | |
| 467 | 520 | /** |
| 521 | + * Read the stored submitted form fields off a donation row. | |
| 522 | + * | |
| 523 | + * The donation_data column is shared JSON that comes back either decoded or | |
| 524 | + * still encoded depending on the caller, so both shapes are handled. | |
| 525 | + * | |
| 526 | + * @param array<string, mixed> $donation Donation row. | |
| 527 | + * @return array<mixed> Stored fields, or [] when the donation has none. | |
| 528 | + * @since 1.5.1 | |
| 529 | + */ | |
| 530 | + private static function extract_stored_fields( $donation ) { | |
| 531 | + $donation_data = $donation['donation_data'] ?? []; | |
| 532 | + | |
| 533 | + if ( is_string( $donation_data ) && '' !== $donation_data ) { | |
| 534 | + $donation_data = json_decode( $donation_data, true ); | |
| 535 | + } | |
| 536 | + | |
| 537 | + if ( ! is_array( $donation_data ) || ! isset( $donation_data['fields'] ) || ! is_array( $donation_data['fields'] ) ) { | |
| 538 | + return []; | |
| 539 | + } | |
| 540 | + | |
| 541 | + return $donation_data['fields']; | |
| 542 | + } | |
| 543 | + | |
| 544 | + /** | |
| 468 | 545 | * Identify a notification by the two fields that survive editing. |
| 469 | 546 | * |
| 470 | 547 | * `trigger` is a sanitized key and `email_to` is a smart tag, so neither is |
| 471 | 548 | * translated and neither changes when a notification is renamed. Together |
| @@ -731,15 +808,28 @@ | ||
| 731 | 808 | * Each entry must be an absolute path to a local, readable file |
| 732 | 809 | * (wp_mail() contract) inside the uploads directory. Non-string, |
| 733 | 810 | * non-existent and out-of-uploads entries are dropped before sending. |
| 734 | 811 | * |
| 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'). | |
| 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'). | |
| 741 | 830 | * @since 1.5.0 |
| 831 | + * @since 1.5.1 A string key names the attachment for the recipient. | |
| 742 | 832 | */ |
| 743 | 833 | $attachments = apply_filters( 'suredonation_email_attachments', [], $notification, $donation_data, $campaign, $donation_id, $event ); |
| 744 | 834 | |
| 745 | 835 | $upload_dir = wp_upload_dir(); |
| @@ -745,30 +835,63 @@ | ||
| 745 | 835 | $upload_dir = wp_upload_dir(); |
| 746 | 836 | $base_real = isset( $upload_dir['basedir'] ) && is_string( $upload_dir['basedir'] ) ? realpath( $upload_dir['basedir'] ) : false; |
| 747 | 837 | $uploads_dir = is_string( $base_real ) ? trailingslashit( wp_normalize_path( $base_real ) ) : ''; |
| 748 | 838 | |
| 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 | - } | |
| 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 | + } | |
| 756 | 845 | |
| 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 ); | |
| 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 ); | |
| 761 | 850 | |
| 762 | - if ( ! is_string( $real ) || '' === $uploads_dir ) { | |
| 763 | - return false; | |
| 764 | - } | |
| 851 | + if ( ! is_string( $real ) || '' === $uploads_dir ) { | |
| 852 | + return false; | |
| 853 | + } | |
| 765 | 854 | |
| 766 | - return 0 === strpos( wp_normalize_path( $real ), $uploads_dir ); | |
| 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; | |
| 767 | 878 | } |
| 768 | - ) | |
| 769 | - ) : []; | |
| 879 | + } | |
| 770 | 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 | + | |
| 771 | 894 | // Send email. |
| 772 | 895 | $sent = wp_mail( $to_email, $subject, $email_body, $headers, $attachments ); |
| 773 | 896 | |
| 774 | 897 | // Log email send attempt. |
| @@ -865,8 +988,11 @@ | ||
| 865 | 988 | '{refund_amount}' => isset( $donation_data['refund_amount'] ) && is_numeric( $donation_data['refund_amount'] ) |
| 866 | 989 | ? esc_html( Payment_Helper::format_amount( (float) $donation_data['refund_amount'], $currency ) ) |
| 867 | 990 | : '', |
| 868 | 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 | + ), | |
| 869 | 995 | ]; |
| 870 | 996 | |
| 871 | 997 | // Apply filters to allow adding custom smart tags. |
| 872 | 998 | $core_tags = $tags; |