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('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