fix race when receiving mail during rule process
[imapami.git] / imapami / __init__.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.rules
33
34 import argparse
35 import getpass
36 import imaplib
37 import inspect
38 import logging
39 import pydoc
40 import re
41 import sys
42 import yaml
43
44 _LOGLEVELS = [logging.CRITICAL, logging.ERROR, logging.WARNING,
45               logging.INFO, logging.DEBUG]
46
47 class Imapami(object):
48     """
49     The yaml configuration is a collection (dictionary). The following keys
50     are reserved:
51
52     - server: <string> [mandatory]
53       Hostname or IP address of the IMAP server to connect to.
54
55     - port: <integer> [optional]
56       The TCP port to connect to. If not specified, use the default,
57       depending on whether SSL is enabled or disabled.
58
59     - ssl: <boolean> [optional, default is True]
60       Enable or disable SSL (True or False).
61
62     - login: <string> [optional]
63       IMAP login name. If not specified, the login is asked on stdin.
64
65     - password: <string> [optional]
66       IMAP password.  If not specified, the password is asked on stdin.
67
68     - logfile: <string> [optional]
69       File where messages are logged. If not specified, no logs are
70       written in a file.
71
72     - loglevel: <integer> [optional]
73       Level of logs written in logfile. Possible value are from 0 (no log)
74       to 4 (debug). Default value is 3 (info).
75
76     - inbox: <string> [optional, default is INBOX]
77       Default mailbox directory where rules get message from. It can be
78       overriden inside the rule.
79
80     - rules: <list of rules> [mandatory]
81       The list of rules to be applied. See the rule syntax for details.
82
83     Any key entry that is not reserved for a configuration parameter is
84     saved as a variable. The variables can be used in rules by referencing
85     them as {my_var} in conditions or actions arguments.
86
87     Variables are text only, it is possible to test them in rule conditions
88     and set them in rule actions.
89
90     """
91     def __init__(self, config, loglevel=3):
92         """
93         Create a Imapami object.
94
95         :arg str config:
96           The yaml configuration.
97         :arg integer loglevel:
98           The level of the console logs.
99         """
100         self.rules = []
101         self.config = {"server": None,
102                        "port": None,
103                        "ssl": True,
104                        "login": None,
105                        "password": None,
106                        "logfile": None,
107                        "loglevel": 3,
108                        "inbox": "INBOX",
109                        "rules": None}
110         self.imap = None
111         self.variables = {}
112         self.logger = self._get_logger(loglevel)
113         self._load_config(config)
114         self._update_logger()
115         self.uidnext = {}
116
117     def _get_logger(self, loglevel):
118         """
119         Create a logger, and configure it to log on console. This is done
120         before configuration is parsed.
121
122         :arg integer loglevel:
123           The level of the console logs from 0 (critical) to 4 (debug).
124         """
125
126         if not isinstance(loglevel, int) or loglevel < 0 or loglevel > 4:
127             sys.stderr.write("Invalid log level\n")
128             raise ValueError
129
130         # create a logger and a stream handler on console
131         logger = logging.getLogger('imapami')
132         logger.setLevel(logging.DEBUG)
133         console_handler = logging.StreamHandler()
134         console_handler.setLevel(_LOGLEVELS[loglevel])
135         formatter = logging.Formatter('%(levelname)s - %(message)s')
136         console_handler.setFormatter(formatter)
137         logger.addHandler(console_handler)
138         self.console_handler = console_handler
139         return logger
140
141     def _update_logger(self):
142         """
143         Update the logger used after the configuration is parsed: add
144         the specified log file in addition to console.
145         """
146         logfile = self.config.get("logfile")
147         # continue to log on console
148         if logfile is None:
149             return
150         # else, add the new file handler
151         file_handler = logging.FileHandler(logfile)
152         loglevel = self.config.get("loglevel")
153         file_handler.setLevel(_LOGLEVELS[loglevel])
154         formatter = logging.Formatter(
155             '%(asctime)s - %(levelname)s - %(message)s')
156         file_handler.setFormatter(formatter)
157         self.logger.addHandler(file_handler)
158
159     def _load_config(self, config):
160         """
161         Load the yaml configuration. It parses the config parameters, the
162         variables, and the list of rules.
163
164         :arg str config:
165           The yaml configuration.
166         """
167         yaml_dict = yaml.safe_load(config)
168         for key in yaml_dict:
169             if key in self.config:
170                 self.config[key] = yaml_dict[key]
171                 self.logger.debug("set config %s = %s", key, yaml_dict[key])
172             else:
173                 self.variables[key] = yaml_dict[key]
174                 self.logger.debug("set variable %s = %s", key, yaml_dict[key])
175         for key in ["server", "ssl", "rules"]:
176             if self.config[key] is None:
177                 self.logger.error("%s is not specified in configuration")
178                 raise ValueError
179         rules = self.config["rules"]
180         if rules is None:
181             self.logger.error("no rule defined")
182             raise ValueError
183         for i, rule in enumerate(rules):
184             try:
185                 self.rules.append(imapami.rules.new(rule))
186             except Exception as e:
187                 self.logger.error("error while processing rule %d: %s",
188                                   i+1, rule.get('name', 'no-name'))
189                 raise e
190
191     def connect(self):
192         """
193         Connect and login to the remote IMAP server.
194         """
195         if self.config.get("ssl") == True:
196             imap_class = imaplib.IMAP4_SSL
197         else:
198             imap_class = imaplib.IMAP4
199
200         login = self.config.get("login")
201         if login is None:
202             login = raw_input("Username: ")
203         password = self.config.get("password")
204         if password is None:
205             password = getpass.getpass()
206         server = self.config.get("server")
207         port = self.config.get("port")
208         if port is None:
209             imap = imap_class(server)
210         else:
211             imap = imap_class(server, port)
212         self.logger.info('Connecting to %s@%s...', login, server)
213         imap.login(login, password)
214         self.imap = imap
215
216     def get_uidnext(self):
217         """
218         Get the state (uidnext) for each inbox used in the configuration.
219         It gives the uid of the next message the will be added in the mbox.
220         We will only care about messages with a uid lower than this uidnext,
221         to avoid race conditions with a message arriving while we are in the
222         middle of rules processing.
223         """
224         self.logger.info('Getting inbox state...')
225         mboxes = [self.config["inbox"]] + [rule.inbox for rule in self.rules]
226         for m in mboxes:
227             if m is None:
228                 continue
229             if self.uidnext.get(m, None) is not None:
230                 continue
231             self.imap.select(m)
232             typ, dat = self.imap.status(m, "(UIDNEXT)")
233             if typ != 'OK':
234                 raise ValueError("cannot get UIDNEXT: %s", typ)
235             match = re.match("[^ ]* \(UIDNEXT ([0-9]+)\)", dat[0])
236             if match is None:
237                 raise ValueError("cannot match UIDNEXT: %s", typ)
238             self.uidnext[m] = int(match.groups()[0])
239         self.logger.info('Done: %r', self.uidnext)
240
241     def process_rules(self):
242         """
243         Process the rules.
244         """
245         self.logger.info('Processing rules...')
246         for rule in self.rules:
247             rule.process(self)
248         self.logger.info('Done.')
249
250     def close(self):
251         """
252         Close the connection to the IMAP server.
253         """
254         self.imap.close()
255         self.logger.info('Connection closed.')
256
257 def show_config_help():
258     """
259     Show the help related to the configuration file.
260     """
261
262     helptxt = """\
263 The configuration file is in YAML format. Refer to http://yaml.org or
264 http://pyyaml.org for details about this format.
265
266 Full examples can be found in configuration samples located in
267 imapami/config-samples.
268
269 Configuration parameters and variables
270 ======================================
271
272 """
273     doc = inspect.getdoc(Imapami)
274     helptxt += doc + '\n\n'
275
276     helptxt += 'Rules syntax\n'
277     helptxt += '============\n\n'
278
279     doc = inspect.getdoc(imapami.rules.ImapamiRule)
280     helptxt += doc + '\n\n'
281
282     conds = imapami.conditions.get()
283     conds_list = conds.keys()
284     conds_list.sort()
285     helptxt += 'Conditions\n'
286     helptxt += '==========\n\n'
287     for c in conds_list:
288         helptxt += "%s\n" % c
289         helptxt += "-" * len(c) + '\n\n'
290         doc = inspect.getdoc(conds[c])
291         if doc is not None:
292             helptxt += doc + '\n\n'
293         else:
294             helptxt += 'No help\n\n'
295
296     actions = imapami.actions.get()
297     actions_list = actions.keys()
298     actions_list.sort()
299     helptxt += 'Actions\n'
300     helptxt += '=======\n\n'
301     for a in actions:
302         helptxt += "%s\n" % a
303         helptxt += "-" * len(a) + '\n\n'
304         doc = inspect.getdoc(actions[a])
305         if doc is not None:
306             helptxt += doc + '\n\n'
307         else:
308             helptxt += 'No help\n\n'
309     pydoc.pager(helptxt)
310
311 def main():
312     """
313     Run imapami: parse arguments, and launch imapami.
314     """
315     parser = argparse.ArgumentParser(
316         description='Process emails stored on an IMAP server.')
317     parser.add_argument(
318         '-c', '--config',
319         help='path configuration file (mandatory)')
320     parser.add_argument(
321         '-C', '--check', action='store_true', default=False,
322         help='Only parse configuration and exit')
323     parser.add_argument(
324         '-H', '--config-help', dest='config_help', action='store_true',
325         default=False, help='Show help about configuration file')
326     parser.add_argument(
327         '-d', '--debug',
328         help='Console debug level, from 0 (no output) to 4 (debug).',
329         type=int, default=3)
330
331     args = parser.parse_args()
332     if args.config_help == True:
333         show_config_help()
334         sys.exit(0)
335
336     if args.config is None:
337         sys.stderr.write('No config file specified\n')
338         parser.print_help()
339         sys.exit(1)
340
341     p = Imapami(open(args.config).read(), args.debug)
342     if args.check == True:
343         sys.stderr.write('Configuration parsing ok\n')
344         sys.exit(0)
345
346     p.connect()
347     p.get_uidnext()
348     p.process_rules()
349     p.close()
350
351 if __name__ == '__main__':
352     main()