%1$s to %2$s followers', 'activitypub' ), \number_format_i18n( $summary['followers_start'] ?? 0 ), \number_format_i18n( $summary['followers_end'] ?? 0 ) ); } // Build supporter text. $supporter_text = ''; if ( ! empty( $summary['top_multiplicator'] ) ) { $supporter_text = \sprintf( /* translators: 1: supporter URL, 2: supporter name, 3: boost count */ \__( '%2$s with %3$s boosts', 'activitypub' ), \esc_url( $summary['top_multiplicator']['url'] ), \esc_html( $summary['top_multiplicator']['name'] ), \number_format_i18n( $summary['top_multiplicator']['count'] ) ); } $args = \array_merge( $summary, array( /* translators: %d: Year */ 'title' => \sprintf( \__( 'Your %d Fediverse Year in Review', 'activitypub' ), $year ), /* translators: %d: Year */ 'intro' => \sprintf( \__( "Here's a look back at your %d activity on the Fediverse.", 'activitypub' ), $year ), 'closing' => \__( 'Thanks for being part of the Fediverse! Here\'s to another great year.', 'activitypub' ), 'most_active_month_name' => $most_active_month_name, 'followers_text' => $followers_text, 'supporter_text' => $supporter_text, 'user_id' => $user_id, ) ); $subject = \sprintf( /* translators: 1: Blog name, 2: Year */ \__( '[%1$s] Your %2$d Fediverse Year in Review', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), $year ); // Build plain text alternative. /* translators: %d: Year */ $alt_body = \sprintf( \__( "Here's your %d Fediverse year in review:\n\n", 'activitypub' ), $year ); if ( ! empty( $args['posts_count'] ) ) { /* translators: %d: Number of posts */ $alt_body .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $args['posts_count'] ); } if ( ! empty( $args['followers_net_change'] ) ) { /* translators: %d: Net follower change */ $alt_body .= \sprintf( \__( "Follower growth: %+d\n", 'activitypub' ), $args['followers_net_change'] ); } if ( ! empty( $most_active_month_name ) ) { /* translators: %s: Month name */ $alt_body .= \sprintf( \__( "Most active month: %s\n", 'activitypub' ), $most_active_month_name ); } Mailer::send( $user_id, $subject, 'stats-report', $args, $alt_body ); } /** * Send the monthly stats report email. * * @param int $user_id The user ID. * @param int $year The year. * @param int $month The month (1-12). * @param bool $force Whether to bypass user preference checks. */ public static function send_monthly_email( $user_id, $year, $month, $force = false ) { $option_name = Statistics_Collector::get_monthly_option_name( $user_id, $year, $month ); $stats = \get_option( $option_name, array() ); if ( empty( $stats ) ) { return; } if ( ! $force && ! self::should_send_report( $user_id, $stats, 'activitypub_mailer_monthly_report', '1' ) ) { return; } // Atomic claim: add_option only succeeds if the row doesn't yet exist, so this // is race-safe across concurrent cron workers and re-entrant invocations. // When $force is true, we still record the marker so a later non-forced cron run // won't send another copy for the same period. $email_sent_option = self::get_email_sent_option_name( $user_id, $year, $month ); if ( ! \add_option( $email_sent_option, \time(), '', false ) ) { if ( ! $force ) { return; } \update_option( $email_sent_option, \time(), false ); } $month_name = \date_i18n( 'F Y', \strtotime( \sprintf( '%d-%02d-01', $year, $month ) ) ); // Build follower text. $followers_text = ''; if ( ! empty( $stats['followers_total'] ) ) { $followers_text = \sprintf( /* translators: %s: total follower count */ \__( 'You now have %s followers', 'activitypub' ), \number_format_i18n( $stats['followers_total'] ) ); } // Build supporter text. $supporter_text = ''; if ( ! empty( $stats['top_multiplicator'] ) ) { $supporter_text = \sprintf( /* translators: 1: supporter URL, 2: supporter name, 3: boost count */ \__( '%2$s with %3$s boosts', 'activitypub' ), \esc_url( $stats['top_multiplicator']['url'] ), \esc_html( $stats['top_multiplicator']['name'] ), \number_format_i18n( $stats['top_multiplicator']['count'] ) ); } $args = \array_merge( $stats, array( /* translators: %s: Month and year, e.g. "March 2025" */ 'title' => \sprintf( \__( 'Your Fediverse Stats for %s', 'activitypub' ), $month_name ), /* translators: %s: Month and year, e.g. "March 2025" */ 'intro' => \sprintf( \__( "Here's how your content performed on the Fediverse in %s.", 'activitypub' ), $month_name ), 'closing' => \__( 'Keep sharing great content on the Fediverse!', 'activitypub' ), 'followers_text' => $followers_text, 'supporter_text' => $supporter_text, 'user_id' => $user_id, ) ); $subject = \sprintf( /* translators: 1: Blog name, 2: Month and year */ \__( '[%1$s] Your Fediverse Stats for %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), $month_name ); // Build plain text alternative. /* translators: %s: Month and year */ $alt_body = \sprintf( \__( "Here's your Fediverse stats for %s:\n\n", 'activitypub' ), $month_name ); if ( ! empty( $stats['posts_count'] ) ) { /* translators: %d: Number of posts */ $alt_body .= \sprintf( \__( "Posts published: %d\n", 'activitypub' ), $stats['posts_count'] ); } if ( ! empty( $stats['followers_count'] ) ) { /* translators: %d: New follower count */ $alt_body .= \sprintf( \__( "New followers: %+d\n", 'activitypub' ), $stats['followers_count'] ); } Mailer::send( $user_id, $subject, 'stats-report', $args, $alt_body ); } /** * Build the option name used to record that a stats email has been sent for a given period. * * The presence of this option is the idempotency signal: a row exists once an email * has been delivered (or claimed for delivery) for the given user and period. * * @param int $user_id The user ID. * @param int $year The year. * @param int|null $month The month (1-12), or null for the annual report. * * @return string The option name. Truncated to fit MySQL's 191-character key. */ private static function get_email_sent_option_name( $user_id, $year, $month = null ) { $suffix = null === $month ? \sprintf( '%d_annual', $year ) : \sprintf( '%d_%d', $year, $month ); return \substr( \sprintf( 'activitypub_stats_emailed_%d_%s', $user_id, $suffix ), 0, 191 ); } /** * Check whether a stats report should be sent. * * Verifies user preference and that there is meaningful activity. * * @param int $user_id The user ID. * @param array $stats The stats data. * @param string $option_name The preference option name (same for blog and user). * @param string $fallback The fallback value for the blog option. * * @return bool True if the report should be sent. */ private static function should_send_report( $user_id, $stats, $option_name, $fallback = '1' ) { if ( empty( $stats ) ) { return false; } // Check user preference. if ( $user_id > \Activitypub\Collection\Actors::BLOG_USER_ID ) { if ( ! \get_user_option( $option_name, $user_id ) ) { return false; } } elseif ( '1' !== \get_option( $option_name, $fallback ) ) { return false; } // Check that there is meaningful activity. if ( ! empty( $stats['posts_count'] ) || ! empty( $stats['followers_count'] ) ) { return true; } $comment_types = \array_keys( Statistics_Collector::get_comment_types_for_stats() ); foreach ( $comment_types as $type ) { if ( ! empty( $stats[ $type . '_count' ] ) ) { return true; } } return false; } }