'unsubscribe', 'sid' => (int) $sub->id, 'key' => Db::unsubscribe_key( $sub ), ), home_url( '/' ) ); } /** * REST one-click unsubscribe URL (RFC 8058). * * @param object $sub Subscription. * @return string */ public static function one_click_url( $sub ) { return add_query_arg( array( 'sid' => (int) $sub->id, 'key' => Db::unsubscribe_key( $sub ), ), rest_url( 'replyping/v1/unsubscribe' ) ); } /** * Normalise an email for rate limiting (lowercase, strip +tag). * * @param string $email Email. * @return string */ private static function normalise_email( $email ) { $email = strtolower( trim( $email ) ); $parts = explode( '@', $email, 2 ); if ( 2 === count( $parts ) ) { $local = explode( '+', $parts[0], 2 ); $email = $local[0] . '@' . $parts[1]; } return $email; } /** * Confirmation rate limits: per email, per IP and global, each per hour. * * @param string $email Email. * @param string $ip Client IP. * @return bool True when sending is allowed (and counts the attempt). */ public static function consume_rate_limit( $email, $ip = '' ) { $limits = array( 'replyping_rl_e_' . md5( self::normalise_email( $email ) ) => self::RATE_LIMIT, 'replyping_rl_global' => (int) apply_filters( 'replyping_confirmation_global_limit', 100 ), ); if ( '' !== $ip ) { $limits[ 'replyping_rl_i_' . md5( $ip ) ] = (int) apply_filters( 'replyping_confirmation_ip_limit', 5 ); } foreach ( $limits as $key => $max ) { if ( (int) get_transient( $key ) >= $max ) { return false; } } foreach ( array_keys( $limits ) as $key ) { set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS ); } return true; } /** * Send a confirmation email. * * @param object $sub Subscription. * @param string $raw_token Raw token. * @param string $ip Client IP for rate limiting. * @return bool */ public static function send_confirmation( $sub, $raw_token, $ip = '' ) { if ( ! self::consume_rate_limit( $sub->email, $ip ) ) { return false; } $post = get_post( (int) $sub->post_id ); if ( ! $post ) { return false; } $confirm = add_query_arg( array( 'replyping_action' => 'confirm', 'token' => $raw_token, ), home_url( '/' ) ); $vars = self::placeholders( $sub, $post, null, array( '{confirm_link}' => $confirm ) ); return self::send( $sub->email, Settings::get( 'confirm_subject' ), Settings::get( 'confirm_body' ), $vars, $sub, 'confirmation', null ); } /** * Send a comment notification. * * @param object $sub Subscription. * @param \WP_Comment $comment Comment. * @param \WP_Post $post Post. * @return bool */ public static function send_notification( $sub, $comment, $post ) { $vars = self::placeholders( $sub, $post, $comment ); return self::send( $sub->email, Settings::get( 'notify_subject' ), Settings::get( 'notify_body' ), $vars, $sub, 'notification', $comment ); } /** * Placeholder map. * * @param object $sub Subscription. * @param \WP_Post $post Post. * @param \WP_Comment|null $comment Comment. * @param array $extra Extra placeholders. * @return array */ private static function placeholders( $sub, $post, $comment, array $extra = array() ) { $vars = array( '{post_title}' => wp_strip_all_tags( get_the_title( $post ) ), '{site_name}' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), '{unsubscribe_link}' => self::unsubscribe_url( $sub ), '{comment_author}' => '', '{comment_excerpt}' => '', '{comment_link}' => get_permalink( $post ), '{confirm_link}' => '', ); if ( $comment ) { $vars['{comment_author}'] = wp_strip_all_tags( $comment->comment_author ); $vars['{comment_excerpt}'] = wp_trim_words( wp_strip_all_tags( $comment->comment_content ), 40 ); $vars['{comment_link}'] = get_comment_link( $comment ); } return array_merge( $vars, $extra ); } /** * Build and send one email through the single send path. * * @param string $to Recipient. * @param string $subject Subject template. * @param string $body Body template. * @param array $vars Placeholder map. * @param object $sub Subscription. * @param string $context 'confirmation' or 'notification'. * @param \WP_Comment|null $comment Comment. * @return bool */ private static function send( $to, $subject, $body, array $vars, $sub, $context, $comment ) { $subject = trim( preg_replace( '/[\r\n]+/', ' ', wp_strip_all_tags( strtr( $subject, $vars ) ) ) ); $message = strtr( $body, $vars ); if ( false === strpos( $body, '{unsubscribe_link}' ) ) { $message .= "\n\n" . __( 'Unsubscribe:', 'replyping' ) . ' ' . $vars['{unsubscribe_link}']; } $headers = array( 'Content-Type: text/plain; charset=UTF-8' ); $headers[] = 'List-Unsubscribe: <' . esc_url_raw( self::one_click_url( $sub ) ) . '>'; $headers[] = 'List-Unsubscribe-Post: List-Unsubscribe=One-Click'; $subject = (string) apply_filters( 'replyping_email_subject', $subject, $sub, $comment, $context ); $message = (string) apply_filters( 'replyping_email_message', $message, $sub, $comment, $context ); $headers = (array) apply_filters( 'replyping_email_headers', $headers, $sub, $comment, $context ); // Return non-null to short-circuit (custom transports). $pre = apply_filters( 'replyping_pre_send_email', null, $to, $subject, $message, $headers, $sub, $context ); if ( null !== $pre ) { return (bool) $pre; } // Scope the From overrides to this send only. add_filter( 'wp_mail_from', array( __CLASS__, 'mail_from' ) ); add_filter( 'wp_mail_from_name', array( __CLASS__, 'mail_from_name' ) ); add_action( 'phpmailer_init', array( __CLASS__, 'maybe_strip_list_headers' ) ); $sent = wp_mail( $to, $subject, $message, $headers ); remove_filter( 'wp_mail_from', array( __CLASS__, 'mail_from' ) ); remove_filter( 'wp_mail_from_name', array( __CLASS__, 'mail_from_name' ) ); remove_action( 'phpmailer_init', array( __CLASS__, 'maybe_strip_list_headers' ) ); return (bool) $sent; } /** * PHPMailer's 'mail' transport Q-encodes long custom headers, which breaks List-Unsubscribe; drop them there. * * @param PHPMailerPHPMailerPHPMailer $phpmailer Mailer instance. */ public static function maybe_strip_list_headers( $phpmailer ) { $enabled = (bool) apply_filters( 'replyping_list_unsubscribe_header_enabled', 'mail' !== $phpmailer->Mailer, $phpmailer ); if ( $enabled ) { return; } $custom = $phpmailer->getCustomHeaders(); $phpmailer->clearCustomHeaders(); foreach ( $custom as $header ) { if ( in_array( strtolower( $header[0] ), array( 'list-unsubscribe', 'list-unsubscribe-post' ), true ) ) { continue; } $phpmailer->addCustomHeader( $header[0], $header[1] ); } } /** * Configured From address. * * @param string $email Default. * @return string */ public static function mail_from( $email ) { $custom = sanitize_email( Settings::get( 'from_email' ) ); return is_email( $custom ) ? $custom : $email; } /** * Configured From name. * * @param string $name Default. * @return string */ public static function mail_from_name( $name ) { $custom = trim( preg_replace( '/[\\\\"\'<>\r\n]+/', '', (string) Settings::get( 'from_name' ) ) ); return '' !== $custom ? $custom : $name; } }