%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;
}
}