--- /dev/null
+include README
+include MANIFEST.in
+graft imapami
+graft config-samples
+global-exclude *.pyc
+global-exclude *.pyo
+global-exclude *~
--- /dev/null
+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.
--- /dev/null
+#!/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()
--- /dev/null
+#
+# 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}
--- /dev/null
+#!/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()
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+#!/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)
--- /dev/null
+#!/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"
--- /dev/null
+# 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
--- /dev/null
+#!/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'],
+)