]> git.droids-corp.org - imapami.git/commitdiff
First public revision
authorOlivier Matz <zer0@droids-corp.org>
Sun, 22 Nov 2015 12:57:41 +0000 (13:57 +0100)
committerOlivier Matz <zer0@droids-corp.org>
Sun, 22 Nov 2015 12:57:41 +0000 (13:57 +0100)
Signed-off-by: Olivier Matz <zer0@droids-corp.org>
12 files changed:
MANIFEST.in [new file with mode: 0644]
README [new file with mode: 0644]
config-samples/extension/imapami-ext.py [new file with mode: 0755]
config-samples/simple/rules.yaml [new file with mode: 0644]
imapami/__init__.py [new file with mode: 0644]
imapami/actions.py [new file with mode: 0644]
imapami/conditions.py [new file with mode: 0644]
imapami/mail.py [new file with mode: 0644]
imapami/rules.py [new file with mode: 0644]
imapami/utils.py [new file with mode: 0644]
pylintrc [new file with mode: 0644]
setup.py [new file with mode: 0644]

diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644 (file)
index 0000000..987207f
--- /dev/null
@@ -0,0 +1,7 @@
+include README
+include MANIFEST.in
+graft imapami
+graft config-samples
+global-exclude *.pyc
+global-exclude *.pyo
+global-exclude *~
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..0e69e9e
--- /dev/null
+++ b/README
@@ -0,0 +1,47 @@
+imapami: your best IMAP friend
+======================================================
+
+Imapami is a tool that connects to an IMAP server and process the mails.
+It can usually replace the filters of your mail client or webmail, and
+provide more advanced features.
+
+For instance, it can:
+
+- move or copy mails into a directory, depending on its header or
+  content
+- pipe mails to a local program
+- archive old mails into a directory
+- flag or modify messages
+
+Run imapami
+-----------
+
+Run ``imapami --help`` for a list of command line options.
+
+Some configuration samples can be found in the ``config-samples``
+directory.  Run ``imapami --config-help`` for a documentation about the
+configuration files.
+
+Pros
+----
+
+- Imapami has a powerful configuration file in YAML:
+
+  - easily understandable.
+  - you can edit it without using your mouse :)
+  - rules can be generated by an external tool.
+
+- It is easily extendable in python to add specific checks or actions:
+
+  - imapami can be used as a lib.
+
+- It runs outside of your mail client:
+
+  - your mail filters do not depend on your mail client or on your
+    webmail provider anymore.
+
+Cons
+----
+
+- compared to procmail which can be run each time a mail is received,
+  imapami needs to be launched periodically, for instance in a crontab.
diff --git a/config-samples/extension/imapami-ext.py b/config-samples/extension/imapami-ext.py
new file mode 100755 (executable)
index 0000000..d6e01cd
--- /dev/null
@@ -0,0 +1,77 @@
+#!/usr/bin/env python
+
+#
+# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#     * Neither the name of the University of California, Berkeley nor the
+#       names of its contributors may be used to endorse or promote products
+#       derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+#
+# This is an example of how imapami can be extended in python. The
+# following code creates a new condition class that is registered. The
+# same can be done for actions.
+#
+# After that, the script calls imapami.run() which behaves like imapami
+# with the new condition registered.
+#
+
+import imapami
+import imapami.conditions
+
+class ImapamiCondList(imapami.conditions.ImapamiCond):
+    """
+    Match if the sender is present in the file.
+
+    The file is a list of mails, one per line.
+
+    Arguments:
+      file: <string> [mandatory]
+        The path to the balcklist file.
+
+    Example:
+      list: {filename: /home/user/list.txt}
+    """
+    name = "list"
+
+    def __init__(self, filename):
+        ImapamiCond.__init__(self, fetch='headers')
+        self.filename = filename
+
+    def check(self, imapami_handler, msg_id, hdrs, body_part1, body_all):
+        From = self.evaluate("{From}", imapami_handler, hdrs)
+        try:
+            f = open(self.filename)
+            while True:
+                l = f.readline()
+                if l == '':
+                    break
+                if From.lower() == l[:-1].lower():
+                    return True
+        except IOError:
+            imapami_handler.logger.warning("error opening list file <%s>")
+        return False
+imapami.conditions.register(ImapamiCondList)
+
+if __name__ == '__main__':
+    imapami.main()
diff --git a/config-samples/simple/rules.yaml b/config-samples/simple/rules.yaml
new file mode 100644 (file)
index 0000000..505424c
--- /dev/null
@@ -0,0 +1,103 @@
+#
+# This is a configuration file of imapami.
+# It is written in YAML.
+# The full documentation of this configuration file is displayed
+# with:
+#   imapami --config-help
+#
+
+# Mandatory.
+# Hostname or IP address of the IMAP server to connect to.
+server: imap.example.com
+
+# Optional.
+# The TCP port to connect to. If not specified, use the default,
+# depending on whether SSL is enabled or disabled.
+# port: 143
+
+# Mandatory.
+# Enable or disable SSL (True or False).
+ssl: True
+
+# Optional.
+# IMAP login name. If not specified, the login is asked on stdin.
+login: user
+
+# Optional.
+# IMAP password.  If not specified, the password is asked on stdin.
+password: cocolasticot
+
+# Optional.
+# File where messages are logged. If not specified, no logs are
+# written in a file.
+logfile: imapami.log
+
+# Optional.
+# Level of logs written in logfile. Possible value are from 0 (no log)
+# to 4 (debug). Default value is 3 (info).
+# loglevel: 3
+
+# Optional.
+# Default mailbox directory to get message from. If not specified,
+# use INBOX.
+# inbox: INBOX
+
+# Any field that is not a reserved keyword is saved as a variable,
+# and can be reused in rules with {var_name}.
+me: toto@example.com
+
+# List of rules. Each rule is composed of several fields:
+# - the directory where the rule apply ("inbox"). If not specified,
+#   the default mailbox directory is used.
+# - a list condition ("if"). All of them must be true to match
+#   the rule. They are all evaluated in the order they appear.
+# - a list of action ("do"). They are all executed in the order
+#   they appear.
+# - a list of actions executed when condition does not match
+#   ("else-do")
+# - a list of actions executed on error when processing an
+#   action list ("on-error-do").
+rules:
+
+# move some spam in another directory and mark as seen
+- name: remove spam
+  if:
+  - or:
+    - regexp: {field: X-Spam-Status, pattern: 'Yes'}
+    - regexp: {field: Subject, pattern: '[Vv]iagra'}
+  do:
+  - log: {msg: 'spam:\n  Subject: {Subject}\n  From: {From}\n  To: {To}', level: 4}
+  - seen: {}
+  - move: {dest: Junk}
+
+# move mailing list mails in a directory
+- name: mailing list foo
+  if:
+  - regexp: {field: Subject, pattern: '\[foo\]'}
+  do:
+  - set-var: {ml: yes}
+  - move: {dest: foo}
+
+# log if at least one ml message was processed
+- name: log if some mailing list messages
+  if:
+  - eq: ['{ml}', 'yes']
+  do:
+  - log: {msg: 'some messages for mailing list'}
+
+# pipe automation tasks to a script and mark as seen
+- name: automation
+  if:
+  - regexp: {field: Subject, pattern: '\[auto\]'}
+  do:
+  - pipe: {command: '/usr/bin/my-prog'}
+  - seen: {}
+
+# move mail whose To or Cc is me into another directory
+- name: urgent mails
+  if:
+  - or:
+    - to: {substr: '{me}'}
+    - cc: {substr: '{me}'}
+  do:
+  - move: {dest: urgent}
diff --git a/imapami/__init__.py b/imapami/__init__.py
new file mode 100644 (file)
index 0000000..5000e77
--- /dev/null
@@ -0,0 +1,325 @@
+#!/usr/bin/env python
+
+#
+# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#     * Neither the name of the University of California, Berkeley nor the
+#       names of its contributors may be used to endorse or promote products
+#       derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+import imapami.actions
+import imapami.conditions
+import imapami.rules
+
+import argparse
+import getpass
+import imaplib
+import inspect
+import logging
+import pydoc
+import sys
+import yaml
+
+_LOGLEVELS = [logging.CRITICAL, logging.ERROR, logging.WARNING,
+              logging.INFO, logging.DEBUG]
+
+class Imapami(object):
+    """
+    The yaml configuration is a collection (dictionary). The following keys
+    are reserved:
+
+    - server: <string> [mandatory]
+      Hostname or IP address of the IMAP server to connect to.
+
+    - port: <integer> [optional]
+      The TCP port to connect to. If not specified, use the default,
+      depending on whether SSL is enabled or disabled.
+
+    - ssl: <boolean> [optional, default is True]
+      Enable or disable SSL (True or False).
+
+    - login: <string> [optional]
+      IMAP login name. If not specified, the login is asked on stdin.
+
+    - password: <string> [optional]
+      IMAP password.  If not specified, the password is asked on stdin.
+
+    - logfile: <string> [optional]
+      File where messages are logged. If not specified, no logs are
+      written in a file.
+
+    - loglevel: <integer> [optional]
+      Level of logs written in logfile. Possible value are from 0 (no log)
+      to 4 (debug). Default value is 3 (info).
+
+    - inbox: <string> [optional, default is INBOX]
+      Default mailbox directory where rules get message from. It can be
+      overriden inside the rule.
+
+    - rules: <list of rules> [mandatory]
+      The list of rules to be applied. See the rule syntax for details.
+
+    Any key entry that is not reserved for a configuration parameter is
+    saved as a variable. The variables can be used in rules by referencing
+    them as {my_var} in conditions or actions arguments.
+
+    Variables are text only, it is possible to test them in rule conditions
+    and set them in rule actions.
+
+    """
+    def __init__(self, config, loglevel=3):
+        """
+        Create a Imapami object.
+
+        :arg str config:
+          The yaml configuration.
+        :arg integer loglevel:
+          The level of the console logs.
+        """
+        self.rules = []
+        self.config = {"server": None,
+                       "port": None,
+                       "ssl": True,
+                       "login": None,
+                       "password": None,
+                       "logfile": None,
+                       "loglevel": 3,
+                       "inbox": "INBOX",
+                       "rules": None}
+        self.imap = None
+        self.variables = {}
+        self.logger = self._get_logger(loglevel)
+        self._load_config(config)
+        self._update_logger()
+
+    def _get_logger(self, loglevel):
+        """
+        Create a logger, and configure it to log on console. This is done
+        before configuration is parsed.
+
+        :arg integer loglevel:
+          The level of the console logs from 0 (critical) to 4 (debug).
+        """
+
+        if not isinstance(loglevel, int) or loglevel < 0 or loglevel > 4:
+            sys.stderr.write("Invalid log level\n")
+            raise ValueError
+
+        # create a logger and a stream handler on console
+        logger = logging.getLogger('imapami')
+        logger.setLevel(logging.DEBUG)
+        console_handler = logging.StreamHandler()
+        console_handler.setLevel(_LOGLEVELS[loglevel])
+        formatter = logging.Formatter('%(levelname)s - %(message)s')
+        console_handler.setFormatter(formatter)
+        logger.addHandler(console_handler)
+        self.console_handler = console_handler
+        return logger
+
+    def _update_logger(self):
+        """
+        Update the logger used after the configuration is parsed: add
+        the specified log file in addition to console.
+        """
+        logfile = self.config.get("logfile")
+        # continue to log on console
+        if logfile is None:
+            return
+        # else, add the new file handler
+        file_handler = logging.FileHandler(logfile)
+        loglevel = self.config.get("loglevel")
+        file_handler.setLevel(_LOGLEVELS[loglevel])
+        formatter = logging.Formatter(
+            '%(asctime)s - %(levelname)s - %(message)s')
+        file_handler.setFormatter(formatter)
+        self.logger.addHandler(file_handler)
+
+    def _load_config(self, config):
+        """
+        Load the yaml configuration. It parses the config parameters, the
+        variables, and the list of rules.
+
+        :arg str config:
+          The yaml configuration.
+        """
+        yaml_dict = yaml.safe_load(config)
+        for key in yaml_dict:
+            if key in self.config:
+                self.config[key] = yaml_dict[key]
+                self.logger.debug("set config %s = %s", key, yaml_dict[key])
+            else:
+                self.variables[key] = yaml_dict[key]
+                self.logger.debug("set variable %s = %s", key, yaml_dict[key])
+        for key in ["server", "ssl", "rules"]:
+            if self.config[key] is None:
+                self.logger.error("%s is not specified in configuration")
+                raise ValueError
+        rules = self.config["rules"]
+        if rules is None:
+            self.logger.error("no rule defined")
+            raise ValueError
+        for i, rule in enumerate(rules):
+            try:
+                self.rules.append(imapami.rules.new(rule))
+            except Exception as e:
+                self.logger.error("error while processing rule %d: %s",
+                                  i+1, rule.get('name', 'no-name'))
+                raise e
+
+    def connect(self):
+        """
+        Connect and login to the remote IMAP server.
+        """
+        if self.config.get("ssl") == True:
+            imap_class = imaplib.IMAP4_SSL
+        else:
+            imap_class = imaplib.IMAP4
+
+        login = self.config.get("login")
+        if login is None:
+            login = raw_input("Username: ")
+        password = self.config.get("password")
+        if password is None:
+            password = getpass.getpass()
+        server = self.config.get("server")
+        port = self.config.get("port")
+        if port is None:
+            imap = imap_class(server)
+        else:
+            imap = imap_class(server, port)
+        self.logger.info('Connecting to %s@%s...', login, server)
+        imap.login(login, password)
+        self.imap = imap
+
+    def process_rules(self):
+        """
+        Process the rules.
+        """
+        self.logger.info('Processing rules...')
+        inbox = self.config["inbox"]
+        for rule in self.rules:
+            rule.process(self, inbox)
+        self.logger.info('Done.')
+
+    def close(self):
+        """
+        Close the connection to the IMAP server.
+        """
+        self.imap.close()
+        self.logger.info('Connection closed.')
+
+def show_config_help():
+    """
+    Show the help related to the configuration file.
+    """
+
+    helptxt = """\
+The configuration file is in YAML format. Refer to http://yaml.org or
+http://pyyaml.org for details about this format.
+
+Full examples can be found in configuration samples located in
+imapami/config-samples.
+
+Configuration parameters and variables
+======================================
+
+"""
+    doc = inspect.getdoc(Imapami)
+    helptxt += doc + '\n\n'
+
+    helptxt += 'Rules syntax\n'
+    helptxt += '============\n\n'
+
+    doc = inspect.getdoc(imapami.rules.ImapamiRule)
+    helptxt += doc + '\n\n'
+
+    conds = imapami.conditions.get()
+    conds_list = conds.keys()
+    conds_list.sort()
+    helptxt += 'Conditions\n'
+    helptxt += '==========\n\n'
+    for c in conds_list:
+        helptxt += "%s\n" % c
+        helptxt += "-" * len(c) + '\n\n'
+        doc = inspect.getdoc(conds[c])
+        if doc is not None:
+            helptxt += doc + '\n\n'
+        else:
+            helptxt += 'No help\n\n'
+
+    actions = imapami.actions.get()
+    actions_list = actions.keys()
+    actions_list.sort()
+    helptxt += 'Actions\n'
+    helptxt += '=======\n\n'
+    for a in actions:
+        helptxt += "%s\n" % a
+        helptxt += "-" * len(a) + '\n\n'
+        doc = inspect.getdoc(actions[a])
+        if doc is not None:
+            helptxt += doc + '\n\n'
+        else:
+            helptxt += 'No help\n\n'
+    pydoc.pager(helptxt)
+
+def main():
+    """
+    Run imapami: parse arguments, and launch imapami.
+    """
+    parser = argparse.ArgumentParser(
+        description='Process emails stored on an IMAP server.')
+    parser.add_argument(
+        '-c', '--config',
+        help='path configuration file (mandatory)')
+    parser.add_argument(
+        '-C', '--check', action='store_true', default=False,
+        help='Only parse configuration and exit')
+    parser.add_argument(
+        '-H', '--config-help', dest='config_help', action='store_true',
+        default=False, help='Show help about configuration file')
+    parser.add_argument(
+        '-d', '--debug',
+        help='Console debug level, from 0 (no output) to 4 (debug).',
+        type=int, default=3)
+
+    args = parser.parse_args()
+    if args.config_help == True:
+        show_config_help()
+        sys.exit(0)
+
+    if args.config is None:
+        sys.stderr.write('No config file specified\n')
+        parser.print_help()
+        sys.exit(1)
+
+    p = Imapami(open(args.config).read(), args.debug)
+    if args.check == True:
+        sys.stderr.write('Configuration parsing ok\n')
+        sys.exit(0)
+
+    p.connect()
+    p.process_rules()
+    p.close()
+
+if __name__ == '__main__':
+    main()
diff --git a/imapami/actions.py b/imapami/actions.py
new file mode 100644 (file)
index 0000000..7525133
--- /dev/null
@@ -0,0 +1,670 @@
+#!/usr/bin/env python
+
+#
+# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#     * Neither the name of the University of California, Berkeley nor the
+#       names of its contributors may be used to endorse or promote products
+#       derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+import imapami.utils
+
+import copy
+from email.mime.multipart import MIMEMultipart
+from email.mime.message import MIMEMessage
+from email.utils import formatdate
+import logging
+import shlex
+import smtplib
+import subprocess
+
+_all_actions = {}
+_LOGLEVELS = [logging.CRITICAL, logging.ERROR, logging.WARNING,
+              logging.INFO, logging.DEBUG]
+
+def register(action_class):
+    """
+    Register an action class
+    """
+    if not issubclass(action_class, ImapamiAction):
+        raise ValueError('"%s" action is not a subclass of ImapamiAction' %
+                         action_class)
+    if action_class.name in _all_actions:
+        raise ValueError('"%s" action is already registered' %
+                         action_class.name)
+    _all_actions[action_class.name] = action_class
+
+class ImapamiAction(object):
+    """
+    This is the parent class for actions.
+
+    An action is a task executed by imapami for a mail when
+    the conditions match. For instance, it can move the mail in
+    another directory, delete a mail, set a variable, execute a
+    script...
+    """
+    name = None
+
+    def __init__(self, fetch=None, terminal=False):
+        """
+        Generic action constructor.
+        :arg str fetch:
+          Set to "headers", "part1", "all" if it is needed to fetch
+          the header, the body or the attachments of the mail
+        :arg boolean terminal:
+          Set to true if the action has to be the last of the list
+          (ex: the mail is deleted or moved)
+        """
+        self.fetch = fetch or "no"
+        self.terminal = terminal
+
+    def evaluate(self, arg, ami, hdrs):
+        """
+        Evaluate a string argument
+
+        Replace variables and headers in arg by their values and
+        return it.
+
+        :arg string arg:
+          The argument to be evaluated
+        :arg Imapami ami:
+          The imapami object, containing the variables
+        :arg email.message.Message hdrs:
+          The headers of the mail, or None if not available
+        :returns:
+          The evaluated argument.
+        """
+        if hdrs is not None:
+            variables = imapami.utils.headers_to_unicode(hdrs)
+        else:
+            variables = {}
+        variables.update(ami.variables)
+        fmt = imapami.utils.VarFormatter()
+        arg = fmt.format(unicode(arg), **variables)
+        return arg
+
+    def process(self, imap, mail):
+        """
+        Process the action
+
+        :arg Imapami ami:
+          The Imapami object
+        :arg ImapamiMail mail:
+          The mail data
+        :returns:
+          True on success, False on error
+        """
+        return True
+
+class ImapamiActionList(ImapamiAction):
+    """
+    Execute a list of actions.
+
+    This command is used internally when the list of actions of a rule
+    is parsed. There is no real need for a user, as the rule already contains
+    a list of actions.
+
+    Arguments:
+      List of actions
+
+    Example:
+      list:
+      - copy: {dest: archive}
+      - move: {dest: mbox}
+    """
+    name = "list"
+    def __init__(self, *action_list):
+        action_list = [new(a) for a in action_list]
+        for a in action_list[:-1]:
+            if a.terminal == True:
+                raise ValueError("terminal action in the middle of an action list")
+        fetch = imapami.utils.highest_fetch_level(
+            [a.fetch for a in action_list])
+        terminal = action_list[-1].terminal
+        ImapamiAction.__init__(self, fetch, terminal)
+        self.action_list = action_list
+    def process(self, ami, mail):
+        ret = True
+        for a in self.action_list:
+            if a.process(ami, mail) == False:
+                ret = False
+        return ret
+register(ImapamiActionList)
+
+class ImapamiActionCopy(ImapamiAction):
+    """
+    Copy the mail in another directory.
+
+    Arguments:
+      dest: <string>
+        destination directory
+
+    Example:
+      copy: {dest: my_subdir}
+    """
+    name = "copy"
+    def __init__(self, dest):
+        ImapamiAction.__init__(self)
+        self.dest = dest
+    def process(self, ami, mail):
+        imap = ami.imap
+        dest = self.evaluate(self.dest, ami, mail.msg)
+        imap.create(dest)
+        ret, msg = imap.copy(mail.item, dest)
+        if ret != "OK":
+            ami.logger.warning(
+                "imap copy returned %s: %s" % (ret, str(msg)))
+            return False
+        return True
+register(ImapamiActionCopy)
+
+class ImapamiActionChangeFlag(ImapamiAction):
+    """
+    Change an IMAP flag of a mail.
+
+    Arguments:
+      flag: <string>
+        Name of the IMAP flag (ex: 'Seen', 'Answered', 'Deleted', ...)
+        Refer to RFC3501 for details.
+      enable: <boolean>
+        True to set the flag, False to reset it.
+
+    Example:
+      change-flag: {flag: Seen, enable: True}
+    """
+    name = "change-flag"
+    def __init__(self, flag, enable):
+        ImapamiAction.__init__(self)
+        self.enable = enable
+        self.flag = flag
+    def process(self, ami, mail):
+        imap = ami.imap
+        if self.enable == True:
+            cmd = '+FLAGS'
+        else:
+            cmd = '-FLAGS'
+        flag = '\\' + self.evaluate(self.flag, ami,
+                                    mail.msg)
+        ret, msg = imap.store(mail.item, cmd, flag)
+        if ret != "OK":
+            ami.logger.warning(
+                "imap store '%s %s' returned %s: %s" % (
+                    cmd, flag, ret, str(msg)))
+            return False
+        return True
+register(ImapamiActionChangeFlag)
+
+class ImapamiActionDelete(ImapamiActionChangeFlag):
+    """
+    Mark a mail as deleted.
+
+    Arguments: None
+
+    Example:
+      deleted: {}
+    """
+    name = "deleted"
+    def __init__(self):
+        ImapamiActionChangeFlag.__init__(self, "Deleted", True)
+        self.terminal = True
+register(ImapamiActionDelete)
+
+class ImapamiActionSeen(ImapamiActionChangeFlag):
+    """
+    Mark a mail as seen.
+
+    Arguments: None
+
+    Example:
+      seen: {}
+    """
+    name = "seen"
+    def __init__(self):
+        ImapamiActionChangeFlag.__init__(self, "Seen", True)
+register(ImapamiActionSeen)
+
+class ImapamiActionUnseen(ImapamiActionChangeFlag):
+    """
+    Mark a mail as not seen.
+
+    Arguments: None
+
+    Example:
+      unseen: {}
+    """
+    name = "unseen"
+    def __init__(self):
+        ImapamiActionChangeFlag.__init__(self, "Seen", False)
+register(ImapamiActionUnseen)
+
+class ImapamiActionMove(ImapamiAction):
+    """
+    Move the mail in another directory.
+
+    Arguments:
+      dest: <string>
+        destination directory
+
+    Example:
+      move: {dest: my_subdir}
+    """
+    name = "move"
+    def __init__(self, dest):
+        ImapamiAction.__init__(self, terminal=True)
+        self.dest = dest
+    def process(self, ami, mail):
+        ret = ImapamiActionCopy.process(
+            self, ami, mail)
+        if ret == False:
+            return False
+        ret = ImapamiActionDelete.process(
+            self, ami, mail)
+        if ret == False:
+            return False
+        return True
+register(ImapamiActionMove)
+
+class ImapamiActionLog(ImapamiAction):
+    """
+    Log a message.
+
+    Arguments:
+      msg: <string>
+        Message to be logged. Like all action string arguments, it
+        can contain variables or header name.
+      level: <integer> [optional, default = 3 (info)]
+        The level of the log from 0 (critical) to 4 (debug). Note that
+        it must be lower or equal to the global log level, else it
+        won't appear in the log file.
+
+    Example:
+      log: {msg: 'Mail from {From} is received', level: 3}
+    """
+    name = "log"
+    def __init__(self, msg, level=3):
+        ImapamiAction.__init__(self, fetch="headers")
+        self.msg = msg
+        if not isinstance(level, int) or level < 0 or level > 4:
+            raise ValueError("invalid log level")
+        self.level = level
+    def process(self, ami, mail):
+        msg = self.evaluate(self.msg, ami, mail.msg)
+        l = _LOGLEVELS[self.level]
+        ami.logger.log(l, msg)
+        return True
+register(ImapamiActionLog)
+
+class ImapamiActionPipe(ImapamiAction):
+    """
+    Pipe the mail to an external program.
+
+    Arguments:
+      command: <string>
+        The command to run.
+      what: <string> [default='headers']
+        Define what is sent to the program. Valid values are:
+          'headers': send the headers
+          'body_part1': send the first part of the message
+          'body_all': send all the message body
+          'headers+body_part1': send headers and first part of the message
+          'headers+body_all': send all
+      shell: <boolean> [default=False]
+        Invoke a shell for this command. This allows for instance to use
+        a pipe or a redirection to a file.
+
+    Example:
+      pipe: {command: 'cat > /tmp/foobar', shell: True}
+    """
+    name = "pipe"
+    def __init__(self, command, what='headers', shell=False):
+        valid = ['headers', 'body_part1', 'body_all',
+                 'headers+body_part1', 'headers+body_all']
+        if not what in valid:
+            raise ValueError("invalid 'what' field. Valid values are: %s" %
+                             valid)
+        if what in ['headers']:
+            fetch = "headers"
+        elif what in ['body_part1', 'headers+body_part1']:
+            fetch = "part1"
+        else:
+            fetch = "all"
+        ImapamiAction.__init__(self, fetch=fetch)
+        self.command = command
+        self.what = what
+        self.shell = shell
+    def process(self, ami, mail):
+        if self.shell == True:
+            command = self.command
+        else:
+            command = shlex.split(self.command)
+        process = subprocess.Popen(command,
+                                   stdin=subprocess.PIPE,
+                                   stdout=subprocess.PIPE,
+                                   stderr=subprocess.PIPE,
+                                   shell=self.shell)
+        data = ''
+        if self.what in ['headers', 'headers+body_part1', 'headers+body_all']:
+            data += str(mail.msg)
+        if self.what in ['body_part1', 'headers+body_part1']:
+            data += mail.body_part1
+        if self.what in ['body_all', 'headers+body_all']:
+            data += mail.body_all
+        stdout, stderr = process.communicate(data)
+        ret = process.wait()
+        ami.variables["stdout"] = stdout
+        ami.variables["stderr"] = stderr
+        ami.variables["ret"] = ret
+        return True
+register(ImapamiActionPipe)
+
+class ImapamiActionSetHeader(ImapamiAction):
+    """
+    Set or remove header fields in the mail.
+
+    Dpending on the mode, it is possible to:
+    - append it at the end of the mail headers (mode=add).
+    - remove all occurences of a header (mode=del)
+    - replace the first occurence of a header (mode=replace)
+
+    As IMAP does not allow to modify a mail, this action creates
+    a modified copy of the original mail, and delete the original
+    one.
+
+    Arguments:
+      <list of commands (see below)>
+
+    Format of a command:
+      mode: <string>
+        Determine if headers should be added, removed, replaced. Can
+        be 'add', 'del', 'replace'.
+      <key (string)>: <value (string)>
+        The key contains the name of the header field (ex: Subject),
+        associated to the value to be set (None for delete operations).
+        Multiple fields can be specified.
+
+    Example:
+      set-header: [{mode: del, Message-Id: None},
+                   {mode: add, Foo: bar, X-mailer: toto},
+                   {mode: replace, Subject: 'subject was: {Subject}'}]
+    """
+    name = "set-header"
+    def __init__(self, *args):
+        ImapamiAction.__init__(self, fetch="all", terminal=True)
+        for cmd in args:
+            if not isinstance(cmd, dict):
+                raise ValueError("set-header command is not a dict")
+            if not cmd.has_key("mode"):
+                raise ValueError("set-header command has no mode")
+            if cmd["mode"] not in ['add', 'del', 'replace']:
+                raise ValueError("invalid mode for set-header command")
+        self.cmdlist = args
+
+    def process(self, ami, mail):
+        parsed_headers = copy.deepcopy(mail.msghdrs)
+        for cmd in self.cmdlist:
+            mode = cmd.pop("mode")
+            fields = cmd
+            if mode == "add":
+                for k, v in fields.iteritems():
+                    parsed_headers[k] = v
+            elif mode == "del":
+                for k, v in fields.iteritems():
+                    del parsed_headers[k]
+            elif mode == "replace":
+                for k, v in fields.iteritems():
+                    parsed_headers.replace_header(k, v)
+
+        ami.imap.append(mail.inbox, mail.flags, mail.internal_date,
+                        str(parsed_headers) + mail.body_all)
+        return True
+register(ImapamiActionSetHeader)
+
+class ImapamiSmtpSender(object):
+    """
+    A SMTP sender using smtp lib
+    """
+
+    def __init__(self, host, port=None, encryption=None, login=None,
+                 password=None):
+        """
+        Initialize the SMTP sender class
+
+        :arg string host:
+          Hostname or ip address of the smtp server.
+        :arg integer port:
+          The port of the smtp server to connect to.
+        :arg string encryption:
+          Define encryption type for smtp: "none", "ssl", "starttls".
+          Default is "none".
+        :arg string login:
+          User login for smtp server. If no login is specified, assume no
+          login is required.
+        :arg string password:
+          Password associated to the login.
+        """
+        self.host = host
+        self.port = port
+        self.encryption = encryption or "none"
+        self.login = login
+        self.password = password
+
+    def send(self, ami, sender, to, mail):
+        """
+        Send a mail.
+
+        :arg Imapami ami:
+          The Imapami object
+        :arg string mail:
+          The mail to send
+        :arg string sender:
+          The sender of the mail
+        :arg string to:
+          To mail address
+        """
+        ami.logger.debug("sending to smtp server %s" % self.host)
+        args = [self.host]
+        if self.port is not None:
+            args.append(self.port)
+        if self.encryption == "ssl":
+            smtp = smtplib.SMTP_SSL(*args)
+        else:
+            smtp = smtplib.SMTP(*args)
+        try:
+            if self.encryption == "starttls":
+                smtp.starttls()
+            if self.login is not None:
+                smtp.login(self.loging, self.password)
+            smtp.sendmail(sender, to, mail)
+            smtp.close()
+        except smtplib.SMTPAuthenticationError as e:
+            ami.logger.warning("smtp authentication error: %s", str(e))
+            return False
+        except (smtplib.SMTPException, smtplib.SMTPHeloError,
+                smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused,
+                smtplib.SMTPDataError, RuntimeError) as e:
+            ami.logger.warning("smtp error: %s", str(e))
+            return False
+        return True
+
+class ImapamiActionForward(ImapamiAction):
+    """
+    Forward a mail.
+
+    The mail is forwarded with its attachments. New mail headers
+    are created from scratch. The subject of the mail is prefixed
+    with 'Fwd: '.
+
+    Arguments:
+      sender: <string>
+        The sender of the mail.
+      to: <string>
+        The of the forwarded mail.
+      host: <string> (mandatory)
+        Hostname or ip address of the smtp server.
+      port: <integer> (optional)
+        The port of the smtp server to connect to.
+      encryption: <string> (optional)
+        Define encryption type for smtp: "none", "ssl", "starttls".
+        Default is none.
+      login: <string> (optional)
+        User login for smtp server. If no login is specified, assume no
+        login is required.
+      password: <string> (optional)
+        Password associated to the login.
+
+    Example:
+      forward: {sender: foo@example.com, to: toto@example.com,
+                host: mail.example.com}
+    """
+    name = "forward"
+    def __init__(self, sender, to, host, port=None, encryption=None, login=None,
+                 password=None):
+        ImapamiAction.__init__(self, fetch="all")
+        self.sender = sender
+        self.to = to
+        self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
+
+    def process(self, ami, mail):
+        sender = self.evaluate(self.sender, ami, mail.msg)
+        to = self.evaluate(self.to, ami, mail.msg)
+        subject = "Fwd: " + self.evaluate('{Subject}', ami, mail.msg)
+        new_mail = MIMEMultipart()
+        new_mail['Date'] = formatdate(localtime=True)
+        new_mail['From'] = sender
+        new_mail['To'] = to
+        new_mail['Subject'] = subject
+        new_mail.attach(MIMEMessage(mail.msg))
+        return self.smtp.send(ami, sender, to, new_mail.as_string())
+register(ImapamiActionForward)
+
+class ImapamiActionBounce(ImapamiAction):
+    """
+    Bounce a mail.
+
+    The mail is transfered with its attachments without modification.
+
+    Arguments:
+      sender: <string>
+        The sender of the mail.
+      to: <string>
+        The of the bounceed mail.
+      host: <string> (mandatory)
+        Hostname or ip address of the smtp server.
+      port: <integer> (optional)
+        The port of the smtp server to connect to.
+      encryption: <string> (optional)
+        Define encryption type for smtp: "none", "ssl", "starttls".
+        Default is none.
+      login: <string> (optional)
+        User login for smtp server. If no login is specified, assume no
+        login is required.
+      password: <string> (optional)
+        Password associated to the login.
+
+    Example:
+      bounce: {Sender: foo@example.com, To: toto@example.com,
+                host: mail.example.com}
+    """
+    name = "bounce"
+    def __init__(self, sender, to, host, port=None, encryption=None, login=None,
+                 password=None):
+        ImapamiAction.__init__(self, fetch="all")
+        self.sender = sender
+        self.to = to
+        self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
+
+    def process(self, ami, mail):
+        sender = self.evaluate(self.sender, ami, mail.msg)
+        to = self.evaluate(self.to, ami, mail.msg)
+        return self.smtp.send(ami, sender, to, str(mail.msg))
+register(ImapamiActionBounce)
+
+class ImapamiActionSetVar(ImapamiAction):
+    """
+    Set a variable.
+
+    As it does not read/write the mail, this action does not need
+    to fetch the mail by default.
+    If a mail header field is used as a variable in the action
+    parameters, the user must ensure that the mail headers are fetched,
+    for instance by setting the rule paramater 'fetch' to 'headers'.
+
+    Arguments:
+      <var name>: <string>
+        Each key contains the name of the variable to be set,
+        and its value contains the string that should be set. If the
+        value is None, the variable is deleted. Multiple fields can be
+        specified.
+
+    Example:
+      set-var: {var1: foo@example.com, var2: None}
+    """
+    name = "set-var"
+    def __init__(self, **kwargs):
+        ImapamiAction.__init__(self)
+        self.kwargs = kwargs
+    def process(self, ami, mail):
+        for k, v in self.kwargs.iteritems():
+            k = self.evaluate(k, ami, mail.msg)
+            if v is None and ami.variables.get(k) is not None:
+                ami.variables.pop(k)
+            else:
+                v = self.evaluate(v, ami, mail.msg)
+                ami.variables[k] = v
+        return True
+register(ImapamiActionSetVar)
+
+def new(config):
+    """
+    Create an action object from its yaml config.
+
+    :arg string config:
+      The yaml action config.
+    :returns:
+      The action object.
+    """
+    logger = logging.getLogger('imapami')
+    logger.debug("parsing action %s", config)
+    if len(config) != 1:
+        raise ValueError("the action config must be a dictionary whose only "
+                         "key is the action name")
+    action_name = config.keys()[0]
+    argslist = []
+    argsdict = {}
+    if isinstance(config[action_name], list):
+        argslist = config[action_name]
+    elif isinstance(config[action_name], dict):
+        argsdict = config[action_name]
+    elif config[action_name] is not None:
+        argslist = [config[action_name]]
+    action_class = _all_actions.get(action_name)
+    if action_class is None:
+        raise ValueError("Invalid action name '%s'" % action_name)
+    logger.debug("new action %s(%s, %s)", action_name, argslist, argsdict)
+    return action_class(*argslist, **argsdict)
+
+def get():
+    """
+    Return a dictionary containing all the registered actions.
+    """
+    return _all_actions
diff --git a/imapami/conditions.py b/imapami/conditions.py
new file mode 100644 (file)
index 0000000..97675a7
--- /dev/null
@@ -0,0 +1,760 @@
+#!/usr/bin/env python
+
+#
+# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#     * Neither the name of the University of California, Berkeley nor the
+#       names of its contributors may be used to endorse or promote products
+#       derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+import imapami.utils
+
+import logging
+import re
+
+_all_conditions = {}
+
+def register(cond_class):
+    """
+    Register a condition class
+    """
+    if not issubclass(cond_class, ImapamiCond):
+        raise ValueError('"%s" condition is not a subclass of ImapamiCond' %
+                         cond_class)
+    if cond_class.name in _all_conditions:
+        raise ValueError('"%s" condition is already registered' %
+                         cond_class.name)
+    _all_conditions[cond_class.name] = cond_class
+
+class ImapamiCond(object):
+    """
+    This is the parent class for conditions.
+
+    A condition is a test performed on the mails located in a
+    specific mailbox directory. For instance, a condition can check
+    the sender, the subject, the date of a mail, or the content of
+    a variable.
+    """
+    def __init__(self, fetch=None, criteria=None):
+        """
+        Generic condition constructor.
+
+        :arg str fetch:
+          Set to "headers", "part1", "all" if it is needed to fetch
+          the header, the body or the attachments of the mail
+        :arg set() criteria:
+          IMAP criteria for IMAP search command. Ex: set(["UNSEEN"])
+        """
+        self.fetch = fetch or "no"
+        self.criteria = criteria or set()
+
+    def evaluate(self, arg, ami, hdrs):
+        """
+        Evaluate a string argument
+
+        Replace variables and headers in arg by their values and
+        return it.
+
+        :arg string arg:
+          The argument to be evaluated
+        :arg Imapami ami:
+          The imapami object, containing the variables
+        :arg email.message.Message hdrs:
+          The headers of the mail, or None if not available
+        :returns:
+          The evaluated argument.
+        """
+        if hdrs is not None:
+            variables = imapami.utils.headers_to_unicode(hdrs)
+        else:
+            variables = {}
+        variables.update(ami.variables)
+        fmt = imapami.utils.VarFormatter()
+        arg = fmt.format(unicode(arg), **variables)
+        return arg
+
+    def check(self, ami, mail):
+        """
+        Check the condition.
+
+        :arg Imapami ami:
+          The Imapami object
+        :arg ImapamiMail mail:
+          The mail data
+        :returns:
+          True if the condition matches, else False.
+        """
+        return True
+
+    def get_criteria(self):
+        """
+        Get the list of IMAP criteria that are passed to the server inside
+        the IMAP search command.
+        The caller will evaluate the variables, so it's not to be done in the
+        ImapamiCond class.
+        """
+        return " ".join([str(c) for c in self.criteria])
+
+class ImapamiCondNot(ImapamiCond):
+    """
+    Invert the result of a condition.
+
+    Arguments:
+      <condition>
+
+    Example:
+      not: {from: foo@example.com}
+    """
+    name = "not"
+    def __init__(self, cond):
+        cond = new(cond)
+        ImapamiCond.__init__(self, fetch=cond.fetch, criteria=cond.criteria)
+        self.cond = cond
+
+    def check(self, ami, mail):
+        return not self.cond.check(ami, mail)
+register(ImapamiCondNot)
+
+class ImapamiCondUnseen(ImapamiCond):
+    """
+    Match if a mail is not marked as seen.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      None
+
+    Example:
+      unseen: {}
+    """
+    name = "unseen"
+    def __init__(self):
+        ImapamiCond.__init__(self, criteria=set(["UNSEEN"]))
+register(ImapamiCondUnseen)
+
+class ImapamiCondFrom(ImapamiCond):
+    """
+    Match if a 'From' field contains the specified substring.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      substr: <string>
+        The substring that should be included in the 'From' header
+        to match.
+
+    Example:
+      from: {substr: foo@example.com}
+    """
+    name = "from"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['FROM "%s"' % substr]))
+register(ImapamiCondFrom)
+
+class ImapamiCondSubject(ImapamiCond):
+    """
+    Match if a 'Subject' field contains the specified substring.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      substr: <string>
+        The substring that should be included in the 'Subject' header
+        to match.
+
+    Example:
+      subject: {substr: foo@example.com}
+    """
+    name = "subject"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['SUBJECT "%s"' % substr]))
+register(ImapamiCondSubject)
+
+class ImapamiCondTo(ImapamiCond):
+    """
+    Match if a 'To' field contains the specified substring.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      substr: <string>
+        The substring that should be included in the 'To' header
+        to match.
+
+    Example:
+      to: {substr: foo@example.com}
+    """
+    name = "to"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['TO "%s"' % substr]))
+register(ImapamiCondTo)
+
+class ImapamiCondCc(ImapamiCond):
+    """
+    Match if a 'Cc' field contains the specified substring.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      substr: <string>
+        The substring that should be included in the 'Cc' header
+        to match.
+
+    Example:
+      cc: {substr: foo@example.com}
+    """
+    name = "cc"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['CC "%s"' % substr]))
+register(ImapamiCondCc)
+
+class ImapamiCondBcc(ImapamiCond):
+    """
+    Match if a 'Bcc' field contains the specified substring.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      substr: <string>
+        The substring that should be included in the 'Bcc' header
+        to match.
+
+    Example:
+      bcc: {substr: foo@example.com}
+    """
+    name = "bcc"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['BCC "%s"' % substr]))
+register(ImapamiCondBcc)
+
+class ImapamiCondBody(ImapamiCond):
+    """
+    Match if the body of the message contains the specified substring.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      substr: <string>
+        The substring that should be included in the 'Body' header
+        to match.
+
+    Example:
+      body: {substr: foobar}
+    """
+    name = "body"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['BODY "%s"' % substr]))
+register(ImapamiCondBody)
+
+class ImapamiCondSince(ImapamiCond):
+    """
+    Match if the message has been sent since the specified date.
+
+    Match for messages whose internal date (disregarding time and timezone)
+    is within or later than the specified date.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      date: <string>
+        The reference date. The format is day-month-year, with:
+        day = 2 digits, month = 3 letter string (Jan, Feb, Mar, Apr,
+        May, Jun, Jul, Aug, Sep, Oct, Nov, Dec), year = 4 digits.
+
+    Example:
+      since: {date: 28-Oct-2015}
+    """
+    name = "since"
+    def __init__(self, date):
+        ImapamiCond.__init__(self, criteria=set(['SINCE "%s"' % date]))
+register(ImapamiCondSince)
+
+class ImapamiCondSentBefore(ImapamiCond):
+    """
+    Match if 'Date' header is earlier than the specified date (disregarding
+    time and timezone).
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      sent-before: <string>
+        The reference date. The format is day-month-year, with:
+        day = 2 digits, month = 3 letter string (Jan, Feb, Mar, Apr,
+        May, Jun, Jul, Aug, Sep, Oct, Nov, Dec), year = 4 digits.
+
+    Example:
+      sent-before: {date: 28-Oct-2015}
+    """
+    name = "sent-before"
+    def __init__(self, date):
+        ImapamiCond.__init__(self, criteria=set(['SENTBEFORE "%s"' % date]))
+register(ImapamiCondSentBefore)
+
+class ImapamiCondSentOn(ImapamiCond):
+    """
+    Match if 'Date' header is within the specified date (disregarding
+    time and timezone).
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      sent-on: <string>
+        The reference date. The format is day-month-year, with:
+        day = 2 digits, month = 3 letter string (Jan, Feb, Mar, Apr,
+        May, Jun, Jul, Aug, Sep, Oct, Nov, Dec), year = 4 digits.
+
+    Example:
+      sent-on: {date: 28-Oct-2015}
+    """
+    name = "sent-on"
+    def __init__(self, date):
+        ImapamiCond.__init__(self, criteria=set(['SENTON "%s"' % date]))
+register(ImapamiCondSentOn)
+
+class ImapamiCondSentSince(ImapamiCond):
+    """
+    Match if 'Date' header is within or later than the specified date
+    (disregarding time and timezone).
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      sent-since: <string>
+        The reference date. The format is day-month-year, with:
+        day = 2 digits, month = 3 letter string (Jan, Feb, Mar, Apr,
+        May, Jun, Jul, Aug, Sep, Oct, Nov, Dec), year = 4 digits.
+
+    Example:
+      sent-since: {date: 28-Oct-2015}
+    """
+    name = "sent-since"
+    def __init__(self, date):
+        ImapamiCond.__init__(self, criteria=set(['SENTSINCE "%s"' % date]))
+register(ImapamiCondSentSince)
+
+class ImapamiCondRecent(ImapamiCond):
+    """
+    Match if the mail is marked as recent
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      None
+
+    Example:
+      recent: {}
+    """
+    name = "recent"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['RECENT']))
+register(ImapamiCondRecent)
+
+class ImapamiCondAnswered(ImapamiCond):
+    """
+    Match if the mail is not marked as answered.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      None
+
+    Example:
+      answered: {}
+    """
+    name = "answered"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['ANSWERED']))
+register(ImapamiCondAnswered)
+
+class ImapamiCondUnanswered(ImapamiCond):
+    """
+    Match if the mail is not marked as answered.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      None
+
+    Example:
+      unanswered: {}
+    """
+    name = "unanswered"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['UNANSWERED']))
+register(ImapamiCondUnanswered)
+
+class ImapamiCondFlagged(ImapamiCond):
+    """
+    Match if the message is flagged.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      None
+
+    Example:
+      flagged: {}
+    """
+    name = "flagged"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['FLAGGED']))
+register(ImapamiCondFlagged)
+
+class ImapamiCondUnflagged(ImapamiCond):
+    """
+    Match if the message is not flagged.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      None
+
+    Example:
+      unflagged: {}
+    """
+    name = "unflagged"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['UNFLAGGED']))
+register(ImapamiCondUnflagged)
+
+class ImapamiCondDraft(ImapamiCond):
+    """
+    Match if the message is marked as draft.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      None
+
+    Example:
+      draft: {}
+    """
+    name = "draft"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['DRAFT']))
+register(ImapamiCondDraft)
+
+class ImapamiCondUndraft(ImapamiCond):
+    """
+    Match if the message is not marked as draft.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      None
+
+    Example:
+      undraft: {}
+    """
+    name = "undraft"
+    def __init__(self, substr):
+        ImapamiCond.__init__(self, criteria=set(['UNDRAFT']))
+register(ImapamiCondUndraft)
+
+class ImapamiCondKeyword(ImapamiCond):
+    """
+    Match if the messages has the specified keyword flag set.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      key: <string>
+        The keyword string.
+
+    Example:
+      keyword: {key: important}
+    """
+    name = "keyword"
+    def __init__(self, key):
+        ImapamiCond.__init__(self, criteria=set(['KEYWORD "%s"' % key]))
+register(ImapamiCondKeyword)
+
+class ImapamiCondUnkeyword(ImapamiCond):
+    """
+    Match if the messages does not have the specified keyword flag set.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      key: <string>
+        The keyword string.
+
+    Example:
+      unkeyword: {key: important}
+    """
+    name = "unkeyword"
+    def __init__(self, key):
+        ImapamiCond.__init__(self, criteria=set(['UNKEYWORD "%s"' % key]))
+register(ImapamiCondUnkeyword)
+
+class ImapamiCondLarger(ImapamiCond):
+    """
+    Match if the message is larger than the specified size.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      size: <integer>
+        The size of the message in bytes.
+
+    Example:
+      larger: {size: 1024}
+    """
+    name = "larger"
+    def __init__(self, size):
+        ImapamiCond.__init__(self, criteria=set(['LARGER "%s"' % size]))
+register(ImapamiCondLarger)
+
+class ImapamiCondSmaller(ImapamiCond):
+    """
+    Match if the message is smaller than the specified size.
+
+    This condition does not require to fetch the mail as it can
+    be filtered by the IMAP server.
+
+    Arguments:
+      size: <integer>
+        The size of the message in bytes.
+
+    Example:
+      smaller: {size: 1024}
+    """
+    name = "smaller"
+    def __init__(self, size):
+        ImapamiCond.__init__(self, criteria=set(['SMALLER "%s"' % size]))
+register(ImapamiCondSmaller)
+
+class ImapamiCondRegex(ImapamiCond):
+    """
+    Match if the regular expression is found in the specified field.
+
+    This condition requires at least to fetch the mail headers, or the
+    full message if the regexp is researched in the body of the mail.
+
+    Arguments:
+      field: <string>
+        The name of the header field (ex: 'From', 'To', ...) where the
+        regular expression is researched or 'part1'/'all' if the regular
+        expression is researched in the body of the mail ('all' includes
+        attachments).
+      pattern: <string>
+        The regular expression, as supported by the python re module.
+
+    Example:
+      regexp: {field: Subject, pattern: '\\[my_list\\]'}
+    """
+    name = "regexp"
+    def __init__(self, field, pattern):
+        if field == "part1":
+            fetch = "part1"
+        elif field == "all":
+            fetch = "all"
+        else:
+            fetch = "headers"
+        ImapamiCond.__init__(self, fetch=fetch)
+        self.field = field
+        self.pattern = pattern
+    def check(self, ami, mail):
+        field = self.evaluate(self.field, ami, mail.msg)
+        pattern = self.evaluate(self.pattern, ami,
+                                mail.msg)
+        if field == "part1":
+            m = re.search(pattern, mail.body_part1)
+        elif field == "all":
+            m = re.search(pattern, mail.body_all)
+        else:
+            data = mail.msg.get(field)
+            if data is None:
+                return False
+            m = re.search(pattern, data)
+        if m:
+            return True
+        return False
+
+register(ImapamiCondRegex)
+
+class ImapamiCondAnd(ImapamiCond):
+    """
+    Match if all conditions of a list match (AND).
+
+    This command is used internally when the list of conditions of a
+    rule is parsed. It can also be used by a user to group conditions:
+    as soon as a condition does not match, this meta condition returns
+    False (lazy evaluation).
+
+    Arguments:
+      List of conditions
+
+    Example:
+      and:
+      - regexp: {field: From, pattern: foo}
+      - regexp: {field: Subject, pattern: bar}
+    """
+    name = "and"
+    def __init__(self, *cond_list):
+        cond_list = [new(c) for c in cond_list]
+        criteria = set().union(*[c.criteria for c in cond_list])
+        fetch = imapami.utils.highest_fetch_level(
+            [c.fetch for c in cond_list])
+        ImapamiCond.__init__(self, fetch=fetch, criteria=criteria)
+        self.cond_list = cond_list
+
+    def check(self, ami, mail):
+        for c in self.cond_list:
+            if c.check(ami, mail) == False:
+                return False
+        return True
+register(ImapamiCondAnd)
+
+class ImapamiCondOr(ImapamiCond):
+    """
+    Match if at least one condition of a list matches (OR).
+
+    Try to match the conditions of the list: as soon as a condition matches,
+    this meta condition returns True (lazy evaluation).
+
+    Arguments:
+      List of conditions
+
+    Example:
+      or:
+      - regexp: {field: From, pattern: foo}
+      - regexp: {field: Subject, pattern: bar}
+    """
+    name = "or"
+    def __init__(self, *cond_list):
+        cond_list = [new(c) for c in cond_list]
+        criteria = ''
+        for c in cond_list:
+            crit = c.get_criteria()
+            if crit == '':
+                continue
+            if criteria == '':
+                criteria = crit
+            else:
+                criteria = 'OR (%s) (%s)' % (criteria, crit)
+        if criteria != '':
+            criteria = set().add(criteria)
+        fetch = imapami.utils.highest_fetch_level(
+            [c.fetch for c in cond_list])
+        ImapamiCond.__init__(self, fetch=fetch, criteria=criteria)
+        self.cond_list = cond_list
+
+    def check(self, ami, mail):
+        for c in self.cond_list:
+            if c.check(ami, mail) == True:
+                return True
+        return False
+register(ImapamiCondOr)
+
+class ImapamiCondEq(ImapamiCond):
+    """
+    Match if strings are equal.
+
+    This condition does not fetch any part of the mail by default.
+    If a mail header field is used as a variable in the condition
+    parameters, the user must ensure that the mail headers are fetched,
+    for instance by setting the rule paramater 'fetch' to 'headers'.
+
+    If more than 2 elements are given in the list, all of them must
+    be equal.
+
+    Arguments:
+      List of strings
+
+    Example:
+      eq: ['foo@example.com', '{From}', '{foo}']
+    """
+    name = "eq"
+    def __init__(self, *str_list):
+        ImapamiCond.__init__(self)
+        if not isinstance(str_list, list) and not isinstance(str_list, tuple):
+            raise ValueError("arguments of 'eq' should be a list/tuple")
+        if len(str_list) < 2:
+            raise ValueError("'eq' argument list is too short")
+        self.str_list = list(str_list)
+
+    def check(self, ami, mail):
+        first = self.evaluate(self.str_list[0], ami,
+                              mail.msg)
+        for s in self.str_list[1:]:
+            if self.evaluate(s, ami, mail.msg) != first:
+                return False
+        return True
+register(ImapamiCondEq)
+
+def new(config):
+    """
+    Create a condition object from its yaml config.
+
+    :arg string config:
+      The yaml condition config.
+    :returns:
+      The condition object.
+    """
+    logger = logging.getLogger('imapami')
+    logger.debug("parsing condition %s", config)
+    if len(config) != 1:
+        raise ValueError("the condition config must be a dictionary whose only "
+                         "key is the condition name")
+    cond_name = config.keys()[0]
+    argslist = []
+    argsdict = {}
+    if isinstance(config[cond_name], list):
+        argslist = config[cond_name]
+    elif isinstance(config[cond_name], dict):
+        argsdict = config[cond_name]
+    elif config[cond_name] is not None:
+        argslist = [config[cond_name]]
+    cond_class = _all_conditions.get(cond_name)
+    if cond_class is None:
+        raise ValueError("Invalid condition name '%s'" % cond_name)
+    logger.debug("new cond %s(%s, %s)", cond_name, argslist, argsdict)
+    return cond_class(*argslist, **argsdict)
+
+def get():
+    """
+    Return a dictionary containing all the registered conditions.
+    """
+    return _all_conditions
diff --git a/imapami/mail.py b/imapami/mail.py
new file mode 100644 (file)
index 0000000..29c4a02
--- /dev/null
@@ -0,0 +1,78 @@
+#!/usr/bin/env python
+
+#
+# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#     * Neither the name of the University of California, Berkeley nor the
+#       names of its contributors may be used to endorse or promote products
+#       derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+import email
+
+class ImapamiMail(object):
+    """
+    An email, fetched from the IMAP server
+
+    Depending on the fetch level, fields may be filled or set to None.
+    """
+    def __init__(self, item, inbox, flags=None, internal_date=None, date=None,
+                 headers=None, body_part1=None, body_all=None):
+        """
+        :arg str item:
+          The IMAP id of the mail
+        :arg str inbox:
+          The IMAP directory where the mail is located
+        :arg str flags:
+          The IMAP flags of the mail
+        :arg str internal_date:
+          The IMAP date of the mail
+        :arg time.struct_time date:
+          The date of the mail, converted in python format
+        :arg str headers:
+          The headers of the mail
+        :arg str body_part1:
+          The first part of the body of the mail
+        :arg str body_all:
+          The whole body of the mail
+        """
+        # always filled
+        self.item = item
+        self.inbox = inbox
+        msg = ''
+        if headers is not None:
+            msg += headers
+        self.msghdrs = email.message_from_string(msg)
+        if body_all is not None:
+            msg += body_all
+        elif body_part1 is not None:
+            msg += body_part1
+        self.msg = email.message_from_string(msg)
+        # fetch level >= headers
+        self.flags = flags
+        self.internal_date = internal_date
+        self.date = date
+        self.headers = headers
+        # fetch level >= part1
+        self.body_part1 = body_part1
+        # fetch level == all
+        self.body_all = body_all
diff --git a/imapami/rules.py b/imapami/rules.py
new file mode 100644 (file)
index 0000000..840d1aa
--- /dev/null
@@ -0,0 +1,303 @@
+#!/usr/bin/env python
+
+#
+# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#     * Neither the name of the University of California, Berkeley nor the
+#       names of its contributors may be used to endorse or promote products
+#       derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+import imapami.actions
+import imapami.conditions
+import imapami.mail
+import imapami.utils
+
+import imaplib
+import logging
+import re
+
+class ImapamiRule(object):
+    """
+    A rule is composed of a list of conditions, and a list of actions that are
+    executed on the mails which match the conditions.
+
+    Arguments:
+      name: <string> [default="no-name"]
+        The name of the rule, useful for debug purposes (logs).
+      if: <condition config>
+        A list of conditions
+      do: <action config>
+        A list of actions executed when the conditions match
+      else-do: <action config>
+        A list of actions executed when the conditions do not match
+      on-error-do: <action config>
+        A list of actions executed on action failure
+      fetch: <string> [optional]
+        When not specified, the fetch level is deduced from the conditions
+        and actions. For instance, if a condition requires to parse the mail
+        headers, the fetch level is automatically set to "headers".
+        This option allows to force the fetch level for the rule. Valid values
+        are: "no", "headers", "part1", and "all".
+      inbox: <string> [optional]
+        Force the input mailbox for this rule. When not specified, use the
+        global inbox configuration.
+
+    Example:
+      rules:
+      - if:
+        - cond1: {cond1_arg1: foo, cond1_arg2: bar}
+        - cond2: {cond2_arg1: foo, cond2_arg2: bar}
+      - do:
+        - action1: {action1_arg1: foo, action1_arg2: bar}
+        - action2: {action2_arg1: foo, action2_arg2: bar}
+      - else-do:
+        - action3: {action3_arg1: foo, action3_arg2: bar}
+      - on-error-do:
+        - action4: {action4_arg1: foo, action4_arg2: bar}
+    """
+    def __init__(self, name, condition, match_action, nomatch_action,
+                 error_action, fetch=None, inbox=None):
+        """
+        Initialize a rule.
+
+        :arg string name:
+          The name of the rule
+        :arg ImapamiCond condition:
+          The condition that must match
+        :arg ImapamiAction match_action:
+          The action to be executed on condition match
+        :arg ImapamiAction nomatch_action:
+          The action to be executed if condition does not match
+        :arg ImapamiAction error_action:
+          The action to be executed if an action fails
+        :arg string fetch:
+          The fetch level if it is forced for this rule (can be "no",
+          "headers", "part1", and "all").
+        :arg string inbox:
+          The input mailbox directory where the rule is executed.
+        """
+        self.name = name
+        self.condition = condition
+        self.match_action = match_action
+        self.nomatch_action = nomatch_action
+        self.error_action = error_action
+        if fetch is None:
+            fetch = "no"
+        elif not fetch in ["no", "headers", "part1", "all"]:
+            raise ValueError(
+                "rule <%s> invalid fetch directive %s " % (self.name, fetch) +
+                "(allowed: no, headers, part1, all)")
+        self.fetch = fetch
+        self.inbox = inbox
+
+    def get_criteria(self, ami):
+        """
+        Return the criteria passed to the IMAP search command for this
+        rule. It depends on the condition list.
+
+        :arg Imapami ami:
+          The Imapami object
+        :returns:
+          The IMAP search criteria to be sent to the server.
+        """
+        criteria = self.condition.get_criteria()
+        if criteria == '':
+            criteria = 'ALL'
+        # evaluate variables
+        variables = ami.variables
+        fmt = imapami.utils.VarFormatter()
+        criteria = fmt.format(criteria, **variables)
+        return criteria
+
+    def _get_fetch_level(self):
+        """
+        Return the required fetch level of the message.
+
+        - 'no' means the message is not fetched
+        - 'headers' means fetch the mail headers only
+        - 'part1' means fetch the headers and the first part of the mail
+        - 'all' means fetch all the message including attachments
+
+        The returned value is the highest required fetch level, retrieved
+        from condition, actions, and the rule.
+        """
+        return imapami.utils.highest_fetch_level(
+            [self.condition.fetch, self.match_action.fetch,
+             self.nomatch_action.fetch, self.error_action.fetch, self.fetch])
+
+    def _search(self, ami, inbox):
+        """
+        Search the mails on the IMAP server
+
+        :arg Imapami ami:
+          The Imapami object
+        :arg string inbox:
+          The default input mailbox directory.
+        :returns:
+          A list of IMAP items
+        """
+        # select the input mailbox
+        if self.inbox is not None:
+            ami.imap.select(self.inbox)
+        else:
+            ami.imap.select(inbox)
+
+        # search messages matching conditions
+        criteria = "(%s)" % self.get_criteria(ami)
+        ami.logger.debug("processing rule %s, inbox %s, imap criteria %s",
+                         self.name, inbox, criteria)
+        resp, items = ami.imap.search(None, criteria)
+        if resp != 'OK':
+            ami.logger.warning(
+                "search failed: server response = %s, skip rule", resp)
+            return
+
+        item_list = items[0].split()
+        ami.logger.debug("matching mails returned by server: %s", item_list)
+        return item_list
+
+    def _get_parts(self, ami):
+        """
+        Determine which parts of a mail should be fetched
+
+        Depending on the rules, we need to fetch nothing, the headers, the
+        first part of the body or all the mail.
+
+        :arg Imapami ami:
+          The Imapami object
+        :returns:
+          A tuple containing:
+          - the part string to be passed to the IMAP server
+          - a list of (key, IMAP_part)
+        """
+        fetch = self._get_fetch_level()
+        parts = []
+        if fetch in ["headers", "part1", "all"]:
+            parts.append(("headers", "FLAGS INTERNALDATE BODY.PEEK[HEADER]"))
+        if fetch in ["part1", "all"]:
+            parts.append(("body_part1", "BODY.PEEK[1]"))
+        if fetch == "all":
+            parts.append(("body_all", "BODY.PEEK[TEXT]"))
+        parts_str = '(%s)' % ' '.join([p[1] for p in parts])
+        ami.logger.debug('get imap parts = %s', parts_str)
+        return parts_str, parts
+
+    def process(self, ami, inbox):
+        """
+        Process the rule.
+
+        :arg Imapami ami:
+          The Imapami object
+        :arg string inbox:
+          The default input mailbox directory.
+        """
+        # get the list of items (mails) matching the condition criteria
+        item_list = self._search(ami, inbox)
+        # determine what parts should be fetched
+        parts_str, parts = self._get_parts(ami)
+
+        # for each item, fetch it, check the conditions, and do the actions
+        for item in item_list:
+            mail_data = {'item': item, 'inbox': inbox}
+            if parts != []:
+                resp, data = ami.imap.fetch(item, parts_str)
+                if resp != 'OK':
+                    ami.logger.warning(
+                        "search failed: server response = %s, skip item",
+                        resp)
+                    continue
+
+                # fill mail_data from fetched parts
+                for i, part in enumerate(parts):
+                    mail_data[part[0]] = data[i][1]
+
+                m = re.match(r'^.*FLAGS \(([^)]*)\) INTERNALDATE ("[^"]*").*$',
+                             data[0][0])
+                if m is None:
+                    ami.logger.warning("cannot parse flags and date %s" %
+                                       data[0][0])
+                    flags, internal_date = '', ''
+                else:
+                    flags, internal_date = m.groups()
+                mail_data['flags'] = flags
+                mail_data['internal_date'] = internal_date
+
+                mail_data['date'] = imaplib.Internaldate2tuple(
+                    mail_data['internal_date'])
+
+            mail = imapami.mail.ImapamiMail(**mail_data)
+            if self.condition.check(ami, mail) == True:
+                ami.logger.debug("item %s matches conditions", item)
+                success = self.match_action.process(ami, mail)
+            else:
+                ami.logger.debug("item %s does not match conditions", item)
+                success = self.nomatch_action.process(ami, mail)
+
+            if success == False:
+                ami.logger.warning(
+                    "at least one action failed for item %s", item)
+                self.error_action.process(ami, mail)
+        ami.imap.expunge()
+
+def new(config):
+    """
+    Create a rule object from its yaml config.
+
+    :arg string config:
+      The yaml rule config.
+    :returns:
+      The rule object.
+    """
+    logger = logging.getLogger('imapami')
+    name = config.get("name")
+    if name is None:
+        name = "no-name"
+    fetch = config.get("fetch")
+    conditions = config.get("if")
+    if conditions is None:
+        logger.debug("no condition for rule <%s>, assume always true", name)
+        condition = imapami.conditions.ImapamiCond()
+    else:
+        cond_list = {"and": conditions}
+        condition = imapami.conditions.new(cond_list)
+    match_actions = config.get("do")
+    if match_actions is None:
+        logger.info("no action for rule <%s>, will do nothing", name)
+        match_action = imapami.actions.ImapamiAction()
+    else:
+        action_list = {"list": match_actions}
+        match_action = imapami.actions.new(action_list)
+    nomatch_actions = config.get("else-do")
+    if nomatch_actions is None:
+        nomatch_action = imapami.actions.ImapamiAction()
+    else:
+        action_list = {"list": nomatch_actions}
+        nomatch_action = imapami.actions.new(action_list)
+    error_actions = config.get("on-error-do")
+    if error_actions is None:
+        error_action = imapami.actions.ImapamiAction()
+    else:
+        action_list = {"list": error_actions}
+        error_action = imapami.actions.new(action_list)
+    return ImapamiRule(name, condition, match_action, nomatch_action,
+                       error_action, fetch)
diff --git a/imapami/utils.py b/imapami/utils.py
new file mode 100644 (file)
index 0000000..abf79b0
--- /dev/null
@@ -0,0 +1,83 @@
+#!/usr/bin/env python
+
+#
+# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#     * Neither the name of the University of California, Berkeley nor the
+#       names of its contributors may be used to endorse or promote products
+#       derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+import email.header
+
+# pylint: disable=deprecated-module
+# see https://www.logilab.org/ticket/2481
+import string
+
+class VarFormatter(string.Formatter):
+    """
+    Simple formatter that does not throw exception if the
+    variable does not exist. In this case, it is replaced by an
+    empty string.
+    """
+    def __init__(self):
+        string.Formatter.__init__(self)
+
+    def get_field(self, field_name, args, kwargs):
+        try:
+            return string.Formatter.get_field(self, field_name, args, kwargs)
+        except (KeyError, AttributeError):
+            return None, field_name
+
+    def format_field(self, value, spec):
+        if value is None:
+            return ''
+        return string.Formatter.format_field(self, value, spec)
+
+def headers_to_unicode(headers):
+    """
+    Convert mail headers into a unicode dictionary
+
+    :arg email.message.Message headers:
+      The email headers
+    """
+    unicode_headers = {}
+    for key, hdr in headers.items():
+        value, encoding = email.header.decode_header(hdr)[0]
+        if encoding is None:
+            value = unicode(value)
+        else:
+            value = value.decode(encoding)
+        unicode_headers[key] = value
+    return unicode_headers
+
+def highest_fetch_level(fetch_list):
+    """
+    Return the highest fetch level for a mail.
+    """
+    if "all" in fetch_list:
+        return "all"
+    if "part1" in fetch_list:
+        return "part1"
+    if "headers" in fetch_list:
+        return "headers"
+    return "no"
diff --git a/pylintrc b/pylintrc
new file mode 100644 (file)
index 0000000..34d8f3a
--- /dev/null
+++ b/pylintrc
@@ -0,0 +1,264 @@
+# Copyright 2014-2015, 6WIND S.A.
+
+[MASTER]
+
+# Specify a configuration file.
+#rcfile=
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Profiled execution.
+profile=no
+
+# Add files or directories to the blacklist. They should be base names, not
+# paths.
+ignore=CVS,.git
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# List of plugins (as comma separated values of python modules names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+
+[MESSAGES CONTROL]
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time.
+#enable=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once).
+disable=attribute-defined-outside-init,
+       bare-except,
+       cyclic-import,
+       duplicate-code,
+       fixme,
+       interface-not-implemented,
+       line-too-long,
+       locally-disabled,
+       missing-docstring,
+       no-member,
+       no-self-use,
+       super-init-not-called,
+       unused-argument,
+
+
+[REPORTS]
+
+# Set the output format. Available formats are text, parseable, colorized, msvs
+# (visual studio) and html
+output-format=text
+
+# Put messages in a separate file for each module / package specified on the
+# command line instead of printing them on stdout. Reports (if any) will be
+# written in a file name "pylint_global.[txt|html]".
+files-output=no
+
+# Tells whether to display a full report or only the messages
+reports=no
+
+# Python expression which should return a note less than 10 (10 is the highest
+# note). You have access to the variables errors warning, statement which
+# respectively contain the number of errors / warnings messages and the total
+# number of statements analyzed. This is used by the global evaluation report
+# (RP0004).
+evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
+
+# Add a comment according to your evaluation note. This is used by the global
+# evaluation report (RP0004).
+comment=no
+
+msg-template={path}:{line} {msg} [{symbol}]
+
+[TYPECHECK]
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# mixin class is detected if its name ends with "mixin" (case insensitive).
+ignore-mixin-members=yes
+
+# List of classes names for which member attributes should not be checked
+# (useful for classes with attributes dynamically set).
+ignored-classes=SQLObject
+
+# When zope mode is activated, add a predefined set of Zope acquired attributes
+# to generated-members.
+zope=no
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E0201 when accessed. Python regular
+# expressions are accepted.
+generated-members=REQUEST,acl_users,aq_parent
+
+
+[BASIC]
+
+# Required attributes for module, separated by a comma
+required-attributes=
+
+# List of builtins function names that should not be used, separated by a comma
+bad-functions=map,filter,apply,input
+
+# Regular expression which should only match correct module names
+module-rgx=[a-z_][a-z0-9_]*$
+
+# Regular expression which should only match correct module level names
+const-rgx=[A-Z_][A-Z0-9_]*|__all__$
+
+# Regular expression which should only match correct class names
+class-rgx=[A-Z_][a-zA-Z0-9]+$
+
+# Regular expression which should only match correct function names
+function-rgx=[a-z_][a-z0-9_]{2,30}$
+
+# Regular expression which should only match correct method names
+method-rgx=[a-z_][a-z0-9_]{2,30}$
+
+# Regular expression which should only match correct instance attribute names
+attr-rgx=[a-z_][a-z0-9_]{0,30}$
+
+# Regular expression which should only match correct argument names
+argument-rgx=[a-z_][a-z0-9_]{0,30}$
+
+# Regular expression which should only match correct variable names
+variable-rgx=[a-z_][a-z0-9_]{0,30}$
+
+# Regular expression which should only match correct list comprehension /
+# generator expression variable names
+inlinevar-rgx=[a-z_][a-z0-9_]*$
+
+# Good variable names which should always be accepted, separated by a comma
+good-names=i,j,k,ex,Run,_
+
+# Bad variable names which should always be refused, separated by a comma
+bad-names=foo,bar,baz,toto,tutu,tata
+
+# Regular expression which should only match functions or classes name which do
+# not require a docstring
+no-docstring-rgx=__.*__
+
+
+[FORMAT]
+
+# Maximum number of characters on a single line.
+max-line-length=80
+
+# Maximum number of lines in a module
+max-module-lines=1500
+
+# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
+# tab).
+indent-string='    '
+
+# List of optional constructs for which whitespace checking is disabled
+no-space-check=
+
+
+[SIMILARITIES]
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+# Ignore comments when computing similarities.
+ignore-comments=yes
+
+# Ignore docstrings when computing similarities.
+ignore-docstrings=yes
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,XXX,TODO
+
+
+[VARIABLES]
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=yes
+
+# A regular expression matching the beginning of the name of dummy variables
+# (i.e. not used).
+dummy-variables-rgx=_|dummy
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid to define new builtins when possible.
+additional-builtins=
+
+
+[IMPORTS]
+
+# Deprecated modules which should not be used, separated by a comma
+deprecated-modules=regsub,string,TERMIOS,Bastion,rexec
+
+# Create a graph of every (i.e. internal and external) dependencies in the
+# given file (report RP0402 must not be disabled)
+import-graph=
+
+# Create a graph of external dependencies in the given file (report RP0402 must
+# not be disabled)
+ext-import-graph=
+
+# Create a graph of internal dependencies in the given file (report RP0402 must
+# not be disabled)
+int-import-graph=
+
+
+[DESIGN]
+
+# Maximum number of arguments for function / method
+max-args=15
+
+# Argument names that match this expression will be ignored. Default to name
+# with leading underscore
+ignored-argument-names=_.*
+
+# Maximum number of locals for function / method body
+max-locals=20
+
+# Maximum number of return / yield for function / method body
+max-returns=6
+
+# Maximum number of branch for function / method body
+max-branches=20
+
+# Maximum number of statements in function / method body
+max-statements=80
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=30
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=0
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=100
+
+
+[CLASSES]
+
+# List of interface methods to ignore, separated by a comma. This is used for
+# instance to not check methods defines in Zope's Interface base class.
+#ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,__new__
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "Exception"
+overgeneral-exceptions=Exception
diff --git a/setup.py b/setup.py
new file mode 100644 (file)
index 0000000..edd2051
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python
+
+#
+# Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#     * Neither the name of the University of California, Berkeley nor the
+#       names of its contributors may be used to endorse or promote products
+#       derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+
+from setuptools import setup
+
+setup(
+    name='imapami',
+    version='0.1.0',
+    author='Olivier Matz',
+    author_email='zer0@droids-corp.org',
+    description='Process and filter mails on an IMAP server.',
+    long_description=open('README').read(),
+    url='http://www.droids-corp.org/',
+    license='BSD',
+    packages=['imapami'],
+    entry_points={
+        'console_scripts': [
+            'imapami = imapami:main',
+        ]
+    },
+    install_requires=['pyyaml'],
+)