diff options
author | shmel1k <shmel1k@ydb.tech> | 2023-11-26 18:16:14 +0300 |
---|---|---|
committer | shmel1k <shmel1k@ydb.tech> | 2023-11-26 18:43:30 +0300 |
commit | b8cf9e88f4c5c64d9406af533d8948deb050d695 (patch) | |
tree | 218eb61fb3c3b96ec08b4d8cdfef383104a87d63 /contrib/python/Twisted/py3/twisted/mail | |
parent | 523f645a83a0ec97a0332dbc3863bb354c92a328 (diff) | |
download | ydb-b8cf9e88f4c5c64d9406af533d8948deb050d695.tar.gz |
add kikimr_configure
Diffstat (limited to 'contrib/python/Twisted/py3/twisted/mail')
21 files changed, 18036 insertions, 0 deletions
diff --git a/contrib/python/Twisted/py3/twisted/mail/__init__.py b/contrib/python/Twisted/py3/twisted/mail/__init__.py new file mode 100644 index 0000000000..0f1604e8a5 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Twisted Mail: Servers and clients for POP3, ESMTP, and IMAP. +""" diff --git a/contrib/python/Twisted/py3/twisted/mail/_cred.py b/contrib/python/Twisted/py3/twisted/mail/_cred.py new file mode 100644 index 0000000000..0a9442627d --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/_cred.py @@ -0,0 +1,105 @@ +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Credential managers for L{twisted.mail}. +""" + + +import hashlib +import hmac + +from zope.interface import implementer + +from twisted.cred import credentials +from twisted.mail._except import IllegalClientResponse +from twisted.mail.interfaces import IChallengeResponse, IClientAuthentication +from twisted.python.compat import nativeString + + +@implementer(IClientAuthentication) +class CramMD5ClientAuthenticator: + def __init__(self, user): + self.user = user + + def getName(self): + return b"CRAM-MD5" + + def challengeResponse(self, secret, chal): + response = hmac.HMAC(secret, chal, digestmod=hashlib.md5).hexdigest() + return self.user + b" " + response.encode("ascii") + + +@implementer(IClientAuthentication) +class LOGINAuthenticator: + def __init__(self, user): + self.user = user + self.challengeResponse = self.challengeUsername + + def getName(self): + return b"LOGIN" + + def challengeUsername(self, secret, chal): + # Respond to something like "Username:" + self.challengeResponse = self.challengeSecret + return self.user + + def challengeSecret(self, secret, chal): + # Respond to something like "Password:" + return secret + + +@implementer(IClientAuthentication) +class PLAINAuthenticator: + def __init__(self, user): + self.user = user + + def getName(self): + return b"PLAIN" + + def challengeResponse(self, secret, chal): + return b"\0" + self.user + b"\0" + secret + + +@implementer(IChallengeResponse) +class LOGINCredentials(credentials.UsernamePassword): + def __init__(self): + self.challenges = [b"Password\0", b"User Name\0"] + self.responses = [b"password", b"username"] + credentials.UsernamePassword.__init__(self, None, None) + + def getChallenge(self): + return self.challenges.pop() + + def setResponse(self, response): + setattr(self, nativeString(self.responses.pop()), response) + + def moreChallenges(self): + return bool(self.challenges) + + +@implementer(IChallengeResponse) +class PLAINCredentials(credentials.UsernamePassword): + def __init__(self): + credentials.UsernamePassword.__init__(self, None, None) + + def getChallenge(self): + return b"" + + def setResponse(self, response): + parts = response.split(b"\0") + if len(parts) != 3: + raise IllegalClientResponse("Malformed Response - wrong number of parts") + useless, self.username, self.password = parts + + def moreChallenges(self): + return False + + +__all__ = [ + "CramMD5ClientAuthenticator", + "LOGINCredentials", + "LOGINAuthenticator", + "PLAINCredentials", + "PLAINAuthenticator", +] diff --git a/contrib/python/Twisted/py3/twisted/mail/_except.py b/contrib/python/Twisted/py3/twisted/mail/_except.py new file mode 100644 index 0000000000..ce01aedeb5 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/_except.py @@ -0,0 +1,350 @@ +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Exceptions in L{twisted.mail}. +""" + +from typing import Optional + + +class IMAP4Exception(Exception): + pass + + +class IllegalClientResponse(IMAP4Exception): + pass + + +class IllegalOperation(IMAP4Exception): + pass + + +class IllegalMailboxEncoding(IMAP4Exception): + pass + + +class MailboxException(IMAP4Exception): + pass + + +class MailboxCollision(MailboxException): + def __str__(self) -> str: + return "Mailbox named %s already exists" % self.args + + +class NoSuchMailbox(MailboxException): + def __str__(self) -> str: + return "No mailbox named %s exists" % self.args + + +class ReadOnlyMailbox(MailboxException): + def __str__(self) -> str: + return "Mailbox open in read-only state" + + +class UnhandledResponse(IMAP4Exception): + pass + + +class NegativeResponse(IMAP4Exception): + pass + + +class NoSupportedAuthentication(IMAP4Exception): + def __init__(self, serverSupports, clientSupports): + IMAP4Exception.__init__(self, "No supported authentication schemes available") + self.serverSupports = serverSupports + self.clientSupports = clientSupports + + def __str__(self) -> str: + return IMAP4Exception.__str__( + self + ) + ": Server supports {!r}, client supports {!r}".format( + self.serverSupports, + self.clientSupports, + ) + + +class IllegalServerResponse(IMAP4Exception): + pass + + +class IllegalIdentifierError(IMAP4Exception): + pass + + +class IllegalQueryError(IMAP4Exception): + pass + + +class MismatchedNesting(IMAP4Exception): + pass + + +class MismatchedQuoting(IMAP4Exception): + pass + + +class SMTPError(Exception): + pass + + +class SMTPClientError(SMTPError): + """ + Base class for SMTP client errors. + """ + + def __init__( + self, + code: int, + resp: bytes, + log: Optional[bytes] = None, + addresses: Optional[object] = None, + isFatal: bool = False, + retry: bool = False, + ): + """ + @param code: The SMTP response code associated with this error. + @param resp: The string response associated with this error. + @param log: A string log of the exchange leading up to and including + the error. + @param isFatal: A boolean indicating whether this connection can + proceed or not. If True, the connection will be dropped. + @param retry: A boolean indicating whether the delivery should be + retried. If True and the factory indicates further retries are + desirable, they will be attempted, otherwise the delivery will be + failed. + """ + if isinstance(resp, str): # type: ignore[unreachable] + resp = resp.encode("utf-8") # type: ignore[unreachable] + + if isinstance(log, str): + log = log.encode("utf-8") # type: ignore[unreachable] + + self.code = code + self.resp = resp + self.log = log + self.addresses = addresses + self.isFatal = isFatal + self.retry = retry + + def __str__(self) -> str: + return self.__bytes__().decode("utf-8") + + def __bytes__(self) -> bytes: + if self.code > 0: + res = [f"{self.code:03d} ".encode() + self.resp] + else: + res = [self.resp] + if self.log: + res.append(self.log) + res.append(b"") + return b"\n".join(res) + + +class ESMTPClientError(SMTPClientError): + """ + Base class for ESMTP client errors. + """ + + +class EHLORequiredError(ESMTPClientError): + """ + The server does not support EHLO. + + This is considered a non-fatal error (the connection will not be dropped). + """ + + +class AUTHRequiredError(ESMTPClientError): + """ + Authentication was required but the server does not support it. + + This is considered a non-fatal error (the connection will not be dropped). + """ + + +class TLSRequiredError(ESMTPClientError): + """ + Transport security was required but the server does not support it. + + This is considered a non-fatal error (the connection will not be dropped). + """ + + +class AUTHDeclinedError(ESMTPClientError): + """ + The server rejected our credentials. + + Either the username, password, or challenge response + given to the server was rejected. + + This is considered a non-fatal error (the connection will not be + dropped). + """ + + +class AuthenticationError(ESMTPClientError): + """ + An error occurred while authenticating. + + Either the server rejected our request for authentication or the + challenge received was malformed. + + This is considered a non-fatal error (the connection will not be + dropped). + """ + + +class SMTPTLSError(ESMTPClientError): + """ + An error occurred while negiotiating for transport security. + + This is considered a non-fatal error (the connection will not be dropped). + """ + + +class SMTPConnectError(SMTPClientError): + """ + Failed to connect to the mail exchange host. + + This is considered a fatal error. A retry will be made. + """ + + def __init__(self, code, resp, log=None, addresses=None, isFatal=True, retry=True): + SMTPClientError.__init__(self, code, resp, log, addresses, isFatal, retry) + + +class SMTPTimeoutError(SMTPClientError): + """ + Failed to receive a response from the server in the expected time period. + + This is considered a fatal error. A retry will be made. + """ + + def __init__(self, code, resp, log=None, addresses=None, isFatal=True, retry=True): + SMTPClientError.__init__(self, code, resp, log, addresses, isFatal, retry) + + +class SMTPProtocolError(SMTPClientError): + """ + The server sent a mangled response. + + This is considered a fatal error. A retry will not be made. + """ + + def __init__(self, code, resp, log=None, addresses=None, isFatal=True, retry=False): + SMTPClientError.__init__(self, code, resp, log, addresses, isFatal, retry) + + +class SMTPDeliveryError(SMTPClientError): + """ + Indicates that a delivery attempt has had an error. + """ + + +class SMTPServerError(SMTPError): + def __init__(self, code, resp): + self.code = code + self.resp = resp + + def __str__(self) -> str: + return "%.3d %s" % (self.code, self.resp) + + +class SMTPAddressError(SMTPServerError): + def __init__(self, addr, code, resp): + from twisted.mail.smtp import Address + + SMTPServerError.__init__(self, code, resp) + self.addr = Address(addr) + + def __str__(self) -> str: + return "%.3d <%s>... %s" % (self.code, self.addr, self.resp) + + +class SMTPBadRcpt(SMTPAddressError): + def __init__(self, addr, code=550, resp="Cannot receive for specified address"): + SMTPAddressError.__init__(self, addr, code, resp) + + +class SMTPBadSender(SMTPAddressError): + def __init__(self, addr, code=550, resp="Sender not acceptable"): + SMTPAddressError.__init__(self, addr, code, resp) + + +class AddressError(SMTPError): + """ + Parse error in address + """ + + +class POP3Error(Exception): + """ + The base class for POP3 errors. + """ + + pass + + +class _POP3MessageDeleted(Exception): + """ + An internal control-flow error which indicates that a deleted message was + requested. + """ + + +class POP3ClientError(Exception): + """ + The base class for all exceptions raised by POP3Client. + """ + + +class InsecureAuthenticationDisallowed(POP3ClientError): + """ + An error indicating secure authentication was required but no mechanism + could be found. + """ + + +class TLSError(POP3ClientError): + """ + An error indicating secure authentication was required but either the + transport does not support TLS or no TLS context factory was supplied. + """ + + +class TLSNotSupportedError(POP3ClientError): + """ + An error indicating secure authentication was required but the server does + not support TLS. + """ + + +class ServerErrorResponse(POP3ClientError): + """ + An error indicating that the server returned an error response to a + request. + + @ivar consumer: See L{__init__} + """ + + def __init__(self, reason, consumer=None): + """ + @type reason: L{bytes} + @param reason: The server response minus the status indicator. + + @type consumer: callable that takes L{object} + @param consumer: The function meant to handle the values for a + multi-line response. + """ + POP3ClientError.__init__(self, reason) + self.consumer = consumer + + +class LineTooLong(POP3ClientError): + """ + An error indicating that the server sent a line which exceeded the + maximum line length (L{LineOnlyReceiver.MAX_LENGTH}). + """ diff --git a/contrib/python/Twisted/py3/twisted/mail/_pop3client.py b/contrib/python/Twisted/py3/twisted/mail/_pop3client.py new file mode 100644 index 0000000000..08efe1ec54 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/_pop3client.py @@ -0,0 +1,1235 @@ +# -*- test-case-name: twisted.mail.test.test_pop3client -*- +# Copyright (c) 2001-2004 Divmod Inc. +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +A POP3 client protocol implementation. + +Don't use this module directly. Use twisted.mail.pop3 instead. + +@author: Jp Calderone +""" + +import re +from hashlib import md5 +from typing import List + +from twisted.internet import defer, error, interfaces +from twisted.mail._except import ( + InsecureAuthenticationDisallowed, + LineTooLong, + ServerErrorResponse, + TLSError, + TLSNotSupportedError, +) +from twisted.protocols import basic, policies +from twisted.python import log + +OK = b"+OK" +ERR = b"-ERR" + + +class _ListSetter: + """ + A utility class to construct a list from a multi-line response accounting + for deleted messages. + + POP3 responses sometimes occur in the form of a list of lines containing + two pieces of data, a message index and a value of some sort. When a + message is deleted, it is omitted from these responses. The L{setitem} + method of this class is meant to be called with these two values. In the + cases where indices are skipped, it takes care of padding out the missing + values with L{None}. + + @ivar L: See L{__init__} + """ + + def __init__(self, L): + """ + @type L: L{list} of L{object} + @param L: The list being constructed. An empty list should be + passed in. + """ + self.L = L + + def setitem(self, itemAndValue): + """ + Add the value at the specified position, padding out missing entries. + + @type itemAndValue: C{tuple} + @param itemAndValue: A tuple of (item, value). The I{item} is the 0-based + index in the list at which the value should be placed. The value is + is an L{object} to put in the list. + """ + (item, value) = itemAndValue + diff = item - len(self.L) + 1 + if diff > 0: + self.L.extend([None] * diff) + self.L[item] = value + + +def _statXform(line): + """ + Parse the response to a STAT command. + + @type line: L{bytes} + @param line: The response from the server to a STAT command minus the + status indicator. + + @rtype: 2-L{tuple} of (0) L{int}, (1) L{int} + @return: The number of messages in the mailbox and the size of the mailbox. + """ + numMsgs, totalSize = line.split(None, 1) + return int(numMsgs), int(totalSize) + + +def _listXform(line): + """ + Parse a line of the response to a LIST command. + + The line from the LIST response consists of a 1-based message number + followed by a size. + + @type line: L{bytes} + @param line: A non-initial line from the multi-line response to a LIST + command. + + @rtype: 2-L{tuple} of (0) L{int}, (1) L{int} + @return: The 0-based index of the message and the size of the message. + """ + index, size = line.split(None, 1) + return int(index) - 1, int(size) + + +def _uidXform(line): + """ + Parse a line of the response to a UIDL command. + + The line from the UIDL response consists of a 1-based message number + followed by a unique id. + + @type line: L{bytes} + @param line: A non-initial line from the multi-line response to a UIDL + command. + + @rtype: 2-L{tuple} of (0) L{int}, (1) L{bytes} + @return: The 0-based index of the message and the unique identifier + for the message. + """ + index, uid = line.split(None, 1) + return int(index) - 1, uid + + +def _codeStatusSplit(line): + """ + Parse the first line of a multi-line server response. + + @type line: L{bytes} + @param line: The first line of a multi-line server response. + + @rtype: 2-tuple of (0) L{bytes}, (1) L{bytes} + @return: The status indicator and the rest of the server response. + """ + parts = line.split(b" ", 1) + if len(parts) == 1: + return parts[0], b"" + return parts + + +def _dotUnquoter(line): + """ + Remove a byte-stuffed termination character at the beginning of a line if + present. + + When the termination character (C{'.'}) appears at the beginning of a line, + the server byte-stuffs it by adding another termination character to + avoid confusion with the terminating sequence (C{'.\\r\\n'}). + + @type line: L{bytes} + @param line: A received line. + + @rtype: L{bytes} + @return: The line without the byte-stuffed termination character at the + beginning if it was present. Otherwise, the line unchanged. + """ + if line.startswith(b".."): + return line[1:] + return line + + +class POP3Client(basic.LineOnlyReceiver, policies.TimeoutMixin): + """ + A POP3 client protocol. + + Instances of this class provide a convenient, efficient API for + retrieving and deleting messages from a POP3 server. + + This API provides a pipelining interface but POP3 pipelining + on the network is not yet supported. + + @type startedTLS: L{bool} + @ivar startedTLS: An indication of whether TLS has been negotiated + successfully. + + @type allowInsecureLogin: L{bool} + @ivar allowInsecureLogin: An indication of whether plaintext login should + be allowed when the server offers no authentication challenge and the + transport does not offer any protection via encryption. + + @type serverChallenge: L{bytes} or L{None} + @ivar serverChallenge: The challenge received in the server greeting. + + @type timeout: L{int} + @ivar timeout: The number of seconds to wait on a response from the server + before timing out a connection. If the number is <= 0, no timeout + checking will be performed. + + @type _capCache: L{None} or L{dict} mapping L{bytes} + to L{list} of L{bytes} and/or L{bytes} to L{None} + @ivar _capCache: The cached server capabilities. Capabilities are not + allowed to change during the session (except when TLS is negotiated), + so the first response to a capabilities command can be used for + later lookups. + + @type _challengeMagicRe: L{Pattern <re.Pattern.search>} + @ivar _challengeMagicRe: A regular expression which matches the + challenge in the server greeting. + + @type _blockedQueue: L{None} or L{list} of 3-L{tuple} + of (0) L{Deferred <defer.Deferred>}, (1) callable which results + in a L{Deferred <defer.Deferred>}, (2) L{tuple} + @ivar _blockedQueue: A list of blocked commands. While a command is + awaiting a response from the server, other commands are blocked. When + no command is outstanding, C{_blockedQueue} is set to L{None}. + Otherwise, it contains a list of information about blocked commands. + Each list entry provides the following information about a blocked + command: the deferred that should be called when the response to the + command is received, the function that sends the command, and the + arguments to the function. + + @type _waiting: L{Deferred <defer.Deferred>} or + L{None} + @ivar _waiting: A deferred which fires when the response to the + outstanding command is received from the server. + + @type _timedOut: L{bool} + @ivar _timedOut: An indication of whether the connection was dropped + because of a timeout. + + @type _greetingError: L{bytes} or L{None} + @ivar _greetingError: The server greeting minus the status indicator, when + the connection was dropped because of an error in the server greeting. + Otherwise, L{None}. + + @type state: L{bytes} + @ivar state: The state which indicates what type of response is expected + from the server. Valid states are: 'WELCOME', 'WAITING', 'SHORT', + 'LONG_INITIAL', 'LONG'. + + @type _xform: L{None} or callable that takes L{bytes} + and returns L{object} + @ivar _xform: The transform function which is used to convert each + line of a multi-line response into usable values for use by the + consumer function. If L{None}, each line of the multi-line response + is sent directly to the consumer function. + + @type _consumer: callable that takes L{object} + @ivar _consumer: The consumer function which is used to store the + values derived by the transform function from each line of a + multi-line response into a list. + """ + + startedTLS = False + allowInsecureLogin = False + timeout = 0 + serverChallenge = None + + _capCache = None + _challengeMagicRe = re.compile(b"(<[^>]+>)") + _blockedQueue = None + _waiting = None + _timedOut = False + _greetingError = None + + def _blocked(self, f, *a): + """ + Block a command, if necessary. + + If commands are being blocked, append information about the function + which sends the command to a list and return a deferred that will be + chained with the return value of the function when it eventually runs. + Otherwise, set up for subsequent commands to be blocked and return + L{None}. + + @type f: callable + @param f: A function which sends a command. + + @type a: L{tuple} + @param a: Arguments to the function. + + @rtype: L{None} or L{Deferred <defer.Deferred>} + @return: L{None} if the command can run immediately. Otherwise, + a deferred that will eventually trigger with the return value of + the function. + """ + if self._blockedQueue is not None: + d = defer.Deferred() + self._blockedQueue.append((d, f, a)) + return d + self._blockedQueue = [] + return None + + def _unblock(self): + """ + Send the next blocked command. + + If there are no more commands in the blocked queue, set up for the next + command to be sent immediately. + """ + if self._blockedQueue == []: + self._blockedQueue = None + elif self._blockedQueue is not None: + _blockedQueue = self._blockedQueue + self._blockedQueue = None + + d, f, a = _blockedQueue.pop(0) + d2 = f(*a) + d2.chainDeferred(d) + # f is a function which uses _blocked (otherwise it wouldn't + # have gotten into the blocked queue), which means it will have + # re-set _blockedQueue to an empty list, so we can put the rest + # of the blocked queue back into it now. + self._blockedQueue.extend(_blockedQueue) + + def sendShort(self, cmd, args): + """ + Send a POP3 command to which a short response is expected. + + Block all further commands from being sent until the response is + received. Transition the state to SHORT. + + @type cmd: L{bytes} + @param cmd: A POP3 command. + + @type args: L{bytes} + @param args: The command arguments. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the entire response is received. + On an OK response, it returns the response from the server minus + the status indicator. On an ERR response, it issues a server + error response failure with the response from the server minus the + status indicator. + """ + d = self._blocked(self.sendShort, cmd, args) + if d is not None: + return d + + if args: + self.sendLine(cmd + b" " + args) + else: + self.sendLine(cmd) + self.state = "SHORT" + self._waiting = defer.Deferred() + return self._waiting + + def sendLong(self, cmd, args, consumer, xform): + """ + Send a POP3 command to which a multi-line response is expected. + + Block all further commands from being sent until the entire response is + received. Transition the state to LONG_INITIAL. + + @type cmd: L{bytes} + @param cmd: A POP3 command. + + @type args: L{bytes} + @param args: The command arguments. + + @type consumer: callable that takes L{object} + @param consumer: A consumer function which should be used to put + the values derived by a transform function from each line of the + multi-line response into a list. + + @type xform: L{None} or callable that takes + L{bytes} and returns L{object} + @param xform: A transform function which should be used to transform + each line of the multi-line response into usable values for use by + a consumer function. If L{None}, each line of the multi-line + response should be sent directly to the consumer function. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + callable that takes L{object} and fails with L{ServerErrorResponse} + @return: A deferred which fires when the entire response is received. + On an OK response, it returns the consumer function. On an ERR + response, it issues a server error response failure with the + response from the server minus the status indicator and the + consumer function. + """ + d = self._blocked(self.sendLong, cmd, args, consumer, xform) + if d is not None: + return d + + if args: + self.sendLine(cmd + b" " + args) + else: + self.sendLine(cmd) + self.state = "LONG_INITIAL" + self._xform = xform + self._consumer = consumer + self._waiting = defer.Deferred() + return self._waiting + + # Twisted protocol callback + def connectionMade(self): + """ + Wait for a greeting from the server after the connection has been made. + + Start the connection in the WELCOME state. + """ + if self.timeout > 0: + self.setTimeout(self.timeout) + + self.state = "WELCOME" + self._blockedQueue = [] + + def timeoutConnection(self): + """ + Drop the connection when the server does not respond in time. + """ + self._timedOut = True + self.transport.loseConnection() + + def connectionLost(self, reason): + """ + Clean up when the connection has been lost. + + When the loss of connection was initiated by the client due to a + timeout, the L{_timedOut} flag will be set. When it was initiated by + the client due to an error in the server greeting, L{_greetingError} + will be set to the server response minus the status indicator. + + @type reason: L{Failure <twisted.python.failure.Failure>} + @param reason: The reason the connection was terminated. + """ + if self.timeout > 0: + self.setTimeout(None) + + if self._timedOut: + reason = error.TimeoutError() + elif self._greetingError: + reason = ServerErrorResponse(self._greetingError) + + d = [] + if self._waiting is not None: + d.append(self._waiting) + self._waiting = None + if self._blockedQueue is not None: + d.extend([deferred for (deferred, f, a) in self._blockedQueue]) + self._blockedQueue = None + for w in d: + w.errback(reason) + + def lineReceived(self, line): + """ + Pass a received line to a state machine function and + transition to the next state. + + @type line: L{bytes} + @param line: A received line. + """ + if self.timeout > 0: + self.resetTimeout() + + state = self.state + self.state = None + state = getattr(self, "state_" + state)(line) or state + if self.state is None: + self.state = state + + def lineLengthExceeded(self, buffer): + """ + Drop the connection when a server response exceeds the maximum line + length (L{LineOnlyReceiver.MAX_LENGTH}). + + @type buffer: L{bytes} + @param buffer: A received line which exceeds the maximum line length. + """ + # XXX - We need to be smarter about this + if self._waiting is not None: + waiting, self._waiting = self._waiting, None + waiting.errback(LineTooLong()) + self.transport.loseConnection() + + # POP3 Client state logic - don't touch this. + def state_WELCOME(self, line): + """ + Handle server responses for the WELCOME state in which the server + greeting is expected. + + WELCOME is the first state. The server should send one line of text + with a greeting and possibly an APOP challenge. Transition the state + to WAITING. + + @type line: L{bytes} + @param line: A line received from the server. + + @rtype: L{bytes} + @return: The next state. + """ + code, status = _codeStatusSplit(line) + if code != OK: + self._greetingError = status + self.transport.loseConnection() + else: + m = self._challengeMagicRe.search(status) + + if m is not None: + self.serverChallenge = m.group(1) + + self.serverGreeting(status) + + self._unblock() + return "WAITING" + + def state_WAITING(self, line): + """ + Log an error for server responses received in the WAITING state during + which the server is not expected to send anything. + + @type line: L{bytes} + @param line: A line received from the server. + """ + log.msg("Illegal line from server: " + repr(line)) + + def state_SHORT(self, line): + """ + Handle server responses for the SHORT state in which the server is + expected to send a single line response. + + Parse the response and fire the deferred which is waiting on receipt of + a complete response. Transition the state back to WAITING. + + @type line: L{bytes} + @param line: A line received from the server. + + @rtype: L{bytes} + @return: The next state. + """ + deferred, self._waiting = self._waiting, None + self._unblock() + code, status = _codeStatusSplit(line) + if code == OK: + deferred.callback(status) + else: + deferred.errback(ServerErrorResponse(status)) + return "WAITING" + + def state_LONG_INITIAL(self, line): + """ + Handle server responses for the LONG_INITIAL state in which the server + is expected to send the first line of a multi-line response. + + Parse the response. On an OK response, transition the state to + LONG. On an ERR response, cleanup and transition the state to + WAITING. + + @type line: L{bytes} + @param line: A line received from the server. + + @rtype: L{bytes} + @return: The next state. + """ + code, status = _codeStatusSplit(line) + if code == OK: + return "LONG" + consumer = self._consumer + deferred = self._waiting + self._consumer = self._waiting = self._xform = None + self._unblock() + deferred.errback(ServerErrorResponse(status, consumer)) + return "WAITING" + + def state_LONG(self, line): + """ + Handle server responses for the LONG state in which the server is + expected to send a non-initial line of a multi-line response. + + On receipt of the last line of the response, clean up, fire the + deferred which is waiting on receipt of a complete response, and + transition the state to WAITING. Otherwise, pass the line to the + transform function, if provided, and then the consumer function. + + @type line: L{bytes} + @param line: A line received from the server. + + @rtype: L{bytes} + @return: The next state. + """ + # This is the state for each line of a long response. + if line == b".": + consumer = self._consumer + deferred = self._waiting + self._consumer = self._waiting = self._xform = None + self._unblock() + deferred.callback(consumer) + return "WAITING" + else: + if self._xform is not None: + self._consumer(self._xform(line)) + else: + self._consumer(line) + return "LONG" + + # Callbacks - override these + def serverGreeting(self, greeting): + """ + Handle the server greeting. + + @type greeting: L{bytes} + @param greeting: The server greeting minus the status indicator. + For servers implementing APOP authentication, this will contain a + challenge string. + """ + + # External API - call these (most of 'em anyway) + def startTLS(self, contextFactory=None): + """ + Switch to encrypted communication using TLS. + + The first step of switching to encrypted communication is obtaining + the server's capabilities. When that is complete, the L{_startTLS} + callback function continues the switching process. + + @type contextFactory: L{None} or + L{ClientContextFactory <twisted.internet.ssl.ClientContextFactory>} + @param contextFactory: The context factory with which to negotiate TLS. + If not provided, try to create a new one. + + @rtype: L{Deferred <defer.Deferred>} which successfully results in + L{dict} mapping L{bytes} to L{list} of L{bytes} and/or L{bytes} to + L{None} or fails with L{TLSError} + @return: A deferred which fires when the transport has been + secured according to the given context factory with the server + capabilities, or which fails with a TLS error if the transport + cannot be secured. + """ + tls = interfaces.ITLSTransport(self.transport, None) + if tls is None: + return defer.fail( + TLSError( + "POP3Client transport does not implement " + "interfaces.ITLSTransport" + ) + ) + + if contextFactory is None: + contextFactory = self._getContextFactory() + + if contextFactory is None: + return defer.fail( + TLSError( + "POP3Client requires a TLS context to " + "initiate the STLS handshake" + ) + ) + + d = self.capabilities() + d.addCallback(self._startTLS, contextFactory, tls) + return d + + def _startTLS(self, caps, contextFactory, tls): + """ + Continue the process of switching to encrypted communication. + + This callback function runs after the server capabilities are received. + + The next step is sending the server an STLS command to request a + switch to encrypted communication. When an OK response is received, + the L{_startedTLS} callback function completes the switch to encrypted + communication. Then, the new server capabilities are requested. + + @type caps: L{dict} mapping L{bytes} to L{list} of L{bytes} and/or + L{bytes} to L{None} + @param caps: The server capabilities. + + @type contextFactory: L{ClientContextFactory + <twisted.internet.ssl.ClientContextFactory>} + @param contextFactory: A context factory with which to negotiate TLS. + + @type tls: L{ITLSTransport <interfaces.ITLSTransport>} + @param tls: A TCP transport that supports switching to TLS midstream. + + @rtype: L{Deferred <defer.Deferred>} which successfully triggers with + L{dict} mapping L{bytes} to L{list} of L{bytes} and/or L{bytes} to + L{None} or fails with L{TLSNotSupportedError} + @return: A deferred which successfully fires when the response from + the server to the request to start TLS has been received and the + new server capabilities have been received or fails when the server + does not support TLS. + """ + assert ( + not self.startedTLS + ), "Client and Server are currently communicating via TLS" + + if b"STLS" not in caps: + return defer.fail( + TLSNotSupportedError( + "Server does not support secure communication " "via TLS / SSL" + ) + ) + + d = self.sendShort(b"STLS", None) + d.addCallback(self._startedTLS, contextFactory, tls) + d.addCallback(lambda _: self.capabilities()) + return d + + def _startedTLS(self, result, context, tls): + """ + Complete the process of switching to encrypted communication. + + This callback function runs after the response to the STLS command has + been received. + + The final steps are discarding the cached capabilities and initiating + TLS negotiation on the transport. + + @type result: L{dict} mapping L{bytes} to L{list} of L{bytes} and/or + L{bytes} to L{None} + @param result: The server capabilities. + + @type context: L{ClientContextFactory + <twisted.internet.ssl.ClientContextFactory>} + @param context: A context factory with which to negotiate TLS. + + @type tls: L{ITLSTransport <interfaces.ITLSTransport>} + @param tls: A TCP transport that supports switching to TLS midstream. + + @rtype: L{dict} mapping L{bytes} to L{list} of L{bytes} and/or L{bytes} + to L{None} + @return: The server capabilities. + """ + self.transport = tls + self.transport.startTLS(context) + self._capCache = None + self.startedTLS = True + return result + + def _getContextFactory(self): + """ + Get a context factory with which to negotiate TLS. + + @rtype: L{None} or + L{ClientContextFactory <twisted.internet.ssl.ClientContextFactory>} + @return: A context factory or L{None} if TLS is not supported on the + client. + """ + try: + from twisted.internet import ssl + except ImportError: + return None + else: + context = ssl.ClientContextFactory() + context.method = ssl.SSL.TLSv1_2_METHOD + return context + + def login(self, username, password): + """ + Log in to the server. + + If APOP is available it will be used. Otherwise, if TLS is + available, an encrypted session will be started and plaintext + login will proceed. Otherwise, if L{allowInsecureLogin} is set, + insecure plaintext login will proceed. Otherwise, + L{InsecureAuthenticationDisallowed} will be raised. + + The first step of logging into the server is obtaining the server's + capabilities. When that is complete, the L{_login} callback function + continues the login process. + + @type username: L{bytes} + @param username: The username with which to log in. + + @type password: L{bytes} + @param password: The password with which to log in. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} + @return: A deferred which fires when the login process is complete. + On a successful login, it returns the server's response minus the + status indicator. + """ + d = self.capabilities() + d.addCallback(self._login, username, password) + return d + + def _login(self, caps, username, password): + """ + Continue the process of logging in to the server. + + This callback function runs after the server capabilities are received. + + If the server provided a challenge in the greeting, proceed with an + APOP login. Otherwise, if the server and the transport support + encrypted communication, try to switch to TLS and then complete + the login process with the L{_loginTLS} callback function. Otherwise, + if insecure authentication is allowed, do a plaintext login. + Otherwise, fail with an L{InsecureAuthenticationDisallowed} error. + + @type caps: L{dict} mapping L{bytes} to L{list} of L{bytes} and/or + L{bytes} to L{None} + @param caps: The server capabilities. + + @type username: L{bytes} + @param username: The username with which to log in. + + @type password: L{bytes} + @param password: The password with which to log in. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} + @return: A deferred which fires when the login process is complete. + On a successful login, it returns the server's response minus the + status indicator. + """ + if self.serverChallenge is not None: + return self._apop(username, password, self.serverChallenge) + + tryTLS = b"STLS" in caps + + # If our transport supports switching to TLS, we might want to + # try to switch to TLS. + tlsableTransport = interfaces.ITLSTransport(self.transport, None) is not None + + # If our transport is not already using TLS, we might want to + # try to switch to TLS. + nontlsTransport = interfaces.ISSLTransport(self.transport, None) is None + + if not self.startedTLS and tryTLS and tlsableTransport and nontlsTransport: + d = self.startTLS() + + d.addCallback(self._loginTLS, username, password) + return d + + elif self.startedTLS or not nontlsTransport or self.allowInsecureLogin: + return self._plaintext(username, password) + else: + return defer.fail(InsecureAuthenticationDisallowed()) + + def _loginTLS(self, res, username, password): + """ + Do a plaintext login over an encrypted transport. + + This callback function runs after the transport switches to encrypted + communication. + + @type res: L{dict} mapping L{bytes} to L{list} of L{bytes} and/or + L{bytes} to L{None} + @param res: The server capabilities. + + @type username: L{bytes} + @param username: The username with which to log in. + + @type password: L{bytes} + @param password: The password with which to log in. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server accepts the username + and password or fails when the server rejects either. On a + successful login, it returns the server's response minus the + status indicator. + """ + return self._plaintext(username, password) + + def _plaintext(self, username, password): + """ + Perform a plaintext login. + + @type username: L{bytes} + @param username: The username with which to log in. + + @type password: L{bytes} + @param password: The password with which to log in. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server accepts the username + and password or fails when the server rejects either. On a + successful login, it returns the server's response minus the + status indicator. + """ + return self.user(username).addCallback(lambda r: self.password(password)) + + def _apop(self, username, password, challenge): + """ + Perform an APOP login. + + @type username: L{bytes} + @param username: The username with which to log in. + + @type password: L{bytes} + @param password: The password with which to log in. + + @type challenge: L{bytes} + @param challenge: A challenge string. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On a successful login, it returns the server response minus + the status indicator. + """ + digest = md5(challenge + password).hexdigest().encode("ascii") + return self.apop(username, digest) + + def apop(self, username, digest): + """ + Send an APOP command to perform authenticated login. + + This should be used in special circumstances only, when it is + known that the server supports APOP authentication, and APOP + authentication is absolutely required. For the common case, + use L{login} instead. + + @type username: L{bytes} + @param username: The username with which to log in. + + @type digest: L{bytes} + @param digest: The challenge response to authenticate with. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On an OK response, the deferred succeeds with the server + response minus the status indicator. On an ERR response, the + deferred fails with a server error response failure. + """ + return self.sendShort(b"APOP", username + b" " + digest) + + def user(self, username): + """ + Send a USER command to perform the first half of plaintext login. + + Unless this is absolutely required, use the L{login} method instead. + + @type username: L{bytes} + @param username: The username with which to log in. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On an OK response, the deferred succeeds with the server + response minus the status indicator. On an ERR response, the + deferred fails with a server error response failure. + """ + return self.sendShort(b"USER", username) + + def password(self, password): + """ + Send a PASS command to perform the second half of plaintext login. + + Unless this is absolutely required, use the L{login} method instead. + + @type password: L{bytes} + @param password: The plaintext password with which to authenticate. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On an OK response, the deferred succeeds with the server + response minus the status indicator. On an ERR response, the + deferred fails with a server error response failure. + """ + return self.sendShort(b"PASS", password) + + def delete(self, index): + """ + Send a DELE command to delete a message from the server. + + @type index: L{int} + @param index: The 0-based index of the message to delete. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On an OK response, the deferred succeeds with the server + response minus the status indicator. On an ERR response, the + deferred fails with a server error response failure. + """ + return self.sendShort(b"DELE", b"%d" % (index + 1,)) + + def _consumeOrSetItem(self, cmd, args, consumer, xform): + """ + Send a command to which a long response is expected and process the + multi-line response into a list accounting for deleted messages. + + @type cmd: L{bytes} + @param cmd: A POP3 command to which a long response is expected. + + @type args: L{bytes} + @param args: The command arguments. + + @type consumer: L{None} or callable that takes + L{object} + @param consumer: L{None} or a function that consumes the output from + the transform function. + + @type xform: L{None}, callable that takes + L{bytes} and returns 2-L{tuple} of (0) L{int}, (1) L{object}, + or callable that takes L{bytes} and returns L{object} + @param xform: A function that parses a line from a multi-line response + and transforms the values into usable form for input to the + consumer function. If no consumer function is specified, the + output must be a message index and corresponding value. If no + transform function is specified, the line is used as is. + + @rtype: L{Deferred <defer.Deferred>} which fires with L{list} of + L{object} or callable that takes L{list} of L{object} + @return: A deferred which fires when the entire response has been + received. When a consumer is not provided, the return value is a + list of the value for each message or L{None} for deleted messages. + Otherwise, it returns the consumer itself. + """ + if consumer is None: + L = [] + consumer = _ListSetter(L).setitem + return self.sendLong(cmd, args, consumer, xform).addCallback(lambda r: L) + return self.sendLong(cmd, args, consumer, xform) + + def _consumeOrAppend(self, cmd, args, consumer, xform): + """ + Send a command to which a long response is expected and process the + multi-line response into a list. + + @type cmd: L{bytes} + @param cmd: A POP3 command which expects a long response. + + @type args: L{bytes} + @param args: The command arguments. + + @type consumer: L{None} or callable that takes + L{object} + @param consumer: L{None} or a function that consumes the output from the + transform function. + + @type xform: L{None} or callable that takes + L{bytes} and returns L{object} + @param xform: A function that transforms a line from a multi-line + response into usable form for input to the consumer function. If + no transform function is specified, the line is used as is. + + @rtype: L{Deferred <defer.Deferred>} which fires with L{list} of + 2-L{tuple} of (0) L{int}, (1) L{object} or callable that + takes 2-L{tuple} of (0) L{int}, (1) L{object} + @return: A deferred which fires when the entire response has been + received. When a consumer is not provided, the return value is a + list of the transformed lines. Otherwise, it returns the consumer + itself. + """ + if consumer is None: + L = [] + consumer = L.append + return self.sendLong(cmd, args, consumer, xform).addCallback(lambda r: L) + return self.sendLong(cmd, args, consumer, xform) + + def capabilities(self, useCache=True): + """ + Send a CAPA command to retrieve the capabilities supported by + the server. + + Not all servers support this command. If the server does not + support this, it is treated as though it returned a successful + response listing no capabilities. At some future time, this may be + changed to instead seek out information about a server's + capabilities in some other fashion (only if it proves useful to do + so, and only if there are servers still in use which do not support + CAPA but which do support POP3 extensions that are useful). + + @type useCache: L{bool} + @param useCache: A flag that determines whether previously retrieved + results should be used if available. + + @rtype: L{Deferred <defer.Deferred>} which successfully results in + L{dict} mapping L{bytes} to L{list} of L{bytes} and/or L{bytes} to + L{None} + @return: A deferred which fires with a mapping of capability name to + parameters. For example:: + + C: CAPA + S: +OK Capability list follows + S: TOP + S: USER + S: SASL CRAM-MD5 KERBEROS_V4 + S: RESP-CODES + S: LOGIN-DELAY 900 + S: PIPELINING + S: EXPIRE 60 + S: UIDL + S: IMPLEMENTATION Shlemazle-Plotz-v302 + S: . + + will be lead to a result of:: + + | {'TOP': None, + | 'USER': None, + | 'SASL': ['CRAM-MD5', 'KERBEROS_V4'], + | 'RESP-CODES': None, + | 'LOGIN-DELAY': ['900'], + | 'PIPELINING': None, + | 'EXPIRE': ['60'], + | 'UIDL': None, + | 'IMPLEMENTATION': ['Shlemazle-Plotz-v302']} + """ + if useCache and self._capCache is not None: + return defer.succeed(self._capCache) + + cache = {} + + def consume(line): + tmp = line.split() + if len(tmp) == 1: + cache[tmp[0]] = None + elif len(tmp) > 1: + cache[tmp[0]] = tmp[1:] + + def capaNotSupported(err): + err.trap(ServerErrorResponse) + return None + + def gotCapabilities(result): + self._capCache = cache + return cache + + d = self._consumeOrAppend(b"CAPA", None, consume, None) + d.addErrback(capaNotSupported).addCallback(gotCapabilities) + return d + + def noop(self): + """ + Send a NOOP command asking the server to do nothing but respond. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On an OK response, the deferred succeeds with the server + response minus the status indicator. On an ERR response, the + deferred fails with a server error response failure. + """ + return self.sendShort(b"NOOP", None) + + def reset(self): + """ + Send a RSET command to unmark any messages that have been flagged + for deletion on the server. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On an OK response, the deferred succeeds with the server + response minus the status indicator. On an ERR response, the + deferred fails with a server error response failure. + """ + return self.sendShort(b"RSET", None) + + def retrieve(self, index, consumer=None, lines=None): + """ + Send a RETR or TOP command to retrieve all or part of a message from + the server. + + @type index: L{int} + @param index: A 0-based message index. + + @type consumer: L{None} or callable that takes + L{bytes} + @param consumer: A function which consumes each transformed line from a + multi-line response as it is received. + + @type lines: L{None} or L{int} + @param lines: If specified, the number of lines of the message to be + retrieved. Otherwise, the entire message is retrieved. + + @rtype: L{Deferred <defer.Deferred>} which fires with L{list} of + L{bytes}, or callable that takes 2-L{tuple} of (0) L{int}, + (1) L{object} + @return: A deferred which fires when the entire response has been + received. When a consumer is not provided, the return value is a + list of the transformed lines. Otherwise, it returns the consumer + itself. + """ + idx = b"%d" % (index + 1,) + if lines is None: + return self._consumeOrAppend(b"RETR", idx, consumer, _dotUnquoter) + + return self._consumeOrAppend( + b"TOP", b"%b %d" % (idx, lines), consumer, _dotUnquoter + ) + + def stat(self): + """ + Send a STAT command to get information about the size of the mailbox. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + a 2-tuple of (0) L{int}, (1) L{int} or fails with + L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On an OK response, the deferred succeeds with the number of + messages in the mailbox and the size of the mailbox in octets. + On an ERR response, the deferred fails with a server error + response failure. + """ + return self.sendShort(b"STAT", None).addCallback(_statXform) + + def listSize(self, consumer=None): + """ + Send a LIST command to retrieve the sizes of all messages on the + server. + + @type consumer: L{None} or callable that takes + 2-L{tuple} of (0) L{int}, (1) L{int} + @param consumer: A function which consumes the 0-based message index + and message size derived from the server response. + + @rtype: L{Deferred <defer.Deferred>} which fires L{list} of L{int} or + callable that takes 2-L{tuple} of (0) L{int}, (1) L{int} + @return: A deferred which fires when the entire response has been + received. When a consumer is not provided, the return value is a + list of message sizes. Otherwise, it returns the consumer itself. + """ + return self._consumeOrSetItem(b"LIST", None, consumer, _listXform) + + def listUID(self, consumer=None): + """ + Send a UIDL command to retrieve the UIDs of all messages on the server. + + @type consumer: L{None} or callable that takes + 2-L{tuple} of (0) L{int}, (1) L{bytes} + @param consumer: A function which consumes the 0-based message index + and UID derived from the server response. + + @rtype: L{Deferred <defer.Deferred>} which fires with L{list} of + L{object} or callable that takes 2-L{tuple} of (0) L{int}, + (1) L{bytes} + @return: A deferred which fires when the entire response has been + received. When a consumer is not provided, the return value is a + list of message sizes. Otherwise, it returns the consumer itself. + """ + return self._consumeOrSetItem(b"UIDL", None, consumer, _uidXform) + + def quit(self): + """ + Send a QUIT command to disconnect from the server. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + L{bytes} or fails with L{ServerErrorResponse} + @return: A deferred which fires when the server response is received. + On an OK response, the deferred succeeds with the server + response minus the status indicator. On an ERR response, the + deferred fails with a server error response failure. + """ + return self.sendShort(b"QUIT", None) + + +__all__: List[str] = [] diff --git a/contrib/python/Twisted/py3/twisted/mail/alias.py b/contrib/python/Twisted/py3/twisted/mail/alias.py new file mode 100644 index 0000000000..4713ad3821 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/alias.py @@ -0,0 +1,765 @@ +# -*- test-case-name: twisted.mail.test.test_mail -*- +# +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + + +""" +Support for aliases(5) configuration files. + +@author: Jp Calderone +""" + +import os +import tempfile + +from zope.interface import implementer + +from twisted.internet import defer, protocol, reactor +from twisted.mail import smtp +from twisted.mail.interfaces import IAlias +from twisted.python import failure, log + + +def handle(result, line, filename, lineNo): + """ + Parse a line from an aliases file. + + @type result: L{dict} mapping L{bytes} to L{list} of L{bytes} + @param result: A dictionary mapping username to aliases to which + the results of parsing the line are added. + + @type line: L{bytes} + @param line: A line from an aliases file. + + @type filename: L{bytes} + @param filename: The full or relative path to the aliases file. + + @type lineNo: L{int} + @param lineNo: The position of the line within the aliases file. + """ + parts = [p.strip() for p in line.split(":", 1)] + if len(parts) != 2: + fmt = "Invalid format on line %d of alias file %s." + arg = (lineNo, filename) + log.err(fmt % arg) + else: + user, alias = parts + result.setdefault(user.strip(), []).extend(map(str.strip, alias.split(","))) + + +def loadAliasFile(domains, filename=None, fp=None): + """ + Load a file containing email aliases. + + Lines in the file should be formatted like so:: + + username: alias1, alias2, ..., aliasN + + Aliases beginning with a C{|} will be treated as programs, will be run, and + the message will be written to their stdin. + + Aliases beginning with a C{:} will be treated as a file containing + additional aliases for the username. + + Aliases beginning with a C{/} will be treated as the full pathname to a file + to which the message will be appended. + + Aliases without a host part will be assumed to be addresses on localhost. + + If a username is specified multiple times, the aliases for each are joined + together as if they had all been on one line. + + Lines beginning with a space or a tab are continuations of the previous + line. + + Lines beginning with a C{#} are comments. + + @type domains: L{dict} mapping L{bytes} to L{IDomain} provider + @param domains: A mapping of domain name to domain object. + + @type filename: L{bytes} or L{None} + @param filename: The full or relative path to a file from which to load + aliases. If omitted, the C{fp} parameter must be specified. + + @type fp: file-like object or L{None} + @param fp: The file from which to load aliases. If specified, + the C{filename} parameter is ignored. + + @rtype: L{dict} mapping L{bytes} to L{AliasGroup} + @return: A mapping from username to group of aliases. + """ + result = {} + close = False + if fp is None: + fp = open(filename) + close = True + else: + filename = getattr(fp, "name", "<unknown>") + i = 0 + prev = "" + try: + for line in fp: + i += 1 + line = line.rstrip() + if line.lstrip().startswith("#"): + continue + elif line.startswith(" ") or line.startswith("\t"): + prev = prev + line + else: + if prev: + handle(result, prev, filename, i) + prev = line + finally: + if close: + fp.close() + if prev: + handle(result, prev, filename, i) + for u, a in result.items(): + result[u] = AliasGroup(a, domains, u) + return result + + +class AliasBase: + """ + The default base class for aliases. + + @ivar domains: See L{__init__}. + + @type original: L{Address} + @ivar original: The original address being aliased. + """ + + def __init__(self, domains, original): + """ + @type domains: L{dict} mapping L{bytes} to L{IDomain} provider + @param domains: A mapping of domain name to domain object. + + @type original: L{bytes} + @param original: The original address being aliased. + """ + self.domains = domains + self.original = smtp.Address(original) + + def domain(self): + """ + Return the domain associated with original address. + + @rtype: L{IDomain} provider + @return: The domain for the original address. + """ + return self.domains[self.original.domain] + + def resolve(self, aliasmap, memo=None): + """ + Map this alias to its ultimate destination. + + @type aliasmap: L{dict} mapping L{bytes} to L{AliasBase} + @param aliasmap: A mapping of username to alias or group of aliases. + + @type memo: L{None} or L{dict} of L{AliasBase} + @param memo: A record of the aliases already considered in the + resolution process. If provided, C{memo} is modified to include + this alias. + + @rtype: L{IMessage <smtp.IMessage>} or L{None} + @return: A message receiver for the ultimate destination or None for + an invalid destination. + """ + if memo is None: + memo = {} + if str(self) in memo: + return None + memo[str(self)] = None + return self.createMessageReceiver() + + +@implementer(IAlias) +class AddressAlias(AliasBase): + """ + An alias which translates one email address into another. + + @type alias : L{Address} + @ivar alias: The destination address. + """ + + def __init__(self, alias, *args): + """ + @type alias: L{Address}, L{User}, L{bytes} or object which can be + converted into L{bytes} + @param alias: The destination address. + + @type args: 2-L{tuple} of (0) L{dict} mapping L{bytes} to L{IDomain} + provider, (1) L{bytes} + @param args: Arguments for L{AliasBase.__init__}. + """ + AliasBase.__init__(self, *args) + self.alias = smtp.Address(alias) + + def __str__(self) -> str: + """ + Build a string representation of this L{AddressAlias} instance. + + @rtype: L{bytes} + @return: A string containing the destination address. + """ + return f"<Address {self.alias}>" + + def createMessageReceiver(self): + """ + Create a message receiver which delivers a message to + the destination address. + + @rtype: L{IMessage <smtp.IMessage>} provider + @return: A message receiver. + """ + return self.domain().exists(str(self.alias)) + + def resolve(self, aliasmap, memo=None): + """ + Map this alias to its ultimate destination. + + @type aliasmap: L{dict} mapping L{bytes} to L{AliasBase} + @param aliasmap: A mapping of username to alias or group of aliases. + + @type memo: L{None} or L{dict} of L{AliasBase} + @param memo: A record of the aliases already considered in the + resolution process. If provided, C{memo} is modified to include + this alias. + + @rtype: L{IMessage <smtp.IMessage>} or L{None} + @return: A message receiver for the ultimate destination or None for + an invalid destination. + """ + if memo is None: + memo = {} + if str(self) in memo: + return None + memo[str(self)] = None + try: + return self.domain().exists(smtp.User(self.alias, None, None, None), memo)() + except smtp.SMTPBadRcpt: + pass + if self.alias.local in aliasmap: + return aliasmap[self.alias.local].resolve(aliasmap, memo) + return None + + +@implementer(smtp.IMessage) +class FileWrapper: + """ + A message receiver which delivers a message to a file. + + @type fp: file-like object + @ivar fp: A file used for temporary storage of the message. + + @type finalname: L{bytes} + @ivar finalname: The name of the file in which the message should be + stored. + """ + + def __init__(self, filename): + """ + @type filename: L{bytes} + @param filename: The name of the file in which the message should be + stored. + """ + self.fp = tempfile.TemporaryFile() + self.finalname = filename + + def lineReceived(self, line): + """ + Write a received line to the temporary file. + + @type line: L{bytes} + @param line: A received line of the message. + """ + self.fp.write(line + "\n") + + def eomReceived(self): + """ + Handle end of message by writing the message to the file. + + @rtype: L{Deferred <defer.Deferred>} which successfully results in + L{bytes} + @return: A deferred which succeeds with the name of the file to which + the message has been stored or fails if the message cannot be + saved to the file. + """ + self.fp.seek(0, 0) + try: + f = open(self.finalname, "a") + except BaseException: + return defer.fail(failure.Failure()) + + with f: + f.write(self.fp.read()) + self.fp.close() + + return defer.succeed(self.finalname) + + def connectionLost(self): + """ + Close the temporary file when the connection is lost. + """ + self.fp.close() + self.fp = None + + def __str__(self) -> str: + """ + Build a string representation of this L{FileWrapper} instance. + + @rtype: L{bytes} + @return: A string containing the file name of the message. + """ + return f"<FileWrapper {self.finalname}>" + + +@implementer(IAlias) +class FileAlias(AliasBase): + """ + An alias which translates an address to a file. + + @ivar filename: See L{__init__}. + """ + + def __init__(self, filename, *args): + """ + @type filename: L{bytes} + @param filename: The name of the file in which to store the message. + + @type args: 2-L{tuple} of (0) L{dict} mapping L{bytes} to L{IDomain} + provider, (1) L{bytes} + @param args: Arguments for L{AliasBase.__init__}. + """ + AliasBase.__init__(self, *args) + self.filename = filename + + def __str__(self) -> str: + """ + Build a string representation of this L{FileAlias} instance. + + @rtype: L{bytes} + @return: A string containing the name of the file. + """ + return f"<File {self.filename}>" + + def createMessageReceiver(self): + """ + Create a message receiver which delivers a message to the file. + + @rtype: L{FileWrapper} + @return: A message receiver which writes a message to the file. + """ + return FileWrapper(self.filename) + + +class ProcessAliasTimeout(Exception): + """ + An error indicating that a timeout occurred while waiting for a process + to complete. + """ + + +@implementer(smtp.IMessage) +class MessageWrapper: + """ + A message receiver which delivers a message to a child process. + + @type completionTimeout: L{int} or L{float} + @ivar completionTimeout: The number of seconds to wait for the child + process to exit before reporting the delivery as a failure. + + @type _timeoutCallID: L{None} or + L{IDelayedCall <twisted.internet.interfaces.IDelayedCall>} provider + @ivar _timeoutCallID: The call used to time out delivery, started when the + connection to the child process is closed. + + @type done: L{bool} + @ivar done: A flag indicating whether the child process has exited + (C{True}) or not (C{False}). + + @type reactor: L{IReactorTime <twisted.internet.interfaces.IReactorTime>} + provider + @ivar reactor: A reactor which will be used to schedule timeouts. + + @ivar protocol: See L{__init__}. + + @type processName: L{bytes} or L{None} + @ivar processName: The process name. + + @type completion: L{Deferred <defer.Deferred>} + @ivar completion: The deferred which will be triggered by the protocol + when the child process exits. + """ + + done = False + + completionTimeout = 60 + _timeoutCallID = None + + reactor = reactor + + def __init__(self, protocol, process=None, reactor=None): + """ + @type protocol: L{ProcessAliasProtocol} + @param protocol: The protocol associated with the child process. + + @type process: L{bytes} or L{None} + @param process: The process name. + + @type reactor: L{None} or L{IReactorTime + <twisted.internet.interfaces.IReactorTime>} provider + @param reactor: A reactor which will be used to schedule timeouts. + """ + self.processName = process + self.protocol = protocol + self.completion = defer.Deferred() + self.protocol.onEnd = self.completion + self.completion.addBoth(self._processEnded) + + if reactor is not None: + self.reactor = reactor + + def _processEnded(self, result): + """ + Record process termination and cancel the timeout call if it is active. + + @type result: L{Failure <failure.Failure>} + @param result: The reason the child process terminated. + + @rtype: L{None} or L{Failure <failure.Failure>} + @return: None, if the process end is expected, or the reason the child + process terminated, if the process end is unexpected. + """ + self.done = True + if self._timeoutCallID is not None: + # eomReceived was called, we're actually waiting for the process to + # exit. + self._timeoutCallID.cancel() + self._timeoutCallID = None + else: + # eomReceived was not called, this is unexpected, propagate the + # error. + return result + + def lineReceived(self, line): + """ + Write a received line to the child process. + + @type line: L{bytes} + @param line: A received line of the message. + """ + if self.done: + return + self.protocol.transport.write(line + "\n") + + def eomReceived(self): + """ + Disconnect from the child process and set up a timeout to wait for it + to exit. + + @rtype: L{Deferred <defer.Deferred>} + @return: A deferred which will be called back when the child process + exits. + """ + if not self.done: + self.protocol.transport.loseConnection() + self._timeoutCallID = self.reactor.callLater( + self.completionTimeout, self._completionCancel + ) + return self.completion + + def _completionCancel(self): + """ + Handle the expiration of the timeout for the child process to exit by + terminating the child process forcefully and issuing a failure to the + L{completion} deferred. + """ + self._timeoutCallID = None + self.protocol.transport.signalProcess("KILL") + exc = ProcessAliasTimeout(f"No answer after {self.completionTimeout} seconds") + self.protocol.onEnd = None + self.completion.errback(failure.Failure(exc)) + + def connectionLost(self): + """ + Ignore notification of lost connection. + """ + + def __str__(self) -> str: + """ + Build a string representation of this L{MessageWrapper} instance. + + @rtype: L{bytes} + @return: A string containing the name of the process. + """ + return f"<ProcessWrapper {self.processName}>" + + +class ProcessAliasProtocol(protocol.ProcessProtocol): + """ + A process protocol which errbacks a deferred when the associated + process ends. + + @type onEnd: L{None} or L{Deferred <defer.Deferred>} + @ivar onEnd: If set, a deferred on which to errback when the process ends. + """ + + onEnd = None + + def processEnded(self, reason): + """ + Call an errback. + + @type reason: L{Failure <failure.Failure>} + @param reason: The reason the child process terminated. + """ + if self.onEnd is not None: + self.onEnd.errback(reason) + + +@implementer(IAlias) +class ProcessAlias(AliasBase): + """ + An alias which is handled by the execution of a program. + + @type path: L{list} of L{bytes} + @ivar path: The arguments to pass to the process. The first string is + the executable's name. + + @type program: L{bytes} + @ivar program: The path of the program to be executed. + + @type reactor: L{IReactorTime <twisted.internet.interfaces.IReactorTime>} + and L{IReactorProcess <twisted.internet.interfaces.IReactorProcess>} + provider + @ivar reactor: A reactor which will be used to create and timeout the + child process. + """ + + reactor = reactor + + def __init__(self, path, *args): + """ + @type path: L{bytes} + @param path: The command to invoke the program consisting of the path + to the executable followed by any arguments. + + @type args: 2-L{tuple} of (0) L{dict} mapping L{bytes} to L{IDomain} + provider, (1) L{bytes} + @param args: Arguments for L{AliasBase.__init__}. + """ + + AliasBase.__init__(self, *args) + self.path = path.split() + self.program = self.path[0] + + def __str__(self) -> str: + """ + Build a string representation of this L{ProcessAlias} instance. + + @rtype: L{bytes} + @return: A string containing the command used to invoke the process. + """ + return f"<Process {self.path}>" + + def spawnProcess(self, proto, program, path): + """ + Spawn a process. + + This wraps the L{spawnProcess + <twisted.internet.interfaces.IReactorProcess.spawnProcess>} method on + L{reactor} so that it can be customized for test purposes. + + @type proto: L{IProcessProtocol + <twisted.internet.interfaces.IProcessProtocol>} provider + @param proto: An object which will be notified of all events related to + the created process. + + @type program: L{bytes} + @param program: The full path name of the file to execute. + + @type path: L{list} of L{bytes} + @param path: The arguments to pass to the process. The first string + should be the executable's name. + + @rtype: L{IProcessTransport + <twisted.internet.interfaces.IProcessTransport>} provider + @return: A process transport. + """ + return self.reactor.spawnProcess(proto, program, path) + + def createMessageReceiver(self): + """ + Launch a process and create a message receiver to pass a message + to the process. + + @rtype: L{MessageWrapper} + @return: A message receiver which delivers a message to the process. + """ + p = ProcessAliasProtocol() + m = MessageWrapper(p, self.program, self.reactor) + self.spawnProcess(p, self.program, self.path) + return m + + +@implementer(smtp.IMessage) +class MultiWrapper: + """ + A message receiver which delivers a single message to multiple other + message receivers. + + @ivar objs: See L{__init__}. + """ + + def __init__(self, objs): + """ + @type objs: L{list} of L{IMessage <smtp.IMessage>} provider + @param objs: Message receivers to which the incoming message should be + directed. + """ + self.objs = objs + + def lineReceived(self, line): + """ + Pass a received line to the message receivers. + + @type line: L{bytes} + @param line: A line of the message. + """ + for o in self.objs: + o.lineReceived(line) + + def eomReceived(self): + """ + Pass the end of message along to the message receivers. + + @rtype: L{DeferredList <defer.DeferredList>} whose successful results + are L{bytes} or L{None} + @return: A deferred list which triggers when all of the message + receivers have finished handling their end of message. + """ + return defer.DeferredList([o.eomReceived() for o in self.objs]) + + def connectionLost(self): + """ + Inform the message receivers that the connection has been lost. + """ + for o in self.objs: + o.connectionLost() + + def __str__(self) -> str: + """ + Build a string representation of this L{MultiWrapper} instance. + + @rtype: L{bytes} + @return: A string containing a list of the message receivers. + """ + return f"<GroupWrapper {map(str, self.objs)!r}>" + + +@implementer(IAlias) +class AliasGroup(AliasBase): + """ + An alias which points to multiple destination aliases. + + @type processAliasFactory: no-argument callable which returns + L{ProcessAlias} + @ivar processAliasFactory: A factory for process aliases. + + @type aliases: L{list} of L{AliasBase} which implements L{IAlias} + @ivar aliases: The destination aliases. + """ + + processAliasFactory = ProcessAlias + + def __init__(self, items, *args): + """ + Create a group of aliases. + + Parse a list of alias strings and, for each, create an appropriate + alias object. + + @type items: L{list} of L{bytes} + @param items: Aliases. + + @type args: n-L{tuple} of (0) L{dict} mapping L{bytes} to L{IDomain} + provider, (1) L{bytes} + @param args: Arguments for L{AliasBase.__init__}. + """ + + AliasBase.__init__(self, *args) + self.aliases = [] + while items: + addr = items.pop().strip() + if addr.startswith(":"): + try: + f = open(addr[1:]) + except BaseException: + log.err(f"Invalid filename in alias file {addr[1:]!r}") + else: + with f: + addr = " ".join([l.strip() for l in f]) + items.extend(addr.split(",")) + elif addr.startswith("|"): + self.aliases.append(self.processAliasFactory(addr[1:], *args)) + elif addr.startswith("/"): + if os.path.isdir(addr): + log.err("Directory delivery not supported") + else: + self.aliases.append(FileAlias(addr, *args)) + else: + self.aliases.append(AddressAlias(addr, *args)) + + def __len__(self): + """ + Return the number of aliases in the group. + + @rtype: L{int} + @return: The number of aliases in the group. + """ + return len(self.aliases) + + def __str__(self) -> str: + """ + Build a string representation of this L{AliasGroup} instance. + + @rtype: L{bytes} + @return: A string containing the aliases in the group. + """ + return "<AliasGroup [%s]>" % (", ".join(map(str, self.aliases))) + + def createMessageReceiver(self): + """ + Create a message receiver for each alias and return a message receiver + which will pass on a message to each of those. + + @rtype: L{MultiWrapper} + @return: A message receiver which passes a message on to message + receivers for each alias in the group. + """ + return MultiWrapper([a.createMessageReceiver() for a in self.aliases]) + + def resolve(self, aliasmap, memo=None): + """ + Map each of the aliases in the group to its ultimate destination. + + @type aliasmap: L{dict} mapping L{bytes} to L{AliasBase} + @param aliasmap: A mapping of username to alias or group of aliases. + + @type memo: L{None} or L{dict} of L{AliasBase} + @param memo: A record of the aliases already considered in the + resolution process. If provided, C{memo} is modified to include + this alias. + + @rtype: L{MultiWrapper} + @return: A message receiver which passes the message on to message + receivers for the ultimate destination of each alias in the group. + """ + if memo is None: + memo = {} + r = [] + for a in self.aliases: + r.append(a.resolve(aliasmap, memo)) + return MultiWrapper(filter(None, r)) diff --git a/contrib/python/Twisted/py3/twisted/mail/bounce.py b/contrib/python/Twisted/py3/twisted/mail/bounce.py new file mode 100644 index 0000000000..adf9d313af --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/bounce.py @@ -0,0 +1,107 @@ +# -*- test-case-name: twisted.mail.test.test_bounce -*- +# +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + + +""" +Support for bounce message generation. +""" +import email.utils +import os +import time +from io import SEEK_END, SEEK_SET, StringIO + +from twisted.mail import smtp + +BOUNCE_FORMAT = """\ +From: postmaster@{failedDomain} +To: {failedFrom} +Subject: Returned Mail: see transcript for details +Message-ID: {messageID} +Content-Type: multipart/report; report-type=delivery-status; + boundary="{boundary}" + +--{boundary} + +{transcript} + +--{boundary} +Content-Type: message/delivery-status +Arrival-Date: {ctime} +Final-Recipient: RFC822; {failedTo} +""" + + +def generateBounce(message, failedFrom, failedTo, transcript="", encoding="utf-8"): + """ + Generate a bounce message for an undeliverable email message. + + @type message: a file-like object + @param message: The undeliverable message. + + @type failedFrom: L{bytes} or L{unicode} + @param failedFrom: The originator of the undeliverable message. + + @type failedTo: L{bytes} or L{unicode} + @param failedTo: The destination of the undeliverable message. + + @type transcript: L{bytes} or L{unicode} + @param transcript: An error message to include in the bounce message. + + @type encoding: L{str} or L{unicode} + @param encoding: Encoding to use, default: utf-8 + + @rtype: 3-L{tuple} of (E{1}) L{bytes}, (E{2}) L{bytes}, (E{3}) L{bytes} + @return: The originator, the destination and the contents of the bounce + message. The destination of the bounce message is the originator of + the undeliverable message. + """ + + if isinstance(failedFrom, bytes): + failedFrom = failedFrom.decode(encoding) + + if isinstance(failedTo, bytes): + failedTo = failedTo.decode(encoding) + + if not transcript: + transcript = """\ +I'm sorry, the following address has permanent errors: {failedTo}. +I've given up, and I will not retry the message again. +""".format( + failedTo=failedTo + ) + + failedAddress = email.utils.parseaddr(failedTo)[1] + data = { + "boundary": "{}_{}_{}".format(time.time(), os.getpid(), "XXXXX"), + "ctime": time.ctime(time.time()), + "failedAddress": failedAddress, + "failedDomain": failedAddress.split("@", 1)[1], + "failedFrom": failedFrom, + "failedTo": failedTo, + "messageID": smtp.messageid(uniq="bounce"), + "message": message, + "transcript": transcript, + } + + fp = StringIO() + fp.write(BOUNCE_FORMAT.format(**data)) + orig = message.tell() + message.seek(0, SEEK_END) + sz = message.tell() + message.seek(orig, SEEK_SET) + if sz > 10000: + while 1: + line = message.readline() + if isinstance(line, bytes): + line = line.decode(encoding) + if len(line) <= 0: + break + fp.write(line) + else: + messageContent = message.read() + if isinstance(messageContent, bytes): + messageContent = messageContent.decode(encoding) + fp.write(messageContent) + return b"", failedFrom.encode(encoding), fp.getvalue().encode(encoding) diff --git a/contrib/python/Twisted/py3/twisted/mail/imap4.py b/contrib/python/Twisted/py3/twisted/mail/imap4.py new file mode 100644 index 0000000000..032624e3db --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/imap4.py @@ -0,0 +1,6233 @@ +# -*- test-case-name: twisted.mail.test.test_imap.IMAP4HelperTests -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +An IMAP4 protocol implementation + +@author: Jp Calderone + +To do:: + Suspend idle timeout while server is processing + Use an async message parser instead of buffering in memory + Figure out a way to not queue multi-message client requests (Flow? A simple callback?) + Clarify some API docs (Query, etc) + Make APPEND recognize (again) non-existent mailboxes before accepting the literal +""" + +import binascii +import codecs +import copy +import email.utils +import functools +import re +import string +import tempfile +import time +import uuid +from base64 import decodebytes, encodebytes +from io import BytesIO +from itertools import chain +from typing import Any, List, cast + +from zope.interface import implementer + +from twisted.cred import credentials +from twisted.cred.error import UnauthorizedLogin, UnhandledCredentials +from twisted.internet import defer, error, interfaces +from twisted.internet.defer import maybeDeferred +from twisted.mail._cred import ( + CramMD5ClientAuthenticator, + LOGINAuthenticator, + LOGINCredentials, + PLAINAuthenticator, + PLAINCredentials, +) +from twisted.mail._except import ( + IllegalClientResponse, + IllegalIdentifierError, + IllegalMailboxEncoding, + IllegalOperation, + IllegalQueryError, + IllegalServerResponse, + IMAP4Exception, + MailboxCollision, + MailboxException, + MismatchedNesting, + MismatchedQuoting, + NegativeResponse, + NoSuchMailbox, + NoSupportedAuthentication, + ReadOnlyMailbox, + UnhandledResponse, +) + +# Re-exported for compatibility reasons +from twisted.mail.interfaces import ( + IAccountIMAP as IAccount, + IClientAuthentication, + ICloseableMailboxIMAP as ICloseableMailbox, + IMailboxIMAP as IMailbox, + IMailboxIMAPInfo as IMailboxInfo, + IMailboxIMAPListener as IMailboxListener, + IMessageIMAP as IMessage, + IMessageIMAPCopier as IMessageCopier, + IMessageIMAPFile as IMessageFile, + IMessageIMAPPart as IMessagePart, + INamespacePresenter, + ISearchableIMAPMailbox as ISearchableMailbox, +) +from twisted.protocols import basic, policies +from twisted.python import log, text +from twisted.python.compat import ( + _get_async_param, + _matchingString, + iterbytes, + nativeString, + networkString, +) + +# locale-independent month names to use instead of strftime's +_MONTH_NAMES = dict( + zip(range(1, 13), "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split()) +) + + +def _swap(this, that, ifIs): + """ + Swap C{this} with C{that} if C{this} is C{ifIs}. + + @param this: The object that may be replaced. + + @param that: The object that may replace C{this}. + + @param ifIs: An object whose identity will be compared to + C{this}. + """ + return that if this is ifIs else this + + +def _swapAllPairs(of, that, ifIs): + """ + Swap each element in each pair in C{of} with C{that} it is + C{ifIs}. + + @param of: A list of 2-L{tuple}s, whose members may be the object + C{that} + @type of: L{list} of 2-L{tuple}s + + @param ifIs: An object whose identity will be compared to members + of each pair in C{of} + + @return: A L{list} of 2-L{tuple}s with all occurences of C{ifIs} + replaced with C{that} + """ + return [ + (_swap(first, that, ifIs), _swap(second, that, ifIs)) for first, second in of + ] + + +class MessageSet: + """ + A set of message identifiers usable by both L{IMAP4Client} and + L{IMAP4Server} via L{IMailboxIMAP.store} and + L{IMailboxIMAP.fetch}. + + These identifiers can be either message sequence numbers or unique + identifiers. See Section 2.3.1, "Message Numbers", RFC 3501. + + This represents the C{sequence-set} described in Section 9, + "Formal Syntax" of RFC 3501: + + - A L{MessageSet} can describe a single identifier, e.g. + C{MessageSet(1)} + + - A L{MessageSet} can describe C{*} via L{None}, e.g. + C{MessageSet(None)} + + - A L{MessageSet} can describe a range of identifiers, e.g. + C{MessageSet(1, 2)}. The range is inclusive and unordered + (see C{seq-range} in RFC 3501, Section 9), so that + C{Message(2, 1)} is equivalent to C{MessageSet(1, 2)}, and + both describe messages 1 and 2. Ranges can include C{*} by + specifying L{None}, e.g. C{MessageSet(None, 1)}. In all + cases ranges are normalized so that the smallest identifier + comes first, and L{None} always comes last; C{Message(2, 1)} + becomes C{MessageSet(1, 2)} and C{MessageSet(None, 1)} + becomes C{MessageSet(1, None)} + + - A L{MessageSet} can describe a sequence of single + identifiers and ranges, constructed by addition. + C{MessageSet(1) + MessageSet(5, 10)} refers the message + identified by C{1} and the messages identified by C{5} + through C{10}. + + B{NB: The meaning of * varies, but it always represents the + largest number in use}. + + B{For servers}: Your L{IMailboxIMAP} provider must set + L{MessageSet.last} to the highest-valued identifier (unique or + message sequence) before iterating over it. + + B{For clients}: C{*} consumes ranges smaller than it, e.g. + C{MessageSet(1, 100) + MessageSet(50, None)} is equivalent to + C{1:*}. + + @type getnext: Function taking L{int} returning L{int} + @ivar getnext: A function that returns the next message number, + used when iterating through the L{MessageSet}. By default, a + function returning the next integer is supplied, but as this + can be rather inefficient for sparse UID iterations, it is + recommended to supply one when messages are requested by UID. + The argument is provided as a hint to the implementation and + may be ignored if it makes sense to do so (eg, if an iterator + is being used that maintains its own state, it is guaranteed + that it will not be called out-of-order). + """ + + _empty: List[Any] = [] + _infinity = float("inf") + + def __init__(self, start=_empty, end=_empty): + """ + Create a new MessageSet() + + @type start: Optional L{int} + @param start: Start of range, or only message number + + @type end: Optional L{int} + @param end: End of range. + """ + self._last = self._empty # Last message/UID in use + self.ranges = [] # List of ranges included + self.getnext = lambda x: x + 1 # A function which will return the next + # message id. Handy for UID requests. + + if start is self._empty: + return + + if isinstance(start, list): + self.ranges = start[:] + self.clean() + else: + self.add(start, end) + + @property + def last(self): + """ + The largest number in use. + This is undefined until it has been set by assigning to this property. + """ + return self._last + + @last.setter + def last(self, value): + """ + Replaces all occurrences of "*". This should be the + largest number in use. Must be set before attempting to + use the MessageSet as a container. + + @raises ValueError: if a largest value has already been set. + """ + if self._last is not self._empty: + raise ValueError("last already set") + + self._last = value + for i, (low, high) in enumerate(self.ranges): + if low is None: + low = value + if high is None: + high = value + if low > high: + low, high = high, low + self.ranges[i] = (low, high) + self.clean() + + def add(self, start, end=_empty): + """ + Add another range + + @type start: L{int} + @param start: Start of range, or only message number + + @type end: Optional L{int} + @param end: End of range. + """ + if end is self._empty: + end = start + + if self._last is not self._empty: + if start is None: + start = self.last + if end is None: + end = self.last + + start, end = sorted( + [start, end], key=functools.partial(_swap, that=self._infinity, ifIs=None) + ) + self.ranges.append((start, end)) + self.clean() + + def __add__(self, other): + if isinstance(other, MessageSet): + ranges = self.ranges + other.ranges + return MessageSet(ranges) + else: + res = MessageSet(self.ranges) + if self.last is not self._empty: + res.last = self.last + try: + res.add(*other) + except TypeError: + res.add(other) + return res + + def extend(self, other): + """ + Extend our messages with another message or set of messages. + + @param other: The messages to include. + @type other: L{MessageSet}, L{tuple} of two L{int}s, or a + single L{int} + """ + if isinstance(other, MessageSet): + self.ranges.extend(other.ranges) + self.clean() + else: + try: + self.add(*other) + except TypeError: + self.add(other) + + return self + + def clean(self): + """ + Clean ranges list, combining adjacent ranges + """ + + ranges = sorted(_swapAllPairs(self.ranges, that=self._infinity, ifIs=None)) + + mergedRanges = [(float("-inf"), float("-inf"))] + + for low, high in ranges: + previousLow, previousHigh = mergedRanges[-1] + + if previousHigh < low - 1: + mergedRanges.append((low, high)) + continue + + mergedRanges[-1] = (min(previousLow, low), max(previousHigh, high)) + + self.ranges = _swapAllPairs(mergedRanges[1:], that=None, ifIs=self._infinity) + + def _noneInRanges(self): + """ + Is there a L{None} in our ranges? + + L{MessageSet.clean} merges overlapping or consecutive ranges. + None is represents a value larger than any number. There are + thus two cases: + + 1. C{(x, *) + (y, z)} such that C{x} is smaller than C{y} + + 2. C{(z, *) + (x, y)} such that C{z} is larger than C{y} + + (Other cases, such as C{y < x < z}, can be split into these + two cases; for example C{(y - 1, y)} + C{(x, x) + (z, z + 1)}) + + In case 1, C{* > y} and C{* > z}, so C{(x, *) + (y, z) = (x, + *)} + + In case 2, C{z > x and z > y}, so the intervals do not merge, + and the ranges are sorted as C{[(x, y), (z, *)]}. C{*} is + represented as C{(*, *)}, so this is the same as 2. but with + a C{z} that is greater than everything. + + The result is that there is a maximum of two L{None}s, and one + of them has to be the high element in the last tuple in + C{self.ranges}. That means checking if C{self.ranges[-1][-1]} + is L{None} suffices to check if I{any} element is L{None}. + + @return: L{True} if L{None} is in some range in ranges and + L{False} if otherwise. + """ + return self.ranges[-1][-1] is None + + def __contains__(self, value): + """ + May raise TypeError if we encounter an open-ended range + + @param value: Is this in our ranges? + @type value: L{int} + """ + + if self._noneInRanges(): + raise TypeError("Can't determine membership; last value not set") + + for low, high in self.ranges: + if low <= value <= high: + return True + + return False + + def _iterator(self): + for l, h in self.ranges: + l = self.getnext(l - 1) + while l <= h: + yield l + l = self.getnext(l) + + def __iter__(self): + if self._noneInRanges(): + raise TypeError("Can't iterate; last value not set") + + return self._iterator() + + def __len__(self): + res = 0 + for l, h in self.ranges: + if l is None: + res += 1 + elif h is None: + raise TypeError("Can't size object; last value not set") + else: + res += (h - l) + 1 + + return res + + def __str__(self) -> str: + p = [] + for low, high in self.ranges: + if low == high: + if low is None: + p.append("*") + else: + p.append(str(low)) + elif high is None: + p.append("%d:*" % (low,)) + else: + p.append("%d:%d" % (low, high)) + return ",".join(p) + + def __repr__(self) -> str: + return f"<MessageSet {str(self)}>" + + def __eq__(self, other: object) -> bool: + if isinstance(other, MessageSet): + return cast(bool, self.ranges == other.ranges) + return NotImplemented + + +class LiteralString: + def __init__(self, size, defered): + self.size = size + self.data = [] + self.defer = defered + + def write(self, data): + self.size -= len(data) + passon = None + if self.size > 0: + self.data.append(data) + else: + if self.size: + data, passon = data[: self.size], data[self.size :] + else: + passon = b"" + if data: + self.data.append(data) + + return passon + + def callback(self, line): + """ + Call deferred with data and rest of line + """ + self.defer.callback((b"".join(self.data), line)) + + +class LiteralFile: + _memoryFileLimit = 1024 * 1024 * 10 + + def __init__(self, size, defered): + self.size = size + self.defer = defered + if size > self._memoryFileLimit: + self.data = tempfile.TemporaryFile() + else: + self.data = BytesIO() + + def write(self, data): + self.size -= len(data) + passon = None + if self.size > 0: + self.data.write(data) + else: + if self.size: + data, passon = data[: self.size], data[self.size :] + else: + passon = b"" + if data: + self.data.write(data) + return passon + + def callback(self, line): + """ + Call deferred with data and rest of line + """ + self.data.seek(0, 0) + self.defer.callback((self.data, line)) + + +class WriteBuffer: + """ + Buffer up a bunch of writes before sending them all to a transport at once. + """ + + def __init__(self, transport, size=8192): + self.bufferSize = size + self.transport = transport + self._length = 0 + self._writes = [] + + def write(self, s): + self._length += len(s) + self._writes.append(s) + if self._length > self.bufferSize: + self.flush() + + def flush(self): + if self._writes: + self.transport.writeSequence(self._writes) + self._writes = [] + self._length = 0 + + +class Command: + _1_RESPONSES = ( + b"CAPABILITY", + b"FLAGS", + b"LIST", + b"LSUB", + b"STATUS", + b"SEARCH", + b"NAMESPACE", + ) + _2_RESPONSES = (b"EXISTS", b"EXPUNGE", b"FETCH", b"RECENT") + _OK_RESPONSES = ( + b"UIDVALIDITY", + b"UNSEEN", + b"READ-WRITE", + b"READ-ONLY", + b"UIDNEXT", + b"PERMANENTFLAGS", + ) + defer = None + + def __init__( + self, + command, + args=None, + wantResponse=(), + continuation=None, + *contArgs, + **contKw, + ): + self.command = command + self.args = args + self.wantResponse = wantResponse + self.continuation = lambda x: continuation(x, *contArgs, **contKw) + self.lines = [] + + def __repr__(self) -> str: + return "<imap4.Command {!r} {!r} {!r} {!r} {!r}>".format( + self.command, self.args, self.wantResponse, self.continuation, self.lines + ) + + def format(self, tag): + if self.args is None: + return b" ".join((tag, self.command)) + return b" ".join((tag, self.command, self.args)) + + def finish(self, lastLine, unusedCallback): + send = [] + unuse = [] + for L in self.lines: + names = parseNestedParens(L) + N = len(names) + if ( + N >= 1 + and names[0] in self._1_RESPONSES + or N >= 2 + and names[1] in self._2_RESPONSES + or N >= 2 + and names[0] == b"OK" + and isinstance(names[1], list) + and names[1][0] in self._OK_RESPONSES + ): + send.append(names) + else: + unuse.append(names) + d, self.defer = self.defer, None + d.callback((send, lastLine)) + if unuse: + unusedCallback(unuse) + + +# Some constants to help define what an atom is and is not - see the grammar +# section of the IMAP4 RFC - <https://tools.ietf.org/html/rfc3501#section-9>. +# Some definitions (SP, CTL, DQUOTE) are also from the ABNF RFC - +# <https://tools.ietf.org/html/rfc2234>. +_SP = b" " +_CTL = bytes(chain(range(0x21), range(0x80, 0x100))) + +# It is easier to define ATOM-CHAR in terms of what it does not match than in +# terms of what it does match. +_nonAtomChars = b']\\\\(){%*"' + _SP + _CTL + +# _nonAtomRE is only used in Query, so it uses native strings. +_nativeNonAtomChars = _nonAtomChars.decode("charmap") +_nonAtomRE = re.compile("[" + _nativeNonAtomChars + "]") + +# This is all the bytes that match the ATOM-CHAR from the grammar in the RFC. +_atomChars = bytes(ch for ch in range(0x100) if ch not in _nonAtomChars) + + +@implementer(IMailboxListener) +class IMAP4Server(basic.LineReceiver, policies.TimeoutMixin): + """ + Protocol implementation for an IMAP4rev1 server. + + The server can be in any of four states: + - Non-authenticated + - Authenticated + - Selected + - Logout + """ + + # Identifier for this server software + IDENT = b"Twisted IMAP4rev1 Ready" + + # Number of seconds before idle timeout + # Initially 1 minute. Raised to 30 minutes after login. + timeOut = 60 + + POSTAUTH_TIMEOUT = 60 * 30 + + # Whether STARTTLS has been issued successfully yet or not. + startedTLS = False + + # Whether our transport supports TLS + canStartTLS = False + + # Mapping of tags to commands we have received + tags = None + + # The object which will handle logins for us + portal = None + + # The account object for this connection + account = None + + # Logout callback + _onLogout = None + + # The currently selected mailbox + mbox = None + + # Command data to be processed when literal data is received + _pendingLiteral = None + + # Maximum length to accept for a "short" string literal + _literalStringLimit = 4096 + + # IChallengeResponse factories for AUTHENTICATE command + challengers = None + + # Search terms the implementation of which needs to be passed both the last + # message identifier (UID) and the last sequence id. + _requiresLastMessageInfo = {b"OR", b"NOT", b"UID"} + + state = "unauth" + + parseState = "command" + + def __init__(self, chal=None, contextFactory=None, scheduler=None): + if chal is None: + chal = {} + self.challengers = chal + self.ctx = contextFactory + if scheduler is None: + scheduler = iterateInReactor + self._scheduler = scheduler + self._queuedAsync = [] + + def capabilities(self): + cap = {b"AUTH": list(self.challengers.keys())} + if self.ctx and self.canStartTLS: + if ( + not self.startedTLS + and interfaces.ISSLTransport(self.transport, None) is None + ): + cap[b"LOGINDISABLED"] = None + cap[b"STARTTLS"] = None + cap[b"NAMESPACE"] = None + cap[b"IDLE"] = None + return cap + + def connectionMade(self): + self.tags = {} + self.canStartTLS = interfaces.ITLSTransport(self.transport, None) is not None + self.setTimeout(self.timeOut) + self.sendServerGreeting() + + def connectionLost(self, reason): + self.setTimeout(None) + if self._onLogout: + self._onLogout() + self._onLogout = None + + def timeoutConnection(self): + self.sendLine(b"* BYE Autologout; connection idle too long") + self.transport.loseConnection() + if self.mbox: + self.mbox.removeListener(self) + cmbx = ICloseableMailbox(self.mbox, None) + if cmbx is not None: + maybeDeferred(cmbx.close).addErrback(log.err) + self.mbox = None + self.state = "timeout" + + def rawDataReceived(self, data): + self.resetTimeout() + passon = self._pendingLiteral.write(data) + if passon is not None: + self.setLineMode(passon) + + # Avoid processing commands while buffers are being dumped to + # our transport + blocked = None + + def _unblock(self): + commands = self.blocked + self.blocked = None + while commands and self.blocked is None: + self.lineReceived(commands.pop(0)) + if self.blocked is not None: + self.blocked.extend(commands) + + def lineReceived(self, line): + if self.blocked is not None: + self.blocked.append(line) + return + + self.resetTimeout() + f = getattr(self, "parse_" + self.parseState) + try: + f(line) + except Exception as e: + self.sendUntaggedResponse(b"BAD Server error: " + networkString(str(e))) + log.err() + + def parse_command(self, line): + args = line.split(None, 2) + rest = None + if len(args) == 3: + tag, cmd, rest = args + elif len(args) == 2: + tag, cmd = args + elif len(args) == 1: + tag = args[0] + self.sendBadResponse(tag, b"Missing command") + return None + else: + self.sendBadResponse(None, b"Null command") + return None + + cmd = cmd.upper() + try: + return self.dispatchCommand(tag, cmd, rest) + except IllegalClientResponse as e: + self.sendBadResponse(tag, b"Illegal syntax: " + networkString(str(e))) + except IllegalOperation as e: + self.sendNegativeResponse( + tag, b"Illegal operation: " + networkString(str(e)) + ) + except IllegalMailboxEncoding as e: + self.sendNegativeResponse( + tag, b"Illegal mailbox name: " + networkString(str(e)) + ) + + def parse_pending(self, line): + d = self._pendingLiteral + self._pendingLiteral = None + self.parseState = "command" + d.callback(line) + + def dispatchCommand(self, tag, cmd, rest, uid=None): + f = self.lookupCommand(cmd) + if f: + fn = f[0] + parseargs = f[1:] + self.__doCommand(tag, fn, [self, tag], parseargs, rest, uid) + else: + self.sendBadResponse(tag, b"Unsupported command") + + def lookupCommand(self, cmd): + return getattr(self, "_".join((self.state, nativeString(cmd.upper()))), None) + + def __doCommand(self, tag, handler, args, parseargs, line, uid): + for i, arg in enumerate(parseargs): + if callable(arg): + parseargs = parseargs[i + 1 :] + maybeDeferred(arg, self, line).addCallback( + self.__cbDispatch, tag, handler, args, parseargs, uid + ).addErrback(self.__ebDispatch, tag) + return + else: + args.append(arg) + + if line: + # Too many arguments + raise IllegalClientResponse("Too many arguments for command: " + repr(line)) + + if uid is not None: + handler(uid=uid, *args) + else: + handler(*args) + + def __cbDispatch(self, result, tag, fn, args, parseargs, uid): + (arg, rest) = result + args.append(arg) + self.__doCommand(tag, fn, args, parseargs, rest, uid) + + def __ebDispatch(self, failure, tag): + if failure.check(IllegalClientResponse): + self.sendBadResponse( + tag, b"Illegal syntax: " + networkString(str(failure.value)) + ) + elif failure.check(IllegalOperation): + self.sendNegativeResponse( + tag, b"Illegal operation: " + networkString(str(failure.value)) + ) + elif failure.check(IllegalMailboxEncoding): + self.sendNegativeResponse( + tag, b"Illegal mailbox name: " + networkString(str(failure.value)) + ) + else: + self.sendBadResponse( + tag, b"Server error: " + networkString(str(failure.value)) + ) + log.err(failure) + + def _stringLiteral(self, size): + if size > self._literalStringLimit: + raise IllegalClientResponse( + "Literal too long! I accept at most %d octets" + % (self._literalStringLimit,) + ) + d = defer.Deferred() + self.parseState = "pending" + self._pendingLiteral = LiteralString(size, d) + self.sendContinuationRequest( + networkString("Ready for %d octets of text" % size) + ) + self.setRawMode() + return d + + def _fileLiteral(self, size): + d = defer.Deferred() + self.parseState = "pending" + self._pendingLiteral = LiteralFile(size, d) + self.sendContinuationRequest( + networkString("Ready for %d octets of data" % size) + ) + self.setRawMode() + return d + + def arg_finalastring(self, line): + """ + Parse an astring from line that represents a command's final + argument. This special case exists to enable parsing empty + string literals. + + @param line: A line that contains a string literal. + @type line: L{bytes} + + @return: A 2-tuple containing the parsed argument and any + trailing data, or a L{Deferred} that fires with that + 2-tuple + @rtype: L{tuple} of (L{bytes}, L{bytes}) or a L{Deferred} + + @see: https://twistedmatrix.com/trac/ticket/9207 + """ + return self.arg_astring(line, final=True) + + def arg_astring(self, line, final=False): + """ + Parse an astring from the line, return (arg, rest), possibly + via a deferred (to handle literals) + + @param line: A line that contains a string literal. + @type line: L{bytes} + + @param final: Is this the final argument? + @type final L{bool} + + @return: A 2-tuple containing the parsed argument and any + trailing data, or a L{Deferred} that fires with that + 2-tuple + @rtype: L{tuple} of (L{bytes}, L{bytes}) or a L{Deferred} + + """ + line = line.strip() + if not line: + raise IllegalClientResponse("Missing argument") + d = None + arg, rest = None, None + if line[0:1] == b'"': + try: + spam, arg, rest = line.split(b'"', 2) + rest = rest[1:] # Strip space + except ValueError: + raise IllegalClientResponse("Unmatched quotes") + elif line[0:1] == b"{": + # literal + if line[-1:] != b"}": + raise IllegalClientResponse("Malformed literal") + try: + size = int(line[1:-1]) + except ValueError: + raise IllegalClientResponse("Bad literal size: " + repr(line[1:-1])) + if final and not size: + return (b"", b"") + d = self._stringLiteral(size) + else: + arg = line.split(b" ", 1) + if len(arg) == 1: + arg.append(b"") + arg, rest = arg + return d or (arg, rest) + + # ATOM: Any CHAR except ( ) { % * " \ ] CTL SP (CHAR is 7bit) + atomre = re.compile( + b"(?P<atom>[" + re.escape(_atomChars) + b"]+)( (?P<rest>.*$)|$)" + ) + + def arg_atom(self, line): + """ + Parse an atom from the line + """ + if not line: + raise IllegalClientResponse("Missing argument") + m = self.atomre.match(line) + if m: + return m.group("atom"), m.group("rest") + else: + raise IllegalClientResponse("Malformed ATOM") + + def arg_plist(self, line): + """ + Parse a (non-nested) parenthesised list from the line + """ + if not line: + raise IllegalClientResponse("Missing argument") + + if line[:1] != b"(": + raise IllegalClientResponse("Missing parenthesis") + + i = line.find(b")") + + if i == -1: + raise IllegalClientResponse("Mismatched parenthesis") + + return (parseNestedParens(line[1:i], 0), line[i + 2 :]) + + def arg_literal(self, line): + """ + Parse a literal from the line + """ + if not line: + raise IllegalClientResponse("Missing argument") + + if line[:1] != b"{": + raise IllegalClientResponse("Missing literal") + + if line[-1:] != b"}": + raise IllegalClientResponse("Malformed literal") + + try: + size = int(line[1:-1]) + except ValueError: + raise IllegalClientResponse(f"Bad literal size: {line[1:-1]!r}") + + return self._fileLiteral(size) + + def arg_searchkeys(self, line): + """ + searchkeys + """ + query = parseNestedParens(line) + # XXX Should really use list of search terms and parse into + # a proper tree + return (query, b"") + + def arg_seqset(self, line): + """ + sequence-set + """ + rest = b"" + arg = line.split(b" ", 1) + if len(arg) == 2: + rest = arg[1] + arg = arg[0] + + try: + return (parseIdList(arg), rest) + except IllegalIdentifierError as e: + raise IllegalClientResponse("Bad message number " + str(e)) + + def arg_fetchatt(self, line): + """ + fetch-att + """ + p = _FetchParser() + p.parseString(line) + return (p.result, b"") + + def arg_flaglist(self, line): + """ + Flag part of store-att-flag + """ + flags = [] + if line[0:1] == b"(": + if line[-1:] != b")": + raise IllegalClientResponse("Mismatched parenthesis") + line = line[1:-1] + + while line: + m = self.atomre.search(line) + if not m: + raise IllegalClientResponse("Malformed flag") + if line[0:1] == b"\\" and m.start() == 1: + flags.append(b"\\" + m.group("atom")) + elif m.start() == 0: + flags.append(m.group("atom")) + else: + raise IllegalClientResponse("Malformed flag") + line = m.group("rest") + + return (flags, b"") + + def arg_line(self, line): + """ + Command line of UID command + """ + return (line, b"") + + def opt_plist(self, line): + """ + Optional parenthesised list + """ + if line.startswith(b"("): + return self.arg_plist(line) + else: + return (None, line) + + def opt_datetime(self, line): + """ + Optional date-time string + """ + if line.startswith(b'"'): + try: + spam, date, rest = line.split(b'"', 2) + except ValueError: + raise IllegalClientResponse("Malformed date-time") + return (date, rest[1:]) + else: + return (None, line) + + def opt_charset(self, line): + """ + Optional charset of SEARCH command + """ + if line[:7].upper() == b"CHARSET": + arg = line.split(b" ", 2) + if len(arg) == 1: + raise IllegalClientResponse("Missing charset identifier") + if len(arg) == 2: + arg.append(b"") + spam, arg, rest = arg + return (arg, rest) + else: + return (None, line) + + def sendServerGreeting(self): + msg = b"[CAPABILITY " + b" ".join(self.listCapabilities()) + b"] " + self.IDENT + self.sendPositiveResponse(message=msg) + + def sendBadResponse(self, tag=None, message=b""): + self._respond(b"BAD", tag, message) + + def sendPositiveResponse(self, tag=None, message=b""): + self._respond(b"OK", tag, message) + + def sendNegativeResponse(self, tag=None, message=b""): + self._respond(b"NO", tag, message) + + def sendUntaggedResponse(self, message, isAsync=None, **kwargs): + isAsync = _get_async_param(isAsync, **kwargs) + if not isAsync or (self.blocked is None): + self._respond(message, None, None) + else: + self._queuedAsync.append(message) + + def sendContinuationRequest(self, msg=b"Ready for additional command text"): + if msg: + self.sendLine(b"+ " + msg) + else: + self.sendLine(b"+") + + def _respond(self, state, tag, message): + if state in (b"OK", b"NO", b"BAD") and self._queuedAsync: + lines = self._queuedAsync + self._queuedAsync = [] + for msg in lines: + self._respond(msg, None, None) + if not tag: + tag = b"*" + if message: + self.sendLine(b" ".join((tag, state, message))) + else: + self.sendLine(b" ".join((tag, state))) + + def listCapabilities(self): + caps = [b"IMAP4rev1"] + for c, v in self.capabilities().items(): + if v is None: + caps.append(c) + elif len(v): + caps.extend([(c + b"=" + cap) for cap in v]) + return caps + + def do_CAPABILITY(self, tag): + self.sendUntaggedResponse(b"CAPABILITY " + b" ".join(self.listCapabilities())) + self.sendPositiveResponse(tag, b"CAPABILITY completed") + + unauth_CAPABILITY = (do_CAPABILITY,) + auth_CAPABILITY = unauth_CAPABILITY + select_CAPABILITY = unauth_CAPABILITY + logout_CAPABILITY = unauth_CAPABILITY + + def do_LOGOUT(self, tag): + self.sendUntaggedResponse(b"BYE Nice talking to you") + self.sendPositiveResponse(tag, b"LOGOUT successful") + self.transport.loseConnection() + + unauth_LOGOUT = (do_LOGOUT,) + auth_LOGOUT = unauth_LOGOUT + select_LOGOUT = unauth_LOGOUT + logout_LOGOUT = unauth_LOGOUT + + def do_NOOP(self, tag): + self.sendPositiveResponse(tag, b"NOOP No operation performed") + + unauth_NOOP = (do_NOOP,) + auth_NOOP = unauth_NOOP + select_NOOP = unauth_NOOP + logout_NOOP = unauth_NOOP + + def do_AUTHENTICATE(self, tag, args): + args = args.upper().strip() + if args not in self.challengers: + self.sendNegativeResponse(tag, b"AUTHENTICATE method unsupported") + else: + self.authenticate(self.challengers[args](), tag) + + unauth_AUTHENTICATE = (do_AUTHENTICATE, arg_atom) + + def authenticate(self, chal, tag): + if self.portal is None: + self.sendNegativeResponse(tag, b"Temporary authentication failure") + return + + self._setupChallenge(chal, tag) + + def _setupChallenge(self, chal, tag): + try: + challenge = chal.getChallenge() + except Exception as e: + self.sendBadResponse(tag, b"Server error: " + networkString(str(e))) + else: + coded = encodebytes(challenge)[:-1] + self.parseState = "pending" + self._pendingLiteral = defer.Deferred() + self.sendContinuationRequest(coded) + self._pendingLiteral.addCallback(self.__cbAuthChunk, chal, tag) + self._pendingLiteral.addErrback(self.__ebAuthChunk, tag) + + def __cbAuthChunk(self, result, chal, tag): + try: + uncoded = decodebytes(result) + except binascii.Error: + raise IllegalClientResponse("Malformed Response - not base64") + + chal.setResponse(uncoded) + if chal.moreChallenges(): + self._setupChallenge(chal, tag) + else: + self.portal.login(chal, None, IAccount).addCallbacks( + self.__cbAuthResp, self.__ebAuthResp, (tag,), None, (tag,), None + ) + + def __cbAuthResp(self, result, tag): + (iface, avatar, logout) = result + assert iface is IAccount, "IAccount is the only supported interface" + self.account = avatar + self.state = "auth" + self._onLogout = logout + self.sendPositiveResponse(tag, b"Authentication successful") + self.setTimeout(self.POSTAUTH_TIMEOUT) + + def __ebAuthResp(self, failure, tag): + if failure.check(UnauthorizedLogin): + self.sendNegativeResponse(tag, b"Authentication failed: unauthorized") + elif failure.check(UnhandledCredentials): + self.sendNegativeResponse( + tag, b"Authentication failed: server misconfigured" + ) + else: + self.sendBadResponse(tag, b"Server error: login failed unexpectedly") + log.err(failure) + + def __ebAuthChunk(self, failure, tag): + self.sendNegativeResponse( + tag, b"Authentication failed: " + networkString(str(failure.value)) + ) + + def do_STARTTLS(self, tag): + if self.startedTLS: + self.sendNegativeResponse(tag, b"TLS already negotiated") + elif self.ctx and self.canStartTLS: + self.sendPositiveResponse(tag, b"Begin TLS negotiation now") + self.transport.startTLS(self.ctx) + self.startedTLS = True + self.challengers = self.challengers.copy() + if b"LOGIN" not in self.challengers: + self.challengers[b"LOGIN"] = LOGINCredentials + if b"PLAIN" not in self.challengers: + self.challengers[b"PLAIN"] = PLAINCredentials + else: + self.sendNegativeResponse(tag, b"TLS not available") + + unauth_STARTTLS = (do_STARTTLS,) + + def do_LOGIN(self, tag, user, passwd): + if b"LOGINDISABLED" in self.capabilities(): + self.sendBadResponse(tag, b"LOGIN is disabled before STARTTLS") + return + + maybeDeferred(self.authenticateLogin, user, passwd).addCallback( + self.__cbLogin, tag + ).addErrback(self.__ebLogin, tag) + + unauth_LOGIN = (do_LOGIN, arg_astring, arg_finalastring) + + def authenticateLogin(self, user, passwd): + """ + Lookup the account associated with the given parameters + + Override this method to define the desired authentication behavior. + + The default behavior is to defer authentication to C{self.portal} + if it is not None, or to deny the login otherwise. + + @type user: L{str} + @param user: The username to lookup + + @type passwd: L{str} + @param passwd: The password to login with + """ + if self.portal: + return self.portal.login( + credentials.UsernamePassword(user, passwd), None, IAccount + ) + raise UnauthorizedLogin() + + def __cbLogin(self, result, tag): + (iface, avatar, logout) = result + if iface is not IAccount: + self.sendBadResponse(tag, b"Server error: login returned unexpected value") + log.err(f"__cbLogin called with {iface!r}, IAccount expected") + else: + self.account = avatar + self._onLogout = logout + self.sendPositiveResponse(tag, b"LOGIN succeeded") + self.state = "auth" + self.setTimeout(self.POSTAUTH_TIMEOUT) + + def __ebLogin(self, failure, tag): + if failure.check(UnauthorizedLogin): + self.sendNegativeResponse(tag, b"LOGIN failed") + else: + self.sendBadResponse( + tag, b"Server error: " + networkString(str(failure.value)) + ) + log.err(failure) + + def do_NAMESPACE(self, tag): + personal = public = shared = None + np = INamespacePresenter(self.account, None) + if np is not None: + personal = np.getPersonalNamespaces() + public = np.getSharedNamespaces() + shared = np.getSharedNamespaces() + self.sendUntaggedResponse( + b"NAMESPACE " + collapseNestedLists([personal, public, shared]) + ) + self.sendPositiveResponse(tag, b"NAMESPACE command completed") + + auth_NAMESPACE = (do_NAMESPACE,) + select_NAMESPACE = auth_NAMESPACE + + def _selectWork(self, tag, name, rw, cmdName): + if self.mbox: + self.mbox.removeListener(self) + cmbx = ICloseableMailbox(self.mbox, None) + if cmbx is not None: + maybeDeferred(cmbx.close).addErrback(log.err) + self.mbox = None + self.state = "auth" + + name = _parseMbox(name) + maybeDeferred(self.account.select, _parseMbox(name), rw).addCallback( + self._cbSelectWork, cmdName, tag + ).addErrback(self._ebSelectWork, cmdName, tag) + + def _ebSelectWork(self, failure, cmdName, tag): + self.sendBadResponse(tag, cmdName + b" failed: Server error") + log.err(failure) + + def _cbSelectWork(self, mbox, cmdName, tag): + if mbox is None: + self.sendNegativeResponse(tag, b"No such mailbox") + return + if "\\noselect" in [s.lower() for s in mbox.getFlags()]: + self.sendNegativeResponse(tag, "Mailbox cannot be selected") + return + + flags = [networkString(flag) for flag in mbox.getFlags()] + self.sendUntaggedResponse(b"%d EXISTS" % (mbox.getMessageCount(),)) + self.sendUntaggedResponse(b"%d RECENT" % (mbox.getRecentCount(),)) + self.sendUntaggedResponse(b"FLAGS (" + b" ".join(flags) + b")") + self.sendPositiveResponse(None, b"[UIDVALIDITY %d]" % (mbox.getUIDValidity(),)) + + s = mbox.isWriteable() and b"READ-WRITE" or b"READ-ONLY" + mbox.addListener(self) + self.sendPositiveResponse(tag, b"[" + s + b"] " + cmdName + b" successful") + self.state = "select" + self.mbox = mbox + + auth_SELECT = (_selectWork, arg_astring, 1, b"SELECT") + select_SELECT = auth_SELECT + + auth_EXAMINE = (_selectWork, arg_astring, 0, b"EXAMINE") + select_EXAMINE = auth_EXAMINE + + def do_IDLE(self, tag): + self.sendContinuationRequest(None) + self.parseTag = tag + self.lastState = self.parseState + self.parseState = "idle" + + def parse_idle(self, *args): + self.parseState = self.lastState + del self.lastState + self.sendPositiveResponse(self.parseTag, b"IDLE terminated") + del self.parseTag + + select_IDLE = (do_IDLE,) + auth_IDLE = select_IDLE + + def do_CREATE(self, tag, name): + name = _parseMbox(name) + try: + result = self.account.create(name) + except MailboxException as c: + self.sendNegativeResponse(tag, networkString(str(c))) + except BaseException: + self.sendBadResponse( + tag, b"Server error encountered while creating mailbox" + ) + log.err() + else: + if result: + self.sendPositiveResponse(tag, b"Mailbox created") + else: + self.sendNegativeResponse(tag, b"Mailbox not created") + + auth_CREATE = (do_CREATE, arg_finalastring) + select_CREATE = auth_CREATE + + def do_DELETE(self, tag, name): + name = _parseMbox(name) + if name.lower() == "inbox": + self.sendNegativeResponse(tag, b"You cannot delete the inbox") + return + try: + self.account.delete(name) + except MailboxException as m: + self.sendNegativeResponse(tag, str(m).encode("imap4-utf-7")) + except BaseException: + self.sendBadResponse( + tag, b"Server error encountered while deleting mailbox" + ) + log.err() + else: + self.sendPositiveResponse(tag, b"Mailbox deleted") + + auth_DELETE = (do_DELETE, arg_finalastring) + select_DELETE = auth_DELETE + + def do_RENAME(self, tag, oldname, newname): + oldname, newname = (_parseMbox(n) for n in (oldname, newname)) + if oldname.lower() == "inbox" or newname.lower() == "inbox": + self.sendNegativeResponse( + tag, b"You cannot rename the inbox, or rename another mailbox to inbox." + ) + return + try: + self.account.rename(oldname, newname) + except TypeError: + self.sendBadResponse(tag, b"Invalid command syntax") + except MailboxException as m: + self.sendNegativeResponse(tag, networkString(str(m))) + except BaseException: + self.sendBadResponse( + tag, b"Server error encountered while renaming mailbox" + ) + log.err() + else: + self.sendPositiveResponse(tag, b"Mailbox renamed") + + auth_RENAME = (do_RENAME, arg_astring, arg_finalastring) + select_RENAME = auth_RENAME + + def do_SUBSCRIBE(self, tag, name): + name = _parseMbox(name) + try: + self.account.subscribe(name) + except MailboxException as m: + self.sendNegativeResponse(tag, networkString(str(m))) + except BaseException: + self.sendBadResponse( + tag, b"Server error encountered while subscribing to mailbox" + ) + log.err() + else: + self.sendPositiveResponse(tag, b"Subscribed") + + auth_SUBSCRIBE = (do_SUBSCRIBE, arg_finalastring) + select_SUBSCRIBE = auth_SUBSCRIBE + + def do_UNSUBSCRIBE(self, tag, name): + name = _parseMbox(name) + try: + self.account.unsubscribe(name) + except MailboxException as m: + self.sendNegativeResponse(tag, networkString(str(m))) + except BaseException: + self.sendBadResponse( + tag, b"Server error encountered while unsubscribing from mailbox" + ) + log.err() + else: + self.sendPositiveResponse(tag, b"Unsubscribed") + + auth_UNSUBSCRIBE = (do_UNSUBSCRIBE, arg_finalastring) + select_UNSUBSCRIBE = auth_UNSUBSCRIBE + + def _listWork(self, tag, ref, mbox, sub, cmdName): + mbox = _parseMbox(mbox) + ref = _parseMbox(ref) + maybeDeferred(self.account.listMailboxes, ref, mbox).addCallback( + self._cbListWork, tag, sub, cmdName + ).addErrback(self._ebListWork, tag) + + def _cbListWork(self, mailboxes, tag, sub, cmdName): + for name, box in mailboxes: + if not sub or self.account.isSubscribed(name): + flags = [networkString(flag) for flag in box.getFlags()] + delim = box.getHierarchicalDelimiter().encode("imap4-utf-7") + resp = ( + DontQuoteMe(cmdName), + map(DontQuoteMe, flags), + delim, + name.encode("imap4-utf-7"), + ) + self.sendUntaggedResponse(collapseNestedLists(resp)) + self.sendPositiveResponse(tag, cmdName + b" completed") + + def _ebListWork(self, failure, tag): + self.sendBadResponse(tag, b"Server error encountered while listing mailboxes.") + log.err(failure) + + auth_LIST = (_listWork, arg_astring, arg_astring, 0, b"LIST") + select_LIST = auth_LIST + + auth_LSUB = (_listWork, arg_astring, arg_astring, 1, b"LSUB") + select_LSUB = auth_LSUB + + def do_STATUS(self, tag, mailbox, names): + nativeNames = [] + for name in names: + nativeNames.append(nativeString(name)) + + mailbox = _parseMbox(mailbox) + + maybeDeferred(self.account.select, mailbox, 0).addCallback( + self._cbStatusGotMailbox, tag, mailbox, nativeNames + ).addErrback(self._ebStatusGotMailbox, tag) + + def _cbStatusGotMailbox(self, mbox, tag, mailbox, names): + if mbox: + maybeDeferred(mbox.requestStatus, names).addCallbacks( + self.__cbStatus, + self.__ebStatus, + (tag, mailbox), + None, + (tag, mailbox), + None, + ) + else: + self.sendNegativeResponse(tag, b"Could not open mailbox") + + def _ebStatusGotMailbox(self, failure, tag): + self.sendBadResponse(tag, b"Server error encountered while opening mailbox.") + log.err(failure) + + auth_STATUS = (do_STATUS, arg_astring, arg_plist) + select_STATUS = auth_STATUS + + def __cbStatus(self, status, tag, box): + # STATUS names should only be ASCII + line = networkString(" ".join(["%s %s" % x for x in status.items()])) + self.sendUntaggedResponse( + b"STATUS " + box.encode("imap4-utf-7") + b" (" + line + b")" + ) + self.sendPositiveResponse(tag, b"STATUS complete") + + def __ebStatus(self, failure, tag, box): + self.sendBadResponse( + tag, b"STATUS " + box + b" failed: " + networkString(str(failure.value)) + ) + + def do_APPEND(self, tag, mailbox, flags, date, message): + mailbox = _parseMbox(mailbox) + maybeDeferred(self.account.select, mailbox).addCallback( + self._cbAppendGotMailbox, tag, flags, date, message + ).addErrback(self._ebAppendGotMailbox, tag) + + def _cbAppendGotMailbox(self, mbox, tag, flags, date, message): + if not mbox: + self.sendNegativeResponse(tag, "[TRYCREATE] No such mailbox") + return + + decodedFlags = [nativeString(flag) for flag in flags] + d = mbox.addMessage(message, decodedFlags, date) + d.addCallback(self.__cbAppend, tag, mbox) + d.addErrback(self.__ebAppend, tag) + + def _ebAppendGotMailbox(self, failure, tag): + self.sendBadResponse(tag, b"Server error encountered while opening mailbox.") + log.err(failure) + + auth_APPEND = (do_APPEND, arg_astring, opt_plist, opt_datetime, arg_literal) + select_APPEND = auth_APPEND + + def __cbAppend(self, result, tag, mbox): + self.sendUntaggedResponse(b"%d EXISTS" % (mbox.getMessageCount(),)) + self.sendPositiveResponse(tag, b"APPEND complete") + + def __ebAppend(self, failure, tag): + self.sendBadResponse( + tag, b"APPEND failed: " + networkString(str(failure.value)) + ) + + def do_CHECK(self, tag): + d = self.checkpoint() + if d is None: + self.__cbCheck(None, tag) + else: + d.addCallbacks( + self.__cbCheck, self.__ebCheck, callbackArgs=(tag,), errbackArgs=(tag,) + ) + + select_CHECK = (do_CHECK,) + + def __cbCheck(self, result, tag): + self.sendPositiveResponse(tag, b"CHECK completed") + + def __ebCheck(self, failure, tag): + self.sendBadResponse(tag, b"CHECK failed: " + networkString(str(failure.value))) + + def checkpoint(self): + """ + Called when the client issues a CHECK command. + + This should perform any checkpoint operations required by the server. + It may be a long running operation, but may not block. If it returns + a deferred, the client will only be informed of success (or failure) + when the deferred's callback (or errback) is invoked. + """ + return None + + def do_CLOSE(self, tag): + d = None + if self.mbox.isWriteable(): + d = maybeDeferred(self.mbox.expunge) + cmbx = ICloseableMailbox(self.mbox, None) + if cmbx is not None: + if d is not None: + d.addCallback(lambda result: cmbx.close()) + else: + d = maybeDeferred(cmbx.close) + if d is not None: + d.addCallbacks(self.__cbClose, self.__ebClose, (tag,), None, (tag,), None) + else: + self.__cbClose(None, tag) + + select_CLOSE = (do_CLOSE,) + + def __cbClose(self, result, tag): + self.sendPositiveResponse(tag, b"CLOSE completed") + self.mbox.removeListener(self) + self.mbox = None + self.state = "auth" + + def __ebClose(self, failure, tag): + self.sendBadResponse(tag, b"CLOSE failed: " + networkString(str(failure.value))) + + def do_EXPUNGE(self, tag): + if self.mbox.isWriteable(): + maybeDeferred(self.mbox.expunge).addCallbacks( + self.__cbExpunge, self.__ebExpunge, (tag,), None, (tag,), None + ) + else: + self.sendNegativeResponse(tag, b"EXPUNGE ignored on read-only mailbox") + + select_EXPUNGE = (do_EXPUNGE,) + + def __cbExpunge(self, result, tag): + for e in result: + self.sendUntaggedResponse(b"%d EXPUNGE" % (e,)) + self.sendPositiveResponse(tag, b"EXPUNGE completed") + + def __ebExpunge(self, failure, tag): + self.sendBadResponse( + tag, b"EXPUNGE failed: " + networkString(str(failure.value)) + ) + log.err(failure) + + def do_SEARCH(self, tag, charset, query, uid=0): + sm = ISearchableMailbox(self.mbox, None) + if sm is not None: + maybeDeferred(sm.search, query, uid=uid).addCallback( + self.__cbSearch, tag, self.mbox, uid + ).addErrback(self.__ebSearch, tag) + else: + # that's not the ideal way to get all messages, there should be a + # method on mailboxes that gives you all of them + s = parseIdList(b"1:*") + maybeDeferred(self.mbox.fetch, s, uid=uid).addCallback( + self.__cbManualSearch, tag, self.mbox, query, uid + ).addErrback(self.__ebSearch, tag) + + select_SEARCH = (do_SEARCH, opt_charset, arg_searchkeys) + + def __cbSearch(self, result, tag, mbox, uid): + if uid: + result = map(mbox.getUID, result) + ids = networkString(" ".join([str(i) for i in result])) + self.sendUntaggedResponse(b"SEARCH " + ids) + self.sendPositiveResponse(tag, b"SEARCH completed") + + def __cbManualSearch(self, result, tag, mbox, query, uid, searchResults=None): + """ + Apply the search filter to a set of messages. Send the response to the + client. + + @type result: L{list} of L{tuple} of (L{int}, provider of + L{imap4.IMessage}) + @param result: A list two tuples of messages with their sequence ids, + sorted by the ids in descending order. + + @type tag: L{str} + @param tag: A command tag. + + @type mbox: Provider of L{imap4.IMailbox} + @param mbox: The searched mailbox. + + @type query: L{list} + @param query: A list representing the parsed form of the search query. + + @param uid: A flag indicating whether the search is over message + sequence numbers or UIDs. + + @type searchResults: L{list} + @param searchResults: The search results so far or L{None} if no + results yet. + """ + if searchResults is None: + searchResults = [] + i = 0 + + # result is a list of tuples (sequenceId, Message) + lastSequenceId = result and result[-1][0] + lastMessageId = result and result[-1][1].getUID() + for i, (msgId, msg) in list(zip(range(5), result)): + # searchFilter and singleSearchStep will mutate the query. Dang. + # Copy it here or else things will go poorly for subsequent + # messages. + if self._searchFilter( + copy.deepcopy(query), msgId, msg, lastSequenceId, lastMessageId + ): + searchResults.append(b"%d" % (msg.getUID() if uid else msgId,)) + + if i == 4: + from twisted.internet import reactor + + reactor.callLater( + 0, + self.__cbManualSearch, + list(result[5:]), + tag, + mbox, + query, + uid, + searchResults, + ) + else: + if searchResults: + self.sendUntaggedResponse(b"SEARCH " + b" ".join(searchResults)) + self.sendPositiveResponse(tag, b"SEARCH completed") + + def _searchFilter(self, query, id, msg, lastSequenceId, lastMessageId): + """ + Pop search terms from the beginning of C{query} until there are none + left and apply them to the given message. + + @param query: A list representing the parsed form of the search query. + + @param id: The sequence number of the message being checked. + + @param msg: The message being checked. + + @type lastSequenceId: L{int} + @param lastSequenceId: The highest sequence number of any message in + the mailbox being searched. + + @type lastMessageId: L{int} + @param lastMessageId: The highest UID of any message in the mailbox + being searched. + + @return: Boolean indicating whether all of the query terms match the + message. + """ + while query: + if not self._singleSearchStep( + query, id, msg, lastSequenceId, lastMessageId + ): + return False + return True + + def _singleSearchStep(self, query, msgId, msg, lastSequenceId, lastMessageId): + """ + Pop one search term from the beginning of C{query} (possibly more than + one element) and return whether it matches the given message. + + @param query: A list representing the parsed form of the search query. + + @param msgId: The sequence number of the message being checked. + + @param msg: The message being checked. + + @param lastSequenceId: The highest sequence number of any message in + the mailbox being searched. + + @param lastMessageId: The highest UID of any message in the mailbox + being searched. + + @return: Boolean indicating whether the query term matched the message. + """ + + q = query.pop(0) + if isinstance(q, list): + if not self._searchFilter(q, msgId, msg, lastSequenceId, lastMessageId): + return False + else: + c = q.upper() + if not c[:1].isalpha(): + # A search term may be a word like ALL, ANSWERED, BCC, etc (see + # below) or it may be a message sequence set. Here we + # recognize a message sequence set "N:M". + messageSet = parseIdList(c, lastSequenceId) + return msgId in messageSet + else: + f = getattr(self, "search_" + nativeString(c), None) + if f is None: + raise IllegalQueryError( + "Invalid search command %s" % nativeString(c) + ) + + if c in self._requiresLastMessageInfo: + result = f(query, msgId, msg, (lastSequenceId, lastMessageId)) + else: + result = f(query, msgId, msg) + + if not result: + return False + return True + + def search_ALL(self, query, id, msg): + """ + Returns C{True} if the message matches the ALL search key (always). + + @type query: A L{list} of L{str} + @param query: A list representing the parsed query string. + + @type id: L{int} + @param id: The sequence number of the message being checked. + + @type msg: Provider of L{imap4.IMessage} + """ + return True + + def search_ANSWERED(self, query, id, msg): + """ + Returns C{True} if the message has been answered. + + @type query: A L{list} of L{str} + @param query: A list representing the parsed query string. + + @type id: L{int} + @param id: The sequence number of the message being checked. + + @type msg: Provider of L{imap4.IMessage} + """ + return "\\Answered" in msg.getFlags() + + def search_BCC(self, query, id, msg): + """ + Returns C{True} if the message has a BCC address matching the query. + + @type query: A L{list} of L{str} + @param query: A list whose first element is a BCC L{str} + + @type id: L{int} + @param id: The sequence number of the message being checked. + + @type msg: Provider of L{imap4.IMessage} + """ + bcc = msg.getHeaders(False, "bcc").get("bcc", "") + return bcc.lower().find(query.pop(0).lower()) != -1 + + def search_BEFORE(self, query, id, msg): + date = parseTime(query.pop(0)) + return email.utils.parsedate(nativeString(msg.getInternalDate())) < date + + def search_BODY(self, query, id, msg): + body = query.pop(0).lower() + return text.strFile(body, msg.getBodyFile(), False) + + def search_CC(self, query, id, msg): + cc = msg.getHeaders(False, "cc").get("cc", "") + return cc.lower().find(query.pop(0).lower()) != -1 + + def search_DELETED(self, query, id, msg): + return "\\Deleted" in msg.getFlags() + + def search_DRAFT(self, query, id, msg): + return "\\Draft" in msg.getFlags() + + def search_FLAGGED(self, query, id, msg): + return "\\Flagged" in msg.getFlags() + + def search_FROM(self, query, id, msg): + fm = msg.getHeaders(False, "from").get("from", "") + return fm.lower().find(query.pop(0).lower()) != -1 + + def search_HEADER(self, query, id, msg): + hdr = query.pop(0).lower() + hdr = msg.getHeaders(False, hdr).get(hdr, "") + return hdr.lower().find(query.pop(0).lower()) != -1 + + def search_KEYWORD(self, query, id, msg): + query.pop(0) + return False + + def search_LARGER(self, query, id, msg): + return int(query.pop(0)) < msg.getSize() + + def search_NEW(self, query, id, msg): + return "\\Recent" in msg.getFlags() and "\\Seen" not in msg.getFlags() + + def search_NOT(self, query, id, msg, lastIDs): + """ + Returns C{True} if the message does not match the query. + + @type query: A L{list} of L{str} + @param query: A list representing the parsed form of the search query. + + @type id: L{int} + @param id: The sequence number of the message being checked. + + @type msg: Provider of L{imap4.IMessage} + @param msg: The message being checked. + + @type lastIDs: L{tuple} + @param lastIDs: A tuple of (last sequence id, last message id). + The I{last sequence id} is an L{int} containing the highest sequence + number of a message in the mailbox. The I{last message id} is an + L{int} containing the highest UID of a message in the mailbox. + """ + (lastSequenceId, lastMessageId) = lastIDs + return not self._singleSearchStep(query, id, msg, lastSequenceId, lastMessageId) + + def search_OLD(self, query, id, msg): + return "\\Recent" not in msg.getFlags() + + def search_ON(self, query, id, msg): + date = parseTime(query.pop(0)) + return email.utils.parsedate(msg.getInternalDate()) == date + + def search_OR(self, query, id, msg, lastIDs): + """ + Returns C{True} if the message matches any of the first two query + items. + + @type query: A L{list} of L{str} + @param query: A list representing the parsed form of the search query. + + @type id: L{int} + @param id: The sequence number of the message being checked. + + @type msg: Provider of L{imap4.IMessage} + @param msg: The message being checked. + + @type lastIDs: L{tuple} + @param lastIDs: A tuple of (last sequence id, last message id). + The I{last sequence id} is an L{int} containing the highest sequence + number of a message in the mailbox. The I{last message id} is an + L{int} containing the highest UID of a message in the mailbox. + """ + (lastSequenceId, lastMessageId) = lastIDs + a = self._singleSearchStep(query, id, msg, lastSequenceId, lastMessageId) + b = self._singleSearchStep(query, id, msg, lastSequenceId, lastMessageId) + return a or b + + def search_RECENT(self, query, id, msg): + return "\\Recent" in msg.getFlags() + + def search_SEEN(self, query, id, msg): + return "\\Seen" in msg.getFlags() + + def search_SENTBEFORE(self, query, id, msg): + """ + Returns C{True} if the message date is earlier than the query date. + + @type query: A L{list} of L{str} + @param query: A list whose first element starts with a stringified date + that is a fragment of an L{imap4.Query()}. The date must be in the + format 'DD-Mon-YYYY', for example '03-March-2003' or '03-Mar-2003'. + + @type id: L{int} + @param id: The sequence number of the message being checked. + + @type msg: Provider of L{imap4.IMessage} + """ + date = msg.getHeaders(False, "date").get("date", "") + date = email.utils.parsedate(date) + return date < parseTime(query.pop(0)) + + def search_SENTON(self, query, id, msg): + """ + Returns C{True} if the message date is the same as the query date. + + @type query: A L{list} of L{str} + @param query: A list whose first element starts with a stringified date + that is a fragment of an L{imap4.Query()}. The date must be in the + format 'DD-Mon-YYYY', for example '03-March-2003' or '03-Mar-2003'. + + @type msg: Provider of L{imap4.IMessage} + """ + date = msg.getHeaders(False, "date").get("date", "") + date = email.utils.parsedate(date) + return date[:3] == parseTime(query.pop(0))[:3] + + def search_SENTSINCE(self, query, id, msg): + """ + Returns C{True} if the message date is later than the query date. + + @type query: A L{list} of L{str} + @param query: A list whose first element starts with a stringified date + that is a fragment of an L{imap4.Query()}. The date must be in the + format 'DD-Mon-YYYY', for example '03-March-2003' or '03-Mar-2003'. + + @type msg: Provider of L{imap4.IMessage} + """ + date = msg.getHeaders(False, "date").get("date", "") + date = email.utils.parsedate(date) + return date > parseTime(query.pop(0)) + + def search_SINCE(self, query, id, msg): + date = parseTime(query.pop(0)) + return email.utils.parsedate(msg.getInternalDate()) > date + + def search_SMALLER(self, query, id, msg): + return int(query.pop(0)) > msg.getSize() + + def search_SUBJECT(self, query, id, msg): + subj = msg.getHeaders(False, "subject").get("subject", "") + return subj.lower().find(query.pop(0).lower()) != -1 + + def search_TEXT(self, query, id, msg): + # XXX - This must search headers too + body = query.pop(0).lower() + return text.strFile(body, msg.getBodyFile(), False) + + def search_TO(self, query, id, msg): + to = msg.getHeaders(False, "to").get("to", "") + return to.lower().find(query.pop(0).lower()) != -1 + + def search_UID(self, query, id, msg, lastIDs): + """ + Returns C{True} if the message UID is in the range defined by the + search query. + + @type query: A L{list} of L{bytes} + @param query: A list representing the parsed form of the search + query. Its first element should be a L{str} that can be interpreted + as a sequence range, for example '2:4,5:*'. + + @type id: L{int} + @param id: The sequence number of the message being checked. + + @type msg: Provider of L{imap4.IMessage} + @param msg: The message being checked. + + @type lastIDs: L{tuple} + @param lastIDs: A tuple of (last sequence id, last message id). + The I{last sequence id} is an L{int} containing the highest sequence + number of a message in the mailbox. The I{last message id} is an + L{int} containing the highest UID of a message in the mailbox. + """ + (lastSequenceId, lastMessageId) = lastIDs + c = query.pop(0) + m = parseIdList(c, lastMessageId) + return msg.getUID() in m + + def search_UNANSWERED(self, query, id, msg): + return "\\Answered" not in msg.getFlags() + + def search_UNDELETED(self, query, id, msg): + return "\\Deleted" not in msg.getFlags() + + def search_UNDRAFT(self, query, id, msg): + return "\\Draft" not in msg.getFlags() + + def search_UNFLAGGED(self, query, id, msg): + return "\\Flagged" not in msg.getFlags() + + def search_UNKEYWORD(self, query, id, msg): + query.pop(0) + return False + + def search_UNSEEN(self, query, id, msg): + return "\\Seen" not in msg.getFlags() + + def __ebSearch(self, failure, tag): + self.sendBadResponse( + tag, b"SEARCH failed: " + networkString(str(failure.value)) + ) + log.err(failure) + + def do_FETCH(self, tag, messages, query, uid=0): + if query: + self._oldTimeout = self.setTimeout(None) + maybeDeferred(self.mbox.fetch, messages, uid=uid).addCallback( + iter + ).addCallback(self.__cbFetch, tag, query, uid).addErrback( + self.__ebFetch, tag + ) + else: + self.sendPositiveResponse(tag, b"FETCH complete") + + select_FETCH = (do_FETCH, arg_seqset, arg_fetchatt) + + def __cbFetch(self, results, tag, query, uid): + if self.blocked is None: + self.blocked = [] + try: + id, msg = next(results) + except StopIteration: + # The idle timeout was suspended while we delivered results, + # restore it now. + self.setTimeout(self._oldTimeout) + del self._oldTimeout + + # All results have been processed, deliver completion notification. + + # It's important to run this *after* resetting the timeout to "rig + # a race" in some test code. writing to the transport will + # synchronously call test code, which synchronously loses the + # connection, calling our connectionLost method, which cancels the + # timeout. We want to make sure that timeout is cancelled *after* + # we reset it above, so that the final state is no timed + # calls. This avoids reactor uncleanliness errors in the test + # suite. + # XXX: Perhaps loopback should be fixed to not call the user code + # synchronously in transport.write? + self.sendPositiveResponse(tag, b"FETCH completed") + + # Instance state is now consistent again (ie, it is as though + # the fetch command never ran), so allow any pending blocked + # commands to execute. + self._unblock() + else: + self.spewMessage(id, msg, query, uid).addCallback( + lambda _: self.__cbFetch(results, tag, query, uid) + ).addErrback(self.__ebSpewMessage) + + def __ebSpewMessage(self, failure): + # This indicates a programming error. + # There's no reliable way to indicate anything to the client, since we + # may have already written an arbitrary amount of data in response to + # the command. + log.err(failure) + self.transport.loseConnection() + + def spew_envelope(self, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.write + _w(b"ENVELOPE " + collapseNestedLists([getEnvelope(msg)])) + + def spew_flags(self, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.writen + encodedFlags = [networkString(flag) for flag in msg.getFlags()] + _w(b"FLAGS " + b"(" + b" ".join(encodedFlags) + b")") + + def spew_internaldate(self, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.write + idate = msg.getInternalDate() + ttup = email.utils.parsedate_tz(nativeString(idate)) + if ttup is None: + log.msg("%d:%r: unpareseable internaldate: %r" % (id, msg, idate)) + raise IMAP4Exception("Internal failure generating INTERNALDATE") + + # need to specify the month manually, as strftime depends on locale + strdate = time.strftime("%d-%%s-%Y %H:%M:%S ", ttup[:9]) + odate = networkString(strdate % (_MONTH_NAMES[ttup[1]],)) + if ttup[9] is None: + odate = odate + b"+0000" + else: + if ttup[9] >= 0: + sign = b"+" + else: + sign = b"-" + odate = ( + odate + + sign + + b"%04d" + % ((abs(ttup[9]) // 3600) * 100 + (abs(ttup[9]) % 3600) // 60,) + ) + _w(b"INTERNALDATE " + _quote(odate)) + + def spew_rfc822header(self, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.write + hdrs = _formatHeaders(msg.getHeaders(True)) + _w(b"RFC822.HEADER " + _literal(hdrs)) + + def spew_rfc822text(self, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.write + _w(b"RFC822.TEXT ") + _f() + return FileProducer(msg.getBodyFile()).beginProducing(self.transport) + + def spew_rfc822size(self, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.write + _w(b"RFC822.SIZE %d" % (msg.getSize(),)) + + def spew_rfc822(self, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.write + _w(b"RFC822 ") + _f() + mf = IMessageFile(msg, None) + if mf is not None: + return FileProducer(mf.open()).beginProducing(self.transport) + return MessageProducer(msg, None, self._scheduler).beginProducing( + self.transport + ) + + def spew_uid(self, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.write + _w(b"UID %d" % (msg.getUID(),)) + + def spew_bodystructure(self, id, msg, _w=None, _f=None): + _w(b"BODYSTRUCTURE " + collapseNestedLists([getBodyStructure(msg, True)])) + + def spew_body(self, part, id, msg, _w=None, _f=None): + if _w is None: + _w = self.transport.write + for p in part.part: + if msg.isMultipart(): + msg = msg.getSubPart(p) + elif p > 0: + # Non-multipart messages have an implicit first part but no + # other parts - reject any request for any other part. + raise TypeError("Requested subpart of non-multipart message") + + if part.header: + hdrs = msg.getHeaders(part.header.negate, *part.header.fields) + hdrs = _formatHeaders(hdrs) + _w(part.__bytes__() + b" " + _literal(hdrs)) + elif part.text: + _w(part.__bytes__() + b" ") + _f() + return FileProducer(msg.getBodyFile()).beginProducing(self.transport) + elif part.mime: + hdrs = _formatHeaders(msg.getHeaders(True)) + _w(part.__bytes__() + b" " + _literal(hdrs)) + elif part.empty: + _w(part.__bytes__() + b" ") + _f() + if part.part: + return FileProducer(msg.getBodyFile()).beginProducing(self.transport) + else: + mf = IMessageFile(msg, None) + if mf is not None: + return FileProducer(mf.open()).beginProducing(self.transport) + return MessageProducer(msg, None, self._scheduler).beginProducing( + self.transport + ) + + else: + _w(b"BODY " + collapseNestedLists([getBodyStructure(msg)])) + + def spewMessage(self, id, msg, query, uid): + wbuf = WriteBuffer(self.transport) + write = wbuf.write + flush = wbuf.flush + + def start(): + write(b"* %d FETCH (" % (id,)) + + def finish(): + write(b")\r\n") + + def space(): + write(b" ") + + def spew(): + seenUID = False + start() + for part in query: + if part.type == "uid": + seenUID = True + if part.type == "body": + yield self.spew_body(part, id, msg, write, flush) + else: + f = getattr(self, "spew_" + part.type) + yield f(id, msg, write, flush) + if part is not query[-1]: + space() + if uid and not seenUID: + space() + yield self.spew_uid(id, msg, write, flush) + finish() + flush() + + return self._scheduler(spew()) + + def __ebFetch(self, failure, tag): + self.setTimeout(self._oldTimeout) + del self._oldTimeout + log.err(failure) + self.sendBadResponse(tag, b"FETCH failed: " + networkString(str(failure.value))) + + def do_STORE(self, tag, messages, mode, flags, uid=0): + mode = mode.upper() + silent = mode.endswith(b"SILENT") + if mode.startswith(b"+"): + mode = 1 + elif mode.startswith(b"-"): + mode = -1 + else: + mode = 0 + + flags = [nativeString(flag) for flag in flags] + maybeDeferred(self.mbox.store, messages, flags, mode, uid=uid).addCallbacks( + self.__cbStore, + self.__ebStore, + (tag, self.mbox, uid, silent), + None, + (tag,), + None, + ) + + select_STORE = (do_STORE, arg_seqset, arg_atom, arg_flaglist) + + def __cbStore(self, result, tag, mbox, uid, silent): + if result and not silent: + for k, v in result.items(): + if uid: + uidstr = b" UID %d" % (mbox.getUID(k),) + else: + uidstr = b"" + + flags = [networkString(flag) for flag in v] + self.sendUntaggedResponse( + b"%d FETCH (FLAGS (%b)%b)" % (k, b" ".join(flags), uidstr) + ) + self.sendPositiveResponse(tag, b"STORE completed") + + def __ebStore(self, failure, tag): + self.sendBadResponse(tag, b"Server error: " + networkString(str(failure.value))) + + def do_COPY(self, tag, messages, mailbox, uid=0): + mailbox = _parseMbox(mailbox) + maybeDeferred(self.account.select, mailbox).addCallback( + self._cbCopySelectedMailbox, tag, messages, mailbox, uid + ).addErrback(self._ebCopySelectedMailbox, tag) + + select_COPY = (do_COPY, arg_seqset, arg_finalastring) + + def _cbCopySelectedMailbox(self, mbox, tag, messages, mailbox, uid): + if not mbox: + self.sendNegativeResponse(tag, "No such mailbox: " + mailbox) + else: + maybeDeferred(self.mbox.fetch, messages, uid).addCallback( + self.__cbCopy, tag, mbox + ).addCallback(self.__cbCopied, tag, mbox).addErrback(self.__ebCopy, tag) + + def _ebCopySelectedMailbox(self, failure, tag): + self.sendBadResponse(tag, b"Server error: " + networkString(str(failure.value))) + + def __cbCopy(self, messages, tag, mbox): + # XXX - This should handle failures with a rollback or something + addedDeferreds = [] + + fastCopyMbox = IMessageCopier(mbox, None) + for id, msg in messages: + if fastCopyMbox is not None: + d = maybeDeferred(fastCopyMbox.copy, msg) + addedDeferreds.append(d) + continue + + # XXX - The following should be an implementation of IMessageCopier.copy + # on an IMailbox->IMessageCopier adapter. + + flags = msg.getFlags() + date = msg.getInternalDate() + + body = IMessageFile(msg, None) + if body is not None: + bodyFile = body.open() + d = maybeDeferred(mbox.addMessage, bodyFile, flags, date) + else: + + def rewind(f): + f.seek(0) + return f + + buffer = tempfile.TemporaryFile() + d = ( + MessageProducer(msg, buffer, self._scheduler) + .beginProducing(None) + .addCallback( + lambda _, b=buffer, f=flags, d=date: mbox.addMessage( + rewind(b), f, d + ) + ) + ) + addedDeferreds.append(d) + return defer.DeferredList(addedDeferreds) + + def __cbCopied(self, deferredIds, tag, mbox): + ids = [] + failures = [] + for status, result in deferredIds: + if status: + ids.append(result) + else: + failures.append(result.value) + if failures: + self.sendNegativeResponse(tag, "[ALERT] Some messages were not copied") + else: + self.sendPositiveResponse(tag, b"COPY completed") + + def __ebCopy(self, failure, tag): + self.sendBadResponse(tag, b"COPY failed:" + networkString(str(failure.value))) + log.err(failure) + + def do_UID(self, tag, command, line): + command = command.upper() + + if command not in (b"COPY", b"FETCH", b"STORE", b"SEARCH"): + raise IllegalClientResponse(command) + + self.dispatchCommand(tag, command, line, uid=1) + + select_UID = (do_UID, arg_atom, arg_line) + + # + # IMailboxListener implementation + # + def modeChanged(self, writeable): + if writeable: + self.sendUntaggedResponse(message=b"[READ-WRITE]", isAsync=True) + else: + self.sendUntaggedResponse(message=b"[READ-ONLY]", isAsync=True) + + def flagsChanged(self, newFlags): + for mId, flags in newFlags.items(): + encodedFlags = [networkString(flag) for flag in flags] + msg = b"%d FETCH (FLAGS (%b))" % (mId, b" ".join(encodedFlags)) + self.sendUntaggedResponse(msg, isAsync=True) + + def newMessages(self, exists, recent): + if exists is not None: + self.sendUntaggedResponse(b"%d EXISTS" % (exists,), isAsync=True) + if recent is not None: + self.sendUntaggedResponse(b"%d RECENT" % (recent,), isAsync=True) + + +TIMEOUT_ERROR = error.TimeoutError() + + +@implementer(IMailboxListener) +class IMAP4Client(basic.LineReceiver, policies.TimeoutMixin): + """IMAP4 client protocol implementation + + @ivar state: A string representing the state the connection is currently + in. + """ + + tags = None + waiting = None + queued = None + tagID = 1 + state = None + + startedTLS = False + + # Number of seconds to wait before timing out a connection. + # If the number is <= 0 no timeout checking will be performed. + timeout = 0 + + # Capabilities are not allowed to change during the session + # So cache the first response and use that for all later + # lookups + _capCache = None + + _memoryFileLimit = 1024 * 1024 * 10 + + # Authentication is pluggable. This maps names to IClientAuthentication + # objects. + authenticators = None + + STATUS_CODES = ("OK", "NO", "BAD", "PREAUTH", "BYE") + + STATUS_TRANSFORMATIONS = {"MESSAGES": int, "RECENT": int, "UNSEEN": int} + + context = None + + def __init__(self, contextFactory=None): + self.tags = {} + self.queued = [] + self.authenticators = {} + self.context = contextFactory + + self._tag = None + self._parts = None + self._lastCmd = None + + def registerAuthenticator(self, auth): + """ + Register a new form of authentication + + When invoking the authenticate() method of IMAP4Client, the first + matching authentication scheme found will be used. The ordering is + that in which the server lists support authentication schemes. + + @type auth: Implementor of C{IClientAuthentication} + @param auth: The object to use to perform the client + side of this authentication scheme. + """ + self.authenticators[auth.getName().upper()] = auth + + def rawDataReceived(self, data): + if self.timeout > 0: + self.resetTimeout() + + self._pendingSize -= len(data) + if self._pendingSize > 0: + self._pendingBuffer.write(data) + else: + passon = b"" + if self._pendingSize < 0: + data, passon = data[: self._pendingSize], data[self._pendingSize :] + self._pendingBuffer.write(data) + rest = self._pendingBuffer + self._pendingBuffer = None + self._pendingSize = None + rest.seek(0, 0) + self._parts.append(rest.read()) + self.setLineMode(passon.lstrip(b"\r\n")) + + # def sendLine(self, line): + # print 'S:', repr(line) + # return basic.LineReceiver.sendLine(self, line) + + def _setupForLiteral(self, rest, octets): + self._pendingBuffer = self.messageFile(octets) + self._pendingSize = octets + if self._parts is None: + self._parts = [rest, b"\r\n"] + else: + self._parts.extend([rest, b"\r\n"]) + self.setRawMode() + + def connectionMade(self): + if self.timeout > 0: + self.setTimeout(self.timeout) + + def connectionLost(self, reason): + """ + We are no longer connected + """ + if self.timeout > 0: + self.setTimeout(None) + if self.queued is not None: + queued = self.queued + self.queued = None + for cmd in queued: + cmd.defer.errback(reason) + if self.tags is not None: + tags = self.tags + self.tags = None + for cmd in tags.values(): + if cmd is not None and cmd.defer is not None: + cmd.defer.errback(reason) + + def lineReceived(self, line): + """ + Attempt to parse a single line from the server. + + @type line: L{bytes} + @param line: The line from the server, without the line delimiter. + + @raise IllegalServerResponse: If the line or some part of the line + does not represent an allowed message from the server at this time. + """ + # print('C: ' + repr(line)) + if self.timeout > 0: + self.resetTimeout() + + lastPart = line.rfind(b"{") + if lastPart != -1: + lastPart = line[lastPart + 1 :] + if lastPart.endswith(b"}"): + # It's a literal a-comin' in + try: + octets = int(lastPart[:-1]) + except ValueError: + raise IllegalServerResponse(line) + if self._parts is None: + self._tag, parts = line.split(None, 1) + else: + parts = line + self._setupForLiteral(parts, octets) + return + + if self._parts is None: + # It isn't a literal at all + self._regularDispatch(line) + else: + # If an expression is in progress, no tag is required here + # Since we didn't find a literal indicator, this expression + # is done. + self._parts.append(line) + tag, rest = self._tag, b"".join(self._parts) + self._tag = self._parts = None + self.dispatchCommand(tag, rest) + + def timeoutConnection(self): + if self._lastCmd and self._lastCmd.defer is not None: + d, self._lastCmd.defer = self._lastCmd.defer, None + d.errback(TIMEOUT_ERROR) + + if self.queued: + for cmd in self.queued: + if cmd.defer is not None: + d, cmd.defer = cmd.defer, d + d.errback(TIMEOUT_ERROR) + + self.transport.loseConnection() + + def _regularDispatch(self, line): + parts = line.split(None, 1) + if len(parts) != 2: + parts.append(b"") + tag, rest = parts + self.dispatchCommand(tag, rest) + + def messageFile(self, octets): + """ + Create a file to which an incoming message may be written. + + @type octets: L{int} + @param octets: The number of octets which will be written to the file + + @rtype: Any object which implements C{write(string)} and + C{seek(int, int)} + @return: A file-like object + """ + if octets > self._memoryFileLimit: + return tempfile.TemporaryFile() + else: + return BytesIO() + + def makeTag(self): + tag = ("%0.4X" % self.tagID).encode("ascii") + self.tagID += 1 + return tag + + def dispatchCommand(self, tag, rest): + if self.state is None: + f = self.response_UNAUTH + else: + f = getattr(self, "response_" + self.state.upper(), None) + if f: + try: + f(tag, rest) + except BaseException: + log.err() + self.transport.loseConnection() + else: + log.err(f"Cannot dispatch: {self.state}, {tag!r}, {rest!r}") + self.transport.loseConnection() + + def response_UNAUTH(self, tag, rest): + if self.state is None: + # Server greeting, this is + status, rest = rest.split(None, 1) + if status.upper() == b"OK": + self.state = "unauth" + elif status.upper() == b"PREAUTH": + self.state = "auth" + else: + # XXX - This is rude. + self.transport.loseConnection() + raise IllegalServerResponse(tag + b" " + rest) + + b, e = rest.find(b"["), rest.find(b"]") + if b != -1 and e != -1: + self.serverGreeting( + self.__cbCapabilities(([parseNestedParens(rest[b + 1 : e])], None)) + ) + else: + self.serverGreeting(None) + else: + self._defaultHandler(tag, rest) + + def response_AUTH(self, tag, rest): + self._defaultHandler(tag, rest) + + def _defaultHandler(self, tag, rest): + if tag == b"*" or tag == b"+": + if not self.waiting: + self._extraInfo([parseNestedParens(rest)]) + else: + cmd = self.tags[self.waiting] + if tag == b"+": + cmd.continuation(rest) + else: + cmd.lines.append(rest) + else: + try: + cmd = self.tags[tag] + except KeyError: + # XXX - This is rude. + self.transport.loseConnection() + raise IllegalServerResponse(tag + b" " + rest) + else: + status, line = rest.split(None, 1) + if status == b"OK": + # Give them this last line, too + cmd.finish(rest, self._extraInfo) + else: + cmd.defer.errback(IMAP4Exception(line)) + del self.tags[tag] + self.waiting = None + self._flushQueue() + + def _flushQueue(self): + if self.queued: + cmd = self.queued.pop(0) + t = self.makeTag() + self.tags[t] = cmd + self.sendLine(cmd.format(t)) + self.waiting = t + + def _extraInfo(self, lines): + # XXX - This is terrible. + # XXX - Also, this should collapse temporally proximate calls into single + # invocations of IMailboxListener methods, where possible. + flags = {} + recent = exists = None + for response in lines: + elements = len(response) + if elements == 1 and response[0] == [b"READ-ONLY"]: + self.modeChanged(False) + elif elements == 1 and response[0] == [b"READ-WRITE"]: + self.modeChanged(True) + elif elements == 2 and response[1] == b"EXISTS": + exists = int(response[0]) + elif elements == 2 and response[1] == b"RECENT": + recent = int(response[0]) + elif elements == 3 and response[1] == b"FETCH": + mId = int(response[0]) + values, _ = self._parseFetchPairs(response[2]) + flags.setdefault(mId, []).extend(values.get("FLAGS", ())) + else: + log.msg(f"Unhandled unsolicited response: {response}") + + if flags: + self.flagsChanged(flags) + if recent is not None or exists is not None: + self.newMessages(exists, recent) + + def sendCommand(self, cmd): + cmd.defer = defer.Deferred() + if self.waiting: + self.queued.append(cmd) + return cmd.defer + t = self.makeTag() + self.tags[t] = cmd + self.sendLine(cmd.format(t)) + self.waiting = t + self._lastCmd = cmd + return cmd.defer + + def getCapabilities(self, useCache=1): + """ + Request the capabilities available on this server. + + This command is allowed in any state of connection. + + @type useCache: C{bool} + @param useCache: Specify whether to use the capability-cache or to + re-retrieve the capabilities from the server. Server capabilities + should never change, so for normal use, this flag should never be + false. + + @rtype: L{Deferred} + @return: A deferred whose callback will be invoked with a + dictionary mapping capability types to lists of supported + mechanisms, or to None if a support list is not applicable. + """ + if useCache and self._capCache is not None: + return defer.succeed(self._capCache) + cmd = b"CAPABILITY" + resp = (b"CAPABILITY",) + d = self.sendCommand(Command(cmd, wantResponse=resp)) + d.addCallback(self.__cbCapabilities) + return d + + def __cbCapabilities(self, result): + (lines, tagline) = result + caps = {} + for rest in lines: + for cap in rest[1:]: + parts = cap.split(b"=", 1) + if len(parts) == 1: + category, value = parts[0], None + else: + category, value = parts + caps.setdefault(category, []).append(value) + + # Preserve a non-ideal API for backwards compatibility. It would + # probably be entirely sensible to have an object with a wider API than + # dict here so this could be presented less insanely. + for category in caps: + if caps[category] == [None]: + caps[category] = None + self._capCache = caps + return caps + + def logout(self): + """ + Inform the server that we are done with the connection. + + This command is allowed in any state of connection. + + @rtype: L{Deferred} + @return: A deferred whose callback will be invoked with None + when the proper server acknowledgement has been received. + """ + d = self.sendCommand(Command(b"LOGOUT", wantResponse=(b"BYE",))) + d.addCallback(self.__cbLogout) + return d + + def __cbLogout(self, result): + (lines, tagline) = result + self.transport.loseConnection() + # We don't particularly care what the server said + return None + + def noop(self): + """ + Perform no operation. + + This command is allowed in any state of connection. + + @rtype: L{Deferred} + @return: A deferred whose callback will be invoked with a list + of untagged status updates the server responds with. + """ + d = self.sendCommand(Command(b"NOOP")) + d.addCallback(self.__cbNoop) + return d + + def __cbNoop(self, result): + # Conceivable, this is elidable. + # It is, afterall, a no-op. + (lines, tagline) = result + return lines + + def startTLS(self, contextFactory=None): + """ + Initiates a 'STARTTLS' request and negotiates the TLS / SSL + Handshake. + + @param contextFactory: The TLS / SSL Context Factory to + leverage. If the contextFactory is None the IMAP4Client will + either use the current TLS / SSL Context Factory or attempt to + create a new one. + + @type contextFactory: C{ssl.ClientContextFactory} + + @return: A Deferred which fires when the transport has been + secured according to the given contextFactory, or which fails + if the transport cannot be secured. + """ + assert ( + not self.startedTLS + ), "Client and Server are currently communicating via TLS" + if contextFactory is None: + contextFactory = self._getContextFactory() + + if contextFactory is None: + return defer.fail( + IMAP4Exception( + "IMAP4Client requires a TLS context to " + "initiate the STARTTLS handshake" + ) + ) + + if b"STARTTLS" not in self._capCache: + return defer.fail( + IMAP4Exception( + "Server does not support secure communication " "via TLS / SSL" + ) + ) + + tls = interfaces.ITLSTransport(self.transport, None) + if tls is None: + return defer.fail( + IMAP4Exception( + "IMAP4Client transport does not implement " + "interfaces.ITLSTransport" + ) + ) + + d = self.sendCommand(Command(b"STARTTLS")) + d.addCallback(self._startedTLS, contextFactory) + d.addCallback(lambda _: self.getCapabilities()) + return d + + def authenticate(self, secret): + """ + Attempt to enter the authenticated state with the server + + This command is allowed in the Non-Authenticated state. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked if the authentication + succeeds and whose errback will be invoked otherwise. + """ + if self._capCache is None: + d = self.getCapabilities() + else: + d = defer.succeed(self._capCache) + d.addCallback(self.__cbAuthenticate, secret) + return d + + def __cbAuthenticate(self, caps, secret): + auths = caps.get(b"AUTH", ()) + for scheme in auths: + if scheme.upper() in self.authenticators: + cmd = Command( + b"AUTHENTICATE", scheme, (), self.__cbContinueAuth, scheme, secret + ) + return self.sendCommand(cmd) + + if self.startedTLS: + return defer.fail( + NoSupportedAuthentication(auths, self.authenticators.keys()) + ) + else: + + def ebStartTLS(err): + err.trap(IMAP4Exception) + # We couldn't negotiate TLS for some reason + return defer.fail( + NoSupportedAuthentication(auths, self.authenticators.keys()) + ) + + d = self.startTLS() + d.addErrback(ebStartTLS) + d.addCallback(lambda _: self.getCapabilities()) + d.addCallback(self.__cbAuthTLS, secret) + return d + + def __cbContinueAuth(self, rest, scheme, secret): + try: + chal = decodebytes(rest + b"\n") + except binascii.Error: + self.sendLine(b"*") + raise IllegalServerResponse(rest) + else: + auth = self.authenticators[scheme] + chal = auth.challengeResponse(secret, chal) + self.sendLine(encodebytes(chal).strip()) + + def __cbAuthTLS(self, caps, secret): + auths = caps.get(b"AUTH", ()) + for scheme in auths: + if scheme.upper() in self.authenticators: + cmd = Command( + b"AUTHENTICATE", scheme, (), self.__cbContinueAuth, scheme, secret + ) + return self.sendCommand(cmd) + raise NoSupportedAuthentication(auths, self.authenticators.keys()) + + def login(self, username, password): + """ + Authenticate with the server using a username and password + + This command is allowed in the Non-Authenticated state. If the + server supports the STARTTLS capability and our transport supports + TLS, TLS is negotiated before the login command is issued. + + A more secure way to log in is to use C{startTLS} or + C{authenticate} or both. + + @type username: L{str} + @param username: The username to log in with + + @type password: L{str} + @param password: The password to log in with + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked if login is successful + and whose errback is invoked otherwise. + """ + d = maybeDeferred(self.getCapabilities) + d.addCallback(self.__cbLoginCaps, username, password) + return d + + def serverGreeting(self, caps): + """ + Called when the server has sent us a greeting. + + @type caps: C{dict} + @param caps: Capabilities the server advertised in its greeting. + """ + + def _getContextFactory(self): + if self.context is not None: + return self.context + try: + from twisted.internet import ssl + except ImportError: + return None + else: + return ssl.ClientContextFactory() + + def __cbLoginCaps(self, capabilities, username, password): + # If the server advertises STARTTLS, we might want to try to switch to TLS + tryTLS = b"STARTTLS" in capabilities + + # If our transport supports switching to TLS, we might want to try to switch to TLS. + tlsableTransport = interfaces.ITLSTransport(self.transport, None) is not None + + # If our transport is not already using TLS, we might want to try to switch to TLS. + nontlsTransport = interfaces.ISSLTransport(self.transport, None) is None + + if not self.startedTLS and tryTLS and tlsableTransport and nontlsTransport: + d = self.startTLS() + + d.addCallbacks( + self.__cbLoginTLS, + self.__ebLoginTLS, + callbackArgs=(username, password), + ) + return d + else: + if nontlsTransport: + log.msg("Server has no TLS support. logging in over cleartext!") + args = b" ".join((_quote(username), _quote(password))) + return self.sendCommand(Command(b"LOGIN", args)) + + def _startedTLS(self, result, context): + self.transport.startTLS(context) + self._capCache = None + self.startedTLS = True + return result + + def __cbLoginTLS(self, result, username, password): + args = b" ".join((_quote(username), _quote(password))) + return self.sendCommand(Command(b"LOGIN", args)) + + def __ebLoginTLS(self, failure): + log.err(failure) + return failure + + def namespace(self): + """ + Retrieve information about the namespaces available to this account + + This command is allowed in the Authenticated and Selected states. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with namespace + information. An example of this information is:: + + [[['', '/']], [], []] + + which indicates a single personal namespace called '' with '/' + as its hierarchical delimiter, and no shared or user namespaces. + """ + cmd = b"NAMESPACE" + resp = (b"NAMESPACE",) + d = self.sendCommand(Command(cmd, wantResponse=resp)) + d.addCallback(self.__cbNamespace) + return d + + def __cbNamespace(self, result): + (lines, last) = result + + # Namespaces and their delimiters qualify and delimit + # mailboxes, so they should be native strings + # + # On Python 2, no decoding is necessary to maintain + # the API contract. + # + # On Python 3, users specify mailboxes with native strings, so + # they should receive namespaces and delimiters as native + # strings. Both cases are possible because of the imap4-utf-7 + # encoding. + def _prepareNamespaceOrDelimiter(namespaceList): + return [element.decode("imap4-utf-7") for element in namespaceList] + + for parts in lines: + if len(parts) == 4 and parts[0] == b"NAMESPACE": + return [ + [] + if pairOrNone is None + else [_prepareNamespaceOrDelimiter(value) for value in pairOrNone] + for pairOrNone in parts[1:] + ] + log.err("No NAMESPACE response to NAMESPACE command") + return [[], [], []] + + def select(self, mailbox): + """ + Select a mailbox + + This command is allowed in the Authenticated and Selected states. + + @type mailbox: L{str} + @param mailbox: The name of the mailbox to select + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with mailbox + information if the select is successful and whose errback is + invoked otherwise. Mailbox information consists of a dictionary + with the following L{str} keys and values:: + + FLAGS: A list of strings containing the flags settable on + messages in this mailbox. + + EXISTS: An integer indicating the number of messages in this + mailbox. + + RECENT: An integer indicating the number of "recent" + messages in this mailbox. + + UNSEEN: The message sequence number (an integer) of the + first unseen message in the mailbox. + + PERMANENTFLAGS: A list of strings containing the flags that + can be permanently set on messages in this mailbox. + + UIDVALIDITY: An integer uniquely identifying this mailbox. + """ + cmd = b"SELECT" + args = _prepareMailboxName(mailbox) + # This appears not to be used, so we can use native strings to + # indicate that the return type is native strings. + resp = ("FLAGS", "EXISTS", "RECENT", "UNSEEN", "PERMANENTFLAGS", "UIDVALIDITY") + d = self.sendCommand(Command(cmd, args, wantResponse=resp)) + d.addCallback(self.__cbSelect, 1) + return d + + def examine(self, mailbox): + """ + Select a mailbox in read-only mode + + This command is allowed in the Authenticated and Selected states. + + @type mailbox: L{str} + @param mailbox: The name of the mailbox to examine + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with mailbox + information if the examine is successful and whose errback + is invoked otherwise. Mailbox information consists of a dictionary + with the following keys and values:: + + 'FLAGS': A list of strings containing the flags settable on + messages in this mailbox. + + 'EXISTS': An integer indicating the number of messages in this + mailbox. + + 'RECENT': An integer indicating the number of \"recent\" + messages in this mailbox. + + 'UNSEEN': An integer indicating the number of messages not + flagged \\Seen in this mailbox. + + 'PERMANENTFLAGS': A list of strings containing the flags that + can be permanently set on messages in this mailbox. + + 'UIDVALIDITY': An integer uniquely identifying this mailbox. + """ + cmd = b"EXAMINE" + args = _prepareMailboxName(mailbox) + resp = ( + b"FLAGS", + b"EXISTS", + b"RECENT", + b"UNSEEN", + b"PERMANENTFLAGS", + b"UIDVALIDITY", + ) + d = self.sendCommand(Command(cmd, args, wantResponse=resp)) + d.addCallback(self.__cbSelect, 0) + return d + + def _intOrRaise(self, value, phrase): + """ + Parse C{value} as an integer and return the result or raise + L{IllegalServerResponse} with C{phrase} as an argument if C{value} + cannot be parsed as an integer. + """ + try: + return int(value) + except ValueError: + raise IllegalServerResponse(phrase) + + def __cbSelect(self, result, rw): + """ + Handle lines received in response to a SELECT or EXAMINE command. + + See RFC 3501, section 6.3.1. + """ + (lines, tagline) = result + # In the absence of specification, we are free to assume: + # READ-WRITE access + datum = {"READ-WRITE": rw} + lines.append(parseNestedParens(tagline)) + for split in lines: + if len(split) > 0 and split[0].upper() == b"OK": + # Handle all the kinds of OK response. + content = split[1] + if isinstance(content, list): + key = content[0] + else: + # not multi-valued, like OK LOGIN + key = content + key = key.upper() + if key == b"READ-ONLY": + datum["READ-WRITE"] = False + elif key == b"READ-WRITE": + datum["READ-WRITE"] = True + elif key == b"UIDVALIDITY": + datum["UIDVALIDITY"] = self._intOrRaise(content[1], split) + elif key == b"UNSEEN": + datum["UNSEEN"] = self._intOrRaise(content[1], split) + elif key == b"UIDNEXT": + datum["UIDNEXT"] = self._intOrRaise(content[1], split) + elif key == b"PERMANENTFLAGS": + datum["PERMANENTFLAGS"] = tuple( + nativeString(flag) for flag in content[1] + ) + else: + log.err(f"Unhandled SELECT response (2): {split}") + elif len(split) == 2: + # Handle FLAGS, EXISTS, and RECENT + if split[0].upper() == b"FLAGS": + datum["FLAGS"] = tuple(nativeString(flag) for flag in split[1]) + elif isinstance(split[1], bytes): + # Must make sure things are strings before treating them as + # strings since some other forms of response have nesting in + # places which results in lists instead. + if split[1].upper() == b"EXISTS": + datum["EXISTS"] = self._intOrRaise(split[0], split) + elif split[1].upper() == b"RECENT": + datum["RECENT"] = self._intOrRaise(split[0], split) + else: + log.err(f"Unhandled SELECT response (0): {split}") + else: + log.err(f"Unhandled SELECT response (1): {split}") + else: + log.err(f"Unhandled SELECT response (4): {split}") + return datum + + def create(self, name): + """ + Create a new mailbox on the server + + This command is allowed in the Authenticated and Selected states. + + @type name: L{str} + @param name: The name of the mailbox to create. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked if the mailbox creation + is successful and whose errback is invoked otherwise. + """ + return self.sendCommand(Command(b"CREATE", _prepareMailboxName(name))) + + def delete(self, name): + """ + Delete a mailbox + + This command is allowed in the Authenticated and Selected states. + + @type name: L{str} + @param name: The name of the mailbox to delete. + + @rtype: L{Deferred} + @return: A deferred whose calblack is invoked if the mailbox is + deleted successfully and whose errback is invoked otherwise. + """ + return self.sendCommand(Command(b"DELETE", _prepareMailboxName(name))) + + def rename(self, oldname, newname): + """ + Rename a mailbox + + This command is allowed in the Authenticated and Selected states. + + @type oldname: L{str} + @param oldname: The current name of the mailbox to rename. + + @type newname: L{str} + @param newname: The new name to give the mailbox. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked if the rename is + successful and whose errback is invoked otherwise. + """ + oldname = _prepareMailboxName(oldname) + newname = _prepareMailboxName(newname) + return self.sendCommand(Command(b"RENAME", b" ".join((oldname, newname)))) + + def subscribe(self, name): + """ + Add a mailbox to the subscription list + + This command is allowed in the Authenticated and Selected states. + + @type name: L{str} + @param name: The mailbox to mark as 'active' or 'subscribed' + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked if the subscription + is successful and whose errback is invoked otherwise. + """ + return self.sendCommand(Command(b"SUBSCRIBE", _prepareMailboxName(name))) + + def unsubscribe(self, name): + """ + Remove a mailbox from the subscription list + + This command is allowed in the Authenticated and Selected states. + + @type name: L{str} + @param name: The mailbox to unsubscribe + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked if the unsubscription + is successful and whose errback is invoked otherwise. + """ + return self.sendCommand(Command(b"UNSUBSCRIBE", _prepareMailboxName(name))) + + def list(self, reference, wildcard): + """ + List a subset of the available mailboxes + + This command is allowed in the Authenticated and Selected + states. + + @type reference: L{str} + @param reference: The context in which to interpret + C{wildcard} + + @type wildcard: L{str} + @param wildcard: The pattern of mailbox names to match, + optionally including either or both of the '*' and '%' + wildcards. '*' will match zero or more characters and + cross hierarchical boundaries. '%' will also match zero + or more characters, but is limited to a single + hierarchical level. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a list of + L{tuple}s, the first element of which is a L{tuple} of + mailbox flags, the second element of which is the + hierarchy delimiter for this mailbox, and the third of + which is the mailbox name; if the command is unsuccessful, + the deferred's errback is invoked instead. B{NB}: the + delimiter and the mailbox name are L{str}s. + """ + cmd = b"LIST" + args = (f'"{reference}" "{wildcard}"').encode("imap4-utf-7") + resp = (b"LIST",) + d = self.sendCommand(Command(cmd, args, wantResponse=resp)) + d.addCallback(self.__cbList, b"LIST") + return d + + def lsub(self, reference, wildcard): + """ + List a subset of the subscribed available mailboxes + + This command is allowed in the Authenticated and Selected states. + + The parameters and returned object are the same as for the L{list} + method, with one slight difference: Only mailboxes which have been + subscribed can be included in the resulting list. + """ + cmd = b"LSUB" + + encodedReference = reference.encode("ascii") + encodedWildcard = wildcard.encode("imap4-utf-7") + args = b"".join( + [ + b'"', + encodedReference, + b'"' b' "', + encodedWildcard, + b'"', + ] + ) + resp = (b"LSUB",) + d = self.sendCommand(Command(cmd, args, wantResponse=resp)) + d.addCallback(self.__cbList, b"LSUB") + return d + + def __cbList(self, result, command): + (lines, last) = result + results = [] + + for parts in lines: + if len(parts) == 4 and parts[0] == command: + # flags + parts[1] = tuple(nativeString(flag) for flag in parts[1]) + + # The mailbox should be a native string. + # On Python 2, this maintains the API's contract. + # + # On Python 3, users specify mailboxes with native + # strings, so they should receive mailboxes as native + # strings. Both cases are possible because of the + # imap4-utf-7 encoding. + # + # Mailbox names contain the hierarchical delimiter, so + # it too should be a native string. + # delimiter + parts[2] = parts[2].decode("imap4-utf-7") + # mailbox + parts[3] = parts[3].decode("imap4-utf-7") + + results.append(tuple(parts[1:])) + return results + + _statusNames = { + name: name.encode("ascii") + for name in ( + "MESSAGES", + "RECENT", + "UIDNEXT", + "UIDVALIDITY", + "UNSEEN", + ) + } + + def status(self, mailbox, *names): + """ + Retrieve the status of the given mailbox + + This command is allowed in the Authenticated and Selected states. + + @type mailbox: L{str} + @param mailbox: The name of the mailbox to query + + @type names: L{bytes} + @param names: The status names to query. These may be any number of: + C{'MESSAGES'}, C{'RECENT'}, C{'UIDNEXT'}, C{'UIDVALIDITY'}, and + C{'UNSEEN'}. + + @rtype: L{Deferred} + @return: A deferred which fires with the status information if the + command is successful and whose errback is invoked otherwise. The + status information is in the form of a C{dict}. Each element of + C{names} is a key in the dictionary. The value for each key is the + corresponding response from the server. + """ + cmd = b"STATUS" + + preparedMailbox = _prepareMailboxName(mailbox) + try: + names = b" ".join(self._statusNames[name] for name in names) + except KeyError: + raise ValueError(f"Unknown names: {set(names) - set(self._statusNames)!r}") + + args = b"".join([preparedMailbox, b" (", names, b")"]) + resp = (b"STATUS",) + d = self.sendCommand(Command(cmd, args, wantResponse=resp)) + d.addCallback(self.__cbStatus) + return d + + def __cbStatus(self, result): + (lines, last) = result + status = {} + for parts in lines: + if parts[0] == b"STATUS": + items = parts[2] + items = [items[i : i + 2] for i in range(0, len(items), 2)] + for k, v in items: + try: + status[nativeString(k)] = v + except UnicodeDecodeError: + raise IllegalServerResponse(repr(items)) + for k in status.keys(): + t = self.STATUS_TRANSFORMATIONS.get(k) + if t: + try: + status[k] = t(status[k]) + except Exception as e: + raise IllegalServerResponse( + "(" + k + " " + status[k] + "): " + str(e) + ) + return status + + def append(self, mailbox, message, flags=(), date=None): + """ + Add the given message to the given mailbox. + + This command is allowed in the Authenticated and Selected states. + + @type mailbox: L{str} + @param mailbox: The mailbox to which to add this message. + + @type message: Any file-like object opened in B{binary mode}. + @param message: The message to add, in RFC822 format. Newlines + in this file should be \\r\\n-style. + + @type flags: Any iterable of L{str} + @param flags: The flags to associated with this message. + + @type date: L{str} + @param date: The date to associate with this message. This should + be of the format DD-MM-YYYY HH:MM:SS +/-HHMM. For example, in + Eastern Standard Time, on July 1st 2004 at half past 1 PM, + \"01-07-2004 13:30:00 -0500\". + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked when this command + succeeds or whose errback is invoked if it fails. + """ + message.seek(0, 2) + L = message.tell() + message.seek(0, 0) + if date: + date = networkString(' "%s"' % nativeString(date)) + else: + date = b"" + + encodedFlags = [networkString(flag) for flag in flags] + + cmd = b"%b (%b)%b {%d}" % ( + _prepareMailboxName(mailbox), + b" ".join(encodedFlags), + date, + L, + ) + + d = self.sendCommand( + Command(b"APPEND", cmd, (), self.__cbContinueAppend, message) + ) + return d + + def __cbContinueAppend(self, lines, message): + s = basic.FileSender() + return s.beginFileTransfer(message, self.transport, None).addCallback( + self.__cbFinishAppend + ) + + def __cbFinishAppend(self, foo): + self.sendLine(b"") + + def check(self): + """ + Tell the server to perform a checkpoint + + This command is allowed in the Selected state. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked when this command + succeeds or whose errback is invoked if it fails. + """ + return self.sendCommand(Command(b"CHECK")) + + def close(self): + """ + Return the connection to the Authenticated state. + + This command is allowed in the Selected state. + + Issuing this command will also remove all messages flagged \\Deleted + from the selected mailbox if it is opened in read-write mode, + otherwise it indicates success by no messages are removed. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked when the command + completes successfully or whose errback is invoked if it fails. + """ + return self.sendCommand(Command(b"CLOSE")) + + def expunge(self): + """ + Return the connection to the Authenticate state. + + This command is allowed in the Selected state. + + Issuing this command will perform the same actions as issuing the + close command, but will also generate an 'expunge' response for + every message deleted. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a list of the + 'expunge' responses when this command is successful or whose errback + is invoked otherwise. + """ + cmd = b"EXPUNGE" + resp = (b"EXPUNGE",) + d = self.sendCommand(Command(cmd, wantResponse=resp)) + d.addCallback(self.__cbExpunge) + return d + + def __cbExpunge(self, result): + (lines, last) = result + ids = [] + for parts in lines: + if len(parts) == 2 and parts[1] == b"EXPUNGE": + ids.append(self._intOrRaise(parts[0], parts)) + return ids + + def search(self, *queries, uid=False): + """ + Search messages in the currently selected mailbox + + This command is allowed in the Selected state. + + Any non-zero number of queries are accepted by this method, as returned + by the C{Query}, C{Or}, and C{Not} functions. + + @param uid: if true, the server is asked to return message UIDs instead + of message sequence numbers. + @type uid: L{bool} + + @rtype: L{Deferred} + @return: A deferred whose callback will be invoked with a list of all + the message sequence numbers return by the search, or whose errback + will be invoked if there is an error. + """ + # Queries should be encoded as ASCII unless a charset + # identifier is provided. See #9201. + queries = [query.encode("charmap") for query in queries] + + cmd = b"UID SEARCH" if uid else b"SEARCH" + args = b" ".join(queries) + d = self.sendCommand(Command(cmd, args, wantResponse=(cmd,))) + d.addCallback(self.__cbSearch) + return d + + def __cbSearch(self, result): + (lines, end) = result + ids = [] + for parts in lines: + if len(parts) > 0 and parts[0] == b"SEARCH": + ids.extend([self._intOrRaise(p, parts) for p in parts[1:]]) + return ids + + def fetchUID(self, messages, uid=0): + """ + Retrieve the unique identifier for one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message sequence numbers to unique message identifiers, or whose + errback is invoked if there is an error. + """ + return self._fetch(messages, useUID=uid, uid=1) + + def fetchFlags(self, messages, uid=0): + """ + Retrieve the flags for one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: The messages for which to retrieve flags. + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to lists of flags, or whose errback is invoked if + there is an error. + """ + return self._fetch(messages, useUID=uid, flags=1) + + def fetchInternalDate(self, messages, uid=0): + """ + Retrieve the internal date associated with one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: The messages for which to retrieve the internal date. + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to date strings, or whose errback is invoked + if there is an error. Date strings take the format of + \"day-month-year time timezone\". + """ + return self._fetch(messages, useUID=uid, internaldate=1) + + def fetchEnvelope(self, messages, uid=0): + """ + Retrieve the envelope data for one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: The messages for which to retrieve envelope + data. + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of + message numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict + mapping message numbers to envelope data, or whose errback + is invoked if there is an error. Envelope data consists + of a sequence of the date, subject, from, sender, + reply-to, to, cc, bcc, in-reply-to, and message-id header + fields. The date, subject, in-reply-to, and message-id + fields are L{str}, while the from, sender, reply-to, to, + cc, and bcc fields contain address data as L{str}s. + Address data consists of a sequence of name, source route, + mailbox name, and hostname. Fields which are not present + for a particular address may be L{None}. + """ + return self._fetch(messages, useUID=uid, envelope=1) + + def fetchBodyStructure(self, messages, uid=0): + """ + Retrieve the structure of the body of one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: The messages for which to retrieve body structure + data. + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to body structure data, or whose errback is invoked + if there is an error. Body structure data describes the MIME-IMB + format of a message and consists of a sequence of mime type, mime + subtype, parameters, content id, description, encoding, and size. + The fields following the size field are variable: if the mime + type/subtype is message/rfc822, the contained message's envelope + information, body structure data, and number of lines of text; if + the mime type is text, the number of lines of text. Extension fields + may also be included; if present, they are: the MD5 hash of the body, + body disposition, body language. + """ + return self._fetch(messages, useUID=uid, bodystructure=1) + + def fetchSimplifiedBody(self, messages, uid=0): + """ + Retrieve the simplified body structure of one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: C{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to body data, or whose errback is invoked + if there is an error. The simplified body structure is the same + as the body structure, except that extension fields will never be + present. + """ + return self._fetch(messages, useUID=uid, body=1) + + def fetchMessage(self, messages, uid=0): + """ + Retrieve one or more entire messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: C{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + + @return: A L{Deferred} which will fire with a C{dict} mapping message + sequence numbers to C{dict}s giving message data for the + corresponding message. If C{uid} is true, the inner dictionaries + have a C{'UID'} key mapped to a L{str} giving the UID for the + message. The text of the message is a L{str} associated with the + C{'RFC822'} key in each dictionary. + """ + return self._fetch(messages, useUID=uid, rfc822=1) + + def fetchHeaders(self, messages, uid=0): + """ + Retrieve headers of one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to dicts of message headers, or whose errback is + invoked if there is an error. + """ + return self._fetch(messages, useUID=uid, rfc822header=1) + + def fetchBody(self, messages, uid=0): + """ + Retrieve body text of one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to file-like objects containing body text, or whose + errback is invoked if there is an error. + """ + return self._fetch(messages, useUID=uid, rfc822text=1) + + def fetchSize(self, messages, uid=0): + """ + Retrieve the size, in octets, of one or more messages + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to sizes, or whose errback is invoked if there is + an error. + """ + return self._fetch(messages, useUID=uid, rfc822size=1) + + def fetchFull(self, messages, uid=0): + """ + Retrieve several different fields of one or more messages + + This command is allowed in the Selected state. This is equivalent + to issuing all of the C{fetchFlags}, C{fetchInternalDate}, + C{fetchSize}, C{fetchEnvelope}, and C{fetchSimplifiedBody} + functions. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to dict of the retrieved data values, or whose + errback is invoked if there is an error. They dictionary keys + are "flags", "date", "size", "envelope", and "body". + """ + return self._fetch( + messages, + useUID=uid, + flags=1, + internaldate=1, + rfc822size=1, + envelope=1, + body=1, + ) + + def fetchAll(self, messages, uid=0): + """ + Retrieve several different fields of one or more messages + + This command is allowed in the Selected state. This is equivalent + to issuing all of the C{fetchFlags}, C{fetchInternalDate}, + C{fetchSize}, and C{fetchEnvelope} functions. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to dict of the retrieved data values, or whose + errback is invoked if there is an error. They dictionary keys + are "flags", "date", "size", and "envelope". + """ + return self._fetch( + messages, useUID=uid, flags=1, internaldate=1, rfc822size=1, envelope=1 + ) + + def fetchFast(self, messages, uid=0): + """ + Retrieve several different fields of one or more messages + + This command is allowed in the Selected state. This is equivalent + to issuing all of the C{fetchFlags}, C{fetchInternalDate}, and + C{fetchSize} functions. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a dict mapping + message numbers to dict of the retrieved data values, or whose + errback is invoked if there is an error. They dictionary keys are + "flags", "date", and "size". + """ + return self._fetch(messages, useUID=uid, flags=1, internaldate=1, rfc822size=1) + + def _parseFetchPairs(self, fetchResponseList): + """ + Given the result of parsing a single I{FETCH} response, construct a + L{dict} mapping response keys to response values. + + @param fetchResponseList: The result of parsing a I{FETCH} response + with L{parseNestedParens} and extracting just the response data + (that is, just the part that comes after C{"FETCH"}). The form + of this input (and therefore the output of this method) is very + disagreeable. A valuable improvement would be to enumerate the + possible keys (representing them as structured objects of some + sort) rather than using strings and tuples of tuples of strings + and so forth. This would allow the keys to be documented more + easily and would allow for a much simpler application-facing API + (one not based on looking up somewhat hard to predict keys in a + dict). Since C{fetchResponseList} notionally represents a + flattened sequence of pairs (identifying keys followed by their + associated values), collapsing such complex elements of this + list as C{["BODY", ["HEADER.FIELDS", ["SUBJECT"]]]} into a + single object would also greatly simplify the implementation of + this method. + + @return: A C{dict} of the response data represented by C{pairs}. Keys + in this dictionary are things like C{"RFC822.TEXT"}, C{"FLAGS"}, or + C{("BODY", ("HEADER.FIELDS", ("SUBJECT",)))}. Values are entirely + dependent on the key with which they are associated, but retain the + same structured as produced by L{parseNestedParens}. + """ + + # TODO: RFC 3501 Section 7.4.2, "FETCH Response", says for + # BODY responses that "8-bit textual data is permitted if a + # charset identifier is part of the body parameter + # parenthesized list". Every other component is 7-bit. This + # should parse out the charset identifier and use it to decode + # 8-bit bodies. Until then, on Python 2 it should continue to + # return native (byte) strings, while on Python 3 it should + # decode bytes to native strings via charmap, ensuring data + # fidelity at the cost of mojibake. + def nativeStringResponse(thing): + if isinstance(thing, bytes): + return thing.decode("charmap") + elif isinstance(thing, list): + return [nativeStringResponse(subthing) for subthing in thing] + + values = {} + unstructured = [] + + responseParts = iter(fetchResponseList) + while True: + try: + key = next(responseParts) + except StopIteration: + break + + try: + value = next(responseParts) + except StopIteration: + raise IllegalServerResponse(b"Not enough arguments", fetchResponseList) + + # The parsed forms of responses like: + # + # BODY[] VALUE + # BODY[TEXT] VALUE + # BODY[HEADER.FIELDS (SUBJECT)] VALUE + # BODY[HEADER.FIELDS (SUBJECT)]<N.M> VALUE + # + # are: + # + # ["BODY", [], VALUE] + # ["BODY", ["TEXT"], VALUE] + # ["BODY", ["HEADER.FIELDS", ["SUBJECT"]], VALUE] + # ["BODY", ["HEADER.FIELDS", ["SUBJECT"]], "<N.M>", VALUE] + # + # Additionally, BODY responses for multipart messages are + # represented as: + # + # ["BODY", VALUE] + # + # with list as the type of VALUE and the type of VALUE[0]. + # + # See #6281 for ideas on how this might be improved. + + if key not in (b"BODY", b"BODY.PEEK"): + # Only BODY (and by extension, BODY.PEEK) responses can have + # body sections. + hasSection = False + elif not isinstance(value, list): + # A BODY section is always represented as a list. Any non-list + # is not a BODY section. + hasSection = False + elif len(value) > 2: + # The list representing a BODY section has at most two elements. + hasSection = False + elif value and isinstance(value[0], list): + # A list containing a list represents the body structure of a + # multipart message, instead. + hasSection = False + else: + # Otherwise it must have a BODY section to examine. + hasSection = True + + # If it has a BODY section, grab some extra elements and shuffle + # around the shape of the key a little bit. + + key = nativeString(key) + unstructured.append(key) + + if hasSection: + if len(value) < 2: + value = [nativeString(v) for v in value] + unstructured.append(value) + + key = (key, tuple(value)) + else: + valueHead = nativeString(value[0]) + valueTail = [nativeString(v) for v in value[1]] + unstructured.append([valueHead, valueTail]) + + key = (key, (valueHead, tuple(valueTail))) + try: + value = next(responseParts) + except StopIteration: + raise IllegalServerResponse( + b"Not enough arguments", fetchResponseList + ) + + # Handle partial ranges + if value.startswith(b"<") and value.endswith(b">"): + try: + int(value[1:-1]) + except ValueError: + # This isn't really a range, it's some content. + pass + else: + value = nativeString(value) + unstructured.append(value) + key = key + (value,) + try: + value = next(responseParts) + except StopIteration: + raise IllegalServerResponse( + b"Not enough arguments", fetchResponseList + ) + + value = nativeStringResponse(value) + unstructured.append(value) + values[key] = value + + return values, unstructured + + def _cbFetch(self, result, requestedParts, structured): + (lines, last) = result + info = {} + for parts in lines: + if len(parts) == 3 and parts[1] == b"FETCH": + id = self._intOrRaise(parts[0], parts) + if id not in info: + info[id] = [parts[2]] + else: + info[id][0].extend(parts[2]) + + results = {} + decodedInfo = {} + for messageId, values in info.items(): + structuredMap, unstructuredList = self._parseFetchPairs(values[0]) + decodedInfo.setdefault(messageId, [[]])[0].extend(unstructuredList) + results.setdefault(messageId, {}).update(structuredMap) + info = decodedInfo + + flagChanges = {} + for messageId in list(results.keys()): + values = results[messageId] + for part in list(values.keys()): + if part not in requestedParts and part == "FLAGS": + flagChanges[messageId] = values["FLAGS"] + # Find flags in the result and get rid of them. + for i in range(len(info[messageId][0])): + if info[messageId][0][i] == "FLAGS": + del info[messageId][0][i : i + 2] + break + del values["FLAGS"] + if not values: + del results[messageId] + + if flagChanges: + self.flagsChanged(flagChanges) + + if structured: + return results + else: + return info + + def fetchSpecific( + self, + messages, + uid=0, + headerType=None, + headerNumber=None, + headerArgs=None, + peek=None, + offset=None, + length=None, + ): + """ + Retrieve a specific section of one or more messages + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @type headerType: L{str} + @param headerType: If specified, must be one of HEADER, HEADER.FIELDS, + HEADER.FIELDS.NOT, MIME, or TEXT, and will determine which part of + the message is retrieved. For HEADER.FIELDS and HEADER.FIELDS.NOT, + C{headerArgs} must be a sequence of header names. For MIME, + C{headerNumber} must be specified. + + @type headerNumber: L{int} or L{int} sequence + @param headerNumber: The nested rfc822 index specifying the entity to + retrieve. For example, C{1} retrieves the first entity of the + message, and C{(2, 1, 3}) retrieves the 3rd entity inside the first + entity inside the second entity of the message. + + @type headerArgs: A sequence of L{str} + @param headerArgs: If C{headerType} is HEADER.FIELDS, these are the + headers to retrieve. If it is HEADER.FIELDS.NOT, these are the + headers to exclude from retrieval. + + @type peek: C{bool} + @param peek: If true, cause the server to not set the \\Seen flag on + this message as a result of this command. + + @type offset: L{int} + @param offset: The number of octets at the beginning of the result to + skip. + + @type length: L{int} + @param length: The number of octets to retrieve. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a mapping of message + numbers to retrieved data, or whose errback is invoked if there is + an error. + """ + fmt = "%s BODY%s[%s%s%s]%s" + if headerNumber is None: + number = "" + elif isinstance(headerNumber, int): + number = str(headerNumber) + else: + number = ".".join(map(str, headerNumber)) + if headerType is None: + header = "" + elif number: + header = "." + headerType + else: + header = headerType + if header and headerType in ("HEADER.FIELDS", "HEADER.FIELDS.NOT"): + if headerArgs is not None: + payload = " (%s)" % " ".join(headerArgs) + else: + payload = " ()" + else: + payload = "" + if offset is None: + extra = "" + else: + extra = "<%d.%d>" % (offset, length) + fetch = uid and b"UID FETCH" or b"FETCH" + cmd = fmt % (messages, peek and ".PEEK" or "", number, header, payload, extra) + + # APPEND components should be encoded as ASCII unless a + # charset identifier is provided. See #9201. + cmd = cmd.encode("charmap") + + d = self.sendCommand(Command(fetch, cmd, wantResponse=(b"FETCH",))) + d.addCallback(self._cbFetch, (), False) + return d + + def _fetch(self, messages, useUID=0, **terms): + messages = str(messages).encode("ascii") + fetch = useUID and b"UID FETCH" or b"FETCH" + + if "rfc822text" in terms: + del terms["rfc822text"] + terms["rfc822.text"] = True + if "rfc822size" in terms: + del terms["rfc822size"] + terms["rfc822.size"] = True + if "rfc822header" in terms: + del terms["rfc822header"] + terms["rfc822.header"] = True + + # The terms in 6.4.5 are all ASCII congruent, so wing it. + # Note that this isn't a public API, so terms in responses + # should not be decoded to native strings. + encodedTerms = [networkString(s) for s in terms] + cmd = messages + b" (" + b" ".join([s.upper() for s in encodedTerms]) + b")" + + d = self.sendCommand(Command(fetch, cmd, wantResponse=(b"FETCH",))) + d.addCallback(self._cbFetch, [t.upper() for t in terms.keys()], True) + return d + + def setFlags(self, messages, flags, silent=1, uid=0): + """ + Set the flags for one or more messages. + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type flags: Any iterable of L{str} + @param flags: The flags to set + + @type silent: L{bool} + @param silent: If true, cause the server to suppress its verbose + response. + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a list of the + server's responses (C{[]} if C{silent} is true) or whose + errback is invoked if there is an error. + """ + return self._store(messages, b"FLAGS", silent, flags, uid) + + def addFlags(self, messages, flags, silent=1, uid=0): + """ + Add to the set flags for one or more messages. + + This command is allowed in the Selected state. + + @type messages: C{MessageSet} or L{str} + @param messages: A message sequence set + + @type flags: Any iterable of L{str} + @param flags: The flags to set + + @type silent: C{bool} + @param silent: If true, cause the server to suppress its verbose + response. + + @type uid: C{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a list of the + server's responses (C{[]} if C{silent} is true) or whose + errback is invoked if there is an error. + """ + return self._store(messages, b"+FLAGS", silent, flags, uid) + + def removeFlags(self, messages, flags, silent=1, uid=0): + """ + Remove from the set flags for one or more messages. + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type flags: Any iterable of L{str} + @param flags: The flags to set + + @type silent: L{bool} + @param silent: If true, cause the server to suppress its verbose + response. + + @type uid: L{bool} + @param uid: Indicates whether the message sequence set is of message + numbers or of unique message IDs. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a list of the + server's responses (C{[]} if C{silent} is true) or whose + errback is invoked if there is an error. + """ + return self._store(messages, b"-FLAGS", silent, flags, uid) + + def _store(self, messages, cmd, silent, flags, uid): + messages = str(messages).encode("ascii") + encodedFlags = [networkString(flag) for flag in flags] + if silent: + cmd = cmd + b".SILENT" + store = uid and b"UID STORE" or b"STORE" + args = b" ".join((messages, cmd, b"(" + b" ".join(encodedFlags) + b")")) + d = self.sendCommand(Command(store, args, wantResponse=(b"FETCH",))) + expected = () + if not silent: + expected = ("FLAGS",) + d.addCallback(self._cbFetch, expected, True) + return d + + def copy(self, messages, mailbox, uid): + """ + Copy the specified messages to the specified mailbox. + + This command is allowed in the Selected state. + + @type messages: L{MessageSet} or L{str} + @param messages: A message sequence set + + @type mailbox: L{str} + @param mailbox: The mailbox to which to copy the messages + + @type uid: C{bool} + @param uid: If true, the C{messages} refers to message UIDs, rather + than message sequence numbers. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with a true value + when the copy is successful, or whose errback is invoked if there + is an error. + """ + messages = str(messages).encode("ascii") + if uid: + cmd = b"UID COPY" + else: + cmd = b"COPY" + args = b" ".join([messages, _prepareMailboxName(mailbox)]) + return self.sendCommand(Command(cmd, args)) + + # + # IMailboxListener methods + # + def modeChanged(self, writeable): + """Override me""" + + def flagsChanged(self, newFlags): + """Override me""" + + def newMessages(self, exists, recent): + """Override me""" + + +def parseIdList(s, lastMessageId=None): + """ + Parse a message set search key into a C{MessageSet}. + + @type s: L{bytes} + @param s: A string description of an id list, for example "1:3, 4:*" + + @type lastMessageId: L{int} + @param lastMessageId: The last message sequence id or UID, depending on + whether we are parsing the list in UID or sequence id context. The + caller should pass in the correct value. + + @rtype: C{MessageSet} + @return: A C{MessageSet} that contains the ids defined in the list + """ + res = MessageSet() + parts = s.split(b",") + for p in parts: + if b":" in p: + low, high = p.split(b":", 1) + try: + if low == b"*": + low = None + else: + low = int(low) + if high == b"*": + high = None + else: + high = int(high) + if low is high is None: + # *:* does not make sense + raise IllegalIdentifierError(p) + # non-positive values are illegal according to RFC 3501 + if (low is not None and low <= 0) or (high is not None and high <= 0): + raise IllegalIdentifierError(p) + # star means "highest value of an id in the mailbox" + high = high or lastMessageId + low = low or lastMessageId + + res.add(low, high) + except ValueError: + raise IllegalIdentifierError(p) + else: + try: + if p == b"*": + p = None + else: + p = int(p) + if p is not None and p <= 0: + raise IllegalIdentifierError(p) + except ValueError: + raise IllegalIdentifierError(p) + else: + res.extend(p or lastMessageId) + return res + + +_SIMPLE_BOOL = ( + "ALL", + "ANSWERED", + "DELETED", + "DRAFT", + "FLAGGED", + "NEW", + "OLD", + "RECENT", + "SEEN", + "UNANSWERED", + "UNDELETED", + "UNDRAFT", + "UNFLAGGED", + "UNSEEN", +) + +_NO_QUOTES = ("LARGER", "SMALLER", "UID") + +_sorted = sorted + + +def Query(sorted=0, **kwarg): + """ + Create a query string + + Among the accepted keywords are:: + + all : If set to a true value, search all messages in the + current mailbox + + answered : If set to a true value, search messages flagged with + \\Answered + + bcc : A substring to search the BCC header field for + + before : Search messages with an internal date before this + value. The given date should be a string in the format + of 'DD-Mon-YYYY'. For example, '03-Mar-2003'. + + body : A substring to search the body of the messages for + + cc : A substring to search the CC header field for + + deleted : If set to a true value, search messages flagged with + \\Deleted + + draft : If set to a true value, search messages flagged with + \\Draft + + flagged : If set to a true value, search messages flagged with + \\Flagged + + from : A substring to search the From header field for + + header : A two-tuple of a header name and substring to search + for in that header + + keyword : Search for messages with the given keyword set + + larger : Search for messages larger than this number of octets + + messages : Search only the given message sequence set. + + new : If set to a true value, search messages flagged with + \\Recent but not \\Seen + + old : If set to a true value, search messages not flagged with + \\Recent + + on : Search messages with an internal date which is on this + date. The given date should be a string in the format + of 'DD-Mon-YYYY'. For example, '03-Mar-2003'. + + recent : If set to a true value, search for messages flagged with + \\Recent + + seen : If set to a true value, search for messages flagged with + \\Seen + + sentbefore : Search for messages with an RFC822 'Date' header before + this date. The given date should be a string in the format + of 'DD-Mon-YYYY'. For example, '03-Mar-2003'. + + senton : Search for messages with an RFC822 'Date' header which is + on this date The given date should be a string in the format + of 'DD-Mon-YYYY'. For example, '03-Mar-2003'. + + sentsince : Search for messages with an RFC822 'Date' header which is + after this date. The given date should be a string in the format + of 'DD-Mon-YYYY'. For example, '03-Mar-2003'. + + since : Search for messages with an internal date that is after + this date.. The given date should be a string in the format + of 'DD-Mon-YYYY'. For example, '03-Mar-2003'. + + smaller : Search for messages smaller than this number of octets + + subject : A substring to search the 'subject' header for + + text : A substring to search the entire message for + + to : A substring to search the 'to' header for + + uid : Search only the messages in the given message set + + unanswered : If set to a true value, search for messages not + flagged with \\Answered + + undeleted : If set to a true value, search for messages not + flagged with \\Deleted + + undraft : If set to a true value, search for messages not + flagged with \\Draft + + unflagged : If set to a true value, search for messages not + flagged with \\Flagged + + unkeyword : Search for messages without the given keyword set + + unseen : If set to a true value, search for messages not + flagged with \\Seen + + @type sorted: C{bool} + @param sorted: If true, the output will be sorted, alphabetically. + The standard does not require it, but it makes testing this function + easier. The default is zero, and this should be acceptable for any + application. + + @rtype: L{str} + @return: The formatted query string + """ + cmd = [] + keys = kwarg.keys() + if sorted: + keys = _sorted(keys) + for k in keys: + v = kwarg[k] + k = k.upper() + if k in _SIMPLE_BOOL and v: + cmd.append(k) + elif k == "HEADER": + cmd.extend([k, str(v[0]), str(v[1])]) + elif k == "KEYWORD" or k == "UNKEYWORD": + # Discard anything that does not fit into an "atom". Perhaps turn + # the case where this actually removes bytes from the value into a + # warning and then an error, eventually. See #6277. + v = _nonAtomRE.sub("", v) + cmd.extend([k, v]) + elif k not in _NO_QUOTES: + if isinstance(v, MessageSet): + fmt = '"%s"' + elif isinstance(v, str): + fmt = '"%s"' + else: + fmt = '"%d"' + cmd.extend([k, fmt % (v,)]) + elif isinstance(v, int): + cmd.extend([k, "%d" % (v,)]) + else: + cmd.extend([k, f"{v}"]) + if len(cmd) > 1: + return "(" + " ".join(cmd) + ")" + else: + return " ".join(cmd) + + +def Or(*args): + """ + The disjunction of two or more queries + """ + if len(args) < 2: + raise IllegalQueryError(args) + elif len(args) == 2: + return "(OR %s %s)" % args + else: + return f"(OR {args[0]} {Or(*args[1:])})" + + +def Not(query): + """The negation of a query""" + return f"(NOT {query})" + + +def wildcardToRegexp(wildcard, delim=None): + wildcard = wildcard.replace("*", "(?:.*?)") + if delim is None: + wildcard = wildcard.replace("%", "(?:.*?)") + else: + wildcard = wildcard.replace("%", "(?:(?:[^%s])*?)" % re.escape(delim)) + return re.compile(wildcard, re.I) + + +def splitQuoted(s): + """ + Split a string into whitespace delimited tokens + + Tokens that would otherwise be separated but are surrounded by \" + remain as a single token. Any token that is not quoted and is + equal to \"NIL\" is tokenized as L{None}. + + @type s: L{bytes} + @param s: The string to be split + + @rtype: L{list} of L{bytes} + @return: A list of the resulting tokens + + @raise MismatchedQuoting: Raised if an odd number of quotes are present + """ + s = s.strip() + result = [] + word = [] + inQuote = inWord = False + qu = _matchingString('"', s) + esc = _matchingString("\x5c", s) + empty = _matchingString("", s) + nil = _matchingString("NIL", s) + for i, c in enumerate(iterbytes(s)): + if c == qu: + if i and s[i - 1 : i] == esc: + word.pop() + word.append(qu) + elif not inQuote: + inQuote = True + else: + inQuote = False + result.append(empty.join(word)) + word = [] + elif ( + not inWord + and not inQuote + and c not in (qu + (string.whitespace.encode("ascii"))) + ): + inWord = True + word.append(c) + elif inWord and not inQuote and c in string.whitespace.encode("ascii"): + w = empty.join(word) + if w == nil: + result.append(None) + else: + result.append(w) + word = [] + inWord = False + elif inWord or inQuote: + word.append(c) + + if inQuote: + raise MismatchedQuoting(s) + if inWord: + w = empty.join(word) + if w == nil: + result.append(None) + else: + result.append(w) + + return result + + +def splitOn(sequence, predicate, transformers): + result = [] + mode = predicate(sequence[0]) + tmp = [sequence[0]] + for e in sequence[1:]: + p = predicate(e) + if p != mode: + result.extend(transformers[mode](tmp)) + tmp = [e] + mode = p + else: + tmp.append(e) + result.extend(transformers[mode](tmp)) + return result + + +def collapseStrings(results): + """ + Turns a list of length-one strings and lists into a list of longer + strings and lists. For example, + + ['a', 'b', ['c', 'd']] is returned as ['ab', ['cd']] + + @type results: L{list} of L{bytes} and L{list} + @param results: The list to be collapsed + + @rtype: L{list} of L{bytes} and L{list} + @return: A new list which is the collapsed form of C{results} + """ + copy = [] + begun = None + + pred = lambda e: isinstance(e, tuple) + tran = { + 0: lambda e: splitQuoted(b"".join(e)), + 1: lambda e: [b"".join([i[0] for i in e])], + } + for i, c in enumerate(results): + if isinstance(c, list): + if begun is not None: + copy.extend(splitOn(results[begun:i], pred, tran)) + begun = None + copy.append(collapseStrings(c)) + elif begun is None: + begun = i + if begun is not None: + copy.extend(splitOn(results[begun:], pred, tran)) + return copy + + +def parseNestedParens(s, handleLiteral=1): + """ + Parse an s-exp-like string into a more useful data structure. + + @type s: L{bytes} + @param s: The s-exp-like string to parse + + @rtype: L{list} of L{bytes} and L{list} + @return: A list containing the tokens present in the input. + + @raise MismatchedNesting: Raised if the number or placement + of opening or closing parenthesis is invalid. + """ + s = s.strip() + inQuote = 0 + contentStack = [[]] + try: + i = 0 + L = len(s) + while i < L: + c = s[i : i + 1] + if inQuote: + if c == b"\\": + contentStack[-1].append(s[i : i + 2]) + i += 2 + continue + elif c == b'"': + inQuote = not inQuote + contentStack[-1].append(c) + i += 1 + else: + if c == b'"': + contentStack[-1].append(c) + inQuote = not inQuote + i += 1 + elif handleLiteral and c == b"{": + end = s.find(b"}", i) + if end == -1: + raise ValueError("Malformed literal") + literalSize = int(s[i + 1 : end]) + contentStack[-1].append((s[end + 3 : end + 3 + literalSize],)) + i = end + 3 + literalSize + elif c == b"(" or c == b"[": + contentStack.append([]) + i += 1 + elif c == b")" or c == b"]": + contentStack[-2].append(contentStack.pop()) + i += 1 + else: + contentStack[-1].append(c) + i += 1 + except IndexError: + raise MismatchedNesting(s) + if len(contentStack) != 1: + raise MismatchedNesting(s) + return collapseStrings(contentStack[0]) + + +def _quote(s): + qu = _matchingString('"', s) + esc = _matchingString("\x5c", s) + return qu + s.replace(esc, esc + esc).replace(qu, esc + qu) + qu + + +def _literal(s: bytes) -> bytes: + return b"{%d}\r\n%b" % (len(s), s) + + +class DontQuoteMe: + def __init__(self, value): + self.value = value + + def __str__(self) -> str: + return str(self.value) + + +_ATOM_SPECIALS = b'(){ %*"' + + +def _needsQuote(s): + if s == b"": + return 1 + for c in iterbytes(s): + if c < b"\x20" or c > b"\x7f": + return 1 + if c in _ATOM_SPECIALS: + return 1 + return 0 + + +def _parseMbox(name): + if isinstance(name, str): + return name + try: + return name.decode("imap4-utf-7") + except BaseException: + log.err() + raise IllegalMailboxEncoding(name) + + +def _prepareMailboxName(name): + if not isinstance(name, str): + name = name.decode("charmap") + name = name.encode("imap4-utf-7") + if _needsQuote(name): + return _quote(name) + return name + + +def _needsLiteral(s): + # change this to "return 1" to wig out stupid clients + cr = _matchingString("\n", s) + lf = _matchingString("\r", s) + return cr in s or lf in s or len(s) > 1000 + + +def collapseNestedLists(items): + """ + Turn a nested list structure into an s-exp-like string. + + Strings in C{items} will be sent as literals if they contain CR or LF, + otherwise they will be quoted. References to None in C{items} will be + translated to the atom NIL. Objects with a 'read' attribute will have + it called on them with no arguments and the returned string will be + inserted into the output as a literal. Integers will be converted to + strings and inserted into the output unquoted. Instances of + C{DontQuoteMe} will be converted to strings and inserted into the output + unquoted. + + This function used to be much nicer, and only quote things that really + needed to be quoted (and C{DontQuoteMe} did not exist), however, many + broken IMAP4 clients were unable to deal with this level of sophistication, + forcing the current behavior to be adopted for practical reasons. + + @type items: Any iterable + + @rtype: L{str} + """ + pieces = [] + for i in items: + if isinstance(i, str): + # anything besides ASCII will have to wait for an RFC 5738 + # implementation. See + # https://twistedmatrix.com/trac/ticket/9258 + i = i.encode("ascii") + if i is None: + pieces.extend([b" ", b"NIL"]) + elif isinstance(i, int): + pieces.extend([b" ", networkString(str(i))]) + elif isinstance(i, DontQuoteMe): + pieces.extend([b" ", i.value]) + elif isinstance(i, bytes): + # XXX warning + if _needsLiteral(i): + pieces.extend([b" ", b"{%d}" % (len(i),), IMAP4Server.delimiter, i]) + else: + pieces.extend([b" ", _quote(i)]) + elif hasattr(i, "read"): + d = i.read() + pieces.extend([b" ", b"{%d}" % (len(d),), IMAP4Server.delimiter, d]) + else: + pieces.extend([b" ", b"(" + collapseNestedLists(i) + b")"]) + return b"".join(pieces[1:]) + + +@implementer(IAccount) +class MemoryAccountWithoutNamespaces: + mailboxes = None + subscriptions = None + top_id = 0 + + def __init__(self, name): + self.name = name + self.mailboxes = {} + self.subscriptions = [] + + def allocateID(self): + id = self.top_id + self.top_id += 1 + return id + + ## + ## IAccount + ## + def addMailbox(self, name, mbox=None): + name = _parseMbox(name.upper()) + if name in self.mailboxes: + raise MailboxCollision(name) + if mbox is None: + mbox = self._emptyMailbox(name, self.allocateID()) + self.mailboxes[name] = mbox + return 1 + + def create(self, pathspec): + paths = [path for path in pathspec.split("/") if path] + for accum in range(1, len(paths)): + try: + self.addMailbox("/".join(paths[:accum])) + except MailboxCollision: + pass + try: + self.addMailbox("/".join(paths)) + except MailboxCollision: + if not pathspec.endswith("/"): + return False + return True + + def _emptyMailbox(self, name, id): + raise NotImplementedError + + def select(self, name, readwrite=1): + return self.mailboxes.get(_parseMbox(name.upper())) + + def delete(self, name): + name = _parseMbox(name.upper()) + # See if this mailbox exists at all + mbox = self.mailboxes.get(name) + if not mbox: + raise MailboxException("No such mailbox") + # See if this box is flagged \Noselect + if r"\Noselect" in mbox.getFlags(): + # Check for hierarchically inferior mailboxes with this one + # as part of their root. + for others in self.mailboxes.keys(): + if others != name and others.startswith(name): + raise MailboxException( + "Hierarchically inferior mailboxes exist and \\Noselect is set" + ) + mbox.destroy() + + # iff there are no hierarchically inferior names, we will + # delete it from our ken. + if len(self._inferiorNames(name)) > 1: + raise MailboxException(f'Name "{name}" has inferior hierarchical names') + del self.mailboxes[name] + + def rename(self, oldname, newname): + oldname = _parseMbox(oldname.upper()) + newname = _parseMbox(newname.upper()) + if oldname not in self.mailboxes: + raise NoSuchMailbox(oldname) + + inferiors = self._inferiorNames(oldname) + inferiors = [(o, o.replace(oldname, newname, 1)) for o in inferiors] + + for old, new in inferiors: + if new in self.mailboxes: + raise MailboxCollision(new) + + for old, new in inferiors: + self.mailboxes[new] = self.mailboxes[old] + del self.mailboxes[old] + + def _inferiorNames(self, name): + inferiors = [] + for infname in self.mailboxes.keys(): + if infname.startswith(name): + inferiors.append(infname) + return inferiors + + def isSubscribed(self, name): + return _parseMbox(name.upper()) in self.subscriptions + + def subscribe(self, name): + name = _parseMbox(name.upper()) + if name not in self.subscriptions: + self.subscriptions.append(name) + + def unsubscribe(self, name): + name = _parseMbox(name.upper()) + if name not in self.subscriptions: + raise MailboxException(f"Not currently subscribed to {name}") + self.subscriptions.remove(name) + + def listMailboxes(self, ref, wildcard): + ref = self._inferiorNames(_parseMbox(ref.upper())) + wildcard = wildcardToRegexp(wildcard, "/") + return [(i, self.mailboxes[i]) for i in ref if wildcard.match(i)] + + +@implementer(INamespacePresenter) +class MemoryAccount(MemoryAccountWithoutNamespaces): + ## + ## INamespacePresenter + ## + def getPersonalNamespaces(self): + return [[b"", b"/"]] + + def getSharedNamespaces(self): + return None + + def getOtherNamespaces(self): + return None + + def getUserNamespaces(self): + # INamespacePresenter.getUserNamespaces + return None + + +_statusRequestDict = { + "MESSAGES": "getMessageCount", + "RECENT": "getRecentCount", + "UIDNEXT": "getUIDNext", + "UIDVALIDITY": "getUIDValidity", + "UNSEEN": "getUnseenCount", +} + + +def statusRequestHelper(mbox, names): + r = {} + for n in names: + r[n] = getattr(mbox, _statusRequestDict[n.upper()])() + return r + + +def parseAddr(addr): + if addr is None: + return [ + (None, None, None), + ] + addr = email.utils.getaddresses([addr]) + return [[fn or None, None] + address.split("@") for fn, address in addr] + + +def getEnvelope(msg): + headers = msg.getHeaders(True) + date = headers.get("date") + subject = headers.get("subject") + from_ = headers.get("from") + sender = headers.get("sender", from_) + reply_to = headers.get("reply-to", from_) + to = headers.get("to") + cc = headers.get("cc") + bcc = headers.get("bcc") + in_reply_to = headers.get("in-reply-to") + mid = headers.get("message-id") + return ( + date, + subject, + parseAddr(from_), + parseAddr(sender), + reply_to and parseAddr(reply_to), + to and parseAddr(to), + cc and parseAddr(cc), + bcc and parseAddr(bcc), + in_reply_to, + mid, + ) + + +def getLineCount(msg): + # XXX - Super expensive, CACHE THIS VALUE FOR LATER RE-USE + # XXX - This must be the number of lines in the ENCODED version + lines = 0 + for _ in msg.getBodyFile(): + lines += 1 + return lines + + +def unquote(s): + if s[0] == s[-1] == '"': + return s[1:-1] + return s + + +def _getContentType(msg): + """ + Return a two-tuple of the main and subtype of the given message. + """ + attrs = None + mm = msg.getHeaders(False, "content-type").get("content-type", "") + mm = "".join(mm.splitlines()) + if mm: + mimetype = mm.split(";") + type = mimetype[0].split("/", 1) + if len(type) == 1: + major = type[0] + minor = None + else: + # length must be 2, because of split('/', 1) + major, minor = type + attrs = dict(x.strip().lower().split("=", 1) for x in mimetype[1:]) + else: + major = minor = None + return major, minor, attrs + + +def _getMessageStructure(message): + """ + Construct an appropriate type of message structure object for the given + message object. + + @param message: A L{IMessagePart} provider + + @return: A L{_MessageStructure} instance of the most specific type available + for the given message, determined by inspecting the MIME type of the + message. + """ + main, subtype, attrs = _getContentType(message) + if main is not None: + main = main.lower() + if subtype is not None: + subtype = subtype.lower() + if main == "multipart": + return _MultipartMessageStructure(message, subtype, attrs) + elif (main, subtype) == ("message", "rfc822"): + return _RFC822MessageStructure(message, main, subtype, attrs) + elif main == "text": + return _TextMessageStructure(message, main, subtype, attrs) + else: + return _SinglepartMessageStructure(message, main, subtype, attrs) + + +class _MessageStructure: + """ + L{_MessageStructure} is a helper base class for message structure classes + representing the structure of particular kinds of messages, as defined by + their MIME type. + """ + + def __init__(self, message, attrs): + """ + @param message: An L{IMessagePart} provider which this structure object + reports on. + + @param attrs: A C{dict} giving the parameters of the I{Content-Type} + header of the message. + """ + self.message = message + self.attrs = attrs + + def _disposition(self, disp): + """ + Parse a I{Content-Disposition} header into a two-sequence of the + disposition and a flattened list of its parameters. + + @return: L{None} if there is no disposition header value, a L{list} with + two elements otherwise. + """ + if disp: + disp = disp.split("; ") + if len(disp) == 1: + disp = (disp[0].lower(), None) + elif len(disp) > 1: + # XXX Poorly tested parser + params = [x for param in disp[1:] for x in param.split("=", 1)] + disp = [disp[0].lower(), params] + return disp + else: + return None + + def _unquotedAttrs(self): + """ + @return: The I{Content-Type} parameters, unquoted, as a flat list with + each Nth element giving a parameter name and N+1th element giving + the corresponding parameter value. + """ + if self.attrs: + unquoted = [(k, unquote(v)) for (k, v) in self.attrs.items()] + return [y for x in sorted(unquoted) for y in x] + return None + + +class _SinglepartMessageStructure(_MessageStructure): + """ + L{_SinglepartMessageStructure} represents the message structure of a + non-I{multipart/*} message. + """ + + _HEADERS = ["content-id", "content-description", "content-transfer-encoding"] + + def __init__(self, message, main, subtype, attrs): + """ + @param message: An L{IMessagePart} provider which this structure object + reports on. + + @param main: A L{str} giving the main MIME type of the message (for + example, C{"text"}). + + @param subtype: A L{str} giving the MIME subtype of the message (for + example, C{"plain"}). + + @param attrs: A C{dict} giving the parameters of the I{Content-Type} + header of the message. + """ + _MessageStructure.__init__(self, message, attrs) + self.main = main + self.subtype = subtype + self.attrs = attrs + + def _basicFields(self): + """ + Return a list of the basic fields for a single-part message. + """ + headers = self.message.getHeaders(False, *self._HEADERS) + + # Number of octets total + size = self.message.getSize() + + major, minor = self.main, self.subtype + + # content-type parameter list + unquotedAttrs = self._unquotedAttrs() + + return [ + major, + minor, + unquotedAttrs, + headers.get("content-id"), + headers.get("content-description"), + headers.get("content-transfer-encoding"), + size, + ] + + def encode(self, extended): + """ + Construct and return a list of the basic and extended fields for a + single-part message. The list suitable to be encoded into a BODY or + BODYSTRUCTURE response. + """ + result = self._basicFields() + if extended: + result.extend(self._extended()) + return result + + def _extended(self): + """ + The extension data of a non-multipart body part are in the + following order: + + 1. body MD5 + + A string giving the body MD5 value as defined in [MD5]. + + 2. body disposition + + A parenthesized list with the same content and function as + the body disposition for a multipart body part. + + 3. body language + + A string or parenthesized list giving the body language + value as defined in [LANGUAGE-TAGS]. + + 4. body location + + A string list giving the body content URI as defined in + [LOCATION]. + + """ + result = [] + headers = self.message.getHeaders( + False, + "content-md5", + "content-disposition", + "content-language", + "content-language", + ) + + result.append(headers.get("content-md5")) + result.append(self._disposition(headers.get("content-disposition"))) + result.append(headers.get("content-language")) + result.append(headers.get("content-location")) + + return result + + +class _TextMessageStructure(_SinglepartMessageStructure): + """ + L{_TextMessageStructure} represents the message structure of a I{text/*} + message. + """ + + def encode(self, extended): + """ + A body type of type TEXT contains, immediately after the basic + fields, the size of the body in text lines. Note that this + size is the size in its content transfer encoding and not the + resulting size after any decoding. + """ + result = _SinglepartMessageStructure._basicFields(self) + result.append(getLineCount(self.message)) + if extended: + result.extend(self._extended()) + return result + + +class _RFC822MessageStructure(_SinglepartMessageStructure): + """ + L{_RFC822MessageStructure} represents the message structure of a + I{message/rfc822} message. + """ + + def encode(self, extended): + """ + A body type of type MESSAGE and subtype RFC822 contains, + immediately after the basic fields, the envelope structure, + body structure, and size in text lines of the encapsulated + message. + """ + result = _SinglepartMessageStructure.encode(self, extended) + contained = self.message.getSubPart(0) + result.append(getEnvelope(contained)) + result.append(getBodyStructure(contained, False)) + result.append(getLineCount(contained)) + return result + + +class _MultipartMessageStructure(_MessageStructure): + """ + L{_MultipartMessageStructure} represents the message structure of a + I{multipart/*} message. + """ + + def __init__(self, message, subtype, attrs): + """ + @param message: An L{IMessagePart} provider which this structure object + reports on. + + @param subtype: A L{str} giving the MIME subtype of the message (for + example, C{"plain"}). + + @param attrs: A C{dict} giving the parameters of the I{Content-Type} + header of the message. + """ + _MessageStructure.__init__(self, message, attrs) + self.subtype = subtype + + def _getParts(self): + """ + Return an iterator over all of the sub-messages of this message. + """ + i = 0 + while True: + try: + part = self.message.getSubPart(i) + except IndexError: + break + else: + yield part + i += 1 + + def encode(self, extended): + """ + Encode each sub-message and added the additional I{multipart} fields. + """ + result = [_getMessageStructure(p).encode(extended) for p in self._getParts()] + result.append(self.subtype) + if extended: + result.extend(self._extended()) + return result + + def _extended(self): + """ + The extension data of a multipart body part are in the following order: + + 1. body parameter parenthesized list + A parenthesized list of attribute/value pairs [e.g., ("foo" + "bar" "baz" "rag") where "bar" is the value of "foo", and + "rag" is the value of "baz"] as defined in [MIME-IMB]. + + 2. body disposition + A parenthesized list, consisting of a disposition type + string, followed by a parenthesized list of disposition + attribute/value pairs as defined in [DISPOSITION]. + + 3. body language + A string or parenthesized list giving the body language + value as defined in [LANGUAGE-TAGS]. + + 4. body location + A string list giving the body content URI as defined in + [LOCATION]. + """ + result = [] + headers = self.message.getHeaders( + False, "content-language", "content-location", "content-disposition" + ) + + result.append(self._unquotedAttrs()) + result.append(self._disposition(headers.get("content-disposition"))) + result.append(headers.get("content-language", None)) + result.append(headers.get("content-location", None)) + + return result + + +def getBodyStructure(msg, extended=False): + """ + RFC 3501, 7.4.2, BODYSTRUCTURE:: + + A parenthesized list that describes the [MIME-IMB] body structure of a + message. This is computed by the server by parsing the [MIME-IMB] header + fields, defaulting various fields as necessary. + + For example, a simple text message of 48 lines and 2279 octets can have + a body structure of: ("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL + "7BIT" 2279 48) + + This is represented as:: + + ["TEXT", "PLAIN", ["CHARSET", "US-ASCII"], None, None, "7BIT", 2279, 48] + + These basic fields are documented in the RFC as: + + 1. body type + + A string giving the content media type name as defined in + [MIME-IMB]. + + 2. body subtype + + A string giving the content subtype name as defined in + [MIME-IMB]. + + 3. body parameter parenthesized list + + A parenthesized list of attribute/value pairs [e.g., ("foo" + "bar" "baz" "rag") where "bar" is the value of "foo" and + "rag" is the value of "baz"] as defined in [MIME-IMB]. + + 4. body id + + A string giving the content id as defined in [MIME-IMB]. + + 5. body description + + A string giving the content description as defined in + [MIME-IMB]. + + 6. body encoding + + A string giving the content transfer encoding as defined in + [MIME-IMB]. + + 7. body size + + A number giving the size of the body in octets. Note that this size is + the size in its transfer encoding and not the resulting size after any + decoding. + + Put another way, the body structure is a list of seven elements. The + semantics of the elements of this list are: + + 1. Byte string giving the major MIME type + 2. Byte string giving the minor MIME type + 3. A list giving the Content-Type parameters of the message + 4. A byte string giving the content identifier for the message part, or + None if it has no content identifier. + 5. A byte string giving the content description for the message part, or + None if it has no content description. + 6. A byte string giving the Content-Encoding of the message body + 7. An integer giving the number of octets in the message body + + The RFC goes on:: + + Multiple parts are indicated by parenthesis nesting. Instead of a body + type as the first element of the parenthesized list, there is a sequence + of one or more nested body structures. The second element of the + parenthesized list is the multipart subtype (mixed, digest, parallel, + alternative, etc.). + + For example, a two part message consisting of a text and a + BASE64-encoded text attachment can have a body structure of: (("TEXT" + "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 1152 23)("TEXT" "PLAIN" + ("CHARSET" "US-ASCII" "NAME" "cc.diff") + "<960723163407.20117h@cac.washington.edu>" "Compiler diff" "BASE64" 4554 + 73) "MIXED") + + This is represented as:: + + [["TEXT", "PLAIN", ["CHARSET", "US-ASCII"], None, None, "7BIT", 1152, + 23], + ["TEXT", "PLAIN", ["CHARSET", "US-ASCII", "NAME", "cc.diff"], + "<960723163407.20117h@cac.washington.edu>", "Compiler diff", + "BASE64", 4554, 73], + "MIXED"] + + In other words, a list of N + 1 elements, where N is the number of parts in + the message. The first N elements are structures as defined by the previous + section. The last element is the minor MIME subtype of the multipart + message. + + Additionally, the RFC describes extension data:: + + Extension data follows the multipart subtype. Extension data is never + returned with the BODY fetch, but can be returned with a BODYSTRUCTURE + fetch. Extension data, if present, MUST be in the defined order. + + The C{extended} flag controls whether extension data might be returned with + the normal data. + """ + return _getMessageStructure(msg).encode(extended) + + +def _formatHeaders(headers): + # TODO: This should use email.header.Header, which handles encoding + hdrs = [ + ": ".join((k.title(), "\r\n".join(v.splitlines()))) + for (k, v) in headers.items() + ] + hdrs = "\r\n".join(hdrs) + "\r\n" + return networkString(hdrs) + + +def subparts(m): + i = 0 + try: + while True: + yield m.getSubPart(i) + i += 1 + except IndexError: + pass + + +def iterateInReactor(i): + """ + Consume an interator at most a single iteration per reactor iteration. + + If the iterator produces a Deferred, the next iteration will not occur + until the Deferred fires, otherwise the next iteration will be taken + in the next reactor iteration. + + @rtype: C{Deferred} + @return: A deferred which fires (with None) when the iterator is + exhausted or whose errback is called if there is an exception. + """ + from twisted.internet import reactor + + d = defer.Deferred() + + def go(last): + try: + r = next(i) + except StopIteration: + d.callback(last) + except BaseException: + d.errback() + else: + if isinstance(r, defer.Deferred): + r.addCallback(go) + else: + reactor.callLater(0, go, r) + + go(None) + return d + + +class MessageProducer: + CHUNK_SIZE = 2**2**2**2 + _uuid4 = staticmethod(uuid.uuid4) + + def __init__(self, msg, buffer=None, scheduler=None): + """ + Produce this message. + + @param msg: The message I am to produce. + @type msg: L{IMessage} + + @param buffer: A buffer to hold the message in. If None, I will + use a L{tempfile.TemporaryFile}. + @type buffer: file-like + """ + self.msg = msg + if buffer is None: + buffer = tempfile.TemporaryFile() + self.buffer = buffer + if scheduler is None: + scheduler = iterateInReactor + self.scheduler = scheduler + self.write = self.buffer.write + + def beginProducing(self, consumer): + self.consumer = consumer + return self.scheduler(self._produce()) + + def _produce(self): + headers = self.msg.getHeaders(True) + boundary = None + if self.msg.isMultipart(): + content = headers.get("content-type") + parts = [x.split("=", 1) for x in content.split(";")[1:]] + parts = {k.lower().strip(): v for (k, v) in parts} + boundary = parts.get("boundary") + if boundary is None: + # Bastards + boundary = f"----={self._uuid4().hex}" + headers["content-type"] += f'; boundary="{boundary}"' + else: + if boundary.startswith('"') and boundary.endswith('"'): + boundary = boundary[1:-1] + boundary = networkString(boundary) + + self.write(_formatHeaders(headers)) + self.write(b"\r\n") + if self.msg.isMultipart(): + for p in subparts(self.msg): + self.write(b"\r\n--" + boundary + b"\r\n") + yield MessageProducer(p, self.buffer, self.scheduler).beginProducing( + None + ) + self.write(b"\r\n--" + boundary + b"--\r\n") + else: + f = self.msg.getBodyFile() + while True: + b = f.read(self.CHUNK_SIZE) + if b: + self.buffer.write(b) + yield None + else: + break + if self.consumer: + self.buffer.seek(0, 0) + yield FileProducer(self.buffer).beginProducing(self.consumer).addCallback( + lambda _: self + ) + + +class _FetchParser: + class Envelope: + # Response should be a list of fields from the message: + # date, subject, from, sender, reply-to, to, cc, bcc, in-reply-to, + # and message-id. + # + # from, sender, reply-to, to, cc, and bcc are themselves lists of + # address information: + # personal name, source route, mailbox name, host name + # + # reply-to and sender must not be None. If not present in a message + # they should be defaulted to the value of the from field. + type = "envelope" + __str__ = lambda self: "envelope" + + class Flags: + type = "flags" + __str__ = lambda self: "flags" + + class InternalDate: + type = "internaldate" + __str__ = lambda self: "internaldate" + + class RFC822Header: + type = "rfc822header" + __str__ = lambda self: "rfc822.header" + + class RFC822Text: + type = "rfc822text" + __str__ = lambda self: "rfc822.text" + + class RFC822Size: + type = "rfc822size" + __str__ = lambda self: "rfc822.size" + + class RFC822: + type = "rfc822" + __str__ = lambda self: "rfc822" + + class UID: + type = "uid" + __str__ = lambda self: "uid" + + class Body: + type = "body" + peek = False + header = None + mime = None + text = None + part = () + empty = False + partialBegin = None + partialLength = None + + def __str__(self) -> str: + return self.__bytes__().decode("ascii") + + def __bytes__(self) -> bytes: + base = b"BODY" + part = b"" + separator = b"" + if self.part: + part = b".".join([str(x + 1).encode("ascii") for x in self.part]) # type: ignore[unreachable] + separator = b"." + # if self.peek: + # base += '.PEEK' + if self.header: + base += ( # type: ignore[unreachable] + b"[" + part + separator + str(self.header).encode("ascii") + b"]" + ) + elif self.text: + base += b"[" + part + separator + b"TEXT]" # type: ignore[unreachable] + elif self.mime: + base += b"[" + part + separator + b"MIME]" # type: ignore[unreachable] + elif self.empty: + base += b"[" + part + b"]" + if self.partialBegin is not None: + base += b"<%d.%d>" % (self.partialBegin, self.partialLength) # type: ignore[unreachable] + return base + + class BodyStructure: + type = "bodystructure" + __str__ = lambda self: "bodystructure" + + # These three aren't top-level, they don't need type indicators + class Header: + negate = False + fields = None + part = None + + def __str__(self) -> str: + return self.__bytes__().decode("ascii") + + def __bytes__(self) -> bytes: + base = b"HEADER" + if self.fields: + base += b".FIELDS" # type: ignore[unreachable] + if self.negate: + base += b".NOT" + fields = [] + for f in self.fields: + f = f.title() + if _needsQuote(f): + f = _quote(f) + fields.append(f) + base += b" (" + b" ".join(fields) + b")" + if self.part: + # TODO: _FetchParser never assigns Header.part - dead + # code? + base = b".".join([(x + 1).__bytes__() for x in self.part]) + b"." + base # type: ignore[unreachable] + return base + + class Text: + pass + + class MIME: + pass + + parts = None + + _simple_fetch_att = [ + (b"envelope", Envelope), + (b"flags", Flags), + (b"internaldate", InternalDate), + (b"rfc822.header", RFC822Header), + (b"rfc822.text", RFC822Text), + (b"rfc822.size", RFC822Size), + (b"rfc822", RFC822), + (b"uid", UID), + (b"bodystructure", BodyStructure), + ] + + def __init__(self): + self.state = ["initial"] + self.result = [] + self.remaining = b"" + + def parseString(self, s): + s = self.remaining + s + try: + while s or self.state: + if not self.state: + raise IllegalClientResponse("Invalid Argument") + # print 'Entering state_' + self.state[-1] + ' with', repr(s) + state = self.state.pop() + try: + used = getattr(self, "state_" + state)(s) + except BaseException: + self.state.append(state) + raise + else: + # print state, 'consumed', repr(s[:used]) + s = s[used:] + finally: + self.remaining = s + + def state_initial(self, s): + # In the initial state, the literals "ALL", "FULL", and "FAST" + # are accepted, as is a ( indicating the beginning of a fetch_att + # token, as is the beginning of a fetch_att token. + if s == b"": + return 0 + + l = s.lower() + if l.startswith(b"all"): + self.result.extend( + (self.Flags(), self.InternalDate(), self.RFC822Size(), self.Envelope()) + ) + return 3 + if l.startswith(b"full"): + self.result.extend( + ( + self.Flags(), + self.InternalDate(), + self.RFC822Size(), + self.Envelope(), + self.Body(), + ) + ) + return 4 + if l.startswith(b"fast"): + self.result.extend( + ( + self.Flags(), + self.InternalDate(), + self.RFC822Size(), + ) + ) + return 4 + + if l.startswith(b"("): + self.state.extend(("close_paren", "maybe_fetch_att", "fetch_att")) + return 1 + + self.state.append("fetch_att") + return 0 + + def state_close_paren(self, s): + if s.startswith(b")"): + return 1 + # TODO: does maybe_fetch_att's startswith(b')') make this dead + # code? + raise Exception("Missing )") + + def state_whitespace(self, s): + # Eat up all the leading whitespace + if not s or not s[0:1].isspace(): + raise Exception("Whitespace expected, none found") + i = 0 + for i in range(len(s)): + if not s[i : i + 1].isspace(): + break + return i + + def state_maybe_fetch_att(self, s): + if not s.startswith(b")"): + self.state.extend(("maybe_fetch_att", "fetch_att", "whitespace")) + return 0 + + def state_fetch_att(self, s): + # Allowed fetch_att tokens are "ENVELOPE", "FLAGS", "INTERNALDATE", + # "RFC822", "RFC822.HEADER", "RFC822.SIZE", "RFC822.TEXT", "BODY", + # "BODYSTRUCTURE", "UID", + # "BODY [".PEEK"] [<section>] ["<" <number> "." <nz_number> ">"] + + l = s.lower() + for name, cls in self._simple_fetch_att: + if l.startswith(name): + self.result.append(cls()) + return len(name) + + b = self.Body() + if l.startswith(b"body.peek"): + b.peek = True + used = 9 + elif l.startswith(b"body"): + used = 4 + else: + raise Exception(f"Nothing recognized in fetch_att: {l}") + + self.pending_body = b + self.state.extend(("got_body", "maybe_partial", "maybe_section")) + return used + + def state_got_body(self, s): + self.result.append(self.pending_body) + del self.pending_body + return 0 + + def state_maybe_section(self, s): + if not s.startswith(b"["): + return 0 + + self.state.extend(("section", "part_number")) + return 1 + + _partExpr = re.compile(rb"(\d+(?:\.\d+)*)\.?") + + def state_part_number(self, s): + m = self._partExpr.match(s) + if m is not None: + self.parts = [int(p) - 1 for p in m.groups()[0].split(b".")] + return m.end() + else: + self.parts = [] + return 0 + + def state_section(self, s): + # Grab "HEADER]" or "HEADER.FIELDS (Header list)]" or + # "HEADER.FIELDS.NOT (Header list)]" or "TEXT]" or "MIME]" or + # just "]". + + l = s.lower() + used = 0 + if l.startswith(b"]"): + self.pending_body.empty = True + used += 1 + elif l.startswith(b"header]"): + h = self.pending_body.header = self.Header() + h.negate = True + h.fields = () + used += 7 + elif l.startswith(b"text]"): + self.pending_body.text = self.Text() + used += 5 + elif l.startswith(b"mime]"): + self.pending_body.mime = self.MIME() + used += 5 + else: + h = self.Header() + if l.startswith(b"header.fields.not"): + h.negate = True + used += 17 + elif l.startswith(b"header.fields"): + used += 13 + else: + raise Exception(f"Unhandled section contents: {l!r}") + + self.pending_body.header = h + self.state.extend(("finish_section", "header_list", "whitespace")) + self.pending_body.part = tuple(self.parts) + self.parts = None + return used + + def state_finish_section(self, s): + if not s.startswith(b"]"): + raise Exception("section must end with ]") + return 1 + + def state_header_list(self, s): + if not s.startswith(b"("): + raise Exception("Header list must begin with (") + end = s.find(b")") + if end == -1: + raise Exception("Header list must end with )") + + headers = s[1:end].split() + self.pending_body.header.fields = [h.upper() for h in headers] + return end + 1 + + def state_maybe_partial(self, s): + # Grab <number.number> or nothing at all + if not s.startswith(b"<"): + return 0 + end = s.find(b">") + if end == -1: + raise Exception("Found < but not >") + + partial = s[1:end] + parts = partial.split(b".", 1) + if len(parts) != 2: + raise Exception( + "Partial specification did not include two .-delimited integers" + ) + begin, length = map(int, parts) + self.pending_body.partialBegin = begin + self.pending_body.partialLength = length + + return end + 1 + + +class FileProducer: + CHUNK_SIZE = 2**2**2**2 + + firstWrite = True + + def __init__(self, f): + self.f = f + + def beginProducing(self, consumer): + self.consumer = consumer + self.produce = consumer.write + d = self._onDone = defer.Deferred() + self.consumer.registerProducer(self, False) + return d + + def resumeProducing(self): + b = b"" + if self.firstWrite: + b = b"{%d}\r\n" % (self._size(),) + self.firstWrite = False + if not self.f: + return + b = b + self.f.read(self.CHUNK_SIZE) + if not b: + self.consumer.unregisterProducer() + self._onDone.callback(self) + self._onDone = self.f = self.consumer = None + else: + self.produce(b) + + def pauseProducing(self): + """ + Pause the producer. This does nothing. + """ + + def stopProducing(self): + """ + Stop the producer. This does nothing. + """ + + def _size(self): + b = self.f.tell() + self.f.seek(0, 2) + e = self.f.tell() + self.f.seek(b, 0) + return e - b + + +def parseTime(s): + # XXX - This may require localization :( + months = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", + ] + expr = { + "day": r"(?P<day>3[0-1]|[1-2]\d|0[1-9]|[1-9]| [1-9])", + "mon": r"(?P<mon>\w+)", + "year": r"(?P<year>\d\d\d\d)", + } + m = re.match("%(day)s-%(mon)s-%(year)s" % expr, s) + if not m: + raise ValueError(f"Cannot parse time string {s!r}") + d = m.groupdict() + try: + d["mon"] = 1 + (months.index(d["mon"].lower()) % 12) + d["year"] = int(d["year"]) + d["day"] = int(d["day"]) + except ValueError: + raise ValueError(f"Cannot parse time string {s!r}") + else: + return time.struct_time((d["year"], d["mon"], d["day"], 0, 0, 0, -1, -1, -1)) + + +# we need to cast Python >=3.3 memoryview to chars (from unsigned bytes), but +# cast is absent in previous versions: thus, the lambda returns the +# memoryview instance while ignoring the format +memory_cast = getattr(memoryview, "cast", lambda *x: x[0]) + + +def modified_base64(s): + s_utf7 = s.encode("utf-7") + return s_utf7[1:-1].replace(b"/", b",") + + +def modified_unbase64(s): + s_utf7 = b"+" + s.replace(b",", b"/") + b"-" + return s_utf7.decode("utf-7") + + +def encoder(s, errors=None): + """ + Encode the given C{unicode} string using the IMAP4 specific variation of + UTF-7. + + @type s: C{unicode} + @param s: The text to encode. + + @param errors: Policy for handling encoding errors. Currently ignored. + + @return: L{tuple} of a L{str} giving the encoded bytes and an L{int} + giving the number of code units consumed from the input. + """ + r = bytearray() + _in = [] + valid_chars = set(map(chr, range(0x20, 0x7F))) - {"&"} + for c in s: + if c in valid_chars: + if _in: + r += b"&" + modified_base64("".join(_in)) + b"-" + del _in[:] + r.append(ord(c)) + elif c == "&": + if _in: + r += b"&" + modified_base64("".join(_in)) + b"-" + del _in[:] + r += b"&-" + else: + _in.append(c) + if _in: + r.extend(b"&" + modified_base64("".join(_in)) + b"-") + return (bytes(r), len(s)) + + +def decoder(s, errors=None): + """ + Decode the given L{str} using the IMAP4 specific variation of UTF-7. + + @type s: L{str} + @param s: The bytes to decode. + + @param errors: Policy for handling decoding errors. Currently ignored. + + @return: a L{tuple} of a C{unicode} string giving the text which was + decoded and an L{int} giving the number of bytes consumed from the + input. + """ + r = [] + decode = [] + s = memory_cast(memoryview(s), "c") + for c in s: + if c == b"&" and not decode: + decode.append(b"&") + elif c == b"-" and decode: + if len(decode) == 1: + r.append("&") + else: + r.append(modified_unbase64(b"".join(decode[1:]))) + decode = [] + elif decode: + decode.append(c) + else: + r.append(c.decode()) + if decode: + r.append(modified_unbase64(b"".join(decode[1:]))) + return ("".join(r), len(s)) + + +class StreamReader(codecs.StreamReader): + def decode(self, s, errors="strict"): + return decoder(s) + + +class StreamWriter(codecs.StreamWriter): + def encode(self, s, errors="strict"): + return encoder(s) + + +_codecInfo = codecs.CodecInfo(encoder, decoder, StreamReader, StreamWriter) + + +def imap4_utf_7(name): + # In Python 3.9, codecs.lookup() was changed to normalize the codec name + # in the same way as encodings.normalize_encoding(). The docstring + # for encodings.normalize_encoding() describes how the codec name is + # normalized. We need to replace '-' with '_' to be compatible with + # older Python versions. + # See: https://bugs.python.org/issue37751 + # https://github.com/python/cpython/pull/17997 + if name.replace("-", "_") == "imap4_utf_7": + return _codecInfo + + +codecs.register(imap4_utf_7) + +__all__ = [ + # Protocol classes + "IMAP4Server", + "IMAP4Client", + # Interfaces + "IMailboxListener", + "IClientAuthentication", + "IAccount", + "IMailbox", + "INamespacePresenter", + "ICloseableMailbox", + "IMailboxInfo", + "IMessage", + "IMessageCopier", + "IMessageFile", + "ISearchableMailbox", + "IMessagePart", + # Exceptions + "IMAP4Exception", + "IllegalClientResponse", + "IllegalOperation", + "IllegalMailboxEncoding", + "UnhandledResponse", + "NegativeResponse", + "NoSupportedAuthentication", + "IllegalServerResponse", + "IllegalIdentifierError", + "IllegalQueryError", + "MismatchedNesting", + "MismatchedQuoting", + "MailboxException", + "MailboxCollision", + "NoSuchMailbox", + "ReadOnlyMailbox", + # Auth objects + "CramMD5ClientAuthenticator", + "PLAINAuthenticator", + "LOGINAuthenticator", + "PLAINCredentials", + "LOGINCredentials", + # Simple query interface + "Query", + "Not", + "Or", + # Miscellaneous + "MemoryAccount", + "statusRequestHelper", +] diff --git a/contrib/python/Twisted/py3/twisted/mail/interfaces.py b/contrib/python/Twisted/py3/twisted/mail/interfaces.py new file mode 100644 index 0000000000..dd87d35a63 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/interfaces.py @@ -0,0 +1,1050 @@ +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Interfaces for L{twisted.mail}. + +@since: 16.5 +""" + + +from zope.interface import Interface + + +class IChallengeResponse(Interface): + """ + An C{IMAPrev4} authorization challenge mechanism. + """ + + def getChallenge(): + """ + Return a client challenge. + + @return: A challenge. + @rtype: L{bytes} + """ + + def setResponse(response): + """ + Extract a username and possibly a password from a response and + assign them to C{username} and C{password} instance variables. + + @param response: A decoded response. + @type response: L{bytes} + + @see: L{credentials.IUsernamePassword} or + L{credentials.IUsernameHashedPassword} + """ + + def moreChallenges(): + """ + Are there more challenges than just the first? If so, callers + should challenge clients with the result of L{getChallenge}, + and check their response with L{setResponse} in a loop until + this returns L{False} + + @return: Are there more challenges? + @rtype: L{bool} + """ + + +class IClientAuthentication(Interface): + def getName(): + """ + Return an identifier associated with this authentication scheme. + + @rtype: L{bytes} + """ + + def challengeResponse(secret, challenge): + """ + Generate a challenge response string. + """ + + +class IServerFactoryPOP3(Interface): + """ + An interface for querying capabilities of a POP3 server. + + Any cap_* method may raise L{NotImplementedError} if the particular + capability is not supported. If L{cap_EXPIRE()} does not raise + L{NotImplementedError}, L{perUserExpiration()} must be implemented, + otherwise they are optional. If L{cap_LOGIN_DELAY()} is implemented, + L{perUserLoginDelay()} must be implemented, otherwise they are optional. + + @type challengers: L{dict} of L{bytes} -> L{IUsernameHashedPassword + <cred.credentials.IUsernameHashedPassword>} + @ivar challengers: A mapping of challenger names to + L{IUsernameHashedPassword <cred.credentials.IUsernameHashedPassword>} + provider. + """ + + def cap_IMPLEMENTATION(): + """ + Return a string describing the POP3 server implementation. + + @rtype: L{bytes} + @return: Server implementation information. + """ + + def cap_EXPIRE(): + """ + Return the minimum number of days messages are retained. + + @rtype: L{int} or L{None} + @return: The minimum number of days messages are retained or none, if + the server never deletes messages. + """ + + def perUserExpiration(): + """ + Indicate whether the message expiration policy differs per user. + + @rtype: L{bool} + @return: C{True} when the message expiration policy differs per user, + C{False} otherwise. + """ + + def cap_LOGIN_DELAY(): + """ + Return the minimum number of seconds between client logins. + + @rtype: L{int} + @return: The minimum number of seconds between client logins. + """ + + def perUserLoginDelay(): + """ + Indicate whether the login delay period differs per user. + + @rtype: L{bool} + @return: C{True} when the login delay differs per user, C{False} + otherwise. + """ + + +class IMailboxPOP3(Interface): + """ + An interface for mailbox access. + + Message indices are 0-based. + + @type loginDelay: L{int} + @ivar loginDelay: The number of seconds between allowed logins for the + user associated with this mailbox. + + @type messageExpiration: L{int} + @ivar messageExpiration: The number of days messages in this mailbox will + remain on the server before being deleted. + """ + + def listMessages(index=None): + """ + Retrieve the size of a message, or, if none is specified, the size of + each message in the mailbox. + + @type index: L{int} or L{None} + @param index: The 0-based index of the message. + + @rtype: L{int}, sequence of L{int}, or L{Deferred <defer.Deferred>} + @return: The number of octets in the specified message, or, if an + index is not specified, a sequence of the number of octets for + all messages in the mailbox or a deferred which fires with + one of those. Any value which corresponds to a deleted message + is set to 0. + + @raise ValueError or IndexError: When the index does not correspond to + a message in the mailbox. The use of ValueError is preferred. + """ + + def getMessage(index): + """ + Retrieve a file containing the contents of a message. + + @type index: L{int} + @param index: The 0-based index of a message. + + @rtype: file-like object + @return: A file containing the message. + + @raise ValueError or IndexError: When the index does not correspond to + a message in the mailbox. The use of ValueError is preferred. + """ + + def getUidl(index): + """ + Get a unique identifier for a message. + + @type index: L{int} + @param index: The 0-based index of a message. + + @rtype: L{bytes} + @return: A string of printable characters uniquely identifying the + message for all time. + + @raise ValueError or IndexError: When the index does not correspond to + a message in the mailbox. The use of ValueError is preferred. + """ + + def deleteMessage(index): + """ + Mark a message for deletion. + + This must not change the number of messages in this mailbox. Further + requests for the size of the deleted message should return 0. Further + requests for the message itself may raise an exception. + + @type index: L{int} + @param index: The 0-based index of a message. + + @raise ValueError or IndexError: When the index does not correspond to + a message in the mailbox. The use of ValueError is preferred. + """ + + def undeleteMessages(): + """ + Undelete all messages marked for deletion. + + Any message which can be undeleted should be returned to its original + position in the message sequence and retain its original UID. + """ + + def sync(): + """ + Discard the contents of any message marked for deletion. + """ + + +class IDomain(Interface): + """ + An interface for email domains. + """ + + def exists(user): + """ + Check whether a user exists in this domain. + + @type user: L{User} + @param user: A user. + + @rtype: no-argument callable which returns L{IMessageSMTP} provider + @return: A function which takes no arguments and returns a message + receiver for the user. + + @raise SMTPBadRcpt: When the given user does not exist in this domain. + """ + + def addUser(user, password): + """ + Add a user to this domain. + + @type user: L{bytes} + @param user: A username. + + @type password: L{bytes} + @param password: A password. + """ + + def getCredentialsCheckers(): + """ + Return credentials checkers for this domain. + + @rtype: L{list} of L{ICredentialsChecker + <twisted.cred.checkers.ICredentialsChecker>} provider + @return: Credentials checkers for this domain. + """ + + +class IAlias(Interface): + """ + An interface for aliases. + """ + + def createMessageReceiver(): + """ + Create a message receiver. + + @rtype: L{IMessageSMTP} provider + @return: A message receiver. + """ + + +class IAliasableDomain(IDomain): + """ + An interface for email domains which can be aliased to other domains. + """ + + def setAliasGroup(aliases): + """ + Set the group of defined aliases for this domain. + + @type aliases: L{dict} of L{bytes} -> L{IAlias} provider + @param aliases: A mapping of domain name to alias. + """ + + def exists(user, memo=None): + """ + Check whether a user exists in this domain or an alias of it. + + @type user: L{User} + @param user: A user. + + @type memo: L{None} or L{dict} of + L{AliasBase <twisted.mail.alias.AliasBase>} + @param memo: A record of the addresses already considered while + resolving aliases. The default value should be used by all external + code. + + @rtype: no-argument callable which returns L{IMessageSMTP} provider + @return: A function which takes no arguments and returns a message + receiver for the user. + + @raise SMTPBadRcpt: When the given user does not exist in this domain + or an alias of it. + """ + + +class IMessageDelivery(Interface): + def receivedHeader(helo, origin, recipients): + """ + Generate the Received header for a message. + + @type helo: 2-L{tuple} of L{bytes} and L{bytes}. + @param helo: The argument to the HELO command and the client's IP + address. + + @type origin: L{Address} + @param origin: The address the message is from + + @type recipients: L{list} of L{User} + @param recipients: A list of the addresses for which this message + is bound. + + @rtype: L{bytes} + @return: The full C{"Received"} header string. + """ + + def validateTo(user): + """ + Validate the address for which the message is destined. + + @type user: L{User} + @param user: The address to validate. + + @rtype: no-argument callable + @return: A L{Deferred} which becomes, or a callable which takes no + arguments and returns an object implementing L{IMessageSMTP}. This + will be called and the returned object used to deliver the message + when it arrives. + + @raise SMTPBadRcpt: Raised if messages to the address are not to be + accepted. + """ + + def validateFrom(helo, origin): + """ + Validate the address from which the message originates. + + @type helo: 2-L{tuple} of L{bytes} and L{bytes}. + @param helo: The argument to the HELO command and the client's IP + address. + + @type origin: L{Address} + @param origin: The address the message is from + + @rtype: L{Deferred} or L{Address} + @return: C{origin} or a L{Deferred} whose callback will be + passed C{origin}. + + @raise SMTPBadSender: Raised of messages from this address are + not to be accepted. + """ + + +class IMessageDeliveryFactory(Interface): + """ + An alternate interface to implement for handling message delivery. + + It is useful to implement this interface instead of L{IMessageDelivery} + directly because it allows the implementor to distinguish between different + messages delivery over the same connection. This can be used to optimize + delivery of a single message to multiple recipients, something which cannot + be done by L{IMessageDelivery} implementors due to their lack of + information. + """ + + def getMessageDelivery(): + """ + Return an L{IMessageDelivery} object. + + This will be called once per message. + """ + + +class IMessageSMTP(Interface): + """ + Interface definition for messages that can be sent via SMTP. + """ + + def lineReceived(line): + """ + Handle another line. + """ + + def eomReceived(): + """ + Handle end of message. + + return a deferred. The deferred should be called with either: + callback(string) or errback(error) + + @rtype: L{Deferred} + """ + + def connectionLost(): + """ + Handle message truncated. + + semantics should be to discard the message + """ + + +class IMessageIMAPPart(Interface): + def getHeaders(negate, *names): + """ + Retrieve a group of message headers. + + @type names: L{tuple} of L{str} + @param names: The names of the headers to retrieve or omit. + + @type negate: L{bool} + @param negate: If True, indicates that the headers listed in C{names} + should be omitted from the return value, rather than included. + + @rtype: L{dict} + @return: A mapping of header field names to header field values + """ + + def getBodyFile(): + """ + Retrieve a file object containing only the body of this message. + """ + + def getSize(): + """ + Retrieve the total size, in octets, of this message. + + @rtype: L{int} + """ + + def isMultipart(): + """ + Indicate whether this message has subparts. + + @rtype: L{bool} + """ + + def getSubPart(part): + """ + Retrieve a MIME sub-message + + @type part: L{int} + @param part: The number of the part to retrieve, indexed from 0. + + @raise IndexError: Raised if the specified part does not exist. + @raise TypeError: Raised if this message is not multipart. + + @rtype: Any object implementing L{IMessageIMAPPart}. + @return: The specified sub-part. + """ + + +class IMessageIMAP(IMessageIMAPPart): + def getUID(): + """ + Retrieve the unique identifier associated with this message. + """ + + def getFlags(): + """ + Retrieve the flags associated with this message. + + @rtype: C{iterable} + @return: The flags, represented as strings. + """ + + def getInternalDate(): + """ + Retrieve the date internally associated with this message. + + @rtype: L{bytes} + @return: An RFC822-formatted date string. + """ + + +class IMessageIMAPFile(Interface): + """ + Optional message interface for representing messages as files. + + If provided by message objects, this interface will be used instead the + more complex MIME-based interface. + """ + + def open(): + """ + Return a file-like object opened for reading. + + Reading from the returned file will return all the bytes of which this + message consists. + """ + + +class ISearchableIMAPMailbox(Interface): + def search(query, uid): + """ + Search for messages that meet the given query criteria. + + If this interface is not implemented by the mailbox, + L{IMailboxIMAP.fetch} and various methods of L{IMessageIMAP} will be + used instead. + + Implementations which wish to offer better performance than the default + implementation should implement this interface. + + @type query: L{list} + @param query: The search criteria + + @type uid: L{bool} + @param uid: If true, the IDs specified in the query are UIDs; otherwise + they are message sequence IDs. + + @rtype: L{list} or L{Deferred} + @return: A list of message sequence numbers or message UIDs which match + the search criteria or a L{Deferred} whose callback will be invoked + with such a list. + + @raise IllegalQueryError: Raised when query is not valid. + """ + + +class IMailboxIMAPListener(Interface): + """ + Interface for objects interested in mailbox events + """ + + def modeChanged(writeable): + """ + Indicates that the write status of a mailbox has changed. + + @type writeable: L{bool} + @param writeable: A true value if write is now allowed, false + otherwise. + """ + + def flagsChanged(newFlags): + """ + Indicates that the flags of one or more messages have changed. + + @type newFlags: L{dict} + @param newFlags: A mapping of message identifiers to tuples of flags + now set on that message. + """ + + def newMessages(exists, recent): + """ + Indicates that the number of messages in a mailbox has changed. + + @type exists: L{int} or L{None} + @param exists: The total number of messages now in this mailbox. If the + total number of messages has not changed, this should be L{None}. + + @type recent: L{int} + @param recent: The number of messages now flagged C{\\Recent}. If the + number of recent messages has not changed, this should be L{None}. + """ + + +class IMessageIMAPCopier(Interface): + def copy(messageObject): + """ + Copy the given message object into this mailbox. + + The message object will be one which was previously returned by + L{IMailboxIMAP.fetch}. + + Implementations which wish to offer better performance than the default + implementation should implement this interface. + + If this interface is not implemented by the mailbox, + L{IMailboxIMAP.addMessage} will be used instead. + + @rtype: L{Deferred} or L{int} + @return: Either the UID of the message or a Deferred which fires with + the UID when the copy finishes. + """ + + +class IMailboxIMAPInfo(Interface): + """ + Interface specifying only the methods required for C{listMailboxes}. + + Implementations can return objects implementing only these methods for + return to C{listMailboxes} if it can allow them to operate more + efficiently. + """ + + def getFlags(): + """ + Return the flags defined in this mailbox + + Flags with the \\ prefix are reserved for use as system flags. + + @rtype: L{list} of L{str} + @return: A list of the flags that can be set on messages in this + mailbox. + """ + + def getHierarchicalDelimiter(): + """ + Get the character which delimits namespaces for in this mailbox. + + @rtype: L{bytes} + """ + + +class IMailboxIMAP(IMailboxIMAPInfo): + def getUIDValidity(): + """ + Return the unique validity identifier for this mailbox. + + @rtype: L{int} + """ + + def getUIDNext(): + """ + Return the likely UID for the next message added to this mailbox. + + @rtype: L{int} + """ + + def getUID(message): + """ + Return the UID of a message in the mailbox + + @type message: L{int} + @param message: The message sequence number + + @rtype: L{int} + @return: The UID of the message. + """ + + def getMessageCount(): + """ + Return the number of messages in this mailbox. + + @rtype: L{int} + """ + + def getRecentCount(): + """ + Return the number of messages with the 'Recent' flag. + + @rtype: L{int} + """ + + def getUnseenCount(): + """ + Return the number of messages with the 'Unseen' flag. + + @rtype: L{int} + """ + + def isWriteable(): + """ + Get the read/write status of the mailbox. + + @rtype: L{int} + @return: A true value if write permission is allowed, a false value + otherwise. + """ + + def destroy(): + """ + Called before this mailbox is deleted, permanently. + + If necessary, all resources held by this mailbox should be cleaned up + here. This function _must_ set the \\Noselect flag on this mailbox. + """ + + def requestStatus(names): + """ + Return status information about this mailbox. + + Mailboxes which do not intend to do any special processing to generate + the return value, C{statusRequestHelper} can be used to build the + dictionary by calling the other interface methods which return the data + for each name. + + @type names: Any iterable + @param names: The status names to return information regarding. The + possible values for each name are: MESSAGES, RECENT, UIDNEXT, + UIDVALIDITY, UNSEEN. + + @rtype: L{dict} or L{Deferred} + @return: A dictionary containing status information about the requested + names is returned. If the process of looking this information up + would be costly, a deferred whose callback will eventually be + passed this dictionary is returned instead. + """ + + def addListener(listener): + """ + Add a mailbox change listener + + @type listener: Any object which implements C{IMailboxIMAPListener} + @param listener: An object to add to the set of those which will be + notified when the contents of this mailbox change. + """ + + def removeListener(listener): + """ + Remove a mailbox change listener + + @type listener: Any object previously added to and not removed from + this mailbox as a listener. + @param listener: The object to remove from the set of listeners. + + @raise ValueError: Raised when the given object is not a listener for + this mailbox. + """ + + def addMessage(message, flags, date): + """ + Add the given message to this mailbox. + + @type message: A file-like object + @param message: The RFC822 formatted message + + @type flags: Any iterable of L{bytes} + @param flags: The flags to associate with this message + + @type date: L{bytes} + @param date: If specified, the date to associate with this message. + + @rtype: L{Deferred} + @return: A deferred whose callback is invoked with the message id if + the message is added successfully and whose errback is invoked + otherwise. + + @raise ReadOnlyMailbox: Raised if this Mailbox is not open for + read-write. + """ + + def expunge(): + """ + Remove all messages flagged \\Deleted. + + @rtype: L{list} or L{Deferred} + @return: The list of message sequence numbers which were deleted, or a + L{Deferred} whose callback will be invoked with such a list. + + @raise ReadOnlyMailbox: Raised if this Mailbox is not open for + read-write. + """ + + def fetch(messages, uid): + """ + Retrieve one or more messages. + + @type messages: C{MessageSet} + @param messages: The identifiers of messages to retrieve information + about + + @type uid: L{bool} + @param uid: If true, the IDs specified in the query are UIDs; otherwise + they are message sequence IDs. + + @rtype: Any iterable of two-tuples of message sequence numbers and + implementors of C{IMessageIMAP}. + """ + + def store(messages, flags, mode, uid): + """ + Set the flags of one or more messages. + + @type messages: A MessageSet object with the list of messages requested + @param messages: The identifiers of the messages to set the flags of. + + @type flags: sequence of L{str} + @param flags: The flags to set, unset, or add. + + @type mode: -1, 0, or 1 + @param mode: If mode is -1, these flags should be removed from the + specified messages. If mode is 1, these flags should be added to + the specified messages. If mode is 0, all existing flags should be + cleared and these flags should be added. + + @type uid: L{bool} + @param uid: If true, the IDs specified in the query are UIDs; otherwise + they are message sequence IDs. + + @rtype: L{dict} or L{Deferred} + @return: A L{dict} mapping message sequence numbers to sequences of + L{str} representing the flags set on the message after this + operation has been performed, or a L{Deferred} whose callback will + be invoked with such a L{dict}. + + @raise ReadOnlyMailbox: Raised if this mailbox is not open for + read-write. + """ + + +class ICloseableMailboxIMAP(Interface): + """ + A supplementary interface for mailboxes which require cleanup on close. + + Implementing this interface is optional. If it is implemented, the protocol + code will call the close method defined whenever a mailbox is closed. + """ + + def close(): + """ + Close this mailbox. + + @return: A L{Deferred} which fires when this mailbox has been closed, + or None if the mailbox can be closed immediately. + """ + + +class IAccountIMAP(Interface): + """ + Interface for Account classes + + Implementors of this interface should consider implementing + C{INamespacePresenter}. + """ + + def addMailbox(name, mbox=None): + """ + Add a new mailbox to this account + + @type name: L{bytes} + @param name: The name associated with this mailbox. It may not contain + multiple hierarchical parts. + + @type mbox: An object implementing C{IMailboxIMAP} + @param mbox: The mailbox to associate with this name. If L{None}, a + suitable default is created and used. + + @rtype: L{Deferred} or L{bool} + @return: A true value if the creation succeeds, or a deferred whose + callback will be invoked when the creation succeeds. + + @raise MailboxException: Raised if this mailbox cannot be added for + some reason. This may also be raised asynchronously, if a + L{Deferred} is returned. + """ + + def create(pathspec): + """ + Create a new mailbox from the given hierarchical name. + + @type pathspec: L{bytes} + @param pathspec: The full hierarchical name of a new mailbox to create. + If any of the inferior hierarchical names to this one do not exist, + they are created as well. + + @rtype: L{Deferred} or L{bool} + @return: A true value if the creation succeeds, or a deferred whose + callback will be invoked when the creation succeeds. + + @raise MailboxException: Raised if this mailbox cannot be added. This + may also be raised asynchronously, if a L{Deferred} is returned. + """ + + def select(name, rw=True): + """ + Acquire a mailbox, given its name. + + @type name: L{bytes} + @param name: The mailbox to acquire + + @type rw: L{bool} + @param rw: If a true value, request a read-write version of this + mailbox. If a false value, request a read-only version. + + @rtype: Any object implementing C{IMailboxIMAP} or L{Deferred} + @return: The mailbox object, or a L{Deferred} whose callback will be + invoked with the mailbox object. None may be returned if the + specified mailbox may not be selected for any reason. + """ + + def delete(name): + """ + Delete the mailbox with the specified name. + + @type name: L{bytes} + @param name: The mailbox to delete. + + @rtype: L{Deferred} or L{bool} + @return: A true value if the mailbox is successfully deleted, or a + L{Deferred} whose callback will be invoked when the deletion + completes. + + @raise MailboxException: Raised if this mailbox cannot be deleted. This + may also be raised asynchronously, if a L{Deferred} is returned. + """ + + def rename(oldname, newname): + """ + Rename a mailbox + + @type oldname: L{bytes} + @param oldname: The current name of the mailbox to rename. + + @type newname: L{bytes} + @param newname: The new name to associate with the mailbox. + + @rtype: L{Deferred} or L{bool} + @return: A true value if the mailbox is successfully renamed, or a + L{Deferred} whose callback will be invoked when the rename + operation is completed. + + @raise MailboxException: Raised if this mailbox cannot be renamed. This + may also be raised asynchronously, if a L{Deferred} is returned. + """ + + def isSubscribed(name): + """ + Check the subscription status of a mailbox + + @type name: L{bytes} + @param name: The name of the mailbox to check + + @rtype: L{Deferred} or L{bool} + @return: A true value if the given mailbox is currently subscribed to, + a false value otherwise. A L{Deferred} may also be returned whose + callback will be invoked with one of these values. + """ + + def subscribe(name): + """ + Subscribe to a mailbox + + @type name: L{bytes} + @param name: The name of the mailbox to subscribe to + + @rtype: L{Deferred} or L{bool} + @return: A true value if the mailbox is subscribed to successfully, or + a Deferred whose callback will be invoked with this value when the + subscription is successful. + + @raise MailboxException: Raised if this mailbox cannot be subscribed + to. This may also be raised asynchronously, if a L{Deferred} is + returned. + """ + + def unsubscribe(name): + """ + Unsubscribe from a mailbox + + @type name: L{bytes} + @param name: The name of the mailbox to unsubscribe from + + @rtype: L{Deferred} or L{bool} + @return: A true value if the mailbox is unsubscribed from successfully, + or a Deferred whose callback will be invoked with this value when + the unsubscription is successful. + + @raise MailboxException: Raised if this mailbox cannot be unsubscribed + from. This may also be raised asynchronously, if a L{Deferred} is + returned. + """ + + def listMailboxes(ref, wildcard): + """ + List all the mailboxes that meet a certain criteria + + @type ref: L{bytes} + @param ref: The context in which to apply the wildcard + + @type wildcard: L{bytes} + @param wildcard: An expression against which to match mailbox names. + '*' matches any number of characters in a mailbox name, and '%' + matches similarly, but will not match across hierarchical + boundaries. + + @rtype: L{list} of L{tuple} + @return: A list of C{(mailboxName, mailboxObject)} which meet the given + criteria. C{mailboxObject} should implement either + C{IMailboxIMAPInfo} or C{IMailboxIMAP}. A Deferred may also be + returned. + """ + + +class INamespacePresenter(Interface): + def getPersonalNamespaces(): + """ + Report the available personal namespaces. + + Typically there should be only one personal namespace. A common name + for it is C{\"\"}, and its hierarchical delimiter is usually C{\"/\"}. + + @rtype: iterable of two-tuples of strings + @return: The personal namespaces and their hierarchical delimiters. If + no namespaces of this type exist, None should be returned. + """ + + def getSharedNamespaces(): + """ + Report the available shared namespaces. + + Shared namespaces do not belong to any individual user but are usually + to one or more of them. Examples of shared namespaces might be + C{\"#news\"} for a usenet gateway. + + @rtype: iterable of two-tuples of strings + @return: The shared namespaces and their hierarchical delimiters. If no + namespaces of this type exist, None should be returned. + """ + + def getUserNamespaces(): + """ + Report the available user namespaces. + + These are namespaces that contain folders belonging to other users + access to which this account has been granted. + + @rtype: iterable of two-tuples of strings + @return: The user namespaces and their hierarchical delimiters. If no + namespaces of this type exist, None should be returned. + """ + + +__all__ = [ + # IMAP + "IAccountIMAP", + "ICloseableMailboxIMAP", + "IMailboxIMAP", + "IMailboxIMAPInfo", + "IMailboxIMAPListener", + "IMessageIMAP", + "IMessageIMAPCopier", + "IMessageIMAPFile", + "IMessageIMAPPart", + "ISearchableIMAPMailbox", + "INamespacePresenter", + # SMTP + "IMessageDelivery", + "IMessageDeliveryFactory", + "IMessageSMTP", + # Domains and aliases + "IDomain", + "IAlias", + "IAliasableDomain", + # POP3 + "IMailboxPOP3", + "IServerFactoryPOP3", + # Authentication + "IClientAuthentication", +] diff --git a/contrib/python/Twisted/py3/twisted/mail/mail.py b/contrib/python/Twisted/py3/twisted/mail/mail.py new file mode 100644 index 0000000000..2dc405344b --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/mail.py @@ -0,0 +1,706 @@ +# -*- test-case-name: twisted.mail.test.test_mail -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Mail service support. +""" + +# System imports +import os +import warnings + +from zope.interface import implementer + +from twisted.application import internet, service +from twisted.cred.portal import Portal + +# Twisted imports +from twisted.internet import defer + +# Sibling imports +from twisted.mail import protocols, smtp +from twisted.mail.interfaces import IAliasableDomain, IDomain +from twisted.python import log, util + + +class DomainWithDefaultDict: + """ + A simulated dictionary for mapping domain names to domain objects with + a default value for non-existing keys. + + @ivar domains: See L{__init__} + @ivar default: See L{__init__} + """ + + def __init__(self, domains, default): + """ + @type domains: L{dict} of L{bytes} -> L{IDomain} provider + @param domains: A mapping of domain name to domain object. + + @type default: L{IDomain} provider + @param default: The default domain. + """ + self.domains = domains + self.default = default + + def setDefaultDomain(self, domain): + """ + Set the default domain. + + @type domain: L{IDomain} provider + @param domain: The default domain. + """ + self.default = domain + + def has_key(self, name): + """ + Test for the presence of a domain name in this dictionary. + + This always returns C{True} because a default value will be returned + if the name doesn't exist in this dictionary. + + @type name: L{bytes} + @param name: A domain name. + + @rtype: L{bool} + @return: C{True} to indicate that the domain name is in this + dictionary. + """ + warnings.warn( + "twisted.mail.mail.DomainWithDefaultDict.has_key was deprecated " + "in Twisted 16.3.0. " + "Use the `in` keyword instead.", + category=DeprecationWarning, + stacklevel=2, + ) + return 1 + + @classmethod + def fromkeys(klass, keys, value=None): + """ + Create a new L{DomainWithDefaultDict} with the specified keys. + + @type keys: iterable of L{bytes} + @param keys: Domain names to serve as keys in the new dictionary. + + @type value: L{None} or L{IDomain} provider + @param value: A domain object to serve as the value for all new keys + in the dictionary. + + @rtype: L{DomainWithDefaultDict} + @return: A new dictionary. + """ + d = klass() + for k in keys: + d[k] = value + return d + + def __contains__(self, name): + """ + Test for the presence of a domain name in this dictionary. + + This always returns C{True} because a default value will be returned + if the name doesn't exist in this dictionary. + + @type name: L{bytes} + @param name: A domain name. + + @rtype: L{bool} + @return: C{True} to indicate that the domain name is in this + dictionary. + """ + return 1 + + def __getitem__(self, name): + """ + Look up a domain name and, if it is present, return the domain object + associated with it. Otherwise return the default domain. + + @type name: L{bytes} + @param name: A domain name. + + @rtype: L{IDomain} provider or L{None} + @return: A domain object. + """ + return self.domains.get(name, self.default) + + def __setitem__(self, name, value): + """ + Associate a domain object with a domain name in this dictionary. + + @type name: L{bytes} + @param name: A domain name. + + @type value: L{IDomain} provider + @param value: A domain object. + """ + self.domains[name] = value + + def __delitem__(self, name): + """ + Delete the entry for a domain name in this dictionary. + + @type name: L{bytes} + @param name: A domain name. + """ + del self.domains[name] + + def __iter__(self): + """ + Return an iterator over the domain names in this dictionary. + + @rtype: iterator over L{bytes} + @return: An iterator over the domain names. + """ + return iter(self.domains) + + def __len__(self): + """ + Return the number of domains in this dictionary. + + @rtype: L{int} + @return: The number of domains in this dictionary. + """ + return len(self.domains) + + def __str__(self) -> str: + """ + Build an informal string representation of this dictionary. + + @rtype: L{bytes} + @return: A string containing the mapping of domain names to domain + objects. + """ + return f"<DomainWithDefaultDict {self.domains}>" + + def __repr__(self) -> str: + """ + Build an "official" string representation of this dictionary. + + @rtype: L{bytes} + @return: A pseudo-executable string describing the underlying domain + mapping of this object. + """ + return f"DomainWithDefaultDict({self.domains})" + + def get(self, key, default=None): + """ + Look up a domain name in this dictionary. + + @type key: L{bytes} + @param key: A domain name. + + @type default: L{IDomain} provider or L{None} + @param default: A domain object to be returned if the domain name is + not in this dictionary. + + @rtype: L{IDomain} provider or L{None} + @return: The domain object associated with the domain name if it is in + this dictionary. Otherwise, the default value. + """ + return self.domains.get(key, default) + + def copy(self): + """ + Make a copy of this dictionary. + + @rtype: L{DomainWithDefaultDict} + @return: A copy of this dictionary. + """ + return DomainWithDefaultDict(self.domains.copy(), self.default) + + def iteritems(self): + """ + Return an iterator over the domain name/domain object pairs in the + dictionary. + + Using the returned iterator while adding or deleting entries from the + dictionary may result in a L{RuntimeError} or failing to iterate over + all the domain name/domain object pairs. + + @rtype: iterator over 2-L{tuple} of (E{1}) L{bytes}, + (E{2}) L{IDomain} provider or L{None} + @return: An iterator over the domain name/domain object pairs. + """ + return self.domains.iteritems() + + def iterkeys(self): + """ + Return an iterator over the domain names in this dictionary. + + Using the returned iterator while adding or deleting entries from the + dictionary may result in a L{RuntimeError} or failing to iterate over + all the domain names. + + @rtype: iterator over L{bytes} + @return: An iterator over the domain names. + """ + return self.domains.iterkeys() + + def itervalues(self): + """ + Return an iterator over the domain objects in this dictionary. + + Using the returned iterator while adding or deleting entries from the + dictionary may result in a L{RuntimeError} or failing to iterate over + all the domain objects. + + @rtype: iterator over L{IDomain} provider or + L{None} + @return: An iterator over the domain objects. + """ + return self.domains.itervalues() + + def keys(self): + """ + Return a list of all domain names in this dictionary. + + @rtype: L{list} of L{bytes} + @return: The domain names in this dictionary. + + """ + return self.domains.keys() + + def values(self): + """ + Return a list of all domain objects in this dictionary. + + @rtype: L{list} of L{IDomain} provider or L{None} + @return: The domain objects in this dictionary. + """ + return self.domains.values() + + def items(self): + """ + Return a list of all domain name/domain object pairs in this + dictionary. + + @rtype: L{list} of 2-L{tuple} of (E{1}) L{bytes}, (E{2}) L{IDomain} + provider or L{None} + @return: Domain name/domain object pairs in this dictionary. + """ + return self.domains.items() + + def popitem(self): + """ + Remove a random domain name/domain object pair from this dictionary and + return it as a tuple. + + @rtype: 2-L{tuple} of (E{1}) L{bytes}, (E{2}) L{IDomain} provider or + L{None} + @return: A domain name/domain object pair. + + @raise KeyError: When this dictionary is empty. + """ + return self.domains.popitem() + + def update(self, other): + """ + Update this dictionary with domain name/domain object pairs from + another dictionary. + + When this dictionary contains a domain name which is in the other + dictionary, its value will be overwritten. + + @type other: L{dict} of L{bytes} -> L{IDomain} provider and/or + L{bytes} -> L{None} + @param other: Another dictionary of domain name/domain object pairs. + + @rtype: L{None} + @return: None. + """ + return self.domains.update(other) + + def clear(self): + """ + Remove all items from this dictionary. + + @rtype: L{None} + @return: None. + """ + return self.domains.clear() + + def setdefault(self, key, default): + """ + Return the domain object associated with the domain name if it is + present in this dictionary. Otherwise, set the value for the + domain name to the default and return that value. + + @type key: L{bytes} + @param key: A domain name. + + @type default: L{IDomain} provider + @param default: A domain object. + + @rtype: L{IDomain} provider or L{None} + @return: The domain object associated with the domain name. + """ + return self.domains.setdefault(key, default) + + +@implementer(IDomain) +class BounceDomain: + """ + A domain with no users. + + This can be used to block off a domain. + """ + + def exists(self, user): + """ + Raise an exception to indicate that the user does not exist in this + domain. + + @type user: L{User} + @param user: A user. + + @raise SMTPBadRcpt: When the given user does not exist in this domain. + """ + raise smtp.SMTPBadRcpt(user) + + def willRelay(self, user, protocol): + """ + Indicate that this domain will not relay. + + @type user: L{Address} + @param user: The destination address. + + @type protocol: L{Protocol <twisted.internet.protocol.Protocol>} + @param protocol: The protocol over which the message to be relayed is + being received. + + @rtype: L{bool} + @return: C{False}. + """ + return False + + def addUser(self, user, password): + """ + Ignore attempts to add a user to this domain. + + @type user: L{bytes} + @param user: A username. + + @type password: L{bytes} + @param password: A password. + """ + pass + + def getCredentialsCheckers(self): + """ + Return no credentials checkers for this domain. + + @rtype: L{list} + @return: The empty list. + """ + return [] + + +@implementer(smtp.IMessage) +class FileMessage: + """ + A message receiver which delivers a message to a file. + + @ivar fp: See L{__init__}. + @ivar name: See L{__init__}. + @ivar finalName: See L{__init__}. + """ + + def __init__(self, fp, name, finalName): + """ + @type fp: file-like object + @param fp: The file in which to store the message while it is being + received. + + @type name: L{bytes} + @param name: The full path name of the temporary file. + + @type finalName: L{bytes} + @param finalName: The full path name that should be given to the file + holding the message after it has been fully received. + """ + self.fp = fp + self.name = name + self.finalName = finalName + + def lineReceived(self, line): + """ + Write a received line to the file. + + @type line: L{bytes} + @param line: A received line. + """ + self.fp.write(line + b"\n") + + def eomReceived(self): + """ + At the end of message, rename the file holding the message to its + final name. + + @rtype: L{Deferred} which successfully results in L{bytes} + @return: A deferred which returns the final name of the file. + """ + self.fp.close() + os.rename(self.name, self.finalName) + return defer.succeed(self.finalName) + + def connectionLost(self): + """ + Delete the file holding the partially received message. + """ + self.fp.close() + os.remove(self.name) + + +class MailService(service.MultiService): + """ + An email service. + + @type queue: L{Queue} or L{None} + @ivar queue: A queue for outgoing messages. + + @type domains: L{dict} of L{bytes} -> L{IDomain} provider + @ivar domains: A mapping of supported domain name to domain object. + + @type portals: L{dict} of L{bytes} -> L{Portal} + @ivar portals: A mapping of domain name to authentication portal. + + @type aliases: L{None} or L{dict} of + L{bytes} -> L{IAlias} provider + @ivar aliases: A mapping of domain name to alias. + + @type smtpPortal: L{Portal} + @ivar smtpPortal: A portal for authentication for the SMTP server. + + @type monitor: L{FileMonitoringService} + @ivar monitor: A service to monitor changes to files. + """ + + queue = None + domains = None + portals = None + aliases = None + smtpPortal = None + + def __init__(self): + """ + Initialize the mail service. + """ + service.MultiService.__init__(self) + # Domains and portals for "client" protocols - POP3, IMAP4, etc + self.domains = DomainWithDefaultDict({}, BounceDomain()) + self.portals = {} + + self.monitor = FileMonitoringService() + self.monitor.setServiceParent(self) + self.smtpPortal = Portal(self) + + def getPOP3Factory(self): + """ + Create a POP3 protocol factory. + + @rtype: L{POP3Factory} + @return: A POP3 protocol factory. + """ + return protocols.POP3Factory(self) + + def getSMTPFactory(self): + """ + Create an SMTP protocol factory. + + @rtype: L{SMTPFactory <protocols.SMTPFactory>} + @return: An SMTP protocol factory. + """ + return protocols.SMTPFactory(self, self.smtpPortal) + + def getESMTPFactory(self): + """ + Create an ESMTP protocol factory. + + @rtype: L{ESMTPFactory <protocols.ESMTPFactory>} + @return: An ESMTP protocol factory. + """ + return protocols.ESMTPFactory(self, self.smtpPortal) + + def addDomain(self, name, domain): + """ + Add a domain for which the service will accept email. + + @type name: L{bytes} + @param name: A domain name. + + @type domain: L{IDomain} provider + @param domain: A domain object. + """ + portal = Portal(domain) + map(portal.registerChecker, domain.getCredentialsCheckers()) + self.domains[name] = domain + self.portals[name] = portal + if self.aliases and IAliasableDomain.providedBy(domain): + domain.setAliasGroup(self.aliases) + + def setQueue(self, queue): + """ + Set the queue for outgoing emails. + + @type queue: L{Queue} + @param queue: A queue for outgoing messages. + """ + self.queue = queue + + def requestAvatar(self, avatarId, mind, *interfaces): + """ + Return a message delivery for an authenticated SMTP user. + + @type avatarId: L{bytes} + @param avatarId: A string which identifies an authenticated user. + + @type mind: L{None} + @param mind: Unused. + + @type interfaces: n-L{tuple} of C{zope.interface.Interface} + @param interfaces: A group of interfaces one of which the avatar must + support. + + @rtype: 3-L{tuple} of (E{1}) L{IMessageDelivery}, + (E{2}) L{ESMTPDomainDelivery}, (E{3}) no-argument callable + @return: A tuple of the supported interface, a message delivery, and + a logout function. + + @raise NotImplementedError: When the given interfaces do not include + L{IMessageDelivery}. + """ + if smtp.IMessageDelivery in interfaces: + a = protocols.ESMTPDomainDelivery(self, avatarId) + return smtp.IMessageDelivery, a, lambda: None + raise NotImplementedError() + + def lookupPortal(self, name): + """ + Find the portal for a domain. + + @type name: L{bytes} + @param name: A domain name. + + @rtype: L{Portal} + @return: A portal. + """ + return self.portals[name] + + def defaultPortal(self): + """ + Return the portal for the default domain. + + The default domain is named ''. + + @rtype: L{Portal} + @return: The portal for the default domain. + """ + return self.portals[""] + + +class FileMonitoringService(internet.TimerService): + """ + A service for monitoring changes to files. + + @type files: L{list} of L{list} of (E{1}) L{float}, (E{2}) L{bytes}, + (E{3}) callable which takes a L{bytes} argument, (E{4}) L{float} + @ivar files: Information about files to be monitored. Each list entry + provides the following information for a file: interval in seconds + between checks, filename, callback function, time of last modification + to the file. + + @type intervals: L{_IntervalDifferentialIterator + <twisted.python.util._IntervalDifferentialIterator>} + @ivar intervals: Intervals between successive file checks. + + @type _call: L{IDelayedCall <twisted.internet.interfaces.IDelayedCall>} + provider + @ivar _call: The next scheduled call to check a file. + + @type index: L{int} + @ivar index: The index of the next file to be checked. + """ + + def __init__(self): + """ + Initialize the file monitoring service. + """ + self.files = [] + self.intervals = iter(util.IntervalDifferential([], 60)) + + def startService(self): + """ + Start the file monitoring service. + """ + service.Service.startService(self) + self._setupMonitor() + + def _setupMonitor(self): + """ + Schedule the next monitoring call. + """ + from twisted.internet import reactor + + t, self.index = self.intervals.next() + self._call = reactor.callLater(t, self._monitor) + + def stopService(self): + """ + Stop the file monitoring service. + """ + service.Service.stopService(self) + if self._call: + self._call.cancel() + self._call = None + + def monitorFile(self, name, callback, interval=10): + """ + Start monitoring a file for changes. + + @type name: L{bytes} + @param name: The name of a file to monitor. + + @type callback: callable which takes a L{bytes} argument + @param callback: The function to call when the file has changed. + + @type interval: L{float} + @param interval: The interval in seconds between checks. + """ + try: + mtime = os.path.getmtime(name) + except BaseException: + mtime = 0 + self.files.append([interval, name, callback, mtime]) + self.intervals.addInterval(interval) + + def unmonitorFile(self, name): + """ + Stop monitoring a file. + + @type name: L{bytes} + @param name: A file name. + """ + for i in range(len(self.files)): + if name == self.files[i][1]: + self.intervals.removeInterval(self.files[i][0]) + del self.files[i] + break + + def _monitor(self): + """ + Monitor a file and make a callback if it has changed. + """ + self._call = None + if self.index is not None: + name, callback, mtime = self.files[self.index][1:] + try: + now = os.path.getmtime(name) + except BaseException: + now = 0 + if now > mtime: + log.msg(f"{name} changed, notifying listener") + self.files[self.index][3] = now + callback(name) + self._setupMonitor() diff --git a/contrib/python/Twisted/py3/twisted/mail/maildir.py b/contrib/python/Twisted/py3/twisted/mail/maildir.py new file mode 100644 index 0000000000..c58bf31a94 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/maildir.py @@ -0,0 +1,910 @@ +# -*- test-case-name: twisted.mail.test.test_mail -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + + +""" +Maildir-style mailbox support. +""" + +import io +import os +import socket +import stat +from hashlib import md5 +from typing import IO + +from zope.interface import implementer + +from twisted.cred import checkers, credentials, portal +from twisted.cred.error import UnauthorizedLogin +from twisted.internet import defer, interfaces, reactor +from twisted.mail import mail, pop3, smtp +from twisted.persisted import dirdbm +from twisted.protocols import basic +from twisted.python import failure, log + +INTERNAL_ERROR = """\ +From: Twisted.mail Internals +Subject: An Error Occurred + + An internal server error has occurred. Please contact the + server administrator. +""" + + +class _MaildirNameGenerator: + """ + A utility class to generate a unique maildir name. + + @type n: L{int} + @ivar n: A counter used to generate unique integers. + + @type p: L{int} + @ivar p: The ID of the current process. + + @type s: L{bytes} + @ivar s: A representation of the hostname. + + @ivar _clock: See C{clock} parameter of L{__init__}. + """ + + n = 0 + p = os.getpid() + s = socket.gethostname().replace("/", r"\057").replace(":", r"\072") + + def __init__(self, clock): + """ + @type clock: L{IReactorTime <interfaces.IReactorTime>} provider + @param clock: A reactor which will be used to learn the current time. + """ + self._clock = clock + + def generate(self): + """ + Generate a string which is intended to be unique across all calls to + this function (across all processes, reboots, etc). + + Strings returned by earlier calls to this method will compare less + than strings returned by later calls as long as the clock provided + doesn't go backwards. + + @rtype: L{bytes} + @return: A unique string. + """ + self.n = self.n + 1 + t = self._clock.seconds() + seconds = str(int(t)) + microseconds = "%07d" % (int((t - int(t)) * 10e6),) + return f"{seconds}.M{microseconds}P{self.p}Q{self.n}.{self.s}" + + +_generateMaildirName = _MaildirNameGenerator(reactor).generate + + +def initializeMaildir(dir): + """ + Create a maildir user directory if it doesn't already exist. + + @type dir: L{bytes} + @param dir: The path name for a user directory. + """ + dir = os.fsdecode(dir) + if not os.path.isdir(dir): + os.mkdir(dir, 0o700) + for subdir in ["new", "cur", "tmp", ".Trash"]: + os.mkdir(os.path.join(dir, subdir), 0o700) + for subdir in ["new", "cur", "tmp"]: + os.mkdir(os.path.join(dir, ".Trash", subdir), 0o700) + # touch + open(os.path.join(dir, ".Trash", "maildirfolder"), "w").close() + + +class MaildirMessage(mail.FileMessage): + """ + A message receiver which adds a header and delivers a message to a file + whose name includes the size of the message. + + @type size: L{int} + @ivar size: The number of octets in the message. + """ + + size = None + + def __init__(self, address, fp, *a, **kw): + """ + @type address: L{bytes} + @param address: The address of the message recipient. + + @type fp: file-like object + @param fp: The file in which to store the message while it is being + received. + + @type a: 2-L{tuple} of (0) L{bytes}, (1) L{bytes} + @param a: Positional arguments for L{FileMessage.__init__}. + + @type kw: L{dict} + @param kw: Keyword arguments for L{FileMessage.__init__}. + """ + header = b"Delivered-To: %s\n" % address + fp.write(header) + self.size = len(header) + mail.FileMessage.__init__(self, fp, *a, **kw) + + def lineReceived(self, line): + """ + Write a line to the file. + + @type line: L{bytes} + @param line: A received line. + """ + mail.FileMessage.lineReceived(self, line) + self.size += len(line) + 1 + + def eomReceived(self): + """ + At the end of message, rename the file holding the message to its final + name concatenated with the size of the file. + + @rtype: L{Deferred <defer.Deferred>} which successfully results in + L{bytes} + @return: A deferred which returns the name of the file holding the + message. + """ + self.finalName = self.finalName + ",S=%d" % self.size + return mail.FileMessage.eomReceived(self) + + +@implementer(mail.IAliasableDomain) +class AbstractMaildirDomain: + """ + An abstract maildir-backed domain. + + @type alias: L{None} or L{dict} mapping + L{bytes} to L{AliasBase} + @ivar alias: A mapping of username to alias. + + @ivar root: See L{__init__}. + """ + + alias = None + root = None + + def __init__(self, service, root): + """ + @type service: L{MailService} + @param service: An email service. + + @type root: L{bytes} + @param root: The maildir root directory. + """ + self.root = root + + def userDirectory(self, user): + """ + Return the maildir directory for a user. + + @type user: L{bytes} + @param user: A username. + + @rtype: L{bytes} or L{None} + @return: The user's mail directory for a valid user. Otherwise, + L{None}. + """ + return None + + def setAliasGroup(self, alias): + """ + Set the group of defined aliases for this domain. + + @type alias: L{dict} mapping L{bytes} to L{IAlias} provider. + @param alias: A mapping of domain name to alias. + """ + self.alias = alias + + def exists(self, user, memo=None): + """ + Check whether a user exists in this domain or an alias of it. + + @type user: L{User} + @param user: A user. + + @type memo: L{None} or L{dict} of L{AliasBase} + @param memo: A record of the addresses already considered while + resolving aliases. The default value should be used by all + external code. + + @rtype: no-argument callable which returns L{IMessage <smtp.IMessage>} + provider. + @return: A function which takes no arguments and returns a message + receiver for the user. + + @raises SMTPBadRcpt: When the given user does not exist in this domain + or an alias of it. + """ + if self.userDirectory(user.dest.local) is not None: + return lambda: self.startMessage(user) + try: + a = self.alias[user.dest.local] + except BaseException: + raise smtp.SMTPBadRcpt(user) + else: + aliases = a.resolve(self.alias, memo) + if aliases: + return lambda: aliases + log.err("Bad alias configuration: " + str(user)) + raise smtp.SMTPBadRcpt(user) + + def startMessage(self, user): + """ + Create a maildir message for a user. + + @type user: L{bytes} + @param user: A username. + + @rtype: L{MaildirMessage} + @return: A message receiver for this user. + """ + if isinstance(user, str): + name, domain = user.split("@", 1) + else: + name, domain = user.dest.local, user.dest.domain + dir = self.userDirectory(name) + fname = _generateMaildirName() + filename = os.path.join(dir, "tmp", fname) + fp = open(filename, "w") + return MaildirMessage( + f"{name}@{domain}", fp, filename, os.path.join(dir, "new", fname) + ) + + def willRelay(self, user, protocol): + """ + Check whether this domain will relay. + + @type user: L{Address} + @param user: The destination address. + + @type protocol: L{SMTP} + @param protocol: The protocol over which the message to be relayed is + being received. + + @rtype: L{bool} + @return: An indication of whether this domain will relay the message to + the destination. + """ + return False + + def addUser(self, user, password): + """ + Add a user to this domain. + + Subclasses should override this method. + + @type user: L{bytes} + @param user: A username. + + @type password: L{bytes} + @param password: A password. + """ + raise NotImplementedError + + def getCredentialsCheckers(self): + """ + Return credentials checkers for this domain. + + Subclasses should override this method. + + @rtype: L{list} of L{ICredentialsChecker + <checkers.ICredentialsChecker>} provider + @return: Credentials checkers for this domain. + """ + raise NotImplementedError + + +@implementer(interfaces.IConsumer) +class _MaildirMailboxAppendMessageTask: + """ + A task which adds a message to a maildir mailbox. + + @ivar mbox: See L{__init__}. + + @type defer: L{Deferred <defer.Deferred>} which successfully returns + L{None} + @ivar defer: A deferred which fires when the task has completed. + + @type opencall: L{IDelayedCall <interfaces.IDelayedCall>} provider or + L{None} + @ivar opencall: A scheduled call to L{prodProducer}. + + @type msg: file-like object + @ivar msg: The message to add. + + @type tmpname: L{bytes} + @ivar tmpname: The pathname of the temporary file holding the message while + it is being transferred. + + @type fh: file + @ivar fh: The new maildir file. + + @type filesender: L{FileSender <basic.FileSender>} + @ivar filesender: A file sender which sends the message. + + @type myproducer: L{IProducer <interfaces.IProducer>} + @ivar myproducer: The registered producer. + + @type streaming: L{bool} + @ivar streaming: Indicates whether the registered producer provides a + streaming interface. + """ + + osopen = staticmethod(os.open) + oswrite = staticmethod(os.write) + osclose = staticmethod(os.close) + osrename = staticmethod(os.rename) + + def __init__(self, mbox, msg): + """ + @type mbox: L{MaildirMailbox} + @param mbox: A maildir mailbox. + + @type msg: L{bytes} or file-like object + @param msg: The message to add. + """ + self.mbox = mbox + self.defer = defer.Deferred() + self.openCall = None + if not hasattr(msg, "read"): + msg = io.BytesIO(msg) + self.msg = msg + + def startUp(self): + """ + Start transferring the message to the mailbox. + """ + self.createTempFile() + if self.fh != -1: + self.filesender = basic.FileSender() + self.filesender.beginFileTransfer(self.msg, self) + + def registerProducer(self, producer, streaming): + """ + Register a producer and start asking it for data if it is + non-streaming. + + @type producer: L{IProducer <interfaces.IProducer>} + @param producer: A producer. + + @type streaming: L{bool} + @param streaming: A flag indicating whether the producer provides a + streaming interface. + """ + self.myproducer = producer + self.streaming = streaming + if not streaming: + self.prodProducer() + + def prodProducer(self): + """ + Repeatedly prod a non-streaming producer to produce data. + """ + self.openCall = None + if self.myproducer is not None: + self.openCall = reactor.callLater(0, self.prodProducer) + self.myproducer.resumeProducing() + + def unregisterProducer(self): + """ + Finish transferring the message to the mailbox. + """ + self.myproducer = None + self.streaming = None + self.osclose(self.fh) + self.moveFileToNew() + + def write(self, data): + """ + Write data to the maildir file. + + @type data: L{bytes} + @param data: Data to be written to the file. + """ + try: + self.oswrite(self.fh, data) + except BaseException: + self.fail() + + def fail(self, err=None): + """ + Fire the deferred to indicate the task completed with a failure. + + @type err: L{Failure <failure.Failure>} + @param err: The error that occurred. + """ + if err is None: + err = failure.Failure() + if self.openCall is not None: + self.openCall.cancel() + self.defer.errback(err) + self.defer = None + + def moveFileToNew(self): + """ + Place the message in the I{new/} directory, add it to the mailbox and + fire the deferred to indicate that the task has completed + successfully. + """ + while True: + newname = os.path.join(self.mbox.path, "new", _generateMaildirName()) + try: + self.osrename(self.tmpname, newname) + break + except OSError as e: + (err, estr) = e.args + import errno + + # if the newname exists, retry with a new newname. + if err != errno.EEXIST: + self.fail() + newname = None + break + if newname is not None: + self.mbox.list.append(newname) + self.defer.callback(None) + self.defer = None + + def createTempFile(self): + """ + Create a temporary file to hold the message as it is being transferred. + """ + attr = ( + os.O_RDWR + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_NOINHERIT", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + tries = 0 + self.fh = -1 + while True: + self.tmpname = os.path.join(self.mbox.path, "tmp", _generateMaildirName()) + try: + self.fh = self.osopen(self.tmpname, attr, 0o600) + return None + except OSError: + tries += 1 + if tries > 500: + self.defer.errback( + RuntimeError( + "Could not create tmp file for %s" % self.mbox.path + ) + ) + self.defer = None + return None + + +class MaildirMailbox(pop3.Mailbox): + """ + A maildir-backed mailbox. + + @ivar path: See L{__init__}. + + @type list: L{list} of L{int} or 2-L{tuple} of (0) file-like object, + (1) L{bytes} + @ivar list: Information about the messages in the mailbox. For undeleted + messages, the file containing the message and the + full path name of the file are stored. Deleted messages are indicated + by 0. + + @type deleted: L{dict} mapping 2-L{tuple} of (0) file-like object, + (1) L{bytes} to L{bytes} + @type deleted: A mapping of the information about a file before it was + deleted to the full path name of the deleted file in the I{.Trash/} + subfolder. + """ + + AppendFactory = _MaildirMailboxAppendMessageTask + + def __init__(self, path): + """ + @type path: L{bytes} + @param path: The directory name for a maildir mailbox. + """ + self.path = path + self.list = [] + self.deleted = {} + initializeMaildir(path) + for name in ("cur", "new"): + for file in os.listdir(os.path.join(path, name)): + self.list.append((file, os.path.join(path, name, file))) + self.list.sort() + self.list = [e[1] for e in self.list] + + def listMessages(self, i=None): + """ + Retrieve the size of a message, or, if none is specified, the size of + each message in the mailbox. + + @type i: L{int} or L{None} + @param i: The 0-based index of a message. + + @rtype: L{int} or L{list} of L{int} + @return: The number of octets in the specified message, or, if an index + is not specified, a list of the number of octets for all messages + in the mailbox. Any value which corresponds to a deleted message + is set to 0. + + @raise IndexError: When the index does not correspond to a message in + the mailbox. + """ + if i is None: + ret = [] + for mess in self.list: + if mess: + ret.append(os.stat(mess)[stat.ST_SIZE]) + else: + ret.append(0) + return ret + return self.list[i] and os.stat(self.list[i])[stat.ST_SIZE] or 0 + + def getMessage(self, i): + """ + Retrieve a file-like object with the contents of a message. + + @type i: L{int} + @param i: The 0-based index of a message. + + @rtype: file-like object + @return: A file containing the message. + + @raise IndexError: When the index does not correspond to a message in + the mailbox. + """ + return open(self.list[i]) + + def getUidl(self, i): + """ + Get a unique identifier for a message. + + @type i: L{int} + @param i: The 0-based index of a message. + + @rtype: L{bytes} + @return: A string of printable characters uniquely identifying the + message for all time. + + @raise IndexError: When the index does not correspond to a message in + the mailbox. + """ + # Returning the actual filename is a mistake. Hash it. + base = os.path.basename(self.list[i]) + return md5(base).hexdigest() + + def deleteMessage(self, i): + """ + Mark a message for deletion. + + Move the message to the I{.Trash/} subfolder so it can be undeleted + by an administrator. + + @type i: L{int} + @param i: The 0-based index of a message. + + @raise IndexError: When the index does not correspond to a message in + the mailbox. + """ + trashFile = os.path.join( + self.path, ".Trash", "cur", os.path.basename(self.list[i]) + ) + os.rename(self.list[i], trashFile) + self.deleted[self.list[i]] = trashFile + self.list[i] = 0 + + def undeleteMessages(self): + """ + Undelete all messages marked for deletion. + + Move each message marked for deletion from the I{.Trash/} subfolder back + to its original position. + """ + for real, trash in self.deleted.items(): + try: + os.rename(trash, real) + except OSError as e: + (err, estr) = e.args + import errno + + # If the file has been deleted from disk, oh well! + if err != errno.ENOENT: + raise + # This is a pass + else: + try: + self.list[self.list.index(0)] = real + except ValueError: + self.list.append(real) + self.deleted.clear() + + def appendMessage(self, txt): + """ + Add a message to the mailbox. + + @type txt: L{bytes} or file-like object + @param txt: A message to add. + + @rtype: L{Deferred <defer.Deferred>} + @return: A deferred which fires when the message has been added to + the mailbox. + """ + task = self.AppendFactory(self, txt) + result = task.defer + task.startUp() + return result + + +@implementer(pop3.IMailbox) +class StringListMailbox: + """ + An in-memory mailbox. + + @ivar msgs: See L{__init__}. + + @type _delete: L{set} of L{int} + @ivar _delete: The indices of messages which have been marked for deletion. + """ + + def __init__(self, msgs): + """ + @type msgs: L{list} of L{bytes} + @param msgs: The contents of each message in the mailbox. + """ + self.msgs = msgs + self._delete = set() + + def listMessages(self, i=None): + """ + Retrieve the size of a message, or, if none is specified, the size of + each message in the mailbox. + + @type i: L{int} or L{None} + @param i: The 0-based index of a message. + + @rtype: L{int} or L{list} of L{int} + @return: The number of octets in the specified message, or, if an index + is not specified, a list of the number of octets in each message in + the mailbox. Any value which corresponds to a deleted message is + set to 0. + + @raise IndexError: When the index does not correspond to a message in + the mailbox. + """ + if i is None: + return [self.listMessages(msg) for msg in range(len(self.msgs))] + if i in self._delete: + return 0 + return len(self.msgs[i]) + + def getMessage(self, i: int) -> IO[bytes]: + """ + Return an in-memory file-like object with the contents of a message. + + @param i: The 0-based index of a message. + + @return: An in-memory file-like object containing the message. + + @raise IndexError: When the index does not correspond to a message in + the mailbox. + """ + return io.BytesIO(self.msgs[i]) + + def getUidl(self, i): + """ + Get a unique identifier for a message. + + @type i: L{int} + @param i: The 0-based index of a message. + + @rtype: L{bytes} + @return: A hash of the contents of the message at the given index. + + @raise IndexError: When the index does not correspond to a message in + the mailbox. + """ + return md5(self.msgs[i]).hexdigest() + + def deleteMessage(self, i): + """ + Mark a message for deletion. + + @type i: L{int} + @param i: The 0-based index of a message to delete. + + @raise IndexError: When the index does not correspond to a message in + the mailbox. + """ + self._delete.add(i) + + def undeleteMessages(self): + """ + Undelete any messages which have been marked for deletion. + """ + self._delete = set() + + def sync(self): + """ + Discard the contents of any messages marked for deletion. + """ + for index in self._delete: + self.msgs[index] = "" + self._delete = set() + + +@implementer(portal.IRealm) +class MaildirDirdbmDomain(AbstractMaildirDomain): + """ + A maildir-backed domain where membership is checked with a + L{DirDBM <dirdbm.DirDBM>} database. + + The directory structure of a MaildirDirdbmDomain is: + + /passwd <-- a DirDBM directory + + /USER/{cur, new, del} <-- each user has these three directories + + @ivar postmaster: See L{__init__}. + + @type dbm: L{DirDBM <dirdbm.DirDBM>} + @ivar dbm: The authentication database for the domain. + """ + + portal = None + _credcheckers = None + + def __init__(self, service, root, postmaster=0): + """ + @type service: L{MailService} + @param service: An email service. + + @type root: L{bytes} + @param root: The maildir root directory. + + @type postmaster: L{bool} + @param postmaster: A flag indicating whether non-existent addresses + should be forwarded to the postmaster (C{True}) or + bounced (C{False}). + """ + root = os.fsencode(root) + AbstractMaildirDomain.__init__(self, service, root) + dbm = os.path.join(root, b"passwd") + if not os.path.exists(dbm): + os.makedirs(dbm) + self.dbm = dirdbm.open(dbm) + self.postmaster = postmaster + + def userDirectory(self, name): + """ + Return the path to a user's mail directory. + + @type name: L{bytes} + @param name: A username. + + @rtype: L{bytes} or L{None} + @return: The path to the user's mail directory for a valid user. For + an invalid user, the path to the postmaster's mailbox if bounces + are redirected there. Otherwise, L{None}. + """ + if name not in self.dbm: + if not self.postmaster: + return None + name = "postmaster" + dir = os.path.join(self.root, name) + if not os.path.exists(dir): + initializeMaildir(dir) + return dir + + def addUser(self, user, password): + """ + Add a user to this domain by adding an entry in the authentication + database and initializing the user's mail directory. + + @type user: L{bytes} + @param user: A username. + + @type password: L{bytes} + @param password: A password. + """ + self.dbm[user] = password + # Ensure it is initialized + self.userDirectory(user) + + def getCredentialsCheckers(self): + """ + Return credentials checkers for this domain. + + @rtype: L{list} of L{ICredentialsChecker + <checkers.ICredentialsChecker>} provider + @return: Credentials checkers for this domain. + """ + if self._credcheckers is None: + self._credcheckers = [DirdbmDatabase(self.dbm)] + return self._credcheckers + + def requestAvatar(self, avatarId, mind, *interfaces): + """ + Get the mailbox for an authenticated user. + + The mailbox for the authenticated user will be returned only if the + given interfaces include L{IMailbox <pop3.IMailbox>}. Requests for + anonymous access will be met with a mailbox containing a message + indicating that an internal error has occurred. + + @type avatarId: L{bytes} or C{twisted.cred.checkers.ANONYMOUS} + @param avatarId: A string which identifies a user or an object which + signals a request for anonymous access. + + @type mind: L{None} + @param mind: Unused. + + @type interfaces: n-L{tuple} of C{zope.interface.Interface} + @param interfaces: A group of interfaces, one of which the avatar + must support. + + @rtype: 3-L{tuple} of (0) L{IMailbox <pop3.IMailbox>}, + (1) L{IMailbox <pop3.IMailbox>} provider, (2) no-argument + callable + @return: A tuple of the supported interface, a mailbox, and a + logout function. + + @raise NotImplementedError: When the given interfaces do not include + L{IMailbox <pop3.IMailbox>}. + """ + if pop3.IMailbox not in interfaces: + raise NotImplementedError("No interface") + if avatarId == checkers.ANONYMOUS: + mbox = StringListMailbox([INTERNAL_ERROR]) + else: + mbox = MaildirMailbox(os.path.join(self.root, avatarId)) + + return (pop3.IMailbox, mbox, lambda: None) + + +@implementer(checkers.ICredentialsChecker) +class DirdbmDatabase: + """ + A credentials checker which authenticates users out of a + L{DirDBM <dirdbm.DirDBM>} database. + + @type dirdbm: L{DirDBM <dirdbm.DirDBM>} + @ivar dirdbm: An authentication database. + """ + + # credentialInterfaces is not used by the class + credentialInterfaces = ( + credentials.IUsernamePassword, + credentials.IUsernameHashedPassword, + ) + + def __init__(self, dbm): + """ + @type dbm: L{DirDBM <dirdbm.DirDBM>} + @param dbm: An authentication database. + """ + self.dirdbm = dbm + + def requestAvatarId(self, c): + """ + Authenticate a user and, if successful, return their username. + + @type c: L{IUsernamePassword <credentials.IUsernamePassword>} or + L{IUsernameHashedPassword <credentials.IUsernameHashedPassword>} + provider. + @param c: Credentials. + + @rtype: L{bytes} + @return: A string which identifies an user. + + @raise UnauthorizedLogin: When the credentials check fails. + """ + if c.username in self.dirdbm: + if c.checkPassword(self.dirdbm[c.username]): + return c.username + raise UnauthorizedLogin() diff --git a/contrib/python/Twisted/py3/twisted/mail/newsfragments/.gitignore b/contrib/python/Twisted/py3/twisted/mail/newsfragments/.gitignore new file mode 100644 index 0000000000..f935021a8f --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/newsfragments/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/contrib/python/Twisted/py3/twisted/mail/pb.py b/contrib/python/Twisted/py3/twisted/mail/pb.py new file mode 100644 index 0000000000..1b57e26c95 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/pb.py @@ -0,0 +1,117 @@ +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + + +import os + +from twisted.spread import pb + + +class Maildir(pb.Referenceable): + def __init__(self, directory, rootDirectory): + self.virtualDirectory = directory + self.rootDirectory = rootDirectory + self.directory = os.path.join(rootDirectory, directory) + + def getFolderMessage(self, folder, name): + if "/" in name: + raise OSError("can only open files in '%s' directory'" % folder) + with open(os.path.join(self.directory, "new", name)) as fp: + return fp.read() + + def deleteFolderMessage(self, folder, name): + if "/" in name: + raise OSError("can only delete files in '%s' directory'" % folder) + os.rename( + os.path.join(self.directory, folder, name), + os.path.join(self.rootDirectory, ".Trash", folder, name), + ) + + def deleteNewMessage(self, name): + return self.deleteFolderMessage("new", name) + + remote_deleteNewMessage = deleteNewMessage + + def deleteCurMessage(self, name): + return self.deleteFolderMessage("cur", name) + + remote_deleteCurMessage = deleteCurMessage + + def getNewMessages(self): + return os.listdir(os.path.join(self.directory, "new")) + + remote_getNewMessages = getNewMessages + + def getCurMessages(self): + return os.listdir(os.path.join(self.directory, "cur")) + + remote_getCurMessages = getCurMessages + + def getNewMessage(self, name): + return self.getFolderMessage("new", name) + + remote_getNewMessage = getNewMessage + + def getCurMessage(self, name): + return self.getFolderMessage("cur", name) + + remote_getCurMessage = getCurMessage + + def getSubFolder(self, name): + if name[0] == ".": + raise OSError("subfolder name cannot begin with a '.'") + name = name.replace("/", ":") + if self.virtualDirectoy == ".": + name = "." + name + else: + name = self.virtualDirectory + ":" + name + if not self._isSubFolder(name): + raise OSError("not a subfolder") + return Maildir(name, self.rootDirectory) + + remote_getSubFolder = getSubFolder + + def _isSubFolder(self, name): + return not os.path.isdir( + os.path.join(self.rootDirectory, name) + ) or not os.path.isfile(os.path.join(self.rootDirectory, name, "maildirfolder")) + + +class MaildirCollection(pb.Referenceable): + def __init__(self, root): + self.root = root + + def getSubFolders(self): + return os.listdir(self.getRoot()) + + remote_getSubFolders = getSubFolders + + def getSubFolder(self, name): + if "/" in name or name[0] == ".": + raise OSError("invalid name") + return Maildir(".", os.path.join(self.getRoot(), name)) + + remote_getSubFolder = getSubFolder + + +class MaildirBroker(pb.Broker): + def proto_getCollection(self, requestID, name, domain, password): + collection = self._getCollection() + if collection is None: + self.sendError(requestID, "permission denied") + else: + self.sendAnswer(requestID, collection) + + def getCollection(self, name, domain, password): + if domain not in self.domains: + return + domain = self.domains[domain] + if name in domain.dbm and domain.dbm[name] == password: + return MaildirCollection(domain.userDirectory(name)) + + +class MaildirClient(pb.Broker): + def getCollection(self, name, domain, password, callback, errback): + requestID = self.newRequestID() + self.waitingForAnswers[requestID] = callback, errback + self.sendCall("getCollection", requestID, name, domain, password) diff --git a/contrib/python/Twisted/py3/twisted/mail/pop3.py b/contrib/python/Twisted/py3/twisted/mail/pop3.py new file mode 100644 index 0000000000..7b230d2059 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/pop3.py @@ -0,0 +1,1704 @@ +# -*- test-case-name: twisted.mail.test.test_pop3 -*- +# +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + + +""" +Post-office Protocol version 3. + +@author: Glyph Lefkowitz +@author: Jp Calderone +""" + +import base64 +import binascii +import warnings +from hashlib import md5 +from typing import Optional + +from zope.interface import implementer + +from twisted import cred +from twisted.internet import defer, interfaces, task +from twisted.mail import smtp +from twisted.mail._except import POP3ClientError, POP3Error, _POP3MessageDeleted +from twisted.mail.interfaces import ( + IMailboxPOP3 as IMailbox, + IServerFactoryPOP3 as IServerFactory, +) +from twisted.protocols import basic, policies +from twisted.python import log + + +# Authentication +@implementer(cred.credentials.IUsernameHashedPassword) +class APOPCredentials: + """ + Credentials for use in APOP authentication. + + @ivar magic: See L{__init__} + @ivar username: See L{__init__} + @ivar digest: See L{__init__} + """ + + def __init__(self, magic, username, digest): + """ + @type magic: L{bytes} + @param magic: The challenge string used to encrypt the password. + + @type username: L{bytes} + @param username: The username associated with these credentials. + + @type digest: L{bytes} + @param digest: An encrypted version of the user's password. Should be + generated as an MD5 hash of the challenge string concatenated with + the plaintext password. + """ + self.magic = magic + self.username = username + self.digest = digest + + def checkPassword(self, password): + """ + Validate a plaintext password against the credentials. + + @type password: L{bytes} + @param password: A plaintext password. + + @rtype: L{bool} + @return: C{True} if the credentials represented by this object match + the given password, C{False} if they do not. + """ + seed = self.magic + password + myDigest = md5(seed).hexdigest() + return myDigest == self.digest + + +class _HeadersPlusNLines: + """ + A utility class to retrieve the header and some lines of the body of a mail + message. + + @ivar _file: See L{__init__} + @ivar _extraLines: See L{__init__} + + @type linecount: L{int} + @ivar linecount: The number of full lines of the message body scanned. + + @type headers: L{bool} + @ivar headers: An indication of which part of the message is being scanned. + C{True} for the header and C{False} for the body. + + @type done: L{bool} + @ivar done: A flag indicating when the desired part of the message has been + scanned. + + @type buf: L{bytes} + @ivar buf: The portion of the message body that has been scanned, up to + C{n} lines. + """ + + def __init__(self, file, extraLines): + """ + @type file: file-like object + @param file: A file containing a mail message. + + @type extraLines: L{int} + @param extraLines: The number of lines of the message body to retrieve. + """ + self._file = file + self._extraLines = extraLines + self.linecount = 0 + self.headers = 1 + self.done = 0 + self.buf = b"" + + def read(self, bytes): + """ + Scan bytes from the file. + + @type bytes: L{int} + @param bytes: The number of bytes to read from the file. + + @rtype: L{bytes} + @return: Each portion of the header as it is scanned. Then, full lines + of the message body as they are scanned. When more than one line + of the header and/or body has been scanned, the result is the + concatenation of the lines. When the scan results in no full + lines, the empty string is returned. + """ + if self.done: + return b"" + data = self._file.read(bytes) + if not data: + return data + if self.headers: + df, sz = data.find(b"\r\n\r\n"), 4 + if df == -1: + df, sz = data.find(b"\n\n"), 2 + if df != -1: + df += sz + val = data[:df] + data = data[df:] + self.linecount = 1 + self.headers = 0 + else: + val = b"" + if self.linecount > 0: + dsplit = (self.buf + data).split(b"\n") + self.buf = dsplit[-1] + for ln in dsplit[:-1]: + if self.linecount > self._extraLines: + self.done = 1 + return val + val += ln + b"\n" + self.linecount += 1 + return val + else: + return data + + +class _IteratorBuffer: + """ + An iterator which buffers the elements of a container and periodically + passes them as input to a writer. + + @ivar write: See L{__init__}. + @ivar memoryBufferSize: See L{__init__}. + + @type bufSize: L{int} + @ivar bufSize: The number of bytes currently in the buffer. + + @type lines: L{list} of L{bytes} + @ivar lines: The buffer, which is a list of strings. + + @type iterator: iterator which yields L{bytes} + @ivar iterator: An iterator over a container of strings. + """ + + bufSize = 0 + + def __init__(self, write, iterable, memoryBufferSize=None): + """ + @type write: callable that takes L{list} of L{bytes} + @param write: A writer which is a callable that takes a list of + strings. + + @type iterable: iterable which yields L{bytes} + @param iterable: An iterable container of strings. + + @type memoryBufferSize: L{int} or L{None} + @param memoryBufferSize: The number of bytes to buffer before flushing + the buffer to the writer. + """ + self.lines = [] + self.write = write + self.iterator = iter(iterable) + if memoryBufferSize is None: + memoryBufferSize = 2**16 + self.memoryBufferSize = memoryBufferSize + + def __iter__(self): + """ + Return an iterator. + + @rtype: iterator which yields L{bytes} + @return: An iterator over strings. + """ + return self + + def __next__(self): + """ + Get the next string from the container, buffer it, and possibly send + the buffer to the writer. + + The contents of the buffer are written when it is full or when no + further values are available from the container. + + @raise StopIteration: When no further values are available from the + container. + """ + try: + v = next(self.iterator) + except StopIteration: + if self.lines: + self.write(self.lines) + # Drop some references, in case they're edges in a cycle. + del self.iterator, self.lines, self.write + raise + else: + if v is not None: + self.lines.append(v) + self.bufSize += len(v) + if self.bufSize > self.memoryBufferSize: + self.write(self.lines) + self.lines = [] + self.bufSize = 0 + + next = __next__ + + +def iterateLineGenerator(proto, gen): + """ + Direct the output of an iterator to the transport of a protocol and arrange + for iteration to take place. + + @type proto: L{POP3} + @param proto: A POP3 server protocol. + + @type gen: iterator which yields L{bytes} + @param gen: An iterator over strings. + + @rtype: L{Deferred <defer.Deferred>} + @return: A deferred which fires when the iterator finishes. + """ + coll = _IteratorBuffer(proto.transport.writeSequence, gen) + return proto.schedule(coll) + + +def successResponse(response): + """ + Format an object as a positive response. + + @type response: stringifyable L{object} + @param response: An object with a string representation. + + @rtype: L{bytes} + @return: A positive POP3 response string. + """ + if not isinstance(response, bytes): + response = str(response).encode("utf-8") + return b"+OK " + response + b"\r\n" + + +def formatStatResponse(msgs): + """ + Format a list of message sizes into a STAT response. + + This generator function is intended to be used with + L{Cooperator <twisted.internet.task.Cooperator>}. + + @type msgs: L{list} of L{int} + @param msgs: A list of message sizes. + + @rtype: L{None} or L{bytes} + @return: Yields none until a result is available, then a string that is + suitable for use in a STAT response. The string consists of the number + of messages and the total size of the messages in octets. + """ + i = 0 + bytes = 0 + for size in msgs: + i += 1 + bytes += size + yield None + yield successResponse(b"%d %d" % (i, bytes)) + + +def formatListLines(msgs): + """ + Format a list of message sizes for use in a LIST response. + + @type msgs: L{list} of L{int} + @param msgs: A list of message sizes. + + @rtype: L{bytes} + @return: Yields a series of strings that are suitable for use as scan + listings in a LIST response. Each string consists of a message number + and its size in octets. + """ + i = 0 + for size in msgs: + i += 1 + yield b"%d %d\r\n" % (i, size) + + +def formatListResponse(msgs): + """ + Format a list of message sizes into a complete LIST response. + + This generator function is intended to be used with + L{Cooperator <twisted.internet.task.Cooperator>}. + + @type msgs: L{list} of L{int} + @param msgs: A list of message sizes. + + @rtype: L{bytes} + @return: Yields a series of strings which make up a complete LIST response. + """ + yield successResponse(b"%d" % (len(msgs),)) + yield from formatListLines(msgs) + yield b".\r\n" + + +def formatUIDListLines(msgs, getUidl): + """ + Format a list of message sizes for use in a UIDL response. + + @param msgs: See L{formatUIDListResponse} + @param getUidl: See L{formatUIDListResponse} + + @rtype: L{bytes} + @return: Yields a series of strings that are suitable for use as unique-id + listings in a UIDL response. Each string consists of a message number + and its unique id. + """ + for i, m in enumerate(msgs): + if m is not None: + uid = getUidl(i) + if not isinstance(uid, bytes): + uid = str(uid).encode("utf-8") + yield b"%d %b\r\n" % (i + 1, uid) + + +def formatUIDListResponse(msgs, getUidl): + """ + Format a list of message sizes into a complete UIDL response. + + This generator function is intended to be used with + L{Cooperator <twisted.internet.task.Cooperator>}. + + @type msgs: L{list} of L{int} + @param msgs: A list of message sizes. + + @type getUidl: one-argument callable returning bytes + @param getUidl: A callable which takes a message index number and returns + the UID of the corresponding message in the mailbox. + + @rtype: L{bytes} + @return: Yields a series of strings which make up a complete UIDL response. + """ + yield successResponse("") + yield from formatUIDListLines(msgs, getUidl) + yield b".\r\n" + + +@implementer(interfaces.IProducer) +class POP3(basic.LineOnlyReceiver, policies.TimeoutMixin): + """ + A POP3 server protocol. + + @type portal: L{Portal} + @ivar portal: A portal for authentication. + + @type factory: L{IServerFactory} provider + @ivar factory: A server factory which provides an interface for querying + capabilities of the server. + + @type timeOut: L{int} + @ivar timeOut: The number of seconds to wait for a command from the client + before disconnecting. + + @type schedule: callable that takes interator and returns + L{Deferred <defer.Deferred>} + @ivar schedule: A callable that arranges for an iterator to be + cooperatively iterated over along with all other iterators which have + been passed to it such that runtime is divided between all of them. It + returns a deferred which fires when the iterator finishes. + + @type magic: L{bytes} or L{None} + @ivar magic: An APOP challenge. If not set, an APOP challenge string + will be generated when a connection is made. + + @type _userIs: L{bytes} or L{None} + @ivar _userIs: The username sent with the USER command. + + @type _onLogout: no-argument callable or L{None} + @ivar _onLogout: The function to be executed when the connection is + lost. + + @type mbox: L{IMailbox} provider + @ivar mbox: The mailbox for the authenticated user. + + @type state: L{bytes} + @ivar state: The state which indicates what type of messages are expected + from the client. Valid states are 'COMMAND' and 'AUTH' + + @type blocked: L{None} or L{list} of 2-L{tuple} of + (E{1}) L{bytes} (E{2}) L{tuple} of L{bytes} + @ivar blocked: A list of blocked commands. While a response to a command + is being generated by the server, other commands are blocked. When + no command is outstanding, C{blocked} is set to none. Otherwise, it + contains a list of information about blocked commands. Each list + entry consists of the command and the arguments to the command. + + @type _highest: L{int} + @ivar _highest: The 1-based index of the highest message retrieved. + + @type _auth: L{IUsernameHashedPassword + <cred.credentials.IUsernameHashedPassword>} provider + @ivar _auth: Authorization credentials. + """ + + magic: Optional[bytes] = None + _userIs = None + _onLogout = None + + AUTH_CMDS = [b"CAPA", b"USER", b"PASS", b"APOP", b"AUTH", b"RPOP", b"QUIT"] + + portal = None + factory = None + + # The mailbox we're serving + mbox = None + + # Set this pretty low -- POP3 clients are expected to log in, download + # everything, and log out. + timeOut = 300 + + state = "COMMAND" + + # PIPELINE + blocked = None + + # Cooperate and suchlike. + schedule = staticmethod(task.coiterate) + + _highest = 0 + + def connectionMade(self): + """ + Send a greeting to the client after the connection has been made. + """ + if self.magic is None: + self.magic = self.generateMagic() + self.successResponse(self.magic) + self.setTimeout(self.timeOut) + if getattr(self.factory, "noisy", True): + log.msg("New connection from " + str(self.transport.getPeer())) + + def connectionLost(self, reason): + """ + Clean up when the connection has been lost. + + @type reason: L{Failure} + @param reason: The reason the connection was terminated. + """ + if self._onLogout is not None: + self._onLogout() + self._onLogout = None + self.setTimeout(None) + + def generateMagic(self): + """ + Generate an APOP challenge. + + @rtype: L{bytes} + @return: An RFC 822 message id format string. + """ + return smtp.messageid() + + def successResponse(self, message=""): + """ + Send a response indicating success. + + @type message: stringifyable L{object} + @param message: An object whose string representation should be + included in the response. + """ + self.transport.write(successResponse(message)) + + def failResponse(self, message=b""): + """ + Send a response indicating failure. + + @type message: stringifyable L{object} + @param message: An object whose string representation should be + included in the response. + """ + if not isinstance(message, bytes): + message = str(message).encode("utf-8") + self.sendLine(b"-ERR " + message) + + def lineReceived(self, line): + """ + Pass a received line to a state machine function. + + @type line: L{bytes} + @param line: A received line. + """ + self.resetTimeout() + getattr(self, "state_" + self.state)(line) + + def _unblock(self, _): + """ + Process as many blocked commands as possible. + + If there are no more blocked commands, set up for the next command to + be sent immediately. + + @type _: L{object} + @param _: Ignored. + """ + commands = self.blocked + self.blocked = None + while commands and self.blocked is None: + cmd, args = commands.pop(0) + self.processCommand(cmd, *args) + if self.blocked is not None: + self.blocked.extend(commands) + + def state_COMMAND(self, line): + """ + Handle received lines for the COMMAND state in which commands from the + client are expected. + + @type line: L{bytes} + @param line: A received command. + """ + try: + return self.processCommand(*line.split(b" ")) + except (ValueError, AttributeError, POP3Error, TypeError) as e: + log.err() + self.failResponse( + b": ".join( + [ + b"bad protocol or server", + e.__class__.__name__.encode("utf-8"), + b"".join(e.args), + ] + ) + ) + + def processCommand(self, command, *args): + """ + Dispatch a command from the client for handling. + + @type command: L{bytes} + @param command: A POP3 command. + + @type args: L{tuple} of L{bytes} + @param args: Arguments to the command. + + @raise POP3Error: When the command is invalid or the command requires + prior authentication which hasn't been performed. + """ + if self.blocked is not None: + self.blocked.append((command, args)) + return + + command = command.upper() + authCmd = command in self.AUTH_CMDS + if not self.mbox and not authCmd: + raise POP3Error(b"not authenticated yet: cannot do " + command) + f = getattr(self, "do_" + command.decode("utf-8"), None) + if f: + return f(*args) + raise POP3Error(b"Unknown protocol command: " + command) + + def listCapabilities(self): + """ + Return a list of server capabilities suitable for use in a CAPA + response. + + @rtype: L{list} of L{bytes} + @return: A list of server capabilities. + """ + baseCaps = [ + b"TOP", + b"USER", + b"UIDL", + b"PIPELINE", + b"CELERITY", + b"AUSPEX", + b"POTENCE", + ] + + if IServerFactory.providedBy(self.factory): + # Oh my god. We can't just loop over a list of these because + # each has spectacularly different return value semantics! + try: + v = self.factory.cap_IMPLEMENTATION() + if v and not isinstance(v, bytes): + v = str(v).encode("utf-8") + except NotImplementedError: + pass + except BaseException: + log.err() + else: + baseCaps.append(b"IMPLEMENTATION " + v) + + try: + v = self.factory.cap_EXPIRE() + if v and not isinstance(v, bytes): + v = str(v).encode("utf-8") + except NotImplementedError: + pass + except BaseException: + log.err() + else: + if v is None: + v = b"NEVER" + if self.factory.perUserExpiration(): + if self.mbox: + v = str(self.mbox.messageExpiration).encode("utf-8") + else: + v = v + b" USER" + baseCaps.append(b"EXPIRE " + v) + + try: + v = self.factory.cap_LOGIN_DELAY() + if v and not isinstance(v, bytes): + v = str(v).encode("utf-8") + except NotImplementedError: + pass + except BaseException: + log.err() + else: + if self.factory.perUserLoginDelay(): + if self.mbox: + v = str(self.mbox.loginDelay).encode("utf-8") + else: + v = v + b" USER" + baseCaps.append(b"LOGIN-DELAY " + v) + + try: + v = self.factory.challengers + except AttributeError: + pass + except BaseException: + log.err() + else: + baseCaps.append(b"SASL " + b" ".join(v.keys())) + return baseCaps + + def do_CAPA(self): + """ + Handle a CAPA command. + + Respond with the server capabilities. + """ + self.successResponse(b"I can do the following:") + for cap in self.listCapabilities(): + self.sendLine(cap) + self.sendLine(b".") + + def do_AUTH(self, args=None): + """ + Handle an AUTH command. + + If the AUTH extension is not supported, send an error response. If an + authentication mechanism was not specified in the command, send a list + of all supported authentication methods. Otherwise, send an + authentication challenge to the client and transition to the + AUTH state. + + @type args: L{bytes} or L{None} + @param args: The name of an authentication mechanism. + """ + if not getattr(self.factory, "challengers", None): + self.failResponse(b"AUTH extension unsupported") + return + + if args is None: + self.successResponse("Supported authentication methods:") + for a in self.factory.challengers: + self.sendLine(a.upper()) + self.sendLine(b".") + return + + auth = self.factory.challengers.get(args.strip().upper()) + if not self.portal or not auth: + self.failResponse(b"Unsupported SASL selected") + return + + self._auth = auth() + chal = self._auth.getChallenge() + + self.sendLine(b"+ " + base64.b64encode(chal)) + self.state = "AUTH" + + def state_AUTH(self, line): + """ + Handle received lines for the AUTH state in which an authentication + challenge response from the client is expected. + + Transition back to the COMMAND state. Check the credentials and + complete the authorization process with the L{_cbMailbox} + callback function on success or the L{_ebMailbox} and L{_ebUnexpected} + errback functions on failure. + + @type line: L{bytes} + @param line: The challenge response. + """ + self.state = "COMMAND" + try: + parts = base64.b64decode(line).split(None, 1) + except binascii.Error: + self.failResponse(b"Invalid BASE64 encoding") + else: + if len(parts) != 2: + self.failResponse(b"Invalid AUTH response") + return + self._auth.username = parts[0] + self._auth.response = parts[1] + d = self.portal.login(self._auth, None, IMailbox) + d.addCallback(self._cbMailbox, parts[0]) + d.addErrback(self._ebMailbox) + d.addErrback(self._ebUnexpected) + + def do_APOP(self, user, digest): + """ + Handle an APOP command. + + Perform APOP authentication and complete the authorization process with + the L{_cbMailbox} callback function on success or the L{_ebMailbox} + and L{_ebUnexpected} errback functions on failure. + + @type user: L{bytes} + @param user: A username. + + @type digest: L{bytes} + @param digest: An MD5 digest string. + """ + d = defer.maybeDeferred(self.authenticateUserAPOP, user, digest) + d.addCallbacks( + self._cbMailbox, self._ebMailbox, callbackArgs=(user,) + ).addErrback(self._ebUnexpected) + + def _cbMailbox(self, result, user): + """ + Complete successful authentication. + + Save the mailbox and logout function for the authenticated user and + send a successful response to the client. + + @type result: C{tuple} + @param result: The first item of the tuple is a + C{zope.interface.Interface} which is the interface + supported by the avatar. The second item of the tuple is a + L{IMailbox} provider which is the mailbox for the + authenticated user. The third item of the tuple is a no-argument + callable which is a function to be invoked when the session is + terminated. + + @type user: L{bytes} + @param user: The user being authenticated. + """ + (interface, avatar, logout) = result + if interface is not IMailbox: + self.failResponse(b"Authentication failed") + log.err("_cbMailbox() called with an interface other than IMailbox") + return + + self.mbox = avatar + self._onLogout = logout + self.successResponse("Authentication succeeded") + if getattr(self.factory, "noisy", True): + log.msg(b"Authenticated login for " + user) + + def _ebMailbox(self, failure): + """ + Handle an expected authentication failure. + + Send an appropriate error response for a L{LoginDenied} or + L{LoginFailed} authentication failure. + + @type failure: L{Failure} + @param failure: The authentication error. + """ + failure = failure.trap(cred.error.LoginDenied, cred.error.LoginFailed) + if issubclass(failure, cred.error.LoginDenied): + self.failResponse("Access denied: " + str(failure)) + elif issubclass(failure, cred.error.LoginFailed): + self.failResponse(b"Authentication failed") + if getattr(self.factory, "noisy", True): + log.msg("Denied login attempt from " + str(self.transport.getPeer())) + + def _ebUnexpected(self, failure): + """ + Handle an unexpected authentication failure. + + Send an error response for an unexpected authentication failure. + + @type failure: L{Failure} + @param failure: The authentication error. + """ + self.failResponse("Server error: " + failure.getErrorMessage()) + log.err(failure) + + def do_USER(self, user): + """ + Handle a USER command. + + Save the username and send a successful response prompting the client + for the password. + + @type user: L{bytes} + @param user: A username. + """ + self._userIs = user + self.successResponse(b"USER accepted, send PASS") + + def do_PASS(self, password, *words): + """ + Handle a PASS command. + + If a USER command was previously received, authenticate the user and + complete the authorization process with the L{_cbMailbox} callback + function on success or the L{_ebMailbox} and L{_ebUnexpected} errback + functions on failure. If a USER command was not previously received, + send an error response. + + @type password: L{bytes} + @param password: A password. + + @type words: L{tuple} of L{bytes} + @param words: Other parts of the password split by spaces. + """ + if self._userIs is None: + self.failResponse(b"USER required before PASS") + return + user = self._userIs + self._userIs = None + password = b" ".join((password,) + words) + d = defer.maybeDeferred(self.authenticateUserPASS, user, password) + d.addCallbacks( + self._cbMailbox, self._ebMailbox, callbackArgs=(user,) + ).addErrback(self._ebUnexpected) + + def _longOperation(self, d): + """ + Stop timeouts and block further command processing while a long + operation completes. + + @type d: L{Deferred <defer.Deferred>} + @param d: A deferred which triggers at the completion of a long + operation. + + @rtype: L{Deferred <defer.Deferred>} + @return: A deferred which triggers after command processing resumes and + timeouts restart after the completion of a long operation. + """ + timeOut = self.timeOut + self.setTimeout(None) + self.blocked = [] + d.addCallback(self._unblock) + d.addCallback(lambda ign: self.setTimeout(timeOut)) + return d + + def _coiterate(self, gen): + """ + Direct the output of an iterator to the transport and arrange for + iteration to take place. + + @type gen: iterable which yields L{bytes} + @param gen: An iterator over strings. + + @rtype: L{Deferred <defer.Deferred>} + @return: A deferred which fires when the iterator finishes. + """ + return self.schedule(_IteratorBuffer(self.transport.writeSequence, gen)) + + def do_STAT(self): + """ + Handle a STAT command. + + @rtype: L{Deferred <defer.Deferred>} + @return: A deferred which triggers after the response to the STAT + command has been issued. + """ + d = defer.maybeDeferred(self.mbox.listMessages) + + def cbMessages(msgs): + return self._coiterate(formatStatResponse(msgs)) + + def ebMessages(err): + self.failResponse(err.getErrorMessage()) + log.msg("Unexpected do_STAT failure:") + log.err(err) + + return self._longOperation(d.addCallbacks(cbMessages, ebMessages)) + + def do_LIST(self, i=None): + """ + Handle a LIST command. + + @type i: L{bytes} or L{None} + @param i: A 1-based message index. + + @rtype: L{Deferred <defer.Deferred>} + @return: A deferred which triggers after the response to the LIST + command has been issued. + """ + if i is None: + d = defer.maybeDeferred(self.mbox.listMessages) + + def cbMessages(msgs): + return self._coiterate(formatListResponse(msgs)) + + def ebMessages(err): + self.failResponse(err.getErrorMessage()) + log.msg("Unexpected do_LIST failure:") + log.err(err) + + return self._longOperation(d.addCallbacks(cbMessages, ebMessages)) + else: + try: + i = int(i) + if i < 1: + raise ValueError() + except ValueError: + if not isinstance(i, bytes): + i = str(i).encode("utf-8") + self.failResponse(b"Invalid message-number: " + i) + else: + d = defer.maybeDeferred(self.mbox.listMessages, i - 1) + + def cbMessage(msg): + self.successResponse(b"%d %d" % (i, msg)) + + def ebMessage(err): + errcls = err.check(ValueError, IndexError) + if errcls is not None: + if errcls is IndexError: + # IndexError was supported for a while, but really + # shouldn't be. One error condition, one exception + # type. See ticket #6669. + warnings.warn( + "twisted.mail.pop3.IMailbox.listMessages may " + "not raise IndexError for out-of-bounds " + "message numbers: raise ValueError instead.", + PendingDeprecationWarning, + ) + invalidNum = i + if invalidNum and not isinstance(invalidNum, bytes): + invalidNum = str(invalidNum).encode("utf-8") + self.failResponse(b"Invalid message-number: " + invalidNum) + else: + self.failResponse(err.getErrorMessage()) + log.msg("Unexpected do_LIST failure:") + log.err(err) + + d.addCallbacks(cbMessage, ebMessage) + return self._longOperation(d) + + def do_UIDL(self, i=None): + """ + Handle a UIDL command. + + @type i: L{bytes} or L{None} + @param i: A 1-based message index. + + @rtype: L{Deferred <defer.Deferred>} + @return: A deferred which triggers after the response to the UIDL + command has been issued. + """ + if i is None: + d = defer.maybeDeferred(self.mbox.listMessages) + + def cbMessages(msgs): + return self._coiterate( + formatUIDListResponse(msgs, self.mbox.getUidl), + ) + + def ebMessages(err): + self.failResponse(err.getErrorMessage()) + log.msg("Unexpected do_UIDL failure:") + log.err(err) + + return self._longOperation(d.addCallbacks(cbMessages, ebMessages)) + else: + try: + i = int(i) + if i < 1: + raise ValueError() + except ValueError: + self.failResponse("Bad message number argument") + else: + try: + msg = self.mbox.getUidl(i - 1) + except IndexError: + # XXX TODO See above comment regarding IndexError. + warnings.warn( + "twisted.mail.pop3.IMailbox.getUidl may not " + "raise IndexError for out-of-bounds message numbers: " + "raise ValueError instead.", + PendingDeprecationWarning, + ) + self.failResponse("Bad message number argument") + except ValueError: + self.failResponse("Bad message number argument") + else: + if not isinstance(msg, bytes): + msg = str(msg).encode("utf-8") + self.successResponse(msg) + + def _getMessageFile(self, i): + """ + Retrieve the size and contents of a message. + + @type i: L{bytes} + @param i: A 1-based message index. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + 2-L{tuple} of (E{1}) L{int}, (E{2}) file-like object + @return: A deferred which successfully fires with the size of the + message and a file containing the contents of the message. + """ + try: + msg = int(i) - 1 + if msg < 0: + raise ValueError() + except ValueError: + self.failResponse("Bad message number argument") + return defer.succeed(None) + + sizeDeferred = defer.maybeDeferred(self.mbox.listMessages, msg) + + def cbMessageSize(size): + if not size: + return defer.fail(_POP3MessageDeleted()) + fileDeferred = defer.maybeDeferred(self.mbox.getMessage, msg) + fileDeferred.addCallback(lambda fObj: (size, fObj)) + return fileDeferred + + def ebMessageSomething(err): + errcls = err.check(_POP3MessageDeleted, ValueError, IndexError) + if errcls is _POP3MessageDeleted: + self.failResponse("message deleted") + elif errcls in (ValueError, IndexError): + if errcls is IndexError: + # XXX TODO See above comment regarding IndexError. + warnings.warn( + "twisted.mail.pop3.IMailbox.listMessages may not " + "raise IndexError for out-of-bounds message numbers: " + "raise ValueError instead.", + PendingDeprecationWarning, + ) + self.failResponse("Bad message number argument") + else: + log.msg("Unexpected _getMessageFile failure:") + log.err(err) + return None + + sizeDeferred.addCallback(cbMessageSize) + sizeDeferred.addErrback(ebMessageSomething) + return sizeDeferred + + def _sendMessageContent(self, i, fpWrapper, successResponse): + """ + Send the contents of a message. + + @type i: L{bytes} + @param i: A 1-based message index. + + @type fpWrapper: callable that takes a file-like object and returns + a file-like object + @param fpWrapper: + + @type successResponse: callable that takes L{int} and returns + L{bytes} + @param successResponse: + + @rtype: L{Deferred} + @return: A deferred which triggers after the message has been sent. + """ + d = self._getMessageFile(i) + + def cbMessageFile(info): + if info is None: + # Some error occurred - a failure response has been sent + # already, just give up. + return + + self._highest = max(self._highest, int(i)) + resp, fp = info + fp = fpWrapper(fp) + self.successResponse(successResponse(resp)) + s = basic.FileSender() + d = s.beginFileTransfer(fp, self.transport, self.transformChunk) + + def cbFileTransfer(lastsent): + if lastsent != b"\n": + line = b"\r\n." + else: + line = b"." + self.sendLine(line) + + def ebFileTransfer(err): + self.transport.loseConnection() + log.msg("Unexpected error in _sendMessageContent:") + log.err(err) + + d.addCallback(cbFileTransfer) + d.addErrback(ebFileTransfer) + return d + + return self._longOperation(d.addCallback(cbMessageFile)) + + def do_TOP(self, i, size): + """ + Handle a TOP command. + + @type i: L{bytes} + @param i: A 1-based message index. + + @type size: L{bytes} + @param size: The number of lines of the message to retrieve. + + @rtype: L{Deferred} + @return: A deferred which triggers after the response to the TOP + command has been issued. + """ + try: + size = int(size) + if size < 0: + raise ValueError + except ValueError: + self.failResponse("Bad line count argument") + else: + return self._sendMessageContent( + i, + lambda fp: _HeadersPlusNLines(fp, size), + lambda size: "Top of message follows", + ) + + def do_RETR(self, i): + """ + Handle a RETR command. + + @type i: L{bytes} + @param i: A 1-based message index. + + @rtype: L{Deferred} + @return: A deferred which triggers after the response to the RETR + command has been issued. + """ + return self._sendMessageContent(i, lambda fp: fp, lambda size: "%d" % (size,)) + + def transformChunk(self, chunk): + """ + Transform a chunk of a message to POP3 message format. + + Make sure each line ends with C{'\\r\\n'} and byte-stuff the + termination character (C{'.'}) by adding an extra one when one appears + at the beginning of a line. + + @type chunk: L{bytes} + @param chunk: A string to transform. + + @rtype: L{bytes} + @return: The transformed string. + """ + return chunk.replace(b"\n", b"\r\n").replace(b"\r\n.", b"\r\n..") + + def finishedFileTransfer(self, lastsent): + """ + Send the termination sequence. + + @type lastsent: L{bytes} + @param lastsent: The last character of the file. + """ + if lastsent != b"\n": + line = b"\r\n." + else: + line = b"." + self.sendLine(line) + + def do_DELE(self, i): + """ + Handle a DELE command. + + Mark a message for deletion and issue a successful response. + + @type i: L{int} + @param i: A 1-based message index. + """ + i = int(i) - 1 + self.mbox.deleteMessage(i) + self.successResponse() + + def do_NOOP(self): + """ + Handle a NOOP command. + + Do nothing but issue a successful response. + """ + self.successResponse() + + def do_RSET(self): + """ + Handle a RSET command. + + Unmark any messages that have been flagged for deletion. + """ + try: + self.mbox.undeleteMessages() + except BaseException: + log.err() + self.failResponse() + else: + self._highest = 0 + self.successResponse() + + def do_LAST(self): + """ + Handle a LAST command. + + Respond with the 1-based index of the highest retrieved message. + """ + self.successResponse(self._highest) + + def do_RPOP(self, user): + """ + Handle an RPOP command. + + RPOP is not supported. Send an error response. + + @type user: L{bytes} + @param user: A username. + + """ + self.failResponse("permission denied, sucker") + + def do_QUIT(self): + """ + Handle a QUIT command. + + Remove any messages marked for deletion, issue a successful response, + and drop the connection. + """ + if self.mbox: + self.mbox.sync() + self.successResponse() + self.transport.loseConnection() + + def authenticateUserAPOP(self, user, digest): + """ + Perform APOP authentication. + + @type user: L{bytes} + @param user: The name of the user attempting to log in. + + @type digest: L{bytes} + @param digest: The challenge response. + + @rtype: L{Deferred <defer.Deferred>} which successfully results in + 3-L{tuple} of (E{1}) L{IMailbox <pop3.IMailbox>}, (E{2}) + L{IMailbox <pop3.IMailbox>} provider, (E{3}) no-argument callable + @return: A deferred which fires when authentication is complete. If + successful, it returns an L{IMailbox <pop3.IMailbox>} interface, a + mailbox, and a function to be invoked with the session is + terminated. If authentication fails, the deferred fails with an + L{UnathorizedLogin <cred.error.UnauthorizedLogin>} error. + + @raise cred.error.UnauthorizedLogin: When authentication fails. + """ + if self.portal is not None: + return self.portal.login( + APOPCredentials(self.magic, user, digest), None, IMailbox + ) + raise cred.error.UnauthorizedLogin() + + def authenticateUserPASS(self, user, password): + """ + Perform authentication for a username/password login. + + @type user: L{bytes} + @param user: The name of the user attempting to log in. + + @type password: L{bytes} + @param password: The password to authenticate with. + + @rtype: L{Deferred <defer.Deferred>} which successfully results in + 3-L{tuple} of (E{1}) L{IMailbox <pop3.IMailbox>}, (E{2}) L{IMailbox + <pop3.IMailbox>} provider, (E{3}) no-argument callable + @return: A deferred which fires when authentication is complete. If + successful, it returns a L{pop3.IMailbox} interface, a mailbox, + and a function to be invoked with the session is terminated. + If authentication fails, the deferred fails with an + L{UnathorizedLogin <cred.error.UnauthorizedLogin>} error. + + @raise cred.error.UnauthorizedLogin: When authentication fails. + """ + if self.portal is not None: + return self.portal.login( + cred.credentials.UsernamePassword(user, password), None, IMailbox + ) + raise cred.error.UnauthorizedLogin() + + def stopProducing(self): + # IProducer.stopProducing + raise NotImplementedError() + + +@implementer(IMailbox) +class Mailbox: + """ + A base class for mailboxes. + """ + + def listMessages(self, i=None): + """ + Retrieve the size of a message, or, if none is specified, the size of + each message in the mailbox. + + @type i: L{int} or L{None} + @param i: The 0-based index of the message. + + @rtype: L{int}, sequence of L{int}, or L{Deferred <defer.Deferred>} + @return: The number of octets in the specified message, or, if an + index is not specified, a sequence of the number of octets for + all messages in the mailbox or a deferred which fires with + one of those. Any value which corresponds to a deleted message + is set to 0. + + @raise ValueError or IndexError: When the index does not correspond to + a message in the mailbox. The use of ValueError is preferred. + """ + return [] + + def getMessage(self, i): + """ + Retrieve a file containing the contents of a message. + + @type i: L{int} + @param i: The 0-based index of a message. + + @rtype: file-like object + @return: A file containing the message. + + @raise ValueError or IndexError: When the index does not correspond to + a message in the mailbox. The use of ValueError is preferred. + """ + raise ValueError + + def getUidl(self, i): + """ + Get a unique identifier for a message. + + @type i: L{int} + @param i: The 0-based index of a message. + + @rtype: L{bytes} + @return: A string of printable characters uniquely identifying the + message for all time. + + @raise ValueError or IndexError: When the index does not correspond to + a message in the mailbox. The use of ValueError is preferred. + """ + raise ValueError + + def deleteMessage(self, i): + """ + Mark a message for deletion. + + This must not change the number of messages in this mailbox. Further + requests for the size of the deleted message should return 0. Further + requests for the message itself may raise an exception. + + @type i: L{int} + @param i: The 0-based index of a message. + + @raise ValueError or IndexError: When the index does not correspond to + a message in the mailbox. The use of ValueError is preferred. + """ + raise ValueError + + def undeleteMessages(self): + """ + Undelete all messages marked for deletion. + + Any message which can be undeleted should be returned to its original + position in the message sequence and retain its original UID. + """ + pass + + def sync(self): + """ + Discard the contents of any message marked for deletion. + """ + pass + + +NONE, SHORT, FIRST_LONG, LONG = range(4) + +NEXT = {} +NEXT[NONE] = NONE +NEXT[SHORT] = NONE +NEXT[FIRST_LONG] = LONG +NEXT[LONG] = NONE + + +class POP3Client(basic.LineOnlyReceiver): + """ + A POP3 client protocol. + + @type mode: L{int} + @ivar mode: The type of response expected from the server. Choices include + none (0), a one line response (1), the first line of a multi-line + response (2), and subsequent lines of a multi-line response (3). + + @type command: L{bytes} + @ivar command: The command most recently sent to the server. + + @type welcomeRe: L{Pattern <re.Pattern.search>} + @ivar welcomeRe: A regular expression which matches the APOP challenge in + the server greeting. + + @type welcomeCode: L{bytes} + @ivar welcomeCode: The APOP challenge passed in the server greeting. + """ + + mode = SHORT + command = b"WELCOME" + import re + + welcomeRe = re.compile(b"<(.*)>") + + def __init__(self): + """ + Issue deprecation warning. + """ + import warnings + + warnings.warn( + "twisted.mail.pop3.POP3Client is deprecated, " + "please use twisted.mail.pop3.AdvancedPOP3Client " + "instead.", + DeprecationWarning, + stacklevel=3, + ) + + def sendShort(self, command, params=None): + """ + Send a POP3 command to which a short response is expected. + + @type command: L{bytes} + @param command: A POP3 command. + + @type params: stringifyable L{object} or L{None} + @param params: Command arguments. + """ + if params is not None: + if not isinstance(params, bytes): + params = str(params).encode("utf-8") + self.sendLine(command + b" " + params) + else: + self.sendLine(command) + self.command = command + self.mode = SHORT + + def sendLong(self, command, params): + """ + Send a POP3 command to which a long response is expected. + + @type command: L{bytes} + @param command: A POP3 command. + + @type params: stringifyable L{object} + @param params: Command arguments. + """ + if params: + if not isinstance(params, bytes): + params = str(params).encode("utf-8") + self.sendLine(command + b" " + params) + else: + self.sendLine(command) + self.command = command + self.mode = FIRST_LONG + + def handle_default(self, line): + """ + Handle responses from the server for which no other handler exists. + + @type line: L{bytes} + @param line: A received line. + """ + if line[:-4] == b"-ERR": + self.mode = NONE + + def handle_WELCOME(self, line): + """ + Handle a server response which is expected to be a server greeting. + + @type line: L{bytes} + @param line: A received line. + """ + code, data = line.split(b" ", 1) + if code != b"+OK": + self.transport.loseConnection() + else: + m = self.welcomeRe.match(line) + if m: + self.welcomeCode = m.group(1) + + def _dispatch(self, command, default, *args): + """ + Dispatch a response from the server for handling. + + Command X is dispatched to handle_X() if it exists. If not, it is + dispatched to the default handler. + + @type command: L{bytes} + @param command: The command. + + @type default: callable that takes L{bytes} or + L{None} + @param default: The default handler. + + @type args: L{tuple} or L{None} + @param args: Arguments to the handler function. + """ + try: + method = getattr(self, "handle_" + command.decode("utf-8"), default) + if method is not None: + method(*args) + except BaseException: + log.err() + + def lineReceived(self, line): + """ + Dispatch a received line for processing. + + The choice of function to handle the received line is based on the + type of response expected to the command sent to the server and how + much of that response has been received. + + An expected one line response to command X is handled by handle_X(). + The first line of a multi-line response to command X is also handled by + handle_X(). Subsequent lines of the multi-line response are handled by + handle_X_continue() except for the last line which is handled by + handle_X_end(). + + @type line: L{bytes} + @param line: A received line. + """ + if self.mode == SHORT or self.mode == FIRST_LONG: + self.mode = NEXT[self.mode] + self._dispatch(self.command, self.handle_default, line) + elif self.mode == LONG: + if line == b".": + self.mode = NEXT[self.mode] + self._dispatch(self.command + b"_end", None) + return + if line[:1] == b".": + line = line[1:] + self._dispatch(self.command + b"_continue", None, line) + + def apopAuthenticate(self, user, password, magic): + """ + Perform an authenticated login. + + @type user: L{bytes} + @param user: The username with which to log in. + + @type password: L{bytes} + @param password: The password with which to log in. + + @type magic: L{bytes} + @param magic: The challenge provided by the server. + """ + digest = md5(magic + password).hexdigest().encode("ascii") + self.apop(user, digest) + + def apop(self, user, digest): + """ + Send an APOP command to perform authenticated login. + + @type user: L{bytes} + @param user: The username with which to log in. + + @type digest: L{bytes} + @param digest: The challenge response with which to authenticate. + """ + self.sendLong(b"APOP", b" ".join((user, digest))) + + def retr(self, i): + """ + Send a RETR command to retrieve a message from the server. + + @type i: L{int} or L{bytes} + @param i: A 0-based message index. + """ + self.sendLong(b"RETR", i) + + def dele(self, i): + """ + Send a DELE command to delete a message from the server. + + @type i: L{int} or L{bytes} + @param i: A 0-based message index. + """ + self.sendShort(b"DELE", i) + + def list(self, i=""): + """ + Send a LIST command to retrieve the size of a message or, if no message + is specified, the sizes of all messages. + + @type i: L{int} or L{bytes} + @param i: A 0-based message index or the empty string to specify all + messages. + """ + self.sendLong(b"LIST", i) + + def uidl(self, i=""): + """ + Send a UIDL command to retrieve the unique identifier of a message or, + if no message is specified, the unique identifiers of all messages. + + @type i: L{int} or L{bytes} + @param i: A 0-based message index or the empty string to specify all + messages. + """ + self.sendLong(b"UIDL", i) + + def user(self, name): + """ + Send a USER command to perform the first half of a plaintext login. + + @type name: L{bytes} + @param name: The username with which to log in. + """ + self.sendShort(b"USER", name) + + def password(self, password): + """ + Perform the second half of a plaintext login. + + @type password: L{bytes} + @param password: The plaintext password with which to authenticate. + """ + self.sendShort(b"PASS", password) + + pass_ = password + + def quit(self): + """ + Send a QUIT command to disconnect from the server. + """ + self.sendShort(b"QUIT") + + +from twisted.mail._except import ( + InsecureAuthenticationDisallowed, + LineTooLong, + ServerErrorResponse, + TLSError, + TLSNotSupportedError, +) +from twisted.mail._pop3client import POP3Client as AdvancedPOP3Client + +__all__ = [ + # Interfaces + "IMailbox", + "IServerFactory", + # Exceptions + "POP3Error", + "POP3ClientError", + "InsecureAuthenticationDisallowed", + "ServerErrorResponse", + "LineTooLong", + "TLSError", + "TLSNotSupportedError", + # Protocol classes + "POP3", + "POP3Client", + "AdvancedPOP3Client", + # Misc + "APOPCredentials", + "Mailbox", +] diff --git a/contrib/python/Twisted/py3/twisted/mail/pop3client.py b/contrib/python/Twisted/py3/twisted/mail/pop3client.py new file mode 100644 index 0000000000..72676a69a8 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/pop3client.py @@ -0,0 +1,22 @@ +""" +Deprecated POP3 client protocol implementation. + +Don't use this module directly. Use twisted.mail.pop3 instead. +""" +import warnings +from typing import List + +from twisted.mail._pop3client import ERR, OK, POP3Client + +warnings.warn( + "twisted.mail.pop3client was deprecated in Twisted 21.2.0. Use twisted.mail.pop3 instead.", + DeprecationWarning, + stacklevel=2, +) + +# Fake usage to please pyflakes as we don't to add them to __all__. +OK +ERR +POP3Client + +__all__: List[str] = [] diff --git a/contrib/python/Twisted/py3/twisted/mail/protocols.py b/contrib/python/Twisted/py3/twisted/mail/protocols.py new file mode 100644 index 0000000000..7bd1eebbee --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/protocols.py @@ -0,0 +1,385 @@ +# -*- test-case-name: twisted.mail.test.test_mail -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Mail protocol support. +""" + + +from zope.interface import implementer + +from twisted.copyright import longversion +from twisted.cred.credentials import CramMD5Credentials, UsernamePassword +from twisted.cred.error import UnauthorizedLogin +from twisted.internet import defer, protocol +from twisted.mail import pop3, relay, smtp +from twisted.python import log + + +@implementer(smtp.IMessageDelivery) +class DomainDeliveryBase: + """ + A base class for message delivery using the domains of a mail service. + + @ivar service: See L{__init__} + @ivar user: See L{__init__} + @ivar host: See L{__init__} + + @type protocolName: L{bytes} + @ivar protocolName: The protocol being used to deliver the mail. + Sub-classes should set this appropriately. + """ + + service = None + protocolName: bytes = b"not-implemented-protocol" + + def __init__(self, service, user, host=smtp.DNSNAME): + """ + @type service: L{MailService} + @param service: A mail service. + + @type user: L{bytes} or L{None} + @param user: The authenticated SMTP user. + + @type host: L{bytes} + @param host: The hostname. + """ + self.service = service + self.user = user + self.host = host + + def receivedHeader(self, helo, origin, recipients): + """ + Generate a received header string for a message. + + @type helo: 2-L{tuple} of (L{bytes}, L{bytes}) + @param helo: The client's identity as sent in the HELO command and its + IP address. + + @type origin: L{Address} + @param origin: The origination address of the message. + + @type recipients: L{list} of L{User} + @param recipients: The destination addresses for the message. + + @rtype: L{bytes} + @return: A received header string. + """ + authStr = heloStr = b"" + if self.user: + authStr = b" auth=" + self.user.encode("xtext") + if helo[0]: + heloStr = b" helo=" + helo[0] + fromUser = b"from " + helo[0] + b" ([" + helo[1] + b"]" + heloStr + authStr + by = ( + b"by " + + self.host + + b" with " + + self.protocolName + + b" (" + + longversion.encode("ascii") + + b")" + ) + forUser = ( + b"for <" + b" ".join(map(bytes, recipients)) + b"> " + smtp.rfc822date() + ) + return b"Received: " + fromUser + b"\n\t" + by + b"\n\t" + forUser + + def validateTo(self, user): + """ + Validate the address for which a message is destined. + + @type user: L{User} + @param user: The destination address. + + @rtype: L{Deferred <defer.Deferred>} which successfully fires with + no-argument callable which returns L{IMessage <smtp.IMessage>} + provider. + @return: A deferred which successfully fires with a no-argument + callable which returns a message receiver for the destination. + + @raise SMTPBadRcpt: When messages cannot be accepted for the + destination address. + """ + # XXX - Yick. This needs cleaning up. + if self.user and self.service.queue: + d = self.service.domains.get(user.dest.domain, None) + if d is None: + d = relay.DomainQueuer(self.service, True) + else: + d = self.service.domains[user.dest.domain] + return defer.maybeDeferred(d.exists, user) + + def validateFrom(self, helo, origin): + """ + Validate the address from which a message originates. + + @type helo: 2-L{tuple} of (L{bytes}, L{bytes}) + @param helo: The client's identity as sent in the HELO command and its + IP address. + + @type origin: L{Address} + @param origin: The origination address of the message. + + @rtype: L{Address} + @return: The origination address. + + @raise SMTPBadSender: When messages cannot be accepted from the + origination address. + """ + if not helo: + raise smtp.SMTPBadSender(origin, 503, "Who are you? Say HELO first.") + if origin.local != b"" and origin.domain == b"": + raise smtp.SMTPBadSender(origin, 501, "Sender address must contain domain.") + return origin + + +class SMTPDomainDelivery(DomainDeliveryBase): + """ + A domain delivery base class for use in an SMTP server. + """ + + protocolName = b"smtp" + + +class ESMTPDomainDelivery(DomainDeliveryBase): + """ + A domain delivery base class for use in an ESMTP server. + """ + + protocolName = b"esmtp" + + +class SMTPFactory(smtp.SMTPFactory): + """ + An SMTP server protocol factory. + + @ivar service: See L{__init__} + @ivar portal: See L{__init__} + + @type protocol: no-argument callable which returns a L{Protocol + <protocol.Protocol>} subclass + @ivar protocol: A callable which creates a protocol. The default value is + L{SMTP}. + """ + + protocol = smtp.SMTP + portal = None + + def __init__(self, service, portal=None): + """ + @type service: L{MailService} + @param service: An email service. + + @type portal: L{Portal <twisted.cred.portal.Portal>} or + L{None} + @param portal: A portal to use for authentication. + """ + smtp.SMTPFactory.__init__(self) + self.service = service + self.portal = portal + + def buildProtocol(self, addr): + """ + Create an instance of an SMTP server protocol. + + @type addr: L{IAddress <twisted.internet.interfaces.IAddress>} provider + @param addr: The address of the SMTP client. + + @rtype: L{SMTP} + @return: An SMTP protocol. + """ + log.msg(f"Connection from {addr}") + p = smtp.SMTPFactory.buildProtocol(self, addr) + p.service = self.service + p.portal = self.portal + return p + + +class ESMTPFactory(SMTPFactory): + """ + An ESMTP server protocol factory. + + @type protocol: no-argument callable which returns a L{Protocol + <protocol.Protocol>} subclass + @ivar protocol: A callable which creates a protocol. The default value is + L{ESMTP}. + + @type context: L{IOpenSSLContextFactory + <twisted.internet.interfaces.IOpenSSLContextFactory>} or L{None} + @ivar context: A factory to generate contexts to be used in negotiating + encrypted communication. + + @type challengers: L{dict} mapping L{bytes} to no-argument callable which + returns L{ICredentials <twisted.cred.credentials.ICredentials>} + subclass provider. + @ivar challengers: A mapping of acceptable authorization mechanism to + callable which creates credentials to use for authentication. + """ + + protocol = smtp.ESMTP + context = None + + def __init__(self, *args): + """ + @param args: Arguments for L{SMTPFactory.__init__} + + @see: L{SMTPFactory.__init__} + """ + SMTPFactory.__init__(self, *args) + self.challengers = {b"CRAM-MD5": CramMD5Credentials} + + def buildProtocol(self, addr): + """ + Create an instance of an ESMTP server protocol. + + @type addr: L{IAddress <twisted.internet.interfaces.IAddress>} provider + @param addr: The address of the ESMTP client. + + @rtype: L{ESMTP} + @return: An ESMTP protocol. + """ + p = SMTPFactory.buildProtocol(self, addr) + p.challengers = self.challengers + p.ctx = self.context + return p + + +class VirtualPOP3(pop3.POP3): + """ + A virtual hosting POP3 server. + + @type service: L{MailService} + @ivar service: The email service that created this server. This must be + set by the service. + + @type domainSpecifier: L{bytes} + @ivar domainSpecifier: The character to use to split an email address into + local-part and domain. The default is '@'. + """ + + service = None + + domainSpecifier = b"@" # Gaagh! I hate POP3. No standardized way + # to indicate user@host. '@' doesn't work + # with NS, e.g. + + def authenticateUserAPOP(self, user, digest): + """ + Perform APOP authentication. + + Override the default lookup scheme to allow virtual domains. + + @type user: L{bytes} + @param user: The name of the user attempting to log in. + + @type digest: L{bytes} + @param digest: The challenge response. + + @rtype: L{Deferred} which successfully results in 3-L{tuple} of + (L{IMailbox <pop3.IMailbox>}, L{IMailbox <pop3.IMailbox>} + provider, no-argument callable) + @return: A deferred which fires when authentication is complete. + If successful, it returns an L{IMailbox <pop3.IMailbox>} interface, + a mailbox and a logout function. If authentication fails, the + deferred fails with an L{UnauthorizedLogin + <twisted.cred.error.UnauthorizedLogin>} error. + """ + user, domain = self.lookupDomain(user) + try: + portal = self.service.lookupPortal(domain) + except KeyError: + return defer.fail(UnauthorizedLogin()) + else: + return portal.login( + pop3.APOPCredentials(self.magic, user, digest), None, pop3.IMailbox + ) + + def authenticateUserPASS(self, user, password): + """ + Perform authentication for a username/password login. + + Override the default lookup scheme to allow virtual domains. + + @type user: L{bytes} + @param user: The name of the user attempting to log in. + + @type password: L{bytes} + @param password: The password to authenticate with. + + @rtype: L{Deferred} which successfully results in 3-L{tuple} of + (L{IMailbox <pop3.IMailbox>}, L{IMailbox <pop3.IMailbox>} + provider, no-argument callable) + @return: A deferred which fires when authentication is complete. + If successful, it returns an L{IMailbox <pop3.IMailbox>} interface, + a mailbox and a logout function. If authentication fails, the + deferred fails with an L{UnauthorizedLogin + <twisted.cred.error.UnauthorizedLogin>} error. + """ + user, domain = self.lookupDomain(user) + try: + portal = self.service.lookupPortal(domain) + except KeyError: + return defer.fail(UnauthorizedLogin()) + else: + return portal.login(UsernamePassword(user, password), None, pop3.IMailbox) + + def lookupDomain(self, user): + """ + Check whether a domain is among the virtual domains supported by the + mail service. + + @type user: L{bytes} + @param user: An email address. + + @rtype: 2-L{tuple} of (L{bytes}, L{bytes}) + @return: The local part and the domain part of the email address if the + domain is supported. + + @raise POP3Error: When the domain is not supported by the mail service. + """ + try: + user, domain = user.split(self.domainSpecifier, 1) + except ValueError: + domain = b"" + if domain not in self.service.domains: + raise pop3.POP3Error("no such domain {}".format(domain.decode("utf-8"))) + return user, domain + + +class POP3Factory(protocol.ServerFactory): + """ + A POP3 server protocol factory. + + @ivar service: See L{__init__} + + @type protocol: no-argument callable which returns a L{Protocol + <protocol.Protocol>} subclass + @ivar protocol: A callable which creates a protocol. The default value is + L{VirtualPOP3}. + """ + + protocol = VirtualPOP3 + service = None + + def __init__(self, service): + """ + @type service: L{MailService} + @param service: An email service. + """ + self.service = service + + def buildProtocol(self, addr): + """ + Create an instance of a POP3 server protocol. + + @type addr: L{IAddress <twisted.internet.interfaces.IAddress>} provider + @param addr: The address of the POP3 client. + + @rtype: L{POP3} + @return: A POP3 protocol. + """ + p = protocol.ServerFactory.buildProtocol(self, addr) + p.service = self.service + return p diff --git a/contrib/python/Twisted/py3/twisted/mail/relay.py b/contrib/python/Twisted/py3/twisted/mail/relay.py new file mode 100644 index 0000000000..4ba50ea378 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/relay.py @@ -0,0 +1,164 @@ +# -*- test-case-name: twisted.mail.test.test_mail -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Support for relaying mail. +""" + +import os +import pickle + +from twisted.internet.address import UNIXAddress +from twisted.mail import smtp +from twisted.python import log + + +class DomainQueuer: + """ + An SMTP domain which add messages to a queue intended for relaying. + """ + + def __init__(self, service, authenticated=False): + self.service = service + self.authed = authenticated + + def exists(self, user): + """ + Check whether mail can be relayed to a user. + + @type user: L{User} + @param user: A user. + + @rtype: no-argument callable which returns L{IMessage <smtp.IMessage>} + provider + @return: A function which takes no arguments and returns a message + receiver for the user. + + @raise SMTPBadRcpt: When mail cannot be relayed to the user. + """ + if self.willRelay(user.dest, user.protocol): + # The most cursor form of verification of the addresses + orig = filter(None, str(user.orig).split("@", 1)) + dest = filter(None, str(user.dest).split("@", 1)) + if len(orig) == 2 and len(dest) == 2: + return lambda: self.startMessage(user) + raise smtp.SMTPBadRcpt(user) + + def willRelay(self, address, protocol): + """ + Check whether we agree to relay. + + The default is to relay for all connections over UNIX + sockets and all connections from localhost. + """ + peer = protocol.transport.getPeer() + return self.authed or isinstance(peer, UNIXAddress) or peer.host == "127.0.0.1" + + def startMessage(self, user): + """ + Create an envelope and a message receiver for the relay queue. + + @type user: L{User} + @param user: A user. + + @rtype: L{IMessage <smtp.IMessage>} + @return: A message receiver. + """ + queue = self.service.queue + envelopeFile, smtpMessage = queue.createNewMessage() + with envelopeFile: + log.msg(f"Queueing mail {str(user.orig)!r} -> {str(user.dest)!r}") + pickle.dump([str(user.orig), str(user.dest)], envelopeFile) + return smtpMessage + + +class RelayerMixin: + # XXX - This is -totally- bogus + # It opens about a -hundred- -billion- files + # and -leaves- them open! + + def loadMessages(self, messagePaths): + self.messages = [] + self.names = [] + for message in messagePaths: + with open(message + "-H", "rb") as fp: + messageContents = pickle.load(fp) + fp = open(message + "-D") + messageContents.append(fp) + self.messages.append(messageContents) + self.names.append(message) + + def getMailFrom(self): + if not self.messages: + return None + return self.messages[0][0] + + def getMailTo(self): + if not self.messages: + return None + return [self.messages[0][1]] + + def getMailData(self): + if not self.messages: + return None + return self.messages[0][2] + + def sentMail(self, code, resp, numOk, addresses, log): + """Since we only use one recipient per envelope, this + will be called with 0 or 1 addresses. We probably want + to do something with the error message if we failed. + """ + if code in smtp.SUCCESS: + # At least one, i.e. all, recipients successfully delivered + os.remove(self.names[0] + "-D") + os.remove(self.names[0] + "-H") + del self.messages[0] + del self.names[0] + + +class SMTPRelayer(RelayerMixin, smtp.SMTPClient): + """ + A base class for SMTP relayers. + """ + + def __init__(self, messagePaths, *args, **kw): + """ + @type messagePaths: L{list} of L{bytes} + @param messagePaths: The base filename for each message to be relayed. + + @type args: 1-L{tuple} of (0) L{bytes} or 2-L{tuple} of + (0) L{bytes}, (1) L{int} + @param args: Positional arguments for L{SMTPClient.__init__} + + @type kw: L{dict} + @param kw: Keyword arguments for L{SMTPClient.__init__} + """ + smtp.SMTPClient.__init__(self, *args, **kw) + self.loadMessages(messagePaths) + + +class ESMTPRelayer(RelayerMixin, smtp.ESMTPClient): + """ + A base class for ESMTP relayers. + """ + + def __init__(self, messagePaths, *args, **kw): + """ + @type messagePaths: L{list} of L{bytes} + @param messagePaths: The base filename for each message to be relayed. + + @type args: 3-L{tuple} of (0) L{bytes}, (1) L{None} or + L{ClientContextFactory + <twisted.internet.ssl.ClientContextFactory>}, + (2) L{bytes} or 4-L{tuple} of (0) L{bytes}, (1) L{None} + or L{ClientContextFactory + <twisted.internet.ssl.ClientContextFactory>}, (2) L{bytes}, + (3) L{int} + @param args: Positional arguments for L{ESMTPClient.__init__} + + @type kw: L{dict} + @param kw: Keyword arguments for L{ESMTPClient.__init__} + """ + smtp.ESMTPClient.__init__(self, *args, **kw) + self.loadMessages(messagePaths) diff --git a/contrib/python/Twisted/py3/twisted/mail/relaymanager.py b/contrib/python/Twisted/py3/twisted/mail/relaymanager.py new file mode 100644 index 0000000000..18cc287833 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/relaymanager.py @@ -0,0 +1,1135 @@ +# -*- test-case-name: twisted.mail.test.test_mail -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Infrastructure for relaying mail through a smart host. + +Traditional peer-to-peer email has been increasingly replaced by smart host +configurations. Instead of sending mail directly to the recipient, a sender +sends mail to a smart host. The smart host finds the mail exchange server for +the recipient and sends on the message. +""" + +import email.utils +import os +import pickle +import time +from typing import Type + +from twisted.application import internet +from twisted.internet import protocol +from twisted.internet.defer import Deferred, DeferredList +from twisted.internet.error import DNSLookupError +from twisted.internet.protocol import connectionDone +from twisted.mail import bounce, relay, smtp +from twisted.python import log +from twisted.python.failure import Failure + + +class ManagedRelayerMixin: + """ + SMTP Relayer which notifies a manager + + Notify the manager about successful mail, failed mail + and broken connections + """ + + def __init__(self, manager): + self.manager = manager + + @property + def factory(self): + return self._factory + + @factory.setter + def factory(self, value): + self._factory = value + + def sentMail(self, code, resp, numOk, addresses, log): + """ + called when e-mail has been sent + + we will always get 0 or 1 addresses. + """ + message = self.names[0] + if code in smtp.SUCCESS: + self.manager.notifySuccess(self.factory, message) + else: + self.manager.notifyFailure(self.factory, message) + del self.messages[0] + del self.names[0] + + def connectionLost(self, reason: Failure = connectionDone) -> None: + """ + called when connection is broken + + notify manager we will try to send no more e-mail + """ + self.manager.notifyDone(self.factory) + + +class SMTPManagedRelayer(ManagedRelayerMixin, relay.SMTPRelayer): # type: ignore[misc] + """ + An SMTP managed relayer. + + This managed relayer is an SMTP client which is responsible for sending a + set of messages and keeping an attempt manager informed about its progress. + + @type factory: L{SMTPManagedRelayerFactory} + @ivar factory: The factory that created this relayer. This must be set by + the factory. + """ + + def __init__(self, messages, manager, *args, **kw): + """ + @type messages: L{list} of L{bytes} + @param messages: The base filenames of messages to be relayed. + + @type manager: L{_AttemptManager} + @param manager: An attempt manager. + + @type args: 1-L{tuple} of (0) L{bytes} or 2-L{tuple} of + (0) L{bytes}, (1) L{int} + @param args: Positional arguments for L{SMTPClient.__init__} + + @type kw: L{dict} + @param kw: Keyword arguments for L{SMTPClient.__init__} + """ + ManagedRelayerMixin.__init__(self, manager) + relay.SMTPRelayer.__init__(self, messages, *args, **kw) + + +class ESMTPManagedRelayer(ManagedRelayerMixin, relay.ESMTPRelayer): # type: ignore[misc] + """ + An ESMTP managed relayer. + + This managed relayer is an ESMTP client which is responsible for sending a + set of messages and keeping an attempt manager informed about its progress. + """ + + def __init__(self, messages, manager, *args, **kw): + """ + @type messages: L{list} of L{bytes} + @param messages: The base filenames of messages to be relayed. + + @type manager: L{_AttemptManager} + @param manager: An attempt manager. + + @type args: 3-L{tuple} of (0) L{bytes}, (1) L{None} or + L{ClientContextFactory + <twisted.internet.ssl.ClientContextFactory>}, (2) L{bytes} or + 4-L{tuple} of (0) L{bytes}, (1) L{None} or + L{ClientContextFactory + <twisted.internet.ssl.ClientContextFactory>}, (2) L{bytes}, + (3) L{int} + @param args: Positional arguments for L{ESMTPClient.__init__} + + @type kw: L{dict} + @param kw: Keyword arguments for L{ESMTPClient.__init__} + """ + ManagedRelayerMixin.__init__(self, manager) + relay.ESMTPRelayer.__init__(self, messages, *args, **kw) + + +class SMTPManagedRelayerFactory(protocol.ClientFactory): + """ + A factory to create an L{SMTPManagedRelayer}. + + This factory creates a managed relayer which relays a set of messages over + SMTP and informs an attempt manager of its progress. + + @ivar messages: See L{__init__} + @ivar manager: See L{__init__} + + @type protocol: callable which returns L{SMTPManagedRelayer} + @ivar protocol: A callable which returns a managed relayer for SMTP. See + L{SMTPManagedRelayer.__init__} for parameters to the callable. + + @type pArgs: 1-L{tuple} of (0) L{bytes} or 2-L{tuple} of + (0) L{bytes}, (1), L{int} + @ivar pArgs: Positional arguments for L{SMTPClient.__init__} + + @type pKwArgs: L{dict} + @ivar pKwArgs: Keyword arguments for L{SMTPClient.__init__} + """ + + protocol: "Type[protocol.Protocol]" = SMTPManagedRelayer + + def __init__(self, messages, manager, *args, **kw): + """ + @type messages: L{list} of L{bytes} + @param messages: The base filenames of messages to be relayed. + + @type manager: L{_AttemptManager} + @param manager: An attempt manager. + + @type args: 1-L{tuple} of (0) L{bytes} or 2-L{tuple} of + (0) L{bytes}, (1), L{int} + @param args: Positional arguments for L{SMTPClient.__init__} + + @type kw: L{dict} + @param kw: Keyword arguments for L{SMTPClient.__init__} + """ + self.messages = messages + self.manager = manager + self.pArgs = args + self.pKwArgs = kw + + def buildProtocol(self, addr): + """ + Create an L{SMTPManagedRelayer}. + + @type addr: L{IAddress <twisted.internet.interfaces.IAddress>} provider + @param addr: The address of the SMTP server. + + @rtype: L{SMTPManagedRelayer} + @return: A managed relayer for SMTP. + """ + protocol = self.protocol( + self.messages, self.manager, *self.pArgs, **self.pKwArgs + ) + protocol.factory = self + return protocol + + def clientConnectionFailed(self, connector, reason): + """ + Notify the attempt manager that a connection could not be established. + + @type connector: L{IConnector <twisted.internet.interfaces.IConnector>} + provider + @param connector: A connector. + + @type reason: L{Failure} + @param reason: The reason the connection attempt failed. + """ + self.manager.notifyNoConnection(self) + self.manager.notifyDone(self) + + +class ESMTPManagedRelayerFactory(SMTPManagedRelayerFactory): + """ + A factory to create an L{ESMTPManagedRelayer}. + + This factory creates a managed relayer which relays a set of messages over + ESMTP and informs an attempt manager of its progress. + + @type protocol: callable which returns L{ESMTPManagedRelayer} + @ivar protocol: A callable which returns a managed relayer for ESMTP. See + L{ESMTPManagedRelayer.__init__} for parameters to the callable. + + @ivar secret: See L{__init__} + @ivar contextFactory: See L{__init__} + """ + + protocol = ESMTPManagedRelayer + + def __init__(self, messages, manager, secret, contextFactory, *args, **kw): + """ + @type messages: L{list} of L{bytes} + @param messages: The base filenames of messages to be relayed. + + @type manager: L{_AttemptManager} + @param manager: An attempt manager. + + @type secret: L{bytes} + @param secret: A string for the authentication challenge response. + + @type contextFactory: L{None} or + L{ClientContextFactory <twisted.internet.ssl.ClientContextFactory>} + @param contextFactory: An SSL context factory. + + @type args: 1-L{tuple} of (0) L{bytes} or 2-L{tuple} of + (0) L{bytes}, (1), L{int} + @param args: Positional arguments for L{SMTPClient.__init__} + + @param kw: Keyword arguments for L{SMTPClient.__init__} + """ + self.secret = secret + self.contextFactory = contextFactory + SMTPManagedRelayerFactory.__init__(self, messages, manager, *args, **kw) + + def buildProtocol(self, addr): + """ + Create an L{ESMTPManagedRelayer}. + + @type addr: L{IAddress <twisted.internet.interfaces.IAddress>} provider + @param addr: The address of the ESMTP server. + + @rtype: L{ESMTPManagedRelayer} + @return: A managed relayer for ESMTP. + """ + s = self.secret and self.secret(addr) + protocol = self.protocol( + self.messages, + self.manager, + s, + self.contextFactory, + *self.pArgs, + **self.pKwArgs, + ) + protocol.factory = self + return protocol + + +class Queue: + """ + A queue for messages to be relayed. + + @ivar directory: See L{__init__} + + @type n: L{int} + @ivar n: A number used to form unique filenames. + + @type waiting: L{dict} of L{bytes} + @ivar waiting: The base filenames of messages waiting to be relayed. + + @type relayed: L{dict} of L{bytes} + @ivar relayed: The base filenames of messages in the process of being + relayed. + + @type noisy: L{bool} + @ivar noisy: A flag which determines whether informational log messages + will be generated (C{True}) or not (C{False}). + """ + + noisy = True + + def __init__(self, directory): + """ + Initialize non-volatile state. + + @type directory: L{bytes} + @param directory: The pathname of the directory holding messages in the + queue. + """ + self.directory = directory + self._init() + + def _init(self): + """ + Initialize volatile state. + """ + self.n = 0 + self.waiting = {} + self.relayed = {} + self.readDirectory() + + def __getstate__(self): + """ + Create a representation of the non-volatile state of the queue. + + @rtype: L{dict} mapping L{bytes} to L{object} + @return: The non-volatile state of the queue. + """ + return {"directory": self.directory} + + def __setstate__(self, state): + """ + Restore the non-volatile state of the queue and recreate the volatile + state. + + @type state: L{dict} mapping L{bytes} to L{object} + @param state: The non-volatile state of the queue. + """ + self.__dict__.update(state) + self._init() + + def readDirectory(self): + """ + Scan the message directory for new messages. + """ + for message in os.listdir(self.directory): + # Skip non data files + if message[-2:] != "-D": + continue + self.addMessage(message[:-2]) + + def getWaiting(self): + """ + Return the base filenames of messages waiting to be relayed. + + @rtype: L{list} of L{bytes} + @return: The base filenames of messages waiting to be relayed. + """ + return self.waiting.keys() + + def hasWaiting(self): + """ + Return an indication of whether the queue has messages waiting to be + relayed. + + @rtype: L{bool} + @return: C{True} if messages are waiting to be relayed. C{False} + otherwise. + """ + return len(self.waiting) > 0 + + def getRelayed(self): + """ + Return the base filenames of messages in the process of being relayed. + + @rtype: L{list} of L{bytes} + @return: The base filenames of messages in the process of being + relayed. + """ + return self.relayed.keys() + + def setRelaying(self, message): + """ + Mark a message as being relayed. + + @type message: L{bytes} + @param message: The base filename of a message. + """ + del self.waiting[message] + self.relayed[message] = 1 + + def setWaiting(self, message): + """ + Mark a message as waiting to be relayed. + + @type message: L{bytes} + @param message: The base filename of a message. + """ + del self.relayed[message] + self.waiting[message] = 1 + + def addMessage(self, message): + """ + Mark a message as waiting to be relayed unless it is in the process of + being relayed. + + @type message: L{bytes} + @param message: The base filename of a message. + """ + if message not in self.relayed: + self.waiting[message] = 1 + if self.noisy: + log.msg("Set " + message + " waiting") + + def done(self, message): + """ + Remove a message from the queue. + + @type message: L{bytes} + @param message: The base filename of a message. + """ + message = os.path.basename(message) + os.remove(self.getPath(message) + "-D") + os.remove(self.getPath(message) + "-H") + del self.relayed[message] + + def getPath(self, message): + """ + Return the full base pathname of a message in the queue. + + @type message: L{bytes} + @param message: The base filename of a message. + + @rtype: L{bytes} + @return: The full base pathname of the message. + """ + return os.path.join(self.directory, message) + + def getEnvelope(self, message): + """ + Get the envelope for a message. + + @type message: L{bytes} + @param message: The base filename of a message. + + @rtype: L{list} of two L{bytes} + @return: A list containing the origination and destination addresses + for the message. + """ + with self.getEnvelopeFile(message) as f: + return pickle.load(f) + + def getEnvelopeFile(self, message): + """ + Return the envelope file for a message in the queue. + + @type message: L{bytes} + @param message: The base filename of a message. + + @rtype: file + @return: The envelope file for the message. + """ + return open(os.path.join(self.directory, message + "-H"), "rb") + + def createNewMessage(self): + """ + Create a new message in the queue. + + @rtype: 2-L{tuple} of (0) file, (1) L{FileMessage} + @return: The envelope file and a message receiver for a new message in + the queue. + """ + fname = f"{os.getpid()}_{time.time()}_{self.n}_{id(self)}" + self.n = self.n + 1 + headerFile = open(os.path.join(self.directory, fname + "-H"), "wb") + tempFilename = os.path.join(self.directory, fname + "-C") + finalFilename = os.path.join(self.directory, fname + "-D") + messageFile = open(tempFilename, "wb") + + from twisted.mail.mail import FileMessage + + return headerFile, FileMessage(messageFile, tempFilename, finalFilename) + + +class _AttemptManager: + """ + A manager for an attempt to relay a set of messages to a mail exchange + server. + + @ivar manager: See L{__init__} + + @type _completionDeferreds: L{list} of L{Deferred} + @ivar _completionDeferreds: Deferreds which are to be notified when the + attempt to relay is finished. + """ + + def __init__(self, manager, noisy=True, reactor=None): + """ + @type manager: L{SmartHostSMTPRelayingManager} + @param manager: A smart host. + + @type noisy: L{bool} + @param noisy: A flag which determines whether informational log + messages will be generated (L{True}) or not (L{False}). + + @type reactor: L{IReactorTime + <twisted.internet.interfaces.IReactorTime>} provider + @param reactor: A reactor which will be used to schedule delayed calls. + """ + self.manager = manager + self._completionDeferreds = [] + self.noisy = noisy + + if not reactor: + from twisted.internet import reactor + self.reactor = reactor + + def getCompletionDeferred(self): + """ + Return a deferred which will fire when the attempt to relay is + finished. + + @rtype: L{Deferred} + @return: A deferred which will fire when the attempt to relay is + finished. + """ + self._completionDeferreds.append(Deferred()) + return self._completionDeferreds[-1] + + def _finish(self, relay, message): + """ + Remove a message from the relay queue and from the smart host's list of + messages being relayed. + + @type relay: L{SMTPManagedRelayerFactory} + @param relay: The factory for the relayer which sent the message. + + @type message: L{bytes} + @param message: The path of the file holding the message. + """ + self.manager.managed[relay].remove(os.path.basename(message)) + self.manager.queue.done(message) + + def notifySuccess(self, relay, message): + """ + Remove a message from the relay queue after it has been successfully + sent. + + @type relay: L{SMTPManagedRelayerFactory} + @param relay: The factory for the relayer which sent the message. + + @type message: L{bytes} + @param message: The path of the file holding the message. + """ + if self.noisy: + log.msg("success sending %s, removing from queue" % message) + self._finish(relay, message) + + def notifyFailure(self, relay, message): + """ + Generate a bounce message for a message which cannot be relayed. + + @type relay: L{SMTPManagedRelayerFactory} + @param relay: The factory for the relayer responsible for the message. + + @type message: L{bytes} + @param message: The path of the file holding the message. + """ + if self.noisy: + log.msg("could not relay " + message) + # Moshe - Bounce E-mail here + # Be careful: if it's a bounced bounce, silently + # discard it + message = os.path.basename(message) + with self.manager.queue.getEnvelopeFile(message) as fp: + from_, to = pickle.load(fp) + from_, to, bounceMessage = bounce.generateBounce( + open(self.manager.queue.getPath(message) + "-D"), from_, to + ) + fp, outgoingMessage = self.manager.queue.createNewMessage() + with fp: + pickle.dump([from_, to], fp) + for line in bounceMessage.splitlines(): + outgoingMessage.lineReceived(line) + outgoingMessage.eomReceived() + self._finish(relay, self.manager.queue.getPath(message)) + + def notifyDone(self, relay): + """ + When the connection is lost or cannot be established, prepare to + resend unsent messages and fire all deferred which are waiting for + the completion of the attempt to relay. + + @type relay: L{SMTPManagedRelayerFactory} + @param relay: The factory for the relayer for the connection. + """ + for message in self.manager.managed.get(relay, ()): + if self.noisy: + log.msg("Setting " + message + " waiting") + self.manager.queue.setWaiting(message) + try: + del self.manager.managed[relay] + except KeyError: + pass + notifications = self._completionDeferreds + self._completionDeferreds = None + for d in notifications: + d.callback(None) + + def notifyNoConnection(self, relay): + """ + When a connection to the mail exchange server cannot be established, + prepare to resend messages later. + + @type relay: L{SMTPManagedRelayerFactory} + @param relay: The factory for the relayer meant to use the connection. + """ + # Back off a bit + try: + msgs = self.manager.managed[relay] + except KeyError: + log.msg("notifyNoConnection passed unknown relay!") + return + + if self.noisy: + log.msg("Backing off on delivery of " + str(msgs)) + + def setWaiting(queue, messages): + map(queue.setWaiting, messages) + + self.reactor.callLater(30, setWaiting, self.manager.queue, msgs) + del self.manager.managed[relay] + + +class SmartHostSMTPRelayingManager: + """ + A smart host which uses SMTP managed relayers to send messages from the + relay queue. + + L{checkState} must be called periodically at which time the state of the + relay queue is checked and new relayers are created as needed. + + In order to relay a set of messages to a mail exchange server, a smart host + creates an attempt manager and a managed relayer factory for that set of + messages. When a connection is made with the mail exchange server, the + managed relayer factory creates a managed relayer to send the messages. + The managed relayer reports on its progress to the attempt manager which, + in turn, updates the smart host's relay queue and information about its + managed relayers. + + @ivar queue: See L{__init__}. + @ivar maxConnections: See L{__init__}. + @ivar maxMessagesPerConnection: See L{__init__}. + + @type fArgs: 3-L{tuple} of (0) L{list} of L{bytes}, + (1) L{_AttemptManager}, (2) L{bytes} or 4-L{tuple} of (0) L{list} + of L{bytes}, (1) L{_AttemptManager}, (2) L{bytes}, (3) L{int} + @ivar fArgs: Positional arguments for + L{SMTPManagedRelayerFactory.__init__}. + + @type fKwArgs: L{dict} + @ivar fKwArgs: Keyword arguments for L{SMTPManagedRelayerFactory.__init__}. + + @type factory: callable which returns L{SMTPManagedRelayerFactory} + @ivar factory: A callable which creates a factory for creating a managed + relayer. See L{SMTPManagedRelayerFactory.__init__} for parameters to + the callable. + + @type PORT: L{int} + @ivar PORT: The port over which to connect to the SMTP server. + + @type mxcalc: L{None} or L{MXCalculator} + @ivar mxcalc: A resource for mail exchange host lookups. + + @type managed: L{dict} mapping L{SMTPManagedRelayerFactory} to L{list} of + L{bytes} + @ivar managed: A mapping of factory for a managed relayer to + filenames of messages the managed relayer is responsible for. + """ + + factory: Type[protocol.ClientFactory] = SMTPManagedRelayerFactory + + PORT = 25 + + mxcalc = None + + def __init__(self, queue, maxConnections=2, maxMessagesPerConnection=10): + """ + Initialize a smart host. + + The default values specify connection limits appropriate for a + low-volume smart host. + + @type queue: L{Queue} + @param queue: A relay queue. + + @type maxConnections: L{int} + @param maxConnections: The maximum number of concurrent connections to + SMTP servers. + + @type maxMessagesPerConnection: L{int} + @param maxMessagesPerConnection: The maximum number of messages for + which a relayer will be given responsibility. + """ + self.maxConnections = maxConnections + self.maxMessagesPerConnection = maxMessagesPerConnection + self.managed = {} # SMTP clients we're managing + self.queue = queue + self.fArgs = () + self.fKwArgs = {} + + def __getstate__(self): + """ + Create a representation of the non-volatile state of this object. + + @rtype: L{dict} mapping L{bytes} to L{object} + @return: The non-volatile state of the queue. + """ + dct = self.__dict__.copy() + del dct["managed"] + return dct + + def __setstate__(self, state): + """ + Restore the non-volatile state of this object and recreate the volatile + state. + + @type state: L{dict} mapping L{bytes} to L{object} + @param state: The non-volatile state of the queue. + """ + self.__dict__.update(state) + self.managed = {} + + def checkState(self): + """ + Check the state of the relay queue and, if possible, launch relayers to + handle waiting messages. + + @rtype: L{None} or L{Deferred} + @return: No return value if no further messages can be relayed or a + deferred which fires when all of the SMTP connections initiated by + this call have disconnected. + """ + self.queue.readDirectory() + if len(self.managed) >= self.maxConnections: + return + if not self.queue.hasWaiting(): + return + + return self._checkStateMX() + + def _checkStateMX(self): + nextMessages = self.queue.getWaiting() + nextMessages.reverse() + + exchanges = {} + for msg in nextMessages: + from_, to = self.queue.getEnvelope(msg) + name, addr = email.utils.parseaddr(to) + parts = addr.split("@", 1) + if len(parts) != 2: + log.err("Illegal message destination: " + to) + continue + domain = parts[1] + + self.queue.setRelaying(msg) + exchanges.setdefault(domain, []).append(self.queue.getPath(msg)) + if len(exchanges) >= (self.maxConnections - len(self.managed)): + break + + if self.mxcalc is None: + self.mxcalc = MXCalculator() + + relays = [] + for domain, msgs in exchanges.iteritems(): + manager = _AttemptManager(self, self.queue.noisy) + factory = self.factory(msgs, manager, *self.fArgs, **self.fKwArgs) + self.managed[factory] = map(os.path.basename, msgs) + relayAttemptDeferred = manager.getCompletionDeferred() + connectSetupDeferred = self.mxcalc.getMX(domain) + connectSetupDeferred.addCallback(lambda mx: str(mx.name)) + connectSetupDeferred.addCallback(self._cbExchange, self.PORT, factory) + connectSetupDeferred.addErrback( + lambda err: (relayAttemptDeferred.errback(err), err)[1] + ) + connectSetupDeferred.addErrback(self._ebExchange, factory, domain) + relays.append(relayAttemptDeferred) + return DeferredList(relays) + + def _cbExchange(self, address, port, factory): + """ + Initiate a connection with a mail exchange server. + + This callback function runs after mail exchange server for the domain + has been looked up. + + @type address: L{bytes} + @param address: The hostname of a mail exchange server. + + @type port: L{int} + @param port: A port number. + + @type factory: L{SMTPManagedRelayerFactory} + @param factory: A factory which can create a relayer for the mail + exchange server. + """ + from twisted.internet import reactor + + reactor.connectTCP(address, port, factory) + + def _ebExchange(self, failure, factory, domain): + """ + Prepare to resend messages later. + + This errback function runs when no mail exchange server for the domain + can be found. + + @type failure: L{Failure} + @param failure: The reason the mail exchange lookup failed. + + @type factory: L{SMTPManagedRelayerFactory} + @param factory: A factory which can create a relayer for the mail + exchange server. + + @type domain: L{bytes} + @param domain: A domain. + """ + log.err("Error setting up managed relay factory for " + domain) + log.err(failure) + + def setWaiting(queue, messages): + map(queue.setWaiting, messages) + + from twisted.internet import reactor + + reactor.callLater(30, setWaiting, self.queue, self.managed[factory]) + del self.managed[factory] + + +class SmartHostESMTPRelayingManager(SmartHostSMTPRelayingManager): + """ + A smart host which uses ESMTP managed relayers to send messages from the + relay queue. + + @type factory: callable which returns L{ESMTPManagedRelayerFactory} + @ivar factory: A callable which creates a factory for creating a managed + relayer. See L{ESMTPManagedRelayerFactory.__init__} for parameters to + the callable. + """ + + factory = ESMTPManagedRelayerFactory + + +def _checkState(manager): + """ + Prompt a relaying manager to check state. + + @type manager: L{SmartHostSMTPRelayingManager} + @param manager: A relaying manager. + """ + manager.checkState() + + +def RelayStateHelper(manager, delay): + """ + Set up a periodic call to prompt a relaying manager to check state. + + @type manager: L{SmartHostSMTPRelayingManager} + @param manager: A relaying manager. + + @type delay: L{float} + @param delay: The number of seconds between calls. + + @rtype: L{TimerService <internet.TimerService>} + @return: A service which periodically reminds a relaying manager to check + state. + """ + return internet.TimerService(delay, _checkState, manager) + + +class CanonicalNameLoop(Exception): + """ + An error indicating that when trying to look up a mail exchange host, a set + of canonical name records was found which form a cycle and resolution was + abandoned. + """ + + +class CanonicalNameChainTooLong(Exception): + """ + An error indicating that when trying to look up a mail exchange host, too + many canonical name records which point to other canonical name records + were encountered and resolution was abandoned. + """ + + +class MXCalculator: + """ + A utility for looking up mail exchange hosts and tracking whether they are + working or not. + + @type clock: L{IReactorTime <twisted.internet.interfaces.IReactorTime>} + provider + @ivar clock: A reactor which will be used to schedule timeouts. + + @type resolver: L{IResolver <twisted.internet.interfaces.IResolver>} + @ivar resolver: A resolver. + + @type badMXs: L{dict} mapping L{bytes} to L{float} + @ivar badMXs: A mapping of non-functioning mail exchange hostname to time + at which another attempt at contacting it may be made. + + @type timeOutBadMX: L{int} + @ivar timeOutBadMX: Period in seconds between attempts to contact a + non-functioning mail exchange host. + + @type fallbackToDomain: L{bool} + @ivar fallbackToDomain: A flag indicating whether to attempt to use the + hostname directly when no mail exchange can be found (C{True}) or + not (C{False}). + """ + + timeOutBadMX = 60 * 60 # One hour + fallbackToDomain = True + + def __init__(self, resolver=None, clock=None): + """ + @type resolver: L{IResolver <twisted.internet.interfaces.IResolver>} + provider or L{None} + @param resolver: A resolver. + + @type clock: L{IReactorTime <twisted.internet.interfaces.IReactorTime>} + provider or L{None} + @param clock: A reactor which will be used to schedule timeouts. + """ + self.badMXs = {} + if resolver is None: + from twisted.names.client import createResolver + + resolver = createResolver() + self.resolver = resolver + if clock is None: + from twisted.internet import reactor as clock + self.clock = clock + + def markBad(self, mx): + """ + Record that a mail exchange host is not currently functioning. + + @type mx: L{bytes} + @param mx: The hostname of a mail exchange host. + """ + self.badMXs[str(mx)] = self.clock.seconds() + self.timeOutBadMX + + def markGood(self, mx): + """ + Record that a mail exchange host is functioning. + + @type mx: L{bytes} + @param mx: The hostname of a mail exchange host. + """ + try: + del self.badMXs[mx] + except KeyError: + pass + + def getMX(self, domain, maximumCanonicalChainLength=3): + """ + Find the name of a host that acts as a mail exchange server + for a domain. + + @type domain: L{bytes} + @param domain: A domain name. + + @type maximumCanonicalChainLength: L{int} + @param maximumCanonicalChainLength: The maximum number of unique + canonical name records to follow while looking up the mail exchange + host. + + @rtype: L{Deferred} which successfully fires with L{Record_MX} + @return: A deferred which succeeds with the MX record for the mail + exchange server for the domain or fails if none can be found. + """ + mailExchangeDeferred = self.resolver.lookupMailExchange(domain) + mailExchangeDeferred.addCallback(self._filterRecords) + mailExchangeDeferred.addCallback( + self._cbMX, domain, maximumCanonicalChainLength + ) + mailExchangeDeferred.addErrback(self._ebMX, domain) + return mailExchangeDeferred + + def _filterRecords(self, records): + """ + Organize the records of a DNS response by record name. + + @type records: 3-L{tuple} of (0) L{list} of L{RRHeader + <twisted.names.dns.RRHeader>}, (1) L{list} of L{RRHeader + <twisted.names.dns.RRHeader>}, (2) L{list} of L{RRHeader + <twisted.names.dns.RRHeader>} + @param records: Answer resource records, authority resource records and + additional resource records. + + @rtype: L{dict} mapping L{bytes} to L{list} of L{IRecord + <twisted.names.dns.IRecord>} provider + @return: A mapping of record name to record payload. + """ + recordBag = {} + for answer in records[0]: + recordBag.setdefault(str(answer.name), []).append(answer.payload) + return recordBag + + def _cbMX(self, answers, domain, cnamesLeft): + """ + Try to find the mail exchange host for a domain from the given DNS + records. + + This will attempt to resolve canonical name record results. It can + recognize loops and will give up on non-cyclic chains after a specified + number of lookups. + + @type answers: L{dict} mapping L{bytes} to L{list} of L{IRecord + <twisted.names.dns.IRecord>} provider + @param answers: A mapping of record name to record payload. + + @type domain: L{bytes} + @param domain: A domain name. + + @type cnamesLeft: L{int} + @param cnamesLeft: The number of unique canonical name records + left to follow while looking up the mail exchange host. + + @rtype: L{Record_MX <twisted.names.dns.Record_MX>} or L{Failure} + @return: An MX record for the mail exchange host or a failure if one + cannot be found. + """ + # Do this import here so that relaymanager.py doesn't depend on + # twisted.names, only MXCalculator will. + from twisted.names import dns, error + + seenAliases = set() + exchanges = [] + # Examine the answers for the domain we asked about + pertinentRecords = answers.get(domain, []) + while pertinentRecords: + record = pertinentRecords.pop() + + # If it's a CNAME, we'll need to do some more processing + if record.TYPE == dns.CNAME: + # Remember that this name was an alias. + seenAliases.add(domain) + + canonicalName = str(record.name) + # See if we have some local records which might be relevant. + if canonicalName in answers: + # Make sure it isn't a loop contained entirely within the + # results we have here. + if canonicalName in seenAliases: + return Failure(CanonicalNameLoop(record)) + + pertinentRecords = answers[canonicalName] + exchanges = [] + else: + if cnamesLeft: + # Request more information from the server. + return self.getMX(canonicalName, cnamesLeft - 1) + else: + # Give up. + return Failure(CanonicalNameChainTooLong(record)) + + # If it's an MX, collect it. + if record.TYPE == dns.MX: + exchanges.append((record.preference, record)) + + if exchanges: + exchanges.sort() + for preference, record in exchanges: + host = str(record.name) + if host not in self.badMXs: + return record + t = self.clock.seconds() - self.badMXs[host] + if t >= 0: + del self.badMXs[host] + return record + return exchanges[0][1] + else: + # Treat no answers the same as an error - jump to the errback to + # try to look up an A record. This provides behavior described as + # a special case in RFC 974 in the section headed I{Interpreting + # the List of MX RRs}. + return Failure(error.DNSNameError(f"No MX records for {domain!r}")) + + def _ebMX(self, failure, domain): + """ + Attempt to use the name of the domain directly when mail exchange + lookup fails. + + @type failure: L{Failure} + @param failure: The reason for the lookup failure. + + @type domain: L{bytes} + @param domain: The domain name. + + @rtype: L{Record_MX <twisted.names.dns.Record_MX>} or L{Failure} + @return: An MX record for the domain or a failure if the fallback to + domain option is not in effect and an error, other than not + finding an MX record, occurred during lookup. + + @raise IOError: When no MX record could be found and the fallback to + domain option is not in effect. + + @raise DNSLookupError: When no MX record could be found and the + fallback to domain option is in effect but no address for the + domain could be found. + """ + from twisted.names import dns, error + + if self.fallbackToDomain: + failure.trap(error.DNSNameError) + log.msg( + "MX lookup failed; attempting to use hostname ({}) directly".format( + domain + ) + ) + + # Alright, I admit, this is a bit icky. + d = self.resolver.getHostByName(domain) + + def cbResolved(addr): + return dns.Record_MX(name=addr) + + def ebResolved(err): + err.trap(error.DNSNameError) + raise DNSLookupError() + + d.addCallbacks(cbResolved, ebResolved) + return d + elif failure.check(error.DNSNameError): + raise OSError(f"No MX found for {domain!r}") + return failure diff --git a/contrib/python/Twisted/py3/twisted/mail/scripts/__init__.py b/contrib/python/Twisted/py3/twisted/mail/scripts/__init__.py new file mode 100644 index 0000000000..f653cc71ed --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/scripts/__init__.py @@ -0,0 +1 @@ +"mail scripts" diff --git a/contrib/python/Twisted/py3/twisted/mail/scripts/mailmail.py b/contrib/python/Twisted/py3/twisted/mail/scripts/mailmail.py new file mode 100644 index 0000000000..cf1e58f5b6 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/scripts/mailmail.py @@ -0,0 +1,386 @@ +# -*- test-case-name: twisted.mail.test.test_mailmail -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + +""" +Implementation module for the I{mailmail} command. +""" + + +import email.utils +import getpass +import os +import sys +from configparser import ConfigParser +from io import StringIO + +from twisted.copyright import version +from twisted.internet import reactor +from twisted.logger import Logger, textFileLogObserver +from twisted.mail import smtp + +GLOBAL_CFG = "/etc/mailmail" +LOCAL_CFG = os.path.expanduser("~/.twisted/mailmail") +SMARTHOST = "127.0.0.1" + +ERROR_FMT = """\ +Subject: Failed Message Delivery + + Message delivery failed. The following occurred: + + %s +-- +The Twisted sendmail application. +""" + +_logObserver = textFileLogObserver(sys.stderr) +_log = Logger(observer=_logObserver) + + +class Options: + """ + Store the values of the parsed command-line options to the I{mailmail} + script. + + @type to: L{list} of L{str} + @ivar to: The addresses to which to deliver this message. + + @type sender: L{str} + @ivar sender: The address from which this message is being sent. + + @type body: C{file} + @ivar body: The object from which the message is to be read. + """ + + +def getlogin(): + try: + return os.getlogin() + except BaseException: + return getpass.getuser() + + +_unsupportedOption = SystemExit("Unsupported option.") + + +def parseOptions(argv): + o = Options() + o.to = [e for e in argv if not e.startswith("-")] + o.sender = getlogin() + + # Just be very stupid + + # Skip -bm -- it is the default + + # Add a non-standard option for querying the version of this tool. + if "--version" in argv: + print("mailmail version:", version) + raise SystemExit() + + # -bp lists queue information. Screw that. + if "-bp" in argv: + raise _unsupportedOption + + # -bs makes sendmail use stdin/stdout as its transport. Screw that. + if "-bs" in argv: + raise _unsupportedOption + + # -F sets who the mail is from, but is overridable by the From header + if "-F" in argv: + o.sender = argv[argv.index("-F") + 1] + o.to.remove(o.sender) + + # -i and -oi makes us ignore lone "." + if ("-i" in argv) or ("-oi" in argv): + raise _unsupportedOption + + # -odb is background delivery + if "-odb" in argv: + o.background = True + else: + o.background = False + + # -odf is foreground delivery + if "-odf" in argv: + o.background = False + else: + o.background = True + + # -oem and -em cause errors to be mailed back to the sender. + # It is also the default. + + # -oep and -ep cause errors to be printed to stderr + if ("-oep" in argv) or ("-ep" in argv): + o.printErrors = True + else: + o.printErrors = False + + # -om causes a copy of the message to be sent to the sender if the sender + # appears in an alias expansion. We do not support aliases. + if "-om" in argv: + raise _unsupportedOption + + # -t causes us to pick the recipients of the message from + # the To, Cc, and Bcc headers, and to remove the Bcc header + # if present. + if "-t" in argv: + o.recipientsFromHeaders = True + o.excludeAddresses = o.to + o.to = [] + else: + o.recipientsFromHeaders = False + o.exludeAddresses = [] + + requiredHeaders = { + "from": [], + "to": [], + "cc": [], + "bcc": [], + "date": [], + } + + buffer = StringIO() + while 1: + write = 1 + line = sys.stdin.readline() + if not line.strip(): + break + + hdrs = line.split(": ", 1) + + hdr = hdrs[0].lower() + if o.recipientsFromHeaders and hdr in ("to", "cc", "bcc"): + o.to.extend([email.utils.parseaddr(hdrs[1])[1]]) + if hdr == "bcc": + write = 0 + elif hdr == "from": + o.sender = email.utils.parseaddr(hdrs[1])[1] + + if hdr in requiredHeaders: + requiredHeaders[hdr].append(hdrs[1]) + + if write: + buffer.write(line) + + if not requiredHeaders["from"]: + buffer.write(f"From: {o.sender}\r\n") + if not requiredHeaders["to"]: + if not o.to: + raise SystemExit("No recipients specified.") + buffer.write("To: {}\r\n".format(", ".join(o.to))) + if not requiredHeaders["date"]: + buffer.write(f"Date: {smtp.rfc822date()}\r\n") + + buffer.write(line) + + if o.recipientsFromHeaders: + for a in o.excludeAddresses: + try: + o.to.remove(a) + except BaseException: + pass + + buffer.seek(0, 0) + o.body = StringIO(buffer.getvalue() + sys.stdin.read()) + return o + + +class Configuration: + """ + + @ivar allowUIDs: A list of UIDs which are allowed to send mail. + @ivar allowGIDs: A list of GIDs which are allowed to send mail. + @ivar denyUIDs: A list of UIDs which are not allowed to send mail. + @ivar denyGIDs: A list of GIDs which are not allowed to send mail. + + @type defaultAccess: L{bool} + @ivar defaultAccess: L{True} if access will be allowed when no other access + control rule matches or L{False} if it will be denied in that case. + + @ivar useraccess: Either C{'allow'} to check C{allowUID} first + or C{'deny'} to check C{denyUID} first. + + @ivar groupaccess: Either C{'allow'} to check C{allowGID} first or + C{'deny'} to check C{denyGID} first. + + @ivar identities: A L{dict} mapping hostnames to credentials to use when + sending mail to that host. + + @ivar smarthost: L{None} or a hostname through which all outgoing mail will + be sent. + + @ivar domain: L{None} or the hostname with which to identify ourselves when + connecting to an MTA. + """ + + def __init__(self): + self.allowUIDs = [] + self.denyUIDs = [] + self.allowGIDs = [] + self.denyGIDs = [] + self.useraccess = "deny" + self.groupaccess = "deny" + + self.identities = {} + self.smarthost = None + self.domain = None + + self.defaultAccess = True + + +def loadConfig(path): + # [useraccess] + # allow=uid1,uid2,... + # deny=uid1,uid2,... + # order=allow,deny + # [groupaccess] + # allow=gid1,gid2,... + # deny=gid1,gid2,... + # order=deny,allow + # [identity] + # host1=username:password + # host2=username:password + # [addresses] + # smarthost=a.b.c.d + # default_domain=x.y.z + + c = Configuration() + + if not os.access(path, os.R_OK): + return c + + p = ConfigParser() + p.read(path) + + au = c.allowUIDs + du = c.denyUIDs + ag = c.allowGIDs + dg = c.denyGIDs + for section, a, d in (("useraccess", au, du), ("groupaccess", ag, dg)): + if p.has_section(section): + for mode, L in (("allow", a), ("deny", d)): + if p.has_option(section, mode) and p.get(section, mode): + for sectionID in p.get(section, mode).split(","): + try: + sectionID = int(sectionID) + except ValueError: + _log.error( + "Illegal {prefix}ID in " + "[{section}] section: {sectionID}", + prefix=section[0].upper(), + section=section, + sectionID=sectionID, + ) + else: + L.append(sectionID) + order = p.get(section, "order") + order = [s.split() for s in [s.lower() for s in order.split(",")]] + if order[0] == "allow": + setattr(c, section, "allow") + else: + setattr(c, section, "deny") + + if p.has_section("identity"): + for host, up in p.items("identity"): + parts = up.split(":", 1) + if len(parts) != 2: + _log.error("Illegal entry in [identity] section: {section}", section=up) + continue + c.identities[host] = parts + + if p.has_section("addresses"): + if p.has_option("addresses", "smarthost"): + c.smarthost = p.get("addresses", "smarthost") + if p.has_option("addresses", "default_domain"): + c.domain = p.get("addresses", "default_domain") + + return c + + +def success(result): + reactor.stop() + + +failed = None + + +def failure(f): + global failed + reactor.stop() + failed = f + + +def sendmail(host, options, ident): + d = smtp.sendmail(host, options.sender, options.to, options.body) + d.addCallbacks(success, failure) + reactor.run() + + +def senderror(failure, options): + recipient = [options.sender] + sender = '"Internally Generated Message ({})"<postmaster@{}>'.format( + sys.argv[0], smtp.DNSNAME.decode("ascii") + ) + error = StringIO() + failure.printTraceback(file=error) + body = StringIO(ERROR_FMT % error.getvalue()) + d = smtp.sendmail("localhost", sender, recipient, body) + d.addBoth(lambda _: reactor.stop()) + + +def deny(conf): + uid = os.getuid() + gid = os.getgid() + + if conf.useraccess == "deny": + if uid in conf.denyUIDs: + return True + if uid in conf.allowUIDs: + return False + else: + if uid in conf.allowUIDs: + return False + if uid in conf.denyUIDs: + return True + + if conf.groupaccess == "deny": + if gid in conf.denyGIDs: + return True + if gid in conf.allowGIDs: + return False + else: + if gid in conf.allowGIDs: + return False + if gid in conf.denyGIDs: + return True + + return not conf.defaultAccess + + +def run(): + o = parseOptions(sys.argv[1:]) + gConf = loadConfig(GLOBAL_CFG) + lConf = loadConfig(LOCAL_CFG) + + if deny(gConf) or deny(lConf): + _log.error("Permission denied") + return + + host = lConf.smarthost or gConf.smarthost or SMARTHOST + + ident = gConf.identities.copy() + ident.update(lConf.identities) + + if lConf.domain: + smtp.DNSNAME = lConf.domain + elif gConf.domain: + smtp.DNSNAME = gConf.domain + + sendmail(host, o, ident) + + if failed: + if o.printErrors: + failed.printTraceback(file=sys.stderr) + raise SystemExit(1) + else: + senderror(failed, o) diff --git a/contrib/python/Twisted/py3/twisted/mail/smtp.py b/contrib/python/Twisted/py3/twisted/mail/smtp.py new file mode 100644 index 0000000000..55511647e6 --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/smtp.py @@ -0,0 +1,2270 @@ +# -*- test-case-name: twisted.mail.test.test_smtp -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. +# +# pylint: disable=I0011,C0103,C9302 + +""" +Simple Mail Transfer Protocol implementation. +""" + + +import base64 +import binascii +import os +import random +import re +import socket +import time +import warnings +from email.utils import parseaddr +from io import BytesIO +from typing import Type + +from zope.interface import implementer + +from twisted import cred +from twisted.copyright import longversion +from twisted.internet import defer, error, protocol, reactor +from twisted.internet._idna import _idnaText +from twisted.internet.interfaces import ISSLTransport, ITLSTransport +from twisted.mail._cred import ( + CramMD5ClientAuthenticator, + LOGINAuthenticator, + LOGINCredentials as _lcredentials, +) +from twisted.mail._except import ( + AddressError, + AUTHDeclinedError, + AuthenticationError, + AUTHRequiredError, + EHLORequiredError, + ESMTPClientError, + SMTPAddressError, + SMTPBadRcpt, + SMTPBadSender, + SMTPClientError, + SMTPConnectError, + SMTPDeliveryError, + SMTPError, + SMTPProtocolError, + SMTPServerError, + SMTPTimeoutError, + SMTPTLSError as TLSError, + TLSRequiredError, +) +from twisted.mail.interfaces import ( + IClientAuthentication, + IMessageDelivery, + IMessageDeliveryFactory, + IMessageSMTP as IMessage, +) +from twisted.protocols import basic, policies +from twisted.python import log, util +from twisted.python.compat import iterbytes, nativeString, networkString +from twisted.python.runtime import platform + +__all__ = [ + "AUTHDeclinedError", + "AUTHRequiredError", + "AddressError", + "AuthenticationError", + "EHLORequiredError", + "ESMTPClientError", + "SMTPAddressError", + "SMTPBadRcpt", + "SMTPBadSender", + "SMTPClientError", + "SMTPConnectError", + "SMTPDeliveryError", + "SMTPError", + "SMTPServerError", + "SMTPTimeoutError", + "TLSError", + "TLSRequiredError", + "SMTPProtocolError", + "IClientAuthentication", + "IMessage", + "IMessageDelivery", + "IMessageDeliveryFactory", + "CramMD5ClientAuthenticator", + "LOGINAuthenticator", + "LOGINCredentials", + "PLAINAuthenticator", + "Address", + "User", + "sendmail", + "SenderMixin", + "ESMTP", + "ESMTPClient", + "ESMTPSender", + "ESMTPSenderFactory", + "SMTP", + "SMTPClient", + "SMTPFactory", + "SMTPSender", + "SMTPSenderFactory", + "idGenerator", + "messageid", + "quoteaddr", + "rfc822date", + "xtextStreamReader", + "xtextStreamWriter", + "xtext_codec", + "xtext_decode", + "xtext_encode", +] + + +# Cache the hostname (XXX Yes - this is broken) +# Encode the DNS name into something we can send over the wire +if platform.isMacOSX(): + # On macOS, getfqdn() is ridiculously slow - use the + # probably-identical-but-sometimes-not gethostname() there. + DNSNAME = socket.gethostname().encode("ascii") +else: + DNSNAME = socket.getfqdn().encode("ascii") + +# Used for fast success code lookup +SUCCESS = dict.fromkeys(range(200, 300)) + + +def rfc822date(timeinfo=None, local=1): + """ + Format an RFC-2822 compliant date string. + + @param timeinfo: (optional) A sequence as returned by C{time.localtime()} + or C{time.gmtime()}. Default is now. + @param local: (optional) Indicates if the supplied time is local or + universal time, or if no time is given, whether now should be local or + universal time. Default is local, as suggested (SHOULD) by rfc-2822. + + @returns: A L{bytes} representing the time and date in RFC-2822 format. + """ + if not timeinfo: + if local: + timeinfo = time.localtime() + else: + timeinfo = time.gmtime() + if local: + if timeinfo[8]: + # DST + tz = -time.altzone + else: + tz = -time.timezone + + (tzhr, tzmin) = divmod(abs(tz), 3600) + if tz: + tzhr *= int(abs(tz) // tz) + (tzmin, tzsec) = divmod(tzmin, 60) + else: + (tzhr, tzmin) = (0, 0) + + return networkString( + "%s, %02d %s %04d %02d:%02d:%02d %+03d%02d" + % ( + ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][timeinfo[6]], + timeinfo[2], + [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ][timeinfo[1] - 1], + timeinfo[0], + timeinfo[3], + timeinfo[4], + timeinfo[5], + tzhr, + tzmin, + ) + ) + + +def idGenerator(): + i = 0 + while True: + yield i + i += 1 + + +_gen = idGenerator() + + +def messageid(uniq=None, N=lambda: next(_gen)): + """ + Return a globally unique random string in RFC 2822 Message-ID format + + <datetime.pid.random@host.dom.ain> + + Optional uniq string will be added to strengthen uniqueness if given. + """ + datetime = time.strftime("%Y%m%d%H%M%S", time.gmtime()) + pid = os.getpid() + rand = random.randrange(2**31 - 1) + if uniq is None: + uniq = "" + else: + uniq = "." + uniq + + return "<{}.{}.{}{}.{}@{}>".format( + datetime, pid, rand, uniq, N(), DNSNAME.decode() + ).encode() + + +def quoteaddr(addr): + """ + Turn an email address, possibly with realname part etc, into + a form suitable for and SMTP envelope. + """ + + if isinstance(addr, Address): + return b"<" + bytes(addr) + b">" + + if isinstance(addr, bytes): + addr = addr.decode("ascii") + + res = parseaddr(addr) + + if res == (None, None): + # It didn't parse, use it as-is + return b"<" + bytes(addr) + b">" + else: + return b"<" + res[1].encode("ascii") + b">" + + +COMMAND, DATA, AUTH = "COMMAND", "DATA", "AUTH" + + +# Character classes for parsing addresses +atom = rb"[-A-Za-z0-9!\#$%&'*+/=?^_`{|}~]" + + +class Address: + """Parse and hold an RFC 2821 address. + + Source routes are stipped and ignored, UUCP-style bang-paths + and %-style routing are not parsed. + + @type domain: C{bytes} + @ivar domain: The domain within which this address resides. + + @type local: C{bytes} + @ivar local: The local (\"user\") portion of this address. + """ + + tstring = re.compile( + rb"""( # A string of + (?:"[^"]*" # quoted string + |\\. # backslash-escaped characted + |""" + + atom + + rb""" # atom character + )+|.) # or any single character""", + re.X, + ) + atomre = re.compile(atom) # match any one atom character + + def __init__(self, addr, defaultDomain=None): + if isinstance(addr, User): + addr = addr.dest + if isinstance(addr, Address): + self.__dict__ = addr.__dict__.copy() + return + elif not isinstance(addr, bytes): + addr = str(addr).encode("ascii") + + self.addrstr = addr + + # Tokenize + atl = list(filter(None, self.tstring.split(addr))) + local = [] + domain = [] + + while atl: + if atl[0] == b"<": + if atl[-1] != b">": + raise AddressError("Unbalanced <>") + atl = atl[1:-1] + elif atl[0] == b"@": + atl = atl[1:] + if not local: + # Source route + while atl and atl[0] != b":": + # remove it + atl = atl[1:] + if not atl: + raise AddressError("Malformed source route") + atl = atl[1:] # remove : + elif domain: + raise AddressError("Too many @") + else: + # Now in domain + domain = [b""] + elif len(atl[0]) == 1 and not self.atomre.match(atl[0]) and atl[0] != b".": + raise AddressError(f"Parse error at {atl[0]!r} of {(addr, atl)!r}") + else: + if not domain: + local.append(atl[0]) + else: + domain.append(atl[0]) + atl = atl[1:] + + self.local = b"".join(local) + self.domain = b"".join(domain) + if self.local != b"" and self.domain == b"": + if defaultDomain is None: + defaultDomain = DNSNAME + self.domain = defaultDomain + + dequotebs = re.compile(rb"\\(.)") + + def dequote(self, addr): + """ + Remove RFC-2821 quotes from address. + """ + res = [] + + if not isinstance(addr, bytes): + addr = str(addr).encode("ascii") + + atl = filter(None, self.tstring.split(addr)) + + for t in atl: + if t[0] == b'"' and t[-1] == b'"': + res.append(t[1:-1]) + elif "\\" in t: + res.append(self.dequotebs.sub(rb"\1", t)) + else: + res.append(t) + + return b"".join(res) + + def __str__(self) -> str: + return self.__bytes__().decode("ascii") + + def __bytes__(self) -> bytes: + if self.local or self.domain: + return b"@".join((self.local, self.domain)) + else: + return b"" + + def __repr__(self) -> str: + return "{}.{}({})".format( + self.__module__, self.__class__.__name__, repr(str(self)) + ) + + +class User: + """ + Hold information about and SMTP message recipient, + including information on where the message came from + """ + + def __init__(self, destination, helo, protocol, orig): + try: + host = protocol.host + except AttributeError: + host = None + self.dest = Address(destination, host) + self.helo = helo + self.protocol = protocol + if isinstance(orig, Address): + self.orig = orig + else: + self.orig = Address(orig, host) + + def __getstate__(self): + """ + Helper for pickle. + + protocol isn't picklabe, but we want User to be, so skip it in + the pickle. + """ + return { + "dest": self.dest, + "helo": self.helo, + "protocol": None, + "orig": self.orig, + } + + def __str__(self) -> str: + return self.__bytes__().decode("ascii") + + def __bytes__(self) -> bytes: + return bytes(self.dest) + + +class SMTP(basic.LineOnlyReceiver, policies.TimeoutMixin): + """ + SMTP server-side protocol. + + @ivar host: The hostname of this mail server. + @type host: L{bytes} + """ + + timeout = 600 + portal = None + + # Control whether we log SMTP events + noisy = True + + # A factory for IMessageDelivery objects. If an + # avatar implementing IMessageDeliveryFactory can + # be acquired from the portal, it will be used to + # create a new IMessageDelivery object for each + # message which is received. + deliveryFactory = None + + # An IMessageDelivery object. A new instance is + # used for each message received if we can get an + # IMessageDeliveryFactory from the portal. Otherwise, + # a single instance is used throughout the lifetime + # of the connection. + delivery = None + + # Cred cleanup function. + _onLogout = None + + def __init__(self, delivery=None, deliveryFactory=None): + self.mode = COMMAND + self._from = None + self._helo = None + self._to = [] + self.delivery = delivery + self.deliveryFactory = deliveryFactory + self.host = DNSNAME + + @property + def host(self): + return self._host + + @host.setter + def host(self, toSet): + if not isinstance(toSet, bytes): + toSet = str(toSet).encode("ascii") + self._host = toSet + + def timeoutConnection(self): + msg = self.host + b" Timeout. Try talking faster next time!" + self.sendCode(421, msg) + self.transport.loseConnection() + + def greeting(self): + return self.host + b" NO UCE NO UBE NO RELAY PROBES" + + def connectionMade(self): + # Ensure user-code always gets something sane for _helo + peer = self.transport.getPeer() + try: + host = peer.host + except AttributeError: # not an IPv4Address + host = str(peer) + self._helo = (None, host) + self.sendCode(220, self.greeting()) + self.setTimeout(self.timeout) + + def sendCode(self, code, message=b""): + """ + Send an SMTP code with a message. + """ + lines = message.splitlines() + lastline = lines[-1:] + for line in lines[:-1]: + self.sendLine(networkString("%3.3d-" % (code,)) + line) + self.sendLine( + networkString("%3.3d " % (code,)) + (lastline and lastline[0] or b"") + ) + + def lineReceived(self, line): + self.resetTimeout() + return getattr(self, "state_" + self.mode)(line) + + def state_COMMAND(self, line): + # Ignore leading and trailing whitespace, as well as an arbitrary + # amount of whitespace between the command and its argument, though + # it is not required by the protocol, for it is a nice thing to do. + line = line.strip() + + parts = line.split(None, 1) + if parts: + method = self.lookupMethod(parts[0]) or self.do_UNKNOWN + if len(parts) == 2: + method(parts[1]) + else: + method(b"") + else: + self.sendSyntaxError() + + def sendSyntaxError(self): + self.sendCode(500, b"Error: bad syntax") + + def lookupMethod(self, command): + """ + + @param command: The command to get from this class. + @type command: L{str} + @return: The function which executes this command. + """ + if not isinstance(command, str): + command = nativeString(command) + + return getattr(self, "do_" + command.upper(), None) + + def lineLengthExceeded(self, line): + if self.mode is DATA: + for message in self.__messages: + message.connectionLost() + self.mode = COMMAND + del self.__messages + self.sendCode(500, b"Line too long") + + def do_UNKNOWN(self, rest): + self.sendCode(500, b"Command not implemented") + + def do_HELO(self, rest): + peer = self.transport.getPeer() + try: + host = peer.host + except AttributeError: + host = str(peer) + + if not isinstance(host, bytes): + host = host.encode("idna") + + self._helo = (rest, host) + self._from = None + self._to = [] + self.sendCode(250, self.host + b" Hello " + host + b", nice to meet you") + + def do_QUIT(self, rest): + self.sendCode(221, b"See you later") + self.transport.loseConnection() + + # A string of quoted strings, backslash-escaped character or + # atom characters + '@.,:' + qstring = rb'("[^"]*"|\\.|' + atom + rb"|[@.,:])+" + + mail_re = re.compile( + rb"""\s*FROM:\s*(?P<path><> # Empty <> + |<""" + + qstring + + rb"""> # <addr> + |""" + + qstring + + rb""" # addr + )\s*(\s(?P<opts>.*))? # Optional WS + ESMTP options + $""", + re.I | re.X, + ) + rcpt_re = re.compile( + rb"\s*TO:\s*(?P<path><" + + qstring + + rb"""> # <addr> + |""" + + qstring + + rb""" # addr + )\s*(\s(?P<opts>.*))? # Optional WS + ESMTP options + $""", + re.I | re.X, + ) + + def do_MAIL(self, rest): + if self._from: + self.sendCode(503, b"Only one sender per message, please") + return + # Clear old recipient list + self._to = [] + m = self.mail_re.match(rest) + if not m: + self.sendCode(501, b"Syntax error") + return + + try: + addr = Address(m.group("path"), self.host) + except AddressError as e: + self.sendCode(553, networkString(str(e))) + return + + validated = defer.maybeDeferred(self.validateFrom, self._helo, addr) + validated.addCallbacks(self._cbFromValidate, self._ebFromValidate) + + def _cbFromValidate(self, fromEmail, code=250, msg=b"Sender address accepted"): + self._from = fromEmail + self.sendCode(code, msg) + + def _ebFromValidate(self, failure): + if failure.check(SMTPBadSender): + self.sendCode( + failure.value.code, + ( + b"Cannot receive from specified address " + + quoteaddr(failure.value.addr) + + b": " + + networkString(failure.value.resp) + ), + ) + elif failure.check(SMTPServerError): + self.sendCode(failure.value.code, networkString(failure.value.resp)) + else: + log.err(failure, "SMTP sender validation failure") + self.sendCode(451, b"Requested action aborted: local error in processing") + + def do_RCPT(self, rest): + if not self._from: + self.sendCode(503, b"Must have sender before recipient") + return + m = self.rcpt_re.match(rest) + if not m: + self.sendCode(501, b"Syntax error") + return + + try: + user = User(m.group("path"), self._helo, self, self._from) + except AddressError as e: + self.sendCode(553, networkString(str(e))) + return + + d = defer.maybeDeferred(self.validateTo, user) + d.addCallbacks(self._cbToValidate, self._ebToValidate, callbackArgs=(user,)) + + def _cbToValidate(self, to, user=None, code=250, msg=b"Recipient address accepted"): + if user is None: + user = to + self._to.append((user, to)) + self.sendCode(code, msg) + + def _ebToValidate(self, failure): + if failure.check(SMTPBadRcpt, SMTPServerError): + self.sendCode(failure.value.code, networkString(failure.value.resp)) + else: + log.err(failure) + self.sendCode(451, b"Requested action aborted: local error in processing") + + def _disconnect(self, msgs): + for msg in msgs: + try: + msg.connectionLost() + except BaseException: + log.msg("msg raised exception from connectionLost") + log.err() + + def do_DATA(self, rest): + if self._from is None or (not self._to): + self.sendCode(503, b"Must have valid receiver and originator") + return + self.mode = DATA + helo, origin = self._helo, self._from + recipients = self._to + + self._from = None + self._to = [] + self.datafailed = None + + msgs = [] + for user, msgFunc in recipients: + try: + msg = msgFunc() + rcvdhdr = self.receivedHeader(helo, origin, [user]) + if rcvdhdr: + msg.lineReceived(rcvdhdr) + msgs.append(msg) + except SMTPServerError as e: + self.sendCode(e.code, e.resp) + self.mode = COMMAND + self._disconnect(msgs) + return + except BaseException: + log.err() + self.sendCode(550, b"Internal server error") + self.mode = COMMAND + self._disconnect(msgs) + return + self.__messages = msgs + + self.__inheader = self.__inbody = 0 + self.sendCode(354, b"Continue") + + if self.noisy: + fmt = "Receiving message for delivery: from=%s to=%s" + log.msg(fmt % (origin, [str(u) for (u, f) in recipients])) + + def connectionLost(self, reason): + # self.sendCode(421, 'Dropping connection.') # This does nothing... + # Ideally, if we (rather than the other side) lose the connection, + # we should be able to tell the other side that we are going away. + # RFC-2821 requires that we try. + if self.mode is DATA: + try: + for message in self.__messages: + try: + message.connectionLost() + except BaseException: + log.err() + del self.__messages + except AttributeError: + pass + if self._onLogout: + self._onLogout() + self._onLogout = None + self.setTimeout(None) + + def do_RSET(self, rest): + self._from = None + self._to = [] + self.sendCode(250, b"I remember nothing.") + + def dataLineReceived(self, line): + if line[:1] == b".": + if line == b".": + self.mode = COMMAND + if self.datafailed: + self.sendCode(self.datafailed.code, self.datafailed.resp) + return + if not self.__messages: + self._messageHandled("thrown away") + return + defer.DeferredList( + [m.eomReceived() for m in self.__messages], consumeErrors=True + ).addCallback(self._messageHandled) + del self.__messages + return + line = line[1:] + + if self.datafailed: + return + + try: + # Add a blank line between the generated Received:-header + # and the message body if the message comes in without any + # headers + if not self.__inheader and not self.__inbody: + if b":" in line: + self.__inheader = 1 + elif line: + for message in self.__messages: + message.lineReceived(b"") + self.__inbody = 1 + + if not line: + self.__inbody = 1 + + for message in self.__messages: + message.lineReceived(line) + except SMTPServerError as e: + self.datafailed = e + for message in self.__messages: + message.connectionLost() + + state_DATA = dataLineReceived + + def _messageHandled(self, resultList): + failures = 0 + for success, result in resultList: + if not success: + failures += 1 + log.err(result) + if failures: + msg = "Could not send e-mail" + resultLen = len(resultList) + if resultLen > 1: + msg += f" ({failures} failures out of {resultLen} recipients)" + self.sendCode(550, networkString(msg)) + else: + self.sendCode(250, b"Delivery in progress") + + def _cbAnonymousAuthentication(self, result): + """ + Save the state resulting from a successful anonymous cred login. + """ + (iface, avatar, logout) = result + if issubclass(iface, IMessageDeliveryFactory): + self.deliveryFactory = avatar + self.delivery = None + elif issubclass(iface, IMessageDelivery): + self.deliveryFactory = None + self.delivery = avatar + else: + raise RuntimeError(f"{iface.__name__} is not a supported interface") + self._onLogout = logout + self.challenger = None + + # overridable methods: + def validateFrom(self, helo, origin): + """ + Validate the address from which the message originates. + + @type helo: C{(bytes, bytes)} + @param helo: The argument to the HELO command and the client's IP + address. + + @type origin: C{Address} + @param origin: The address the message is from + + @rtype: C{Deferred} or C{Address} + @return: C{origin} or a C{Deferred} whose callback will be + passed C{origin}. + + @raise SMTPBadSender: Raised of messages from this address are + not to be accepted. + """ + if self.deliveryFactory is not None: + self.delivery = self.deliveryFactory.getMessageDelivery() + + if self.delivery is not None: + return defer.maybeDeferred(self.delivery.validateFrom, helo, origin) + + # No login has been performed, no default delivery object has been + # provided: try to perform an anonymous login and then invoke this + # method again. + if self.portal: + result = self.portal.login( + cred.credentials.Anonymous(), + None, + IMessageDeliveryFactory, + IMessageDelivery, + ) + + def ebAuthentication(err): + """ + Translate cred exceptions into SMTP exceptions so that the + protocol code which invokes C{validateFrom} can properly report + the failure. + """ + if err.check(cred.error.UnauthorizedLogin): + exc = SMTPBadSender(origin) + elif err.check(cred.error.UnhandledCredentials): + exc = SMTPBadSender( + origin, resp="Unauthenticated senders not allowed" + ) + else: + return err + return defer.fail(exc) + + result.addCallbacks(self._cbAnonymousAuthentication, ebAuthentication) + + def continueValidation(ignored): + """ + Re-attempt from address validation. + """ + return self.validateFrom(helo, origin) + + result.addCallback(continueValidation) + return result + + raise SMTPBadSender(origin) + + def validateTo(self, user): + """ + Validate the address for which the message is destined. + + @type user: L{User} + @param user: The address to validate. + + @rtype: no-argument callable + @return: A C{Deferred} which becomes, or a callable which + takes no arguments and returns an object implementing C{IMessage}. + This will be called and the returned object used to deliver the + message when it arrives. + + @raise SMTPBadRcpt: Raised if messages to the address are + not to be accepted. + """ + if self.delivery is not None: + return self.delivery.validateTo(user) + raise SMTPBadRcpt(user) + + def receivedHeader(self, helo, origin, recipients): + if self.delivery is not None: + return self.delivery.receivedHeader(helo, origin, recipients) + + heloStr = b"" + if helo[0]: + heloStr = b" helo=" + helo[0] + domain = networkString(self.transport.getHost().host) + + from_ = b"from " + helo[0] + b" ([" + helo[1] + b"]" + heloStr + b")" + by = b"by %s with %s (%s)" % (domain, self.__class__.__name__, longversion) + for_ = b"for %s; %s" % (" ".join(map(str, recipients)), rfc822date()) + return b"Received: " + from_ + b"\n\t" + by + b"\n\t" + for_ + + +class SMTPFactory(protocol.ServerFactory): + """ + Factory for SMTP. + """ + + # override in instances or subclasses + domain = DNSNAME + timeout = 600 + protocol = SMTP + + portal = None + + def __init__(self, portal=None): + self.portal = portal + + def buildProtocol(self, addr): + p = protocol.ServerFactory.buildProtocol(self, addr) + p.portal = self.portal + p.host = self.domain + return p + + +class SMTPClient(basic.LineReceiver, policies.TimeoutMixin): + """ + SMTP client for sending emails. + + After the client has connected to the SMTP server, it repeatedly calls + L{SMTPClient.getMailFrom}, L{SMTPClient.getMailTo} and + L{SMTPClient.getMailData} and uses this information to send an email. + It then calls L{SMTPClient.getMailFrom} again; if it returns L{None}, the + client will disconnect, otherwise it will continue as normal i.e. call + L{SMTPClient.getMailTo} and L{SMTPClient.getMailData} and send a new email. + """ + + # If enabled then log SMTP client server communication + debug = True + + # Number of seconds to wait before timing out a connection. If + # None, perform no timeout checking. + timeout = None + + def __init__(self, identity, logsize=10): + if isinstance(identity, str): + identity = identity.encode("ascii") + + self.identity = identity or b"" + self.toAddressesResult = [] + self.successAddresses = [] + self._from = None + self.resp = [] + self.code = -1 + self.log = util.LineLog(logsize) + + def sendLine(self, line): + # Log sendLine only if you are in debug mode for performance + if self.debug: + self.log.append(b">>> " + line) + + basic.LineReceiver.sendLine(self, line) + + def connectionMade(self): + self.setTimeout(self.timeout) + + self._expected = [220] + self._okresponse = self.smtpState_helo + self._failresponse = self.smtpConnectionFailed + + def connectionLost(self, reason=protocol.connectionDone): + """ + We are no longer connected + """ + self.setTimeout(None) + self.mailFile = None + + def timeoutConnection(self): + self.sendError( + SMTPTimeoutError( + -1, b"Timeout waiting for SMTP server response", self.log.str() + ) + ) + + def lineReceived(self, line): + self.resetTimeout() + + # Log lineReceived only if you are in debug mode for performance + if self.debug: + self.log.append(b"<<< " + line) + + why = None + + try: + self.code = int(line[:3]) + except ValueError: + # This is a fatal error and will disconnect the transport + # lineReceived will not be called again. + self.sendError( + SMTPProtocolError( + -1, + f"Invalid response from SMTP server: {line}", + self.log.str(), + ) + ) + return + + if line[0:1] == b"0": + # Verbose informational message, ignore it + return + + self.resp.append(line[4:]) + + if line[3:4] == b"-": + # Continuation + return + + if self.code in self._expected: + why = self._okresponse(self.code, b"\n".join(self.resp)) + else: + why = self._failresponse(self.code, b"\n".join(self.resp)) + + self.code = -1 + self.resp = [] + return why + + def smtpConnectionFailed(self, code, resp): + self.sendError(SMTPConnectError(code, resp, self.log.str())) + + def smtpTransferFailed(self, code, resp): + if code < 0: + self.sendError(SMTPProtocolError(code, resp, self.log.str())) + else: + self.smtpState_msgSent(code, resp) + + def smtpState_helo(self, code, resp): + self.sendLine(b"HELO " + self.identity) + self._expected = SUCCESS + self._okresponse = self.smtpState_from + + def smtpState_from(self, code, resp): + self._from = self.getMailFrom() + self._failresponse = self.smtpTransferFailed + if self._from is not None: + self.sendLine(b"MAIL FROM:" + quoteaddr(self._from)) + self._expected = [250] + self._okresponse = self.smtpState_to + else: + # All messages have been sent, disconnect + self._disconnectFromServer() + + def smtpState_disconnect(self, code, resp): + self.transport.loseConnection() + + def smtpState_to(self, code, resp): + self.toAddresses = iter(self.getMailTo()) + self.toAddressesResult = [] + self.successAddresses = [] + self._okresponse = self.smtpState_toOrData + self._expected = range(0, 1000) + self.lastAddress = None + return self.smtpState_toOrData(0, b"") + + def smtpState_toOrData(self, code, resp): + if self.lastAddress is not None: + self.toAddressesResult.append((self.lastAddress, code, resp)) + if code in SUCCESS: + self.successAddresses.append(self.lastAddress) + try: + self.lastAddress = next(self.toAddresses) + except StopIteration: + if self.successAddresses: + self.sendLine(b"DATA") + self._expected = [354] + self._okresponse = self.smtpState_data + else: + return self.smtpState_msgSent(code, "No recipients accepted") + else: + self.sendLine(b"RCPT TO:" + quoteaddr(self.lastAddress)) + + def smtpState_data(self, code, resp): + s = basic.FileSender() + d = s.beginFileTransfer(self.getMailData(), self.transport, self.transformChunk) + + def ebTransfer(err): + self.sendError(err.value) + + d.addCallbacks(self.finishedFileTransfer, ebTransfer) + self._expected = SUCCESS + self._okresponse = self.smtpState_msgSent + + def smtpState_msgSent(self, code, resp): + if self._from is not None: + self.sentMail( + code, resp, len(self.successAddresses), self.toAddressesResult, self.log + ) + + self.toAddressesResult = [] + self._from = None + self.sendLine(b"RSET") + self._expected = SUCCESS + self._okresponse = self.smtpState_from + + ## + ## Helpers for FileSender + ## + def transformChunk(self, chunk): + """ + Perform the necessary local to network newline conversion and escape + leading periods. + + This method also resets the idle timeout so that as long as process is + being made sending the message body, the client will not time out. + """ + self.resetTimeout() + return chunk.replace(b"\n", b"\r\n").replace(b"\r\n.", b"\r\n..") + + def finishedFileTransfer(self, lastsent): + if lastsent != b"\n": + line = b"\r\n." + else: + line = b"." + self.sendLine(line) + + ## + # these methods should be overridden in subclasses + def getMailFrom(self): + """ + Return the email address the mail is from. + """ + raise NotImplementedError + + def getMailTo(self): + """ + Return a list of emails to send to. + """ + raise NotImplementedError + + def getMailData(self): + """ + Return file-like object containing data of message to be sent. + + Lines in the file should be delimited by '\\n'. + """ + raise NotImplementedError + + def sendError(self, exc): + """ + If an error occurs before a mail message is sent sendError will be + called. This base class method sends a QUIT if the error is + non-fatal and disconnects the connection. + + @param exc: The SMTPClientError (or child class) raised + @type exc: C{SMTPClientError} + """ + if isinstance(exc, SMTPClientError) and not exc.isFatal: + self._disconnectFromServer() + else: + # If the error was fatal then the communication channel with the + # SMTP Server is broken so just close the transport connection + self.smtpState_disconnect(-1, None) + + def sentMail(self, code, resp, numOk, addresses, log): + """ + Called when an attempt to send an email is completed. + + If some addresses were accepted, code and resp are the response + to the DATA command. If no addresses were accepted, code is -1 + and resp is an informative message. + + @param code: the code returned by the SMTP Server + @param resp: The string response returned from the SMTP Server + @param numOk: the number of addresses accepted by the remote host. + @param addresses: is a list of tuples (address, code, resp) listing + the response to each RCPT command. + @param log: is the SMTP session log + """ + raise NotImplementedError + + def _disconnectFromServer(self): + self._expected = range(0, 1000) + self._okresponse = self.smtpState_disconnect + self.sendLine(b"QUIT") + + +class ESMTPClient(SMTPClient): + """ + A client for sending emails over ESMTP. + + @ivar heloFallback: Whether or not to fall back to plain SMTP if the C{EHLO} + command is not recognised by the server. If L{requireAuthentication} is + C{True}, or L{requireTransportSecurity} is C{True} and the connection is + not over TLS, this fallback flag will not be honored. + @type heloFallback: L{bool} + + @ivar requireAuthentication: If C{True}, refuse to proceed if authentication + cannot be performed. Overrides L{heloFallback}. + @type requireAuthentication: L{bool} + + @ivar requireTransportSecurity: If C{True}, refuse to proceed if the + transport cannot be secured. If the transport layer is not already + secured via TLS, this will override L{heloFallback}. + @type requireAuthentication: L{bool} + + @ivar context: The context factory to use for STARTTLS, if desired. + @type context: L{IOpenSSLClientConnectionCreator} + + @ivar _tlsMode: Whether or not the connection is over TLS. + @type _tlsMode: L{bool} + """ + + heloFallback = True + requireAuthentication = False + requireTransportSecurity = False + context = None + _tlsMode = False + + def __init__(self, secret, contextFactory=None, *args, **kw): + SMTPClient.__init__(self, *args, **kw) + self.authenticators = [] + self.secret = secret + self.context = contextFactory + + def __getattr__(self, name): + if name == "tlsMode": + warnings.warn( + "tlsMode attribute of twisted.mail.smtp.ESMTPClient " + "is deprecated since Twisted 13.0", + category=DeprecationWarning, + stacklevel=2, + ) + return self._tlsMode + else: + raise AttributeError( + "%s instance has no attribute %r" + % ( + self.__class__.__name__, + name, + ) + ) + + def __setattr__(self, name, value): + if name == "tlsMode": + warnings.warn( + "tlsMode attribute of twisted.mail.smtp.ESMTPClient " + "is deprecated since Twisted 13.0", + category=DeprecationWarning, + stacklevel=2, + ) + self._tlsMode = value + else: + self.__dict__[name] = value + + def esmtpEHLORequired(self, code=-1, resp=None): + """ + Fail because authentication is required, but the server does not support + ESMTP, which is required for authentication. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError( + EHLORequiredError( + 502, b"Server does not support ESMTP " b"Authentication", self.log.str() + ) + ) + + def esmtpAUTHRequired(self, code=-1, resp=None): + """ + Fail because authentication is required, but the server does not support + any schemes we support. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + tmp = [] + + for a in self.authenticators: + tmp.append(a.getName().upper()) + + auth = b"[%s]" % b", ".join(tmp) + + self.sendError( + AUTHRequiredError( + 502, + b"Server does not support Client " b"Authentication schemes %s" % auth, + self.log.str(), + ) + ) + + def esmtpTLSRequired(self, code=-1, resp=None): + """ + Fail because TLS is required and the server does not support it. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError( + TLSRequiredError( + 502, + b"Server does not support secure " b"communication via TLS / SSL", + self.log.str(), + ) + ) + + def esmtpTLSFailed(self, code=-1, resp=None): + """ + Fail because the TLS handshake wasn't able to be completed. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError( + TLSError( + code, b"Could not complete the SSL/TLS " b"handshake", self.log.str() + ) + ) + + def esmtpAUTHDeclined(self, code=-1, resp=None): + """ + Fail because the authentication was rejected. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError(AUTHDeclinedError(code, resp, self.log.str())) + + def esmtpAUTHMalformedChallenge(self, code=-1, resp=None): + """ + Fail because the server sent a malformed authentication challenge. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError( + AuthenticationError( + 501, + b"Login failed because the " + b"SMTP Server returned a malformed Authentication Challenge", + self.log.str(), + ) + ) + + def esmtpAUTHServerError(self, code=-1, resp=None): + """ + Fail because of some other authentication error. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError(AuthenticationError(code, resp, self.log.str())) + + def registerAuthenticator(self, auth): + """ + Registers an Authenticator with the ESMTPClient. The ESMTPClient will + attempt to login to the SMTP Server in the order the Authenticators are + registered. The most secure Authentication mechanism should be + registered first. + + @param auth: The Authentication mechanism to register + @type auth: L{IClientAuthentication} implementor + + @return: L{None} + """ + self.authenticators.append(auth) + + def connectionMade(self): + """ + Called when a connection has been made, and triggers sending an C{EHLO} + to the server. + """ + self._tlsMode = ISSLTransport.providedBy(self.transport) + SMTPClient.connectionMade(self) + self._okresponse = self.esmtpState_ehlo + + def esmtpState_ehlo(self, code, resp): + """ + Send an C{EHLO} to the server. + + If L{heloFallback} is C{True}, and there is no requirement for TLS or + authentication, the client will fall back to basic SMTP. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + + @return: L{None} + """ + self._expected = SUCCESS + + self._okresponse = self.esmtpState_serverConfig + self._failresponse = self.esmtpEHLORequired + + if self._tlsMode: + needTLS = False + else: + needTLS = self.requireTransportSecurity + + if self.heloFallback and not self.requireAuthentication and not needTLS: + self._failresponse = self.smtpState_helo + + self.sendLine(b"EHLO " + self.identity) + + def esmtpState_serverConfig(self, code, resp): + """ + Handle a positive response to the I{EHLO} command by parsing the + capabilities in the server's response and then taking the most + appropriate next step towards entering a mail transaction. + """ + items = {} + for line in resp.splitlines(): + e = line.split(None, 1) + if len(e) > 1: + items[e[0]] = e[1] + else: + items[e[0]] = None + + self.tryTLS(code, resp, items) + + def tryTLS(self, code, resp, items): + """ + Take a necessary step towards being able to begin a mail transaction. + + The step may be to ask the server to being a TLS session. If TLS is + already in use or not necessary and not available then the step may be + to authenticate with the server. If TLS is necessary and not available, + fail the mail transmission attempt. + + This is an internal helper method. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + + @param items: A mapping of ESMTP extensions offered by the server. Keys + are extension identifiers and values are the associated values. + @type items: L{dict} mapping L{bytes} to L{bytes} + + @return: L{None} + """ + + # has tls can tls must tls result + # t t t authenticate + # t t f authenticate + # t f t authenticate + # t f f authenticate + + # f t t STARTTLS + # f t f STARTTLS + # f f t esmtpTLSRequired + # f f f authenticate + + hasTLS = self._tlsMode + canTLS = self.context and b"STARTTLS" in items + mustTLS = self.requireTransportSecurity + + if hasTLS or not (canTLS or mustTLS): + self.authenticate(code, resp, items) + elif canTLS: + self._expected = [220] + self._okresponse = self.esmtpState_starttls + self._failresponse = self.esmtpTLSFailed + self.sendLine(b"STARTTLS") + else: + self.esmtpTLSRequired() + + def esmtpState_starttls(self, code, resp): + """ + Handle a positive response to the I{STARTTLS} command by starting a new + TLS session on C{self.transport}. + + Upon success, re-handshake with the server to discover what capabilities + it has when TLS is in use. + """ + try: + self.transport.startTLS(self.context) + self._tlsMode = True + except BaseException: + log.err() + self.esmtpTLSFailed(451) + + # Send another EHLO once TLS has been started to + # get the TLS / AUTH schemes. Some servers only allow AUTH in TLS mode. + self.esmtpState_ehlo(code, resp) + + def authenticate(self, code, resp, items): + if self.secret and items.get(b"AUTH"): + schemes = items[b"AUTH"].split() + tmpSchemes = {} + + # XXX: May want to come up with a more efficient way to do this + for s in schemes: + tmpSchemes[s.upper()] = 1 + + for a in self.authenticators: + auth = a.getName().upper() + + if auth in tmpSchemes: + self._authinfo = a + + # Special condition handled + if auth == b"PLAIN": + self._okresponse = self.smtpState_from + self._failresponse = self._esmtpState_plainAuth + self._expected = [235] + challenge = base64.b64encode( + self._authinfo.challengeResponse(self.secret, 1) + ) + self.sendLine(b"AUTH %s %s" % (auth, challenge)) + else: + self._expected = [334] + self._okresponse = self.esmtpState_challenge + # If some error occurs here, the server declined the + # AUTH before the user / password phase. This would be + # a very rare case + self._failresponse = self.esmtpAUTHServerError + self.sendLine(b"AUTH " + auth) + return + + if self.requireAuthentication: + self.esmtpAUTHRequired() + else: + self.smtpState_from(code, resp) + + def _esmtpState_plainAuth(self, code, resp): + self._okresponse = self.smtpState_from + self._failresponse = self.esmtpAUTHDeclined + self._expected = [235] + challenge = base64.b64encode(self._authinfo.challengeResponse(self.secret, 2)) + self.sendLine(b"AUTH PLAIN " + challenge) + + def esmtpState_challenge(self, code, resp): + self._authResponse(self._authinfo, resp) + + def _authResponse(self, auth, challenge): + self._failresponse = self.esmtpAUTHDeclined + try: + challenge = base64.b64decode(challenge) + except binascii.Error: + # Illegal challenge, give up, then quit + self.sendLine(b"*") + self._okresponse = self.esmtpAUTHMalformedChallenge + self._failresponse = self.esmtpAUTHMalformedChallenge + else: + resp = auth.challengeResponse(self.secret, challenge) + self._expected = [235, 334] + self._okresponse = self.smtpState_maybeAuthenticated + self.sendLine(base64.b64encode(resp)) + + def smtpState_maybeAuthenticated(self, code, resp): + """ + Called to handle the next message from the server after sending a + response to a SASL challenge. The server response might be another + challenge or it might indicate authentication has succeeded. + """ + if code == 235: + # Yes, authenticated! + del self._authinfo + self.smtpState_from(code, resp) + else: + # No, not authenticated yet. Keep trying. + self._authResponse(self._authinfo, resp) + + +class ESMTP(SMTP): + ctx = None + canStartTLS = False + startedTLS = False + + authenticated = False + + def __init__(self, chal=None, contextFactory=None): + SMTP.__init__(self) + if chal is None: + chal = {} + self.challengers = chal + self.authenticated = False + self.ctx = contextFactory + + def connectionMade(self): + SMTP.connectionMade(self) + self.canStartTLS = ITLSTransport.providedBy(self.transport) + self.canStartTLS = self.canStartTLS and (self.ctx is not None) + + def greeting(self): + return SMTP.greeting(self) + b" ESMTP" + + def extensions(self): + """ + SMTP service extensions + + @return: the SMTP service extensions that are supported. + @rtype: L{dict} with L{bytes} keys and a value of either L{None} or a + L{list} of L{bytes}. + """ + ext = {b"AUTH": list(self.challengers.keys())} + if self.canStartTLS and not self.startedTLS: + ext[b"STARTTLS"] = None + return ext + + def lookupMethod(self, command): + command = nativeString(command) + + m = SMTP.lookupMethod(self, command) + if m is None: + m = getattr(self, "ext_" + command.upper(), None) + return m + + def listExtensions(self): + r = [] + for c, v in self.extensions().items(): + if v is not None: + if v: + # Intentionally omit extensions with empty argument lists + r.append(c + b" " + b" ".join(v)) + else: + r.append(c) + + return b"\n".join(r) + + def do_EHLO(self, rest): + peer = self.transport.getPeer().host + + if not isinstance(peer, bytes): + peer = peer.encode("idna") + + self._helo = (rest, peer) + self._from = None + self._to = [] + self.sendCode( + 250, + ( + self.host + + b" Hello " + + peer + + b", nice to meet you\n" + + self.listExtensions() + ), + ) + + def ext_STARTTLS(self, rest): + if self.startedTLS: + self.sendCode(503, b"TLS already negotiated") + elif self.ctx and self.canStartTLS: + self.sendCode(220, b"Begin TLS negotiation now") + self.transport.startTLS(self.ctx) + self.startedTLS = True + else: + self.sendCode(454, b"TLS not available") + + def ext_AUTH(self, rest): + if self.authenticated: + self.sendCode(503, b"Already authenticated") + return + parts = rest.split(None, 1) + chal = self.challengers.get(parts[0].upper(), lambda: None)() + if not chal: + self.sendCode(504, b"Unrecognized authentication type") + return + + self.mode = AUTH + self.challenger = chal + + if len(parts) > 1: + chal.getChallenge() # Discard it, apparently the client does not + # care about it. + rest = parts[1] + else: + rest = None + self.state_AUTH(rest) + + def _cbAuthenticated(self, loginInfo): + """ + Save the state resulting from a successful cred login and mark this + connection as authenticated. + """ + result = SMTP._cbAnonymousAuthentication(self, loginInfo) + self.authenticated = True + return result + + def _ebAuthenticated(self, reason): + """ + Handle cred login errors by translating them to the SMTP authenticate + failed. Translate all other errors into a generic SMTP error code and + log the failure for inspection. Stop all errors from propagating. + + @param reason: Reason for failure. + """ + self.challenge = None + if reason.check(cred.error.UnauthorizedLogin): + self.sendCode(535, b"Authentication failed") + else: + log.err(reason, "SMTP authentication failure") + self.sendCode(451, b"Requested action aborted: local error in processing") + + def state_AUTH(self, response): + """ + Handle one step of challenge/response authentication. + + @param response: The text of a response. If None, this + function has been called as a result of an AUTH command with + no initial response. A response of '*' aborts authentication, + as per RFC 2554. + """ + if self.portal is None: + self.sendCode(454, b"Temporary authentication failure") + self.mode = COMMAND + return + + if response is None: + challenge = self.challenger.getChallenge() + encoded = base64.b64encode(challenge) + self.sendCode(334, encoded) + return + + if response == b"*": + self.sendCode(501, b"Authentication aborted") + self.challenger = None + self.mode = COMMAND + return + + try: + uncoded = base64.b64decode(response) + except (TypeError, binascii.Error): + self.sendCode(501, b"Syntax error in parameters or arguments") + self.challenger = None + self.mode = COMMAND + return + + self.challenger.setResponse(uncoded) + if self.challenger.moreChallenges(): + challenge = self.challenger.getChallenge() + coded = base64.b64encode(challenge) + self.sendCode(334, coded) + return + + self.mode = COMMAND + result = self.portal.login( + self.challenger, None, IMessageDeliveryFactory, IMessageDelivery + ) + result.addCallback(self._cbAuthenticated) + result.addCallback( + lambda ign: self.sendCode(235, b"Authentication successful.") + ) + result.addErrback(self._ebAuthenticated) + + +class SenderMixin: + """ + Utility class for sending emails easily. + + Use with SMTPSenderFactory or ESMTPSenderFactory. + """ + + done = 0 + + def getMailFrom(self): + if not self.done: + self.done = 1 + return str(self.factory.fromEmail) + else: + return None + + def getMailTo(self): + return self.factory.toEmail + + def getMailData(self): + return self.factory.file + + def sendError(self, exc): + # Call the base class to close the connection with the SMTP server + SMTPClient.sendError(self, exc) + + # Do not retry to connect to SMTP Server if: + # 1. No more retries left (This allows the correct error to be returned to the errorback) + # 2. retry is false + # 3. The error code is not in the 4xx range (Communication Errors) + + if self.factory.retries >= 0 or ( + not exc.retry and not (exc.code >= 400 and exc.code < 500) + ): + self.factory.sendFinished = True + self.factory.result.errback(exc) + + def sentMail(self, code, resp, numOk, addresses, log): + # Do not retry, the SMTP server acknowledged the request + self.factory.sendFinished = True + if code not in SUCCESS: + errlog = [] + for addr, acode, aresp in addresses: + if acode not in SUCCESS: + errlog.append( + addr + b": " + networkString("%03d" % (acode,)) + b" " + aresp + ) + + errlog.append(log.str()) + + exc = SMTPDeliveryError(code, resp, b"\n".join(errlog), addresses) + self.factory.result.errback(exc) + else: + self.factory.result.callback((numOk, addresses)) + + +class SMTPSender(SenderMixin, SMTPClient): + """ + SMTP protocol that sends a single email based on information it + gets from its factory, a L{SMTPSenderFactory}. + """ + + +class SMTPSenderFactory(protocol.ClientFactory): + """ + Utility factory for sending emails easily. + + @type currentProtocol: L{SMTPSender} + @ivar currentProtocol: The current running protocol returned by + L{buildProtocol}. + + @type sendFinished: C{bool} + @ivar sendFinished: When the value is set to True, it means the message has + been sent or there has been an unrecoverable error or the sending has + been cancelled. The default value is False. + """ + + domain = DNSNAME + protocol: Type[SMTPClient] = SMTPSender + + def __init__(self, fromEmail, toEmail, file, deferred, retries=5, timeout=None): + """ + @param fromEmail: The RFC 2821 address from which to send this + message. + + @param toEmail: A sequence of RFC 2821 addresses to which to + send this message. + + @param file: A file-like object containing the message to send. + + @param deferred: A Deferred to callback or errback when sending + of this message completes. + @type deferred: L{defer.Deferred} + + @param retries: The number of times to retry delivery of this + message. + + @param timeout: Period, in seconds, for which to wait for + server responses, or None to wait forever. + """ + assert isinstance(retries, int) + + if isinstance(toEmail, str): + toEmail = [toEmail.encode("ascii")] + elif isinstance(toEmail, bytes): + toEmail = [toEmail] + else: + toEmailFinal = [] + for _email in toEmail: + if not isinstance(_email, bytes): + _email = _email.encode("ascii") + + toEmailFinal.append(_email) + toEmail = toEmailFinal + + self.fromEmail = Address(fromEmail) + self.nEmails = len(toEmail) + self.toEmail = toEmail + self.file = file + self.result = deferred + self.result.addBoth(self._removeDeferred) + self.sendFinished = False + self.currentProtocol = None + + self.retries = -retries + self.timeout = timeout + + def _removeDeferred(self, result): + del self.result + return result + + def clientConnectionFailed(self, connector, err): + self._processConnectionError(connector, err) + + def clientConnectionLost(self, connector, err): + self._processConnectionError(connector, err) + + def _processConnectionError(self, connector, err): + self.currentProtocol = None + if (self.retries < 0) and (not self.sendFinished): + log.msg("SMTP Client retrying server. Retry: %s" % -self.retries) + + # Rewind the file in case part of it was read while attempting to + # send the message. + self.file.seek(0, 0) + connector.connect() + self.retries += 1 + elif not self.sendFinished: + # If we were unable to communicate with the SMTP server a ConnectionDone will be + # returned. We want a more clear error message for debugging + if err.check(error.ConnectionDone): + err.value = SMTPConnectError(-1, "Unable to connect to server.") + self.result.errback(err.value) + + def buildProtocol(self, addr): + p = self.protocol(self.domain, self.nEmails * 2 + 2) + p.factory = self + p.timeout = self.timeout + self.currentProtocol = p + self.result.addBoth(self._removeProtocol) + return p + + def _removeProtocol(self, result): + """ + Remove the protocol created in C{buildProtocol}. + + @param result: The result/error passed to the callback/errback of + L{defer.Deferred}. + + @return: The C{result} untouched. + """ + if self.currentProtocol: + self.currentProtocol = None + return result + + +class LOGINCredentials(_lcredentials): + """ + L{LOGINCredentials} generates challenges for I{LOGIN} authentication. + + For interoperability with Outlook, the challenge generated does not exactly + match the one defined in the + U{draft specification<http://sepp.oetiker.ch/sasl-2.1.19-ds/draft-murchison-sasl-login-00.txt>}. + """ + + def __init__(self): + _lcredentials.__init__(self) + self.challenges = [b"Password:", b"Username:"] + + +@implementer(IClientAuthentication) +class PLAINAuthenticator: + def __init__(self, user): + self.user = user + + def getName(self): + return b"PLAIN" + + def challengeResponse(self, secret, chal=1): + if chal == 1: + return self.user + b"\0" + self.user + b"\0" + secret + else: + return b"\0" + self.user + b"\0" + secret + + +class ESMTPSender(SenderMixin, ESMTPClient): + requireAuthentication = True + requireTransportSecurity = True + + def __init__(self, username, secret, contextFactory=None, *args, **kw): + self.heloFallback = 0 + self.username = username + + self._hostname = kw.pop("hostname", None) + + if contextFactory is None: + contextFactory = self._getContextFactory() + + ESMTPClient.__init__(self, secret, contextFactory, *args, **kw) + + self._registerAuthenticators() + + def _registerAuthenticators(self): + # Register Authenticator in order from most secure to least secure + self.registerAuthenticator(CramMD5ClientAuthenticator(self.username)) + self.registerAuthenticator(LOGINAuthenticator(self.username)) + self.registerAuthenticator(PLAINAuthenticator(self.username)) + + def _getContextFactory(self): + if self.context is not None: + return self.context + if self._hostname is None: + return None + try: + from twisted.internet.ssl import optionsForClientTLS + except ImportError: + return None + else: + context = optionsForClientTLS(self._hostname) + return context + + +class ESMTPSenderFactory(SMTPSenderFactory): + """ + Utility factory for sending emails easily. + + @type currentProtocol: L{ESMTPSender} + @ivar currentProtocol: The current running protocol as made by + L{buildProtocol}. + """ + + protocol = ESMTPSender + + def __init__( + self, + username, + password, + fromEmail, + toEmail, + file, + deferred, + retries=5, + timeout=None, + contextFactory=None, + heloFallback=False, + requireAuthentication=True, + requireTransportSecurity=True, + hostname=None, + ): + SMTPSenderFactory.__init__( + self, fromEmail, toEmail, file, deferred, retries, timeout + ) + self.username = username + self.password = password + self._contextFactory = contextFactory + self._heloFallback = heloFallback + self._requireAuthentication = requireAuthentication + self._requireTransportSecurity = requireTransportSecurity + self._hostname = hostname + + def buildProtocol(self, addr): + """ + Build an L{ESMTPSender} protocol configured with C{heloFallback}, + C{requireAuthentication}, and C{requireTransportSecurity} as specified + in L{__init__}. + + This sets L{currentProtocol} on the factory, as well as returning it. + + @rtype: L{ESMTPSender} + """ + p = self.protocol( + self.username, + self.password, + self._contextFactory, + self.domain, + self.nEmails * 2 + 2, + hostname=self._hostname, + ) + p.heloFallback = self._heloFallback + p.requireAuthentication = self._requireAuthentication + p.requireTransportSecurity = self._requireTransportSecurity + p.factory = self + p.timeout = self.timeout + self.currentProtocol = p + self.result.addBoth(self._removeProtocol) + return p + + +def sendmail( + smtphost, + from_addr, + to_addrs, + msg, + senderDomainName=None, + port=25, + reactor=reactor, + username=None, + password=None, + requireAuthentication=False, + requireTransportSecurity=False, +): + """ + Send an email. + + This interface is intended to be a replacement for L{smtplib.SMTP.sendmail} + and related methods. To maintain backwards compatibility, it will fall back + to plain SMTP, if ESMTP support is not available. If ESMTP support is + available, it will attempt to provide encryption via STARTTLS and + authentication if a secret is provided. + + @param smtphost: The host the message should be sent to. + @type smtphost: L{bytes} + + @param from_addr: The (envelope) address sending this mail. + @type from_addr: L{bytes} + + @param to_addrs: A list of addresses to send this mail to. A string will + be treated as a list of one address. + @type to_addrs: L{list} of L{bytes} or L{bytes} + + @param msg: The message, including headers, either as a file or a string. + File-like objects need to support read() and close(). Lines must be + delimited by '\\n'. If you pass something that doesn't look like a file, + we try to convert it to a string (so you should be able to pass an + L{email.message} directly, but doing the conversion with + L{email.generator} manually will give you more control over the process). + + @param senderDomainName: Name by which to identify. If None, try to pick + something sane (but this depends on external configuration and may not + succeed). + @type senderDomainName: L{bytes} + + @param port: Remote port to which to connect. + @type port: L{int} + + @param username: The username to use, if wanting to authenticate. + @type username: L{bytes} or L{unicode} + + @param password: The secret to use, if wanting to authenticate. If you do + not specify this, SMTP authentication will not occur. + @type password: L{bytes} or L{unicode} + + @param requireTransportSecurity: Whether or not STARTTLS is required. + @type requireTransportSecurity: L{bool} + + @param requireAuthentication: Whether or not authentication is required. + @type requireAuthentication: L{bool} + + @param reactor: The L{reactor} used to make the TCP connection. + + @rtype: L{Deferred} + @returns: A cancellable L{Deferred}, its callback will be called if a + message is sent to ANY address, the errback if no message is sent. When + the C{cancel} method is called, it will stop retrying and disconnect + the connection immediately. + + The callback will be called with a tuple (numOk, addresses) where numOk + is the number of successful recipient addresses and addresses is a list + of tuples (address, code, resp) giving the response to the RCPT command + for each address. + """ + if not hasattr(msg, "read"): + # It's not a file + msg = BytesIO(bytes(msg)) + + def cancel(d): + """ + Cancel the L{twisted.mail.smtp.sendmail} call, tell the factory not to + retry and disconnect the connection. + + @param d: The L{defer.Deferred} to be cancelled. + """ + factory.sendFinished = True + if factory.currentProtocol: + factory.currentProtocol.transport.abortConnection() + else: + # Connection hasn't been made yet + connector.disconnect() + + d = defer.Deferred(cancel) + + if isinstance(username, str): + username = username.encode("utf-8") + if isinstance(password, str): + password = password.encode("utf-8") + + tlsHostname = smtphost + if not isinstance(tlsHostname, str): + tlsHostname = _idnaText(tlsHostname) + + factory = ESMTPSenderFactory( + username, + password, + from_addr, + to_addrs, + msg, + d, + heloFallback=True, + requireAuthentication=requireAuthentication, + requireTransportSecurity=requireTransportSecurity, + hostname=tlsHostname, + ) + + if senderDomainName is not None: + factory.domain = networkString(senderDomainName) + + connector = reactor.connectTCP(smtphost, port, factory) + + return d + + +import codecs + + +def xtext_encode(s, errors=None): + r = [] + for ch in iterbytes(s): + o = ord(ch) + if ch == "+" or ch == "=" or o < 33 or o > 126: + r.append(networkString(f"+{o:02X}")) + else: + r.append(bytes((o,))) + return (b"".join(r), len(s)) + + +def xtext_decode(s, errors=None): + """ + Decode the xtext-encoded string C{s}. + + @param s: String to decode. + @param errors: codec error handling scheme. + @return: The decoded string. + """ + r = [] + i = 0 + while i < len(s): + if s[i : i + 1] == b"+": + try: + r.append(chr(int(bytes(s[i + 1 : i + 3]), 16))) + except ValueError: + r.append(ord(s[i : i + 3])) + i += 3 + else: + r.append(bytes(s[i : i + 1]).decode("ascii")) + i += 1 + return ("".join(r), len(s)) + + +class xtextStreamReader(codecs.StreamReader): + def decode(self, s, errors="strict"): + return xtext_decode(s) + + +class xtextStreamWriter(codecs.StreamWriter): + def decode(self, s, errors="strict"): + return xtext_encode(s) + + +def xtext_codec(name): + if name == "xtext": + return (xtext_encode, xtext_decode, xtextStreamReader, xtextStreamWriter) + + +codecs.register(xtext_codec) diff --git a/contrib/python/Twisted/py3/twisted/mail/tap.py b/contrib/python/Twisted/py3/twisted/mail/tap.py new file mode 100644 index 0000000000..1217808e3a --- /dev/null +++ b/contrib/python/Twisted/py3/twisted/mail/tap.py @@ -0,0 +1,384 @@ +# -*- test-case-name: twisted.mail.test.test_options -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. + + +""" +Support for creating mail servers with twistd. +""" + +import os + +from twisted.application import internet +from twisted.cred import checkers, strcred +from twisted.internet import endpoints +from twisted.mail import alias, mail, maildir, relay, relaymanager +from twisted.python import usage + + +class Options(usage.Options, strcred.AuthOptionMixin): + """ + An options list parser for twistd mail. + + @type synopsis: L{bytes} + @ivar synopsis: A description of options for use in the usage message. + + @type optParameters: L{list} of L{list} of (0) L{bytes}, (1) L{bytes}, + (2) L{object}, (3) L{bytes}, (4) L{None} or + callable which takes L{bytes} and returns L{object} + @ivar optParameters: Information about supported parameters. See + L{Options <twisted.python.usage.Options>} for details. + + @type optFlags: L{list} of L{list} of (0) L{bytes}, (1) L{bytes} or + L{None}, (2) L{bytes} + @ivar optFlags: Information about supported flags. See + L{Options <twisted.python.usage.Options>} for details. + + @type _protoDefaults: L{dict} mapping L{bytes} to L{int} + @ivar _protoDefaults: A mapping of default service to port. + + @type compData: L{Completions <usage.Completions>} + @ivar compData: Metadata for the shell tab completion system. + + @type longdesc: L{bytes} + @ivar longdesc: A long description of the plugin for use in the usage + message. + + @type service: L{MailService} + @ivar service: The email service. + + @type last_domain: L{IDomain} provider or L{None} + @ivar last_domain: The most recently specified domain. + """ + + synopsis = "[options]" + + optParameters = [ + [ + "relay", + "R", + None, + "Relay messages according to their envelope 'To', using " + "the given path as a queue directory.", + ], + ["hostname", "H", None, "The hostname by which to identify this server."], + ] + + optFlags = [ + ["esmtp", "E", "Use RFC 1425/1869 SMTP extensions"], + ["disable-anonymous", None, "Disallow non-authenticated SMTP connections"], + ["no-pop3", None, "Disable the default POP3 server."], + ["no-smtp", None, "Disable the default SMTP server."], + ] + + _protoDefaults = { + "pop3": 8110, + "smtp": 8025, + } + + compData = usage.Completions(optActions={"hostname": usage.CompleteHostnames()}) + + longdesc = """ + An SMTP / POP3 email server plugin for twistd. + + Examples: + + 1. SMTP and POP server + + twistd mail --maildirdbmdomain=example.com=/tmp/example.com + --user=joe=password + + Starts an SMTP server that only accepts emails to joe@example.com and saves + them to /tmp/example.com. + + Also starts a POP mail server which will allow a client to log in using + username: joe@example.com and password: password and collect any email that + has been saved in /tmp/example.com. + + 2. SMTP relay + + twistd mail --relay=/tmp/mail_queue + + Starts an SMTP server that accepts emails to any email address and relays + them to an appropriate remote SMTP server. Queued emails will be + temporarily stored in /tmp/mail_queue. + """ + + def __init__(self): + """ + Parse options and create a mail service. + """ + usage.Options.__init__(self) + self.service = mail.MailService() + self.last_domain = None + for service in self._protoDefaults: + self[service] = [] + + def addEndpoint(self, service, description): + """ + Add an endpoint to a service. + + @type service: L{bytes} + @param service: A service, either C{b'smtp'} or C{b'pop3'}. + + @type description: L{bytes} + @param description: An endpoint description string or a TCP port + number. + """ + from twisted.internet import reactor + + self[service].append(endpoints.serverFromString(reactor, description)) + + def opt_pop3(self, description): + """ + Add a POP3 port listener on the specified endpoint. + + You can listen on multiple ports by specifying multiple --pop3 options. + """ + self.addEndpoint("pop3", description) + + opt_p = opt_pop3 + + def opt_smtp(self, description): + """ + Add an SMTP port listener on the specified endpoint. + + You can listen on multiple ports by specifying multiple --smtp options. + """ + self.addEndpoint("smtp", description) + + opt_s = opt_smtp + + def opt_default(self): + """ + Make the most recently specified domain the default domain. + """ + if self.last_domain: + self.service.addDomain("", self.last_domain) + else: + raise usage.UsageError("Specify a domain before specifying using --default") + + opt_D = opt_default + + def opt_maildirdbmdomain(self, domain): + """ + Generate an SMTP/POP3 virtual domain. + + This option requires an argument of the form 'NAME=PATH' where NAME is + the DNS domain name for which email will be accepted and where PATH is + a the filesystem path to a Maildir folder. + [Example: 'example.com=/tmp/example.com'] + """ + try: + name, path = domain.split("=") + except ValueError: + raise usage.UsageError( + "Argument to --maildirdbmdomain must be of the form 'name=path'" + ) + + self.last_domain = maildir.MaildirDirdbmDomain( + self.service, os.path.abspath(path) + ) + self.service.addDomain(name, self.last_domain) + + opt_d = opt_maildirdbmdomain + + def opt_user(self, user_pass): + """ + Add a user and password to the last specified domain. + """ + try: + user, password = user_pass.split("=", 1) + except ValueError: + raise usage.UsageError( + "Argument to --user must be of the form 'user=password'" + ) + if self.last_domain: + self.last_domain.addUser(user, password) + else: + raise usage.UsageError("Specify a domain before specifying users") + + opt_u = opt_user + + def opt_bounce_to_postmaster(self): + """ + Send undeliverable messages to the postmaster. + """ + self.last_domain.postmaster = 1 + + opt_b = opt_bounce_to_postmaster + + def opt_aliases(self, filename): + """ + Specify an aliases(5) file to use for the last specified domain. + """ + if self.last_domain is not None: + if mail.IAliasableDomain.providedBy(self.last_domain): + aliases = alias.loadAliasFile(self.service.domains, filename) + self.last_domain.setAliasGroup(aliases) + self.service.monitor.monitorFile( + filename, AliasUpdater(self.service.domains, self.last_domain) + ) + else: + raise usage.UsageError( + "%s does not support alias files" + % (self.last_domain.__class__.__name__,) + ) + else: + raise usage.UsageError("Specify a domain before specifying aliases") + + opt_A = opt_aliases + + def _getEndpoints(self, reactor, service): + """ + Return a list of endpoints for the specified service, constructing + defaults if necessary. + + If no endpoints were configured for the service and the protocol + was not explicitly disabled with a I{--no-*} option, a default + endpoint for the service is created. + + @type reactor: L{IReactorTCP <twisted.internet.interfaces.IReactorTCP>} + provider + @param reactor: If any endpoints are created, the reactor with + which they are created. + + @type service: L{bytes} + @param service: The type of service for which to retrieve endpoints, + either C{b'pop3'} or C{b'smtp'}. + + @rtype: L{list} of L{IStreamServerEndpoint + <twisted.internet.interfaces.IStreamServerEndpoint>} provider + @return: The endpoints for the specified service as configured by the + command line parameters. + """ + if self[service]: + # If there are any services set up, just return those. + return self[service] + elif self["no-" + service]: + # If there are no services, but the service was explicitly disabled, + # return nothing. + return [] + else: + # Otherwise, return the old default service. + return [endpoints.TCP4ServerEndpoint(reactor, self._protoDefaults[service])] + + def postOptions(self): + """ + Check the validity of the specified set of options and + configure authentication. + + @raise UsageError: When the set of options is invalid. + """ + from twisted.internet import reactor + + if self["esmtp"] and self["hostname"] is None: + raise usage.UsageError("--esmtp requires --hostname") + + # If the --auth option was passed, this will be present -- otherwise, + # it won't be, which is also a perfectly valid state. + if "credCheckers" in self: + for ch in self["credCheckers"]: + self.service.smtpPortal.registerChecker(ch) + + if not self["disable-anonymous"]: + self.service.smtpPortal.registerChecker(checkers.AllowAnonymousAccess()) + + anything = False + for service in self._protoDefaults: + self[service] = self._getEndpoints(reactor, service) + if self[service]: + anything = True + + if not anything: + raise usage.UsageError("You cannot disable all protocols") + + +class AliasUpdater: + """ + A callable object which updates the aliases for a domain from an aliases(5) + file. + + @ivar domains: See L{__init__}. + @ivar domain: See L{__init__}. + """ + + def __init__(self, domains, domain): + """ + @type domains: L{dict} mapping L{bytes} to L{IDomain} provider + @param domains: A mapping of domain name to domain object + + @type domain: L{IAliasableDomain} provider + @param domain: The domain to update. + """ + self.domains = domains + self.domain = domain + + def __call__(self, new): + """ + Update the aliases for a domain from an aliases(5) file. + + @type new: L{bytes} + @param new: The name of an aliases(5) file. + """ + self.domain.setAliasGroup(alias.loadAliasFile(self.domains, new)) + + +def makeService(config): + """ + Configure a service for operating a mail server. + + The returned service may include POP3 servers, SMTP servers, or both, + depending on the configuration passed in. If there are multiple servers, + they will share all of their non-network state (i.e. the same user accounts + are available on all of them). + + @type config: L{Options <usage.Options>} + @param config: Configuration options specifying which servers to include in + the returned service and where they should keep mail data. + + @rtype: L{IService <twisted.application.service.IService>} provider + @return: A service which contains the requested mail servers. + """ + if config["esmtp"]: + rmType = relaymanager.SmartHostESMTPRelayingManager + smtpFactory = config.service.getESMTPFactory + else: + rmType = relaymanager.SmartHostSMTPRelayingManager + smtpFactory = config.service.getSMTPFactory + + if config["relay"]: + dir = config["relay"] + if not os.path.isdir(dir): + os.mkdir(dir) + + config.service.setQueue(relaymanager.Queue(dir)) + default = relay.DomainQueuer(config.service) + + manager = rmType(config.service.queue) + if config["esmtp"]: + manager.fArgs += (None, None) + manager.fArgs += (config["hostname"],) + + helper = relaymanager.RelayStateHelper(manager, 1) + helper.setServiceParent(config.service) + config.service.domains.setDefaultDomain(default) + + if config["pop3"]: + f = config.service.getPOP3Factory() + for endpoint in config["pop3"]: + svc = internet.StreamServerEndpointService(endpoint, f) + svc.setServiceParent(config.service) + + if config["smtp"]: + f = smtpFactory() + if config["hostname"]: + f.domain = config["hostname"] + f.fArgs = (f.domain,) + if config["esmtp"]: + f.fArgs = (None, None) + f.fArgs + for endpoint in config["smtp"]: + svc = internet.StreamServerEndpointService(endpoint, f) + svc.setServiceParent(config.service) + + return config.service |