PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/emails/email-handler.php +174 -4 1.4.0 → 1.6.1 View file →
@@ -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;