]> code.delx.au - offlineimap/blob - offlineimap/imapserver.py
Revert "Initial stab at showing list of folders that aren't being synced"
[offlineimap] / offlineimap / imapserver.py
1 # IMAP server support
2 # Copyright (C) 2002 - 2007 John Goerzen
3 # <jgoerzen@complete.org>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
18
19 import imaplib
20 from offlineimap import imaplibutil, imaputil, threadutil
21 from offlineimap.ui import UIBase
22 from threading import *
23 import thread, hmac, os
24 import base64
25
26 try:
27 # do we have a recent pykerberos?
28 have_gss = False
29 import kerberos
30 if 'authGSSClientWrap' in dir(kerberos):
31 have_gss = True
32 except ImportError:
33 pass
34
35 class UsefulIMAPMixIn:
36 def getstate(self):
37 return self.state
38 def getselectedfolder(self):
39 if self.getstate() == 'SELECTED':
40 return self.selectedfolder
41 return None
42
43 def select(self, mailbox='INBOX', readonly=None, force = 0):
44 if (not force) and self.getselectedfolder() == mailbox \
45 and self.is_readonly == readonly:
46 # No change; return.
47 return
48 result = self.__class__.__bases__[1].select(self, mailbox, readonly)
49 if result[0] != 'OK':
50 raise ValueError, "Error from select: %s" % str(result)
51 if self.getstate() == 'SELECTED':
52 self.selectedfolder = mailbox
53 else:
54 self.selectedfolder = None
55
56 def _mesg(self, s, secs=None):
57 imaplibutil.new_mesg(self, s, secs)
58
59 class UsefulIMAP4(UsefulIMAPMixIn, imaplib.IMAP4):
60 def open(self, host = '', port = imaplib.IMAP4_PORT):
61 imaplibutil.new_open(self, host, port)
62
63 class UsefulIMAP4_SSL(UsefulIMAPMixIn, imaplibutil.WrappedIMAP4_SSL):
64 def open(self, host = '', port = imaplib.IMAP4_SSL_PORT):
65 imaplibutil.new_open_ssl(self, host, port)
66
67 class UsefulIMAP4_Tunnel(UsefulIMAPMixIn, imaplibutil.IMAP4_Tunnel): pass
68
69 class IMAPServer:
70 GSS_STATE_STEP = 0
71 GSS_STATE_WRAP = 1
72 def __init__(self, config, reposname,
73 username = None, password = None, hostname = None,
74 port = None, ssl = 1, maxconnections = 1, tunnel = None,
75 reference = '""'):
76 self.reposname = reposname
77 self.config = config
78 self.username = username
79 self.password = password
80 self.passworderror = None
81 self.goodpassword = None
82 self.hostname = hostname
83 self.tunnel = tunnel
84 self.port = port
85 self.usessl = ssl
86 self.delim = None
87 self.root = None
88 if port == None:
89 if ssl:
90 self.port = 993
91 else:
92 self.port = 143
93 self.maxconnections = maxconnections
94 self.availableconnections = []
95 self.assignedconnections = []
96 self.lastowner = {}
97 self.semaphore = BoundedSemaphore(self.maxconnections)
98 self.connectionlock = Lock()
99 self.reference = reference
100 self.gss_step = self.GSS_STATE_STEP
101 self.gss_vc = None
102 self.gssapi = False
103
104 def getpassword(self):
105 if self.goodpassword != None:
106 return self.goodpassword
107
108 if self.password != None and self.passworderror == None:
109 return self.password
110
111 self.password = UIBase.getglobalui().getpass(self.reposname,
112 self.config,
113 self.passworderror)
114 self.passworderror = None
115
116 return self.password
117
118 def getdelim(self):
119 """Returns this server's folder delimiter. Can only be called
120 after one or more calls to acquireconnection."""
121 return self.delim
122
123 def getroot(self):
124 """Returns this server's folder root. Can only be called after one
125 or more calls to acquireconnection."""
126 return self.root
127
128
129 def releaseconnection(self, connection):
130 """Releases a connection, returning it to the pool."""
131 self.connectionlock.acquire()
132 self.assignedconnections.remove(connection)
133 self.availableconnections.append(connection)
134 self.connectionlock.release()
135 self.semaphore.release()
136
137 def md5handler(self, response):
138 ui = UIBase.getglobalui()
139 challenge = response.strip()
140 ui.debug('imap', 'md5handler: got challenge %s' % challenge)
141
142 passwd = self.getpassword()
143 retval = self.username + ' ' + hmac.new(passwd, challenge).hexdigest()
144 ui.debug('imap', 'md5handler: returning %s' % retval)
145 return retval
146
147 def plainauth(self, imapobj):
148 UIBase.getglobalui().debug('imap',
149 'Attempting plain authentication')
150 imapobj.login(self.username, self.getpassword())
151
152 def gssauth(self, response):
153 data = base64.b64encode(response)
154 try:
155 if self.gss_step == self.GSS_STATE_STEP:
156 if not self.gss_vc:
157 rc, self.gss_vc = kerberos.authGSSClientInit('imap@' +
158 self.hostname)
159 response = kerberos.authGSSClientResponse(self.gss_vc)
160 rc = kerberos.authGSSClientStep(self.gss_vc, data)
161 if rc != kerberos.AUTH_GSS_CONTINUE:
162 self.gss_step = self.GSS_STATE_WRAP
163 elif self.gss_step == self.GSS_STATE_WRAP:
164 rc = kerberos.authGSSClientUnwrap(self.gss_vc, data)
165 response = kerberos.authGSSClientResponse(self.gss_vc)
166 rc = kerberos.authGSSClientWrap(self.gss_vc, response,
167 self.username)
168 response = kerberos.authGSSClientResponse(self.gss_vc)
169 except kerberos.GSSError, err:
170 # Kerberos errored out on us, respond with None to cancel the
171 # authentication
172 UIBase.getglobalui().debug('imap',
173 '%s: %s' % (err[0][0], err[1][0]))
174 return None
175
176 if not response:
177 response = ''
178 return base64.b64decode(response)
179
180 def acquireconnection(self):
181 """Fetches a connection from the pool, making sure to create a new one
182 if needed, to obey the maximum connection limits, etc.
183 Opens a connection to the server and returns an appropriate
184 object."""
185
186 self.semaphore.acquire()
187 self.connectionlock.acquire()
188 imapobj = None
189
190 if len(self.availableconnections): # One is available.
191 # Try to find one that previously belonged to this thread
192 # as an optimization. Start from the back since that's where
193 # they're popped on.
194 threadid = thread.get_ident()
195 imapobj = None
196 for i in range(len(self.availableconnections) - 1, -1, -1):
197 tryobj = self.availableconnections[i]
198 if self.lastowner[tryobj] == threadid:
199 imapobj = tryobj
200 del(self.availableconnections[i])
201 break
202 if not imapobj:
203 imapobj = self.availableconnections[0]
204 del(self.availableconnections[0])
205 self.assignedconnections.append(imapobj)
206 self.lastowner[imapobj] = thread.get_ident()
207 self.connectionlock.release()
208 return imapobj
209
210 self.connectionlock.release() # Release until need to modify data
211
212 success = 0
213 while not success:
214 # Generate a new connection.
215 if self.tunnel:
216 UIBase.getglobalui().connecting('tunnel', self.tunnel)
217 imapobj = UsefulIMAP4_Tunnel(self.tunnel)
218 success = 1
219 elif self.usessl:
220 UIBase.getglobalui().connecting(self.hostname, self.port)
221 imapobj = UsefulIMAP4_SSL(self.hostname, self.port)
222 else:
223 UIBase.getglobalui().connecting(self.hostname, self.port)
224 imapobj = UsefulIMAP4(self.hostname, self.port)
225
226 imapobj.mustquote = imaplibutil.mustquote
227
228 if not self.tunnel:
229 try:
230 # Try GSSAPI and continue if it fails
231 if 'AUTH=GSSAPI' in imapobj.capabilities and have_gss:
232 UIBase.getglobalui().debug('imap',
233 'Attempting GSSAPI authentication')
234 try:
235 imapobj.authenticate('GSSAPI', self.gssauth)
236 except imapobj.error, val:
237 UIBase.getglobalui().debug('imap',
238 'GSSAPI Authentication failed')
239 else:
240 self.gssapi = True
241 self.password = None
242
243 if not self.gssapi:
244 if 'AUTH=CRAM-MD5' in imapobj.capabilities:
245 UIBase.getglobalui().debug('imap',
246 'Attempting CRAM-MD5 authentication')
247 try:
248 imapobj.authenticate('CRAM-MD5', self.md5handler)
249 except imapobj.error, val:
250 self.plainauth(imapobj)
251 else:
252 self.plainauth(imapobj)
253 # Would bail by here if there was a failure.
254 success = 1
255 self.goodpassword = self.password
256 except imapobj.error, val:
257 self.passworderror = str(val)
258 self.password = None
259
260 if self.delim == None:
261 listres = imapobj.list(self.reference, '""')[1]
262 if listres == [None] or listres == None:
263 # Some buggy IMAP servers do not respond well to LIST "" ""
264 # Work around them.
265 listres = imapobj.list(self.reference, '"*"')[1]
266 self.delim, self.root = \
267 imaputil.imapsplit(listres[0])[1:]
268 self.delim = imaputil.dequote(self.delim)
269 self.root = imaputil.dequote(self.root)
270
271 self.connectionlock.acquire()
272 self.assignedconnections.append(imapobj)
273 self.lastowner[imapobj] = thread.get_ident()
274 self.connectionlock.release()
275 return imapobj
276
277 def connectionwait(self):
278 """Waits until there is a connection available. Note that between
279 the time that a connection becomes available and the time it is
280 requested, another thread may have grabbed it. This function is
281 mainly present as a way to avoid spawning thousands of threads
282 to copy messages, then have them all wait for 3 available connections.
283 It's OK if we have maxconnections + 1 or 2 threads, which is what
284 this will help us do."""
285 threadutil.semaphorewait(self.semaphore)
286
287 def close(self):
288 # Make sure I own all the semaphores. Let the threads finish
289 # their stuff. This is a blocking method.
290 self.connectionlock.acquire()
291 threadutil.semaphorereset(self.semaphore, self.maxconnections)
292 for imapobj in self.assignedconnections + self.availableconnections:
293 imapobj.logout()
294 self.assignedconnections = []
295 self.availableconnections = []
296 self.lastowner = {}
297 self.connectionlock.release()
298
299 def keepalive(self, timeout, event):
300 """Sends a NOOP to each connection recorded. It will wait a maximum
301 of timeout seconds between doing this, and will continue to do so
302 until the Event object as passed is true. This method is expected
303 to be invoked in a separate thread, which should be join()'d after
304 the event is set."""
305 ui = UIBase.getglobalui()
306 ui.debug('imap', 'keepalive thread started')
307 while 1:
308 ui.debug('imap', 'keepalive: top of loop')
309 event.wait(timeout)
310 ui.debug('imap', 'keepalive: after wait')
311 if event.isSet():
312 ui.debug('imap', 'keepalive: event is set; exiting')
313 return
314 ui.debug('imap', 'keepalive: acquiring connectionlock')
315 self.connectionlock.acquire()
316 numconnections = len(self.assignedconnections) + \
317 len(self.availableconnections)
318 self.connectionlock.release()
319 ui.debug('imap', 'keepalive: connectionlock released')
320 threads = []
321 imapobjs = []
322
323 for i in range(numconnections):
324 ui.debug('imap', 'keepalive: processing connection %d of %d' % (i, numconnections))
325 imapobj = self.acquireconnection()
326 ui.debug('imap', 'keepalive: connection %d acquired' % i)
327 imapobjs.append(imapobj)
328 thr = threadutil.ExitNotifyThread(target = imapobj.noop)
329 thr.setDaemon(1)
330 thr.start()
331 threads.append(thr)
332 ui.debug('imap', 'keepalive: thread started')
333
334 ui.debug('imap', 'keepalive: joining threads')
335
336 for thr in threads:
337 # Make sure all the commands have completed.
338 thr.join()
339
340 ui.debug('imap', 'keepalive: releasing connections')
341
342 for imapobj in imapobjs:
343 self.releaseconnection(imapobj)
344
345 ui.debug('imap', 'keepalive: bottom of loop')
346
347 class ConfigedIMAPServer(IMAPServer):
348 """This class is designed for easier initialization given a ConfigParser
349 object and an account name. The passwordhash is used if
350 passwords for certain accounts are known. If the password for this
351 account is listed, it will be obtained from there."""
352 def __init__(self, repository, passwordhash = {}):
353 """Initialize the object. If the account is not a tunnel,
354 the password is required."""
355 self.repos = repository
356 self.config = self.repos.getconfig()
357 usetunnel = self.repos.getpreauthtunnel()
358 if not usetunnel:
359 host = self.repos.gethost()
360 user = self.repos.getuser()
361 port = self.repos.getport()
362 ssl = self.repos.getssl()
363 reference = self.repos.getreference()
364 server = None
365 password = None
366
367 if repository.getname() in passwordhash:
368 password = passwordhash[repository.getname()]
369
370 # Connect to the remote server.
371 if usetunnel:
372 IMAPServer.__init__(self, self.config, self.repos.getname(),
373 tunnel = usetunnel,
374 reference = reference,
375 maxconnections = self.repos.getmaxconnections())
376 else:
377 if not password:
378 password = self.repos.getpassword()
379 IMAPServer.__init__(self, self.config, self.repos.getname(),
380 user, password, host, port, ssl,
381 self.repos.getmaxconnections(),
382 reference = reference)