diff --git a/emails/backend/inmemory/__init__.py b/emails/backend/inmemory/__init__.py new file mode 100644 index 0000000..9d1e8c5 --- /dev/null +++ b/emails/backend/inmemory/__init__.py @@ -0,0 +1,36 @@ +# encoding: utf-8 +from __future__ import unicode_literals + +__all__ = ['InMemoryBackend', ] + +import logging + + +class InMemoryBackend(object): + + """ + InMemoryBackend store message in memory for testing purposes. + """ + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.messages = {} + + def sendmail(self, from_addr, to_addrs, msg, **kwargs): + + logging.debug('InMemoryBackend.sendmail(%s, %s, %r, %s)', from_addr, to_addrs, msg, kwargs) + + if not to_addrs: + return None + + if not isinstance(to_addrs, (list, tuple)): + to_addrs = [to_addrs, ] + + for addr in to_addrs: + data = dict(from_addr=from_addr, + message=msg.as_string(), + source_message=msg, + **kwargs) + self.messages.setdefault(addr.lower(), []).append(data) + + return True diff --git a/emails/message.py b/emails/message.py index cca653f..df35bde 100644 --- a/emails/message.py +++ b/emails/message.py @@ -7,7 +7,8 @@ from .compat import (string_types, is_callable, formataddr as compat_formataddr, from .utils import (SafeMIMEText, SafeMIMEMultipart, sanitize_address, parse_name_and_email, load_email_charsets, encode_header as encode_header_, - renderable, format_date_header, parse_name_and_email_list) + renderable, format_date_header, parse_name_and_email_list, + cached_property) from .exc import BadHeaderError from .backend import ObjectFactory, SMTPBackend from .store import MemoryFileStore, BaseFile @@ -37,7 +38,9 @@ class BaseMessage(object): headers=None, html=None, text=None, - attachments=None): + attachments=None, + cc=None, + bcc=None): self._attachments = None self.charset = charset or 'utf-8' # utf-8 is standard de-facto, yeah @@ -46,6 +49,8 @@ class BaseMessage(object): self.set_date(date) self.set_mail_from(mail_from) self.set_mail_to(mail_to) + self.set_cc(cc) + self.set_bcc(bcc) self.set_headers(headers) self.set_html(html=html) self.set_text(text=text) @@ -73,6 +78,29 @@ class BaseMessage(object): mail_to = property(get_mail_to, set_mail_to) + def set_cc(self, addr): + self._cc = parse_name_and_email_list(addr) + + def get_cc(self): + return self._cc + + cc = property(get_cc, set_cc) + + def set_bcc(self, addr): + self._bcc = parse_name_and_email_list(addr) + + def get_bcc(self): + return self._bcc + + bcc = property(get_bcc, set_bcc) + + def get_recipients_emails(self): + """ + Returns message recipient's emails for actual sending. + :return: list of emails + """ + return list(set([a[1] for a in self._mail_to] + [a[1] for a in self._cc] + [a[1] for a in self._bcc])) + def set_headers(self, headers): self._headers = headers or {} @@ -224,8 +252,12 @@ class MessageBuildMixin(object): self.set_header(msg, 'Subject', subject) self.set_header(msg, 'From', self.encode_address_header(self._mail_from), encode=False) - self.set_header(msg, 'To', self._mail_to and ", ".join([self.encode_address_header(addr) - for addr in self._mail_to]) or None, encode=False) + + if self._mail_to: + self.set_header(msg, 'To', ", ".join([self.encode_address_header(addr) for addr in self._mail_to]), encode=False) + + if self._cc: + self.set_header(msg, 'Cc', ", ".join([self.encode_address_header(addr) for addr in self._cc]), encode=False) return msg @@ -312,12 +344,9 @@ class MessageSendMixin(object): smtp_pool_factory = ObjectFactory smtp_cls = SMTPBackend - @property + @cached_property def smtp_pool(self): - pool = getattr(self, '_smtp_pool', None) - if pool is None: - pool = self._smtp_pool = self.smtp_pool_factory(cls=self.smtp_cls) - return pool + return self.smtp_pool_factory(cls=self.smtp_cls) def send(self, to=None, @@ -342,17 +371,17 @@ class MessageSendMixin(object): raise ValueError( "smtp must be a dict or an object with method 'sendmail'. got %s" % type(smtp)) - mail_to = to - if mail_to: - mail_to = parse_name_and_email(mail_to) - to_addr = mail_to[1] + to_addrs = None + + if to: if set_mail_to: - self.set_mail_to(mail_to) + self.set_mail_to(to) + else: + to_addrs = [a[1] for a in parse_name_and_email_list(to)] - else: - to_addr = self._mail_to[0][1] + to_addrs = to_addrs or self.get_recipients_emails() - if not to_addr: + if not to_addrs: raise ValueError('No to-addr') if mail_from: @@ -368,7 +397,7 @@ class MessageSendMixin(object): if not from_addr: raise ValueError('No "from" addr') - params = dict(from_addr=from_addr, to_addrs=[to_addr, ], msg=self, + params = dict(from_addr=from_addr, to_addrs=to_addrs, msg=self, mail_options=smtp_mail_options, rcpt_options=smtp_rcpt_options) return smtp.sendmail(**params) diff --git a/emails/testsuite/message/test_message.py b/emails/testsuite/message/test_message.py index 3a16434..30561b0 100644 --- a/emails/testsuite/message/test_message.py +++ b/emails/testsuite/message/test_message.py @@ -10,6 +10,7 @@ from emails import Message import emails.exc from emails.compat import to_unicode, StringIO, is_py2, is_py34_plus from emails.utils import decode_header, MessageID +from emails.backend.inmemory import InMemoryBackend from .helpers import common_email_data @@ -171,12 +172,23 @@ def test_message_id(): assert m.as_message()['Message-ID'] == 'XXX' -def test_several_recipients_in_to_header(): +def test_several_recipients(): + + # Test multiple recipients in "To" header + params = dict(html='...', mail_from='a@b.c') - m = Message(mail_to=['d@e.f', 'g@h.i'], **params) - assert m.as_message()['To'] == 'd@e.f, g@h.i' + m = Message(mail_to=['a@x.z', 'b@x.z'], cc='c@x.z', **params) + assert m.as_message()['To'] == 'a@x.z, b@x.z' + assert m.as_message()['cc'] == 'c@x.z' - m = Message(mail_to=[('♡', 'd@e.f'), ('웃', 'g@h.i')], **params) - assert m.as_message()['To'] == '=?utf-8?b?4pmh?= , =?utf-8?b?7JuD?= ' + m = Message(mail_to=[('♡', 'a@x.z'), ('웃', 'b@x.z')], **params) + assert m.as_message()['To'] == '=?utf-8?b?4pmh?= , =?utf-8?b?7JuD?= ' + # Test sending to several emails + + backend = InMemoryBackend() + m = Message(mail_to=[('♡', 'a@x.z'), ('웃', 'b@x.z')], cc=['c@x.z', 'b@x.z'], bcc=['c@x.z', 'd@x.z'], **params) + m.send(smtp=backend) + for addr in ['a@x.z', 'b@x.z', 'c@x.z', 'd@x.z']: + assert len(backend.messages[addr]) == 1 diff --git a/emails/utils.py b/emails/utils.py index 67d2985..46a5abd 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -41,6 +41,24 @@ def load_email_charsets(): charset) +class cached_property(object): + """ + A property that is only computed once per instance and then replaces itself + with an ordinary attribute. Deleting the attribute resets the property. + Source: https://github.com/bottlepy/bottle/commit/fa7733e075da0d790d809aa3d2f53071897e6f76 + """ # noqa + + def __init__(self, func): + self.__doc__ = getattr(func, '__doc__') + self.func = func + + def __get__(self, obj, cls): + if obj is None: + return self + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value + + # Django's CachedDnsName: # Cached the hostname, but do it lazily: socket.getfqdn() can take a couple of # seconds, which slows down the restart of the server.