init.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. # OfflineIMAP initialization code
  2. # Copyright (C) 2002-2017 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 sys
  19. import threading
  20. import signal
  21. import socket
  22. import logging
  23. import traceback
  24. import collections
  25. from optparse import OptionParser
  26. import offlineimap
  27. from offlineimap.utils.distro_utils import get_os_name
  28. import imaplib2 as imaplib
  29. # Ensure that `ui` gets loaded before `threadutil` in order to
  30. # break the circular dependency between `threadutil` and `Curses`.
  31. from offlineimap.ui import UI_LIST, setglobalui, getglobalui
  32. from offlineimap import threadutil, accounts, folder, mbnames
  33. from offlineimap import globals as glob
  34. from offlineimap.CustomConfig import CustomConfigParser
  35. from offlineimap.utils import stacktrace
  36. from offlineimap.repository import Repository
  37. from offlineimap.folder.IMAP import MSGCOPY_NAMESPACE
  38. ACCOUNT_LIMITED_THREAD_NAME = 'MAX_ACCOUNTS'
  39. PYTHON_VERSION = sys.version.split(' ')[0]
  40. def syncitall(list_accounts, config):
  41. """The target when in multithreading mode for running accounts threads."""
  42. threads = threadutil.accountThreads() # The collection of accounts threads.
  43. for accountname in list_accounts:
  44. # Start a new thread per account and store it in the collection.
  45. account = accounts.SyncableAccount(config, accountname)
  46. thread = threadutil.InstanceLimitedThread(
  47. ACCOUNT_LIMITED_THREAD_NAME,
  48. target=account.syncrunner,
  49. name="Account sync %s" % accountname
  50. )
  51. thread.daemon = True
  52. # The add() method expects a started thread.
  53. thread.start()
  54. threads.add(thread)
  55. # Wait for the threads to finish.
  56. threads.wait() # Blocks until all accounts are processed.
  57. class OfflineImap:
  58. """The main class that encapsulates the high level use of OfflineImap.
  59. To invoke OfflineImap you would call it with::
  60. oi = OfflineImap()
  61. oi.run()
  62. """
  63. def get_env_info(self):
  64. # Transitional code between imaplib2 versions
  65. try:
  66. # imaplib2, previous versions, based on Python 2.x
  67. l_imaplib_version = imaplib.__version__
  68. except AttributeError:
  69. # New imaplib2, version >= 3.06
  70. l_imaplib_version = imaplib.version()
  71. except:
  72. # This should not happen
  73. l_imaplib_version = " Unknown"
  74. info = "imaplib2 v%s, Python v%s" % (l_imaplib_version, PYTHON_VERSION)
  75. try:
  76. import ssl
  77. info = "%s, %s" % (info, ssl.OPENSSL_VERSION)
  78. except:
  79. pass
  80. return info
  81. def run(self):
  82. """Parse the commandline and invoke everything"""
  83. # next line also sets self.config and self.ui
  84. options, args = self.__parse_cmd_options()
  85. if options.diagnostics:
  86. self.__serverdiagnostics(options)
  87. elif options.migrate_fmd5:
  88. self.__migratefmd5(options)
  89. elif options.mbnames_prune:
  90. mbnames.init(self.config, self.ui, options.dryrun)
  91. mbnames.prune(self.config.get("general", "accounts"))
  92. mbnames.write()
  93. elif options.deletefolder:
  94. return self.__deletefolder(options)
  95. else:
  96. return self.__sync(options)
  97. def __parse_cmd_options(self):
  98. parser = OptionParser(
  99. version=offlineimap.__version__,
  100. description="%s.\n\n%s" % (offlineimap.__copyright__,
  101. offlineimap.__license__)
  102. )
  103. parser.add_option("-V",
  104. action="store_true", dest="version",
  105. default=False,
  106. help="show full version infos")
  107. parser.add_option("--dry-run",
  108. action="store_true", dest="dryrun",
  109. default=False,
  110. help="dry run mode")
  111. parser.add_option("--info",
  112. action="store_true", dest="diagnostics",
  113. default=False,
  114. help="output information on the configured email repositories")
  115. parser.add_option("-1",
  116. action="store_true", dest="singlethreading",
  117. default=False,
  118. help="(the number one) disable all multithreading operations")
  119. parser.add_option("-P", dest="profiledir", metavar="DIR",
  120. help="sets OfflineIMAP into profile mode.")
  121. parser.add_option("-a", dest="accounts",
  122. metavar="account1[,account2[,...]]",
  123. help="list of accounts to sync")
  124. parser.add_option("-c", dest="configfile", metavar="FILE",
  125. default=None,
  126. help="specifies a configuration file to use")
  127. parser.add_option("-d", dest="debugtype",
  128. metavar="type1[,type2[,...]]",
  129. help="enables debugging for OfflineIMAP "
  130. " (types: imap, maildir, thread)")
  131. parser.add_option("-l", dest="logfile", metavar="FILE",
  132. help="log to FILE")
  133. parser.add_option("-s",
  134. action="store_true", dest="syslog",
  135. default=False,
  136. help="log to syslog")
  137. parser.add_option("-f", dest="folders",
  138. metavar="folder1[,folder2[,...]]",
  139. help="only sync the specified folders")
  140. parser.add_option("-k", dest="configoverride",
  141. action="append",
  142. metavar="[section:]option=value",
  143. help="override configuration file option")
  144. parser.add_option("-o",
  145. action="store_true", dest="runonce",
  146. default=False,
  147. help="run only once (ignore autorefresh)")
  148. parser.add_option("-q",
  149. action="store_true", dest="quick",
  150. default=False,
  151. help="run only quick synchronizations (don't update flags)")
  152. parser.add_option("-u", dest="interface",
  153. help="specifies an alternative user interface"
  154. " (quiet, basic, syslog, ttyui, blinkenlights, machineui)")
  155. parser.add_option("--delete-folder", dest="deletefolder",
  156. default=None,
  157. metavar="FOLDERNAME",
  158. help="Delete a folder (on the remote repository)")
  159. parser.add_option("--migrate-fmd5-using-nametrans",
  160. action="store_true", dest="migrate_fmd5", default=False,
  161. help="migrate FMD5 hashes from versions prior to 6.3.5")
  162. parser.add_option("--mbnames-prune",
  163. action="store_true", dest="mbnames_prune", default=False,
  164. help="remove mbnames entries for accounts not in accounts")
  165. parser.add_option("--ignore-keyring",
  166. action="store_true", dest="ignore_keyring", default=False,
  167. help="Ignore password which is stored in system keyring")
  168. parser.add_option("--update-keyring",
  169. action="store_true", dest="update_keyring", default=False,
  170. help="Update system keyring with used password")
  171. (options, args) = parser.parse_args()
  172. glob.set_options(options)
  173. if options.version:
  174. print(("offlineimap v%s, %s" % (
  175. offlineimap.__version__, self.get_env_info())
  176. ))
  177. sys.exit(0)
  178. # Read in configuration file.
  179. if not options.configfile:
  180. # Try XDG location, then fall back to ~/.offlineimaprc
  181. xdg_var = 'XDG_CONFIG_HOME'
  182. if xdg_var not in os.environ or not os.environ[xdg_var]:
  183. xdg_home = os.path.expanduser('~/.config')
  184. else:
  185. xdg_home = os.environ[xdg_var]
  186. options.configfile = os.path.join(xdg_home, "offlineimap", "config")
  187. if not os.path.exists(options.configfile):
  188. options.configfile = os.path.expanduser('~/.offlineimaprc')
  189. configfilename = options.configfile
  190. else:
  191. configfilename = os.path.expanduser(options.configfile)
  192. config = CustomConfigParser()
  193. if not os.path.exists(configfilename):
  194. # TODO, initialize and make use of chosen ui for logging
  195. logging.error(" *** Config file '%s' does not exist; aborting!" %
  196. configfilename)
  197. sys.exit(1)
  198. config.read(configfilename)
  199. # Profile mode chosen?
  200. if options.profiledir:
  201. if not options.singlethreading:
  202. # TODO, make use of chosen ui for logging
  203. logging.warning("Profile mode: Forcing to singlethreaded.")
  204. options.singlethreading = True
  205. if os.path.exists(options.profiledir):
  206. # TODO, make use of chosen ui for logging
  207. logging.warning("Profile mode: Directory '%s' already exists!" %
  208. options.profiledir)
  209. else:
  210. os.mkdir(options.profiledir)
  211. # TODO, make use of chosen ui for logging
  212. logging.warning("Profile mode: Potentially large data will be "
  213. "created in '%s'" % options.profiledir)
  214. # Override a config value.
  215. if options.configoverride:
  216. for option in options.configoverride:
  217. (key, value) = option.split('=', 1)
  218. if ':' in key:
  219. (secname, key) = key.split(':', 1)
  220. section = secname.replace("_", " ")
  221. else:
  222. section = "general"
  223. config.set(section, key, value)
  224. # Which ui to use? CLI option overrides config file.
  225. ui_type = config.getdefault('general', 'ui', 'ttyui')
  226. if options.interface is not None:
  227. ui_type = options.interface
  228. if '.' in ui_type:
  229. # Transform Curses.Blinkenlights -> Blinkenlights.
  230. ui_type = ui_type.split('.')[-1]
  231. # TODO, make use of chosen ui for logging
  232. logging.warning('Using old interface name, consider using one '
  233. 'of %s' % ', '.join(list(UI_LIST.keys())))
  234. if options.diagnostics:
  235. ui_type = 'ttyui' # Enforce this UI for --info.
  236. # dry-run? Set [general]dry-run=True.
  237. if options.dryrun:
  238. config.set('general', 'dry-run', 'True')
  239. config.set_if_not_exists('general', 'dry-run', 'False')
  240. # ignore_keyring? Set [general]ignore_keyring=True.
  241. if options.ignore_keyring:
  242. config.set('general', 'ignore-keyring', 'True')
  243. config.set_if_not_exists('general', 'ignore-keyring', 'False')
  244. # update_keyring? Set [general]update_keyring=True.
  245. if options.update_keyring:
  246. config.set('general', 'update-keyring', 'True')
  247. config.set_if_not_exists('general', 'update-keyring', 'False')
  248. try:
  249. # Create the ui class.
  250. self.ui = UI_LIST[ui_type.lower()](config)
  251. except KeyError:
  252. logging.error("UI '%s' does not exist, choose one of: %s" %
  253. (ui_type, ', '.join(list(UI_LIST.keys()))))
  254. sys.exit(1)
  255. setglobalui(self.ui)
  256. # Set up additional log files.
  257. if options.logfile:
  258. self.ui.setlogfile(options.logfile)
  259. # Set up syslog.
  260. if options.syslog:
  261. self.ui.setup_sysloghandler()
  262. # Welcome blurb.
  263. self.ui.init_banner()
  264. self.ui.info(self.get_env_info())
  265. if options.debugtype:
  266. self.ui.logger.setLevel(logging.DEBUG)
  267. if options.debugtype.lower() == 'all':
  268. options.debugtype = 'imap,maildir,thread'
  269. # Force single threading?
  270. if not ('thread' in options.debugtype.split(',')
  271. and not options.singlethreading):
  272. self.ui._msg("Debug mode: Forcing to singlethreaded.")
  273. options.singlethreading = True
  274. debugtypes = options.debugtype.split(',') + ['']
  275. for dtype in debugtypes:
  276. dtype = dtype.strip()
  277. self.ui.add_debug(dtype)
  278. if options.runonce:
  279. # Must kill the possible default option.
  280. if config.has_option('DEFAULT', 'autorefresh'):
  281. config.remove_option('DEFAULT', 'autorefresh')
  282. # FIXME: spaghetti code alert!
  283. for section in accounts.getaccountlist(config):
  284. config.remove_option('Account ' + section, "autorefresh")
  285. if options.quick:
  286. for section in accounts.getaccountlist(config):
  287. config.set('Account ' + section, "quick", '-1')
  288. # Custom folder list specified?
  289. if options.folders:
  290. foldernames = options.folders.split(",")
  291. folderfilter = "lambda f: f in %s" % foldernames
  292. folderincludes = "[]"
  293. for accountname in accounts.getaccountlist(config):
  294. account_section = 'Account ' + accountname
  295. remote_repo_section = 'Repository ' + \
  296. config.get(account_section, 'remoterepository')
  297. config.set(remote_repo_section, "folderfilter", folderfilter)
  298. config.set(remote_repo_section, "folderincludes",
  299. folderincludes)
  300. if options.logfile:
  301. sys.stderr = self.ui.logfile
  302. socktimeout = config.getdefaultint("general", "socktimeout", 0)
  303. if socktimeout > 0:
  304. socket.setdefaulttimeout(socktimeout)
  305. threadutil.initInstanceLimit(
  306. ACCOUNT_LIMITED_THREAD_NAME,
  307. config.getdefaultint('general', 'maxsyncaccounts', 1)
  308. )
  309. for reposname in config.getsectionlist('Repository'):
  310. # Limit the number of threads. Limitation on usage is handled at the
  311. # imapserver level.
  312. for namespace in [accounts.FOLDER_NAMESPACE + reposname,
  313. MSGCOPY_NAMESPACE + reposname]:
  314. if options.singlethreading:
  315. threadutil.initInstanceLimit(namespace, 1)
  316. else:
  317. threadutil.initInstanceLimit(
  318. namespace,
  319. config.getdefaultint(
  320. 'Repository ' + reposname,
  321. 'maxconnections', 2)
  322. )
  323. self.config = config
  324. return options, args
  325. def __dumpstacks(self, context=1, sighandler_deep=2):
  326. """ Signal handler: dump a stack trace for each existing thread."""
  327. currentThreadId = threading.currentThread().ident
  328. def unique_count(l):
  329. d = collections.defaultdict(lambda: 0)
  330. for v in l:
  331. d[tuple(v)] += 1
  332. return list((k, v) for k, v in list(d.items()))
  333. stack_displays = []
  334. for threadId, stack in list(sys._current_frames().items()):
  335. stack_display = []
  336. for filename, lineno, name, line in traceback.extract_stack(stack):
  337. stack_display.append(' File: "%s", line %d, in %s'
  338. % (filename, lineno, name))
  339. if line:
  340. stack_display.append(" %s" % (line.strip()))
  341. if currentThreadId == threadId:
  342. stack_display = stack_display[:- (sighandler_deep * 2)]
  343. stack_display.append(' => Stopped to handle current signal. ')
  344. stack_displays.append(stack_display)
  345. stacks = unique_count(stack_displays)
  346. self.ui.debug('thread', "** Thread List:\n")
  347. for stack, times in stacks:
  348. if times == 1:
  349. msg = "%s Thread is at:\n%s\n"
  350. else:
  351. msg = "%s Threads are at:\n%s\n"
  352. self.ui.debug('thread', msg % (times, '\n'.join(stack[- (context * 2):])))
  353. self.ui.debug('thread', "Dumped a total of %d Threads." %
  354. len(list(sys._current_frames().keys())))
  355. def _get_activeaccounts(self, options):
  356. activeaccounts = []
  357. errormsg = None
  358. activeaccountnames = self.config.get("general", "accounts")
  359. if options.accounts:
  360. activeaccountnames = options.accounts
  361. activeaccountnames = [x.lstrip() for x in activeaccountnames.split(",")]
  362. allaccounts = accounts.getaccountlist(self.config)
  363. for accountname in activeaccountnames:
  364. if accountname in allaccounts:
  365. activeaccounts.append(accountname)
  366. else:
  367. errormsg = "Valid accounts are: %s" % (
  368. ", ".join(allaccounts))
  369. self.ui.error("The account '%s' does not exist" % accountname)
  370. if len(activeaccounts) < 1:
  371. errormsg = "No accounts are defined!"
  372. if errormsg is not None:
  373. self.ui.terminate(1, errormsg=errormsg)
  374. return activeaccounts
  375. def __sync(self, options):
  376. """Invoke the correct single/multithread syncing
  377. self.config is supposed to have been correctly initialized
  378. already."""
  379. def sig_handler(sig, frame):
  380. if sig == signal.SIGUSR1:
  381. # tell each account to stop sleeping
  382. accounts.Account.set_abort_event(self.config, 1)
  383. elif sig in (signal.SIGUSR2, signal.SIGABRT):
  384. # tell each account to stop looping
  385. getglobalui().warn("Terminating after this sync...")
  386. accounts.Account.set_abort_event(self.config, 2)
  387. elif sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
  388. # tell each account to ABORT ASAP (ctrl-c)
  389. getglobalui().warn("Preparing to shutdown after sync (this may "
  390. "take some time), press CTRL-C three "
  391. "times to shutdown immediately")
  392. accounts.Account.set_abort_event(self.config, 3)
  393. if 'thread' in self.ui.debuglist:
  394. self.__dumpstacks(5)
  395. # Abort after three Ctrl-C keystrokes
  396. self.num_sigterm += 1
  397. if self.num_sigterm >= 3:
  398. getglobalui().warn("Signaled thrice. Aborting!")
  399. sys.exit(1)
  400. elif sig == signal.SIGQUIT:
  401. stacktrace.dump(sys.stderr)
  402. os.abort()
  403. try:
  404. self.num_sigterm = 0
  405. # We cannot use signals in Windows
  406. if get_os_name() != 'windows':
  407. signal.signal(signal.SIGHUP, sig_handler)
  408. signal.signal(signal.SIGUSR1, sig_handler)
  409. signal.signal(signal.SIGUSR2, sig_handler)
  410. signal.signal(signal.SIGABRT, sig_handler)
  411. signal.signal(signal.SIGTERM, sig_handler)
  412. signal.signal(signal.SIGINT, sig_handler)
  413. signal.signal(signal.SIGQUIT, sig_handler)
  414. # Various initializations that need to be performed:
  415. activeaccounts = self._get_activeaccounts(options)
  416. mbnames.init(self.config, self.ui, options.dryrun)
  417. if options.singlethreading:
  418. # Singlethreaded.
  419. self.__sync_singlethreaded(activeaccounts, options.profiledir)
  420. else:
  421. # Multithreaded.
  422. t = threadutil.ExitNotifyThread(
  423. target=syncitall,
  424. name='Sync Runner',
  425. args=(activeaccounts, self.config,)
  426. )
  427. # Special exit message for the monitor to stop looping.
  428. t.exit_message = threadutil.STOP_MONITOR
  429. t.start()
  430. threadutil.monitor()
  431. # All sync are done.
  432. mbnames.write()
  433. self.ui.terminate()
  434. return 0
  435. except SystemExit:
  436. raise
  437. except Exception as e:
  438. self.ui.error(e)
  439. self.ui.terminate()
  440. return 1
  441. def __sync_singlethreaded(self, list_accounts, profiledir):
  442. """Executed in singlethreaded mode only.
  443. :param accs: A list of accounts that should be synced
  444. """
  445. for accountname in list_accounts:
  446. account = accounts.SyncableAccount(self.config, accountname)
  447. threading.currentThread().name = \
  448. "Account sync %s" % account.getname()
  449. if not profiledir:
  450. account.syncrunner()
  451. # Profile mode.
  452. else:
  453. try:
  454. import cProfile as profile
  455. except ImportError:
  456. import profile
  457. prof = profile.Profile()
  458. try:
  459. prof = prof.runctx("account.syncrunner()", globals(), locals())
  460. except SystemExit:
  461. pass
  462. from datetime import datetime
  463. dt = datetime.now().strftime('%Y%m%d%H%M%S')
  464. prof.dump_stats(os.path.join(
  465. profiledir, "%s_%s.prof" % (dt, account.getname())))
  466. def __serverdiagnostics(self, options):
  467. self.ui.info(" imaplib2: %s" % imaplib.__version__)
  468. for accountname in self._get_activeaccounts(options):
  469. account = accounts.Account(self.config, accountname)
  470. account.serverdiagnostics()
  471. def __deletefolder(self, options):
  472. list_accounts = self._get_activeaccounts(options)
  473. if len(list_accounts) != 1:
  474. self.ui.error("you must supply only one account with '-a'")
  475. return 1
  476. account = accounts.Account(self.config, list_accounts.pop())
  477. return account.deletefolder(options.deletefolder)
  478. def __migratefmd5(self, options):
  479. for accountname in self._get_activeaccounts(options):
  480. account = accounts.Account(self.config, accountname)
  481. localrepo = Repository(account, 'local')
  482. if localrepo.getfoldertype() != folder.Maildir.MaildirFolder:
  483. continue
  484. folders = localrepo.getfolders()
  485. for f in folders:
  486. f.migratefmd5(options.dryrun)
  487. def main():
  488. oi = OfflineImap()
  489. oi.run()
  490. if __name__ == "__main__":
  491. main()