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