use uids to manipulate mails
[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.uid("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.uid("FETCH", item, parts_str)
224                 print resp, data
225                 if resp != 'OK':
226                     ami.logger.warning(
227                         "search failed: server response = %s, skip item",
228                         resp)
229                     continue
230
231                 # fill mail_data from fetched parts
232                 for i, part in enumerate(parts):
233                     mail_data[part[0]] = data[i][1]
234
235                 m = re.match(r'^.*FLAGS \(([^)]*)\) INTERNALDATE ("[^"]*").*$',
236                              data[0][0])
237                 if m is None:
238                     ami.logger.warning("cannot parse flags and date %s" %
239                                        data[0][0])
240                     flags, internal_date = '', ''
241                 else:
242                     flags, internal_date = m.groups()
243                 mail_data['flags'] = flags
244                 mail_data['internal_date'] = internal_date
245
246                 mail_data['date'] = imaplib.Internaldate2tuple(
247                     mail_data['internal_date'])
248
249             mail = imapami.mail.ImapamiMail(**mail_data)
250             if self.condition.check(ami, mail) == True:
251                 ami.logger.debug("item %s matches conditions", item)
252                 success = self.match_action.process(ami, mail)
253             else:
254                 ami.logger.debug("item %s does not match conditions", item)
255                 success = self.nomatch_action.process(ami, mail)
256
257             if success == False:
258                 ami.logger.warning(
259                     "at least one action failed for item %s", item)
260                 self.error_action.process(ami, mail)
261         ami.imap.expunge()
262
263 def new(config):
264     """
265     Create a rule object from its yaml config.
266
267     :arg string config:
268       The yaml rule config.
269     :returns:
270       The rule object.
271     """
272     logger = logging.getLogger('imapami')
273     name = config.get("name")
274     if name is None:
275         name = "no-name"
276     fetch = config.get("fetch")
277     conditions = config.get("if")
278     if conditions is None:
279         logger.debug("no condition for rule <%s>, assume always true", name)
280         condition = imapami.conditions.ImapamiCond()
281     else:
282         cond_list = {"and": conditions}
283         condition = imapami.conditions.new(cond_list)
284     match_actions = config.get("do")
285     if match_actions is None:
286         logger.info("no action for rule <%s>, will do nothing", name)
287         match_action = imapami.actions.ImapamiAction()
288     else:
289         action_list = {"list": match_actions}
290         match_action = imapami.actions.new(action_list)
291     nomatch_actions = config.get("else-do")
292     if nomatch_actions is None:
293         nomatch_action = imapami.actions.ImapamiAction()
294     else:
295         action_list = {"list": nomatch_actions}
296         nomatch_action = imapami.actions.new(action_list)
297     error_actions = config.get("on-error-do")
298     if error_actions is None:
299         error_action = imapami.actions.ImapamiAction()
300     else:
301         action_list = {"list": error_actions}
302         error_action = imapami.actions.new(action_list)
303     return ImapamiRule(name, condition, match_action, nomatch_action,
304                        error_action, fetch)