$donation_data Donation data array. * @param int $form_id Form post ID. * @param string $event The event that triggered this call. * @return void * @since 1.0.0 */ public static function send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id = 0, $event = self::EVENT_DONATION_COMPLETED ) { // Prevent duplicate emails for the same donation + event (e.g. AJAX and // webhook racing). Claimed immediately after the check rather than once // the form and campaign are resolved: those lookups are a DB read, the // notification merge and a get_post(), and holding the gap open across // them lets both racers pass the check before either claims — which is // exactly the pair this lock exists to separate, and they arrive together // on essentially every Stripe donation. // // A claim that resolves nothing is released again at each early return // below, so a caller that bailed does not swallow the retry that would // have succeeded. // // get/set is still non-atomic (TOCTOU) and the worst case is a duplicate // email, not data corruption. wp_cache_add() would only be atomic with an // external object cache; most installs use DB transients. $lock_key = $donation_id > 0 ? 'suredonation_email_lock_' . $event . '_' . $donation_id : ''; if ( '' !== $lock_key && get_transient( $lock_key ) ) { return; } if ( '' !== $lock_key ) { set_transient( $lock_key, true, 60 ); // Companion to the 60-second race lock, kept long enough to answer a // different question: has this donation's email for this event been // attempted at all? The webhook reconciler needs that to decide // whether a donation the frontend already completed still owes the // donor a receipt, and the race lock expires far too soon to say. set_transient( self::sent_marker_key( $event, $donation_id ), true, WEEK_IN_SECONDS ); } // One lookup covers both the form and the donation timestamp; callers // build their own data array and rarely carry created_at. $needs_form_id = empty( $form_id ); $needs_timestamp = empty( $donation_data['created_at'] ); // The submitted form fields live in the donation_data JSON column, which // callers building their own array never carry. Resolved here, once, so // the {form_fields} tag does not re-read the row for every tag pass. $needs_fields = ! isset( $donation_data['fields'] ); if ( ( $needs_form_id || $needs_timestamp || $needs_fields ) && ! empty( $donation_id ) ) { $donation = Donations::get( $donation_id ); if ( is_array( $donation ) ) { if ( $needs_form_id && isset( $donation['form_id'] ) && is_scalar( $donation['form_id'] ) ) { $form_id = absint( $donation['form_id'] ); } if ( $needs_timestamp && isset( $donation['created_at'] ) && is_string( $donation['created_at'] ) ) { $donation_data['created_at'] = $donation['created_at']; } if ( $needs_fields ) { $donation_data['fields'] = self::extract_stored_fields( $donation ); } } } $notifications = self::get_form_notifications( $form_id ); if ( empty( $notifications ) ) { self::release_send_lock( $lock_key ); return; } // campaign_id 0 is a supported standalone form, not an error — see the // note on Donations::add(). Everything downstream already tolerates a // null campaign, so only a genuinely missing campaign should stop the send. $campaign = $campaign_id > 0 ? get_post( $campaign_id ) : null; if ( $campaign_id > 0 && ! $campaign ) { self::release_send_lock( $lock_key ); return; } foreach ( $notifications as $notification ) { if ( empty( $notification['status'] ) ) { continue; } // Only send notifications whose trigger matches the current event. $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : ''; if ( empty( $trigger ) || ( 'all' !== $trigger && $trigger !== $event ) ) { continue; } // Resolve email_to using smart tags. $email_to_raw = isset( $notification['email_to'] ) && is_string( $notification['email_to'] ) ? $notification['email_to'] : ''; $email_to = self::process_smart_tags( $email_to_raw, $donation_data, $campaign ); // Support comma-separated recipients. $recipients = array_map( 'trim', explode( ',', $email_to ) ); $recipients = array_filter( $recipients, static function ( string $email ): bool { return (bool) is_email( $email ); } ); if ( empty( $recipients ) ) { continue; } foreach ( $recipients as $recipient ) { self::send_email( $recipient, $notification, $donation_data, $campaign, $donation_id, $event ); } } } /** * Send donation confirmation emails. * * @param int $donation_id Donation ID. * @param int $campaign_id Campaign ID. * @param array $donation_data Donation data array. * @param int $form_id Form post ID. * @return void * @since 0.0.1 */ /** * Transient key recording that an email was attempted for a donation+event. * * @param string $event Email event. * @param int $donation_id Donation ID. * @return string * @since 1.6.0 */ public static function sent_marker_key( $event, $donation_id ) { return 'suredonation_email_sent_' . (string) $event . '_' . absint( $donation_id ); } /** * Whether an email for this donation and event has already been attempted. * * Answers "does this donation still owe the donor a receipt?", which the * PayPal webhook reconciler asks about a donation the frontend already * marked completed. Deliberately not `receipt_sent`: that column is written * only by Pro's PDF attachment path, so on free-only sites it is never set. * * @param int $donation_id Donation ID. * @param string $event Email event; defaults to the donation receipt. * @return bool * @since 1.6.0 */ public static function has_sent( $donation_id, $event = self::EVENT_DONATION_COMPLETED ) { return (bool) get_transient( self::sent_marker_key( $event, $donation_id ) ); } /** * Send donation confirmation emails. * * @param int $donation_id Donation ID. * @param int $campaign_id Campaign ID. * @param array $donation_data Donation data array. * @param int $form_id Form post ID. * @return void * @since 1.0.0 */ public static function send_donation_confirmation( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_COMPLETED ); } /** * Send donation processing emails. * * @param int $donation_id Donation ID. * @param int $campaign_id Campaign ID. * @param array $donation_data Donation data array. * @param int $form_id Form post ID. * @return void * @since 1.0.0 */ public static function send_donation_processing( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_PROCESSING ); } /** * Send donation failed emails. * * @param int $donation_id Donation ID. * @param int $campaign_id Campaign ID. * @param array $donation_data Donation data array. * @param int $form_id Form post ID. * @return void * @since 1.0.0 */ public static function send_donation_failed( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_DONATION_FAILED ); } /** * Send refund processed emails. * * @param int $donation_id Donation ID. * @param int $campaign_id Campaign ID. * @param array $donation_data Donation data array. * @param int $form_id Form post ID. * @return void * @since 1.0.0 */ public static function send_refund_processed( $donation_id, $campaign_id, $donation_data, $form_id = 0 ) { self::send_donation_emails( $donation_id, $campaign_id, $donation_data, $form_id, self::EVENT_REFUND_PROCESSED ); } /** * Option recording that stored rows have had their keys back-filled. * * @since 1.4.0 */ public const KEY_BACKFILL_OPTION = 'suredonation_notification_keys_backfilled'; /** * Write resolved identities back to rows saved before keys existed. * * Resolution is otherwise re-derived on every send and never persisted, so a * form saved before this release stays dependent on guesswork for the rest of * its life — and stays exposed to whatever breaks the guess, whether that is * a rename, a locale difference or an edited recipient. Doing it once, here, * is what makes the fallback a migration rather than a permanent code path. * * Deliberately runs in admin context only: it writes, and the read path it * repairs is reached during donor payment requests. * * Only identity is written: keys, and the triggers the old sanitizer * overwrote. Deliberately not the dedupe — it discards one of two rows that * resolve to the same key, and where both were customised that is an edit * the admin cannot get back. In memory that loss lasts one request; on disk * it is permanent. Collapsing duplicates costs nothing to redo per read, so * it stays there. * * Missing defaults are not appended either. That is a read-time concern * which depends on whether Pro is active, and baking today's answer into the * row would strand the form the next time that changes. * * @return void * @since 1.4.0 */ public static function backfill_notification_keys() { $defaults = Assets::get_instance()->get_default_email_notifications(); // Keyed to the defaults that were available, not a bare "done" flag. // Which rows can be identified depends on what is registered at the time: // with Pro inactive or on an older build, its templates and their former // names are simply absent, and its rows resolve to nothing. Recording the // set means the pass runs again once that set changes — when Pro is // activated or updated — instead of a one-time run deciding forever. $signature = self::default_keys_signature( $defaults ); if ( get_option( self::KEY_BACKFILL_OPTION ) === $signature ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration over a meta key with no API equivalent. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value != ''", Assets::EMAIL_NOTIFICATIONS_META_KEY ) ); if ( is_array( $rows ) && ! empty( $rows ) ) { foreach ( $rows as $row ) { $stored = json_decode( (string) $row->meta_value, true ); if ( ! is_array( $stored ) || empty( $stored ) ) { continue; } $resolved = self::add_identity_keys( $stored, $defaults ); $resolved = self::restore_rewritten_triggers( $resolved, $defaults ); // Only rows that were identified are written. A row that resolved // to nothing keeps exactly what it had, including its status: the // read path still parks it so it cannot mis-send, but parking it // on disk would outlive the reason for it — a row unidentifiable // today because Pro is a version behind would stay switched off // after Pro caught up, with nothing to switch it back. foreach ( $resolved as $index => $row_data ) { if ( empty( $row_data['key'] ) ) { $resolved[ $index ] = $stored[ $index ]; } } if ( $resolved === $stored ) { continue; } update_post_meta( (int) $row->post_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, wp_slash( (string) wp_json_encode( $resolved ) ) ); } } update_option( self::KEY_BACKFILL_OPTION, $signature, false ); } /** * Signature of the default keys currently registered. * * @param array> $defaults Default notifications. * @return string Signature. * @since 1.4.0 */ private static function default_keys_signature( $defaults ) { $keys = []; foreach ( $defaults as $default ) { if ( ! empty( $default['key'] ) && is_string( $default['key'] ) ) { $keys[] = $default['key']; } } sort( $keys ); return md5( (string) wp_json_encode( $keys ) ); } /** * Give back a send lock claimed by a call that delivered nothing. * * @param string $lock_key Lock transient name, or '' when unlocked. * @return void * @since 1.4.0 */ private static function release_send_lock( $lock_key ) { if ( '' !== $lock_key ) { delete_transient( $lock_key ); } } /** * Get email notifications for a form. * * Stored settings are authoritative, but they are only written when an admin * opens the form's Email Notifications tab and saves. Until then the meta is * empty, and reading it literally means a form sends nothing at all. Defaults * fill the gaps so delivery never depends on having visited the editor. * * Resolved per read and not persisted: sending happens mid-payment, and a * write there would be a side effect on a path that only needs to read. The * resolution is persisted once by backfill_notification_keys(), which runs * in admin context, so this stays a fallback rather than the normal path. * * @param int $form_id Form post ID. * @return array> Array of notification configs. * @since 1.0.0 */ private static function get_form_notifications( $form_id ) { if ( empty( $form_id ) ) { return []; } $raw = get_post_meta( $form_id, Assets::EMAIL_NOTIFICATIONS_META_KEY, true ); $notifications = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : null; if ( ! is_array( $notifications ) ) { $notifications = []; } $defaults = Assets::get_instance()->get_default_email_notifications(); if ( empty( $notifications ) ) { return $defaults; } $notifications = self::add_identity_keys( $notifications, $defaults ); $notifications = self::restore_rewritten_triggers( $notifications, $defaults ); $notifications = self::drop_duplicate_notifications( $notifications ); return self::add_missing_notifications( $notifications, $defaults ); } /** * Stamp a stable identity onto rows saved before keys existed. * * Older rows carry only `id` and `name`, and neither identifies anything on * its own: `id` is reassigned whenever the editor re-seeds a set, and `name` * is user-editable and translated — it resolves in the admin's locale when * saved and the site's locale when an email is sent. * * So the fallback is `trigger` + `email_to`. Both are untranslated, neither * changes when a notification is renamed, and the pair is unique across every * default (`trigger` alone is not — donor and admin templates share one). * * @param array> $notifications Stored notifications. * @param array> $defaults Default notifications. * @return array> Notifications with a `key` where one could be resolved. * @since 1.4.0 */ private static function add_identity_keys( $notifications, $defaults ) { $by_name = []; $by_signature = []; foreach ( $defaults as $default ) { $key = isset( $default['key'] ) && is_string( $default['key'] ) ? $default['key'] : ''; if ( '' === $key ) { continue; } $signature = self::notification_signature( $default ); if ( '' !== $signature && ! isset( $by_signature[ $signature ] ) ) { $by_signature[ $signature ] = $key; } if ( isset( $default['name'] ) && is_string( $default['name'] ) ) { $by_name[ $default['name'] ] = $key; } // A default that has been renamed carries the names it used to have, // so rows stored under the old wording still resolve. if ( isset( $default['legacy_names'] ) && is_array( $default['legacy_names'] ) ) { foreach ( $default['legacy_names'] as $legacy ) { if ( is_string( $legacy ) && ! isset( $by_name[ $legacy ] ) ) { $by_name[ $legacy ] = $key; } } } } foreach ( $notifications as $index => $notification ) { if ( ! empty( $notification['key'] ) ) { continue; } $name = isset( $notification['name'] ) && is_string( $notification['name'] ) ? $notification['name'] : ''; if ( '' !== $name && isset( $by_name[ $name ] ) ) { $notifications[ $index ]['key'] = $by_name[ $name ]; continue; } // The name did not match, which a rename or a locale difference is // enough to cause. Fall back to what the admin did not edit and // gettext does not touch. // // Not `id`: the editor used to reassign ids when re-seeding, so a // stored id lands on whichever default happens to hold it and a donor // row can inherit an admin row's key — routing it to the wrong trigger // and suppressing the default it was mistaken for. No key at all is // recoverable; a confidently wrong one is not. $signature = self::notification_signature( $notification ); if ( '' !== $signature && isset( $by_signature[ $signature ] ) ) { $notifications[ $index ]['key'] = $by_signature[ $signature ]; continue; } // Unidentifiable, and 'all' means "fire on every event" — the value // the old sanitizer fell back to. Leaving it enabled would send a // recurring template to a one-time donor, so it is parked rather than // guessed at. // // Parking here is in memory only, so the editor still shows the row // switched on. backfill_notification_keys() is what reconciles the // two: it persists the resolution, which restores the real trigger // for rows that resolve and records the off state for those that do // not, so the toggle stops disagreeing with what actually sends. if ( 'all' === ( $notification['trigger'] ?? '' ) ) { $notifications[ $index ]['status'] = false; } } return $notifications; } /** * Read the stored submitted form fields off a donation row. * * The donation_data column is shared JSON that comes back either decoded or * still encoded depending on the caller, so both shapes are handled. * * @param array $donation Donation row. * @return array Stored fields, or [] when the donation has none. * @since 1.5.1 */ private static function extract_stored_fields( $donation ) { $donation_data = $donation['donation_data'] ?? []; if ( is_string( $donation_data ) && '' !== $donation_data ) { $donation_data = json_decode( $donation_data, true ); } if ( ! is_array( $donation_data ) || ! isset( $donation_data['fields'] ) || ! is_array( $donation_data['fields'] ) ) { return []; } return $donation_data['fields']; } /** * Identify a notification by the two fields that survive editing. * * `trigger` is a sanitized key and `email_to` is a smart tag, so neither is * translated and neither changes when a notification is renamed. Together * they are unique across every default; `trigger` on its own is not, because * the donor and admin templates for an event share it. * * @param array $notification Notification row. * @return string Signature, or '' when the row cannot supply one. * @since 1.4.0 */ private static function notification_signature( $notification ) { $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : ''; $email_to = isset( $notification['email_to'] ) && is_string( $notification['email_to'] ) ? $notification['email_to'] : ''; if ( '' === $trigger || '' === $email_to ) { return ''; } return $trigger . '|' . $email_to; } /** * Restore triggers rewritten by an older save. * * Saving a form while Pro was inactive rewrote its recurring triggers to * 'all', so those notifications fire on every donation event. The editor * repairs this when the tab is opened; doing it here as well means a form * nobody edits stops mis-sending too. * * Only 'all' is corrected, and only against the row's resolved key. 'all' is * the exact value the old sanitizer fell back to, so any other mismatch is * treated as a deliberate choice and left alone. * * @param array> $notifications Stored notifications. * @param array> $defaults Default notifications. * @return array> Notifications with triggers restored. * @since 1.4.0 */ private static function restore_rewritten_triggers( $notifications, $defaults ) { $by_key = []; foreach ( $defaults as $default ) { if ( ! empty( $default['key'] ) && is_string( $default['key'] ) && isset( $default['trigger'] ) ) { $by_key[ $default['key'] ] = $default['trigger']; } } foreach ( $notifications as $index => $notification ) { $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : ''; $trigger = isset( $notification['trigger'] ) && is_string( $notification['trigger'] ) ? $notification['trigger'] : ''; if ( 'all' !== $trigger || '' === $key || ! isset( $by_key[ $key ] ) ) { continue; } $notifications[ $index ]['trigger'] = $by_key[ $key ]; } return $notifications; } /** * Collapse rows that resolve to the same notification. * * A form that went through the deactivate/reactivate cycle holds the admin's * customised row alongside a pristine copy the editor appended when it failed * to recognise the original. Restoring the trigger above makes the two exact * twins, so without this the donor receives both. * * The customised row wins: it is the one carrying the admin's edits, and the * pristine copy only exists because of the recognition bug. * * @param array> $notifications Stored notifications. * @return array> Notifications with duplicates removed. * @since 1.4.0 */ private static function drop_duplicate_notifications( $notifications ) { $seen = []; $kept = []; foreach ( $notifications as $notification ) { $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : ''; // Without a resolved key there is nothing safe to compare on, so the // row is kept as-is rather than guessed at. if ( '' === $key ) { $kept[] = $notification; continue; } if ( ! isset( $seen[ $key ] ) ) { $seen[ $key ] = count( $kept ); $kept[] = $notification; continue; } // Prefer whichever copy the admin actually edited. $existing = $kept[ $seen[ $key ] ]; if ( self::is_customised( $notification ) && ! self::is_customised( $existing ) ) { $kept[ $seen[ $key ] ] = $notification; } } return $kept; } /** * Whether a stored row differs from the default it came from. * * @param array $notification Stored notification. * @return bool True when the row carries admin edits. * @since 1.4.0 */ private static function is_customised( $notification ) { $key = isset( $notification['key'] ) && is_string( $notification['key'] ) ? $notification['key'] : ''; if ( '' === $key ) { return false; } // Cached: this runs inside a payment request, twice per duplicate row, and // rebuilding the array means ~40 __() calls plus an apply_filters pass // each time. The defaults are constant for the life of the request. static $defaults = null; if ( null === $defaults ) { $defaults = Assets::get_instance()->get_default_email_notifications(); } foreach ( $defaults as $default ) { if ( ( $default['key'] ?? '' ) !== $key ) { continue; } foreach ( [ 'subject', 'email_body', 'email_to', 'from_name', 'from_email', 'reply_to', 'name' ] as $field ) { if ( ( $notification[ $field ] ?? '' ) !== ( $default[ $field ] ?? '' ) ) { return true; } } return false; } return false; } /** * Add defaults that the stored set does not already cover. * * Notifications cannot be added or deleted in the editor, so a default with * no stored counterpart was never seeded — most often because Pro was * activated after the form was last saved. Without this, those forms send * nothing for the events Pro adds. * * Matching is on the stable key only. Falling back to `id` or `name` here * would reintroduce the very ambiguity the key exists to remove: a locale * difference used to make a present notification look absent (so it was * added twice), while a reassigned id could collide with a different * notification's default and make an absent one look present (so it was * never sent at all). * * @param array> $notifications Stored notifications. * @param array> $defaults Default notifications. * @return array> Stored notifications plus any missing defaults. * @since 1.4.0 */ private static function add_missing_notifications( $notifications, $defaults ) { $stored_keys = []; $unresolved_slots = []; foreach ( $notifications as $notification ) { if ( ! empty( $notification['key'] ) && is_string( $notification['key'] ) ) { $stored_keys[ $notification['key'] ] = true; continue; } // A row we could not identify still occupies its slot. Appending the // default that belongs there would leave two enabled rows on one // trigger and send the donor two of every email — so the slot is // recorded and the default withheld. Matched on the full signature, // not just the trigger: the donor and admin templates for an event // share a trigger, and withholding both because one row is // unidentified would silence a notification that is working. if ( ! empty( $notification['status'] ) ) { $signature = self::notification_signature( $notification ); if ( '' !== $signature ) { $unresolved_slots[ $signature ] = true; } } } foreach ( $defaults as $default ) { $key = isset( $default['key'] ) && is_string( $default['key'] ) ? $default['key'] : ''; if ( '' === $key || isset( $stored_keys[ $key ] ) ) { continue; } $signature = self::notification_signature( $default ); if ( '' !== $signature && isset( $unresolved_slots[ $signature ] ) ) { continue; } $notifications[] = $default; } return $notifications; } /** * Send email using notification settings. * * @param string $to_email Recipient email address. * @param array $notification Notification settings. * @param array $donation_data Donation data for smart tags. * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form. * @param int $donation_id Optional donation ID. * @param string $event The event that triggered this email. * @return bool True if email was sent successfully. * @since 0.0.1 */ private static function send_email( $to_email, $notification, $donation_data, $campaign, $donation_id = 0, $event = '' ) { if ( empty( $to_email ) || ! is_email( $to_email ) ) { return false; } // Prepare email data - ensure string types for process_smart_tags. $subject_raw = isset( $notification['subject'] ) && is_string( $notification['subject'] ) ? $notification['subject'] : ''; $email_body_raw = isset( $notification['email_body'] ) && is_string( $notification['email_body'] ) ? $notification['email_body'] : ''; $subject = self::process_smart_tags( $subject_raw, $donation_data, $campaign ); $email_body = self::process_smart_tags( $email_body_raw, $donation_data, $campaign ); // Get from name and email - ensure string types. $from_name_raw = isset( $notification['from_name'] ) && is_string( $notification['from_name'] ) ? $notification['from_name'] : ''; $from_name = ! empty( $from_name_raw ) ? $from_name_raw : get_bloginfo( 'name' ); $from_email = isset( $notification['from_email'] ) && is_string( $notification['from_email'] ) && ! empty( $notification['from_email'] ) ? $notification['from_email'] : get_option( 'admin_email' ); $reply_to = isset( $notification['reply_to'] ) && is_string( $notification['reply_to'] ) && ! empty( $notification['reply_to'] ) ? $notification['reply_to'] : ( is_string( $from_email ) ? $from_email : '' ); // Process smart tags in from fields. $from_name = self::process_smart_tags( is_string( $from_name ) ? $from_name : '', $donation_data, $campaign ); $from_email = self::process_smart_tags( is_string( $from_email ) ? $from_email : '', $donation_data, $campaign ); $reply_to = self::process_smart_tags( is_string( $reply_to ) ? $reply_to : '', $donation_data, $campaign ); $subject = str_replace( [ "\r", "\n" ], '', $subject ); // Sanitize header values: strip CRLF to prevent header injection, validate emails. $from_name = str_replace( [ "\r", "\n" ], '', $from_name ); $admin_email = get_option( 'admin_email' ); $from_email = is_email( $from_email ) ? (string) $from_email : ( is_string( $admin_email ) ? $admin_email : '' ); $reply_to = is_email( $reply_to ) ? (string) $reply_to : $from_email; // Set email headers. $headers = [ 'Content-Type: text/html; charset=UTF-8', sprintf( 'From: %s <%s>', (string) $from_name, $from_email ), sprintf( 'Reply-To: %s', $reply_to ), ]; // Convert plain text to HTML if needed. $email_body = self::format_email_body( $email_body ); /** * Filter attachments for outgoing notification emails. * * Each entry must be an absolute path to a local, readable file * (wp_mail() contract) inside the uploads directory. Non-string, * non-existent and out-of-uploads entries are dropped before sending. * * A STRING KEY names the attachment for the recipient: wp_mail() passes * it to PHPMailer as the display name, so the file can be stored under * one name and delivered under another. Keys are run through * sanitize_file_name(), given the real file's extension when they lack * it, and dropped if nothing usable survives -- in which case the file's * own name is used. * * The display name is best effort. Core has never documented the * key-as-name behaviour, and a plugin that REPLACES pluggable wp_mail() * (some API-based mailers do) may iterate values only and drop the key, * delivering the file under its stored name instead. * * @param array $attachments Attachment file paths, optionally keyed by display name. Default empty. * @param array $notification Notification settings. * @param array $donation_data Donation data. * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form. * @param int $donation_id Donation ID (0 when not available). * @param string $event The event that triggered this email (e.g. 'donation_completed'). * @since 1.5.0 * @since 1.5.1 A string key names the attachment for the recipient. */ $attachments = apply_filters( 'suredonation_email_attachments', [], $notification, $donation_data, $campaign, $donation_id, $event ); $upload_dir = wp_upload_dir(); $base_real = isset( $upload_dir['basedir'] ) && is_string( $upload_dir['basedir'] ) ? realpath( $upload_dir['basedir'] ) : false; $uploads_dir = is_string( $base_real ) ? trailingslashit( wp_normalize_path( $base_real ) ) : ''; $attachments = is_array( $attachments ) ? array_filter( $attachments, static function ( $path ) use ( $uploads_dir ) { if ( ! is_string( $path ) || '' === $path || ! file_exists( $path ) ) { return false; } // Containment check: only files inside the uploads directory // may be attached — a filtered-in traversal path or symlink // must not exfiltrate arbitrary server files by email. $real = realpath( $path ); if ( ! is_string( $real ) || '' === $uploads_dir ) { return false; } return 0 === strpos( wp_normalize_path( $real ), $uploads_dir ); } ) : []; // Re-key rather than array_values(): a string key is the name the // recipient sees, which is how a receipt stored under an unguessable // filename arrives as something readable. Keys that do not survive // sanitize_file_name() are dropped so the attachment falls back to the // file's own name — never to attacker-shaped text in a mail header. $named_attachments = []; foreach ( $attachments as $key => $path ) { $name = is_string( $key ) ? sanitize_file_name( $key ) : ''; // sanitize_file_name() neither requires nor preserves an extension, so // a key like "Receipt for Ada" would be delivered with none at all -- // PHPMailer takes the content type from the PATH, leaving the reader a // file their OS cannot open by double-clicking. Reconcile the two. if ( '' !== $name ) { $real_ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ); if ( '' !== $real_ext && strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) ) !== $real_ext ) { $name .= '.' . $real_ext; } } // A name already claimed by an earlier attachment would silently // replace it, losing a file that used to be sent. Keep both: the // loser falls back to its own filename rather than disappearing. if ( '' !== $name && ! isset( $named_attachments[ $name ] ) ) { $named_attachments[ $name ] = $path; continue; } $named_attachments[] = $path; } $attachments = $named_attachments; // Send email. $sent = wp_mail( $to_email, $subject, $email_body, $headers, $attachments ); // Log email send attempt. // 4th param is display name (not machine ID). Pre-release plugin (v0.0.1) with no // external consumers of this hook, so no backward-compatibility concern. $notification_name = isset( $notification['name'] ) && is_string( $notification['name'] ) ? $notification['name'] : ''; do_action( 'suredonation_email_sent', $donation_id, $to_email, $sent, $notification_name ); return $sent; } /** * Process smart tags in email content. * * @param string $content Content with smart tags. * @param array $donation_data Donation data. * @param \WP_Post|null $campaign Campaign post object, or null for a standalone form. * @return string Processed content. * @since 0.0.1 */ public static function process_smart_tags( $content, $donation_data, $campaign ) { // Get currency symbol - ensure string type. $currency = isset( $donation_data['currency'] ) && is_string( $donation_data['currency'] ) ? $donation_data['currency'] : 'USD'; $campaign_title = ( $campaign instanceof \WP_Post ) ? $campaign->post_title : ''; // Calculate total amount (base + fees) - ensure numeric types. $amount_value = $donation_data['amount'] ?? 0; $fees_covered_value = $donation_data['fees_covered'] ?? 0; $base_amount = is_numeric( $amount_value ) ? (float) $amount_value : 0.0; $fees_covered = is_numeric( $fees_covered_value ) ? (float) $fees_covered_value : 0.0; $total_amount = $base_amount + $fees_covered; // Format amounts with currency symbol. $formatted_amount = Payment_Helper::format_amount( $total_amount, $currency ); // Get date format - ensure string type. $date_format = get_option( 'date_format' ); $date_format = is_string( $date_format ) ? $date_format : 'Y-m-d'; // Prefer the donation's own timestamp. Falling back to "now" dates a // receipt to when the email happened to be sent, which is wrong whenever // that is not the moment of the donation — a delayed or redelivered // gateway webhook, or a recurring charge whose template labels the field // as the start date. $created_at = isset( $donation_data['created_at'] ) && is_string( $donation_data['created_at'] ) ? $donation_data['created_at'] : ''; $created_stamp = '' !== $created_at ? strtotime( $created_at ) : false; $donation_date = false !== $created_stamp ? wp_date( $date_format, $created_stamp ) : current_time( $date_format ); // Smart tags mapping. $donor_name = isset( $donation_data['donor_name'] ) && is_string( $donation_data['donor_name'] ) ? $donation_data['donor_name'] : __( 'Donor', 'suredonation' ); $donor_email = isset( $donation_data['donor_email'] ) && is_string( $donation_data['donor_email'] ) ? $donation_data['donor_email'] : ''; $transaction_id = isset( $donation_data['transaction_id'] ) && is_string( $donation_data['transaction_id'] ) ? $donation_data['transaction_id'] : ''; if ( empty( $transaction_id ) && isset( $donation_data['id'] ) ) { $transaction_id = is_scalar( $donation_data['id'] ) ? (string) $donation_data['id'] : ''; } // Subscription smart tags. $subscription_id = isset( $donation_data['subscription_id'] ) && is_string( $donation_data['subscription_id'] ) ? $donation_data['subscription_id'] : ''; $admin_email = get_option( 'admin_email', '' ); // Payment method smart tags. $gateway = isset( $donation_data['gateway'] ) && is_string( $donation_data['gateway'] ) ? $donation_data['gateway'] : 'stripe'; $payment_method = Helper::get_payment_method_label( $gateway ); $payment_status = isset( $donation_data['payment_status'] ) && is_string( $donation_data['payment_status'] ) ? $donation_data['payment_status'] : ''; $offline_instructions = ''; if ( 'offline' === $gateway ) { $offline_instructions = Offline_Helper::get_offline_instructions(); } $tags = [ '{donor_name}' => esc_html( $donor_name ), '{donor_email}' => esc_html( $donor_email ), '{amount}' => esc_html( $formatted_amount ), '{campaign_name}' => esc_html( $campaign_title ), '{donation_date}' => esc_html( (string) $donation_date ), '{transaction_id}' => esc_html( $transaction_id ), '{site_title}' => esc_html( get_bloginfo( 'name' ) ), '{admin_email}' => esc_html( Helper::get_string_value( $admin_email ) ), '{site_url}' => esc_url( home_url() ), '{admin_url}' => esc_url( admin_url( 'admin.php?page=suredonation' ) ), '{subscription_id}' => esc_html( $subscription_id ), '{subscription_interval}' => isset( $donation_data['subscription_interval'] ) && is_string( $donation_data['subscription_interval'] ) ? esc_html( $donation_data['subscription_interval'] ) : '', '{payment_method}' => esc_html( $payment_method ), '{donation_amount}' => esc_html( Payment_Helper::format_amount( $base_amount, $currency ) ), '{donation_total}' => esc_html( $formatted_amount ), '{payment_status}' => Helper::render_payment_status_badge( $payment_status ), '{success_badge}' => Helper::render_success_badge(), '{donation_receipt}' => Helper::render_donation_receipt( $donation_data, $campaign_title ), '{refund_amount}' => isset( $donation_data['refund_amount'] ) && is_numeric( $donation_data['refund_amount'] ) ? esc_html( Payment_Helper::format_amount( (float) $donation_data['refund_amount'], $currency ) ) : '', '{offline_instructions}' => wp_kses_post( $offline_instructions ), '{form_fields}' => Helper::render_submitted_fields( isset( $donation_data['fields'] ) && is_array( $donation_data['fields'] ) ? $donation_data['fields'] : [] ), ]; // Apply filters to allow adding custom smart tags. $core_tags = $tags; $tags = apply_filters( 'suredonation_email_smart_tags', $tags, $donation_data, $campaign ); // Escape anything the filter introduced. Core tags deliberately carry // markup, so they are compared by value rather than by key — checking the // key alone let a callback overwrite an existing tag and slip raw HTML // into every email untouched. // // A consequence worth knowing: a callback that *appends* to a core tag // changes its value, so the result is escaped and any markup or bare `&` // the core tag carried is encoded a second time. Allowlisting the // markup-carrying tags would avoid that, but it would also reopen the // overwrite path above, so the escaping wins and the filter should // replace a tag outright rather than concatenate onto it. foreach ( $tags as $tag_key => $tag_value ) { if ( isset( $core_tags[ $tag_key ] ) && $core_tags[ $tag_key ] === $tag_value ) { continue; } // A non-scalar cannot be rendered; casting one would emit "Array" or // fatal on an object, inside a payment webhook. if ( ! is_scalar( $tag_value ) ) { unset( $tags[ $tag_key ] ); continue; } $tags[ $tag_key ] = esc_html( (string) $tag_value ); } // Replace smart tags. return str_replace( array_keys( $tags ), array_values( $tags ), $content ); } /** * Format email body with HTML wrapper. * * @param string $body Email body content. * @return string Formatted HTML email. * @since 0.0.1 */ private static function format_email_body( $body ) { $email_template = Email_Template::get_instance(); return $email_template->render( $body ); } }