diff --git a/analytics/lib/counts.py b/analytics/lib/counts.py index 19c547e822..32f85bf740 100644 --- a/analytics/lib/counts.py +++ b/analytics/lib/counts.py @@ -516,6 +516,11 @@ count_stats_ = [ CountStat.DAY, interval=timedelta(days=15)-UserActivityInterval.MIN_INTERVAL_LENGTH), CountStat('minutes_active::day', DataCollector(UserCount, do_pull_minutes_active), CountStat.DAY), + # Rate limiting stats + + # Used to limit the number of invitation emails sent by a realm + LoggingCountStat('invites_sent::day', RealmCount, CountStat.DAY), + # Dependent stats # Must come after their dependencies. diff --git a/analytics/tests/test_counts.py b/analytics/tests/test_counts.py index 61b01038b1..430086930b 100644 --- a/analytics/tests/test_counts.py +++ b/analytics/tests/test_counts.py @@ -19,11 +19,13 @@ from analytics.models import Anomaly, BaseCount, \ FillState, InstallationCount, RealmCount, StreamCount, \ UserCount, installation_epoch, last_successful_fill from zerver.lib.actions import do_activate_user, do_create_user, \ - do_deactivate_user, do_reactivate_user, update_user_activity_interval + do_deactivate_user, do_reactivate_user, update_user_activity_interval, \ + do_invite_users, do_revoke_user_invite, do_resend_user_invite_email, \ + InvitationError from zerver.lib.timestamp import TimezoneNotUTCException, floor_to_day from zerver.models import Client, Huddle, Message, Realm, \ RealmAuditLog, Recipient, Stream, UserActivityInterval, \ - UserProfile, get_client, get_user + UserProfile, get_client, get_user, PreregistrationUser class AnalyticsTestCase(TestCase): MINUTE = timedelta(seconds = 60) @@ -747,6 +749,45 @@ class TestLoggingCountStats(AnalyticsTestCase): self.assertEqual(1, RealmCount.objects.filter(property=property, subgroup=False) .aggregate(Sum('value'))['value__sum']) + def test_invites_sent(self) -> None: + property = 'invites_sent::day' + + def assertInviteCountEquals(count: int) -> None: + self.assertEqual(count, RealmCount.objects.filter(property=property, subgroup=None) + .aggregate(Sum('value'))['value__sum']) + + user = self.create_user(email='first@domain.tld') + stream, _ = self.create_stream_with_recipient() + do_invite_users(user, ['user1@domain.tld', 'user2@domain.tld'], [stream]) + assertInviteCountEquals(2) + + # We currently send emails when re-inviting users that haven't + # turned into accounts, so count them towards the total + do_invite_users(user, ['user1@domain.tld', 'user2@domain.tld'], [stream]) + assertInviteCountEquals(4) + + # Test mix of good and malformed invite emails + try: + do_invite_users(user, ['user3@domain.tld', 'malformed'], [stream]) + except InvitationError: + pass + assertInviteCountEquals(4) + + # Test inviting existing users + try: + do_invite_users(user, ['first@domain.tld', 'user4@domain.tld'], [stream]) + except InvitationError: + pass + assertInviteCountEquals(5) + + # Revoking invite should not give you credit + do_revoke_user_invite(PreregistrationUser.objects.filter(realm=user.realm).first()) + assertInviteCountEquals(5) + + # Resending invite should cost you + do_resend_user_invite_email(PreregistrationUser.objects.first()) + assertInviteCountEquals(6) + class TestDeleteStats(AnalyticsTestCase): def test_do_drop_all_analytics_tables(self) -> None: user = self.create_user() diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 684ff76fe1..afeac4433a 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -3972,6 +3972,12 @@ def do_invite_users(user_profile: UserProfile, raise InvitationError(_("We weren't able to invite anyone."), skipped, sent_invitations=False) + # We do this here rather than in the invite queue processor since this + # is used for rate limiting invitations, rather than keeping track of + # when exactly invitations were sent + do_increment_logging_stat(user_profile.realm, COUNT_STATS['invites_sent::day'], + None, timezone_now(), increment=len(validated_emails)) + # Now that we are past all the possible errors, we actually create # the PreregistrationUser objects and trigger the email invitations. for email in validated_emails: @@ -4030,6 +4036,9 @@ def do_resend_user_invite_email(prereg_user: PreregistrationUser) -> str: prereg_user.invited_at = timezone_now() prereg_user.save() + do_increment_logging_stat(prereg_user.realm, COUNT_STATS['invites_sent::day'], + None, prereg_user.invited_at) + clear_scheduled_invitation_emails(prereg_user.email) # We don't store the custom email body, so just set it to None event = {"prereg_id": prereg_user.id, "referrer_id": prereg_user.referred_by.id, "email_body": None}