diff options
Diffstat (limited to 'Lib/smtplib.py')
-rwxr-xr-x | Lib/smtplib.py | 250 |
1 files changed, 154 insertions, 96 deletions
diff --git a/Lib/smtplib.py b/Lib/smtplib.py index a01a184c9f..14e62504d3 100755 --- a/Lib/smtplib.py +++ b/Lib/smtplib.py @@ -1,4 +1,4 @@ -#! /usr/bin/env python +#! /usr/bin/env python3 '''SMTP/ESMTP client class. @@ -42,21 +42,25 @@ Example: # 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 from sys import stderr -__all__ = ["SMTPException","SMTPServerDisconnected","SMTPResponseException", - "SMTPSenderRefused","SMTPRecipientsRefused","SMTPDataError", - "SMTPConnectError","SMTPHeloError","SMTPAuthenticationError", - "quoteaddr","quotedata","SMTP"] +__all__ = ["SMTPException", "SMTPServerDisconnected", "SMTPResponseException", + "SMTPSenderRefused", "SMTPRecipientsRefused", "SMTPDataError", + "SMTPConnectError", "SMTPHeloError", "SMTPAuthenticationError", + "quoteaddr", "quotedata", "SMTP"] SMTP_PORT = 25 SMTP_SSL_PORT = 465 -CRLF="\r\n" +CRLF = "\r\n" +bCRLF = b"\r\n" OLDSTYLE_AUTH = re.compile(r"auth=(.*)", re.I) @@ -109,7 +113,7 @@ class SMTPRecipientsRefused(SMTPException): def __init__(self, recipients): self.recipients = recipients - self.args = ( recipients,) + self.args = (recipients,) class SMTPDataError(SMTPResponseException): @@ -138,7 +142,7 @@ def quoteaddr(addr): m = email.utils.parseaddr(addr)[1] except AttributeError: pass - if m == (None, None): # Indicates parse failure or AttributeError + if m == (None, None): # Indicates parse failure or AttributeError # something weird here.. punt -ddm return "<%s>" % addr elif m is None: @@ -147,6 +151,7 @@ def quoteaddr(addr): else: return "<%s>" % m +# Legacy method kept for backward compatibility. def quotedata(data): """Quote data for email. @@ -156,6 +161,12 @@ def quotedata(data): 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: @@ -174,7 +185,8 @@ else: chr = None while chr != b"\n": chr = self.sslobj.read(1) - if not chr: break + if not chr: + break str += chr return str @@ -269,10 +281,11 @@ class SMTP: def _get_socket(self, host, port, timeout): # This makes it simpler for SMTP_SSL to use the SMTP connect code # and just alter the socket connection bit. - if self.debuglevel > 0: print('connect:', (host, port), file=stderr) + if self.debuglevel > 0: + print('connect:', (host, port), file=stderr) return socket.create_connection((host, port), timeout) - def connect(self, host='localhost', port = 0): + def connect(self, host='localhost', port=0): """Connect to a host on a given port. If the hostname ends with a colon (`:') followed by a number, and @@ -286,20 +299,25 @@ class SMTP: if not port and (host.find(':') == host.rfind(':')): i = host.rfind(':') if i >= 0: - host, port = host[:i], host[i+1:] - try: port = int(port) + host, port = host[:i], host[i + 1:] + try: + port = int(port) except ValueError: raise socket.error("nonnumeric port") - if not port: port = self.default_port - if self.debuglevel > 0: print('connect:', (host, port), file=stderr) + if not port: + port = self.default_port + if self.debuglevel > 0: + print('connect:', (host, port), file=stderr) self.sock = self._get_socket(host, port, self.timeout) (code, msg) = self.getreply() - if self.debuglevel > 0: print("connect:", msg, file=stderr) + if self.debuglevel > 0: + print("connect:", msg, file=stderr) return (code, msg) def send(self, s): """Send `s' to the server.""" - if self.debuglevel > 0: print('send:', repr(s), file=stderr) + if self.debuglevel > 0: + print('send:', repr(s), file=stderr) if hasattr(self, 'sock') and self.sock: if isinstance(s, str): s = s.encode("ascii") @@ -332,7 +350,7 @@ class SMTP: Raises SMTPServerDisconnected if end-of-file is reached. """ - resp=[] + resp = [] if self.file is None: self.file = self.sock.makefile('rb') while 1: @@ -343,9 +361,10 @@ class SMTP: if not line: self.close() raise SMTPServerDisconnected("Connection unexpectedly closed") - if self.debuglevel > 0: print('reply:', repr(line), file=stderr) + if self.debuglevel > 0: + print('reply:', repr(line), file=stderr) resp.append(line[4:].strip(b' \t\r\n')) - code=line[:3] + code = line[:3] # Check that the error code is syntactically correct. # Don't attempt to read a continuation line if it is broken. try: @@ -359,12 +378,12 @@ class SMTP: errmsg = b"\n".join(resp) if self.debuglevel > 0: - print('reply: retcode (%s); Msg: %s' % (errcode,errmsg), file=stderr) + print('reply: retcode (%s); Msg: %s' % (errcode, errmsg), file=stderr) return errcode, errmsg def docmd(self, cmd, args=""): """Send a command, and return its response code.""" - self.putcmd(cmd,args) + self.putcmd(cmd, args) return self.getreply() # std smtp commands @@ -374,9 +393,9 @@ class SMTP: host. """ self.putcmd("helo", name or self.local_hostname) - (code,msg)=self.getreply() - self.helo_resp=msg - return (code,msg) + (code, msg) = self.getreply() + self.helo_resp = msg + return (code, msg) def ehlo(self, name=''): """ SMTP 'ehlo' command. @@ -385,20 +404,20 @@ class SMTP: """ self.esmtp_features = {} self.putcmd(self.ehlo_msg, name or self.local_hostname) - (code,msg)=self.getreply() + (code, msg) = self.getreply() # According to RFC1869 some (badly written) # MTA's will disconnect on an ehlo. Toss an exception if # that happens -ddm if code == -1 and len(msg) == 0: self.close() raise SMTPServerDisconnected("Server not connected") - self.ehlo_resp=msg + self.ehlo_resp = msg if code != 250: - return (code,msg) - self.does_esmtp=1 + return (code, msg) + self.does_esmtp = 1 #parse the ehlo response -ddm assert isinstance(self.ehlo_resp, bytes), repr(self.ehlo_resp) - resp=self.ehlo_resp.decode("latin-1").split('\n') + resp = self.ehlo_resp.decode("latin-1").split('\n') del resp[0] for each in resp: # To be able to communicate with as many SMTP servers as possible, @@ -418,16 +437,16 @@ class SMTP: # It's actually stricter, in that only spaces are allowed between # parameters, but were not going to check for that here. Note # 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: - feature=m.group("feature").lower() - params=m.string[m.end("feature"):].strip() + feature = m.group("feature").lower() + params = m.string[m.end("feature"):].strip() if feature == "auth": self.esmtp_features[feature] = self.esmtp_features.get(feature, "") \ + " " + params else: - self.esmtp_features[feature]=params - return (code,msg) + self.esmtp_features[feature] = params + return (code, msg) def has_extn(self, opt): """Does the server support a given SMTP service extension?""" @@ -447,51 +466,57 @@ class SMTP: """SMTP 'noop' command -- doesn't do anything :>""" return self.docmd("noop") - def mail(self,sender,options=[]): + def mail(self, sender, options=[]): """SMTP 'mail' command -- begins mail xfer session.""" optionlist = '' if options and self.does_esmtp: optionlist = ' ' + ' '.join(options) - self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender) ,optionlist)) + self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender), optionlist)) return self.getreply() - def rcpt(self,recip,options=[]): + def rcpt(self, recip, options=[]): """SMTP 'rcpt' command -- indicates 1 recipient for this mail.""" optionlist = '' if options and self.does_esmtp: optionlist = ' ' + ' '.join(options) - self.putcmd("rcpt","TO:%s%s" % (quoteaddr(recip),optionlist)) + self.putcmd("rcpt", "TO:%s%s" % (quoteaddr(recip), optionlist)) return self.getreply() - def data(self,msg): + def data(self, msg): """SMTP 'DATA' command -- sends message data to server. 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() - if self.debuglevel >0 : print("data:", (code,repl), file=stderr) + (code, repl) = self.getreply() + if self.debuglevel > 0: + print("data:", (code, repl), file=stderr) if code != 354: - raise SMTPDataError(code,repl) + 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) - return (code,msg) + (code, msg) = self.getreply() + if self.debuglevel > 0: + print("data:", (code, msg), file=stderr) + return (code, msg) def verify(self, address): """SMTP 'verify' command -- checks for address validity.""" self.putcmd("vrfy", quoteaddr(address)) return self.getreply() # a.k.a. - vrfy=verify + vrfy = verify def expn(self, address): """SMTP 'expn' command -- expands a mailing list.""" @@ -549,7 +574,6 @@ class SMTP: s = "\0%s\0%s" % (user, password) return encode_base64(s.encode('ascii'), eol='') - AUTH_PLAIN = "PLAIN" AUTH_CRAM_MD5 = "CRAM-MD5" AUTH_LOGIN = "LOGIN" @@ -559,45 +583,46 @@ class SMTP: if not self.has_extn("auth"): raise SMTPException("SMTP AUTH extension not supported by server.") - # Authentication methods the server supports: - authlist = self.esmtp_features["auth"].split() + # Authentication methods the server claims to support + advertised_authlist = self.esmtp_features["auth"].split() # List of authentication methods we support: from preferred to # less preferred methods. Except for the purpose of testing the weaker # ones, we prefer stronger methods like CRAM-MD5: preferred_auths = [AUTH_CRAM_MD5, AUTH_PLAIN, AUTH_LOGIN] - # Determine the authentication method we'll use - authmethod = None - for method in preferred_auths: - if method in authlist: - authmethod = method - break - - if authmethod == AUTH_CRAM_MD5: - (code, resp) = self.docmd("AUTH", AUTH_CRAM_MD5) - if code == 503: - # 503 == 'Error: already authenticated' - return (code, resp) - (code, resp) = self.docmd(encode_cram_md5(resp, user, password)) - elif authmethod == AUTH_PLAIN: - (code, resp) = self.docmd("AUTH", - AUTH_PLAIN + " " + encode_plain(user, password)) - elif authmethod == AUTH_LOGIN: - (code, resp) = self.docmd("AUTH", - "%s %s" % (AUTH_LOGIN, encode_base64(user.encode('ascii'), eol=''))) - if code != 334: - raise SMTPAuthenticationError(code, resp) - (code, resp) = self.docmd(encode_base64(password.encode('ascii'), eol='')) - elif authmethod is None: + # We try the authentication methods the server advertises, but only the + # ones *we* support. And in our preferred order. + authlist = [auth for auth in preferred_auths if auth in advertised_authlist] + if not authlist: raise SMTPException("No suitable authentication method found.") - if code not in (235, 503): + + # Some servers advertise authentication methods they don't really + # support, so if authentication fails, we continue until we've tried + # all methods. + for authmethod in authlist: + if authmethod == AUTH_CRAM_MD5: + (code, resp) = self.docmd("AUTH", AUTH_CRAM_MD5) + if code == 334: + (code, resp) = self.docmd(encode_cram_md5(resp, user, password)) + elif authmethod == AUTH_PLAIN: + (code, resp) = self.docmd("AUTH", + AUTH_PLAIN + " " + encode_plain(user, password)) + elif authmethod == AUTH_LOGIN: + (code, resp) = self.docmd("AUTH", + "%s %s" % (AUTH_LOGIN, encode_base64(user.encode('ascii'), eol=''))) + if code == 334: + (code, resp) = self.docmd(encode_base64(password.encode('ascii'), eol='')) + # 235 == 'Authentication successful' # 503 == 'Error: already authenticated' - raise SMTPAuthenticationError(code, resp) - return (code, resp) + if code in (235, 503): + return (code, resp) - def starttls(self, keyfile = None, certfile = None): + # We could not login sucessfully. Return result of last attempt. + raise SMTPAuthenticationError(code, resp) + + def starttls(self, keyfile=None, certfile=None): """Puts the connection to the SMTP server into TLS mode. If there has been no previous EHLO or HELO command this session, this @@ -647,6 +672,10 @@ class SMTP: - 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. A string is encoded to bytes using the ascii codec, and lone + \r and \n characters are converted to \r\n characters. + 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 @@ -692,6 +721,8 @@ class SMTP: """ self.ehlo_or_helo_if_needed() esmtp_opts = [] + if isinstance(msg, str): + msg = _fix_eols(msg).encode('ascii') if self.does_esmtp: # Hmmm? what's this? -ddm # self.esmtp_features['7bit']="" @@ -699,29 +730,54 @@ class SMTP: esmtp_opts.append("size=%d" % len(msg)) for option in mail_options: esmtp_opts.append(option) - - (code,resp) = self.mail(from_addr, esmtp_opts) + (code, resp) = self.mail(from_addr, esmtp_opts) if code != 250: self.rset() raise SMTPSenderRefused(code, resp, from_addr) - senderrs={} + senderrs = {} if isinstance(to_addrs, str): to_addrs = [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): - senderrs[each]=(code,resp) - if len(senderrs)==len(to_addrs): + senderrs[each] = (code, resp) + if len(senderrs) == len(to_addrs): # the server refused all our recipients self.rset() raise SMTPRecipientsRefused(senderrs) - (code,resp) = self.data(msg) + (code, resp) = self.data(msg) if code != 250: self.rset() raise SMTPDataError(code, resp) #if we got here then somebody got our mail return senderrs + def send_message(self, msg, from_addr=None, to_addrs=None, + mail_options=[], rcpt_options={}): + """Converts message to a bytestring and passes it to sendmail. + + The arguments are as for sendmail, except that msg is an + email.message.Message object. If from_addr is None, the from_addr is + taken from the 'From' header of the Message. If to_addrs is None, its + value is composed from the addresses listed in the 'To', 'CC', and + 'Bcc' fields. Regardless of the values of from_addr and to_addr, any + Bcc field in the Message object is deleted. The Message object is then + serialized using email.generator.BytesGenerator and sendmail is called + to transmit the 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'] + with io.BytesIO() as bytesmsg: + g = email.generator.BytesGenerator(bytesmsg) + g.flatten(msg, linesep='\r\n') + flatmsg = bytesmsg.getvalue() + return self.sendmail(from_addr, to_addrs, flatmsg, mail_options, + rcpt_options) def close(self): """Close the connection to the SMTP server.""" @@ -732,7 +788,6 @@ class SMTP: self.sock.close() self.sock = None - def quit(self): """Terminate the SMTP session.""" res = self.docmd("quit") @@ -758,7 +813,8 @@ if _have_ssl: self.default_port = SMTP_SSL_PORT def _get_socket(self, host, port, timeout): - if self.debuglevel > 0: print('connect:', (host, port), file=stderr) + if self.debuglevel > 0: + print('connect:', (host, port), file=stderr) new_socket = socket.create_connection((host, port), timeout) new_socket = ssl.wrap_socket(new_socket, self.keyfile, self.certfile) self.file = SSLFakeFile(new_socket) @@ -786,11 +842,11 @@ class LMTP(SMTP): ehlo_msg = "lhlo" - def __init__(self, host = '', port = LMTP_PORT, local_hostname = None): + def __init__(self, host='', port=LMTP_PORT, local_hostname=None): """Initialize a new instance.""" SMTP.__init__(self, host, port, local_hostname) - def connect(self, host = 'localhost', port = 0): + def connect(self, host='localhost', port=0): """Connect to the LMTP daemon, on either a Unix or a TCP socket.""" if host[0] != '/': return SMTP.connect(self, host, port) @@ -800,13 +856,15 @@ class LMTP(SMTP): self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.connect(host) except socket.error as msg: - if self.debuglevel > 0: print('connect fail:', host, file=stderr) + if self.debuglevel > 0: + print('connect fail:', host, file=stderr) if self.sock: self.sock.close() self.sock = None raise socket.error(msg) (code, msg) = self.getreply() - if self.debuglevel > 0: print('connect:', msg, file=stderr) + if self.debuglevel > 0: + print('connect:', msg, file=stderr) return (code, msg) @@ -820,7 +878,7 @@ if __name__ == '__main__': return sys.stdin.readline().strip() fromaddr = prompt("From") - toaddrs = prompt("To").split(',') + toaddrs = prompt("To").split(',') print("Enter message, end with ^D:") msg = '' while 1: |