Skip to content

Commit 823a161

Browse files
committed
Fix global SEND_DEFAULTS merging
Replace generic `combine` with specific `merge_dicts_deep`, `merge_dicts_shallow`, `merge_dicts_one_level` or `concat_lists`, depending on appropriate behavior for each message attribute. Fixes merging global `SEND_DEFAULTS` with message `esp_extra` for ESP APIs that use nested payload structures. And clarifies intent for other properties.
1 parent f911ee7 commit 823a161

File tree

6 files changed

+270
-38
lines changed

6 files changed

+270
-38
lines changed

CHANGELOG.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ vNext
3030

3131
*unreleased changes*
3232

33+
Fixes
34+
~~~~~
35+
36+
* Correctly merge global ``SEND_DEFAULTS`` with message ``esp_extra``
37+
for ESP APIs that use a nested structure (including Mandrill and SparkPost).
38+
Clarify intent of global defaults merging code for other message properties.
39+
(Thanks to `@mounirmesselmeni`_ for reporting the issue.)
40+
3341
Other
3442
~~~~~
3543

@@ -1571,6 +1579,7 @@ Features
15711579
.. _@mark-mishyn: https://github.com/mark-mishyn
15721580
.. _@martinezleoml: https://github.com/martinezleoml
15731581
.. _@mbk-ok: https://github.com/mbk-ok
1582+
.. _@mounirmesselmeni: https://github.com/mounirmesselmeni
15741583
.. _@mwheels: https://github.com/mwheels
15751584
.. _@nuschk: https://github.com/nuschk
15761585
.. _@puru02: https://github.com/puru02

anymail/backends/base.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@
1818
from ..utils import (
1919
UNSET,
2020
Attachment,
21-
combine,
21+
concat_lists,
2222
force_non_lazy,
2323
force_non_lazy_dict,
2424
force_non_lazy_list,
2525
get_anymail_setting,
2626
is_lazy,
2727
last,
28+
merge_dicts_deep,
29+
merge_dicts_one_level,
30+
merge_dicts_shallow,
2831
parse_address_list,
2932
parse_single_address,
3033
)
@@ -253,8 +256,7 @@ class BasePayload:
253256
# attr: the property name
254257
# combiner: optional function(default_value, value) -> value
255258
# to combine settings defaults with the EmailMessage property value
256-
# (usually `combine` to merge, or `last` for message value to override default;
257-
# use `None` if settings defaults aren't supported)
259+
# (use `None` if settings defaults aren't supported)
258260
# converter: optional function(value) -> value transformation
259261
# (can be a callable or the string name of a Payload method, or `None`)
260262
# The converter must force any Django lazy translation strings to text.
@@ -263,29 +265,29 @@ class BasePayload:
263265
base_message_attrs = (
264266
# Standard EmailMessage/EmailMultiAlternatives props
265267
("from_email", last, parse_address_list), # multiple from_emails are allowed
266-
("to", combine, parse_address_list),
267-
("cc", combine, parse_address_list),
268-
("bcc", combine, parse_address_list),
268+
("to", concat_lists, parse_address_list),
269+
("cc", concat_lists, parse_address_list),
270+
("bcc", concat_lists, parse_address_list),
269271
("subject", last, force_non_lazy),
270-
("reply_to", combine, parse_address_list),
271-
("extra_headers", combine, force_non_lazy_dict),
272+
("reply_to", concat_lists, parse_address_list),
273+
("extra_headers", merge_dicts_shallow, force_non_lazy_dict),
272274
("body", last, force_non_lazy), # set_body handles content_subtype
273-
("alternatives", combine, "prepped_alternatives"),
274-
("attachments", combine, "prepped_attachments"),
275+
("alternatives", concat_lists, "prepped_alternatives"),
276+
("attachments", concat_lists, "prepped_attachments"),
275277
)
276278
anymail_message_attrs = (
277279
# Anymail expando-props
278280
("envelope_sender", last, parse_single_address),
279-
("metadata", combine, force_non_lazy_dict),
281+
("metadata", merge_dicts_shallow, force_non_lazy_dict),
280282
("send_at", last, "aware_datetime"),
281-
("tags", combine, force_non_lazy_list),
283+
("tags", concat_lists, force_non_lazy_list),
282284
("track_clicks", last, None),
283285
("track_opens", last, None),
284286
("template_id", last, force_non_lazy),
285-
("merge_data", combine, force_non_lazy_dict),
286-
("merge_global_data", combine, force_non_lazy_dict),
287-
("merge_metadata", combine, force_non_lazy_dict),
288-
("esp_extra", combine, force_non_lazy_dict),
287+
("merge_data", merge_dicts_one_level, force_non_lazy_dict),
288+
("merge_global_data", merge_dicts_shallow, force_non_lazy_dict),
289+
("merge_metadata", merge_dicts_one_level, force_non_lazy_dict),
290+
("esp_extra", merge_dicts_deep, force_non_lazy_dict),
289291
)
290292
esp_message_attrs = () # subclasses can override
291293

anymail/utils.py

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import mimetypes
33
from base64 import b64encode
44
from collections.abc import Mapping, MutableMapping
5+
from copy import copy, deepcopy
56
from email.mime.base import MIMEBase
67
from email.utils import formatdate, getaddresses, parsedate_to_datetime, unquote
78
from urllib.parse import urlsplit, urlunsplit
@@ -20,17 +21,44 @@
2021
UNSET = type("UNSET", (object,), {}) # Used as non-None default value
2122

2223

23-
def combine(*args):
24+
def concat_lists(*args):
2425
"""
25-
Combines all non-UNSET args, by shallow merging mappings and concatenating sequences
26+
Combines all non-UNSET args, by concatenating lists (or sequence-like types).
27+
Does not modify any args.
2628
27-
>>> combine({'a': 1, 'b': 2}, UNSET, {'b': 3, 'c': 4}, UNSET)
28-
{'a': 1, 'b': 3, 'c': 4}
29-
>>> combine([1, 2], UNSET, [3, 4], UNSET)
29+
>>> concat_lists([1, 2], UNSET, [3, 4], UNSET)
3030
[1, 2, 3, 4]
31-
>>> combine({'a': 1}, None, {'b': 2}) # None suppresses earlier args
31+
>>> concat_lists([1, 2], None, [3, 4]) # None suppresses earlier args
32+
[3, 4]
33+
>>> concat_lists()
34+
UNSET
35+
36+
"""
37+
result = UNSET
38+
for value in args:
39+
if value is None:
40+
# None is a request to suppress any earlier values
41+
result = UNSET
42+
elif value is not UNSET:
43+
if result is UNSET:
44+
result = list(value)
45+
else:
46+
result = result + list(value) # concatenate sequence-like
47+
return result
48+
49+
50+
def merge_dicts_shallow(*args):
51+
"""
52+
Shallow-merges all non-UNSET args.
53+
Does not modify any args.
54+
55+
>>> merge_dicts_shallow({'a': 1, 'b': 2}, UNSET, {'b': 3, 'c': 4}, UNSET)
56+
{'a': 1, 'b': 3, 'c': 4}
57+
>>> merge_dicts_shallow({'a': {'a1': 1, 'a2': 2}}, {'a': {'a1': 11, 'a3': 33}})
58+
{'a': {'a1': 11, 'a3': 33}}
59+
>>> merge_dicts_shallow({'a': 1}, None, {'b': 2}) # None suppresses earlier args
3260
{'b': 2}
33-
>>> combine()
61+
>>> merge_dicts_shallow()
3462
UNSET
3563
3664
"""
@@ -41,23 +69,65 @@ def combine(*args):
4169
result = UNSET
4270
elif value is not UNSET:
4371
if result is UNSET:
44-
try:
45-
result = value.copy() # will shallow merge if dict-like
46-
except AttributeError:
47-
result = value # will concatenate if sequence-like
72+
result = copy(value)
4873
else:
49-
try:
50-
result.update(value) # shallow merge if dict-like
51-
except AttributeError:
52-
result = result + value # concatenate if sequence-like
74+
result.update(value)
75+
return result
76+
77+
78+
def merge_dicts_deep(*args):
79+
"""
80+
Deep-merges all non-UNSET args.
81+
Does not modify any args.
82+
83+
>>> merge_dicts_deep({'a': 1, 'b': 2}, UNSET, {'b': 3, 'c': 4}, UNSET)
84+
{'a': 1, 'b': 3, 'c': 4}
85+
>>> merge_dicts_deep({'a': {'a1': 1, 'a2': 2}}, {'a': {'a1': 11, 'a3': 33}})
86+
{'a': {'a1': 11, 'a2': 2, 'a3': 33}}
87+
>>> merge_dicts_deep({'a': 1}, None, {'b': 2}) # None suppresses earlier args
88+
{'b': 2}
89+
>>> merge_dicts_deep()
90+
UNSET
91+
92+
"""
93+
result = UNSET
94+
for value in args:
95+
if value is None:
96+
# None is a request to suppress any earlier values
97+
result = UNSET
98+
elif value is not UNSET:
99+
if result is UNSET:
100+
result = deepcopy(value)
101+
else:
102+
update_deep(result, value)
103+
return result
104+
105+
106+
def merge_dicts_one_level(*args):
107+
"""
108+
Mixture of merge_dicts_deep and merge_dicts_shallow:
109+
Deep merges first level, shallow merges second level.
110+
Does not modify any args.
111+
112+
(Useful for {"email": {options...}, ...} style dicts,
113+
like merge_data: shallow merges the options for each email.)
114+
"""
115+
result = UNSET
116+
for value in args:
117+
if value is None:
118+
# None is a request to suppress any earlier values
119+
result = UNSET
120+
elif value is not UNSET:
121+
if result is UNSET:
122+
result = {}
123+
for k, v in value.items():
124+
result.setdefault(k, {}).update(v)
53125
return result
54126

55127

56128
def last(*args):
57129
"""Returns the last of its args which is not UNSET.
58130
59-
(Essentially `combine` without the merge behavior)
60-
61131
>>> last(1, 2, UNSET, 3, UNSET, UNSET)
62132
3
63133
>>> last(1, 2, None, UNSET) # None suppresses earlier args

anymail/webhooks/mailgun.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
)
2222
from ..utils import (
2323
UNSET,
24-
combine,
2524
get_anymail_setting,
25+
merge_dicts_shallow,
2626
parse_single_address,
2727
querydict_getfirst,
2828
)
@@ -341,7 +341,9 @@ def _extract_legacy_metadata(self, esp_event):
341341
if len(variables) >= 1:
342342
# Each X-Mailgun-Variables value is JSON. Parse and merge them all into
343343
# single dict:
344-
metadata = combine(*[json.loads(value) for value in variables])
344+
metadata = merge_dicts_shallow(
345+
*[json.loads(value) for value in variables]
346+
)
345347

346348
elif event_type in self._known_legacy_event_fields:
347349
# For other events, we must extract from the POST fields, ignoring known

tests/test_general_backend.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,10 @@ def test_esp_send_defaults(self):
209209
"tags": ["globaltag"],
210210
"track_clicks": True,
211211
"track_opens": False,
212-
"esp_extra": {"globalextra": "globalsetting"},
212+
"esp_extra": {
213+
"globalextra": "globalsetting",
214+
"deepextra": {"deep1": "globaldeep1", "deep2": "globaldeep2"},
215+
},
213216
}
214217
}
215218
)
@@ -218,7 +221,10 @@ def test_send_defaults_combine_with_message(self):
218221
self.message.metadata = {"message": "messagevalue", "other": "override"}
219222
self.message.tags = ["messagetag"]
220223
self.message.track_clicks = False
221-
self.message.esp_extra = {"messageextra": "messagesetting"}
224+
self.message.esp_extra = {
225+
"messageextra": "messagesetting",
226+
"deepextra": {"deep2": "messagedeep2", "deep3": "messagedeep3"},
227+
}
222228

223229
self.message.send()
224230
params = self.get_send_params()
@@ -234,8 +240,13 @@ def test_send_defaults_combine_with_message(self):
234240
self.assertEqual(params["tags"], ["globaltag", "messagetag"])
235241
self.assertEqual(params["track_clicks"], False) # message overrides
236242
self.assertEqual(params["track_opens"], False) # (no message setting)
243+
# esp_extra is deep merged:
237244
self.assertEqual(params["globalextra"], "globalsetting")
238245
self.assertEqual(params["messageextra"], "messagesetting")
246+
self.assertEqual(
247+
params["deepextra"],
248+
{"deep1": "globaldeep1", "deep2": "messagedeep2", "deep3": "messagedeep3"},
249+
)
239250

240251
# Send another message to make sure original SEND_DEFAULTS unchanged
241252
send_mail("subject", "body", "from@example.com", ["to@example.com"])
@@ -247,6 +258,9 @@ def test_send_defaults_combine_with_message(self):
247258
self.assertEqual(params["track_clicks"], True)
248259
self.assertEqual(params["track_opens"], False)
249260
self.assertEqual(params["globalextra"], "globalsetting")
261+
self.assertEqual(
262+
params["deepextra"], {"deep1": "globaldeep1", "deep2": "globaldeep2"}
263+
)
250264

251265
@override_settings(
252266
ANYMAIL={

0 commit comments

Comments
 (0)