imapserver.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  1. # IMAP server support
  2. # Copyright (C) 2002-2018 John Goerzen & contributors.
  3. #
  4. # This program is free software; you can redistribute it and/or modify
  5. # it under the terms of the GNU General Public License as published by
  6. # the Free Software Foundation; either version 2 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU General Public License
  15. # along with this program; if not, write to the Free Software
  16. # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
  17. import datetime
  18. import hashlib
  19. import hmac
  20. import json
  21. import urllib.request
  22. import urllib.parse
  23. import urllib.error
  24. import time
  25. import errno
  26. import socket
  27. from socket import gaierror
  28. from sys import exc_info
  29. from ssl import SSLError, cert_time_to_seconds
  30. from threading import Lock, BoundedSemaphore, Thread, Event, current_thread
  31. import offlineimap.accounts
  32. from offlineimap import imaplibutil, imaputil, threadutil, OfflineImapError
  33. from offlineimap.ui import getglobalui
  34. try:
  35. import gssapi
  36. have_gss = True
  37. except ImportError:
  38. have_gss = False
  39. class IMAPServer:
  40. """Initializes all variables from an IMAPRepository() instance
  41. Various functions, such as acquireconnection() return an IMAP4
  42. object on which we can operate.
  43. Public instance variables are: self.:
  44. delim The server's folder delimiter. Only valid after acquireconnection()
  45. """
  46. def __init__(self, repos):
  47. """:repos: a IMAPRepository instance."""
  48. self.ui = getglobalui()
  49. self.repos = repos
  50. self.config = repos.getconfig()
  51. self.ignore_keyring = self.config.getboolean('general', 'ignore-keyring')
  52. self.update_keyring = self.config.getboolean('general', 'update-keyring')
  53. self.preauth_tunnel = repos.getpreauthtunnel()
  54. self.transport_tunnel = repos.gettransporttunnel()
  55. if self.preauth_tunnel and self.transport_tunnel:
  56. raise OfflineImapError('%s: ' % repos +
  57. 'you must enable precisely one '
  58. 'type of tunnel (preauth or transport), '
  59. 'not both', OfflineImapError.ERROR.REPO)
  60. self.tunnel = \
  61. self.preauth_tunnel if self.preauth_tunnel \
  62. else self.transport_tunnel
  63. self.username = \
  64. None if self.preauth_tunnel else repos.getuser()
  65. self.user_identity = repos.get_remote_identity()
  66. self.authmechs = repos.get_auth_mechanisms()
  67. self.password = None
  68. self.passworderror = None
  69. self.goodpassword = None
  70. self.usessl = repos.getssl()
  71. self.useipv6 = repos.getipv6()
  72. if self.useipv6 is True:
  73. self.af = socket.AF_INET6
  74. elif self.useipv6 is False:
  75. self.af = socket.AF_INET
  76. else:
  77. self.af = socket.AF_UNSPEC
  78. self.hostname = None if self.transport_tunnel or self.preauth_tunnel else repos.gethost()
  79. self.port = repos.getport()
  80. if self.port is None:
  81. self.port = 993 if self.usessl else 143
  82. self.sslclientcert = repos.getsslclientcert()
  83. self.sslclientkey = repos.getsslclientkey()
  84. self.sslcacertfile = repos.getsslcacertfile()
  85. if self.sslcacertfile is None:
  86. self.__verifycert = None # Disable cert verification.
  87. # This way of working sucks hard...
  88. self.fingerprint = repos.get_ssl_fingerprint()
  89. self.tlslevel = repos.gettlslevel()
  90. self.sslversion = repos.getsslversion()
  91. self.starttls = repos.getstarttls()
  92. if self.usessl \
  93. and self.tlslevel != "tls_compat" \
  94. and self.sslversion is None:
  95. raise Exception("When 'tls_level' is not 'tls_compat' "
  96. "the 'ssl_version' must be set explicitly.")
  97. self.oauth2_refresh_token = repos.getoauth2_refresh_token()
  98. self.oauth2_access_token = repos.getoauth2_access_token()
  99. self.oauth2_client_id = repos.getoauth2_client_id()
  100. self.oauth2_client_secret = repos.getoauth2_client_secret()
  101. self.oauth2_request_url = repos.getoauth2_request_url()
  102. self.oauth2_access_token_expires_at = None
  103. self.delim = None
  104. self.root = None
  105. self.maxconnections = repos.getmaxconnections()
  106. self.availableconnections = []
  107. self.assignedconnections = []
  108. self.lastowner = {}
  109. self.semaphore = BoundedSemaphore(self.maxconnections)
  110. self.connectionlock = Lock()
  111. self.reference = repos.getreference()
  112. self.idlefolders = repos.getidlefolders()
  113. self.gss_vc = None
  114. self.gssapi = False
  115. # In order to support proxy connection, we have to override the
  116. # default socket instance with our own socksified socket instance.
  117. # We add this option to bypass the GFW in China.
  118. self.proxied_socket = self._get_proxy('proxy', socket.socket)
  119. # Turns out that the GFW in China is no longer blocking imap.gmail.com
  120. # However accounts.google.com (for oauth2) definitey is. Therefore
  121. # it is not strictly necessary to use a proxy for *both* IMAP *and*
  122. # oauth2, so a new option is added: authproxy.
  123. # Set proxy for use in authentication (only) if desired.
  124. # If not set, is same as proxy option (compatible with current configs)
  125. # To use a proxied_socket but not an authproxied_socket
  126. # set authproxy = '' in config
  127. self.authproxied_socket = self._get_proxy('authproxy',
  128. self.proxied_socket)
  129. def _get_proxy(self, proxysection, dfltsocket):
  130. _account_section = 'Account ' + self.repos.account.name
  131. if not self.config.has_option(_account_section, proxysection):
  132. return dfltsocket
  133. proxy = self.config.get(_account_section, proxysection)
  134. if proxy == '':
  135. # explicitly set no proxy (overrides default return of dfltsocket)
  136. return socket.socket
  137. # Powered by PySocks.
  138. try:
  139. import socks
  140. proxy_type, host, port = proxy.split(":")
  141. port = int(port)
  142. socks.setdefaultproxy(getattr(socks, proxy_type), host, port)
  143. return socks.socksocket
  144. except ImportError:
  145. self.ui.warn("PySocks not installed, ignoring proxy option.")
  146. except (AttributeError, ValueError) as e:
  147. self.ui.warn("Bad proxy option %s for account %s: %s "
  148. "Ignoring %s option." %
  149. (proxy, self.repos.account.name, e, proxysection))
  150. return dfltsocket
  151. def __getpassword(self):
  152. """Returns the server password or None"""
  153. if self.goodpassword is not None: # use cached good one first
  154. return self.goodpassword
  155. if self.password is not None and self.passworderror is None:
  156. return self.password # non-failed preconfigured one
  157. # get 1) configured password first 2) fall back to asking via UI
  158. self.password = self.repos.getpassword(self.ignore_keyring) or \
  159. self.ui.getpass(self.username, self.config, self.passworderror)
  160. if self.update_keyring:
  161. self.repos.updatepassword(self.password)
  162. self.passworderror = None
  163. return self.password
  164. def __md5handler(self, response):
  165. challenge = response.strip()
  166. self.ui.debug('imap', '__md5handler: got challenge %s' % challenge)
  167. passwd = self.__getpassword()
  168. retval = self.username + ' ' +\
  169. hmac.new(bytes(passwd, encoding='utf-8'), challenge,
  170. digestmod=hashlib.md5).hexdigest()
  171. self.ui.debug('imap', '__md5handler: returning %s' % retval)
  172. return retval
  173. def __loginauth(self, imapobj):
  174. """ Basic authentication via LOGIN command."""
  175. self.ui.debug('imap', 'Attempting IMAP LOGIN authentication')
  176. imapobj.login(self.username, self.__getpassword())
  177. def __plainhandler(self, response):
  178. """Implements SASL PLAIN authentication, RFC 4616,
  179. http://tools.ietf.org/html/rfc4616"""
  180. authc = self.username
  181. if not authc:
  182. raise OfflineImapError("No username provided for '%s'"
  183. % self.repos.getname(),
  184. OfflineImapError.ERROR.REPO)
  185. passwd = self.__getpassword()
  186. authz = ''
  187. NULL = '\x00'
  188. if self.user_identity is not None:
  189. authz = self.user_identity
  190. retval = NULL.join((authz, authc, passwd))
  191. self.ui.debug('imap', '__plainhandler: returning %s %s '
  192. '(passwd hidden for log)' % (authz, authc))
  193. return retval
  194. def __xoauth2handler(self, response):
  195. now = datetime.datetime.now()
  196. if self.oauth2_access_token_expires_at \
  197. and self.oauth2_access_token_expires_at < now:
  198. self.oauth2_access_token = None
  199. self.ui.debug('imap', 'xoauth2handler: oauth2_access_token expired')
  200. if self.oauth2_access_token is None:
  201. if self.oauth2_request_url is None:
  202. raise OfflineImapError("No remote oauth2_request_url for "
  203. "repository '%s' specified." %
  204. self, OfflineImapError.ERROR.REPO)
  205. # Generate new access token.
  206. params = {}
  207. params['client_id'] = self.oauth2_client_id
  208. params['client_secret'] = self.oauth2_client_secret
  209. params['refresh_token'] = self.oauth2_refresh_token
  210. params['grant_type'] = 'refresh_token'
  211. self.ui.debug('imap', 'xoauth2handler: url "%s"' %
  212. self.oauth2_request_url)
  213. self.ui.debug('imap', 'xoauth2handler: params "%s"' % params)
  214. original_socket = socket.socket
  215. socket.socket = self.authproxied_socket
  216. try:
  217. response = urllib.request.urlopen(
  218. self.oauth2_request_url, urllib.parse.urlencode(params).encode('utf-8')).read()
  219. except Exception as e:
  220. try:
  221. msg = "%s (configuration is: %s)" % (e, str(params))
  222. except Exception as eparams:
  223. msg = "%s [cannot display configuration: %s]" % (e, eparams)
  224. self.ui.error(msg)
  225. raise
  226. finally:
  227. socket.socket = original_socket
  228. resp = json.loads(response)
  229. self.ui.debug('imap', 'xoauth2handler: response "%s"' % resp)
  230. if 'error' in resp:
  231. raise OfflineImapError("xoauth2handler got: %s" % resp,
  232. OfflineImapError.ERROR.REPO)
  233. self.oauth2_access_token = resp['access_token']
  234. if 'expires_in' in resp:
  235. self.oauth2_access_token_expires_at = now + datetime.timedelta(
  236. seconds=resp['expires_in'] / 2
  237. )
  238. self.ui.debug('imap', 'xoauth2handler: access_token "%s expires %s"' % (
  239. self.oauth2_access_token, self.oauth2_access_token_expires_at))
  240. auth_string = 'user=%s\1auth=Bearer %s\1\1' % (
  241. self.username, self.oauth2_access_token)
  242. # auth_string = base64.b64encode(auth_string)
  243. self.ui.debug('imap', 'xoauth2handler: returning "%s"' % auth_string)
  244. return auth_string
  245. # Perform the next step handling a GSSAPI connection.
  246. # Client sends first, so token will be ignored if there is no context.
  247. def __gsshandler(self, token):
  248. if token == "":
  249. token = None
  250. try:
  251. if not self.gss_vc:
  252. name = gssapi.Name('imap@' + self.hostname,
  253. gssapi.NameType.hostbased_service)
  254. self.gss_vc = gssapi.SecurityContext(usage="initiate",
  255. name=name)
  256. if not self.gss_vc.complete:
  257. response = self.gss_vc.step(token)
  258. return response if response else ""
  259. elif token is None:
  260. # uh... context is complete, so there's no negotiation we can
  261. # do. But we also don't have a token, so we can't send any
  262. # kind of response. Empirically, some (but not all) servers
  263. # seem to put us in this state, and seem fine with getting no
  264. # GSSAPI content in response, so give it to them.
  265. return ""
  266. # Don't bother checking qop because we're over a TLS channel
  267. # already. But hey, if some server started encrypting tomorrow,
  268. # we'd be ready since krb5 always requests integrity and
  269. # confidentiality support.
  270. response = self.gss_vc.unwrap(token)
  271. # This is a behavior we got from pykerberos. First byte is one,
  272. # first four bytes are preserved (pykerberos calls this a length).
  273. # Any additional bytes are username.
  274. reply = b'\x01' + response.message[1:4]
  275. reply += bytes(self.username, 'utf-8')
  276. response = self.gss_vc.wrap(reply, response.encrypted)
  277. return response.message if response.message else ""
  278. except gssapi.exceptions.GSSError as err:
  279. # GSSAPI errored out on us; respond with None to cancel the
  280. # authentication
  281. self.ui.debug('imap', err.gen_message())
  282. return None
  283. def __start_tls(self, imapobj):
  284. if 'STARTTLS' in imapobj.capabilities and not self.usessl:
  285. self.ui.debug('imap', 'Using STARTTLS connection')
  286. try:
  287. imapobj.starttls()
  288. except imapobj.error as e:
  289. raise OfflineImapError("Failed to start "
  290. "TLS connection: %s" % str(e),
  291. OfflineImapError.ERROR.REPO,
  292. exc_info()[2])
  293. # All __authn_* procedures are helpers that do authentication.
  294. # They are class methods that take one parameter, IMAP object.
  295. #
  296. # Each function should return True if authentication was
  297. # successful and False if authentication wasn't even tried
  298. # for some reason (but not when IMAP has no such authentication
  299. # capability, calling code checks that).
  300. #
  301. # Functions can also raise exceptions; two types are special
  302. # and will be handled by the calling code:
  303. #
  304. # - imapobj.error means that there was some error that
  305. # comes from imaplib2;
  306. #
  307. # - OfflineImapError means that function detected some
  308. # problem by itself.
  309. def __authn_gssapi(self, imapobj):
  310. if not have_gss:
  311. return False
  312. self.connectionlock.acquire()
  313. try:
  314. imapobj.authenticate('GSSAPI', self.__gsshandler)
  315. return True
  316. except imapobj.error:
  317. self.gssapi = False
  318. raise
  319. finally:
  320. self.connectionlock.release()
  321. def __authn_cram_md5(self, imapobj):
  322. imapobj.authenticate('CRAM-MD5', self.__md5handler)
  323. return True
  324. def __authn_plain(self, imapobj):
  325. imapobj.authenticate('PLAIN', self.__plainhandler)
  326. return True
  327. def __authn_xoauth2(self, imapobj):
  328. if self.oauth2_refresh_token is None \
  329. and self.oauth2_access_token is None:
  330. return False
  331. imapobj.authenticate('XOAUTH2', self.__xoauth2handler)
  332. return True
  333. def __authn_login(self, imapobj):
  334. # Use LOGIN command, unless LOGINDISABLED is advertized
  335. # (per RFC 2595)
  336. if 'LOGINDISABLED' in imapobj.capabilities:
  337. raise OfflineImapError("IMAP LOGIN is "
  338. "disabled by server. Need to use SSL?",
  339. OfflineImapError.ERROR.REPO)
  340. else:
  341. self.__loginauth(imapobj)
  342. return True
  343. def __authn_helper(self, imapobj):
  344. """Authentication machinery for self.acquireconnection().
  345. Raises OfflineImapError() of type ERROR.REPO when
  346. there are either fatal problems or no authentications
  347. succeeded.
  348. If any authentication method succeeds, routine should exit:
  349. warnings for failed methods are to be produced in the
  350. respective except blocks."""
  351. # Stack stores pairs of (method name, exception)
  352. exc_stack = []
  353. tried_to_authn = False
  354. tried_tls = False
  355. # Authentication routines, hash keyed by method name
  356. # with value that is a tuple with
  357. # - authentication function,
  358. # - tryTLS flag,
  359. # - check IMAP capability flag.
  360. auth_methods = {
  361. "GSSAPI": (self.__authn_gssapi, False, True),
  362. "XOAUTH2": (self.__authn_xoauth2, True, True),
  363. "CRAM-MD5": (self.__authn_cram_md5, True, True),
  364. "PLAIN": (self.__authn_plain, True, True),
  365. "LOGIN": (self.__authn_login, True, False),
  366. }
  367. # GSSAPI is tried first by default: we will probably go TLS after it and
  368. # GSSAPI mustn't be tunneled over TLS.
  369. for m in self.authmechs:
  370. if m not in auth_methods:
  371. raise Exception("Bad authentication method %s, "
  372. "please, file OfflineIMAP bug" % m)
  373. func, tryTLS, check_cap = auth_methods[m]
  374. # TLS must be initiated before checking capabilities:
  375. # they could have been changed after STARTTLS.
  376. if tryTLS and self.starttls and not tried_tls:
  377. tried_tls = True
  378. self.__start_tls(imapobj)
  379. if check_cap:
  380. cap = "AUTH=" + m
  381. if cap not in imapobj.capabilities:
  382. continue
  383. tried_to_authn = True
  384. self.ui.debug('imap', 'Attempting '
  385. '%s authentication' % m)
  386. try:
  387. if func(imapobj):
  388. return
  389. except (imapobj.error, OfflineImapError) as e:
  390. self.ui.warn('%s authentication failed: %s' % (m, e))
  391. exc_stack.append((m, e))
  392. if len(exc_stack):
  393. msg = "\n\t".join([": ".join((x[0], str(x[1]))) for x in exc_stack])
  394. raise OfflineImapError("All authentication types "
  395. "failed:\n\t%s" % msg, OfflineImapError.ERROR.REPO)
  396. if not tried_to_authn:
  397. methods = ", ".join([x[5:] for x in
  398. [x for x in imapobj.capabilities if x[0:5] == "AUTH="]])
  399. raise OfflineImapError("Repository %s: no supported "
  400. "authentication mechanisms found; configured %s, "
  401. "server advertises %s" % (self.repos,
  402. ", ".join(self.authmechs), methods),
  403. OfflineImapError.ERROR.REPO)
  404. def __verifycert(self, cert, hostname):
  405. """Verify that cert (in socket.getpeercert() format) matches hostname.
  406. CRLs are not handled.
  407. Returns error message if any problems are found and None on success."""
  408. errstr = "CA Cert verifying failed: "
  409. if not cert:
  410. return '%s no certificate received' % errstr
  411. dnsname = hostname.lower()
  412. certnames = []
  413. # cert expired?
  414. notafter = cert.get('notAfter')
  415. if notafter:
  416. if time.time() >= cert_time_to_seconds(notafter):
  417. return '%s certificate expired %s' % (errstr, notafter)
  418. # First read commonName
  419. for s in cert.get('subject', []):
  420. key, value = s[0]
  421. if key == 'commonName':
  422. certnames.append(value.lower())
  423. if len(certnames) == 0:
  424. return '%s no commonName found in certificate' % errstr
  425. # Then read subjectAltName
  426. for key, value in cert.get('subjectAltName', []):
  427. if key == 'DNS':
  428. certnames.append(value.lower())
  429. # And finally try to match hostname with one of these names
  430. for certname in certnames:
  431. if (certname == dnsname or
  432. '.' in dnsname and certname == '*.' + dnsname.split('.', 1)[1]):
  433. return None
  434. return '%s no matching domain name found in certificate' % errstr
  435. def acquireconnection(self):
  436. """Fetches a connection from the pool, making sure to create a new one
  437. if needed, to obey the maximum connection limits, etc.
  438. Opens a connection to the server and returns an appropriate
  439. object."""
  440. self.semaphore.acquire()
  441. self.connectionlock.acquire()
  442. curThread = current_thread()
  443. imapobj = None
  444. imap_debug = 0
  445. if 'imap' in self.ui.debuglist:
  446. imap_debug = 5
  447. if len(self.availableconnections): # One is available.
  448. # Try to find one that previously belonged to this thread
  449. # as an optimization. Start from the back since that's where
  450. # they're popped on.
  451. for i in range(len(self.availableconnections) - 1, -1, -1):
  452. tryobj = self.availableconnections[i]
  453. if self.lastowner[tryobj] == curThread.ident:
  454. imapobj = tryobj
  455. del (self.availableconnections[i])
  456. break
  457. if not imapobj:
  458. imapobj = self.availableconnections[0]
  459. del (self.availableconnections[0])
  460. self.assignedconnections.append(imapobj)
  461. self.lastowner[imapobj] = curThread.ident
  462. self.connectionlock.release()
  463. return imapobj
  464. self.connectionlock.release() # Release until need to modify data
  465. # Must be careful here that if we fail we should bail out gracefully
  466. # and release locks / threads so that the next attempt can try...
  467. success = False
  468. try:
  469. while success is not True:
  470. # Generate a new connection.
  471. if self.tunnel:
  472. self.ui.connecting(
  473. self.repos.getname(), 'tunnel', self.tunnel)
  474. imapobj = imaplibutil.IMAP4_Tunnel(
  475. self.tunnel,
  476. timeout=socket.getdefaulttimeout(),
  477. debug=imap_debug,
  478. use_socket=self.proxied_socket,
  479. )
  480. success = True
  481. elif self.usessl:
  482. self.ui.connecting(
  483. self.repos.getname(), self.hostname, self.port)
  484. self.ui.debug('imap', "%s: level '%s', version '%s'" %
  485. (self.repos.getname(), self.tlslevel, self.sslversion))
  486. imapobj = imaplibutil.WrappedIMAP4_SSL(
  487. host=self.hostname,
  488. port=self.port,
  489. keyfile=self.sslclientkey,
  490. certfile=self.sslclientcert,
  491. ca_certs=self.sslcacertfile,
  492. cert_verify_cb=self.__verifycert,
  493. ssl_version=self.sslversion,
  494. debug=imap_debug,
  495. timeout=socket.getdefaulttimeout(),
  496. fingerprint=self.fingerprint,
  497. use_socket=self.proxied_socket,
  498. tls_level=self.tlslevel,
  499. af=self.af,
  500. )
  501. else:
  502. self.ui.connecting(
  503. self.repos.getname(), self.hostname, self.port)
  504. imapobj = imaplibutil.WrappedIMAP4(
  505. self.hostname, self.port,
  506. timeout=socket.getdefaulttimeout(),
  507. use_socket=self.proxied_socket,
  508. debug=imap_debug,
  509. af=self.af,
  510. )
  511. # If 'ID' extension is used by the server, we should use it
  512. if 'ID' in imapobj.capabilities:
  513. l_str = '("name" "OfflineIMAP" "version" "{}")'.format(offlineimap.__version__)
  514. imapobj.id(l_str)
  515. if not self.preauth_tunnel:
  516. try:
  517. self.__authn_helper(imapobj)
  518. self.goodpassword = self.password
  519. success = True
  520. except OfflineImapError as e:
  521. self.passworderror = str(e)
  522. raise
  523. # Enable compression
  524. if self.repos.getconfboolean('usecompression', 0):
  525. imapobj.enable_compression()
  526. # update capabilities after login, e.g. gmail serves different ones
  527. typ, dat = imapobj.capability()
  528. if dat != [None]:
  529. # Get the capabilities and convert them to string from bytes
  530. s_dat = [x.decode('utf-8') for x in dat[-1].upper().split()]
  531. imapobj.capabilities = tuple(s_dat)
  532. if self.delim is None:
  533. listres = imapobj.list(self.reference, '""')[1]
  534. if listres == [None] or listres is None:
  535. # Some buggy IMAP servers do not respond well to LIST "" ""
  536. # Work around them.
  537. listres = imapobj.list(self.reference, '"*"')[1]
  538. if listres == [None] or listres is None:
  539. # No Folders were returned. This occurs, e.g. if the
  540. # 'reference' prefix does not exist on the mail
  541. # server. Raise exception.
  542. err = "Server '%s' returned no folders in '%s'" % \
  543. (self.repos.getname(), self.reference)
  544. self.ui.warn(err)
  545. raise Exception(err)
  546. self.delim, self.root = \
  547. imaputil.imapsplit(listres[0])[1:]
  548. self.delim = imaputil.dequote(self.delim)
  549. self.root = imaputil.dequote(self.root)
  550. with self.connectionlock:
  551. self.assignedconnections.append(imapobj)
  552. self.lastowner[imapobj] = curThread.ident
  553. return imapobj
  554. except Exception as e:
  555. """If we are here then we did not succeed in getting a
  556. connection - we should clean up and then re-raise the
  557. error..."""
  558. self.semaphore.release()
  559. severity = OfflineImapError.ERROR.REPO
  560. if type(e) == gaierror:
  561. # DNS related errors. Abort Repo sync
  562. # TODO: special error msg for e.errno == 2 "Name or service not known"?
  563. reason = "Could not resolve name '%s' for repository " \
  564. "'%s'. Make sure you have configured the ser" \
  565. "ver name correctly and that you are online." % \
  566. (self.hostname, self.repos)
  567. raise OfflineImapError(reason, severity, exc_info()[2])
  568. if isinstance(e, SSLError) and e.errno == errno.EPERM:
  569. # SSL unknown protocol error
  570. # happens e.g. when connecting via SSL to a non-SSL service
  571. if self.port != 993:
  572. reason = "Could not connect via SSL to host '%s' and non-s" \
  573. "tandard ssl port %d configured. Make sure you connect" \
  574. " to the correct port. Got: %s" % (
  575. self.hostname, self.port, e)
  576. else:
  577. reason = "Unknown SSL protocol connecting to host '%s' for " \
  578. "repository '%s'. OpenSSL responded:\n%s" \
  579. % (self.hostname, self.repos, e)
  580. raise OfflineImapError(reason, severity, exc_info()[2])
  581. if isinstance(e, socket.error) and e.args and e.args[0] == errno.ECONNREFUSED:
  582. # "Connection refused", can be a non-existing port, or an unauthorized
  583. # webproxy (open WLAN?)
  584. reason = "Connection to host '%s:%d' for repository '%s' was " \
  585. "refused. Make sure you have the right host and port " \
  586. "configured and that you are actually able to access the " \
  587. "network." % (self.hostname, self.port, self.repos)
  588. raise OfflineImapError(reason, severity, exc_info()[2])
  589. # Could not acquire connection to the remote;
  590. # socket.error(last_error) raised
  591. if str(e)[:24] == "can't open socket; error":
  592. raise OfflineImapError(
  593. "Could not connect to remote server '%s' "
  594. "for repository '%s'. Remote does not answer." % (self.hostname, self.repos),
  595. OfflineImapError.ERROR.REPO,
  596. exc_info()[2])
  597. if e.args:
  598. try:
  599. if e.args[0][:35] == 'IMAP4 protocol error: socket error:':
  600. raise OfflineImapError(
  601. "Could not connect to remote server '{}' "
  602. "for repository '{}'. Connection Refused.".format(
  603. self.hostname, self.repos),
  604. OfflineImapError.ERROR.CRITICAL)
  605. except:
  606. pass
  607. # re-raise all other errors
  608. raise
  609. def connectionwait(self):
  610. """Waits until there is a connection available.
  611. Note that between the time that a connection becomes available and the
  612. time it is requested, another thread may have grabbed it. This function
  613. is mainly present as a way to avoid spawning thousands of threads to
  614. copy messages, then have them all wait for 3 available connections.
  615. It's OK if we have maxconnections + 1 or 2 threads, which is what this
  616. will help us do."""
  617. self.semaphore.acquire() # Blocking until maxconnections has free slots.
  618. self.semaphore.release()
  619. def close(self):
  620. # Make sure I own all the semaphores. Let the threads finish
  621. # their stuff. This is a blocking method.
  622. with self.connectionlock:
  623. # first, wait till all connections had been released.
  624. # TODO: won't work IMHO, as releaseconnection() also
  625. # requires the connectionlock, leading to a potential
  626. # deadlock! Audit & check!
  627. threadutil.semaphorereset(self.semaphore, self.maxconnections)
  628. for imapobj in self.assignedconnections + self.availableconnections:
  629. imapobj.logout()
  630. self.assignedconnections = []
  631. self.availableconnections = []
  632. self.lastowner = {}
  633. # reset GSSAPI state
  634. self.gss_vc = None
  635. self.gssapi = False
  636. def keepalive(self, timeout, event):
  637. """Sends a NOOP to each connection recorded.
  638. It will wait a maximum of timeout seconds between doing this, and will
  639. continue to do so until the Event object as passed is true. This method
  640. is expected to be invoked in a separate thread, which should be join()'d
  641. after the event is set."""
  642. self.ui.debug('imap', 'keepalive thread started')
  643. while not event.isSet():
  644. self.connectionlock.acquire()
  645. numconnections = len(self.assignedconnections) + \
  646. len(self.availableconnections)
  647. self.connectionlock.release()
  648. threads = []
  649. for i in range(numconnections):
  650. self.ui.debug('imap', 'keepalive: processing connection %d of %d' %
  651. (i, numconnections))
  652. if len(self.idlefolders) > i:
  653. # IDLE thread
  654. idler = IdleThread(self, self.idlefolders[i])
  655. else:
  656. # NOOP thread
  657. idler = IdleThread(self)
  658. idler.start()
  659. threads.append(idler)
  660. self.ui.debug('imap', 'keepalive: waiting for timeout')
  661. event.wait(timeout)
  662. self.ui.debug('imap', 'keepalive: after wait')
  663. for idler in threads:
  664. # Make sure all the commands have completed.
  665. idler.stop()
  666. idler.join()
  667. self.ui.debug('imap', 'keepalive: all threads joined')
  668. self.ui.debug('imap', 'keepalive: event is set; exiting')
  669. return
  670. def releaseconnection(self, connection, drop_conn=False):
  671. """Releases a connection, returning it to the pool.
  672. :param connection: Connection object
  673. :param drop_conn: If True, the connection will be released and
  674. not be reused. This can be used to indicate broken connections."""
  675. if connection is None:
  676. return # Noop on bad connection.
  677. self.connectionlock.acquire()
  678. try:
  679. self.assignedconnections.remove(connection)
  680. except ValueError:
  681. self.connectionlock.release()
  682. return
  683. # Don't reuse broken connections
  684. if connection.Terminate or drop_conn:
  685. connection.logout()
  686. else:
  687. self.availableconnections.append(connection)
  688. self.connectionlock.release()
  689. self.semaphore.release()
  690. class IdleThread:
  691. def __init__(self, parent, folder=None):
  692. """If invoked without 'folder', perform a NOOP and wait for
  693. self.stop() to be called. If invoked with folder, switch to IDLE
  694. mode and synchronize once we have a new message"""
  695. self.parent = parent
  696. self.folder = folder
  697. self.stop_sig = Event()
  698. self.ui = getglobalui()
  699. if folder is None:
  700. self.thread = Thread(target=self.noop)
  701. else:
  702. self.thread = Thread(target=self.__idle)
  703. self.thread.setDaemon(True)
  704. def start(self):
  705. self.thread.start()
  706. def stop(self):
  707. self.stop_sig.set()
  708. def join(self):
  709. self.thread.join()
  710. def noop(self):
  711. # TODO: AFAIK this is not optimal, we will send a NOOP on one
  712. # random connection (ie not enough to keep all connections
  713. # open). In case we do the noop multiple times, we can well use
  714. # the same connection every time, as we get a random one. This
  715. # function should IMHO send a noop on ALL available connections
  716. # to the server.
  717. imapobj = self.parent.acquireconnection()
  718. try:
  719. imapobj.noop()
  720. except imapobj.abort:
  721. self.ui.warn('Attempting NOOP on dropped connection %s' %
  722. imapobj.identifier)
  723. self.parent.releaseconnection(imapobj, True)
  724. imapobj = None
  725. finally:
  726. if imapobj:
  727. self.parent.releaseconnection(imapobj)
  728. self.stop_sig.wait() # wait until we are supposed to quit
  729. def __dosync(self):
  730. remoterepos = self.parent.repos
  731. account = remoterepos.account
  732. remoterepos = account.remoterepos
  733. remotefolder = remoterepos.getfolder(self.folder, decode=False)
  734. hook = account.getconf('presynchook', '')
  735. account.callhook(hook, "idle")
  736. offlineimap.accounts.syncfolder(account, remotefolder, quick=False)
  737. hook = account.getconf('postsynchook', '')
  738. account.callhook(hook, "idle")
  739. ui = getglobalui()
  740. ui.unregisterthread(current_thread()) # syncfolder registered the thread
  741. def __idle(self):
  742. """Invoke IDLE mode until timeout or self.stop() is invoked."""
  743. def callback(args):
  744. """IDLE callback function invoked by imaplib2.
  745. This is invoked when a) The IMAP server tells us something
  746. while in IDLE mode, b) we get an Exception (e.g. on dropped
  747. connections, or c) the standard imaplib IDLE timeout of 29
  748. minutes kicks in."""
  749. result, cb_arg, exc_data = args
  750. if exc_data is None and not self.stop_sig.isSet():
  751. # No Exception, and we are not supposed to stop:
  752. self.needsync = True
  753. self.stop_sig.set() # Continue to sync.
  754. def noop(imapobj):
  755. """Factorize the noop code."""
  756. try:
  757. # End IDLE mode with noop, imapobj can point to a dropped conn.
  758. imapobj.noop()
  759. except imapobj.abort:
  760. self.ui.warn('Attempting NOOP on dropped connection %s' %
  761. imapobj.identifier)
  762. self.parent.releaseconnection(imapobj, True)
  763. else:
  764. self.parent.releaseconnection(imapobj)
  765. while not self.stop_sig.isSet():
  766. self.needsync = False
  767. success = False # Successfully selected FOLDER?
  768. while not success:
  769. imapobj = self.parent.acquireconnection()
  770. try:
  771. imapobj.select(self.folder)
  772. except OfflineImapError as e:
  773. if e.severity == OfflineImapError.ERROR.FOLDER_RETRY:
  774. # Connection closed, release connection and retry.
  775. self.ui.error(e, exc_info()[2])
  776. self.parent.releaseconnection(imapobj, True)
  777. elif e.severity == OfflineImapError.ERROR.FOLDER:
  778. # Just continue the process on such error for now.
  779. self.ui.error(e, exc_info()[2])
  780. else:
  781. # Stops future attempts to sync this account.
  782. raise
  783. else:
  784. success = True
  785. if "IDLE" in imapobj.capabilities:
  786. imapobj.idle(callback=callback)
  787. else:
  788. self.ui.warn("IMAP IDLE not supported on server '%s'."
  789. "Sleep until next refresh cycle." % imapobj.identifier)
  790. noop(imapobj) # XXX: why?
  791. self.stop_sig.wait() # self.stop() or IDLE callback are invoked.
  792. noop(imapobj)
  793. if self.needsync:
  794. # Here not via self.stop, but because IDLE responded. Do
  795. # another round and invoke actual syncing.
  796. self.stop_sig.clear()
  797. self.__dosync()