mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 11:51:05 -05:00
Add merge_metadata for other ESPs
Support merge_metadata in Mailgun, Mailjet, Mandrill, Postmark, SparkPost, and Test backends. (SendGrid covered in earlier PR.) Also: * Add `merge_metadata` to AnymailMessage, AnymailMessageMixin * Add `is_batch()` logic to BasePayload, for consistent handling * Docs Note: Mailjet implementation switches *all* batch sending from their "Recipients" field to to the "Messages" array bulk sending option. This allows an independent payload for each batch recipient. In addition to supporting merge_metadata, this also removes the prior limitation on mixing Cc/Bcc with merge_data. Closes #141.
This commit is contained in:
@@ -250,11 +250,16 @@ class BasePayload(object):
|
||||
)
|
||||
esp_message_attrs = () # subclasses can override
|
||||
|
||||
# If any of these attrs are set on a message, treat the message
|
||||
# as a batch send (separate message for each `to` recipient):
|
||||
batch_attrs = ('merge_data', 'merge_metadata')
|
||||
|
||||
def __init__(self, message, defaults, backend):
|
||||
self.message = message
|
||||
self.defaults = defaults
|
||||
self.backend = backend
|
||||
self.esp_name = backend.esp_name
|
||||
self._batch_attrs_used = {attr: UNSET for attr in self.batch_attrs}
|
||||
|
||||
self.init_payload()
|
||||
|
||||
@@ -287,6 +292,20 @@ class BasePayload(object):
|
||||
# AttributeError here? Your Payload subclass is missing a set_<attr> implementation
|
||||
setter = getattr(self, 'set_%s' % attr)
|
||||
setter(value)
|
||||
if attr in self.batch_attrs:
|
||||
self._batch_attrs_used[attr] = (value is not UNSET)
|
||||
|
||||
def is_batch(self):
|
||||
"""
|
||||
Return True if the message should be treated as a batch send.
|
||||
|
||||
Intended to be used inside serialize_data or similar, after all relevant
|
||||
attributes have been processed. Will error if called before that (e.g.,
|
||||
inside a set_<attr> method or during __init__).
|
||||
"""
|
||||
batch_attrs_used = self._batch_attrs_used.values()
|
||||
assert UNSET not in batch_attrs_used, "Cannot call is_batch before all attributes processed"
|
||||
return any(batch_attrs_used)
|
||||
|
||||
def unsupported_feature(self, feature):
|
||||
if not self.backend.ignore_unsupported_features:
|
||||
|
||||
@@ -68,8 +68,10 @@ class MailgunPayload(RequestsPayload):
|
||||
self.all_recipients = [] # used for backend.parse_recipient_status
|
||||
|
||||
# late-binding of recipient-variables:
|
||||
self.merge_data = None
|
||||
self.merge_global_data = None
|
||||
self.merge_data = {}
|
||||
self.merge_global_data = {}
|
||||
self.metadata = {}
|
||||
self.merge_metadata = {}
|
||||
self.to_emails = []
|
||||
|
||||
super(MailgunPayload, self).__init__(message, defaults, backend, auth=auth, *args, **kwargs)
|
||||
@@ -117,32 +119,51 @@ class MailgunPayload(RequestsPayload):
|
||||
return params
|
||||
|
||||
def serialize_data(self):
|
||||
self.populate_recipient_variables()
|
||||
if self.is_batch() or self.merge_global_data:
|
||||
self.populate_recipient_variables()
|
||||
return self.data
|
||||
|
||||
def populate_recipient_variables(self):
|
||||
"""Populate Mailgun recipient-variables header from merge data"""
|
||||
merge_data = self.merge_data
|
||||
"""Populate Mailgun recipient-variables from merge data and metadata"""
|
||||
merge_metadata_keys = set() # all keys used in any recipient's merge_metadata
|
||||
for recipient_metadata in self.merge_metadata.values():
|
||||
merge_metadata_keys.update(recipient_metadata.keys())
|
||||
metadata_vars = {key: "v:%s" % key for key in merge_metadata_keys} # custom-var for key
|
||||
|
||||
if self.merge_global_data is not None:
|
||||
# Mailgun doesn't support global variables.
|
||||
# We emulate them by populating recipient-variables for all recipients.
|
||||
if merge_data is not None:
|
||||
merge_data = merge_data.copy() # don't modify the original, which doesn't belong to us
|
||||
else:
|
||||
merge_data = {}
|
||||
for email in self.to_emails:
|
||||
try:
|
||||
recipient_data = merge_data[email]
|
||||
except KeyError:
|
||||
merge_data[email] = self.merge_global_data
|
||||
else:
|
||||
# Merge globals (recipient_data wins in conflict)
|
||||
merge_data[email] = self.merge_global_data.copy()
|
||||
merge_data[email].update(recipient_data)
|
||||
# Set up custom-var substitutions for merge metadata
|
||||
# data['v:SomeMergeMetadataKey'] = '%recipient.v:SomeMergeMetadataKey%'
|
||||
for var in metadata_vars.values():
|
||||
self.data[var] = "%recipient.{var}%".format(var=var)
|
||||
|
||||
if merge_data is not None:
|
||||
self.data['recipient-variables'] = self.serialize_json(merge_data)
|
||||
# Any (toplevel) metadata that is also in (any) merge_metadata must be be moved
|
||||
# into recipient-variables; and all merge_metadata vars must have defaults
|
||||
# (else they'll get the '%recipient.v:SomeMergeMetadataKey%' literal string).
|
||||
base_metadata = {metadata_vars[key]: self.metadata.get(key, '')
|
||||
for key in merge_metadata_keys}
|
||||
|
||||
recipient_vars = {}
|
||||
for addr in self.to_emails:
|
||||
# For each recipient, Mailgun recipient-variables[addr] is merger of:
|
||||
# 1. metadata, for any keys that appear in merge_metadata
|
||||
recipient_data = base_metadata.copy()
|
||||
|
||||
# 2. merge_metadata[addr], with keys prefixed with 'v:'
|
||||
if addr in self.merge_metadata:
|
||||
recipient_data.update({
|
||||
metadata_vars[key]: value for key, value in self.merge_metadata[addr].items()
|
||||
})
|
||||
|
||||
# 3. merge_global_data (because Mailgun doesn't support global variables)
|
||||
recipient_data.update(self.merge_global_data)
|
||||
|
||||
# 4. merge_data[addr]
|
||||
if addr in self.merge_data:
|
||||
recipient_data.update(self.merge_data[addr])
|
||||
|
||||
if recipient_data:
|
||||
recipient_vars[addr] = recipient_data
|
||||
|
||||
self.data['recipient-variables'] = self.serialize_json(recipient_vars)
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
@@ -210,6 +231,7 @@ class MailgunPayload(RequestsPayload):
|
||||
self.sender_domain = email.domain
|
||||
|
||||
def set_metadata(self, metadata):
|
||||
self.metadata = metadata # save for handling merge_metadata later
|
||||
for key, value in metadata.items():
|
||||
self.data["v:%s" % key] = value
|
||||
|
||||
@@ -242,6 +264,10 @@ class MailgunPayload(RequestsPayload):
|
||||
# Processed at serialization time (to allow merging global data)
|
||||
self.merge_global_data = merge_global_data
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
# Processed at serialization time (to allow combining with merge_data)
|
||||
self.merge_metadata = merge_metadata
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
self.data.update(extra)
|
||||
# Allow override of sender_domain via esp_extra
|
||||
|
||||
@@ -80,8 +80,10 @@ class MailjetPayload(RequestsPayload):
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
# Late binding of recipients and their variables
|
||||
self.recipients = {}
|
||||
self.merge_data = None
|
||||
self.recipients = {'to': []}
|
||||
self.metadata = None
|
||||
self.merge_data = {}
|
||||
self.merge_metadata = {}
|
||||
super(MailjetPayload, self).__init__(message, defaults, backend,
|
||||
auth=auth, headers=http_headers, *args, **kwargs)
|
||||
|
||||
@@ -89,22 +91,37 @@ class MailjetPayload(RequestsPayload):
|
||||
return "send"
|
||||
|
||||
def serialize_data(self):
|
||||
self._finish_recipients()
|
||||
self._populate_sender_from_template()
|
||||
if self.is_batch():
|
||||
self.data = {'Messages': [
|
||||
self._data_for_recipient(to_addr)
|
||||
for to_addr in self.recipients['to']
|
||||
]}
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
#
|
||||
# Payload construction
|
||||
#
|
||||
def _data_for_recipient(self, email):
|
||||
# Return send data for single recipient, without modifying self.data
|
||||
data = self.data.copy()
|
||||
data['To'] = self._format_email_for_mailjet(email)
|
||||
|
||||
def _finish_recipients(self):
|
||||
# NOTE do not set both To and Recipients, it behaves specially: each
|
||||
# recipient receives a separate mail but the To address receives one
|
||||
# listing all recipients.
|
||||
if "cc" in self.recipients or "bcc" in self.recipients:
|
||||
self._finish_recipients_single()
|
||||
else:
|
||||
self._finish_recipients_with_vars()
|
||||
if email.addr_spec in self.merge_data:
|
||||
recipient_merge_data = self.merge_data[email.addr_spec]
|
||||
if 'Vars' in data:
|
||||
data['Vars'] = data['Vars'].copy() # clone merge_global_data
|
||||
data['Vars'].update(recipient_merge_data)
|
||||
else:
|
||||
data['Vars'] = recipient_merge_data
|
||||
|
||||
if email.addr_spec in self.merge_metadata:
|
||||
recipient_metadata = self.merge_metadata[email.addr_spec]
|
||||
if self.metadata:
|
||||
metadata = self.metadata.copy() # clone toplevel metadata
|
||||
metadata.update(recipient_metadata)
|
||||
else:
|
||||
metadata = recipient_metadata
|
||||
data["Mj-EventPayLoad"] = self.serialize_json(metadata)
|
||||
|
||||
return data
|
||||
|
||||
def _populate_sender_from_template(self):
|
||||
# If no From address was given, use the address from the template.
|
||||
@@ -137,42 +154,21 @@ class MailjetPayload(RequestsPayload):
|
||||
email_message=self.message, response=response, backend=self.backend)
|
||||
self.set_from_email(parsed)
|
||||
|
||||
def _finish_recipients_with_vars(self):
|
||||
"""Send bulk mail with different variables for each mail."""
|
||||
assert "Cc" not in self.data and "Bcc" not in self.data
|
||||
recipients = []
|
||||
merge_data = self.merge_data or {}
|
||||
for email in self.recipients["to"]:
|
||||
recipient = {
|
||||
"Email": email.addr_spec,
|
||||
"Name": email.display_name,
|
||||
"Vars": merge_data.get(email.addr_spec)
|
||||
}
|
||||
# Strip out empty Name and Vars
|
||||
recipient = {k: v for k, v in recipient.items() if v}
|
||||
recipients.append(recipient)
|
||||
self.data["Recipients"] = recipients
|
||||
def _format_email_for_mailjet(self, email):
|
||||
"""Return EmailAddress email converted to a string that Mailjet can parse properly"""
|
||||
# Workaround Mailjet 3.0 bug parsing display-name with commas
|
||||
# (see test_comma_in_display_name in test_mailjet_backend for details)
|
||||
if "," in email.display_name:
|
||||
return EmailAddress(email.display_name.encode('utf-8'), email.addr_spec).formataddr('utf-8')
|
||||
else:
|
||||
return email.address
|
||||
|
||||
def _finish_recipients_single(self):
|
||||
"""Send a single mail with some To, Cc and Bcc headers."""
|
||||
assert "Recipients" not in self.data
|
||||
if self.merge_data:
|
||||
# When Cc and Bcc headers are given, then merge data cannot be set.
|
||||
raise NotImplementedError("Cannot set merge data with bcc/cc")
|
||||
for recipient_type, emails in self.recipients.items():
|
||||
# Workaround Mailjet 3.0 bug parsing display-name with commas
|
||||
# (see test_comma_in_display_name in test_mailjet_backend for details)
|
||||
formatted_emails = [
|
||||
email.address if "," not in email.display_name
|
||||
# else name has a comma, so force it into MIME encoded-word utf-8 syntax:
|
||||
else EmailAddress(email.display_name.encode('utf-8'), email.addr_spec).formataddr('utf-8')
|
||||
for email in emails
|
||||
]
|
||||
self.data[recipient_type.capitalize()] = ", ".join(formatted_emails)
|
||||
#
|
||||
# Payload construction
|
||||
#
|
||||
|
||||
def init_payload(self):
|
||||
self.data = {
|
||||
}
|
||||
self.data = {}
|
||||
|
||||
def set_from_email(self, email):
|
||||
self.data["FromEmail"] = email.addr_spec
|
||||
@@ -181,9 +177,10 @@ class MailjetPayload(RequestsPayload):
|
||||
|
||||
def set_recipients(self, recipient_type, emails):
|
||||
assert recipient_type in ["to", "cc", "bcc"]
|
||||
# Will be handled later in serialize_data
|
||||
if emails:
|
||||
self.recipients[recipient_type] = emails
|
||||
self.recipients[recipient_type] = emails # save for recipient_status processing
|
||||
self.data[recipient_type.capitalize()] = ", ".join(
|
||||
[self._format_email_for_mailjet(email) for email in emails])
|
||||
|
||||
def set_subject(self, subject):
|
||||
self.data["Subject"] = subject
|
||||
@@ -225,8 +222,8 @@ class MailjetPayload(RequestsPayload):
|
||||
self.data["Sender"] = email.addr_spec # ??? v3 docs unclear
|
||||
|
||||
def set_metadata(self, metadata):
|
||||
# Mailjet expects a single string payload
|
||||
self.data["Mj-EventPayLoad"] = self.serialize_json(metadata)
|
||||
self.metadata = metadata # keep original in case we need to merge with merge_metadata
|
||||
|
||||
def set_tags(self, tags):
|
||||
# The choices here are CustomID or Campaign, and Campaign seems closer
|
||||
@@ -257,5 +254,9 @@ class MailjetPayload(RequestsPayload):
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.data["Vars"] = merge_global_data
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
# Will be handled later in serialize_data
|
||||
self.merge_metadata = merge_metadata
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
self.data.update(extra)
|
||||
|
||||
@@ -79,6 +79,9 @@ class MandrillPayload(RequestsPayload):
|
||||
|
||||
def serialize_data(self):
|
||||
self.process_esp_extra()
|
||||
if self.is_batch():
|
||||
# hide recipients from each other
|
||||
self.data['message']['preserve_recipients'] = False
|
||||
return self.serialize_json(self.data)
|
||||
|
||||
#
|
||||
@@ -163,7 +166,6 @@ class MandrillPayload(RequestsPayload):
|
||||
self.data.setdefault("template_content", []) # Mandrill requires something here
|
||||
|
||||
def set_merge_data(self, merge_data):
|
||||
self.data['message']['preserve_recipients'] = False # if merge, hide recipients from each other
|
||||
self.data['message']['merge_vars'] = [
|
||||
{'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]}
|
||||
for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility
|
||||
@@ -176,6 +178,13 @@ class MandrillPayload(RequestsPayload):
|
||||
for var, value in merge_global_data.items()
|
||||
]
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
# recipient_metadata format is similar to, but not quite the same as, merge_vars:
|
||||
self.data['message']['recipient_metadata'] = [
|
||||
{'rcpt': rcpt, 'values': rcpt_data}
|
||||
for rcpt, rcpt_data in merge_metadata.items()
|
||||
]
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
# late bind in serialize_data, so that obsolete Djrill attrs can contribute
|
||||
self.esp_extra = extra
|
||||
|
||||
@@ -156,10 +156,11 @@ class PostmarkPayload(RequestsPayload):
|
||||
self.to_emails = []
|
||||
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
|
||||
self.merge_data = None
|
||||
self.merge_metadata = None
|
||||
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
||||
|
||||
def get_api_endpoint(self):
|
||||
batch_send = self.merge_data is not None and len(self.to_emails) > 1
|
||||
batch_send = self.is_batch() and len(self.to_emails) > 1
|
||||
if 'TemplateAlias' in self.data or 'TemplateId' in self.data or 'TemplateModel' in self.data:
|
||||
if batch_send:
|
||||
return "email/batchWithTemplates"
|
||||
@@ -197,6 +198,14 @@ class PostmarkPayload(RequestsPayload):
|
||||
data["TemplateModel"].update(recipient_data)
|
||||
else:
|
||||
data["TemplateModel"] = recipient_data
|
||||
if self.merge_metadata and to.addr_spec in self.merge_metadata:
|
||||
recipient_metadata = self.merge_metadata[to.addr_spec]
|
||||
if "Metadata" in data:
|
||||
# merge recipient_metadata into toplevel metadata
|
||||
data["Metadata"] = data["Metadata"].copy()
|
||||
data["Metadata"].update(recipient_metadata)
|
||||
else:
|
||||
data["Metadata"] = recipient_metadata
|
||||
return data
|
||||
|
||||
#
|
||||
@@ -298,6 +307,10 @@ class PostmarkPayload(RequestsPayload):
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.data["TemplateModel"] = merge_global_data
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
# late-bind
|
||||
self.merge_metadata = merge_metadata
|
||||
|
||||
def set_esp_extra(self, extra):
|
||||
self.data.update(extra)
|
||||
# Special handling for 'server_token':
|
||||
|
||||
@@ -95,11 +95,12 @@ class SparkPostPayload(BasePayload):
|
||||
self.all_recipients = []
|
||||
self.to_emails = []
|
||||
self.merge_data = {}
|
||||
self.merge_metadata = {}
|
||||
|
||||
def get_api_params(self):
|
||||
# Compose recipients param from to_emails and merge_data (if any)
|
||||
recipients = []
|
||||
if len(self.merge_data) > 0:
|
||||
if self.is_batch():
|
||||
# Build JSON recipient structures
|
||||
for email in self.to_emails:
|
||||
rcpt = {'address': {'email': email.addr_spec}}
|
||||
@@ -109,6 +110,10 @@ class SparkPostPayload(BasePayload):
|
||||
rcpt['substitution_data'] = self.merge_data[email.addr_spec]
|
||||
except KeyError:
|
||||
pass # no merge_data or none for this recipient
|
||||
try:
|
||||
rcpt['metadata'] = self.merge_metadata[email.addr_spec]
|
||||
except KeyError:
|
||||
pass # no merge_metadata or none for this recipient
|
||||
recipients.append(rcpt)
|
||||
else:
|
||||
# Just use simple recipients list
|
||||
@@ -213,6 +218,9 @@ class SparkPostPayload(BasePayload):
|
||||
def set_merge_data(self, merge_data):
|
||||
self.merge_data = merge_data # merged into params['recipients'] in get_api_params
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
self.merge_metadata = merge_metadata # merged into params['recipients'] in get_api_params
|
||||
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.params['substitution_data'] = merge_global_data
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ class EmailBackend(AnymailBaseBackend):
|
||||
esp_name = "Test"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Allow replacing the payload, for testing.
|
||||
# (Real backends would generally not implement this option.)
|
||||
self._payload_class = kwargs.pop('payload_class', TestPayload)
|
||||
super(EmailBackend, self).__init__(*args, **kwargs)
|
||||
if not hasattr(mail, 'outbox'):
|
||||
mail.outbox = [] # see django.core.mail.backends.locmem
|
||||
@@ -32,7 +35,7 @@ class EmailBackend(AnymailBaseBackend):
|
||||
return mail.outbox.index(message)
|
||||
|
||||
def build_message_payload(self, message, defaults):
|
||||
return TestPayload(backend=self, message=message, defaults=defaults)
|
||||
return self._payload_class(backend=self, message=message, defaults=defaults)
|
||||
|
||||
def post_to_esp(self, payload, message):
|
||||
# Keep track of the sent messages and params (for test cases)
|
||||
@@ -130,6 +133,9 @@ class TestPayload(BasePayload):
|
||||
def set_merge_data(self, merge_data):
|
||||
self.params['merge_data'] = merge_data
|
||||
|
||||
def set_merge_metadata(self, merge_metadata):
|
||||
self.params['merge_metadata'] = merge_metadata
|
||||
|
||||
def set_merge_global_data(self, merge_global_data):
|
||||
self.params['merge_global_data'] = merge_global_data
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class AnymailMessageMixin(object):
|
||||
self.template_id = kwargs.pop('template_id', UNSET)
|
||||
self.merge_data = kwargs.pop('merge_data', UNSET)
|
||||
self.merge_global_data = kwargs.pop('merge_global_data', UNSET)
|
||||
self.merge_metadata = kwargs.pop('merge_metadata', UNSET)
|
||||
self.anymail_status = AnymailStatus()
|
||||
|
||||
# noinspection PyArgumentList
|
||||
|
||||
Reference in New Issue
Block a user