Commits

Anonymous committed 61bec1e

Changes by Per Cederquist and The Dragon.

Per writes:

"""
The application where Signum Support uses smtplib needs to be able to
report good error messages to the user when sending email fails. To
help in diagnosing problems it is useful to be able to report the
entire message sent by the server, not only the SMTP error code of the
offending command.

A lot of the functions in sendmail.py unfortunately discards the
message, leaving only the code. The enclosed patch fixes that
problem.

The enclosed patch also introduces a base class for exceptions that
include an SMTP error code and error message, and make the code and
message available on separate attributes, so that surrounding code can
deal with them in whatever way it sees fit. I've also added some
documentation to the exception classes.

The constructor will now raise an exception if it cannot connect to
the SMTP server.

The data() method will raise an SMTPDataError if it doesn't receive
the expected 354 code in the middle of the exchange.

According to section 5.2.10 of RFC 1123 a smtp client must accept "any
text, including no text at all" after the error code. If the response
of a HELO command contains no text self.helo_resp will be set to the
empty string (""). The patch fixes the test in the sendmail() method
so that helo_resp is tested against None; if it has the empty string
as value the sendmail() method would invoke the helo() method again.

The code no longer accepts a -1 reply from the ehlo() method in
sendmail().

[Text about removing SMTPRecipientsRefused deleted --GvR]
"""

and also:

"""
smtplib.py appends an extra blank line to the outgoing mail if the
`msg' argument to the sendmail method already contains a trailing
newline. This patch should fix the problem.
"""

The Dragon writes:

"""
Mostly I just re-added the SMTPRecipientsRefused exception
(the exeption object now has the appropriate info in it ) [Per had
removed this in his patch --GvR] and tweaked the behavior of the
sendmail method whence it throws the newly added SMTPHeloException (it
was closing the connection, which it shouldn't. whatever catches the
exception should do that. )

I pondered the change of the return values to tuples all around,
and after some thinking I decided that regularizing the return values was
too much of the Right Thing (tm) to not do.

My one concern is that code expecting an integer & getting a tuple
may fail silently.

(i.e. if it's doing :

x.somemethod() >= 400:
expecting an integer, the expression will always be true if it gets a
tuple instead. )

However, most smtplib code I've seen only really uses the
sendmail() method, so this wouldn't bother it. Usually code I've seen
that calls the other methods usually only calls helo() and ehlo() for
doing ESMTP, a feature which was not in the smtplib included with 1.5.1,
and thus I would think not much code uses it yet.
"""

Comments (0)

Files changed (1)

 SMTP_PORT = 25
 CRLF="\r\n"
 
-# used for exceptions 
-class SMTPException(Exception): pass
-class SMTPServerDisconnected(SMTPException): pass
-class SMTPSenderRefused(SMTPException): pass
-class SMTPRecipientsRefused(SMTPException): pass
-class SMTPDataError(SMTPException): pass
+# Exception classes used by this module. 
+class SMTPException(Exception):
+    """Base class for all exceptions raised by this module."""
+
+class SMTPServerDisconnected(SMTPException):
+    """Not connected to any SMTP server.
+
+    This exception is raised when the server unexpectedly disconnects,
+    or when an attempt is made to use the SMTP instance before
+    connecting it to a server.
+    """
+
+class SMTPResponseException(SMTPException):
+    """Base class for all exceptions that include an SMTP error code.
+
+    These exceptions are generated in some instances when the SMTP
+    server returns an error code.  The error code is stored in the
+    `smtp_code' attribute of the error, and the `smtp_error' attribute
+    is set to the error message.
+    """
+
+    def __init__(self, code, msg):
+        self.smtp_code = code
+        self.smtp_error = msg
+        self.args = (code, msg)
+
+class SMTPSenderRefused(SMTPResponseException):
+    """Sender address refused.
+    In addition to the attributes set by on all SMTPResponseException
+    exceptions, this sets `sender' to the string that the SMTP refused
+    """
+
+    def __init__(self, code, msg, sender):
+        self.smtp_code = code
+        self.smtp_error = msg
+        self.sender = sender
+        self.args = (code, msg, sender)
+
+class SMTPRecipientsRefused(SMTPResponseException):
+    """All recipient  addresses refused.
+    The errors for each recipient are accessable thru the attribute
+    'recipients', which is a dictionary of exactly the same sort as 
+    SMTP.sendmail() returns.  
+    """
+
+    def __init__(self, recipients):
+        self.recipients = recipients
+        self.args = ( recipients,)
+
+
+
+class SMTPDataError(SMTPResponseException):
+    """The SMTP server didn't accept the data."""
+
+class SMTPConnectError(SMTPResponseException):
+    """Error during connection establishment"""
+
+class SMTPHeloError(SMTPResponseException):
+    """The server refused our HELO reply"""
 
 def quoteaddr(addr):
     """Quote a subset of the email addresses defined by RFC 821.
 
         If specified, `host' is the name of the remote host to which to
         connect.  If specified, `port' specifies the port to which to connect.
-        By default, smtplib.SMTP_PORT is used.
+        By default, smtplib.SMTP_PORT is used.  An SMTPConnectError is
+        raised if the specified `host' doesn't respond correctly.
 
         """
         self.esmtp_features = {}
-        if host: self.connect(host, port)
+        if host:
+            (code, msg) = self.connect(host, port)
+            if code != 220:
+                raise SMTPConnectError(code, msg)
     
     def set_debuglevel(self, debuglevel):
         """Set the debug output level.
         self.sock.connect(host, port)
         (code,msg)=self.getreply()
         if self.debuglevel >0 : print "connect:", msg
-        return msg
+        return (code,msg)
     
     def send(self, str):
         """Send `str' to the server."""
         Raises SMTPServerDisconnected if end-of-file is reached.
         """
         resp=[]
-	if self.file is None:
-	    self.file = self.sock.makefile('rb')
+        if self.file is None:
+            self.file = self.sock.makefile('rb')
         while 1:
             line = self.file.readline()
-	    if line == '':
-		self.close()
-		raise SMTPServerDisconnected("Connection unexpectedly closed")
+            if line == '':
+                self.close()
+                raise SMTPServerDisconnected("Connection unexpectedly closed")
             if self.debuglevel > 0: print 'reply:', `line`
             resp.append(string.strip(line[4:]))
             code=line[:3]
-	    # Check that the error code is syntactically correct.
-	    # Don't attempt to read a continuation line if it is broken.
-	    try:
-		errcode = string.atoi(code)
-	    except ValueError:
-		errcode = -1
-		break
+            # Check that the error code is syntactically correct.
+            # Don't attempt to read a continuation line if it is broken.
+            try:
+                errcode = string.atoi(code)
+            except ValueError:
+                errcode = -1
+                break
             # Check if multiline response.
             if line[3:4]!="-":
                 break
     def docmd(self, cmd, args=""):
         """Send a command, and return its response code."""
         self.putcmd(cmd,args)
-        (code,msg)=self.getreply()
-        return code
+        return self.getreply()
 
     # std smtp commands
     def helo(self, name=''):
         self.putcmd("helo",name)
         (code,msg)=self.getreply()
         self.helo_resp=msg
-        return code
+        return (code,msg)
 
     def ehlo(self, name=''):
         """ SMTP 'ehlo' command.
             raise SMTPServerDisconnected("Server not connected")
         self.ehlo_resp=msg
         if code<>250:
-            return code
+            return (code,msg)
         self.does_esmtp=1
         #parse the ehlo responce -ddm
         resp=string.split(self.ehlo_resp,'\n')
                 feature=string.lower(m.group("feature"))
                 params=string.strip(m.string[m.end("feature"):])
                 self.esmtp_features[feature]=params
-        return code
+        return (code,msg)
 
     def has_extn(self, opt):
         """Does the server support a given SMTP service extension?"""
         """SMTP 'help' command.
         Returns help text from server."""
         self.putcmd("help", args)
-        (code,msg)=self.getreply()
-        return msg
+        return self.getreply()
 
     def rset(self):
         """SMTP 'rset' command -- resets session."""
-        code=self.docmd("rset")
-        return code
+        return self.docmd("rset")
 
     def noop(self):
         """SMTP 'noop' command -- doesn't do anything :>"""
-        code=self.docmd("noop")
-        return code
+        return self.docmd("noop")
 
     def mail(self,sender,options=[]):
         """SMTP 'mail' command -- begins mail xfer session."""
 
     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.
         """
         self.putcmd("data")
         (code,repl)=self.getreply()
         if self.debuglevel >0 : print "data:", (code,repl)
         if code <> 354:
-            return -1
+            raise SMTPDataError(code,repl)
         else:
             self.send(quotedata(msg))
             self.send("%s.%s" % (CRLF, CRLF))
             (code,msg)=self.getreply()
             if self.debuglevel >0 : print "data:", (code,msg)
-            return code
+            return (code,msg)
 
     def verify(self, address):
         """SMTP 'verify' command -- checks for address validity."""
         fails, HELO will be tried and ESMTP options suppressed.
 
         This method will return normally if the mail is accepted for at least
-        one recipient.  Otherwise it will throw an exception (either
-        SMTPSenderRefused, SMTPRecipientsRefused, or SMTPDataError) That is,
-        if this method does not throw an exception, then someone should get
-        your mail.  If this method does not throw an exception, it returns a
-        dictionary, with one entry for each recipient that was refused.
+        one recipient. It returns a dictionary, with one entry for each
+        recipient that was refused.  Each entry contains a tuple of
+        the SMTP error code and the accompanying error message sent by
+        the server.
+
+        This method may raise the following exceptions:
+
+         SMTPHeloError          The server didn't reply properly to
+                                the helo greeting. 
+         SMTPRecipientsRefused  The server rejected for ALL recipients
+                                (no mail was sent).
+         SMTPSenderRefused      The server didn't accept the from_addr.
+         SMTPDataError          The server replied with an unexpected
+                                error code (other than a refusal of
+                                a recipient).
+
+        Note: the connection will be open even after an exception is raised.
 
         Example:
       
         empty dictionary.
 
         """
-        if not self.helo_resp and not self.ehlo_resp:
-            if self.ehlo() >= 400:
-                self.helo()
+        if self.helo_resp is None and self.ehlo_resp is None:
+            if not (200 <= self.ehlo()[0] <= 299):
+                (code,resp) = self.helo()
+                if not (200 <= code <= 299):
+                    raise SMTPHeloError(code, resp)
         esmtp_opts = []
         if self.does_esmtp:
             # Hmmm? what's this? -ddm
         (code,resp) = self.mail(from_addr, esmtp_opts)
         if code <> 250:
             self.rset()
-            raise SMTPSenderRefused('%s: %s' % (from_addr, resp))
+            raise SMTPSenderRefused(code, resp, from_addr)
         senderrs={}
         if type(to_addrs) == types.StringType:
             to_addrs = [to_addrs]
         if len(senderrs)==len(to_addrs):
             # the server refused all our recipients
             self.rset()
-            raise SMTPRecipientsRefused(string.join(
-		map(lambda x:"%s: %s" % (x[0], x[1][1]), senderrs.items()),
-					'; '))
-        code=self.data(msg)
-        if code <>250 :
+            raise SMTPRecipientsRefused(senderrs)
+        (code,resp)=self.data(msg)
+        if code <> 250:
             self.rset()
-            raise SMTPDataError('data transmission error: %s' % code)
+            raise SMTPDataError(code, resp)
         #if we got here then somebody got our mail
         return senderrs