Port various other bugfixes in smtplib.py from upstream

This commit is contained in:
Kovid Goyal 2016-07-05 09:39:03 +05:30
parent 0b86d5175a
commit 415ebb7a6c

View File

@ -1,3 +1,4 @@
#!/usr/bin/env python2
from __future__ import print_function from __future__ import print_function
'''SMTP/ESMTP client class. '''SMTP/ESMTP client class.
@ -47,21 +48,23 @@ import re
import email.utils import email.utils
import base64 import base64
import hmac import hmac
import sys
from email.base64mime import encode as encode_base64 from email.base64mime import encode as encode_base64
from sys import stderr
from functools import partial from functools import partial
__all__ = ["SMTPException","SMTPServerDisconnected","SMTPResponseException", __all__ = ["SMTPException", "SMTPServerDisconnected", "SMTPResponseException",
"SMTPSenderRefused","SMTPRecipientsRefused","SMTPDataError", "SMTPSenderRefused", "SMTPRecipientsRefused", "SMTPDataError",
"SMTPConnectError","SMTPHeloError","SMTPAuthenticationError", "SMTPConnectError", "SMTPHeloError", "SMTPAuthenticationError",
"quoteaddr","quotedata","SMTP"] "quoteaddr", "quotedata", "SMTP"]
SMTP_PORT = 25 SMTP_PORT = 25
SMTP_SSL_PORT = 465 SMTP_SSL_PORT = 465
CRLF="\r\n" CRLF = "\r\n"
_MAXLINE = 8192 # more than 8 times larger than RFC 821, 4.5.3
OLDSTYLE_AUTH = re.compile(r"auth=(.*)", re.I) OLDSTYLE_AUTH = re.compile(r"auth=(.*)", re.I)
# Exception classes used by this module. # Exception classes used by this module.
class SMTPException(Exception): class SMTPException(Exception):
"""Base class for all exceptions raised by this module.""" """Base class for all exceptions raised by this module."""
@ -130,6 +133,7 @@ class SMTPAuthenticationError(SMTPResponseException):
combination provided. combination provided.
""" """
def quoteaddr(addr): def quoteaddr(addr):
"""Quote a subset of the email addresses defined by RFC 821. """Quote a subset of the email addresses defined by RFC 821.
@ -149,6 +153,13 @@ def quoteaddr(addr):
else: else:
return "<%s>" % m return "<%s>" % m
def _addr_only(addrstring):
displayname, addr = email.utils.parseaddr(addrstring)
if (displayname, addr) == ('', ''):
# parseaddr couldn't parse it, so use it as is.
return addrstring
return addr
def quotedata(data): def quotedata(data):
"""Quote data for email. """Quote data for email.
@ -172,10 +183,14 @@ else:
def __init__(self, sslobj): def __init__(self, sslobj):
self.sslobj = sslobj self.sslobj = sslobj
def readline(self): def readline(self, size=-1):
if size < 0:
size = None
str = "" str = ""
chr = None chr = None
while chr != "\n": while chr != "\n":
if size is not None and len(str) >= size:
break
chr = self.sslobj.read(1) chr = self.sslobj.read(1)
if not chr: if not chr:
break break
@ -222,10 +237,11 @@ class SMTP:
ehlo_msg = "ehlo" ehlo_msg = "ehlo"
ehlo_resp = None ehlo_resp = None
does_esmtp = 0 does_esmtp = 0
default_port = SMTP_PORT
def __init__(self, host='', port=0, local_hostname=None, def __init__(self, host='', port=0, local_hostname=None,
timeout=socket._GLOBAL_DEFAULT_TIMEOUT, timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
debug_to=partial(print, file=sys.stderr)): debug_to=partial(print, file=stderr)):
"""Initialize a new instance. """Initialize a new instance.
If specified, `host' is the name of the remote host to which to If specified, `host' is the name of the remote host to which to
@ -241,7 +257,6 @@ class SMTP:
self.timeout = timeout self.timeout = timeout
self.debug = debug_to self.debug = debug_to
self.esmtp_features = {} self.esmtp_features = {}
self.default_port = SMTP_PORT
if host: if host:
(code, msg) = self.connect(host, port) (code, msg) = self.connect(host, port)
if code != 220: if code != 220:
@ -296,7 +311,7 @@ class SMTP:
if not port and (host.find(':') == host.rfind(':')): if not port and (host.find(':') == host.rfind(':')):
i = host.rfind(':') i = host.rfind(':')
if i >= 0: if i >= 0:
host, port = host[:i], host[i+1:] host, port = host[:i], host[i + 1:]
try: try:
port = int(port) port = int(port)
except ValueError: except ValueError:
@ -351,21 +366,25 @@ class SMTP:
Raises SMTPServerDisconnected if end-of-file is reached. Raises SMTPServerDisconnected if end-of-file is reached.
""" """
resp=[] resp = []
if self.file is None: if self.file is None:
self.file = self.sock.makefile('rb') self.file = self.sock.makefile('rb')
while True: while True:
try: try:
line = self.file.readline() line = self.file.readline(_MAXLINE + 1)
except socket.error: except socket.error as e:
line = '' self.close()
raise SMTPServerDisconnected("Connection unexpectedly closed: " +
str(e))
if line == '': if line == '':
self.close() self.close()
raise SMTPServerDisconnected("Connection unexpectedly closed") raise SMTPServerDisconnected("Connection unexpectedly closed")
if self.debuglevel > 0: if self.debuglevel > 0:
self.debug('reply:', repr(line)) self.debug('reply:', repr(line))
if len(line) > _MAXLINE:
raise SMTPResponseException(500, "Line too long.")
resp.append(line[4:].strip()) resp.append(line[4:].strip())
code=line[:3] code = line[:3]
# Check that the error code is syntactically correct. # Check that the error code is syntactically correct.
# Don't attempt to read a continuation line if it is broken. # Don't attempt to read a continuation line if it is broken.
try: try:
@ -374,7 +393,7 @@ class SMTP:
errcode = -1 errcode = -1
break break
# Check if multiline response. # Check if multiline response.
if line[3:4]!="-": if line[3:4] != "-":
break break
errmsg = "\n".join(resp) errmsg = "\n".join(resp)
@ -384,7 +403,7 @@ class SMTP:
def docmd(self, cmd, args=""): def docmd(self, cmd, args=""):
"""Send a command, and return its response code.""" """Send a command, and return its response code."""
self.putcmd(cmd,args) self.putcmd(cmd, args)
return self.getreply() return self.getreply()
# std smtp commands # std smtp commands
@ -394,9 +413,9 @@ class SMTP:
host. host.
""" """
self.putcmd("helo", name or self.local_hostname) self.putcmd("helo", name or self.local_hostname)
(code,msg)=self.getreply() (code, msg) = self.getreply()
self.helo_resp=msg self.helo_resp = msg
return (code,msg) return (code, msg)
def ehlo(self, name=''): def ehlo(self, name=''):
""" SMTP 'ehlo' command. """ SMTP 'ehlo' command.
@ -405,19 +424,19 @@ class SMTP:
""" """
self.esmtp_features = {} self.esmtp_features = {}
self.putcmd(self.ehlo_msg, name or self.local_hostname) self.putcmd(self.ehlo_msg, name or self.local_hostname)
(code,msg)=self.getreply() (code, msg) = self.getreply()
# According to RFC1869 some (badly written) # According to RFC1869 some (badly written)
# MTA's will disconnect on an ehlo. Toss an exception if # MTA's will disconnect on an ehlo. Toss an exception if
# that happens -ddm # that happens -ddm
if code == -1 and len(msg) == 0: if code == -1 and len(msg) == 0:
self.close() self.close()
raise SMTPServerDisconnected("Server not connected") raise SMTPServerDisconnected("Server not connected")
self.ehlo_resp=msg self.ehlo_resp = msg
if code != 250: if code != 250:
return (code,msg) return (code, msg)
self.does_esmtp=1 self.does_esmtp = 1
# parse the ehlo response -ddm # parse the ehlo response -ddm
resp=self.ehlo_resp.split('\n') resp = self.ehlo_resp.split('\n')
del resp[0] del resp[0]
for each in resp: for each in resp:
# To be able to communicate with as many SMTP servers as possible, # To be able to communicate with as many SMTP servers as possible,
@ -437,16 +456,16 @@ class SMTP:
# It's actually stricter, in that only spaces are allowed between # It's actually stricter, in that only spaces are allowed between
# parameters, but were not going to check for that here. Note # parameters, but were not going to check for that here. Note
# that the space isn't present if there are no parameters. # that the space isn't present if there are no parameters.
m=re.match(r'(?P<feature>[A-Za-z0-9][A-Za-z0-9\-]*) ?',each) m = re.match(r'(?P<feature>[A-Za-z0-9][A-Za-z0-9\-]*) ?', each)
if m: if m:
feature=m.group("feature").lower() feature = m.group("feature").lower()
params=m.string[m.end("feature"):].strip() params = m.string[m.end("feature"):].strip()
if feature == "auth": if feature == "auth":
self.esmtp_features[feature] = self.esmtp_features.get(feature, "") \ self.esmtp_features[feature] = self.esmtp_features.get(feature, "") \
+ " " + params + " " + params
else: else:
self.esmtp_features[feature]=params self.esmtp_features[feature] = params
return (code,msg) return (code, msg)
def has_extn(self, opt): def has_extn(self, opt):
"""Does the server support a given SMTP service extension?""" """Does the server support a given SMTP service extension?"""
@ -466,23 +485,23 @@ class SMTP:
"""SMTP 'noop' command -- doesn't do anything :>""" """SMTP 'noop' command -- doesn't do anything :>"""
return self.docmd("noop") return self.docmd("noop")
def mail(self,sender,options=[]): def mail(self, sender, options=[]):
"""SMTP 'mail' command -- begins mail xfer session.""" """SMTP 'mail' command -- begins mail xfer session."""
optionlist = '' optionlist = ''
if options and self.does_esmtp: if options and self.does_esmtp:
optionlist = ' ' + ' '.join(options) optionlist = ' ' + ' '.join(options)
self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender) ,optionlist)) self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender), optionlist))
return self.getreply() return self.getreply()
def rcpt(self,recip,options=[]): def rcpt(self, recip, options=[]):
"""SMTP 'rcpt' command -- indicates 1 recipient for this mail.""" """SMTP 'rcpt' command -- indicates 1 recipient for this mail."""
optionlist = '' optionlist = ''
if options and self.does_esmtp: if options and self.does_esmtp:
optionlist = ' ' + ' '.join(options) optionlist = ' ' + ' '.join(options)
self.putcmd("rcpt","TO:%s%s" % (quoteaddr(recip),optionlist)) self.putcmd("rcpt", "TO:%s%s" % (quoteaddr(recip), optionlist))
return self.getreply() return self.getreply()
def data(self,msg): def data(self, msg):
"""SMTP 'DATA' command -- sends message data to server. """SMTP 'DATA' command -- sends message data to server.
Automatically quotes lines beginning with a period per rfc821. Automatically quotes lines beginning with a period per rfc821.
@ -491,32 +510,32 @@ class SMTP:
response code received when the all data is sent. response code received when the all data is sent.
""" """
self.putcmd("data") self.putcmd("data")
(code,repl)=self.getreply() (code, repl) = self.getreply()
if self.debuglevel >0 : if self.debuglevel > 0:
self.debug("data:", (code,repl)) self.debug("data:", (code, repl))
if code != 354: if code != 354:
raise SMTPDataError(code,repl) raise SMTPDataError(code, repl)
else: else:
q = quotedata(msg) q = quotedata(msg)
if q[-2:] != CRLF: if q[-2:] != CRLF:
q = q + CRLF q = q + CRLF
q = q + "." + CRLF q = q + "." + CRLF
self.send(q) self.send(q)
(code,msg)=self.getreply() (code, msg) = self.getreply()
if self.debuglevel > 0 : if self.debuglevel > 0 :
self.debug("data:", (code,msg)) self.debug("data:", (code, msg))
return (code,msg) return (code, msg)
def verify(self, address): def verify(self, address):
"""SMTP 'verify' command -- checks for address validity.""" """SMTP 'verify' command -- checks for address validity."""
self.putcmd("vrfy", quoteaddr(address)) self.putcmd("vrfy", _addr_only(address))
return self.getreply() return self.getreply()
# a.k.a. # a.k.a.
vrfy=verify vrfy = verify
def expn(self, address): def expn(self, address):
"""SMTP 'expn' command -- expands a mailing list.""" """SMTP 'expn' command -- expands a mailing list."""
self.putcmd("expn", quoteaddr(address)) self.putcmd("expn", _addr_only(address))
return self.getreply() return self.getreply()
# some useful methods # some useful methods
@ -725,22 +744,22 @@ class SMTP:
for option in mail_options: for option in mail_options:
esmtp_opts.append(option) esmtp_opts.append(option)
(code,resp) = self.mail(from_addr, esmtp_opts) (code, resp) = self.mail(from_addr, esmtp_opts)
if code != 250: if code != 250:
self.rset() self.rset()
raise SMTPSenderRefused(code, resp, from_addr) raise SMTPSenderRefused(code, resp, from_addr)
senderrs={} senderrs = {}
if isinstance(to_addrs, basestring): if isinstance(to_addrs, basestring):
to_addrs = [to_addrs] to_addrs = [to_addrs]
for each in to_addrs: for each in to_addrs:
(code,resp)=self.rcpt(each, rcpt_options) (code, resp) = self.rcpt(each, rcpt_options)
if (code != 250) and (code != 251): if (code != 250) and (code != 251):
senderrs[each]=(code,resp) senderrs[each] = (code,resp)
if len(senderrs)==len(to_addrs): if len(senderrs) == len(to_addrs):
# the server refused all our recipients # the server refused all our recipients
self.rset() self.rset()
raise SMTPRecipientsRefused(senderrs) raise SMTPRecipientsRefused(senderrs)
(code,resp) = self.data(msg) (code, resp) = self.data(msg)
if code != 250: if code != 250:
self.rset() self.rset()
raise SMTPDataError(code, resp) raise SMTPDataError(code, resp)
@ -749,16 +768,24 @@ class SMTP:
def close(self): def close(self):
"""Close the connection to the SMTP server.""" """Close the connection to the SMTP server."""
if self.file: try:
self.file.close() file = self.file
self.file = None self.file = None
if self.sock: if file:
self.sock.close() file.close()
finally:
sock = self.sock
self.sock = None self.sock = None
if sock:
sock.close()
def quit(self): def quit(self):
"""Terminate the SMTP session.""" """Terminate the SMTP session."""
res = self.docmd("quit") res = self.docmd("quit")
# A new EHLO is required after reconnecting with connect()
self.ehlo_resp = self.helo_resp = None
self.esmtp_features = {}
self.does_esmtp = False
self.close() self.close()
return res return res
@ -772,15 +799,17 @@ if _have_ssl:
are also optional - they can contain a PEM formatted private key and are also optional - they can contain a PEM formatted private key and
certificate chain file for the SSL connection. certificate chain file for the SSL connection.
""" """
default_port = SMTP_SSL_PORT
def __init__(self, host='', port=0, local_hostname=None, def __init__(self, host='', port=0, local_hostname=None,
keyfile=None, certfile=None, keyfile=None, certfile=None,
timeout=socket._GLOBAL_DEFAULT_TIMEOUT, timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
debug_to=partial(print, file=sys.stderr)): debug_to=partial(print, file=stderr)):
self.keyfile = keyfile self.keyfile = keyfile
self.certfile = certfile self.certfile = certfile
SMTP.__init__(self, host, port, local_hostname, timeout, SMTP.__init__(self, host, port, local_hostname, timeout,
debug_to=debug_to) debug_to=debug_to)
self.default_port = SMTP_SSL_PORT
def _get_socket(self, host, port, timeout): def _get_socket(self, host, port, timeout):
if self.debuglevel > 0: if self.debuglevel > 0:
@ -825,18 +854,40 @@ class LMTP(SMTP):
try: try:
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(host) self.sock.connect(host)
except socket.error as msg: except socket.error:
if self.debuglevel > 0: if self.debuglevel > 0:
self.debug('connect fail:', host) self.debug('connect fail:', host)
if self.sock: if self.sock:
self.sock.close() self.sock.close()
self.sock = None self.sock = None
raise socket.error(msg) raise
(code, msg) = self.getreply() (code, msg) = self.getreply()
if self.debuglevel > 0: if self.debuglevel > 0:
self.debug("connect:", msg) self.debug("connect:", msg)
return (code, msg) return (code, msg)
# Test the sendmail method, which tests most of the others.
# Note: This always sends to localhost.
if __name__ == '__main__':
import sys
def prompt(prompt):
sys.stdout.write(prompt + ": ")
return sys.stdin.readline().strip()
fromaddr = prompt("From")
toaddrs = prompt("To").split(',')
print ("Enter message, end with ^D:")
msg = ''
while 1:
line = sys.stdin.readline()
if not line:
break
msg = msg + line
print ("Message length is %d" % len(msg))
server = SMTP('localhost')
server.set_debuglevel(1)
server.sendmail(fromaddr, toaddrs, msg)
server.quit()