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