From fc1c28ddd351f96566166dad25d563900a21aa23 Mon Sep 17 00:00:00 2001 From: groovecoder Date: Thu, 19 Sep 2024 16:15:33 -0500 Subject: [PATCH] MPP-3119: wip add disabled_mask_for_spam email --- .../templates/emails/direct_email_footer.html | 19 ++ .../templates/emails/direct_email_header.html | 173 +++++++++++ .../emails/disabled_mask_for_spam.html | 32 ++ .../emails/disabled_mask_for_spam.txt | 11 + .../emails/reply_requires_premium.html | 190 +----------- .../disabled_mask_for_spam_expected.email | 289 ++++++++++++++++++ ...eply_requires_premium_first_expected.email | 5 +- ...ply_requires_premium_second_expected.email | 5 +- emails/tests/views_tests.py | 47 +++ emails/urls.py | 1 + emails/views.py | 68 ++++- privaterelay/pending_locales/en/pending.ftl | 18 ++ 12 files changed, 668 insertions(+), 190 deletions(-) create mode 100644 emails/templates/emails/direct_email_footer.html create mode 100644 emails/templates/emails/direct_email_header.html create mode 100644 emails/templates/emails/disabled_mask_for_spam.html create mode 100644 emails/templates/emails/disabled_mask_for_spam.txt create mode 100644 emails/tests/fixtures/disabled_mask_for_spam_expected.email diff --git a/emails/templates/emails/direct_email_footer.html b/emails/templates/emails/direct_email_footer.html new file mode 100644 index 0000000000..0320c0bb6a --- /dev/null +++ b/emails/templates/emails/direct_email_footer.html @@ -0,0 +1,19 @@ +{% comment %} + Note that Django only loads strings from some Fluent files. + See privaterelay/ftl_bundles.py. +{% endcomment %} +{% load ftl %} + + + + + + + + + diff --git a/emails/templates/emails/direct_email_header.html b/emails/templates/emails/direct_email_header.html new file mode 100644 index 0000000000..8e798c200c --- /dev/null +++ b/emails/templates/emails/direct_email_header.html @@ -0,0 +1,173 @@ +{% comment %} + Note that Django only loads strings from some Fluent files. + See privaterelay/ftl_bundles.py. +{% endcomment %} +{% load ftl %} + + + + + + + + + + + + + + + + + + +
+ warning icon + + {% ftlmsg 'upgrade-for-more-protection' %} +
diff --git a/emails/templates/emails/disabled_mask_for_spam.html b/emails/templates/emails/disabled_mask_for_spam.html new file mode 100644 index 0000000000..31d645037f --- /dev/null +++ b/emails/templates/emails/disabled_mask_for_spam.html @@ -0,0 +1,32 @@ +{% comment %} + Note that Django only loads strings from some Fluent files. + See privaterelay/ftl_bundles.py. +{% endcomment %} +{% load ftl %} +{% load email_extras %} +{% withftl bundle='privaterelay.ftl_bundles.main' language=language %} + +{% include "emails/direct_email_header.html" %} + + + + + +
+ {% with mask|striptags|urlencode as mask_url %} +

+ warning icon + {% ftlmsg 'relay-disabled-your-mask' %} +

+

+ {% ftlmsg 'relay-received-spam-complaint-html' mask=mask %} {% ftlmsg 'relay-disabled-your-mask-detail-html' mask=mask %} +

+ + {% ftlmsg 're-enable-your-mask' %} + + {% endwith %} +
+ + {% include "emails/direct_email_footer.html" %} + +{% endwithftl %} diff --git a/emails/templates/emails/disabled_mask_for_spam.txt b/emails/templates/emails/disabled_mask_for_spam.txt new file mode 100644 index 0000000000..b75eb7c535 --- /dev/null +++ b/emails/templates/emails/disabled_mask_for_spam.txt @@ -0,0 +1,11 @@ +{% load ftl %} +{% load email_extras %} +{% withftl bundle='privaterelay.ftl_bundles.main' language=language %} +{% ftlmsg 'relay-disabled-your-mask' %} + +{% ftlmsg 'relay-received-spam-complaint' mask=mask %} {% ftlmsg 'relay-disabled-your-mask-detail' mask=mask %} +{% with mask|striptags|urlencode as mask_url %} +{% ftlmsg 're-enable-your-mask' %} +{{ SITE_ORIGIN }}/accounts/profile/#{{ mask_url }} +{% endwith %} +{% endwithftl %} diff --git a/emails/templates/emails/reply_requires_premium.html b/emails/templates/emails/reply_requires_premium.html index 20932d351d..75147724dd 100644 --- a/emails/templates/emails/reply_requires_premium.html +++ b/emails/templates/emails/reply_requires_premium.html @@ -5,176 +5,7 @@ {% load ftl %} {% load email_extras %} {% withftl bundle='privaterelay.ftl_bundles.main' language=language %} - - - - - - - - - - - - - - - - - - - -
- warning icon - - {% ftlmsg 'upgrade-for-more-protection' %} -
- +{% include "emails/direct_email_header.html" %}
@@ -198,20 +29,5 @@

- - - - - - - - - - - -{% endwithftl %} \ No newline at end of file +{% include "emails/direct_email_footer.html" %} +{% endwithftl %} diff --git a/emails/tests/fixtures/disabled_mask_for_spam_expected.email b/emails/tests/fixtures/disabled_mask_for_spam_expected.email new file mode 100644 index 0000000000..3eb2776de8 --- /dev/null +++ b/emails/tests/fixtures/disabled_mask_for_spam_expected.email @@ -0,0 +1,289 @@ +Subject: =?utf-8?q?=E2=81=A8Firefox_Relay=E2=81=A9?= has disabled one of your + email masks. +From: reply@relay.example.com +To: +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="==[BOUNDARY0]==" + +--==[BOUNDARY0]== +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + + + + +=E2=81=A8Firefox Relay=E2=81=A9 has disabled one of your email masks. + +=E2=81=A8Firefox Relay=E2=81=A9 received a spam complaint for an email sent t= +o =E2=81=A8w41fwbt4q@test.com=E2=81=A9. This usually happens if you or your e= +mail provider mark an email as spam. To prevent further spam, =E2=81=A8Firefo= +x Relay=E2=81=A9 has disabled your =E2=81=A8w41fwbt4q@test.com=E2=81=A9 mask. + +Visit your =E2=81=A8Firefox Relay=E2=81=A9 dashboard to re-enable this mask. +http://127.0.0.1:8000/accounts/profile/#w41fwbt4q%40test.com + + + +--==[BOUNDARY0]== +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: quoted-printable +MIME-Version: 1.0 + + + + + + + + + + + + + + + + + + + + + + + + + + +
=20 + 3D"= =20 + =20 + Upgrade for more protection=20 +
=20 + + + + + =20 + +
+ =20 +

=20 + 3D"warning + =E2=81=A8Firefox Relay=E2=81=A9 has disabled one of your = +email masks. +

+

+ Firefox Relay received a spam complaint for an email sent to = +w41fwbt4q@test.com. This usually happens if you or your emai= +l provider mark an email as spam. To prevent further spam, Firefox Relay has = +disabled your w41fwbt4q@test.com mask. +

=20 + + Visit your =E2=81=A8Firefox Relay=E2=81=A9 dashboard to r= +e-enable this mask. + + =20 +
+ + =20 + + + + + + =20 + +
=20 + 3D"= =20 + =20 + Upgrade to =E2=81=A8Firefox = +Relay Premium=E2=81=A9=20 + Manage your masks=20 +
=20 + + + + + + +--==[BOUNDARY0]==-- diff --git a/emails/tests/fixtures/reply_requires_premium_first_expected.email b/emails/tests/fixtures/reply_requires_premium_first_expected.email index bfab7be2e5..13227fe6e1 100644 --- a/emails/tests/fixtures/reply_requires_premium_first_expected.email +++ b/emails/tests/fixtures/reply_requires_premium_first_expected.email @@ -38,6 +38,7 @@ MIME-Version: 1.0 + @@ -223,7 +224,7 @@ ail" style=3D"color: white;">Upgrade for more protection=20 =20 - =20 + @@ -281,4 +282,6 @@ dium=3Demail" style=3D"color: white;">Manage your masks=20 + + --==[BOUNDARY0]==-- diff --git a/emails/tests/fixtures/reply_requires_premium_second_expected.email b/emails/tests/fixtures/reply_requires_premium_second_expected.email index a8a81d85bc..dee7b0bd31 100644 --- a/emails/tests/fixtures/reply_requires_premium_second_expected.email +++ b/emails/tests/fixtures/reply_requires_premium_second_expected.email @@ -37,6 +37,7 @@ MIME-Version: 1.0 + @@ -222,7 +223,7 @@ ail" style=3D"color: white;">Upgrade for more protection=20
=20 - =20 + @@ -280,4 +281,6 @@ dium=3Demail" style=3D"color: white;">Manage your masks=20 + + --==[BOUNDARY0]==-- diff --git a/emails/tests/views_tests.py b/emails/tests/views_tests.py index 6a43e290aa..90fa3bec29 100644 --- a/emails/tests/views_tests.py +++ b/emails/tests/views_tests.py @@ -12,6 +12,7 @@ from unittest.mock import Mock, patch from uuid import uuid4 +from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.http import HttpResponse @@ -45,6 +46,7 @@ from emails.views import ( EmailDroppedReason, ReplyHeadersNotFound, + _build_disabled_mask_for_spam_email, _build_reply_requires_premium_email, _get_address, _get_keys_from_headers, @@ -1051,6 +1053,7 @@ def test_sns_message_with_hard_bounce_and_optout(self) -> None: @override_settings(STATSD_ENABLED=True) +@override_settings(RELAY_FROM_ADDRESS="reply@relay.example.com") class ComplaintHandlingTest(TestCase): """ Test Complaint notifications and events. @@ -1078,6 +1081,12 @@ def setUp(self): }, } self.complaint_body = {"Message": json.dumps(complaint)} + ses_client_patcher = patch( + "emails.apps.EmailsConfig.ses_client", + spec_set=["send_raw_email"], + ) + self.mock_ses_client = ses_client_patcher.start() + self.addCleanup(ses_client_patcher.stop) def test_notification_type_complaint(self): """ @@ -1158,7 +1167,45 @@ def test_complaint_disables_mask(self): assert response.status_code == 200 self.ra.refresh_from_db() + source = self.mock_ses_client.send_raw_email.call_args.kwargs["Source"] + destinations = self.mock_ses_client.send_raw_email.call_args.kwargs["Destinations"] + raw_message = self.mock_ses_client.send_raw_email.call_args.kwargs["RawMessage"] + data_without_newlines = raw_message["Data"].replace("\n", "") + assert self.ra.enabled is False + self.mock_ses_client.send_raw_email.assert_called_once() + assert source == settings.RELAY_FROM_ADDRESS + assert destinations == [self.ra.user.email] + assert "To prevent further spam" in data_without_newlines + assert self.ra.full_address in data_without_newlines + + # re-enable the mask for other tests + self.ra.enabled = True + self.ra.save() + self.ra.refresh_from_db() + + def test_build_disabled_mask_for_spam_email(self): + free_user = make_free_test_user() + test_mask_address = "w41fwbt4q" + relay_address = baker.make( + RelayAddress, user=free_user, address=test_mask_address, domain=2 + ) + + original_spam_email: dict = {"mask": relay_address.full_address} + + msg = _build_disabled_mask_for_spam_email(relay_address, original_spam_email) + + assert msg["Subject"] == main.format("relay-disabled-your-mask") + assert msg["From"] == settings.RELAY_FROM_ADDRESS + assert msg["To"] == free_user.email + + text_content, html_content = get_text_and_html_content(msg) + assert test_mask_address in text_content + assert test_mask_address in html_content + + assert_email_equals_fixture( + msg.as_string(), "disabled_mask_for_spam", replace_mime_boundaries=True + ) class SNSNotificationRemoveEmailsInS3Test(TestCase): diff --git a/emails/urls.py b/emails/urls.py index d0a9f0ca15..025443752b 100644 --- a/emails/urls.py +++ b/emails/urls.py @@ -13,4 +13,5 @@ path("first_time_user_test", views.first_time_user_test), path("reply_requires_premium_test", views.reply_requires_premium_test), path("first_forwarded_email", views.first_forwarded_email_test), + path("disabled_mask_for_spam_test", views.disabled_mask_for_spam_test), ] diff --git a/emails/views.py b/emails/views.py index 69fb8b64aa..5a8a9d0466 100644 --- a/emails/views.py +++ b/emails/views.py @@ -139,6 +139,32 @@ def reply_requires_premium_test(request): return render(request, "emails/reply_requires_premium.html", email_context) +def disabled_mask_for_spam_test(request): + """ + Demonstrate rendering of the "Disabled mask for spam" email. + + Settings like language can be given in the querystring, otherwise settings + come from a random free profile. + """ + mask = "abc123456@mozmail.com" + email_context = { + "mask": mask, + "SITE_ORIGIN": settings.SITE_ORIGIN, + } + for param in request.GET: + email_context[param] = request.GET.get(param) + + for param in request.GET: + if param == "content-type" and request.GET[param] == "text/plain": + return render( + request, + "emails/disabled_mask_for_spam.txt", + email_context, + "text/plain; charset=utf-8", + ) + return render(request, "emails/disabled_mask_for_spam.html", email_context) + + def first_forwarded_email_test(request: HttpRequest) -> HttpResponse: # TO DO: Update with correct context when trigger is created first_forwarded_email_html = render_to_string( @@ -1574,6 +1600,46 @@ def _handle_bounce(message_json: AWS_SNSMessageJSON) -> HttpResponse: return HttpResponse("OK", status=200) +def _build_disabled_mask_for_spam_email( + mask: RelayAddress | DomainAddress, original_spam_email: dict +) -> EmailMessage: + ctx = { + "mask": mask.full_address, + "spam_email": original_spam_email, + "SITE_ORIGIN": settings.SITE_ORIGIN, + } + html_body = render_to_string("emails/disabled_mask_for_spam.html", ctx) + text_body = render_to_string("emails/disabled_mask_for_spam.txt", ctx) + + # Create the message + msg = EmailMessage() + msg["Subject"] = ftl_bundle.format("relay-disabled-your-mask") + msg["From"] = settings.RELAY_FROM_ADDRESS + msg["To"] = mask.user.email + msg.set_content(text_body) + msg.add_alternative(html_body, subtype="html") + return msg + + +def _send_disabled_mask_for_spam_email( + mask: RelayAddress | DomainAddress, original_spam_email: dict +) -> None: + msg = _build_disabled_mask_for_spam_email(mask, original_spam_email) + if not settings.RELAY_FROM_ADDRESS: + raise ValueError( + "Must set settings.RELAY_FROM_ADDRESS to send disabled_mask_for_spam email." + ) + try: + ses_send_raw_email( + source_address=settings.RELAY_FROM_ADDRESS, + destination_address=mask.user.email, + message=msg, + ) + except ClientError as e: + logger.error("reply_not_allowed_ses_client_error", extra=e.response["Error"]) + incr_if_enabled("free_user_reply_attempt", 1) + + def _handle_complaint(message_json: AWS_SNSMessageJSON) -> HttpResponse: """ Handle an AWS SES complaint notification. @@ -1650,7 +1716,7 @@ def _handle_complaint(message_json: AWS_SNSMessageJSON) -> HttpResponse: address = _get_address(destination_address, False) address.enabled = False address.save() - # TODO: email the user that we disabled the mask + _send_disabled_mask_for_spam_email(address, message_json.get("mail", {})) except ( ObjectDoesNotExist, RelayAddress.DoesNotExist, diff --git a/privaterelay/pending_locales/en/pending.ftl b/privaterelay/pending_locales/en/pending.ftl index 5f35a2cc62..2c9d4b9b24 100644 --- a/privaterelay/pending_locales/en/pending.ftl +++ b/privaterelay/pending_locales/en/pending.ftl @@ -3,3 +3,21 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. # This is the Django equivalent of frontend/pendingTranslations.ftl + +## Email sent to users when Relay disables their mask after the user marks a forwarded +## email as spam. + +relay-disabled-your-mask = { -brand-name-firefox-relay } has disabled one of your email masks. +# Variables +# $mask (string) - the Relay email mask that sent a spam complaint +relay-received-spam-complaint-html = { -brand-name-firefox-relay } received a spam complaint for an email sent to { $mask }. This usually happens if you or your email provider mark an email as spam. +# Variables +# $mask (string) - the Relay email mask that sent a spam complaint +relay-received-spam-complaint = { -brand-name-firefox-relay } received a spam complaint for an email sent to { $mask }. This usually happens if you or your email provider mark an email as spam. +# Variables +# $mask (string) - the Relay email mask that sent a spam complaint +relay-disabled-your-mask-detail-html = To prevent further spam, { -brand-name-firefox-relay } has disabled your { $mask } mask. +# Variables +# $mask (string) - the Relay email mask that sent a spam complaint +relay-disabled-your-mask-detail = To prevent further spam, { -brand-name-firefox-relay } has disabled your { $mask } mask. +re-enable-your-mask = Visit your { -brand-name-firefox-relay } dashboard to re-enable this mask.