IMAP.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. """ IMAP repository support """
  2. # Copyright (C) 2002-2016 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 os
  18. import netrc
  19. import errno
  20. import codecs
  21. from sys import exc_info
  22. from threading import Event
  23. import six
  24. from offlineimap import folder, imaputil, imapserver, OfflineImapError
  25. from offlineimap.repository.Base import BaseRepository
  26. from offlineimap.threadutil import ExitNotifyThread
  27. from offlineimap.utils.distro import get_os_sslcertfile, get_os_sslcertfile_searchpath
  28. class IMAPRepository(BaseRepository):
  29. def __init__(self, reposname, account):
  30. self.idlefolders = None
  31. BaseRepository.__init__(self, reposname, account)
  32. # self.ui is being set by the BaseRepository
  33. self._host = None
  34. # Must be set before calling imapserver.IMAPServer(self)
  35. self.oauth2_request_url = None
  36. self.imapserver = imapserver.IMAPServer(self)
  37. self.folders = None
  38. self.copy_ignore_eval = None
  39. # Keep alive.
  40. self.kaevent = None
  41. self.kathread = None
  42. # Only set the newmail_hook in an IMAP repository.
  43. if self.config.has_option(self.getsection(), 'newmail_hook'):
  44. self.newmail_hook = self.localeval.eval(
  45. self.getconf('newmail_hook'))
  46. if self.getconf('sep', None):
  47. self.ui.info("The 'sep' setting is being ignored for IMAP "
  48. "repository '%s' (it's autodetected)"% self)
  49. def startkeepalive(self):
  50. keepalivetime = self.getkeepalive()
  51. if not keepalivetime:
  52. return
  53. self.kaevent = Event()
  54. self.kathread = ExitNotifyThread(target=self.imapserver.keepalive,
  55. name="Keep alive " + self.getname(),
  56. args=(keepalivetime, self.kaevent))
  57. self.kathread.setDaemon(1)
  58. self.kathread.start()
  59. def stopkeepalive(self):
  60. if self.kaevent is None:
  61. return # Keepalive is not active.
  62. self.kaevent.set()
  63. self.kathread = None
  64. self.kaevent = None
  65. def holdordropconnections(self):
  66. if not self.getholdconnectionopen():
  67. self.dropconnections()
  68. def dropconnections(self):
  69. self.imapserver.close()
  70. def get_copy_ignore_UIDs(self, foldername):
  71. """Return a list of UIDs to not copy for this foldername."""
  72. if self.copy_ignore_eval is None:
  73. if self.config.has_option(self.getsection(),
  74. 'copy_ignore_eval'):
  75. self.copy_ignore_eval = self.localeval.eval(
  76. self.getconf('copy_ignore_eval'))
  77. else:
  78. self.copy_ignore_eval = lambda x: None
  79. return self.copy_ignore_eval(foldername)
  80. def getholdconnectionopen(self):
  81. if self.getidlefolders():
  82. return True
  83. return self.getconfboolean("holdconnectionopen", False)
  84. def getkeepalive(self):
  85. num = self.getconfint("keepalive", 0)
  86. if num == 0 and self.getidlefolders():
  87. return 29*60
  88. return num
  89. def getsep(self):
  90. """Return the folder separator for the IMAP repository
  91. This requires that self.imapserver has been initialized with an
  92. acquireconnection() or it will still be `None`"""
  93. assert self.imapserver.delim != None, "'%s' " \
  94. "repository called getsep() before the folder separator was " \
  95. "queried from the server"% self
  96. return self.imapserver.delim
  97. def gethost(self):
  98. """Return the configured hostname to connect to
  99. :returns: hostname as string or throws Exception"""
  100. if self._host: # Use cached value if possible.
  101. return self._host
  102. # 1) Check for remotehosteval setting.
  103. if self.config.has_option(self.getsection(), 'remotehosteval'):
  104. host = self.getconf('remotehosteval')
  105. try:
  106. host = self.localeval.eval(host)
  107. except Exception as e:
  108. six.reraise(OfflineImapError,
  109. OfflineImapError(
  110. "remotehosteval option for repository "
  111. "'%s' failed:\n%s"% (self, e),
  112. OfflineImapError.ERROR.REPO),
  113. exc_info()[2])
  114. if host:
  115. self._host = host
  116. return self._host
  117. # 2) Check for plain remotehost setting.
  118. host = self.getconf('remotehost', None)
  119. if host != None:
  120. self._host = host
  121. return self._host
  122. # No success.
  123. raise OfflineImapError("No remote host for repository "
  124. "'%s' specified."% self, OfflineImapError.ERROR.REPO)
  125. def get_remote_identity(self):
  126. """Remote identity is used for certain SASL mechanisms
  127. (currently -- PLAIN) to inform server about the ID
  128. we want to authorize as instead of our login name."""
  129. identity = self.getconf('remote_identity', default=None)
  130. if identity != None:
  131. identity = identity.encode('UTF-8')
  132. return identity
  133. def get_auth_mechanisms(self):
  134. supported = ["GSSAPI", "XOAUTH2", "CRAM-MD5", "PLAIN", "LOGIN"]
  135. # Mechanisms are ranged from the strongest to the
  136. # weakest ones.
  137. # TODO: we need DIGEST-MD5, it must come before CRAM-MD5
  138. # due to the chosen-plaintext resistance.
  139. default = ["GSSAPI", "XOAUTH2", "CRAM-MD5", "PLAIN", "LOGIN"]
  140. mechs = self.getconflist('auth_mechanisms', r',\s*',
  141. default)
  142. for m in mechs:
  143. if m not in supported:
  144. raise OfflineImapError("Repository %s: "% self + \
  145. "unknown authentication mechanism '%s'"% m,
  146. OfflineImapError.ERROR.REPO)
  147. self.ui.debug('imap', "Using authentication mechanisms %s" % mechs)
  148. return mechs
  149. def getuser(self):
  150. user = None
  151. localeval = self.localeval
  152. if self.config.has_option(self.getsection(), 'remoteusereval'):
  153. user = self.getconf('remoteusereval')
  154. if user != None:
  155. return localeval.eval(user).encode('UTF-8')
  156. if self.config.has_option(self.getsection(), 'remoteuser'):
  157. # Assume the configuration file to be UTF-8 encoded so we must not
  158. # encode this string again.
  159. user = self.getconf('remoteuser')
  160. if user != None:
  161. return user
  162. try:
  163. netrcentry = netrc.netrc().authenticators(self.gethost())
  164. except IOError as inst:
  165. if inst.errno != errno.ENOENT:
  166. raise
  167. else:
  168. if netrcentry:
  169. return netrcentry[0]
  170. try:
  171. netrcentry = netrc.netrc('/etc/netrc').authenticators(self.gethost())
  172. except IOError as inst:
  173. if inst.errno not in (errno.ENOENT, errno.EACCES):
  174. raise
  175. else:
  176. if netrcentry:
  177. return netrcentry[0]
  178. def getport(self):
  179. port = None
  180. if self.config.has_option(self.getsection(), 'remoteporteval'):
  181. port = self.getconf('remoteporteval')
  182. if port != None:
  183. return self.localeval.eval(port)
  184. return self.getconfint('remoteport', None)
  185. def getipv6(self):
  186. return self.getconfboolean('ipv6', None)
  187. def getssl(self):
  188. return self.getconfboolean('ssl', True)
  189. def getsslclientcert(self):
  190. xforms = [os.path.expanduser, os.path.expandvars, os.path.abspath]
  191. return self.getconf_xform('sslclientcert', xforms, None)
  192. def getsslclientkey(self):
  193. xforms = [os.path.expanduser, os.path.expandvars, os.path.abspath]
  194. return self.getconf_xform('sslclientkey', xforms, None)
  195. def getsslcacertfile(self):
  196. """Determines CA bundle.
  197. Returns path to the CA bundle. It is either explicitely specified
  198. or requested via "OS-DEFAULT" value (and we will search known
  199. locations for the current OS and distribution).
  200. If search via "OS-DEFAULT" route yields nothing, we will throw an
  201. exception to make our callers distinguish between not specified
  202. value and non-existent default CA bundle.
  203. It is also an error to specify non-existent file via configuration:
  204. it will error out later, but, perhaps, with less verbose explanation,
  205. so we will also throw an exception. It is consistent with
  206. the above behaviour, so any explicitely-requested configuration
  207. that doesn't result in an existing file will give an exception.
  208. """
  209. xforms = [os.path.expanduser, os.path.expandvars, os.path.abspath]
  210. cacertfile = self.getconf_xform('sslcacertfile', xforms, None)
  211. # Can't use above cacertfile because of abspath.
  212. if self.getconf('sslcacertfile', None) == "OS-DEFAULT":
  213. cacertfile = get_os_sslcertfile()
  214. if cacertfile == None:
  215. searchpath = get_os_sslcertfile_searchpath()
  216. if searchpath:
  217. reason = "Default CA bundle was requested, "\
  218. "but no existing locations available. "\
  219. "Tried %s." % (", ".join(searchpath))
  220. else:
  221. reason = "Default CA bundle was requested, "\
  222. "but OfflineIMAP doesn't know any for your "\
  223. "current operating system."
  224. raise OfflineImapError(reason, OfflineImapError.ERROR.REPO)
  225. if cacertfile is None:
  226. return None
  227. if not os.path.isfile(cacertfile):
  228. reason = "CA certfile for repository '%s' couldn't be found. "\
  229. "No such file: '%s'" % (self.name, cacertfile)
  230. raise OfflineImapError(reason, OfflineImapError.ERROR.REPO)
  231. return cacertfile
  232. def gettlslevel(self):
  233. return self.getconf('tls_level', 'tls_compat')
  234. def getsslversion(self):
  235. return self.getconf('ssl_version', None)
  236. def getstarttls(self):
  237. return self.getconfboolean('starttls', True)
  238. def get_ssl_fingerprint(self):
  239. """Return array of possible certificate fingerprints.
  240. Configuration item cert_fingerprint can contain multiple
  241. comma-separated fingerprints in hex form."""
  242. value = self.getconf('cert_fingerprint', "")
  243. return [f.strip().lower() for f in value.split(',') if f]
  244. def setoauth2_request_url(self, url):
  245. self.oauth2_request_url = url
  246. def getoauth2_request_url(self):
  247. if self.oauth2_request_url is not None: # Use cached value if possible.
  248. return self.oauth2_request_url
  249. self.setoauth2_request_url(self.getconf('oauth2_request_url', None))
  250. return self.oauth2_request_url
  251. def getoauth2_refresh_token(self):
  252. refresh_token = self.getconf('oauth2_refresh_token', None)
  253. if refresh_token is None:
  254. refresh_token = self.localeval.eval(
  255. self.getconf('oauth2_refresh_token_eval', "None")
  256. )
  257. if refresh_token is not None:
  258. refresh_token = refresh_token.strip("\n")
  259. return refresh_token
  260. def getoauth2_access_token(self):
  261. access_token = self.getconf('oauth2_access_token', None)
  262. if access_token is None:
  263. access_token = self.localeval.eval(
  264. self.getconf('oauth2_access_token_eval', "None")
  265. )
  266. if access_token is not None:
  267. access_token = access_token.strip("\n")
  268. return access_token
  269. def getoauth2_client_id(self):
  270. client_id = self.getconf('oauth2_client_id', None)
  271. if client_id is None:
  272. client_id = self.localeval.eval(
  273. self.getconf('oauth2_client_id_eval', "None")
  274. )
  275. if client_id is not None:
  276. client_id = client_id.strip("\n")
  277. return client_id
  278. def getoauth2_client_secret(self):
  279. client_secret = self.getconf('oauth2_client_secret', None)
  280. if client_secret is None:
  281. client_secret = self.localeval.eval(
  282. self.getconf('oauth2_client_secret_eval', "None")
  283. )
  284. if client_secret is not None:
  285. client_secret = client_secret.strip("\n")
  286. return client_secret
  287. def getpreauthtunnel(self):
  288. return self.getconf('preauthtunnel', None)
  289. def gettransporttunnel(self):
  290. return self.getconf('transporttunnel', None)
  291. def getreference(self):
  292. return self.getconf('reference', '')
  293. def getdecodefoldernames(self):
  294. return self.getconfboolean('decodefoldernames', False)
  295. def getidlefolders(self):
  296. if self.idlefolders is None:
  297. self.idlefolders = self.localeval.eval(
  298. self.getconf('idlefolders', '[]')
  299. )
  300. return self.idlefolders
  301. def getmaxconnections(self):
  302. num1 = len(self.getidlefolders())
  303. num2 = self.getconfint('maxconnections', 1)
  304. return max(num1, num2)
  305. def getexpunge(self):
  306. return self.getconfboolean('expunge', True)
  307. def getpassword(self):
  308. """Return the IMAP password for this repository.
  309. It tries to get passwords in the following order:
  310. 1. evaluate Repository 'remotepasseval'
  311. 2. read password from Repository 'remotepass'
  312. 3. read password from file specified in Repository 'remotepassfile'
  313. 4. read password from ~/.netrc
  314. 5. read password from /etc/netrc
  315. On success we return the password.
  316. If all strategies fail we return None."""
  317. # 1. Evaluate Repository 'remotepasseval'.
  318. passwd = self.getconf('remotepasseval', None)
  319. if passwd is not None:
  320. return self.localeval.eval(passwd).encode('UTF-8')
  321. # 2. Read password from Repository 'remotepass'.
  322. password = self.getconf('remotepass', None)
  323. if password is not None:
  324. # Assume the configuration file to be UTF-8 encoded so we must not
  325. # encode this string again.
  326. return password
  327. # 3. Read password from file specified in Repository 'remotepassfile'.
  328. passfile = self.getconf('remotepassfile', None)
  329. if passfile is not None:
  330. fd = codecs.open(os.path.expanduser(passfile), 'r', 'UTF-8')
  331. password = fd.readline().strip()
  332. fd.close()
  333. return password.encode('UTF-8')
  334. # 4. Read password from ~/.netrc.
  335. try:
  336. netrcentry = netrc.netrc().authenticators(self.gethost())
  337. except IOError as inst:
  338. if inst.errno != errno.ENOENT:
  339. raise
  340. else:
  341. if netrcentry:
  342. user = self.getuser()
  343. if user is None or user == netrcentry[0]:
  344. return netrcentry[2]
  345. # 5. Read password from /etc/netrc.
  346. try:
  347. netrcentry = netrc.netrc('/etc/netrc').authenticators(self.gethost())
  348. except IOError as inst:
  349. if inst.errno not in (errno.ENOENT, errno.EACCES):
  350. raise
  351. else:
  352. if netrcentry:
  353. user = self.getuser()
  354. if user is None or user == netrcentry[0]:
  355. return netrcentry[2]
  356. # No strategy yielded a password!
  357. return None
  358. def getfolder(self, foldername):
  359. """Return instance of OfflineIMAP representative folder."""
  360. return self.getfoldertype()(self.imapserver, foldername, self)
  361. def getfoldertype(self):
  362. return folder.IMAP.IMAPFolder
  363. def connect(self):
  364. imapobj = self.imapserver.acquireconnection()
  365. self.imapserver.releaseconnection(imapobj)
  366. def forgetfolders(self):
  367. self.folders = None
  368. def getfolders(self):
  369. """Return a list of instances of OfflineIMAP representative folder."""
  370. if self.folders is not None:
  371. return self.folders
  372. retval = []
  373. imapobj = self.imapserver.acquireconnection()
  374. # check whether to list all folders, or subscribed only
  375. listfunction = imapobj.list
  376. if self.getconfboolean('subscribedonly', False):
  377. listfunction = imapobj.lsub
  378. try:
  379. result, listresult = listfunction(directory=self.imapserver.reference)
  380. if result != 'OK':
  381. raise OfflineImapError("Could not list the folders for"
  382. " repository %s. Server responded: %s"%
  383. (self.name, self, str(listresult)),
  384. OfflineImapError.ERROR.FOLDER)
  385. finally:
  386. self.imapserver.releaseconnection(imapobj)
  387. for s in listresult:
  388. if s == None or \
  389. (isinstance(s, str) and s == ''):
  390. # Bug in imaplib: empty strings in results from
  391. # literals. TODO: still relevant?
  392. continue
  393. try:
  394. flags, delim, name = imaputil.imapsplit(s)
  395. except ValueError:
  396. self.ui.error(
  397. "could not correctly parse server response; got: %s"% s)
  398. raise
  399. flaglist = [x.lower() for x in imaputil.flagsplit(flags)]
  400. if '\\noselect' in flaglist:
  401. continue
  402. foldername = imaputil.dequote(name)
  403. retval.append(self.getfoldertype()(self.imapserver, foldername,
  404. self))
  405. # Add all folderincludes
  406. if len(self.folderincludes):
  407. imapobj = self.imapserver.acquireconnection()
  408. try:
  409. for foldername in self.folderincludes:
  410. try:
  411. imapobj.select(foldername, readonly=True)
  412. except OfflineImapError as e:
  413. # couldn't select this folderinclude, so ignore folder.
  414. if e.severity > OfflineImapError.ERROR.FOLDER:
  415. raise
  416. self.ui.error(e, exc_info()[2],
  417. 'Invalid folderinclude:')
  418. continue
  419. retval.append(self.getfoldertype()(
  420. self.imapserver, foldername, self))
  421. finally:
  422. self.imapserver.releaseconnection(imapobj)
  423. if self.foldersort is None:
  424. # default sorting by case insensitive transposed name
  425. retval.sort(key=lambda x: str.lower(x.getvisiblename()))
  426. else:
  427. # do foldersort in a python3-compatible way
  428. # http://bytes.com/topic/python/answers/844614-python-3-sorting-comparison-function
  429. def cmp2key(mycmp):
  430. """Converts a cmp= function into a key= function
  431. We need to keep cmp functions for backward compatibility"""
  432. class K(object):
  433. def __init__(self, obj, *args):
  434. self.obj = obj
  435. def __cmp__(self, other):
  436. return mycmp(self.obj.getvisiblename(), other.obj.getvisiblename())
  437. return K
  438. retval.sort(key=cmp2key(self.foldersort))
  439. self.folders = retval
  440. return self.folders
  441. def deletefolder(self, foldername):
  442. """Delete a folder on the IMAP server."""
  443. imapobj = self.imapserver.acquireconnection()
  444. try:
  445. result = imapobj.delete(foldername)
  446. if result[0] != 'OK':
  447. raise OfflineImapError("Folder '%s'[%s] could not be deleted. "
  448. "Server responded: %s"% (foldername, self, str(result)),
  449. OfflineImapError.ERROR.FOLDER)
  450. finally:
  451. self.imapserver.releaseconnection(imapobj)
  452. def makefolder(self, foldername):
  453. """Create a folder on the IMAP server
  454. This will not update the list cached in :meth:`getfolders`. You
  455. will need to invoke :meth:`forgetfolders` to force new caching
  456. when you are done creating folders yourself.
  457. :param foldername: Full path of the folder to be created."""
  458. if foldername is '':
  459. return
  460. if self.getreference():
  461. foldername = self.getreference() + self.getsep() + foldername
  462. if not foldername: # Create top level folder as folder separator.
  463. foldername = self.getsep()
  464. self.ui.makefolder(self, foldername)
  465. if self.account.dryrun:
  466. return
  467. imapobj = self.imapserver.acquireconnection()
  468. try:
  469. result = imapobj.create(foldername)
  470. if result[0] != 'OK':
  471. raise OfflineImapError("Folder '%s'[%s] could not be created. "
  472. "Server responded: %s"% (foldername, self, str(result)),
  473. OfflineImapError.ERROR.FOLDER)
  474. finally:
  475. self.imapserver.releaseconnection(imapobj)
  476. class MappedIMAPRepository(IMAPRepository):
  477. def getfoldertype(self):
  478. return folder.UIDMaps.MappedIMAPFolder