actions: fix move
[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         imap = ami.imap
277         dest = self.evaluate(self.dest, ami, mail.msg)
278         imap.create(dest)
279         ret, msg = imap.copy(mail.item, dest)
280         if ret != "OK":
281             ami.logger.warning(
282                 "imap copy returned %s: %s" % (ret, str(msg)))
283             return False
284         ret, msg = imap.store(mail.item, '+FLAGS', '\\Deleted')
285         if ret != "OK":
286             ami.logger.warning(
287                 "imap store '%s %s' returned %s: %s" % (
288                     cmd, flag, ret, str(msg)))
289             return False
290         return True
291 register(ImapamiActionMove)
292
293 class ImapamiActionLog(ImapamiAction):
294     """
295     Log a message.
296
297     Arguments:
298       msg: <string>
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.
305
306     Example:
307       log: {msg: 'Mail from {From} is received', level: 3}
308     """
309     name = "log"
310     def __init__(self, msg, level=3):
311         ImapamiAction.__init__(self, fetch="headers")
312         self.msg = msg
313         if not isinstance(level, int) or level < 0 or level > 4:
314             raise ValueError("invalid log level")
315         self.level = 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)
320         return True
321 register(ImapamiActionLog)
322
323 class ImapamiActionPipe(ImapamiAction):
324     """
325     Pipe the mail to an external program.
326
327     Arguments:
328       command: <string>
329         The command to run.
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.
340
341     Example:
342       pipe: {command: 'cat > /tmp/foobar', shell: True}
343     """
344     name = "pipe"
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" %
350                              valid)
351         if what in ['headers']:
352             fetch = "headers"
353         elif what in ['body_part1', 'headers+body_part1']:
354             fetch = "part1"
355         else:
356             fetch = "all"
357         ImapamiAction.__init__(self, fetch=fetch)
358         self.command = command
359         self.what = what
360         self.shell = shell
361     def process(self, ami, mail):
362         if self.shell == True:
363             command = self.command
364         else:
365             command = shlex.split(self.command)
366         process = subprocess.Popen(command,
367                                    stdin=subprocess.PIPE,
368                                    stdout=subprocess.PIPE,
369                                    stderr=subprocess.PIPE,
370                                    shell=self.shell)
371         data = ''
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)
379         ret = process.wait()
380         ami.variables["stdout"] = stdout
381         ami.variables["stderr"] = stderr
382         ami.variables["ret"] = ret
383         return True
384 register(ImapamiActionPipe)
385
386 class ImapamiActionSetHeader(ImapamiAction):
387     """
388     Set or remove header fields in the mail.
389
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)
394
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
397     one.
398
399     Arguments:
400       <list of commands (see below)>
401
402     Format of a command:
403       mode: <string>
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.
410
411     Example:
412       set-header: [{mode: del, Message-Id: None},
413                    {mode: add, Foo: bar, X-mailer: toto},
414                    {mode: replace, Subject: 'subject was: {Subject}'}]
415     """
416     name = "set-header"
417     def __init__(self, *args):
418         ImapamiAction.__init__(self, fetch="all", terminal=True)
419         for cmd in args:
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")
426         self.cmdlist = args
427
428     def process(self, ami, mail):
429         parsed_headers = copy.deepcopy(mail.msghdrs)
430         for cmd in self.cmdlist:
431             mode = cmd.pop("mode")
432             fields = cmd
433             if mode == "add":
434                 for k, v in fields.iteritems():
435                     parsed_headers[k] = v
436             elif mode == "del":
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)
442
443         ami.imap.append(mail.inbox, mail.flags, mail.internal_date,
444                         str(parsed_headers) + mail.body_all)
445         return True
446 register(ImapamiActionSetHeader)
447
448 class ImapamiSmtpSender(object):
449     """
450     A SMTP sender using smtp lib
451     """
452
453     def __init__(self, host, port=None, encryption=None, login=None,
454                  password=None):
455         """
456         Initialize the SMTP sender class
457
458         :arg string host:
459           Hostname or ip address of the smtp server.
460         :arg integer port:
461           The port of the smtp server to connect to.
462         :arg string encryption:
463           Define encryption type for smtp: "none", "ssl", "starttls".
464           Default is "none".
465         :arg string login:
466           User login for smtp server. If no login is specified, assume no
467           login is required.
468         :arg string password:
469           Password associated to the login.
470         """
471         self.host = host
472         self.port = port
473         self.encryption = encryption or "none"
474         self.login = login
475         self.password = password
476
477     def send(self, ami, sender, to, mail):
478         """
479         Send a mail.
480
481         :arg Imapami ami:
482           The Imapami object
483         :arg string mail:
484           The mail to send
485         :arg string sender:
486           The sender of the mail
487         :arg string to:
488           To mail address
489         """
490         ami.logger.debug("sending to smtp server %s" % self.host)
491         args = [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)
496         else:
497             smtp = smtplib.SMTP(*args)
498         try:
499             if self.encryption == "starttls":
500                 smtp.starttls()
501             if self.login is not None:
502                 smtp.login(self.loging, self.password)
503             smtp.sendmail(sender, to, mail)
504             smtp.close()
505         except smtplib.SMTPAuthenticationError as e:
506             ami.logger.warning("smtp authentication error: %s", str(e))
507             return False
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))
512             return False
513         return True
514
515 class ImapamiActionForward(ImapamiAction):
516     """
517     Forward a mail.
518
519     The mail is forwarded with its attachments. New mail headers
520     are created from scratch. The subject of the mail is prefixed
521     with 'Fwd: '.
522
523     Arguments:
524       sender: <string>
525         The sender of the mail.
526       to: <string>
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".
534         Default is none.
535       login: <string> (optional)
536         User login for smtp server. If no login is specified, assume no
537         login is required.
538       password: <string> (optional)
539         Password associated to the login.
540
541     Example:
542       forward: {sender: foo@example.com, to: toto@example.com,
543                 host: mail.example.com}
544     """
545     name = "forward"
546     def __init__(self, sender, to, host, port=None, encryption=None, login=None,
547                  password=None):
548         ImapamiAction.__init__(self, fetch="all")
549         self.sender = sender
550         self.to = to
551         self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
552
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
560         new_mail['To'] = to
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)
565
566 class ImapamiActionBounce(ImapamiAction):
567     """
568     Bounce a mail.
569
570     The mail is transfered with its attachments without modification.
571
572     Arguments:
573       sender: <string>
574         The sender of the mail.
575       to: <string>
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".
583         Default is none.
584       login: <string> (optional)
585         User login for smtp server. If no login is specified, assume no
586         login is required.
587       password: <string> (optional)
588         Password associated to the login.
589
590     Example:
591       bounce: {Sender: foo@example.com, To: toto@example.com,
592                 host: mail.example.com}
593     """
594     name = "bounce"
595     def __init__(self, sender, to, host, port=None, encryption=None, login=None,
596                  password=None):
597         ImapamiAction.__init__(self, fetch="all")
598         self.sender = sender
599         self.to = to
600         self.smtp = ImapamiSmtpSender(host, port, encryption, login, password)
601
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)
607
608 class ImapamiActionSetVar(ImapamiAction):
609     """
610     Set a variable.
611
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'.
617
618     Arguments:
619       <var name>: <string>
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
623         specified.
624
625     Example:
626       set-var: {var1: foo@example.com, var2: None}
627     """
628     name = "set-var"
629     def __init__(self, **kwargs):
630         ImapamiAction.__init__(self)
631         self.kwargs = kwargs
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:
636                 ami.variables.pop(k)
637             else:
638                 v = self.evaluate(v, ami, mail.msg)
639                 ami.variables[k] = v
640         return True
641 register(ImapamiActionSetVar)
642
643 def new(config):
644     """
645     Create an action object from its yaml config.
646
647     :arg string config:
648       The yaml action config.
649     :returns:
650       The action object.
651     """
652     logger = logging.getLogger('imapami')
653     logger.debug("parsing action %s", config)
654     if len(config) != 1:
655         raise ValueError("the action config must be a dictionary whose only "
656                          "key is the action name")
657     action_name = config.keys()[0]
658     argslist = []
659     argsdict = {}
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)
671
672 def get():
673     """
674     Return a dictionary containing all the registered actions.
675     """
676     return _all_actions