Building a Tool for
Organizing IMAP Email

PyATL

Doug Hellmann

What is imapautofiler?

  • I send too much email.
  • I don't like gmail.
  • I need to keep my sent email somewhat organized.
  • Automation FTW!

Design Considerations

  • Must support multiple email clients
  • Must be easy to add new rules without writing code
  • Reduce errors in specifying actions

Standalone Program

usage: imapautofiler [-h] [-c CONFIG_FILE] [--list-mailboxes]

optional arguments:
  -h, --help            show this help message and exit
  -c CONFIG_FILE, --config-file CONFIG_FILE
  --list-mailboxes      instead of processing rules,
                          print a list of mailboxes

Processing Overview

  1. Read the configuration file.
  2. Connect to the IMAP server.
  3. For each mailbox in the config file,
  4. For each message,
  5. For each rule,
  6. If the rule matches the message,
  7. Run the associated action.

Configuring IMAP Server Connection

server:
  hostname: "mail.example.com"
  username: "doughellmann@example.com"

Connecting with IMAPClient

        cfg = config.get_config(args.config_file)
        conn = imapclient.IMAPClient(
            cfg['server']['hostname'],
            use_uid=True,
            ssl=True,
        )
        username = cfg['server']['username']
        password = cfg['server'].get('password')
        if not password:
            password = getpass.getpass(
                'Password for {}:'.format(username))
        conn.login(username, password)

Rule Class

How do the rules work?

class Rule(metaclass=abc.ABCMeta):

    def __init__(self, rule_data, cfg):
        self._data = rule_data
        self._cfg = cfg

    @abc.abstractmethod
    def check(self):
        raise NotImplementedError()

    def get_action(self):
        return self._data.get('action', {})

Header Rule

      - headers:
          - name: "subject"
            substring: "[pyatl]"
        action:
          name: "move"
          dest-mailbox: "INBOX.PyATL"

HeaderSubString Class

class HeaderSubString(Rule):

    def __init__(self, rule_data, cfg):
        super().__init__(rule_data, cfg)
        self._header_name = rule_data['name']
        self._substring = rule_data['substring'].lower()

    def check(self, message):
        header_value = message.get(self._header_name, '').lower()
        return (self._substring in header_value)

Combination Rule: OR

      - or:
          rules:
            - headers:
                - name: "to"
                  substring: "pyatl-list@meetup.com"
            - headers:
                - name: "cc"
                  substring: "pyatl-list@meetup.com"
        action:
          name: "move"
          dest-mailbox: "INBOX.PyATL"

Or Class

class Or(Rule):
    "True if any one of the sub-rules is true."

    def __init__(self, rule_data, cfg):
        super().__init__(rule_data, cfg)
        self._sub_rules = [
            factory(r, cfg)
            for r in rule_data['or'].get('rules', [])
        ]

    def check(self, message):
        return any(
            r.check(message)
            for r in self._sub_rules
        )

Recipient Rule

      - recipient:
          substring: "pyatl-list@meetup.com"
        action:
          name: "move"
          dest-mailbox: "INBOX.PyATL"

Recipient Class

class Recipient(Or):
    "True if any recipient sub-rule matches."

    def __init__(self, rule_data, cfg):
        rules = []
        for header in ['to', 'cc']:
            header_data = {}
            header_data.update(rule_data['recipient'])
            header_data['name'] = header
            rules.append({'headers': [header_data]})
        rule_data['or'] = {
            'rules': rules,
        }
        super().__init__(rule_data, cfg)

Instantiating Rules

def factory(rule_data, cfg):
    """Create a rule processor.

    Using the rule type, instantiate a rule processor that can check
    the rule against a message.

    """
    if 'or' in rule_data:
        return Or(rule_data, cfg)
    if 'headers' in rule_data:
        return Headers(rule_data, cfg)
    if 'recipient' in rule_data:
        return Recipient(rule_data, cfg)
    raise ValueError('Unknown rule type {!r}'.format(rule_data))

Processing Rules

def process_rules(cfg, debug, conn):
    num_messages = 0
    num_processed = 0

    for mailbox in cfg['mailboxes']:      # multiple mailboxes allowed
        mailbox_name = mailbox['name']
        conn.select_folder(mailbox_name)

        mailbox_rules = [                 # convert data to instances
            rules.factory(r, cfg)
            for r in mailbox['rules']
        ]

Processing Rules

        msg_ids = conn.search(['ALL'])

        for msg_id in msg_ids:
            num_messages += 1
            message = get_message(conn, msg_id)

            for rule in mailbox_rules:
                if rule.check(message):
                    action = actions.factory(rule.get_action(), cfg)
                    action.invoke(conn, msg_id, message)
                    num_processed += 1
                    break

        # Remove messages that we just moved.
        conn.expunge()

Rule Actions

        msg_ids = conn.search(['ALL'])

        for msg_id in msg_ids:
            num_messages += 1
            message = get_message(conn, msg_id)

            for rule in mailbox_rules:
                if rule.check(message):
                    action = actions.factory(rule.get_action(), cfg)
                    action.invoke(conn, msg_id, message)
                    num_processed += 1
                    break

        # Remove messages that we just moved.
        conn.expunge()

Action Class

class Action(metaclass=abc.ABCMeta):

    def __init__(self, action_data, cfg):
        self._data = action_data
        self._cfg = cfg

    @abc.abstractmethod
    def invoke(self, conn, message_id, message):
        raise NotImplementedError()

Move Action

        action:
          name: "move"
          dest-mailbox: "INBOX.PyATL"

Move Class

class Move(Action):

    def __init__(self, action_data, cfg):
        super().__init__(action_data, cfg)
        self._dest_mailbox = self._data.get('dest-mailbox')

    def invoke(self, conn, message_id, message):
        conn.copy([message_id], self._dest_mailbox)
        conn.add_flags([message_id], [imapclient.DELETED])

Trash Action

      - headers:
          - name: to
            substring: plans@tripit.com
        action:
          name: "trash"

Trash Class

class Trash(Move):

    def __init__(self, action_data, cfg):
        super().__init__(action_data, cfg)
        if self._dest_mailbox is None:
            self._dest_mailbox = cfg.get('trash-mailbox')
        if self._dest_mailbox is None:
            raise ValueError('no "trash-mailbox" set in config')

Action Factory

def factory(action_data, cfg):
    "Create an Action instance."
    name = action_data.get('name')
    if name == 'move':
        return Move(action_data, cfg)
    if name == 'delete':
        return Delete(action_data, cfg)
    if name == 'trash':
        return Trash(action_data, cfg)
    raise ValueError('unrecognized rule action {!r}'.format(action_data))

Sample Run

$ imapautofiler
Move: 13704 (Re: livarot france - Google Search) to INBOX.Theresa
Trash: 13706 (Help Anne with theme work) to INBOX.Trash
Trash: 13707 (Fwd: Change in openstack/oslo.db[master]: Warn on URL without a drivername) to INBOX.Trash
Move: 13709 ([oslo][all][logging] logging debugging improvement work status) to INBOX.OpenStack.Dev List
Move: 13712 (Re: ) to INBOX.Personal
Trash: 13713 (Fwd: [Python-Dev] 2017 Python Language Summit coverage) to INBOX.Trash
Move: 13732 (Re: [Openstack-operators] [openstack-dev] [upgrades][skip-level][leapfrog] - RFC - Skipping releases when upgrading) to INBOX.OpenStack.Misc Lists

Future Work

  • Date-based rules
  • Body rules (attachments, text content, etc.)
  • Multiple accounts per config file
  • Copy action
  • Transferring messages between accounts
  • Improved documentation

Resources

 imapautofiler/imapautofiler
http://imapautofiler.readthedocs.io/

 @doughellmann

 imapautofiler/presentation-organize-email-imapautofiler
https://doughellmann.com/presentations/organize-email-imapautofiler

Creative Commons License  This work is licensed under a Creativle Commons Attribution 4.0 International License.