]> code.delx.au - offlineimap/blob - offlineimap/imapserver.py
Added comment
[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
25
26 class UsefulIMAPMixIn:
27 def getstate(self):
28 return self.state
29 def getselectedfolder(self):
30 if self.getstate() == 'SELECTED':
31 return self.selectedfolder
32 return None
33
34 def select(self, mailbox='INBOX', readonly=None, force = 0):
35 if (not force) and self.getselectedfolder() == mailbox \
36 and self.is_readonly == readonly:
37 # No change; return.
38 return
39 result = self.__class__.__bases__[1].select(self, mailbox, readonly)
40 if result[0] != 'OK':
41 raise ValueError, "Error from select: %s" % str(result)
42 if self.getstate() == 'SELECTED':
43 self.selectedfolder = mailbox
44 else:
45 self.selectedfolder = None
46
47 def _mesg(self, s, secs=None):
48 imaplibutil.new_mesg(self, s, secs)
49
50 class UsefulIMAP4(UsefulIMAPMixIn, imaplib.IMAP4):
51 def open(self, host = '', port = imaplib.IMAP4_PORT):
52 imaplibutil.new_open(self, host, port)
53
54 class UsefulIMAP4_SSL(UsefulIMAPMixIn, imaplib.IMAP4_SSL):
55 def open(self, host = '', port = imaplib.IMAP4_SSL_PORT):
56 imaplibutil.new_open_ssl(self, host, port)
57
58 class UsefulIMAP4_Tunnel(UsefulIMAPMixIn, imaplibutil.IMAP4_Tunnel): pass
59
60 class IMAPServer:
61 def __init__(self, config, reposname,
62 username = None, password = None, hostname = None,
63 port = None, ssl = 1, maxconnections = 1, tunnel = None,
64 reference = '""'):
65 self.reposname = reposname
66 self.config = config
67 self.username = username
68 self.password = password
69 self.passworderror = None
70 self.hostname = hostname
71 self.tunnel = tunnel
72 self.port = port
73 self.usessl = ssl
74 self.delim = None
75 self.root = None
76 if port == None:
77 if ssl:
78 self.port = 993
79 else:
80 self.port = 143
81 self.maxconnections = maxconnections
82 self.availableconnections = []
83 self.assignedconnections = []
84 self.lastowner = {}
85 self.semaphore = BoundedSemaphore(self.maxconnections)
86 self.connectionlock = Lock()
87 self.reference = reference
88
89 def getpassword(self):
90 if self.password != None and self.passworderror == None:
91 return self.password
92
93 self.password = UIBase.getglobalui().getpass(self.reposname,
94 self.config,
95 self.passworderror)
96 self.passworderror = None
97
98 return self.password
99
100 def getdelim(self):
101 """Returns this server's folder delimiter. Can only be called
102 after one or more calls to acquireconnection."""
103 return self.delim
104
105 def getroot(self):
106 """Returns this server's folder root. Can only be called after one
107 or more calls to acquireconnection."""
108 return self.root
109
110
111 def releaseconnection(self, connection):
112 """Releases a connection, returning it to the pool."""
113 self.connectionlock.acquire()
114 self.assignedconnections.remove(connection)
115 self.availableconnections.append(connection)
116 self.connectionlock.release()
117 self.semaphore.release()
118
119 def md5handler(self, response):
120 ui = UIBase.getglobalui()
121 challenge = response.strip()
122 ui.debug('imap', 'md5handler: got challenge %s' % challenge)
123
124 passwd = self.getpassword()
125 retval = self.username + ' ' + hmac.new(passwd, challenge).hexdigest()
126 ui.debug('imap', 'md5handler: returning %s' % retval)
127 return retval
128
129 def plainauth(self, imapobj):
130 UIBase.getglobalui().debug('imap',
131 'Attempting plain authentication')
132 imapobj.login(self.username, self.getpassword())
133
134
135 def acquireconnection(self):
136 """Fetches a connection from the pool, making sure to create a new one
137 if needed, to obey the maximum connection limits, etc.
138 Opens a connection to the server and returns an appropriate
139 object."""
140
141 self.semaphore.acquire()
142 self.connectionlock.acquire()
143 imapobj = None
144
145 if len(self.availableconnections): # One is available.
146 # Try to find one that previously belonged to this thread
147 # as an optimization. Start from the back since that's where
148 # they're popped on.
149 threadid = thread.get_ident()
150 imapobj = None
151 for i in range(len(self.availableconnections) - 1, -1, -1):
152 tryobj = self.availableconnections[i]
153 if self.lastowner[tryobj] == threadid:
154 imapobj = tryobj
155 del(self.availableconnections[i])
156 break
157 if not imapobj:
158 imapobj = self.availableconnections[0]
159 del(self.availableconnections[0])
160 self.assignedconnections.append(imapobj)
161 self.lastowner[imapobj] = thread.get_ident()
162 self.connectionlock.release()
163 return imapobj
164
165 self.connectionlock.release() # Release until need to modify data
166
167 success = 0
168 while not success:
169 # Generate a new connection.
170 if self.tunnel:
171 UIBase.getglobalui().connecting('tunnel', self.tunnel)
172 imapobj = UsefulIMAP4_Tunnel(self.tunnel)
173 success = 1
174 elif self.usessl:
175 UIBase.getglobalui().connecting(self.hostname, self.port)
176 imapobj = UsefulIMAP4_SSL(self.hostname, self.port)
177 else:
178 UIBase.getglobalui().connecting(self.hostname, self.port)
179 imapobj = UsefulIMAP4(self.hostname, self.port)
180
181 imapobj.mustquote = imaplibutil.mustquote
182
183 if not self.tunnel:
184 try:
185 if 'AUTH=CRAM-MD5' in imapobj.capabilities:
186 UIBase.getglobalui().debug('imap',
187 'Attempting CRAM-MD5 authentication')
188 try:
189 imapobj.authenticate('CRAM-MD5', self.md5handler)
190 except imapobj.error, val:
191 self.plainauth(imapobj)
192 else:
193 self.plainauth(imapobj)
194 # Would bail by here if there was a failure.
195 success = 1
196 except imapobj.error, val:
197 self.passworderror = str(val)
198 self.password = None
199
200 if self.delim == None:
201 listres = imapobj.list(self.reference, '""')[1]
202 if listres == [None] or listres == None:
203 # Some buggy IMAP servers do not respond well to LIST "" ""
204 # Work around them.
205 listres = imapobj.list(self.reference, '"*"')[1]
206 self.delim, self.root = \
207 imaputil.imapsplit(listres[0])[1:]
208 self.delim = imaputil.dequote(self.delim)
209 self.root = imaputil.dequote(self.root)
210
211 self.connectionlock.acquire()
212 self.assignedconnections.append(imapobj)
213 self.lastowner[imapobj] = thread.get_ident()
214 self.connectionlock.release()
215 return imapobj
216
217 def connectionwait(self):
218 """Waits until there is a connection available. Note that between
219 the time that a connection becomes available and the time it is
220 requested, another thread may have grabbed it. This function is
221 mainly present as a way to avoid spawning thousands of threads
222 to copy messages, then have them all wait for 3 available connections.
223 It's OK if we have maxconnections + 1 or 2 threads, which is what
224 this will help us do."""
225 threadutil.semaphorewait(self.semaphore)
226
227 def close(self):
228 # Make sure I own all the semaphores. Let the threads finish
229 # their stuff. This is a blocking method.
230 self.connectionlock.acquire()
231 threadutil.semaphorereset(self.semaphore, self.maxconnections)
232 for imapobj in self.assignedconnections + self.availableconnections:
233 imapobj.logout()
234 self.assignedconnections = []
235 self.availableconnections = []
236 self.lastowner = {}
237 self.connectionlock.release()
238
239 def keepalive(self, timeout, event):
240 """Sends a NOOP to each connection recorded. It will wait a maximum
241 of timeout seconds between doing this, and will continue to do so
242 until the Event object as passed is true. This method is expected
243 to be invoked in a separate thread, which should be join()'d after
244 the event is set."""
245 ui = UIBase.getglobalui()
246 ui.debug('imap', 'keepalive thread started')
247 while 1:
248 ui.debug('imap', 'keepalive: top of loop')
249 event.wait(timeout)
250 ui.debug('imap', 'keepalive: after wait')
251 if event.isSet():
252 ui.debug('imap', 'keepalive: event is set; exiting')
253 return
254 ui.debug('imap', 'keepalive: acquiring connectionlock')
255 self.connectionlock.acquire()
256 numconnections = len(self.assignedconnections) + \
257 len(self.availableconnections)
258 self.connectionlock.release()
259 ui.debug('imap', 'keepalive: connectionlock released')
260 threads = []
261 imapobjs = []
262
263 for i in range(numconnections):
264 ui.debug('imap', 'keepalive: processing connection %d of %d' % (i, numconnections))
265 imapobj = self.acquireconnection()
266 ui.debug('imap', 'keepalive: connection %d acquired' % i)
267 imapobjs.append(imapobj)
268 thr = threadutil.ExitNotifyThread(target = imapobj.noop)
269 thr.setDaemon(1)
270 thr.start()
271 threads.append(thr)
272 ui.debug('imap', 'keepalive: thread started')
273
274 ui.debug('imap', 'keepalive: joining threads')
275
276 for thr in threads:
277 # Make sure all the commands have completed.
278 thr.join()
279
280 ui.debug('imap', 'keepalive: releasing connections')
281
282 for imapobj in imapobjs:
283 self.releaseconnection(imapobj)
284
285 ui.debug('imap', 'keepalive: bottom of loop')
286
287 class ConfigedIMAPServer(IMAPServer):
288 """This class is designed for easier initialization given a ConfigParser
289 object and an account name. The passwordhash is used if
290 passwords for certain accounts are known. If the password for this
291 account is listed, it will be obtained from there."""
292 def __init__(self, repository, passwordhash = {}):
293 """Initialize the object. If the account is not a tunnel,
294 the password is required."""
295 self.repos = repository
296 self.config = self.repos.getconfig()
297 usetunnel = self.repos.getpreauthtunnel()
298 if not usetunnel:
299 host = self.repos.gethost()
300 user = self.repos.getuser()
301 port = self.repos.getport()
302 ssl = self.repos.getssl()
303 reference = self.repos.getreference()
304 server = None
305 password = None
306
307 if repository.getname() in passwordhash:
308 password = passwordhash[repository.getname()]
309
310 # Connect to the remote server.
311 if usetunnel:
312 IMAPServer.__init__(self, self.config, self.repos.getname(),
313 tunnel = usetunnel,
314 reference = reference,
315 maxconnections = self.repos.getmaxconnections())
316 else:
317 if not password:
318 password = self.repos.getpassword()
319 IMAPServer.__init__(self, self.config, self.repos.getname(),
320 user, password, host, port, ssl,
321 self.repos.getmaxconnections(),
322 reference = reference)