First public revision
[imapami.git] / imapami / conditions.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 logging
33 import re
34
35 _all_conditions = {}
36
37 def register(cond_class):
38     """
39     Register a condition class
40     """
41     if not issubclass(cond_class, ImapamiCond):
42         raise ValueError('"%s" condition is not a subclass of ImapamiCond' %
43                          cond_class)
44     if cond_class.name in _all_conditions:
45         raise ValueError('"%s" condition is already registered' %
46                          cond_class.name)
47     _all_conditions[cond_class.name] = cond_class
48
49 class ImapamiCond(object):
50     """
51     This is the parent class for conditions.
52
53     A condition is a test performed on the mails located in a
54     specific mailbox directory. For instance, a condition can check
55     the sender, the subject, the date of a mail, or the content of
56     a variable.
57     """
58     def __init__(self, fetch=None, criteria=None):
59         """
60         Generic condition constructor.
61
62         :arg str fetch:
63           Set to "headers", "part1", "all" if it is needed to fetch
64           the header, the body or the attachments of the mail
65         :arg set() criteria:
66           IMAP criteria for IMAP search command. Ex: set(["UNSEEN"])
67         """
68         self.fetch = fetch or "no"
69         self.criteria = criteria or set()
70
71     def evaluate(self, arg, ami, hdrs):
72         """
73         Evaluate a string argument
74
75         Replace variables and headers in arg by their values and
76         return it.
77
78         :arg string arg:
79           The argument to be evaluated
80         :arg Imapami ami:
81           The imapami object, containing the variables
82         :arg email.message.Message hdrs:
83           The headers of the mail, or None if not available
84         :returns:
85           The evaluated argument.
86         """
87         if hdrs is not None:
88             variables = imapami.utils.headers_to_unicode(hdrs)
89         else:
90             variables = {}
91         variables.update(ami.variables)
92         fmt = imapami.utils.VarFormatter()
93         arg = fmt.format(unicode(arg), **variables)
94         return arg
95
96     def check(self, ami, mail):
97         """
98         Check the condition.
99
100         :arg Imapami ami:
101           The Imapami object
102         :arg ImapamiMail mail:
103           The mail data
104         :returns:
105           True if the condition matches, else False.
106         """
107         return True
108
109     def get_criteria(self):
110         """
111         Get the list of IMAP criteria that are passed to the server inside
112         the IMAP search command.
113         The caller will evaluate the variables, so it's not to be done in the
114         ImapamiCond class.
115         """
116         return " ".join([str(c) for c in self.criteria])
117
118 class ImapamiCondNot(ImapamiCond):
119     """
120     Invert the result of a condition.
121
122     Arguments:
123       <condition>
124
125     Example:
126       not: {from: foo@example.com}
127     """
128     name = "not"
129     def __init__(self, cond):
130         cond = new(cond)
131         ImapamiCond.__init__(self, fetch=cond.fetch, criteria=cond.criteria)
132         self.cond = cond
133
134     def check(self, ami, mail):
135         return not self.cond.check(ami, mail)
136 register(ImapamiCondNot)
137
138 class ImapamiCondUnseen(ImapamiCond):
139     """
140     Match if a mail is not marked as seen.
141
142     This condition does not require to fetch the mail as it can
143     be filtered by the IMAP server.
144
145     Arguments:
146       None
147
148     Example:
149       unseen: {}
150     """
151     name = "unseen"
152     def __init__(self):
153         ImapamiCond.__init__(self, criteria=set(["UNSEEN"]))
154 register(ImapamiCondUnseen)
155
156 class ImapamiCondFrom(ImapamiCond):
157     """
158     Match if a 'From' field contains the specified substring.
159
160     This condition does not require to fetch the mail as it can
161     be filtered by the IMAP server.
162
163     Arguments:
164       substr: <string>
165         The substring that should be included in the 'From' header
166         to match.
167
168     Example:
169       from: {substr: foo@example.com}
170     """
171     name = "from"
172     def __init__(self, substr):
173         ImapamiCond.__init__(self, criteria=set(['FROM "%s"' % substr]))
174 register(ImapamiCondFrom)
175
176 class ImapamiCondSubject(ImapamiCond):
177     """
178     Match if a 'Subject' field contains the specified substring.
179
180     This condition does not require to fetch the mail as it can
181     be filtered by the IMAP server.
182
183     Arguments:
184       substr: <string>
185         The substring that should be included in the 'Subject' header
186         to match.
187
188     Example:
189       subject: {substr: foo@example.com}
190     """
191     name = "subject"
192     def __init__(self, substr):
193         ImapamiCond.__init__(self, criteria=set(['SUBJECT "%s"' % substr]))
194 register(ImapamiCondSubject)
195
196 class ImapamiCondTo(ImapamiCond):
197     """
198     Match if a 'To' field contains the specified substring.
199
200     This condition does not require to fetch the mail as it can
201     be filtered by the IMAP server.
202
203     Arguments:
204       substr: <string>
205         The substring that should be included in the 'To' header
206         to match.
207
208     Example:
209       to: {substr: foo@example.com}
210     """
211     name = "to"
212     def __init__(self, substr):
213         ImapamiCond.__init__(self, criteria=set(['TO "%s"' % substr]))
214 register(ImapamiCondTo)
215
216 class ImapamiCondCc(ImapamiCond):
217     """
218     Match if a 'Cc' field contains the specified substring.
219
220     This condition does not require to fetch the mail as it can
221     be filtered by the IMAP server.
222
223     Arguments:
224       substr: <string>
225         The substring that should be included in the 'Cc' header
226         to match.
227
228     Example:
229       cc: {substr: foo@example.com}
230     """
231     name = "cc"
232     def __init__(self, substr):
233         ImapamiCond.__init__(self, criteria=set(['CC "%s"' % substr]))
234 register(ImapamiCondCc)
235
236 class ImapamiCondBcc(ImapamiCond):
237     """
238     Match if a 'Bcc' field contains the specified substring.
239
240     This condition does not require to fetch the mail as it can
241     be filtered by the IMAP server.
242
243     Arguments:
244       substr: <string>
245         The substring that should be included in the 'Bcc' header
246         to match.
247
248     Example:
249       bcc: {substr: foo@example.com}
250     """
251     name = "bcc"
252     def __init__(self, substr):
253         ImapamiCond.__init__(self, criteria=set(['BCC "%s"' % substr]))
254 register(ImapamiCondBcc)
255
256 class ImapamiCondBody(ImapamiCond):
257     """
258     Match if the body of the message contains the specified substring.
259
260     This condition does not require to fetch the mail as it can
261     be filtered by the IMAP server.
262
263     Arguments:
264       substr: <string>
265         The substring that should be included in the 'Body' header
266         to match.
267
268     Example:
269       body: {substr: foobar}
270     """
271     name = "body"
272     def __init__(self, substr):
273         ImapamiCond.__init__(self, criteria=set(['BODY "%s"' % substr]))
274 register(ImapamiCondBody)
275
276 class ImapamiCondSince(ImapamiCond):
277     """
278     Match if the message has been sent since the specified date.
279
280     Match for messages whose internal date (disregarding time and timezone)
281     is within or later than the specified date.
282
283     This condition does not require to fetch the mail as it can
284     be filtered by the IMAP server.
285
286     Arguments:
287       date: <string>
288         The reference date. The format is day-month-year, with:
289         day = 2 digits, month = 3 letter string (Jan, Feb, Mar, Apr,
290         May, Jun, Jul, Aug, Sep, Oct, Nov, Dec), year = 4 digits.
291
292     Example:
293       since: {date: 28-Oct-2015}
294     """
295     name = "since"
296     def __init__(self, date):
297         ImapamiCond.__init__(self, criteria=set(['SINCE "%s"' % date]))
298 register(ImapamiCondSince)
299
300 class ImapamiCondSentBefore(ImapamiCond):
301     """
302     Match if 'Date' header is earlier than the specified date (disregarding
303     time and timezone).
304
305     This condition does not require to fetch the mail as it can
306     be filtered by the IMAP server.
307
308     Arguments:
309       sent-before: <string>
310         The reference date. The format is day-month-year, with:
311         day = 2 digits, month = 3 letter string (Jan, Feb, Mar, Apr,
312         May, Jun, Jul, Aug, Sep, Oct, Nov, Dec), year = 4 digits.
313
314     Example:
315       sent-before: {date: 28-Oct-2015}
316     """
317     name = "sent-before"
318     def __init__(self, date):
319         ImapamiCond.__init__(self, criteria=set(['SENTBEFORE "%s"' % date]))
320 register(ImapamiCondSentBefore)
321
322 class ImapamiCondSentOn(ImapamiCond):
323     """
324     Match if 'Date' header is within the specified date (disregarding
325     time and timezone).
326
327     This condition does not require to fetch the mail as it can
328     be filtered by the IMAP server.
329
330     Arguments:
331       sent-on: <string>
332         The reference date. The format is day-month-year, with:
333         day = 2 digits, month = 3 letter string (Jan, Feb, Mar, Apr,
334         May, Jun, Jul, Aug, Sep, Oct, Nov, Dec), year = 4 digits.
335
336     Example:
337       sent-on: {date: 28-Oct-2015}
338     """
339     name = "sent-on"
340     def __init__(self, date):
341         ImapamiCond.__init__(self, criteria=set(['SENTON "%s"' % date]))
342 register(ImapamiCondSentOn)
343
344 class ImapamiCondSentSince(ImapamiCond):
345     """
346     Match if 'Date' header is within or later than the specified date
347     (disregarding time and timezone).
348
349     This condition does not require to fetch the mail as it can
350     be filtered by the IMAP server.
351
352     Arguments:
353       sent-since: <string>
354         The reference date. The format is day-month-year, with:
355         day = 2 digits, month = 3 letter string (Jan, Feb, Mar, Apr,
356         May, Jun, Jul, Aug, Sep, Oct, Nov, Dec), year = 4 digits.
357
358     Example:
359       sent-since: {date: 28-Oct-2015}
360     """
361     name = "sent-since"
362     def __init__(self, date):
363         ImapamiCond.__init__(self, criteria=set(['SENTSINCE "%s"' % date]))
364 register(ImapamiCondSentSince)
365
366 class ImapamiCondRecent(ImapamiCond):
367     """
368     Match if the mail is marked as recent
369
370     This condition does not require to fetch the mail as it can
371     be filtered by the IMAP server.
372
373     Arguments:
374       None
375
376     Example:
377       recent: {}
378     """
379     name = "recent"
380     def __init__(self, substr):
381         ImapamiCond.__init__(self, criteria=set(['RECENT']))
382 register(ImapamiCondRecent)
383
384 class ImapamiCondAnswered(ImapamiCond):
385     """
386     Match if the mail is not marked as answered.
387
388     This condition does not require to fetch the mail as it can
389     be filtered by the IMAP server.
390
391     Arguments:
392       None
393
394     Example:
395       answered: {}
396     """
397     name = "answered"
398     def __init__(self, substr):
399         ImapamiCond.__init__(self, criteria=set(['ANSWERED']))
400 register(ImapamiCondAnswered)
401
402 class ImapamiCondUnanswered(ImapamiCond):
403     """
404     Match if the mail is not marked as answered.
405
406     This condition does not require to fetch the mail as it can
407     be filtered by the IMAP server.
408
409     Arguments:
410       None
411
412     Example:
413       unanswered: {}
414     """
415     name = "unanswered"
416     def __init__(self, substr):
417         ImapamiCond.__init__(self, criteria=set(['UNANSWERED']))
418 register(ImapamiCondUnanswered)
419
420 class ImapamiCondFlagged(ImapamiCond):
421     """
422     Match if the message is flagged.
423
424     This condition does not require to fetch the mail as it can
425     be filtered by the IMAP server.
426
427     Arguments:
428       None
429
430     Example:
431       flagged: {}
432     """
433     name = "flagged"
434     def __init__(self, substr):
435         ImapamiCond.__init__(self, criteria=set(['FLAGGED']))
436 register(ImapamiCondFlagged)
437
438 class ImapamiCondUnflagged(ImapamiCond):
439     """
440     Match if the message is not flagged.
441
442     This condition does not require to fetch the mail as it can
443     be filtered by the IMAP server.
444
445     Arguments:
446       None
447
448     Example:
449       unflagged: {}
450     """
451     name = "unflagged"
452     def __init__(self, substr):
453         ImapamiCond.__init__(self, criteria=set(['UNFLAGGED']))
454 register(ImapamiCondUnflagged)
455
456 class ImapamiCondDraft(ImapamiCond):
457     """
458     Match if the message is marked as draft.
459
460     This condition does not require to fetch the mail as it can
461     be filtered by the IMAP server.
462
463     Arguments:
464       None
465
466     Example:
467       draft: {}
468     """
469     name = "draft"
470     def __init__(self, substr):
471         ImapamiCond.__init__(self, criteria=set(['DRAFT']))
472 register(ImapamiCondDraft)
473
474 class ImapamiCondUndraft(ImapamiCond):
475     """
476     Match if the message is not marked as draft.
477
478     This condition does not require to fetch the mail as it can
479     be filtered by the IMAP server.
480
481     Arguments:
482       None
483
484     Example:
485       undraft: {}
486     """
487     name = "undraft"
488     def __init__(self, substr):
489         ImapamiCond.__init__(self, criteria=set(['UNDRAFT']))
490 register(ImapamiCondUndraft)
491
492 class ImapamiCondKeyword(ImapamiCond):
493     """
494     Match if the messages has the specified keyword flag set.
495
496     This condition does not require to fetch the mail as it can
497     be filtered by the IMAP server.
498
499     Arguments:
500       key: <string>
501         The keyword string.
502
503     Example:
504       keyword: {key: important}
505     """
506     name = "keyword"
507     def __init__(self, key):
508         ImapamiCond.__init__(self, criteria=set(['KEYWORD "%s"' % key]))
509 register(ImapamiCondKeyword)
510
511 class ImapamiCondUnkeyword(ImapamiCond):
512     """
513     Match if the messages does not have the specified keyword flag set.
514
515     This condition does not require to fetch the mail as it can
516     be filtered by the IMAP server.
517
518     Arguments:
519       key: <string>
520         The keyword string.
521
522     Example:
523       unkeyword: {key: important}
524     """
525     name = "unkeyword"
526     def __init__(self, key):
527         ImapamiCond.__init__(self, criteria=set(['UNKEYWORD "%s"' % key]))
528 register(ImapamiCondUnkeyword)
529
530 class ImapamiCondLarger(ImapamiCond):
531     """
532     Match if the message is larger than the specified size.
533
534     This condition does not require to fetch the mail as it can
535     be filtered by the IMAP server.
536
537     Arguments:
538       size: <integer>
539         The size of the message in bytes.
540
541     Example:
542       larger: {size: 1024}
543     """
544     name = "larger"
545     def __init__(self, size):
546         ImapamiCond.__init__(self, criteria=set(['LARGER "%s"' % size]))
547 register(ImapamiCondLarger)
548
549 class ImapamiCondSmaller(ImapamiCond):
550     """
551     Match if the message is smaller than the specified size.
552
553     This condition does not require to fetch the mail as it can
554     be filtered by the IMAP server.
555
556     Arguments:
557       size: <integer>
558         The size of the message in bytes.
559
560     Example:
561       smaller: {size: 1024}
562     """
563     name = "smaller"
564     def __init__(self, size):
565         ImapamiCond.__init__(self, criteria=set(['SMALLER "%s"' % size]))
566 register(ImapamiCondSmaller)
567
568 class ImapamiCondRegex(ImapamiCond):
569     """
570     Match if the regular expression is found in the specified field.
571
572     This condition requires at least to fetch the mail headers, or the
573     full message if the regexp is researched in the body of the mail.
574
575     Arguments:
576       field: <string>
577         The name of the header field (ex: 'From', 'To', ...) where the
578         regular expression is researched or 'part1'/'all' if the regular
579         expression is researched in the body of the mail ('all' includes
580         attachments).
581       pattern: <string>
582         The regular expression, as supported by the python re module.
583
584     Example:
585       regexp: {field: Subject, pattern: '\\[my_list\\]'}
586     """
587     name = "regexp"
588     def __init__(self, field, pattern):
589         if field == "part1":
590             fetch = "part1"
591         elif field == "all":
592             fetch = "all"
593         else:
594             fetch = "headers"
595         ImapamiCond.__init__(self, fetch=fetch)
596         self.field = field
597         self.pattern = pattern
598     def check(self, ami, mail):
599         field = self.evaluate(self.field, ami, mail.msg)
600         pattern = self.evaluate(self.pattern, ami,
601                                 mail.msg)
602         if field == "part1":
603             m = re.search(pattern, mail.body_part1)
604         elif field == "all":
605             m = re.search(pattern, mail.body_all)
606         else:
607             data = mail.msg.get(field)
608             if data is None:
609                 return False
610             m = re.search(pattern, data)
611         if m:
612             return True
613         return False
614
615 register(ImapamiCondRegex)
616
617 class ImapamiCondAnd(ImapamiCond):
618     """
619     Match if all conditions of a list match (AND).
620
621     This command is used internally when the list of conditions of a
622     rule is parsed. It can also be used by a user to group conditions:
623     as soon as a condition does not match, this meta condition returns
624     False (lazy evaluation).
625
626     Arguments:
627       List of conditions
628
629     Example:
630       and:
631       - regexp: {field: From, pattern: foo}
632       - regexp: {field: Subject, pattern: bar}
633     """
634     name = "and"
635     def __init__(self, *cond_list):
636         cond_list = [new(c) for c in cond_list]
637         criteria = set().union(*[c.criteria for c in cond_list])
638         fetch = imapami.utils.highest_fetch_level(
639             [c.fetch for c in cond_list])
640         ImapamiCond.__init__(self, fetch=fetch, criteria=criteria)
641         self.cond_list = cond_list
642
643     def check(self, ami, mail):
644         for c in self.cond_list:
645             if c.check(ami, mail) == False:
646                 return False
647         return True
648 register(ImapamiCondAnd)
649
650 class ImapamiCondOr(ImapamiCond):
651     """
652     Match if at least one condition of a list matches (OR).
653
654     Try to match the conditions of the list: as soon as a condition matches,
655     this meta condition returns True (lazy evaluation).
656
657     Arguments:
658       List of conditions
659
660     Example:
661       or:
662       - regexp: {field: From, pattern: foo}
663       - regexp: {field: Subject, pattern: bar}
664     """
665     name = "or"
666     def __init__(self, *cond_list):
667         cond_list = [new(c) for c in cond_list]
668         criteria = ''
669         for c in cond_list:
670             crit = c.get_criteria()
671             if crit == '':
672                 continue
673             if criteria == '':
674                 criteria = crit
675             else:
676                 criteria = 'OR (%s) (%s)' % (criteria, crit)
677         if criteria != '':
678             criteria = set().add(criteria)
679         fetch = imapami.utils.highest_fetch_level(
680             [c.fetch for c in cond_list])
681         ImapamiCond.__init__(self, fetch=fetch, criteria=criteria)
682         self.cond_list = cond_list
683
684     def check(self, ami, mail):
685         for c in self.cond_list:
686             if c.check(ami, mail) == True:
687                 return True
688         return False
689 register(ImapamiCondOr)
690
691 class ImapamiCondEq(ImapamiCond):
692     """
693     Match if strings are equal.
694
695     This condition does not fetch any part of the mail by default.
696     If a mail header field is used as a variable in the condition
697     parameters, the user must ensure that the mail headers are fetched,
698     for instance by setting the rule paramater 'fetch' to 'headers'.
699
700     If more than 2 elements are given in the list, all of them must
701     be equal.
702
703     Arguments:
704       List of strings
705
706     Example:
707       eq: ['foo@example.com', '{From}', '{foo}']
708     """
709     name = "eq"
710     def __init__(self, *str_list):
711         ImapamiCond.__init__(self)
712         if not isinstance(str_list, list) and not isinstance(str_list, tuple):
713             raise ValueError("arguments of 'eq' should be a list/tuple")
714         if len(str_list) < 2:
715             raise ValueError("'eq' argument list is too short")
716         self.str_list = list(str_list)
717
718     def check(self, ami, mail):
719         first = self.evaluate(self.str_list[0], ami,
720                               mail.msg)
721         for s in self.str_list[1:]:
722             if self.evaluate(s, ami, mail.msg) != first:
723                 return False
724         return True
725 register(ImapamiCondEq)
726
727 def new(config):
728     """
729     Create a condition object from its yaml config.
730
731     :arg string config:
732       The yaml condition config.
733     :returns:
734       The condition object.
735     """
736     logger = logging.getLogger('imapami')
737     logger.debug("parsing condition %s", config)
738     if len(config) != 1:
739         raise ValueError("the condition config must be a dictionary whose only "
740                          "key is the condition name")
741     cond_name = config.keys()[0]
742     argslist = []
743     argsdict = {}
744     if isinstance(config[cond_name], list):
745         argslist = config[cond_name]
746     elif isinstance(config[cond_name], dict):
747         argsdict = config[cond_name]
748     elif config[cond_name] is not None:
749         argslist = [config[cond_name]]
750     cond_class = _all_conditions.get(cond_name)
751     if cond_class is None:
752         raise ValueError("Invalid condition name '%s'" % cond_name)
753     logger.debug("new cond %s(%s, %s)", cond_name, argslist, argsdict)
754     return cond_class(*argslist, **argsdict)
755
756 def get():
757     """
758     Return a dictionary containing all the registered conditions.
759     """
760     return _all_conditions