Commit | Line | Data |
---|---|---|
c638d827 CR |
1 | #!/usr/bin/env python |
2 | """\ | |
3 | This script is a wrapper around the mailgw.py script that exists in roundup. | |
4 | It runs as service instead of running as a one-time shot. | |
5 | It also connects to a secure IMAP server. The main reasons for this script are: | |
6 | ||
7 | 1) The roundup-mailgw script isn't designed to run as a server. It | |
8 | expects that you either run it by hand, and enter the password each | |
9 | time, or you supply the password on the command line. I prefer to | |
10 | run a server that I initialize with the password, and then it just | |
11 | runs. I don't want to have to pass it on the command line, so | |
12 | running through crontab isn't a possibility. (This wouldn't be a | |
13 | problem on a local machine running through a mailspool.) | |
14 | 2) mailgw.py somehow screws up SSL support so IMAP4_SSL doesn't work. So | |
15 | hopefully running that work outside of the mailgw will allow it to work. | |
16 | 3) I wanted to be able to check multiple projects at the same time. | |
17 | roundup-mailgw is only for 1 mailbox and 1 project. | |
18 | ||
19 | ||
20 | *TODO*: | |
21 | For the first round, the program spawns a new roundup-mailgw for | |
22 | each imap message that it finds and pipes the result in. In the | |
23 | future it might be more practical to actually include the roundup | |
24 | files and run the appropriate commands using python. | |
25 | ||
26 | *TODO*: | |
27 | Look into supporting a logfile instead of using 2>/logfile | |
28 | ||
29 | *TODO*: | |
30 | Add an option for changing the uid/gid of the running process. | |
31 | """ | |
32 | ||
33 | import getpass | |
34 | import logging | |
35 | import imaplib | |
36 | import optparse | |
37 | import os | |
38 | import re | |
39 | import time | |
40 | ||
41 | logging.basicConfig() | |
42 | log = logging.getLogger('IMAPServer') | |
43 | ||
44 | version = '0.1.2' | |
45 | ||
46 | class RoundupMailbox: | |
47 | """This contains all the info about each mailbox. | |
48 | Username, Password, server, security, roundup database | |
49 | """ | |
50 | def __init__(self, dbhome='', username=None, password=None, mailbox=None | |
51 | , server=None, protocol='imaps'): | |
52 | self.username = username | |
53 | self.password = password | |
54 | self.mailbox = mailbox | |
55 | self.server = server | |
56 | self.protocol = protocol | |
57 | self.dbhome = dbhome | |
58 | ||
59 | try: | |
60 | if not self.dbhome: | |
61 | self.dbhome = raw_input('Tracker home: ') | |
62 | if not os.path.exists(self.dbhome): | |
63 | raise ValueError, 'Invalid home address: ' \ | |
64 | 'directory "%s" does not exist.' % self.dbhome | |
65 | ||
66 | if not self.server: | |
67 | self.server = raw_input('Server: ') | |
68 | if not self.server: | |
69 | raise ValueError, 'No Servername supplied' | |
70 | protocol = raw_input('protocol [imaps]? ') | |
71 | self.protocol = protocol | |
72 | ||
73 | if not self.username: | |
74 | self.username = raw_input('Username: ') | |
75 | if not self.username: | |
76 | raise ValueError, 'Invalid Username' | |
77 | ||
78 | if not self.password: | |
79 | print 'For server %s, user %s' % (self.server, self.username) | |
80 | self.password = getpass.getpass() | |
81 | # password can be empty because it could be superceeded | |
82 | # by a later entry | |
83 | ||
84 | #if self.mailbox is None: | |
85 | # self.mailbox = raw_input('Mailbox [INBOX]: ') | |
86 | # # We allow an empty mailbox because that will | |
87 | # # select the INBOX, whatever it is called | |
88 | ||
89 | except (KeyboardInterrupt, EOFError): | |
90 | raise ValueError, 'Canceled by User' | |
91 | ||
92 | def __str__(self): | |
93 | return 'Mailbox{ server:%(server)s, protocol:%(protocol)s, ' \ | |
94 | 'username:%(username)s, mailbox:%(mailbox)s, ' \ | |
95 | 'dbhome:%(dbhome)s }' % self.__dict__ | |
96 | ||
97 | ||
98 | # [als] class name is misleading. this is imap client, not imap server | |
99 | class IMAPServer: | |
100 | ||
101 | """IMAP mail gatherer. | |
102 | ||
103 | This class runs as a server process. It is configured with a list of | |
104 | mailboxes to connect to, along with the roundup database directories | |
105 | that correspond with each email address. It then connects to each | |
106 | mailbox at a specified interval, and if there are new messages it | |
107 | reads them, and sends the result to the roundup.mailgw. | |
108 | ||
109 | *TODO*: | |
110 | Try to be smart about how you access the mailboxes so that you can | |
111 | connect once, and access multiple mailboxes and possibly multiple | |
112 | usernames. | |
113 | ||
114 | *NOTE*: | |
115 | This assumes that if you are using the same user on the same | |
116 | server, you are using the same password. (the last one supplied is | |
117 | used.) Empty passwords are ignored. Only the last protocol | |
118 | supplied is used. | |
119 | """ | |
120 | ||
121 | def __init__(self, pidfile=None, delay=5, daemon=False): | |
122 | #This is sorted by servername, then username, then mailboxes | |
123 | self.mailboxes = {} | |
124 | self.delay = float(delay) | |
125 | self.pidfile = pidfile | |
126 | self.daemon = daemon | |
127 | ||
128 | def setDelay(self, delay): | |
129 | self.delay = delay | |
130 | ||
131 | def addMailbox(self, mailbox): | |
132 | """ The linkage is as follows: | |
133 | servers -- users - mailbox:dbhome | |
134 | So there can be multiple servers, each with multiple users. | |
135 | Each username can be associated with multiple mailboxes. | |
136 | each mailbox is associated with 1 database home | |
137 | """ | |
138 | log.info('Adding mailbox %s', mailbox) | |
139 | if not self.mailboxes.has_key(mailbox.server): | |
140 | self.mailboxes[mailbox.server] = {'protocol':'imaps', 'users':{}} | |
141 | server = self.mailboxes[mailbox.server] | |
142 | if mailbox.protocol: | |
143 | server['protocol'] = mailbox.protocol | |
144 | ||
145 | if not server['users'].has_key(mailbox.username): | |
146 | server['users'][mailbox.username] = {'password':'', 'mailboxes':{}} | |
147 | user = server['users'][mailbox.username] | |
148 | if mailbox.password: | |
149 | user['password'] = mailbox.password | |
150 | ||
151 | if user['mailboxes'].has_key(mailbox.mailbox): | |
152 | raise ValueError, 'Mailbox is already defined' | |
153 | ||
154 | user['mailboxes'][mailbox.mailbox] = mailbox.dbhome | |
155 | ||
156 | def _process(self, message, dbhome): | |
157 | """Actually process one of the email messages""" | |
158 | child = os.popen('roundup-mailgw %s' % dbhome, 'wb') | |
159 | child.write(message) | |
160 | child.close() | |
161 | #print message | |
162 | ||
163 | def _getMessages(self, serv, count, dbhome): | |
164 | """This assumes that you currently have a mailbox open, and want to | |
165 | process all messages that are inside. | |
166 | """ | |
167 | for n in range(1, count+1): | |
168 | (t, data) = serv.fetch(n, '(RFC822)') | |
169 | if t == 'OK': | |
170 | self._process(data[0][1], dbhome) | |
171 | serv.store(n, '+FLAGS', r'(\Deleted)') | |
172 | ||
173 | def checkBoxes(self): | |
174 | """This actually goes out and does all the checking. | |
175 | Returns False if there were any errors, otherwise returns true. | |
176 | """ | |
177 | noErrors = True | |
178 | for server in self.mailboxes: | |
179 | log.info('Connecting to server: %s', server) | |
180 | s_vals = self.mailboxes[server] | |
181 | ||
182 | try: | |
183 | for user in s_vals['users']: | |
184 | u_vals = s_vals['users'][user] | |
185 | # TODO: As near as I can tell, you can only | |
186 | # login with 1 username for each connection to a server. | |
187 | protocol = s_vals['protocol'].lower() | |
188 | if protocol == 'imaps': | |
189 | serv = imaplib.IMAP4_SSL(server) | |
190 | elif protocol == 'imap': | |
191 | serv = imaplib.IMAP4(server) | |
192 | else: | |
193 | raise ValueError, 'Unknown protocol %s' % protocol | |
194 | ||
195 | password = u_vals['password'] | |
196 | ||
197 | try: | |
198 | log.info('Connecting as user: %s', user) | |
199 | serv.login(user, password) | |
200 | ||
201 | for mbox in u_vals['mailboxes']: | |
202 | dbhome = u_vals['mailboxes'][mbox] | |
203 | log.info('Using mailbox: %s, home: %s', | |
204 | mbox, dbhome) | |
205 | #access a specific mailbox | |
206 | if mbox: | |
207 | (t, data) = serv.select(mbox) | |
208 | else: | |
209 | # Select the default mailbox (INBOX) | |
210 | (t, data) = serv.select() | |
211 | try: | |
212 | nMessages = int(data[0]) | |
213 | except ValueError: | |
214 | nMessages = 0 | |
215 | ||
216 | log.info('Found %s messages', nMessages) | |
217 | ||
218 | if nMessages: | |
219 | self._getMessages(serv, nMessages, dbhome) | |
220 | serv.expunge() | |
221 | ||
222 | # We are done with this mailbox | |
223 | serv.close() | |
224 | except: | |
225 | log.exception('Exception with server %s user %s', | |
226 | server, user) | |
227 | noErrors = False | |
228 | ||
229 | serv.logout() | |
230 | serv.shutdown() | |
231 | del serv | |
232 | except: | |
233 | log.exception('Exception while connecting to %s', server) | |
234 | noErrors = False | |
235 | return noErrors | |
236 | ||
237 | ||
238 | def makeDaemon(self): | |
239 | """Turn this process into a daemon. | |
240 | ||
241 | - make our parent PID 1 | |
242 | ||
243 | Write our new PID to the pidfile. | |
244 | ||
245 | From A.M. Kuuchling (possibly originally Greg Ward) with | |
246 | modification from Oren Tirosh, and finally a small mod from me. | |
247 | Originally taken from roundup.scripts.roundup_server.py | |
248 | """ | |
249 | log.info('Running as Daemon') | |
250 | # Fork once | |
251 | if os.fork() != 0: | |
252 | os._exit(0) | |
253 | ||
254 | # Create new session | |
255 | os.setsid() | |
256 | ||
257 | # Second fork to force PPID=1 | |
258 | pid = os.fork() | |
259 | if pid: | |
260 | if self.pidfile: | |
261 | pidfile = open(self.pidfile, 'w') | |
262 | pidfile.write(str(pid)) | |
263 | pidfile.close() | |
264 | os._exit(0) | |
265 | ||
266 | def run(self): | |
267 | """Run email gathering daemon. | |
268 | ||
269 | This spawns itself as a daemon, and then runs continually, just | |
270 | sleeping inbetween checks. It is recommended that you run | |
271 | checkBoxes once first before you select run. That way you can | |
272 | know if there were any failures. | |
273 | """ | |
274 | if self.daemon: | |
275 | self.makeDaemon() | |
276 | while True: | |
277 | ||
278 | time.sleep(self.delay * 60.0) | |
279 | log.info('Time: %s', time.strftime('%Y-%m-%d %H:%M:%S')) | |
280 | self.checkBoxes() | |
281 | ||
282 | def getItems(s): | |
283 | """Parse a string looking for userame@server""" | |
284 | myRE = re.compile( | |
285 | r'((?P<protocol>[^:]+)://)?'#You can supply a protocol if you like | |
286 | r'(' #The username part is optional | |
287 | r'(?P<username>[^:]+)' #You can supply the password as | |
288 | r'(:(?P<password>.+))?' #username:password@server | |
289 | r'@)?' | |
290 | r'(?P<server>[^/]+)' | |
291 | r'(/(?P<mailbox>.+))?$' | |
292 | ) | |
293 | m = myRE.match(s) | |
294 | if m: | |
295 | return m.groupdict() | |
296 | else: | |
297 | return None | |
298 | ||
299 | def main(): | |
300 | """This is what is called if run at the prompt""" | |
301 | parser = optparse.OptionParser( | |
302 | version=('%prog ' + version), | |
303 | usage="""usage: %prog [options] (home server)... | |
304 | ||
305 | So each entry has a home, and then the server configuration. Home is just | |
306 | a path to the roundup issue tracker. The server is something of the form: | |
307 | ||
308 | imaps://user:password@server/mailbox | |
309 | ||
310 | If you don't supply the protocol, imaps is assumed. Without user or | |
311 | password, you will be prompted for them. The server must be supplied. | |
312 | Without mailbox the INBOX is used. | |
313 | ||
314 | Examples: | |
315 | %prog /home/roundup/trackers/test imaps://test@imap.example.com/test | |
316 | %prog /home/roundup/trackers/test imap.example.com \ | |
317 | /home/roundup/trackers/test2 imap.example.com/test2 | |
318 | """ | |
319 | ) | |
320 | parser.add_option('-d', '--delay', dest='delay', type='float', | |
321 | metavar='<sec>', default=5, | |
322 | help="Set the delay between checks in minutes. (default 5)" | |
323 | ) | |
324 | parser.add_option('-p', '--pid-file', dest='pidfile', | |
325 | metavar='<file>', default=None, | |
326 | help="The pid of the server process will be written to <file>" | |
327 | ) | |
328 | parser.add_option('-n', '--no-daemon', dest='daemon', | |
329 | action='store_false', default=True, | |
330 | help="Do not fork into the background after running the first check." | |
331 | ) | |
332 | parser.add_option('-v', '--verbose', dest='verbose', | |
333 | action='store_const', const=logging.INFO, | |
334 | help="Be more verbose in letting you know what is going on." | |
335 | " Enables informational messages." | |
336 | ) | |
337 | parser.add_option('-V', '--very-verbose', dest='verbose', | |
338 | action='store_const', const=logging.DEBUG, | |
339 | help="Be very verbose in letting you know what is going on." | |
340 | " Enables debugging messages." | |
341 | ) | |
342 | parser.add_option('-q', '--quiet', dest='verbose', | |
343 | action='store_const', const=logging.ERROR, | |
344 | help="Be less verbose. Ignores warnings, only prints errors." | |
345 | ) | |
346 | parser.add_option('-Q', '--very-quiet', dest='verbose', | |
347 | action='store_const', const=logging.CRITICAL, | |
348 | help="Be much less verbose. Ignores warnings and errors." | |
349 | " Only print CRITICAL messages." | |
350 | ) | |
351 | ||
352 | (opts, args) = parser.parse_args() | |
353 | if (len(args) == 0) or (len(args) % 2 == 1): | |
354 | parser.error('Invalid number of arguments. ' | |
355 | 'Each site needs a home and a server.') | |
356 | ||
357 | log.setLevel(opts.verbose) | |
358 | myServer = IMAPServer(delay=opts.delay, pidfile=opts.pidfile, | |
359 | daemon=opts.daemon) | |
360 | for i in range(0,len(args),2): | |
361 | home = args[i] | |
362 | server = args[i+1] | |
363 | if not os.path.exists(home): | |
364 | parser.error('Home: "%s" does not exist' % home) | |
365 | ||
366 | info = getItems(server) | |
367 | if not info: | |
368 | parser.error('Invalid server string: "%s"' % server) | |
369 | ||
370 | myServer.addMailbox( | |
371 | RoundupMailbox(dbhome=home, mailbox=info['mailbox'] | |
372 | , username=info['username'], password=info['password'] | |
373 | , server=info['server'], protocol=info['protocol'] | |
374 | ) | |
375 | ) | |
376 | ||
377 | if myServer.checkBoxes(): | |
378 | myServer.run() | |
379 | ||
380 | if __name__ == '__main__': | |
381 | main() | |
382 | ||
383 | # vim: et ft=python si sts=4 sw=4 |