4 # Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
6 # Redistribution and use in source and binary forms, with or without
7 # modification, are permitted provided that the following conditions are met:
9 # * Redistributions of source code must retain the above copyright
10 # notice, this list of conditions and the following disclaimer.
11 # * Redistributions in binary form must reproduce the above copyright
12 # notice, this list of conditions and the following disclaimer in the
13 # documentation and/or other materials provided with the distribution.
14 # * Neither the name of the University of California, Berkeley nor the
15 # names of its contributors may be used to endorse or promote products
16 # derived from this software without specific prior written permission.
18 # THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
19 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 # DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
22 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25 # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
33 from email.mime.multipart import MIMEMultipart
34 from email.mime.message import MIMEMessage
35 from email.utils import formatdate
42 _LOGLEVELS = [logging.CRITICAL, logging.ERROR, logging.WARNING,
43 logging.INFO, logging.DEBUG]
45 def register(action_class):
47 Register an action class
49 if not issubclass(action_class, ImapamiAction):
50 raise ValueError('"%s" action is not a subclass of ImapamiAction' %
52 if action_class.name in _all_actions:
53 raise ValueError('"%s" action is already registered' %
55 _all_actions[action_class.name] = action_class
57 class ImapamiAction(object):
59 This is the parent class for actions.
61 An action is a task executed by imapami for a mail when
62 the conditions match. For instance, it can move the mail in
63 another directory, delete a mail, set a variable, execute a
68 def __init__(self, fetch=None, terminal=False):
70 Generic action constructor.
72 Set to "headers", "part1", "all" if it is needed to fetch
73 the header, the body or the attachments of the mail
74 :arg boolean terminal:
75 Set to true if the action has to be the last of the list
76 (ex: the mail is deleted or moved)
78 self.fetch = fetch or "no"
79 self.terminal = terminal
81 def evaluate(self, arg, ami, hdrs):
83 Evaluate a string argument
85 Replace variables and headers in arg by their values and
89 The argument to be evaluated
91 The imapami object, containing the variables
92 :arg email.message.Message hdrs:
93 The headers of the mail, or None if not available
95 The evaluated argument.
98 variables = imapami.utils.headers_to_unicode(hdrs)
101 variables.update(ami.variables)
102 fmt = imapami.utils.VarFormatter()
103 arg = fmt.format(unicode(arg), **variables)
106 def process(self, imap, mail):
112 :arg ImapamiMail mail:
115 True on success, False on error
119 class ImapamiActionList(ImapamiAction):
121 Execute a list of actions.
123 This command is used internally when the list of actions of a rule
124 is parsed. There is no real need for a user, as the rule already contains
132 - copy: {dest: archive}
136 def __init__(self, *action_list):
137 action_list = [new(a) for a in action_list]
138 for a in action_list[:-1]:
139 if a.terminal == True:
140 raise ValueError("terminal action in the middle of an action list")
141 fetch = imapami.utils.highest_fetch_level(
142 [a.fetch for a in action_list])
143 terminal = action_list[-1].terminal
144 ImapamiAction.__init__(self, fetch, terminal)
145 self.action_list = action_list
146 def process(self, ami, mail):
148 for a in self.action_list:
149 if a.process(ami, mail) == False:
152 register(ImapamiActionList)
154 class ImapamiActionCopy(ImapamiAction):
156 Copy the mail in another directory.
160 destination directory
163 copy: {dest: my_subdir}
166 def __init__(self, dest):
167 ImapamiAction.__init__(self)
169 def process(self, ami, mail):
171 dest = self.evaluate(self.dest, ami, mail.msg)
173 ret, msg = imap.copy(mail.item, dest)
176 "imap copy returned %s: %s" % (ret, str(msg)))
179 register(ImapamiActionCopy)
181 class ImapamiActionChangeFlag(ImapamiAction):
183 Change an IMAP flag of a mail.
187 Name of the IMAP flag (ex: 'Seen', 'Answered', 'Deleted', ...)
188 Refer to RFC3501 for details.
190 True to set the flag, False to reset it.
193 change-flag: {flag: Seen, enable: True}
196 def __init__(self, flag, enable):
197 ImapamiAction.__init__(self)
200 def process(self, ami, mail):
202 if self.enable == True:
206 flag = '\\' + self.evaluate(self.flag, ami,
208 ret, msg = imap.store(mail.item, cmd, flag)
211 "imap store '%s %s' returned %s: %s" % (
212 cmd, flag, ret, str(msg)))
215 register(ImapamiActionChangeFlag)
217 class ImapamiActionDelete(ImapamiActionChangeFlag):
219 Mark a mail as deleted.
228 ImapamiActionChangeFlag.__init__(self, "Deleted", True)
230 register(ImapamiActionDelete)
232 class ImapamiActionSeen(ImapamiActionChangeFlag):
243 ImapamiActionChangeFlag.__init__(self, "Seen", True)
244 register(ImapamiActionSeen)
246 class ImapamiActionUnseen(ImapamiActionChangeFlag):
248 Mark a mail as not seen.
257 ImapamiActionChangeFlag.__init__(self, "Seen", False)
258 register(ImapamiActionUnseen)
260 class ImapamiActionMove(ImapamiAction):
262 Move the mail in another directory.
266 destination directory
269 move: {dest: my_subdir}
272 def __init__(self, dest):
273 ImapamiAction.__init__(self, terminal=True)
275 def process(self, ami, mail):
276 ret = ImapamiActionCopy.process(
280 ret = ImapamiActionDelete.process(
285 register(ImapamiActionMove)
287 class ImapamiActionLog(ImapamiAction):
293 Message to be logged. Like all action string arguments, it
294 can contain variables or header name.
295 level: <integer> [optional, default = 3 (info)]
296 The level of the log from 0 (critical) to 4 (debug). Note that
297 it must be lower or equal to the global log level, else it
298 won't appear in the log file.
301 log: {msg: 'Mail from {From} is received', level: 3}
304 def __init__(self, msg, level=3):
305 ImapamiAction.__init__(self, fetch="headers")
307 if not isinstance(level, int) or level < 0 or level > 4:
308 raise ValueError("invalid log level")
310 def process(self, ami, mail):
311 msg = self.evaluate(self.msg, ami, mail.msg)
312 l = _LOGLEVELS[self.level]
313 ami.logger.log(l, msg)
315 register(ImapamiActionLog)
317 class ImapamiActionPipe(ImapamiAction):
319 Pipe the mail to an external program.
324 what: <string> [default='headers']
325 Define what is sent to the program. Valid values are:
326 'headers': send the headers
327 'body_part1': send the first part of the message
328 'body_all': send all the message body
329 'headers+body_part1': send headers and first part of the message
330 'headers+body_all': send all
331 shell: <boolean> [default=False]
332 Invoke a shell for this command. This allows for instance to use
333 a pipe or a redirection to a file.
336 pipe: {command: 'cat > /tmp/foobar', shell: True}
339 def __init__(self, command, what='headers', shell=False):
340 valid = ['headers', 'body_part1', 'body_all',
341 'headers+body_part1', 'headers+body_all']
342 if not what in valid:
343 raise ValueError("invalid 'what' field. Valid values are: %s" %
345 if what in ['headers']:
347 elif what in ['body_part1', 'headers+body_part1']:
351 ImapamiAction.__init__(self, fetch=fetch)
352 self.command = command
355 def process(self, ami, mail):
356 if self.shell == True:
357 command = self.command
359 command = shlex.split(self.command)
360 process = subprocess.Popen(command,
361 stdin=subprocess.PIPE,
362 stdout=subprocess.PIPE,
363 stderr=subprocess.PIPE,
366 if self.what in ['headers', 'headers+body_part1', 'headers+body_all']:
367 data += str(mail.msg)
368 if self.what in ['body_part1', 'headers+body_part1']:
369 data += mail.body_part1
370 if self.what in ['body_all', 'headers+body_all']:
371 data += mail.body_all
372 stdout, stderr = process.communicate(data)
374 ami.variables["stdout"] = stdout
375 ami.variables["stderr"] = stderr
376 ami.variables["ret"] = ret
378 register(ImapamiActionPipe)
380 class ImapamiActionSetHeader(ImapamiAction):
382 Set or remove header fields in the mail.
384 Dpending on the mode, it is possible to:
385 - append it at the end of the mail headers (mode=add).
386 - remove all occurences of a header (mode=del)
387 - replace the first occurence of a header (mode=replace)
389 As IMAP does not allow to modify a mail, this action creates
390 a modified copy of the original mail, and delete the original
394 <list of commands (see below)>
398 Determine if headers should be added, removed, replaced. Can
399 be 'add', 'del', 'replace'.
400 <key (string)>: <value (string)>
401 The key contains the name of the header field (ex: Subject),
402 associated to the value to be set (None for delete operations).
403 Multiple fields can be specified.
406 set-header: [{mode: del, Message-Id: None},
407 {mode: add, Foo: bar, X-mailer: toto},
408 {mode: replace, Subject: 'subject was: {Subject}'}]
411 def __init__(self, *args):
412 ImapamiAction.__init__(self, fetch="all", terminal=True)
414 if not isinstance(cmd, dict):
415 raise ValueError("set-header command is not a dict")
416 if not cmd.has_key("mode"):
417 raise ValueError("set-header command has no mode")
418 if cmd["mode"] not in ['add', 'del', 'replace']:
419 raise ValueError("invalid mode for set-header command")
422 def process(self, ami, mail):
423 parsed_headers = copy.deepcopy(mail.msghdrs)
424 for cmd in self.cmdlist:
425 mode = cmd.pop("mode")
428 for k, v in fields.iteritems():
429 parsed_headers[k] = v
431 for k, v in fields.iteritems():
432 del parsed_headers[k]
433 elif mode == "replace":
434 for k, v in fields.iteritems():
435 parsed_headers.replace_header(k, v)
437 ami.imap.append(mail.inbox, mail.flags, mail.internal_date,
438 str(parsed_headers) + mail.body_all)
440 register(ImapamiActionSetHeader)
442 class ImapamiSmtpSender(object):
444 A SMTP sender using smtp lib
447 def __init__(self, host, port=None, encryption=None, login=None,
450 Initialize the SMTP sender class
453 Hostname or ip address of the smtp server.
455 The port of the smtp server to connect to.
456 :arg string encryption:
457 Define encryption type for smtp: "none", "ssl", "starttls".
460 User login for smtp server. If no login is specified, assume no
462 :arg string password:
463 Password associated to the login.
467 self.encryption = encryption or "none"
469 self.password = password
471 def send(self, ami, sender, to, mail):
480 The sender of the mail
484 ami.logger.debug("sending to smtp server %s" % self.host)
486 if self.port is not None:
487 args.append(self.port)
488 if self.encryption == "ssl":
489 smtp = smtplib.SMTP_SSL(*args)
491 smtp = smtplib.SMTP(*args)
493 if self.encryption == "starttls":
495 if self.login is not None:
496 smtp.login(self.loging, self.password)
497 smtp.sendmail(sender, to, mail)
499 except smtplib.SMTPAuthenticationError as e:
500 ami.logger.warning("smtp authentication error: %s", str(e))
502 except (smtplib.SMTPException, smtplib.SMTPHeloError,
503 smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused,
504 smtplib.SMTPDataError, RuntimeError) as e:
505 ami.logger.warning("smtp error: %s", str(e))
509 class ImapamiActionForward(ImapamiAction):
513 The mail is forwarded with its attachments. New mail headers
514 are created from scratch. The subject of the mail is prefixed
519 The sender of the mail.
521 The of the forwarded mail.
522 host: <string> (mandatory)
523 Hostname or ip address of the smtp server.
524 port: <integer> (optional)
525 The port of the smtp server to connect to.
526 encryption: <string> (optional)
527 Define encryption type for smtp: "none", "ssl", "starttls".
529 login: <string> (optional)
530 User login for smtp server. If no login is specified, assume no
532 password: <string> (optional)
533 Password associated to the login.
536 forward: {sender: foo@example.com, to: toto@example.com,
537 host: mail.example.com}
540 def __init__(self, sender, to, host, port=None, encryption=None, login=None,
542 ImapamiAction.__init__(self, fetch="all")
545 self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
547 def process(self, ami, mail):
548 sender = self.evaluate(self.sender, ami, mail.msg)
549 to = self.evaluate(self.to, ami, mail.msg)
550 subject = "Fwd: " + self.evaluate('{Subject}', ami, mail.msg)
551 new_mail = MIMEMultipart()
552 new_mail['Date'] = formatdate(localtime=True)
553 new_mail['From'] = sender
555 new_mail['Subject'] = subject
556 new_mail.attach(MIMEMessage(mail.msg))
557 return self.smtp.send(ami, sender, to, new_mail.as_string())
558 register(ImapamiActionForward)
560 class ImapamiActionBounce(ImapamiAction):
564 The mail is transfered with its attachments without modification.
568 The sender of the mail.
570 The of the bounceed mail.
571 host: <string> (mandatory)
572 Hostname or ip address of the smtp server.
573 port: <integer> (optional)
574 The port of the smtp server to connect to.
575 encryption: <string> (optional)
576 Define encryption type for smtp: "none", "ssl", "starttls".
578 login: <string> (optional)
579 User login for smtp server. If no login is specified, assume no
581 password: <string> (optional)
582 Password associated to the login.
585 bounce: {Sender: foo@example.com, To: toto@example.com,
586 host: mail.example.com}
589 def __init__(self, sender, to, host, port=None, encryption=None, login=None,
591 ImapamiAction.__init__(self, fetch="all")
594 self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
596 def process(self, ami, mail):
597 sender = self.evaluate(self.sender, ami, mail.msg)
598 to = self.evaluate(self.to, ami, mail.msg)
599 return self.smtp.send(ami, sender, to, str(mail.msg))
600 register(ImapamiActionBounce)
602 class ImapamiActionSetVar(ImapamiAction):
606 As it does not read/write the mail, this action does not need
607 to fetch the mail by default.
608 If a mail header field is used as a variable in the action
609 parameters, the user must ensure that the mail headers are fetched,
610 for instance by setting the rule paramater 'fetch' to 'headers'.
614 Each key contains the name of the variable to be set,
615 and its value contains the string that should be set. If the
616 value is None, the variable is deleted. Multiple fields can be
620 set-var: {var1: foo@example.com, var2: None}
623 def __init__(self, **kwargs):
624 ImapamiAction.__init__(self)
626 def process(self, ami, mail):
627 for k, v in self.kwargs.iteritems():
628 k = self.evaluate(k, ami, mail.msg)
629 if v is None and ami.variables.get(k) is not None:
632 v = self.evaluate(v, ami, mail.msg)
635 register(ImapamiActionSetVar)
639 Create an action object from its yaml config.
642 The yaml action config.
646 logger = logging.getLogger('imapami')
647 logger.debug("parsing action %s", config)
649 raise ValueError("the action config must be a dictionary whose only "
650 "key is the action name")
651 action_name = config.keys()[0]
654 if isinstance(config[action_name], list):
655 argslist = config[action_name]
656 elif isinstance(config[action_name], dict):
657 argsdict = config[action_name]
658 elif config[action_name] is not None:
659 argslist = [config[action_name]]
660 action_class = _all_actions.get(action_name)
661 if action_class is None:
662 raise ValueError("Invalid action name '%s'" % action_name)
663 logger.debug("new action %s(%s, %s)", action_name, argslist, argsdict)
664 return action_class(*argslist, **argsdict)
668 Return a dictionary containing all the registered actions.