diff --git a/anymail/backends/base.py b/anymail/backends/base.py index 745a6bc..c947276 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -1,5 +1,8 @@ +from datetime import date, datetime + from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend +from django.utils.timezone import is_naive, get_current_timezone, make_aware, utc from ..exceptions import AnymailError, AnymailUnsupportedFeature from ..utils import Attachment, ParsedEmail, UNSET, combine, last, get_anymail_setting @@ -191,7 +194,7 @@ class BasePayload(object): anymail_message_attrs = ( # Anymail expando-props ('metadata', combine, None), - ('send_at', last, None), # normalize to datetime? + ('send_at', last, 'aware_datetime'), ('tags', combine, None), ('track_clicks', last, None), ('track_opens', last, None), @@ -219,6 +222,7 @@ class BasePayload(object): if not callable(converter): converter = getattr(self, converter) value = converter(value) + if value is not UNSET: if attr == 'body': setter = self.set_html_body if message.content_subtype == 'html' else self.set_text_body else: @@ -246,6 +250,29 @@ class BasePayload(object): str_encoding = self.message.encoding or settings.DEFAULT_CHARSET return [Attachment(attachment, str_encoding) for attachment in attachments] + def aware_datetime(self, value): + """Converts a date or datetime or timestamp to an aware datetime. + + Naive datetimes are assumed to be in Django's current_timezone. + Dates are interpreted as midnight that date, in Django's current_timezone. + Integers are interpreted as POSIX timestamps (which are inherently UTC). + + Anything else (e.g., str) is returned unchanged, which won't be portable. + """ + if isinstance(value, datetime): + dt = value + else: + if isinstance(value, date): + dt = datetime(value.year, value.month, value.day) # naive, midnight + else: + try: + dt = datetime.utcfromtimestamp(value).replace(tzinfo=utc) + except (TypeError, ValueError): + return value + if is_naive(dt): + dt = make_aware(dt, get_current_timezone()) + return dt + # # Abstract implementation # diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index bc8f16b..9f540ff 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -57,21 +57,15 @@ def _expand_merge_vars(vardict): def encode_date_for_mandrill(dt): - """Format a date or datetime for use as a Mandrill API date field + """Format a datetime for use as a Mandrill API date field - datetime becomes "YYYY-MM-DD HH:MM:SS" - converted to UTC, if timezone-aware - microseconds removed - date becomes "YYYY-MM-DD 00:00:00" - anything else gets returned intact + Mandrill expects "YYYY-MM-DD HH:MM:SS" in UTC """ if isinstance(dt, datetime): dt = dt.replace(microsecond=0) if dt.utcoffset() is not None: dt = (dt - dt.utcoffset()).replace(tzinfo=None) return dt.isoformat(' ') - elif isinstance(dt, date): - return dt.isoformat() + ' 00:00:00' else: return dt @@ -128,9 +122,10 @@ class MandrillPayload(RequestsPayload): def add_alternative(self, content, mimetype): if mimetype != 'text/html': self.unsupported_feature("alternative part with mimetype '%s'" % mimetype) - if "html" in self.data["message"]: + elif "html" in self.data["message"]: self.unsupported_feature("multiple html parts") - self.data["message"]["html"] = content + else: + self.set_html_body(content) def add_attachment(self, attachment): key = "images" if attachment.inline else "attachments" diff --git a/anymail/tests/test_mandrill_send.py b/anymail/tests/test_mandrill_send.py index 6c53dba..9513c91 100644 --- a/anymail/tests/test_mandrill_send.py +++ b/anymail/tests/test_mandrill_send.py @@ -8,7 +8,7 @@ import re import six import unittest from base64 import b64decode -from datetime import date, datetime, timedelta, tzinfo +from datetime import date, datetime from decimal import Decimal from email.mime.base import MIMEBase from email.mime.image import MIMEImage @@ -18,6 +18,7 @@ from django.core.exceptions import ImproperlyConfigured from django.core.mail import make_msgid from django.test import TestCase from django.test.utils import override_settings +from django.utils.timezone import get_fixed_timezone, override as override_current_timezone from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused, AnymailSerializationError, AnymailUnsupportedFeature) @@ -451,33 +452,39 @@ class DjrillMandrillFeatureTests(DjrillBackendMockAPITestCase): ]) def test_send_at(self): - # String passed unchanged - self.message.send_at = "2013-11-12 01:02:03" - self.message.send() - data = self.get_api_call_data() - self.assertEqual(data['send_at'], "2013-11-12 01:02:03") + utc_plus_6 = get_fixed_timezone(6 * 60) + utc_minus_8 = get_fixed_timezone(-8 * 60) - # Timezone-naive datetime assumed to be UTC - self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) - self.message.send() - data = self.get_api_call_data() - self.assertEqual(data['send_at'], "2022-10-11 12:13:14") + with override_current_timezone(utc_plus_6): + # Timezone-naive datetime assumed to be Django current_timezone + self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['send_at'], "2022-10-11 06:13:14") # 12:13 UTC+6 == 06:13 UTC - # Timezone-aware datetime converted to UTC: - class GMTminus8(tzinfo): - def utcoffset(self, dt): return timedelta(hours=-8) - def dst(self, dt): return timedelta(0) + # Timezone-aware datetime converted to UTC: + self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8) + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['send_at'], "2016-03-04 13:06:07") # 05:06 UTC-8 == 13:06 UTC - self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=GMTminus8()) - self.message.send() - data = self.get_api_call_data() - self.assertEqual(data['send_at'], "2016-03-04 13:06:07") + # Date-only treated as midnight in current timezone + self.message.send_at = date(2022, 10, 22) + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['send_at'], "2022-10-21 18:00:00") # 00:00 UTC+6 == 18:00-1d UTC - # Date-only treated as midnight UTC - self.message.send_at = date(2022, 10, 22) - self.message.send() - data = self.get_api_call_data() - self.assertEqual(data['send_at'], "2022-10-22 00:00:00") + # POSIX timestamp + self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['send_at'], "2022-05-06 07:08:09") + + # String passed unchanged (this is *not* portable between ESPs) + self.message.send_at = "2013-11-12 01:02:03" + self.message.send() + data = self.get_api_call_data() + self.assertEqual(data['send_at'], "2013-11-12 01:02:03") def test_default_omits_options(self): """Make sure by default we don't send any Mandrill-specific options.