| @@ -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 ); |
| @@ -139,9 +153,9 @@ | ||
| 139 | 153 | continue; |
| 140 | 154 | } |
| 141 | 155 | |
| 142 | 156 | foreach ( $recipients as $recipient ) { |
| 143 | - self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id ); | |
| 157 | + self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id, $event ); | |
| 144 | 158 | } |
| 145 | 159 | } |
| 146 | 160 | } |
| 147 | 161 | |
| @@ -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 |
| @@ -677,12 +754,13 @@ | ||
| 677 | 754 | * @param array<string, mixed> $notification Notification settings. |
| 678 | 755 | * @param array<string, mixed> $donation_data Donation data for smart tags. |
| 679 | 756 | * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form. |
| 680 | 757 | * @param int $donation_id Optional donation ID. |
| 758 | + * @param string $event The event that triggered this email. | |
| 681 | 759 | * @return bool True if email was sent successfully. |
| 682 | 760 | * @since 0.0.1 |
| 683 | 761 | */ |
| 684 | - private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0 ) { | |
| 762 | + private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0, $event = '' ) { | |
| 685 | 763 | if ( empty( $to_email ) || ! is_email( $to_email ) ) { |
| 686 | 764 | return false; |
| 687 | 765 | } |
| 688 | 766 | |
| @@ -723,10 +801,99 @@ | ||
| 723 | 801 | |
| 724 | 802 | // Convert plain text to HTML if needed. |
| 725 | 803 | $email_body = self::format_email_body( $email_body ); |
| 726 | 804 | |
| 805 | + /** | |
| 806 | + * Filter attachments for outgoing notification emails. | |
| 807 | + * | |
| 808 | + * Each entry must be an absolute path to a local, readable file | |
| 809 | + * (wp_mail() contract) inside the uploads directory. Non-string, | |
| 810 | + * non-existent and out-of-uploads entries are dropped before sending. | |
| 811 | + * | |
| 812 | + * A STRING KEY names the attachment for the recipient: wp_mail() passes | |
| 813 | + * it to PHPMailer as the display name, so the file can be stored under | |
| 814 | + * one name and delivered under another. Keys are run through | |
| 815 | + * sanitize_file_name(), given the real file's extension when they lack | |
| 816 | + * it, and dropped if nothing usable survives -- in which case the file's | |
| 817 | + * own name is used. | |
| 818 | + * | |
| 819 | + * The display name is best effort. Core has never documented the | |
| 820 | + * key-as-name behaviour, and a plugin that REPLACES pluggable wp_mail() | |
| 821 | + * (some API-based mailers do) may iterate values only and drop the key, | |
| 822 | + * delivering the file under its stored name instead. | |
| 823 | + * | |
| 824 | + * @param array<int|string, string> $attachments Attachment file paths, optionally keyed by display name. Default empty. | |
| 825 | + * @param array<string, mixed> $notification Notification settings. | |
| 826 | + * @param array<string, mixed> $donation_data Donation data. | |
| 827 | + * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form. | |
| 828 | + * @param int $donation_id Donation ID (0 when not available). | |
| 829 | + * @param string $event The event that triggered this email (e.g. 'donation_completed'). | |
| 830 | + * @since 1.5.0 | |
| 831 | + * @since 1.5.1 A string key names the attachment for the recipient. | |
| 832 | + */ | |
| 833 | + $attachments = apply_filters( 'suredonation_email_attachments', [], $notification, $donation_data, $campaign, $donation_id, $event ); | |
| 834 | + | |
| 835 | + $upload_dir = wp_upload_dir(); | |
| 836 | + $base_real = isset( $upload_dir['basedir'] ) && is_string( $upload_dir['basedir'] ) ? realpath( $upload_dir['basedir'] ) : false; | |
| 837 | + $uploads_dir = is_string( $base_real ) ? trailingslashit( wp_normalize_path( $base_real ) ) : ''; | |
| 838 | + | |
| 839 | + $attachments = is_array( $attachments ) ? array_filter( | |
| 840 | + $attachments, | |
| 841 | + static function ( $path ) use ( $uploads_dir ) { | |
| 842 | + if ( ! is_string( $path ) || '' === $path || ! file_exists( $path ) ) { | |
| 843 | + return false; | |
| 844 | + } | |
| 845 | + | |
| 846 | + // Containment check: only files inside the uploads directory | |
| 847 | + // may be attached — a filtered-in traversal path or symlink | |
| 848 | + // must not exfiltrate arbitrary server files by email. | |
| 849 | + $real = realpath( $path ); | |
| 850 | + | |
| 851 | + if ( ! is_string( $real ) || '' === $uploads_dir ) { | |
| 852 | + return false; | |
| 853 | + } | |
| 854 | + | |
| 855 | + return 0 === strpos( wp_normalize_path( $real ), $uploads_dir ); | |
| 856 | + } | |
| 857 | + ) : []; | |
| 858 | + | |
| 859 | + // Re-key rather than array_values(): a string key is the name the | |
| 860 | + // recipient sees, which is how a receipt stored under an unguessable | |
| 861 | + // filename arrives as something readable. Keys that do not survive | |
| 862 | + // sanitize_file_name() are dropped so the attachment falls back to the | |
| 863 | + // file's own name — never to attacker-shaped text in a mail header. | |
| 864 | + $named_attachments = []; | |
| 865 | + | |
| 866 | + foreach ( $attachments as $key => $path ) { | |
| 867 | + $name = is_string( $key ) ? sanitize_file_name( $key ) : ''; | |
| 868 | + | |
| 869 | + // sanitize_file_name() neither requires nor preserves an extension, so | |
| 870 | + // a key like "Receipt for Ada" would be delivered with none at all -- | |
| 871 | + // PHPMailer takes the content type from the PATH, leaving the reader a | |
| 872 | + // file their OS cannot open by double-clicking. Reconcile the two. | |
| 873 | + if ( '' !== $name ) { | |
| 874 | + $real_ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ); | |
| 875 | + | |
| 876 | + if ( '' !== $real_ext && strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) ) !== $real_ext ) { | |
| 877 | + $name .= '.' . $real_ext; | |
| 878 | + } | |
| 879 | + } | |
| 880 | + | |
| 881 | + // A name already claimed by an earlier attachment would silently | |
| 882 | + // replace it, losing a file that used to be sent. Keep both: the | |
| 883 | + // loser falls back to its own filename rather than disappearing. | |
| 884 | + if ( '' !== $name && ! isset( $named_attachments[ $name ] ) ) { | |
| 885 | + $named_attachments[ $name ] = $path; | |
| 886 | + continue; | |
| 887 | + } | |
| 888 | + | |
| 889 | + $named_attachments[] = $path; | |
| 890 | + } | |
| 891 | + | |
| 892 | + $attachments = $named_attachments; | |
| 893 | + | |
| 727 | 894 | // Send email. |
| 728 | - $sent = wp_mail( $to_email, $subject, $email_body, $headers ); | |
| 895 | + $sent = wp_mail( $to_email, $subject, $email_body, $headers, $attachments ); | |
| 729 | 896 | |
| 730 | 897 | // Log email send attempt. |
| 731 | 898 | // 4th param is display name (not machine ID). Pre-release plugin (v0.0.1) with no |
| 732 | 899 | // external consumers of this hook, so no backward-compatibility concern. |
| @@ -821,8 +988,11 @@ | ||
| 821 | 988 | '{refund_amount}' => isset( $donation_data['refund_amount'] ) && is_numeric( $donation_data['refund_amount'] ) |
| 822 | 989 | ? esc_html( Payment_Helper::format_amount( (float) $donation_data['refund_amount'], $currency ) ) |
| 823 | 990 | : '', |
| 824 | 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 | + ), | |
| 825 | 995 | ]; |
| 826 | 996 | |
| 827 | 997 | // Apply filters to allow adding custom smart tags. |
| 828 | 998 | $core_tags = $tags; |