6257 lines
209 KiB
Python
6257 lines
209 KiB
Python
# -*- test-case-name: twisted.mail.test.test_imap -*-
|
|
# 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 rfc822
|
|
import base64
|
|
import binascii
|
|
import hmac
|
|
import re
|
|
import copy
|
|
import tempfile
|
|
import string
|
|
import time
|
|
import random
|
|
import types
|
|
|
|
import email.Utils
|
|
|
|
try:
|
|
import cStringIO as StringIO
|
|
except:
|
|
import StringIO
|
|
|
|
from zope.interface import implements, Interface
|
|
|
|
from twisted.protocols import basic
|
|
from twisted.protocols import policies
|
|
from twisted.internet import defer
|
|
from twisted.internet import error
|
|
from twisted.internet.defer import maybeDeferred
|
|
from twisted.python import log, text
|
|
from twisted.internet import interfaces
|
|
|
|
from twisted import cred
|
|
import twisted.cred.error
|
|
import twisted.cred.credentials
|
|
|
|
|
|
# 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()))
|
|
|
|
|
|
class MessageSet(object):
|
|
"""
|
|
Essentially an infinite bitfield, with some extra features.
|
|
|
|
@type getnext: Function taking C{int} returning C{int}
|
|
@ivar getnext: A function that returns the next message number,
|
|
used when iterating through the 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 = []
|
|
|
|
def __init__(self, start=_empty, end=_empty):
|
|
"""
|
|
Create a new MessageSet()
|
|
|
|
@type start: Optional C{int}
|
|
@param start: Start of range, or only message number
|
|
|
|
@type end: Optional C{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, types.ListType):
|
|
self.ranges = start[:]
|
|
self.clean()
|
|
else:
|
|
self.add(start,end)
|
|
|
|
# Ooo. A property.
|
|
def last():
|
|
def _setLast(self, value):
|
|
if self._last is not self._empty:
|
|
raise ValueError("last already set")
|
|
|
|
self._last = value
|
|
for i, (l, h) in enumerate(self.ranges):
|
|
if l is not None:
|
|
break # There are no more Nones after this
|
|
l = value
|
|
if h is None:
|
|
h = value
|
|
if l > h:
|
|
l, h = h, l
|
|
self.ranges[i] = (l, h)
|
|
|
|
self.clean()
|
|
|
|
def _getLast(self):
|
|
return self._last
|
|
|
|
doc = '''
|
|
"Highest" message number, refered to by "*".
|
|
Must be set before attempting to use the MessageSet.
|
|
'''
|
|
return _getLast, _setLast, None, doc
|
|
last = property(*last())
|
|
|
|
def add(self, start, end=_empty):
|
|
"""
|
|
Add another range
|
|
|
|
@type start: C{int}
|
|
@param start: Start of range, or only message number
|
|
|
|
@type end: Optional C{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
|
|
|
|
if start > end:
|
|
# Try to keep in low, high order if possible
|
|
# (But we don't know what None means, this will keep
|
|
# None at the start of the ranges list)
|
|
start, end = end, start
|
|
|
|
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)
|
|
try:
|
|
res.add(*other)
|
|
except TypeError:
|
|
res.add(other)
|
|
return res
|
|
|
|
|
|
def extend(self, other):
|
|
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
|
|
"""
|
|
|
|
self.ranges.sort()
|
|
|
|
oldl, oldh = None, None
|
|
for i,(l, h) in enumerate(self.ranges):
|
|
if l is None:
|
|
continue
|
|
# l is >= oldl and h is >= oldh due to sort()
|
|
if oldl is not None and l <= oldh + 1:
|
|
l = oldl
|
|
h = max(oldh, h)
|
|
self.ranges[i - 1] = None
|
|
self.ranges[i] = (l, h)
|
|
|
|
oldl, oldh = l, h
|
|
|
|
self.ranges = filter(None, self.ranges)
|
|
|
|
|
|
def __contains__(self, value):
|
|
"""
|
|
May raise TypeError if we encounter an open-ended range
|
|
"""
|
|
for l, h in self.ranges:
|
|
if l is None:
|
|
raise TypeError(
|
|
"Can't determine membership; last value not set")
|
|
if l <= value <= h:
|
|
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)
|
|
if l is None:
|
|
break
|
|
|
|
def __iter__(self):
|
|
if self.ranges and self.ranges[0][0] is None:
|
|
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:
|
|
if h is None:
|
|
res += 1
|
|
else:
|
|
raise TypeError("Can't size object; last value not set")
|
|
else:
|
|
res += (h - l) + 1
|
|
|
|
return res
|
|
|
|
def __str__(self):
|
|
p = []
|
|
for low, high in self.ranges:
|
|
if low == high:
|
|
if low is None:
|
|
p.append('*')
|
|
else:
|
|
p.append(str(low))
|
|
elif low is None:
|
|
p.append('%d:*' % (high,))
|
|
else:
|
|
p.append('%d:%d' % (low, high))
|
|
return ','.join(p)
|
|
|
|
def __repr__(self):
|
|
return '<MessageSet %s>' % (str(self),)
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, MessageSet):
|
|
return self.ranges == other.ranges
|
|
return False
|
|
|
|
|
|
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 = ''
|
|
if data:
|
|
self.data.append(data)
|
|
return passon
|
|
|
|
def callback(self, line):
|
|
"""
|
|
Call defered with data and rest of line
|
|
"""
|
|
self.defer.callback((''.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 = StringIO.StringIO()
|
|
|
|
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 = ''
|
|
if data:
|
|
self.data.write(data)
|
|
return passon
|
|
|
|
def callback(self, line):
|
|
"""
|
|
Call defered 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 = ('CAPABILITY', 'FLAGS', 'LIST', 'LSUB', 'STATUS', 'SEARCH', 'NAMESPACE')
|
|
_2_RESPONSES = ('EXISTS', 'EXPUNGE', 'FETCH', 'RECENT')
|
|
_OK_RESPONSES = ('UIDVALIDITY', 'UNSEEN', 'READ-WRITE', 'READ-ONLY', 'UIDNEXT', '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 format(self, tag):
|
|
if self.args is None:
|
|
return ' '.join((tag, self.command))
|
|
return ' '.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] == 'OK' and isinstance(names[1], types.ListType) 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)
|
|
|
|
class LOGINCredentials(cred.credentials.UsernamePassword):
|
|
def __init__(self):
|
|
self.challenges = ['Password\0', 'User Name\0']
|
|
self.responses = ['password', 'username']
|
|
cred.credentials.UsernamePassword.__init__(self, None, None)
|
|
|
|
def getChallenge(self):
|
|
return self.challenges.pop()
|
|
|
|
def setResponse(self, response):
|
|
setattr(self, self.responses.pop(), response)
|
|
|
|
def moreChallenges(self):
|
|
return bool(self.challenges)
|
|
|
|
class PLAINCredentials(cred.credentials.UsernamePassword):
|
|
def __init__(self):
|
|
cred.credentials.UsernamePassword.__init__(self, None, None)
|
|
|
|
def getChallenge(self):
|
|
return ''
|
|
|
|
def setResponse(self, response):
|
|
parts = response.split('\0')
|
|
if len(parts) != 3:
|
|
raise IllegalClientResponse("Malformed Response - wrong number of parts")
|
|
useless, self.username, self.password = parts
|
|
|
|
def moreChallenges(self):
|
|
return False
|
|
|
|
class IMAP4Exception(Exception):
|
|
def __init__(self, *args):
|
|
Exception.__init__(self, *args)
|
|
|
|
class IllegalClientResponse(IMAP4Exception): pass
|
|
|
|
class IllegalOperation(IMAP4Exception): pass
|
|
|
|
class IllegalMailboxEncoding(IMAP4Exception): pass
|
|
|
|
class IMailboxListener(Interface):
|
|
"""Interface for objects interested in mailbox events"""
|
|
|
|
def modeChanged(writeable):
|
|
"""Indicates that the write status of a mailbox has changed.
|
|
|
|
@type writeable: C{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: C{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: C{int} or C{None}
|
|
@param exists: The total number of messages now in this mailbox.
|
|
If the total number of messages has not changed, this should be
|
|
C{None}.
|
|
|
|
@type recent: C{int}
|
|
@param recent: The number of messages now flagged \\Recent.
|
|
If the number of recent messages has not changed, this should be
|
|
C{None}.
|
|
"""
|
|
|
|
# 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 = ' '
|
|
_CTL = ''.join(chr(ch) for ch in 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 = r'(){%*"\]' + _SP + _CTL
|
|
|
|
# This is all the bytes that match the ATOM-CHAR from the grammar in the RFC.
|
|
_atomChars = ''.join(chr(ch) for ch in range(0x100) if chr(ch) not in _nonAtomChars)
|
|
|
|
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
|
|
"""
|
|
implements(IMailboxListener)
|
|
|
|
# Identifier for this server software
|
|
IDENT = '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 = set(["OR", "NOT", "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 = {'AUTH': self.challengers.keys()}
|
|
if self.ctx and self.canStartTLS:
|
|
if not self.startedTLS and interfaces.ISSLTransport(self.transport, None) is None:
|
|
cap['LOGINDISABLED'] = None
|
|
cap['STARTTLS'] = None
|
|
cap['NAMESPACE'] = None
|
|
cap['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('* 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, e:
|
|
self.sendUntaggedResponse('BAD Server error: ' + 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, 'Missing command')
|
|
return None
|
|
else:
|
|
self.sendBadResponse(None, 'Null command')
|
|
return None
|
|
|
|
cmd = cmd.upper()
|
|
try:
|
|
return self.dispatchCommand(tag, cmd, rest)
|
|
except IllegalClientResponse, e:
|
|
self.sendBadResponse(tag, 'Illegal syntax: ' + str(e))
|
|
except IllegalOperation, e:
|
|
self.sendNegativeResponse(tag, 'Illegal operation: ' + str(e))
|
|
except IllegalMailboxEncoding, e:
|
|
self.sendNegativeResponse(tag, 'Illegal mailbox name: ' + 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, 'Unsupported command')
|
|
|
|
def lookupCommand(self, cmd):
|
|
return getattr(self, '_'.join((self.state, 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, (arg, rest), tag, fn, args, parseargs, uid):
|
|
args.append(arg)
|
|
self.__doCommand(tag, fn, args, parseargs, rest, uid)
|
|
|
|
def __ebDispatch(self, failure, tag):
|
|
if failure.check(IllegalClientResponse):
|
|
self.sendBadResponse(tag, 'Illegal syntax: ' + str(failure.value))
|
|
elif failure.check(IllegalOperation):
|
|
self.sendNegativeResponse(tag, 'Illegal operation: ' +
|
|
str(failure.value))
|
|
elif failure.check(IllegalMailboxEncoding):
|
|
self.sendNegativeResponse(tag, 'Illegal mailbox name: ' +
|
|
str(failure.value))
|
|
else:
|
|
self.sendBadResponse(tag, 'Server error: ' + 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('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('Ready for %d octets of data' % size)
|
|
self.setRawMode()
|
|
return d
|
|
|
|
def arg_astring(self, line):
|
|
"""
|
|
Parse an astring from the line, return (arg, rest), possibly
|
|
via a deferred (to handle literals)
|
|
"""
|
|
line = line.strip()
|
|
if not line:
|
|
raise IllegalClientResponse("Missing argument")
|
|
d = None
|
|
arg, rest = None, None
|
|
if line[0] == '"':
|
|
try:
|
|
spam, arg, rest = line.split('"',2)
|
|
rest = rest[1:] # Strip space
|
|
except ValueError:
|
|
raise IllegalClientResponse("Unmatched quotes")
|
|
elif line[0] == '{':
|
|
# literal
|
|
if line[-1] != '}':
|
|
raise IllegalClientResponse("Malformed literal")
|
|
try:
|
|
size = int(line[1:-1])
|
|
except ValueError:
|
|
raise IllegalClientResponse("Bad literal size: " + line[1:-1])
|
|
d = self._stringLiteral(size)
|
|
else:
|
|
arg = line.split(' ',1)
|
|
if len(arg) == 1:
|
|
arg.append('')
|
|
arg, rest = arg
|
|
return d or (arg, rest)
|
|
|
|
# ATOM: Any CHAR except ( ) { % * " \ ] CTL SP (CHAR is 7bit)
|
|
atomre = re.compile(r'(?P<atom>[%s]+)( (?P<rest>.*$)|$)' % (re.escape(_atomChars),))
|
|
|
|
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[0] != "(":
|
|
raise IllegalClientResponse("Missing parenthesis")
|
|
|
|
i = line.find(")")
|
|
|
|
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[0] != '{':
|
|
raise IllegalClientResponse("Missing literal")
|
|
|
|
if line[-1] != '}':
|
|
raise IllegalClientResponse("Malformed literal")
|
|
|
|
try:
|
|
size = int(line[1:-1])
|
|
except ValueError:
|
|
raise IllegalClientResponse("Bad literal size: " + line[1:-1])
|
|
|
|
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, '')
|
|
|
|
def arg_seqset(self, line):
|
|
"""
|
|
sequence-set
|
|
"""
|
|
rest = ''
|
|
arg = line.split(' ',1)
|
|
if len(arg) == 2:
|
|
rest = arg[1]
|
|
arg = arg[0]
|
|
|
|
try:
|
|
return (parseIdList(arg), rest)
|
|
except IllegalIdentifierError, e:
|
|
raise IllegalClientResponse("Bad message number " + str(e))
|
|
|
|
def arg_fetchatt(self, line):
|
|
"""
|
|
fetch-att
|
|
"""
|
|
p = _FetchParser()
|
|
p.parseString(line)
|
|
return (p.result, '')
|
|
|
|
def arg_flaglist(self, line):
|
|
"""
|
|
Flag part of store-att-flag
|
|
"""
|
|
flags = []
|
|
if line[0] == '(':
|
|
if line[-1] != ')':
|
|
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] == '\\' and m.start() == 1:
|
|
flags.append('\\' + m.group('atom'))
|
|
elif m.start() == 0:
|
|
flags.append(m.group('atom'))
|
|
else:
|
|
raise IllegalClientResponse("Malformed flag")
|
|
line = m.group('rest')
|
|
|
|
return (flags, '')
|
|
|
|
def arg_line(self, line):
|
|
"""
|
|
Command line of UID command
|
|
"""
|
|
return (line, '')
|
|
|
|
def opt_plist(self, line):
|
|
"""
|
|
Optional parenthesised list
|
|
"""
|
|
if line.startswith('('):
|
|
return self.arg_plist(line)
|
|
else:
|
|
return (None, line)
|
|
|
|
def opt_datetime(self, line):
|
|
"""
|
|
Optional date-time string
|
|
"""
|
|
if line.startswith('"'):
|
|
try:
|
|
spam, date, rest = line.split('"',2)
|
|
except IndexError:
|
|
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() == 'CHARSET':
|
|
arg = line.split(' ',2)
|
|
if len(arg) == 1:
|
|
raise IllegalClientResponse("Missing charset identifier")
|
|
if len(arg) == 2:
|
|
arg.append('')
|
|
spam, arg, rest = arg
|
|
return (arg, rest)
|
|
else:
|
|
return (None, line)
|
|
|
|
def sendServerGreeting(self):
|
|
msg = '[CAPABILITY %s] %s' % (' '.join(self.listCapabilities()), self.IDENT)
|
|
self.sendPositiveResponse(message=msg)
|
|
|
|
def sendBadResponse(self, tag = None, message = ''):
|
|
self._respond('BAD', tag, message)
|
|
|
|
def sendPositiveResponse(self, tag = None, message = ''):
|
|
self._respond('OK', tag, message)
|
|
|
|
def sendNegativeResponse(self, tag = None, message = ''):
|
|
self._respond('NO', tag, message)
|
|
|
|
def sendUntaggedResponse(self, message, async=False):
|
|
if not async or (self.blocked is None):
|
|
self._respond(message, None, None)
|
|
else:
|
|
self._queuedAsync.append(message)
|
|
|
|
def sendContinuationRequest(self, msg = 'Ready for additional command text'):
|
|
if msg:
|
|
self.sendLine('+ ' + msg)
|
|
else:
|
|
self.sendLine('+')
|
|
|
|
def _respond(self, state, tag, message):
|
|
if state in ('OK', 'NO', 'BAD') and self._queuedAsync:
|
|
lines = self._queuedAsync
|
|
self._queuedAsync = []
|
|
for msg in lines:
|
|
self._respond(msg, None, None)
|
|
if not tag:
|
|
tag = '*'
|
|
if message:
|
|
self.sendLine(' '.join((tag, state, message)))
|
|
else:
|
|
self.sendLine(' '.join((tag, state)))
|
|
|
|
def listCapabilities(self):
|
|
caps = ['IMAP4rev1']
|
|
for c, v in self.capabilities().iteritems():
|
|
if v is None:
|
|
caps.append(c)
|
|
elif len(v):
|
|
caps.extend([('%s=%s' % (c, cap)) for cap in v])
|
|
return caps
|
|
|
|
def do_CAPABILITY(self, tag):
|
|
self.sendUntaggedResponse('CAPABILITY ' + ' '.join(self.listCapabilities()))
|
|
self.sendPositiveResponse(tag, '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('BYE Nice talking to you')
|
|
self.sendPositiveResponse(tag, '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, '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, '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, 'Temporary authentication failure')
|
|
return
|
|
|
|
self._setupChallenge(chal, tag)
|
|
|
|
def _setupChallenge(self, chal, tag):
|
|
try:
|
|
challenge = chal.getChallenge()
|
|
except Exception, e:
|
|
self.sendBadResponse(tag, 'Server error: ' + str(e))
|
|
else:
|
|
coded = base64.encodestring(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 = base64.decodestring(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, (iface, avatar, logout), tag):
|
|
assert iface is IAccount, "IAccount is the only supported interface"
|
|
self.account = avatar
|
|
self.state = 'auth'
|
|
self._onLogout = logout
|
|
self.sendPositiveResponse(tag, 'Authentication successful')
|
|
self.setTimeout(self.POSTAUTH_TIMEOUT)
|
|
|
|
def __ebAuthResp(self, failure, tag):
|
|
if failure.check(cred.error.UnauthorizedLogin):
|
|
self.sendNegativeResponse(tag, 'Authentication failed: unauthorized')
|
|
elif failure.check(cred.error.UnhandledCredentials):
|
|
self.sendNegativeResponse(tag, 'Authentication failed: server misconfigured')
|
|
else:
|
|
self.sendBadResponse(tag, 'Server error: login failed unexpectedly')
|
|
log.err(failure)
|
|
|
|
def __ebAuthChunk(self, failure, tag):
|
|
self.sendNegativeResponse(tag, 'Authentication failed: ' + str(failure.value))
|
|
|
|
def do_STARTTLS(self, tag):
|
|
if self.startedTLS:
|
|
self.sendNegativeResponse(tag, 'TLS already negotiated')
|
|
elif self.ctx and self.canStartTLS:
|
|
self.sendPositiveResponse(tag, 'Begin TLS negotiation now')
|
|
self.transport.startTLS(self.ctx)
|
|
self.startedTLS = True
|
|
self.challengers = self.challengers.copy()
|
|
if 'LOGIN' not in self.challengers:
|
|
self.challengers['LOGIN'] = LOGINCredentials
|
|
if 'PLAIN' not in self.challengers:
|
|
self.challengers['PLAIN'] = PLAINCredentials
|
|
else:
|
|
self.sendNegativeResponse(tag, 'TLS not available')
|
|
|
|
unauth_STARTTLS = (do_STARTTLS,)
|
|
|
|
def do_LOGIN(self, tag, user, passwd):
|
|
if 'LOGINDISABLED' in self.capabilities():
|
|
self.sendBadResponse(tag, '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_astring)
|
|
|
|
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: C{str}
|
|
@param user: The username to lookup
|
|
|
|
@type passwd: C{str}
|
|
@param passwd: The password to login with
|
|
"""
|
|
if self.portal:
|
|
return self.portal.login(
|
|
cred.credentials.UsernamePassword(user, passwd),
|
|
None, IAccount
|
|
)
|
|
raise cred.error.UnauthorizedLogin()
|
|
|
|
def __cbLogin(self, (iface, avatar, logout), tag):
|
|
if iface is not IAccount:
|
|
self.sendBadResponse(tag, 'Server error: login returned unexpected value')
|
|
log.err("__cbLogin called with %r, IAccount expected" % (iface,))
|
|
else:
|
|
self.account = avatar
|
|
self._onLogout = logout
|
|
self.sendPositiveResponse(tag, 'LOGIN succeeded')
|
|
self.state = 'auth'
|
|
self.setTimeout(self.POSTAUTH_TIMEOUT)
|
|
|
|
def __ebLogin(self, failure, tag):
|
|
if failure.check(cred.error.UnauthorizedLogin):
|
|
self.sendNegativeResponse(tag, 'LOGIN failed')
|
|
else:
|
|
self.sendBadResponse(tag, 'Server error: ' + 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('NAMESPACE ' + collapseNestedLists([personal, public, shared]))
|
|
self.sendPositiveResponse(tag, "NAMESPACE command completed")
|
|
|
|
auth_NAMESPACE = (do_NAMESPACE,)
|
|
select_NAMESPACE = auth_NAMESPACE
|
|
|
|
def _parseMbox(self, name):
|
|
if isinstance(name, unicode):
|
|
return name
|
|
try:
|
|
return name.decode('imap4-utf-7')
|
|
except:
|
|
log.err()
|
|
raise IllegalMailboxEncoding(name)
|
|
|
|
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 = self._parseMbox(name)
|
|
maybeDeferred(self.account.select, self._parseMbox(name), rw
|
|
).addCallback(self._cbSelectWork, cmdName, tag
|
|
).addErrback(self._ebSelectWork, cmdName, tag
|
|
)
|
|
|
|
def _ebSelectWork(self, failure, cmdName, tag):
|
|
self.sendBadResponse(tag, "%s failed: Server error" % (cmdName,))
|
|
log.err(failure)
|
|
|
|
def _cbSelectWork(self, mbox, cmdName, tag):
|
|
if mbox is None:
|
|
self.sendNegativeResponse(tag, 'No such mailbox')
|
|
return
|
|
if '\\noselect' in [s.lower() for s in mbox.getFlags()]:
|
|
self.sendNegativeResponse(tag, 'Mailbox cannot be selected')
|
|
return
|
|
|
|
flags = mbox.getFlags()
|
|
self.sendUntaggedResponse(str(mbox.getMessageCount()) + ' EXISTS')
|
|
self.sendUntaggedResponse(str(mbox.getRecentCount()) + ' RECENT')
|
|
self.sendUntaggedResponse('FLAGS (%s)' % ' '.join(flags))
|
|
self.sendPositiveResponse(None, '[UIDVALIDITY %d]' % mbox.getUIDValidity())
|
|
|
|
s = mbox.isWriteable() and 'READ-WRITE' or 'READ-ONLY'
|
|
mbox.addListener(self)
|
|
self.sendPositiveResponse(tag, '[%s] %s successful' % (s, cmdName))
|
|
self.state = 'select'
|
|
self.mbox = mbox
|
|
|
|
auth_SELECT = ( _selectWork, arg_astring, 1, 'SELECT' )
|
|
select_SELECT = auth_SELECT
|
|
|
|
auth_EXAMINE = ( _selectWork, arg_astring, 0, '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, "IDLE terminated")
|
|
del self.parseTag
|
|
|
|
select_IDLE = ( do_IDLE, )
|
|
auth_IDLE = select_IDLE
|
|
|
|
|
|
def do_CREATE(self, tag, name):
|
|
name = self._parseMbox(name)
|
|
try:
|
|
result = self.account.create(name)
|
|
except MailboxException, c:
|
|
self.sendNegativeResponse(tag, str(c))
|
|
except:
|
|
self.sendBadResponse(tag, "Server error encountered while creating mailbox")
|
|
log.err()
|
|
else:
|
|
if result:
|
|
self.sendPositiveResponse(tag, 'Mailbox created')
|
|
else:
|
|
self.sendNegativeResponse(tag, 'Mailbox not created')
|
|
|
|
auth_CREATE = (do_CREATE, arg_astring)
|
|
select_CREATE = auth_CREATE
|
|
|
|
def do_DELETE(self, tag, name):
|
|
name = self._parseMbox(name)
|
|
if name.lower() == 'inbox':
|
|
self.sendNegativeResponse(tag, 'You cannot delete the inbox')
|
|
return
|
|
try:
|
|
self.account.delete(name)
|
|
except MailboxException, m:
|
|
self.sendNegativeResponse(tag, str(m))
|
|
except:
|
|
self.sendBadResponse(tag, "Server error encountered while deleting mailbox")
|
|
log.err()
|
|
else:
|
|
self.sendPositiveResponse(tag, 'Mailbox deleted')
|
|
|
|
auth_DELETE = (do_DELETE, arg_astring)
|
|
select_DELETE = auth_DELETE
|
|
|
|
def do_RENAME(self, tag, oldname, newname):
|
|
oldname, newname = [self._parseMbox(n) for n in oldname, newname]
|
|
if oldname.lower() == 'inbox' or newname.lower() == 'inbox':
|
|
self.sendNegativeResponse(tag, 'You cannot rename the inbox, or rename another mailbox to inbox.')
|
|
return
|
|
try:
|
|
self.account.rename(oldname, newname)
|
|
except TypeError:
|
|
self.sendBadResponse(tag, 'Invalid command syntax')
|
|
except MailboxException, m:
|
|
self.sendNegativeResponse(tag, str(m))
|
|
except:
|
|
self.sendBadResponse(tag, "Server error encountered while renaming mailbox")
|
|
log.err()
|
|
else:
|
|
self.sendPositiveResponse(tag, 'Mailbox renamed')
|
|
|
|
auth_RENAME = (do_RENAME, arg_astring, arg_astring)
|
|
select_RENAME = auth_RENAME
|
|
|
|
def do_SUBSCRIBE(self, tag, name):
|
|
name = self._parseMbox(name)
|
|
try:
|
|
self.account.subscribe(name)
|
|
except MailboxException, m:
|
|
self.sendNegativeResponse(tag, str(m))
|
|
except:
|
|
self.sendBadResponse(tag, "Server error encountered while subscribing to mailbox")
|
|
log.err()
|
|
else:
|
|
self.sendPositiveResponse(tag, 'Subscribed')
|
|
|
|
auth_SUBSCRIBE = (do_SUBSCRIBE, arg_astring)
|
|
select_SUBSCRIBE = auth_SUBSCRIBE
|
|
|
|
def do_UNSUBSCRIBE(self, tag, name):
|
|
name = self._parseMbox(name)
|
|
try:
|
|
self.account.unsubscribe(name)
|
|
except MailboxException, m:
|
|
self.sendNegativeResponse(tag, str(m))
|
|
except:
|
|
self.sendBadResponse(tag, "Server error encountered while unsubscribing from mailbox")
|
|
log.err()
|
|
else:
|
|
self.sendPositiveResponse(tag, 'Unsubscribed')
|
|
|
|
auth_UNSUBSCRIBE = (do_UNSUBSCRIBE, arg_astring)
|
|
select_UNSUBSCRIBE = auth_UNSUBSCRIBE
|
|
|
|
def _listWork(self, tag, ref, mbox, sub, cmdName):
|
|
mbox = self._parseMbox(mbox)
|
|
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 = box.getFlags()
|
|
delim = box.getHierarchicalDelimiter()
|
|
resp = (DontQuoteMe(cmdName), map(DontQuoteMe, flags), delim, name.encode('imap4-utf-7'))
|
|
self.sendUntaggedResponse(collapseNestedLists(resp))
|
|
self.sendPositiveResponse(tag, '%s completed' % (cmdName,))
|
|
|
|
def _ebListWork(self, failure, tag):
|
|
self.sendBadResponse(tag, "Server error encountered while listing mailboxes.")
|
|
log.err(failure)
|
|
|
|
auth_LIST = (_listWork, arg_astring, arg_astring, 0, 'LIST')
|
|
select_LIST = auth_LIST
|
|
|
|
auth_LSUB = (_listWork, arg_astring, arg_astring, 1, 'LSUB')
|
|
select_LSUB = auth_LSUB
|
|
|
|
def do_STATUS(self, tag, mailbox, names):
|
|
mailbox = self._parseMbox(mailbox)
|
|
maybeDeferred(self.account.select, mailbox, 0
|
|
).addCallback(self._cbStatusGotMailbox, tag, mailbox, names
|
|
).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, "Could not open mailbox")
|
|
|
|
def _ebStatusGotMailbox(self, failure, tag):
|
|
self.sendBadResponse(tag, "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):
|
|
line = ' '.join(['%s %s' % x for x in status.iteritems()])
|
|
self.sendUntaggedResponse('STATUS %s (%s)' % (box, line))
|
|
self.sendPositiveResponse(tag, 'STATUS complete')
|
|
|
|
def __ebStatus(self, failure, tag, box):
|
|
self.sendBadResponse(tag, 'STATUS %s failed: %s' % (box, str(failure.value)))
|
|
|
|
def do_APPEND(self, tag, mailbox, flags, date, message):
|
|
mailbox = self._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
|
|
|
|
d = mbox.addMessage(message, flags, date)
|
|
d.addCallback(self.__cbAppend, tag, mbox)
|
|
d.addErrback(self.__ebAppend, tag)
|
|
|
|
def _ebAppendGotMailbox(self, failure, tag):
|
|
self.sendBadResponse(tag, "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('%d EXISTS' % mbox.getMessageCount())
|
|
self.sendPositiveResponse(tag, 'APPEND complete')
|
|
|
|
def __ebAppend(self, failure, tag):
|
|
self.sendBadResponse(tag, 'APPEND failed: ' + 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, 'CHECK completed')
|
|
|
|
def __ebCheck(self, failure, tag):
|
|
self.sendBadResponse(tag, 'CHECK failed: ' + 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, 'CLOSE completed')
|
|
self.mbox.removeListener(self)
|
|
self.mbox = None
|
|
self.state = 'auth'
|
|
|
|
def __ebClose(self, failure, tag):
|
|
self.sendBadResponse(tag, 'CLOSE failed: ' + 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, 'EXPUNGE ignored on read-only mailbox')
|
|
|
|
select_EXPUNGE = (do_EXPUNGE,)
|
|
|
|
def __cbExpunge(self, result, tag):
|
|
for e in result:
|
|
self.sendUntaggedResponse('%d EXPUNGE' % e)
|
|
self.sendPositiveResponse(tag, 'EXPUNGE completed')
|
|
|
|
def __ebExpunge(self, failure, tag):
|
|
self.sendBadResponse(tag, 'EXPUNGE failed: ' + 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('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 = ' '.join([str(i) for i in result])
|
|
self.sendUntaggedResponse('SEARCH ' + ids)
|
|
self.sendPositiveResponse(tag, '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: C{list} of C{tuple} of (C{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: C{str}
|
|
@param tag: A command tag.
|
|
|
|
@type mbox: Provider of L{imap4.IMailbox}
|
|
@param mbox: The searched mailbox.
|
|
|
|
@type query: C{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: C{list}
|
|
@param searchResults: The search results so far or C{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, (id, msg)) in 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), id, msg,
|
|
lastSequenceId, lastMessageId):
|
|
if uid:
|
|
searchResults.append(str(msg.getUID()))
|
|
else:
|
|
searchResults.append(str(id))
|
|
if i == 4:
|
|
from twisted.internet import reactor
|
|
reactor.callLater(
|
|
0, self.__cbManualSearch, result[5:], tag, mbox, query, uid,
|
|
searchResults)
|
|
else:
|
|
if searchResults:
|
|
self.sendUntaggedResponse('SEARCH ' + ' '.join(searchResults))
|
|
self.sendPositiveResponse(tag, '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: C{int}
|
|
@param lastSequenceId: The highest sequence number of any message in
|
|
the mailbox being searched.
|
|
|
|
@type lastMessageId: C{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, id, 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 id: 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, id, 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 id in messageSet
|
|
else:
|
|
f = getattr(self, 'search_' + c, None)
|
|
if f is None:
|
|
raise IllegalQueryError("Invalid search command %s" % c)
|
|
|
|
if c in self._requiresLastMessageInfo:
|
|
result = f(query, id, msg, (lastSequenceId,
|
|
lastMessageId))
|
|
else:
|
|
result = f(query, id, 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 C{list} of C{str}
|
|
@param query: A list representing the parsed query string.
|
|
|
|
@type id: C{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 C{list} of C{str}
|
|
@param query: A list representing the parsed query string.
|
|
|
|
@type id: C{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 C{list} of C{str}
|
|
@param query: A list whose first element is a BCC C{str}
|
|
|
|
@type id: C{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 rfc822.parsedate(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, (lastSequenceId, lastMessageId)):
|
|
"""
|
|
Returns C{True} if the message does not match the query.
|
|
|
|
@type query: A C{list} of C{str}
|
|
@param query: A list representing the parsed form of the search query.
|
|
|
|
@type id: C{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 lastSequenceId: C{int}
|
|
@param lastSequenceId: The highest sequence number of a message in the
|
|
mailbox.
|
|
|
|
@type lastMessageId: C{int}
|
|
@param lastMessageId: The highest UID of a message in the mailbox.
|
|
"""
|
|
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 rfc822.parsedate(msg.getInternalDate()) == date
|
|
|
|
def search_OR(self, query, id, msg, (lastSequenceId, lastMessageId)):
|
|
"""
|
|
Returns C{True} if the message matches any of the first two query
|
|
items.
|
|
|
|
@type query: A C{list} of C{str}
|
|
@param query: A list representing the parsed form of the search query.
|
|
|
|
@type id: C{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 lastSequenceId: C{int}
|
|
@param lastSequenceId: The highest sequence number of a message in the
|
|
mailbox.
|
|
|
|
@type lastMessageId: C{int}
|
|
@param lastMessageId: The highest UID of a message in the mailbox.
|
|
"""
|
|
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 C{list} of C{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: C{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 = rfc822.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 C{list} of C{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 = rfc822.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 C{list} of C{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 = rfc822.parsedate(date)
|
|
return date > parseTime(query.pop(0))
|
|
|
|
def search_SINCE(self, query, id, msg):
|
|
date = parseTime(query.pop(0))
|
|
return rfc822.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, (lastSequenceId, lastMessageId)):
|
|
"""
|
|
Returns C{True} if the message UID is in the range defined by the
|
|
search query.
|
|
|
|
@type query: A C{list} of C{str}
|
|
@param query: A list representing the parsed form of the search
|
|
query. Its first element should be a C{str} that can be interpreted
|
|
as a sequence range, for example '2:4,5:*'.
|
|
|
|
@type id: C{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 lastSequenceId: C{int}
|
|
@param lastSequenceId: The highest sequence number of a message in the
|
|
mailbox.
|
|
|
|
@type lastMessageId: C{int}
|
|
@param lastMessageId: The highest UID of a message in the mailbox.
|
|
"""
|
|
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, 'SEARCH failed: ' + 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, '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 = results.next()
|
|
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, '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('ENVELOPE ' + collapseNestedLists([getEnvelope(msg)]))
|
|
|
|
def spew_flags(self, id, msg, _w=None, _f=None):
|
|
if _w is None:
|
|
_w = self.transport.write
|
|
_w('FLAGS ' + '(%s)' % (' '.join(msg.getFlags())))
|
|
|
|
def spew_internaldate(self, id, msg, _w=None, _f=None):
|
|
if _w is None:
|
|
_w = self.transport.write
|
|
idate = msg.getInternalDate()
|
|
ttup = rfc822.parsedate_tz(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 = strdate % (_MONTH_NAMES[ttup[1]],)
|
|
if ttup[9] is None:
|
|
odate = odate + "+0000"
|
|
else:
|
|
if ttup[9] >= 0:
|
|
sign = "+"
|
|
else:
|
|
sign = "-"
|
|
odate = odate + sign + str(((abs(ttup[9]) // 3600) * 100 + (abs(ttup[9]) % 3600) // 60)).zfill(4)
|
|
_w('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('RFC822.HEADER ' + _literal(hdrs))
|
|
|
|
def spew_rfc822text(self, id, msg, _w=None, _f=None):
|
|
if _w is None:
|
|
_w = self.transport.write
|
|
_w('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('RFC822.SIZE ' + str(msg.getSize()))
|
|
|
|
def spew_rfc822(self, id, msg, _w=None, _f=None):
|
|
if _w is None:
|
|
_w = self.transport.write
|
|
_w('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('UID ' + str(msg.getUID()))
|
|
|
|
def spew_bodystructure(self, id, msg, _w=None, _f=None):
|
|
_w('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(str(part) + ' ' + _literal(hdrs))
|
|
elif part.text:
|
|
_w(str(part) + ' ')
|
|
_f()
|
|
return FileProducer(msg.getBodyFile()
|
|
).beginProducing(self.transport
|
|
)
|
|
elif part.mime:
|
|
hdrs = _formatHeaders(msg.getHeaders(True))
|
|
_w(str(part) + ' ' + _literal(hdrs))
|
|
elif part.empty:
|
|
_w(str(part) + ' ')
|
|
_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('BODY ' + collapseNestedLists([getBodyStructure(msg)]))
|
|
|
|
def spewMessage(self, id, msg, query, uid):
|
|
wbuf = WriteBuffer(self.transport)
|
|
write = wbuf.write
|
|
flush = wbuf.flush
|
|
def start():
|
|
write('* %d FETCH (' % (id,))
|
|
def finish():
|
|
write(')\r\n')
|
|
def space():
|
|
write(' ')
|
|
|
|
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, 'FETCH failed: ' + str(failure.value))
|
|
|
|
def do_STORE(self, tag, messages, mode, flags, uid=0):
|
|
mode = mode.upper()
|
|
silent = mode.endswith('SILENT')
|
|
if mode.startswith('+'):
|
|
mode = 1
|
|
elif mode.startswith('-'):
|
|
mode = -1
|
|
else:
|
|
mode = 0
|
|
|
|
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.iteritems():
|
|
if uid:
|
|
uidstr = ' UID %d' % mbox.getUID(k)
|
|
else:
|
|
uidstr = ''
|
|
self.sendUntaggedResponse('%d FETCH (FLAGS (%s)%s)' %
|
|
(k, ' '.join(v), uidstr))
|
|
self.sendPositiveResponse(tag, 'STORE completed')
|
|
|
|
def __ebStore(self, failure, tag):
|
|
self.sendBadResponse(tag, 'Server error: ' + str(failure.value))
|
|
|
|
def do_COPY(self, tag, messages, mailbox, uid=0):
|
|
mailbox = self._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_astring)
|
|
|
|
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, 'Server error: ' + str(failure.value))
|
|
|
|
def __cbCopy(self, messages, tag, mbox):
|
|
# XXX - This should handle failures with a rollback or something
|
|
addedDeferreds = []
|
|
addedIDs = []
|
|
failures = []
|
|
|
|
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, 'COPY completed')
|
|
|
|
def __ebCopy(self, failure, tag):
|
|
self.sendBadResponse(tag, 'COPY failed:' + str(failure.value))
|
|
log.err(failure)
|
|
|
|
def do_UID(self, tag, command, line):
|
|
command = command.upper()
|
|
|
|
if command not in ('COPY', 'FETCH', 'STORE', '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='[READ-WRITE]', async=True)
|
|
else:
|
|
self.sendUntaggedResponse(message='[READ-ONLY]', async=True)
|
|
|
|
def flagsChanged(self, newFlags):
|
|
for (mId, flags) in newFlags.iteritems():
|
|
msg = '%d FETCH (FLAGS (%s))' % (mId, ' '.join(flags))
|
|
self.sendUntaggedResponse(msg, async=True)
|
|
|
|
def newMessages(self, exists, recent):
|
|
if exists is not None:
|
|
self.sendUntaggedResponse('%d EXISTS' % exists, async=True)
|
|
if recent is not None:
|
|
self.sendUntaggedResponse('%d RECENT' % recent, async=True)
|
|
|
|
|
|
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):
|
|
return (IMAP4Exception.__str__(self)
|
|
+ ': Server supports %r, client supports %r'
|
|
% (self.serverSupports, self.clientSupports))
|
|
|
|
class IllegalServerResponse(IMAP4Exception): pass
|
|
|
|
TIMEOUT_ERROR = error.TimeoutError()
|
|
|
|
class IMAP4Client(basic.LineReceiver, policies.TimeoutMixin):
|
|
"""IMAP4 client protocol implementation
|
|
|
|
@ivar state: A string representing the state the connection is currently
|
|
in.
|
|
"""
|
|
implements(IMailboxListener)
|
|
|
|
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 = ''
|
|
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('\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, '\r\n']
|
|
else:
|
|
self._parts.extend([rest, '\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.itervalues():
|
|
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: C{str}
|
|
@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('{')
|
|
if lastPart != -1:
|
|
lastPart = line[lastPart + 1:]
|
|
if lastPart.endswith('}'):
|
|
# 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, ''.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('')
|
|
tag, rest = parts
|
|
self.dispatchCommand(tag, rest)
|
|
|
|
def messageFile(self, octets):
|
|
"""Create a file to which an incoming message may be written.
|
|
|
|
@type octets: C{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 StringIO.StringIO()
|
|
|
|
def makeTag(self):
|
|
tag = '%0.4X' % self.tagID
|
|
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:
|
|
log.err()
|
|
self.transport.loseConnection()
|
|
else:
|
|
log.err("Cannot dispatch: %s, %s, %s" % (self.state, tag, rest))
|
|
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() == 'OK':
|
|
self.state = 'unauth'
|
|
elif status.upper() == 'PREAUTH':
|
|
self.state = 'auth'
|
|
else:
|
|
# XXX - This is rude.
|
|
self.transport.loseConnection()
|
|
raise IllegalServerResponse(tag + ' ' + rest)
|
|
|
|
b, e = rest.find('['), rest.find(']')
|
|
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 == '*' or tag == '+':
|
|
if not self.waiting:
|
|
self._extraInfo([parseNestedParens(rest)])
|
|
else:
|
|
cmd = self.tags[self.waiting]
|
|
if tag == '+':
|
|
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 + ' ' + rest)
|
|
else:
|
|
status, line = rest.split(None, 1)
|
|
if status == '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] == ['READ-ONLY']:
|
|
self.modeChanged(False)
|
|
elif elements == 1 and response[0] == ['READ-WRITE']:
|
|
self.modeChanged(True)
|
|
elif elements == 2 and response[1] == 'EXISTS':
|
|
exists = int(response[0])
|
|
elif elements == 2 and response[1] == 'RECENT':
|
|
recent = int(response[0])
|
|
elif elements == 3 and response[1] == 'FETCH':
|
|
mId = int(response[0])
|
|
values = self._parseFetchPairs(response[2])
|
|
flags.setdefault(mId, []).extend(values.get('FLAGS', ()))
|
|
else:
|
|
log.msg('Unhandled unsolicited response: %s' % (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: C{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 = 'CAPABILITY'
|
|
resp = ('CAPABILITY',)
|
|
d = self.sendCommand(Command(cmd, wantResponse=resp))
|
|
d.addCallback(self.__cbCapabilities)
|
|
return d
|
|
|
|
def __cbCapabilities(self, (lines, tagline)):
|
|
caps = {}
|
|
for rest in lines:
|
|
for cap in rest[1:]:
|
|
parts = cap.split('=', 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: C{Deferred}
|
|
@return: A deferred whose callback will be invoked with None
|
|
when the proper server acknowledgement has been received.
|
|
"""
|
|
d = self.sendCommand(Command('LOGOUT', wantResponse=('BYE',)))
|
|
d.addCallback(self.__cbLogout)
|
|
return d
|
|
|
|
def __cbLogout(self, (lines, tagline)):
|
|
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: C{Deferred}
|
|
@return: A deferred whose callback will be invoked with a list
|
|
of untagged status updates the server responds with.
|
|
"""
|
|
d = self.sendCommand(Command('NOOP'))
|
|
d.addCallback(self.__cbNoop)
|
|
return d
|
|
|
|
def __cbNoop(self, (lines, tagline)):
|
|
# Conceivable, this is elidable.
|
|
# It is, afterall, a no-op.
|
|
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 '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('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: C{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('AUTH', ())
|
|
for scheme in auths:
|
|
if scheme.upper() in self.authenticators:
|
|
cmd = Command('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 = base64.decodestring(rest + '\n')
|
|
except binascii.Error:
|
|
self.sendLine('*')
|
|
raise IllegalServerResponse(rest)
|
|
else:
|
|
auth = self.authenticators[scheme]
|
|
chal = auth.challengeResponse(secret, chal)
|
|
self.sendLine(base64.encodestring(chal).strip())
|
|
|
|
def __cbAuthTLS(self, caps, secret):
|
|
auths = caps.get('AUTH', ())
|
|
for scheme in auths:
|
|
if scheme.upper() in self.authenticators:
|
|
cmd = Command('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: C{str}
|
|
@param username: The username to log in with
|
|
|
|
@type password: C{str}
|
|
@param password: The password to log in with
|
|
|
|
@rtype: C{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:
|
|
context = ssl.ClientContextFactory()
|
|
context.method = ssl.SSL.TLSv1_METHOD
|
|
return context
|
|
|
|
def __cbLoginCaps(self, capabilities, username, password):
|
|
# If the server advertises STARTTLS, we might want to try to switch to TLS
|
|
tryTLS = '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 = ' '.join((_quote(username), _quote(password)))
|
|
return self.sendCommand(Command('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 = ' '.join((_quote(username), _quote(password)))
|
|
return self.sendCommand(Command('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: C{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 = 'NAMESPACE'
|
|
resp = ('NAMESPACE',)
|
|
d = self.sendCommand(Command(cmd, wantResponse=resp))
|
|
d.addCallback(self.__cbNamespace)
|
|
return d
|
|
|
|
def __cbNamespace(self, (lines, last)):
|
|
for parts in lines:
|
|
if len(parts) == 4 and parts[0] == 'NAMESPACE':
|
|
return [e or [] for e 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: C{str}
|
|
@param mailbox: The name of the mailbox to select
|
|
|
|
@rtype: C{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 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 = 'SELECT'
|
|
args = _prepareMailboxName(mailbox)
|
|
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: C{str}
|
|
@param mailbox: The name of the mailbox to examine
|
|
|
|
@rtype: C{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 = 'EXAMINE'
|
|
args = _prepareMailboxName(mailbox)
|
|
resp = ('FLAGS', 'EXISTS', 'RECENT', 'UNSEEN', 'PERMANENTFLAGS', '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, (lines, tagline), rw):
|
|
"""
|
|
Handle lines received in response to a SELECT or EXAMINE command.
|
|
|
|
See RFC 3501, section 6.3.1.
|
|
"""
|
|
# In the absense 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() == 'OK':
|
|
# Handle all the kinds of OK response.
|
|
content = split[1]
|
|
key = content[0].upper()
|
|
if key == 'READ-ONLY':
|
|
datum['READ-WRITE'] = False
|
|
elif key == 'READ-WRITE':
|
|
datum['READ-WRITE'] = True
|
|
elif key == 'UIDVALIDITY':
|
|
datum['UIDVALIDITY'] = self._intOrRaise(
|
|
content[1], split)
|
|
elif key == 'UNSEEN':
|
|
datum['UNSEEN'] = self._intOrRaise(content[1], split)
|
|
elif key == 'UIDNEXT':
|
|
datum['UIDNEXT'] = self._intOrRaise(content[1], split)
|
|
elif key == 'PERMANENTFLAGS':
|
|
datum['PERMANENTFLAGS'] = tuple(content[1])
|
|
else:
|
|
log.err('Unhandled SELECT response (2): %s' % (split,))
|
|
elif len(split) == 2:
|
|
# Handle FLAGS, EXISTS, and RECENT
|
|
if split[0].upper() == 'FLAGS':
|
|
datum['FLAGS'] = tuple(split[1])
|
|
elif isinstance(split[1], str):
|
|
# 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() == 'EXISTS':
|
|
datum['EXISTS'] = self._intOrRaise(split[0], split)
|
|
elif split[1].upper() == 'RECENT':
|
|
datum['RECENT'] = self._intOrRaise(split[0], split)
|
|
else:
|
|
log.err('Unhandled SELECT response (0): %s' % (split,))
|
|
else:
|
|
log.err('Unhandled SELECT response (1): %s' % (split,))
|
|
else:
|
|
log.err('Unhandled SELECT response (4): %s' % (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: C{str}
|
|
@param name: The name of the mailbox to create.
|
|
|
|
@rtype: C{Deferred}
|
|
@return: A deferred whose callback is invoked if the mailbox creation
|
|
is successful and whose errback is invoked otherwise.
|
|
"""
|
|
return self.sendCommand(Command('CREATE', _prepareMailboxName(name)))
|
|
|
|
def delete(self, name):
|
|
"""Delete a mailbox
|
|
|
|
This command is allowed in the Authenticated and Selected states.
|
|
|
|
@type name: C{str}
|
|
@param name: The name of the mailbox to delete.
|
|
|
|
@rtype: C{Deferred}
|
|
@return: A deferred whose calblack is invoked if the mailbox is
|
|
deleted successfully and whose errback is invoked otherwise.
|
|
"""
|
|
return self.sendCommand(Command('DELETE', _prepareMailboxName(name)))
|
|
|
|
def rename(self, oldname, newname):
|
|
"""Rename a mailbox
|
|
|
|
This command is allowed in the Authenticated and Selected states.
|
|
|
|
@type oldname: C{str}
|
|
@param oldname: The current name of the mailbox to rename.
|
|
|
|
@type newname: C{str}
|
|
@param newname: The new name to give the mailbox.
|
|
|
|
@rtype: C{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('RENAME', ' '.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: C{str}
|
|
@param name: The mailbox to mark as 'active' or 'subscribed'
|
|
|
|
@rtype: C{Deferred}
|
|
@return: A deferred whose callback is invoked if the subscription
|
|
is successful and whose errback is invoked otherwise.
|
|
"""
|
|
return self.sendCommand(Command('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: C{str}
|
|
@param name: The mailbox to unsubscribe
|
|
|
|
@rtype: C{Deferred}
|
|
@return: A deferred whose callback is invoked if the unsubscription
|
|
is successful and whose errback is invoked otherwise.
|
|
"""
|
|
return self.sendCommand(Command('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: C{str}
|
|
@param reference: The context in which to interpret C{wildcard}
|
|
|
|
@type wildcard: C{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: C{Deferred}
|
|
@return: A deferred whose callback is invoked with a list of C{tuple}s,
|
|
the first element of which is a C{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.
|
|
"""
|
|
cmd = 'LIST'
|
|
args = '"%s" "%s"' % (reference, wildcard.encode('imap4-utf-7'))
|
|
resp = ('LIST',)
|
|
d = self.sendCommand(Command(cmd, args, wantResponse=resp))
|
|
d.addCallback(self.__cbList, '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 C{list}
|
|
method, with one slight difference: Only mailboxes which have been
|
|
subscribed can be included in the resulting list.
|
|
"""
|
|
cmd = 'LSUB'
|
|
args = '"%s" "%s"' % (reference, wildcard.encode('imap4-utf-7'))
|
|
resp = ('LSUB',)
|
|
d = self.sendCommand(Command(cmd, args, wantResponse=resp))
|
|
d.addCallback(self.__cbList, 'LSUB')
|
|
return d
|
|
|
|
def __cbList(self, (lines, last), command):
|
|
results = []
|
|
for parts in lines:
|
|
if len(parts) == 4 and parts[0] == command:
|
|
parts[1] = tuple(parts[1])
|
|
results.append(tuple(parts[1:]))
|
|
return results
|
|
|
|
def status(self, mailbox, *names):
|
|
"""
|
|
Retrieve the status of the given mailbox
|
|
|
|
This command is allowed in the Authenticated and Selected states.
|
|
|
|
@type mailbox: C{str}
|
|
@param mailbox: The name of the mailbox to query
|
|
|
|
@type *names: C{str}
|
|
@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: C{Deferred}
|
|
@return: A deferred which fires with 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 = 'STATUS'
|
|
args = "%s (%s)" % (_prepareMailboxName(mailbox), ' '.join(names))
|
|
resp = ('STATUS',)
|
|
d = self.sendCommand(Command(cmd, args, wantResponse=resp))
|
|
d.addCallback(self.__cbStatus)
|
|
return d
|
|
|
|
def __cbStatus(self, (lines, last)):
|
|
status = {}
|
|
for parts in lines:
|
|
if parts[0] == 'STATUS':
|
|
items = parts[2]
|
|
items = [items[i:i+2] for i in range(0, len(items), 2)]
|
|
status.update(dict(items))
|
|
for k in status.keys():
|
|
t = self.STATUS_TRANSFORMATIONS.get(k)
|
|
if t:
|
|
try:
|
|
status[k] = t(status[k])
|
|
except Exception, e:
|
|
raise IllegalServerResponse('(%s %s): %s' % (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: C{str}
|
|
@param mailbox: The mailbox to which to add this message.
|
|
|
|
@type message: Any file-like object
|
|
@param message: The message to add, in RFC822 format. Newlines
|
|
in this file should be \\r\\n-style.
|
|
|
|
@type flags: Any iterable of C{str}
|
|
@param flags: The flags to associated with this message.
|
|
|
|
@type date: C{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: C{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)
|
|
fmt = '%s (%s)%s {%d}'
|
|
if date:
|
|
date = ' "%s"' % date
|
|
else:
|
|
date = ''
|
|
cmd = fmt % (
|
|
_prepareMailboxName(mailbox), ' '.join(flags),
|
|
date, L
|
|
)
|
|
d = self.sendCommand(Command('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('')
|
|
|
|
def check(self):
|
|
"""Tell the server to perform a checkpoint
|
|
|
|
This command is allowed in the Selected state.
|
|
|
|
@rtype: C{Deferred}
|
|
@return: A deferred whose callback is invoked when this command
|
|
succeeds or whose errback is invoked if it fails.
|
|
"""
|
|
return self.sendCommand(Command('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: C{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('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: C{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 = 'EXPUNGE'
|
|
resp = ('EXPUNGE',)
|
|
d = self.sendCommand(Command(cmd, wantResponse=resp))
|
|
d.addCallback(self.__cbExpunge)
|
|
return d
|
|
|
|
|
|
def __cbExpunge(self, (lines, last)):
|
|
ids = []
|
|
for parts in lines:
|
|
if len(parts) == 2 and parts[1] == 'EXPUNGE':
|
|
ids.append(self._intOrRaise(parts[0], parts))
|
|
return ids
|
|
|
|
|
|
def search(self, *queries, **kwarg):
|
|
"""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.
|
|
|
|
One keyword argument is accepted: if uid is passed in with a non-zero
|
|
value, the server is asked to return message UIDs instead of message
|
|
sequence numbers.
|
|
|
|
@rtype: C{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.
|
|
"""
|
|
if kwarg.get('uid'):
|
|
cmd = 'UID SEARCH'
|
|
else:
|
|
cmd = 'SEARCH'
|
|
args = ' '.join(queries)
|
|
d = self.sendCommand(Command(cmd, args, wantResponse=(cmd,)))
|
|
d.addCallback(self.__cbSearch)
|
|
return d
|
|
|
|
|
|
def __cbSearch(self, (lines, end)):
|
|
ids = []
|
|
for parts in lines:
|
|
if len(parts) > 0 and parts[0] == '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: C{MessageSet} or C{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: C{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: C{MessageSet} or C{str}
|
|
@param messages: The messages for which to retrieve flags.
|
|
|
|
@type uid: C{bool}
|
|
@param uid: Indicates whether the message sequence set is of message
|
|
numbers or of unique message IDs.
|
|
|
|
@rtype: C{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(str(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: C{MessageSet} or C{str}
|
|
@param messages: The messages for which to retrieve the internal date.
|
|
|
|
@type uid: C{bool}
|
|
@param uid: Indicates whether the message sequence set is of message
|
|
numbers or of unique message IDs.
|
|
|
|
@rtype: C{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(str(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: C{MessageSet} or C{str}
|
|
@param messages: The messages for which to retrieve envelope data.
|
|
|
|
@type uid: C{bool}
|
|
@param uid: Indicates whether the message sequence set is of message
|
|
numbers or of unique message IDs.
|
|
|
|
@rtype: C{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 strings, while the from, sender, reply-to,
|
|
to, cc, and bcc fields contain address data. 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 C{None}.
|
|
"""
|
|
return self._fetch(str(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: C{MessageSet} or C{str}
|
|
@param messages: The messages for which to retrieve body structure
|
|
data.
|
|
|
|
@type uid: C{bool}
|
|
@param uid: Indicates whether the message sequence set is of message
|
|
numbers or of unique message IDs.
|
|
|
|
@rtype: C{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: C{MessageSet} or C{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: C{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 C{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 C{str} giving the UID for the
|
|
message. The text of the message is a C{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: C{MessageSet} or C{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: C{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: C{MessageSet} or C{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: C{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: C{MessageSet} or C{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: C{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: C{MessageSet} or C{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: C{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: C{MessageSet} or C{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: C{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: C{MessageSet} or C{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: C{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
|
|
C{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
|
|
disagreable. 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}.
|
|
"""
|
|
values = {}
|
|
responseParts = iter(fetchResponseList)
|
|
while True:
|
|
try:
|
|
key = responseParts.next()
|
|
except StopIteration:
|
|
break
|
|
|
|
try:
|
|
value = responseParts.next()
|
|
except StopIteration:
|
|
raise IllegalServerResponse(
|
|
"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 ("BODY", "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.
|
|
if hasSection:
|
|
if len(value) < 2:
|
|
key = (key, tuple(value))
|
|
else:
|
|
key = (key, (value[0], tuple(value[1])))
|
|
try:
|
|
value = responseParts.next()
|
|
except StopIteration:
|
|
raise IllegalServerResponse(
|
|
"Not enough arguments", fetchResponseList)
|
|
|
|
# Handle partial ranges
|
|
if value.startswith('<') and value.endswith('>'):
|
|
try:
|
|
int(value[1:-1])
|
|
except ValueError:
|
|
# This isn't really a range, it's some content.
|
|
pass
|
|
else:
|
|
key = key + (value,)
|
|
try:
|
|
value = responseParts.next()
|
|
except StopIteration:
|
|
raise IllegalServerResponse(
|
|
"Not enough arguments", fetchResponseList)
|
|
|
|
values[key] = value
|
|
return values
|
|
|
|
|
|
def _cbFetch(self, (lines, last), requestedParts, structured):
|
|
info = {}
|
|
for parts in lines:
|
|
if len(parts) == 3 and parts[1] == 'FETCH':
|
|
id = self._intOrRaise(parts[0], parts)
|
|
if id not in info:
|
|
info[id] = [parts[2]]
|
|
else:
|
|
info[id][0].extend(parts[2])
|
|
|
|
results = {}
|
|
for (messageId, values) in info.iteritems():
|
|
mapping = self._parseFetchPairs(values[0])
|
|
results.setdefault(messageId, {}).update(mapping)
|
|
|
|
flagChanges = {}
|
|
for messageId in results.keys():
|
|
values = results[messageId]
|
|
for part in 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: C{MessageSet} or C{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.
|
|
|
|
@type headerType: C{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: C{int} or C{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 C{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: C{int}
|
|
@param offset: The number of octets at the beginning of the result
|
|
to skip.
|
|
|
|
@type length: C{int}
|
|
@param length: The number of octets to retrieve.
|
|
|
|
@rtype: C{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 not in ('TEXT', 'MIME'):
|
|
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 'UID FETCH' or 'FETCH'
|
|
cmd = fmt % (messages, peek and '.PEEK' or '', number, header, payload, extra)
|
|
d = self.sendCommand(Command(fetch, cmd, wantResponse=('FETCH',)))
|
|
d.addCallback(self._cbFetch, (), False)
|
|
return d
|
|
|
|
|
|
def _fetch(self, messages, useUID=0, **terms):
|
|
fetch = useUID and 'UID FETCH' or '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
|
|
|
|
cmd = '%s (%s)' % (messages, ' '.join([s.upper() for s in terms.keys()]))
|
|
d = self.sendCommand(Command(fetch, cmd, wantResponse=('FETCH',)))
|
|
d.addCallback(self._cbFetch, map(str.upper, 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: C{MessageSet} or C{str}
|
|
@param messages: A message sequence set
|
|
|
|
@type flags: Any iterable of C{str}
|
|
@param flags: The flags to set
|
|
|
|
@type silent: C{bool}
|
|
@param silent: If true, cause the server to supress 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: C{Deferred}
|
|
@return: A deferred whose callback is invoked with a list of the
|
|
the server's responses (C{[]} if C{silent} is true) or whose
|
|
errback is invoked if there is an error.
|
|
"""
|
|
return self._store(str(messages), '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 C{str}
|
|
@param messages: A message sequence set
|
|
|
|
@type flags: Any iterable of C{str}
|
|
@param flags: The flags to set
|
|
|
|
@type silent: C{bool}
|
|
@param silent: If true, cause the server to supress 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: C{Deferred}
|
|
@return: A deferred whose callback is invoked with a list of the
|
|
the server's responses (C{[]} if C{silent} is true) or whose
|
|
errback is invoked if there is an error.
|
|
"""
|
|
return self._store(str(messages),'+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: C{MessageSet} or C{str}
|
|
@param messages: A message sequence set
|
|
|
|
@type flags: Any iterable of C{str}
|
|
@param flags: The flags to set
|
|
|
|
@type silent: C{bool}
|
|
@param silent: If true, cause the server to supress 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: C{Deferred}
|
|
@return: A deferred whose callback is invoked with a list of the
|
|
the server's responses (C{[]} if C{silent} is true) or whose
|
|
errback is invoked if there is an error.
|
|
"""
|
|
return self._store(str(messages), '-FLAGS', silent, flags, uid)
|
|
|
|
|
|
def _store(self, messages, cmd, silent, flags, uid):
|
|
if silent:
|
|
cmd = cmd + '.SILENT'
|
|
store = uid and 'UID STORE' or 'STORE'
|
|
args = ' '.join((messages, cmd, '(%s)' % ' '.join(flags)))
|
|
d = self.sendCommand(Command(store, args, wantResponse=('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: C{str}
|
|
@param messages: A message sequence set
|
|
|
|
@type mailbox: C{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: C{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.
|
|
"""
|
|
if uid:
|
|
cmd = 'UID COPY'
|
|
else:
|
|
cmd = 'COPY'
|
|
args = '%s %s' % (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"""
|
|
|
|
|
|
class IllegalIdentifierError(IMAP4Exception): pass
|
|
|
|
def parseIdList(s, lastMessageId=None):
|
|
"""
|
|
Parse a message set search key into a C{MessageSet}.
|
|
|
|
@type s: C{str}
|
|
@param s: A string description of a id list, for example "1:3, 4:*"
|
|
|
|
@type lastMessageId: C{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(',')
|
|
for p in parts:
|
|
if ':' in p:
|
|
low, high = p.split(':', 1)
|
|
try:
|
|
if low == '*':
|
|
low = None
|
|
else:
|
|
low = long(low)
|
|
if high == '*':
|
|
high = None
|
|
else:
|
|
high = long(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
|
|
|
|
# RFC says that 2:4 and 4:2 are equivalent
|
|
if low > high:
|
|
low, high = high, low
|
|
res.extend((low, high))
|
|
except ValueError:
|
|
raise IllegalIdentifierError(p)
|
|
else:
|
|
try:
|
|
if p == '*':
|
|
p = None
|
|
else:
|
|
p = long(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
|
|
|
|
class IllegalQueryError(IMAP4Exception): pass
|
|
|
|
_SIMPLE_BOOL = (
|
|
'ALL', 'ANSWERED', 'DELETED', 'DRAFT', 'FLAGGED', 'NEW', 'OLD', 'RECENT',
|
|
'SEEN', 'UNANSWERED', 'UNDELETED', 'UNDRAFT', 'UNFLAGGED', 'UNSEEN'
|
|
)
|
|
|
|
_NO_QUOTES = (
|
|
'LARGER', 'SMALLER', 'UID'
|
|
)
|
|
|
|
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: C{str}
|
|
@return: The formatted query string
|
|
"""
|
|
cmd = []
|
|
keys = kwarg.keys()
|
|
if sorted:
|
|
keys.sort()
|
|
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, v[0], '"%s"' % (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 = string.translate(v, string.maketrans('', ''), _nonAtomChars)
|
|
cmd.extend([k, v])
|
|
elif k not in _NO_QUOTES:
|
|
cmd.extend([k, '"%s"' % (v,)])
|
|
else:
|
|
cmd.extend([k, '%s' % (v,)])
|
|
if len(cmd) > 1:
|
|
return '(%s)' % ' '.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 '(OR %s %s)' % (args[0], Or(*args[1:]))
|
|
|
|
def Not(query):
|
|
"""The negation of a query"""
|
|
return '(NOT %s)' % (query,)
|
|
|
|
class MismatchedNesting(IMAP4Exception):
|
|
pass
|
|
|
|
class MismatchedQuoting(IMAP4Exception):
|
|
pass
|
|
|
|
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 C{None}.
|
|
|
|
@type s: C{str}
|
|
@param s: The string to be split
|
|
|
|
@rtype: C{list} of C{str}
|
|
@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
|
|
for i, c in enumerate(s):
|
|
if c == '"':
|
|
if i and s[i-1] == '\\':
|
|
word.pop()
|
|
word.append('"')
|
|
elif not inQuote:
|
|
inQuote = True
|
|
else:
|
|
inQuote = False
|
|
result.append(''.join(word))
|
|
word = []
|
|
elif not inWord and not inQuote and c not in ('"' + string.whitespace):
|
|
inWord = True
|
|
word.append(c)
|
|
elif inWord and not inQuote and c in string.whitespace:
|
|
w = ''.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 = ''.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: C{list} of C{str} and C{list}
|
|
@param results: The list to be collapsed
|
|
|
|
@rtype: C{list} of C{str} and C{list}
|
|
@return: A new list which is the collapsed form of C{results}
|
|
"""
|
|
copy = []
|
|
begun = None
|
|
listsList = [isinstance(s, types.ListType) for s in results]
|
|
|
|
pred = lambda e: isinstance(e, types.TupleType)
|
|
tran = {
|
|
0: lambda e: splitQuoted(''.join(e)),
|
|
1: lambda e: [''.join([i[0] for i in e])]
|
|
}
|
|
for (i, c, isList) in zip(range(len(results)), results, listsList):
|
|
if isList:
|
|
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: C{str}
|
|
@param s: The s-exp-like string to parse
|
|
|
|
@rtype: C{list} of C{str} and C{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]
|
|
if inQuote:
|
|
if c == '\\':
|
|
contentStack[-1].append(s[i:i+2])
|
|
i += 2
|
|
continue
|
|
elif c == '"':
|
|
inQuote = not inQuote
|
|
contentStack[-1].append(c)
|
|
i += 1
|
|
else:
|
|
if c == '"':
|
|
contentStack[-1].append(c)
|
|
inQuote = not inQuote
|
|
i += 1
|
|
elif handleLiteral and c == '{':
|
|
end = s.find('}', 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 == '(' or c == '[':
|
|
contentStack.append([])
|
|
i += 1
|
|
elif c == ')' or c == ']':
|
|
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):
|
|
return '"%s"' % (s.replace('\\', '\\\\').replace('"', '\\"'),)
|
|
|
|
def _literal(s):
|
|
return '{%d}\r\n%s' % (len(s), s)
|
|
|
|
class DontQuoteMe:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def __str__(self):
|
|
return str(self.value)
|
|
|
|
_ATOM_SPECIALS = '(){ %*"'
|
|
def _needsQuote(s):
|
|
if s == '':
|
|
return 1
|
|
for c in s:
|
|
if c < '\x20' or c > '\x7f':
|
|
return 1
|
|
if c in _ATOM_SPECIALS:
|
|
return 1
|
|
return 0
|
|
|
|
def _prepareMailboxName(name):
|
|
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
|
|
return '\n' in s or '\r' 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: C{str}
|
|
"""
|
|
pieces = []
|
|
for i in items:
|
|
if i is None:
|
|
pieces.extend([' ', 'NIL'])
|
|
elif isinstance(i, (DontQuoteMe, int, long)):
|
|
pieces.extend([' ', str(i)])
|
|
elif isinstance(i, types.StringTypes):
|
|
if _needsLiteral(i):
|
|
pieces.extend([' ', '{', str(len(i)), '}', IMAP4Server.delimiter, i])
|
|
else:
|
|
pieces.extend([' ', _quote(i)])
|
|
elif hasattr(i, 'read'):
|
|
d = i.read()
|
|
pieces.extend([' ', '{', str(len(d)), '}', IMAP4Server.delimiter, d])
|
|
else:
|
|
pieces.extend([' ', '(%s)' % (collapseNestedLists(i),)])
|
|
return ''.join(pieces[1:])
|
|
|
|
|
|
class IClientAuthentication(Interface):
|
|
def getName():
|
|
"""Return an identifier associated with this authentication scheme.
|
|
|
|
@rtype: C{str}
|
|
"""
|
|
|
|
def challengeResponse(secret, challenge):
|
|
"""Generate a challenge response string"""
|
|
|
|
|
|
|
|
class CramMD5ClientAuthenticator:
|
|
implements(IClientAuthentication)
|
|
|
|
def __init__(self, user):
|
|
self.user = user
|
|
|
|
def getName(self):
|
|
return "CRAM-MD5"
|
|
|
|
def challengeResponse(self, secret, chal):
|
|
response = hmac.HMAC(secret, chal).hexdigest()
|
|
return '%s %s' % (self.user, response)
|
|
|
|
|
|
|
|
class LOGINAuthenticator:
|
|
implements(IClientAuthentication)
|
|
|
|
def __init__(self, user):
|
|
self.user = user
|
|
self.challengeResponse = self.challengeUsername
|
|
|
|
def getName(self):
|
|
return "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
|
|
|
|
class PLAINAuthenticator:
|
|
implements(IClientAuthentication)
|
|
|
|
def __init__(self, user):
|
|
self.user = user
|
|
|
|
def getName(self):
|
|
return "PLAIN"
|
|
|
|
def challengeResponse(self, secret, chal):
|
|
return '\0%s\0%s' % (self.user, secret)
|
|
|
|
|
|
class MailboxException(IMAP4Exception): pass
|
|
|
|
class MailboxCollision(MailboxException):
|
|
def __str__(self):
|
|
return 'Mailbox named %s already exists' % self.args
|
|
|
|
class NoSuchMailbox(MailboxException):
|
|
def __str__(self):
|
|
return 'No mailbox named %s exists' % self.args
|
|
|
|
class ReadOnlyMailbox(MailboxException):
|
|
def __str__(self):
|
|
return 'Mailbox open in read-only state'
|
|
|
|
|
|
class IAccount(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: C{str}
|
|
@param name: The name associated with this mailbox. It may not
|
|
contain multiple hierarchical parts.
|
|
|
|
@type mbox: An object implementing C{IMailbox}
|
|
@param mbox: The mailbox to associate with this name. If C{None},
|
|
a suitable default is created and used.
|
|
|
|
@rtype: C{Deferred} or C{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 C{Deferred}
|
|
is returned.
|
|
"""
|
|
|
|
def create(pathspec):
|
|
"""Create a new mailbox from the given hierarchical name.
|
|
|
|
@type pathspec: C{str}
|
|
@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: C{Deferred} or C{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 C{Deferred} is
|
|
returned.
|
|
"""
|
|
|
|
def select(name, rw=True):
|
|
"""Acquire a mailbox, given its name.
|
|
|
|
@type name: C{str}
|
|
@param name: The mailbox to acquire
|
|
|
|
@type rw: C{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{IMailbox} or C{Deferred}
|
|
@return: The mailbox object, or a C{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: C{str}
|
|
@param name: The mailbox to delete.
|
|
|
|
@rtype: C{Deferred} or C{bool}
|
|
@return: A true value if the mailbox is successfully deleted, or a
|
|
C{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 C{Deferred} is returned.
|
|
"""
|
|
|
|
def rename(oldname, newname):
|
|
"""Rename a mailbox
|
|
|
|
@type oldname: C{str}
|
|
@param oldname: The current name of the mailbox to rename.
|
|
|
|
@type newname: C{str}
|
|
@param newname: The new name to associate with the mailbox.
|
|
|
|
@rtype: C{Deferred} or C{bool}
|
|
@return: A true value if the mailbox is successfully renamed, or a
|
|
C{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 C{Deferred}
|
|
is returned.
|
|
"""
|
|
|
|
def isSubscribed(name):
|
|
"""Check the subscription status of a mailbox
|
|
|
|
@type name: C{str}
|
|
@param name: The name of the mailbox to check
|
|
|
|
@rtype: C{Deferred} or C{bool}
|
|
@return: A true value if the given mailbox is currently subscribed
|
|
to, a false value otherwise. A C{Deferred} may also be returned
|
|
whose callback will be invoked with one of these values.
|
|
"""
|
|
|
|
def subscribe(name):
|
|
"""Subscribe to a mailbox
|
|
|
|
@type name: C{str}
|
|
@param name: The name of the mailbox to subscribe to
|
|
|
|
@rtype: C{Deferred} or C{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
|
|
C{Deferred} is returned.
|
|
"""
|
|
|
|
def unsubscribe(name):
|
|
"""Unsubscribe from a mailbox
|
|
|
|
@type name: C{str}
|
|
@param name: The name of the mailbox to unsubscribe from
|
|
|
|
@rtype: C{Deferred} or C{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
|
|
C{Deferred} is returned.
|
|
"""
|
|
|
|
def listMailboxes(ref, wildcard):
|
|
"""List all the mailboxes that meet a certain criteria
|
|
|
|
@type ref: C{str}
|
|
@param ref: The context in which to apply the wildcard
|
|
|
|
@type wildcard: C{str}
|
|
@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: C{list} of C{tuple}
|
|
@return: A list of C{(mailboxName, mailboxObject)} which meet the
|
|
given criteria. C{mailboxObject} should implement either
|
|
C{IMailboxInfo} or C{IMailbox}. 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 \"\", and its hierarchical delimiter is usually
|
|
\"/\".
|
|
|
|
@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 \"#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.
|
|
"""
|
|
|
|
|
|
class MemoryAccount(object):
|
|
implements(IAccount, INamespacePresenter)
|
|
|
|
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 = 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 = filter(None, pathspec.split('/'))
|
|
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(name.upper())
|
|
|
|
def delete(self, name):
|
|
name = 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 self._inferiorNames(name) > 1:
|
|
del self.mailboxes[name]
|
|
|
|
def rename(self, oldname, newname):
|
|
oldname = oldname.upper()
|
|
newname = 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 name.upper() in self.subscriptions
|
|
|
|
def subscribe(self, name):
|
|
name = name.upper()
|
|
if name not in self.subscriptions:
|
|
self.subscriptions.append(name)
|
|
|
|
def unsubscribe(self, name):
|
|
name = name.upper()
|
|
if name not in self.subscriptions:
|
|
raise MailboxException, "Not currently subscribed to " + name
|
|
self.subscriptions.remove(name)
|
|
|
|
def listMailboxes(self, ref, wildcard):
|
|
ref = self._inferiorNames(ref.upper())
|
|
wildcard = wildcardToRegexp(wildcard, '/')
|
|
return [(i, self.mailboxes[i]) for i in ref if wildcard.match(i)]
|
|
|
|
##
|
|
## INamespacePresenter
|
|
##
|
|
def getPersonalNamespaces(self):
|
|
return [["", "/"]]
|
|
|
|
def getSharedNamespaces(self):
|
|
return None
|
|
|
|
def getOtherNamespaces(self):
|
|
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),]
|
|
addrs = email.Utils.getaddresses([addr])
|
|
return [[fn or None, None] + addr.split('@') for fn, addr in addrs]
|
|
|
|
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', None)
|
|
if mm:
|
|
mm = ''.join(mm.splitlines())
|
|
mimetype = mm.split(';')
|
|
if mimetype:
|
|
type = mimetype[0].split('/', 1)
|
|
if len(type) == 1:
|
|
major = type[0]
|
|
minor = None
|
|
elif len(type) == 2:
|
|
major, minor = type
|
|
else:
|
|
major = minor = None
|
|
attrs = dict(x.strip().lower().split('=', 1) for x in mimetype[1:])
|
|
else:
|
|
major = minor = None
|
|
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(object):
|
|
"""
|
|
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: C{None} if there is no disposition header value, a C{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.iteritems()]
|
|
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 C{str} giving the main MIME type of the message (for
|
|
example, C{"text"}).
|
|
|
|
@param subtype: A C{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 C{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)
|
|
|
|
|
|
|
|
class IMessagePart(Interface):
|
|
def getHeaders(negate, *names):
|
|
"""Retrieve a group of message headers.
|
|
|
|
@type names: C{tuple} of C{str}
|
|
@param names: The names of the headers to retrieve or omit.
|
|
|
|
@type negate: C{bool}
|
|
@param negate: If True, indicates that the headers listed in C{names}
|
|
should be omitted from the return value, rather than included.
|
|
|
|
@rtype: C{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: C{int}
|
|
"""
|
|
|
|
def isMultipart():
|
|
"""Indicate whether this message has subparts.
|
|
|
|
@rtype: C{bool}
|
|
"""
|
|
|
|
def getSubPart(part):
|
|
"""Retrieve a MIME sub-message
|
|
|
|
@type part: C{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 C{IMessagePart}.
|
|
@return: The specified sub-part.
|
|
"""
|
|
|
|
class IMessage(IMessagePart):
|
|
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: C{str}
|
|
@return: An RFC822-formatted date string.
|
|
"""
|
|
|
|
class IMessageFile(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 an file-like object opened for reading.
|
|
|
|
Reading from the returned file will return all the bytes
|
|
of which this message consists.
|
|
"""
|
|
|
|
class ISearchableMailbox(Interface):
|
|
def search(query, uid):
|
|
"""Search for messages that meet the given query criteria.
|
|
|
|
If this interface is not implemented by the mailbox, L{IMailbox.fetch}
|
|
and various methods of L{IMessage} will be used instead.
|
|
|
|
Implementations which wish to offer better performance than the
|
|
default implementation should implement this interface.
|
|
|
|
@type query: C{list}
|
|
@param query: The search criteria
|
|
|
|
@type uid: C{bool}
|
|
@param uid: If true, the IDs specified in the query are UIDs;
|
|
otherwise they are message sequence IDs.
|
|
|
|
@rtype: C{list} or C{Deferred}
|
|
@return: A list of message sequence numbers or message UIDs which
|
|
match the search criteria or a C{Deferred} whose callback will be
|
|
invoked with such a list.
|
|
|
|
@raise IllegalQueryError: Raised when query is not valid.
|
|
"""
|
|
|
|
class IMessageCopier(Interface):
|
|
def copy(messageObject):
|
|
"""Copy the given message object into this mailbox.
|
|
|
|
The message object will be one which was previously returned by
|
|
L{IMailbox.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, IMailbox.addMessage
|
|
will be used instead.
|
|
|
|
@rtype: C{Deferred} or C{int}
|
|
@return: Either the UID of the message or a Deferred which fires
|
|
with the UID when the copy finishes.
|
|
"""
|
|
|
|
class IMailboxInfo(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: C{list} of C{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: C{str}
|
|
"""
|
|
|
|
class IMailbox(IMailboxInfo):
|
|
def getUIDValidity():
|
|
"""Return the unique validity identifier for this mailbox.
|
|
|
|
@rtype: C{int}
|
|
"""
|
|
|
|
def getUIDNext():
|
|
"""Return the likely UID for the next message added to this mailbox.
|
|
|
|
@rtype: C{int}
|
|
"""
|
|
|
|
def getUID(message):
|
|
"""Return the UID of a message in the mailbox
|
|
|
|
@type message: C{int}
|
|
@param message: The message sequence number
|
|
|
|
@rtype: C{int}
|
|
@return: The UID of the message.
|
|
"""
|
|
|
|
def getMessageCount():
|
|
"""Return the number of messages in this mailbox.
|
|
|
|
@rtype: C{int}
|
|
"""
|
|
|
|
def getRecentCount():
|
|
"""Return the number of messages with the 'Recent' flag.
|
|
|
|
@rtype: C{int}
|
|
"""
|
|
|
|
def getUnseenCount():
|
|
"""Return the number of messages with the 'Unseen' flag.
|
|
|
|
@rtype: C{int}
|
|
"""
|
|
|
|
def isWriteable():
|
|
"""Get the read/write status of the mailbox.
|
|
|
|
@rtype: C{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: C{dict} or C{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{IMailboxListener}
|
|
@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 = None):
|
|
"""Add the given message to this mailbox.
|
|
|
|
@type message: A file-like object
|
|
@param message: The RFC822 formatted message
|
|
|
|
@type flags: Any iterable of C{str}
|
|
@param flags: The flags to associate with this message
|
|
|
|
@type date: C{str}
|
|
@param date: If specified, the date to associate with this
|
|
message.
|
|
|
|
@rtype: C{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: C{list} or C{Deferred}
|
|
@return: The list of message sequence numbers which were deleted,
|
|
or a C{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: C{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{IMessage}.
|
|
"""
|
|
|
|
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 C{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: C{bool}
|
|
@param uid: If true, the IDs specified in the query are UIDs;
|
|
otherwise they are message sequence IDs.
|
|
|
|
@rtype: C{dict} or C{Deferred}
|
|
@return: A C{dict} mapping message sequence numbers to sequences of C{str}
|
|
representing the flags set on the message after this operation has
|
|
been performed, or a C{Deferred} whose callback will be invoked with
|
|
such a C{dict}.
|
|
|
|
@raise ReadOnlyMailbox: Raised if this mailbox is not open for
|
|
read-write.
|
|
"""
|
|
|
|
class ICloseableMailbox(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 C{Deferred} which fires when this mailbox
|
|
has been closed, or None if the mailbox can be closed
|
|
immediately.
|
|
"""
|
|
|
|
def _formatHeaders(headers):
|
|
hdrs = [': '.join((k.title(), '\r\n'.join(v.splitlines()))) for (k, v)
|
|
in headers.iteritems()]
|
|
hdrs = '\r\n'.join(hdrs) + '\r\n'
|
|
return 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 = i.next()
|
|
except StopIteration:
|
|
d.callback(last)
|
|
except:
|
|
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
|
|
|
|
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 = dict([(k.lower().strip(), v) for (k, v) in parts])
|
|
boundary = parts.get('boundary')
|
|
if boundary is None:
|
|
# Bastards
|
|
boundary = '----=_%f_boundary_%f' % (time.time(), random.random())
|
|
headers['content-type'] += '; boundary="%s"' % (boundary,)
|
|
else:
|
|
if boundary.startswith('"') and boundary.endswith('"'):
|
|
boundary = boundary[1:-1]
|
|
|
|
self.write(_formatHeaders(headers))
|
|
self.write('\r\n')
|
|
if self.msg.isMultipart():
|
|
for p in subparts(self.msg):
|
|
self.write('\r\n--%s\r\n' % (boundary,))
|
|
yield MessageProducer(p, self.buffer, self.scheduler
|
|
).beginProducing(None
|
|
)
|
|
self.write('\r\n--%s--\r\n' % (boundary,))
|
|
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):
|
|
base = 'BODY'
|
|
part = ''
|
|
separator = ''
|
|
if self.part:
|
|
part = '.'.join([str(x + 1) for x in self.part])
|
|
separator = '.'
|
|
# if self.peek:
|
|
# base += '.PEEK'
|
|
if self.header:
|
|
base += '[%s%s%s]' % (part, separator, self.header,)
|
|
elif self.text:
|
|
base += '[%s%sTEXT]' % (part, separator)
|
|
elif self.mime:
|
|
base += '[%s%sMIME]' % (part, separator)
|
|
elif self.empty:
|
|
base += '[%s]' % (part,)
|
|
if self.partialBegin is not None:
|
|
base += '<%d.%d>' % (self.partialBegin, self.partialLength)
|
|
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):
|
|
base = 'HEADER'
|
|
if self.fields:
|
|
base += '.FIELDS'
|
|
if self.negate:
|
|
base += '.NOT'
|
|
fields = []
|
|
for f in self.fields:
|
|
f = f.title()
|
|
if _needsQuote(f):
|
|
f = _quote(f)
|
|
fields.append(f)
|
|
base += ' (%s)' % ' '.join(fields)
|
|
if self.part:
|
|
base = '.'.join([str(x + 1) for x in self.part]) + '.' + base
|
|
return base
|
|
|
|
class Text:
|
|
pass
|
|
|
|
class MIME:
|
|
pass
|
|
|
|
parts = None
|
|
|
|
_simple_fetch_att = [
|
|
('envelope', Envelope),
|
|
('flags', Flags),
|
|
('internaldate', InternalDate),
|
|
('rfc822.header', RFC822Header),
|
|
('rfc822.text', RFC822Text),
|
|
('rfc822.size', RFC822Size),
|
|
('rfc822', RFC822),
|
|
('uid', UID),
|
|
('bodystructure', BodyStructure),
|
|
]
|
|
|
|
def __init__(self):
|
|
self.state = ['initial']
|
|
self.result = []
|
|
self.remaining = ''
|
|
|
|
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:
|
|
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 == '':
|
|
return 0
|
|
|
|
l = s.lower()
|
|
if l.startswith('all'):
|
|
self.result.extend((
|
|
self.Flags(), self.InternalDate(),
|
|
self.RFC822Size(), self.Envelope()
|
|
))
|
|
return 3
|
|
if l.startswith('full'):
|
|
self.result.extend((
|
|
self.Flags(), self.InternalDate(),
|
|
self.RFC822Size(), self.Envelope(),
|
|
self.Body()
|
|
))
|
|
return 4
|
|
if l.startswith('fast'):
|
|
self.result.extend((
|
|
self.Flags(), self.InternalDate(), self.RFC822Size(),
|
|
))
|
|
return 4
|
|
|
|
if l.startswith('('):
|
|
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(')'):
|
|
return 1
|
|
raise Exception("Missing )")
|
|
|
|
def state_whitespace(self, s):
|
|
# Eat up all the leading whitespace
|
|
if not s or not s[0].isspace():
|
|
raise Exception("Whitespace expected, none found")
|
|
i = 0
|
|
for i in range(len(s)):
|
|
if not s[i].isspace():
|
|
break
|
|
return i
|
|
|
|
def state_maybe_fetch_att(self, s):
|
|
if not s.startswith(')'):
|
|
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('body.peek'):
|
|
b.peek = True
|
|
used = 9
|
|
elif l.startswith('body'):
|
|
used = 4
|
|
else:
|
|
raise Exception("Nothing recognized in fetch_att: %s" % (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("["):
|
|
return 0
|
|
|
|
self.state.extend(('section', 'part_number'))
|
|
return 1
|
|
|
|
_partExpr = re.compile(r'(\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('.')]
|
|
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(']'):
|
|
self.pending_body.empty = True
|
|
used += 1
|
|
elif l.startswith('header]'):
|
|
h = self.pending_body.header = self.Header()
|
|
h.negate = True
|
|
h.fields = ()
|
|
used += 7
|
|
elif l.startswith('text]'):
|
|
self.pending_body.text = self.Text()
|
|
used += 5
|
|
elif l.startswith('mime]'):
|
|
self.pending_body.mime = self.MIME()
|
|
used += 5
|
|
else:
|
|
h = self.Header()
|
|
if l.startswith('header.fields.not'):
|
|
h.negate = True
|
|
used += 17
|
|
elif l.startswith('header.fields'):
|
|
used += 13
|
|
else:
|
|
raise Exception("Unhandled section contents: %r" % (l,))
|
|
|
|
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(']'):
|
|
raise Exception("section must end with ]")
|
|
return 1
|
|
|
|
def state_header_list(self, s):
|
|
if not s.startswith('('):
|
|
raise Exception("Header list must begin with (")
|
|
end = s.find(')')
|
|
if end == -1:
|
|
raise Exception("Header list must end with )")
|
|
|
|
headers = s[1:end].split()
|
|
self.pending_body.header.fields = map(str.upper, headers)
|
|
return end + 1
|
|
|
|
def state_maybe_partial(self, s):
|
|
# Grab <number.number> or nothing at all
|
|
if not s.startswith('<'):
|
|
return 0
|
|
end = s.find('>')
|
|
if end == -1:
|
|
raise Exception("Found < but not >")
|
|
|
|
partial = s[1:end]
|
|
parts = partial.split('.', 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 = ''
|
|
if self.firstWrite:
|
|
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):
|
|
pass
|
|
|
|
def stopProducing(self):
|
|
pass
|
|
|
|
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, "Cannot parse time string %r" % (s,)
|
|
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, "Cannot parse time string %r" % (s,)
|
|
else:
|
|
return time.struct_time(
|
|
(d['year'], d['mon'], d['day'], 0, 0, 0, -1, -1, -1)
|
|
)
|
|
|
|
import codecs
|
|
def modified_base64(s):
|
|
s_utf7 = s.encode('utf-7')
|
|
return s_utf7[1:-1].replace('/', ',')
|
|
|
|
def modified_unbase64(s):
|
|
s_utf7 = '+' + s.replace(',', '/') + '-'
|
|
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: C{tuple} of a C{str} giving the encoded bytes and an C{int}
|
|
giving the number of code units consumed from the input.
|
|
"""
|
|
r = []
|
|
_in = []
|
|
for c in s:
|
|
if ord(c) in (range(0x20, 0x26) + range(0x27, 0x7f)):
|
|
if _in:
|
|
r.extend(['&', modified_base64(''.join(_in)), '-'])
|
|
del _in[:]
|
|
r.append(str(c))
|
|
elif c == '&':
|
|
if _in:
|
|
r.extend(['&', modified_base64(''.join(_in)), '-'])
|
|
del _in[:]
|
|
r.append('&-')
|
|
else:
|
|
_in.append(c)
|
|
if _in:
|
|
r.extend(['&', modified_base64(''.join(_in)), '-'])
|
|
return (''.join(r), len(s))
|
|
|
|
def decoder(s, errors=None):
|
|
"""
|
|
Decode the given C{str} using the IMAP4 specific variation of UTF-7.
|
|
|
|
@type s: C{str}
|
|
@param s: The bytes to decode.
|
|
|
|
@param errors: Policy for handling decoding errors. Currently ignored.
|
|
|
|
@return: a C{tuple} of a C{unicode} string giving the text which was
|
|
decoded and an C{int} giving the number of bytes consumed from the
|
|
input.
|
|
"""
|
|
r = []
|
|
decode = []
|
|
for c in s:
|
|
if c == '&' and not decode:
|
|
decode.append('&')
|
|
elif c == '-' and decode:
|
|
if len(decode) == 1:
|
|
r.append('&')
|
|
else:
|
|
r.append(modified_unbase64(''.join(decode[1:])))
|
|
decode = []
|
|
elif decode:
|
|
decode.append(c)
|
|
else:
|
|
r.append(c)
|
|
if decode:
|
|
r.append(modified_unbase64(''.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 = (encoder, decoder, StreamReader, StreamWriter)
|
|
try:
|
|
_codecInfoClass = codecs.CodecInfo
|
|
except AttributeError:
|
|
pass
|
|
else:
|
|
_codecInfo = _codecInfoClass(*_codecInfo)
|
|
|
|
def imap4_utf_7(name):
|
|
if name == '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',
|
|
|
|
# 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',
|
|
]
|