22
33'''SMTP/ESMTP client class.
44
5- This should follow RFC 821 (SMTP) and RFC 1869 (ESMTP).
5+ This should follow RFC 821 (SMTP), RFC 1869 (ESMTP) and RFC 2554 (SMTP
6+ Authentication).
67
78Notes:
89
3637# Eric S. Raymond <[email protected] > 3738# Better RFC 821 compliance (MAIL and RCPT, and CRLF in data)
3839# by Carey Evans <[email protected] >, for picky mail servers. 40+ # RFC 2554 (authentication) support by Gerhard Haering <[email protected] >. 3941#
4042# This was modified from the Python 1.5 library HTTP lib.
4143
4244import socket
4345import re
4446import rfc822
4547import types
48+ import base64
49+ import hmac
4650
4751__all__ = ["SMTPException" ,"SMTPServerDisconnected" ,"SMTPResponseException" ,
4852 "SMTPSenderRefused" ,"SMTPRecipientsRefused" ,"SMTPDataError" ,
49- "SMTPConnectError" ,"SMTPHeloError" ,"quoteaddr" , "quotedata " ,
50- "SMTP" ]
53+ "SMTPConnectError" ,"SMTPHeloError" ,"SMTPAuthenticationError " ,
54+ "quoteaddr" , "quotedata" , " SMTP" ]
5155
5256SMTP_PORT = 25
5357CRLF = "\r \n "
@@ -80,6 +84,7 @@ def __init__(self, code, msg):
8084
8185class SMTPSenderRefused (SMTPResponseException ):
8286 """Sender address refused.
87+
8388 In addition to the attributes set by on all SMTPResponseException
8489 exceptions, this sets `sender' to the string that the SMTP refused.
8590 """
@@ -92,6 +97,7 @@ def __init__(self, code, msg, sender):
9297
9398class SMTPRecipientsRefused (SMTPException ):
9499 """All recipient addresses refused.
100+
95101 The errors for each recipient are accessible through the attribute
96102 'recipients', which is a dictionary of exactly the same sort as
97103 SMTP.sendmail() returns.
@@ -111,6 +117,12 @@ class SMTPConnectError(SMTPResponseException):
111117class SMTPHeloError (SMTPResponseException ):
112118 """The server refused our HELO reply."""
113119
120+ class SMTPAuthenticationError (SMTPResponseException ):
121+ """Authentication error.
122+
123+ Most probably the server didn't accept the username/password
124+ combination provided.
125+ """
114126
115127def quoteaddr (addr ):
116128 """Quote a subset of the email addresses defined by RFC 821.
@@ -416,6 +428,84 @@ def expn(self, address):
416428 return self .getreply ()
417429
418430 # some useful methods
431+
432+ def login (self , user , password ):
433+ """Log in on an SMTP server that requires authentication.
434+
435+ The arguments are:
436+ - user: The user name to authenticate with.
437+ - password: The password for the authentication.
438+
439+ If there has been no previous EHLO or HELO command this session, this
440+ method tries ESMTP EHLO first.
441+
442+ This method will return normally if the authentication was successful.
443+
444+ This method may raise the following exceptions:
445+
446+ SMTPHeloError The server didn't reply properly to
447+ the helo greeting.
448+ SMTPAuthenticationError The server didn't accept the username/
449+ password combination.
450+ SMTPError No suitable authentication method was
451+ found.
452+ """
453+
454+ def encode_cram_md5 (challenge , user , password ):
455+ challenge = base64 .decodestring (challenge )
456+ response = user + " " + hmac .HMAC (password , challenge ).hexdigest ()
457+ return base64 .encodestring (response )[:- 1 ]
458+
459+ def encode_plain (user , password ):
460+ return base64 .encodestring ("%s\0 %s\0 %s" %
461+ (user , user , password ))[:- 1 ]
462+
463+ AUTH_PLAIN = "PLAIN"
464+ AUTH_CRAM_MD5 = "CRAM-MD5"
465+
466+ if self .helo_resp is None and self .ehlo_resp is None :
467+ if not (200 <= self .ehlo ()[0 ] <= 299 ):
468+ (code , resp ) = self .helo ()
469+ if not (200 <= code <= 299 ):
470+ raise SMTPHeloError (code , resp )
471+
472+ if not self .has_extn ("auth" ):
473+ raise SMTPException ("SMTP AUTH extension not supported by server." )
474+
475+ # Authentication methods the server supports:
476+ authlist = self .esmtp_features ["auth" ].split ()
477+
478+ # List of authentication methods we support: from preferred to
479+ # less preferred methods. Except for the purpose of testing the weaker
480+ # ones, we prefer stronger methods like CRAM-MD5:
481+ preferred_auths = [AUTH_CRAM_MD5 , AUTH_PLAIN ]
482+ #preferred_auths = [AUTH_PLAIN, AUTH_CRAM_MD5]
483+
484+ # Determine the authentication method we'll use
485+ authmethod = None
486+ for method in preferred_auths :
487+ if method in authlist :
488+ authmethod = method
489+ break
490+ if self .debuglevel > 0 : print "AuthMethod:" , authmethod
491+
492+ if authmethod == AUTH_CRAM_MD5 :
493+ (code , resp ) = self .docmd ("AUTH" , AUTH_CRAM_MD5 )
494+ if code == 503 :
495+ # 503 == 'Error: already authenticated'
496+ return (code , resp )
497+ (code , resp ) = self .docmd (encode_cram_md5 (resp , user , password ))
498+ elif authmethod == AUTH_PLAIN :
499+ (code , resp ) = self .docmd ("AUTH" ,
500+ AUTH_PLAIN + " " + encode_plain (user , password ))
501+ elif authmethod == None :
502+ raise SMTPError ("No suitable authentication method found." )
503+ if code not in [235 , 503 ]:
504+ # 235 == 'Authentication successful'
505+ # 503 == 'Error: already authenticated'
506+ raise SMTPAuthenticationError (code , resp )
507+ return (code , resp )
508+
419509 def sendmail (self , from_addr , to_addrs , msg , mail_options = [],
420510 rcpt_options = []):
421511 """This command performs an entire mail transaction.
0 commit comments