summaryrefslogtreecommitdiff
path: root/bzrlib/email_message.py
diff options
context:
space:
mode:
Diffstat (limited to 'bzrlib/email_message.py')
-rw-r--r--bzrlib/email_message.py209
1 files changed, 209 insertions, 0 deletions
diff --git a/bzrlib/email_message.py b/bzrlib/email_message.py
new file mode 100644
index 0000000..fd9a29d
--- /dev/null
+++ b/bzrlib/email_message.py
@@ -0,0 +1,209 @@
+# Copyright (C) 2007 Canonical Ltd
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+"""A convenience class around email.Message and email.MIMEMultipart."""
+
+from __future__ import absolute_import
+
+from email import (
+ Header,
+ Message,
+ MIMEMultipart,
+ MIMEText,
+ Utils,
+ )
+
+from bzrlib import __version__ as _bzrlib_version
+from bzrlib.osutils import safe_unicode
+from bzrlib.smtp_connection import SMTPConnection
+
+
+class EmailMessage(object):
+ """An email message.
+
+ The constructor needs an origin address, a destination address or addresses
+ and a subject, and accepts a body as well. Add additional parts to the
+ message with add_inline_attachment(). Retrieve the entire formatted message
+ with as_string().
+
+ Headers can be accessed with get() and msg[], and modified with msg[] =.
+ """
+
+ def __init__(self, from_address, to_address, subject, body=None):
+ """Create an email message.
+
+ :param from_address: The origin address, to be put on the From header.
+ :param to_address: The destination address of the message, to be put in
+ the To header. Can also be a list of addresses.
+ :param subject: The subject of the message.
+ :param body: If given, the body of the message.
+
+ All four parameters can be unicode strings or byte strings, but for the
+ addresses and subject byte strings must be encoded in UTF-8. For the
+ body any byte string will be accepted; if it's not ASCII or UTF-8,
+ it'll be sent with charset=8-bit.
+ """
+ self._headers = {}
+ self._body = body
+ self._parts = []
+
+ if isinstance(to_address, basestring):
+ to_address = [ to_address ]
+
+ to_addresses = []
+
+ for addr in to_address:
+ to_addresses.append(self.address_to_encoded_header(addr))
+
+ self._headers['To'] = ', '.join(to_addresses)
+ self._headers['From'] = self.address_to_encoded_header(from_address)
+ self._headers['Subject'] = Header.Header(safe_unicode(subject))
+ self._headers['User-Agent'] = 'Bazaar (%s)' % _bzrlib_version
+
+ def add_inline_attachment(self, body, filename=None, mime_subtype='plain'):
+ """Add an inline attachment to the message.
+
+ :param body: A text to attach. Can be an unicode string or a byte
+ string, and it'll be sent as ascii, utf-8, or 8-bit, in that
+ preferred order.
+ :param filename: The name for the attachment. This will give a default
+ name for email programs to save the attachment.
+ :param mime_subtype: MIME subtype of the attachment (eg. 'plain' for
+ text/plain [default]).
+
+ The attachment body will be displayed inline, so do not use this
+ function to attach binary attachments.
+ """
+ # add_inline_attachment() has been called, so the message will be a
+ # MIMEMultipart; add the provided body, if any, as the first attachment
+ if self._body is not None:
+ self._parts.append((self._body, None, 'plain'))
+ self._body = None
+
+ self._parts.append((body, filename, mime_subtype))
+
+ def as_string(self, boundary=None):
+ """Return the entire formatted message as a string.
+
+ :param boundary: The boundary to use between MIME parts, if applicable.
+ Used for tests.
+ """
+ if not self._parts:
+ msgobj = Message.Message()
+ if self._body is not None:
+ body, encoding = self.string_with_encoding(self._body)
+ msgobj.set_payload(body, encoding)
+ else:
+ msgobj = MIMEMultipart.MIMEMultipart()
+
+ if boundary is not None:
+ msgobj.set_boundary(boundary)
+
+ for body, filename, mime_subtype in self._parts:
+ body, encoding = self.string_with_encoding(body)
+ payload = MIMEText.MIMEText(body, mime_subtype, encoding)
+
+ if filename is not None:
+ content_type = payload['Content-Type']
+ content_type += '; name="%s"' % filename
+ payload.replace_header('Content-Type', content_type)
+
+ payload['Content-Disposition'] = 'inline'
+ msgobj.attach(payload)
+
+ # sort headers here to ease testing
+ for header, value in sorted(self._headers.items()):
+ msgobj[header] = value
+
+ return msgobj.as_string()
+
+ __str__ = as_string
+
+ def get(self, header, failobj=None):
+ """Get a header from the message, returning failobj if not present."""
+ return self._headers.get(header, failobj)
+
+ def __getitem__(self, header):
+ """Get a header from the message, returning None if not present.
+
+ This method intentionally does not raise KeyError to mimic the behavior
+ of __getitem__ in email.Message.
+ """
+ return self._headers.get(header, None)
+
+ def __setitem__(self, header, value):
+ return self._headers.__setitem__(header, value)
+
+ @staticmethod
+ def send(config, from_address, to_address, subject, body, attachment=None,
+ attachment_filename=None, attachment_mime_subtype='plain'):
+ """Create an email message and send it with SMTPConnection.
+
+ :param config: config object to pass to SMTPConnection constructor.
+
+ See EmailMessage.__init__() and EmailMessage.add_inline_attachment()
+ for an explanation of the rest of parameters.
+ """
+ msg = EmailMessage(from_address, to_address, subject, body)
+ if attachment is not None:
+ msg.add_inline_attachment(attachment, attachment_filename,
+ attachment_mime_subtype)
+ SMTPConnection(config).send_email(msg)
+
+ @staticmethod
+ def address_to_encoded_header(address):
+ """RFC2047-encode an address if necessary.
+
+ :param address: An unicode string, or UTF-8 byte string.
+ :return: A possibly RFC2047-encoded string.
+ """
+ # Can't call Header over all the address, because that encodes both the
+ # name and the email address, which is not permitted by RFCs.
+ user, email = Utils.parseaddr(address)
+ if not user:
+ return email
+ else:
+ return Utils.formataddr((str(Header.Header(safe_unicode(user))),
+ email))
+
+ @staticmethod
+ def string_with_encoding(string_):
+ """Return a str object together with an encoding.
+
+ :param string\\_: A str or unicode object.
+ :return: A tuple (str, encoding), where encoding is one of 'ascii',
+ 'utf-8', or '8-bit', in that preferred order.
+ """
+ # Python's email module base64-encodes the body whenever the charset is
+ # not explicitly set to ascii. Because of this, and because we want to
+ # avoid base64 when it's not necessary in order to be most compatible
+ # with the capabilities of the receiving side, we check with encode()
+ # and decode() whether the body is actually ascii-only.
+ if isinstance(string_, unicode):
+ try:
+ return (string_.encode('ascii'), 'ascii')
+ except UnicodeEncodeError:
+ return (string_.encode('utf-8'), 'utf-8')
+ else:
+ try:
+ string_.decode('ascii')
+ return (string_, 'ascii')
+ except UnicodeDecodeError:
+ try:
+ string_.decode('utf-8')
+ return (string_, 'utf-8')
+ except UnicodeDecodeError:
+ return (string_, '8-bit')