8
\$\begingroup\$

From time to time I use Python's poplib module to inspect/manipulate POP3 mailboxes, often interactively. I find the interface very cumbersome however, so I decided to try to make a wrapper that's easier to work with. I haven't tried to make it complete, rather it mostly just does what I need it to do.

I'm mostly happy with it (though constructive criticism is always welcome), but there's one thing that feels a bit weird. I don't have much experience writing context managers. I suppose it would normally be more straightforward to make the Pop3 class itself a context manager, but instead I created two context managers apart from the Pop3 class because that allows me to have the code work for connections with and without SSL with a minimum of duplicated code. Maybe there's a better way?

import email
import email.utils
import poplib
from collections import OrderedDict
from functools import lru_cache

"""
Wrapper around poplib.POP3/poplib.POP3_SSL to make it easier to use. Doesn't (currently)
offer all functionality of poplib, but it does what I need it to.

Best used via the context manager connect() or connect_ssl():

    with pop3.connect_ssl(host, username, password) as p:
        for msg in p.messages.values():   # messages is a mapping: `num` -> `message`
            print(msg.summary)
            print('   to:', msg.headers['To'])
"""

class Message:
    """Message represents a message on a POP3 server"""

    def __init__(self, pop3, num, size):
        """Initialize a new instance. Don't call this manually: this is only
        meant to be called by Pop3 instances."""
        self.pop3 = pop3
        self.num = num
        self.size = size
        self.marked_for_deletion = False

    def __repr__(self):
        return 'Message(num=%d, size=%d)' % (self.num, self.size)

    def __str__(self):
        return self.summary

    @property
    @lru_cache()
    def headers(self):
        """The message's headers as an email.message instance"""
        return self.pop3._top(self.num, 0)

    @property
    @lru_cache()
    def email(self):
        """The complete email as an email.message instance"""
        return self.pop3._retr(self.num)

    @property
    def summary(self):
        """Summary line: deletion flag, message number, from, date, subject"""
        return '%s%-3d %-20s %-25s %s' % (
            '*' if self.marked_for_deletion else ' ',
            self.num,
            self.headers['From'],
            email.utils.parsedate_to_datetime(self.headers['Date']).isoformat(sep=' '),
            self.headers['Subject'],
            )

    def mark_for_deletion(self):
        """Mark this message for deletion. The server will delete the message
        when the connection is closed."""
        self.pop3._mark_for_deletion(self.num)

class Pop3:
    """Connection to a POP3 mailbox (simple wrapper around poplib.POP3 or poplib.POP3_SSL)

    Attribute `messages` holds the messages in the mailbox, as follows:
    Messages are represented by Message instances. Each message has an attribute num which
    is used to uniquely identify the message when communication with the server. 
    `messages` is an OrderedDict indexed on `num`.
    Use e.g. `messages.values()` to get all messages.
    """

    def __init__(self, poplib_pop3, username, password):
        """Initialize a new instance. This is normally called from the connect or
        connect_ssl context managers.

        poplib_pop3: pre-made poplib.POP3 or poplib.POP3_SSL instance
        username: username
        password: password
        """
        self.poplib_pop3 = poplib_pop3
        self.poplib_pop3.user(username)
        self.poplib_pop3.pass_(password)
        self.messages = self._request_list()

    def _request_list(self):
        """Request the list of messages from the server"""
        response, msg_infos, size = self.poplib_pop3.list()
        messages = OrderedDict()
        for msg_info in msg_infos:
            msg_num_string, size_string = msg_info.split()
            msg_num = int(msg_num_string)
            size = int(size_string)
            messages[msg_num] = Message(self, msg_num, size)
        return messages

    @property
    def greeting(self):
        """Server greeting"""
        return self.poplib_pop3.getwelcome()

    def _mark_for_deletion(self, num):
        """Mark message <num> for deletion"""
        if num not in self.messages:
            raise KeyError('Invalid message number %d' % num)
        self.poplib_pop3.dele(num)
        self.messages[num].marked_for_deletion = True

    def _email_from_lines(self, lines):
        """Parse email as email.message from lines as we get them from the server"""
        # lines as we get them from the poplib module are bytestrings, but the
        # email module needs a string. Which codec to use? Depends on the
        # encoding specified in the headers, I would think, but we don't know
        # that yet.
        # Use UTF-8 for now ...
        message = ''.join(line.decode('UTF-8') + '\n' for line in lines)
        return email.message_from_string(message)

    def _top(self, num, nr_lines_extra):
        """Retrieve header + nr_lines_extra lines from server as an email.message instance"""
        if num not in self.messages:
            raise KeyError('Invalid message number %d' % num)
        response, lines, size = self.poplib_pop3.top(num, nr_lines_extra)
        return self._email_from_lines(lines)

    def _retr(self, num):
        """Retrieve message <num> from server as an email.message instance"""
        if num not in self.messages:
            raise KeyError('Invalid message number %d' % num)
        response, lines, size = self.poplib_pop3.retr(num)
        return self._email_from_lines(lines)

    def reset_deletion_marks(self):
        """Reset all deletion marks"""
        self.poplib_pop3.rset()

    def close(self):
        """Close the connection. Normally handed for you by the context manager."""
        self.poplib_pop3.quit()

class connect:
    """Context manager for Pop3 without SSL"""
    def __init__(self, host, username, password, port=poplib.POP3_PORT, timeout=None):
        self.pop3 = Pop3(
            poplib.POP3(host, port, timeout),
            username,
            password
            )
    def __enter__(self):
        return self.pop3
    def __exit__(self, exc_type, exc_value, traceback):
        self.pop3.close()

class connect_ssl:
    """Context manager for Pop3 with SSL"""
    def __init__(self, host, username, password, port=poplib.POP3_SSL_PORT, keyfile=None, certfile=None, timeout=None, context=None):
        self.pop3 = Pop3(
            poplib.POP3_SSL(host, port, keyfile, certfile, timeout, context),
            username,
            password
            )
    def __enter__(self):
        return self.pop3
    def __exit__(self, exc_type, exc_value, traceback):
        self.pop3.close()

You can also find the code at https://gitlab.com/roelschroeven-unixtools/pop3tool

\$\endgroup\$
1
\$\begingroup\$

Good job, this is some very clean code. There's some small things you could improve, but for the most part this is readable and to the point. I'll be going over some smaller issues first, and then cover your main question.

  • Module-level docstrings should be at the very top of a source code file. This also makes it clear that it belongs to the entire module, and isn't additional documentation for a specific piece of code.

  • "%"-formatting is going out of fashion, can be a pain to deal with, and is (arguably) hard to read. Luckily, there's str.format, which fixes these issues.

  • Lines shouldn't exceed 79 characters, according to PEP8. Certain scenarios exist where you're allowed to break this rule, like with multi-line strings, or in cases where wrapping long lines would hurt readability. Informally, the limit is often either 80 or 100 characters.


I suppose it would normally be more straightforward to make the Pop3 class itself a context manager, but instead I created two context managers apart from the Pop3 class because that allows me to have the code work for connections with and without SSL with a minimum of duplicated code. Maybe there's a better way?

Strictly speaking, there's no need for connect and connect_ssl, so you should do away with them and make Pop3 implement the context manager protocol.

On the other hand, nothing strikes me as odd about the current implementation (although if you really wanna go overboard, you should create a Pop3SSL class to avoid violating the single responsibility principle) and as long as it works for you, just keep it the way it is!

\$\endgroup\$
  • \$\begingroup\$ Thanks! I've moved the docstring to the top now. As for the "%"-formatting: I like it because it's what I know best, coming from a C background. To me it's more readable. But familiarizing myself with str.format is certainly on my todo list. And about the 79 characters: I followed Raymond Hettinger's advice in his "Beyond PEP 8 -- Best practices for beautiful intelligible code" talk at Pycon 2015 (youtube.com/watch?v=wf-BqAjZb8M): "My recommendation for the line length for code outside the standard library, when you can set your own policies, is 90-ish." \$\endgroup\$ – Roel Schroeven Feb 15 '18 at 19:56
  • 1
    \$\begingroup\$ And yeah, I'm thinking about making Pop3 implementing the context manager protocol. Not sure though. There's the single responsibilty principle on one hand, and DRY on the other hand. \$\endgroup\$ – Roel Schroeven Feb 15 '18 at 19:58
  • \$\begingroup\$ In this particular case, I think Getting The Job Done is more important than complying with all the programming principles. If the current setup works for personal use, it's probably best to leave it :) (I've gotten into the bad habit of wanting to make every piece of code I write reusable and scalable which has certainly not helped me become a better developer in the long term). \$\endgroup\$ – Daniel Feb 15 '18 at 22:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.