diff --git a/anymail/backends/unisender_go.py b/anymail/backends/unisender_go.py index 32fcc11a..83629fd9 100644 --- a/anymail/backends/unisender_go.py +++ b/anymail/backends/unisender_go.py @@ -17,7 +17,7 @@ class EmailBackend(AnymailRequestsBackend): """Unsidender GO v1 API Email Backend""" - esp_name = "UnisenderGo" + esp_name = "Unisender Go" def __init__(self, **kwargs: typing.Any): """Init options from Django settings""" @@ -34,13 +34,9 @@ def __init__(self, **kwargs: typing.Any): "merge_field_format", esp_name=esp_name, kwargs=kwargs, default=None ) - api_url = get_anymail_setting( - "api_url", esp_name=esp_name, kwargs=kwargs, default=None - ) # Don't set default, because url depends on location - # url template is https://go.unisender.//transactional/api/v1 + api_url = get_anymail_setting("api_url", esp_name=esp_name, kwargs=kwargs) + # Don't set default, because url depends on location - if api_url is None: - raise AnymailConfigurationError("api_url required") super().__init__(api_url, **kwargs) def build_message_payload( @@ -169,19 +165,107 @@ def __init__( def get_api_endpoint(self) -> str: return "email/send.json" - def init_payload(self) -> None: - self.data = {"headers": CaseInsensitiveDict()} # becomes json + def set_skip_unsubscribe(self, extra: dict) -> None: + """ + By default, Unisender Go adds unsubscribe link. - # by default, Unisender Go adds unsubscribe link - # to fix this, you have to request tech support - if ( - get_anymail_setting( - "skip_unsubscribe", esp_name=self.esp_name, default=False - ) - is True + It's needed due to guarantee not abusing users with spam. + Before to use this setting, you have to request tech support. + + Expects skip_unsubscribe to be True or False, then transfers it to 0 or 1. + Anyway, works if skip_unsubscribe converts to True or False (for flexibility). + """ + if "skip_unsubscribe" in extra and extra["skip_unsubscribe"]: + self.data["skip_unsubscribe"] = 1 + + def set_global_language(self, extra): + """ + Language for link language and unsubscribe page. + Options: 'be', 'de', 'en', 'es', 'fr', 'it', 'pl', 'pt', 'ru', 'ua', 'kz'. + """ + if "global_language" in extra and extra["global_language"]: + self.data["global_language"] = extra["global_language"] + + def set_amp(self, extra): + """AMP-part of email""" + if "amp" in extra and extra["amp"]: + self.data["amp"] = extra["amp"] + + def set_bypass_settings(self, extra): + """ + Set extra settings with bypass prefix. + + bypass_global: optional 0/1 (0 by default) + If 1: To ignore list of global unavailability. + Can be forbidden for some system records. + + bypass_unavailable: optional 0/1 (0 by default) + If 1: To ignore current project unavailable addresses. + Works only with bypass_global = 1. + + bypass_unsubscribed: optional 0/1 (0 by default) + If 1: To ignore list of unsubscribed people. + Works only with bypass_global=1 and requires tech support's approve. + + bypass_complained: optional 0/1 (0 by default) + If 1: To ignore complainers on project. + Works only with bypass_global=1 and requires tech support's approve. + """ + bypass_fields = ( + "bypass_global", + "bypass_unavailable", + "bypass_unsubscribed", + "bypass_complained", + ) + for field in bypass_fields: + if field in extra: + self.data[field] = extra[field] + + def set_template_engine(self, extra): + """ + Templating choosing parameter. Can be either 'simple' or 'velocity' or 'none'. + + 'simple' by default. + 'none' available only for emails with + ‘track_links’ and ‘track_read’ equal 0 and with turned off unsubscribe block. + + "Simple" templating is for simple substitutions. + "Velocity" templating allows loops, arrays, etc. + """ + if "template_engine" in extra and extra["template_engine"]: + self.data["template_engine"] = extra["template_engine"] + + def set_esp_extra(self, extra: dict) -> None: + """Set every esp extra parameter with its docstring""" + self.set_skip_unsubscribe(extra) + self.set_global_language(extra) + self.set_template_engine(extra) + self.set_amp(extra) + self.set_bypass_settings(extra) + + def set_global_settings_from_config(self): + """ + Here we set variables from global config. + + If there is no esp_extra in backend's kwargs, set_esp_extra won't be called. + So we have to set default values in init. + You can change them with backend's kwarg esp_extra. + """ + if get_anymail_setting( + "skip_unsubscribe", esp_name=self.esp_name, default=False ): self.data["skip_unsubscribe"] = 1 + global_language = get_anymail_setting( + "global_language", esp_name=self.esp_name, default=None + ) + if global_language: + self.data["global_language"] = global_language + + def init_payload(self) -> None: + self.data = {"headers": CaseInsensitiveDict()} # becomes json + self.set_global_settings_from_config() + def serialize_data(self) -> str: """Performs any necessary serialization on self.data, and returns the result.""" if self.generate_message_id: @@ -211,7 +295,7 @@ def set_merge_global_data(self, merge_global_data: dict[str, str]) -> None: } def set_anymail_id(self) -> None: - """Ensure each personalization has a known anymail_id for later event tracking""" + """Ensure each personalization has a known anymail_id for event tracking""" for recipient in self.data["recipients"]: anymail_id = str(uuid.uuid4()) @@ -246,6 +330,13 @@ def set_reply_to(self, emails: list[EmailAddress]) -> None: self.data["reply_to"] = emails[0].addr_spec def set_extra_headers(self, headers: dict[str, str]) -> None: + """ + Available service extra headers are: + - X-UNISENDER-GO-Global-Language + - X-UNISENDER-GO-Template-Engine + + Value in header has higher priority than in config. + """ self.data["headers"].update(headers) def set_text_body(self, body: str) -> None: @@ -263,10 +354,13 @@ def set_html_body(self, body: str) -> None: self.data["body"]["html"] = body def add_attachment(self, attachment: Attachment) -> None: + """Seek! Name must not have / in it, esp fails in this case.""" + if "/" in attachment.name: + raise AnymailConfigurationError("found '/' in attachment name") att = { "content": attachment.b64content, "type": attachment.mimetype, - "name": attachment.name or "", # required -- submit empty string if unknown + "name": attachment.name or "", # required - submit empty string if unknown } if attachment.inline: self.data.setdefault("inline_attachments", []).append(att) diff --git a/anymail/webhooks/unisender_go.py b/anymail/webhooks/unisender_go.py index fd1c2a03..3c0081c4 100644 --- a/anymail/webhooks/unisender_go.py +++ b/anymail/webhooks/unisender_go.py @@ -69,8 +69,9 @@ class UnisenderGoTrackingWebhookView(AnymailCoreWebhookView): """Handler for UniSender delivery and engagement tracking webhooks""" - esp_name = "UnisenderGo" + esp_name = "Unisender Go" signal = tracking + warn_if_no_basic_auth = False # because we validate against signature event_types = { "sent": EventType.SENT, @@ -120,7 +121,7 @@ def validate_request(self, request: HttpRequest) -> None: """ request_json = json.loads(request.body.decode("utf-8")) request_auth = request_json.get("auth", "") - request_json["auth"] = settings.ANYMAIL_UNISENDERGO_API_KEY + request_json["auth"] = settings.ANYMAIL_UNISENDER_GO_API_KEY json_with_key = json.dumps(request_json, separators=(",", ":")) expected_auth = md5(json_with_key.encode("utf-8")).hexdigest() @@ -166,7 +167,7 @@ def esp_to_anymail_event(self, esp_event: dict) -> AnymailTrackingEvent | None: mta_response=delivery_info.get("destination_response", ""), tags=None, metadata=metadata, - click_url=None, - user_agent=delivery_info.get("use_ragent", ""), + click_url=event_data.get("url"), + user_agent=delivery_info.get("user_agent", ""), esp_event=event_data, ) diff --git a/tests/test_unisender_go_payload.py b/tests/test_unisender_go_payload.py index 3afc9236..46e233bb 100644 --- a/tests/test_unisender_go_payload.py +++ b/tests/test_unisender_go_payload.py @@ -1,6 +1,6 @@ from __future__ import annotations -from django.test import SimpleTestCase, override_settings +from django.test import SimpleTestCase, override_settings, tag from anymail.backends.unisender_go import EmailBackend, UnisenderGoPayload from anymail.message import AnymailMessageMixin @@ -18,12 +18,10 @@ SUBSTITUTION_TWO = {"arg2": "arg2"} +@tag("unisender_go") +@override_settings(ANYMAIL_UNISENDER_GO_API_KEY=None, ANYMAIL_UNISENDER_GO_API_URL="") class TestUnisenderGoPayload(SimpleTestCase): - @override_settings( - ANYMAIL_UNISENDERGO_SKIP_UNSUBSCRIBE=False, - ANYMAIL_UNISENDERGO_API_KEY=None, - ANYMAIL_UNISENDERGO_API_URL="", - ) + @override_settings(ANYMAIL_UNISENDER_GO_SKIP_UNSUBSCRIBE=False) def test_unisender_go_payload__full(self): substitutions = {TO_EMAIL: SUBSTITUTION_ONE, OTHER_TO_EMAIL: SUBSTITUTION_TWO} email = AnymailMessageMixin( @@ -58,11 +56,7 @@ def test_unisender_go_payload__full(self): self.assertEqual(payload.data, expected_payload) - @override_settings( - ANYMAIL_UNISENDERGO_SKIP_UNSUBSCRIBE=False, - ANYMAIL_UNISENDERGO_API_KEY=None, - ANYMAIL_UNISENDERGO_API_URL="", - ) + @override_settings(ANYMAIL_UNISENDER_GO_SKIP_UNSUBSCRIBE=False) def test_unisender_go_payload__parse_from__with_name(self): email = AnymailMessageMixin( subject=SUBJECT, @@ -85,9 +79,7 @@ def test_unisender_go_payload__parse_from__with_name(self): self.assertEqual(payload.data, expected_payload) @override_settings( - ANYMAIL_UNISENDERGO_SKIP_UNSUBSCRIBE=False, - ANYMAIL_UNISENDERGO_API_KEY=None, - ANYMAIL_UNISENDERGO_API_URL="", + ANYMAIL_UNISENDER_GO_SKIP_UNSUBSCRIBE=False, ) def test_unisender_go_payload__parse_from__without_name(self): email = AnymailMessageMixin( @@ -111,16 +103,38 @@ def test_unisender_go_payload__parse_from__without_name(self): self.assertEqual(payload.data, expected_payload) @override_settings( - ANYMAIL_UNISENDERGO_SKIP_UNSUBSCRIBE=True, - ANYMAIL_UNISENDERGO_API_KEY=None, - ANYMAIL_UNISENDERGO_API_URL="", + ANYMAIL_UNISENDER_GO_SKIP_UNSUBSCRIBE=True, ) - def test_unisender_go_payload__parse_from__with_unsub(self): + def test_unisender_go_payload__parse_from__with_unsub__in_settings(self): + email = AnymailMessageMixin( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + ) + backend = EmailBackend() + + payload = UnisenderGoPayload(message=email, backend=backend, defaults={}) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {}, + "recipients": [{"email": TO_EMAIL, "substitutions": {"to_name": ""}}], + "subject": SUBJECT, + "skip_unsubscribe": 1, + } + + self.assertEqual(payload.data, expected_payload) + + @override_settings(ANYMAIL_UNISENDER_GO_SKIP_UNSUBSCRIBE=False) + def test_unisender_go_payload__parse_from__with_unsub__in_args(self): email = AnymailMessageMixin( subject=SUBJECT, merge_global_data=GLOBAL_DATA, from_email=f"{FROM_NAME} <{FROM_EMAIL}>", to=[TO_EMAIL], + esp_extra={"skip_unsubscribe": 1}, ) backend = EmailBackend() @@ -136,3 +150,83 @@ def test_unisender_go_payload__parse_from__with_unsub(self): } self.assertEqual(payload.data, expected_payload) + + @override_settings( + ANYMAIL_UNISENDER_GO_GLOBAL_LANGUAGE="en", + ) + def test_unisender_go_payload__parse_from__global_language__in_settings(self): + email = AnymailMessageMixin( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + ) + backend = EmailBackend() + + payload = UnisenderGoPayload(message=email, backend=backend, defaults={}) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {}, + "recipients": [{"email": TO_EMAIL, "substitutions": {"to_name": ""}}], + "subject": SUBJECT, + "global_language": "en", + } + + self.assertEqual(payload.data, expected_payload) + + @override_settings(ANYMAIL_UNISENDER_GO_GLOBAL_LANGUAGE="fr") + def test_unisender_go_payload__parse_from__global_language__in_args(self): + email = AnymailMessageMixin( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + esp_extra={"global_language": "en"}, + ) + backend = EmailBackend() + + payload = UnisenderGoPayload(message=email, backend=backend, defaults={}) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {}, + "recipients": [{"email": TO_EMAIL, "substitutions": {"to_name": ""}}], + "subject": SUBJECT, + "global_language": "en", + } + + self.assertEqual(payload.data, expected_payload) + + def test_unisender_go_payload__parse_from__bypass_esp_extra(self): + email = AnymailMessageMixin( + subject=SUBJECT, + merge_global_data=GLOBAL_DATA, + from_email=f"{FROM_NAME} <{FROM_EMAIL}>", + to=[TO_EMAIL], + esp_extra={ + "bypass_global": 1, + "bypass_unavailable": 1, + "bypass_unsubscribed": 1, + "bypass_complained": 1, + }, + ) + backend = EmailBackend() + + payload = UnisenderGoPayload(message=email, backend=backend, defaults={}) + expected_payload = { + "from_email": FROM_EMAIL, + "from_name": FROM_NAME, + "global_substitutions": GLOBAL_DATA, + "headers": {}, + "recipients": [{"email": TO_EMAIL, "substitutions": {"to_name": ""}}], + "subject": SUBJECT, + "bypass_global": 1, + "bypass_unavailable": 1, + "bypass_unsubscribed": 1, + "bypass_complained": 1, + } + + self.assertEqual(payload.data, expected_payload) diff --git a/tests/test_unisender_go_webhooks.py b/tests/test_unisender_go_webhooks.py index 2d3407a9..d3f1bd86 100644 --- a/tests/test_unisender_go_webhooks.py +++ b/tests/test_unisender_go_webhooks.py @@ -5,7 +5,7 @@ import uuid from datetime import timezone -from django.test import RequestFactory, SimpleTestCase, override_settings +from django.test import RequestFactory, SimpleTestCase, override_settings, tag from anymail.exceptions import AnymailWebhookValidationFailure from anymail.signals import EventType, RejectReason @@ -90,6 +90,7 @@ def _request_json_to_dict_with_hashed_key(request_json: bytes) -> dict[str, str] return {"auth": new_auth, "key": "value"} +@tag("unisender_go") class TestUnisenderGoWebhooks(SimpleTestCase): def test_sent_event(self): request = RequestFactory().post( @@ -125,7 +126,7 @@ def test_without_delivery_info(self): self.assertEqual(len(events), 1) - @override_settings(ANYMAIL_UNISENDERGO_API_KEY=TEST_API_KEY) + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY=TEST_API_KEY) def test_check_authorization(self): """Asserts that nothing is failing""" request_data = _request_json_to_dict_with_hashed_key( @@ -138,7 +139,7 @@ def test_check_authorization(self): view.validate_request(request) - @override_settings(ANYMAIL_UNISENDERGO_API_KEY=TEST_API_KEY) + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY=TEST_API_KEY) def test_check_authorization__fail__ordinar_quoters(self): request_json = b"{'auth':'api_key','key':'value'}" request_data = _request_json_to_dict_with_hashed_key(request_json) @@ -150,7 +151,7 @@ def test_check_authorization__fail__ordinar_quoters(self): with self.assertRaises(AnymailWebhookValidationFailure): view.validate_request(request) - @override_settings(ANYMAIL_UNISENDERGO_API_KEY=TEST_API_KEY) + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY=TEST_API_KEY) def test_check_authorization__fail__spaces_after_semicolon(self): request_json = b'{"auth": "api_key","key": "value"}' request_data = _request_json_to_dict_with_hashed_key(request_json) @@ -162,7 +163,7 @@ def test_check_authorization__fail__spaces_after_semicolon(self): with self.assertRaises(AnymailWebhookValidationFailure): view.validate_request(request) - @override_settings(ANYMAIL_UNISENDERGO_API_KEY=TEST_API_KEY) + @override_settings(ANYMAIL_UNISENDER_GO_API_KEY=TEST_API_KEY) def test_check_authorization__fail__spaces_after_comma(self): request_json = b'{"auth":"api_key", "key":"value"}' request_data = _request_json_to_dict_with_hashed_key(request_json)