4 # Copyright 2015, Olivier MATZ <zer0@droids-corp.org>
6 # Redistribution and use in source and binary forms, with or without
7 # modification, are permitted provided that the following conditions are met:
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.
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.
30 import imapami.actions
31 import imapami.conditions
39 class ImapamiRule(object):
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.
45 name: <string> [default="no-name"]
46 The name of the rule, useful for debug purposes (logs).
47 if: <condition 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.
68 - cond1: {cond1_arg1: foo, cond1_arg2: bar}
69 - cond2: {cond2_arg1: foo, cond2_arg2: bar}
71 - action1: {action1_arg1: foo, action1_arg2: bar}
72 - action2: {action2_arg1: foo, action2_arg2: bar}
74 - action3: {action3_arg1: foo, action3_arg2: bar}
76 - action4: {action4_arg1: foo, action4_arg2: bar}
78 def __init__(self, name, condition, match_action, nomatch_action,
79 error_action, fetch=None, inbox=None):
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
94 The fetch level if it is forced for this rule (can be "no",
95 "headers", "part1", and "all").
97 The input mailbox directory where the rule is executed.
100 self.condition = condition
101 self.match_action = match_action
102 self.nomatch_action = nomatch_action
103 self.error_action = error_action
106 elif not fetch in ["no", "headers", "part1", "all"]:
108 "rule <%s> invalid fetch directive %s " % (self.name, fetch) +
109 "(allowed: no, headers, part1, all)")
113 def get_criteria(self, ami):
115 Return the criteria passed to the IMAP search command for this
116 rule. It depends on the condition list.
121 The IMAP search criteria to be sent to the server.
123 criteria = self.condition.get_criteria()
127 variables = ami.variables
128 fmt = imapami.utils.VarFormatter()
129 criteria = fmt.format(criteria, **variables)
132 def _get_fetch_level(self):
134 Return the required fetch level of the message.
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
141 The returned value is the highest required fetch level, retrieved
142 from condition, actions, and the rule.
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])
148 def _search(self, ami, inbox):
150 Search the mails on the IMAP server
155 The default input mailbox directory.
159 # select the input mailbox
160 if self.inbox is not None:
161 ami.imap.select(self.inbox)
163 ami.imap.select(inbox)
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)
172 "search failed: server response = %s, skip rule", resp)
175 item_list = items[0].split()
176 ami.logger.debug("matching mails returned by server: %s", item_list)
179 def _get_parts(self, ami):
181 Determine which parts of a mail should be fetched
183 Depending on the rules, we need to fetch nothing, the headers, the
184 first part of the body or all the mail.
190 - the part string to be passed to the IMAP server
191 - a list of (key, IMAP_part)
193 fetch = self._get_fetch_level()
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]"))
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
205 def process(self, ami, inbox):
212 The default input mailbox directory.
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)
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}
223 resp, data = ami.imap.fetch(item, parts_str)
226 "search failed: server response = %s, skip item",
230 # fill mail_data from fetched parts
231 for i, part in enumerate(parts):
232 mail_data[part[0]] = data[i][1]
234 m = re.match(r'^.*FLAGS \(([^)]*)\) INTERNALDATE ("[^"]*").*$',
237 ami.logger.warning("cannot parse flags and date %s" %
239 flags, internal_date = '', ''
241 flags, internal_date = m.groups()
242 mail_data['flags'] = flags
243 mail_data['internal_date'] = internal_date
245 mail_data['date'] = imaplib.Internaldate2tuple(
246 mail_data['internal_date'])
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)
253 ami.logger.debug("item %s does not match conditions", item)
254 success = self.nomatch_action.process(ami, mail)
258 "at least one action failed for item %s", item)
259 self.error_action.process(ami, mail)
264 Create a rule object from its yaml config.
267 The yaml rule config.
271 logger = logging.getLogger('imapami')
272 name = config.get("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()
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()
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()
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()
300 action_list = {"list": error_actions}
301 error_action = imapami.actions.new(action_list)
302 return ImapamiRule(name, condition, match_action, nomatch_action,