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.uid("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.uid("FETCH", item, parts_str)
227 "search failed: server response = %s, skip item",
231 # fill mail_data from fetched parts
232 for i, part in enumerate(parts):
233 mail_data[part[0]] = data[i][1]
235 m = re.match(r'^.*FLAGS \(([^)]*)\) INTERNALDATE ("[^"]*").*$',
238 ami.logger.warning("cannot parse flags and date %s" %
240 flags, internal_date = '', ''
242 flags, internal_date = m.groups()
243 mail_data['flags'] = flags
244 mail_data['internal_date'] = internal_date
246 mail_data['date'] = imaplib.Internaldate2tuple(
247 mail_data['internal_date'])
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)
254 ami.logger.debug("item %s does not match conditions", item)
255 success = self.nomatch_action.process(ami, mail)
259 "at least one action failed for item %s", item)
260 self.error_action.process(ami, mail)
265 Create a rule object from its yaml config.
268 The yaml rule config.
272 logger = logging.getLogger('imapami')
273 name = config.get("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()
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()
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()
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()
301 action_list = {"list": error_actions}
302 error_action = imapami.actions.new(action_list)
303 return ImapamiRule(name, condition, match_action, nomatch_action,