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):
277 dest = self.evaluate(self.dest, ami, mail.msg)
279 ret, msg = imap.copy(mail.item, dest)
282 "imap copy returned %s: %s" % (ret, str(msg)))
284 ret, msg = imap.store(mail.item, '+FLAGS', '\\Deleted')
287 "imap store '%s %s' returned %s: %s" % (
288 cmd, flag, ret, str(msg)))
291 register(ImapamiActionMove)
293 class ImapamiActionLog(ImapamiAction):
299 Message to be logged. Like all action string arguments, it
300 can contain variables or header name.
301 level: <integer> [optional, default = 3 (info)]
302 The level of the log from 0 (critical) to 4 (debug). Note that
303 it must be lower or equal to the global log level, else it
304 won't appear in the log file.
307 log: {msg: 'Mail from {From} is received', level: 3}
310 def __init__(self, msg, level=3):
311 ImapamiAction.__init__(self, fetch="headers")
313 if not isinstance(level, int) or level < 0 or level > 4:
314 raise ValueError("invalid log level")
316 def process(self, ami, mail):
317 msg = self.evaluate(self.msg, ami, mail.msg)
318 l = _LOGLEVELS[self.level]
319 ami.logger.log(l, msg)
321 register(ImapamiActionLog)
323 class ImapamiActionPipe(ImapamiAction):
325 Pipe the mail to an external program.
330 what: <string> [default='headers']
331 Define what is sent to the program. Valid values are:
332 'headers': send the headers
333 'body_part1': send the first part of the message
334 'body_all': send all the message body
335 'headers+body_part1': send headers and first part of the message
336 'headers+body_all': send all
337 shell: <boolean> [default=False]
338 Invoke a shell for this command. This allows for instance to use
339 a pipe or a redirection to a file.
342 pipe: {command: 'cat > /tmp/foobar', shell: True}
345 def __init__(self, command, what='headers', shell=False):
346 valid = ['headers', 'body_part1', 'body_all',
347 'headers+body_part1', 'headers+body_all']
348 if not what in valid:
349 raise ValueError("invalid 'what' field. Valid values are: %s" %
351 if what in ['headers']:
353 elif what in ['body_part1', 'headers+body_part1']:
357 ImapamiAction.__init__(self, fetch=fetch)
358 self.command = command
361 def process(self, ami, mail):
362 if self.shell == True:
363 command = self.command
365 command = shlex.split(self.command)
366 process = subprocess.Popen(command,
367 stdin=subprocess.PIPE,
368 stdout=subprocess.PIPE,
369 stderr=subprocess.PIPE,
372 if self.what in ['headers', 'headers+body_part1', 'headers+body_all']:
373 data += str(mail.msg)
374 if self.what in ['body_part1', 'headers+body_part1']:
375 data += mail.body_part1
376 if self.what in ['body_all', 'headers+body_all']:
377 data += mail.body_all
378 stdout, stderr = process.communicate(data)
380 ami.variables["stdout"] = stdout
381 ami.variables["stderr"] = stderr
382 ami.variables["ret"] = ret
384 register(ImapamiActionPipe)
386 class ImapamiActionSetHeader(ImapamiAction):
388 Set or remove header fields in the mail.
390 Dpending on the mode, it is possible to:
391 - append it at the end of the mail headers (mode=add).
392 - remove all occurences of a header (mode=del)
393 - replace the first occurence of a header (mode=replace)
395 As IMAP does not allow to modify a mail, this action creates
396 a modified copy of the original mail, and delete the original
400 <list of commands (see below)>
404 Determine if headers should be added, removed, replaced. Can
405 be 'add', 'del', 'replace'.
406 <key (string)>: <value (string)>
407 The key contains the name of the header field (ex: Subject),
408 associated to the value to be set (None for delete operations).
409 Multiple fields can be specified.
412 set-header: [{mode: del, Message-Id: None},
413 {mode: add, Foo: bar, X-mailer: toto},
414 {mode: replace, Subject: 'subject was: {Subject}'}]
417 def __init__(self, *args):
418 ImapamiAction.__init__(self, fetch="all", terminal=True)
420 if not isinstance(cmd, dict):
421 raise ValueError("set-header command is not a dict")
422 if not cmd.has_key("mode"):
423 raise ValueError("set-header command has no mode")
424 if cmd["mode"] not in ['add', 'del', 'replace']:
425 raise ValueError("invalid mode for set-header command")
428 def process(self, ami, mail):
429 parsed_headers = copy.deepcopy(mail.msghdrs)
430 for cmd in self.cmdlist:
431 mode = cmd.pop("mode")
434 for k, v in fields.iteritems():
435 parsed_headers[k] = v
437 for k, v in fields.iteritems():
438 del parsed_headers[k]
439 elif mode == "replace":
440 for k, v in fields.iteritems():
441 parsed_headers.replace_header(k, v)
443 ami.imap.append(mail.inbox, mail.flags, mail.internal_date,
444 str(parsed_headers) + mail.body_all)
446 register(ImapamiActionSetHeader)
448 class ImapamiSmtpSender(object):
450 A SMTP sender using smtp lib
453 def __init__(self, host, port=None, encryption=None, login=None,
456 Initialize the SMTP sender class
459 Hostname or ip address of the smtp server.
461 The port of the smtp server to connect to.
462 :arg string encryption:
463 Define encryption type for smtp: "none", "ssl", "starttls".
466 User login for smtp server. If no login is specified, assume no
468 :arg string password:
469 Password associated to the login.
473 self.encryption = encryption or "none"
475 self.password = password
477 def send(self, ami, sender, to, mail):
486 The sender of the mail
490 ami.logger.debug("sending to smtp server %s" % self.host)
492 if self.port is not None:
493 args.append(self.port)
494 if self.encryption == "ssl":
495 smtp = smtplib.SMTP_SSL(*args)
497 smtp = smtplib.SMTP(*args)
499 if self.encryption == "starttls":
501 if self.login is not None:
502 smtp.login(self.loging, self.password)
503 smtp.sendmail(sender, to, mail)
505 except smtplib.SMTPAuthenticationError as e:
506 ami.logger.warning("smtp authentication error: %s", str(e))
508 except (smtplib.SMTPException, smtplib.SMTPHeloError,
509 smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused,
510 smtplib.SMTPDataError, RuntimeError) as e:
511 ami.logger.warning("smtp error: %s", str(e))
515 class ImapamiActionForward(ImapamiAction):
519 The mail is forwarded with its attachments. New mail headers
520 are created from scratch. The subject of the mail is prefixed
525 The sender of the mail.
527 The of the forwarded mail.
528 host: <string> (mandatory)
529 Hostname or ip address of the smtp server.
530 port: <integer> (optional)
531 The port of the smtp server to connect to.
532 encryption: <string> (optional)
533 Define encryption type for smtp: "none", "ssl", "starttls".
535 login: <string> (optional)
536 User login for smtp server. If no login is specified, assume no
538 password: <string> (optional)
539 Password associated to the login.
542 forward: {sender: foo@example.com, to: toto@example.com,
543 host: mail.example.com}
546 def __init__(self, sender, to, host, port=None, encryption=None, login=None,
548 ImapamiAction.__init__(self, fetch="all")
551 self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
553 def process(self, ami, mail):
554 sender = self.evaluate(self.sender, ami, mail.msg)
555 to = self.evaluate(self.to, ami, mail.msg)
556 subject = "Fwd: " + self.evaluate('{Subject}', ami, mail.msg)
557 new_mail = MIMEMultipart()
558 new_mail['Date'] = formatdate(localtime=True)
559 new_mail['From'] = sender
561 new_mail['Subject'] = subject
562 new_mail.attach(MIMEMessage(mail.msg))
563 return self.smtp.send(ami, sender, to, new_mail.as_string())
564 register(ImapamiActionForward)
566 class ImapamiActionBounce(ImapamiAction):
570 The mail is transfered with its attachments without modification.
574 The sender of the mail.
576 The of the bounceed mail.
577 host: <string> (mandatory)
578 Hostname or ip address of the smtp server.
579 port: <integer> (optional)
580 The port of the smtp server to connect to.
581 encryption: <string> (optional)
582 Define encryption type for smtp: "none", "ssl", "starttls".
584 login: <string> (optional)
585 User login for smtp server. If no login is specified, assume no
587 password: <string> (optional)
588 Password associated to the login.
591 bounce: {Sender: foo@example.com, To: toto@example.com,
592 host: mail.example.com}
595 def __init__(self, sender, to, host, port=None, encryption=None, login=None,
597 ImapamiAction.__init__(self, fetch="all")
600 self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
602 def process(self, ami, mail):
603 sender = self.evaluate(self.sender, ami, mail.msg)
604 to = self.evaluate(self.to, ami, mail.msg)
605 return self.smtp.send(ami, sender, to, str(mail.msg))
606 register(ImapamiActionBounce)
608 class ImapamiActionSetVar(ImapamiAction):
612 As it does not read/write the mail, this action does not need
613 to fetch the mail by default.
614 If a mail header field is used as a variable in the action
615 parameters, the user must ensure that the mail headers are fetched,
616 for instance by setting the rule paramater 'fetch' to 'headers'.
620 Each key contains the name of the variable to be set,
621 and its value contains the string that should be set. If the
622 value is None, the variable is deleted. Multiple fields can be
626 set-var: {var1: foo@example.com, var2: None}
629 def __init__(self, **kwargs):
630 ImapamiAction.__init__(self)
632 def process(self, ami, mail):
633 for k, v in self.kwargs.iteritems():
634 k = self.evaluate(k, ami, mail.msg)
635 if v is None and ami.variables.get(k) is not None:
638 v = self.evaluate(v, ami, mail.msg)
641 register(ImapamiActionSetVar)
645 Create an action object from its yaml config.
648 The yaml action config.
652 logger = logging.getLogger('imapami')
653 logger.debug("parsing action %s", config)
655 raise ValueError("the action config must be a dictionary whose only "
656 "key is the action name")
657 action_name = config.keys()[0]
660 if isinstance(config[action_name], list):
661 argslist = config[action_name]
662 elif isinstance(config[action_name], dict):
663 argsdict = config[action_name]
664 elif config[action_name] is not None:
665 argslist = [config[action_name]]
666 action_class = _all_actions.get(action_name)
667 if action_class is None:
668 raise ValueError("Invalid action name '%s'" % action_name)
669 logger.debug("new action %s(%s, %s)", action_name, argslist, argsdict)
670 return action_class(*argslist, **argsdict)
674 Return a dictionary containing all the registered actions.