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 = '(\\%s)' % self.evaluate(self.flag, ami, mail.msg)
207 ret, msg = imap.uid("STORE", mail.item, cmd, flag)
210 "imap store '%s %s' returned %s: %s" % (
211 cmd, flag, ret, str(msg)))
214 register(ImapamiActionChangeFlag)
216 class ImapamiActionDelete(ImapamiActionChangeFlag):
218 Mark a mail as deleted.
227 ImapamiActionChangeFlag.__init__(self, "Deleted", True)
229 register(ImapamiActionDelete)
231 class ImapamiActionSeen(ImapamiActionChangeFlag):
242 ImapamiActionChangeFlag.__init__(self, "Seen", True)
243 register(ImapamiActionSeen)
245 class ImapamiActionUnseen(ImapamiActionChangeFlag):
247 Mark a mail as not seen.
256 ImapamiActionChangeFlag.__init__(self, "Seen", False)
257 register(ImapamiActionUnseen)
259 class ImapamiActionMove(ImapamiAction):
261 Move the mail in another directory.
265 destination directory
268 move: {dest: my_subdir}
271 def __init__(self, dest):
272 ImapamiAction.__init__(self, terminal=True)
274 def process(self, ami, mail):
276 dest = self.evaluate(self.dest, ami, mail.msg)
278 ret, msg = imap.uid("COPY", mail.item, dest)
281 "imap copy returned %s: %s" % (ret, str(msg)))
283 ret, msg = imap.uid("STORE", mail.item, '+FLAGS', '(\\Deleted)')
286 "imap delete returned %s: %s" % (ret, str(msg)))
289 register(ImapamiActionMove)
291 class ImapamiActionLog(ImapamiAction):
297 Message to be logged. Like all action string arguments, it
298 can contain variables or header name.
299 level: <integer> [optional, default = 3 (info)]
300 The level of the log from 0 (critical) to 4 (debug). Note that
301 it must be lower or equal to the global log level, else it
302 won't appear in the log file.
305 log: {msg: 'Mail from {From} is received', level: 3}
308 def __init__(self, msg, level=3):
309 ImapamiAction.__init__(self, fetch="headers")
311 if not isinstance(level, int) or level < 0 or level > 4:
312 raise ValueError("invalid log level")
314 def process(self, ami, mail):
315 msg = self.evaluate(self.msg, ami, mail.msg)
316 l = _LOGLEVELS[self.level]
317 ami.logger.log(l, msg)
319 register(ImapamiActionLog)
321 class ImapamiActionPipe(ImapamiAction):
323 Pipe the mail to an external program.
328 what: <string> [default='headers']
329 Define what is sent to the program. Valid values are:
330 'headers': send the headers
331 'body_part1': send the first part of the message
332 'body_all': send all the message body
333 'headers+body_part1': send headers and first part of the message
334 'headers+body_all': send all
335 shell: <boolean> [default=False]
336 Invoke a shell for this command. This allows for instance to use
337 a pipe or a redirection to a file.
340 pipe: {command: 'cat > /tmp/foobar', shell: True}
343 def __init__(self, command, what='headers', shell=False):
344 valid = ['headers', 'body_part1', 'body_all',
345 'headers+body_part1', 'headers+body_all']
346 if not what in valid:
347 raise ValueError("invalid 'what' field. Valid values are: %s" %
349 if what in ['headers']:
351 elif what in ['body_part1', 'headers+body_part1']:
355 ImapamiAction.__init__(self, fetch=fetch)
356 self.command = command
359 def process(self, ami, mail):
360 if self.shell == True:
361 command = self.command
363 command = shlex.split(self.command)
364 process = subprocess.Popen(command,
365 stdin=subprocess.PIPE,
366 stdout=subprocess.PIPE,
367 stderr=subprocess.PIPE,
370 if self.what in ['headers', 'headers+body_part1', 'headers+body_all']:
371 data += str(mail.msg)
372 if self.what in ['body_part1', 'headers+body_part1']:
373 data += mail.body_part1
374 if self.what in ['body_all', 'headers+body_all']:
375 data += mail.body_all
376 stdout, stderr = process.communicate(data)
378 ami.variables["stdout"] = stdout
379 ami.variables["stderr"] = stderr
380 ami.variables["ret"] = ret
382 register(ImapamiActionPipe)
384 class ImapamiActionSetHeader(ImapamiAction):
386 Set or remove header fields in the mail.
388 Dpending on the mode, it is possible to:
389 - append it at the end of the mail headers (mode=add).
390 - remove all occurences of a header (mode=del)
391 - replace the first occurence of a header (mode=replace)
393 As IMAP does not allow to modify a mail, this action creates
394 a modified copy of the original mail, and delete the original
398 <list of commands (see below)>
402 Determine if headers should be added, removed, replaced. Can
403 be 'add', 'del', 'replace'.
404 <key (string)>: <value (string)>
405 The key contains the name of the header field (ex: Subject),
406 associated to the value to be set (None for delete operations).
407 Multiple fields can be specified.
410 set-header: [{mode: del, Message-Id: None},
411 {mode: add, Foo: bar, X-mailer: toto},
412 {mode: replace, Subject: 'subject was: {Subject}'}]
415 def __init__(self, *args):
416 ImapamiAction.__init__(self, fetch="all", terminal=True)
418 if not isinstance(cmd, dict):
419 raise ValueError("set-header command is not a dict")
420 if not cmd.has_key("mode"):
421 raise ValueError("set-header command has no mode")
422 if cmd["mode"] not in ['add', 'del', 'replace']:
423 raise ValueError("invalid mode for set-header command")
426 def process(self, ami, mail):
427 parsed_headers = copy.deepcopy(mail.msghdrs)
428 for cmd in self.cmdlist:
429 mode = cmd.pop("mode")
432 for k, v in fields.iteritems():
433 parsed_headers[k] = v
435 for k, v in fields.iteritems():
436 del parsed_headers[k]
437 elif mode == "replace":
438 for k, v in fields.iteritems():
439 parsed_headers.replace_header(k, v)
441 ami.imap.append(mail.inbox, mail.flags, mail.internal_date,
442 str(parsed_headers) + mail.body_all)
444 register(ImapamiActionSetHeader)
446 class ImapamiSmtpSender(object):
448 A SMTP sender using smtp lib
451 def __init__(self, host, port=None, encryption=None, login=None,
454 Initialize the SMTP sender class
457 Hostname or ip address of the smtp server.
459 The port of the smtp server to connect to.
460 :arg string encryption:
461 Define encryption type for smtp: "none", "ssl", "starttls".
464 User login for smtp server. If no login is specified, assume no
466 :arg string password:
467 Password associated to the login.
471 self.encryption = encryption or "none"
473 self.password = password
475 def send(self, ami, sender, to, mail):
484 The sender of the mail
488 ami.logger.debug("sending to smtp server %s" % self.host)
490 if self.port is not None:
491 args.append(self.port)
492 if self.encryption == "ssl":
493 smtp = smtplib.SMTP_SSL(*args)
495 smtp = smtplib.SMTP(*args)
497 if self.encryption == "starttls":
499 if self.login is not None:
500 smtp.login(self.loging, self.password)
501 smtp.sendmail(sender, to, mail)
503 except smtplib.SMTPAuthenticationError as e:
504 ami.logger.warning("smtp authentication error: %s", str(e))
506 except (smtplib.SMTPException, smtplib.SMTPHeloError,
507 smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused,
508 smtplib.SMTPDataError, RuntimeError) as e:
509 ami.logger.warning("smtp error: %s", str(e))
513 class ImapamiActionForward(ImapamiAction):
517 The mail is forwarded with its attachments. New mail headers
518 are created from scratch. The subject of the mail is prefixed
523 The sender of the mail.
525 The of the forwarded mail.
526 host: <string> (mandatory)
527 Hostname or ip address of the smtp server.
528 port: <integer> (optional)
529 The port of the smtp server to connect to.
530 encryption: <string> (optional)
531 Define encryption type for smtp: "none", "ssl", "starttls".
533 login: <string> (optional)
534 User login for smtp server. If no login is specified, assume no
536 password: <string> (optional)
537 Password associated to the login.
540 forward: {sender: foo@example.com, to: toto@example.com,
541 host: mail.example.com}
544 def __init__(self, sender, to, host, port=None, encryption=None, login=None,
546 ImapamiAction.__init__(self, fetch="all")
549 self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
551 def process(self, ami, mail):
552 sender = self.evaluate(self.sender, ami, mail.msg)
553 to = self.evaluate(self.to, ami, mail.msg)
554 subject = "Fwd: " + self.evaluate('{Subject}', ami, mail.msg)
555 new_mail = MIMEMultipart()
556 new_mail['Date'] = formatdate(localtime=True)
557 new_mail['From'] = sender
559 new_mail['Subject'] = subject
560 new_mail.attach(MIMEMessage(mail.msg))
561 return self.smtp.send(ami, sender, to, new_mail.as_string())
562 register(ImapamiActionForward)
564 class ImapamiActionBounce(ImapamiAction):
568 The mail is transfered with its attachments without modification.
572 The sender of the mail.
574 The of the bounceed mail.
575 host: <string> (mandatory)
576 Hostname or ip address of the smtp server.
577 port: <integer> (optional)
578 The port of the smtp server to connect to.
579 encryption: <string> (optional)
580 Define encryption type for smtp: "none", "ssl", "starttls".
582 login: <string> (optional)
583 User login for smtp server. If no login is specified, assume no
585 password: <string> (optional)
586 Password associated to the login.
589 bounce: {Sender: foo@example.com, To: toto@example.com,
590 host: mail.example.com}
593 def __init__(self, sender, to, host, port=None, encryption=None, login=None,
595 ImapamiAction.__init__(self, fetch="all")
598 self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
600 def process(self, ami, mail):
601 sender = self.evaluate(self.sender, ami, mail.msg)
602 to = self.evaluate(self.to, ami, mail.msg)
603 return self.smtp.send(ami, sender, to, str(mail.msg))
604 register(ImapamiActionBounce)
606 class ImapamiActionSetVar(ImapamiAction):
610 As it does not read/write the mail, this action does not need
611 to fetch the mail by default.
612 If a mail header field is used as a variable in the action
613 parameters, the user must ensure that the mail headers are fetched,
614 for instance by setting the rule paramater 'fetch' to 'headers'.
618 Each key contains the name of the variable to be set,
619 and its value contains the string that should be set. If the
620 value is None, the variable is deleted. Multiple fields can be
624 set-var: {var1: foo@example.com, var2: None}
627 def __init__(self, **kwargs):
628 ImapamiAction.__init__(self)
630 def process(self, ami, mail):
631 for k, v in self.kwargs.iteritems():
632 k = self.evaluate(k, ami, mail.msg)
633 if v is None and ami.variables.get(k) is not None:
636 v = self.evaluate(v, ami, mail.msg)
639 register(ImapamiActionSetVar)
643 Create an action object from its yaml config.
646 The yaml action config.
650 logger = logging.getLogger('imapami')
651 logger.debug("parsing action %s", config)
653 raise ValueError("the action config must be a dictionary whose only "
654 "key is the action name")
655 action_name = config.keys()[0]
658 if isinstance(config[action_name], list):
659 argslist = config[action_name]
660 elif isinstance(config[action_name], dict):
661 argsdict = config[action_name]
662 elif config[action_name] is not None:
663 argslist = [config[action_name]]
664 action_class = _all_actions.get(action_name)
665 if action_class is None:
666 raise ValueError("Invalid action name '%s'" % action_name)
667 logger.debug("new action %s(%s, %s)", action_name, argslist, argsdict)
668 return action_class(*argslist, **argsdict)
672 Return a dictionary containing all the registered actions.