First public revision
[imapami.git] / imapami / rules.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.actions
31 import imapami.conditions
32 import imapami.mail
33 import imapami.utils
34
35 import imaplib
36 import logging
37 import re
38
39 class ImapamiRule(object):
40     """
41     A rule is composed of a list of conditions, and a list of actions that are
42     executed on the mails which match the conditions.
43
44     Arguments:
45       name: <string> [default="no-name"]
46         The name of the rule, useful for debug purposes (logs).
47       if: <condition config>
48         A list of conditions
49       do: <action config>
50         A list of actions executed when the conditions match
51       else-do: <action config>
52         A list of actions executed when the conditions do not match
53       on-error-do: <action config>
54         A list of actions executed on action failure
55       fetch: <string> [optional]
56         When not specified, the fetch level is deduced from the conditions
57         and actions. For instance, if a condition requires to parse the mail
58         headers, the fetch level is automatically set to "headers".
59         This option allows to force the fetch level for the rule. Valid values
60         are: "no", "headers", "part1", and "all".
61       inbox: <string> [optional]
62         Force the input mailbox for this rule. When not specified, use the
63         global inbox configuration.
64
65     Example:
66       rules:
67       - if:
68         - cond1: {cond1_arg1: foo, cond1_arg2: bar}
69         - cond2: {cond2_arg1: foo, cond2_arg2: bar}
70       - do:
71         - action1: {action1_arg1: foo, action1_arg2: bar}
72         - action2: {action2_arg1: foo, action2_arg2: bar}
73       - else-do:
74         - action3: {action3_arg1: foo, action3_arg2: bar}
75       - on-error-do:
76         - action4: {action4_arg1: foo, action4_arg2: bar}
77     """
78     def __init__(self, name, condition, match_action, nomatch_action,
79                  error_action, fetch=None, inbox=None):
80         """
81         Initialize a rule.
82
83         :arg string name:
84           The name of the rule
85         :arg ImapamiCond condition:
86           The condition that must match
87         :arg ImapamiAction match_action:
88           The action to be executed on condition match
89         :arg ImapamiAction nomatch_action:
90           The action to be executed if condition does not match
91         :arg ImapamiAction error_action:
92           The action to be executed if an action fails
93         :arg string fetch:
94           The fetch level if it is forced for this rule (can be "no",
95           "headers", "part1", and "all").
96         :arg string inbox:
97           The input mailbox directory where the rule is executed.
98         """
99         self.name = name
100         self.condition = condition
101         self.match_action = match_action
102         self.nomatch_action = nomatch_action
103         self.error_action = error_action
104         if fetch is None:
105             fetch = "no"
106         elif not fetch in ["no", "headers", "part1", "all"]:
107             raise ValueError(
108                 "rule <%s> invalid fetch directive %s " % (self.name, fetch) +
109                 "(allowed: no, headers, part1, all)")
110         self.fetch = fetch
111         self.inbox = inbox
112
113     def get_criteria(self, ami):
114         """
115         Return the criteria passed to the IMAP search command for this
116         rule. It depends on the condition list.
117
118         :arg Imapami ami:
119           The Imapami object
120         :returns:
121           The IMAP search criteria to be sent to the server.
122         """
123         criteria = self.condition.get_criteria()
124         if criteria == '':
125             criteria = 'ALL'
126         # evaluate variables
127         variables = ami.variables
128         fmt = imapami.utils.VarFormatter()
129         criteria = fmt.format(criteria, **variables)
130         return criteria
131
132     def _get_fetch_level(self):
133         """
134         Return the required fetch level of the message.
135
136         - 'no' means the message is not fetched
137         - 'headers' means fetch the mail headers only
138         - 'part1' means fetch the headers and the first part of the mail
139         - 'all' means fetch all the message including attachments
140
141         The returned value is the highest required fetch level, retrieved
142         from condition, actions, and the rule.
143         """
144         return imapami.utils.highest_fetch_level(
145             [self.condition.fetch, self.match_action.fetch,
146              self.nomatch_action.fetch, self.error_action.fetch, self.fetch])
147
148     def _search(self, ami, inbox):
149         """
150         Search the mails on the IMAP server
151
152         :arg Imapami ami:
153           The Imapami object
154         :arg string inbox:
155           The default input mailbox directory.
156         :returns:
157           A list of IMAP items
158         """
159         # select the input mailbox
160         if self.inbox is not None:
161             ami.imap.select(self.inbox)
162         else:
163             ami.imap.select(inbox)
164
165         # search messages matching conditions
166         criteria = "(%s)" % self.get_criteria(ami)
167         ami.logger.debug("processing rule %s, inbox %s, imap criteria %s",
168                          self.name, inbox, criteria)
169         resp, items = ami.imap.search(None, criteria)
170         if resp != 'OK':
171             ami.logger.warning(
172                 "search failed: server response = %s, skip rule", resp)
173             return
174
175         item_list = items[0].split()
176         ami.logger.debug("matching mails returned by server: %s", item_list)
177         return item_list
178
179     def _get_parts(self, ami):
180         """
181         Determine which parts of a mail should be fetched
182
183         Depending on the rules, we need to fetch nothing, the headers, the
184         first part of the body or all the mail.
185
186         :arg Imapami ami:
187           The Imapami object
188         :returns:
189           A tuple containing:
190           - the part string to be passed to the IMAP server
191           - a list of (key, IMAP_part)
192         """
193         fetch = self._get_fetch_level()
194         parts = []
195         if fetch in ["headers", "part1", "all"]:
196             parts.append(("headers", "FLAGS INTERNALDATE BODY.PEEK[HEADER]"))
197         if fetch in ["part1", "all"]:
198             parts.append(("body_part1", "BODY.PEEK[1]"))
199         if fetch == "all":
200             parts.append(("body_all", "BODY.PEEK[TEXT]"))
201         parts_str = '(%s)' % ' '.join([p[1] for p in parts])
202         ami.logger.debug('get imap parts = %s', parts_str)
203         return parts_str, parts
204
205     def process(self, ami, inbox):
206         """
207         Process the rule.
208
209         :arg Imapami ami:
210           The Imapami object
211         :arg string inbox:
212           The default input mailbox directory.
213         """
214         # get the list of items (mails) matching the condition criteria
215         item_list = self._search(ami, inbox)
216         # determine what parts should be fetched
217         parts_str, parts = self._get_parts(ami)
218
219         # for each item, fetch it, check the conditions, and do the actions
220         for item in item_list:
221             mail_data = {'item': item, 'inbox': inbox}
222             if parts != []:
223                 resp, data = ami.imap.fetch(item, parts_str)
224                 if resp != 'OK':
225                     ami.logger.warning(
226                         "search failed: server response = %s, skip item",
227                         resp)
228                     continue
229
230                 # fill mail_data from fetched parts
231                 for i, part in enumerate(parts):
232                     mail_data[part[0]] = data[i][1]
233
234                 m = re.match(r'^.*FLAGS \(([^)]*)\) INTERNALDATE ("[^"]*").*$',
235                              data[0][0])
236                 if m is None:
237                     ami.logger.warning("cannot parse flags and date %s" %
238                                        data[0][0])
239                     flags, internal_date = '', ''
240                 else:
241                     flags, internal_date = m.groups()
242                 mail_data['flags'] = flags
243                 mail_data['internal_date'] = internal_date
244
245                 mail_data['date'] = imaplib.Internaldate2tuple(
246                     mail_data['internal_date'])
247
248             mail = imapami.mail.ImapamiMail(**mail_data)
249             if self.condition.check(ami, mail) == True:
250                 ami.logger.debug("item %s matches conditions", item)
251                 success = self.match_action.process(ami, mail)
252             else:
253                 ami.logger.debug("item %s does not match conditions", item)
254                 success = self.nomatch_action.process(ami, mail)
255
256             if success == False:
257                 ami.logger.warning(
258                     "at least one action failed for item %s", item)
259                 self.error_action.process(ami, mail)
260         ami.imap.expunge()
261
262 def new(config):
263     """
264     Create a rule object from its yaml config.
265
266     :arg string config:
267       The yaml rule config.
268     :returns:
269       The rule object.
270     """
271     logger = logging.getLogger('imapami')
272     name = config.get("name")
273     if name is None:
274         name = "no-name"
275     fetch = config.get("fetch")
276     conditions = config.get("if")
277     if conditions is None:
278         logger.debug("no condition for rule <%s>, assume always true", name)
279         condition = imapami.conditions.ImapamiCond()
280     else:
281         cond_list = {"and": conditions}
282         condition = imapami.conditions.new(cond_list)
283     match_actions = config.get("do")
284     if match_actions is None:
285         logger.info("no action for rule <%s>, will do nothing", name)
286         match_action = imapami.actions.ImapamiAction()
287     else:
288         action_list = {"list": match_actions}
289         match_action = imapami.actions.new(action_list)
290     nomatch_actions = config.get("else-do")
291     if nomatch_actions is None:
292         nomatch_action = imapami.actions.ImapamiAction()
293     else:
294         action_list = {"list": nomatch_actions}
295         nomatch_action = imapami.actions.new(action_list)
296     error_actions = config.get("on-error-do")
297     if error_actions is None:
298         error_action = imapami.actions.ImapamiAction()
299     else:
300         action_list = {"list": error_actions}
301         error_action = imapami.actions.new(action_list)
302     return ImapamiRule(name, condition, match_action, nomatch_action,
303                        error_action, fetch)