Providing IMAP Access to Mailboxes

IMAP is a powerful and full-featured protocol for mail management. But its many features are both a blessing and a curse. IMAP is a great protocol to use: it provides all the necessary tools to store, organize, and mail on a central server. But IMAP's complexity makes it an intimidating protocol to implement: the base IMAP protocol spec in RFC 2060 runs to more than 80 dense pages. IMAP also puts a lot of burden on the mail server. IMAP servers have to support message parsing (to allow the client to download only selected parts of a message), message searching (using a special query language), and the ability to reference messages using two completely different sets of numeric identifiers (message sequence numbers and unique IDs).

This complexity has historically caused all but the most dedicated developers to steer clear of IMAP, settling for the simpler but less capable POP3. So the fact that twisted.mail includes IMAP support is a great opportunity for developers. It makes it possible for you to write your own custom IMAP server, but without having to deal with all the nasty details of the IMAP protocol.

8.4.1. How Do I Do That?

To make an IMAP server, write classes to implement the IAccount, IMailbox, IMessage, and IMessagePart interfaces defined in twisted.mail.imap4. Then set up a realm that makes your IAccount implementation available as an avatar. Wrap the realm in a Portal, and set up a Factory that will pass the Portal to an imap4.IMAP4Server Protocol. Example 8-4 demonstrates a complete IMAP server.

Example 8-4. imapserver.py


from twisted.mail import imap4, maildir

from twisted.internet import reactor, defer, protocol

from twisted.cred import portal, checkers, credentials

from twisted.cred import error as credError

from twisted.python import filepath

from zope.interface import implements

import time, os, random, pickle



MAILBOXDELIMITER = "."



class IMAPUserAccount(object):

 implements(imap4.IAccount)



 def __init__(self, userDir):

 self.dir = userDir

 self.mailboxCache = {}

 # make sure Inbox exists

 inbox = self._getMailbox("Inbox", create=True)



 def listMailboxes(self, ref, wildcard):

 for box in os.listdir(self.dir):

 yield box, self._getMailbox(box)



 def select(self, path, rw=True):

 "return an object implementing IMailbox for the given path"

 return self._getMailbox(path)



 def _getMailbox(self, path, create=False):

 """

 Helper function to get a mailbox object at the given

 path, optionally creating it if it doesn't already exist.

 """

 # According to the IMAP spec, Inbox is case-insensitive

 pathParts = path.split(MAILBOXDELIMITER)

 if pathParts[0].lower() == 'inbox': pathParts[0] = 'Inbox'

 path = MAILBOXDELIMITER.join(pathParts)



 if not self.mailboxCache.has_key(path):

 fullPath = os.path.join(self.dir, path)

 if not os.path.exists(fullPath):

 if create:

 maildir.initializeMaildir(fullPath)

 else:

 raise KeyError, "No such mailbox"

 self.mailboxCache[path] = IMAPMailbox(fullPath)

 return self.mailboxCache[path]



 def create(self, path):

 "create a mailbox at path and return it"

 self._getMailbox(path, create=True)



 def delete(self, path):

 "delete the mailbox at path"

 raise imap4.MailboxException("Permission denied.")



 def rename(self, oldname, newname):

 "rename a mailbox"

 oldPath = os.path.join(self.dir, oldname)

 newPath = os.path.join(self.dir, newname)

 os.rename(oldPath, newPath)



 def isSubscribed(self, path):

 "return a true value if user is subscribed to the mailbox"

 return self._getMailbox(path).metadata.get('subscribed', False)



 def subscribe(self, path):

 "mark a mailbox as subscribed"

 box = self._getMailbox(path)

 box.metadata['subscribed'] = True

 box.saveMetadata()

 return True



 def unsubscribe(self, path):

 "mark a mailbox as unsubscribed"

 box = self._getMailbox(path)

 box.metadata['subscribed'] = False

 box.saveMetadata()

 return True



class ExtendedMaildir(maildir.MaildirMailbox):

 """

 Extends maildir.MaildirMailbox to expose more

 of the underlying filename data

 """

 def __iter__(self):

 "iterates through the full paths of all messages in the maildir"

 return iter(self.list)



 def __len__(self):

 return len(self.list)



 def __getitem__(self, i):

 return self.list[i]



 def deleteMessage(self, filename):

 index = self.list.index(filename)

 os.remove(filename)

 del(self.list[index])



class IMAPMailbox(object):

 implements(imap4.IMailbox)



 def __init__(self, path):

 self.maildir = ExtendedMaildir(path)

 self.metadataFile = os.path.join(path, '.imap-metadata.pickle')

 if os.path.exists(self.metadataFile):

 self.metadata = pickle.load(file(self.metadataFile, 'r+b'))

 else:

 self.metadata = {}

 self.initMetadata()

 self.listeners = []

 self._assignUIDs()



 def initMetadata(self):

 if not self.metadata.has_key('flags'):

 self.metadata['flags'] = {} # dict of message IDs to flags

 if not self.metadata.has_key('uidvalidity'):

 # create a unique integer ID to identify this version of

 # the mailbox, so the client could tell if it was deleted

 # and replaced by a different mailbox with the same name

 self.metadata['uidvalidity'] = random.randint(1000000, 9999999)

 if not self.metadata.has_key('uids'):

 self.metadata['uids'] = {}

 if not self.metadata.has_key('uidnext'):

 self.metadata['uidnext'] = 1 # next UID to be assigned



 def saveMetadata(self):

 pickle.dump(self.metadata, file(self.metadataFile, 'w+b'))



 def _assignUIDs(self):

 # make sure every message has a uid

 for messagePath in self.maildir:

 messageFile = os.path.basename(messagePath)

 if not self.metadata['uids'].has_key(messageFile):

 self.metadata['uids'][messageFile] =

self.metadata['uidnext']

 self.metadata['uidnext'] += 1

 self.saveMetadata()



 def getHierarchicalDelimiter(self):

 return MAILBOXDELIMITER



 def getFlags(self):

 "return list of flags supported by this mailbox"

 return [r'Seen', r'Unseen', r'Deleted',

 r'Flagged', r'Answered', r'Recent']



 def getMessageCount(self):

 return len(self.maildir)



 def getRecentCount(self):

 return 0



 def getUnseenCount(self):

 def messageIsUnseen(filename):

 filename = os.path.basename(filename)

 uid = self.metadata['uids'].get(filename)

 flags = self.metadata['flags'].get(uid, [])

 if not r'Seen' in flags:

 return True

 return len(filter(messageIsUnseen, self.maildir))



 def isWriteable(self):

 return True



 def getUIDValidity(self):

 return self.metadata['uidvalidity']



 def getUID(self, messageNum):

 filename = os.path.basename(self.maildir[messageNum-1])

 return self.metadata['uids'][filename]



 def getUIDNext(self):

 return self.folder.metadata['uidnext']



 def _uidMessageSetToSeqDict(self, messageSet):

 """

 take a MessageSet object containing UIDs, and return

 a dictionary mapping sequence numbers to filenames

 """

 # if messageSet.last is None, it means 'the end', and needs to

 # be set to a sane high number before attempting to iterate

 # through the MessageSet

 if not messageSet.last:

 messageSet.last = self.metadata['uidnext']

 allUIDs = []

 for filename in self.maildir:

 shortFilename = os.path.basename(filename)

 allUIDs.append(self.metadata['uids'][shortFilename])

 allUIDs.sort()

 seqMap = {}

 for uid in messageSet:

 # the message set covers a span of UIDs. not all of them

 # will necessarily exist, so check each one for validity

 if uid in allUIDs:

 sequence = allUIDs.index(uid)+1

 seqMap[sequence] = self.maildir[sequence-1]

 return seqMap



 def _seqMessageSetToSeqDict(self, messageSet):

 """

 take a MessageSet object containing message sequence numbers,

 and return a dictionary mapping sequence number to filenames

 """

 # if messageSet.last is None, it means 'the end', and needs to

 # be set to a sane high number before attempting to iterate

 # through the MessageSet

 if not messageSet.last: messageSet.last = len(self.maildir)-1

 seqMap = {}

 for messageNo in messageSet:

 seqMap[messageNo] = self.maildir[messageNo-1]

 return seqMap



 def fetch(self, messages, uid):

 if uid:

 messagesToFetch = self._uidMessageSetToSeqDict(messages)

 else:

 messagesToFetch = self._seqMessageSetToSeqDict(messages)

 for seq, filename in messagesToFetch.items():

 uid = self.getUID(seq)

 flags = self.metadata['flags'].get(uid, [])

 yield seq, MaildirMessage(file(filename).read(), uid, flags)



 def addListener(self, listener):

 self.listeners.append(listener)

 return True



 def removeListener(self, listener):

 self.listeners.remove(listener)

 return True



 def requestStatus(self, path):

 return imap4.statusRequestHelper(self, path)



 def addMessage(self, msg, flags=None, date=None):

 if flags is None: flags = []

 return self.maildir.appendMessage(msg).addCallback(

 self._addedMessage, flags)



 def _addedMessage(self, _, flags):

 # the first argument is the value returned from

 # MaildirMailbox.appendMessage. It doesn't contain any meaningful

 # information and can be discarded. Using the name "_" is a Twisted

 # idiom for unimportant return values.

 self._assignUIDs()

 messageFile = os.path.basename(self.maildir[-1])

 messageID = self.metadata['uids'][messageFile]

 self.metadata['flags'][messageID] = flags

 self.saveMetadata()



 def store(self, messageSet, flags, mode, uid):

 if uid:

 messages = self._uidMessageSetToSeqDict(messageSet)

 else:

 messages = self._seqMessageSetToSeqDict(messageSet)

 setFlags = {}

 for seq, filename in messages.items():

 uid = self.getUID(seq)

 if mode == 0: # replace flags

 messageFlags = self.metadata['flags'][uid] = flags

 else:

 messageFlags = self.metadata['flags'].setdefault(uid, [])

 for flag in flags:

 # mode 1 is append, mode -1 is delete

 if mode == 1 and not messageFlags.count(flag):

 messageFlags.append(flag)

 elif mode == -1 and messageFlags.count(flag):

 messageFlags.remove(flag)

 setFlags[seq] = messageFlags

 self.saveMetadata()

 return setFlags



 def expunge(self):

 "remove all messages marked for deletion"

 removed = []

 for filename in self.maildir:

 uid = self.metadata['uids'].get(os.path.basename(filename))

 if r"Deleted" in self.metadata['flags'].get(uid, []):

 self.maildir.deleteMessage(filename)

 # you could also throw away the metadata here

 removed.append(uid)

 return removed



 def destroy(self):

 "complete remove the mailbox and all its contents"

 raise imap4.MailboxException("Permission denied.")



from cStringIO import StringIO

import email



class MaildirMessagePart(object):

 implements(imap4.IMessagePart)



 def __init__(self, mimeMessage):

 self.message = mimeMessage

 self.data = str(self.message)



 def getHeaders(self, negate, *names):

 """

 Return a dict mapping header name to header value. If *names

 is empty, match all headers; if negate is true, return only

 headers _not_ listed in *names.

 """

 if not names: names = self.message.keys()

 headers = {}

 if negate:

 for header in self.message.keys():

 if header.upper() not in names:

 headers[header.lower()] = self.message.get(header, '')

 else:

 for name in names:

 headers[name.lower()] = self.message.get(name, '')

 return headers



 def getBodyFile(self):

 "return a file-like object containing this message's body"

 bodyData = str(self.message.get_payload())

 return StringIO(bodyData)



 def getSize(self):

 return len(self.data)



 def getInternalDate(self):

 return self.message.get('Date', '')



 def isMultipart(self):

 return self.message.is_multipart()



 def getSubPart(self, partNo):

 return MaildirMessagePart(self.message.get_payload(partNo))



class MaildirMessage(MaildirMessagePart):

 implements(imap4.IMessage)



 def __init__(self, messageData, uid, flags):

 self.data = messageData

 self.message = email.message_from_string(self.data)

 self.uid = uid

 self.flags = flags



 def getUID(self):

 return self.uid



 def getFlags(self):

 return self.flags



class MailUserRealm(object):

 implements(portal.IRealm)

 avatarInterfaces = {

 imap4.IAccount: IMAPUserAccount,

 }



 def __init__(self, baseDir):

 self.baseDir = baseDir



 def requestAvatar(self, avatarId, mind, *interfaces):

 for requestedInterface in interfaces:

 if self.avatarInterfaces.has_key(requestedInterface):

 # make sure the user dir exists (avatarId is username)

 userDir = os.path.join(self.baseDir, avatarId)

 if not os.path.exists(userDir):

 os.mkdir(userDir)

 # return an instance of the correct class

 avatarClass = self.avatarInterfaces[requestedInterface]

 avatar = avatarClass(userDir)

 # null logout function: take no arguments and do nothing

 logout = lambda: None

 return defer.succeed((requestedInterface, avatar, logout))



 # none of the requested interfaces was supported

 raise KeyError("None of the requested interfaces is supported")



def passwordFileToDict(filename):

 passwords = {}

 for line in file(filename):

 if line and line.count(':'):

 username, password = line.strip().split(':')

 passwords[username] = password

 return passwords



class CredentialsChecker(object):

 implements(checkers.ICredentialsChecker)

 credentialInterfaces = (credentials.IUsernamePassword,

 credentials.IUsernameHashedPassword)



 def __init__(self, passwords):

 "passwords: a dict-like object mapping usernames to passwords"

 self.passwords = passwords



 def requestAvatarId(self, credentials):

 """

 check to see if the supplied credentials authenticate.

 if so, return an 'avatar id', in this case the name of

 the IMAP user.

 The supplied credentials will implement one of the classes

 in self.credentialInterfaces. In this case both

 IUsernamePassword and IUsernameHashedPassword have a

 checkPassword method that takes the real password and checks

 it against the supplied password.

 """

 username = credentials.username

 if self.passwords.has_key(username):

 realPassword = self.passwords[username]

 checking = defer.maybeDeferred(

 credentials.checkPassword, realPassword)

 # pass result of checkPassword, and the username that was

 # being authenticated, to self._checkedPassword

 checking.addCallback(self._checkedPassword, username)

 return checking

 else:

 raise credError.UnauthorizedLogin("No such user")



 def _checkedPassword(self, matched, username):

 if matched:

 # password was correct

 return username

 else:

 raise credError.UnauthorizedLogin("Bad password")



class IMAPServerProtocol(imap4.IMAP4Server):

 "Subclass of imap4.IMAP4Server that adds debugging."

 debug = True



 def lineReceived(self, line):

 if self.debug:

 print "CLIENT:", line

 imap4.IMAP4Server.lineReceived(self, line)



 def sendLine(self, line):

 imap4.IMAP4Server.sendLine(self, line)

 if self.debug:

 print "SERVER:", line



class IMAPFactory(protocol.Factory):

 protocol = IMAPServerProtocol

 portal = None # placeholder



 def buildProtocol(self, address):

 p = self.protocol()

 p.portal = self.portal

 p.factory = self

 return p



if __name__ == "__main__":

 import sys

 dataDir = sys.argv[1]



 portal = portal.Portal(MailUserRealm(dataDir))

 passwordFile = os.path.join(dataDir, 'passwords.txt')

 passwords = passwordFileToDict(passwordFile)

 passwordChecker = CredentialsChecker(passwords)

 portal.registerChecker(passwordChecker)



 factory = IMAPFactory()

 factory.portal = portal



 reactor.listenTCP(143, factory)

 reactor.run()

Run imapserver.py from the command line with the name of the base mail directory as the only argument. This should be the same directory you used for the SMTP and POP3 servers in Examples 8-1 and 8-3:


 $ python imapserver.py mail_storage

Once the server is running, set up your mail client to connect to localhost using IMAP. You should be able to see the Inbox folder, create folders and subfolders, subscribe to folders, view messages, mark messages as read or unread, and move messages between folders, as shown in Figure 8-6.

8.4.2. How Does That Work?

Compared to the other examples in this chapter, there's a lot of code required to make an IMAP server. But don't let that intimidate you. Most of the code in Example 8-4 is in the classes IMAPUserAccount, IMAPMaildir, MaildirMessagePart, and MaildirMessage, which respectively implement the interfaces imap4.IAccount, imap4.IMailbox, imap4.IMessagePart, and imap4.IMessage. These interfaces have a lot of methods, because the IMAP server needs to be able to do a lot of different things. However, most of the methods themselves are pretty simple, taking just a few lines of code. The following subsections go through the interfaces one at a time, to look at how they're used in Example 8-4.

Figure 8-6. Working with messages on the IMAP server

 

8.4.2.1. IAccount

The imap4.IAccount interface defines a user account, and provides access to the user's mailboxes . imap4.IAccount defines methods for listing, creating, deleting, renaming, and subscribing to mailboxes. Mailboxes are hierarchal, with a server-defined delimiter character. In Example 8-4, the delimiter character is a period, so the folder MailingLists.Twisted would be considered a subfolder of MailingLists. In this case, the user's mailboxes are a set of maildir directories kept within a single parent directory. The use of a period as a delimiter makes it easier to keep all the maildirs in a single flat directory structure while still keeping track of their hierarchy. You could feel free to use another delmiter character, such as a forward slash (/), if it were more convenient for your needs.

According to RFC 2060, each IMAP user will have a folder called Inbox available in her account at all times. The name Inbox is case-insensitive, however, so different mail clients may ask for INBOX, Inbox, or inbox. Make sure you account for this in your code.

The select and create methods of IAccount return objects implementing IMailbox. The list method returns an iterable of tuples, with each tuple containing a path and an object implementing IMailbox.

8.4.2.2. IMailbox

The imap4.IMailbox interface represents a single mailbox on the IMAP server. It has methods for getting information about the mailbox, reading and writing messages, and storing metadata. There are a couple of considerations to keep in mind when you write a class to implement IMailbox. First, note that an IMAP mailbox is more than just a collection of messages. It also is responsible for managing metadata about those messages and the mailbox itself. In Example 8-4, the IMAPMaildir class keeps metadata in a dictionary, which is pickled and stored in a hidden file in the maildir directory for persistence between sessions. These are some of the specific kinds of metadata you'll need to track:

 

Message UIDs

Every message in an IMAP mailbox has a permanent unique identifier. These are sequential, and are maintained over the life of the mailbox. The first ever message in a mailbox will have a UID of 1, the second a UID of 2, etc. These are different from simple sequence numbers in that they continue to build over the lifetime of a mailbox. For example, if you added 1000 messages to a new IMAP mailbox, they would be assigned the UIDs 11001. If you then deleted all those messages and added a single new message, it would be given a UID of 1002, even though there wouldn't be any other messages in the mailbox. UIDs are used by the client for caching purposes, to avoid repeatedly downloading the same message. In IMailbox, the getUID function returns the UID of a message, given its sequence number. The getUIDNext function returns the UID number that will be assigned to the next message added to the mailbox.

 

Message flags

Every message in the mailbox can have an arbitrary number of flags associated with it. The flags are set by the client to keep track of metadata about that message, such as whether it's been read, replied to, or flagged. RFC 2060 defines a set of system flags that all IMAP clients should use the same way: Seen, Answered, Flagged, Deleted, Draft, and Recent. The IMailbox getFlags methods returns the list of flags supported by the mailbox. The store method sets the flags on a group of messages.

 

The mailbox UID validity identifier

There can be times when a mailbox keeps the same name, but changes its list of UIDs. The most common example of this is when a mailbox is deleted and a new mailbox with the same name is created. This could cause confusion for a mail client, since it may have a cached copy of the message with UID 1, which is now different from the message on the server with UID 1. To prevent this, IMAP has the concept of a UID validity identifier. Each mailbox has a unique number associated with it. As long as this number remains the same, the client can be confident that its UID numbers are still valid. If this number changes, the client knows to forget all its cached UID information. You should assign a UID validity identifier number for each mailbox, and return it as the result of getUIDValidity.

 

The mailbox subscription status

IMAP clients may not want or need to display all the mailboxes that actually exist on the server. The client can tell the server which mailboxes it is interested in by subscribing to those mailboxes. Subscribing doesn't do anything special other than toggling a subscribed bit on the mailbox. The subscription status of each mailbox is stored on the server, so that it can be maintained from session to session and between different clients.

The second thing to be aware of when implementing IMailbox is that there are two different numbering schemes that clients may use to refer to messages . The first is UIDs, persistent unique identifiers. The second is sequence numbers, which are a simple list of numbers from 1 to the number of messages in the mailbox. The fetch and store methods of IMailbox take an imap4.MessageSet object along with a Boolean uid argument. The MessageSet is an iterable list of numbers, but you have to check the uid argument to determine whether it's a list of UIDs or a list of sequence numbers. The IMAPMaildir class in Example 8-4 uses two methods, _uidMessageSetToSeqDict and _seqMessageSetToSeqDict, to take either type of MessageSet and normalize it into a dictionary mapping sequence numbers to the filenames it uses internally to identify messages. Note that the lists returned by fetch and store always use sequence numbers, whether or not the uid argument was true.

The last property of a MessageSet may be set to None, which means that the MessageSet covers all messages from its start to the end of the mailbox. You can't actually iterate through a MessageSet in this state, though, so you should set last to a value just beyond the last message in the mailbox before you attempt to use the MessageSet.

The IMAP protocol includes support for server-to-client notification . A client will generally keep an open connection to the IMAP server, so rather than having the client periodically ask the server if there are any new messages, the server can send a message to the client to alert it as soon as messages have arrived. The IMailbox interface includes methods for managing these notifications. The addListener method registers an object implementing imap4.IMailboxListener. If you keep track of this object, you can use it later to notify the client when a new message arrives. The IMAPMaildir class in Example 8-4 keeps track of listeners, but doesn't actually use them. Example 8-5 shows how you might use listeners to notify the client of new messages.

Example 8-5. Using listener objects for notification


# run this code to let the client know when a new message

# appears in the mailbox

for listener in self.listeners:

 listener.newMessages(self.getMessageCount( ), self.getRecentCount( ))

 

8.4.3. IMessagePart and IMessage

In IMAP, an email message is not just a blob of data: it has a structure that can be accessed one part at a time. In its simplest form, a message is a group of headers and a body. More complex messages can have a multi-part body, with alternative versions of the body content (such as HTML and plain text) and attachments (which may themselves be other messages with their own structure).

The IMessagePart interface provides methods for investigating the structure of one part of a multi-part message. IMessage inherits from IMessagePart and adds the getUID and getFlags methods for retrieving metadata about the message. Implementing IMessagePart would be a chore if not for the excellent email module in the Python standard library, which contains a complete set of classes for parsing and working with email messages. In Example 8-4, the MaildirMessage class uses the email.message_from_string function to parse the message data into an email.Message.Message object. MaildirMessagePart takes an email.Message.Message object and wraps it in the IMessagePart interface.

8.4.4. Putting It All Together

Once you've implented IAccount, IMailbox, IMessagePart, and IMessage, just tie the pieces together to get a working IMAP server . Example 8-4 uses the same classes, MailUserRealm and CredentialsChecker, as the POP3 server in Example 8-3, except that the realm is set up to return IMAPMaildir avatars when the imap4.IMailbox interface is requested. The IMAPFactory object creates IMAPServerProtocol objects and sets their portal attribute to a Portal wrapping the realm and credentials checkers. The IMAPServerProtocol class is another example of how you can inherit from a Twisted Protocol class and add some print statements for debugging.

Getting Started

Building Simple Clients and Servers

Web Clients

Web Servers

Web Services and RPC

Authentication

Mail Clients

Mail Servers

NNTP Clients and Servers

SSH

Services, Processes, and Logging



Twisted Network Programming Essentials
Twisted Network Programming Essentials
ISBN: 0596100329
EAN: 2147483647
Year: 2004
Pages: 107
Authors: Abe Fettig

Flylib.com © 2008-2020.
If you may any questions please contact us: flylib@qtcs.net