From a5fad708a8f0fa43f183e5ec1044b0cd3da6d4ad Mon Sep 17 00:00:00 2001 From: Olivier Matz Date: Sun, 22 Nov 2015 13:57:41 +0100 Subject: [PATCH 1/1] First public revision Signed-off-by: Olivier Matz --- MANIFEST.in | 7 + README | 47 ++ config-samples/extension/imapami-ext.py | 77 +++ config-samples/simple/rules.yaml | 103 ++++ imapami/__init__.py | 325 ++++++++++ imapami/actions.py | 670 +++++++++++++++++++++ imapami/conditions.py | 760 ++++++++++++++++++++++++ imapami/mail.py | 78 +++ imapami/rules.py | 303 ++++++++++ imapami/utils.py | 83 +++ pylintrc | 264 ++++++++ setup.py | 48 ++ 12 files changed, 2765 insertions(+) create mode 100644 MANIFEST.in create mode 100644 README create mode 100755 config-samples/extension/imapami-ext.py create mode 100644 config-samples/simple/rules.yaml create mode 100644 imapami/__init__.py create mode 100644 imapami/actions.py create mode 100644 imapami/conditions.py create mode 100644 imapami/mail.py create mode 100644 imapami/rules.py create mode 100644 imapami/utils.py create mode 100644 pylintrc create mode 100644 setup.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..987207f --- /dev/null +++ b/MANIFEST.in @@ -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 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 index 0000000..d6e01cd --- /dev/null +++ b/config-samples/extension/imapami-ext.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python + +# +# Copyright 2015, Olivier MATZ +# +# 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: [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 index 0000000..505424c --- /dev/null +++ b/config-samples/simple/rules.yaml @@ -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 index 0000000..5000e77 --- /dev/null +++ b/imapami/__init__.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python + +# +# Copyright 2015, Olivier MATZ +# +# 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: [mandatory] + Hostname or IP address of the IMAP server to connect to. + + - port: [optional] + The TCP port to connect to. If not specified, use the default, + depending on whether SSL is enabled or disabled. + + - ssl: [optional, default is True] + Enable or disable SSL (True or False). + + - login: [optional] + IMAP login name. If not specified, the login is asked on stdin. + + - password: [optional] + IMAP password. If not specified, the password is asked on stdin. + + - logfile: [optional] + File where messages are logged. If not specified, no logs are + written in a file. + + - loglevel: [optional] + Level of logs written in logfile. Possible value are from 0 (no log) + to 4 (debug). Default value is 3 (info). + + - inbox: [optional, default is INBOX] + Default mailbox directory where rules get message from. It can be + overriden inside the rule. + + - 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 index 0000000..7525133 --- /dev/null +++ b/imapami/actions.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python + +# +# Copyright 2015, Olivier MATZ +# +# 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: + 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: + Name of the IMAP flag (ex: 'Seen', 'Answered', 'Deleted', ...) + Refer to RFC3501 for details. + enable: + 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: + 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: + Message to be logged. Like all action string arguments, it + can contain variables or header name. + level: [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: + The command to run. + what: [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: [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: + + + Format of a command: + mode: + Determine if headers should be added, removed, replaced. Can + be 'add', 'del', 'replace'. + : + 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: + The sender of the mail. + to: + The of the forwarded mail. + host: (mandatory) + Hostname or ip address of the smtp server. + port: (optional) + The port of the smtp server to connect to. + encryption: (optional) + Define encryption type for smtp: "none", "ssl", "starttls". + Default is none. + login: (optional) + User login for smtp server. If no login is specified, assume no + login is required. + password: (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: + The sender of the mail. + to: + The of the bounceed mail. + host: (mandatory) + Hostname or ip address of the smtp server. + port: (optional) + The port of the smtp server to connect to. + encryption: (optional) + Define encryption type for smtp: "none", "ssl", "starttls". + Default is none. + login: (optional) + User login for smtp server. If no login is specified, assume no + login is required. + password: (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: + : + 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 index 0000000..97675a7 --- /dev/null +++ b/imapami/conditions.py @@ -0,0 +1,760 @@ +#!/usr/bin/env python + +# +# Copyright 2015, Olivier MATZ +# +# 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: + + + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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 index 0000000..29c4a02 --- /dev/null +++ b/imapami/mail.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python + +# +# Copyright 2015, Olivier MATZ +# +# 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 index 0000000..840d1aa --- /dev/null +++ b/imapami/rules.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python + +# +# Copyright 2015, Olivier MATZ +# +# 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: [default="no-name"] + The name of the rule, useful for debug purposes (logs). + if: + A list of conditions + do: + A list of actions executed when the conditions match + else-do: + A list of actions executed when the conditions do not match + on-error-do: + A list of actions executed on action failure + fetch: [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: [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 index 0000000..abf79b0 --- /dev/null +++ b/imapami/utils.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python + +# +# Copyright 2015, Olivier MATZ +# +# 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 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 index 0000000..edd2051 --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python + +# +# Copyright 2015, Olivier MATZ +# +# 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'], +) -- 2.39.5