#!/usr/bin/env python ## ## Name: eyepopper.py ## Purpose: Simple mailbox-to-POP3 gateway. ## Author: M. J. Fromberger ## Info: $Id: eyepopper.py 664 2008-09-19 03:21:03Z sting $ ## from __future__ import with_statement from SocketServer import TCPServer, StreamRequestHandler import getopt, hashlib, os, re, socket, sys import pdb __version__ = "1.1" # {{ class MessageBase class MessageBase (object): """Base class representing a message stored in a Unix mailbox file. Subclasses must override: ._data -- the complete mailbox data (string). ._path -- the pathname of the mailbox file (string). Instances provide the following attributes: .start -- position of first character of message .end -- position after last character of message .poplen -- length of message including CRLF conversion .bodypos -- position of first character of mesage body .uid -- unique identifier for message .content -- the complete content of the message (computed) .head -- the headers of the message (computed) .body -- the body of the message (computed) """ def __init__(self, start, end): self.start = start self.end = end text = self._data[start:end] self.poplen = (end - start) + len(re.findall(r'\n', text)) bmark = re.search(r'\n\n', text) if bmark is None: pdb.set_trace() self.bodypos = start + bmark.end() hash = hashlib.md5() hash.update(text) hash.update(self._path) self.uid = hash.hexdigest() def get_content(self): return self._data[self.start : self.end] def get_head(self): return self._data[self.start : self.bodypos] def get_body(self): return self._data[self.bodypos : self.end] def get_top(self, n): """As .get_content(), but return at most n lines from the beginning of the message body, n >= 0. """ assert n >= 0 lines = self.body.split('\n') return self.head + '\n'.join(lines[:n]) content = property(get_content, None, None, "Return the complete content of the message.") head = property(get_head, None, None, "Return the headers of the message.") body = property(get_body, None, None, "Return the body of the message.") # }} # {{ class MailboxFile class MailboxFile (object): """Abstracts a Unix mailbox file stored on disk.""" def __init__(self, path): """Construct a new mailbox abstraction around the given file.""" self._path = os.path.realpath(path) self.build_index() def __len__(self): """Return the number of messages in this mailbox.""" return len(self._msgs) def __getitem__(self, itm): """Index into the mailbox by message.""" return self._msgs[itm] def __iter__(self): """Iterate over the messages in this mailbox.""" return iter(self._msgs) def mailbox_path(self): """Return the pathname of the mailbox.""" return self._path def mailbox_size(self): """Return the total size of the mailbox in octets.""" return sum(m.poplen for m in self._msgs) def build_index(self): """Construct an index of the contents of the associated disk file.""" ex = re.compile(r'^From \w+.*\n\w+:', re.MULTILINE | re.UNICODE) with file(self._path, 'rt') as fp: data = fp.read() class Message (MessageBase): _data = data _path = self._path msgs = [{}] # sentinel for msg in ex.finditer(data): msgs[-1]['end'] = msg.start() - 1 msgs.append({'start': msg.start()}) msgs[-1]['end'] = len(data) msgs.pop(0) self._msgs = list(Message(m['start'], m['end']) for m in msgs) self._data = data # }} # {{ class POP3Server class POP3Server (TCPServer): """Implements a simple read-only POP3 server.""" allow_reuse_address = True def __init__(self, port, mailboxes, debug = False, users = ()): """Create a new server instance. port -- TCP port to listen on. mailboxes -- MailboxFile objects to serve. debug -- enable debugging output? """ self._debug = debug self._host = 'localhost' self._port = port self._boxes = list(mailboxes) self._dels = set() self._users = dict(users) TCPServer.__init__(self, (self._host, port), POP3Handler) def _diag(self, msg, *args): if self._debug: print >> sys.stderr, msg % args def run(self): """Run the server until it is killed or closed.""" self.server_activate() self.running = True try: self._diag('* SERVER STARTING at %s %s', self._host, self._port) while self.running: self.handle_request() self._diag('* SERVER SHUTTING DOWN') except KeyboardInterrupt: print >> sys.stderr, "\n>> INTERRUPT <<" finally: self.server_close() def mail_total_count(self): """[client] Return the total number of messages in all known mailboxes. """ return sum(len(b) for b in self._boxes) def mail_total_size(self): """[client] Return the total size of the messages in all known mailboxes. """ return sum(b.mailbox_size() for b in self._boxes) def mail_get_index(self, pos): """[client] Fetch a message object by its global index (0-based).""" if pos < 0: raise IndexError("Out of range") for mbox in self._boxes: if len(mbox) > pos: break pos -= len(mbox) else: raise IndexError("Out of range") return mbox[pos] def mail_get_all(self): """[client] Iterate over all the messages in all known mailboxes.""" for mbox in self._boxes: for msg in mbox: yield msg def mail_mark_deleted(self, pos): """[client] Mark the specified message as "deleted".""" self.mail_get_index(pos) # range test self._dels.add(pos) def mail_get_deleted(self): """[client] Return a set of indices of deleted messages.""" return self._dels def mail_clear_deleted(self): """[client] Clear the list of "deleted" messages.""" self._dels.clear() def mail_add_mailbox(self, path): """[client] Add a new mailbox to the list of known mailboxes.""" for box in self._boxes: if box.mailbox_path() == path: return box = MailboxFile(path) # may fail self._boxes.append(box) def is_mail_deleted(self, pos): """[client] Check whether the message specified is "deleted".""" return pos in self._dels def is_user_ok(self, user): """[client] Check whether a user ID is acceptable.""" return len(self._users) == 0 or user in self._users def is_auth_ok(self, user, pw): """[client] Check user authentication.""" return len(self._users) == 0 or ( user in self._users and self._users[user] == pw) # }} # {{ class StateError class StateError (Exception): """An exception used internally by POP3Handler.""" pass # }} # {{ class POP3Handler class POP3Handler (StreamRequestHandler): """Implements a very simple POP3 service. In addition to the usual POP3 suite, this handler implements the following client commands: SHUTDOWN -- close down the server immediately. ADDBOX -- load a new mailbox into the running server. """ welcome_banner = 'POP3 server ready' def cmd_unknown(self, cmd, data): """Handle unknown commands.""" self.wfile.write('-ERR Command not understood\r\n') def _diag(self, msg, *args): self.server._diag(msg, *args) def check_state(self, cmd, data, state): if self.state <> state: self.wfile.write('-ERR Invalid command\r\n') raise StateError def check_args(self, cmd, data, min, max): args = re.split(r'\s+', data) if data else [] if min <= len(args) and (max < 0 or len(args) <= max): return args else: self.wfile.write('-ERR Wrong number of arguments (%s)\r\n' % cmd) raise StateError def parse_args(self, args): try: return list(int(x) for x in args) except ValueError: self.wfile.write('-ERR Invalid argument\r\n') raise StateError def check_message(self, pos): try: msg = self.server.mail_get_index(pos) if self.server.is_mail_deleted(pos): self.wfile.write('-ERR Message is deleted\r\n') raise StateError return msg except IndexError: self.wfile.write('-ERR Message out of range\r\n') raise StateError def send_data(self, data): esc = re.compile(r'^\.', re.MULTILINE | re.UNICODE) text = esc.sub('..', data.replace('\n', '\r\n')) self.wfile.write('+OK %d octets\r\n' % len(text)) self.wfile.write(text) if not text.endswith('\r\n'): self.wfile.write('\r\n') self.wfile.write('.\r\n') def cmd_NOOP(self, cmd, data): self.check_args(cmd, data, 0, 0) self.wfile.write('+OK Nothing accomplished\r\n') def cmd_QUIT(self, cmd, data): self.check_args(cmd, data, 0, 0) self.state = 'UPDATE' # triggers exit from handler loop self._diag('- Received QUIT command, entering UPDATE state.') def cmd_USER(self, cmd, data): try: self.check_state(cmd, data, 'AUTH') self.check_args(cmd, data, 1, 1) if self.server.is_user_ok(data): self.wfile.write('+OK %s\r\n' % data) self.state = 'USER' self.userid = data else: self.wfile.write('-ERR User invalid\r\n') except StateError: if self.state == 'USER': self.state = 'AUTH' def cmd_PASS(self, cmd, data): self.check_state(cmd, data, 'USER') self.check_args(cmd, data, 1, 1) if self.server.is_auth_ok(self.userid, data): self.wfile.write('+OK Ready\r\n') self.state = 'TRANS' self._diag('- Authenticated "%s", entering TRANSACTION state.', self.userid) else: self.wfile.write('-ERR Access denied\r\n') self.state = 'AUTH' def cmd_STAT(self, cmd, data): self.check_state(cmd, data, 'TRANS') self.check_args(cmd, data, 0, 0) self.wfile.write('+OK %d %d\r\n' % (self.server.mail_total_count(), self.server.mail_total_size())) def list_cmd(extract): def do_command(self, cmd, data): self.check_state(cmd, data, 'TRANS') args = self.parse_args(self.check_args(cmd, data, 0, 1)) if args: msg = self.check_message(args[0] - 1) elt = extract(msg) self.wfile.write('+OK %d %s\r\n' % (args[0], elt)) else: self.wfile.write('+OK %d messages (%d octets)\r\n' % ( self.server.mail_total_count(), self.server.mail_total_size())) for pos, msg in enumerate(self.server.mail_get_all()): if not self.server.is_mail_deleted(pos): elt = extract(msg) self.wfile.write('%d %s\r\n' % (pos + 1, elt)) self.wfile.write('.\r\n') return do_command cmd_LIST = list_cmd(lambda m: m.poplen) cmd_UIDL = list_cmd(lambda m: m.uid) def cmd_RETR(self, cmd, data): self.check_state(cmd, data, 'TRANS') args = self.parse_args(self.check_args(cmd, data, 1, 1)) msg = self.check_message(args[0] - 1) self.send_data(msg.content) def cmd_DELE(self, cmd, data): self.check_state(cmd, data, 'TRANS') args = self.parse_args(self.check_args(cmd, data, 1, 1)) msg = self.check_message(args[0] - 1) self.server.mail_mark_deleted(args[0] - 1) self.wfile.write('+OK Message %d deleted\r\n' % args[0]) self._diag('- Marked message %d for deletion.', args[0]) def cmd_RSET(self, cmd, data): self.check_state(cmd, data, 'TRANS') self.check_args(cmd, data, 0, 0) self.server.mail_clear_deleted() self.wfile.write('+OK Reset\r\n') self._diag('- Reset deleted messages list.') def cmd_TOP(self, cmd, data): self.check_state(cmd, data, 'TRANS') msgid, n = self.parse_args(self.check_args(cmd, data, 2, 2)) if n < 0: self.wfile.write('-ERR Invalid argument\r\n') return msg = self.check_message(msgid - 1) self.send_data(msg.get_top(n)) def cmd_SHUTDOWN(self, cmd, data): self.check_state(cmd, data, 'TRANS') self.check_args(cmd, data, 0, 0) self.server.running = False self.state = 'UPDATE' # triggers exit from handler loop self._diag('- Client requested server SHUTDOWN.') def cmd_ADDBOX(self, cmd, data): self.check_state(cmd, data, 'TRANS') if not data or data.isspace(): self.wfile.write('-ERR Invalid argument\r\n') return try: self.server.mail_add_mailbox(data) self.wfile.write('+OK Mailbox added %s\r\n' % data) self._diag('- Client requested mailbox "%s".', data) except (OSError, IOError), e: self.wfile.write('-ERR Mailbox not added (%s)\r\n' % e.strerror) def handle(self): """Required entry point for use with SocketServer. Dispatches received commands to .cmd_XXXX() methods based on the first word of the command line received from the client. Command names are case-insensitive, but the method names to implement them must be capitalized. If .cmd_unknown() is defined, it is given all commands that are not otherwise recognized. Note: This implementation is not thread-safe; in particular, it uses state on the server object without locks. """ try: self.wfile.write('+OK %s\r\n' % self.welcome_banner) self.state = 'AUTH' self._diag('- Client connected, entering AUTH state.') while self.state <> 'UPDATE': line = self.rfile.readline() args = line.rstrip().split(' ', 1) cmd = args[0] data = '' if len(args) > 1: data = args[1] hname = 'cmd_%s' % cmd.upper() try: getattr(self, hname, self.cmd_unknown)(cmd, data) except StateError: pass # When control reaches here, we are in the "UPDATE" state # of the POP3 protocol. Since this is read-only, deleted # messages will not actually be removed; the POP3 RFC # wants this to be an error. if len(self.server.mail_get_deleted()) == 0: self.wfile.write('+OK Goodnight sweet prince\r\n') else: self.wfile.write('-ERR Unable to delete messages\r\n') self.server.mail_clear_deleted() self._diag('* REQUEST COMPLETE\n') except socket.error, e: pass self.wfile.close() self.rfile.close() # }} # {{ main(argv) def main(argv): def usage(short = True): """Print a human-readable usage message.""" print >> sys.stderr, "Usage: eyepopper.py [options] mailbox*" if short: print >> sys.stderr, " [use -h or --help for options]\n" else: print >> sys.stderr, """ This program implements a local read-only POP3 server that serves up messages stored in plain text mailbox files. It can be used to import such messages into clients that do not fully grok Unix mailbox format. Options: -h/--help : display this help message. -p/--port : listen on the specified port (default: %s). -q/--quiet : disable diagnostic output (run quietly) -u/--user : : allow this username/password combination. The POP_PORT environment variable will be used to specify the port, if it is defined and the --port option is not provided. Only one client will be served at a time, and only clients connecting from localhost are accepted. By default, any username and password are accepted. If the --user option is used one or more times, only the user/password combinations specified are granted access. The name and password are separated by a colon. Username and password combinations can also be supplied via the POP_USERS environment variable, where multiple entries are delimited by carriage returns. """ % listen_port # Process command-line options try: opts, args = getopt.gnu_getopt(argv, 'hp:qu:', ('help', 'port=', 'quiet', 'user=')) except getopt.GetoptError, e: print >> sys.stderr, "Error: %s" % e usage() return 1 listen_port = int(os.getenv('POP_PORT', 1110)) debugging = True legal_users = {} for opt, arg in opts: if opt in ('-h', '--help'): usage(short = False) return 0 elif opt in ('-q', '--quiet'): debugging = False elif opt in ('-p', '--port'): listen_port = int(arg) elif opt in ('-u', '--user'): try: name, pw = arg.strip().split(':', 1) legal_users[name] = pw except ValueError: print >> sys.stderr, "Error: Invalid user specification: %s" % arg usage() return 1 else: raise NotImplementedError("unimplemented option") # Load users from the environment, if any are defined for line in os.getenv('POP_USERS', '').split('\n'): try: name, pw = line.strip().split(':', 1) legal_users[name] = pw except ValueError: pass # skip quietly # Load mailbox files try: boxes = [MailboxFile(p) for p in args] except (OSError, IOError), e: print >> sys.stderr, "Error: %s" % e return 2 # Start up server if debugging: print >> sys.stderr, "EyePopper v. %s by M. J. Fromberger" % __version__ if boxes: print >> sys.stderr, "Mailboxes:\n -", print >> sys.stderr, '\n - '.join('%s (%d msg)' % (b.mailbox_path(), len(b)) for b in boxes) print >> sys.stderr if legal_users: print >> sys.stderr, "Legal users: %s\n" % ', '.join(legal_users) pop = POP3Server(listen_port, boxes, debug = debugging, users = legal_users) pop.run() return 0 # }} if __name__ == "__main__": res = main(sys.argv[1:]) sys.exit(res) # Here there be dragons