+#!/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