Code

- optimisation for date: if the database provides us with a datetime
[roundup.git] / scripts / imapServer.py
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:
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.
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.
26 *TODO*:
27   Look into supporting a logfile instead of using 2>/logfile
29 *TODO*:
30   Add an option for changing the uid/gid of the running process.
31 """
33 import getpass
34 import logging
35 import imaplib
36 import optparse
37 import os
38 import re
39 import time
41 logging.basicConfig()
42 log = logging.getLogger('roundup.IMAPServer')
44 version = '0.1.2'
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
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
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
73             if not self.username:
74                 self.username = raw_input('Username: ')
75                 if not self.username:
76                     raise ValueError, 'Invalid Username'
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
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
89         except (KeyboardInterrupt, EOFError):
90             raise ValueError, 'Canceled by User'
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__
98 # [als] class name is misleading.  this is imap client, not imap server
99 class IMAPServer:
101     """IMAP mail gatherer.
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.
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.
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     """
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
128     def setDelay(self, delay):
129         self.delay = delay
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
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
151         if user['mailboxes'].has_key(mailbox.mailbox):
152             raise ValueError, 'Mailbox is already defined'
154         user['mailboxes'][mailbox.mailbox] = mailbox.dbhome
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
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)')
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]
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
195                     password = u_vals['password']
197                     try:
198                         log.info('Connecting as user: %s', user)
199                         serv.login(user, password)
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
216                             log.info('Found %s messages', nMessages)
218                             if nMessages:
219                                 self._getMessages(serv, nMessages, dbhome)
220                                 serv.expunge()
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
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
238     def makeDaemon(self):
239         """Turn this process into a daemon.
241         - make our parent PID 1
243         Write our new PID to the pidfile.
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)
254         # Create new session
255         os.setsid()
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)
266     def run(self):
267         """Run email gathering daemon.
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:
278             time.sleep(self.delay * 60.0)
279             log.info('Time: %s', time.strftime('%Y-%m-%d %H:%M:%S'))
280             self.checkBoxes()
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
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)...
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:
308     imaps://user:password@server/mailbox
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.
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     )
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.')
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)
366         info = getItems(server)
367         if not info:
368             parser.error('Invalid server string: "%s"' % server)
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         )
377     if myServer.checkBoxes():
378         myServer.run()
380 if __name__ == '__main__':
381     main()
383 # vim: et ft=python si sts=4 sw=4