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