16
17
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18
19
"""Post-office Protocol version 3
21
@author U{Glyph Lefkowitz<mailto:glyph@twistedmatrix.com>}
22
@author U{Jp Calderone<mailto:exarkun@twistedmatrix.com>}
24
API Stability: Unstable
34
from twisted.protocols import smtp
22
35
from twisted.protocols import basic
23
import os, time, string, operator, stat, md5, binascii
36
from twisted.protocols import policies
37
from twisted.internet import protocol
38
from twisted.internet import defer
39
from twisted.internet import interfaces
40
from twisted.python import components
41
from twisted.python import log
43
from twisted import cred
44
import twisted.cred.error
45
import twisted.cred.credentials
50
class APOPCredentials:
51
__implements__ = (cred.credentials.IUsernamePassword,)
53
def __init__(self, magic, username, digest):
55
self.username = username
58
def checkPassword(self, password):
59
seed = self.magic + password
60
my_digest = md5.new(seed).hexdigest()
61
if my_digest == self.digest:
66
class _HeadersPlusNLines:
67
def __init__(self, f, n):
75
def read(self, bytes):
78
data = self.f.read(bytes)
82
df, sz = data.find('\r\n\r\n'), 4
84
df, sz = data.find('\n\n'), 2
93
if self.linecount > 0:
94
dsplit = (self.buf+data).split('\n')
96
for ln in dsplit[:-1]:
97
if self.linecount > self.n:
26
107
class POP3Error(Exception):
29
class POP3(basic.LineReceiver):
110
class POP3(basic.LineOnlyReceiver, policies.TimeoutMixin):
112
__implements__ = (interfaces.IProducer,)
119
AUTH_CMDS = ['CAPA', 'USER', 'PASS', 'APOP', 'AUTH', 'RPOP', 'QUIT']
121
# A reference to the newcred Portal instance we will authenticate
128
# The mailbox we're serving
131
# Set this pretty low -- POP3 clients are expected to log in, download
132
# everything, and log out.
135
# Current protocol state
33
141
def connectionMade(self):
34
142
if self.magic is None:
35
self.magic = '<%s>' % time.time()
143
self.magic = self.generateMagic()
37
144
self.successResponse(self.magic)
145
self.setTimeout(self.timeOut)
146
log.msg("New connection from " + str(self.transport.getPeer()))
148
def connectionLost(self, reason):
149
if self._onLogout is not None:
151
self._onLogout = None
152
self.setTimeout(None)
154
def generateMagic(self):
155
return smtp.messageid()
39
157
def successResponse(self, message=''):
40
self.transport.write('+OK %s\r\n' % message)
158
self.sendLine('+OK ' + str(message))
42
160
def failResponse(self, message=''):
43
self.transport.write('-ERR %s\r\n' % message)
161
self.sendLine('-ERR ' + str(message))
163
# def sendLine(self, line):
164
# print 'S:', repr(line)
165
# basic.LineOnlyReceiver.sendLine(self, line)
45
167
def lineReceived(self, line):
168
# print 'C:', repr(line)
170
getattr(self, 'state_' + self.state)(line)
172
def _unblock(self, _):
173
commands = self.blocked
175
while commands and self.blocked is None:
176
cmd, args = commands.pop(0)
177
self.processCommand(cmd, *args)
178
if self.blocked is not None:
179
self.blocked.extend(commands)
181
def state_COMMAND(self, line):
47
return apply(self.processCommand, tuple(string.split(line)))
48
except (ValueError, AttributeError, POP3Error), e:
183
return self.processCommand(*line.split())
184
except (ValueError, AttributeError, POP3Error, TypeError), e:
49
186
self.failResponse('bad protocol or server: %s: %s' % (e.__class__.__name__, e))
51
188
def processCommand(self, command, *args):
189
if self.blocked is not None:
190
self.blocked.append((command, args))
52
193
command = string.upper(command)
53
if self.mbox is None and command != 'APOP':
54
raise POP3Error("not authenticated yet: cannot do %s" % command)
55
return apply(getattr(self, 'do_'+command), args)
194
authCmd = command in self.AUTH_CMDS
195
if not self.mbox and not authCmd:
196
raise POP3Error("not authenticated yet: cannot do " + command)
197
f = getattr(self, 'do_' + command, None)
200
raise POP3Error("Unknown protocol command: " + command)
203
def listCapabilities(self):
214
if components.implements(self.factory, IServerFactory):
215
# Oh my god. We can't just loop over a list of these because
216
# each has spectacularly different return value semantics!
218
v = self.factory.cap_IMPLEMENTATION()
219
except NotImplementedError:
224
baseCaps.append("IMPLEMENTATION " + str(v))
227
v = self.factory.cap_EXPIRE()
228
except NotImplementedError:
235
if self.factory.perUserExpiration():
237
v = str(self.mbox.messageExpiration)
241
baseCaps.append("EXPIRE " + v)
244
v = self.factory.cap_LOGIN_DELAY()
245
except NotImplementedError:
250
if self.factory.perUserLoginDelay():
252
v = str(self.mbox.loginDelay)
256
baseCaps.append("LOGIN-DELAY " + v)
259
v = self.factory.challengers
260
except AttributeError:
265
baseCaps.append("SASL " + ' '.join(v.keys()))
269
self.successResponse("I can do the following:")
270
for cap in self.listCapabilities():
274
def do_AUTH(self, args=None):
275
if not getattr(self.factory, 'challengers', None):
276
self.failResponse("AUTH extension unsupported")
280
self.successResponse("Supported authentication methods:")
281
for a in self.factory.challengers:
282
self.sendLine(a.upper())
286
auth = self.factory.challengers.get(args.strip().upper())
287
if not self.portal or not auth:
288
self.failResponse("Unsupported SASL selected")
292
chal = self._auth.getChallenge()
294
self.sendLine('+ ' + base64.encodestring(chal).rstrip('\n'))
297
def state_AUTH(self, line):
298
self.state = "COMMAND"
300
parts = base64.decodestring(line).split(None, 1)
301
except binascii.Error:
302
self.failResponse("Invalid BASE64 encoding")
305
self.failResponse("Invalid AUTH response")
307
self._auth.username = parts[0]
308
self._auth.response = parts[1]
309
d = self.portal.login(self._auth, None, IMailbox)
310
d.addCallback(self._cbMailbox, parts[0])
311
d.addErrback(self._ebMailbox)
312
d.addErrback(self._ebUnexpected)
57
314
def do_APOP(self, user, digest):
58
self.mbox = self.authenticateUserAPOP(user, digest)
59
self.successResponse()
315
d = defer.maybeDeferred(self.authenticateUserAPOP, user, digest)
316
d.addCallbacks(self._cbMailbox, self._ebMailbox, callbackArgs=(user,)
317
).addErrback(self._ebUnexpected)
319
def _cbMailbox(self, (interface, avatar, logout), user):
320
if interface is not IMailbox:
321
self.failResponse('Authentication failed')
322
log.err("_cbMailbox() called with an interface other than IMailbox")
326
self._onLogout = logout
327
self.successResponse('Authentication succeeded')
328
log.msg("Authenticated login for " + user)
330
def _ebMailbox(self, failure):
331
failure = failure.trap(cred.error.LoginDenied, cred.error.LoginFailed)
332
if issubclass(failure, cred.error.LoginDenied):
333
self.failResponse("Access denied: " + str(failure))
334
elif issubclass(failure, cred.error.LoginFailed):
335
self.failResponse('Authentication failed')
336
log.msg("Denied login attempt from " + str(self.transport.getPeer()))
338
def _ebUnexpected(self, failure):
339
self.failResponse('Server error: ' + failure.getErrorMessage())
342
def do_USER(self, user):
344
self.successResponse('USER accepted, send PASS')
346
def do_PASS(self, password):
347
if self._userIs is None:
348
self.failResponse("USER required before PASS")
352
d = defer.maybeDeferred(self.authenticateUserPASS, user, password)
353
d.addCallbacks(self._cbMailbox, self._ebMailbox, callbackArgs=(user,)
354
).addErrback(self._ebUnexpected)
359
msg = self.mbox.listMessages()
363
self.successResponse('%d %d' % (i, sum))
61
365
def do_LIST(self, i=None):
62
messages = self.mbox.listMessages()
63
total = reduce(operator.add, messages, 0)
64
self.successResponse(len(messages))
66
for message in messages:
68
self.transport.write('%d %d\r\n' % (i, message))
70
self.transport.write('.\r\n')
367
messages = self.mbox.listMessages()
370
lines.append('%d %d%s' % (len(lines) + 1, msg, self.delimiter))
371
self.successResponse(len(lines))
372
self.transport.writeSequence(lines)
375
msg = self.mbox.listMessages(int(i) - 1)
376
self.successResponse(str(msg))
72
378
def do_UIDL(self, i=None):
73
messages = self.mbox.listMessages()
74
self.successResponse()
75
for i in range(len(messages)):
77
self.transport.write('%d %s\r\n' % (i+1, self.mbox.getUidl(i)))
78
self.transport.write('.\r\n')
380
messages = self.mbox.listMessages()
381
self.successResponse()
386
uid = self.mbox.getUidl(i)
387
lines.append('%d %s%s' % (i + 1, uid, self.delimiter))
389
self.transport.writeSequence(lines)
392
msg = self.mbox.getUidl(int(i) - 1)
393
self.successResponse(str(msg))
80
395
def getMessageFile(self, i):
82
list = self.mbox.listMessages()
398
resp = self.mbox.listMessages(i)
399
except (IndexError, ValueError), e:
86
400
self.failResponse('index out of range')
91
405
return resp, self.mbox.getMessage(i)
93
407
def do_TOP(self, i, size):
408
self.highest = max(self.highest, i)
94
409
resp, fp = self.getMessageFile(i)
97
size = max(int(size), resp)
98
self.successResponse(size)
107
self.transport.write(line[:size]+'\r\n')
108
size = size-len(line[:size])
413
fp = _HeadersPlusNLines(fp, size)
414
self.successResponse("Top of message follows")
415
s = basic.FileSender()
417
s.beginFileTransfer(fp, self.transport, self.transformChunk
418
).addCallback(self.finishedFileTransfer
419
).addCallback(self._unblock
111
423
def do_RETR(self, i):
424
self.highest = max(self.highest, i)
112
425
resp, fp = self.getMessageFile(i)
115
428
self.successResponse(resp)
124
self.transport.write(line+'\r\n')
125
self.transport.write('.\r\n')
429
s = basic.FileSender()
431
s.beginFileTransfer(fp, self.transport, self.transformChunk
432
).addCallback(self.finishedFileTransfer
433
).addCallback(self._unblock
437
def transformChunk(self, chunk):
438
return chunk.replace('\n', '\r\n').replace('\r\n.', '\r\n..')
440
def finishedFileTransfer(self, lastsent):
127
447
def do_DELE(self, i):
129
449
self.mbox.deleteMessage(i)
130
450
self.successResponse()
453
"""Perform no operation. Return a success code"""
454
self.successResponse()
457
"""Unset all deleted message flags"""
459
self.mbox.undeleteMessages()
465
self.successResponse()
468
"""Respond with the highest message access thus far"""
469
# omg this is such a retarded protocol
470
self.successResponse(self.highest)
472
def do_RPOP(self, user):
473
self.failResponse('permission denied, sucker')
132
475
def do_QUIT(self):
134
478
self.successResponse()
135
479
self.transport.loseConnection()
137
481
def authenticateUserAPOP(self, user, digest):
482
"""Perform authentication of an APOP login.
485
@param user: The name of the user attempting to log in.
488
@param digest: The response string with which the user replied.
491
@return: A deferred whose callback is invoked if the login is
492
successful, and whose errback will be invoked otherwise. The
493
callback will be passed a 3-tuple consisting of IMailbox,
494
an object implementing IMailbox, and a zero-argument callable
495
to be invoked when this session is terminated.
497
if self.portal is not None:
498
return self.portal.login(
499
APOPCredentials(self.magic, user, digest),
503
raise cred.error.UnauthorizedLogin()
505
def authenticateUserPASS(self, user, password):
506
"""Perform authentication of a username/password login.
509
@param user: The name of the user attempting to log in.
511
@type password: C{str}
512
@param password: The password to attempt to authenticate with.
515
@return: A deferred whose callback is invoked if the login is
516
successful, and whose errback will be invoked otherwise. The
517
callback will be passed a 3-tuple consisting of IMailbox,
518
an object implementing IMailbox, and a zero-argument callable
519
to be invoked when this session is terminated.
521
if self.portal is not None:
522
return self.portal.login(
523
cred.credentials.UsernamePassword(user, password),
527
raise cred.error.UnauthorizedLogin()
529
class IServerFactory(components.Interface):
530
"""Interface for querying additional parameters of this POP3 server.
532
Any cap_* method may raise NotImplementedError if the particular
533
capability is not supported. If cap_EXPIRE() does not raise
534
NotImplementedError, perUserExpiration() must be implemented, otherwise
535
they are optional. If cap_LOGIN_DELAY() is implemented,
536
perUserLoginDelay() must be implemented, otherwise they are optional.
538
@ivar challengers: A dictionary mapping challenger names to classes
539
implementing C{IUsernameHashedPassword}.
542
def cap_IMPLEMENTATION(self):
543
"""Return a string describing this POP3 server implementation."""
545
def cap_EXPIRE(self):
546
"""Return the minimum number of days messages are retained."""
548
def perUserExpiration(self):
549
"""Indicate whether message expiration is per-user.
551
@return: True if it is, false otherwise.
554
def cap_LOGIN_DELAY(self):
555
"""Return the minimum number of seconds between client logins."""
557
def perUserLoginDelay(self):
558
"""Indicate whether the login delay period is per-user.
560
@return: True if it is, false otherwise.
563
class IMailbox(components.Interface):
565
@type loginDelay: C{int}
566
@ivar loginDelay: The number of seconds between allowed logins for the
567
user associated with this mailbox. None
569
@type messageExpiration: C{int}
570
@ivar messageExpiration: The number of days messages in this mailbox will
571
remain on the server before being deleted.
574
def listMessages(self, index=None):
575
"""Retrieve the size of one or more messages.
577
@type index: C{int} or C{None}
578
@param index: The number of the message for which to retrieve the
579
size (starting at 0), or None to retrieve the size of all messages.
581
@rtype: C{int} or any iterable of C{int}
582
@return: The number of octets in the specified message, or an
583
iterable of integers representing the number of octets in all the
587
def getMessage(self, index):
588
"""Retrieve a file-like object for a particular message.
591
@param index: The number of the message to retrieve
593
@rtype: A file-like object
596
def getUidl(self, index):
597
"""Get a unique identifier for a particular message.
600
@param index: The number of the message for which to retrieve a UIDL
603
@return: A string of printable characters uniquely identifying for all
604
time the specified message.
607
def deleteMessage(self, index):
608
"""Delete a particular message.
610
This must not change the number of messages in this mailbox. Further
611
requests for the size of deleted messages should return 0. Further
612
requests for the message itself may raise an exception.
615
@param index: The number of the message to delete.
618
def undeleteMessages(self):
619
"""Undelete any messages possible.
621
If a message can be deleted it, it should return it its original
622
position in the message sequence and retain the same UIDL.
626
"""Perform checkpointing.
628
This method will be called to indicate the mailbox should attempt to
629
clean up any remaining deleted messages.
633
__implements__ = (IMailbox,)
143
def listMessages(self):
635
def listMessages(self, i=None):
145
637
def getMessage(self, i):