Index: Doc/library/smtplib.rst =================================================================== --- Doc/library/smtplib.rst (revision 86166) +++ Doc/library/smtplib.rst (working copy) @@ -264,7 +264,7 @@ Send mail. The required arguments are an :rfc:`822` from-address string, a list of :rfc:`822` to-address strings (a bare string will be treated as a list with 1 - address), and a message string. The caller may pass a list of ESMTP options + address), and a message. The caller may pass a list of ESMTP options (such as ``8bitmime``) to be used in ``MAIL FROM`` commands as *mail_options*. ESMTP options (such as ``DSN`` commands) that should be used with all ``RCPT`` commands can be passed as *rcpt_options*. (If you need to use different ESMTP @@ -275,8 +275,32 @@ The *from_addr* and *to_addrs* parameters are used to construct the message envelope used by the transport agents. The :class:`SMTP` does not modify the - message headers in any way. + message headers in any way, with the sole exception of the :mailheader:`Bcc` + field if *msg* is a :class:`~email.message.Message` object. + msg may be a string containing characters in the ASCII range, or a byte + string, or a :class:`~email.message.Message` object. A string is encoded to + bytes using the ascii codec, and lone ``\r`` and ``\n`` characters are + converted to ``\r\n`` characters. A byte string is transmitted as is. A + Message object is serialized using :class:`~email.generator.BytesGenerator`. + + When msg is a Message object, *from_addr* and *to_addrs* may be set to + ``None``, in which case the *from_addr* is taken from the :mailheader:`From` + header of the Message, and *to_addrs* is composed from the addresses listed + in the :mailheader:`To`, :mailheader:`CC`, and :mailheader:`Bcc` fields. + Regardless, any Bcc field in the ``Message`` object is deleted before it is + serialized. + + .. note:: + + The :mailheader:`Bcc` header is removed *only* when *msg* is a + :class:`~email.message.Message` object. + + If there has been no previous EHLO or HELO command this session, this method + tries ESMTP EHLO first. If the server does ESMTP, message size and each of + the specified options will be passed to it. If EHLO fails, HELO will be + tried and ESMTP options suppressed. + If there has been no previous ``EHLO`` or ``HELO`` command this session, this method tries ESMTP ``EHLO`` first. If the server does ESMTP, message size and each of the specified options will be passed to it (if the option is in the @@ -366,5 +390,5 @@ .. note:: In general, you will want to use the :mod:`email` package's features to - construct an email message, which you can then convert to a string and send - via :meth:`sendmail`; see :ref:`email-examples`. + construct an email message, which you can then send + via :meth:`~smtplib.SMTP.sendmail`; see :ref:`email-examples`. Index: Lib/smtplib.py =================================================================== --- Lib/smtplib.py (revision 86166) +++ Lib/smtplib.py (working copy) @@ -42,8 +42,11 @@ # This was modified from the Python 1.5 library HTTP lib. import socket +import io import re import email.utils +import email.message +import email.generator import base64 import hmac from email.base64mime import body_encode as encode_base64 @@ -57,6 +60,7 @@ SMTP_PORT = 25 SMTP_SSL_PORT = 465 CRLF="\r\n" +bCRLF=b"\r\n" OLDSTYLE_AUTH = re.compile(r"auth=(.*)", re.I) @@ -147,6 +151,7 @@ else: return "<%s>" % m +# Legacy method kept for backward compatibility. def quotedata(data): """Quote data for email. @@ -156,6 +161,12 @@ return re.sub(r'(?m)^\.', '..', re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data)) +def _quote_periods(bindata): + return re.sub(br'(?m)^\.', '..', bindata) + +def _fix_eols(data): + return re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data) + try: import ssl except ImportError: @@ -469,7 +480,9 @@ Automatically quotes lines beginning with a period per rfc821. Raises SMTPDataError if there is an unexpected reply to the DATA command; the return value from this method is the final - response code received when the all data is sent. + response code received when the all data is sent. If msg + is a string, lone '\r' and '\n' characters are converted to + '\r\n' characters. If msg is bytes, it is transmitted as is. """ self.putcmd("data") (code,repl)=self.getreply() @@ -477,10 +490,12 @@ if code != 354: raise SMTPDataError(code,repl) else: - q = quotedata(msg) - if q[-2:] != CRLF: - q = q + CRLF - q = q + "." + CRLF + if isinstance(msg, str): + msg = _fix_eols(msg).encode('ascii') + q = _quote_periods(msg) + if q[-2:] != bCRLF: + q = q + bCRLF + q = q + b"." + bCRLF self.send(q) (code,msg)=self.getreply() if self.debuglevel >0 : print("data:", (code,msg), file=stderr) @@ -648,6 +663,18 @@ - rcpt_options : List of ESMTP options (such as DSN commands) for all the rcpt commands. + msg may be a string containing characters in the ASCII range, or a byte + string, or an email.message.Message object. A string is encoded to + bytes using the ascii codec, and lone \r and \n characters are + converted to \r\n characters. A byte string is transmitted as is. A + Message object is serialized using email.generator.BytesGenerator. + + When msg is a Message object, from_addr and to_addrs may be set to + None, in which case the from_addr is taken from the 'From' header of + the Message, and the to_addrs is composed from the addresses listed in + the 'To', 'CC', and 'Bcc' fields. Regardless, any Bcc field in + the Message object is deleted before it is serialized. + If there has been no previous EHLO or HELO command this session, this method tries ESMTP EHLO first. If the server does ESMTP, message size and each of the specified options will be passed to it. If EHLO @@ -693,6 +720,22 @@ """ self.ehlo_or_helo_if_needed() esmtp_opts = [] + if isinstance(msg, email.message.Message): + if from_addr is None: + from_addr = msg['From'] + if to_addrs is None: + addr_fields = [f for f in (msg['To'], msg['Bcc'], msg['CC']) + if f is not None] + to_addrs = [a[1] for a in email.utils.getaddresses(addr_fields)] + del msg['Bcc'] + bytesmsg = io.BytesIO() + g = email.generator.BytesGenerator(bytesmsg) + g.flatten(msg, linesep='\r\n') + bytesmsg = bytesmsg.getvalue() + elif isinstance(msg, str): + bytesmsg = _fix_eols(msg).encode('ascii') + else: + bytesmsg = msg if self.does_esmtp: # Hmmm? what's this? -ddm # self.esmtp_features['7bit']="" @@ -716,7 +759,7 @@ # the server refused all our recipients self.rset() raise SMTPRecipientsRefused(senderrs) - (code,resp) = self.data(msg) + (code,resp) = self.data(bytesmsg) if code != 250: self.rset() raise SMTPDataError(code, resp) Index: Lib/test/test_smtplib.py =================================================================== --- Lib/test/test_smtplib.py (revision 86166) +++ Lib/test/test_smtplib.py (working copy) @@ -1,9 +1,11 @@ import asyncore +import email.mime.text import email.utils import socket import smtpd import smtplib import io +import re import sys import time import select @@ -57,6 +59,13 @@ def tearDown(self): smtplib.socket = socket + # This method is no longer used but is retained for backward compatibility, + # so test to make sure it still works. + def testQuoteData(self): + teststr = "abc\n.jkl\rfoo\r\n..blue" + expected = "abc\r\n..jkl\r\nfoo\r\n...blue" + self.assertEqual(expected, smtplib.quotedata(teststr)) + def testBasic1(self): mock_socket.reply_with(b"220 Hola mundo") # connects @@ -150,6 +159,8 @@ @unittest.skipUnless(threading, 'Threading required for this test.') class DebuggingServerTests(unittest.TestCase): + maxDiff = None + def setUp(self): self.real_getfqdn = socket.getfqdn socket.getfqdn = mock_socket.getfqdn @@ -161,6 +172,9 @@ self._threads = support.threading_setup() self.serv_evt = threading.Event() self.client_evt = threading.Event() + # Capture SMTPChannel debug output + self.old_DEBUGSTREAM = smtpd.DEBUGSTREAM + smtpd.DEBUGSTREAM = io.StringIO() # Pick a random unused port by passing 0 for the port number self.serv = smtpd.DebuggingServer((HOST, 0), ('nowhere', -1)) # Keep a note of what port was assigned @@ -183,6 +197,9 @@ support.threading_cleanup(*self._threads) # restore sys.stdout sys.stdout = self.old_stdout + # restore DEBUGSTREAM + smtpd.DEBUGSTREAM.close() + smtpd.DEBUGSTREAM = self.old_DEBUGSTREAM def testBasic(self): # connect @@ -247,7 +264,96 @@ mexpect = '%s%s\n%s' % (MSG_BEGIN, m, MSG_END) self.assertEqual(self.output.getvalue(), mexpect) + def testSendBinary(self): + m = b'A test message' + smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) + smtp.sendmail('John', 'Sally', m) + # XXX (see comment in testSend) + time.sleep(0.01) + smtp.quit() + self.client_evt.set() + self.serv_evt.wait() + self.output.flush() + mexpect = '%s%s\n%s' % (MSG_BEGIN, m.decode('ascii'), MSG_END) + self.assertEqual(self.output.getvalue(), mexpect) + + def testSendMessage(self): + m = email.mime.text.MIMEText('A test message') + smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) + smtp.sendmail('John', 'Sally', m) + # XXX (see comment in testSend) + time.sleep(0.01) + smtp.quit() + + self.client_evt.set() + self.serv_evt.wait() + self.output.flush() + # Add the X-Peer header that DebuggingServer adds + # XXX: I'm not sure hardcoding this IP will work on linux-vserver. + m['X-Peer'] = '127.0.0.1' + mexpect = '%s%s\n%s' % (MSG_BEGIN, m.as_string(), MSG_END) + self.assertEqual(self.output.getvalue(), mexpect) + + def testSendMessageWithAddresses(self): + m = email.mime.text.MIMEText('A test message') + m['From'] = 'foo@bar.com' + m['To'] = 'John' + m['CC'] = 'Sally, Fred' + m['Bcc'] = 'John Root , "Dinsdale" ' + smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) + smtp.sendmail(None, None, m) + # XXX (see comment in testSend) + time.sleep(0.01) + smtp.quit() + + self.client_evt.set() + self.serv_evt.wait() + self.output.flush() + # Add the X-Peer header that DebuggingServer adds + # XXX: I'm not sure hardcoding this IP will work on linux-vserver. + m['X-Peer'] = '127.0.0.1' + # The Bcc header is deleted before serialization. + del m['Bcc'] + mexpect = '%s%s\n%s' % (MSG_BEGIN, m.as_string(), MSG_END) + self.assertEqual(self.output.getvalue(), mexpect) + debugout = smtpd.DEBUGSTREAM.getvalue() + sender = re.compile("^sender: foo@bar.com$", re.MULTILINE) + self.assertRegexpMatches(debugout, sender) + for addr in ('John', 'Sally', 'Fred', 'root@localhost', + 'warped@silly.walks.com'): + to_addr = re.compile(r"^recips: .*'{}'.*$".format(addr), + re.MULTILINE) + self.assertRegexpMatches(debugout, to_addr) + + def testSendMessageWithSomeAddresses(self): + # Make sure nothing breaks if not all of the three 'to' headers exist + m = email.mime.text.MIMEText('A test message') + m['From'] = 'foo@bar.com' + m['To'] = 'John, Dinsdale' + smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=3) + smtp.sendmail(None, None, m) + # XXX (see comment in testSend) + time.sleep(0.01) + smtp.quit() + + self.client_evt.set() + self.serv_evt.wait() + self.output.flush() + # Add the X-Peer header that DebuggingServer adds + # XXX: I'm not sure hardcoding this IP will work on linux-vserver. + m['X-Peer'] = '127.0.0.1' + mexpect = '%s%s\n%s' % (MSG_BEGIN, m.as_string(), MSG_END) + self.assertEqual(self.output.getvalue(), mexpect) + debugout = smtpd.DEBUGSTREAM.getvalue() + sender = re.compile("^sender: foo@bar.com$", re.MULTILINE) + self.assertRegexpMatches(debugout, sender) + for addr in ('John', 'Dinsdale'): + to_addr = re.compile(r"^recips: .*'{}'.*$".format(addr), + re.MULTILINE) + self.assertRegexpMatches(debugout, to_addr) + + class NonConnectingTests(unittest.TestCase): def setUp(self):