7957 lines
264 KiB
Python
7957 lines
264 KiB
Python
# -*- test-case-name: twisted.mail.test.test_imap -*-
|
|
# Copyright (c) Twisted Matrix Laboratories.
|
|
# See LICENSE for details.
|
|
|
|
|
|
"""
|
|
Test case for twisted.mail.imap4
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import codecs
|
|
import functools
|
|
import locale
|
|
import os
|
|
import uuid
|
|
from collections import OrderedDict
|
|
from io import BytesIO
|
|
from itertools import chain
|
|
from typing import Optional, Type
|
|
from unittest import skipIf
|
|
|
|
from zope.interface import implementer
|
|
from zope.interface.verify import verifyClass, verifyObject
|
|
|
|
from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
|
|
from twisted.cred.credentials import (
|
|
CramMD5Credentials,
|
|
IUsernameHashedPassword,
|
|
IUsernamePassword,
|
|
)
|
|
from twisted.cred.error import UnauthorizedLogin
|
|
from twisted.cred.portal import IRealm, Portal
|
|
from twisted.internet import defer, error, interfaces, reactor
|
|
from twisted.internet.defer import Deferred
|
|
from twisted.internet.task import Clock
|
|
from twisted.internet.testing import StringTransport, StringTransportWithDisconnection
|
|
from twisted.mail import imap4
|
|
from twisted.mail.imap4 import MessageSet
|
|
from twisted.mail.interfaces import (
|
|
IChallengeResponse,
|
|
IClientAuthentication,
|
|
ICloseableMailboxIMAP,
|
|
)
|
|
from twisted.protocols import loopback
|
|
from twisted.python import failure, log, util
|
|
from twisted.python.compat import iterbytes, nativeString, networkString
|
|
from twisted.trial.unittest import SynchronousTestCase, TestCase
|
|
|
|
try:
|
|
from twisted.test.ssl_helpers import ClientTLSContext, ServerTLSContext
|
|
except ImportError:
|
|
ClientTLSContext = None # type: ignore[assignment,misc]
|
|
ServerTLSContext = None # type: ignore[assignment,misc]
|
|
|
|
|
|
def strip(f):
|
|
return lambda result, f=f: f()
|
|
|
|
|
|
class IMAP4UTF7Tests(TestCase):
|
|
tests = [
|
|
["Hello world", b"Hello world"],
|
|
["Hello & world", b"Hello &- world"],
|
|
["Hello\xffworld", b"Hello&AP8-world"],
|
|
["\xff\xfe\xfd\xfc", b"&AP8A,gD9APw-"],
|
|
[
|
|
"~peter/mail/\u65e5\u672c\u8a9e/\u53f0\u5317",
|
|
b"~peter/mail/&ZeVnLIqe-/&U,BTFw-",
|
|
], # example from RFC 2060
|
|
]
|
|
|
|
def test_encodeWithErrors(self):
|
|
"""
|
|
Specifying an error policy to C{unicode.encode} with the
|
|
I{imap4-utf-7} codec should produce the same result as not
|
|
specifying the error policy.
|
|
"""
|
|
text = "Hello world"
|
|
self.assertEqual(
|
|
text.encode("imap4-utf-7", "strict"), text.encode("imap4-utf-7")
|
|
)
|
|
|
|
def test_decodeWithErrors(self):
|
|
"""
|
|
Similar to L{test_encodeWithErrors}, but for C{bytes.decode}.
|
|
"""
|
|
bytes = b"Hello world"
|
|
self.assertEqual(
|
|
bytes.decode("imap4-utf-7", "strict"), bytes.decode("imap4-utf-7")
|
|
)
|
|
|
|
def test_encodeAmpersand(self):
|
|
"""
|
|
Unicode strings that contain an ampersand (C{&}) can be
|
|
encoded to bytes with the I{imap4-utf-7} codec.
|
|
"""
|
|
text = "&Hello&\N{VULGAR FRACTION ONE HALF}&"
|
|
self.assertEqual(
|
|
text.encode("imap4-utf-7"),
|
|
b"&-Hello&-&AL0-&-",
|
|
)
|
|
|
|
def test_decodeWithoutFinalASCIIShift(self):
|
|
"""
|
|
An I{imap4-utf-7} encoded string that does not shift back to
|
|
ASCII (i.e., it lacks a final C{-}) can be decoded.
|
|
"""
|
|
self.assertEqual(
|
|
b"&AL0".decode("imap4-utf-7"),
|
|
"\N{VULGAR FRACTION ONE HALF}",
|
|
)
|
|
|
|
def test_getreader(self):
|
|
"""
|
|
C{codecs.getreader('imap4-utf-7')} returns the I{imap4-utf-7} stream
|
|
reader class.
|
|
"""
|
|
reader = codecs.getreader("imap4-utf-7")(BytesIO(b"Hello&AP8-world"))
|
|
self.assertEqual(reader.read(), "Hello\xffworld")
|
|
|
|
def test_getwriter(self):
|
|
"""
|
|
C{codecs.getwriter('imap4-utf-7')} returns the I{imap4-utf-7} stream
|
|
writer class.
|
|
"""
|
|
output = BytesIO()
|
|
writer = codecs.getwriter("imap4-utf-7")(output)
|
|
writer.write("Hello\xffworld")
|
|
self.assertEqual(output.getvalue(), b"Hello&AP8-world")
|
|
|
|
def test_encode(self):
|
|
"""
|
|
The I{imap4-utf-7} can be used to encode a unicode string into a byte
|
|
string according to the IMAP4 modified UTF-7 encoding rules.
|
|
"""
|
|
for input, output in self.tests:
|
|
self.assertEqual(input.encode("imap4-utf-7"), output)
|
|
|
|
def test_decode(self):
|
|
"""
|
|
The I{imap4-utf-7} can be used to decode a byte string into a unicode
|
|
string according to the IMAP4 modified UTF-7 encoding rules.
|
|
"""
|
|
for input, output in self.tests:
|
|
self.assertEqual(input, output.decode("imap4-utf-7"))
|
|
|
|
def test_printableSingletons(self):
|
|
"""
|
|
The IMAP4 modified UTF-7 implementation encodes all printable
|
|
characters which are in ASCII using the corresponding ASCII byte.
|
|
"""
|
|
# All printables represent themselves
|
|
for o in chain(range(0x20, 0x26), range(0x27, 0x7F)):
|
|
charbyte = chr(o).encode()
|
|
self.assertEqual(charbyte, chr(o).encode("imap4-utf-7"))
|
|
self.assertEqual(chr(o), charbyte.decode("imap4-utf-7"))
|
|
self.assertEqual("&".encode("imap4-utf-7"), b"&-")
|
|
self.assertEqual(b"&-".decode("imap4-utf-7"), "&")
|
|
|
|
|
|
class BufferingConsumer:
|
|
def __init__(self):
|
|
self.buffer = []
|
|
|
|
def write(self, bytes):
|
|
self.buffer.append(bytes)
|
|
if self.consumer:
|
|
self.consumer.resumeProducing()
|
|
|
|
def registerProducer(self, consumer, streaming):
|
|
self.consumer = consumer
|
|
self.consumer.resumeProducing()
|
|
|
|
def unregisterProducer(self):
|
|
self.consumer = None
|
|
|
|
|
|
class MessageProducerTests(SynchronousTestCase):
|
|
def testSinglePart(self):
|
|
body = b"This is body text. Rar."
|
|
headers = OrderedDict()
|
|
headers["from"] = "sender@host"
|
|
headers["to"] = "recipient@domain"
|
|
headers["subject"] = "booga booga boo"
|
|
headers["content-type"] = "text/plain"
|
|
|
|
msg = FakeyMessage(headers, (), None, body, 123, None)
|
|
|
|
c = BufferingConsumer()
|
|
p = imap4.MessageProducer(msg)
|
|
d = p.beginProducing(c)
|
|
|
|
def cbProduced(result):
|
|
self.assertIdentical(result, p)
|
|
self.assertEqual(
|
|
b"".join(c.buffer),
|
|
b"{119}\r\n"
|
|
b"From: sender@host\r\n"
|
|
b"To: recipient@domain\r\n"
|
|
b"Subject: booga booga boo\r\n"
|
|
b"Content-Type: text/plain\r\n"
|
|
b"\r\n" + body,
|
|
)
|
|
|
|
return d.addCallback(cbProduced)
|
|
|
|
def testSingleMultiPart(self):
|
|
outerBody = b""
|
|
innerBody = b"Contained body message text. Squarge."
|
|
headers = OrderedDict()
|
|
headers["from"] = "sender@host"
|
|
headers["to"] = "recipient@domain"
|
|
headers["subject"] = "booga booga boo"
|
|
headers["content-type"] = 'multipart/alternative; boundary="xyz"'
|
|
|
|
innerHeaders = OrderedDict()
|
|
innerHeaders["subject"] = "this is subject text"
|
|
innerHeaders["content-type"] = "text/plain"
|
|
msg = FakeyMessage(
|
|
headers,
|
|
(),
|
|
None,
|
|
outerBody,
|
|
123,
|
|
[FakeyMessage(innerHeaders, (), None, innerBody, None, None)],
|
|
)
|
|
|
|
c = BufferingConsumer()
|
|
p = imap4.MessageProducer(msg)
|
|
d = p.beginProducing(c)
|
|
|
|
def cbProduced(result):
|
|
self.failUnlessIdentical(result, p)
|
|
|
|
self.assertEqual(
|
|
b"".join(c.buffer),
|
|
b"{239}\r\n"
|
|
b"From: sender@host\r\n"
|
|
b"To: recipient@domain\r\n"
|
|
b"Subject: booga booga boo\r\n"
|
|
b'Content-Type: multipart/alternative; boundary="xyz"\r\n'
|
|
b"\r\n"
|
|
b"\r\n"
|
|
b"--xyz\r\n"
|
|
b"Subject: this is subject text\r\n"
|
|
b"Content-Type: text/plain\r\n"
|
|
b"\r\n" + innerBody + b"\r\n--xyz--\r\n",
|
|
)
|
|
|
|
return d.addCallback(cbProduced)
|
|
|
|
def testMultipleMultiPart(self):
|
|
outerBody = b""
|
|
innerBody1 = b"Contained body message text. Squarge."
|
|
innerBody2 = b"Secondary <i>message</i> text of squarge body."
|
|
headers = OrderedDict()
|
|
headers["from"] = "sender@host"
|
|
headers["to"] = "recipient@domain"
|
|
headers["subject"] = "booga booga boo"
|
|
headers["content-type"] = 'multipart/alternative; boundary="xyz"'
|
|
innerHeaders = OrderedDict()
|
|
innerHeaders["subject"] = "this is subject text"
|
|
innerHeaders["content-type"] = "text/plain"
|
|
innerHeaders2 = OrderedDict()
|
|
innerHeaders2["subject"] = "<b>this is subject</b>"
|
|
innerHeaders2["content-type"] = "text/html"
|
|
msg = FakeyMessage(
|
|
headers,
|
|
(),
|
|
None,
|
|
outerBody,
|
|
123,
|
|
[
|
|
FakeyMessage(innerHeaders, (), None, innerBody1, None, None),
|
|
FakeyMessage(innerHeaders2, (), None, innerBody2, None, None),
|
|
],
|
|
)
|
|
|
|
c = BufferingConsumer()
|
|
p = imap4.MessageProducer(msg)
|
|
d = p.beginProducing(c)
|
|
|
|
def cbProduced(result):
|
|
self.failUnlessIdentical(result, p)
|
|
|
|
self.assertEqual(
|
|
b"".join(c.buffer),
|
|
b"{354}\r\n"
|
|
b"From: sender@host\r\n"
|
|
b"To: recipient@domain\r\n"
|
|
b"Subject: booga booga boo\r\n"
|
|
b'Content-Type: multipart/alternative; boundary="xyz"\r\n'
|
|
b"\r\n"
|
|
b"\r\n"
|
|
b"--xyz\r\n"
|
|
b"Subject: this is subject text\r\n"
|
|
b"Content-Type: text/plain\r\n"
|
|
b"\r\n" + innerBody1 + b"\r\n--xyz\r\n"
|
|
b"Subject: <b>this is subject</b>\r\n"
|
|
b"Content-Type: text/html\r\n"
|
|
b"\r\n" + innerBody2 + b"\r\n--xyz--\r\n",
|
|
)
|
|
|
|
return d.addCallback(cbProduced)
|
|
|
|
def test_multiPartNoBoundary(self):
|
|
"""
|
|
A boundary is generated if none is provided.
|
|
"""
|
|
outerBody = b""
|
|
innerBody = b"Contained body message text. Squarge."
|
|
headers = OrderedDict()
|
|
headers["from"] = "sender@host"
|
|
headers["to"] = "recipient@domain"
|
|
headers["subject"] = "booga booga boo"
|
|
headers["content-type"] = "multipart/alternative"
|
|
|
|
innerHeaders = OrderedDict()
|
|
innerHeaders["subject"] = "this is subject text"
|
|
innerHeaders["content-type"] = "text/plain"
|
|
msg = FakeyMessage(
|
|
headers,
|
|
(),
|
|
None,
|
|
outerBody,
|
|
123,
|
|
[FakeyMessage(innerHeaders, (), None, innerBody, None, None)],
|
|
)
|
|
|
|
c = BufferingConsumer()
|
|
p = imap4.MessageProducer(msg)
|
|
p._uuid4 = lambda: uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
|
|
|
d = p.beginProducing(c)
|
|
|
|
def cbProduced(result):
|
|
self.failUnlessIdentical(result, p)
|
|
self.assertEqual(
|
|
b"".join(c.buffer),
|
|
b"{341}\r\n"
|
|
b"From: sender@host\r\n"
|
|
b"To: recipient@domain\r\n"
|
|
b"Subject: booga booga boo\r\n"
|
|
b"Content-Type: multipart/alternative; boundary="
|
|
b'"----=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"'
|
|
b"\r\n"
|
|
b"\r\n"
|
|
b"\r\n"
|
|
b"------=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n"
|
|
b"Subject: this is subject text\r\n"
|
|
b"Content-Type: text/plain\r\n"
|
|
b"\r\n"
|
|
+ innerBody
|
|
+ b"\r\n------=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--\r\n",
|
|
)
|
|
|
|
return d.addCallback(cbProduced)
|
|
|
|
def test_multiPartNoQuotes(self):
|
|
"""
|
|
A boundary without does not have them added.
|
|
"""
|
|
outerBody = b""
|
|
innerBody = b"Contained body message text. Squarge."
|
|
headers = OrderedDict()
|
|
headers["from"] = "sender@host"
|
|
headers["to"] = "recipient@domain"
|
|
headers["subject"] = "booga booga boo"
|
|
headers["content-type"] = "multipart/alternative; boundary=xyz"
|
|
|
|
innerHeaders = OrderedDict()
|
|
innerHeaders["subject"] = "this is subject text"
|
|
innerHeaders["content-type"] = "text/plain"
|
|
msg = FakeyMessage(
|
|
headers,
|
|
(),
|
|
None,
|
|
outerBody,
|
|
123,
|
|
[FakeyMessage(innerHeaders, (), None, innerBody, None, None)],
|
|
)
|
|
|
|
c = BufferingConsumer()
|
|
p = imap4.MessageProducer(msg)
|
|
d = p.beginProducing(c)
|
|
|
|
def cbProduced(result):
|
|
self.failUnlessIdentical(result, p)
|
|
self.assertEqual(
|
|
b"".join(c.buffer),
|
|
b"{237}\r\n"
|
|
b"From: sender@host\r\n"
|
|
b"To: recipient@domain\r\n"
|
|
b"Subject: booga booga boo\r\n"
|
|
b"Content-Type: multipart/alternative; boundary="
|
|
b"xyz"
|
|
b"\r\n"
|
|
b"\r\n"
|
|
b"\r\n"
|
|
b"--xyz\r\n"
|
|
b"Subject: this is subject text\r\n"
|
|
b"Content-Type: text/plain\r\n"
|
|
b"\r\n" + innerBody + b"\r\n--xyz--\r\n",
|
|
)
|
|
|
|
return d.addCallback(cbProduced)
|
|
|
|
|
|
class MessageSetTests(SynchronousTestCase):
|
|
"""
|
|
Tests for L{MessageSet}.
|
|
"""
|
|
|
|
def test_equalityIterationAndAddition(self):
|
|
"""
|
|
Test the following properties of L{MessageSet} addition and
|
|
equality:
|
|
|
|
1. Two empty L{MessageSet}s are equal to each other;
|
|
|
|
2. A L{MessageSet} is not equal to any other object;
|
|
|
|
2. Adding a L{MessageSet} and another L{MessageSet} or an
|
|
L{int} representing a single message or a sequence of
|
|
L{int}s representing a sequence of message numbers
|
|
produces a new L{MessageSet} that:
|
|
|
|
3. Has a length equal to the number of messages within
|
|
each sequence of message numbers;
|
|
|
|
4. Yields each message number in ascending order when
|
|
iterated over;
|
|
|
|
6. L{MessageSet.add} with a single message or a start and
|
|
end message satisfies 3 and 4 above.
|
|
"""
|
|
m1 = MessageSet()
|
|
m2 = MessageSet()
|
|
|
|
self.assertEqual(m1, m2)
|
|
self.assertNotEqual(m1, ())
|
|
|
|
m1 = m1 + 1
|
|
self.assertEqual(len(m1), 1)
|
|
self.assertEqual(list(m1), [1])
|
|
|
|
m1 = m1 + (1, 3)
|
|
self.assertEqual(len(m1), 3)
|
|
self.assertEqual(list(m1), [1, 2, 3])
|
|
|
|
m2 = m2 + (1, 3)
|
|
self.assertEqual(m1, m2)
|
|
self.assertEqual(list(m1 + m2), [1, 2, 3])
|
|
|
|
m1.add(5)
|
|
self.assertEqual(len(m1), 4)
|
|
self.assertEqual(list(m1), [1, 2, 3, 5])
|
|
|
|
self.assertNotEqual(m1, m2)
|
|
|
|
m1.add(6, 8)
|
|
self.assertEqual(len(m1), 7)
|
|
self.assertEqual(list(m1), [1, 2, 3, 5, 6, 7, 8])
|
|
|
|
def test_lengthWithWildcardRange(self):
|
|
"""
|
|
A L{MessageSet} that has a range that ends with L{None} raises
|
|
a L{TypeError} when its length is requested.
|
|
"""
|
|
self.assertRaises(TypeError, len, MessageSet(1, None))
|
|
|
|
def test_reprSanity(self):
|
|
"""
|
|
L{MessageSet.__repr__} does not raise an exception
|
|
"""
|
|
repr(MessageSet(1, 2))
|
|
|
|
def test_stringRepresentationWithWildcards(self):
|
|
"""
|
|
In a L{MessageSet}, in the presence of wildcards, if the
|
|
highest message id is known, the wildcard should get replaced
|
|
by that high value.
|
|
"""
|
|
inputs = [
|
|
imap4.parseIdList(b"*"),
|
|
imap4.parseIdList(b"1:*"),
|
|
imap4.parseIdList(b"3:*", 6),
|
|
imap4.parseIdList(b"*:2", 6),
|
|
]
|
|
|
|
outputs = [
|
|
"*",
|
|
"1:*",
|
|
"3:6",
|
|
"2:6",
|
|
]
|
|
|
|
for i, o in zip(inputs, outputs):
|
|
self.assertEqual(str(i), o)
|
|
|
|
def test_stringRepresentationWithInversion(self):
|
|
"""
|
|
In a L{MessageSet}, inverting the high and low numbers in a
|
|
range doesn't affect the meaning of the range. For example,
|
|
3:2 displays just like 2:3, because according to the RFC they
|
|
have the same meaning.
|
|
"""
|
|
inputs = [
|
|
imap4.parseIdList(b"2:3"),
|
|
imap4.parseIdList(b"3:2"),
|
|
]
|
|
|
|
outputs = [
|
|
"2:3",
|
|
"2:3",
|
|
]
|
|
|
|
for i, o in zip(inputs, outputs):
|
|
self.assertEqual(str(i), o)
|
|
|
|
def test_createWithSingleMessageNumber(self):
|
|
"""
|
|
Creating a L{MessageSet} with a single message number adds
|
|
only that message to the L{MessageSet}; its serialized form
|
|
includes only that message number, its length is one, and it
|
|
yields only that message number.
|
|
"""
|
|
m = MessageSet(1)
|
|
self.assertEqual(str(m), "1")
|
|
self.assertEqual(len(m), 1)
|
|
self.assertEqual(list(m), [1])
|
|
|
|
def test_createWithSequence(self):
|
|
"""
|
|
Creating a L{MessageSet} with both a start and end message
|
|
number adds the sequence between to the L{MessageSet}; its
|
|
serialized form consists that range, its length is the length
|
|
of the sequence, and it yields the message numbers inclusively
|
|
between the start and end.
|
|
"""
|
|
m = MessageSet(1, 10)
|
|
self.assertEqual(str(m), "1:10")
|
|
self.assertEqual(len(m), 10)
|
|
self.assertEqual(list(m), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
|
|
|
def test_createWithSingleWildcard(self):
|
|
"""
|
|
Creating a L{MessageSet} with a single L{None}, representing
|
|
C{*}, adds C{*} to the range; its serialized form includes
|
|
only C{*}, its length is one, but it cannot be iterated over
|
|
because its endpoint is unknown.
|
|
"""
|
|
m = MessageSet(None)
|
|
self.assertEqual(str(m), "*")
|
|
self.assertEqual(len(m), 1)
|
|
self.assertRaises(TypeError, list, m)
|
|
|
|
def test_setLastSingleWildcard(self):
|
|
"""
|
|
Setting L{MessageSet.last} replaces L{None}, representing
|
|
C{*}, with that number, making that L{MessageSet} iterable.
|
|
"""
|
|
singleMessageReplaced = MessageSet(None)
|
|
singleMessageReplaced.last = 10
|
|
self.assertEqual(list(singleMessageReplaced), [10])
|
|
|
|
rangeReplaced = MessageSet(3, None)
|
|
rangeReplaced.last = 1
|
|
self.assertEqual(list(rangeReplaced), [1, 2, 3])
|
|
|
|
def test_setLastWithWildcardRange(self):
|
|
"""
|
|
Setting L{MessageSet.last} replaces L{None} in all ranges.
|
|
"""
|
|
m = MessageSet(1, None)
|
|
m.add(2, None)
|
|
m.last = 5
|
|
self.assertEqual(list(m), [1, 2, 3, 4, 5])
|
|
|
|
def test_setLastTwiceFails(self):
|
|
"""
|
|
L{MessageSet.last} cannot be set twice.
|
|
"""
|
|
m = MessageSet(1, None)
|
|
m.last = 2
|
|
with self.assertRaises(ValueError):
|
|
m.last = 3
|
|
|
|
def test_lastOverridesNoneInAdd(self):
|
|
"""
|
|
Adding a L{None}, representing C{*}, or a sequence that
|
|
includes L{None} to a L{MessageSet} whose
|
|
L{last<MessageSet.last>} property has been set replaces all
|
|
occurrences of L{None} with the value of
|
|
L{last<MessageSet.last>}.
|
|
"""
|
|
hasLast = MessageSet(1)
|
|
hasLast.last = 4
|
|
|
|
hasLast.add(None)
|
|
self.assertEqual(list(hasLast), [1, 4])
|
|
|
|
self.assertEqual(list(hasLast + (None, 5)), [1, 4, 5])
|
|
|
|
hasLast.add(3, None)
|
|
self.assertEqual(list(hasLast), [1, 3, 4])
|
|
|
|
def test_getLast(self):
|
|
"""
|
|
Accessing L{MessageSet.last} returns the last value.
|
|
"""
|
|
m = MessageSet(1, None)
|
|
m.last = 2
|
|
self.assertEqual(m.last, 2)
|
|
|
|
def test_extend(self):
|
|
"""
|
|
L{MessageSet.extend} accepts as its arugment an L{int} or
|
|
L{None}, or a sequence L{int}s or L{None}s of length two, or
|
|
another L{MessageSet}, combining its argument with its
|
|
instance's existing ranges.
|
|
"""
|
|
extendWithInt = MessageSet()
|
|
extendWithInt.extend(1)
|
|
self.assertEqual(list(extendWithInt), [1])
|
|
|
|
extendWithNone = MessageSet()
|
|
extendWithNone.extend(None)
|
|
self.assertEqual(str(extendWithNone), "*")
|
|
|
|
extendWithSequenceOfInts = MessageSet()
|
|
extendWithSequenceOfInts.extend((1, 3))
|
|
self.assertEqual(list(extendWithSequenceOfInts), [1, 2, 3])
|
|
|
|
extendWithSequenceOfNones = MessageSet()
|
|
extendWithSequenceOfNones.extend((None, None))
|
|
self.assertEqual(str(extendWithSequenceOfNones), "*")
|
|
|
|
extendWithMessageSet = MessageSet()
|
|
extendWithMessageSet.extend(MessageSet(1, 3))
|
|
self.assertEqual(list(extendWithMessageSet), [1, 2, 3])
|
|
|
|
def test_contains(self):
|
|
"""
|
|
A L{MessageSet} contains a number if the number falls within
|
|
one of its ranges, and raises L{TypeError} if any range
|
|
contains L{None}.
|
|
"""
|
|
hasFive = MessageSet(1, 7)
|
|
doesNotHaveFive = MessageSet(1, 4) + MessageSet(6, 7)
|
|
|
|
self.assertIn(5, hasFive)
|
|
self.assertNotIn(5, doesNotHaveFive)
|
|
|
|
hasFiveButHasNone = hasFive + None
|
|
with self.assertRaises(TypeError):
|
|
5 in hasFiveButHasNone
|
|
|
|
hasFiveButHasNoneInSequence = hasFive + (10, 12)
|
|
hasFiveButHasNoneInSequence.add(8, None)
|
|
with self.assertRaises(TypeError):
|
|
5 in hasFiveButHasNoneInSequence
|
|
|
|
def test_rangesMerged(self):
|
|
"""
|
|
Adding a sequence of message numbers to a L{MessageSet} that
|
|
begins or ends immediately before or after an existing
|
|
sequence in that L{MessageSet}, or overlaps one, merges the two.
|
|
"""
|
|
|
|
mergeAfter = MessageSet(1, 3)
|
|
mergeBefore = MessageSet(6, 8)
|
|
|
|
mergeBetweenSequence = mergeAfter + mergeBefore
|
|
mergeBetweenNumber = mergeAfter + MessageSet(5, 7)
|
|
|
|
self.assertEqual(list(mergeAfter + (2, 4)), [1, 2, 3, 4])
|
|
self.assertEqual(list(mergeAfter + (3, 5)), [1, 2, 3, 4, 5])
|
|
|
|
self.assertEqual(list(mergeBefore + (5, 7)), [5, 6, 7, 8])
|
|
self.assertEqual(list(mergeBefore + (4, 6)), [4, 5, 6, 7, 8])
|
|
|
|
self.assertEqual(list(mergeBetweenSequence + (3, 5)), [1, 2, 3, 4, 5, 6, 7, 8])
|
|
self.assertEqual(
|
|
list(mergeBetweenNumber + MessageSet(4)), [1, 2, 3, 4, 5, 6, 7]
|
|
)
|
|
|
|
def test_seq_rangeExamples(self):
|
|
"""
|
|
Test the C{seq-range} examples from Section 9, "Formal Syntax"
|
|
of RFC 3501::
|
|
|
|
Example: 2:4 and 4:2 are equivalent and indicate values
|
|
2, 3, and 4.
|
|
|
|
Example: a unique identifier sequence range of
|
|
3291:* includes the UID of the last message in
|
|
the mailbox, even if that value is less than 3291.
|
|
|
|
@see: U{http://tools.ietf.org/html/rfc3501#section-9}
|
|
"""
|
|
|
|
self.assertEqual(MessageSet(2, 4), MessageSet(4, 2))
|
|
self.assertEqual(list(MessageSet(2, 4)), [2, 3, 4])
|
|
|
|
m = MessageSet(3291, None)
|
|
m.last = 3290
|
|
self.assertEqual(list(m), [3290, 3291])
|
|
|
|
def test_sequence_setExamples(self):
|
|
"""
|
|
Test the C{sequence-set} examples from Section 9, "Formal
|
|
Syntax" of RFC 3501. In particular, L{MessageSet} reorders
|
|
and coalesces overlaps::
|
|
|
|
Example: a message sequence number set of
|
|
2,4:7,9,12:* for a mailbox with 15 messages is
|
|
equivalent to 2,4,5,6,7,9,12,13,14,15
|
|
|
|
Example: a message sequence number set of *:4,5:7
|
|
for a mailbox with 10 messages is equivalent to
|
|
10,9,8,7,6,5,4,5,6,7 and MAY be reordered and
|
|
overlap coalesced to be 4,5,6,7,8,9,10.
|
|
|
|
@see: U{http://tools.ietf.org/html/rfc3501#section-9}
|
|
"""
|
|
fromFifteenMessages = (
|
|
MessageSet(2) + MessageSet(4, 7) + MessageSet(9) + MessageSet(12, None)
|
|
)
|
|
fromFifteenMessages.last = 15
|
|
self.assertEqual(
|
|
",".join(str(i) for i in fromFifteenMessages), "2,4,5,6,7,9,12,13,14,15"
|
|
)
|
|
|
|
fromTenMessages = MessageSet(None, 4) + MessageSet(5, 7)
|
|
fromTenMessages.last = 10
|
|
self.assertEqual(",".join(str(i) for i in fromTenMessages), "4,5,6,7,8,9,10")
|
|
|
|
|
|
class IMAP4HelperTests(TestCase):
|
|
"""
|
|
Tests for various helper utilities in the IMAP4 module.
|
|
"""
|
|
|
|
def test_commandRepr(self):
|
|
"""
|
|
L{imap4.Command}'s C{repr} does not raise an exception.
|
|
"""
|
|
repr(imap4.Command(b"COMMAND", [b"arg"], (b"extra")))
|
|
|
|
def test_fileProducer(self):
|
|
b = ((b"x" * 1) + (b"y" * 1) + (b"z" * 1)) * 10
|
|
c = BufferingConsumer()
|
|
f = BytesIO(b)
|
|
p = imap4.FileProducer(f)
|
|
d = p.beginProducing(c)
|
|
|
|
def cbProduced(result):
|
|
self.failUnlessIdentical(result, p)
|
|
self.assertEqual(b"{%d}\r\n%b" % (len(b), b), b"".join(c.buffer))
|
|
return result
|
|
|
|
def cbResume(result):
|
|
# Calling resumeProducing after completion does not raise
|
|
# an exception
|
|
p.resumeProducing()
|
|
return result
|
|
|
|
d.addCallback(cbProduced)
|
|
d.addCallback(cbResume)
|
|
# The second cbProduced ensures calling resumeProducing after
|
|
# completion does not change the result.
|
|
return d.addCallback(cbProduced)
|
|
|
|
def test_wildcard(self):
|
|
cases = [
|
|
[
|
|
"foo/%gum/bar",
|
|
["foo/bar", "oo/lalagum/bar", "foo/gumx/bar", "foo/gum/baz"],
|
|
["foo/xgum/bar", "foo/gum/bar"],
|
|
],
|
|
[
|
|
"foo/x%x/bar",
|
|
["foo", "bar", "fuz fuz fuz", "foo/*/bar", "foo/xyz/bar", "foo/xx/baz"],
|
|
["foo/xyx/bar", "foo/xx/bar", "foo/xxxxxxxxxxxxxx/bar"],
|
|
],
|
|
[
|
|
"foo/xyz*abc/bar",
|
|
["foo/xyz/bar", "foo/abc/bar", "foo/xyzab/cbar", "foo/xyza/bcbar"],
|
|
["foo/xyzabc/bar", "foo/xyz/abc/bar", "foo/xyz/123/abc/bar"],
|
|
],
|
|
]
|
|
|
|
for wildcard, fail, succeed in cases:
|
|
wildcard = imap4.wildcardToRegexp(wildcard, "/")
|
|
for x in fail:
|
|
self.assertFalse(wildcard.match(x))
|
|
for x in succeed:
|
|
self.assertTrue(wildcard.match(x))
|
|
|
|
def test_wildcardNoDelim(self):
|
|
cases = [
|
|
[
|
|
"foo/%gum/bar",
|
|
["foo/bar", "oo/lalagum/bar", "foo/gumx/bar", "foo/gum/baz"],
|
|
["foo/xgum/bar", "foo/gum/bar", "foo/x/gum/bar"],
|
|
],
|
|
[
|
|
"foo/x%x/bar",
|
|
["foo", "bar", "fuz fuz fuz", "foo/*/bar", "foo/xyz/bar", "foo/xx/baz"],
|
|
["foo/xyx/bar", "foo/xx/bar", "foo/xxxxxxxxxxxxxx/bar", "foo/x/x/bar"],
|
|
],
|
|
[
|
|
"foo/xyz*abc/bar",
|
|
["foo/xyz/bar", "foo/abc/bar", "foo/xyzab/cbar", "foo/xyza/bcbar"],
|
|
["foo/xyzabc/bar", "foo/xyz/abc/bar", "foo/xyz/123/abc/bar"],
|
|
],
|
|
]
|
|
|
|
for wildcard, fail, succeed in cases:
|
|
wildcard = imap4.wildcardToRegexp(wildcard, None)
|
|
for x in fail:
|
|
self.assertFalse(wildcard.match(x), x)
|
|
for x in succeed:
|
|
self.assertTrue(wildcard.match(x), x)
|
|
|
|
def test_headerFormatter(self):
|
|
"""
|
|
L{imap4._formatHeaders} accepts a C{dict} of header name/value pairs and
|
|
returns a string representing those headers in the standard multiline,
|
|
C{":"}-separated format.
|
|
"""
|
|
cases = [
|
|
(
|
|
{"Header1": "Value1", "Header2": "Value2"},
|
|
b"Header2: Value2\r\nHeader1: Value1\r\n",
|
|
),
|
|
]
|
|
|
|
for input, expected in cases:
|
|
output = imap4._formatHeaders(input)
|
|
self.assertEqual(
|
|
sorted(output.splitlines(True)), sorted(expected.splitlines(True))
|
|
)
|
|
|
|
def test_quotedSplitter(self):
|
|
cases = [
|
|
b"""Hello World""",
|
|
b'''Hello "World!"''',
|
|
b'''World "Hello" "How are you?"''',
|
|
b'''"Hello world" How "are you?"''',
|
|
b"""foo bar "baz buz" NIL""",
|
|
b'''foo bar "baz buz" "NIL"''',
|
|
b"""foo NIL "baz buz" bar""",
|
|
b"""foo "NIL" "baz buz" bar""",
|
|
b""""NIL" bar "baz buz" foo""",
|
|
b'oo \\"oo\\" oo',
|
|
b'"oo \\"oo\\" oo"',
|
|
b"oo \t oo",
|
|
b'"oo \t oo"',
|
|
b"oo \\t oo",
|
|
b'"oo \\t oo"',
|
|
rb"oo \o oo",
|
|
rb'"oo \o oo"',
|
|
b"oo \\o oo",
|
|
b'"oo \\o oo"',
|
|
]
|
|
|
|
answers = [
|
|
[b"Hello", b"World"],
|
|
[b"Hello", b"World!"],
|
|
[b"World", b"Hello", b"How are you?"],
|
|
[b"Hello world", b"How", b"are you?"],
|
|
[b"foo", b"bar", b"baz buz", None],
|
|
[b"foo", b"bar", b"baz buz", b"NIL"],
|
|
[b"foo", None, b"baz buz", b"bar"],
|
|
[b"foo", b"NIL", b"baz buz", b"bar"],
|
|
[b"NIL", b"bar", b"baz buz", b"foo"],
|
|
[b"oo", b'"oo"', b"oo"],
|
|
[b'oo "oo" oo'],
|
|
[b"oo", b"oo"],
|
|
[b"oo \t oo"],
|
|
[b"oo", b"\\t", b"oo"],
|
|
[b"oo \\t oo"],
|
|
[b"oo", rb"\o", b"oo"],
|
|
[rb"oo \o oo"],
|
|
[b"oo", b"\\o", b"oo"],
|
|
[b"oo \\o oo"],
|
|
]
|
|
|
|
errors = [
|
|
b'"mismatched quote',
|
|
b'mismatched quote"',
|
|
b'mismatched"quote',
|
|
b'"oops here is" another"',
|
|
]
|
|
|
|
for s in errors:
|
|
self.assertRaises(imap4.MismatchedQuoting, imap4.splitQuoted, s)
|
|
|
|
for case, expected in zip(cases, answers):
|
|
self.assertEqual(imap4.splitQuoted(case), expected)
|
|
|
|
def test_stringCollapser(self):
|
|
cases = [
|
|
[b"a", b"b", b"c", b"d", b"e"],
|
|
[b"a", b" ", b'"', b"b", b"c", b" ", b'"', b" ", b"d", b"e"],
|
|
[[b"a", b"b", b"c"], b"d", b"e"],
|
|
[b"a", [b"b", b"c", b"d"], b"e"],
|
|
[b"a", b"b", [b"c", b"d", b"e"]],
|
|
[b'"', b"a", b" ", b'"', [b"b", b"c", b"d"], b'"', b" ", b"e", b'"'],
|
|
[b"a", [b'"', b" ", b"b", b"c", b" ", b" ", b'"'], b"d", b"e"],
|
|
]
|
|
|
|
answers = [
|
|
[b"abcde"],
|
|
[b"a", b"bc ", b"de"],
|
|
[[b"abc"], b"de"],
|
|
[b"a", [b"bcd"], b"e"],
|
|
[b"ab", [b"cde"]],
|
|
[b"a ", [b"bcd"], b" e"],
|
|
[b"a", [b" bc "], b"de"],
|
|
]
|
|
|
|
for case, expected in zip(cases, answers):
|
|
self.assertEqual(imap4.collapseStrings(case), expected)
|
|
|
|
def test_parenParser(self):
|
|
s = b"\r\n".join([b"xx"] * 4)
|
|
|
|
def check(case, expected):
|
|
parsed = imap4.parseNestedParens(case)
|
|
self.assertEqual(parsed, [expected])
|
|
# XXX This code used to work, but changes occurred within the
|
|
# imap4.py module which made it no longer necessary for *all* of it
|
|
# to work. In particular, only the part that makes
|
|
# 'BODY.PEEK[HEADER.FIELDS.NOT (Subject Bcc Cc)]' come out
|
|
# correctly no longer needs to work. So, I am loathe to delete the
|
|
# entire section of the test. --exarkun
|
|
|
|
# self.assertEqual(b'(' + imap4.collapseNestedLists(parsed) + b')',
|
|
# expected)
|
|
|
|
check(
|
|
b"(BODY.PEEK[HEADER.FIELDS.NOT (subject bcc cc)] {%d}\r\n%b)" % (len(s), s),
|
|
[b"BODY.PEEK", [b"HEADER.FIELDS.NOT", [b"subject", b"bcc", b"cc"]], s],
|
|
)
|
|
check(
|
|
b'(FLAGS (\\Seen) INTERNALDATE "17-Jul-1996 02:44:25 -0700" '
|
|
b"RFC822.SIZE 4286 ENVELOPE "
|
|
b'("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)" '
|
|
b'"IMAP4rev1 WG mtg summary and minutes" '
|
|
b'(("Terry Gray" NIL gray cac.washington.edu)) '
|
|
b'(("Terry Gray" NIL gray cac.washington.edu)) '
|
|
b'(("Terry Gray" NIL gray cac.washington.edu)) '
|
|
b"((NIL NIL imap cac.washington.edu)) "
|
|
b"((NIL NIL minutes CNRI.Reston.VA.US) "
|
|
b'("John Klensin" NIL KLENSIN INFOODS.MIT.EDU)) NIL NIL '
|
|
b"<B27397-0100000@cac.washington.edu>) "
|
|
b"BODY (TEXT PLAIN (CHARSET US-ASCII) NIL NIL 7BIT 3028 92))",
|
|
[
|
|
b"FLAGS",
|
|
[rb"\Seen"],
|
|
b"INTERNALDATE",
|
|
b"17-Jul-1996 02:44:25 -0700",
|
|
b"RFC822.SIZE",
|
|
b"4286",
|
|
b"ENVELOPE",
|
|
[
|
|
b"Wed, 17 Jul 1996 02:23:25 -0700 (PDT)",
|
|
b"IMAP4rev1 WG mtg summary and minutes",
|
|
[[b"Terry Gray", None, b"gray", b"cac.washington.edu"]],
|
|
[[b"Terry Gray", None, b"gray", b"cac.washington.edu"]],
|
|
[[b"Terry Gray", None, b"gray", b"cac.washington.edu"]],
|
|
[[None, None, b"imap", b"cac.washington.edu"]],
|
|
[
|
|
[None, None, b"minutes", b"CNRI.Reston.VA.US"],
|
|
[b"John Klensin", None, b"KLENSIN", b"INFOODS.MIT.EDU"],
|
|
],
|
|
None,
|
|
None,
|
|
b"<B27397-0100000@cac.washington.edu>",
|
|
],
|
|
b"BODY",
|
|
[
|
|
b"TEXT",
|
|
b"PLAIN",
|
|
[b"CHARSET", b"US-ASCII"],
|
|
None,
|
|
None,
|
|
b"7BIT",
|
|
b"3028",
|
|
b"92",
|
|
],
|
|
],
|
|
)
|
|
|
|
check(b'("oo \\"oo\\" oo")', [b'oo "oo" oo'])
|
|
check(b'("oo \\\\ oo")', [b"oo \\\\ oo"])
|
|
check(b'("oo \\ oo")', [b"oo \\ oo"])
|
|
|
|
check(b'("oo \\o")', [b"oo \\o"])
|
|
check(rb'("oo \o")', [rb"oo \o"])
|
|
check(rb"(oo \o)", [b"oo", rb"\o"])
|
|
check(b"(oo \\o)", [b"oo", b"\\o"])
|
|
|
|
def test_fetchParserSimple(self):
|
|
cases = [
|
|
["ENVELOPE", "Envelope", "envelope"],
|
|
["FLAGS", "Flags", "flags"],
|
|
["INTERNALDATE", "InternalDate", "internaldate"],
|
|
["RFC822.HEADER", "RFC822Header", "rfc822.header"],
|
|
["RFC822.SIZE", "RFC822Size", "rfc822.size"],
|
|
["RFC822.TEXT", "RFC822Text", "rfc822.text"],
|
|
["RFC822", "RFC822", "rfc822"],
|
|
["UID", "UID", "uid"],
|
|
["BODYSTRUCTURE", "BodyStructure", "bodystructure"],
|
|
]
|
|
|
|
for inp, outp, asString in cases:
|
|
inp = inp.encode("ascii")
|
|
p = imap4._FetchParser()
|
|
p.parseString(inp)
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], getattr(p, outp)))
|
|
self.assertEqual(str(p.result[0]), asString)
|
|
|
|
def test_fetchParserMacros(self):
|
|
cases = [
|
|
[b"ALL", (4, [b"flags", b"internaldate", b"rfc822.size", b"envelope"])],
|
|
[
|
|
b"FULL",
|
|
(5, [b"flags", b"internaldate", b"rfc822.size", b"envelope", b"body"]),
|
|
],
|
|
[b"FAST", (3, [b"flags", b"internaldate", b"rfc822.size"])],
|
|
]
|
|
|
|
for inp, outp in cases:
|
|
p = imap4._FetchParser()
|
|
p.parseString(inp)
|
|
self.assertEqual(len(p.result), outp[0])
|
|
expectedResult = [str(token).lower().encode("ascii") for token in p.result]
|
|
expectedResult.sort()
|
|
outp[1].sort()
|
|
self.assertEqual(expectedResult, outp[1])
|
|
|
|
def test_fetchParserBody(self):
|
|
P = imap4._FetchParser
|
|
|
|
p = P()
|
|
p.parseString(b"BODY")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, False)
|
|
self.assertEqual(p.result[0].header, None)
|
|
self.assertEqual(str(p.result[0]), "BODY")
|
|
|
|
p = P()
|
|
p.parseString(b"BODY.PEEK")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, True)
|
|
self.assertEqual(str(p.result[0]), "BODY")
|
|
|
|
p = P()
|
|
p.parseString(b"BODY[]")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].empty, True)
|
|
self.assertEqual(str(p.result[0]), "BODY[]")
|
|
|
|
p = P()
|
|
p.parseString(b"BODY[HEADER]")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, False)
|
|
self.assertTrue(isinstance(p.result[0].header, p.Header))
|
|
self.assertEqual(p.result[0].header.negate, True)
|
|
self.assertEqual(p.result[0].header.fields, ())
|
|
self.assertEqual(p.result[0].empty, False)
|
|
self.assertEqual(str(p.result[0]), "BODY[HEADER]")
|
|
|
|
p = P()
|
|
p.parseString(b"BODY.PEEK[HEADER]")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, True)
|
|
self.assertTrue(isinstance(p.result[0].header, p.Header))
|
|
self.assertEqual(p.result[0].header.negate, True)
|
|
self.assertEqual(p.result[0].header.fields, ())
|
|
self.assertEqual(p.result[0].empty, False)
|
|
self.assertEqual(str(p.result[0]), "BODY[HEADER]")
|
|
|
|
p = P()
|
|
p.parseString(b"BODY[HEADER.FIELDS (Subject Cc Message-Id)]")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, False)
|
|
self.assertTrue(isinstance(p.result[0].header, p.Header))
|
|
self.assertEqual(p.result[0].header.negate, False)
|
|
self.assertEqual(p.result[0].header.fields, [b"SUBJECT", b"CC", b"MESSAGE-ID"])
|
|
self.assertEqual(p.result[0].empty, False)
|
|
self.assertEqual(
|
|
bytes(p.result[0]), b"BODY[HEADER.FIELDS (Subject Cc Message-Id)]"
|
|
)
|
|
|
|
p = P()
|
|
p.parseString(b"BODY.PEEK[HEADER.FIELDS (Subject Cc Message-Id)]")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, True)
|
|
self.assertTrue(isinstance(p.result[0].header, p.Header))
|
|
self.assertEqual(p.result[0].header.negate, False)
|
|
self.assertEqual(p.result[0].header.fields, [b"SUBJECT", b"CC", b"MESSAGE-ID"])
|
|
self.assertEqual(p.result[0].empty, False)
|
|
self.assertEqual(
|
|
bytes(p.result[0]), b"BODY[HEADER.FIELDS (Subject Cc Message-Id)]"
|
|
)
|
|
|
|
p = P()
|
|
p.parseString(b"BODY.PEEK[HEADER.FIELDS.NOT (Subject Cc Message-Id)]")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, True)
|
|
self.assertTrue(isinstance(p.result[0].header, p.Header))
|
|
self.assertEqual(p.result[0].header.negate, True)
|
|
self.assertEqual(p.result[0].header.fields, [b"SUBJECT", b"CC", b"MESSAGE-ID"])
|
|
self.assertEqual(p.result[0].empty, False)
|
|
self.assertEqual(
|
|
bytes(p.result[0]), b"BODY[HEADER.FIELDS.NOT (Subject Cc Message-Id)]"
|
|
)
|
|
|
|
p = P()
|
|
p.parseString(b"BODY[1.MIME]<10.50>")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, False)
|
|
self.assertTrue(isinstance(p.result[0].mime, p.MIME))
|
|
self.assertEqual(p.result[0].part, (0,))
|
|
self.assertEqual(p.result[0].partialBegin, 10)
|
|
self.assertEqual(p.result[0].partialLength, 50)
|
|
self.assertEqual(p.result[0].empty, False)
|
|
self.assertEqual(bytes(p.result[0]), b"BODY[1.MIME]<10.50>")
|
|
|
|
p = P()
|
|
p.parseString(
|
|
b"BODY.PEEK[1.3.9.11.HEADER.FIELDS.NOT (Message-Id Date)]<103.69>"
|
|
)
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertTrue(isinstance(p.result[0], p.Body))
|
|
self.assertEqual(p.result[0].peek, True)
|
|
self.assertTrue(isinstance(p.result[0].header, p.Header))
|
|
self.assertEqual(p.result[0].part, (0, 2, 8, 10))
|
|
self.assertEqual(p.result[0].header.fields, [b"MESSAGE-ID", b"DATE"])
|
|
self.assertEqual(p.result[0].partialBegin, 103)
|
|
self.assertEqual(p.result[0].partialLength, 69)
|
|
self.assertEqual(p.result[0].empty, False)
|
|
self.assertEqual(
|
|
bytes(p.result[0]),
|
|
b"BODY[1.3.9.11.HEADER.FIELDS.NOT (Message-Id Date)]<103.69>",
|
|
)
|
|
|
|
def test_fetchParserQuotedHeader(self):
|
|
"""
|
|
Parsing a C{BODY} whose C{HEADER} values require quoting
|
|
results in a object that perserves that quoting when
|
|
serialized.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
p.parseString(b"BODY[HEADER.FIELDS ((Quoted)]")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertEqual(p.result[0].peek, False)
|
|
self.assertIsInstance(p.result[0], p.Body)
|
|
self.assertIsInstance(p.result[0].header, p.Header)
|
|
self.assertEqual(bytes(p.result[0]), b'BODY[HEADER.FIELDS ("(Quoted")]')
|
|
|
|
def test_fetchParserEmptyString(self):
|
|
"""
|
|
Parsing an empty string results in no data.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
p.parseString(b"")
|
|
self.assertFalse(len(p.result))
|
|
|
|
def test_fetchParserUnknownAttribute(self):
|
|
"""
|
|
Parsing a string with an unknown attribute raises an
|
|
L{Exception}.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"UNKNOWN")
|
|
|
|
def test_fetchParserIncompleteStringEndsInWhitespace(self):
|
|
"""
|
|
Parsing a string that prematurely ends in whitespace raises an
|
|
L{Exception}.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY[HEADER.FIELDS ")
|
|
|
|
def test_fetchParserExpectedWhitespace(self):
|
|
"""
|
|
Parsing a string that contains an unexpected character rather
|
|
than whitespace raises an L{Exception}.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY[HEADER.FIELDS!]")
|
|
|
|
def test_fetchParserTextSection(self):
|
|
"""
|
|
A C{BODY} can contain a C{TEXT} section.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
p.parseString(b"BODY[TEXT]")
|
|
self.assertEqual(len(p.result), 1)
|
|
self.assertIsInstance(p.result[0], p.Body)
|
|
self.assertEqual(p.result[0].peek, False)
|
|
self.assertIsInstance(p.result[0].text, p.Text)
|
|
self.assertEqual(bytes(p.result[0]), b"BODY[TEXT]")
|
|
|
|
def test_fetchParserUnknownSection(self):
|
|
"""
|
|
Parsing a C{BODY} with an unknown section raises an
|
|
L{Exception}.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY[UNKNOWN]")
|
|
|
|
def test_fetchParserMissingSectionClose(self):
|
|
"""
|
|
Parsing a C{BODY} with an unterminated section list raises an
|
|
L{Exception}.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY[HEADER")
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY[HEADER.FIELDS (SUBJECT)")
|
|
|
|
def test_fetchParserHeaderMissingParentheses(self):
|
|
"""
|
|
Parsing a C{BODY} whose C{HEADER.FIELDS} list does not begin
|
|
with an open parenthesis (C{(}) or end with a close
|
|
parenthesis (C{)}) raises an L{Exception}.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY[HEADER.FIELDS Missing)]")
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY[HEADER.FIELDS (Missing]")
|
|
|
|
def test_fetchParserDotlessPartial(self):
|
|
"""
|
|
Parsing a C{BODY} with a range that lacks a period (C{.})
|
|
raises an L{Exception}.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY<01>")
|
|
|
|
def test_fetchParserUnclosedPartial(self):
|
|
"""
|
|
Parsing a C{BODY} with a partial range that's missing its
|
|
closing greater than sign (C{>}) raises an L{EXCEPTION}.
|
|
"""
|
|
p = imap4._FetchParser()
|
|
self.assertRaises(Exception, p.parseString, b"BODY<0")
|
|
|
|
def test_files(self):
|
|
inputStructure = [
|
|
"foo",
|
|
"bar",
|
|
"baz",
|
|
BytesIO(b"this is a file\r\n"),
|
|
"buz",
|
|
"biz",
|
|
]
|
|
|
|
output = b'"foo" "bar" "baz" {16}\r\nthis is a file\r\n "buz" "biz"'
|
|
|
|
self.assertEqual(imap4.collapseNestedLists(inputStructure), output)
|
|
|
|
def test_quoteAvoider(self):
|
|
input = [
|
|
b"foo",
|
|
imap4.DontQuoteMe(b"bar"),
|
|
b"baz",
|
|
BytesIO(b"this is a file\r\n"),
|
|
b"this is\r\nquoted",
|
|
imap4.DontQuoteMe(b"buz"),
|
|
b"",
|
|
]
|
|
|
|
output = (
|
|
b'"foo" bar "baz"'
|
|
b" {16}\r\nthis is a file\r\n "
|
|
b"{15}\r\nthis is\r\nquoted"
|
|
b' buz ""'
|
|
)
|
|
|
|
self.assertEqual(imap4.collapseNestedLists(input), output)
|
|
|
|
def test_literals(self):
|
|
cases = [
|
|
(b"({10}\r\n0123456789)", [[b"0123456789"]]),
|
|
]
|
|
|
|
for case, expected in cases:
|
|
self.assertEqual(imap4.parseNestedParens(case), expected)
|
|
|
|
def test_queryBuilder(self):
|
|
inputs = [
|
|
imap4.Query(flagged=1),
|
|
imap4.Query(sorted=1, unflagged=1, deleted=1),
|
|
imap4.Or(imap4.Query(flagged=1), imap4.Query(deleted=1)),
|
|
imap4.Query(before="today"),
|
|
imap4.Or(imap4.Query(deleted=1), imap4.Query(unseen=1), imap4.Query(new=1)),
|
|
imap4.Or(
|
|
imap4.Not(
|
|
imap4.Or(
|
|
imap4.Query(sorted=1, since="yesterday", smaller=1000),
|
|
imap4.Query(sorted=1, before="tuesday", larger=10000),
|
|
imap4.Query(sorted=1, unseen=1, deleted=1, before="today"),
|
|
imap4.Not(imap4.Query(subject="spam")),
|
|
),
|
|
),
|
|
imap4.Not(imap4.Query(uid="1:5")),
|
|
),
|
|
]
|
|
|
|
outputs = [
|
|
"FLAGGED",
|
|
"(DELETED UNFLAGGED)",
|
|
"(OR FLAGGED DELETED)",
|
|
'(BEFORE "today")',
|
|
"(OR DELETED (OR UNSEEN NEW))",
|
|
'(OR (NOT (OR (SINCE "yesterday" SMALLER 1000) ' # Continuing
|
|
'(OR (BEFORE "tuesday" LARGER 10000) (OR (BEFORE ' # Some more
|
|
'"today" DELETED UNSEEN) (NOT (SUBJECT "spam")))))) ' # And more
|
|
"(NOT (UID 1:5)))",
|
|
]
|
|
|
|
for query, expected in zip(inputs, outputs):
|
|
self.assertEqual(query, expected)
|
|
|
|
def test_queryKeywordFlagWithQuotes(self):
|
|
"""
|
|
When passed the C{keyword} argument, L{imap4.Query} returns an unquoted
|
|
string.
|
|
|
|
@see: U{http://tools.ietf.org/html/rfc3501#section-9}
|
|
@see: U{http://tools.ietf.org/html/rfc3501#section-6.4.4}
|
|
"""
|
|
query = imap4.Query(keyword="twisted")
|
|
self.assertEqual("(KEYWORD twisted)", query)
|
|
|
|
def test_queryUnkeywordFlagWithQuotes(self):
|
|
"""
|
|
When passed the C{unkeyword} argument, L{imap4.Query} returns an
|
|
unquoted string.
|
|
|
|
@see: U{http://tools.ietf.org/html/rfc3501#section-9}
|
|
@see: U{http://tools.ietf.org/html/rfc3501#section-6.4.4}
|
|
"""
|
|
query = imap4.Query(unkeyword="twisted")
|
|
self.assertEqual("(UNKEYWORD twisted)", query)
|
|
|
|
def test_queryWithMesssageSet(self):
|
|
"""
|
|
When passed a L{MessageSet}, L{imap4.Query} returns a query
|
|
containing a quoted string representing the ID sequence.
|
|
"""
|
|
query = imap4.Query(messages=imap4.MessageSet(1, None))
|
|
self.assertEqual(query, '(MESSAGES "1:*")')
|
|
|
|
def test_queryWithInteger(self):
|
|
"""
|
|
When passed an L{int}, L{imap4.Query} returns a query
|
|
containing a quoted integer.
|
|
"""
|
|
query = imap4.Query(messages=1)
|
|
self.assertEqual(query, '(MESSAGES "1")')
|
|
|
|
def test_queryOrIllegalQuery(self):
|
|
"""
|
|
An L{imap4.Or} query with less than two arguments raises an
|
|
L{imap4.IllegalQueryError}.
|
|
"""
|
|
self.assertRaises(imap4.IllegalQueryError, imap4.Or, imap4.Query(messages=1))
|
|
|
|
def _keywordFilteringTest(self, keyword):
|
|
"""
|
|
Helper to implement tests for value filtering of KEYWORD and UNKEYWORD
|
|
queries.
|
|
|
|
@param keyword: A native string giving the name of the L{imap4.Query}
|
|
keyword argument to test.
|
|
"""
|
|
# Check all the printable exclusions
|
|
self.assertEqual(
|
|
f"({keyword.upper()} twistedrocks)",
|
|
imap4.Query(**{keyword: r'twisted (){%*"\] rocks'}),
|
|
)
|
|
|
|
# Check all the non-printable exclusions
|
|
self.assertEqual(
|
|
f"({keyword.upper()} twistedrocks)",
|
|
imap4.Query(
|
|
**{
|
|
keyword: "twisted %s rocks"
|
|
% ("".join(chr(ch) for ch in range(33)),)
|
|
}
|
|
),
|
|
)
|
|
|
|
def test_queryKeywordFlag(self):
|
|
r"""
|
|
When passed the C{keyword} argument, L{imap4.Query} returns an
|
|
C{atom} that consists of one or more non-special characters.
|
|
|
|
List of the invalid characters:
|
|
|
|
( ) { % * " \ ] CTL SP
|
|
|
|
@see: U{ABNF definition of CTL and SP<https://tools.ietf.org/html/rfc2234>}
|
|
@see: U{IMAP4 grammar<http://tools.ietf.org/html/rfc3501#section-9>}
|
|
@see: U{IMAP4 SEARCH specification<http://tools.ietf.org/html/rfc3501#section-6.4.4>}
|
|
"""
|
|
self._keywordFilteringTest("keyword")
|
|
|
|
def test_queryUnkeywordFlag(self):
|
|
r"""
|
|
When passed the C{unkeyword} argument, L{imap4.Query} returns an
|
|
C{atom} that consists of one or more non-special characters.
|
|
|
|
List of the invalid characters:
|
|
|
|
( ) { % * " \ ] CTL SP
|
|
|
|
@see: U{ABNF definition of CTL and SP<https://tools.ietf.org/html/rfc2234>}
|
|
@see: U{IMAP4 grammar<http://tools.ietf.org/html/rfc3501#section-9>}
|
|
@see: U{IMAP4 SEARCH specification<http://tools.ietf.org/html/rfc3501#section-6.4.4>}
|
|
"""
|
|
self._keywordFilteringTest("unkeyword")
|
|
|
|
def test_invalidIdListParser(self):
|
|
"""
|
|
Trying to parse an invalid representation of a sequence range raises an
|
|
L{IllegalIdentifierError}.
|
|
"""
|
|
inputs = [b"*:*", b"foo", b"4:", b"bar:5"]
|
|
|
|
for input in inputs:
|
|
self.assertRaises(
|
|
imap4.IllegalIdentifierError, imap4.parseIdList, input, 12345
|
|
)
|
|
|
|
def test_invalidIdListParserNonPositive(self):
|
|
"""
|
|
Zeroes and negative values are not accepted in id range expressions. RFC
|
|
3501 states that sequence numbers and sequence ranges consist of
|
|
non-negative numbers (RFC 3501 section 9, the seq-number grammar item).
|
|
"""
|
|
inputs = [b"0:5", b"0:0", b"*:0", b"0", b"-3:5", b"1:-2", b"-1"]
|
|
|
|
for input in inputs:
|
|
self.assertRaises(
|
|
imap4.IllegalIdentifierError, imap4.parseIdList, input, 12345
|
|
)
|
|
|
|
def test_parseIdList(self):
|
|
"""
|
|
The function to parse sequence ranges yields appropriate L{MessageSet}
|
|
objects.
|
|
"""
|
|
inputs = [
|
|
b"1:*",
|
|
b"5:*",
|
|
b"1:2,5:*",
|
|
b"*",
|
|
b"1",
|
|
b"1,2",
|
|
b"1,3,5",
|
|
b"1:10",
|
|
b"1:10,11",
|
|
b"1:5,10:20",
|
|
b"1,5:10",
|
|
b"1,5:10,15:20",
|
|
b"1:10,15,20:25",
|
|
b"4:2",
|
|
]
|
|
|
|
outputs = [
|
|
MessageSet(1, None),
|
|
MessageSet(5, None),
|
|
MessageSet(5, None) + MessageSet(1, 2),
|
|
MessageSet(None, None),
|
|
MessageSet(1),
|
|
MessageSet(1, 2),
|
|
MessageSet(1) + MessageSet(3) + MessageSet(5),
|
|
MessageSet(1, 10),
|
|
MessageSet(1, 11),
|
|
MessageSet(1, 5) + MessageSet(10, 20),
|
|
MessageSet(1) + MessageSet(5, 10),
|
|
MessageSet(1) + MessageSet(5, 10) + MessageSet(15, 20),
|
|
MessageSet(1, 10) + MessageSet(15) + MessageSet(20, 25),
|
|
MessageSet(2, 4),
|
|
]
|
|
|
|
lengths = [None, None, None, 1, 1, 2, 3, 10, 11, 16, 7, 13, 17, 3]
|
|
|
|
for input, expected in zip(inputs, outputs):
|
|
self.assertEqual(imap4.parseIdList(input), expected)
|
|
|
|
for input, expected in zip(inputs, lengths):
|
|
if expected is None:
|
|
self.assertRaises(TypeError, len, imap4.parseIdList(input))
|
|
else:
|
|
L = len(imap4.parseIdList(input))
|
|
self.assertEqual(L, expected, f"len({input!r}) = {L!r} != {expected!r}")
|
|
|
|
def test_parseTimeInvalidFormat(self):
|
|
"""
|
|
L{imap4.parseTime} raises L{ValueError} when given a a time
|
|
string whose format is invalid.
|
|
"""
|
|
self.assertRaises(ValueError, imap4.parseTime, "invalid")
|
|
|
|
def test_parseTimeInvalidValues(self):
|
|
"""
|
|
L{imap4.parseTime} raises L{ValueError} when given a time
|
|
string composed of invalid values.
|
|
"""
|
|
invalidStrings = [
|
|
"invalid-July-2017",
|
|
"2-invalid-2017",
|
|
"2-July-invalid",
|
|
]
|
|
for invalid in invalidStrings:
|
|
self.assertRaises(ValueError, imap4.parseTime, invalid)
|
|
|
|
def test_statusRequestHelper(self):
|
|
"""
|
|
L{imap4.statusRequestHelper} builds a L{dict} mapping the
|
|
requested status names to values extracted from the provided
|
|
L{IMailboxIMAP}'s.
|
|
"""
|
|
mbox = SimpleMailbox()
|
|
|
|
expected = {
|
|
"MESSAGES": mbox.getMessageCount(),
|
|
"RECENT": mbox.getRecentCount(),
|
|
"UIDNEXT": mbox.getUIDNext(),
|
|
"UIDVALIDITY": mbox.getUIDValidity(),
|
|
"UNSEEN": mbox.getUnseenCount(),
|
|
}
|
|
|
|
result = imap4.statusRequestHelper(mbox, expected.keys())
|
|
|
|
self.assertEqual(expected, result)
|
|
|
|
|
|
@implementer(imap4.IMailboxInfo, imap4.IMailbox, imap4.ICloseableMailbox)
|
|
class SimpleMailbox:
|
|
flags = ("\\Flag1", "Flag2", "\\AnotherSysFlag", "LastFlag")
|
|
messages: list[tuple[bytes, list[bytes], bytes, int]] = []
|
|
mUID = 0
|
|
rw = 1
|
|
closed = False
|
|
|
|
def __init__(self):
|
|
self.listeners = []
|
|
self.addListener = self.listeners.append
|
|
self.removeListener = self.listeners.remove
|
|
|
|
def getFlags(self):
|
|
return self.flags
|
|
|
|
def getUIDValidity(self):
|
|
return 42
|
|
|
|
def getUIDNext(self):
|
|
return len(self.messages) + 1
|
|
|
|
def getMessageCount(self):
|
|
return 9
|
|
|
|
def getRecentCount(self):
|
|
return 3
|
|
|
|
def getUnseenCount(self):
|
|
return 4
|
|
|
|
def isWriteable(self):
|
|
return self.rw
|
|
|
|
def destroy(self):
|
|
pass
|
|
|
|
def getHierarchicalDelimiter(self):
|
|
return "/"
|
|
|
|
def requestStatus(self, names):
|
|
r = {}
|
|
if "MESSAGES" in names:
|
|
r["MESSAGES"] = self.getMessageCount()
|
|
if "RECENT" in names:
|
|
r["RECENT"] = self.getRecentCount()
|
|
if "UIDNEXT" in names:
|
|
r["UIDNEXT"] = self.getMessageCount() + 1
|
|
if "UIDVALIDITY" in names:
|
|
r["UIDVALIDITY"] = self.getUID()
|
|
if "UNSEEN" in names:
|
|
r["UNSEEN"] = self.getUnseenCount()
|
|
return defer.succeed(r)
|
|
|
|
def addMessage(self, message, flags, date=None):
|
|
self.messages.append((message, flags, date, self.mUID))
|
|
self.mUID += 1
|
|
return defer.succeed(None)
|
|
|
|
def expunge(self):
|
|
delete = []
|
|
for i in self.messages:
|
|
if "\\Deleted" in i[1]:
|
|
delete.append(i)
|
|
for i in delete:
|
|
self.messages.remove(i)
|
|
return [i[3] for i in delete]
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
def fetch(self, messages, uid):
|
|
# IMailboxIMAP.fetch
|
|
pass
|
|
|
|
def getUID(self, message):
|
|
# IMailboxIMAP.getUID
|
|
pass
|
|
|
|
def store(self, messages, flags, mode, uid):
|
|
# IMailboxIMAP.store
|
|
pass
|
|
|
|
|
|
@implementer(imap4.IMailboxInfo, imap4.IMailbox)
|
|
class UncloseableMailbox:
|
|
"""
|
|
A mailbox that cannot be closed.
|
|
"""
|
|
|
|
flags = ("\\Flag1", "Flag2", "\\AnotherSysFlag", "LastFlag")
|
|
messages: list[tuple[bytes, list[bytes], bytes, int]] = []
|
|
mUID = 0
|
|
rw = 1
|
|
closed = False
|
|
|
|
def __init__(self):
|
|
self.listeners = []
|
|
self.addListener = self.listeners.append
|
|
self.removeListener = self.listeners.remove
|
|
|
|
def getFlags(self):
|
|
"""
|
|
The flags
|
|
|
|
@return: A sequence of flags.
|
|
"""
|
|
return self.flags
|
|
|
|
def getUIDValidity(self):
|
|
"""
|
|
The UID validity value.
|
|
|
|
@return: The value.
|
|
"""
|
|
return 42
|
|
|
|
def getUIDNext(self):
|
|
"""
|
|
The next UID.
|
|
|
|
@return: The UID.
|
|
"""
|
|
return len(self.messages) + 1
|
|
|
|
def getMessageCount(self):
|
|
"""
|
|
The number of messages.
|
|
|
|
@return: The number.
|
|
"""
|
|
return 9
|
|
|
|
def getRecentCount(self):
|
|
"""
|
|
The recent messages.
|
|
|
|
@return: The number.
|
|
"""
|
|
return 3
|
|
|
|
def getUnseenCount(self):
|
|
"""
|
|
The recent messages.
|
|
|
|
@return: The number.
|
|
"""
|
|
return 4
|
|
|
|
def isWriteable(self):
|
|
"""
|
|
The recent messages.
|
|
|
|
@return: Whether or not the mailbox is writable.
|
|
"""
|
|
return self.rw
|
|
|
|
def destroy(self):
|
|
"""
|
|
Destroy this mailbox.
|
|
"""
|
|
pass
|
|
|
|
def getHierarchicalDelimiter(self):
|
|
"""
|
|
Return the hierarchical delimiter.
|
|
|
|
@return: The delimiter.
|
|
"""
|
|
return "/"
|
|
|
|
def requestStatus(self, names):
|
|
"""
|
|
Return the mailbox's status.
|
|
|
|
@param names: The status items to include.
|
|
|
|
@return: A L{dict} of status data.
|
|
"""
|
|
r = {}
|
|
if "MESSAGES" in names:
|
|
r["MESSAGES"] = self.getMessageCount()
|
|
if "RECENT" in names:
|
|
r["RECENT"] = self.getRecentCount()
|
|
if "UIDNEXT" in names:
|
|
r["UIDNEXT"] = self.getMessageCount() + 1
|
|
if "UIDVALIDITY" in names:
|
|
r["UIDVALIDITY"] = self.getUID()
|
|
if "UNSEEN" in names:
|
|
r["UNSEEN"] = self.getUnseenCount()
|
|
return defer.succeed(r)
|
|
|
|
def addMessage(self, message, flags, date=None):
|
|
"""
|
|
Add a message to the mailbox.
|
|
|
|
@param message: The message body.
|
|
|
|
@param flags: The message flags.
|
|
|
|
@param date: The message date.
|
|
|
|
@return: A L{Deferred} that fires when the message has been
|
|
added.
|
|
"""
|
|
self.messages.append((message, flags, date, self.mUID))
|
|
self.mUID += 1
|
|
return defer.succeed(None)
|
|
|
|
def expunge(self):
|
|
"""
|
|
Delete messages marked for deletion.
|
|
|
|
@return: A L{list} of deleted message IDs.
|
|
"""
|
|
delete = []
|
|
for i in self.messages:
|
|
if "\\Deleted" in i[1]:
|
|
delete.append(i)
|
|
for i in delete:
|
|
self.messages.remove(i)
|
|
return [i[3] for i in delete]
|
|
|
|
def fetch(self, messages, uid):
|
|
# IMailboxIMAP.fetch
|
|
pass
|
|
|
|
def getUID(self, message):
|
|
# IMailboxIMAP.getUID
|
|
pass
|
|
|
|
def store(self, messages, flags, mode, uid):
|
|
# IMailboxIMAP.store
|
|
pass
|
|
|
|
|
|
class AccountWithoutNamespaces(imap4.MemoryAccountWithoutNamespaces):
|
|
"""
|
|
An in-memory account that does not provide L{INamespacePresenter}.
|
|
"""
|
|
|
|
mailboxFactory = SimpleMailbox
|
|
|
|
def _emptyMailbox(self, name, id):
|
|
return self.mailboxFactory()
|
|
|
|
def select(self, name, rw=1):
|
|
mbox = imap4.MemoryAccount.select(self, name)
|
|
if mbox is not None:
|
|
mbox.rw = rw
|
|
return mbox
|
|
|
|
|
|
class Account(AccountWithoutNamespaces, imap4.MemoryAccount):
|
|
"""
|
|
An in-memory account that provides L{INamespacePresenter}.
|
|
"""
|
|
|
|
|
|
class SimpleServer(imap4.IMAP4Server):
|
|
theAccount = Account(b"testuser")
|
|
|
|
def __init__(self, *args, **kw):
|
|
imap4.IMAP4Server.__init__(self, *args, **kw)
|
|
realm = TestRealm(accountHolder=self)
|
|
portal = Portal(realm)
|
|
c = InMemoryUsernamePasswordDatabaseDontUse()
|
|
c.addUser(b"testuser", b"password-test")
|
|
self.checker = c
|
|
self.portal = portal
|
|
portal.registerChecker(c)
|
|
self.timeoutTest = False
|
|
|
|
def lineReceived(self, line):
|
|
if self.timeoutTest:
|
|
# Do not send a response
|
|
return
|
|
|
|
imap4.IMAP4Server.lineReceived(self, line)
|
|
|
|
|
|
class SimpleClient(imap4.IMAP4Client):
|
|
def __init__(self, deferred, contextFactory=None):
|
|
imap4.IMAP4Client.__init__(self, contextFactory)
|
|
self.deferred = deferred
|
|
self.events = []
|
|
|
|
def serverGreeting(self, caps):
|
|
self.deferred.callback(None)
|
|
|
|
def modeChanged(self, writeable):
|
|
self.events.append(["modeChanged", writeable])
|
|
self.transport.loseConnection()
|
|
|
|
def flagsChanged(self, newFlags):
|
|
self.events.append(["flagsChanged", newFlags])
|
|
self.transport.loseConnection()
|
|
|
|
def newMessages(self, exists, recent):
|
|
self.events.append(["newMessages", exists, recent])
|
|
self.transport.loseConnection()
|
|
|
|
|
|
class IMAP4HelperMixin:
|
|
serverCTX: Optional[ServerTLSContext] = None
|
|
clientCTX: Optional[ClientTLSContext] = None
|
|
|
|
def setUp(self):
|
|
d = defer.Deferred()
|
|
self.server = SimpleServer(contextFactory=self.serverCTX)
|
|
self.client = SimpleClient(d, contextFactory=self.clientCTX)
|
|
self.connected = d
|
|
|
|
SimpleMailbox.messages = []
|
|
theAccount = Account(b"testuser")
|
|
theAccount.mboxType = SimpleMailbox
|
|
SimpleServer.theAccount = theAccount
|
|
|
|
def tearDown(self):
|
|
del self.server
|
|
del self.client
|
|
del self.connected
|
|
|
|
def _cbStopClient(self, ignore):
|
|
self.client.transport.loseConnection()
|
|
|
|
def _ebGeneral(self, failure):
|
|
self.client.transport.loseConnection()
|
|
self.server.transport.loseConnection()
|
|
log.err(failure, "Problem with " + str(self))
|
|
|
|
def loopback(self):
|
|
return loopback.loopbackAsync(self.server, self.client)
|
|
|
|
def assertClientFailureMessage(self, failure, expected):
|
|
"""
|
|
Assert that the provided failure is an L{IMAP4Exception} with
|
|
the given message.
|
|
|
|
@param failure: A failure whose value L{IMAP4Exception}
|
|
@type failure: L{failure.Failure}
|
|
|
|
@param expected: The expected failure message.
|
|
@type expected: L{bytes}
|
|
"""
|
|
failure.trap(imap4.IMAP4Exception)
|
|
message = str(failure.value)
|
|
expected = repr(expected)
|
|
|
|
self.assertEqual(message, expected)
|
|
|
|
|
|
class IMAP4ServerTests(IMAP4HelperMixin, TestCase):
|
|
def testCapability(self):
|
|
caps = {}
|
|
|
|
def getCaps():
|
|
def gotCaps(c):
|
|
caps.update(c)
|
|
self.server.transport.loseConnection()
|
|
|
|
return self.client.getCapabilities().addCallback(gotCaps)
|
|
|
|
d1 = self.connected.addCallback(strip(getCaps)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
expected = {b"IMAP4rev1": None, b"NAMESPACE": None, b"IDLE": None}
|
|
return d.addCallback(lambda _: self.assertEqual(expected, caps))
|
|
|
|
def testCapabilityWithAuth(self):
|
|
caps = {}
|
|
self.server.challengers[b"CRAM-MD5"] = CramMD5Credentials
|
|
|
|
def getCaps():
|
|
def gotCaps(c):
|
|
caps.update(c)
|
|
self.server.transport.loseConnection()
|
|
|
|
return self.client.getCapabilities().addCallback(gotCaps)
|
|
|
|
d1 = self.connected.addCallback(strip(getCaps)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
|
|
expCap = {
|
|
b"IMAP4rev1": None,
|
|
b"NAMESPACE": None,
|
|
b"IDLE": None,
|
|
b"AUTH": [b"CRAM-MD5"],
|
|
}
|
|
|
|
return d.addCallback(lambda _: self.assertEqual(expCap, caps))
|
|
|
|
def testLogout(self):
|
|
self.loggedOut = 0
|
|
|
|
def logout():
|
|
def setLoggedOut():
|
|
self.loggedOut = 1
|
|
|
|
self.client.logout().addCallback(strip(setLoggedOut))
|
|
|
|
self.connected.addCallback(strip(logout)).addErrback(self._ebGeneral)
|
|
d = self.loopback()
|
|
return d.addCallback(lambda _: self.assertEqual(self.loggedOut, 1))
|
|
|
|
def testNoop(self):
|
|
self.responses = None
|
|
|
|
def noop():
|
|
def setResponses(responses):
|
|
self.responses = responses
|
|
self.server.transport.loseConnection()
|
|
|
|
self.client.noop().addCallback(setResponses)
|
|
|
|
self.connected.addCallback(strip(noop)).addErrback(self._ebGeneral)
|
|
d = self.loopback()
|
|
return d.addCallback(lambda _: self.assertEqual(self.responses, []))
|
|
|
|
def testLogin(self):
|
|
def login():
|
|
d = self.client.login(b"testuser", b"password-test")
|
|
d.addCallback(self._cbStopClient)
|
|
|
|
d1 = self.connected.addCallback(strip(login)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([d1, self.loopback()])
|
|
return d.addCallback(self._cbTestLogin)
|
|
|
|
def _cbTestLogin(self, ignored):
|
|
self.assertEqual(self.server.account, SimpleServer.theAccount)
|
|
self.assertEqual(self.server.state, "auth")
|
|
|
|
def testFailedLogin(self):
|
|
def login():
|
|
d = self.client.login(b"testuser", b"wrong-password")
|
|
d.addBoth(self._cbStopClient)
|
|
|
|
d1 = self.connected.addCallback(strip(login)).addErrback(self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestFailedLogin)
|
|
|
|
def _cbTestFailedLogin(self, ignored):
|
|
self.assertEqual(self.server.account, None)
|
|
self.assertEqual(self.server.state, "unauth")
|
|
|
|
def test_loginWithoutPortal(self):
|
|
"""
|
|
Attempting to log into a server that has no L{Portal} results
|
|
in a failed login.
|
|
"""
|
|
self.server.portal = None
|
|
|
|
def login():
|
|
d = self.client.login(b"testuser", b"wrong-password")
|
|
d.addBoth(self._cbStopClient)
|
|
|
|
d1 = self.connected.addCallback(strip(login)).addErrback(self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestFailedLogin)
|
|
|
|
def test_nonIAccountAvatar(self):
|
|
"""
|
|
The server responds with a C{BAD} response when its portal
|
|
attempts to log a user in with checker that claims to support
|
|
L{IAccount} but returns an an avatar interface that is not
|
|
L{IAccount}.
|
|
"""
|
|
|
|
def brokenRequestAvatar(*_, **__):
|
|
return ("Not IAccount", "Not an account", lambda: None)
|
|
|
|
self.server.portal.realm.requestAvatar = brokenRequestAvatar
|
|
|
|
def login():
|
|
d = self.client.login(b"testuser", b"password-test")
|
|
d.addBoth(self._cbStopClient)
|
|
|
|
d1 = self.connected.addCallback(strip(login)).addErrback(self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestFailedLogin)
|
|
|
|
def test_loginException(self):
|
|
"""
|
|
Any exception raised by L{IMAP4Server.authenticateLogin} that
|
|
is not L{UnauthorizedLogin} is logged results in a C{BAD}
|
|
response.
|
|
"""
|
|
|
|
class UnexpectedException(Exception):
|
|
"""
|
|
An unexpected exception.
|
|
"""
|
|
|
|
def raisesUnexpectedException(user, passwd):
|
|
raise UnexpectedException("Whoops")
|
|
|
|
self.server.authenticateLogin = raisesUnexpectedException
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
|
|
d1.addErrback(self.assertClientFailureMessage, b"Server error: Whoops")
|
|
|
|
@d1.addCallback
|
|
def assertErrorLogged(_):
|
|
self.assertTrue(self.flushLoggedErrors(UnexpectedException))
|
|
|
|
d1.addErrback(self._ebGeneral)
|
|
d1.addBoth(self._cbStopClient)
|
|
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestFailedLogin)
|
|
|
|
def testLoginRequiringQuoting(self):
|
|
self.server.checker.users = {b"{test}user": b"{test}password"}
|
|
|
|
def login():
|
|
d = self.client.login(b"{test}user", b"{test}password")
|
|
d.addErrback(log.err, "Problem with " + str(self))
|
|
d.addCallback(self._cbStopClient)
|
|
|
|
d1 = self.connected.addCallback(strip(login)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestLoginRequiringQuoting)
|
|
|
|
def _cbTestLoginRequiringQuoting(self, ignored):
|
|
self.assertEqual(self.server.account, SimpleServer.theAccount)
|
|
self.assertEqual(self.server.state, "auth")
|
|
|
|
def testNamespace(self):
|
|
self.namespaceArgs = None
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def namespace():
|
|
def gotNamespace(args):
|
|
self.namespaceArgs = args
|
|
self._cbStopClient(None)
|
|
|
|
return self.client.namespace().addCallback(gotNamespace)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(namespace))
|
|
d1.addErrback(self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
|
|
@d.addCallback
|
|
def assertAllPairsNativeStrings(ignored):
|
|
for namespaces in self.namespaceArgs:
|
|
for pair in namespaces:
|
|
for value in pair:
|
|
self.assertIsInstance(value, str)
|
|
return self.namespaceArgs
|
|
|
|
d.addCallback(self.assertEqual, [[["", "/"]], [], []])
|
|
return d
|
|
|
|
def test_mailboxWithoutNamespace(self):
|
|
"""
|
|
A mailbox that does not provide L{INamespacePresenter} returns
|
|
empty L{list}s for its personal, shared, and user namespaces.
|
|
"""
|
|
self.server.theAccount = AccountWithoutNamespaces(b"testuser")
|
|
self.namespaceArgs = None
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def namespace():
|
|
def gotNamespace(args):
|
|
self.namespaceArgs = args
|
|
self._cbStopClient(None)
|
|
|
|
return self.client.namespace().addCallback(gotNamespace)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(namespace))
|
|
d1.addErrback(self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(lambda _: self.namespaceArgs)
|
|
d.addCallback(self.assertEqual, [[], [], []])
|
|
return d
|
|
|
|
def testSelect(self):
|
|
SimpleServer.theAccount.addMailbox("test-mailbox")
|
|
self.selectedArgs = None
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def select():
|
|
def selected(args):
|
|
self.selectedArgs = args
|
|
self._cbStopClient(None)
|
|
|
|
d = self.client.select("test-mailbox")
|
|
d.addCallback(selected)
|
|
return d
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(select))
|
|
d1.addErrback(self._ebGeneral)
|
|
d2 = self.loopback()
|
|
return defer.gatherResults([d1, d2]).addCallback(self._cbTestSelect)
|
|
|
|
def test_selectWithoutMailbox(self):
|
|
"""
|
|
A client that selects a mailbox that does not exist receives a
|
|
C{NO} response.
|
|
"""
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def select():
|
|
return self.client.select("test-mailbox")
|
|
|
|
self.connected.addCallback(strip(login))
|
|
self.connected.addCallback(strip(select))
|
|
self.connected.addErrback(self.assertClientFailureMessage, b"No such mailbox")
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
|
|
connectionComplete = defer.gatherResults([self.connected, self.loopback()])
|
|
|
|
@connectionComplete.addCallback
|
|
def assertNoMailboxSelected(_):
|
|
self.assertIsNone(self.server.mbox)
|
|
|
|
return connectionComplete
|
|
|
|
def _cbTestSelect(self, ignored):
|
|
mbox = SimpleServer.theAccount.mailboxes["TEST-MAILBOX"]
|
|
self.assertEqual(self.server.mbox, mbox)
|
|
self.assertEqual(
|
|
self.selectedArgs,
|
|
{
|
|
"EXISTS": 9,
|
|
"RECENT": 3,
|
|
"UIDVALIDITY": 42,
|
|
"FLAGS": ("\\Flag1", "Flag2", "\\AnotherSysFlag", "LastFlag"),
|
|
"READ-WRITE": True,
|
|
},
|
|
)
|
|
|
|
def test_examine(self):
|
|
"""
|
|
L{IMAP4Client.examine} issues an I{EXAMINE} command to the server and
|
|
returns a L{Deferred} which fires with a C{dict} with as many of the
|
|
following keys as the server includes in its response: C{'FLAGS'},
|
|
C{'EXISTS'}, C{'RECENT'}, C{'UNSEEN'}, C{'READ-WRITE'}, C{'READ-ONLY'},
|
|
C{'UIDVALIDITY'}, and C{'PERMANENTFLAGS'}.
|
|
|
|
Unfortunately the server doesn't generate all of these so it's hard to
|
|
test the client's handling of them here. See
|
|
L{IMAP4ClientExamineTests} below.
|
|
|
|
See U{RFC 3501<http://www.faqs.org/rfcs/rfc3501.html>}, section 6.3.2,
|
|
for details.
|
|
"""
|
|
SimpleServer.theAccount.addMailbox("test-mailbox")
|
|
self.examinedArgs = None
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def examine():
|
|
def examined(args):
|
|
self.examinedArgs = args
|
|
self._cbStopClient(None)
|
|
|
|
d = self.client.examine("test-mailbox")
|
|
d.addCallback(examined)
|
|
return d
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(examine))
|
|
d1.addErrback(self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestExamine)
|
|
|
|
def _cbTestExamine(self, ignored):
|
|
mbox = SimpleServer.theAccount.mailboxes["TEST-MAILBOX"]
|
|
self.assertEqual(self.server.mbox, mbox)
|
|
self.assertEqual(
|
|
self.examinedArgs,
|
|
{
|
|
"EXISTS": 9,
|
|
"RECENT": 3,
|
|
"UIDVALIDITY": 42,
|
|
"FLAGS": ("\\Flag1", "Flag2", "\\AnotherSysFlag", "LastFlag"),
|
|
"READ-WRITE": False,
|
|
},
|
|
)
|
|
|
|
def testCreate(self):
|
|
succeed = ("testbox", "test/box", "test/", "test/box/box", "INBOX")
|
|
fail = ("testbox", "test/box")
|
|
|
|
def cb():
|
|
self.result.append(1)
|
|
|
|
def eb(failure):
|
|
self.result.append(0)
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def create():
|
|
for name in succeed + fail:
|
|
d = self.client.create(name)
|
|
d.addCallback(strip(cb)).addErrback(eb)
|
|
d.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
|
|
self.result = []
|
|
d1 = self.connected.addCallback(strip(login)).addCallback(strip(create))
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestCreate, succeed, fail)
|
|
|
|
def _cbTestCreate(self, ignored, succeed, fail):
|
|
self.assertEqual(self.result, [1] * len(succeed) + [0] * len(fail))
|
|
mbox = sorted(SimpleServer.theAccount.mailboxes)
|
|
answers = sorted(["inbox", "testbox", "test/box", "test", "test/box/box"])
|
|
self.assertEqual(mbox, [a.upper() for a in answers])
|
|
|
|
def testDelete(self):
|
|
SimpleServer.theAccount.addMailbox("delete/me")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def delete():
|
|
return self.client.delete("delete/me")
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(delete), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(
|
|
lambda _: self.assertEqual(list(SimpleServer.theAccount.mailboxes), [])
|
|
)
|
|
return d
|
|
|
|
def testDeleteWithInferiorHierarchicalNames(self):
|
|
"""
|
|
Attempting to delete a mailbox with hierarchically inferior
|
|
names fails with an informative error.
|
|
|
|
@see: U{https://tools.ietf.org/html/rfc3501#section-6.3.4}
|
|
|
|
@return: A L{Deferred} with assertions.
|
|
"""
|
|
SimpleServer.theAccount.addMailbox("delete")
|
|
SimpleServer.theAccount.addMailbox("delete/me")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def delete():
|
|
return self.client.delete("delete")
|
|
|
|
def assertIMAPException(failure):
|
|
failure.trap(imap4.IMAP4Exception)
|
|
self.assertEqual(
|
|
str(failure.value),
|
|
str(b'Name "DELETE" has inferior hierarchical names'),
|
|
)
|
|
|
|
loggedIn = self.connected.addCallback(strip(login))
|
|
loggedIn.addCallbacks(strip(delete), self._ebGeneral)
|
|
loggedIn.addErrback(assertIMAPException)
|
|
loggedIn.addCallbacks(self._cbStopClient)
|
|
|
|
loopedBack = self.loopback()
|
|
d = defer.gatherResults([loggedIn, loopedBack])
|
|
d.addCallback(
|
|
lambda _: self.assertEqual(
|
|
sorted(SimpleServer.theAccount.mailboxes), ["DELETE", "DELETE/ME"]
|
|
)
|
|
)
|
|
return d
|
|
|
|
def testIllegalInboxDelete(self):
|
|
self.stashed = None
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def delete():
|
|
return self.client.delete("inbox")
|
|
|
|
def stash(result):
|
|
self.stashed = result
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(delete), self._ebGeneral)
|
|
d1.addBoth(stash)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(
|
|
lambda _: self.assertTrue(isinstance(self.stashed, failure.Failure))
|
|
)
|
|
return d
|
|
|
|
def testNonExistentDelete(self):
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def delete():
|
|
return self.client.delete("delete/me")
|
|
|
|
def deleteFailed(failure):
|
|
self.failure = failure
|
|
|
|
self.failure = None
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(delete)).addErrback(deleteFailed)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(
|
|
lambda _: self.assertEqual(str(self.failure.value), str(b"No such mailbox"))
|
|
)
|
|
return d
|
|
|
|
def testIllegalDelete(self):
|
|
m = SimpleMailbox()
|
|
m.flags = (r"\Noselect",)
|
|
SimpleServer.theAccount.addMailbox("delete", m)
|
|
SimpleServer.theAccount.addMailbox("delete/me")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def delete():
|
|
return self.client.delete("delete")
|
|
|
|
def deleteFailed(failure):
|
|
self.failure = failure
|
|
|
|
self.failure = None
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(delete)).addErrback(deleteFailed)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
expected = str(
|
|
b"Hierarchically inferior mailboxes exist " b"and \\Noselect is set"
|
|
)
|
|
d.addCallback(lambda _: self.assertEqual(str(self.failure.value), expected))
|
|
return d
|
|
|
|
def testRename(self):
|
|
SimpleServer.theAccount.addMailbox("oldmbox")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def rename():
|
|
return self.client.rename(b"oldmbox", b"newname")
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(rename), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(
|
|
lambda _: self.assertEqual(
|
|
list(SimpleServer.theAccount.mailboxes.keys()), ["NEWNAME"]
|
|
)
|
|
)
|
|
return d
|
|
|
|
def testIllegalInboxRename(self):
|
|
self.stashed = None
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def rename():
|
|
return self.client.rename("inbox", "frotz")
|
|
|
|
def stash(stuff):
|
|
self.stashed = stuff
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(rename), self._ebGeneral)
|
|
d1.addBoth(stash)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(
|
|
lambda _: self.assertTrue(isinstance(self.stashed, failure.Failure))
|
|
)
|
|
return d
|
|
|
|
def testHierarchicalRename(self):
|
|
SimpleServer.theAccount.create("oldmbox/m1")
|
|
SimpleServer.theAccount.create("oldmbox/m2")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def rename():
|
|
return self.client.rename("oldmbox", "newname")
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(rename), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestHierarchicalRename)
|
|
|
|
def _cbTestHierarchicalRename(self, ignored):
|
|
mboxes = SimpleServer.theAccount.mailboxes.keys()
|
|
expected = ["newname", "newname/m1", "newname/m2"]
|
|
mboxes = list(sorted(mboxes))
|
|
self.assertEqual(mboxes, [s.upper() for s in expected])
|
|
|
|
def testSubscribe(self):
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def subscribe():
|
|
return self.client.subscribe("this/mbox")
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(subscribe), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(
|
|
lambda _: self.assertEqual(
|
|
SimpleServer.theAccount.subscriptions, ["THIS/MBOX"]
|
|
)
|
|
)
|
|
return d
|
|
|
|
def testUnsubscribe(self):
|
|
SimpleServer.theAccount.subscriptions = ["THIS/MBOX", "THAT/MBOX"]
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def unsubscribe():
|
|
return self.client.unsubscribe("this/mbox")
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(unsubscribe), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(
|
|
lambda _: self.assertEqual(
|
|
SimpleServer.theAccount.subscriptions, ["THAT/MBOX"]
|
|
)
|
|
)
|
|
return d
|
|
|
|
def _listSetup(self, f):
|
|
SimpleServer.theAccount.addMailbox("root/subthing")
|
|
SimpleServer.theAccount.addMailbox("root/another-thing")
|
|
SimpleServer.theAccount.addMailbox("non-root/subthing")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def listed(answers):
|
|
self.listed = answers
|
|
|
|
self.listed = None
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(f), self._ebGeneral)
|
|
d1.addCallbacks(listed, self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
return defer.gatherResults([d1, d2]).addCallback(lambda _: self.listed)
|
|
|
|
def assertListDelimiterAndMailboxAreStrings(self, results):
|
|
"""
|
|
Assert a C{LIST} response's delimiter and mailbox are native
|
|
strings.
|
|
|
|
@param results: A list of tuples as returned by
|
|
L{IMAP4Client.list} or L{IMAP4Client.lsub}.
|
|
"""
|
|
for result in results:
|
|
self.assertIsInstance(result[1], str, "delimiter %r is not a str")
|
|
self.assertIsInstance(result[2], str, "mailbox %r is not a str")
|
|
return results
|
|
|
|
def testList(self):
|
|
def mailboxList():
|
|
return self.client.list("root", "%")
|
|
|
|
d = self._listSetup(mailboxList)
|
|
|
|
@d.addCallback
|
|
def assertListContents(listed):
|
|
expectedContents = [
|
|
(sorted(SimpleMailbox.flags), "/", "ROOT/SUBTHING"),
|
|
(sorted(SimpleMailbox.flags), "/", "ROOT/ANOTHER-THING"),
|
|
]
|
|
|
|
for _ in range(2):
|
|
flags, delimiter, mailbox = listed.pop(0)
|
|
self.assertIn(
|
|
(sorted(flags), delimiter, mailbox),
|
|
expectedContents,
|
|
)
|
|
|
|
self.assertFalse(listed, f"More results than expected: {listed!r}")
|
|
|
|
return d
|
|
|
|
def testLSub(self):
|
|
SimpleServer.theAccount.subscribe("ROOT/SUBTHING")
|
|
|
|
def lsub():
|
|
return self.client.lsub("root", "%")
|
|
|
|
d = self._listSetup(lsub)
|
|
d.addCallback(self.assertListDelimiterAndMailboxAreStrings)
|
|
d.addCallback(self.assertEqual, [(SimpleMailbox.flags, "/", "ROOT/SUBTHING")])
|
|
return d
|
|
|
|
def testStatus(self):
|
|
SimpleServer.theAccount.addMailbox("root/subthing")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def status():
|
|
return self.client.status("root/subthing", "MESSAGES", "UIDNEXT", "UNSEEN")
|
|
|
|
def statused(result):
|
|
self.statused = result
|
|
|
|
self.statused = None
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(status), self._ebGeneral)
|
|
d1.addCallbacks(statused, self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
d.addCallback(
|
|
lambda _: self.assertEqual(
|
|
self.statused, {"MESSAGES": 9, "UIDNEXT": b"10", "UNSEEN": 4}
|
|
)
|
|
)
|
|
return d
|
|
|
|
def testFailedStatus(self):
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def status():
|
|
return self.client.status(
|
|
"root/nonexistent", "MESSAGES", "UIDNEXT", "UNSEEN"
|
|
)
|
|
|
|
def statused(result):
|
|
self.statused = result
|
|
|
|
def failed(failure):
|
|
self.failure = failure
|
|
|
|
self.statused = self.failure = None
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(status), self._ebGeneral)
|
|
d1.addCallbacks(statused, failed)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
return defer.gatherResults([d1, d2]).addCallback(self._cbTestFailedStatus)
|
|
|
|
def _cbTestFailedStatus(self, ignored):
|
|
self.assertEqual(self.statused, None)
|
|
self.assertEqual(self.failure.value.args, (b"Could not open mailbox",))
|
|
|
|
def testFullAppend(self):
|
|
infile = util.sibpath(__file__, "rfc822.message")
|
|
SimpleServer.theAccount.addMailbox("root/subthing")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
@defer.inlineCallbacks
|
|
def append():
|
|
with open(infile, "rb") as message:
|
|
result = yield self.client.append(
|
|
"root/subthing",
|
|
message,
|
|
("\\SEEN", "\\DELETED"),
|
|
"Tue, 17 Jun 2003 11:22:16 -0600 (MDT)",
|
|
)
|
|
defer.returnValue(result)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(append), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
|
|
d = defer.gatherResults([d1, d2])
|
|
|
|
return d.addCallback(self._cbTestFullAppend, infile)
|
|
|
|
def _cbTestFullAppend(self, ignored, infile):
|
|
mb = SimpleServer.theAccount.mailboxes["ROOT/SUBTHING"]
|
|
self.assertEqual(1, len(mb.messages))
|
|
self.assertEqual(
|
|
(["\\SEEN", "\\DELETED"], b"Tue, 17 Jun 2003 11:22:16 -0600 (MDT)", 0),
|
|
mb.messages[0][1:],
|
|
)
|
|
with open(infile, "rb") as f:
|
|
self.assertEqual(f.read(), mb.messages[0][0].getvalue())
|
|
|
|
def testPartialAppend(self):
|
|
infile = util.sibpath(__file__, "rfc822.message")
|
|
SimpleServer.theAccount.addMailbox("PARTIAL/SUBTHING")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
@defer.inlineCallbacks
|
|
def append():
|
|
with open(infile, "rb") as message:
|
|
result = yield self.client.sendCommand(
|
|
imap4.Command(
|
|
b"APPEND",
|
|
# Using networkString is cheating! In this
|
|
# particular case the mailbox name happens to
|
|
# be ASCII. In real code, the mailbox would
|
|
# be encoded with imap4-utf-7.
|
|
networkString(
|
|
"PARTIAL/SUBTHING "
|
|
'(\\SEEN) "Right now" '
|
|
"{%d}" % (os.path.getsize(infile),)
|
|
),
|
|
(),
|
|
self.client._IMAP4Client__cbContinueAppend,
|
|
message,
|
|
)
|
|
)
|
|
defer.returnValue(result)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(append), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestPartialAppend, infile)
|
|
|
|
def _cbTestPartialAppend(self, ignored, infile):
|
|
mb = SimpleServer.theAccount.mailboxes["PARTIAL/SUBTHING"]
|
|
self.assertEqual(1, len(mb.messages))
|
|
self.assertEqual((["\\SEEN"], b"Right now", 0), mb.messages[0][1:])
|
|
with open(infile, "rb") as f:
|
|
self.assertEqual(f.read(), mb.messages[0][0].getvalue())
|
|
|
|
def _testCheck(self):
|
|
SimpleServer.theAccount.addMailbox(b"root/subthing")
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def select():
|
|
return self.client.select(b"root/subthing")
|
|
|
|
def check():
|
|
return self.client.check()
|
|
|
|
d = self.connected.addCallback(strip(login))
|
|
d.addCallbacks(strip(select), self._ebGeneral)
|
|
d.addCallbacks(strip(check), self._ebGeneral)
|
|
d.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
return self.loopback()
|
|
|
|
def test_check(self):
|
|
"""
|
|
Trigger the L{imap.IMAP4Server._cbSelectWork} callback
|
|
by selecting an mbox.
|
|
"""
|
|
return self._testCheck()
|
|
|
|
def test_checkFail(self):
|
|
"""
|
|
Trigger the L{imap.IMAP4Server._ebSelectWork} errback
|
|
by failing when we select an mbox.
|
|
"""
|
|
|
|
def failSelect(self, name, rw=1):
|
|
raise imap4.IllegalMailboxEncoding("encoding")
|
|
|
|
def checkResponse(ignore):
|
|
failures = self.flushLoggedErrors()
|
|
self.assertEqual(failures[1].value.args[0], b"SELECT failed: Server error")
|
|
|
|
self.patch(Account, "select", failSelect)
|
|
d = self._testCheck()
|
|
return d.addCallback(checkResponse)
|
|
|
|
def testClose(self):
|
|
m = SimpleMailbox()
|
|
m.messages = [
|
|
(b"Message 1", ("\\Deleted", "AnotherFlag"), None, 0),
|
|
(b"Message 2", ("AnotherFlag",), None, 1),
|
|
(b"Message 3", ("\\Deleted",), None, 2),
|
|
]
|
|
SimpleServer.theAccount.addMailbox("mailbox", m)
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def select():
|
|
return self.client.select(b"mailbox")
|
|
|
|
def close():
|
|
return self.client.close()
|
|
|
|
d = self.connected.addCallback(strip(login))
|
|
d.addCallbacks(strip(select), self._ebGeneral)
|
|
d.addCallbacks(strip(close), self._ebGeneral)
|
|
d.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
return defer.gatherResults([d, d2]).addCallback(self._cbTestClose, m)
|
|
|
|
def _cbTestClose(self, ignored, m):
|
|
self.assertEqual(len(m.messages), 1)
|
|
self.assertEqual(m.messages[0], (b"Message 2", ("AnotherFlag",), None, 1))
|
|
self.assertTrue(m.closed)
|
|
|
|
def testExpunge(self):
|
|
m = SimpleMailbox()
|
|
m.messages = [
|
|
(b"Message 1", ("\\Deleted", "AnotherFlag"), None, 0),
|
|
(b"Message 2", ("AnotherFlag",), None, 1),
|
|
(b"Message 3", ("\\Deleted",), None, 2),
|
|
]
|
|
SimpleServer.theAccount.addMailbox("mailbox", m)
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def select():
|
|
return self.client.select("mailbox")
|
|
|
|
def expunge():
|
|
return self.client.expunge()
|
|
|
|
def expunged(results):
|
|
self.assertFalse(self.server.mbox is None)
|
|
self.results = results
|
|
|
|
self.results = None
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallbacks(strip(select), self._ebGeneral)
|
|
d1.addCallbacks(strip(expunge), self._ebGeneral)
|
|
d1.addCallbacks(expunged, self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestExpunge, m)
|
|
|
|
def _cbTestExpunge(self, ignored, m):
|
|
self.assertEqual(len(m.messages), 1)
|
|
self.assertEqual(m.messages[0], (b"Message 2", ("AnotherFlag",), None, 1))
|
|
|
|
self.assertEqual(self.results, [0, 2])
|
|
|
|
|
|
class IMAP4ServerParsingTests(SynchronousTestCase):
|
|
"""
|
|
Test L{imap4.IMAP4Server}'s command parsing.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.transport = StringTransport()
|
|
self.server = imap4.IMAP4Server()
|
|
self.server.makeConnection(self.transport)
|
|
self.transport.clear()
|
|
|
|
def tearDown(self):
|
|
self.server.connectionLost(failure.Failure(error.ConnectionDone()))
|
|
|
|
def test_parseMethodExceptionLogged(self):
|
|
"""
|
|
L{imap4.IMAP4Server} logs exceptions raised by parse methods.
|
|
"""
|
|
|
|
class UnhandledException(Exception):
|
|
"""
|
|
An unhandled exception.
|
|
"""
|
|
|
|
def raisesValueError(line):
|
|
raise UnhandledException
|
|
|
|
self.server.parseState = "command"
|
|
self.server.parse_command = raisesValueError
|
|
|
|
self.server.lineReceived(b"invalid")
|
|
|
|
self.assertTrue(self.flushLoggedErrors(UnhandledException))
|
|
|
|
def test_missingCommand(self):
|
|
"""
|
|
L{imap4.IMAP4Server.parse_command} sends a C{BAD} response to
|
|
a line that includes a tag but no command.
|
|
"""
|
|
self.server.parse_command(b"001")
|
|
|
|
self.assertEqual(self.transport.value(), b"001 BAD Missing command\r\n")
|
|
|
|
self.server.connectionLost(
|
|
failure.Failure(error.ConnectionDone("Done")),
|
|
)
|
|
|
|
def test_emptyLine(self):
|
|
"""
|
|
L{imap4.IMAP4Server.parse_command} sends a C{BAD} response to
|
|
an empty line.
|
|
"""
|
|
self.server.parse_command(b"")
|
|
|
|
self.assertEqual(self.transport.value(), b"* BAD Null command\r\n")
|
|
|
|
def assertParseExceptionResponse(self, exception, tag, expectedResponse):
|
|
"""
|
|
Assert that the given exception results in the expected
|
|
response.
|
|
|
|
@param exception: The exception to raise.
|
|
@type exception: L{Exception}
|
|
|
|
@param tag: The IMAP tag.
|
|
|
|
@type: L{bytes}
|
|
|
|
@param expectedResponse: The expected bad response.
|
|
@type expectedResponse: L{bytes}
|
|
"""
|
|
|
|
def raises(tag, cmd, rest):
|
|
raise exception
|
|
|
|
self.server.dispatchCommand = raises
|
|
|
|
self.server.parse_command(b" ".join([tag, b"invalid"]))
|
|
|
|
self.assertEqual(self.transport.value(), b" ".join([tag, expectedResponse]))
|
|
|
|
def test_parsingRaisesIllegalClientResponse(self):
|
|
"""
|
|
When a parsing method raises L{IllegalClientResponse}, the
|
|
server sends a C{BAD} response.
|
|
"""
|
|
self.assertParseExceptionResponse(
|
|
imap4.IllegalClientResponse("client response"),
|
|
b"001",
|
|
b"BAD Illegal syntax: client response\r\n",
|
|
)
|
|
|
|
def test_parsingRaisesIllegalOperationResponse(self):
|
|
"""
|
|
When a parsing method raises L{IllegalOperation}, the server
|
|
sends a C{NO} response.
|
|
"""
|
|
self.assertParseExceptionResponse(
|
|
imap4.IllegalOperation("operation"),
|
|
b"001",
|
|
b"NO Illegal operation: operation\r\n",
|
|
)
|
|
|
|
def test_parsingRaisesIllegalMailboxEncoding(self):
|
|
"""
|
|
When a parsing method raises L{IllegalMailboxEncoding}, the
|
|
server sends a C{NO} response.
|
|
"""
|
|
self.assertParseExceptionResponse(
|
|
imap4.IllegalMailboxEncoding("encoding"),
|
|
b"001",
|
|
b"NO Illegal mailbox name: encoding\r\n",
|
|
)
|
|
|
|
def test_unsupportedCommand(self):
|
|
"""
|
|
L{imap4.IMAP4Server} responds to an unsupported command with a
|
|
C{BAD} response.
|
|
"""
|
|
self.server.lineReceived(b"001 HULLABALOO")
|
|
self.assertEqual(self.transport.value(), b"001 BAD Unsupported command\r\n")
|
|
|
|
def test_tooManyArgumentsForCommand(self):
|
|
"""
|
|
L{imap4.IMAP4Server} responds with a C{BAD} response to a
|
|
command with more arguments than expected.
|
|
"""
|
|
self.server.lineReceived(b"001 LOGIN A B C")
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
(
|
|
b"001 BAD Illegal syntax:"
|
|
+ b" Too many arguments for command: "
|
|
+ repr(b"C").encode("utf-8")
|
|
+ b"\r\n"
|
|
),
|
|
)
|
|
|
|
def assertCommandExceptionResponse(self, exception, tag, expectedResponse):
|
|
"""
|
|
Assert that the given exception results in the expected
|
|
response.
|
|
|
|
@param exception: The exception to raise.
|
|
@type exception: L{Exception}
|
|
|
|
@param: The IMAP tag.
|
|
|
|
@type: L{bytes}
|
|
|
|
@param expectedResponse: The expected bad response.
|
|
@type expectedResponse: L{bytes}
|
|
"""
|
|
|
|
def raises(serverInstance, tag, user, passwd):
|
|
raise exception
|
|
|
|
self.assertEqual(self.server.state, "unauth")
|
|
|
|
self.server.unauth_LOGIN = (raises,) + self.server.unauth_LOGIN[1:]
|
|
|
|
self.server.dispatchCommand(tag, b"LOGIN", b"user passwd")
|
|
|
|
self.assertEqual(self.transport.value(), b" ".join([tag, expectedResponse]))
|
|
|
|
def test_commandRaisesIllegalClientResponse(self):
|
|
"""
|
|
When a command raises L{IllegalClientResponse}, the
|
|
server sends a C{BAD} response.
|
|
"""
|
|
self.assertCommandExceptionResponse(
|
|
imap4.IllegalClientResponse("client response"),
|
|
b"001",
|
|
b"BAD Illegal syntax: client response\r\n",
|
|
)
|
|
|
|
def test_commandRaisesIllegalOperationResponse(self):
|
|
"""
|
|
When a command raises L{IllegalOperation}, the server sends a
|
|
C{NO} response.
|
|
"""
|
|
self.assertCommandExceptionResponse(
|
|
imap4.IllegalOperation("operation"),
|
|
b"001",
|
|
b"NO Illegal operation: operation\r\n",
|
|
)
|
|
|
|
def test_commandRaisesIllegalMailboxEncoding(self):
|
|
"""
|
|
When a command raises L{IllegalMailboxEncoding}, the server
|
|
sends a C{NO} response.
|
|
"""
|
|
self.assertCommandExceptionResponse(
|
|
imap4.IllegalMailboxEncoding("encoding"),
|
|
b"001",
|
|
b"NO Illegal mailbox name: encoding\r\n",
|
|
)
|
|
|
|
def test_commandRaisesUnhandledException(self):
|
|
"""
|
|
Wehn a command raises an unhandled exception, the server sends
|
|
a C{BAD} response and logs the exception.
|
|
"""
|
|
|
|
class UnhandledException(Exception):
|
|
"""
|
|
An unhandled exception.
|
|
"""
|
|
|
|
self.assertCommandExceptionResponse(
|
|
UnhandledException("unhandled"),
|
|
b"001",
|
|
b"BAD Server error: unhandled\r\n",
|
|
)
|
|
|
|
self.assertTrue(self.flushLoggedErrors(UnhandledException))
|
|
|
|
def test_stringLiteralTooLong(self):
|
|
"""
|
|
A string literal whose length exceeds the maximum allowed
|
|
length results in a C{BAD} response.
|
|
"""
|
|
self.server._literalStringLimit = 4
|
|
self.server.lineReceived(b"001 LOGIN {5}\r\n")
|
|
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"001 BAD Illegal syntax: Literal too long!"
|
|
b" I accept at most 4 octets\r\n",
|
|
)
|
|
|
|
def test_arg_astringEmptyLine(self):
|
|
"""
|
|
An empty string argument raises L{imap4.IllegalClientResponse}.
|
|
"""
|
|
for empty in [b"", b"\r\n", b" "]:
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, self.server.arg_astring, empty
|
|
)
|
|
|
|
def test_arg_astringUnmatchedQuotes(self):
|
|
"""
|
|
An unmatched quote in a string argument raises
|
|
L{imap4.IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, self.server.arg_astring, b'"open'
|
|
)
|
|
|
|
def test_arg_astringUnmatchedLiteralBraces(self):
|
|
"""
|
|
An unmatched brace in a string literal's size raises
|
|
L{imap4.IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_astring, b"{0")
|
|
|
|
def test_arg_astringInvalidLiteralSize(self):
|
|
"""
|
|
A non-integral string literal size raises
|
|
L{imap4.IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, self.server.arg_astring, b"{[object Object]}"
|
|
)
|
|
|
|
def test_arg_atomEmptyLine(self):
|
|
"""
|
|
An empty atom raises L{IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_atom, b"")
|
|
|
|
def test_arg_atomMalformedAtom(self):
|
|
"""
|
|
A malformed atom raises L{IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, self.server.arg_atom, b" not an atom "
|
|
)
|
|
|
|
def test_arg_plistEmptyLine(self):
|
|
"""
|
|
An empty parenthesized list raises L{IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_plist, b"")
|
|
|
|
def test_arg_plistUnmatchedParentheses(self):
|
|
"""
|
|
A parenthesized with unmatched parentheses raises
|
|
L{IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_plist, b"(foo")
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_plist, b"foo)")
|
|
|
|
def test_arg_literalEmptyLine(self):
|
|
"""
|
|
An empty file literal raises L{IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_literal, b"")
|
|
|
|
def test_arg_literalUnmatchedBraces(self):
|
|
"""
|
|
A literal with unmatched braces raises
|
|
L{IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_literal, b"{10")
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_literal, b"10}")
|
|
|
|
def test_arg_literalInvalidLiteralSize(self):
|
|
"""
|
|
A non-integral literal size raises
|
|
L{imap4.IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, self.server.arg_literal, b"{[object Object]}"
|
|
)
|
|
|
|
def test_arg_seqsetReturnsRest(self):
|
|
"""
|
|
A sequence set returns the unparsed portion of a line.
|
|
"""
|
|
sequence = b"1:* blah blah blah"
|
|
_, rest = self.server.arg_seqset(sequence)
|
|
self.assertEqual(rest, b"blah blah blah")
|
|
|
|
def test_arg_seqsetInvalidSequence(self):
|
|
"""
|
|
An invalid sequence raises L{imap4.IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.arg_seqset, b"x:y")
|
|
|
|
def test_arg_flaglistOneFlag(self):
|
|
"""
|
|
A single flag that is not contained in a list is parsed.
|
|
"""
|
|
flag = b"flag"
|
|
parsed, rest = self.server.arg_flaglist(flag)
|
|
self.assertEqual(parsed, [flag])
|
|
self.assertFalse(rest)
|
|
|
|
def test_arg_flaglistMismatchedParentehses(self):
|
|
"""
|
|
A list of flags with unmatched parentheses raises
|
|
L{imap4.IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse,
|
|
self.server.arg_flaglist,
|
|
b"(invalid",
|
|
)
|
|
|
|
def test_arg_flaglistMalformedFlag(self):
|
|
"""
|
|
A list of flags that contains a malformed flag raises
|
|
L{imap4.IllegalClientResponse}.
|
|
"""
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, self.server.arg_flaglist, b"(first \x00)"
|
|
)
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, self.server.arg_flaglist, b"(first \x00second)"
|
|
)
|
|
|
|
def test_opt_plistMissingOpenParenthesis(self):
|
|
"""
|
|
A line that does not begin with an open parenthesis (C{(}) is
|
|
parsed as L{None}, and the remainder is the whole line.
|
|
"""
|
|
line = b"not ("
|
|
plist, remainder = self.server.opt_plist(line)
|
|
self.assertIsNone(plist)
|
|
self.assertEqual(remainder, line)
|
|
|
|
def test_opt_datetimeMissingOpenQuote(self):
|
|
"""
|
|
A line that does not begin with a double quote (C{"}) is
|
|
parsed as L{None}, and the remainder is the whole line.
|
|
"""
|
|
line = b'not "'
|
|
dt, remainder = self.server.opt_datetime(line)
|
|
self.assertIsNone(dt)
|
|
self.assertEqual(remainder, line)
|
|
|
|
def test_opt_datetimeMissingCloseQuote(self):
|
|
"""
|
|
A line that does not have a closing double quote (C{"}) raises
|
|
L{imap4.IllegalClientResponse}.
|
|
"""
|
|
line = b'"21-Jul-2017 19:37:07 -0700'
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.opt_datetime, line)
|
|
|
|
def test_opt_charsetMissingIdentifier(self):
|
|
"""
|
|
A line that contains C{CHARSET} but no character set
|
|
identifier raises L{imap4.IllegalClientResponse}.
|
|
"""
|
|
line = b"CHARSET"
|
|
self.assertRaises(imap4.IllegalClientResponse, self.server.opt_charset, line)
|
|
|
|
def test_opt_charsetEndOfLine(self):
|
|
"""
|
|
A line that ends with a C{CHARSET} identifier is parsed as
|
|
that identifier, and the remainder is the empty string.
|
|
"""
|
|
line = b"CHARSET UTF-8"
|
|
identifier, remainder = self.server.opt_charset(line)
|
|
self.assertEqual(identifier, b"UTF-8")
|
|
self.assertEqual(remainder, b"")
|
|
|
|
def test_opt_charsetWithRemainder(self):
|
|
"""
|
|
A line that has additional data after a C{CHARSET} identifier
|
|
is parsed as that identifier, and the remainder is that
|
|
additional data.
|
|
"""
|
|
line = b"CHARSET UTF-8 remainder"
|
|
identifier, remainder = self.server.opt_charset(line)
|
|
self.assertEqual(identifier, b"UTF-8")
|
|
self.assertEqual(remainder, b"remainder")
|
|
|
|
|
|
class IMAP4ServerSearchTests(IMAP4HelperMixin, TestCase):
|
|
"""
|
|
Tests for the behavior of the search_* functions in L{imap4.IMAP4Server}.
|
|
"""
|
|
|
|
def setUp(self):
|
|
IMAP4HelperMixin.setUp(self)
|
|
self.earlierQuery = ["10-Dec-2009"]
|
|
self.sameDateQuery = ["13-Dec-2009"]
|
|
self.laterQuery = ["16-Dec-2009"]
|
|
self.seq = 0
|
|
self.msg = FakeyMessage(
|
|
{"date": "Mon, 13 Dec 2009 21:25:10 GMT"},
|
|
[],
|
|
"13 Dec 2009 00:00:00 GMT",
|
|
"",
|
|
1234,
|
|
None,
|
|
)
|
|
|
|
def test_searchSentBefore(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_SENTBEFORE} returns True if the message date
|
|
is earlier than the query date.
|
|
"""
|
|
self.assertFalse(
|
|
self.server.search_SENTBEFORE(self.earlierQuery, self.seq, self.msg)
|
|
)
|
|
self.assertTrue(
|
|
self.server.search_SENTBEFORE(self.laterQuery, self.seq, self.msg)
|
|
)
|
|
|
|
def test_searchWildcard(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_UID} returns True if the message UID is in
|
|
the search range.
|
|
"""
|
|
self.assertFalse(
|
|
self.server.search_UID([b"2:3"], self.seq, self.msg, (1, 1234))
|
|
)
|
|
# 2:* should get translated to 2:<max UID> and then to 1:2
|
|
self.assertTrue(self.server.search_UID([b"2:*"], self.seq, self.msg, (1, 1234)))
|
|
self.assertTrue(self.server.search_UID([b"*"], self.seq, self.msg, (1, 1234)))
|
|
|
|
def test_searchWildcardHigh(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_UID} should return True if there is a
|
|
wildcard, because a wildcard means "highest UID in the mailbox".
|
|
"""
|
|
self.assertTrue(
|
|
self.server.search_UID([b"1235:*"], self.seq, self.msg, (1234, 1))
|
|
)
|
|
|
|
def test_reversedSearchTerms(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_SENTON} returns True if the message date is
|
|
the same as the query date.
|
|
"""
|
|
msgset = imap4.parseIdList(b"4:2")
|
|
self.assertEqual(list(msgset), [2, 3, 4])
|
|
|
|
def test_searchSentOn(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_SENTON} returns True if the message date is
|
|
the same as the query date.
|
|
"""
|
|
self.assertFalse(
|
|
self.server.search_SENTON(self.earlierQuery, self.seq, self.msg)
|
|
)
|
|
self.assertTrue(
|
|
self.server.search_SENTON(self.sameDateQuery, self.seq, self.msg)
|
|
)
|
|
self.assertFalse(self.server.search_SENTON(self.laterQuery, self.seq, self.msg))
|
|
|
|
def test_searchSentSince(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_SENTSINCE} returns True if the message date
|
|
is later than the query date.
|
|
"""
|
|
self.assertTrue(
|
|
self.server.search_SENTSINCE(self.earlierQuery, self.seq, self.msg)
|
|
)
|
|
self.assertFalse(
|
|
self.server.search_SENTSINCE(self.laterQuery, self.seq, self.msg)
|
|
)
|
|
|
|
def test_searchOr(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_OR} returns true if either of the two
|
|
expressions supplied to it returns true and returns false if neither
|
|
does.
|
|
"""
|
|
self.assertTrue(
|
|
self.server.search_OR(
|
|
["SENTSINCE"] + self.earlierQuery + ["SENTSINCE"] + self.laterQuery,
|
|
self.seq,
|
|
self.msg,
|
|
(None, None),
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
self.server.search_OR(
|
|
["SENTSINCE"] + self.laterQuery + ["SENTSINCE"] + self.earlierQuery,
|
|
self.seq,
|
|
self.msg,
|
|
(None, None),
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
self.server.search_OR(
|
|
["SENTON"] + self.laterQuery + ["SENTSINCE"] + self.laterQuery,
|
|
self.seq,
|
|
self.msg,
|
|
(None, None),
|
|
)
|
|
)
|
|
|
|
def test_searchNot(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_NOT} returns the negation of the result
|
|
of the expression supplied to it.
|
|
"""
|
|
self.assertFalse(
|
|
self.server.search_NOT(
|
|
["SENTSINCE"] + self.earlierQuery, self.seq, self.msg, (None, None)
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
self.server.search_NOT(
|
|
["SENTON"] + self.laterQuery, self.seq, self.msg, (None, None)
|
|
)
|
|
)
|
|
|
|
def test_searchBefore(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_BEFORE} returns True if the
|
|
internal message date is before the query date.
|
|
"""
|
|
self.assertFalse(
|
|
self.server.search_BEFORE(self.earlierQuery, self.seq, self.msg)
|
|
)
|
|
self.assertFalse(
|
|
self.server.search_BEFORE(self.sameDateQuery, self.seq, self.msg)
|
|
)
|
|
self.assertTrue(self.server.search_BEFORE(self.laterQuery, self.seq, self.msg))
|
|
|
|
def test_searchOn(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_ON} returns True if the
|
|
internal message date is the same as the query date.
|
|
"""
|
|
self.assertFalse(self.server.search_ON(self.earlierQuery, self.seq, self.msg))
|
|
self.assertFalse(self.server.search_ON(self.sameDateQuery, self.seq, self.msg))
|
|
self.assertFalse(self.server.search_ON(self.laterQuery, self.seq, self.msg))
|
|
|
|
def test_searchSince(self):
|
|
"""
|
|
L{imap4.IMAP4Server.search_SINCE} returns True if the
|
|
internal message date is greater than the query date.
|
|
"""
|
|
self.assertTrue(self.server.search_SINCE(self.earlierQuery, self.seq, self.msg))
|
|
self.assertTrue(
|
|
self.server.search_SINCE(self.sameDateQuery, self.seq, self.msg)
|
|
)
|
|
self.assertFalse(self.server.search_SINCE(self.laterQuery, self.seq, self.msg))
|
|
|
|
|
|
@implementer(IRealm)
|
|
class TestRealm:
|
|
"""
|
|
A L{IRealm} for tests.
|
|
|
|
@cvar theAccount: An C{Account} instance. Tests can set this to
|
|
ensure predictable account retrieval.
|
|
"""
|
|
|
|
theAccount = None
|
|
|
|
def __init__(self, accountHolder=None):
|
|
"""
|
|
Create a realm for testing.
|
|
|
|
@param accountHolder: (optional) An object whose C{theAccount}
|
|
attribute will be returned instead of
|
|
L{TestRealm.theAccount}. Attribute access occurs on every
|
|
avatar request, so any modifications to
|
|
C{accountHolder.theAccount} will be reflected here.
|
|
"""
|
|
if accountHolder:
|
|
self._getAccount = lambda: accountHolder.theAccount
|
|
else:
|
|
self._getAccount = lambda: self.theAccount
|
|
|
|
def requestAvatar(self, avatarId, mind, *interfaces):
|
|
return imap4.IAccount, self._getAccount(), lambda: None
|
|
|
|
|
|
class TestChecker:
|
|
credentialInterfaces = (IUsernameHashedPassword, IUsernamePassword)
|
|
|
|
users = {b"testuser": b"secret"}
|
|
|
|
def requestAvatarId(self, credentials):
|
|
if credentials.username in self.users:
|
|
return defer.maybeDeferred(
|
|
credentials.checkPassword, self.users[credentials.username]
|
|
).addCallback(self._cbCheck, credentials.username)
|
|
|
|
def _cbCheck(self, result, username):
|
|
if result:
|
|
return username
|
|
raise UnauthorizedLogin()
|
|
|
|
|
|
class AuthenticatorTests(IMAP4HelperMixin, TestCase):
|
|
def setUp(self):
|
|
IMAP4HelperMixin.setUp(self)
|
|
|
|
realm = TestRealm()
|
|
realm.theAccount = Account(b"testuser")
|
|
self.portal = Portal(realm)
|
|
self.portal.registerChecker(TestChecker())
|
|
self.server.portal = self.portal
|
|
|
|
self.authenticated = 0
|
|
self.account = realm.theAccount
|
|
|
|
def test_customChallengers(self):
|
|
"""
|
|
L{imap4.IMAP4Server} accepts a L{dict} mapping challenge type
|
|
names to L{twisted.mail.interfaces.IChallengeResponse}
|
|
providers.
|
|
"""
|
|
|
|
@implementer(IChallengeResponse, IUsernamePassword)
|
|
class SPECIALAuth:
|
|
def getChallenge(self):
|
|
return b"SPECIAL"
|
|
|
|
def setResponse(self, response):
|
|
self.username, self.password = response.split(None, 1)
|
|
|
|
def moreChallenges(self):
|
|
return False
|
|
|
|
def checkPassword(self, password):
|
|
self.password = self.password
|
|
|
|
special = SPECIALAuth()
|
|
verifyObject(IChallengeResponse, special)
|
|
|
|
server = imap4.IMAP4Server({b"SPECIAL": SPECIALAuth})
|
|
server.portal = self.portal
|
|
|
|
transport = StringTransport()
|
|
server.makeConnection(transport)
|
|
self.addCleanup(server.connectionLost, error.ConnectionDone("Connection done."))
|
|
|
|
self.assertIn(b"AUTH=SPECIAL", transport.value())
|
|
|
|
transport.clear()
|
|
server.dataReceived(b"001 AUTHENTICATE SPECIAL\r\n")
|
|
|
|
self.assertIn(base64.b64encode(special.getChallenge()), transport.value())
|
|
|
|
transport.clear()
|
|
server.dataReceived(base64.b64encode(b"username password") + b"\r\n")
|
|
|
|
self.assertEqual(transport.value(), b"001 OK Authentication successful\r\n")
|
|
|
|
def test_unsupportedMethod(self):
|
|
"""
|
|
An unsupported C{AUTHENTICATE} method results in a negative
|
|
response.
|
|
"""
|
|
server = imap4.IMAP4Server()
|
|
server.portal = self.portal
|
|
|
|
transport = StringTransport()
|
|
server.makeConnection(transport)
|
|
self.addCleanup(server.connectionLost, error.ConnectionDone("Connection done."))
|
|
|
|
transport.clear()
|
|
|
|
server.dataReceived(b"001 AUTHENTICATE UNKNOWN\r\n")
|
|
self.assertEqual(
|
|
transport.value(), b"001 NO AUTHENTICATE method unsupported\r\n"
|
|
)
|
|
|
|
def test_missingPortal(self):
|
|
"""
|
|
An L{imap4.IMAP4Server} that is missing a L{Portal} responds
|
|
negatively to an authentication
|
|
"""
|
|
self.server.challengers[b"LOGIN"] = imap4.LOGINCredentials
|
|
|
|
cAuth = imap4.LOGINAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
self.server.portal = None
|
|
|
|
def auth():
|
|
return self.client.authenticate(b"secret")
|
|
|
|
d = self.connected.addCallback(strip(auth))
|
|
d.addErrback(
|
|
self.assertClientFailureMessage, b"Temporary authentication failure"
|
|
)
|
|
d.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
|
|
return defer.gatherResults([d, self.loopback()])
|
|
|
|
def test_challengerRaisesException(self):
|
|
"""
|
|
When a challenger's
|
|
L{getChallenge<IChallengeResponse.getChallenge>} method raises
|
|
any exception, a C{NO} response is sent.
|
|
"""
|
|
|
|
@implementer(IChallengeResponse)
|
|
class ValueErrorAuthChallenge:
|
|
message = b"A challenge failure"
|
|
|
|
def getChallenge(self):
|
|
raise ValueError(self.message)
|
|
|
|
def setResponse(self, response):
|
|
"""
|
|
Never called.
|
|
|
|
@param response: See L{IChallengeResponse.setResponse}
|
|
"""
|
|
|
|
def moreChallenges(self):
|
|
"""
|
|
Never called.
|
|
"""
|
|
|
|
@implementer(IClientAuthentication)
|
|
class ValueErrorAuthenticator:
|
|
def getName(self):
|
|
return b"ERROR"
|
|
|
|
def challengeResponse(self, secret, chal):
|
|
return b"IGNORED"
|
|
|
|
bad = ValueErrorAuthChallenge()
|
|
verifyObject(IChallengeResponse, bad)
|
|
|
|
self.server.challengers[b"ERROR"] = ValueErrorAuthChallenge
|
|
self.client.registerAuthenticator(ValueErrorAuthenticator())
|
|
|
|
def auth():
|
|
return self.client.authenticate(b"secret")
|
|
|
|
d = self.connected.addCallback(strip(auth))
|
|
d.addErrback(
|
|
self.assertClientFailureMessage,
|
|
("Server error: " + str(ValueErrorAuthChallenge.message)).encode("ascii"),
|
|
)
|
|
d.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
|
|
return defer.gatherResults([d, self.loopback()])
|
|
|
|
def test_authNotBase64(self):
|
|
"""
|
|
A client that responds with a challenge that cannot be decoded
|
|
as Base 64 receives an L{IllegalClientResponse}.
|
|
"""
|
|
|
|
@implementer(IChallengeResponse)
|
|
class NotBase64AuthChallenge:
|
|
message = b"Malformed Response - not base64"
|
|
|
|
def getChallenge(self):
|
|
return b"SomeChallenge"
|
|
|
|
def setResponse(self, response):
|
|
"""
|
|
Never called.
|
|
|
|
@param response: See L{IChallengeResponse.setResponse}
|
|
"""
|
|
|
|
def moreChallenges(self):
|
|
"""
|
|
Never called.
|
|
"""
|
|
|
|
notBase64 = NotBase64AuthChallenge()
|
|
verifyObject(IChallengeResponse, notBase64)
|
|
|
|
server = imap4.IMAP4Server()
|
|
server.portal = self.portal
|
|
server.challengers[b"NOTBASE64"] = NotBase64AuthChallenge
|
|
|
|
transport = StringTransport()
|
|
server.makeConnection(transport)
|
|
self.addCleanup(server.connectionLost, error.ConnectionDone("Connection done."))
|
|
|
|
self.assertIn(b"AUTH=NOTBASE64", transport.value())
|
|
|
|
transport.clear()
|
|
server.dataReceived(b"001 AUTHENTICATE NOTBASE64\r\n")
|
|
|
|
self.assertIn(base64.b64encode(notBase64.getChallenge()), transport.value())
|
|
|
|
transport.clear()
|
|
server.dataReceived(b"\x00 Not base64\r\n")
|
|
|
|
self.assertEqual(
|
|
transport.value(),
|
|
b"".join([b"001 NO Authentication failed: ", notBase64.message, b"\r\n"]),
|
|
)
|
|
|
|
def test_unhandledCredentials(self):
|
|
"""
|
|
A challenger that causes the login to fail
|
|
L{UnhandledCredentials} results in an C{NO} response.
|
|
|
|
@return: A L{Deferred} that fires when the authorization has
|
|
failed.
|
|
"""
|
|
realm = TestRealm()
|
|
portal = Portal(realm)
|
|
# This portal has no checkers, so all logins will fail with
|
|
# UnhandledCredentials
|
|
self.server.portal = portal
|
|
|
|
self.server.challengers[b"LOGIN"] = loginCred = imap4.LOGINCredentials
|
|
|
|
verifyClass(IChallengeResponse, loginCred)
|
|
|
|
cAuth = imap4.LOGINAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
def auth():
|
|
return self.client.authenticate(b"secret")
|
|
|
|
d1 = self.connected.addCallback(strip(auth))
|
|
d1.addErrback(
|
|
self.assertClientFailureMessage,
|
|
b"Authentication failed: server misconfigured",
|
|
)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d
|
|
|
|
def test_unexpectedLoginFailure(self):
|
|
"""
|
|
If the portal raises an exception other than
|
|
L{UnauthorizedLogin} or L{UnhandledCredentials}, the server
|
|
responds with a C{BAD} response and the exception is logged.
|
|
"""
|
|
|
|
class UnexpectedException(Exception):
|
|
"""
|
|
An unexpected exception.
|
|
"""
|
|
|
|
class FailingChecker:
|
|
"""
|
|
A credentials checker whose L{requestAvatarId} method
|
|
raises L{UnexpectedException}.
|
|
"""
|
|
|
|
credentialInterfaces = (IUsernameHashedPassword, IUsernamePassword)
|
|
|
|
def requestAvatarId(self, credentials):
|
|
raise UnexpectedException("Unexpected error.")
|
|
|
|
realm = TestRealm()
|
|
portal = Portal(realm)
|
|
portal.registerChecker(FailingChecker())
|
|
self.server.portal = portal
|
|
|
|
self.server.challengers[b"LOGIN"] = loginCred = imap4.LOGINCredentials
|
|
|
|
verifyClass(IChallengeResponse, loginCred)
|
|
|
|
cAuth = imap4.LOGINAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
def auth():
|
|
return self.client.authenticate(b"secret")
|
|
|
|
def assertUnexpectedExceptionLogged():
|
|
self.assertTrue(self.flushLoggedErrors(UnexpectedException))
|
|
|
|
d1 = self.connected.addCallback(strip(auth))
|
|
d1.addErrback(
|
|
self.assertClientFailureMessage, b"Server error: login failed unexpectedly"
|
|
)
|
|
d1.addCallback(strip(assertUnexpectedExceptionLogged))
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d
|
|
|
|
def testCramMD5(self):
|
|
self.server.challengers[b"CRAM-MD5"] = CramMD5Credentials
|
|
cAuth = imap4.CramMD5ClientAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
def auth():
|
|
return self.client.authenticate(b"secret")
|
|
|
|
def authed():
|
|
self.authenticated = 1
|
|
|
|
d1 = self.connected.addCallback(strip(auth))
|
|
d1.addCallbacks(strip(authed), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d2 = self.loopback()
|
|
d = defer.gatherResults([d1, d2])
|
|
return d.addCallback(self._cbTestCramMD5)
|
|
|
|
def _cbTestCramMD5(self, ignored):
|
|
self.assertEqual(self.authenticated, 1)
|
|
self.assertEqual(self.server.account, self.account)
|
|
|
|
def testFailedCramMD5(self):
|
|
self.server.challengers[b"CRAM-MD5"] = CramMD5Credentials
|
|
cAuth = imap4.CramMD5ClientAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
def misauth():
|
|
return self.client.authenticate(b"not the secret")
|
|
|
|
def authed():
|
|
self.authenticated = 1
|
|
|
|
def misauthed():
|
|
self.authenticated = -1
|
|
|
|
d1 = self.connected.addCallback(strip(misauth))
|
|
d1.addCallbacks(strip(authed), strip(misauthed))
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestFailedCramMD5)
|
|
|
|
def _cbTestFailedCramMD5(self, ignored):
|
|
self.assertEqual(self.authenticated, -1)
|
|
self.assertEqual(self.server.account, None)
|
|
|
|
def testLOGIN(self):
|
|
self.server.challengers[b"LOGIN"] = loginCred = imap4.LOGINCredentials
|
|
|
|
verifyClass(IChallengeResponse, loginCred)
|
|
|
|
cAuth = imap4.LOGINAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
def auth():
|
|
return self.client.authenticate(b"secret")
|
|
|
|
def authed():
|
|
self.authenticated = 1
|
|
|
|
d1 = self.connected.addCallback(strip(auth))
|
|
d1.addCallbacks(strip(authed), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestLOGIN)
|
|
|
|
def _cbTestLOGIN(self, ignored):
|
|
self.assertEqual(self.authenticated, 1)
|
|
self.assertEqual(self.server.account, self.account)
|
|
|
|
def testFailedLOGIN(self):
|
|
self.server.challengers[b"LOGIN"] = imap4.LOGINCredentials
|
|
cAuth = imap4.LOGINAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
def misauth():
|
|
return self.client.authenticate(b"not the secret")
|
|
|
|
def authed():
|
|
self.authenticated = 1
|
|
|
|
def misauthed():
|
|
self.authenticated = -1
|
|
|
|
d1 = self.connected.addCallback(strip(misauth))
|
|
d1.addCallbacks(strip(authed), strip(misauthed))
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestFailedLOGIN)
|
|
|
|
def _cbTestFailedLOGIN(self, ignored):
|
|
self.assertEqual(self.authenticated, -1)
|
|
self.assertEqual(self.server.account, None)
|
|
|
|
def testPLAIN(self):
|
|
self.server.challengers[b"PLAIN"] = plainCred = imap4.PLAINCredentials
|
|
|
|
verifyClass(IChallengeResponse, plainCred)
|
|
|
|
cAuth = imap4.PLAINAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
def auth():
|
|
return self.client.authenticate(b"secret")
|
|
|
|
def authed():
|
|
self.authenticated = 1
|
|
|
|
d1 = self.connected.addCallback(strip(auth))
|
|
d1.addCallbacks(strip(authed), self._ebGeneral)
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestPLAIN)
|
|
|
|
def _cbTestPLAIN(self, ignored):
|
|
self.assertEqual(self.authenticated, 1)
|
|
self.assertEqual(self.server.account, self.account)
|
|
|
|
def testFailedPLAIN(self):
|
|
self.server.challengers[b"PLAIN"] = imap4.PLAINCredentials
|
|
cAuth = imap4.PLAINAuthenticator(b"testuser")
|
|
self.client.registerAuthenticator(cAuth)
|
|
|
|
def misauth():
|
|
return self.client.authenticate(b"not the secret")
|
|
|
|
def authed():
|
|
self.authenticated = 1
|
|
|
|
def misauthed():
|
|
self.authenticated = -1
|
|
|
|
d1 = self.connected.addCallback(strip(misauth))
|
|
d1.addCallbacks(strip(authed), strip(misauthed))
|
|
d1.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestFailedPLAIN)
|
|
|
|
def _cbTestFailedPLAIN(self, ignored):
|
|
self.assertEqual(self.authenticated, -1)
|
|
self.assertEqual(self.server.account, None)
|
|
|
|
|
|
class SASLPLAINTests(TestCase):
|
|
"""
|
|
Tests for I{SASL PLAIN} authentication, as implemented by
|
|
L{imap4.PLAINAuthenticator} and L{imap4.PLAINCredentials}.
|
|
|
|
@see: U{http://www.faqs.org/rfcs/rfc2595.html}
|
|
@see: U{http://www.faqs.org/rfcs/rfc4616.html}
|
|
"""
|
|
|
|
def test_authenticatorChallengeResponse(self):
|
|
"""
|
|
L{PLAINAuthenticator.challengeResponse} returns challenge strings of
|
|
the form::
|
|
|
|
NUL<authn-id>NUL<secret>
|
|
"""
|
|
username = b"testuser"
|
|
secret = b"secret"
|
|
chal = b"challenge"
|
|
cAuth = imap4.PLAINAuthenticator(username)
|
|
response = cAuth.challengeResponse(secret, chal)
|
|
self.assertEqual(response, b"\0" + username + b"\0" + secret)
|
|
|
|
def test_credentialsSetResponse(self):
|
|
"""
|
|
L{PLAINCredentials.setResponse} parses challenge strings of the
|
|
form::
|
|
|
|
NUL<authn-id>NUL<secret>
|
|
"""
|
|
cred = imap4.PLAINCredentials()
|
|
cred.setResponse(b"\0testuser\0secret")
|
|
self.assertEqual(cred.username, b"testuser")
|
|
self.assertEqual(cred.password, b"secret")
|
|
|
|
def test_credentialsInvalidResponse(self):
|
|
"""
|
|
L{PLAINCredentials.setResponse} raises L{imap4.IllegalClientResponse}
|
|
when passed a string not of the expected form.
|
|
"""
|
|
cred = imap4.PLAINCredentials()
|
|
self.assertRaises(imap4.IllegalClientResponse, cred.setResponse, b"hello")
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, cred.setResponse, b"hello\0world"
|
|
)
|
|
self.assertRaises(
|
|
imap4.IllegalClientResponse, cred.setResponse, b"hello\0world\0Zoom!\0"
|
|
)
|
|
|
|
|
|
class UnsolicitedResponseTests(IMAP4HelperMixin, TestCase):
|
|
def testReadWrite(self):
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def loggedIn():
|
|
self.server.modeChanged(1)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(loggedIn)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestReadWrite)
|
|
|
|
def _cbTestReadWrite(self, ignored):
|
|
E = self.client.events
|
|
self.assertEqual(E, [["modeChanged", 1]])
|
|
|
|
def testReadOnly(self):
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def loggedIn():
|
|
self.server.modeChanged(0)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(loggedIn)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestReadOnly)
|
|
|
|
def _cbTestReadOnly(self, ignored):
|
|
E = self.client.events
|
|
self.assertEqual(E, [["modeChanged", 0]])
|
|
|
|
def testFlagChange(self):
|
|
flags = {1: ["\\Answered", "\\Deleted"], 5: [], 10: ["\\Recent"]}
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def loggedIn():
|
|
self.server.flagsChanged(flags)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(loggedIn)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestFlagChange, flags)
|
|
|
|
def _cbTestFlagChange(self, ignored, flags):
|
|
E = self.client.events
|
|
expect = [["flagsChanged", {x[0]: x[1]}] for x in flags.items()]
|
|
E.sort(key=lambda o: o[0])
|
|
expect.sort(key=lambda o: o[0])
|
|
self.assertEqual(E, expect)
|
|
|
|
def testNewMessages(self):
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def loggedIn():
|
|
self.server.newMessages(10, None)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(loggedIn)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestNewMessages)
|
|
|
|
def _cbTestNewMessages(self, ignored):
|
|
E = self.client.events
|
|
self.assertEqual(E, [["newMessages", 10, None]])
|
|
|
|
def testNewRecentMessages(self):
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def loggedIn():
|
|
self.server.newMessages(None, 10)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(loggedIn)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestNewRecentMessages)
|
|
|
|
def _cbTestNewRecentMessages(self, ignored):
|
|
E = self.client.events
|
|
self.assertEqual(E, [["newMessages", None, 10]])
|
|
|
|
def testNewMessagesAndRecent(self):
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def loggedIn():
|
|
self.server.newMessages(20, 10)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(loggedIn)).addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([self.loopback(), d1])
|
|
return d.addCallback(self._cbTestNewMessagesAndRecent)
|
|
|
|
def _cbTestNewMessagesAndRecent(self, ignored):
|
|
E = self.client.events
|
|
self.assertEqual(E, [["newMessages", 20, None], ["newMessages", None, 10]])
|
|
|
|
|
|
class ClientCapabilityTests(TestCase):
|
|
"""
|
|
Tests for issuance of the CAPABILITY command and handling of its response.
|
|
"""
|
|
|
|
def setUp(self):
|
|
"""
|
|
Create an L{imap4.IMAP4Client} connected to a L{StringTransport}.
|
|
"""
|
|
self.transport = StringTransport()
|
|
self.protocol = imap4.IMAP4Client()
|
|
self.protocol.makeConnection(self.transport)
|
|
self.protocol.dataReceived(b"* OK [IMAP4rev1]\r\n")
|
|
|
|
def test_simpleAtoms(self):
|
|
"""
|
|
A capability response consisting only of atoms without C{'='} in them
|
|
should result in a dict mapping those atoms to L{None}.
|
|
"""
|
|
capabilitiesResult = self.protocol.getCapabilities(useCache=False)
|
|
self.protocol.dataReceived(b"* CAPABILITY IMAP4rev1 LOGINDISABLED\r\n")
|
|
self.protocol.dataReceived(b"0001 OK Capability completed.\r\n")
|
|
|
|
def gotCapabilities(capabilities):
|
|
self.assertEqual(capabilities, {b"IMAP4rev1": None, b"LOGINDISABLED": None})
|
|
|
|
capabilitiesResult.addCallback(gotCapabilities)
|
|
return capabilitiesResult
|
|
|
|
def test_categoryAtoms(self):
|
|
"""
|
|
A capability response consisting of atoms including C{'='} should have
|
|
those atoms split on that byte and have capabilities in the same
|
|
category aggregated into lists in the resulting dictionary.
|
|
|
|
(n.b. - I made up the word "category atom"; the protocol has no notion
|
|
of structure here, but rather allows each capability to define the
|
|
semantics of its entry in the capability response in a freeform manner.
|
|
If I had realized this earlier, the API for capabilities would look
|
|
different. As it is, we can hope that no one defines any crazy
|
|
semantics which are incompatible with this API, or try to figure out a
|
|
better API when someone does. -exarkun)
|
|
"""
|
|
capabilitiesResult = self.protocol.getCapabilities(useCache=False)
|
|
self.protocol.dataReceived(b"* CAPABILITY IMAP4rev1 AUTH=LOGIN AUTH=PLAIN\r\n")
|
|
self.protocol.dataReceived(b"0001 OK Capability completed.\r\n")
|
|
|
|
def gotCapabilities(capabilities):
|
|
self.assertEqual(
|
|
capabilities, {b"IMAP4rev1": None, b"AUTH": [b"LOGIN", b"PLAIN"]}
|
|
)
|
|
|
|
capabilitiesResult.addCallback(gotCapabilities)
|
|
return capabilitiesResult
|
|
|
|
def test_mixedAtoms(self):
|
|
"""
|
|
A capability response consisting of both simple and category atoms of
|
|
the same type should result in a list containing L{None} as well as the
|
|
values for the category.
|
|
"""
|
|
capabilitiesResult = self.protocol.getCapabilities(useCache=False)
|
|
# Exercise codepath for both orderings of =-having and =-missing
|
|
# capabilities.
|
|
self.protocol.dataReceived(
|
|
b"* CAPABILITY IMAP4rev1 FOO FOO=BAR BAR=FOO BAR\r\n"
|
|
)
|
|
self.protocol.dataReceived(b"0001 OK Capability completed.\r\n")
|
|
|
|
def gotCapabilities(capabilities):
|
|
self.assertEqual(
|
|
capabilities,
|
|
{b"IMAP4rev1": None, b"FOO": [None, b"BAR"], b"BAR": [b"FOO", None]},
|
|
)
|
|
|
|
capabilitiesResult.addCallback(gotCapabilities)
|
|
return capabilitiesResult
|
|
|
|
|
|
class StillSimplerClient(imap4.IMAP4Client):
|
|
"""
|
|
An IMAP4 client which keeps track of unsolicited flag changes.
|
|
"""
|
|
|
|
def __init__(self):
|
|
imap4.IMAP4Client.__init__(self)
|
|
self.flags = {}
|
|
|
|
def flagsChanged(self, newFlags):
|
|
self.flags.update(newFlags)
|
|
|
|
|
|
class HandCraftedTests(IMAP4HelperMixin, TestCase):
|
|
def testTrailingLiteral(self):
|
|
transport = StringTransport()
|
|
c = imap4.IMAP4Client()
|
|
c.makeConnection(transport)
|
|
c.lineReceived(b"* OK [IMAP4rev1]")
|
|
|
|
def cbCheckTransport(ignored):
|
|
self.assertEqual(
|
|
transport.value().splitlines()[-1],
|
|
b"0003 FETCH 1 (RFC822)",
|
|
)
|
|
|
|
def cbSelect(ignored):
|
|
d = c.fetchMessage("1")
|
|
c.dataReceived(
|
|
b"* 1 FETCH (RFC822 {10}\r\n0123456789\r\n RFC822.SIZE 10)\r\n"
|
|
)
|
|
c.dataReceived(b"0003 OK FETCH\r\n")
|
|
d.addCallback(cbCheckTransport)
|
|
return d
|
|
|
|
def cbLogin(ignored):
|
|
d = c.select("inbox")
|
|
c.lineReceived(b"0002 OK SELECT")
|
|
d.addCallback(cbSelect)
|
|
return d
|
|
|
|
d = c.login(b"blah", b"blah")
|
|
c.dataReceived(b"0001 OK LOGIN\r\n")
|
|
d.addCallback(cbLogin)
|
|
return d
|
|
|
|
def test_fragmentedStringLiterals(self):
|
|
"""
|
|
String literals whose data is not immediately available are
|
|
parsed.
|
|
"""
|
|
self.server.checker.addUser(b"testuser", b"password-test")
|
|
transport = StringTransport()
|
|
self.server.makeConnection(transport)
|
|
|
|
transport.clear()
|
|
self.server.dataReceived(b"01 LOGIN {8}\r\n")
|
|
self.assertEqual(transport.value(), b"+ Ready for 8 octets of text\r\n")
|
|
|
|
transport.clear()
|
|
self.server.dataReceived(b"testuser {13}\r\n")
|
|
self.assertEqual(transport.value(), b"+ Ready for 13 octets of text\r\n")
|
|
|
|
transport.clear()
|
|
self.server.dataReceived(b"password")
|
|
self.assertNot(transport.value())
|
|
self.server.dataReceived(b"-test\r\n")
|
|
self.assertEqual(transport.value(), b"01 OK LOGIN succeeded\r\n")
|
|
self.assertEqual(self.server.state, "auth")
|
|
|
|
self.server.connectionLost(error.ConnectionDone("Connection done."))
|
|
|
|
def test_emptyStringLiteral(self):
|
|
"""
|
|
Empty string literals are parsed.
|
|
"""
|
|
self.server.checker.users = {b"": b""}
|
|
transport = StringTransport()
|
|
self.server.makeConnection(transport)
|
|
|
|
transport.clear()
|
|
self.server.dataReceived(b"01 LOGIN {0}\r\n")
|
|
self.assertEqual(transport.value(), b"+ Ready for 0 octets of text\r\n")
|
|
|
|
transport.clear()
|
|
self.server.dataReceived(b"{0}\r\n")
|
|
self.assertEqual(transport.value(), b"01 OK LOGIN succeeded\r\n")
|
|
self.assertEqual(self.server.state, "auth")
|
|
|
|
self.server.connectionLost(error.ConnectionDone("Connection done."))
|
|
|
|
def test_unsolicitedResponseMixedWithSolicitedResponse(self):
|
|
"""
|
|
If unsolicited data is received along with solicited data in the
|
|
response to a I{FETCH} command issued by L{IMAP4Client.fetchSpecific},
|
|
the unsolicited data is passed to the appropriate callback and not
|
|
included in the result with which the L{Deferred} returned by
|
|
L{IMAP4Client.fetchSpecific} fires.
|
|
"""
|
|
transport = StringTransport()
|
|
c = StillSimplerClient()
|
|
c.makeConnection(transport)
|
|
c.lineReceived(b"* OK [IMAP4rev1]")
|
|
|
|
def login():
|
|
d = c.login(b"blah", b"blah")
|
|
c.dataReceived(b"0001 OK LOGIN\r\n")
|
|
return d
|
|
|
|
def select():
|
|
d = c.select("inbox")
|
|
c.lineReceived(b"0002 OK SELECT")
|
|
return d
|
|
|
|
def fetch():
|
|
d = c.fetchSpecific(
|
|
"1:*", headerType="HEADER.FIELDS", headerArgs=["SUBJECT"]
|
|
)
|
|
c.dataReceived(b'* 1 FETCH (BODY[HEADER.FIELDS ("SUBJECT")] {38}\r\n')
|
|
c.dataReceived(b"Subject: Suprise for your woman...\r\n")
|
|
c.dataReceived(b"\r\n")
|
|
c.dataReceived(b")\r\n")
|
|
c.dataReceived(b"* 1 FETCH (FLAGS (\\Seen))\r\n")
|
|
c.dataReceived(b'* 2 FETCH (BODY[HEADER.FIELDS ("SUBJECT")] {75}\r\n')
|
|
c.dataReceived(
|
|
b"Subject: What you been doing. Order your meds here . ,. handcuff madsen\r\n"
|
|
)
|
|
c.dataReceived(b"\r\n")
|
|
c.dataReceived(b")\r\n")
|
|
c.dataReceived(b"0003 OK FETCH completed\r\n")
|
|
return d
|
|
|
|
def test(res):
|
|
self.assertEqual(
|
|
transport.value().splitlines()[-1],
|
|
b"0003 FETCH 1:* BODY[HEADER.FIELDS (SUBJECT)]",
|
|
)
|
|
|
|
self.assertEqual(
|
|
res,
|
|
{
|
|
1: [
|
|
[
|
|
"BODY",
|
|
["HEADER.FIELDS", ["SUBJECT"]],
|
|
"Subject: Suprise for your woman...\r\n\r\n",
|
|
]
|
|
],
|
|
2: [
|
|
[
|
|
"BODY",
|
|
["HEADER.FIELDS", ["SUBJECT"]],
|
|
"Subject: What you been doing. Order your meds here . ,. handcuff madsen\r\n\r\n",
|
|
]
|
|
],
|
|
},
|
|
)
|
|
|
|
self.assertEqual(c.flags, {1: ["\\Seen"]})
|
|
|
|
return (
|
|
login()
|
|
.addCallback(strip(select))
|
|
.addCallback(strip(fetch))
|
|
.addCallback(test)
|
|
)
|
|
|
|
def test_literalWithoutPrecedingWhitespace(self):
|
|
"""
|
|
Literals should be recognized even when they are not preceded by
|
|
whitespace.
|
|
"""
|
|
transport = StringTransport()
|
|
protocol = imap4.IMAP4Client()
|
|
|
|
protocol.makeConnection(transport)
|
|
protocol.lineReceived(b"* OK [IMAP4rev1]")
|
|
|
|
def login():
|
|
d = protocol.login(b"blah", b"blah")
|
|
protocol.dataReceived(b"0001 OK LOGIN\r\n")
|
|
return d
|
|
|
|
def select():
|
|
d = protocol.select(b"inbox")
|
|
protocol.lineReceived(b"0002 OK SELECT")
|
|
return d
|
|
|
|
def fetch():
|
|
d = protocol.fetchSpecific(
|
|
"1:*", headerType="HEADER.FIELDS", headerArgs=["SUBJECT"]
|
|
)
|
|
protocol.dataReceived(
|
|
b'* 1 FETCH (BODY[HEADER.FIELDS ({7}\r\nSUBJECT)] "Hello")\r\n'
|
|
)
|
|
protocol.dataReceived(b"0003 OK FETCH completed\r\n")
|
|
return d
|
|
|
|
def test(result):
|
|
self.assertEqual(
|
|
transport.value().splitlines()[-1],
|
|
b"0003 FETCH 1:* BODY[HEADER.FIELDS (SUBJECT)]",
|
|
)
|
|
self.assertEqual(
|
|
result, {1: [["BODY", ["HEADER.FIELDS", ["SUBJECT"]], "Hello"]]}
|
|
)
|
|
|
|
d = login()
|
|
d.addCallback(strip(select))
|
|
d.addCallback(strip(fetch))
|
|
d.addCallback(test)
|
|
return d
|
|
|
|
def test_nonIntegerLiteralLength(self):
|
|
"""
|
|
If the server sends a literal length which cannot be parsed as an
|
|
integer, L{IMAP4Client.lineReceived} should cause the protocol to be
|
|
disconnected by raising L{imap4.IllegalServerResponse}.
|
|
"""
|
|
transport = StringTransport()
|
|
protocol = imap4.IMAP4Client()
|
|
|
|
protocol.makeConnection(transport)
|
|
protocol.lineReceived(b"* OK [IMAP4rev1]")
|
|
|
|
def login():
|
|
d = protocol.login(b"blah", b"blah")
|
|
protocol.dataReceived(b"0001 OK LOGIN\r\n")
|
|
return d
|
|
|
|
def select():
|
|
d = protocol.select("inbox")
|
|
protocol.lineReceived(b"0002 OK SELECT")
|
|
return d
|
|
|
|
def fetch():
|
|
protocol.fetchSpecific(
|
|
"1:*", headerType="HEADER.FIELDS", headerArgs=["SUBJECT"]
|
|
)
|
|
|
|
self.assertEqual(
|
|
transport.value().splitlines()[-1],
|
|
b"0003 FETCH 1:* BODY[HEADER.FIELDS (SUBJECT)]",
|
|
)
|
|
|
|
self.assertRaises(
|
|
imap4.IllegalServerResponse,
|
|
protocol.dataReceived,
|
|
b"* 1 FETCH {xyz}\r\n...",
|
|
)
|
|
|
|
d = login()
|
|
d.addCallback(strip(select))
|
|
d.addCallback(strip(fetch))
|
|
return d
|
|
|
|
def test_flagsChangedInsideFetchSpecificResponse(self):
|
|
"""
|
|
Any unrequested flag information received along with other requested
|
|
information in an untagged I{FETCH} received in response to a request
|
|
issued with L{IMAP4Client.fetchSpecific} is passed to the
|
|
C{flagsChanged} callback.
|
|
"""
|
|
transport = StringTransport()
|
|
c = StillSimplerClient()
|
|
c.makeConnection(transport)
|
|
c.lineReceived(b"* OK [IMAP4rev1]")
|
|
|
|
def login():
|
|
d = c.login(b"blah", b"blah")
|
|
c.dataReceived(b"0001 OK LOGIN\r\n")
|
|
return d
|
|
|
|
def select():
|
|
d = c.select("inbox")
|
|
c.lineReceived(b"0002 OK SELECT")
|
|
return d
|
|
|
|
def fetch():
|
|
d = c.fetchSpecific(
|
|
b"1:*", headerType="HEADER.FIELDS", headerArgs=["SUBJECT"]
|
|
)
|
|
# This response includes FLAGS after the requested data.
|
|
c.dataReceived(b'* 1 FETCH (BODY[HEADER.FIELDS ("SUBJECT")] {22}\r\n')
|
|
c.dataReceived(b"Subject: subject one\r\n")
|
|
c.dataReceived(b" FLAGS (\\Recent))\r\n")
|
|
# And this one includes it before! Either is possible.
|
|
c.dataReceived(
|
|
b'* 2 FETCH (FLAGS (\\Seen) BODY[HEADER.FIELDS ("SUBJECT")] {22}\r\n'
|
|
)
|
|
c.dataReceived(b"Subject: subject two\r\n")
|
|
c.dataReceived(b")\r\n")
|
|
c.dataReceived(b"0003 OK FETCH completed\r\n")
|
|
return d
|
|
|
|
def test(res):
|
|
self.assertEqual(
|
|
res,
|
|
{
|
|
1: [
|
|
[
|
|
"BODY",
|
|
["HEADER.FIELDS", ["SUBJECT"]],
|
|
"Subject: subject one\r\n",
|
|
]
|
|
],
|
|
2: [
|
|
[
|
|
"BODY",
|
|
["HEADER.FIELDS", ["SUBJECT"]],
|
|
"Subject: subject two\r\n",
|
|
]
|
|
],
|
|
},
|
|
)
|
|
|
|
self.assertEqual(c.flags, {1: ["\\Recent"], 2: ["\\Seen"]})
|
|
|
|
return (
|
|
login()
|
|
.addCallback(strip(select))
|
|
.addCallback(strip(fetch))
|
|
.addCallback(test)
|
|
)
|
|
|
|
def test_flagsChangedInsideFetchMessageResponse(self):
|
|
"""
|
|
Any unrequested flag information received along with other requested
|
|
information in an untagged I{FETCH} received in response to a request
|
|
issued with L{IMAP4Client.fetchMessage} is passed to the
|
|
C{flagsChanged} callback.
|
|
"""
|
|
transport = StringTransport()
|
|
c = StillSimplerClient()
|
|
c.makeConnection(transport)
|
|
c.lineReceived(b"* OK [IMAP4rev1]")
|
|
|
|
def login():
|
|
d = c.login(b"blah", b"blah")
|
|
c.dataReceived(b"0001 OK LOGIN\r\n")
|
|
return d
|
|
|
|
def select():
|
|
d = c.select("inbox")
|
|
c.lineReceived(b"0002 OK SELECT")
|
|
return d
|
|
|
|
def fetch():
|
|
d = c.fetchMessage("1:*")
|
|
c.dataReceived(b"* 1 FETCH (RFC822 {24}\r\n")
|
|
c.dataReceived(b"Subject: first subject\r\n")
|
|
c.dataReceived(b" FLAGS (\\Seen))\r\n")
|
|
c.dataReceived(b"* 2 FETCH (FLAGS (\\Recent \\Seen) RFC822 {25}\r\n")
|
|
c.dataReceived(b"Subject: second subject\r\n")
|
|
c.dataReceived(b")\r\n")
|
|
c.dataReceived(b"0003 OK FETCH completed\r\n")
|
|
return d
|
|
|
|
def test(res):
|
|
self.assertEqual(
|
|
transport.value().splitlines()[-1],
|
|
b"0003 FETCH 1:* (RFC822)",
|
|
)
|
|
|
|
self.assertEqual(
|
|
res,
|
|
{
|
|
1: {"RFC822": "Subject: first subject\r\n"},
|
|
2: {"RFC822": "Subject: second subject\r\n"},
|
|
},
|
|
)
|
|
|
|
self.assertEqual(c.flags, {1: ["\\Seen"], 2: ["\\Recent", "\\Seen"]})
|
|
|
|
return (
|
|
login()
|
|
.addCallback(strip(select))
|
|
.addCallback(strip(fetch))
|
|
.addCallback(test)
|
|
)
|
|
|
|
def test_authenticationChallengeDecodingException(self):
|
|
"""
|
|
When decoding a base64 encoded authentication message from the server,
|
|
decoding errors are logged and then the client closes the connection.
|
|
"""
|
|
transport = StringTransportWithDisconnection()
|
|
protocol = imap4.IMAP4Client()
|
|
transport.protocol = protocol
|
|
|
|
protocol.makeConnection(transport)
|
|
protocol.lineReceived(
|
|
b"* OK [CAPABILITY IMAP4rev1 IDLE NAMESPACE AUTH=CRAM-MD5] "
|
|
b"Twisted IMAP4rev1 Ready"
|
|
)
|
|
cAuth = imap4.CramMD5ClientAuthenticator(b"testuser")
|
|
protocol.registerAuthenticator(cAuth)
|
|
|
|
d = protocol.authenticate("secret")
|
|
# Should really be something describing the base64 decode error. See
|
|
# #6021.
|
|
self.assertFailure(d, error.ConnectionDone)
|
|
|
|
protocol.dataReceived(b"+ Something bad! and bad\r\n")
|
|
|
|
# This should not really be logged. See #6021.
|
|
logged = self.flushLoggedErrors(imap4.IllegalServerResponse)
|
|
self.assertEqual(len(logged), 1)
|
|
self.assertEqual(logged[0].value.args[0], b"Something bad! and bad")
|
|
return d
|
|
|
|
|
|
class PreauthIMAP4ClientMixin:
|
|
"""
|
|
Mixin for L{SynchronousTestCase} subclasses which
|
|
provides a C{setUp} method which creates an L{IMAP4Client}
|
|
connected to a L{StringTransport} and puts it into the
|
|
I{authenticated} state.
|
|
|
|
@ivar transport: A L{StringTransport} to which C{client} is
|
|
connected.
|
|
|
|
@ivar client: An L{IMAP4Client} which is connected to
|
|
C{transport}.
|
|
"""
|
|
|
|
clientProtocol: Type[imap4.IMAP4Client] = imap4.IMAP4Client
|
|
|
|
def setUp(self):
|
|
"""
|
|
Create an IMAP4Client connected to a fake transport and in the
|
|
authenticated state.
|
|
"""
|
|
self.transport = StringTransport()
|
|
self.client = self.clientProtocol()
|
|
self.client.makeConnection(self.transport)
|
|
self.client.dataReceived(b"* PREAUTH Hello unittest\r\n")
|
|
|
|
|
|
class SelectionTestsMixin(PreauthIMAP4ClientMixin):
|
|
"""
|
|
Mixin for test cases which defines tests which apply to both I{EXAMINE} and
|
|
I{SELECT} support.
|
|
"""
|
|
|
|
def _examineOrSelect(self):
|
|
"""
|
|
Issue either an I{EXAMINE} or I{SELECT} command (depending on
|
|
C{self.method}), assert that the correct bytes are written to the
|
|
transport, and return the L{Deferred} returned by whichever method was
|
|
called.
|
|
"""
|
|
d = getattr(self.client, self.method)("foobox")
|
|
self.assertEqual(
|
|
self.transport.value(), b"0001 " + self.command + b" foobox\r\n"
|
|
)
|
|
return d
|
|
|
|
def _response(self, *lines):
|
|
"""
|
|
Deliver the given (unterminated) response lines to C{self.client} and
|
|
then deliver a tagged SELECT or EXAMINE completion line to finish the
|
|
SELECT or EXAMINE response.
|
|
"""
|
|
for line in lines:
|
|
self.client.dataReceived(line + b"\r\n")
|
|
self.client.dataReceived(
|
|
b"0001 OK [READ-ONLY] " + self.command + b" completed\r\n"
|
|
)
|
|
|
|
def test_exists(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{EXISTS} response, the L{Deferred} return by L{IMAP4Client.select} or
|
|
L{IMAP4Client.examine} fires with a C{dict} including the value
|
|
associated with the C{'EXISTS'} key.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* 3 EXISTS")
|
|
self.assertEqual(self.successResultOf(d), {"READ-WRITE": False, "EXISTS": 3})
|
|
|
|
def test_nonIntegerExists(self):
|
|
"""
|
|
If the server returns a non-integer EXISTS value in its response to a
|
|
I{SELECT} or I{EXAMINE} command, the L{Deferred} returned by
|
|
L{IMAP4Client.select} or L{IMAP4Client.examine} fails with
|
|
L{IllegalServerResponse}.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* foo EXISTS")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_recent(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{RECENT} response, the L{Deferred} return by L{IMAP4Client.select} or
|
|
L{IMAP4Client.examine} fires with a C{dict} including the value
|
|
associated with the C{'RECENT'} key.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* 5 RECENT")
|
|
self.assertEqual(self.successResultOf(d), {"READ-WRITE": False, "RECENT": 5})
|
|
|
|
def test_nonIntegerRecent(self):
|
|
"""
|
|
If the server returns a non-integer RECENT value in its response to a
|
|
I{SELECT} or I{EXAMINE} command, the L{Deferred} returned by
|
|
L{IMAP4Client.select} or L{IMAP4Client.examine} fails with
|
|
L{IllegalServerResponse}.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* foo RECENT")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_unseen(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{UNSEEN} response, the L{Deferred} returned by L{IMAP4Client.select} or
|
|
L{IMAP4Client.examine} fires with a C{dict} including the value
|
|
associated with the C{'UNSEEN'} key.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* OK [UNSEEN 8] Message 8 is first unseen")
|
|
self.assertEqual(self.successResultOf(d), {"READ-WRITE": False, "UNSEEN": 8})
|
|
|
|
def test_nonIntegerUnseen(self):
|
|
"""
|
|
If the server returns a non-integer UNSEEN value in its response to a
|
|
I{SELECT} or I{EXAMINE} command, the L{Deferred} returned by
|
|
L{IMAP4Client.select} or L{IMAP4Client.examine} fails with
|
|
L{IllegalServerResponse}.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* OK [UNSEEN foo] Message foo is first unseen")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_uidvalidity(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{UIDVALIDITY} response, the L{Deferred} returned by
|
|
L{IMAP4Client.select} or L{IMAP4Client.examine} fires with a C{dict}
|
|
including the value associated with the C{'UIDVALIDITY'} key.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* OK [UIDVALIDITY 12345] UIDs valid")
|
|
self.assertEqual(
|
|
self.successResultOf(d), {"READ-WRITE": False, "UIDVALIDITY": 12345}
|
|
)
|
|
|
|
def test_nonIntegerUIDVALIDITY(self):
|
|
"""
|
|
If the server returns a non-integer UIDVALIDITY value in its response to
|
|
a I{SELECT} or I{EXAMINE} command, the L{Deferred} returned by
|
|
L{IMAP4Client.select} or L{IMAP4Client.examine} fails with
|
|
L{IllegalServerResponse}.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* OK [UIDVALIDITY foo] UIDs valid")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_uidnext(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{UIDNEXT} response, the L{Deferred} returned by L{IMAP4Client.select}
|
|
or L{IMAP4Client.examine} fires with a C{dict} including the value
|
|
associated with the C{'UIDNEXT'} key.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* OK [UIDNEXT 4392] Predicted next UID")
|
|
self.assertEqual(
|
|
self.successResultOf(d), {"READ-WRITE": False, "UIDNEXT": 4392}
|
|
)
|
|
|
|
def test_nonIntegerUIDNEXT(self):
|
|
"""
|
|
If the server returns a non-integer UIDNEXT value in its response to a
|
|
I{SELECT} or I{EXAMINE} command, the L{Deferred} returned by
|
|
L{IMAP4Client.select} or L{IMAP4Client.examine} fails with
|
|
L{IllegalServerResponse}.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* OK [UIDNEXT foo] Predicted next UID")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_flags(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{FLAGS} response, the L{Deferred} returned by L{IMAP4Client.select} or
|
|
L{IMAP4Client.examine} fires with a C{dict} including the value
|
|
associated with the C{'FLAGS'} key.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)")
|
|
self.assertEqual(
|
|
self.successResultOf(d),
|
|
{
|
|
"READ-WRITE": False,
|
|
"FLAGS": ("\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"),
|
|
},
|
|
)
|
|
|
|
def test_permanentflags(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{FLAGS} response, the L{Deferred} returned by L{IMAP4Client.select} or
|
|
L{IMAP4Client.examine} fires with a C{dict} including the value
|
|
associated with the C{'FLAGS'} key.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(
|
|
b"* OK [PERMANENTFLAGS (\\Starred)] Just one permanent flag in "
|
|
b"that list up there"
|
|
)
|
|
self.assertEqual(
|
|
self.successResultOf(d),
|
|
{"READ-WRITE": False, "PERMANENTFLAGS": ("\\Starred",)},
|
|
)
|
|
|
|
def test_unrecognizedOk(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{OK} with unrecognized response code text, parsing does not fail.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* OK [X-MADE-UP] I just made this response text up.")
|
|
# The value won't show up in the result. It would be okay if it did
|
|
# someday, perhaps. This shouldn't ever happen, though.
|
|
self.assertEqual(self.successResultOf(d), {"READ-WRITE": False})
|
|
|
|
def test_bareOk(self):
|
|
"""
|
|
If the server response to a I{SELECT} or I{EXAMINE} command includes an
|
|
I{OK} with no response code text, parsing does not fail.
|
|
"""
|
|
d = self._examineOrSelect()
|
|
self._response(b"* OK")
|
|
self.assertEqual(self.successResultOf(d), {"READ-WRITE": False})
|
|
|
|
|
|
class IMAP4ClientExamineTests(SelectionTestsMixin, SynchronousTestCase):
|
|
"""
|
|
Tests for the L{IMAP4Client.examine} method.
|
|
|
|
An example of usage of the EXAMINE command from RFC 3501, section 6.3.2::
|
|
|
|
S: * 17 EXISTS
|
|
S: * 2 RECENT
|
|
S: * OK [UNSEEN 8] Message 8 is first unseen
|
|
S: * OK [UIDVALIDITY 3857529045] UIDs valid
|
|
S: * OK [UIDNEXT 4392] Predicted next UID
|
|
S: * FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)
|
|
S: * OK [PERMANENTFLAGS ()] No permanent flags permitted
|
|
S: A932 OK [READ-ONLY] EXAMINE completed
|
|
"""
|
|
|
|
method = "examine"
|
|
command = b"EXAMINE"
|
|
|
|
|
|
class IMAP4ClientSelectTests(SelectionTestsMixin, SynchronousTestCase):
|
|
r"""
|
|
Tests for the L{IMAP4Client.select} method.
|
|
|
|
An example of usage of the SELECT command from RFC 3501, section 6.3.1::
|
|
|
|
C: A142 SELECT INBOX
|
|
S: * 172 EXISTS
|
|
S: * 1 RECENT
|
|
S: * OK [UNSEEN 12] Message 12 is first unseen
|
|
S: * OK [UIDVALIDITY 3857529045] UIDs valid
|
|
S: * OK [UIDNEXT 4392] Predicted next UID
|
|
S: * FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
|
|
S: * OK [PERMANENTFLAGS (\Deleted \Seen \*)] Limited
|
|
S: A142 OK [READ-WRITE] SELECT completed
|
|
"""
|
|
|
|
method = "select"
|
|
command = b"SELECT"
|
|
|
|
|
|
class IMAP4ClientExpungeTests(PreauthIMAP4ClientMixin, SynchronousTestCase):
|
|
"""
|
|
Tests for the L{IMAP4Client.expunge} method.
|
|
|
|
An example of usage of the EXPUNGE command from RFC 3501, section 6.4.3::
|
|
|
|
C: A202 EXPUNGE
|
|
S: * 3 EXPUNGE
|
|
S: * 3 EXPUNGE
|
|
S: * 5 EXPUNGE
|
|
S: * 8 EXPUNGE
|
|
S: A202 OK EXPUNGE completed
|
|
"""
|
|
|
|
def _expunge(self):
|
|
d = self.client.expunge()
|
|
self.assertEqual(self.transport.value(), b"0001 EXPUNGE\r\n")
|
|
self.transport.clear()
|
|
return d
|
|
|
|
def _response(self, sequenceNumbers):
|
|
for number in sequenceNumbers:
|
|
self.client.lineReceived(networkString(f"* {number} EXPUNGE"))
|
|
self.client.lineReceived(b"0001 OK EXPUNGE COMPLETED")
|
|
|
|
def test_expunge(self):
|
|
"""
|
|
L{IMAP4Client.expunge} sends the I{EXPUNGE} command and returns a
|
|
L{Deferred} which fires with a C{list} of message sequence numbers
|
|
given by the server's response.
|
|
"""
|
|
d = self._expunge()
|
|
self._response([3, 3, 5, 8])
|
|
self.assertEqual(self.successResultOf(d), [3, 3, 5, 8])
|
|
|
|
def test_nonIntegerExpunged(self):
|
|
"""
|
|
If the server responds with a non-integer where a message sequence
|
|
number is expected, the L{Deferred} returned by L{IMAP4Client.expunge}
|
|
fails with L{IllegalServerResponse}.
|
|
"""
|
|
d = self._expunge()
|
|
self._response([3, 3, "foo", 8])
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
|
|
class IMAP4ClientSearchTests(PreauthIMAP4ClientMixin, SynchronousTestCase):
|
|
"""
|
|
Tests for the L{IMAP4Client.search} method.
|
|
|
|
An example of usage of the SEARCH command from RFC 3501, section 6.4.4::
|
|
|
|
C: A282 SEARCH FLAGGED SINCE 1-Feb-1994 NOT FROM "Smith"
|
|
S: * SEARCH 2 84 882
|
|
S: A282 OK SEARCH completed
|
|
C: A283 SEARCH TEXT "string not in mailbox"
|
|
S: * SEARCH
|
|
S: A283 OK SEARCH completed
|
|
C: A284 SEARCH CHARSET UTF-8 TEXT {6}
|
|
C: XXXXXX
|
|
S: * SEARCH 43
|
|
S: A284 OK SEARCH completed
|
|
"""
|
|
|
|
def _search(self):
|
|
d = self.client.search(imap4.Query(text="ABCDEF"))
|
|
self.assertEqual(self.transport.value(), b'0001 SEARCH (TEXT "ABCDEF")\r\n')
|
|
return d
|
|
|
|
def _response(self, messageNumbers):
|
|
self.client.lineReceived(
|
|
b"* SEARCH " + networkString(" ".join(map(str, messageNumbers)))
|
|
)
|
|
self.client.lineReceived(b"0001 OK SEARCH completed")
|
|
|
|
def test_search(self):
|
|
"""
|
|
L{IMAP4Client.search} sends the I{SEARCH} command and returns a
|
|
L{Deferred} which fires with a C{list} of message sequence numbers
|
|
given by the server's response.
|
|
"""
|
|
d = self._search()
|
|
self._response([2, 5, 10])
|
|
self.assertEqual(self.successResultOf(d), [2, 5, 10])
|
|
|
|
def test_nonIntegerFound(self):
|
|
"""
|
|
If the server responds with a non-integer where a message sequence
|
|
number is expected, the L{Deferred} returned by L{IMAP4Client.search}
|
|
fails with L{IllegalServerResponse}.
|
|
"""
|
|
d = self._search()
|
|
self._response([2, "foo", 10])
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
|
|
class IMAP4ClientFetchTests(PreauthIMAP4ClientMixin, SynchronousTestCase):
|
|
"""
|
|
Tests for the L{IMAP4Client.fetch} method.
|
|
|
|
See RFC 3501, section 6.4.5.
|
|
"""
|
|
|
|
def test_fetchUID(self):
|
|
"""
|
|
L{IMAP4Client.fetchUID} sends the I{FETCH UID} command and returns a
|
|
L{Deferred} which fires with a C{dict} mapping message sequence numbers
|
|
to C{dict}s mapping C{'UID'} to that message's I{UID} in the server's
|
|
response.
|
|
"""
|
|
d = self.client.fetchUID("1:7")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 1:7 (UID)\r\n")
|
|
self.client.lineReceived(b"* 2 FETCH (UID 22)")
|
|
self.client.lineReceived(b"* 3 FETCH (UID 23)")
|
|
self.client.lineReceived(b"* 4 FETCH (UID 24)")
|
|
self.client.lineReceived(b"* 5 FETCH (UID 25)")
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(
|
|
self.successResultOf(d),
|
|
{2: {"UID": "22"}, 3: {"UID": "23"}, 4: {"UID": "24"}, 5: {"UID": "25"}},
|
|
)
|
|
|
|
def test_fetchUIDNonIntegerFound(self):
|
|
"""
|
|
If the server responds with a non-integer where a message sequence
|
|
number is expected, the L{Deferred} returned by L{IMAP4Client.fetchUID}
|
|
fails with L{IllegalServerResponse}.
|
|
"""
|
|
d = self.client.fetchUID("1")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 1 (UID)\r\n")
|
|
self.client.lineReceived(b"* foo FETCH (UID 22)")
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_incompleteFetchUIDResponse(self):
|
|
"""
|
|
If the server responds with an incomplete I{FETCH} response line, the
|
|
L{Deferred} returned by L{IMAP4Client.fetchUID} fails with
|
|
L{IllegalServerResponse}.
|
|
"""
|
|
d = self.client.fetchUID("1:7")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 1:7 (UID)\r\n")
|
|
self.client.lineReceived(b"* 2 FETCH (UID 22)")
|
|
self.client.lineReceived(b"* 3 FETCH (UID)")
|
|
self.client.lineReceived(b"* 4 FETCH (UID 24)")
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_fetchBody(self):
|
|
"""
|
|
L{IMAP4Client.fetchBody} sends the I{FETCH BODY} command and returns a
|
|
L{Deferred} which fires with a C{dict} mapping message sequence numbers
|
|
to C{dict}s mapping C{'RFC822.TEXT'} to that message's body as given in
|
|
the server's response.
|
|
"""
|
|
d = self.client.fetchBody("3")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 3 (RFC822.TEXT)\r\n")
|
|
self.client.lineReceived(b'* 3 FETCH (RFC822.TEXT "Message text")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(self.successResultOf(d), {3: {"RFC822.TEXT": "Message text"}})
|
|
|
|
def test_fetchSpecific(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific} sends the I{BODY[]} command if no
|
|
parameters beyond the message set to retrieve are given. It returns a
|
|
L{Deferred} which fires with a C{dict} mapping message sequence numbers
|
|
to C{list}s of corresponding message data given by the server's
|
|
response.
|
|
"""
|
|
d = self.client.fetchSpecific("7")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 7 BODY[]\r\n")
|
|
self.client.lineReceived(b'* 7 FETCH (BODY[] "Some body")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(self.successResultOf(d), {7: [["BODY", [], "Some body"]]})
|
|
|
|
def test_fetchSpecificPeek(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific} issues a I{BODY.PEEK[]} command if passed
|
|
C{True} for the C{peek} parameter.
|
|
"""
|
|
d = self.client.fetchSpecific("6", peek=True)
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 6 BODY.PEEK[]\r\n")
|
|
# BODY.PEEK responses are just BODY
|
|
self.client.lineReceived(b'* 6 FETCH (BODY[] "Some body")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(self.successResultOf(d), {6: [["BODY", [], "Some body"]]})
|
|
|
|
def test_fetchSpecificNumbered(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific}, when passed a sequence for
|
|
C{headerNumber}, sends the I{BODY[N.M]} command. It returns a
|
|
L{Deferred} which fires with a C{dict} mapping message sequence numbers
|
|
to C{list}s of corresponding message data given by the server's
|
|
response.
|
|
"""
|
|
d = self.client.fetchSpecific("7", headerNumber=(1, 2, 3))
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 7 BODY[1.2.3]\r\n")
|
|
self.client.lineReceived(b'* 7 FETCH (BODY[1.2.3] "Some body")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(
|
|
self.successResultOf(d), {7: [["BODY", ["1.2.3"], "Some body"]]}
|
|
)
|
|
|
|
def test_fetchSpecificText(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific}, when passed C{'TEXT'} for C{headerType},
|
|
sends the I{BODY[TEXT]} command. It returns a L{Deferred} which fires
|
|
with a C{dict} mapping message sequence numbers to C{list}s of
|
|
corresponding message data given by the server's response.
|
|
"""
|
|
d = self.client.fetchSpecific("8", headerType="TEXT")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 8 BODY[TEXT]\r\n")
|
|
self.client.lineReceived(b'* 8 FETCH (BODY[TEXT] "Some body")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(
|
|
self.successResultOf(d), {8: [["BODY", ["TEXT"], "Some body"]]}
|
|
)
|
|
|
|
def test_fetchSpecificNumberedText(self):
|
|
"""
|
|
If passed a value for the C{headerNumber} parameter and C{'TEXT'} for
|
|
the C{headerType} parameter, L{IMAP4Client.fetchSpecific} sends a
|
|
I{BODY[number.TEXT]} request and returns a L{Deferred} which fires with
|
|
a C{dict} mapping message sequence numbers to C{list}s of message data
|
|
given by the server's response.
|
|
"""
|
|
d = self.client.fetchSpecific("4", headerType="TEXT", headerNumber=7)
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 4 BODY[7.TEXT]\r\n")
|
|
self.client.lineReceived(b'* 4 FETCH (BODY[7.TEXT] "Some body")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(
|
|
self.successResultOf(d), {4: [["BODY", ["7.TEXT"], "Some body"]]}
|
|
)
|
|
|
|
def test_incompleteFetchSpecificTextResponse(self):
|
|
"""
|
|
If the server responds to a I{BODY[TEXT]} request with a I{FETCH} line
|
|
which is truncated after the I{BODY[TEXT]} tokens, the L{Deferred}
|
|
returned by L{IMAP4Client.fetchUID} fails with
|
|
L{IllegalServerResponse}.
|
|
"""
|
|
d = self.client.fetchSpecific("8", headerType="TEXT")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 8 BODY[TEXT]\r\n")
|
|
self.client.lineReceived(b"* 8 FETCH (BODY[TEXT])")
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_fetchSpecificMIME(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific}, when passed C{'MIME'} for C{headerType},
|
|
sends the I{BODY[MIME]} command. It returns a L{Deferred} which fires
|
|
with a C{dict} mapping message sequence numbers to C{list}s of
|
|
corresponding message data given by the server's response.
|
|
"""
|
|
d = self.client.fetchSpecific("8", headerType="MIME")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 8 BODY[MIME]\r\n")
|
|
self.client.lineReceived(b'* 8 FETCH (BODY[MIME] "Some body")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(
|
|
self.successResultOf(d), {8: [["BODY", ["MIME"], "Some body"]]}
|
|
)
|
|
|
|
def test_fetchSpecificPartial(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific}, when passed C{offset} and C{length},
|
|
sends a partial content request (like I{BODY[TEXT]<offset.length>}).
|
|
It returns a L{Deferred} which fires with a C{dict} mapping message
|
|
sequence numbers to C{list}s of corresponding message data given by the
|
|
server's response.
|
|
"""
|
|
d = self.client.fetchSpecific("9", headerType="TEXT", offset=17, length=3)
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 9 BODY[TEXT]<17.3>\r\n")
|
|
self.client.lineReceived(b'* 9 FETCH (BODY[TEXT]<17> "foo")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(
|
|
self.successResultOf(d), {9: [["BODY", ["TEXT"], "<17>", "foo"]]}
|
|
)
|
|
|
|
def test_incompleteFetchSpecificPartialResponse(self):
|
|
"""
|
|
If the server responds to a I{BODY[TEXT]} request with a I{FETCH} line
|
|
which is truncated after the I{BODY[TEXT]<offset>} tokens, the
|
|
L{Deferred} returned by L{IMAP4Client.fetchUID} fails with
|
|
L{IllegalServerResponse}.
|
|
"""
|
|
d = self.client.fetchSpecific("8", headerType="TEXT")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 8 BODY[TEXT]\r\n")
|
|
self.client.lineReceived(b"* 8 FETCH (BODY[TEXT]<17>)")
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
def test_fetchSpecificHTML(self):
|
|
"""
|
|
If the body of a message begins with I{<} and ends with I{>} (as,
|
|
for example, HTML bodies typically will), this is still interpreted
|
|
as the body by L{IMAP4Client.fetchSpecific} (and particularly, not
|
|
as a length indicator for a response to a request for a partial
|
|
body).
|
|
"""
|
|
d = self.client.fetchSpecific("7")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 7 BODY[]\r\n")
|
|
self.client.lineReceived(b'* 7 FETCH (BODY[] "<html>test</html>")')
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(
|
|
self.successResultOf(d), {7: [["BODY", [], "<html>test</html>"]]}
|
|
)
|
|
|
|
def assertFetchSpecificFieldsWithEmptyList(self, section):
|
|
"""
|
|
Assert that the provided C{BODY} section, when invoked with no
|
|
arguments, produces an empty list, and that it returns a
|
|
L{Deferred} which fires with a C{dict} mapping message
|
|
sequence numbers to C{list}s of corresponding message data
|
|
given by the server's response.
|
|
|
|
@param section: The C{BODY} section to test: either
|
|
C{'HEADER.FIELDS'} or C{'HEADER.FIELDS.NOT'}
|
|
@type section: L{str}
|
|
"""
|
|
d = self.client.fetchSpecific("10", headerType=section)
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"0001 FETCH 10 BODY[" + section.encode("ascii") + b" ()]\r\n",
|
|
)
|
|
# It's unclear what the response would look like - would it be
|
|
# an empty string? No IMAP server parses an empty list of headers
|
|
self.client.lineReceived(
|
|
b"* 10 FETCH (BODY[" + section.encode("ascii") + b' ()] "")'
|
|
)
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(self.successResultOf(d), {10: [["BODY", [section, []], ""]]})
|
|
|
|
def test_fetchSpecificHeaderFieldsWithoutHeaders(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific}, when passed C{'HEADER.FIELDS'}
|
|
for C{headerType} but no C{headerArgs}, sends the
|
|
I{BODY[HEADER.FIELDS]} command with no arguments. It returns
|
|
a L{Deferred} which fires with a C{dict} mapping message
|
|
sequence numbers to C{list}s of corresponding message data
|
|
given by the server's response.
|
|
"""
|
|
self.assertFetchSpecificFieldsWithEmptyList("HEADER.FIELDS")
|
|
|
|
def test_fetchSpecificHeaderFieldsNotWithoutHeaders(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific}, when passed
|
|
C{'HEADER.FIELDS.NOT'} for C{headerType} but no C{headerArgs},
|
|
sends the I{BODY[HEADER.FIELDS.NOT]} command with no
|
|
arguments. It returns a L{Deferred} which fires with a
|
|
C{dict} mapping message sequence numbers to C{list}s of
|
|
corresponding message data given by the server's response.
|
|
"""
|
|
self.assertFetchSpecificFieldsWithEmptyList("HEADER.FIELDS.NOT")
|
|
|
|
def test_fetchSpecificHeader(self):
|
|
"""
|
|
L{IMAP4Client.fetchSpecific}, when passed C{'HEADER'} for
|
|
C{headerType}, sends the I{BODY[HEADER]} command. It returns
|
|
a L{Deferred} which fires with a C{dict} mapping message
|
|
sequence numbers to C{list}s of corresponding message data
|
|
given by the server's response.
|
|
"""
|
|
d = self.client.fetchSpecific("11", headerType="HEADER")
|
|
self.assertEqual(self.transport.value(), b"0001 FETCH 11 BODY[HEADER]\r\n")
|
|
self.client.lineReceived(
|
|
b"* 11 FETCH (BODY[HEADER]"
|
|
b' "From: someone@localhost\r\nSubject: Some subject")'
|
|
)
|
|
self.client.lineReceived(b"0001 OK FETCH completed")
|
|
self.assertEqual(
|
|
self.successResultOf(d),
|
|
{
|
|
11: [
|
|
[
|
|
"BODY",
|
|
["HEADER"],
|
|
"From: someone@localhost\r\nSubject: Some subject",
|
|
]
|
|
]
|
|
},
|
|
)
|
|
|
|
|
|
class IMAP4ClientStoreTests(PreauthIMAP4ClientMixin, TestCase):
|
|
r"""
|
|
Tests for the L{IMAP4Client.setFlags}, L{IMAP4Client.addFlags}, and
|
|
L{IMAP4Client.removeFlags} methods.
|
|
|
|
An example of usage of the STORE command, in terms of which these three
|
|
methods are implemented, from RFC 3501, section 6.4.6::
|
|
|
|
C: A003 STORE 2:4 +FLAGS (\Deleted)
|
|
S: * 2 FETCH (FLAGS (\Deleted \Seen))
|
|
S: * 3 FETCH (FLAGS (\Deleted))
|
|
S: * 4 FETCH (FLAGS (\Deleted \Flagged \Seen))
|
|
S: A003 OK STORE completed
|
|
"""
|
|
|
|
clientProtocol = StillSimplerClient
|
|
|
|
def _flagsTest(self, method, item):
|
|
"""
|
|
Test a non-silent flag modifying method. Call the method, assert that
|
|
the correct bytes are sent, deliver a I{FETCH} response, and assert
|
|
that the result of the Deferred returned by the method is correct.
|
|
|
|
@param method: The name of the method to test.
|
|
@param item: The data item which is expected to be specified.
|
|
"""
|
|
d = getattr(self.client, method)("3", ("\\Read", "\\Seen"), False)
|
|
self.assertEqual(
|
|
self.transport.value(), b"0001 STORE 3 " + item + b" (\\Read \\Seen)\r\n"
|
|
)
|
|
self.client.lineReceived(b"* 3 FETCH (FLAGS (\\Read \\Seen))")
|
|
self.client.lineReceived(b"0001 OK STORE completed")
|
|
self.assertEqual(self.successResultOf(d), {3: {"FLAGS": ["\\Read", "\\Seen"]}})
|
|
|
|
def _flagsSilentlyTest(self, method, item):
|
|
"""
|
|
Test a silent flag modifying method. Call the method, assert that the
|
|
correct bytes are sent, deliver an I{OK} response, and assert that the
|
|
result of the Deferred returned by the method is correct.
|
|
|
|
@param method: The name of the method to test.
|
|
@param item: The data item which is expected to be specified.
|
|
"""
|
|
d = getattr(self.client, method)("3", ("\\Read", "\\Seen"), True)
|
|
self.assertEqual(
|
|
self.transport.value(), b"0001 STORE 3 " + item + b" (\\Read \\Seen)\r\n"
|
|
)
|
|
self.client.lineReceived(b"0001 OK STORE completed")
|
|
self.assertEqual(self.successResultOf(d), {})
|
|
|
|
def _flagsSilentlyWithUnsolicitedDataTest(self, method, item):
|
|
"""
|
|
Test unsolicited data received in response to a silent flag modifying
|
|
method. Call the method, assert that the correct bytes are sent,
|
|
deliver the unsolicited I{FETCH} response, and assert that the result
|
|
of the Deferred returned by the method is correct.
|
|
|
|
@param method: The name of the method to test.
|
|
@param item: The data item which is expected to be specified.
|
|
"""
|
|
d = getattr(self.client, method)("3", ("\\Read", "\\Seen"), True)
|
|
self.assertEqual(
|
|
self.transport.value(), b"0001 STORE 3 " + item + b" (\\Read \\Seen)\r\n"
|
|
)
|
|
self.client.lineReceived(b"* 2 FETCH (FLAGS (\\Read \\Seen))")
|
|
self.client.lineReceived(b"0001 OK STORE completed")
|
|
self.assertEqual(self.successResultOf(d), {})
|
|
self.assertEqual(self.client.flags, {2: ["\\Read", "\\Seen"]})
|
|
|
|
def test_setFlags(self):
|
|
"""
|
|
When passed a C{False} value for the C{silent} parameter,
|
|
L{IMAP4Client.setFlags} sends the I{STORE} command with a I{FLAGS} data
|
|
item and returns a L{Deferred} which fires with a C{dict} mapping
|
|
message sequence numbers to C{dict}s mapping C{'FLAGS'} to the new
|
|
flags of those messages.
|
|
"""
|
|
self._flagsTest("setFlags", b"FLAGS")
|
|
|
|
def test_setFlagsSilently(self):
|
|
"""
|
|
When passed a C{True} value for the C{silent} parameter,
|
|
L{IMAP4Client.setFlags} sends the I{STORE} command with a
|
|
I{FLAGS.SILENT} data item and returns a L{Deferred} which fires with an
|
|
empty dictionary.
|
|
"""
|
|
self._flagsSilentlyTest("setFlags", b"FLAGS.SILENT")
|
|
|
|
def test_setFlagsSilentlyWithUnsolicitedData(self):
|
|
"""
|
|
If unsolicited flag data is received in response to a I{STORE}
|
|
I{FLAGS.SILENT} request, that data is passed to the C{flagsChanged}
|
|
callback.
|
|
"""
|
|
self._flagsSilentlyWithUnsolicitedDataTest("setFlags", b"FLAGS.SILENT")
|
|
|
|
def test_addFlags(self):
|
|
"""
|
|
L{IMAP4Client.addFlags} is like L{IMAP4Client.setFlags}, but sends
|
|
I{+FLAGS} instead of I{FLAGS}.
|
|
"""
|
|
self._flagsTest("addFlags", b"+FLAGS")
|
|
|
|
def test_addFlagsSilently(self):
|
|
"""
|
|
L{IMAP4Client.addFlags} with a C{True} value for C{silent} behaves like
|
|
L{IMAP4Client.setFlags} with a C{True} value for C{silent}, but it
|
|
sends I{+FLAGS.SILENT} instead of I{FLAGS.SILENT}.
|
|
"""
|
|
self._flagsSilentlyTest("addFlags", b"+FLAGS.SILENT")
|
|
|
|
def test_addFlagsSilentlyWithUnsolicitedData(self):
|
|
"""
|
|
L{IMAP4Client.addFlags} behaves like L{IMAP4Client.setFlags} when used
|
|
in silent mode and unsolicited data is received.
|
|
"""
|
|
self._flagsSilentlyWithUnsolicitedDataTest("addFlags", b"+FLAGS.SILENT")
|
|
|
|
def test_removeFlags(self):
|
|
"""
|
|
L{IMAP4Client.removeFlags} is like L{IMAP4Client.setFlags}, but sends
|
|
I{-FLAGS} instead of I{FLAGS}.
|
|
"""
|
|
self._flagsTest("removeFlags", b"-FLAGS")
|
|
|
|
def test_removeFlagsSilently(self):
|
|
"""
|
|
L{IMAP4Client.removeFlags} with a C{True} value for C{silent} behaves
|
|
like L{IMAP4Client.setFlags} with a C{True} value for C{silent}, but it
|
|
sends I{-FLAGS.SILENT} instead of I{FLAGS.SILENT}.
|
|
"""
|
|
self._flagsSilentlyTest("removeFlags", b"-FLAGS.SILENT")
|
|
|
|
def test_removeFlagsSilentlyWithUnsolicitedData(self):
|
|
"""
|
|
L{IMAP4Client.removeFlags} behaves like L{IMAP4Client.setFlags} when
|
|
used in silent mode and unsolicited data is received.
|
|
"""
|
|
self._flagsSilentlyWithUnsolicitedDataTest("removeFlags", b"-FLAGS.SILENT")
|
|
|
|
|
|
class IMAP4ClientStatusTests(PreauthIMAP4ClientMixin, SynchronousTestCase):
|
|
"""
|
|
Tests for the L{IMAP4Client.status} method.
|
|
|
|
An example of usage of the STATUS command from RFC 3501, section
|
|
5.1.2::
|
|
|
|
C: A042 STATUS blurdybloop (UIDNEXT MESSAGES)
|
|
S: * STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292)
|
|
S: A042 OK STATUS completed
|
|
|
|
@see: U{https://tools.ietf.org/html/rfc3501#section-5.1.2}
|
|
"""
|
|
|
|
def testUnknownName(self):
|
|
"""
|
|
Only allow sending the C{STATUS} names defined in RFC 3501.
|
|
|
|
@see: U{https://tools.ietf.org/html/rfc3501#section-5.1.2}
|
|
"""
|
|
exc = self.assertRaises(
|
|
ValueError,
|
|
self.client.status,
|
|
"ignored",
|
|
"IMPOSSIBLE?!",
|
|
)
|
|
self.assertEqual(str(exc), "Unknown names: " + repr({"IMPOSSIBLE?!"}))
|
|
|
|
def testUndecodableName(self):
|
|
"""
|
|
C{STATUS} names that cannot be decoded as ASCII cause the
|
|
status Deferred to fail with L{IllegalServerResponse}
|
|
"""
|
|
|
|
d = self.client.status("blurdybloop", "MESSAGES")
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"0001 STATUS blurdybloop (MESSAGES)\r\n",
|
|
)
|
|
|
|
self.client.lineReceived(
|
|
b"* STATUS blurdybloop " b'(MESSAGES 1 ASCIINAME "OK" NOT\xffASCII "NO")'
|
|
)
|
|
self.client.lineReceived(b"0001 OK STATUS completed")
|
|
self.failureResultOf(d, imap4.IllegalServerResponse)
|
|
|
|
|
|
class IMAP4ClientCopyTests(PreauthIMAP4ClientMixin, SynchronousTestCase):
|
|
"""
|
|
Tests for the L{IMAP4Client.copy} method.
|
|
|
|
An example of the C{COPY} command, which this method implements,
|
|
from RFC 3501, section 6.4.7::
|
|
|
|
C: A003 COPY 2:4 MEETING
|
|
S: A003 OK COPY completed
|
|
"""
|
|
|
|
clientProtocol = StillSimplerClient
|
|
|
|
def test_copySequenceNumbers(self):
|
|
"""
|
|
L{IMAP4Client.copy} copies the messages identified by their
|
|
sequence numbers to the mailbox, returning a L{Deferred} that
|
|
succeeds with a true value.
|
|
"""
|
|
d = self.client.copy("2:3", "MEETING", uid=False)
|
|
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"0001 COPY 2:3 MEETING\r\n",
|
|
)
|
|
|
|
self.client.lineReceived(b"0001 OK COPY completed")
|
|
self.assertEqual(self.successResultOf(d), ([], b"OK COPY completed"))
|
|
|
|
def test_copySequenceNumbersFails(self):
|
|
"""
|
|
L{IMAP4Client.copy} returns a L{Deferred} that fails with an
|
|
L{IMAP4Exception} when the messages specified by the given
|
|
sequence numbers could not be copied to the mailbox.
|
|
"""
|
|
d = self.client.copy("2:3", "MEETING", uid=False)
|
|
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"0001 COPY 2:3 MEETING\r\n",
|
|
)
|
|
|
|
self.client.lineReceived(b"0001 BAD COPY failed")
|
|
self.assertIsInstance(self.failureResultOf(d).value, imap4.IMAP4Exception)
|
|
|
|
def test_copyUIDs(self):
|
|
"""
|
|
L{IMAP4Client.copy} copies the messages identified by their
|
|
UIDs to the mailbox, returning a L{Deferred} that succeeds
|
|
with a true value.
|
|
"""
|
|
d = self.client.copy("2:3", "MEETING", uid=True)
|
|
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"0001 UID COPY 2:3 MEETING\r\n",
|
|
)
|
|
|
|
self.client.lineReceived(b"0001 OK COPY completed")
|
|
self.assertEqual(self.successResultOf(d), ([], b"OK COPY completed"))
|
|
|
|
def test_copyUIDsFails(self):
|
|
"""
|
|
L{IMAP4Client.copy} returns a L{Deferred} that fails with an
|
|
L{IMAP4Exception} when the messages specified by the given
|
|
UIDs could not be copied to the mailbox.
|
|
"""
|
|
d = self.client.copy("2:3", "MEETING", uid=True)
|
|
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"0001 UID COPY 2:3 MEETING\r\n",
|
|
)
|
|
|
|
self.client.lineReceived(b"0001 BAD COPY failed")
|
|
self.assertIsInstance(self.failureResultOf(d).value, imap4.IMAP4Exception)
|
|
|
|
|
|
class FakeyServer(imap4.IMAP4Server):
|
|
state = "select"
|
|
timeout = None
|
|
|
|
def sendServerGreeting(self):
|
|
pass
|
|
|
|
|
|
@implementer(imap4.IMessage)
|
|
class FakeyMessage(util.FancyStrMixin):
|
|
showAttributes = ("headers", "flags", "date", "_body", "uid")
|
|
|
|
def __init__(self, headers, flags, date, body, uid, subpart):
|
|
self.headers = headers
|
|
self.flags = flags
|
|
self._body = body
|
|
self.size = len(body)
|
|
self.date = date
|
|
self.uid = uid
|
|
self.subpart = subpart
|
|
|
|
def getHeaders(self, negate, *names):
|
|
self.got_headers = negate, names
|
|
return self.headers
|
|
|
|
def getFlags(self):
|
|
return self.flags
|
|
|
|
def getInternalDate(self):
|
|
return self.date
|
|
|
|
def getBodyFile(self):
|
|
return BytesIO(self._body)
|
|
|
|
def getSize(self):
|
|
return self.size
|
|
|
|
def getUID(self):
|
|
return self.uid
|
|
|
|
def isMultipart(self):
|
|
return self.subpart is not None
|
|
|
|
def getSubPart(self, part):
|
|
self.got_subpart = part
|
|
return self.subpart[part]
|
|
|
|
|
|
class NewStoreTests(TestCase, IMAP4HelperMixin):
|
|
result = None
|
|
storeArgs = None
|
|
|
|
def setUp(self):
|
|
self.received_messages = self.received_uid = None
|
|
|
|
self.server = imap4.IMAP4Server()
|
|
self.server.state = "select"
|
|
self.server.mbox = self
|
|
self.connected = defer.Deferred()
|
|
self.client = SimpleClient(self.connected)
|
|
|
|
def addListener(self, x):
|
|
pass
|
|
|
|
def removeListener(self, x):
|
|
pass
|
|
|
|
def store(self, *args, **kw):
|
|
self.storeArgs = args, kw
|
|
return self.response
|
|
|
|
def _storeWork(self):
|
|
def connected():
|
|
return self.function(self.messages, self.flags, self.silent, self.uid)
|
|
|
|
def result(R):
|
|
self.result = R
|
|
|
|
self.connected.addCallback(strip(connected)).addCallback(result).addCallback(
|
|
self._cbStopClient
|
|
).addErrback(self._ebGeneral)
|
|
|
|
def check(ignored):
|
|
self.assertEqual(self.result, self.expected)
|
|
self.assertEqual(self.storeArgs, self.expectedArgs)
|
|
|
|
d = loopback.loopbackTCP(self.server, self.client, noisy=False)
|
|
d.addCallback(check)
|
|
return d
|
|
|
|
def testSetFlags(self, uid=0):
|
|
self.function = self.client.setFlags
|
|
self.messages = "1,5,9"
|
|
self.flags = ["\\A", "\\B", "C"]
|
|
self.silent = False
|
|
self.uid = uid
|
|
self.response = {
|
|
1: ["\\A", "\\B", "C"],
|
|
5: ["\\A", "\\B", "C"],
|
|
9: ["\\A", "\\B", "C"],
|
|
}
|
|
self.expected = {
|
|
1: {"FLAGS": ["\\A", "\\B", "C"]},
|
|
5: {"FLAGS": ["\\A", "\\B", "C"]},
|
|
9: {"FLAGS": ["\\A", "\\B", "C"]},
|
|
}
|
|
msg = imap4.MessageSet()
|
|
msg.add(1)
|
|
msg.add(5)
|
|
msg.add(9)
|
|
self.expectedArgs = ((msg, ["\\A", "\\B", "C"], 0), {"uid": 0})
|
|
return self._storeWork()
|
|
|
|
|
|
class GetBodyStructureTests(TestCase):
|
|
"""
|
|
Tests for L{imap4.getBodyStructure}, a helper for constructing a list which
|
|
directly corresponds to the wire information needed for a I{BODY} or
|
|
I{BODYSTRUCTURE} response.
|
|
"""
|
|
|
|
def test_singlePart(self):
|
|
"""
|
|
L{imap4.getBodyStructure} accepts a L{IMessagePart} provider and returns
|
|
a list giving the basic fields for the I{BODY} response for that
|
|
message.
|
|
"""
|
|
body = b"hello, world"
|
|
major = "image"
|
|
minor = "jpeg"
|
|
charset = "us-ascii"
|
|
identifier = "some kind of id"
|
|
description = "great justice"
|
|
encoding = "maximum"
|
|
msg = FakeyMessage(
|
|
{
|
|
"content-type": major + "/" + minor + "; charset=" + charset + "; x=y",
|
|
"content-id": identifier,
|
|
"content-description": description,
|
|
"content-transfer-encoding": encoding,
|
|
},
|
|
(),
|
|
b"",
|
|
body,
|
|
123,
|
|
None,
|
|
)
|
|
structure = imap4.getBodyStructure(msg)
|
|
self.assertEqual(
|
|
[
|
|
major,
|
|
minor,
|
|
["charset", charset, "x", "y"],
|
|
identifier,
|
|
description,
|
|
encoding,
|
|
len(body),
|
|
],
|
|
structure,
|
|
)
|
|
|
|
def test_emptyContentType(self):
|
|
"""
|
|
L{imap4.getBodyStructure} returns L{None} for the major and
|
|
minor MIME types of a L{IMessagePart} provider whose headers
|
|
lack a C{Content-Type}, or have an empty value for it.
|
|
"""
|
|
missing = FakeyMessage({}, (), b"", b"", 123, None)
|
|
missingContentTypeStructure = imap4.getBodyStructure(missing)
|
|
missingMajor, missingMinor = missingContentTypeStructure[:2]
|
|
self.assertIs(None, missingMajor)
|
|
self.assertIs(None, missingMinor)
|
|
|
|
empty = FakeyMessage({"content-type": ""}, (), b"", b"", 123, None)
|
|
emptyContentTypeStructure = imap4.getBodyStructure(empty)
|
|
emptyMajor, emptyMinor = emptyContentTypeStructure[:2]
|
|
self.assertIs(None, emptyMajor)
|
|
self.assertIs(None, emptyMinor)
|
|
|
|
newline = FakeyMessage({"content-type": "\n"}, (), b"", b"", 123, None)
|
|
newlineContentTypeStructure = imap4.getBodyStructure(newline)
|
|
newlineMajor, newlineMinor = newlineContentTypeStructure[:2]
|
|
self.assertIs(None, newlineMajor)
|
|
self.assertIs(None, newlineMinor)
|
|
|
|
def test_onlyMajorContentType(self):
|
|
"""
|
|
L{imap4.getBodyStructure} returns only a non-L{None} major
|
|
MIME type for a L{IMessagePart} provider whose headers only
|
|
have a main a C{Content-Type}.
|
|
"""
|
|
main = FakeyMessage({"content-type": "main"}, (), b"", b"", 123, None)
|
|
mainStructure = imap4.getBodyStructure(main)
|
|
mainMajor, mainMinor = mainStructure[:2]
|
|
self.assertEqual(mainMajor, "main")
|
|
self.assertIs(mainMinor, None)
|
|
|
|
def test_singlePartExtended(self):
|
|
"""
|
|
L{imap4.getBodyStructure} returns a list giving the basic and extended
|
|
fields for a I{BODYSTRUCTURE} response if passed C{True} for the
|
|
C{extended} parameter.
|
|
"""
|
|
body = b"hello, world"
|
|
major = "image"
|
|
minor = "jpeg"
|
|
charset = "us-ascii"
|
|
identifier = "some kind of id"
|
|
description = "great justice"
|
|
encoding = "maximum"
|
|
md5 = "abcdefabcdef"
|
|
msg = FakeyMessage(
|
|
{
|
|
"content-type": major + "/" + minor + "; charset=" + charset + "; x=y",
|
|
"content-id": identifier,
|
|
"content-description": description,
|
|
"content-transfer-encoding": encoding,
|
|
"content-md5": md5,
|
|
"content-disposition": "attachment; name=foo; size=bar",
|
|
"content-language": "fr",
|
|
"content-location": "France",
|
|
},
|
|
(),
|
|
"",
|
|
body,
|
|
123,
|
|
None,
|
|
)
|
|
structure = imap4.getBodyStructure(msg, extended=True)
|
|
self.assertEqual(
|
|
[
|
|
major,
|
|
minor,
|
|
["charset", charset, "x", "y"],
|
|
identifier,
|
|
description,
|
|
encoding,
|
|
len(body),
|
|
md5,
|
|
["attachment", ["name", "foo", "size", "bar"]],
|
|
"fr",
|
|
"France",
|
|
],
|
|
structure,
|
|
)
|
|
|
|
def test_singlePartWithMissing(self):
|
|
"""
|
|
For fields with no information contained in the message headers,
|
|
L{imap4.getBodyStructure} fills in L{None} values in its result.
|
|
"""
|
|
major = "image"
|
|
minor = "jpeg"
|
|
body = b"hello, world"
|
|
msg = FakeyMessage(
|
|
{"content-type": major + "/" + minor}, (), b"", body, 123, None
|
|
)
|
|
structure = imap4.getBodyStructure(msg, extended=True)
|
|
self.assertEqual(
|
|
[major, minor, None, None, None, None, len(body), None, None, None, None],
|
|
structure,
|
|
)
|
|
|
|
def test_textPart(self):
|
|
"""
|
|
For a I{text/*} message, the number of lines in the message body are
|
|
included after the common single-part basic fields.
|
|
"""
|
|
body = b"hello, world\nhow are you?\ngoodbye\n"
|
|
major = "text"
|
|
minor = "jpeg"
|
|
charset = "us-ascii"
|
|
identifier = "some kind of id"
|
|
description = "great justice"
|
|
encoding = "maximum"
|
|
msg = FakeyMessage(
|
|
{
|
|
"content-type": major + "/" + minor + "; charset=" + charset + "; x=y",
|
|
"content-id": identifier,
|
|
"content-description": description,
|
|
"content-transfer-encoding": encoding,
|
|
},
|
|
(),
|
|
b"",
|
|
body,
|
|
123,
|
|
None,
|
|
)
|
|
structure = imap4.getBodyStructure(msg)
|
|
self.assertEqual(
|
|
[
|
|
major,
|
|
minor,
|
|
["charset", charset, "x", "y"],
|
|
identifier,
|
|
description,
|
|
encoding,
|
|
len(body),
|
|
len(body.splitlines()),
|
|
],
|
|
structure,
|
|
)
|
|
|
|
def test_rfc822Message(self):
|
|
"""
|
|
For a I{message/rfc822} message, the common basic fields are followed
|
|
by information about the contained message.
|
|
"""
|
|
body = b"hello, world\nhow are you?\ngoodbye\n"
|
|
major = "text"
|
|
minor = "jpeg"
|
|
charset = "us-ascii"
|
|
identifier = "some kind of id"
|
|
description = "great justice"
|
|
encoding = "maximum"
|
|
msg = FakeyMessage(
|
|
{
|
|
"content-type": major + "/" + minor + "; charset=" + charset + "; x=y",
|
|
"from": "Alice <alice@example.com>",
|
|
"to": "Bob <bob@example.com>",
|
|
"content-id": identifier,
|
|
"content-description": description,
|
|
"content-transfer-encoding": encoding,
|
|
},
|
|
(),
|
|
"",
|
|
body,
|
|
123,
|
|
None,
|
|
)
|
|
|
|
container = FakeyMessage(
|
|
{
|
|
"content-type": "message/rfc822",
|
|
},
|
|
(),
|
|
b"",
|
|
b"",
|
|
123,
|
|
[msg],
|
|
)
|
|
|
|
structure = imap4.getBodyStructure(container)
|
|
self.assertEqual(
|
|
[
|
|
"message",
|
|
"rfc822",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
0,
|
|
imap4.getEnvelope(msg),
|
|
imap4.getBodyStructure(msg),
|
|
3,
|
|
],
|
|
structure,
|
|
)
|
|
|
|
def test_multiPart(self):
|
|
"""
|
|
For a I{multipart/*} message, L{imap4.getBodyStructure} returns a list
|
|
containing the body structure information for each part of the message
|
|
followed by an element giving the MIME subtype of the message.
|
|
"""
|
|
oneSubPart = FakeyMessage(
|
|
{
|
|
"content-type": "image/jpeg; x=y",
|
|
"content-id": "some kind of id",
|
|
"content-description": "great justice",
|
|
"content-transfer-encoding": "maximum",
|
|
},
|
|
(),
|
|
b"",
|
|
b"hello world",
|
|
123,
|
|
None,
|
|
)
|
|
|
|
anotherSubPart = FakeyMessage(
|
|
{
|
|
"content-type": "text/plain; charset=us-ascii",
|
|
},
|
|
(),
|
|
b"",
|
|
b"some stuff",
|
|
321,
|
|
None,
|
|
)
|
|
|
|
container = FakeyMessage(
|
|
{
|
|
"content-type": "multipart/related",
|
|
},
|
|
(),
|
|
b"",
|
|
b"",
|
|
555,
|
|
[oneSubPart, anotherSubPart],
|
|
)
|
|
|
|
self.assertEqual(
|
|
[
|
|
imap4.getBodyStructure(oneSubPart),
|
|
imap4.getBodyStructure(anotherSubPart),
|
|
"related",
|
|
],
|
|
imap4.getBodyStructure(container),
|
|
)
|
|
|
|
def test_multiPartExtended(self):
|
|
"""
|
|
When passed a I{multipart/*} message and C{True} for the C{extended}
|
|
argument, L{imap4.getBodyStructure} includes extended structure
|
|
information from the parts of the multipart message and extended
|
|
structure information about the multipart message itself.
|
|
"""
|
|
oneSubPart = FakeyMessage(
|
|
{
|
|
b"content-type": b"image/jpeg; x=y",
|
|
b"content-id": b"some kind of id",
|
|
b"content-description": b"great justice",
|
|
b"content-transfer-encoding": b"maximum",
|
|
},
|
|
(),
|
|
b"",
|
|
b"hello world",
|
|
123,
|
|
None,
|
|
)
|
|
|
|
anotherSubPart = FakeyMessage(
|
|
{
|
|
b"content-type": b"text/plain; charset=us-ascii",
|
|
},
|
|
(),
|
|
b"",
|
|
b"some stuff",
|
|
321,
|
|
None,
|
|
)
|
|
|
|
container = FakeyMessage(
|
|
{
|
|
"content-type": "multipart/related; foo=bar",
|
|
"content-language": "es",
|
|
"content-location": "Spain",
|
|
"content-disposition": "attachment; name=monkeys",
|
|
},
|
|
(),
|
|
b"",
|
|
b"",
|
|
555,
|
|
[oneSubPart, anotherSubPart],
|
|
)
|
|
|
|
self.assertEqual(
|
|
[
|
|
imap4.getBodyStructure(oneSubPart, extended=True),
|
|
imap4.getBodyStructure(anotherSubPart, extended=True),
|
|
"related",
|
|
["foo", "bar"],
|
|
["attachment", ["name", "monkeys"]],
|
|
"es",
|
|
"Spain",
|
|
],
|
|
imap4.getBodyStructure(container, extended=True),
|
|
)
|
|
|
|
|
|
class NewFetchTests(TestCase, IMAP4HelperMixin):
|
|
def setUp(self):
|
|
self.received_messages = self.received_uid = None
|
|
self.result = None
|
|
|
|
self.server = imap4.IMAP4Server()
|
|
self.server.state = "select"
|
|
self.server.mbox = self
|
|
self.connected = defer.Deferred()
|
|
self.client = SimpleClient(self.connected)
|
|
|
|
def addListener(self, x):
|
|
pass
|
|
|
|
def removeListener(self, x):
|
|
pass
|
|
|
|
def fetch(self, messages, uid):
|
|
self.received_messages = messages
|
|
self.received_uid = uid
|
|
return iter(zip(range(len(self.msgObjs)), self.msgObjs))
|
|
|
|
def _fetchWork(self, uid):
|
|
if uid:
|
|
for i, msg in zip(range(len(self.msgObjs)), self.msgObjs):
|
|
self.expected[i]["UID"] = str(msg.getUID())
|
|
|
|
def result(R):
|
|
self.result = R
|
|
|
|
self.connected.addCallback(
|
|
lambda _: self.function(self.messages, uid)
|
|
).addCallback(result).addCallback(self._cbStopClient).addErrback(
|
|
self._ebGeneral
|
|
)
|
|
|
|
d = loopback.loopbackTCP(self.server, self.client, noisy=False)
|
|
d.addCallback(lambda x: self.assertEqual(self.result, self.expected))
|
|
return d
|
|
|
|
def testFetchUID(self):
|
|
self.function = lambda m, u: self.client.fetchUID(m)
|
|
|
|
self.messages = "7"
|
|
self.msgObjs = [
|
|
FakeyMessage({}, (), b"", b"", 12345, None),
|
|
FakeyMessage({}, (), b"", b"", 999, None),
|
|
FakeyMessage({}, (), b"", b"", 10101, None),
|
|
]
|
|
self.expected = {
|
|
0: {"UID": "12345"},
|
|
1: {"UID": "999"},
|
|
2: {"UID": "10101"},
|
|
}
|
|
return self._fetchWork(0)
|
|
|
|
def testFetchFlags(self, uid=0):
|
|
self.function = self.client.fetchFlags
|
|
self.messages = "9"
|
|
self.msgObjs = [
|
|
FakeyMessage({}, ["FlagA", "FlagB", "\\FlagC"], b"", b"", 54321, None),
|
|
FakeyMessage({}, ["\\FlagC", "FlagA", "FlagB"], b"", b"", 12345, None),
|
|
]
|
|
self.expected = {
|
|
0: {"FLAGS": ["FlagA", "FlagB", "\\FlagC"]},
|
|
1: {"FLAGS": ["\\FlagC", "FlagA", "FlagB"]},
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchFlagsUID(self):
|
|
return self.testFetchFlags(1)
|
|
|
|
def testFetchInternalDate(self, uid=0):
|
|
self.function = self.client.fetchInternalDate
|
|
self.messages = "13"
|
|
self.msgObjs = [
|
|
FakeyMessage({}, (), b"Fri, 02 Nov 2003 21:25:10 GMT", b"", 23232, None),
|
|
FakeyMessage({}, (), b"Thu, 29 Dec 2013 11:31:52 EST", b"", 101, None),
|
|
FakeyMessage({}, (), b"Mon, 10 Mar 1992 02:44:30 CST", b"", 202, None),
|
|
FakeyMessage({}, (), b"Sat, 11 Jan 2000 14:40:24 PST", b"", 303, None),
|
|
]
|
|
self.expected = {
|
|
0: {"INTERNALDATE": "02-Nov-2003 21:25:10 +0000"},
|
|
1: {"INTERNALDATE": "29-Dec-2013 11:31:52 -0500"},
|
|
2: {"INTERNALDATE": "10-Mar-1992 02:44:30 -0600"},
|
|
3: {"INTERNALDATE": "11-Jan-2000 14:40:24 -0800"},
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchInternalDateUID(self):
|
|
return self.testFetchInternalDate(1)
|
|
|
|
# if alternate locale is not available, the previous test will be skipped,
|
|
# please install this locale for it to run. Avoid using locale.getlocale
|
|
# to learn the current locale; its values don't round-trip well on all
|
|
# platforms. Fortunately setlocale returns a value which does round-trip
|
|
# well.
|
|
currentLocale = locale.setlocale(locale.LC_ALL, None)
|
|
try:
|
|
locale.setlocale(locale.LC_ALL, "es_AR.UTF8")
|
|
except locale.Error:
|
|
noEsARLocale = True
|
|
else:
|
|
locale.setlocale(locale.LC_ALL, currentLocale)
|
|
noEsARLocale = False
|
|
|
|
@skipIf(noEsARLocale, "The es_AR.UTF8 locale is not installed.")
|
|
def test_fetchInternalDateLocaleIndependent(self):
|
|
"""
|
|
The month name in the date is locale independent.
|
|
"""
|
|
# Fake that we're in a language where December is not Dec
|
|
currentLocale = locale.setlocale(locale.LC_ALL, None)
|
|
locale.setlocale(locale.LC_ALL, "es_AR.UTF8")
|
|
self.addCleanup(locale.setlocale, locale.LC_ALL, currentLocale)
|
|
return self.testFetchInternalDate(1)
|
|
|
|
def testFetchEnvelope(self, uid=0):
|
|
self.function = self.client.fetchEnvelope
|
|
self.messages = "15"
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
{
|
|
"from": "user@domain",
|
|
"to": "resu@domain",
|
|
"date": "thursday",
|
|
"subject": "it is a message",
|
|
"message-id": "id-id-id-yayaya",
|
|
},
|
|
(),
|
|
b"",
|
|
b"",
|
|
65656,
|
|
None,
|
|
),
|
|
]
|
|
self.expected = {
|
|
0: {
|
|
"ENVELOPE": [
|
|
"thursday",
|
|
"it is a message",
|
|
[[None, None, "user", "domain"]],
|
|
[[None, None, "user", "domain"]],
|
|
[[None, None, "user", "domain"]],
|
|
[[None, None, "resu", "domain"]],
|
|
None,
|
|
None,
|
|
None,
|
|
"id-id-id-yayaya",
|
|
]
|
|
}
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchEnvelopeUID(self):
|
|
return self.testFetchEnvelope(1)
|
|
|
|
def test_fetchBodyStructure(self, uid=0):
|
|
"""
|
|
L{IMAP4Client.fetchBodyStructure} issues a I{FETCH BODYSTRUCTURE}
|
|
command and returns a Deferred which fires with a structure giving the
|
|
result of parsing the server's response. The structure is a list
|
|
reflecting the parenthesized data sent by the server, as described by
|
|
RFC 3501, section 7.4.2.
|
|
"""
|
|
self.function = self.client.fetchBodyStructure
|
|
self.messages = "3:9,10:*"
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
{
|
|
"content-type": 'text/plain; name=thing; key="value"',
|
|
"content-id": "this-is-the-content-id",
|
|
"content-description": "describing-the-content-goes-here!",
|
|
"content-transfer-encoding": "8BIT",
|
|
"content-md5": "abcdef123456",
|
|
"content-disposition": "attachment; filename=monkeys",
|
|
"content-language": "es",
|
|
"content-location": "http://example.com/monkeys",
|
|
},
|
|
(),
|
|
"",
|
|
b"Body\nText\nGoes\nHere\n",
|
|
919293,
|
|
None,
|
|
)
|
|
]
|
|
self.expected = {
|
|
0: {
|
|
"BODYSTRUCTURE": [
|
|
"text",
|
|
"plain",
|
|
["key", "value", "name", "thing"],
|
|
"this-is-the-content-id",
|
|
"describing-the-content-goes-here!",
|
|
"8BIT",
|
|
"20",
|
|
"4",
|
|
"abcdef123456",
|
|
["attachment", ["filename", "monkeys"]],
|
|
"es",
|
|
"http://example.com/monkeys",
|
|
]
|
|
}
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchBodyStructureUID(self):
|
|
"""
|
|
If passed C{True} for the C{uid} argument, C{fetchBodyStructure} can
|
|
also issue a I{UID FETCH BODYSTRUCTURE} command.
|
|
"""
|
|
return self.test_fetchBodyStructure(1)
|
|
|
|
def test_fetchBodyStructureMultipart(self, uid=0):
|
|
"""
|
|
L{IMAP4Client.fetchBodyStructure} can also parse the response to a
|
|
I{FETCH BODYSTRUCTURE} command for a multipart message.
|
|
"""
|
|
self.function = self.client.fetchBodyStructure
|
|
self.messages = "3:9,10:*"
|
|
innerMessage = FakeyMessage(
|
|
{
|
|
"content-type": 'text/plain; name=thing; key="value"',
|
|
"content-id": "this-is-the-content-id",
|
|
"content-description": "describing-the-content-goes-here!",
|
|
"content-transfer-encoding": "8BIT",
|
|
"content-language": "fr",
|
|
"content-md5": "123456abcdef",
|
|
"content-disposition": "inline",
|
|
"content-location": "outer space",
|
|
},
|
|
(),
|
|
b"",
|
|
b"Body\nText\nGoes\nHere\n",
|
|
919293,
|
|
None,
|
|
)
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
{
|
|
"content-type": 'multipart/mixed; boundary="xyz"',
|
|
"content-language": "en",
|
|
"content-location": "nearby",
|
|
},
|
|
(),
|
|
b"",
|
|
b"",
|
|
919293,
|
|
[innerMessage],
|
|
)
|
|
]
|
|
self.expected = {
|
|
0: {
|
|
"BODYSTRUCTURE": [
|
|
[
|
|
"text",
|
|
"plain",
|
|
["key", "value", "name", "thing"],
|
|
"this-is-the-content-id",
|
|
"describing-the-content-goes-here!",
|
|
"8BIT",
|
|
"20",
|
|
"4",
|
|
"123456abcdef",
|
|
["inline", None],
|
|
"fr",
|
|
"outer space",
|
|
],
|
|
"mixed",
|
|
["boundary", "xyz"],
|
|
None,
|
|
"en",
|
|
"nearby",
|
|
]
|
|
}
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchSimplifiedBody(self, uid=0):
|
|
self.function = self.client.fetchSimplifiedBody
|
|
self.messages = "21"
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
{},
|
|
(),
|
|
b"",
|
|
b"Yea whatever",
|
|
91825,
|
|
[
|
|
FakeyMessage(
|
|
{"content-type": "image/jpg"},
|
|
(),
|
|
b"",
|
|
b"Body Body Body",
|
|
None,
|
|
None,
|
|
)
|
|
],
|
|
)
|
|
]
|
|
self.expected = {0: {"BODY": [None, None, None, None, None, None, "12"]}}
|
|
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchSimplifiedBodyUID(self):
|
|
return self.testFetchSimplifiedBody(1)
|
|
|
|
def testFetchSimplifiedBodyText(self, uid=0):
|
|
self.function = self.client.fetchSimplifiedBody
|
|
self.messages = "21"
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
{"content-type": "text/plain"}, (), b"", b"Yea whatever", 91825, None
|
|
)
|
|
]
|
|
self.expected = {
|
|
0: {"BODY": ["text", "plain", None, None, None, None, "12", "1"]}
|
|
}
|
|
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchSimplifiedBodyTextUID(self):
|
|
return self.testFetchSimplifiedBodyText(1)
|
|
|
|
def testFetchSimplifiedBodyRFC822(self, uid=0):
|
|
self.function = self.client.fetchSimplifiedBody
|
|
self.messages = "21"
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
{"content-type": "message/rfc822"},
|
|
(),
|
|
b"",
|
|
b"Yea whatever",
|
|
91825,
|
|
[
|
|
FakeyMessage(
|
|
{"content-type": "image/jpg"},
|
|
(),
|
|
"",
|
|
b"Body Body Body",
|
|
None,
|
|
None,
|
|
)
|
|
],
|
|
)
|
|
]
|
|
self.expected = {
|
|
0: {
|
|
"BODY": [
|
|
"message",
|
|
"rfc822",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
"12",
|
|
[
|
|
None,
|
|
None,
|
|
[[None, None, None]],
|
|
[[None, None, None]],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
],
|
|
["image", "jpg", None, None, None, None, "14"],
|
|
"1",
|
|
]
|
|
}
|
|
}
|
|
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchSimplifiedBodyRFC822UID(self):
|
|
return self.testFetchSimplifiedBodyRFC822(1)
|
|
|
|
def test_fetchSimplifiedBodyMultipart(self):
|
|
"""
|
|
L{IMAP4Client.fetchSimplifiedBody} returns a dictionary mapping message
|
|
sequence numbers to fetch responses for the corresponding messages. In
|
|
particular, for a multipart message, the value in the dictionary maps
|
|
the string C{"BODY"} to a list giving the body structure information for
|
|
that message, in the form of a list of subpart body structure
|
|
information followed by the subtype of the message (eg C{"alternative"}
|
|
for a I{multipart/alternative} message). This structure is self-similar
|
|
in the case where a subpart is itself multipart.
|
|
"""
|
|
self.function = self.client.fetchSimplifiedBody
|
|
self.messages = "21"
|
|
|
|
# A couple non-multipart messages to use as the inner-most payload
|
|
singles = [
|
|
FakeyMessage(
|
|
{"content-type": "text/plain"}, (), b"date", b"Stuff", 54321, None
|
|
),
|
|
FakeyMessage(
|
|
{"content-type": "text/html"}, (), b"date", b"Things", 32415, None
|
|
),
|
|
]
|
|
|
|
# A multipart/alternative message containing the above non-multipart
|
|
# messages. This will be the payload of the outer-most message.
|
|
alternative = FakeyMessage(
|
|
{"content-type": "multipart/alternative"},
|
|
(),
|
|
b"",
|
|
b"Irrelevant",
|
|
12345,
|
|
singles,
|
|
)
|
|
|
|
# The outer-most message, also with a multipart type, containing just
|
|
# the single middle message.
|
|
mixed = FakeyMessage(
|
|
# The message is multipart/mixed
|
|
{"content-type": "multipart/mixed"},
|
|
(),
|
|
b"",
|
|
b"RootOf",
|
|
98765,
|
|
[alternative],
|
|
)
|
|
|
|
self.msgObjs = [mixed]
|
|
|
|
self.expected = {
|
|
0: {
|
|
"BODY": [
|
|
[
|
|
["text", "plain", None, None, None, None, "5", "1"],
|
|
["text", "html", None, None, None, None, "6", "1"],
|
|
"alternative",
|
|
],
|
|
"mixed",
|
|
]
|
|
}
|
|
}
|
|
|
|
return self._fetchWork(False)
|
|
|
|
def testFetchMessage(self, uid=0):
|
|
self.function = self.client.fetchMessage
|
|
self.messages = "1,3,7,10101"
|
|
self.msgObjs = [
|
|
FakeyMessage({"Header": "Value"}, (), b"", b"BODY TEXT\r\n", 91, None),
|
|
]
|
|
self.expected = {0: {"RFC822": "Header: Value\r\n\r\nBODY TEXT\r\n"}}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchMessageUID(self):
|
|
return self.testFetchMessage(1)
|
|
|
|
def testFetchHeaders(self, uid=0):
|
|
self.function = self.client.fetchHeaders
|
|
self.messages = "9,6,2"
|
|
self.msgObjs = [
|
|
FakeyMessage({"H1": "V1", "H2": "V2"}, (), b"", b"", 99, None),
|
|
]
|
|
|
|
headers = nativeString(imap4._formatHeaders({"H1": "V1", "H2": "V2"}))
|
|
|
|
self.expected = {
|
|
0: {"RFC822.HEADER": headers},
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchHeadersUID(self):
|
|
return self.testFetchHeaders(1)
|
|
|
|
def testFetchBody(self, uid=0):
|
|
self.function = self.client.fetchBody
|
|
self.messages = "1,2,3,4,5,6,7"
|
|
self.msgObjs = [
|
|
FakeyMessage({"Header": "Value"}, (), "", b"Body goes here\r\n", 171, None),
|
|
]
|
|
self.expected = {
|
|
0: {"RFC822.TEXT": "Body goes here\r\n"},
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchBodyUID(self):
|
|
return self.testFetchBody(1)
|
|
|
|
def testFetchBodyParts(self):
|
|
"""
|
|
Test the server's handling of requests for specific body sections.
|
|
"""
|
|
self.function = self.client.fetchSpecific
|
|
self.messages = "1"
|
|
outerBody = ""
|
|
innerBody1 = b"Contained body message text. Squarge."
|
|
innerBody2 = b"Secondary <i>message</i> text of squarge body."
|
|
headers = OrderedDict()
|
|
headers["from"] = "sender@host"
|
|
headers["to"] = "recipient@domain"
|
|
headers["subject"] = "booga booga boo"
|
|
headers["content-type"] = 'multipart/alternative; boundary="xyz"'
|
|
innerHeaders = OrderedDict()
|
|
innerHeaders["subject"] = "this is subject text"
|
|
innerHeaders["content-type"] = "text/plain"
|
|
innerHeaders2 = OrderedDict()
|
|
innerHeaders2["subject"] = "<b>this is subject</b>"
|
|
innerHeaders2["content-type"] = "text/html"
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
headers,
|
|
(),
|
|
None,
|
|
outerBody,
|
|
123,
|
|
[
|
|
FakeyMessage(innerHeaders, (), None, innerBody1, None, None),
|
|
FakeyMessage(innerHeaders2, (), None, innerBody2, None, None),
|
|
],
|
|
)
|
|
]
|
|
self.expected = {0: [["BODY", ["1"], "Contained body message text. Squarge."]]}
|
|
|
|
def result(R):
|
|
self.result = R
|
|
|
|
self.connected.addCallback(
|
|
lambda _: self.function(self.messages, headerNumber=1)
|
|
)
|
|
self.connected.addCallback(result)
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
|
|
d = loopback.loopbackTCP(self.server, self.client, noisy=False)
|
|
d.addCallback(lambda ign: self.assertEqual(self.result, self.expected))
|
|
return d
|
|
|
|
def test_fetchBodyPartOfNonMultipart(self):
|
|
"""
|
|
Single-part messages have an implicit first part which clients
|
|
should be able to retrieve explicitly. Test that a client
|
|
requesting part 1 of a text/plain message receives the body of the
|
|
text/plain part.
|
|
"""
|
|
self.function = self.client.fetchSpecific
|
|
self.messages = "1"
|
|
parts = [1]
|
|
outerBody = b"DA body"
|
|
headers = OrderedDict()
|
|
headers["from"] = "sender@host"
|
|
headers["to"] = "recipient@domain"
|
|
headers["subject"] = "booga booga boo"
|
|
headers["content-type"] = "text/plain"
|
|
self.msgObjs = [FakeyMessage(headers, (), None, outerBody, 123, None)]
|
|
|
|
self.expected = {0: [["BODY", ["1"], "DA body"]]}
|
|
|
|
def result(R):
|
|
self.result = R
|
|
|
|
self.connected.addCallback(
|
|
lambda _: self.function(self.messages, headerNumber=parts)
|
|
)
|
|
self.connected.addCallback(result)
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
|
|
d = loopback.loopbackTCP(self.server, self.client, noisy=False)
|
|
d.addCallback(lambda ign: self.assertEqual(self.result, self.expected))
|
|
return d
|
|
|
|
def testFetchSize(self, uid=0):
|
|
self.function = self.client.fetchSize
|
|
self.messages = "1:100,2:*"
|
|
self.msgObjs = [
|
|
FakeyMessage({}, (), b"", b"x" * 20, 123, None),
|
|
]
|
|
self.expected = {
|
|
0: {"RFC822.SIZE": "20"},
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchSizeUID(self):
|
|
return self.testFetchSize(1)
|
|
|
|
def testFetchFull(self, uid=0):
|
|
self.function = self.client.fetchFull
|
|
self.messages = "1,3"
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
{},
|
|
("\\XYZ", "\\YZX", "Abc"),
|
|
b"Sun, 25 Jul 2010 06:20:30 -0400 (EDT)",
|
|
b"xyz" * 2,
|
|
654,
|
|
None,
|
|
),
|
|
FakeyMessage(
|
|
{},
|
|
("\\One", "\\Two", "Three"),
|
|
b"Mon, 14 Apr 2003 19:43:44 -0400",
|
|
b"abc" * 4,
|
|
555,
|
|
None,
|
|
),
|
|
]
|
|
self.expected = {
|
|
0: {
|
|
"FLAGS": ["\\XYZ", "\\YZX", "Abc"],
|
|
"INTERNALDATE": "25-Jul-2010 06:20:30 -0400",
|
|
"RFC822.SIZE": "6",
|
|
"ENVELOPE": [
|
|
None,
|
|
None,
|
|
[[None, None, None]],
|
|
[[None, None, None]],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
],
|
|
"BODY": [None, None, None, None, None, None, "6"],
|
|
},
|
|
1: {
|
|
"FLAGS": ["\\One", "\\Two", "Three"],
|
|
"INTERNALDATE": "14-Apr-2003 19:43:44 -0400",
|
|
"RFC822.SIZE": "12",
|
|
"ENVELOPE": [
|
|
None,
|
|
None,
|
|
[[None, None, None]],
|
|
[[None, None, None]],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
],
|
|
"BODY": [None, None, None, None, None, None, "12"],
|
|
},
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchFullUID(self):
|
|
return self.testFetchFull(1)
|
|
|
|
def testFetchAll(self, uid=0):
|
|
self.function = self.client.fetchAll
|
|
self.messages = "1,2:3"
|
|
self.msgObjs = [
|
|
FakeyMessage(
|
|
{}, (), b"Mon, 14 Apr 2003 19:43:44 +0400", b"Lalala", 10101, None
|
|
),
|
|
FakeyMessage(
|
|
{}, (), b"Tue, 15 Apr 2003 19:43:44 +0200", b"Alalal", 20202, None
|
|
),
|
|
]
|
|
self.expected = {
|
|
0: {
|
|
"ENVELOPE": [
|
|
None,
|
|
None,
|
|
[[None, None, None]],
|
|
[[None, None, None]],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
],
|
|
"RFC822.SIZE": "6",
|
|
"INTERNALDATE": "14-Apr-2003 19:43:44 +0400",
|
|
"FLAGS": [],
|
|
},
|
|
1: {
|
|
"ENVELOPE": [
|
|
None,
|
|
None,
|
|
[[None, None, None]],
|
|
[[None, None, None]],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
],
|
|
"RFC822.SIZE": "6",
|
|
"INTERNALDATE": "15-Apr-2003 19:43:44 +0200",
|
|
"FLAGS": [],
|
|
},
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchAllUID(self):
|
|
return self.testFetchAll(1)
|
|
|
|
def testFetchFast(self, uid=0):
|
|
self.function = self.client.fetchFast
|
|
self.messages = "1"
|
|
self.msgObjs = [
|
|
FakeyMessage({}, ("\\X",), b"19 Mar 2003 19:22:21 -0500", b"", 9, None),
|
|
]
|
|
self.expected = {
|
|
0: {
|
|
"FLAGS": ["\\X"],
|
|
"INTERNALDATE": "19-Mar-2003 19:22:21 -0500",
|
|
"RFC822.SIZE": "0",
|
|
},
|
|
}
|
|
return self._fetchWork(uid)
|
|
|
|
def testFetchFastUID(self):
|
|
return self.testFetchFast(1)
|
|
|
|
|
|
class DefaultSearchTests(IMAP4HelperMixin, TestCase):
|
|
"""
|
|
Test the behavior of the server's SEARCH implementation, particularly in
|
|
the face of unhandled search terms.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.server = imap4.IMAP4Server()
|
|
self.server.state = "select"
|
|
self.server.mbox = self
|
|
self.connected = defer.Deferred()
|
|
self.client = SimpleClient(self.connected)
|
|
self.msgObjs = [
|
|
FakeyMessage({}, (), b"", b"", 999, None),
|
|
FakeyMessage({}, (), b"", b"", 10101, None),
|
|
FakeyMessage({}, (), b"", b"", 12345, None),
|
|
FakeyMessage({}, (), b"", b"", 20001, None),
|
|
FakeyMessage({}, (), b"", b"", 20002, None),
|
|
]
|
|
|
|
def fetch(self, messages, uid):
|
|
"""
|
|
Pretend to be a mailbox and let C{self.server} lookup messages on me.
|
|
"""
|
|
return list(zip(range(1, len(self.msgObjs) + 1), self.msgObjs))
|
|
|
|
def _messageSetSearchTest(self, queryTerms, expectedMessages):
|
|
"""
|
|
Issue a search with given query and verify that the returned messages
|
|
match the given expected messages.
|
|
|
|
@param queryTerms: A string giving the search query.
|
|
@param expectedMessages: A list of the message sequence numbers
|
|
expected as the result of the search.
|
|
@return: A L{Deferred} which fires when the test is complete.
|
|
"""
|
|
|
|
def search():
|
|
return self.client.search(queryTerms)
|
|
|
|
d = self.connected.addCallback(strip(search))
|
|
|
|
def searched(results):
|
|
self.assertEqual(results, expectedMessages)
|
|
|
|
d.addCallback(searched)
|
|
d.addCallback(self._cbStopClient)
|
|
d.addErrback(self._ebGeneral)
|
|
self.loopback()
|
|
return d
|
|
|
|
def test_searchMessageSet(self):
|
|
"""
|
|
Test that a search which starts with a message set properly limits
|
|
the search results to messages in that set.
|
|
"""
|
|
return self._messageSetSearchTest("1", [1])
|
|
|
|
def test_searchMessageSetWithStar(self):
|
|
"""
|
|
If the search filter ends with a star, all the message from the
|
|
starting point are returned.
|
|
"""
|
|
return self._messageSetSearchTest("2:*", [2, 3, 4, 5])
|
|
|
|
def test_searchMessageSetWithStarFirst(self):
|
|
"""
|
|
If the search filter starts with a star, the result should be identical
|
|
with if the filter would end with a star.
|
|
"""
|
|
return self._messageSetSearchTest("*:2", [2, 3, 4, 5])
|
|
|
|
def test_searchMessageSetUIDWithStar(self):
|
|
"""
|
|
If the search filter ends with a star, all the message from the
|
|
starting point are returned (also for the SEARCH UID case).
|
|
"""
|
|
return self._messageSetSearchTest("UID 10000:*", [2, 3, 4, 5])
|
|
|
|
def test_searchMessageSetUIDWithStarFirst(self):
|
|
"""
|
|
If the search filter starts with a star, the result should be identical
|
|
with if the filter would end with a star (also for the SEARCH UID case).
|
|
"""
|
|
return self._messageSetSearchTest("UID *:10000", [2, 3, 4, 5])
|
|
|
|
def test_searchMessageSetUIDWithStarAndHighStart(self):
|
|
"""
|
|
A search filter of 1234:* should include the UID of the last message in
|
|
the mailbox, even if its UID is less than 1234.
|
|
"""
|
|
# in our fake mbox the highest message UID is 20002
|
|
return self._messageSetSearchTest("UID 30000:*", [5])
|
|
|
|
def test_searchMessageSetWithList(self):
|
|
"""
|
|
If the search filter contains nesting terms, one of which includes a
|
|
message sequence set with a wildcard, IT ALL WORKS GOOD.
|
|
"""
|
|
# 6 is bigger than the biggest message sequence number, but that's
|
|
# okay, because N:* includes the biggest message sequence number even
|
|
# if N is bigger than that (read the rfc nub).
|
|
return self._messageSetSearchTest("(6:*)", [5])
|
|
|
|
def test_searchOr(self):
|
|
"""
|
|
If the search filter contains an I{OR} term, all messages
|
|
which match either subexpression are returned.
|
|
"""
|
|
return self._messageSetSearchTest("OR 1 2", [1, 2])
|
|
|
|
def test_searchOrMessageSet(self):
|
|
"""
|
|
If the search filter contains an I{OR} term with a
|
|
subexpression which includes a message sequence set wildcard,
|
|
all messages in that set are considered for inclusion in the
|
|
results.
|
|
"""
|
|
return self._messageSetSearchTest("OR 2:* 2:*", [2, 3, 4, 5])
|
|
|
|
def test_searchNot(self):
|
|
"""
|
|
If the search filter contains a I{NOT} term, all messages
|
|
which do not match the subexpression are returned.
|
|
"""
|
|
return self._messageSetSearchTest("NOT 3", [1, 2, 4, 5])
|
|
|
|
def test_searchNotMessageSet(self):
|
|
"""
|
|
If the search filter contains a I{NOT} term with a
|
|
subexpression which includes a message sequence set wildcard,
|
|
no messages in that set are considered for inclusion in the
|
|
result.
|
|
"""
|
|
return self._messageSetSearchTest("NOT 2:*", [1])
|
|
|
|
def test_searchAndMessageSet(self):
|
|
"""
|
|
If the search filter contains multiple terms implicitly
|
|
conjoined with a message sequence set wildcard, only the
|
|
intersection of the results of each term are returned.
|
|
"""
|
|
return self._messageSetSearchTest("2:* 3", [3])
|
|
|
|
def test_searchInvalidCriteria(self):
|
|
"""
|
|
If the search criteria is not a valid key, a NO result is returned to
|
|
the client (resulting in an error callback), and an IllegalQueryError is
|
|
logged on the server side.
|
|
"""
|
|
queryTerms = "FOO"
|
|
|
|
def search():
|
|
return self.client.search(queryTerms)
|
|
|
|
d = self.connected.addCallback(strip(search))
|
|
d = self.assertFailure(d, imap4.IMAP4Exception)
|
|
|
|
def errorReceived(results):
|
|
"""
|
|
Verify that the server logs an IllegalQueryError and the
|
|
client raises an IMAP4Exception with 'Search failed:...'
|
|
"""
|
|
self.client.transport.loseConnection()
|
|
self.server.transport.loseConnection()
|
|
|
|
# Check what the server logs
|
|
errors = self.flushLoggedErrors(imap4.IllegalQueryError)
|
|
self.assertEqual(len(errors), 1)
|
|
|
|
# Verify exception given to client has the correct message
|
|
self.assertEqual(
|
|
str(b"SEARCH failed: Invalid search command FOO"),
|
|
str(results),
|
|
)
|
|
|
|
d.addCallback(errorReceived)
|
|
d.addErrback(self._ebGeneral)
|
|
self.loopback()
|
|
return d
|
|
|
|
|
|
@implementer(imap4.ISearchableMailbox)
|
|
class FetchSearchStoreTests(TestCase, IMAP4HelperMixin):
|
|
def setUp(self):
|
|
self.expected = self.result = None
|
|
self.server_received_query = None
|
|
self.server_received_uid = None
|
|
self.server_received_parts = None
|
|
self.server_received_messages = None
|
|
|
|
self.server = imap4.IMAP4Server()
|
|
self.server.state = "select"
|
|
self.server.mbox = self
|
|
self.connected = defer.Deferred()
|
|
self.client = SimpleClient(self.connected)
|
|
|
|
def search(self, query, uid):
|
|
# Look for a specific bad query, so we can verify we handle it properly
|
|
if query == [b"FOO"]:
|
|
raise imap4.IllegalQueryError("FOO is not a valid search criteria")
|
|
|
|
self.server_received_query = query
|
|
self.server_received_uid = uid
|
|
return self.expected
|
|
|
|
def addListener(self, *a, **kw):
|
|
pass
|
|
|
|
removeListener = addListener
|
|
|
|
def _searchWork(self, uid):
|
|
def search():
|
|
return self.client.search(self.query, uid=uid)
|
|
|
|
def result(R):
|
|
self.result = R
|
|
|
|
self.connected.addCallback(strip(search)).addCallback(result).addCallback(
|
|
self._cbStopClient
|
|
).addErrback(self._ebGeneral)
|
|
|
|
def check(ignored):
|
|
# Ensure no short-circuiting weirdness is going on
|
|
self.assertFalse(self.result is self.expected)
|
|
|
|
self.assertEqual(self.result, self.expected)
|
|
self.assertEqual(self.uid, self.server_received_uid)
|
|
self.assertEqual(
|
|
# Queries should be decoded as ASCII unless a charset
|
|
# identifier is provided. See #9201.
|
|
imap4.parseNestedParens(self.query.encode("charmap")),
|
|
self.server_received_query,
|
|
)
|
|
|
|
d = loopback.loopbackTCP(self.server, self.client, noisy=False)
|
|
d.addCallback(check)
|
|
return d
|
|
|
|
def testSearch(self):
|
|
self.query = imap4.Or(
|
|
imap4.Query(header=("subject", "substring")),
|
|
imap4.Query(larger=1024, smaller=4096),
|
|
)
|
|
self.expected = [1, 4, 5, 7]
|
|
self.uid = 0
|
|
return self._searchWork(0)
|
|
|
|
def testUIDSearch(self):
|
|
self.query = imap4.Or(
|
|
imap4.Query(header=("subject", "substring")),
|
|
imap4.Query(larger=1024, smaller=4096),
|
|
)
|
|
self.uid = 1
|
|
self.expected = [1, 2, 3]
|
|
return self._searchWork(1)
|
|
|
|
def getUID(self, msg):
|
|
try:
|
|
return self.expected[msg]["UID"]
|
|
except (TypeError, IndexError):
|
|
return self.expected[msg - 1]
|
|
except KeyError:
|
|
return 42
|
|
|
|
def fetch(self, messages, uid):
|
|
self.server_received_uid = uid
|
|
self.server_received_messages = str(messages)
|
|
return self.expected
|
|
|
|
def _fetchWork(self, fetch):
|
|
def result(R):
|
|
self.result = R
|
|
|
|
self.connected.addCallback(strip(fetch)).addCallback(result).addCallback(
|
|
self._cbStopClient
|
|
).addErrback(self._ebGeneral)
|
|
|
|
def check(ignored):
|
|
# Ensure no short-circuiting weirdness is going on
|
|
self.assertFalse(self.result is self.expected)
|
|
|
|
self.parts and self.parts.sort()
|
|
self.server_received_parts and self.server_received_parts.sort()
|
|
|
|
if self.uid:
|
|
for k, v in self.expected.items():
|
|
v["UID"] = str(k)
|
|
|
|
self.assertEqual(self.result, self.expected)
|
|
self.assertEqual(self.uid, self.server_received_uid)
|
|
self.assertEqual(self.parts, self.server_received_parts)
|
|
self.assertEqual(
|
|
imap4.parseIdList(self.messages),
|
|
imap4.parseIdList(self.server_received_messages),
|
|
)
|
|
|
|
d = loopback.loopbackTCP(self.server, self.client, noisy=False)
|
|
d.addCallback(check)
|
|
return d
|
|
|
|
def test_invalidTerm(self):
|
|
"""
|
|
If, as part of a search, an ISearchableMailbox raises an
|
|
IllegalQueryError (e.g. due to invalid search criteria), client sees a
|
|
failure response, and an IllegalQueryError is logged on the server.
|
|
"""
|
|
query = "FOO"
|
|
|
|
def search():
|
|
return self.client.search(query)
|
|
|
|
d = self.connected.addCallback(strip(search))
|
|
d = self.assertFailure(d, imap4.IMAP4Exception)
|
|
|
|
def errorReceived(results):
|
|
"""
|
|
Verify that the server logs an IllegalQueryError and the
|
|
client raises an IMAP4Exception with 'Search failed:...'
|
|
"""
|
|
self.client.transport.loseConnection()
|
|
self.server.transport.loseConnection()
|
|
|
|
# Check what the server logs
|
|
errors = self.flushLoggedErrors(imap4.IllegalQueryError)
|
|
self.assertEqual(len(errors), 1)
|
|
|
|
# Verify exception given to client has the correct message
|
|
self.assertEqual(
|
|
str(b"SEARCH failed: FOO is not a valid search criteria"), str(results)
|
|
)
|
|
|
|
d.addCallback(errorReceived)
|
|
d.addErrback(self._ebGeneral)
|
|
self.loopback()
|
|
return d
|
|
|
|
|
|
class FakeMailbox:
|
|
def __init__(self):
|
|
self.args = []
|
|
|
|
def addMessage(self, body, flags, date):
|
|
self.args.append((body, flags, date))
|
|
return defer.succeed(None)
|
|
|
|
|
|
@implementer(imap4.IMessageFile)
|
|
class FeaturefulMessage:
|
|
def getFlags(self):
|
|
return "flags"
|
|
|
|
def getInternalDate(self):
|
|
return "internaldate"
|
|
|
|
def open(self):
|
|
return BytesIO(b"open")
|
|
|
|
|
|
@implementer(imap4.IMessageCopier)
|
|
class MessageCopierMailbox:
|
|
def __init__(self):
|
|
self.msgs = []
|
|
|
|
def copy(self, msg):
|
|
self.msgs.append(msg)
|
|
return len(self.msgs)
|
|
|
|
|
|
class CopyWorkerTests(TestCase):
|
|
def testFeaturefulMessage(self):
|
|
s = imap4.IMAP4Server()
|
|
|
|
# Yes. I am grabbing this uber-non-public method to test it.
|
|
# It is complex. It needs to be tested directly!
|
|
# Perhaps it should be refactored, simplified, or split up into
|
|
# not-so-private components, but that is a task for another day.
|
|
|
|
# Ha ha! Addendum! Soon it will be split up, and this test will
|
|
# be re-written to just use the default adapter for IMailbox to
|
|
# IMessageCopier and call .copy on that adapter.
|
|
f = s._IMAP4Server__cbCopy
|
|
|
|
m = FakeMailbox()
|
|
d = f([(i, FeaturefulMessage()) for i in range(1, 11)], "tag", m)
|
|
|
|
def cbCopy(results):
|
|
for a in m.args:
|
|
self.assertEqual(a[0].read(), b"open")
|
|
self.assertEqual(a[1], "flags")
|
|
self.assertEqual(a[2], "internaldate")
|
|
|
|
for status, result in results:
|
|
self.assertTrue(status)
|
|
self.assertEqual(result, None)
|
|
|
|
return d.addCallback(cbCopy)
|
|
|
|
def testUnfeaturefulMessage(self):
|
|
s = imap4.IMAP4Server()
|
|
|
|
# See above comment
|
|
f = s._IMAP4Server__cbCopy
|
|
|
|
m = FakeMailbox()
|
|
msgs = [
|
|
FakeyMessage(
|
|
{"Header-Counter": str(i)}, (), b"Date", b"Body %d" % (i,), i + 10, None
|
|
)
|
|
for i in range(1, 11)
|
|
]
|
|
d = f([im for im in zip(range(1, 11), msgs)], "tag", m)
|
|
|
|
def cbCopy(results):
|
|
seen = []
|
|
for a in m.args:
|
|
seen.append(a[0].read())
|
|
self.assertEqual(a[1], ())
|
|
self.assertEqual(a[2], b"Date")
|
|
|
|
seen.sort()
|
|
exp = sorted(
|
|
b"Header-Counter: %d\r\n\r\nBody %d" % (i, i) for i in range(1, 11)
|
|
)
|
|
self.assertEqual(seen, exp)
|
|
|
|
for status, result in results:
|
|
self.assertTrue(status)
|
|
self.assertEqual(result, None)
|
|
|
|
return d.addCallback(cbCopy)
|
|
|
|
def testMessageCopier(self):
|
|
s = imap4.IMAP4Server()
|
|
|
|
# See above comment
|
|
f = s._IMAP4Server__cbCopy
|
|
|
|
m = MessageCopierMailbox()
|
|
msgs = [object() for i in range(1, 11)]
|
|
d = f([im for im in zip(range(1, 11), msgs)], b"tag", m)
|
|
|
|
def cbCopy(results):
|
|
self.assertEqual(results, list(zip([1] * 10, range(1, 11))))
|
|
for orig, new in zip(msgs, m.msgs):
|
|
self.assertIdentical(orig, new)
|
|
|
|
return d.addCallback(cbCopy)
|
|
|
|
|
|
@skipIf(not ClientTLSContext, "OpenSSL not present")
|
|
@skipIf(not interfaces.IReactorSSL(reactor, None), "Reactor doesn't support SSL")
|
|
class TLSTests(IMAP4HelperMixin, TestCase):
|
|
serverCTX = None
|
|
clientCTX = None
|
|
if ServerTLSContext: # type: ignore[truthy-function]
|
|
serverCTX = ServerTLSContext()
|
|
if ClientTLSContext: # type: ignore[truthy-function]
|
|
clientCTX = ClientTLSContext()
|
|
|
|
def loopback(self):
|
|
return loopback.loopbackTCP(self.server, self.client, noisy=False)
|
|
|
|
def testAPileOfThings(self):
|
|
SimpleServer.theAccount.addMailbox(b"inbox")
|
|
called = []
|
|
|
|
def login():
|
|
called.append(None)
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def list():
|
|
called.append(None)
|
|
return self.client.list(b"inbox", b"%")
|
|
|
|
def status():
|
|
called.append(None)
|
|
return self.client.status(b"inbox", "UIDNEXT")
|
|
|
|
def examine():
|
|
called.append(None)
|
|
return self.client.examine(b"inbox")
|
|
|
|
def logout():
|
|
called.append(None)
|
|
return self.client.logout()
|
|
|
|
self.client.requireTransportSecurity = True
|
|
|
|
methods = [login, list, status, examine, logout]
|
|
for method in methods:
|
|
self.connected.addCallback(strip(method))
|
|
|
|
self.connected.addCallbacks(self._cbStopClient, self._ebGeneral)
|
|
|
|
def check(ignored):
|
|
self.assertEqual(self.server.startedTLS, True)
|
|
self.assertEqual(self.client.startedTLS, True)
|
|
self.assertEqual(len(called), len(methods))
|
|
|
|
d = self.loopback()
|
|
d.addCallback(check)
|
|
return d
|
|
|
|
def testLoginLogin(self):
|
|
self.server.checker.addUser(b"testuser", b"password-test")
|
|
success = []
|
|
self.client.registerAuthenticator(imap4.LOGINAuthenticator(b"testuser"))
|
|
self.connected.addCallback(
|
|
lambda _: self.client.authenticate(b"password-test")
|
|
).addCallback(lambda _: self.client.logout()).addCallback(
|
|
success.append
|
|
).addCallback(
|
|
self._cbStopClient
|
|
).addErrback(
|
|
self._ebGeneral
|
|
)
|
|
|
|
d = self.loopback()
|
|
d.addCallback(lambda x: self.assertEqual(len(success), 1))
|
|
return d
|
|
|
|
def startTLSAndAssertSession(self):
|
|
"""
|
|
Begin a C{STARTTLS} sequence and assert that it results in a
|
|
TLS session.
|
|
|
|
@return: A L{Deferred} that fires when the underlying
|
|
connection between the client and server has been terminated.
|
|
"""
|
|
success = []
|
|
self.connected.addCallback(strip(self.client.startTLS))
|
|
|
|
def checkSecure(ignored):
|
|
self.assertTrue(interfaces.ISSLTransport.providedBy(self.client.transport))
|
|
|
|
self.connected.addCallback(checkSecure)
|
|
self.connected.addCallback(success.append)
|
|
|
|
d = self.loopback()
|
|
d.addCallback(lambda x: self.assertTrue(success))
|
|
return defer.gatherResults([d, self.connected])
|
|
|
|
def test_startTLS(self):
|
|
"""
|
|
L{IMAP4Client.startTLS} triggers TLS negotiation and returns a
|
|
L{Deferred} which fires after the client's transport is using
|
|
encryption.
|
|
"""
|
|
disconnected = self.startTLSAndAssertSession()
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
return disconnected
|
|
|
|
def test_startTLSDefault(self) -> Deferred[object]:
|
|
"""
|
|
L{IMAPClient.startTLS} supplies a default TLS context if none is
|
|
supplied.
|
|
"""
|
|
self.assertIsNotNone(self.client.context)
|
|
self.client.context = None
|
|
disconnected: Deferred[object] = self.startTLSAndAssertSession()
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
return disconnected
|
|
|
|
def test_doubleSTARTTLS(self):
|
|
"""
|
|
A server that receives a second C{STARTTLS} sends a C{NO}
|
|
response.
|
|
"""
|
|
|
|
class DoubleSTARTTLSClient(SimpleClient):
|
|
def startTLS(self):
|
|
if not self.startedTLS:
|
|
return SimpleClient.startTLS(self)
|
|
|
|
return self.sendCommand(imap4.Command(b"STARTTLS"))
|
|
|
|
self.client = DoubleSTARTTLSClient(
|
|
self.connected, contextFactory=self.clientCTX
|
|
)
|
|
|
|
disconnected = self.startTLSAndAssertSession()
|
|
|
|
self.connected.addCallback(strip(self.client.startTLS))
|
|
self.connected.addErrback(
|
|
self.assertClientFailureMessage, b"TLS already negotiated"
|
|
)
|
|
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
|
|
return disconnected
|
|
|
|
def test_startTLSWithExistingChallengers(self):
|
|
"""
|
|
Starting a TLS negotiation with an L{IMAP4Server} that already
|
|
has C{LOGIN} and C{PLAIN} L{IChallengeResponse} factories uses
|
|
those factories.
|
|
"""
|
|
self.server.challengers = {
|
|
b"LOGIN": imap4.LOGINCredentials,
|
|
b"PLAIN": imap4.PLAINCredentials,
|
|
}
|
|
|
|
@defer.inlineCallbacks
|
|
def assertLOGINandPLAIN():
|
|
capabilities = yield self.client.getCapabilities()
|
|
self.assertIn(b"AUTH", capabilities)
|
|
self.assertIn(b"LOGIN", capabilities[b"AUTH"])
|
|
self.assertIn(b"PLAIN", capabilities[b"AUTH"])
|
|
|
|
self.connected.addCallback(strip(assertLOGINandPLAIN))
|
|
|
|
disconnected = self.startTLSAndAssertSession()
|
|
|
|
self.connected.addCallback(strip(assertLOGINandPLAIN))
|
|
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
|
|
return disconnected
|
|
|
|
def test_loginBeforeSTARTTLS(self):
|
|
"""
|
|
A client that attempts to log in before issuing the
|
|
C{STARTTLS} command receives a C{NO} response.
|
|
"""
|
|
# Prevent the client from issuing STARTTLS.
|
|
self.client.startTLS = lambda: defer.succeed(
|
|
([], "OK Begin TLS negotiation now")
|
|
)
|
|
self.connected.addCallback(
|
|
lambda _: self.client.login(b"wrong", b"time"),
|
|
)
|
|
|
|
self.connected.addErrback(
|
|
self.assertClientFailureMessage,
|
|
b"LOGIN is disabled before STARTTLS",
|
|
)
|
|
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
|
|
return defer.gatherResults([self.loopback(), self.connected])
|
|
|
|
def testFailedStartTLS(self):
|
|
failures = []
|
|
|
|
def breakServerTLS(ign):
|
|
self.server.canStartTLS = False
|
|
|
|
self.connected.addCallback(breakServerTLS)
|
|
self.connected.addCallback(lambda ign: self.client.startTLS())
|
|
self.connected.addErrback(
|
|
lambda err: failures.append(err.trap(imap4.IMAP4Exception))
|
|
)
|
|
self.connected.addCallback(self._cbStopClient)
|
|
self.connected.addErrback(self._ebGeneral)
|
|
|
|
def check(ignored):
|
|
self.assertTrue(failures)
|
|
self.assertIdentical(failures[0], imap4.IMAP4Exception)
|
|
|
|
return self.loopback().addCallback(check)
|
|
|
|
|
|
class SlowMailbox(SimpleMailbox):
|
|
howSlow = 2
|
|
callLater = None
|
|
fetchDeferred = None
|
|
|
|
# Not a very nice implementation of fetch(), but it'll
|
|
# do for the purposes of testing.
|
|
def fetch(self, messages, uid):
|
|
d = defer.Deferred()
|
|
self.callLater(self.howSlow, d.callback, ())
|
|
self.fetchDeferred.callback(None)
|
|
return d
|
|
|
|
|
|
class TimeoutTests(IMAP4HelperMixin, TestCase):
|
|
def test_serverTimeout(self):
|
|
"""
|
|
The *client* has a timeout mechanism which will close connections that
|
|
are inactive for a period.
|
|
"""
|
|
c = Clock()
|
|
self.server.timeoutTest = True
|
|
self.client.timeout = 5 # seconds
|
|
self.client.callLater = c.callLater
|
|
self.selectedArgs = None
|
|
|
|
def login():
|
|
d = self.client.login(b"testuser", b"password-test")
|
|
c.advance(5)
|
|
d.addErrback(timedOut)
|
|
return d
|
|
|
|
def timedOut(failure):
|
|
self._cbStopClient(None)
|
|
failure.trap(error.TimeoutError)
|
|
|
|
d = self.connected.addCallback(strip(login))
|
|
d.addErrback(self._ebGeneral)
|
|
return defer.gatherResults([d, self.loopback()])
|
|
|
|
def test_serverTimesOut(self):
|
|
"""
|
|
The server times out a connection.
|
|
"""
|
|
c = Clock()
|
|
self.server.callLater = c.callLater
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def expireTime():
|
|
c.advance(self.server.POSTAUTH_TIMEOUT * 2)
|
|
|
|
d = self.connected.addCallback(strip(login))
|
|
d.addCallback(strip(expireTime))
|
|
|
|
# The loopback method's Deferred fires the connection is
|
|
# closed, and the server closes the connection as a result of
|
|
# expireTime.
|
|
return defer.gatherResults([d, self.loopback()])
|
|
|
|
def test_serverUnselectsMailbox(self):
|
|
"""
|
|
The server unsets the selected mailbox when timing out a
|
|
connection.
|
|
"""
|
|
self.patch(SimpleServer.theAccount, "mailboxFactory", UncloseableMailbox)
|
|
SimpleServer.theAccount.addMailbox("mailbox-test")
|
|
mbox = SimpleServer.theAccount.mailboxes["MAILBOX-TEST"]
|
|
self.assertFalse(ICloseableMailboxIMAP.providedBy(mbox))
|
|
|
|
c = Clock()
|
|
self.server.callLater = c.callLater
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def select():
|
|
return self.client.select("mailbox-test")
|
|
|
|
def assertSet():
|
|
self.assertIs(mbox, self.server.mbox)
|
|
|
|
def expireTime():
|
|
c.advance(self.server.POSTAUTH_TIMEOUT * 2)
|
|
|
|
def assertUnset():
|
|
self.assertFalse(self.server.mbox)
|
|
|
|
d = self.connected.addCallback(strip(login))
|
|
d.addCallback(strip(select))
|
|
d.addCallback(strip(assertSet))
|
|
d.addCallback(strip(expireTime))
|
|
d.addCallback(strip(assertUnset))
|
|
|
|
# The loopback method's Deferred fires the connection is
|
|
# closed, and the server closes the connection as a result of
|
|
# expireTime.
|
|
return defer.gatherResults([d, self.loopback()])
|
|
|
|
def test_serverTimesOutAndClosesMailbox(self):
|
|
"""
|
|
The server closes the selected, closeable mailbox when timing
|
|
out a connection.
|
|
"""
|
|
SimpleServer.theAccount.addMailbox("mailbox-test")
|
|
mbox = SimpleServer.theAccount.mailboxes["MAILBOX-TEST"]
|
|
verifyObject(ICloseableMailboxIMAP, mbox)
|
|
|
|
c = Clock()
|
|
self.server.callLater = c.callLater
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def select():
|
|
return self.client.select("mailbox-test")
|
|
|
|
def assertMailboxOpen():
|
|
self.assertFalse(mbox.closed)
|
|
|
|
def expireTime():
|
|
c.advance(self.server.POSTAUTH_TIMEOUT * 2)
|
|
|
|
def assertMailboxClosed():
|
|
self.assertTrue(mbox.closed)
|
|
|
|
d = self.connected.addCallback(strip(login))
|
|
d.addCallback(strip(select))
|
|
d.addCallback(strip(assertMailboxOpen))
|
|
d.addCallback(strip(expireTime))
|
|
d.addCallback(strip(assertMailboxClosed))
|
|
|
|
# The loopback method's Deferred fires the connection is
|
|
# closed, and the server closes the connection as a result of
|
|
# expireTime.
|
|
return defer.gatherResults([d, self.loopback()])
|
|
|
|
def test_longFetchDoesntTimeout(self):
|
|
"""
|
|
The connection timeout does not take effect during fetches.
|
|
"""
|
|
c = Clock()
|
|
SlowMailbox.callLater = c.callLater
|
|
SlowMailbox.fetchDeferred = defer.Deferred()
|
|
self.server.callLater = c.callLater
|
|
SimpleServer.theAccount.mailboxFactory = SlowMailbox
|
|
SimpleServer.theAccount.addMailbox("mailbox-test")
|
|
|
|
self.server.setTimeout(1)
|
|
|
|
def login():
|
|
return self.client.login(b"testuser", b"password-test")
|
|
|
|
def select():
|
|
self.server.setTimeout(1)
|
|
return self.client.select("mailbox-test")
|
|
|
|
def fetch():
|
|
return self.client.fetchUID("1:*")
|
|
|
|
def stillConnected():
|
|
self.assertNotEqual(self.server.state, "timeout")
|
|
|
|
def cbAdvance(ignored):
|
|
for i in range(4):
|
|
c.advance(0.5)
|
|
|
|
SlowMailbox.fetchDeferred.addCallback(cbAdvance)
|
|
|
|
d1 = self.connected.addCallback(strip(login))
|
|
d1.addCallback(strip(select))
|
|
d1.addCallback(strip(fetch))
|
|
d1.addCallback(strip(stillConnected))
|
|
d1.addCallback(self._cbStopClient)
|
|
d1.addErrback(self._ebGeneral)
|
|
d = defer.gatherResults([d1, self.loopback()])
|
|
return d
|
|
|
|
def test_idleClientDoesDisconnect(self):
|
|
"""
|
|
The *server* has a timeout mechanism which will close connections that
|
|
are inactive for a period.
|
|
"""
|
|
c = Clock()
|
|
# Hook up our server protocol
|
|
transport = StringTransportWithDisconnection()
|
|
transport.protocol = self.server
|
|
self.server.callLater = c.callLater
|
|
self.server.makeConnection(transport)
|
|
|
|
# Make sure we can notice when the connection goes away
|
|
lost = []
|
|
connLost = self.server.connectionLost
|
|
self.server.connectionLost = lambda reason: (
|
|
lost.append(None),
|
|
connLost(reason),
|
|
)[1]
|
|
|
|
# 2/3rds of the idle timeout elapses...
|
|
c.pump([0.0] + [self.server.timeOut / 3.0] * 2)
|
|
self.assertFalse(lost, lost)
|
|
|
|
# Now some more
|
|
c.pump([0.0, self.server.timeOut / 2.0])
|
|
self.assertTrue(lost)
|
|
|
|
|
|
class DisconnectionTests(TestCase):
|
|
def testClientDisconnectFailsDeferreds(self):
|
|
c = imap4.IMAP4Client()
|
|
t = StringTransportWithDisconnection()
|
|
c.makeConnection(t)
|
|
d = self.assertFailure(
|
|
c.login(b"testuser", "example.com"), error.ConnectionDone
|
|
)
|
|
c.connectionLost(error.ConnectionDone("Connection closed"))
|
|
return d
|
|
|
|
|
|
class SynchronousMailbox:
|
|
"""
|
|
Trivial, in-memory mailbox implementation which can produce a message
|
|
synchronously.
|
|
"""
|
|
|
|
def __init__(self, messages):
|
|
self.messages = messages
|
|
|
|
def fetch(self, msgset, uid):
|
|
assert not uid, "Cannot handle uid requests."
|
|
for msg in msgset:
|
|
yield msg, self.messages[msg - 1]
|
|
|
|
|
|
class PipeliningTests(TestCase):
|
|
"""
|
|
Tests for various aspects of the IMAP4 server's pipelining support.
|
|
"""
|
|
|
|
messages = [
|
|
FakeyMessage({}, [], b"", b"0", None, None),
|
|
FakeyMessage({}, [], b"", b"1", None, None),
|
|
FakeyMessage({}, [], b"", b"2", None, None),
|
|
]
|
|
|
|
def setUp(self):
|
|
self.iterators = []
|
|
|
|
self.transport = StringTransport()
|
|
self.server = imap4.IMAP4Server(None, None, self.iterateInReactor)
|
|
self.server.makeConnection(self.transport)
|
|
|
|
mailbox = SynchronousMailbox(self.messages)
|
|
|
|
# Skip over authentication and folder selection
|
|
self.server.state = "select"
|
|
self.server.mbox = mailbox
|
|
|
|
# Get rid of any greeting junk
|
|
self.transport.clear()
|
|
|
|
def iterateInReactor(self, iterator):
|
|
"""
|
|
A fake L{imap4.iterateInReactor} that records the iterators it
|
|
receives.
|
|
|
|
@param iterator: An iterator.
|
|
|
|
@return: A L{Deferred} associated with this iterator.
|
|
"""
|
|
d = defer.Deferred()
|
|
self.iterators.append((iterator, d))
|
|
return d
|
|
|
|
def flushPending(self, asLongAs=lambda: True):
|
|
"""
|
|
Advance pending iterators enqueued with L{iterateInReactor} in
|
|
a round-robin fashion, resuming the transport's producer until
|
|
it has completed. This ensures bodies are flushed.
|
|
|
|
@param asLongAs: (optional) An optional predicate function.
|
|
Flushing iterators continues as long as there are
|
|
iterators and this returns L{True}.
|
|
"""
|
|
while self.iterators and asLongAs():
|
|
for e in self.iterators[0][0]:
|
|
while self.transport.producer:
|
|
self.transport.producer.resumeProducing()
|
|
else:
|
|
self.iterators.pop(0)[1].callback(None)
|
|
|
|
def tearDown(self):
|
|
self.server.connectionLost(failure.Failure(error.ConnectionDone()))
|
|
|
|
def test_synchronousFetch(self):
|
|
"""
|
|
Test that pipelined FETCH commands which can be responded to
|
|
synchronously are responded to correctly.
|
|
"""
|
|
# Here's some pipelined stuff
|
|
self.server.dataReceived(
|
|
b"01 FETCH 1 BODY[]\r\n" b"02 FETCH 2 BODY[]\r\n" b"03 FETCH 3 BODY[]\r\n"
|
|
)
|
|
|
|
self.flushPending()
|
|
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"".join(
|
|
[
|
|
b"* 1 FETCH (BODY[] )\r\n",
|
|
networkString(
|
|
"01 OK FETCH completed\r\n{5}\r\n\r\n\r\n%s"
|
|
% (nativeString(self.messages[0].getBodyFile().read()),)
|
|
),
|
|
b"* 2 FETCH (BODY[] )\r\n",
|
|
networkString(
|
|
"02 OK FETCH completed\r\n{5}\r\n\r\n\r\n%s"
|
|
% (nativeString(self.messages[1].getBodyFile().read()),)
|
|
),
|
|
b"* 3 FETCH (BODY[] )\r\n",
|
|
networkString(
|
|
"03 OK FETCH completed\r\n{5}\r\n\r\n\r\n%s"
|
|
% (nativeString(self.messages[2].getBodyFile().read()),)
|
|
),
|
|
]
|
|
),
|
|
)
|
|
|
|
def test_bufferedServerStatus(self):
|
|
"""
|
|
When a server status change occurs during an ongoing FETCH
|
|
command, the server status is buffered until the FETCH
|
|
completes.
|
|
"""
|
|
self.server.dataReceived(b"01 FETCH 1,2 BODY[]\r\n")
|
|
|
|
# Two iterations yields the untagged response and the first
|
|
# fetched message's body
|
|
twice = functools.partial(next, iter([True, True, False]))
|
|
self.flushPending(asLongAs=twice)
|
|
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"".join(
|
|
[
|
|
# The untagged response...
|
|
b"* 1 FETCH (BODY[] )\r\n",
|
|
# ...and its body
|
|
networkString(
|
|
"{5}\r\n\r\n\r\n%s"
|
|
% (nativeString(self.messages[0].getBodyFile().read()),)
|
|
),
|
|
]
|
|
),
|
|
)
|
|
|
|
self.transport.clear()
|
|
|
|
# A server status change...
|
|
self.server.modeChanged(writeable=True)
|
|
|
|
# ...remains buffered...
|
|
self.assertFalse(self.transport.value())
|
|
|
|
self.flushPending()
|
|
|
|
self.assertEqual(
|
|
self.transport.value(),
|
|
b"".join(
|
|
[
|
|
# The untagged response...
|
|
b"* 2 FETCH (BODY[] )\r\n",
|
|
# ...the status change...
|
|
b"* [READ-WRITE]\r\n",
|
|
# ...and the completion status and final message's body
|
|
networkString(
|
|
"01 OK FETCH completed\r\n{5}\r\n\r\n\r\n%s"
|
|
% (nativeString(self.messages[1].getBodyFile().read()),)
|
|
),
|
|
]
|
|
),
|
|
)
|
|
|
|
|
|
class IMAP4ServerFetchTests(TestCase):
|
|
"""
|
|
This test case is for the FETCH tests that require
|
|
a C{StringTransport}.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.transport = StringTransport()
|
|
self.server = imap4.IMAP4Server()
|
|
self.server.state = "select"
|
|
self.server.makeConnection(self.transport)
|
|
|
|
def test_fetchWithPartialValidArgument(self):
|
|
"""
|
|
If by any chance, extra bytes got appended at the end of a valid
|
|
FETCH arguments, the client should get a BAD - arguments invalid
|
|
response.
|
|
|
|
See U{RFC 3501<http://tools.ietf.org/html/rfc3501#section-6.4.5>},
|
|
section 6.4.5,
|
|
"""
|
|
# We need to clear out the welcome message.
|
|
self.transport.clear()
|
|
# Let's send out the faulty command.
|
|
self.server.dataReceived(b"0001 FETCH 1 FULLL\r\n")
|
|
expected = b"0001 BAD Illegal syntax: Invalid Argument\r\n"
|
|
self.assertEqual(self.transport.value(), expected)
|
|
self.transport.clear()
|
|
self.server.connectionLost(error.ConnectionDone("Connection closed"))
|
|
|
|
|
|
class LiteralTestsMixin:
|
|
"""
|
|
Shared tests for literal classes.
|
|
|
|
@ivar literalFactory: A callable that returns instances of the
|
|
literal under test.
|
|
"""
|
|
|
|
def setUp(self):
|
|
"""
|
|
Shared setup.
|
|
"""
|
|
self.deferred = defer.Deferred()
|
|
|
|
def test_partialWrite(self):
|
|
"""
|
|
The literal returns L{None} when given less data than the
|
|
literal requires.
|
|
"""
|
|
literal = self.literalFactory(1024, self.deferred)
|
|
self.assertIs(None, literal.write(b"incomplete"))
|
|
self.assertNoResult(self.deferred)
|
|
|
|
def test_exactWrite(self):
|
|
"""
|
|
The literal returns an empty L{bytes} instance when given
|
|
exactly the data the literal requires.
|
|
"""
|
|
data = b"complete"
|
|
literal = self.literalFactory(len(data), self.deferred)
|
|
leftover = literal.write(data)
|
|
|
|
self.assertIsInstance(leftover, bytes)
|
|
self.assertFalse(leftover)
|
|
self.assertNoResult(self.deferred)
|
|
|
|
def test_overlongWrite(self):
|
|
"""
|
|
The literal returns any left over L{bytes} when given more
|
|
data than the literal requires.
|
|
"""
|
|
data = b"completeleftover"
|
|
literal = self.literalFactory(len(b"complete"), self.deferred)
|
|
|
|
leftover = literal.write(data)
|
|
|
|
self.assertEqual(leftover, b"leftover")
|
|
|
|
def test_emptyLiteral(self):
|
|
"""
|
|
The literal returns an empty L{bytes} instance
|
|
when given an empty L{bytes} instance.
|
|
"""
|
|
literal = self.literalFactory(0, self.deferred)
|
|
data = b"leftover"
|
|
|
|
leftover = literal.write(data)
|
|
|
|
self.assertEqual(leftover, data)
|
|
|
|
|
|
class LiteralStringTests(LiteralTestsMixin, SynchronousTestCase):
|
|
"""
|
|
Tests for L{self.literalFactory}.
|
|
"""
|
|
|
|
literalFactory = imap4.LiteralString
|
|
|
|
def test_callback(self):
|
|
"""
|
|
Calling L{imap4.LiteralString.callback} with a line fires the
|
|
instance's L{Deferred} with a 2-L{tuple} whose first element
|
|
is the collected data and whose second is the provided line.
|
|
"""
|
|
data = b"data"
|
|
extra = b"extra"
|
|
|
|
literal = imap4.LiteralString(len(data), self.deferred)
|
|
|
|
for c in iterbytes(data):
|
|
literal.write(c)
|
|
|
|
literal.callback(b"extra")
|
|
|
|
result = self.successResultOf(self.deferred)
|
|
self.assertEqual(result, (data, extra))
|
|
|
|
|
|
class LiteralFileTests(LiteralTestsMixin, TestCase):
|
|
"""
|
|
Tests for L{imap4.LiteralFile}.
|
|
"""
|
|
|
|
literalFactory = imap4.LiteralFile
|
|
|
|
def test_callback(self):
|
|
"""
|
|
Calling L{imap4.LiteralFile.callback} with a line fires the
|
|
instance's L{Deferred} with a 2-L{tuple} whose first element
|
|
is the file and whose second is the provided line.
|
|
"""
|
|
data = b"data"
|
|
extra = b"extra"
|
|
|
|
literal = imap4.LiteralFile(len(data), self.deferred)
|
|
|
|
for c in iterbytes(data):
|
|
literal.write(c)
|
|
|
|
literal.callback(b"extra")
|
|
|
|
result = self.successResultOf(self.deferred)
|
|
self.assertEqual(len(result), 2)
|
|
|
|
dataFile, extra = result
|
|
self.assertEqual(dataFile.read(), b"data")
|
|
|
|
def test_callbackSpooledToDisk(self):
|
|
"""
|
|
A L{imap4.LiteralFile} whose size exceeds the maximum
|
|
in-memory size spools its content to disk, and invoking its
|
|
L{callback} with a line fires the instance's L{Deferred} with
|
|
a 2-L{tuple} whose first element is the spooled file and whose second
|
|
is the provided line.
|
|
"""
|
|
data = b"data"
|
|
extra = b"extra"
|
|
|
|
self.patch(imap4.LiteralFile, "_memoryFileLimit", 1)
|
|
|
|
literal = imap4.LiteralFile(len(data), self.deferred)
|
|
|
|
for c in iterbytes(data):
|
|
literal.write(c)
|
|
|
|
literal.callback(b"extra")
|
|
|
|
result = self.successResultOf(self.deferred)
|
|
self.assertEqual(len(result), 2)
|
|
|
|
dataFile, extra = result
|
|
self.assertEqual(dataFile.read(), b"data")
|
|
|
|
|
|
class WriteBufferTests(SynchronousTestCase):
|
|
"""
|
|
Tests for L{imap4.WriteBuffer}.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.transport = StringTransport()
|
|
|
|
def test_partialWrite(self):
|
|
"""
|
|
L{imap4.WriteBuffer} buffers writes that are smaller than its
|
|
buffer size.
|
|
"""
|
|
buf = imap4.WriteBuffer(self.transport)
|
|
data = b"x" * buf.bufferSize
|
|
|
|
buf.write(data)
|
|
|
|
self.assertFalse(self.transport.value())
|
|
|
|
def test_overlongWrite(self):
|
|
"""
|
|
L{imap4.WriteBuffer} writes data without buffering it when
|
|
the size of the data exceeds the size of its buffer.
|
|
"""
|
|
buf = imap4.WriteBuffer(self.transport)
|
|
data = b"x" * (buf.bufferSize + 1)
|
|
|
|
buf.write(data)
|
|
|
|
self.assertEqual(self.transport.value(), data)
|
|
|
|
def test_writesImplyFlush(self):
|
|
"""
|
|
L{imap4.WriteBuffer} buffers writes until its buffer's size
|
|
exceeds its maximum value.
|
|
"""
|
|
buf = imap4.WriteBuffer(self.transport)
|
|
firstData = b"x" * buf.bufferSize
|
|
secondData = b"y"
|
|
|
|
buf.write(firstData)
|
|
|
|
self.assertFalse(self.transport.value())
|
|
|
|
buf.write(secondData)
|
|
|
|
self.assertEqual(self.transport.value(), firstData + secondData)
|
|
|
|
def test_explicitFlush(self):
|
|
"""
|
|
L{imap4.WriteBuffer.flush} flushes the buffer even when its
|
|
size is smaller than the buffer size.
|
|
"""
|
|
buf = imap4.WriteBuffer(self.transport)
|
|
data = b"x" * (buf.bufferSize)
|
|
|
|
buf.write(data)
|
|
|
|
self.assertFalse(self.transport.value())
|
|
|
|
buf.flush()
|
|
|
|
self.assertEqual(self.transport.value(), data)
|
|
|
|
def test_explicitFlushEmptyBuffer(self):
|
|
"""
|
|
L{imap4.WriteBuffer.flush} has no effect if when the buffer is
|
|
empty.
|
|
"""
|
|
buf = imap4.WriteBuffer(self.transport)
|
|
|
|
buf.flush()
|
|
|
|
self.assertFalse(self.transport.value())
|