diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py index 33f8d8fd..21c17fce 100644 --- a/anymail/backends/sparkpost.py +++ b/anymail/backends/sparkpost.py @@ -242,6 +242,36 @@ def set_merge_metadata(self, merge_metadata): if to_email in merge_metadata: recipient["metadata"] = merge_metadata[to_email] + def set_merge_headers(self, merge_headers): + def header_var(field): + return "Header__" + field.title().replace("-", "_") + + merge_header_fields = set() + + for recipient in self.data["recipients"]: + to_email = recipient["address"]["email"] + if to_email in merge_headers: + recipient_headers = merge_headers[to_email] + recipient.setdefault("substitution_data", {}).update( + {header_var(key): value for key, value in recipient_headers.items()} + ) + merge_header_fields.update(recipient_headers.keys()) + + if merge_header_fields: + headers = self.data.setdefault("content", {}).setdefault("headers", {}) + # Global substitution_data supplies defaults for defined headers: + self.data.setdefault("substitution_data", {}).update( + { + header_var(field): headers[field] + for field in merge_header_fields + if field in headers + } + ) + # Indirect merge_headers through substitution_data: + headers.update( + {field: "{{%s}}" % header_var(field) for field in merge_header_fields} + ) + def set_send_at(self, send_at): try: start_time = send_at.replace(microsecond=0).isoformat() diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index 25589b15..29d8e7a3 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -605,6 +605,58 @@ def test_merge_metadata(self): ) self.assertEqual(data["metadata"], {"notification_batch": "zx912"}) + def test_merge_headers(self): + self.set_mock_result(accepted=2) + self.message.to = ["alice@example.com", "Bob "] + self.message.extra_headers = { + "X-Custom-1": "custom 1", + "X-Custom-2": "custom 2 (default)", + } + self.message.merge_headers = { + "alice@example.com": { + "X-Custom-2": "custom 2 alice", + "X-Custom-3": "custom 3 alice", + }, + "bob@example.com": {"X-Custom-2": "custom 2 bob"}, + } + + self.message.send() + data = self.get_api_call_json() + recipients = data["recipients"] + self.assertEqual(len(recipients), 2) + self.assertEqual(recipients[0]["address"]["email"], "alice@example.com") + self.assertEqual( + recipients[0]["substitution_data"], + { + "Header__X_Custom_2": "custom 2 alice", + "Header__X_Custom_3": "custom 3 alice", + }, + ) + self.assertEqual(recipients[1]["address"]["email"], "bob@example.com") + self.assertEqual( + recipients[1]["substitution_data"], + { + "Header__X_Custom_2": "custom 2 bob", + }, + ) + # Indirect merge_headers through template substitutions: + self.assertEqual( + data["content"]["headers"], + { + "X-Custom-1": "custom 1", # (not a merge_header, value unchanged) + "X-Custom-2": "{{Header__X_Custom_2}}", + "X-Custom-3": "{{Header__X_Custom_3}}", + }, + ) + # Defaults for merge_headers in global substitution_data: + self.assertEqual( + data["substitution_data"], + { + "Header__X_Custom_2": "custom 2 (default)", + # No default specified for X-Custom-3; SparkPost will use empty string + }, + ) + def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. diff --git a/tests/test_sparkpost_integration.py b/tests/test_sparkpost_integration.py index ba0edd97..b24a201c 100644 --- a/tests/test_sparkpost_integration.py +++ b/tests/test_sparkpost_integration.py @@ -116,6 +116,19 @@ def test_merge_data(self): "to2@test.sink.sparkpostmail.com": {"value": "two"}, }, merge_global_data={"global": "global_value"}, + merge_metadata={ + "to1@test.sink.sparkpostmail.com": {"meta1": "one"}, + "to2@test.sink.sparkpostmail.com": {"meta1": "two"}, + }, + headers={ + "X-Custom": "custom header default", + }, + merge_headers={ + # (Note that SparkPost doesn't support custom List-Unsubscribe headers) + "to1@test.sink.sparkpostmail.com": { + "X-Custom": "custom header one", + }, + }, ) message.send() recipient_status = message.anymail_status.recipients