diff --git a/api/api/management/commands/sendapimoveannouncement.py b/api/api/management/commands/sendapimoveannouncement.py new file mode 100644 index 00000000000..595511a0bd6 --- /dev/null +++ b/api/api/management/commands/sendapimoveannouncement.py @@ -0,0 +1,118 @@ +import argparse + +from django.conf import settings +from django.core.mail import send_mail + +import django_redis +from django_tqdm import BaseCommand + +from api.models.oauth import OAuth2Verification + + +message = """ +On 3 June 2024, the Openverse API’s domain will be changed to api.openverse.org and api.openverse.engineering will start +redirecting to api.openverse.org. Existing API credentials and all other aspects of integrations with the +Openverse API will continue to work without changes, provided requests follow redirects. + +For the best experience, and to avoid redirects, please update code referencing api.openverse.engineering to use api.openverse.org. + +To read more about this change, including opportunities to ask questions or give feedback, please read the Make Openverse blog post announcing it: + +https://make.wordpress.org/openverse/2024/05/06/the-openverse-api-is-moving-to-api-openverse-org/ + +You are receiving this email because you’ve registered and verified with the Openverse API. + +Openverse only sends emails for critical operational notifications. +""" + + +class Command(BaseCommand): + help = "Resends verification emails for unverified Oauth applications." + """ + This command sends a corrected oauth verification email to users who have + not yet verified their Openverse oauth applications. A previous version sent + an email with an incorrectly formatted link. + + It stores a cache of successfully sent emails in Redis, so running it multiple + times (in case of failure) should not be an issue. + """ + + processed_key = "apimoveannouncement:processed" + + def add_arguments(self, parser): + parser.add_argument( + "--dry_run", + help="Count the emails to send, and don't do anything else.", + type=bool, + default=True, + action=argparse.BooleanOptionalAction, + ) + + def handle(self, *args, **options): + dry = options["dry_run"] + + redis = django_redis.get_redis_connection("default") + + sent_emails = [ + email.decode("utf-8") for email in redis.smembers(self.processed_key) + ] + + # Join from verification because it has a reference to both the application and the email + unsent_email_addresses = ( + OAuth2Verification.objects.filter( + # Only send to verified email addresses to avoid bounce rate increase + associated_application__verified=True + ) + .exclude( + email__in=sent_emails, + ) + .distinct("email") + .values_list("email", flat=True) + ) + + count_to_process = unsent_email_addresses.count() + errored_emails = [] + + if dry: + self.info( + f"{count_to_process} announcement emails to send. This is a dry run, exiting without making changes." + ) + return + + if not count_to_process: + self.info("No emails to send.") + return + + with self.tqdm(total=count_to_process) as progress: + while chunk := unsent_email_addresses.exclude( + email__in=sent_emails + errored_emails + )[:100]: + for email in chunk: + try: + send_mail( + subject="Openverse API is moving to api.openverse.org", + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[email], + fail_silently=False, + ) + sent_emails.append(email) + redis.sadd(self.processed_key, email) + except BaseException as err: + errored_emails.append(email) + self.error(f"Unable to process {email}: " f"{err}") + + progress.update(1) + + if errored_emails: + joined = "\n".join(errored_emails) + self.info( + self.style.WARNING( + f"The following emails were unable to be processed.\n\n" + f"{joined}" + "\n\nPlease check the output above for the error related" + "to each email." + ) + ) + + self.info(self.style.SUCCESS("Sent API move notifications.")) diff --git a/api/test/unit/management/commands/test_sendapimoveannouncement.py b/api/test/unit/management/commands/test_sendapimoveannouncement.py new file mode 100644 index 00000000000..63f027990e7 --- /dev/null +++ b/api/test/unit/management/commands/test_sendapimoveannouncement.py @@ -0,0 +1,102 @@ +import smtplib +from io import StringIO + +from django.core.management import call_command + +import pytest + +from test.factory.models.oauth2 import ( + OAuth2VerificationFactory, +) + + +command_module_path = "api.management.commands.sendapimoveannouncement" + + +@pytest.fixture +def captured_emails(monkeypatch) -> list[str]: + captured = [] + + def send_mail(*args, **kwargs): + captured.extend(kwargs["recipient_list"]) + + monkeypatch.setattr(f"{command_module_path}.send_mail", send_mail) + + yield captured + + +@pytest.fixture +def failed_emails(monkeypatch) -> list[str]: + failed = [] + + def send_mail(*args, **kwargs): + failed.extend( + kwargs["recipient_list"], + ) + raise smtplib.SMTPAuthenticationError(1, "beep boop bad password") + + monkeypatch.setattr(f"{command_module_path}.send_mail", send_mail) + + yield failed + + +def call_cmd(**options): + out = StringIO() + err = StringIO() + options.update(stdout=out, stderr=err) + call_command("sendapimoveannouncement", **options) + + res = out.getvalue(), err.getvalue() + print(res) + + return res + + +def make_emails(count: int, *, verified: bool): + verifications = OAuth2VerificationFactory.create_batch( + count, associated_application__verified=verified + ) + return [v.email for v in verifications] + + +@pytest.mark.django_db +def test_should_not_resend_for_already_sent(redis, captured_emails): + emails = make_emails(10, verified=True) + for email in emails: + redis.sadd("apimoveannouncement:processed", email) + + call_cmd(dry_run=False) + assert captured_emails == [] + + +@pytest.mark.django_db +def test_should_not_count_email_as_sent_if_failed(failed_emails, redis): + emails = make_emails(1, verified=True) + call_cmd(dry_run=False) + assert failed_emails == emails + + for email in emails: + assert not (redis.sismember("apimoveannouncement:processed", email)) + + +@pytest.mark.django_db +def test_should_not_send_to_unverified_emails(captured_emails): + make_emails(10, verified=False) + + call_cmd(dry_run=False) + assert len(captured_emails) == 0 + + +@pytest.mark.django_db +def test_should_not_send_to_email_twice(captured_emails): + emails = make_emails(10, verified=True) + OAuth2VerificationFactory.create( + email=emails[0], + ) + + call_cmd(dry_run=False) + # Not 11 + assert len(captured_emails) == 10 + + # Only appears once + assert len([email for email in emails if email == emails[0]]) == 1