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.uid("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.uid("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.uid("COPY", mail.item, dest)
282 "imap copy returned %s: %s" % (ret, str(msg)))
284 ret, msg = imap.uid("STORE", mail.item, '+FLAGS', '\\Deleted')
287 "imap delete returned %s: %s" % (ret, str(msg)))
290 register(ImapamiActionMove)
292 class ImapamiActionLog(ImapamiAction):
298 Message to be logged. Like all action string arguments, it
299 can contain variables or header name.
300 level: <integer> [optional, default = 3 (info)]
301 The level of the log from 0 (critical) to 4 (debug). Note that
302 it must be lower or equal to the global log level, else it
303 won't appear in the log file.
306 log: {msg: 'Mail from {From} is received', level: 3}
309 def __init__(self, msg, level=3):
310 ImapamiAction.__init__(self, fetch="headers")
312 if not isinstance(level, int) or level < 0 or level > 4:
313 raise ValueError("invalid log level")
315 def process(self, ami, mail):
316 msg = self.evaluate(self.msg, ami, mail.msg)
317 l = _LOGLEVELS[self.level]
318 ami.logger.log(l, msg)
320 register(ImapamiActionLog)
322 class ImapamiActionPipe(ImapamiAction):
324 Pipe the mail to an external program.
329 what: <string> [default='headers']
330 Define what is sent to the program. Valid values are:
331 'headers': send the headers
332 'body_part1': send the first part of the message
333 'body_all': send all the message body
334 'headers+body_part1': send headers and first part of the message
335 'headers+body_all': send all
336 shell: <boolean> [default=False]
337 Invoke a shell for this command. This allows for instance to use
338 a pipe or a redirection to a file.
341 pipe: {command: 'cat > /tmp/foobar', shell: True}
344 def __init__(self, command, what='headers', shell=False):
345 valid = ['headers', 'body_part1', 'body_all',
346 'headers+body_part1', 'headers+body_all']
347 if not what in valid:
348 raise ValueError("invalid 'what' field. Valid values are: %s" %
350 if what in ['headers']:
352 elif what in ['body_part1', 'headers+body_part1']:
356 ImapamiAction.__init__(self, fetch=fetch)
357 self.command = command
360 def process(self, ami, mail):
361 if self.shell == True:
362 command = self.command
364 command = shlex.split(self.command)
365 process = subprocess.Popen(command,
366 stdin=subprocess.PIPE,
367 stdout=subprocess.PIPE,
368 stderr=subprocess.PIPE,
371 if self.what in ['headers', 'headers+body_part1', 'headers+body_all']:
372 data += str(mail.msg)
373 if self.what in ['body_part1', 'headers+body_part1']:
374 data += mail.body_part1
375 if self.what in ['body_all', 'headers+body_all']:
376 data += mail.body_all
377 stdout, stderr = process.communicate(data)
379 ami.variables["stdout"] = stdout
380 ami.variables["stderr"] = stderr
381 ami.variables["ret"] = ret
383 register(ImapamiActionPipe)
385 class ImapamiActionSetHeader(ImapamiAction):
387 Set or remove header fields in the mail.
389 Dpending on the mode, it is possible to:
390 - append it at the end of the mail headers (mode=add).
391 - remove all occurences of a header (mode=del)
392 - replace the first occurence of a header (mode=replace)
394 As IMAP does not allow to modify a mail, this action creates
395 a modified copy of the original mail, and delete the original
399 <list of commands (see below)>
403 Determine if headers should be added, removed, replaced. Can
404 be 'add', 'del', 'replace'.
405 <key (string)>: <value (string)>
406 The key contains the name of the header field (ex: Subject),
407 associated to the value to be set (None for delete operations).
408 Multiple fields can be specified.
411 set-header: [{mode: del, Message-Id: None},
412 {mode: add, Foo: bar, X-mailer: toto},
413 {mode: replace, Subject: 'subject was: {Subject}'}]
416 def __init__(self, *args):
417 ImapamiAction.__init__(self, fetch="all", terminal=True)
419 if not isinstance(cmd, dict):
420 raise ValueError("set-header command is not a dict")
421 if not cmd.has_key("mode"):
422 raise ValueError("set-header command has no mode")
423 if cmd["mode"] not in ['add', 'del', 'replace']:
424 raise ValueError("invalid mode for set-header command")
427 def process(self, ami, mail):
428 parsed_headers = copy.deepcopy(mail.msghdrs)
429 for cmd in self.cmdlist:
430 mode = cmd.pop("mode")
433 for k, v in fields.iteritems():
434 parsed_headers[k] = v
436 for k, v in fields.iteritems():
437 del parsed_headers[k]
438 elif mode == "replace":
439 for k, v in fields.iteritems():
440 parsed_headers.replace_header(k, v)
442 ami.imap.append(mail.inbox, mail.flags, mail.internal_date,
443 str(parsed_headers) + mail.body_all)
445 register(ImapamiActionSetHeader)
447 class ImapamiSmtpSender(object):
449 A SMTP sender using smtp lib
452 def __init__(self, host, port=None, encryption=None, login=None,
455 Initialize the SMTP sender class
458 Hostname or ip address of the smtp server.
460 The port of the smtp server to connect to.
461 :arg string encryption:
462 Define encryption type for smtp: "none", "ssl", "starttls".
465 User login for smtp server. If no login is specified, assume no
467 :arg string password:
468 Password associated to the login.
472 self.encryption = encryption or "none"
474 self.password = password
476 def send(self, ami, sender, to, mail):
485 The sender of the mail
489 ami.logger.debug("sending to smtp server %s" % self.host)
491 if self.port is not None:
492 args.append(self.port)
493 if self.encryption == "ssl":
494 smtp = smtplib.SMTP_SSL(*args)
496 smtp = smtplib.SMTP(*args)
498 if self.encryption == "starttls":
500 if self.login is not None:
501 smtp.login(self.loging, self.password)
502 smtp.sendmail(sender, to, mail)
504 except smtplib.SMTPAuthenticationError as e:
505 ami.logger.warning("smtp authentication error: %s", str(e))
507 except (smtplib.SMTPException, smtplib.SMTPHeloError,
508 smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused,
509 smtplib.SMTPDataError, RuntimeError) as e:
510 ami.logger.warning("smtp error: %s", str(e))
514 class ImapamiActionForward(ImapamiAction):
518 The mail is forwarded with its attachments. New mail headers
519 are created from scratch. The subject of the mail is prefixed
524 The sender of the mail.
526 The of the forwarded mail.
527 host: <string> (mandatory)
528 Hostname or ip address of the smtp server.
529 port: <integer> (optional)
530 The port of the smtp server to connect to.
531 encryption: <string> (optional)
532 Define encryption type for smtp: "none", "ssl", "starttls".
534 login: <string> (optional)
535 User login for smtp server. If no login is specified, assume no
537 password: <string> (optional)
538 Password associated to the login.
541 forward: {sender: foo@example.com, to: toto@example.com,
542 host: mail.example.com}
545 def __init__(self, sender, to, host, port=None, encryption=None, login=None,
547 ImapamiAction.__init__(self, fetch="all")
550 self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
552 def process(self, ami, mail):
553 sender = self.evaluate(self.sender, ami, mail.msg)
554 to = self.evaluate(self.to, ami, mail.msg)
555 subject = "Fwd: " + self.evaluate('{Subject}', ami, mail.msg)
556 new_mail = MIMEMultipart()
557 new_mail['Date'] = formatdate(localtime=True)
558 new_mail['From'] = sender
560 new_mail['Subject'] = subject
561 new_mail.attach(MIMEMessage(mail.msg))
562 return self.smtp.send(ami, sender, to, new_mail.as_string())
563 register(ImapamiActionForward)
565 class ImapamiActionBounce(ImapamiAction):
569 The mail is transfered with its attachments without modification.
573 The sender of the mail.
575 The of the bounceed mail.
576 host: <string> (mandatory)
577 Hostname or ip address of the smtp server.
578 port: <integer> (optional)
579 The port of the smtp server to connect to.
580 encryption: <string> (optional)
581 Define encryption type for smtp: "none", "ssl", "starttls".
583 login: <string> (optional)
584 User login for smtp server. If no login is specified, assume no
586 password: <string> (optional)
587 Password associated to the login.
590 bounce: {Sender: foo@example.com, To: toto@example.com,
591 host: mail.example.com}
594 def __init__(self, sender, to, host, port=None, encryption=None, login=None,
596 ImapamiAction.__init__(self, fetch="all")
599 self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
601 def process(self, ami, mail):
602 sender = self.evaluate(self.sender, ami, mail.msg)
603 to = self.evaluate(self.to, ami, mail.msg)
604 return self.smtp.send(ami, sender, to, str(mail.msg))
605 register(ImapamiActionBounce)
607 class ImapamiActionSetVar(ImapamiAction):
611 As it does not read/write the mail, this action does not need
612 to fetch the mail by default.
613 If a mail header field is used as a variable in the action
614 parameters, the user must ensure that the mail headers are fetched,
615 for instance by setting the rule paramater 'fetch' to 'headers'.
619 Each key contains the name of the variable to be set,
620 and its value contains the string that should be set. If the
621 value is None, the variable is deleted. Multiple fields can be
625 set-var: {var1: foo@example.com, var2: None}
628 def __init__(self, **kwargs):
629 ImapamiAction.__init__(self)
631 def process(self, ami, mail):
632 for k, v in self.kwargs.iteritems():
633 k = self.evaluate(k, ami, mail.msg)
634 if v is None and ami.variables.get(k) is not None:
637 v = self.evaluate(v, ami, mail.msg)
640 register(ImapamiActionSetVar)
644 Create an action object from its yaml config.
647 The yaml action config.
651 logger = logging.getLogger('imapami')
652 logger.debug("parsing action %s", config)
654 raise ValueError("the action config must be a dictionary whose only "
655 "key is the action name")
656 action_name = config.keys()[0]
659 if isinstance(config[action_name], list):
660 argslist = config[action_name]
661 elif isinstance(config[action_name], dict):
662 argsdict = config[action_name]
663 elif config[action_name] is not None:
664 argslist = [config[action_name]]
665 action_class = _all_actions.get(action_name)
666 if action_class is None:
667 raise ValueError("Invalid action name '%s'" % action_name)
668 logger.debug("new action %s(%s, %s)", action_name, argslist, argsdict)
669 return action_class(*argslist, **argsdict)
673 Return a dictionary containing all the registered actions.