actions: surround flags with parenthesis
[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.uid("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 = '(\\%s)' % self.evaluate(self.flag, ami, mail.msg)
207         ret, msg = imap.uid("STORE", mail.item, cmd, flag)
208         if ret != "OK":
209             ami.logger.warning(
210                 "imap store '%s %s' returned %s: %s" % (
211                     cmd, flag, ret, str(msg)))
212             return False
213         return True
214 register(ImapamiActionChangeFlag)
215
216 class ImapamiActionDelete(ImapamiActionChangeFlag):
217     """
218     Mark a mail as deleted.
219
220     Arguments: None
221
222     Example:
223       deleted: {}
224     """
225     name = "deleted"
226     def __init__(self):
227         ImapamiActionChangeFlag.__init__(self, "Deleted", True)
228         self.terminal = True
229 register(ImapamiActionDelete)
230
231 class ImapamiActionSeen(ImapamiActionChangeFlag):
232     """
233     Mark a mail as seen.
234
235     Arguments: None
236
237     Example:
238       seen: {}
239     """
240     name = "seen"
241     def __init__(self):
242         ImapamiActionChangeFlag.__init__(self, "Seen", True)
243 register(ImapamiActionSeen)
244
245 class ImapamiActionUnseen(ImapamiActionChangeFlag):
246     """
247     Mark a mail as not seen.
248
249     Arguments: None
250
251     Example:
252       unseen: {}
253     """
254     name = "unseen"
255     def __init__(self):
256         ImapamiActionChangeFlag.__init__(self, "Seen", False)
257 register(ImapamiActionUnseen)
258
259 class ImapamiActionMove(ImapamiAction):
260     """
261     Move the mail in another directory.
262
263     Arguments:
264       dest: <string>
265         destination directory
266
267     Example:
268       move: {dest: my_subdir}
269     """
270     name = "move"
271     def __init__(self, dest):
272         ImapamiAction.__init__(self, terminal=True)
273         self.dest = dest
274     def process(self, ami, mail):
275         imap = ami.imap
276         dest = self.evaluate(self.dest, ami, mail.msg)
277         imap.create(dest)
278         ret, msg = imap.uid("COPY", mail.item, dest)
279         if ret != "OK":
280             ami.logger.warning(
281                 "imap copy returned %s: %s" % (ret, str(msg)))
282             return False
283         ret, msg = imap.uid("STORE", mail.item, '+FLAGS', '(\\Deleted)')
284         if ret != "OK":
285             ami.logger.warning(
286                 "imap delete returned %s: %s" % (ret, str(msg)))
287             return False
288         return True
289 register(ImapamiActionMove)
290
291 class ImapamiActionLog(ImapamiAction):
292     """
293     Log a message.
294
295     Arguments:
296       msg: <string>
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.
303
304     Example:
305       log: {msg: 'Mail from {From} is received', level: 3}
306     """
307     name = "log"
308     def __init__(self, msg, level=3):
309         ImapamiAction.__init__(self, fetch="headers")
310         self.msg = msg
311         if not isinstance(level, int) or level < 0 or level > 4:
312             raise ValueError("invalid log level")
313         self.level = 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)
318         return True
319 register(ImapamiActionLog)
320
321 class ImapamiActionPipe(ImapamiAction):
322     """
323     Pipe the mail to an external program.
324
325     Arguments:
326       command: <string>
327         The command to run.
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.
338
339     Example:
340       pipe: {command: 'cat > /tmp/foobar', shell: True}
341     """
342     name = "pipe"
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" %
348                              valid)
349         if what in ['headers']:
350             fetch = "headers"
351         elif what in ['body_part1', 'headers+body_part1']:
352             fetch = "part1"
353         else:
354             fetch = "all"
355         ImapamiAction.__init__(self, fetch=fetch)
356         self.command = command
357         self.what = what
358         self.shell = shell
359     def process(self, ami, mail):
360         if self.shell == True:
361             command = self.command
362         else:
363             command = shlex.split(self.command)
364         process = subprocess.Popen(command,
365                                    stdin=subprocess.PIPE,
366                                    stdout=subprocess.PIPE,
367                                    stderr=subprocess.PIPE,
368                                    shell=self.shell)
369         data = ''
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)
377         ret = process.wait()
378         ami.variables["stdout"] = stdout
379         ami.variables["stderr"] = stderr
380         ami.variables["ret"] = ret
381         return True
382 register(ImapamiActionPipe)
383
384 class ImapamiActionSetHeader(ImapamiAction):
385     """
386     Set or remove header fields in the mail.
387
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)
392
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
395     one.
396
397     Arguments:
398       <list of commands (see below)>
399
400     Format of a command:
401       mode: <string>
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.
408
409     Example:
410       set-header: [{mode: del, Message-Id: None},
411                    {mode: add, Foo: bar, X-mailer: toto},
412                    {mode: replace, Subject: 'subject was: {Subject}'}]
413     """
414     name = "set-header"
415     def __init__(self, *args):
416         ImapamiAction.__init__(self, fetch="all", terminal=True)
417         for cmd in args:
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")
424         self.cmdlist = args
425
426     def process(self, ami, mail):
427         parsed_headers = copy.deepcopy(mail.msghdrs)
428         for cmd in self.cmdlist:
429             mode = cmd.pop("mode")
430             fields = cmd
431             if mode == "add":
432                 for k, v in fields.iteritems():
433                     parsed_headers[k] = v
434             elif mode == "del":
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)
440
441         ami.imap.append(mail.inbox, mail.flags, mail.internal_date,
442                         str(parsed_headers) + mail.body_all)
443         return True
444 register(ImapamiActionSetHeader)
445
446 class ImapamiSmtpSender(object):
447     """
448     A SMTP sender using smtp lib
449     """
450
451     def __init__(self, host, port=None, encryption=None, login=None,
452                  password=None):
453         """
454         Initialize the SMTP sender class
455
456         :arg string host:
457           Hostname or ip address of the smtp server.
458         :arg integer port:
459           The port of the smtp server to connect to.
460         :arg string encryption:
461           Define encryption type for smtp: "none", "ssl", "starttls".
462           Default is "none".
463         :arg string login:
464           User login for smtp server. If no login is specified, assume no
465           login is required.
466         :arg string password:
467           Password associated to the login.
468         """
469         self.host = host
470         self.port = port
471         self.encryption = encryption or "none"
472         self.login = login
473         self.password = password
474
475     def send(self, ami, sender, to, mail):
476         """
477         Send a mail.
478
479         :arg Imapami ami:
480           The Imapami object
481         :arg string mail:
482           The mail to send
483         :arg string sender:
484           The sender of the mail
485         :arg string to:
486           To mail address
487         """
488         ami.logger.debug("sending to smtp server %s" % self.host)
489         args = [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)
494         else:
495             smtp = smtplib.SMTP(*args)
496         try:
497             if self.encryption == "starttls":
498                 smtp.starttls()
499             if self.login is not None:
500                 smtp.login(self.loging, self.password)
501             smtp.sendmail(sender, to, mail)
502             smtp.close()
503         except smtplib.SMTPAuthenticationError as e:
504             ami.logger.warning("smtp authentication error: %s", str(e))
505             return False
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))
510             return False
511         return True
512
513 class ImapamiActionForward(ImapamiAction):
514     """
515     Forward a mail.
516
517     The mail is forwarded with its attachments. New mail headers
518     are created from scratch. The subject of the mail is prefixed
519     with 'Fwd: '.
520
521     Arguments:
522       sender: <string>
523         The sender of the mail.
524       to: <string>
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".
532         Default is none.
533       login: <string> (optional)
534         User login for smtp server. If no login is specified, assume no
535         login is required.
536       password: <string> (optional)
537         Password associated to the login.
538
539     Example:
540       forward: {sender: foo@example.com, to: toto@example.com,
541                 host: mail.example.com}
542     """
543     name = "forward"
544     def __init__(self, sender, to, host, port=None, encryption=None, login=None,
545                  password=None):
546         ImapamiAction.__init__(self, fetch="all")
547         self.sender = sender
548         self.to = to
549         self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
550
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
558         new_mail['To'] = to
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)
563
564 class ImapamiActionBounce(ImapamiAction):
565     """
566     Bounce a mail.
567
568     The mail is transfered with its attachments without modification.
569
570     Arguments:
571       sender: <string>
572         The sender of the mail.
573       to: <string>
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".
581         Default is none.
582       login: <string> (optional)
583         User login for smtp server. If no login is specified, assume no
584         login is required.
585       password: <string> (optional)
586         Password associated to the login.
587
588     Example:
589       bounce: {Sender: foo@example.com, To: toto@example.com,
590                 host: mail.example.com}
591     """
592     name = "bounce"
593     def __init__(self, sender, to, host, port=None, encryption=None, login=None,
594                  password=None):
595         ImapamiAction.__init__(self, fetch="all")
596         self.sender = sender
597         self.to = to
598         self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
599
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)
605
606 class ImapamiActionSetVar(ImapamiAction):
607     """
608     Set a variable.
609
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'.
615
616     Arguments:
617       <var name>: <string>
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
621         specified.
622
623     Example:
624       set-var: {var1: foo@example.com, var2: None}
625     """
626     name = "set-var"
627     def __init__(self, **kwargs):
628         ImapamiAction.__init__(self)
629         self.kwargs = kwargs
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:
634                 ami.variables.pop(k)
635             else:
636                 v = self.evaluate(v, ami, mail.msg)
637                 ami.variables[k] = v
638         return True
639 register(ImapamiActionSetVar)
640
641 def new(config):
642     """
643     Create an action object from its yaml config.
644
645     :arg string config:
646       The yaml action config.
647     :returns:
648       The action object.
649     """
650     logger = logging.getLogger('imapami')
651     logger.debug("parsing action %s", config)
652     if len(config) != 1:
653         raise ValueError("the action config must be a dictionary whose only "
654                          "key is the action name")
655     action_name = config.keys()[0]
656     argslist = []
657     argsdict = {}
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)
669
670 def get():
671     """
672     Return a dictionary containing all the registered actions.
673     """
674     return _all_actions