This repository has been archived on 2023-02-21. You can view files and clone it, but cannot push or open issues or pull requests.
python-emails/emails/message.py

465 lines
13 KiB
Python

# coding: utf-8
from __future__ import unicode_literals
import time
from functools import wraps
from dateutil.parser import parse as dateutil_parse
from email.header import Header
from email.utils import formatdate, getaddresses
from emails.compat import string_types, to_unicode, is_callable, to_bytes, to_native
from .utils import (SafeMIMEText, SafeMIMEMultipart, sanitize_address,
parse_name_and_email, load_email_charsets,
encode_header as encode_header_)
from .exc import BadHeaderError
from .backend import ObjectFactory
from .backend.smtp import SMTPBackend
from .store import MemoryFileStore, BaseFile
from .signers import DKIMSigner
load_email_charsets() # sic!
def renderable(f):
@wraps(f)
def wrapper(self, *args, **kwargs):
r = f(self, *args, **kwargs)
render = getattr(r, 'render', None)
if render:
d = render(**(self.render_data or {}))
return d
else:
return r
return wrapper
class IncompleteMessage(Exception):
pass
class BaseMessage(object):
"""
Base email message with html part, text part and attachments.
"""
ROOT_PREAMBLE = 'This is a multi-part message in MIME format.\n'
# Header names that contain structured address data (RFC #5322)
ADDRESS_HEADERS = set(['from', 'sender', 'reply-to', 'to', 'cc', 'bcc',
'resent-from', 'resent-sender', 'resent-to',
'resent-cc', 'resent-bcc'])
attachment_cls = BaseFile
filestore_cls = MemoryFileStore
def __init__(self,
charset=None,
message_id=None,
date=None,
subject=None,
mail_from=None,
mail_to=None,
headers=None,
html=None,
text=None,
attachments=None):
self._attachments = None
self.charset = charset or 'utf-8' # utf-8 is standard de-facto, yeah
self._message_id = message_id
self.set_subject(subject)
self.set_date(date)
self.set_mail_from(mail_from)
self.set_mail_to(mail_to)
self.set_headers(headers)
self.set_html(html=html)
self.set_text(text=text)
self.render_data = {}
self.after_build = None
if attachments:
for a in attachments:
self.attachments.add(a)
def set_mail_from(self, mail_from):
# In: ('Alice', '<alice@me.com>' )
self._mail_from = mail_from and parse_name_and_email(mail_from) or None
def get_mail_from(self):
# Out: ('Alice', '<alice@me.com>') or None
return self._mail_from
mail_from = property(get_mail_from, set_mail_from)
def set_mail_to(self, mail_to):
# Now we parse only one to-addr
# TODO: parse list of to-addrs
mail_to = mail_to and parse_name_and_email(mail_to)
self._mail_to = mail_to and [mail_to, ] or []
def get_mail_to(self):
return self._mail_to
mail_to = property(get_mail_to, set_mail_to)
def set_headers(self, headers):
self._headers = headers
def set_html(self, html, url=None):
if hasattr(html, 'read'):
html = html.read()
self._html = html
self._html_url = url
def get_html(self):
return self._html
html = property(get_html, set_html)
def set_text(self, text, url=None):
if hasattr(text, 'read'):
text = text.read()
self._text = text
self._text_url = url
def get_text(self):
return self._text
text = property(get_text, set_text)
@property
@renderable
def html_body(self):
return self._html
@property
@renderable
def text_body(self):
return self._text
def set_subject(self, value):
self._subject = value
@renderable
def get_subject(self):
return self._subject
subject = property(get_subject, set_subject)
def render(self, **kwargs):
self.render_data = kwargs
def set_date(self, value, reformat_date=True):
if isinstance(value, string_types) and reformat_date:
_d = dateutil_parse(value)
value = time.mktime(_d.timetuple())
value = formatdate(value, True)
self._date = value
def get_date(self):
if self._date is False:
return None
timeval = self._date
if timeval:
if is_callable(timeval):
timeval = timeval()
elif timeval is None:
timeval = formatdate(None, True)
return timeval
message_date = property(get_date, set_date)
def message_id(self):
mid = self._message_id
if mid is False:
return None
return is_callable(mid) and mid() or mid
def encode_header(self, value):
return encode_header_(value, self.charset)
def encode_name_header(self, realname, email):
if realname:
r = "%s <%s>" % (self.encode_header(realname), email)
return r
else:
return email
def set_header(self, msg, key, value, encode=True):
if value is None:
# TODO: may be remove header here ?
return
# Prevent header injection
if '\n' in value or '\r' in value:
raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (value, key))
if key.lower() in self.ADDRESS_HEADERS:
value = ', '.join(sanitize_address(addr, self.charset)
for addr in getaddresses((value,)))
msg[key] = encode and self.encode_header(value) or value
@property
def attachments(self):
if self._attachments is None:
self._attachments = self.filestore_cls(self.attachment_cls)
return self._attachments
def attach(self, **kwargs):
if 'content_disposition' not in kwargs:
kwargs['content_disposition'] = 'attachment'
self.attachments.add(kwargs)
def _build_message(self, message_cls=None):
message_cls = message_cls or SafeMIMEMultipart
msg = message_cls()
msg.preamble = self.ROOT_PREAMBLE
self.set_header(msg, 'Date', self.message_date, encode=False)
self.set_header(msg, 'Message-ID', self.message_id(), encode=False)
if self._headers:
for (name, value) in self._headers.items():
self.set_header(msg, name, value)
subject = self.subject
if subject is not None:
self.set_header(msg, 'Subject', subject)
mail_from = self._mail_from and self.encode_name_header(*self._mail_from) or None
self.set_header(msg, 'From', mail_from, encode=False)
mail_to = self._mail_to and self.encode_name_header(*self._mail_to[0]) or None
self.set_header(msg, 'To', mail_to, encode=False)
msgrel = SafeMIMEMultipart('related')
msg.attach(msgrel)
msgalt = SafeMIMEMultipart('alternative')
msgrel.attach(msgalt)
_text = self.text_body
_html = self.html_body
if not (_html or _text):
raise ValueError("Message must contain 'html' or 'text' part")
if _text:
msgtext = SafeMIMEText(_text, 'plain', charset=self.charset)
msgtext.set_charset(self.charset)
msgalt.attach(msgtext)
if _html:
msghtml = SafeMIMEText(_html, 'html', charset=self.charset)
msghtml.set_charset(self.charset)
msgalt.attach(msghtml)
for f in self.attachments:
part = f.mime
if part:
if f.is_inline:
msgrel.attach(part)
else:
msg.attach(part)
if self.after_build:
self.after_build(self, msg)
return msg
class MessageSendMixin(object):
smtp_pool_factory = ObjectFactory
smtp_cls = SMTPBackend
@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
def send(self,
to=None,
set_mail_to=True,
mail_from=None,
set_mail_from=False,
render=None,
smtp_mail_options=None,
smtp_rcpt_options=None,
smtp=None):
if render is not None:
self.render(**render)
if smtp is None:
smtp = {'host': 'localhost', 'port': 25, 'timeout': 5}
if isinstance(smtp, dict):
smtp = self.smtp_pool[smtp]
if not hasattr(smtp, 'sendmail'):
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]
if set_mail_to:
self.set_mail_to(mail_to)
else:
to_addr = self._mail_to[0][1]
if not to_addr:
raise ValueError('No to-addr')
if mail_from:
if set_mail_from:
self.set_mail_from(mail_from)
from_addr = self._mail_from[1]
else:
mail_from = parse_name_and_email(mail_from)
from_addr = mail_from[1]
else:
from_addr = self._mail_from[1]
if not from_addr:
raise ValueError('No "from" addr')
params = dict(from_addr=from_addr,
to_addrs=[to_addr, ],
msg=self)
if smtp_mail_options:
params['mail_options'] = smtp_mail_options
if smtp_rcpt_options:
params['rcpt_options'] = smtp_rcpt_options
response = smtp.sendmail(**params)
return response[0]
class MessageTransformerMixin(object):
transformer_cls = None
_transformer = None
def create_transformer(self, transformer_cls=None, **kw):
cls = transformer_cls or self.transformer_cls
if cls is None:
from .transformer import MessageTransformer # avoid cyclic import
cls = MessageTransformer
self._transformer = cls(message=self, **kw)
def destroy_transformer(self):
self._transformer = None
@property
def transformer(self):
if self._transformer is None:
self.create_transformer()
return self._transformer
def set_html(self, **kw):
# When html set, remove old transformer
self.destroy_transformer()
BaseMessage.set_html(self, **kw)
class MessageDKIMMixin(object):
dkim_cls = DKIMSigner
_dkim_signer = None
def dkim(self, **kwargs):
self._dkim_signer = self.dkim_cls(**kwargs)
def dkim_sign_message(self, msg):
"""
Add DKIM header
"""
if self._dkim_signer:
return self._dkim_signer.sign_message(msg)
return msg
def dkim_sign_string(self, message_string):
"""
Add DKIM header
"""
if self._dkim_signer:
return self._dkim_signer.sign_message_string(message_string)
return message_string
def as_message(self, message_cls=None):
return self.dkim_sign_message(self._build_message(message_cls=message_cls))
message = as_message
def as_string(self, message_cls=None):
"""
Returns message as string.
Note: this method costs one less message-to-string conversions
for dkim in compare to self.as_message().as_string()
Changes:
v0.4.2: now returns bytes, not native string
"""
return self.dkim_sign_string(to_bytes(self._build_message(message_cls=message_cls).as_string()))
class Message(MessageSendMixin, MessageTransformerMixin, MessageDKIMMixin, BaseMessage):
"""
Email message with:
- DKIM signer
- smtp send
- Message.transformer object
"""
pass
def html(**kwargs):
return Message(**kwargs)
class DjangoMessageProxy(object):
"""
Class obsoletes with emails.django_.DjangoMessage
Class looks like django.core.mail.EmailMessage for standard django email backend.
Example usage:
message = emails.Message(html='...', subject='...', mail_from='robot@company.ltd')
connection = django.core.mail.get_connection()
message.set_mail_to('somebody@somewhere.net')
connection.send_messages([DjangoMessageProxy(message), ])
"""
def __init__(self, message, recipients=None, context=None):
self._message = message
self._recipients = recipients
self._context = context and context.copy() or {}
self.from_email = message.mail_from[1]
self.encoding = message.charset
def recipients(self):
return self._recipients or [r[1] for r in self._message.mail_to]
def message(self):
self._message.render(**self._context)
return self._message.message()