| 1 | # -*- test-case-name: twisted.mail.test.test_smtp -*- |
|---|
| 2 | # Copyright (c) Twisted Matrix Laboratories. |
|---|
| 3 | # See LICENSE for details. |
|---|
| 4 | |
|---|
| 5 | """ |
|---|
| 6 | Simple Mail Transfer Protocol implementation. |
|---|
| 7 | """ |
|---|
| 8 | |
|---|
| 9 | import time, re, base64, types, socket, os, random, rfc822 |
|---|
| 10 | import binascii |
|---|
| 11 | from email.base64MIME import encode as encode_base64 |
|---|
| 12 | |
|---|
| 13 | from zope.interface import implements, Interface |
|---|
| 14 | |
|---|
| 15 | from twisted.copyright import longversion |
|---|
| 16 | from twisted.protocols import basic |
|---|
| 17 | from twisted.protocols import policies |
|---|
| 18 | from twisted.internet import protocol |
|---|
| 19 | from twisted.internet import defer |
|---|
| 20 | from twisted.internet import error |
|---|
| 21 | from twisted.internet import reactor |
|---|
| 22 | from twisted.internet.interfaces import ITLSTransport |
|---|
| 23 | from twisted.python import log |
|---|
| 24 | from twisted.python import util |
|---|
| 25 | |
|---|
| 26 | from twisted import cred |
|---|
| 27 | from twisted.python.runtime import platform |
|---|
| 28 | |
|---|
| 29 | try: |
|---|
| 30 | from cStringIO import StringIO |
|---|
| 31 | except ImportError: |
|---|
| 32 | from StringIO import StringIO |
|---|
| 33 | |
|---|
| 34 | # Cache the hostname (XXX Yes - this is broken) |
|---|
| 35 | if platform.isMacOSX(): |
|---|
| 36 | # On OS X, getfqdn() is ridiculously slow - use the |
|---|
| 37 | # probably-identical-but-sometimes-not gethostname() there. |
|---|
| 38 | DNSNAME = socket.gethostname() |
|---|
| 39 | else: |
|---|
| 40 | DNSNAME = socket.getfqdn() |
|---|
| 41 | |
|---|
| 42 | # Used for fast success code lookup |
|---|
| 43 | SUCCESS = dict.fromkeys(xrange(200,300)) |
|---|
| 44 | |
|---|
| 45 | class IMessageDelivery(Interface): |
|---|
| 46 | def receivedHeader(helo, origin, recipients): |
|---|
| 47 | """ |
|---|
| 48 | Generate the Received header for a message |
|---|
| 49 | |
|---|
| 50 | @type helo: C{(str, str)} |
|---|
| 51 | @param helo: The argument to the HELO command and the client's IP |
|---|
| 52 | address. |
|---|
| 53 | |
|---|
| 54 | @type origin: C{Address} |
|---|
| 55 | @param origin: The address the message is from |
|---|
| 56 | |
|---|
| 57 | @type recipients: C{list} of L{User} |
|---|
| 58 | @param recipients: A list of the addresses for which this message |
|---|
| 59 | is bound. |
|---|
| 60 | |
|---|
| 61 | @rtype: C{str} |
|---|
| 62 | @return: The full \"Received\" header string. |
|---|
| 63 | """ |
|---|
| 64 | |
|---|
| 65 | def validateTo(user): |
|---|
| 66 | """ |
|---|
| 67 | Validate the address for which the message is destined. |
|---|
| 68 | |
|---|
| 69 | @type user: C{User} |
|---|
| 70 | @param user: The address to validate. |
|---|
| 71 | |
|---|
| 72 | @rtype: no-argument callable |
|---|
| 73 | @return: A C{Deferred} which becomes, or a callable which |
|---|
| 74 | takes no arguments and returns an object implementing C{IMessage}. |
|---|
| 75 | This will be called and the returned object used to deliver the |
|---|
| 76 | message when it arrives. |
|---|
| 77 | |
|---|
| 78 | @raise SMTPBadRcpt: Raised if messages to the address are |
|---|
| 79 | not to be accepted. |
|---|
| 80 | """ |
|---|
| 81 | |
|---|
| 82 | def validateFrom(helo, origin): |
|---|
| 83 | """ |
|---|
| 84 | Validate the address from which the message originates. |
|---|
| 85 | |
|---|
| 86 | @type helo: C{(str, str)} |
|---|
| 87 | @param helo: The argument to the HELO command and the client's IP |
|---|
| 88 | address. |
|---|
| 89 | |
|---|
| 90 | @type origin: C{Address} |
|---|
| 91 | @param origin: The address the message is from |
|---|
| 92 | |
|---|
| 93 | @rtype: C{Deferred} or C{Address} |
|---|
| 94 | @return: C{origin} or a C{Deferred} whose callback will be |
|---|
| 95 | passed C{origin}. |
|---|
| 96 | |
|---|
| 97 | @raise SMTPBadSender: Raised of messages from this address are |
|---|
| 98 | not to be accepted. |
|---|
| 99 | """ |
|---|
| 100 | |
|---|
| 101 | class IMessageDeliveryFactory(Interface): |
|---|
| 102 | """An alternate interface to implement for handling message delivery. |
|---|
| 103 | |
|---|
| 104 | It is useful to implement this interface instead of L{IMessageDelivery} |
|---|
| 105 | directly because it allows the implementor to distinguish between |
|---|
| 106 | different messages delivery over the same connection. This can be |
|---|
| 107 | used to optimize delivery of a single message to multiple recipients, |
|---|
| 108 | something which cannot be done by L{IMessageDelivery} implementors |
|---|
| 109 | due to their lack of information. |
|---|
| 110 | """ |
|---|
| 111 | def getMessageDelivery(): |
|---|
| 112 | """Return an L{IMessageDelivery} object. |
|---|
| 113 | |
|---|
| 114 | This will be called once per message. |
|---|
| 115 | """ |
|---|
| 116 | |
|---|
| 117 | class SMTPError(Exception): |
|---|
| 118 | pass |
|---|
| 119 | |
|---|
| 120 | |
|---|
| 121 | |
|---|
| 122 | class SMTPClientError(SMTPError): |
|---|
| 123 | """Base class for SMTP client errors. |
|---|
| 124 | """ |
|---|
| 125 | def __init__(self, code, resp, log=None, addresses=None, isFatal=False, retry=False): |
|---|
| 126 | """ |
|---|
| 127 | @param code: The SMTP response code associated with this error. |
|---|
| 128 | @param resp: The string response associated with this error. |
|---|
| 129 | |
|---|
| 130 | @param log: A string log of the exchange leading up to and including |
|---|
| 131 | the error. |
|---|
| 132 | @type log: L{str} |
|---|
| 133 | |
|---|
| 134 | @param isFatal: A boolean indicating whether this connection can |
|---|
| 135 | proceed or not. If True, the connection will be dropped. |
|---|
| 136 | |
|---|
| 137 | @param retry: A boolean indicating whether the delivery should be |
|---|
| 138 | retried. If True and the factory indicates further retries are |
|---|
| 139 | desirable, they will be attempted, otherwise the delivery will |
|---|
| 140 | be failed. |
|---|
| 141 | """ |
|---|
| 142 | self.code = code |
|---|
| 143 | self.resp = resp |
|---|
| 144 | self.log = log |
|---|
| 145 | self.addresses = addresses |
|---|
| 146 | self.isFatal = isFatal |
|---|
| 147 | self.retry = retry |
|---|
| 148 | |
|---|
| 149 | |
|---|
| 150 | def __str__(self): |
|---|
| 151 | if self.code > 0: |
|---|
| 152 | res = ["%.3d %s" % (self.code, self.resp)] |
|---|
| 153 | else: |
|---|
| 154 | res = [self.resp] |
|---|
| 155 | if self.log: |
|---|
| 156 | res.append(self.log) |
|---|
| 157 | res.append('') |
|---|
| 158 | return '\n'.join(res) |
|---|
| 159 | |
|---|
| 160 | |
|---|
| 161 | class ESMTPClientError(SMTPClientError): |
|---|
| 162 | """Base class for ESMTP client errors. |
|---|
| 163 | """ |
|---|
| 164 | |
|---|
| 165 | class EHLORequiredError(ESMTPClientError): |
|---|
| 166 | """The server does not support EHLO. |
|---|
| 167 | |
|---|
| 168 | This is considered a non-fatal error (the connection will not be |
|---|
| 169 | dropped). |
|---|
| 170 | """ |
|---|
| 171 | |
|---|
| 172 | class AUTHRequiredError(ESMTPClientError): |
|---|
| 173 | """Authentication was required but the server does not support it. |
|---|
| 174 | |
|---|
| 175 | This is considered a non-fatal error (the connection will not be |
|---|
| 176 | dropped). |
|---|
| 177 | """ |
|---|
| 178 | |
|---|
| 179 | class TLSRequiredError(ESMTPClientError): |
|---|
| 180 | """Transport security was required but the server does not support it. |
|---|
| 181 | |
|---|
| 182 | This is considered a non-fatal error (the connection will not be |
|---|
| 183 | dropped). |
|---|
| 184 | """ |
|---|
| 185 | |
|---|
| 186 | class AUTHDeclinedError(ESMTPClientError): |
|---|
| 187 | """The server rejected our credentials. |
|---|
| 188 | |
|---|
| 189 | Either the username, password, or challenge response |
|---|
| 190 | given to the server was rejected. |
|---|
| 191 | |
|---|
| 192 | This is considered a non-fatal error (the connection will not be |
|---|
| 193 | dropped). |
|---|
| 194 | """ |
|---|
| 195 | |
|---|
| 196 | class AuthenticationError(ESMTPClientError): |
|---|
| 197 | """An error ocurred while authenticating. |
|---|
| 198 | |
|---|
| 199 | Either the server rejected our request for authentication or the |
|---|
| 200 | challenge received was malformed. |
|---|
| 201 | |
|---|
| 202 | This is considered a non-fatal error (the connection will not be |
|---|
| 203 | dropped). |
|---|
| 204 | """ |
|---|
| 205 | |
|---|
| 206 | class TLSError(ESMTPClientError): |
|---|
| 207 | """An error occurred while negiotiating for transport security. |
|---|
| 208 | |
|---|
| 209 | This is considered a non-fatal error (the connection will not be |
|---|
| 210 | dropped). |
|---|
| 211 | """ |
|---|
| 212 | |
|---|
| 213 | class SMTPConnectError(SMTPClientError): |
|---|
| 214 | """Failed to connect to the mail exchange host. |
|---|
| 215 | |
|---|
| 216 | This is considered a fatal error. A retry will be made. |
|---|
| 217 | """ |
|---|
| 218 | def __init__(self, code, resp, log=None, addresses=None, isFatal=True, retry=True): |
|---|
| 219 | SMTPClientError.__init__(self, code, resp, log, addresses, isFatal, retry) |
|---|
| 220 | |
|---|
| 221 | class SMTPTimeoutError(SMTPClientError): |
|---|
| 222 | """Failed to receive a response from the server in the expected time period. |
|---|
| 223 | |
|---|
| 224 | This is considered a fatal error. A retry will be made. |
|---|
| 225 | """ |
|---|
| 226 | def __init__(self, code, resp, log=None, addresses=None, isFatal=True, retry=True): |
|---|
| 227 | SMTPClientError.__init__(self, code, resp, log, addresses, isFatal, retry) |
|---|
| 228 | |
|---|
| 229 | class SMTPProtocolError(SMTPClientError): |
|---|
| 230 | """The server sent a mangled response. |
|---|
| 231 | |
|---|
| 232 | This is considered a fatal error. A retry will not be made. |
|---|
| 233 | """ |
|---|
| 234 | def __init__(self, code, resp, log=None, addresses=None, isFatal=True, retry=False): |
|---|
| 235 | SMTPClientError.__init__(self, code, resp, log, addresses, isFatal, retry) |
|---|
| 236 | |
|---|
| 237 | class SMTPDeliveryError(SMTPClientError): |
|---|
| 238 | """Indicates that a delivery attempt has had an error. |
|---|
| 239 | """ |
|---|
| 240 | |
|---|
| 241 | class SMTPServerError(SMTPError): |
|---|
| 242 | def __init__(self, code, resp): |
|---|
| 243 | self.code = code |
|---|
| 244 | self.resp = resp |
|---|
| 245 | |
|---|
| 246 | def __str__(self): |
|---|
| 247 | return "%.3d %s" % (self.code, self.resp) |
|---|
| 248 | |
|---|
| 249 | class SMTPAddressError(SMTPServerError): |
|---|
| 250 | def __init__(self, addr, code, resp): |
|---|
| 251 | SMTPServerError.__init__(self, code, resp) |
|---|
| 252 | self.addr = Address(addr) |
|---|
| 253 | |
|---|
| 254 | def __str__(self): |
|---|
| 255 | return "%.3d <%s>... %s" % (self.code, self.addr, self.resp) |
|---|
| 256 | |
|---|
| 257 | class SMTPBadRcpt(SMTPAddressError): |
|---|
| 258 | def __init__(self, addr, code=550, |
|---|
| 259 | resp='Cannot receive for specified address'): |
|---|
| 260 | SMTPAddressError.__init__(self, addr, code, resp) |
|---|
| 261 | |
|---|
| 262 | class SMTPBadSender(SMTPAddressError): |
|---|
| 263 | def __init__(self, addr, code=550, resp='Sender not acceptable'): |
|---|
| 264 | SMTPAddressError.__init__(self, addr, code, resp) |
|---|
| 265 | |
|---|
| 266 | def rfc822date(timeinfo=None,local=1): |
|---|
| 267 | """ |
|---|
| 268 | Format an RFC-2822 compliant date string. |
|---|
| 269 | |
|---|
| 270 | @param timeinfo: (optional) A sequence as returned by C{time.localtime()} |
|---|
| 271 | or C{time.gmtime()}. Default is now. |
|---|
| 272 | @param local: (optional) Indicates if the supplied time is local or |
|---|
| 273 | universal time, or if no time is given, whether now should be local or |
|---|
| 274 | universal time. Default is local, as suggested (SHOULD) by rfc-2822. |
|---|
| 275 | |
|---|
| 276 | @returns: A string representing the time and date in RFC-2822 format. |
|---|
| 277 | """ |
|---|
| 278 | if not timeinfo: |
|---|
| 279 | if local: |
|---|
| 280 | timeinfo = time.localtime() |
|---|
| 281 | else: |
|---|
| 282 | timeinfo = time.gmtime() |
|---|
| 283 | if local: |
|---|
| 284 | if timeinfo[8]: |
|---|
| 285 | # DST |
|---|
| 286 | tz = -time.altzone |
|---|
| 287 | else: |
|---|
| 288 | tz = -time.timezone |
|---|
| 289 | |
|---|
| 290 | (tzhr, tzmin) = divmod(abs(tz), 3600) |
|---|
| 291 | if tz: |
|---|
| 292 | tzhr *= int(abs(tz)/tz) |
|---|
| 293 | (tzmin, tzsec) = divmod(tzmin, 60) |
|---|
| 294 | else: |
|---|
| 295 | (tzhr, tzmin) = (0,0) |
|---|
| 296 | |
|---|
| 297 | return "%s, %02d %s %04d %02d:%02d:%02d %+03d%02d" % ( |
|---|
| 298 | ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][timeinfo[6]], |
|---|
| 299 | timeinfo[2], |
|---|
| 300 | ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', |
|---|
| 301 | 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][timeinfo[1] - 1], |
|---|
| 302 | timeinfo[0], timeinfo[3], timeinfo[4], timeinfo[5], |
|---|
| 303 | tzhr, tzmin) |
|---|
| 304 | |
|---|
| 305 | def idGenerator(): |
|---|
| 306 | i = 0 |
|---|
| 307 | while True: |
|---|
| 308 | yield i |
|---|
| 309 | i += 1 |
|---|
| 310 | |
|---|
| 311 | def messageid(uniq=None, N=idGenerator().next): |
|---|
| 312 | """Return a globally unique random string in RFC 2822 Message-ID format |
|---|
| 313 | |
|---|
| 314 | <datetime.pid.random@host.dom.ain> |
|---|
| 315 | |
|---|
| 316 | Optional uniq string will be added to strenghten uniqueness if given. |
|---|
| 317 | """ |
|---|
| 318 | datetime = time.strftime('%Y%m%d%H%M%S', time.gmtime()) |
|---|
| 319 | pid = os.getpid() |
|---|
| 320 | rand = random.randrange(2**31L-1) |
|---|
| 321 | if uniq is None: |
|---|
| 322 | uniq = '' |
|---|
| 323 | else: |
|---|
| 324 | uniq = '.' + uniq |
|---|
| 325 | |
|---|
| 326 | return '<%s.%s.%s%s.%s@%s>' % (datetime, pid, rand, uniq, N(), DNSNAME) |
|---|
| 327 | |
|---|
| 328 | def quoteaddr(addr): |
|---|
| 329 | """Turn an email address, possibly with realname part etc, into |
|---|
| 330 | a form suitable for and SMTP envelope. |
|---|
| 331 | """ |
|---|
| 332 | |
|---|
| 333 | if isinstance(addr, Address): |
|---|
| 334 | return '<%s>' % str(addr) |
|---|
| 335 | |
|---|
| 336 | res = rfc822.parseaddr(addr) |
|---|
| 337 | |
|---|
| 338 | if res == (None, None): |
|---|
| 339 | # It didn't parse, use it as-is |
|---|
| 340 | return '<%s>' % str(addr) |
|---|
| 341 | else: |
|---|
| 342 | return '<%s>' % str(res[1]) |
|---|
| 343 | |
|---|
| 344 | COMMAND, DATA, AUTH = 'COMMAND', 'DATA', 'AUTH' |
|---|
| 345 | |
|---|
| 346 | class AddressError(SMTPError): |
|---|
| 347 | "Parse error in address" |
|---|
| 348 | |
|---|
| 349 | # Character classes for parsing addresses |
|---|
| 350 | atom = r"[-A-Za-z0-9!\#$%&'*+/=?^_`{|}~]" |
|---|
| 351 | |
|---|
| 352 | class Address: |
|---|
| 353 | """Parse and hold an RFC 2821 address. |
|---|
| 354 | |
|---|
| 355 | Source routes are stipped and ignored, UUCP-style bang-paths |
|---|
| 356 | and %-style routing are not parsed. |
|---|
| 357 | |
|---|
| 358 | @type domain: C{str} |
|---|
| 359 | @ivar domain: The domain within which this address resides. |
|---|
| 360 | |
|---|
| 361 | @type local: C{str} |
|---|
| 362 | @ivar local: The local (\"user\") portion of this address. |
|---|
| 363 | """ |
|---|
| 364 | |
|---|
| 365 | tstring = re.compile(r'''( # A string of |
|---|
| 366 | (?:"[^"]*" # quoted string |
|---|
| 367 | |\\. # backslash-escaped characted |
|---|
| 368 | |''' + atom + r''' # atom character |
|---|
| 369 | )+|.) # or any single character''',re.X) |
|---|
| 370 | atomre = re.compile(atom) # match any one atom character |
|---|
| 371 | |
|---|
| 372 | def __init__(self, addr, defaultDomain=None): |
|---|
| 373 | if isinstance(addr, User): |
|---|
| 374 | addr = addr.dest |
|---|
| 375 | if isinstance(addr, Address): |
|---|
| 376 | self.__dict__ = addr.__dict__.copy() |
|---|
| 377 | return |
|---|
| 378 | elif not isinstance(addr, types.StringTypes): |
|---|
| 379 | addr = str(addr) |
|---|
| 380 | self.addrstr = addr |
|---|
| 381 | |
|---|
| 382 | # Tokenize |
|---|
| 383 | atl = filter(None,self.tstring.split(addr)) |
|---|
| 384 | |
|---|
| 385 | local = [] |
|---|
| 386 | domain = [] |
|---|
| 387 | |
|---|
| 388 | while atl: |
|---|
| 389 | if atl[0] == '<': |
|---|
| 390 | if atl[-1] != '>': |
|---|
| 391 | raise AddressError, "Unbalanced <>" |
|---|
| 392 | atl = atl[1:-1] |
|---|
| 393 | elif atl[0] == '@': |
|---|
| 394 | atl = atl[1:] |
|---|
| 395 | if not local: |
|---|
| 396 | # Source route |
|---|
| 397 | while atl and atl[0] != ':': |
|---|
| 398 | # remove it |
|---|
| 399 | atl = atl[1:] |
|---|
| 400 | if not atl: |
|---|
| 401 | raise AddressError, "Malformed source route" |
|---|
| 402 | atl = atl[1:] # remove : |
|---|
| 403 | elif domain: |
|---|
| 404 | raise AddressError, "Too many @" |
|---|
| 405 | else: |
|---|
| 406 | # Now in domain |
|---|
| 407 | domain = [''] |
|---|
| 408 | elif len(atl[0]) == 1 and not self.atomre.match(atl[0]) and atl[0] != '.': |
|---|
| 409 | raise AddressError, "Parse error at %r of %r" % (atl[0], (addr, atl)) |
|---|
| 410 | else: |
|---|
| 411 | if not domain: |
|---|
| 412 | local.append(atl[0]) |
|---|
| 413 | else: |
|---|
| 414 | domain.append(atl[0]) |
|---|
| 415 | atl = atl[1:] |
|---|
| 416 | |
|---|
| 417 | self.local = ''.join(local) |
|---|
| 418 | self.domain = ''.join(domain) |
|---|
| 419 | if self.local != '' and self.domain == '': |
|---|
| 420 | if defaultDomain is None: |
|---|
| 421 | defaultDomain = DNSNAME |
|---|
| 422 | self.domain = defaultDomain |
|---|
| 423 | |
|---|
| 424 | dequotebs = re.compile(r'\\(.)') |
|---|
| 425 | |
|---|
| 426 | def dequote(self,addr): |
|---|
| 427 | """Remove RFC-2821 quotes from address.""" |
|---|
| 428 | res = [] |
|---|
| 429 | |
|---|
| 430 | atl = filter(None,self.tstring.split(str(addr))) |
|---|
| 431 | |
|---|
| 432 | for t in atl: |
|---|
| 433 | if t[0] == '"' and t[-1] == '"': |
|---|
| 434 | res.append(t[1:-1]) |
|---|
| 435 | elif '\\' in t: |
|---|
| 436 | res.append(self.dequotebs.sub(r'\1',t)) |
|---|
| 437 | else: |
|---|
| 438 | res.append(t) |
|---|
| 439 | |
|---|
| 440 | return ''.join(res) |
|---|
| 441 | |
|---|
| 442 | def __str__(self): |
|---|
| 443 | if self.local or self.domain: |
|---|
| 444 | return '@'.join((self.local, self.domain)) |
|---|
| 445 | else: |
|---|
| 446 | return '' |
|---|
| 447 | |
|---|
| 448 | def __repr__(self): |
|---|
| 449 | return "%s.%s(%s)" % (self.__module__, self.__class__.__name__, |
|---|
| 450 | repr(str(self))) |
|---|
| 451 | |
|---|
| 452 | class User: |
|---|
| 453 | """Hold information about and SMTP message recipient, |
|---|
| 454 | including information on where the message came from |
|---|
| 455 | """ |
|---|
| 456 | |
|---|
| 457 | def __init__(self, destination, helo, protocol, orig): |
|---|
| 458 | host = getattr(protocol, 'host', None) |
|---|
| 459 | self.dest = Address(destination, host) |
|---|
| 460 | self.helo = helo |
|---|
| 461 | self.protocol = protocol |
|---|
| 462 | if isinstance(orig, Address): |
|---|
| 463 | self.orig = orig |
|---|
| 464 | else: |
|---|
| 465 | self.orig = Address(orig, host) |
|---|
| 466 | |
|---|
| 467 | def __getstate__(self): |
|---|
| 468 | """Helper for pickle. |
|---|
| 469 | |
|---|
| 470 | protocol isn't picklabe, but we want User to be, so skip it in |
|---|
| 471 | the pickle. |
|---|
| 472 | """ |
|---|
| 473 | return { 'dest' : self.dest, |
|---|
| 474 | 'helo' : self.helo, |
|---|
| 475 | 'protocol' : None, |
|---|
| 476 | 'orig' : self.orig } |
|---|
| 477 | |
|---|
| 478 | def __str__(self): |
|---|
| 479 | return str(self.dest) |
|---|
| 480 | |
|---|
| 481 | class IMessage(Interface): |
|---|
| 482 | """Interface definition for messages that can be sent via SMTP.""" |
|---|
| 483 | |
|---|
| 484 | def lineReceived(line): |
|---|
| 485 | """handle another line""" |
|---|
| 486 | |
|---|
| 487 | def eomReceived(): |
|---|
| 488 | """handle end of message |
|---|
| 489 | |
|---|
| 490 | return a deferred. The deferred should be called with either: |
|---|
| 491 | callback(string) or errback(error) |
|---|
| 492 | """ |
|---|
| 493 | |
|---|
| 494 | def connectionLost(): |
|---|
| 495 | """handle message truncated |
|---|
| 496 | |
|---|
| 497 | semantics should be to discard the message |
|---|
| 498 | """ |
|---|
| 499 | |
|---|
| 500 | class SMTP(basic.LineOnlyReceiver, policies.TimeoutMixin): |
|---|
| 501 | """SMTP server-side protocol.""" |
|---|
| 502 | |
|---|
| 503 | timeout = 600 |
|---|
| 504 | host = DNSNAME |
|---|
| 505 | portal = None |
|---|
| 506 | |
|---|
| 507 | # Control whether we log SMTP events |
|---|
| 508 | noisy = True |
|---|
| 509 | |
|---|
| 510 | # A factory for IMessageDelivery objects. If an |
|---|
| 511 | # avatar implementing IMessageDeliveryFactory can |
|---|
| 512 | # be acquired from the portal, it will be used to |
|---|
| 513 | # create a new IMessageDelivery object for each |
|---|
| 514 | # message which is received. |
|---|
| 515 | deliveryFactory = None |
|---|
| 516 | |
|---|
| 517 | # An IMessageDelivery object. A new instance is |
|---|
| 518 | # used for each message received if we can get an |
|---|
| 519 | # IMessageDeliveryFactory from the portal. Otherwise, |
|---|
| 520 | # a single instance is used throughout the lifetime |
|---|
| 521 | # of the connection. |
|---|
| 522 | delivery = None |
|---|
| 523 | |
|---|
| 524 | # Cred cleanup function. |
|---|
| 525 | _onLogout = None |
|---|
| 526 | |
|---|
| 527 | def __init__(self, delivery=None, deliveryFactory=None): |
|---|
| 528 | self.mode = COMMAND |
|---|
| 529 | self._from = None |
|---|
| 530 | self._helo = None |
|---|
| 531 | self._to = [] |
|---|
| 532 | self.delivery = delivery |
|---|
| 533 | self.deliveryFactory = deliveryFactory |
|---|
| 534 | |
|---|
| 535 | def timeoutConnection(self): |
|---|
| 536 | msg = '%s Timeout. Try talking faster next time!' % (self.host,) |
|---|
| 537 | self.sendCode(421, msg) |
|---|
| 538 | self.transport.loseConnection() |
|---|
| 539 | |
|---|
| 540 | def greeting(self): |
|---|
| 541 | return '%s NO UCE NO UBE NO RELAY PROBES' % (self.host,) |
|---|
| 542 | |
|---|
| 543 | def connectionMade(self): |
|---|
| 544 | # Ensure user-code always gets something sane for _helo |
|---|
| 545 | peer = self.transport.getPeer() |
|---|
| 546 | try: |
|---|
| 547 | host = peer.host |
|---|
| 548 | except AttributeError: # not an IPv4Address |
|---|
| 549 | host = str(peer) |
|---|
| 550 | self._helo = (None, host) |
|---|
| 551 | self.sendCode(220, self.greeting()) |
|---|
| 552 | self.setTimeout(self.timeout) |
|---|
| 553 | |
|---|
| 554 | def sendCode(self, code, message=''): |
|---|
| 555 | "Send an SMTP code with a message." |
|---|
| 556 | lines = message.splitlines() |
|---|
| 557 | lastline = lines[-1:] |
|---|
| 558 | for line in lines[:-1]: |
|---|
| 559 | self.sendLine('%3.3d-%s' % (code, line)) |
|---|
| 560 | self.sendLine('%3.3d %s' % (code, |
|---|
| 561 | lastline and lastline[0] or '')) |
|---|
| 562 | |
|---|
| 563 | def lineReceived(self, line): |
|---|
| 564 | self.resetTimeout() |
|---|
| 565 | return getattr(self, 'state_' + self.mode)(line) |
|---|
| 566 | |
|---|
| 567 | def state_COMMAND(self, line): |
|---|
| 568 | # Ignore leading and trailing whitespace, as well as an arbitrary |
|---|
| 569 | # amount of whitespace between the command and its argument, though |
|---|
| 570 | # it is not required by the protocol, for it is a nice thing to do. |
|---|
| 571 | line = line.strip() |
|---|
| 572 | |
|---|
| 573 | parts = line.split(None, 1) |
|---|
| 574 | if parts: |
|---|
| 575 | method = self.lookupMethod(parts[0]) or self.do_UNKNOWN |
|---|
| 576 | if len(parts) == 2: |
|---|
| 577 | method(parts[1]) |
|---|
| 578 | else: |
|---|
| 579 | method('') |
|---|
| 580 | else: |
|---|
| 581 | self.sendSyntaxError() |
|---|
| 582 | |
|---|
| 583 | def sendSyntaxError(self): |
|---|
| 584 | self.sendCode(500, 'Error: bad syntax') |
|---|
| 585 | |
|---|
| 586 | def lookupMethod(self, command): |
|---|
| 587 | return getattr(self, 'do_' + command.upper(), None) |
|---|
| 588 | |
|---|
| 589 | def lineLengthExceeded(self, line): |
|---|
| 590 | if self.mode is DATA: |
|---|
| 591 | for message in self.__messages: |
|---|
| 592 | message.connectionLost() |
|---|
| 593 | self.mode = COMMAND |
|---|
| 594 | del self.__messages |
|---|
| 595 | self.sendCode(500, 'Line too long') |
|---|
| 596 | |
|---|
| 597 | def do_UNKNOWN(self, rest): |
|---|
| 598 | self.sendCode(500, 'Command not implemented') |
|---|
| 599 | |
|---|
| 600 | def do_HELO(self, rest): |
|---|
| 601 | peer = self.transport.getPeer() |
|---|
| 602 | try: |
|---|
| 603 | host = peer.host |
|---|
| 604 | except AttributeError: |
|---|
| 605 | host = str(peer) |
|---|
| 606 | self._helo = (rest, host) |
|---|
| 607 | self._from = None |
|---|
| 608 | self._to = [] |
|---|
| 609 | self.sendCode(250, '%s Hello %s, nice to meet you' % (self.host, host)) |
|---|
| 610 | |
|---|
| 611 | def do_QUIT(self, rest): |
|---|
| 612 | self.sendCode(221, 'See you later') |
|---|
| 613 | self.transport.loseConnection() |
|---|
| 614 | |
|---|
| 615 | # A string of quoted strings, backslash-escaped character or |
|---|
| 616 | # atom characters + '@.,:' |
|---|
| 617 | qstring = r'("[^"]*"|\\.|' + atom + r'|[@.,:])+' |
|---|
| 618 | |
|---|
| 619 | mail_re = re.compile(r'''\s*FROM:\s*(?P<path><> # Empty <> |
|---|
| 620 | |<''' + qstring + r'''> # <addr> |
|---|
| 621 | |''' + qstring + r''' # addr |
|---|
| 622 | )\s*(\s(?P<opts>.*))? # Optional WS + ESMTP options |
|---|
| 623 | $''',re.I|re.X) |
|---|
| 624 | rcpt_re = re.compile(r'\s*TO:\s*(?P<path><' + qstring + r'''> # <addr> |
|---|
| 625 | |''' + qstring + r''' # addr |
|---|
| 626 | )\s*(\s(?P<opts>.*))? # Optional WS + ESMTP options |
|---|
| 627 | $''',re.I|re.X) |
|---|
| 628 | |
|---|
| 629 | def do_MAIL(self, rest): |
|---|
| 630 | if self._from: |
|---|
| 631 | self.sendCode(503,"Only one sender per message, please") |
|---|
| 632 | return |
|---|
| 633 | # Clear old recipient list |
|---|
| 634 | self._to = [] |
|---|
| 635 | m = self.mail_re.match(rest) |
|---|
| 636 | if not m: |
|---|
| 637 | self.sendCode(501, "Syntax error") |
|---|
| 638 | return |
|---|
| 639 | |
|---|
| 640 | try: |
|---|
| 641 | addr = Address(m.group('path'), self.host) |
|---|
| 642 | except AddressError, e: |
|---|
| 643 | self.sendCode(553, str(e)) |
|---|
| 644 | return |
|---|
| 645 | |
|---|
| 646 | validated = defer.maybeDeferred(self.validateFrom, self._helo, addr) |
|---|
| 647 | validated.addCallbacks(self._cbFromValidate, self._ebFromValidate) |
|---|
| 648 | |
|---|
| 649 | |
|---|
| 650 | def _cbFromValidate(self, from_, code=250, msg='Sender address accepted'): |
|---|
| 651 | self._from = from_ |
|---|
| 652 | self.sendCode(code, msg) |
|---|
| 653 | |
|---|
| 654 | |
|---|
| 655 | def _ebFromValidate(self, failure): |
|---|
| 656 | if failure.check(SMTPBadSender): |
|---|
| 657 | self.sendCode(failure.value.code, |
|---|
| 658 | 'Cannot receive from specified address %s: %s' |
|---|
| 659 | % (quoteaddr(failure.value.addr), failure.value.resp)) |
|---|
| 660 | elif failure.check(SMTPServerError): |
|---|
| 661 | self.sendCode(failure.value.code, failure.value.resp) |
|---|
| 662 | else: |
|---|
| 663 | log.err(failure, "SMTP sender validation failure") |
|---|
| 664 | self.sendCode( |
|---|
| 665 | 451, |
|---|
| 666 | 'Requested action aborted: local error in processing') |
|---|
| 667 | |
|---|
| 668 | |
|---|
| 669 | def do_RCPT(self, rest): |
|---|
| 670 | if not self._from: |
|---|
| 671 | self.sendCode(503, "Must have sender before recipient") |
|---|
| 672 | return |
|---|
| 673 | m = self.rcpt_re.match(rest) |
|---|
| 674 | if not m: |
|---|
| 675 | self.sendCode(501, "Syntax error") |
|---|
| 676 | return |
|---|
| 677 | |
|---|
| 678 | try: |
|---|
| 679 | user = User(m.group('path'), self._helo, self, self._from) |
|---|
| 680 | except AddressError, e: |
|---|
| 681 | self.sendCode(553, str(e)) |
|---|
| 682 | return |
|---|
| 683 | |
|---|
| 684 | d = defer.maybeDeferred(self.validateTo, user) |
|---|
| 685 | d.addCallbacks( |
|---|
| 686 | self._cbToValidate, |
|---|
| 687 | self._ebToValidate, |
|---|
| 688 | callbackArgs=(user,) |
|---|
| 689 | ) |
|---|
| 690 | |
|---|
| 691 | def _cbToValidate(self, to, user=None, code=250, msg='Recipient address accepted'): |
|---|
| 692 | if user is None: |
|---|
| 693 | user = to |
|---|
| 694 | self._to.append((user, to)) |
|---|
| 695 | self.sendCode(code, msg) |
|---|
| 696 | |
|---|
| 697 | def _ebToValidate(self, failure): |
|---|
| 698 | if failure.check(SMTPBadRcpt, SMTPServerError): |
|---|
| 699 | self.sendCode(failure.value.code, failure.value.resp) |
|---|
| 700 | else: |
|---|
| 701 | log.err(failure) |
|---|
| 702 | self.sendCode( |
|---|
| 703 | 451, |
|---|
| 704 | 'Requested action aborted: local error in processing' |
|---|
| 705 | ) |
|---|
| 706 | |
|---|
| 707 | def _disconnect(self, msgs): |
|---|
| 708 | for msg in msgs: |
|---|
| 709 | try: |
|---|
| 710 | msg.connectionLost() |
|---|
| 711 | except: |
|---|
| 712 | log.msg("msg raised exception from connectionLost") |
|---|
| 713 | log.err() |
|---|
| 714 | |
|---|
| 715 | def do_DATA(self, rest): |
|---|
| 716 | if self._from is None or (not self._to): |
|---|
| 717 | self.sendCode(503, 'Must have valid receiver and originator') |
|---|
| 718 | return |
|---|
| 719 | self.mode = DATA |
|---|
| 720 | helo, origin = self._helo, self._from |
|---|
| 721 | recipients = self._to |
|---|
| 722 | |
|---|
| 723 | self._from = None |
|---|
| 724 | self._to = [] |
|---|
| 725 | self.datafailed = None |
|---|
| 726 | |
|---|
| 727 | msgs = [] |
|---|
| 728 | for (user, msgFunc) in recipients: |
|---|
| 729 | try: |
|---|
| 730 | msg = msgFunc() |
|---|
| 731 | rcvdhdr = self.receivedHeader(helo, origin, [user]) |
|---|
| 732 | if rcvdhdr: |
|---|
| 733 | msg.lineReceived(rcvdhdr) |
|---|
| 734 | msgs.append(msg) |
|---|
| 735 | except SMTPServerError, e: |
|---|
| 736 | self.sendCode(e.code, e.resp) |
|---|
| 737 | self.mode = COMMAND |
|---|
| 738 | self._disconnect(msgs) |
|---|
| 739 | return |
|---|
| 740 | except: |
|---|
| 741 | log.err() |
|---|
| 742 | self.sendCode(550, "Internal server error") |
|---|
| 743 | self.mode = COMMAND |
|---|
| 744 | self._disconnect(msgs) |
|---|
| 745 | return |
|---|
| 746 | self.__messages = msgs |
|---|
| 747 | |
|---|
| 748 | self.__inheader = self.__inbody = 0 |
|---|
| 749 | self.sendCode(354, 'Continue') |
|---|
| 750 | |
|---|
| 751 | if self.noisy: |
|---|
| 752 | fmt = 'Receiving message for delivery: from=%s to=%s' |
|---|
| 753 | log.msg(fmt % (origin, [str(u) for (u, f) in recipients])) |
|---|
| 754 | |
|---|
| 755 | def connectionLost(self, reason): |
|---|
| 756 | # self.sendCode(421, 'Dropping connection.') # This does nothing... |
|---|
| 757 | # Ideally, if we (rather than the other side) lose the connection, |
|---|
| 758 | # we should be able to tell the other side that we are going away. |
|---|
| 759 | # RFC-2821 requires that we try. |
|---|
| 760 | if self.mode is DATA: |
|---|
| 761 | try: |
|---|
| 762 | for message in self.__messages: |
|---|
| 763 | try: |
|---|
| 764 | message.connectionLost() |
|---|
| 765 | except: |
|---|
| 766 | log.err() |
|---|
| 767 | del self.__messages |
|---|
| 768 | except AttributeError: |
|---|
| 769 | pass |
|---|
| 770 | if self._onLogout: |
|---|
| 771 | self._onLogout() |
|---|
| 772 | self._onLogout = None |
|---|
| 773 | self.setTimeout(None) |
|---|
| 774 | |
|---|
| 775 | def do_RSET(self, rest): |
|---|
| 776 | self._from = None |
|---|
| 777 | self._to = [] |
|---|
| 778 | self.sendCode(250, 'I remember nothing.') |
|---|
| 779 | |
|---|
| 780 | def dataLineReceived(self, line): |
|---|
| 781 | if line[:1] == '.': |
|---|
| 782 | if line == '.': |
|---|
| 783 | self.mode = COMMAND |
|---|
| 784 | if self.datafailed: |
|---|
| 785 | self.sendCode(self.datafailed.code, |
|---|
| 786 | self.datafailed.resp) |
|---|
| 787 | return |
|---|
| 788 | if not self.__messages: |
|---|
| 789 | self._messageHandled("thrown away") |
|---|
| 790 | return |
|---|
| 791 | defer.DeferredList([ |
|---|
| 792 | m.eomReceived() for m in self.__messages |
|---|
| 793 | ], consumeErrors=True).addCallback(self._messageHandled |
|---|
| 794 | ) |
|---|
| 795 | del self.__messages |
|---|
| 796 | return |
|---|
| 797 | line = line[1:] |
|---|
| 798 | |
|---|
| 799 | if self.datafailed: |
|---|
| 800 | return |
|---|
| 801 | |
|---|
| 802 | try: |
|---|
| 803 | # Add a blank line between the generated Received:-header |
|---|
| 804 | # and the message body if the message comes in without any |
|---|
| 805 | # headers |
|---|
| 806 | if not self.__inheader and not self.__inbody: |
|---|
| 807 | if ':' in line: |
|---|
| 808 | self.__inheader = 1 |
|---|
| 809 | elif line: |
|---|
| 810 | for message in self.__messages: |
|---|
| 811 | message.lineReceived('') |
|---|
| 812 | self.__inbody = 1 |
|---|
| 813 | |
|---|
| 814 | if not line: |
|---|
| 815 | self.__inbody = 1 |
|---|
| 816 | |
|---|
| 817 | for message in self.__messages: |
|---|
| 818 | message.lineReceived(line) |
|---|
| 819 | except SMTPServerError, e: |
|---|
| 820 | self.datafailed = e |
|---|
| 821 | for message in self.__messages: |
|---|
| 822 | message.connectionLost() |
|---|
| 823 | state_DATA = dataLineReceived |
|---|
| 824 | |
|---|
| 825 | def _messageHandled(self, resultList): |
|---|
| 826 | failures = 0 |
|---|
| 827 | for (success, result) in resultList: |
|---|
| 828 | if not success: |
|---|
| 829 | failures += 1 |
|---|
| 830 | log.err(result) |
|---|
| 831 | if failures: |
|---|
| 832 | msg = 'Could not send e-mail' |
|---|
| 833 | L = len(resultList) |
|---|
| 834 | if L > 1: |
|---|
| 835 | msg += ' (%d failures out of %d recipients)' % (failures, L) |
|---|
| 836 | self.sendCode(550, msg) |
|---|
| 837 | else: |
|---|
| 838 | self.sendCode(250, 'Delivery in progress') |
|---|
| 839 | |
|---|
| 840 | |
|---|
| 841 | def _cbAnonymousAuthentication(self, (iface, avatar, logout)): |
|---|
| 842 | """ |
|---|
| 843 | Save the state resulting from a successful anonymous cred login. |
|---|
| 844 | """ |
|---|
| 845 | if issubclass(iface, IMessageDeliveryFactory): |
|---|
| 846 | self.deliveryFactory = avatar |
|---|
| 847 | self.delivery = None |
|---|
| 848 | elif issubclass(iface, IMessageDelivery): |
|---|
| 849 | self.deliveryFactory = None |
|---|
| 850 | self.delivery = avatar |
|---|
| 851 | else: |
|---|
| 852 | raise RuntimeError("%s is not a supported interface" % (iface.__name__,)) |
|---|
| 853 | self._onLogout = logout |
|---|
| 854 | self.challenger = None |
|---|
| 855 | |
|---|
| 856 | |
|---|
| 857 | # overridable methods: |
|---|
| 858 | def validateFrom(self, helo, origin): |
|---|
| 859 | """ |
|---|
| 860 | Validate the address from which the message originates. |
|---|
| 861 | |
|---|
| 862 | @type helo: C{(str, str)} |
|---|
| 863 | @param helo: The argument to the HELO command and the client's IP |
|---|
| 864 | address. |
|---|
| 865 | |
|---|
| 866 | @type origin: C{Address} |
|---|
| 867 | @param origin: The address the message is from |
|---|
| 868 | |
|---|
| 869 | @rtype: C{Deferred} or C{Address} |
|---|
| 870 | @return: C{origin} or a C{Deferred} whose callback will be |
|---|
| 871 | passed C{origin}. |
|---|
| 872 | |
|---|
| 873 | @raise SMTPBadSender: Raised of messages from this address are |
|---|
| 874 | not to be accepted. |
|---|
| 875 | """ |
|---|
| 876 | if self.deliveryFactory is not None: |
|---|
| 877 | self.delivery = self.deliveryFactory.getMessageDelivery() |
|---|
| 878 | |
|---|
| 879 | if self.delivery is not None: |
|---|
| 880 | return defer.maybeDeferred(self.delivery.validateFrom, |
|---|
| 881 | helo, origin) |
|---|
| 882 | |
|---|
| 883 | # No login has been performed, no default delivery object has been |
|---|
| 884 | # provided: try to perform an anonymous login and then invoke this |
|---|
| 885 | # method again. |
|---|
| 886 | if self.portal: |
|---|
| 887 | |
|---|
| 888 | result = self.portal.login( |
|---|
| 889 | cred.credentials.Anonymous(), |
|---|
| 890 | None, |
|---|
| 891 | IMessageDeliveryFactory, IMessageDelivery) |
|---|
| 892 | |
|---|
| 893 | def ebAuthentication(err): |
|---|
| 894 | """ |
|---|
| 895 | Translate cred exceptions into SMTP exceptions so that the |
|---|
| 896 | protocol code which invokes C{validateFrom} can properly report |
|---|
| 897 | the failure. |
|---|
| 898 | """ |
|---|
| 899 | if err.check(cred.error.UnauthorizedLogin): |
|---|
| 900 | exc = SMTPBadSender(origin) |
|---|
| 901 | elif err.check(cred.error.UnhandledCredentials): |
|---|
| 902 | exc = SMTPBadSender( |
|---|
| 903 | origin, resp="Unauthenticated senders not allowed") |
|---|
| 904 | else: |
|---|
| 905 | return err |
|---|
| 906 | return defer.fail(exc) |
|---|
| 907 | |
|---|
| 908 | result.addCallbacks( |
|---|
| 909 | self._cbAnonymousAuthentication, ebAuthentication) |
|---|
| 910 | |
|---|
| 911 | def continueValidation(ignored): |
|---|
| 912 | """ |
|---|
| 913 | Re-attempt from address validation. |
|---|
| 914 | """ |
|---|
| 915 | return self.validateFrom(helo, origin) |
|---|
| 916 | |
|---|
| 917 | result.addCallback(continueValidation) |
|---|
| 918 | return result |
|---|
| 919 | |
|---|
| 920 | raise SMTPBadSender(origin) |
|---|
| 921 | |
|---|
| 922 | |
|---|
| 923 | def validateTo(self, user): |
|---|
| 924 | """ |
|---|
| 925 | Validate the address for which the message is destined. |
|---|
| 926 | |
|---|
| 927 | @type user: C{User} |
|---|
| 928 | @param user: The address to validate. |
|---|
| 929 | |
|---|
| 930 | @rtype: no-argument callable |
|---|
| 931 | @return: A C{Deferred} which becomes, or a callable which |
|---|
| 932 | takes no arguments and returns an object implementing C{IMessage}. |
|---|
| 933 | This will be called and the returned object used to deliver the |
|---|
| 934 | message when it arrives. |
|---|
| 935 | |
|---|
| 936 | @raise SMTPBadRcpt: Raised if messages to the address are |
|---|
| 937 | not to be accepted. |
|---|
| 938 | """ |
|---|
| 939 | if self.delivery is not None: |
|---|
| 940 | return self.delivery.validateTo(user) |
|---|
| 941 | raise SMTPBadRcpt(user) |
|---|
| 942 | |
|---|
| 943 | def receivedHeader(self, helo, origin, recipients): |
|---|
| 944 | if self.delivery is not None: |
|---|
| 945 | return self.delivery.receivedHeader(helo, origin, recipients) |
|---|
| 946 | |
|---|
| 947 | heloStr = "" |
|---|
| 948 | if helo[0]: |
|---|
| 949 | heloStr = " helo=%s" % (helo[0],) |
|---|
| 950 | domain = self.transport.getHost().host |
|---|
| 951 | from_ = "from %s ([%s]%s)" % (helo[0], helo[1], heloStr) |
|---|
| 952 | by = "by %s with %s (%s)" % (domain, |
|---|
| 953 | self.__class__.__name__, |
|---|
| 954 | longversion) |
|---|
| 955 | for_ = "for %s; %s" % (' '.join(map(str, recipients)), |
|---|
| 956 | rfc822date()) |
|---|
| 957 | return "Received: %s\n\t%s\n\t%s" % (from_, by, for_) |
|---|
| 958 | |
|---|
| 959 | def startMessage(self, recipients): |
|---|
| 960 | if self.delivery: |
|---|
| 961 | return self.delivery.startMessage(recipients) |
|---|
| 962 | return [] |
|---|
| 963 | |
|---|
| 964 | |
|---|
| 965 | class SMTPFactory(protocol.ServerFactory): |
|---|
| 966 | """Factory for SMTP.""" |
|---|
| 967 | |
|---|
| 968 | # override in instances or subclasses |
|---|
| 969 | domain = DNSNAME |
|---|
| 970 | timeout = 600 |
|---|
| 971 | protocol = SMTP |
|---|
| 972 | |
|---|
| 973 | portal = None |
|---|
| 974 | |
|---|
| 975 | def __init__(self, portal = None): |
|---|
| 976 | self.portal = portal |
|---|
| 977 | |
|---|
| 978 | def buildProtocol(self, addr): |
|---|
| 979 | p = protocol.ServerFactory.buildProtocol(self, addr) |
|---|
| 980 | p.portal = self.portal |
|---|
| 981 | p.host = self.domain |
|---|
| 982 | return p |
|---|
| 983 | |
|---|
| 984 | class SMTPClient(basic.LineReceiver, policies.TimeoutMixin): |
|---|
| 985 | """ |
|---|
| 986 | SMTP client for sending emails. |
|---|
| 987 | |
|---|
| 988 | After the client has connected to the SMTP server, it repeatedly calls |
|---|
| 989 | L{SMTPClient.getMailFrom}, L{SMTPClient.getMailTo} and |
|---|
| 990 | L{SMTPClient.getMailData} and uses this information to send an email. |
|---|
| 991 | It then calls L{SMTPClient.getMailFrom} again; if it returns C{None}, the |
|---|
| 992 | client will disconnect, otherwise it will continue as normal i.e. call |
|---|
| 993 | L{SMTPClient.getMailTo} and L{SMTPClient.getMailData} and send a new email. |
|---|
| 994 | """ |
|---|
| 995 | |
|---|
| 996 | # If enabled then log SMTP client server communication |
|---|
| 997 | debug = True |
|---|
| 998 | |
|---|
| 999 | # Number of seconds to wait before timing out a connection. If |
|---|
| 1000 | # None, perform no timeout checking. |
|---|
| 1001 | timeout = None |
|---|
| 1002 | |
|---|
| 1003 | def __init__(self, identity, logsize=10): |
|---|
| 1004 | self.identity = identity or '' |
|---|
| 1005 | self.toAddressesResult = [] |
|---|
| 1006 | self.successAddresses = [] |
|---|
| 1007 | self._from = None |
|---|
| 1008 | self.resp = [] |
|---|
| 1009 | self.code = -1 |
|---|
| 1010 | self.log = util.LineLog(logsize) |
|---|
| 1011 | |
|---|
| 1012 | def sendLine(self, line): |
|---|
| 1013 | # Log sendLine only if you are in debug mode for performance |
|---|
| 1014 | if self.debug: |
|---|
| 1015 | self.log.append('>>> ' + line) |
|---|
| 1016 | |
|---|
| 1017 | basic.LineReceiver.sendLine(self,line) |
|---|
| 1018 | |
|---|
| 1019 | def connectionMade(self): |
|---|
| 1020 | self.setTimeout(self.timeout) |
|---|
| 1021 | |
|---|
| 1022 | self._expected = [ 220 ] |
|---|
| 1023 | self._okresponse = self.smtpState_helo |
|---|
| 1024 | self._failresponse = self.smtpConnectionFailed |
|---|
| 1025 | |
|---|
| 1026 | def connectionLost(self, reason=protocol.connectionDone): |
|---|
| 1027 | """We are no longer connected""" |
|---|
| 1028 | self.setTimeout(None) |
|---|
| 1029 | self.mailFile = None |
|---|
| 1030 | |
|---|
| 1031 | def timeoutConnection(self): |
|---|
| 1032 | self.sendError( |
|---|
| 1033 | SMTPTimeoutError( |
|---|
| 1034 | -1, "Timeout waiting for SMTP server response", |
|---|
| 1035 | self.log.str())) |
|---|
| 1036 | |
|---|
| 1037 | def lineReceived(self, line): |
|---|
| 1038 | self.resetTimeout() |
|---|
| 1039 | |
|---|
| 1040 | # Log lineReceived only if you are in debug mode for performance |
|---|
| 1041 | if self.debug: |
|---|
| 1042 | self.log.append('<<< ' + line) |
|---|
| 1043 | |
|---|
| 1044 | why = None |
|---|
| 1045 | |
|---|
| 1046 | try: |
|---|
| 1047 | self.code = int(line[:3]) |
|---|
| 1048 | except ValueError: |
|---|
| 1049 | # This is a fatal error and will disconnect the transport lineReceived will not be called again |
|---|
| 1050 | self.sendError(SMTPProtocolError(-1, "Invalid response from SMTP server: %s" % line, self.log.str())) |
|---|
| 1051 | return |
|---|
| 1052 | |
|---|
| 1053 | if line[0] == '0': |
|---|
| 1054 | # Verbose informational message, ignore it |
|---|
| 1055 | return |
|---|
| 1056 | |
|---|
| 1057 | self.resp.append(line[4:]) |
|---|
| 1058 | |
|---|
| 1059 | if line[3:4] == '-': |
|---|
| 1060 | # continuation |
|---|
| 1061 | return |
|---|
| 1062 | |
|---|
| 1063 | if self.code in self._expected: |
|---|
| 1064 | why = self._okresponse(self.code,'\n'.join(self.resp)) |
|---|
| 1065 | else: |
|---|
| 1066 | why = self._failresponse(self.code,'\n'.join(self.resp)) |
|---|
| 1067 | |
|---|
| 1068 | self.code = -1 |
|---|
| 1069 | self.resp = [] |
|---|
| 1070 | return why |
|---|
| 1071 | |
|---|
| 1072 | def smtpConnectionFailed(self, code, resp): |
|---|
| 1073 | self.sendError(SMTPConnectError(code, resp, self.log.str())) |
|---|
| 1074 | |
|---|
| 1075 | def smtpTransferFailed(self, code, resp): |
|---|
| 1076 | if code < 0: |
|---|
| 1077 | self.sendError(SMTPProtocolError(code, resp, self.log.str())) |
|---|
| 1078 | else: |
|---|
| 1079 | self.smtpState_msgSent(code, resp) |
|---|
| 1080 | |
|---|
| 1081 | def smtpState_helo(self, code, resp): |
|---|
| 1082 | self.sendLine('HELO ' + self.identity) |
|---|
| 1083 | self._expected = SUCCESS |
|---|
| 1084 | self._okresponse = self.smtpState_from |
|---|
| 1085 | |
|---|
| 1086 | def smtpState_from(self, code, resp): |
|---|
| 1087 | self._from = self.getMailFrom() |
|---|
| 1088 | self._failresponse = self.smtpTransferFailed |
|---|
| 1089 | if self._from is not None: |
|---|
| 1090 | self.sendLine('MAIL FROM:%s' % quoteaddr(self._from)) |
|---|
| 1091 | self._expected = [250] |
|---|
| 1092 | self._okresponse = self.smtpState_to |
|---|
| 1093 | else: |
|---|
| 1094 | # All messages have been sent, disconnect |
|---|
| 1095 | self._disconnectFromServer() |
|---|
| 1096 | |
|---|
| 1097 | def smtpState_disconnect(self, code, resp): |
|---|
| 1098 | self.transport.loseConnection() |
|---|
| 1099 | |
|---|
| 1100 | def smtpState_to(self, code, resp): |
|---|
| 1101 | self.toAddresses = iter(self.getMailTo()) |
|---|
| 1102 | self.toAddressesResult = [] |
|---|
| 1103 | self.successAddresses = [] |
|---|
| 1104 | self._okresponse = self.smtpState_toOrData |
|---|
| 1105 | self._expected = xrange(0,1000) |
|---|
| 1106 | self.lastAddress = None |
|---|
| 1107 | return self.smtpState_toOrData(0, '') |
|---|
| 1108 | |
|---|
| 1109 | def smtpState_toOrData(self, code, resp): |
|---|
| 1110 | if self.lastAddress is not None: |
|---|
| 1111 | self.toAddressesResult.append((self.lastAddress, code, resp)) |
|---|
| 1112 | if code in SUCCESS: |
|---|
| 1113 | self.successAddresses.append(self.lastAddress) |
|---|
| 1114 | try: |
|---|
| 1115 | self.lastAddress = self.toAddresses.next() |
|---|
| 1116 | except StopIteration: |
|---|
| 1117 | if self.successAddresses: |
|---|
| 1118 | self.sendLine('DATA') |
|---|
| 1119 | self._expected = [ 354 ] |
|---|
| 1120 | self._okresponse = self.smtpState_data |
|---|
| 1121 | else: |
|---|
| 1122 | return self.smtpState_msgSent(code,'No recipients accepted') |
|---|
| 1123 | else: |
|---|
| 1124 | self.sendLine('RCPT TO:%s' % quoteaddr(self.lastAddress)) |
|---|
| 1125 | |
|---|
| 1126 | def smtpState_data(self, code, resp): |
|---|
| 1127 | s = basic.FileSender() |
|---|
| 1128 | d = s.beginFileTransfer( |
|---|
| 1129 | self.getMailData(), self.transport, self.transformChunk) |
|---|
| 1130 | def ebTransfer(err): |
|---|
| 1131 | self.sendError(err.value) |
|---|
| 1132 | d.addCallbacks(self.finishedFileTransfer, ebTransfer) |
|---|
| 1133 | self._expected = SUCCESS |
|---|
| 1134 | self._okresponse = self.smtpState_msgSent |
|---|
| 1135 | |
|---|
| 1136 | |
|---|
| 1137 | def smtpState_msgSent(self, code, resp): |
|---|
| 1138 | if self._from is not None: |
|---|
| 1139 | self.sentMail(code, resp, len(self.successAddresses), |
|---|
| 1140 | self.toAddressesResult, self.log) |
|---|
| 1141 | |
|---|
| 1142 | self.toAddressesResult = [] |
|---|
| 1143 | self._from = None |
|---|
| 1144 | self.sendLine('RSET') |
|---|
| 1145 | self._expected = SUCCESS |
|---|
| 1146 | self._okresponse = self.smtpState_from |
|---|
| 1147 | |
|---|
| 1148 | ## |
|---|
| 1149 | ## Helpers for FileSender |
|---|
| 1150 | ## |
|---|
| 1151 | def transformChunk(self, chunk): |
|---|
| 1152 | """ |
|---|
| 1153 | Perform the necessary local to network newline conversion and escape |
|---|
| 1154 | leading periods. |
|---|
| 1155 | |
|---|
| 1156 | This method also resets the idle timeout so that as long as process is |
|---|
| 1157 | being made sending the message body, the client will not time out. |
|---|
| 1158 | """ |
|---|
| 1159 | self.resetTimeout() |
|---|
| 1160 | return chunk.replace('\n', '\r\n').replace('\r\n.', '\r\n..') |
|---|
| 1161 | |
|---|
| 1162 | def finishedFileTransfer(self, lastsent): |
|---|
| 1163 | if lastsent != '\n': |
|---|
| 1164 | line = '\r\n.' |
|---|
| 1165 | else: |
|---|
| 1166 | line = '.' |
|---|
| 1167 | self.sendLine(line) |
|---|
| 1168 | |
|---|
| 1169 | ## |
|---|
| 1170 | # these methods should be overriden in subclasses |
|---|
| 1171 | def getMailFrom(self): |
|---|
| 1172 | """Return the email address the mail is from.""" |
|---|
| 1173 | raise NotImplementedError |
|---|
| 1174 | |
|---|
| 1175 | def getMailTo(self): |
|---|
| 1176 | """Return a list of emails to send to.""" |
|---|
| 1177 | raise NotImplementedError |
|---|
| 1178 | |
|---|
| 1179 | def getMailData(self): |
|---|
| 1180 | """Return file-like object containing data of message to be sent. |
|---|
| 1181 | |
|---|
| 1182 | Lines in the file should be delimited by '\\n'. |
|---|
| 1183 | """ |
|---|
| 1184 | raise NotImplementedError |
|---|
| 1185 | |
|---|
| 1186 | def sendError(self, exc): |
|---|
| 1187 | """ |
|---|
| 1188 | If an error occurs before a mail message is sent sendError will be |
|---|
| 1189 | called. This base class method sends a QUIT if the error is |
|---|
| 1190 | non-fatal and disconnects the connection. |
|---|
| 1191 | |
|---|
| 1192 | @param exc: The SMTPClientError (or child class) raised |
|---|
| 1193 | @type exc: C{SMTPClientError} |
|---|
| 1194 | """ |
|---|
| 1195 | if isinstance(exc, SMTPClientError) and not exc.isFatal: |
|---|
| 1196 | self._disconnectFromServer() |
|---|
| 1197 | else: |
|---|
| 1198 | # If the error was fatal then the communication channel with the |
|---|
| 1199 | # SMTP Server is broken so just close the transport connection |
|---|
| 1200 | self.smtpState_disconnect(-1, None) |
|---|
| 1201 | |
|---|
| 1202 | |
|---|
| 1203 | def sentMail(self, code, resp, numOk, addresses, log): |
|---|
| 1204 | """Called when an attempt to send an email is completed. |
|---|
| 1205 | |
|---|
| 1206 | If some addresses were accepted, code and resp are the response |
|---|
| 1207 | to the DATA command. If no addresses were accepted, code is -1 |
|---|
| 1208 | and resp is an informative message. |
|---|
| 1209 | |
|---|
| 1210 | @param code: the code returned by the SMTP Server |
|---|
| 1211 | @param resp: The string response returned from the SMTP Server |
|---|
| 1212 | @param numOK: the number of addresses accepted by the remote host. |
|---|
| 1213 | @param addresses: is a list of tuples (address, code, resp) listing |
|---|
| 1214 | the response to each RCPT command. |
|---|
| 1215 | @param log: is the SMTP session log |
|---|
| 1216 | """ |
|---|
| 1217 | raise NotImplementedError |
|---|
| 1218 | |
|---|
| 1219 | def _disconnectFromServer(self): |
|---|
| 1220 | self._expected = xrange(0, 1000) |
|---|
| 1221 | self._okresponse = self.smtpState_disconnect |
|---|
| 1222 | self.sendLine('QUIT') |
|---|
| 1223 | |
|---|
| 1224 | |
|---|
| 1225 | |
|---|
| 1226 | class ESMTPClient(SMTPClient): |
|---|
| 1227 | # Fall back to HELO if the server does not support EHLO |
|---|
| 1228 | heloFallback = True |
|---|
| 1229 | |
|---|
| 1230 | # Refuse to proceed if authentication cannot be performed |
|---|
| 1231 | requireAuthentication = False |
|---|
| 1232 | |
|---|
| 1233 | # Refuse to proceed if TLS is not available |
|---|
| 1234 | requireTransportSecurity = False |
|---|
| 1235 | |
|---|
| 1236 | # Indicate whether or not our transport can be considered secure. |
|---|
| 1237 | tlsMode = False |
|---|
| 1238 | |
|---|
| 1239 | # ClientContextFactory to use for STARTTLS |
|---|
| 1240 | context = None |
|---|
| 1241 | |
|---|
| 1242 | def __init__(self, secret, contextFactory=None, *args, **kw): |
|---|
| 1243 | SMTPClient.__init__(self, *args, **kw) |
|---|
| 1244 | self.authenticators = [] |
|---|
| 1245 | self.secret = secret |
|---|
| 1246 | self.context = contextFactory |
|---|
| 1247 | self.tlsMode = False |
|---|
| 1248 | |
|---|
| 1249 | |
|---|
| 1250 | def esmtpEHLORequired(self, code=-1, resp=None): |
|---|
| 1251 | self.sendError(EHLORequiredError(502, "Server does not support ESMTP Authentication", self.log.str())) |
|---|
| 1252 | |
|---|
| 1253 | |
|---|
| 1254 | def esmtpAUTHRequired(self, code=-1, resp=None): |
|---|
| 1255 | tmp = [] |
|---|
| 1256 | |
|---|
| 1257 | for a in self.authenticators: |
|---|
| 1258 | tmp.append(a.getName().upper()) |
|---|
| 1259 | |
|---|
| 1260 | auth = "[%s]" % ', '.join(tmp) |
|---|
| 1261 | |
|---|
| 1262 | self.sendError(AUTHRequiredError(502, "Server does not support Client Authentication schemes %s" % auth, |
|---|
| 1263 | self.log.str())) |
|---|
| 1264 | |
|---|
| 1265 | |
|---|
| 1266 | def esmtpTLSRequired(self, code=-1, resp=None): |
|---|
| 1267 | self.sendError(TLSRequiredError(502, "Server does not support secure communication via TLS / SSL", |
|---|
| 1268 | self.log.str())) |
|---|
| 1269 | |
|---|
| 1270 | def esmtpTLSFailed(self, code=-1, resp=None): |
|---|
| 1271 | self.sendError(TLSError(code, "Could not complete the SSL/TLS handshake", self.log.str())) |
|---|
| 1272 | |
|---|
| 1273 | def esmtpAUTHDeclined(self, code=-1, resp=None): |
|---|
| 1274 | self.sendError(AUTHDeclinedError(code, resp, self.log.str())) |
|---|
| 1275 | |
|---|
| 1276 | def esmtpAUTHMalformedChallenge(self, code=-1, resp=None): |
|---|
| 1277 | str = "Login failed because the SMTP Server returned a malformed Authentication Challenge" |
|---|
| 1278 | self.sendError(AuthenticationError(501, str, self.log.str())) |
|---|
| 1279 | |
|---|
| 1280 | def esmtpAUTHServerError(self, code=-1, resp=None): |
|---|
| 1281 | self.sendError(AuthenticationError(code, resp, self.log.str())) |
|---|
| 1282 | |
|---|
| 1283 | def registerAuthenticator(self, auth): |
|---|
| 1284 | """Registers an Authenticator with the ESMTPClient. The ESMTPClient |
|---|
| 1285 | will attempt to login to the SMTP Server in the order the |
|---|
| 1286 | Authenticators are registered. The most secure Authentication |
|---|
| 1287 | mechanism should be registered first. |
|---|
| 1288 | |
|---|
| 1289 | @param auth: The Authentication mechanism to register |
|---|
| 1290 | @type auth: class implementing C{IClientAuthentication} |
|---|
| 1291 | """ |
|---|
| 1292 | |
|---|
| 1293 | self.authenticators.append(auth) |
|---|
| 1294 | |
|---|
| 1295 | def connectionMade(self): |
|---|
| 1296 | SMTPClient.connectionMade(self) |
|---|
| 1297 | self._okresponse = self.esmtpState_ehlo |
|---|
| 1298 | |
|---|
| 1299 | def esmtpState_ehlo(self, code, resp): |
|---|
| 1300 | self._expected = SUCCESS |
|---|
| 1301 | |
|---|
| 1302 | self._okresponse = self.esmtpState_serverConfig |
|---|
| 1303 | self._failresponse = self.esmtpEHLORequired |
|---|
| 1304 | |
|---|
| 1305 | if self.heloFallback: |
|---|
| 1306 | self._failresponse = self.smtpState_helo |
|---|
| 1307 | |
|---|
| 1308 | self.sendLine('EHLO ' + self.identity) |
|---|
| 1309 | |
|---|
| 1310 | def esmtpState_serverConfig(self, code, resp): |
|---|
| 1311 | items = {} |
|---|
| 1312 | for line in resp.splitlines(): |
|---|
| 1313 | e = line.split(None, 1) |
|---|
| 1314 | if len(e) > 1: |
|---|
| 1315 | items[e[0]] = e[1] |
|---|
| 1316 | else: |
|---|
| 1317 | items[e[0]] = None |
|---|
| 1318 | |
|---|
| 1319 | if self.tlsMode: |
|---|
| 1320 | self.authenticate(code, resp, items) |
|---|
| 1321 | else: |
|---|
| 1322 | self.tryTLS(code, resp, items) |
|---|
| 1323 | |
|---|
| 1324 | def tryTLS(self, code, resp, items): |
|---|
| 1325 | if self.context and 'STARTTLS' in items: |
|---|
| 1326 | self._expected = [220] |
|---|
| 1327 | self._okresponse = self.esmtpState_starttls |
|---|
| 1328 | self._failresponse = self.esmtpTLSFailed |
|---|
| 1329 | self.sendLine('STARTTLS') |
|---|
| 1330 | elif self.requireTransportSecurity: |
|---|
| 1331 | self.tlsMode = False |
|---|
| 1332 | self.esmtpTLSRequired() |
|---|
| 1333 | else: |
|---|
| 1334 | self.tlsMode = False |
|---|
| 1335 | self.authenticate(code, resp, items) |
|---|
| 1336 | |
|---|
| 1337 | def esmtpState_starttls(self, code, resp): |
|---|
| 1338 | try: |
|---|
| 1339 | self.transport.startTLS(self.context) |
|---|
| 1340 | self.tlsMode = True |
|---|
| 1341 | except: |
|---|
| 1342 | log.err() |
|---|
| 1343 | self.esmtpTLSFailed(451) |
|---|
| 1344 | |
|---|
| 1345 | # Send another EHLO once TLS has been started to |
|---|
| 1346 | # get the TLS / AUTH schemes. Some servers only allow AUTH in TLS mode. |
|---|
| 1347 | self.esmtpState_ehlo(code, resp) |
|---|
| 1348 | |
|---|
| 1349 | def authenticate(self, code, resp, items): |
|---|
| 1350 | if self.secret and items.get('AUTH'): |
|---|
| 1351 | schemes = items['AUTH'].split() |
|---|
| 1352 | tmpSchemes = {} |
|---|
| 1353 | |
|---|
| 1354 | #XXX: May want to come up with a more efficient way to do this |
|---|
| 1355 | for s in schemes: |
|---|
| 1356 | tmpSchemes[s.upper()] = 1 |
|---|
| 1357 | |
|---|
| 1358 | for a in self.authenticators: |
|---|
| 1359 | auth = a.getName().upper() |
|---|
| 1360 | |
|---|
| 1361 | if auth in tmpSchemes: |
|---|
| 1362 | self._authinfo = a |
|---|
| 1363 | |
|---|
| 1364 | # Special condition handled |
|---|
| 1365 | if auth == "PLAIN": |
|---|
| 1366 | self._okresponse = self.smtpState_from |
|---|
| 1367 | self._failresponse = self._esmtpState_plainAuth |
|---|
| 1368 | self._expected = [235] |
|---|
| 1369 | challenge = encode_base64(self._authinfo.challengeResponse(self.secret, 1), eol="") |
|---|
| 1370 | self.sendLine('AUTH ' + auth + ' ' + challenge) |
|---|
| 1371 | else: |
|---|
| 1372 | self._expected = [334] |
|---|
| 1373 | self._okresponse = self.esmtpState_challenge |
|---|
| 1374 | # If some error occurs here, the server declined the AUTH |
|---|
| 1375 | # before the user / password phase. This would be |
|---|
| 1376 | # a very rare case |
|---|
| 1377 | self._failresponse = self.esmtpAUTHServerError |
|---|
| 1378 | self.sendLine('AUTH ' + auth) |
|---|
| 1379 | return |
|---|
| 1380 | |
|---|
| 1381 | if self.requireAuthentication: |
|---|
| 1382 | self.esmtpAUTHRequired() |
|---|
| 1383 | else: |
|---|
| 1384 | self.smtpState_from(code, resp) |
|---|
| 1385 | |
|---|
| 1386 | def _esmtpState_plainAuth(self, code, resp): |
|---|
| 1387 | self._okresponse = self.smtpState_from |
|---|
| 1388 | self._failresponse = self.esmtpAUTHDeclined |
|---|
| 1389 | self._expected = [235] |
|---|
| 1390 | challenge = encode_base64(self._authinfo.challengeResponse(self.secret, 2), eol="") |
|---|
| 1391 | self.sendLine('AUTH PLAIN ' + challenge) |
|---|
| 1392 | |
|---|
| 1393 | def esmtpState_challenge(self, code, resp): |
|---|
| 1394 | self._authResponse(self._authinfo, resp) |
|---|
| 1395 | |
|---|
| 1396 | def _authResponse(self, auth, challenge): |
|---|
| 1397 | self._failresponse = self.esmtpAUTHDeclined |
|---|
| 1398 | try: |
|---|
| 1399 | challenge = base64.decodestring(challenge) |
|---|
| 1400 | except binascii.Error: |
|---|
| 1401 | # Illegal challenge, give up, then quit |
|---|
| 1402 | self.sendLine('*') |
|---|
| 1403 | self._okresponse = self.esmtpAUTHMalformedChallenge |
|---|
| 1404 | self._failresponse = self.esmtpAUTHMalformedChallenge |
|---|
| 1405 | else: |
|---|
| 1406 | resp = auth.challengeResponse(self.secret, challenge) |
|---|
| 1407 | self._expected = [235, 334] |
|---|
| 1408 | self._okresponse = self.smtpState_maybeAuthenticated |
|---|
| 1409 | self.sendLine(encode_base64(resp, eol="")) |
|---|
| 1410 | |
|---|
| 1411 | |
|---|
| 1412 | def smtpState_maybeAuthenticated(self, code, resp): |
|---|
| 1413 | """ |
|---|
| 1414 | Called to handle the next message from the server after sending a |
|---|
| 1415 | response to a SASL challenge. The server response might be another |
|---|
| 1416 | challenge or it might indicate authentication has succeeded. |
|---|
| 1417 | """ |
|---|
| 1418 | if code == 235: |
|---|
| 1419 | # Yes, authenticated! |
|---|
| 1420 | del self._authinfo |
|---|
| 1421 | self.smtpState_from(code, resp) |
|---|
| 1422 | else: |
|---|
| 1423 | # No, not authenticated yet. Keep trying. |
|---|
| 1424 | self._authResponse(self._authinfo, resp) |
|---|
| 1425 | |
|---|
| 1426 | |
|---|
| 1427 | |
|---|
| 1428 | class ESMTP(SMTP): |
|---|
| 1429 | |
|---|
| 1430 | ctx = None |
|---|
| 1431 | canStartTLS = False |
|---|
| 1432 | startedTLS = False |
|---|
| 1433 | |
|---|
| 1434 | authenticated = False |
|---|
| 1435 | |
|---|
| 1436 | def __init__(self, chal = None, contextFactory = None): |
|---|
| 1437 | SMTP.__init__(self) |
|---|
| 1438 | if chal is None: |
|---|
| 1439 | chal = {} |
|---|
| 1440 | self.challengers = chal |
|---|
| 1441 | self.authenticated = False |
|---|
| 1442 | self.ctx = contextFactory |
|---|
| 1443 | |
|---|
| 1444 | def connectionMade(self): |
|---|
| 1445 | SMTP.connectionMade(self) |
|---|
| 1446 | self.canStartTLS = ITLSTransport.providedBy(self.transport) |
|---|
| 1447 | self.canStartTLS = self.canStartTLS and (self.ctx is not None) |
|---|
| 1448 | |
|---|
| 1449 | |
|---|
| 1450 | def greeting(self): |
|---|
| 1451 | return SMTP.greeting(self) + ' ESMTP' |
|---|
| 1452 | |
|---|
| 1453 | |
|---|
| 1454 | def extensions(self): |
|---|
| 1455 | ext = {'AUTH': self.challengers.keys()} |
|---|
| 1456 | if self.canStartTLS and not self.startedTLS: |
|---|
| 1457 | ext['STARTTLS'] = None |
|---|
| 1458 | return ext |
|---|
| 1459 | |
|---|
| 1460 | def lookupMethod(self, command): |
|---|
| 1461 | m = SMTP.lookupMethod(self, command) |
|---|
| 1462 | if m is None: |
|---|
| 1463 | m = getattr(self, 'ext_' + command.upper(), None) |
|---|
| 1464 | return m |
|---|
| 1465 | |
|---|
| 1466 | def listExtensions(self): |
|---|
| 1467 | r = [] |
|---|
| 1468 | for (c, v) in self.extensions().iteritems(): |
|---|
| 1469 | if v is not None: |
|---|
| 1470 | if v: |
|---|
| 1471 | # Intentionally omit extensions with empty argument lists |
|---|
| 1472 | r.append('%s %s' % (c, ' '.join(v))) |
|---|
| 1473 | else: |
|---|
| 1474 | r.append(c) |
|---|
| 1475 | return '\n'.join(r) |
|---|
| 1476 | |
|---|
| 1477 | def do_EHLO(self, rest): |
|---|
| 1478 | peer = self.transport.getPeer().host |
|---|
| 1479 | self._helo = (rest, peer) |
|---|
| 1480 | self._from = None |
|---|
| 1481 | self._to = [] |
|---|
| 1482 | self.sendCode( |
|---|
| 1483 | 250, |
|---|
| 1484 | '%s Hello %s, nice to meet you\n%s' % ( |
|---|
| 1485 | self.host, peer, |
|---|
| 1486 | self.listExtensions(), |
|---|
| 1487 | ) |
|---|
| 1488 | ) |
|---|
| 1489 | |
|---|
| 1490 | def ext_STARTTLS(self, rest): |
|---|
| 1491 | if self.startedTLS: |
|---|
| 1492 | self.sendCode(503, 'TLS already negotiated') |
|---|
| 1493 | elif self.ctx and self.canStartTLS: |
|---|
| 1494 | self.sendCode(220, 'Begin TLS negotiation now') |
|---|
| 1495 | self.transport.startTLS(self.ctx) |
|---|
| 1496 | self.startedTLS = True |
|---|
| 1497 | else: |
|---|
| 1498 | self.sendCode(454, 'TLS not available') |
|---|
| 1499 | |
|---|
| 1500 | def ext_AUTH(self, rest): |
|---|
| 1501 | if self.authenticated: |
|---|
| 1502 | self.sendCode(503, 'Already authenticated') |
|---|
| 1503 | return |
|---|
| 1504 | parts = rest.split(None, 1) |
|---|
| 1505 | chal = self.challengers.get(parts[0].upper(), lambda: None)() |
|---|
| 1506 | if not chal: |
|---|
| 1507 | self.sendCode(504, 'Unrecognized authentication type') |
|---|
| 1508 | return |
|---|
| 1509 | |
|---|
| 1510 | self.mode = AUTH |
|---|
| 1511 | self.challenger = chal |
|---|
| 1512 | |
|---|
| 1513 | if len(parts) > 1: |
|---|
| 1514 | chal.getChallenge() # Discard it, apparently the client does not |
|---|
| 1515 | # care about it. |
|---|
| 1516 | rest = parts[1] |
|---|
| 1517 | else: |
|---|
| 1518 | rest = None |
|---|
| 1519 | self.state_AUTH(rest) |
|---|
| 1520 | |
|---|
| 1521 | |
|---|
| 1522 | def _cbAuthenticated(self, loginInfo): |
|---|
| 1523 | """ |
|---|
| 1524 | Save the state resulting from a successful cred login and mark this |
|---|
| 1525 | connection as authenticated. |
|---|
| 1526 | """ |
|---|
| 1527 | result = SMTP._cbAnonymousAuthentication(self, loginInfo) |
|---|
| 1528 | self.authenticated = True |
|---|
| 1529 | return result |
|---|
| 1530 | |
|---|
| 1531 | |
|---|
| 1532 | def _ebAuthenticated(self, reason): |
|---|
| 1533 | """ |
|---|
| 1534 | Handle cred login errors by translating them to the SMTP authenticate |
|---|
| 1535 | failed. Translate all other errors into a generic SMTP error code and |
|---|
| 1536 | log the failure for inspection. Stop all errors from propagating. |
|---|
| 1537 | """ |
|---|
| 1538 | self.challenge = None |
|---|
| 1539 | if reason.check(cred.error.UnauthorizedLogin): |
|---|
| 1540 | self.sendCode(535, 'Authentication failed') |
|---|
| 1541 | else: |
|---|
| 1542 | log.err(reason, "SMTP authentication failure") |
|---|
| 1543 | self.sendCode( |
|---|
| 1544 | 451, |
|---|
| 1545 | 'Requested action aborted: local error in processing') |
|---|
| 1546 | |
|---|
| 1547 | |
|---|
| 1548 | def state_AUTH(self, response): |
|---|
| 1549 | """ |
|---|
| 1550 | Handle one step of challenge/response authentication. |
|---|
| 1551 | |
|---|
| 1552 | @param response: The text of a response. If None, this |
|---|
| 1553 | function has been called as a result of an AUTH command with |
|---|
| 1554 | no initial response. A response of '*' aborts authentication, |
|---|
| 1555 | as per RFC 2554. |
|---|
| 1556 | """ |
|---|
| 1557 | if self.portal is None: |
|---|
| 1558 | self.sendCode(454, 'Temporary authentication failure') |
|---|
| 1559 | self.mode = COMMAND |
|---|
| 1560 | return |
|---|
| 1561 | |
|---|
| 1562 | if response is None: |
|---|
| 1563 | challenge = self.challenger.getChallenge() |
|---|
| 1564 | encoded = challenge.encode('base64') |
|---|
| 1565 | self.sendCode(334, encoded) |
|---|
| 1566 | return |
|---|
| 1567 | |
|---|
| 1568 | if response == '*': |
|---|
| 1569 | self.sendCode(501, 'Authentication aborted') |
|---|
| 1570 | self.challenger = None |
|---|
| 1571 | self.mode = COMMAND |
|---|
| 1572 | return |
|---|
| 1573 | |
|---|
| 1574 | try: |
|---|
| 1575 | uncoded = response.decode('base64') |
|---|
| 1576 | except binascii.Error: |
|---|
| 1577 | self.sendCode(501, 'Syntax error in parameters or arguments') |
|---|
| 1578 | self.challenger = None |
|---|
| 1579 | self.mode = COMMAND |
|---|
| 1580 | return |
|---|
| 1581 | |
|---|
| 1582 | self.challenger.setResponse(uncoded) |
|---|
| 1583 | if self.challenger.moreChallenges(): |
|---|
| 1584 | challenge = self.challenger.getChallenge() |
|---|
| 1585 | coded = challenge.encode('base64')[:-1] |
|---|
| 1586 | self.sendCode(334, coded) |
|---|
| 1587 | return |
|---|
| 1588 | |
|---|
| 1589 | self.mode = COMMAND |
|---|
| 1590 | result = self.portal.login( |
|---|
| 1591 | self.challenger, None, |
|---|
| 1592 | IMessageDeliveryFactory, IMessageDelivery) |
|---|
| 1593 | result.addCallback(self._cbAuthenticated) |
|---|
| 1594 | result.addCallback(lambda ign: self.sendCode(235, 'Authentication successful.')) |
|---|
| 1595 | result.addErrback(self._ebAuthenticated) |
|---|
| 1596 | |
|---|
| 1597 | |
|---|
| 1598 | |
|---|
| 1599 | class SenderMixin: |
|---|
| 1600 | """Utility class for sending emails easily. |
|---|
| 1601 | |
|---|
| 1602 | Use with SMTPSenderFactory or ESMTPSenderFactory. |
|---|
| 1603 | """ |
|---|
| 1604 | done = 0 |
|---|
| 1605 | |
|---|
| 1606 | def getMailFrom(self): |
|---|
| 1607 | if not self.done: |
|---|
| 1608 | self.done = 1 |
|---|
| 1609 | return str(self.factory.fromEmail) |
|---|
| 1610 | else: |
|---|
| 1611 | return None |
|---|
| 1612 | |
|---|
| 1613 | def getMailTo(self): |
|---|
| 1614 | return self.factory.toEmail |
|---|
| 1615 | |
|---|
| 1616 | def getMailData(self): |
|---|
| 1617 | return self.factory.file |
|---|
| 1618 | |
|---|
| 1619 | def sendError(self, exc): |
|---|
| 1620 | # Call the base class to close the connection with the SMTP server |
|---|
| 1621 | SMTPClient.sendError(self, exc) |
|---|
| 1622 | |
|---|
| 1623 | # Do not retry to connect to SMTP Server if: |
|---|
| 1624 | # 1. No more retries left (This allows the correct error to be returned to the errorback) |
|---|
| 1625 | # 2. retry is false |
|---|
| 1626 | # 3. The error code is not in the 4xx range (Communication Errors) |
|---|
| 1627 | |
|---|
| 1628 | if (self.factory.retries >= 0 or |
|---|
| 1629 | (not exc.retry and not (exc.code >= 400 and exc.code < 500))): |
|---|
| 1630 | self.factory.sendFinished = 1 |
|---|
| 1631 | self.factory.result.errback(exc) |
|---|
| 1632 | |
|---|
| 1633 | def sentMail(self, code, resp, numOk, addresses, log): |
|---|
| 1634 | # Do not retry, the SMTP server acknowledged the request |
|---|
| 1635 | self.factory.sendFinished = 1 |
|---|
| 1636 | if code not in SUCCESS: |
|---|
| 1637 | errlog = [] |
|---|
| 1638 | for addr, acode, aresp in addresses: |
|---|
| 1639 | if acode not in SUCCESS: |
|---|
| 1640 | errlog.append("%s: %03d %s" % (addr, acode, aresp)) |
|---|
| 1641 | |
|---|
| 1642 | errlog.append(log.str()) |
|---|
| 1643 | |
|---|
| 1644 | exc = SMTPDeliveryError(code, resp, '\n'.join(errlog), addresses) |
|---|
| 1645 | self.factory.result.errback(exc) |
|---|
| 1646 | else: |
|---|
| 1647 | self.factory.result.callback((numOk, addresses)) |
|---|
| 1648 | |
|---|
| 1649 | |
|---|
| 1650 | class SMTPSender(SenderMixin, SMTPClient): |
|---|
| 1651 | """ |
|---|
| 1652 | SMTP protocol that sends a single email based on information it |
|---|
| 1653 | gets from its factory, a L{SMTPSenderFactory}. |
|---|
| 1654 | """ |
|---|
| 1655 | |
|---|
| 1656 | |
|---|
| 1657 | class SMTPSenderFactory(protocol.ClientFactory): |
|---|
| 1658 | """ |
|---|
| 1659 | Utility factory for sending emails easily. |
|---|
| 1660 | """ |
|---|
| 1661 | |
|---|
| 1662 | domain = DNSNAME |
|---|
| 1663 | protocol = SMTPSender |
|---|
| 1664 | |
|---|
| 1665 | def __init__(self, fromEmail, toEmail, file, deferred, retries=5, |
|---|
| 1666 | timeout=None): |
|---|
| 1667 | """ |
|---|
| 1668 | @param fromEmail: The RFC 2821 address from which to send this |
|---|
| 1669 | message. |
|---|
| 1670 | |
|---|
| 1671 | @param toEmail: A sequence of RFC 2821 addresses to which to |
|---|
| 1672 | send this message. |
|---|
| 1673 | |
|---|
| 1674 | @param file: A file-like object containing the message to send. |
|---|
| 1675 | |
|---|
| 1676 | @param deferred: A Deferred to callback or errback when sending |
|---|
| 1677 | of this message completes. |
|---|
| 1678 | |
|---|
| 1679 | @param retries: The number of times to retry delivery of this |
|---|
| 1680 | message. |
|---|
| 1681 | |
|---|
| 1682 | @param timeout: Period, in seconds, for which to wait for |
|---|
| 1683 | server responses, or None to wait forever. |
|---|
| 1684 | """ |
|---|
| 1685 | assert isinstance(retries, (int, long)) |
|---|
| 1686 | |
|---|
| 1687 | if isinstance(toEmail, types.StringTypes): |
|---|
| 1688 | toEmail = [toEmail] |
|---|
| 1689 | self.fromEmail = Address(fromEmail) |
|---|
| 1690 | self.nEmails = len(toEmail) |
|---|
| 1691 | self.toEmail = toEmail |
|---|
| 1692 | self.file = file |
|---|
| 1693 | self.result = deferred |
|---|
| 1694 | self.result.addBoth(self._removeDeferred) |
|---|
| 1695 | self.sendFinished = 0 |
|---|
| 1696 | |
|---|
| 1697 | self.retries = -retries |
|---|
| 1698 | self.timeout = timeout |
|---|
| 1699 | |
|---|
| 1700 | def _removeDeferred(self, argh): |
|---|
| 1701 | del self.result |
|---|
| 1702 | return argh |
|---|
| 1703 | |
|---|
| 1704 | def clientConnectionFailed(self, connector, err): |
|---|
| 1705 | self._processConnectionError(connector, err) |
|---|
| 1706 | |
|---|
| 1707 | def clientConnectionLost(self, connector, err): |
|---|
| 1708 | self._processConnectionError(connector, err) |
|---|
| 1709 | |
|---|
| 1710 | def _processConnectionError(self, connector, err): |
|---|
| 1711 | if self.retries < self.sendFinished <= 0: |
|---|
| 1712 | log.msg("SMTP Client retrying server. Retry: %s" % -self.retries) |
|---|
| 1713 | |
|---|
| 1714 | # Rewind the file in case part of it was read while attempting to |
|---|
| 1715 | # send the message. |
|---|
| 1716 | self.file.seek(0, 0) |
|---|
| 1717 | connector.connect() |
|---|
| 1718 | self.retries += 1 |
|---|
| 1719 | elif self.sendFinished <= 0: |
|---|
| 1720 | # If we were unable to communicate with the SMTP server a ConnectionDone will be |
|---|
| 1721 | # returned. We want a more clear error message for debugging |
|---|
| 1722 | if err.check(error.ConnectionDone): |
|---|
| 1723 | err.value = SMTPConnectError(-1, "Unable to connect to server.") |
|---|
| 1724 | self.result.errback(err.value) |
|---|
| 1725 | |
|---|
| 1726 | def buildProtocol(self, addr): |
|---|
| 1727 | p = self.protocol(self.domain, self.nEmails*2+2) |
|---|
| 1728 | p.factory = self |
|---|
| 1729 | p.timeout = self.timeout |
|---|
| 1730 | return p |
|---|
| 1731 | |
|---|
| 1732 | |
|---|
| 1733 | |
|---|
| 1734 | from twisted.mail.imap4 import IClientAuthentication |
|---|
| 1735 | from twisted.mail.imap4 import CramMD5ClientAuthenticator, LOGINAuthenticator |
|---|
| 1736 | from twisted.mail.imap4 import LOGINCredentials as _lcredentials |
|---|
| 1737 | |
|---|
| 1738 | class LOGINCredentials(_lcredentials): |
|---|
| 1739 | """ |
|---|
| 1740 | L{LOGINCredentials} generates challenges for I{LOGIN} authentication. |
|---|
| 1741 | |
|---|
| 1742 | For interoperability with Outlook, the challenge generated does not exactly |
|---|
| 1743 | match the one defined in the |
|---|
| 1744 | U{draft specification<http://sepp.oetiker.ch/sasl-2.1.19-ds/draft-murchison-sasl-login-00.txt>}. |
|---|
| 1745 | """ |
|---|
| 1746 | |
|---|
| 1747 | def __init__(self): |
|---|
| 1748 | _lcredentials.__init__(self) |
|---|
| 1749 | self.challenges = ['Password:', 'Username:'] |
|---|
| 1750 | |
|---|
| 1751 | |
|---|
| 1752 | |
|---|
| 1753 | class PLAINAuthenticator: |
|---|
| 1754 | implements(IClientAuthentication) |
|---|
| 1755 | |
|---|
| 1756 | def __init__(self, user): |
|---|
| 1757 | self.user = user |
|---|
| 1758 | |
|---|
| 1759 | def getName(self): |
|---|
| 1760 | return "PLAIN" |
|---|
| 1761 | |
|---|
| 1762 | def challengeResponse(self, secret, chal=1): |
|---|
| 1763 | if chal == 1: |
|---|
| 1764 | return "%s\0%s\0%s" % (self.user, self.user, secret) |
|---|
| 1765 | else: |
|---|
| 1766 | return "%s\0%s" % (self.user, secret) |
|---|
| 1767 | |
|---|
| 1768 | |
|---|
| 1769 | |
|---|
| 1770 | class ESMTPSender(SenderMixin, ESMTPClient): |
|---|
| 1771 | |
|---|
| 1772 | requireAuthentication = True |
|---|
| 1773 | requireTransportSecurity = True |
|---|
| 1774 | |
|---|
| 1775 | def __init__(self, username, secret, contextFactory=None, *args, **kw): |
|---|
| 1776 | self.heloFallback = 0 |
|---|
| 1777 | self.username = username |
|---|
| 1778 | |
|---|
| 1779 | if contextFactory is None: |
|---|
| 1780 | contextFactory = self._getContextFactory() |
|---|
| 1781 | |
|---|
| 1782 | ESMTPClient.__init__(self, secret, contextFactory, *args, **kw) |
|---|
| 1783 | |
|---|
| 1784 | self._registerAuthenticators() |
|---|
| 1785 | |
|---|
| 1786 | def _registerAuthenticators(self): |
|---|
| 1787 | # Register Authenticator in order from most secure to least secure |
|---|
| 1788 | self.registerAuthenticator(CramMD5ClientAuthenticator(self.username)) |
|---|
| 1789 | self.registerAuthenticator(LOGINAuthenticator(self.username)) |
|---|
| 1790 | self.registerAuthenticator(PLAINAuthenticator(self.username)) |
|---|
| 1791 | |
|---|
| 1792 | def _getContextFactory(self): |
|---|
| 1793 | if self.context is not None: |
|---|
| 1794 | return self.context |
|---|
| 1795 | try: |
|---|
| 1796 | from twisted.internet import ssl |
|---|
| 1797 | except ImportError: |
|---|
| 1798 | return None |
|---|
| 1799 | else: |
|---|
| 1800 | try: |
|---|
| 1801 | context = ssl.ClientContextFactory() |
|---|
| 1802 | context.method = ssl.SSL.TLSv1_METHOD |
|---|
| 1803 | return context |
|---|
| 1804 | except AttributeError: |
|---|
| 1805 | return None |
|---|
| 1806 | |
|---|
| 1807 | |
|---|
| 1808 | class ESMTPSenderFactory(SMTPSenderFactory): |
|---|
| 1809 | """ |
|---|
| 1810 | Utility factory for sending emails easily. |
|---|
| 1811 | """ |
|---|
| 1812 | |
|---|
| 1813 | protocol = ESMTPSender |
|---|
| 1814 | |
|---|
| 1815 | def __init__(self, username, password, fromEmail, toEmail, file, |
|---|
| 1816 | deferred, retries=5, timeout=None, |
|---|
| 1817 | contextFactory=None, heloFallback=False, |
|---|
| 1818 | requireAuthentication=True, |
|---|
| 1819 | requireTransportSecurity=True): |
|---|
| 1820 | |
|---|
| 1821 | SMTPSenderFactory.__init__(self, fromEmail, toEmail, file, deferred, retries, timeout) |
|---|
| 1822 | self.username = username |
|---|
| 1823 | self.password = password |
|---|
| 1824 | self._contextFactory = contextFactory |
|---|
| 1825 | self._heloFallback = heloFallback |
|---|
| 1826 | self._requireAuthentication = requireAuthentication |
|---|
| 1827 | self._requireTransportSecurity = requireTransportSecurity |
|---|
| 1828 | |
|---|
| 1829 | def buildProtocol(self, addr): |
|---|
| 1830 | p = self.protocol(self.username, self.password, self._contextFactory, self.domain, self.nEmails*2+2) |
|---|
| 1831 | p.heloFallback = self._heloFallback |
|---|
| 1832 | p.requireAuthentication = self._requireAuthentication |
|---|
| 1833 | p.requireTransportSecurity = self._requireTransportSecurity |
|---|
| 1834 | p.factory = self |
|---|
| 1835 | p.timeout = self.timeout |
|---|
| 1836 | return p |
|---|
| 1837 | |
|---|
| 1838 | def sendmail(smtphost, from_addr, to_addrs, msg, senderDomainName=None, port=25): |
|---|
| 1839 | """Send an email |
|---|
| 1840 | |
|---|
| 1841 | This interface is intended to be a direct replacement for |
|---|
| 1842 | smtplib.SMTP.sendmail() (with the obvious change that |
|---|
| 1843 | you specify the smtphost as well). Also, ESMTP options |
|---|
| 1844 | are not accepted, as we don't do ESMTP yet. I reserve the |
|---|
| 1845 | right to implement the ESMTP options differently. |
|---|
| 1846 | |
|---|
| 1847 | @param smtphost: The host the message should be sent to |
|---|
| 1848 | @param from_addr: The (envelope) address sending this mail. |
|---|
| 1849 | @param to_addrs: A list of addresses to send this mail to. A string will |
|---|
| 1850 | be treated as a list of one address |
|---|
| 1851 | @param msg: The message, including headers, either as a file or a string. |
|---|
| 1852 | File-like objects need to support read() and close(). Lines must be |
|---|
| 1853 | delimited by '\\n'. If you pass something that doesn't look like a |
|---|
| 1854 | file, we try to convert it to a string (so you should be able to |
|---|
| 1855 | pass an email.Message directly, but doing the conversion with |
|---|
| 1856 | email.Generator manually will give you more control over the |
|---|
| 1857 | process). |
|---|
| 1858 | |
|---|
| 1859 | @param senderDomainName: Name by which to identify. If None, try |
|---|
| 1860 | to pick something sane (but this depends on external configuration |
|---|
| 1861 | and may not succeed). |
|---|
| 1862 | |
|---|
| 1863 | @param port: Remote port to which to connect. |
|---|
| 1864 | |
|---|
| 1865 | @rtype: L{Deferred} |
|---|
| 1866 | @returns: A L{Deferred}, its callback will be called if a message is sent |
|---|
| 1867 | to ANY address, the errback if no message is sent. |
|---|
| 1868 | |
|---|
| 1869 | The callback will be called with a tuple (numOk, addresses) where numOk |
|---|
| 1870 | is the number of successful recipient addresses and addresses is a list |
|---|
| 1871 | of tuples (address, code, resp) giving the response to the RCPT command |
|---|
| 1872 | for each address. |
|---|
| 1873 | """ |
|---|
| 1874 | if not hasattr(msg,'read'): |
|---|
| 1875 | # It's not a file |
|---|
| 1876 | msg = StringIO(str(msg)) |
|---|
| 1877 | |
|---|
| 1878 | d = defer.Deferred() |
|---|
| 1879 | factory = SMTPSenderFactory(from_addr, to_addrs, msg, d) |
|---|
| 1880 | |
|---|
| 1881 | if senderDomainName is not None: |
|---|
| 1882 | factory.domain = senderDomainName |
|---|
| 1883 | |
|---|
| 1884 | reactor.connectTCP(smtphost, port, factory) |
|---|
| 1885 | |
|---|
| 1886 | return d |
|---|
| 1887 | |
|---|
| 1888 | |
|---|
| 1889 | |
|---|
| 1890 | ## |
|---|
| 1891 | ## Yerg. Codecs! |
|---|
| 1892 | ## |
|---|
| 1893 | import codecs |
|---|
| 1894 | def xtext_encode(s, errors=None): |
|---|
| 1895 | r = [] |
|---|
| 1896 | for ch in s: |
|---|
| 1897 | o = ord(ch) |
|---|
| 1898 | if ch == '+' or ch == '=' or o < 33 or o > 126: |
|---|
| 1899 | r.append('+%02X' % o) |
|---|
| 1900 | else: |
|---|
| 1901 | r.append(chr(o)) |
|---|
| 1902 | return (''.join(r), len(s)) |
|---|
| 1903 | |
|---|
| 1904 | |
|---|
| 1905 | def xtext_decode(s, errors=None): |
|---|
| 1906 | """ |
|---|
| 1907 | Decode the xtext-encoded string C{s}. |
|---|
| 1908 | """ |
|---|
| 1909 | r = [] |
|---|
| 1910 | i = 0 |
|---|
| 1911 | while i < len(s): |
|---|
| 1912 | if s[i] == '+': |
|---|
| 1913 | try: |
|---|
| 1914 | r.append(chr(int(s[i + 1:i + 3], 16))) |
|---|
| 1915 | except ValueError: |
|---|
| 1916 | r.append(s[i:i + 3]) |
|---|
| 1917 | i += 3 |
|---|
| 1918 | else: |
|---|
| 1919 | r.append(s[i]) |
|---|
| 1920 | i += 1 |
|---|
| 1921 | return (''.join(r), len(s)) |
|---|
| 1922 | |
|---|
| 1923 | class xtextStreamReader(codecs.StreamReader): |
|---|
| 1924 | def decode(self, s, errors='strict'): |
|---|
| 1925 | return xtext_decode(s) |
|---|
| 1926 | |
|---|
| 1927 | class xtextStreamWriter(codecs.StreamWriter): |
|---|
| 1928 | def decode(self, s, errors='strict'): |
|---|
| 1929 | return xtext_encode(s) |
|---|
| 1930 | |
|---|
| 1931 | def xtext_codec(name): |
|---|
| 1932 | if name == 'xtext': |
|---|
| 1933 | return (xtext_encode, xtext_decode, xtextStreamReader, xtextStreamWriter) |
|---|
| 1934 | codecs.register(xtext_codec) |
|---|