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 # 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)
166 "search failed: server response = %s, skip rule", resp)
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)
174 def _get_parts(self, ami):
176 Determine which parts of a mail should be fetched
178 Depending on the rules, we need to fetch nothing, the headers, the
179 first part of the body or all the mail.
185 - the part string to be passed to the IMAP server
186 - a list of (key, IMAP_part)
188 fetch = self._get_fetch_level()
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]"))
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
200 def process(self, ami):
207 if self.inbox is not None:
210 inbox = ami.config["inbox"]
211 ami.imap.select(inbox)
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)
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}
222 resp, data = ami.imap.uid("FETCH", item, parts_str)
225 "search failed: server response = %s, skip item",
229 # fill mail_data from fetched parts
230 for i, part in enumerate(parts):
231 mail_data[part[0]] = data[i][1]
233 m = re.match(r'^.*FLAGS \(([^)]*)\) INTERNALDATE ("[^"]*").*$',
236 ami.logger.warning("cannot parse flags and date %s" %
238 flags, internal_date = '', ''
240 flags, internal_date = m.groups()
241 mail_data['flags'] = flags
242 mail_data['internal_date'] = internal_date
244 mail_data['date'] = imaplib.Internaldate2tuple(
245 mail_data['internal_date'])
247 mail = imapami.mail.ImapamiMail(**mail_data)
248 if self.condition.check(ami, mail) == True:
249 ami.logger.debug("item %s matches conditions", item)
250 success = self.match_action.process(ami, mail)
252 ami.logger.debug("item %s does not match conditions", item)
253 success = self.nomatch_action.process(ami, mail)
257 "at least one action failed for item %s", item)
258 self.error_action.process(ami, mail)
263 Create a rule object from its yaml config.
266 The yaml rule config.
270 logger = logging.getLogger('imapami')
271 name = config.get("name")
274 fetch = config.get("fetch")
275 conditions = config.get("if")
276 if conditions is None:
277 logger.debug("no condition for rule <%s>, assume always true", name)
278 condition = imapami.conditions.ImapamiCond()
280 cond_list = {"and": conditions}
281 condition = imapami.conditions.new(cond_list)
282 match_actions = config.get("do")
283 if match_actions is None:
284 logger.info("no action for rule <%s>, will do nothing", name)
285 match_action = imapami.actions.ImapamiAction()
287 action_list = {"list": match_actions}
288 match_action = imapami.actions.new(action_list)
289 nomatch_actions = config.get("else-do")
290 if nomatch_actions is None:
291 nomatch_action = imapami.actions.ImapamiAction()
293 action_list = {"list": nomatch_actions}
294 nomatch_action = imapami.actions.new(action_list)
295 error_actions = config.get("on-error-do")
296 if error_actions is None:
297 error_action = imapami.actions.ImapamiAction()
299 action_list = {"list": error_actions}
300 error_action = imapami.actions.new(action_list)
301 return ImapamiRule(name, condition, match_action, nomatch_action,