Skip to content

Commit f0e467c

Browse files
Check if a user exists before adding them to the mailing list (Fixes #591) (#592)
* Check if a user exists before adding them to the mailing list (Fixes #591) * Fix function call * Apply suggestion from @MelissaAutumn
1 parent 6ed685c commit f0e467c

File tree

2 files changed

+174
-45
lines changed

2 files changed

+174
-45
lines changed

src/thunderbird_accounts/subscription/tasks.py

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import sentry_sdk
12
from requests.exceptions import JSONDecodeError
23
import base64
34
from django.conf import settings
45
import datetime
56
import logging
67
import requests
8+
import hashlib
79

810
from celery import shared_task
911
from django.core.signing import Signer, BadSignature
@@ -412,6 +414,30 @@ def update_thundermail_quota(self, plan_uuid):
412414
def add_subscriber_to_mailchimp_list(self, user_uuid):
413415
"""Adds a user's thundermail address to the primary tbpro mailing list.
414416
This mailing list contains automations to trigger welcome emails and such."""
417+
try:
418+
basic_auth = base64.b64encode(
419+
f'this_does_not_support_bearer_auth:{settings.MAILCHIMP_API_KEY}'.encode()
420+
).decode()
421+
except (UnicodeEncodeError, UnicodeDecodeError) as ex:
422+
logging.error(f'Could not send request to mailchimp due to error: {ex}')
423+
return {
424+
'user_uuid': user_uuid,
425+
'task_status': 'failed',
426+
'reason': 'unicode error',
427+
}
428+
429+
def mailchimp_api_query(method: str, api_endpoint: str, _json: dict | None = None) -> requests.Response:
430+
"""Small method to query mailchimp's api"""
431+
api_url = f'https://{settings.MAILCHIMP_DC}.api.mailchimp.com/3.0/lists/{settings.MAILCHIMP_LIST_ID}'
432+
response: requests.Response = requests.request(
433+
method=method.upper(),
434+
url=f'{api_url}{api_endpoint}',
435+
headers={'Authorization': f'Basic {basic_auth}'},
436+
json=_json,
437+
)
438+
response.raise_for_status()
439+
return response
440+
415441
try:
416442
user: User = User.objects.get(pk=user_uuid)
417443
except User.DoesNotExist:
@@ -430,26 +456,52 @@ def add_subscriber_to_mailchimp_list(self, user_uuid):
430456
'reason': 'user is not subscribed',
431457
}
432458

433-
try:
434-
basic_auth = base64.b64encode(
435-
f'this_does_not_support_bearer_auth:{settings.MAILCHIMP_API_KEY}'.encode()
436-
).decode()
437-
except (UnicodeEncodeError, UnicodeDecodeError) as ex:
438-
logging.error(f'Could not send request to mailchimp due to error: {ex}')
439-
return {
440-
'user_uuid': user_uuid,
441-
'task_status': 'failed',
442-
'reason': 'unicode error',
443-
}
444-
459+
# Loop through the thundermail and recovery email and attempt to update or add a new tag to the mailchimp entry.
460+
# It's a bit of a bit long and most of that is error catching.
445461
for val in [(user.stalwart_primary_email, 'new_user'), (user.email, 'welcome')]:
462+
email, tag = val
463+
md5_hasher = hashlib.new('md5')
464+
md5_hasher.update(email.lower().encode())
465+
hashed_email = md5_hasher.hexdigest()
466+
467+
# Check if the user is on the list, and if they are then update their tags array with our new one.
446468
try:
447-
email, tag = val
469+
response = mailchimp_api_query('get', f'/members/{hashed_email}')
470+
response.raise_for_status()
471+
472+
data = response.json() or {}
473+
tags = {t.get('name'): True for t in data.get('tags', [])}
474+
475+
# They're already in the mailing list with the assigned tag, so don't do anything.
476+
if tags.get(tag):
477+
continue
478+
479+
response = mailchimp_api_query(
480+
'post', f'/members/{hashed_email}/tags', _json={'tags': [{'name': tag, 'status': 'active'}]}
481+
)
448482

449-
response: requests.Response = requests.post(
450-
f'https://{settings.MAILCHIMP_DC}.api.mailchimp.com/3.0/lists/{settings.MAILCHIMP_LIST_ID}/members',
451-
headers={'Authorization': f'Basic {basic_auth}'},
452-
json={
483+
# Update tag has no response, so if we haven't ran into an exception continue along to the next email/tag.
484+
continue
485+
486+
except requests.exceptions.RequestException as ex:
487+
# Something error error'd out, we won't capture this just yet but we should add the context
488+
# in case the next request fails.
489+
490+
# Error details reference: https://mailchimp.com/developer/marketing/docs/errors/#error-glossary
491+
try:
492+
error_details = ex.response.json()
493+
except (JSONDecodeError, AttributeError):
494+
error_details = {}
495+
496+
# Send some extra information to sentry
497+
sentry_sdk.set_extra('tag_error_details', error_details)
498+
499+
# Try to create the user with the tag we want
500+
try:
501+
response = mailchimp_api_query(
502+
'post',
503+
'/members',
504+
_json={
453505
'email_address': email,
454506
'status': 'subscribed',
455507
'email_type': 'html',
@@ -458,21 +510,23 @@ def add_subscriber_to_mailchimp_list(self, user_uuid):
458510
'tags': [tag], # Tagged for automation purposes
459511
},
460512
)
461-
response.raise_for_status()
462513
except requests.exceptions.RequestException as ex:
463-
logging.error(f'Could not send request to mailchimp due to error: {ex}')
464-
514+
# Error details reference: https://mailchimp.com/developer/marketing/docs/errors/#error-glossary
465515
try:
466-
title = ex.response.json().get('title')
467-
except JSONDecodeError:
468-
title = 'N/A'
516+
error_details = ex.response.json()
517+
except (JSONDecodeError, AttributeError):
518+
error_details = {}
519+
520+
# Send some extra information to sentry
521+
sentry_sdk.set_extra('error_details', error_details)
522+
sentry_sdk.capture_exception(ex)
469523

470524
return {
471525
'user_uuid': user_uuid,
472526
'task_status': 'failed',
473527
'reason': 'mailchimp error',
474-
'error_msg_title': title,
475-
'error_status_code': ex.response.status_code,
528+
'error_msg_title': error_details.get('title', 'N/A'),
529+
'error_status_code': ex.response.status_code if ex.response else None,
476530
}
477531
return {
478532
'user_uuid': user_uuid,

src/thunderbird_accounts/subscription/tests/test_tasks.py

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import requests
22
import json
33
import datetime
4+
import hashlib
45
from pathlib import Path
56
from unittest.mock import patch, Mock, MagicMock
67

@@ -1022,14 +1023,25 @@ def setUp(self):
10221023
def tearDown(self):
10231024
super().tearDown()
10241025

1025-
@patch('requests.post')
1026-
def test_success(self, requests_post_mock: MagicMock):
1026+
@patch('requests.request')
1027+
def test_success_create(self, request_mock: MagicMock):
10271028
"""Ensure that given a subscribed user with a valid stalwart email that
10281029
they will successfully be added to the mailchimp list"""
1029-
fake_response = requests.Response()
1030-
fake_response.status_code = 200
1030+
get_member_response = requests.Response()
1031+
get_member_response.status_code = 404
1032+
1033+
create_member_response = requests.Response()
1034+
create_member_response.status_code = 200
1035+
1036+
request_mock.side_effect = [
1037+
# Thundermail
1038+
get_member_response,
1039+
create_member_response,
1040+
# Recovery Email
1041+
get_member_response,
1042+
create_member_response,
1043+
]
10311044

1032-
requests_post_mock.return_value = fake_response
10331045
task_results: dict | None = tasks.add_subscriber_to_mailchimp_list.delay(str(self.test_user.uuid)).get(
10341046
timeout=10
10351047
)
@@ -1038,53 +1050,116 @@ def test_success(self, requests_post_mock: MagicMock):
10381050
self.assertEqual(task_results.get('task_status'), 'success')
10391051
self.assertEqual(task_results.get('user_uuid'), str(self.test_user.uuid))
10401052

1041-
# We call it twice
1042-
self.assertEqual(requests_post_mock.call_count, 2)
1043-
thundermail_request, recovery_email_request = requests_post_mock.call_args_list
1053+
# We call it 4 times (2 for both emails)
1054+
self.assertEqual(request_mock.call_count, 4)
1055+
1056+
# Reverse order because of .pop()
1057+
email_addresses = [self.test_user.email, self.test_user.stalwart_primary_email]
1058+
for i in range(0, 4, 2):
1059+
get_req = request_mock.call_args_list[i]
1060+
update_tag_req = request_mock.call_args_list[i + 1]
1061+
address_to_test = email_addresses.pop()
1062+
1063+
md5_hasher = hashlib.new('md5')
1064+
md5_hasher.update(address_to_test.lower().encode())
1065+
hashed_email = md5_hasher.hexdigest()
1066+
1067+
# Test the GET request and the POST (update tag) request
1068+
self.assertIn(hashed_email, get_req[1]['url'])
1069+
self.assertNotIn('/tags', update_tag_req[1]['url'])
1070+
self.assertEqual(address_to_test, update_tag_req[1]['json']['email_address'])
1071+
1072+
@patch('requests.request')
1073+
def test_success_update(self, request_mock: MagicMock):
1074+
"""Ensure that given a subscribed user with a valid stalwart email that
1075+
they will successfully have the proper tag added to their entry."""
1076+
fake_response = requests.Response()
1077+
fake_response.status_code = 200
1078+
1079+
get_member_response = requests.Response()
1080+
get_member_response.status_code = 200
1081+
get_member_response._content = b'{}'
10441082

1045-
# Ensure the call args were correct
1046-
self.assertEqual(thundermail_request[1]['json']['email_address'], self.test_user.stalwart_primary_email)
1047-
self.assertEqual(recovery_email_request[1]['json']['email_address'], self.test_user.email)
1083+
update_member_tag = requests.Response()
1084+
update_member_tag.status_code = 200
1085+
1086+
request_mock.side_effect = [
1087+
# Thundermail
1088+
get_member_response,
1089+
update_member_tag,
1090+
# Recovery Email
1091+
get_member_response,
1092+
update_member_tag,
1093+
]
1094+
1095+
task_results: dict | None = tasks.add_subscriber_to_mailchimp_list.delay(str(self.test_user.uuid)).get(
1096+
timeout=10
1097+
)
1098+
1099+
self.assertIsNotNone(task_results)
1100+
self.assertEqual(task_results.get('task_status'), 'success')
1101+
self.assertEqual(task_results.get('user_uuid'), str(self.test_user.uuid))
10481102

1049-
@patch('requests.post')
1050-
def test_bad_user(self, requests_post_mock: MagicMock):
1103+
# We call it 4 times (2 for both emails)
1104+
self.assertEqual(request_mock.call_count, 4)
1105+
1106+
# Reverse order because of .pop()
1107+
email_addresses = [self.test_user.email, self.test_user.stalwart_primary_email]
1108+
tags = ['welcome', 'new_user']
1109+
for i in range(0, 4, 2):
1110+
get_req = request_mock.call_args_list[i]
1111+
update_tag_req = request_mock.call_args_list[i + 1]
1112+
address_to_test = email_addresses.pop()
1113+
tag_to_test = tags.pop()
1114+
1115+
md5_hasher = hashlib.new('md5')
1116+
md5_hasher.update(address_to_test.lower().encode())
1117+
hashed_email = md5_hasher.hexdigest()
1118+
1119+
# Test the GET request and the POST (update tag) request
1120+
self.assertIn(hashed_email, get_req[1]['url'])
1121+
self.assertIn('/tags', update_tag_req[1]['url'])
1122+
self.assertEqual([{'name': tag_to_test, 'status': 'active'}], update_tag_req[1]['json']['tags'])
1123+
1124+
@patch('requests.request')
1125+
def test_bad_user(self, request_mock: MagicMock):
10511126
"""Ensure that we catch users that do not exist."""
10521127
fake_response = requests.Response()
10531128
fake_response.status_code = 500
10541129

10551130
bad_user_uuid = '4b6c15c9b2c94c9389de992f05d1441b'
10561131

1057-
requests_post_mock.return_value = fake_response
1132+
request_mock.return_value = fake_response
10581133
task_results: dict | None = tasks.add_subscriber_to_mailchimp_list.delay(bad_user_uuid).get(timeout=10)
10591134

10601135
self.assertIsNotNone(task_results)
10611136
self.assertEqual(task_results.get('task_status'), 'failed')
10621137
self.assertEqual(task_results.get('user_uuid'), bad_user_uuid)
10631138
self.assertEqual(task_results.get('reason'), 'user does not exist')
10641139

1065-
@patch('requests.post')
1066-
def test_user_not_subscribed(self, requests_post_mock: MagicMock):
1140+
@patch('requests.request')
1141+
def test_user_not_subscribed(self, request_mock: MagicMock):
10671142
"""Ensure that we catch users who are not subscribed"""
10681143
fake_response = requests.Response()
10691144
fake_response.status_code = 500
10701145

10711146
test_user = User.objects.create_user('test2@example.com', 'test2@example.org', '1234')
10721147

1073-
requests_post_mock.return_value = fake_response
1148+
request_mock.return_value = fake_response
10741149
task_results: dict | None = tasks.add_subscriber_to_mailchimp_list.delay(str(test_user.uuid)).get(timeout=10)
10751150

10761151
self.assertIsNotNone(task_results)
10771152
self.assertEqual(task_results.get('task_status'), 'failed')
10781153
self.assertEqual(task_results.get('user_uuid'), str(test_user.uuid))
10791154
self.assertEqual(task_results.get('reason'), 'user is not subscribed')
10801155

1081-
@patch('requests.post')
1082-
def test_bad_request(self, requests_post_mock: MagicMock):
1156+
@patch('requests.request')
1157+
def test_bad_request(self, request_mock: MagicMock):
10831158
"""Ensure we catch bad requests from mailchimp"""
10841159
fake_response = requests.Response()
10851160
fake_response.status_code = 404
10861161

1087-
requests_post_mock.return_value = fake_response
1162+
request_mock.return_value = fake_response
10881163
task_results: dict | None = tasks.add_subscriber_to_mailchimp_list.delay(str(self.test_user.uuid)).get(
10891164
timeout=10
10901165
)

0 commit comments

Comments
 (0)