First public revision
[imapami.git] / imapami / actions.py
1 #!/usr/bin/env python
2
3 #
4 # Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
5 #
6 # Redistribution and use in source and binary forms, with or without
7 # modification, are permitted provided that the following conditions are met:
8 #
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.
17 #
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.
28 #
29
30 import imapami.utils
31
32 import copy
33 from email.mime.multipart import MIMEMultipart
34 from email.mime.message import MIMEMessage
35 from email.utils import formatdate
36 import logging
37 import shlex
38 import smtplib
39 import subprocess
40
41 _all_actions = {}
42 _LOGLEVELS = [logging.CRITICAL, logging.ERROR, logging.WARNING,
43               logging.INFO, logging.DEBUG]
44
45 def register(action_class):
46     """
47     Register an action class
48     """
49     if not issubclass(action_class, ImapamiAction):
50         raise ValueError('"%s" action is not a subclass of ImapamiAction' %
51                          action_class)
52     if action_class.name in _all_actions:
53         raise ValueError('"%s" action is already registered' %
54                          action_class.name)
55     _all_actions[action_class.name] = action_class
56
57 class ImapamiAction(object):
58     """
59     This is the parent class for actions.
60
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
64     script...
65     """
66     name = None
67
68     def __init__(self, fetch=None, terminal=False):
69         """
70         Generic action constructor.
71         :arg str fetch:
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)
77         """
78         self.fetch = fetch or "no"
79         self.terminal = terminal
80
81     def evaluate(self, arg, ami, hdrs):
82         """
83         Evaluate a string argument
84
85         Replace variables and headers in arg by their values and
86         return it.
87
88         :arg string arg:
89           The argument to be evaluated
90         :arg Imapami ami:
91           The imapami object, containing the variables
92         :arg email.message.Message hdrs:
93           The headers of the mail, or None if not available
94         :returns:
95           The evaluated argument.
96         """
97         if hdrs is not None:
98             variables = imapami.utils.headers_to_unicode(hdrs)
99         else:
100             variables = {}
101         variables.update(ami.variables)
102         fmt = imapami.utils.VarFormatter()
103         arg = fmt.format(unicode(arg), **variables)
104         return arg
105
106     def process(self, imap, mail):
107         """
108         Process the action
109
110         :arg Imapami ami:
111           The Imapami object
112         :arg ImapamiMail mail:
113           The mail data
114         :returns:
115           True on success, False on error
116         """
117         return True
118
119 class ImapamiActionList(ImapamiAction):
120     """
121     Execute a list of actions.
122
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
125     a list of actions.
126
127     Arguments:
128       List of actions
129
130     Example:
131       list:
132       - copy: {dest: archive}
133       - move: {dest: mbox}
134     """
135     name = "list"
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):
147         ret = True
148         for a in self.action_list:
149             if a.process(ami, mail) == False:
150                 ret = False
151         return ret
152 register(ImapamiActionList)
153
154 class ImapamiActionCopy(ImapamiAction):
155     """
156     Copy the mail in another directory.
157
158     Arguments:
159       dest: <string>
160         destination directory
161
162     Example:
163       copy: {dest: my_subdir}
164     """
165     name = "copy"
166     def __init__(self, dest):
167         ImapamiAction.__init__(self)
168         self.dest = dest
169     def process(self, ami, mail):
170         imap = ami.imap
171         dest = self.evaluate(self.dest, ami, mail.msg)
172         imap.create(dest)
173         ret, msg = imap.copy(mail.item, dest)
174         if ret != "OK":
175             ami.logger.warning(
176                 "imap copy returned %s: %s" % (ret, str(msg)))
177             return False
178         return True
179 register(ImapamiActionCopy)
180
181 class ImapamiActionChangeFlag(ImapamiAction):
182     """
183     Change an IMAP flag of a mail.
184
185     Arguments:
186       flag: <string>
187         Name of the IMAP flag (ex: 'Seen', 'Answered', 'Deleted', ...)
188         Refer to RFC3501 for details.
189       enable: <boolean>
190         True to set the flag, False to reset it.
191
192     Example:
193       change-flag: {flag: Seen, enable: True}
194     """
195     name = "change-flag"
196     def __init__(self, flag, enable):
197         ImapamiAction.__init__(self)
198         self.enable = enable
199         self.flag = flag
200     def process(self, ami, mail):
201         imap = ami.imap
202         if self.enable == True:
203             cmd = '+FLAGS'
204         else:
205             cmd = '-FLAGS'
206         flag = '\\' + self.evaluate(self.flag, ami,
207                                     mail.msg)
208         ret, msg = imap.store(mail.item, cmd, flag)
209         if ret != "OK":
210             ami.logger.warning(
211                 "imap store '%s %s' returned %s: %s" % (
212                     cmd, flag, ret, str(msg)))
213             return False
214         return True
215 register(ImapamiActionChangeFlag)
216
217 class ImapamiActionDelete(ImapamiActionChangeFlag):
218     """
219     Mark a mail as deleted.
220
221     Arguments: None
222
223     Example:
224       deleted: {}
225     """
226     name = "deleted"
227     def __init__(self):
228         ImapamiActionChangeFlag.__init__(self, "Deleted", True)
229         self.terminal = True
230 register(ImapamiActionDelete)
231
232 class ImapamiActionSeen(ImapamiActionChangeFlag):
233     """
234     Mark a mail as seen.
235
236     Arguments: None
237
238     Example:
239       seen: {}
240     """
241     name = "seen"
242     def __init__(self):
243         ImapamiActionChangeFlag.__init__(self, "Seen", True)
244 register(ImapamiActionSeen)
245
246 class ImapamiActionUnseen(ImapamiActionChangeFlag):
247     """
248     Mark a mail as not seen.
249
250     Arguments: None
251
252     Example:
253       unseen: {}
254     """
255     name = "unseen"
256     def __init__(self):
257         ImapamiActionChangeFlag.__init__(self, "Seen", False)
258 register(ImapamiActionUnseen)
259
260 class ImapamiActionMove(ImapamiAction):
261     """
262     Move the mail in another directory.
263
264     Arguments:
265       dest: <string>
266         destination directory
267
268     Example:
269       move: {dest: my_subdir}
270     """
271     name = "move"
272     def __init__(self, dest):
273         ImapamiAction.__init__(self, terminal=True)
274         self.dest = dest
275     def process(self, ami, mail):
276         ret = ImapamiActionCopy.process(
277             self, ami, mail)
278         if ret == False:
279             return False
280         ret = ImapamiActionDelete.process(
281             self, ami, mail)
282         if ret == False:
283             return False
284         return True
285 register(ImapamiActionMove)
286
287 class ImapamiActionLog(ImapamiAction):
288     """
289     Log a message.
290
291     Arguments:
292       msg: <string>
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.
299
300     Example:
301       log: {msg: 'Mail from {From} is received', level: 3}
302     """
303     name = "log"
304     def __init__(self, msg, level=3):
305         ImapamiAction.__init__(self, fetch="headers")
306         self.msg = msg
307         if not isinstance(level, int) or level < 0 or level > 4:
308             raise ValueError("invalid log level")
309         self.level = 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)
314         return True
315 register(ImapamiActionLog)
316
317 class ImapamiActionPipe(ImapamiAction):
318     """
319     Pipe the mail to an external program.
320
321     Arguments:
322       command: <string>
323         The command to run.
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.
334
335     Example:
336       pipe: {command: 'cat > /tmp/foobar', shell: True}
337     """
338     name = "pipe"
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" %
344                              valid)
345         if what in ['headers']:
346             fetch = "headers"
347         elif what in ['body_part1', 'headers+body_part1']:
348             fetch = "part1"
349         else:
350             fetch = "all"
351         ImapamiAction.__init__(self, fetch=fetch)
352         self.command = command
353         self.what = what
354         self.shell = shell
355     def process(self, ami, mail):
356         if self.shell == True:
357             command = self.command
358         else:
359             command = shlex.split(self.command)
360         process = subprocess.Popen(command,
361                                    stdin=subprocess.PIPE,
362                                    stdout=subprocess.PIPE,
363                                    stderr=subprocess.PIPE,
364                                    shell=self.shell)
365         data = ''
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)
373         ret = process.wait()
374         ami.variables["stdout"] = stdout
375         ami.variables["stderr"] = stderr
376         ami.variables["ret"] = ret
377         return True
378 register(ImapamiActionPipe)
379
380 class ImapamiActionSetHeader(ImapamiAction):
381     """
382     Set or remove header fields in the mail.
383
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)
388
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
391     one.
392
393     Arguments:
394       <list of commands (see below)>
395
396     Format of a command:
397       mode: <string>
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.
404
405     Example:
406       set-header: [{mode: del, Message-Id: None},
407                    {mode: add, Foo: bar, X-mailer: toto},
408                    {mode: replace, Subject: 'subject was: {Subject}'}]
409     """
410     name = "set-header"
411     def __init__(self, *args):
412         ImapamiAction.__init__(self, fetch="all", terminal=True)
413         for cmd in args:
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")
420         self.cmdlist = args
421
422     def process(self, ami, mail):
423         parsed_headers = copy.deepcopy(mail.msghdrs)
424         for cmd in self.cmdlist:
425             mode = cmd.pop("mode")
426             fields = cmd
427             if mode == "add":
428                 for k, v in fields.iteritems():
429                     parsed_headers[k] = v
430             elif mode == "del":
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)
436
437         ami.imap.append(mail.inbox, mail.flags, mail.internal_date,
438                         str(parsed_headers) + mail.body_all)
439         return True
440 register(ImapamiActionSetHeader)
441
442 class ImapamiSmtpSender(object):
443     """
444     A SMTP sender using smtp lib
445     """
446
447     def __init__(self, host, port=None, encryption=None, login=None,
448                  password=None):
449         """
450         Initialize the SMTP sender class
451
452         :arg string host:
453           Hostname or ip address of the smtp server.
454         :arg integer port:
455           The port of the smtp server to connect to.
456         :arg string encryption:
457           Define encryption type for smtp: "none", "ssl", "starttls".
458           Default is "none".
459         :arg string login:
460           User login for smtp server. If no login is specified, assume no
461           login is required.
462         :arg string password:
463           Password associated to the login.
464         """
465         self.host = host
466         self.port = port
467         self.encryption = encryption or "none"
468         self.login = login
469         self.password = password
470
471     def send(self, ami, sender, to, mail):
472         """
473         Send a mail.
474
475         :arg Imapami ami:
476           The Imapami object
477         :arg string mail:
478           The mail to send
479         :arg string sender:
480           The sender of the mail
481         :arg string to:
482           To mail address
483         """
484         ami.logger.debug("sending to smtp server %s" % self.host)
485         args = [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)
490         else:
491             smtp = smtplib.SMTP(*args)
492         try:
493             if self.encryption == "starttls":
494                 smtp.starttls()
495             if self.login is not None:
496                 smtp.login(self.loging, self.password)
497             smtp.sendmail(sender, to, mail)
498             smtp.close()
499         except smtplib.SMTPAuthenticationError as e:
500             ami.logger.warning("smtp authentication error: %s", str(e))
501             return False
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))
506             return False
507         return True
508
509 class ImapamiActionForward(ImapamiAction):
510     """
511     Forward a mail.
512
513     The mail is forwarded with its attachments. New mail headers
514     are created from scratch. The subject of the mail is prefixed
515     with 'Fwd: '.
516
517     Arguments:
518       sender: <string>
519         The sender of the mail.
520       to: <string>
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".
528         Default is none.
529       login: <string> (optional)
530         User login for smtp server. If no login is specified, assume no
531         login is required.
532       password: <string> (optional)
533         Password associated to the login.
534
535     Example:
536       forward: {sender: foo@example.com, to: toto@example.com,
537                 host: mail.example.com}
538     """
539     name = "forward"
540     def __init__(self, sender, to, host, port=None, encryption=None, login=None,
541                  password=None):
542         ImapamiAction.__init__(self, fetch="all")
543         self.sender = sender
544         self.to = to
545         self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
546
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
554         new_mail['To'] = to
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)
559
560 class ImapamiActionBounce(ImapamiAction):
561     """
562     Bounce a mail.
563
564     The mail is transfered with its attachments without modification.
565
566     Arguments:
567       sender: <string>
568         The sender of the mail.
569       to: <string>
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".
577         Default is none.
578       login: <string> (optional)
579         User login for smtp server. If no login is specified, assume no
580         login is required.
581       password: <string> (optional)
582         Password associated to the login.
583
584     Example:
585       bounce: {Sender: foo@example.com, To: toto@example.com,
586                 host: mail.example.com}
587     """
588     name = "bounce"
589     def __init__(self, sender, to, host, port=None, encryption=None, login=None,
590                  password=None):
591         ImapamiAction.__init__(self, fetch="all")
592         self.sender = sender
593         self.to = to
594         self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
595
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)
601
602 class ImapamiActionSetVar(ImapamiAction):
603     """
604     Set a variable.
605
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'.
611
612     Arguments:
613       <var name>: <string>
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
617         specified.
618
619     Example:
620       set-var: {var1: foo@example.com, var2: None}
621     """
622     name = "set-var"
623     def __init__(self, **kwargs):
624         ImapamiAction.__init__(self)
625         self.kwargs = kwargs
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:
630                 ami.variables.pop(k)
631             else:
632                 v = self.evaluate(v, ami, mail.msg)
633                 ami.variables[k] = v
634         return True
635 register(ImapamiActionSetVar)
636
637 def new(config):
638     """
639     Create an action object from its yaml config.
640
641     :arg string config:
642       The yaml action config.
643     :returns:
644       The action object.
645     """
646     logger = logging.getLogger('imapami')
647     logger.debug("parsing action %s", config)
648     if len(config) != 1:
649         raise ValueError("the action config must be a dictionary whose only "
650                          "key is the action name")
651     action_name = config.keys()[0]
652     argslist = []
653     argsdict = {}
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)
665
666 def get():
667     """
668     Return a dictionary containing all the registered actions.
669     """
670     return _all_actions