3adff91ccccbef00c3bffaf5762086b97baca501
[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         # search messages matching conditions
160         criteria = "(%s)" % self.get_criteria(ami)
161         ami.logger.debug("processing rule %s, inbox %s, imap criteria %s",
162                          self.name, inbox, criteria)
163         resp, items = ami.imap.uid("SEARCH", None, criteria)
164         if resp != 'OK':
165             ami.logger.warning(
166                 "search failed: server response = %s, skip rule", resp)
167             return
168
169         item_list = items[0].split()
170         item_list = [i for i in item_list if int(i) < ami.uidnext[inbox]]
171         ami.logger.debug("matching mails returned by server: %s", item_list)
172         return item_list
173
174     def _get_parts(self, ami):
175         """
176         Determine which parts of a mail should be fetched
177
178         Depending on the rules, we need to fetch nothing, the headers, the
179         first part of the body or all the mail.
180
181         :arg Imapami ami:
182           The Imapami object
183         :returns:
184           A tuple containing:
185           - the part string to be passed to the IMAP server
186           - a list of (key, IMAP_part)
187         """
188         fetch = self._get_fetch_level()
189         parts = []
190         if fetch in ["headers", "part1", "all"]:
191             parts.append(("headers", "FLAGS INTERNALDATE BODY.PEEK[HEADER]"))
192         if fetch in ["part1", "all"]:
193             parts.append(("body_part1", "BODY.PEEK[1]"))
194         if fetch == "all":
195             parts.append(("body_all", "BODY.PEEK[TEXT]"))
196         parts_str = '(%s)' % ' '.join([p[1] for p in parts])
197         ami.logger.debug('get imap parts = %s', parts_str)
198         return parts_str, parts
199
200     def process(self, ami):
201         """
202         Process the rule.
203
204         :arg Imapami ami:
205           The Imapami object
206         """
207         if self.inbox is not None:
208             inbox = self.inbox
209         else:
210             inbox = ami.config["inbox"]
211         ami.imap.select(inbox)
212
213         # get the list of items (mails) matching the condition criteria
214         item_list = self._search(ami, inbox)
215         # determine what parts should be fetched
216         parts_str, parts = self._get_parts(ami)
217
218         # for each item, fetch it, check the conditions, and do the actions
219         for item in item_list:
220             mail_data = {'item': item, 'inbox': inbox}
221             if parts != []:
222                 resp, data = ami.imap.uid("FETCH", item, parts_str)
223                 print resp, data
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)