sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 7619cb9e42ecd21e6a184bfa13661cec3d721bb5
parent ef385eced7f0a2bbc3bb909861a9f5d077b5f179
Author: Gaute Hope <eg@gaute.vetsj.com>
Date:   Thu, 15 Aug 2013 10:23:33 +0200

Merge tag 'release-0.14.0': Sup Release 0.14.0

Release 0.14.0

Diffstat:
M .gitignore | 4 ++++
M .travis.yml | 3 +--
M CONTRIBUTORS | 43 ++++++++++++++++++++++---------------------
M Gemfile | 2 +-
M History.txt | 19 +++++++++++++++++++
M Rakefile | 3 ++-
M ReleaseNotes | 28 ++++++++++++++++++++++++++++
M bin/sup | 27 ++++++++++++++-------------
M bin/sup-add | 4 +++-
M bin/sup-config | 17 +++++++++++------
M bin/sup-dump | 2 ++
M bin/sup-import-dump | 4 +++-
A bin/sup-psych-ify-config-files | 16 ++++++++++++++++
M bin/sup-recover-sources | 2 ++
M bin/sup-sync | 4 +++-
M bin/sup-sync-back | 4 +++-
M bin/sup-tweak-labels | 4 +++-
M doc/FAQ.txt | 4 ++--
D doc/NewUserGuide.txt | 258 -------------------------------------------------------------------------------
M lib/sup.rb | 145 ++++++++++++++++++++++++++++++++++---------------------------------------------
M lib/sup/account.rb | 3 ++-
M lib/sup/buffer.rb | 30 +++++++++++++++---------------
M lib/sup/colormap.rb | 1 +
M lib/sup/contact.rb | 4 +++-
M lib/sup/crypto.rb | 62 ++++++++++++++++++++++++++++++++++++++++++++------------------
M lib/sup/draft.rb | 7 +++++--
M lib/sup/hook.rb | 10 ++++++++++
D lib/sup/horizontal-selector.rb | 50 --------------------------------------------------
A lib/sup/horizontal_selector.rb | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M lib/sup/index.rb | 141 +++++++++++++++++++++++++++++++++++++++++++++----------------------------------
R lib/sup/interactive-lock.rb -> lib/sup/interactive_lock.rb | 0
M lib/sup/label.rb | 4 +++-
M lib/sup/logger.rb | 2 +-
A lib/sup/logger/singleton.rb | 10 ++++++++++
D lib/sup/message-chunks.rb | 289 ------------------------------------------------------------------------------
M lib/sup/message.rb | 18 +++++++++++++-----
A lib/sup/message_chunks.rb | 289 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/buffer-list-mode.rb -> lib/sup/modes/buffer_list_mode.rb | 0
D lib/sup/modes/completion-mode.rb | 55 -------------------------------------------------------
A lib/sup/modes/completion_mode.rb | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
D lib/sup/modes/compose-mode.rb | 36 ------------------------------------
A lib/sup/modes/compose_mode.rb | 38 ++++++++++++++++++++++++++++++++++++++
D lib/sup/modes/console-mode.rb | 114 -------------------------------------------------------------------------------
A lib/sup/modes/console_mode.rb | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/contact-list-mode.rb -> lib/sup/modes/contact_list_mode.rb | 0
D lib/sup/modes/edit-message-mode.rb | 679 -------------------------------------------------------------------------------
R lib/sup/modes/edit-message-async-mode.rb -> lib/sup/modes/edit_message_async_mode.rb | 0
A lib/sup/modes/edit_message_mode.rb | 684 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/file-browser-mode.rb -> lib/sup/modes/file_browser_mode.rb | 0
R lib/sup/modes/forward-mode.rb -> lib/sup/modes/forward_mode.rb | 0
R lib/sup/modes/help-mode.rb -> lib/sup/modes/help_mode.rb | 0
D lib/sup/modes/inbox-mode.rb | 127 -------------------------------------------------------------------------------
A lib/sup/modes/inbox_mode.rb | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/label-list-mode.rb -> lib/sup/modes/label_list_mode.rb | 0
R lib/sup/modes/label-search-results-mode.rb -> lib/sup/modes/label_search_results_mode.rb | 0
D lib/sup/modes/line-cursor-mode.rb | 184 -------------------------------------------------------------------------------
A lib/sup/modes/line_cursor_mode.rb | 184 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/log-mode.rb -> lib/sup/modes/log_mode.rb | 0
R lib/sup/modes/person-search-results-mode.rb -> lib/sup/modes/person_search_results_mode.rb | 0
R lib/sup/modes/poll-mode.rb -> lib/sup/modes/poll_mode.rb | 0
R lib/sup/modes/reply-mode.rb -> lib/sup/modes/reply_mode.rb | 0
R lib/sup/modes/resume-mode.rb -> lib/sup/modes/resume_mode.rb | 0
D lib/sup/modes/scroll-mode.rb | 252 -------------------------------------------------------------------------------
A lib/sup/modes/scroll_mode.rb | 252 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/search-list-mode.rb -> lib/sup/modes/search_list_mode.rb | 0
R lib/sup/modes/search-results-mode.rb -> lib/sup/modes/search_results_mode.rb | 0
R lib/sup/modes/text-mode.rb -> lib/sup/modes/text_mode.rb | 0
D lib/sup/modes/thread-index-mode.rb | 950 -------------------------------------------------------------------------------
D lib/sup/modes/thread-view-mode.rb | 897 -------------------------------------------------------------------------------
A lib/sup/modes/thread_index_mode.rb | 950 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A lib/sup/modes/thread_view_mode.rb | 901 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M lib/sup/poll.rb | 137 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
M lib/sup/rfc2047.rb | 4 +---
M lib/sup/sent.rb | 9 +++++++--
A lib/sup/service/label_service.rb | 45 +++++++++++++++++++++++++++++++++++++++++++++
M lib/sup/source.rb | 36 +++++++++++++++---------------------
M lib/sup/textfield.rb | 10 ++++++++++
M lib/sup/util.rb | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
A lib/sup/util/path.rb | 9 +++++++++
A lib/sup/util/query.rb | 14 ++++++++++++++
A lib/sup/util/uri.rb | 15 +++++++++++++++
M lib/sup/version.rb | 2 +-
D release-script.txt | 17 -----------------
M sup.gemspec | 35 +++++++++++++++++++++++++----------
A test/gnupg_test_home/gpg.conf | 1 +
A test/gnupg_test_home/pubring.gpg | 0
A test/gnupg_test_home/receiver_pubring.gpg | 0
A test/gnupg_test_home/receiver_secring.gpg | 0
A test/gnupg_test_home/receiver_trustdb.gpg | 0
A test/gnupg_test_home/secring.gpg | 0
A test/gnupg_test_home/sup-test-2@foo.bar.asc | 20 ++++++++++++++++++++
A test/gnupg_test_home/trustdb.gpg | 0
A test/integration/test_label_service.rb | 18 ++++++++++++++++++
A test/test_crypto.rb | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M test/test_header_parsing.rb | 8 ++++----
A test/test_helper.rb | 7 +++++++
M test/test_message.rb | 18 +++++-------------
A test/test_yaml_migration.rb | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M test/test_yaml_regressions.rb | 4 ++--
A test/unit/service/test_label_service.rb | 19 +++++++++++++++++++
A test/unit/test_horizontal_selector.rb | 40 ++++++++++++++++++++++++++++++++++++++++
A test/unit/util/test_query.rb | 37 +++++++++++++++++++++++++++++++++++++
A test/unit/util/test_string.rb | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A test/unit/util/test_uri.rb | 19 +++++++++++++++++++
104 files changed, 4782 insertions(+), 4298 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -11,3 +11,7 @@ sup-exception-log.txt
 # bundler stuff
 Gemfile.lock
 .bundle
+
+# generated file for gnupg test
+test/gnupg_test_home/random_seed
+
diff --git a/.travis.yml b/.travis.yml
@@ -3,11 +3,10 @@ language: ruby
 rvm:
   - 2.0.0
   - 1.9.3
-  - 1.8.7
 
 before_install:
   - sudo apt-get update -qq
-  - sudo apt-get install -qq uuid-dev uuid libncursesw5-dev libncursesw5
+  - sudo apt-get install -qq uuid-dev uuid libncursesw5-dev libncursesw5 gnupg2
 
 script: bundle exec rake travis
 
diff --git a/CONTRIBUTORS b/CONTRIBUTORS
@@ -1,8 +1,8 @@
-William Morgan <wmorgan-sup at the masanjin dot nets>
+William Morgan <william at the twitter dot coms>
 Rich Lane <rlane at the club.cc.cmu dot edus>
 Gaute Hope <eg at the gaute.vetsj dot coms>
-Hamish Downer <dmishd at the gmail dot coms>
 Whyme Lyu <callme5long at the gmail dot coms>
+Hamish Downer <dmishd at the gmail dot coms>
 Sascha Silbe <sascha-pgp at the silbe dot orgs>
 Ismo Puustinen <ismo at the iki dot fis>
 Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
@@ -11,57 +11,58 @@ Eric Sherman <hyperbolist at the gmail dot coms>
 Tero Tilus <tero at the tilus dot nets>
 Ben Walton <bwalton at the artsci.utoronto dot cas>
 Mike Stipicevic <stipim at the rpi dot edus>
+Clint Byrum <clint at the ubuntu dot coms>
 Marcus Williams <marcus-sup at the bar-coded dot nets>
 Lionel Ott <white.magic at the gmx dot des>
 Gaudenz Steinlin <gaudenz at the soziologie dot chs>
 Damien Leone <damien.leone at the fensalir dot frs>
 Ingmar Vanhassel <ingmar at the exherbo dot orgs>
 Mark Alexander <marka at the pobox dot coms>
-Eric Weikl <eric.weikl at the tngtech dot coms>
+Eric Weikl <eric.weikl at the gmx dot nets>
 Christopher Warrington <chrisw at the rice dot edus>
 W. Trevor King <wking at the drexel dot edus>
 Richard Brown <rbrown at the exherbo dot orgs>
+Anthony Martinez <pi+sup at the pihost dot uss>
 Marc Hartstein <marc.hartstein at the alum.vassar dot edus>
 Israel Herraiz <israel.herraiz at the gmail dot coms>
-Anthony Martinez <pi+sup at the pihost dot uss>
 Bo Borgerson <gigabo at the gmail dot coms>
-William Erik Baxter <web at the superscript dot coms>
 Michael Hamann <michael at the content-space dot des>
+Jonathan Lassoff <jof at the thejof dot coms>
+William Erik Baxter <web at the superscript dot coms>
 Grant Hollingworth <grant at the antiflux dot orgs>
+Markus Klinik <markus.klinik at the gmx dot des>
+Ico Doornekamp <ico at the pruts dot nls>
 Adeodato Simó <dato at the net.com.org dot ess>
 Daniel Schoepe <daniel.schoepe at the googlemail dot coms>
 Jason Petsod <jason at the petsod dot orgs>
-Steve Goldman <sgoldman at the tower-research dot coms>
+Edward Z. Yang <edwardzyang at the thewritingpot dot coms>
 Robin Burchell <viroteck at the viroteck dot nets>
+Steve Goldman <sgoldman at the tower-research dot coms>
 Peter Harkins <ph at the malaprop dot orgs>
-Edward Z. Yang <ezyang at the MIT dot EDUs>
 Decklin Foster <decklin at the red-bean dot coms>
 Cameron Matheson <cam+sup at the cammunism dot orgs>
 Carl Worth <cworth at the cworth dot orgs>
-Jeff Balogh <its.jeff.balogh at the gmail dot coms>
-Alex Vandiver <alexmv at the mit dot edus>
+Alex Vandiver <alex at the chmrr dot nets>
 Andrew Pimlott <andrew at the pimlott dot nets>
+Jeff Balogh <its.jeff.balogh at the gmail dot coms>
 Matías Aguirre <matiasaguirre at the gmail dot coms>
-Anthony Martinez <pi at the pihost dot uss>
 Kornilios Kourtis <kkourt at the cslab.ece.ntua dot grs>
 Kevin Riggle <kevinr at the free-dissociation dot coms>
 Giorgio Lando <patroclo7 at the gmail dot coms>
 Benoît PIERRE <benoit.pierre at the gmail dot coms>
+Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
 Alvaro Herrera <alvherre at the alvh.no-ip dot orgs>
-Eric Weikl <eric.weikl at the gmx dot nets>
+Steven Lawrance <stl at the koffein dot nets>
 Jonah <Jonah at the GoodCoffee dot cas>
-ian <ian at the lorf dot orgs>
-Adam Lloyd <adam at the alloy-d dot nets>
+ian <itaylor at the uark dot edus>
 Todd Eisenberger <teisenbe at the andrew.cmu dot edus>
-Steven Walter <swalter at the monarch.(none)>
-Alex Vandiver <alex at the chmrr dot nets>
+Adam Lloyd <adam at the alloy-d dot nets>
+MichaelRevell <mikearevell at the gmail dot coms>
+Per Andersson <avtobiff at the gmail dot coms>
 Gregor Hoffleit <gregor at the sam.mediasupervision dot des>
+Steven Walter <swalter at the monarch.(none)>
 Jon M. Dugan <jdugan at the es dot nets>
-Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
-Stefan Lundström <lundst at the snabb.(none)>
 Matthias Vallentin <vallentin at the icir dot orgs>
-Steven Lawrance <stl at the redhat dot coms>
-Jonathan Lassoff <jof at the thejof dot coms>
-ian <itaylor at the uark dot edus>
-Gregor Hoffleit <gregor at the hoffleit dot des>
+Stefan Lundström <lundst at the snabb.(none)>
+Horacio Sanson <horacio at the skillupjapan.co dot jps>
 Kirill Smelkov <kirr at the landau.phys.spbu dot rus>
diff --git a/Gemfile b/Gemfile
@@ -1,3 +1,3 @@
-source 'http://rubygems.org/'
+source 'https://rubygems.org/'
 
 gemspec
diff --git a/History.txt b/History.txt
@@ -1,3 +1,22 @@
+== 0.14.0 / 2013-08-15
+
+* CJK compatability
+* Psych over Syck
+* Ruby 1.8 deprecated
+* Thread safety
+* No more Iconv, but using built in Ruby encodings. Better UTF-8
+  handling.
+* GPGME 2.0 support
+
+== 0.13.2 / 2013-06-26
+
+* FreeBSD 10 comptability
+* More threadsafe polling
+
+== 0.13.1 / 2013-06-21
+
+* Bugfixes
+
 == 0.13.0 / 2013-05-15
 
 * Bugfixes
diff --git a/Rakefile b/Rakefile
@@ -3,9 +3,10 @@ require 'rake/testtask'
 
 Rake::TestTask.new(:test) do |test|
   test.libs << 'test'
-  test.test_files = FileList.new('test/test_*.rb')
+  test.test_files = FileList.new('test/**/test_*.rb')
   test.verbose = true
 end
+task :default => :test
 
 require 'rubygems/package_task'
 # For those who don't have `rubygems-bundler` installed
diff --git a/ReleaseNotes b/ReleaseNotes
@@ -1,3 +1,31 @@
+Release 0.14.0:
+
+CJK-compatability, Psych usage, thread safety, GPGME 2.0 support. Sup is now
+Ruby 1.9 based, and apart from RMail - ready for Ruby 2.0.0.
+
+Sup now uses Psych as a YAML parser (default by Ruby) and your previous
+configuration files (~/.sup/*.yaml) may need to be migrated or re-created for
+them to work with the new sup. A migration script is included for this.
+
+Check https://github.com/sup-heliotrope/sup/wiki/Migration-0.13-to-0.14 for
+the latest instructions.
+
+First back up your ~/.sup directory and index, after installing the new sup
+run:
+
+$ sup-psych-ify-config-files
+
+to migrate your files. You should now be all set for buisness.
+
+
+Release 0.13.2:
+
+FreeBSD compatability and more thread safe polling.
+
+Release 0.13.1:
+
+Another ruby 1.8 compatible release, various fixes.
+
 Release 0.13.0:
 
 Collection of bugfixes and stability fixes since 0.12.1. We now depend on our
diff --git a/bin/sup b/bin/sup
@@ -1,4 +1,7 @@
 #!/usr/bin/env ruby
+# encoding: utf-8
+
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
 
 require 'rubygems'
 
@@ -6,8 +9,6 @@ require 'ncursesw'
 
 no_gpgme = false
 begin
-  # gpgme broke its API in 2.0, so make sure we have the old version for now.
-  gem 'gpgme', '=1.0.8'
   require 'gpgme'
 rescue LoadError
   no_gpgme = true
@@ -15,7 +16,7 @@ end
 
 require 'fileutils'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 if ENV['SUP_PROFILE']
   require 'ruby-prof'
@@ -104,8 +105,6 @@ end
 ## ncurses.so that's been compiled against libncursesw. (note the w.) why
 ## this works, i have no idea. much like pretty much every aspect of
 ## dealing with curses.  cargo cult programming at its best.
-##
-## BSD users: if libc.so.6 is not found, try installing compat6x.
 require 'dl/import'
 require 'rbconfig'
 module LibC
@@ -113,6 +112,7 @@ module LibC
   setlocale_lib = case RbConfig::CONFIG['arch']
     when /darwin/; "libc.dylib"
     when /cygwin/; "cygwin1.dll"
+    when /freebsd/; "libc.so.7"
     else; "libc.so.6"
   end
 
@@ -125,9 +125,6 @@ module LibC
   rescue RuntimeError => e
     warn "cannot dlload setlocale(); ncurses wide character support probably broken."
     warn "dlload error was #{e.class}: #{e.message}"
-    if RbConfig::CONFIG['arch'] =~ /bsd/
-      warn "BSD variant detected. You may have to install a compat6x package to acquire libc."
-    end
   end
 end
 
@@ -160,7 +157,11 @@ begin
 
   $die = false
   trap("TERM") { |x| $die = true }
-  trap("WINCH") { |x| BufferManager.sigwinch_happened! }
+  trap("WINCH") do |x|
+   ::Thread.new do
+     BufferManager.sigwinch_happened!
+   end
+  end
 
   if(s = Redwood::SourceManager.source_for DraftManager.source_name)
     DraftManager.source = s
@@ -292,8 +293,8 @@ begin
       b.mode.load_in_background if new
     when :search
       completions = LabelManager.all_labels.map { |l| "label:#{LabelManager.string_for l}" }
-      completions = completions.each { |l| l.force_encoding 'UTF-8' if l.methods.include?(:encoding) }
-      completions += ["from:", "to:", "after:", "before:", "date:", "limit:", "AND", "OR", "NOT"]
+      completions = completions.each { |l| l.fix_encoding }
+      completions += Index::COMPL_PREFIXES
       query = BufferManager.ask_many_with_completions :search, "Search all messages (enter for saved searches): ", completions
       unless query.nil?
         if query.empty?
@@ -306,7 +307,7 @@ begin
       SearchResultsMode.spawn_from_query "is:unread"
     when :list_labels
       labels = LabelManager.all_labels.map { |l| LabelManager.string_for l }
-      labels = labels.each { |l| l.force_encoding 'UTF-8' if l.methods.include?(:encoding) }
+      labels = labels.each { |l| l.fix_encoding }
 
       user_label = bm.ask_with_completions :label, "Show threads with label (enter for listing): ", labels
       unless user_label.nil?
@@ -414,7 +415,7 @@ unless Redwood::exceptions.empty?
 We are very sorry. It seems that an error occurred in Sup. Please
 accept our sincere apologies. Please submit the contents of
 #{BASE_DIR}/exception-log.txt and a brief report of the
-circumstances to https://github.com/sup-heliotrope/sup/issues so that 
+circumstances to https://github.com/sup-heliotrope/sup/issues so that
 we might address this problem. Thank you!
 
 Sincerely,
diff --git a/bin/sup-add b/bin/sup-add
@@ -1,10 +1,12 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'uri'
 require 'rubygems'
 require 'highline/import'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 $opts = Trollop::options do
   version "sup-add (sup #{Redwood::VERSION})"
diff --git a/bin/sup-config b/bin/sup-config
@@ -1,5 +1,7 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 require 'highline/import'
 require 'trollop'
@@ -19,11 +21,12 @@ EOS
 end
 
 def axe q, default=nil
-  ans = if default && !default.empty?
-    ask "#{q} (enter for \"#{default}\"): "
-  else
-    ask "#{q}: "
-  end
+  question = if default && !default.empty?
+               "#{q} (enter for \"#{default}\"): "
+             else
+               "#{q}: "
+             end
+  ans = ask question
   ans.empty? ? default : ans.to_s
 end
 
@@ -36,6 +39,8 @@ def build_cmd cmd
 end
 
 def add_source
+  require "sup/util/uri"
+
   type = nil
 
   say "Ok, adding a new source."
@@ -69,7 +74,7 @@ def add_source
     end
 
     uri = begin
-      URI::Generic.build components
+      Redwood::Util::Uri.build components
     rescue URI::Error => e
       say "Whoopsie! I couldn't build a URI from that: #{e.message}"
       if axe_yes("Try again?") then next else return end
diff --git a/bin/sup-dump b/bin/sup-dump
@@ -1,5 +1,7 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 require 'xapian'
 require 'trollop'
diff --git a/bin/sup-import-dump b/bin/sup-import-dump
@@ -1,9 +1,11 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'uri'
 require 'rubygems'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 PROGRESS_UPDATE_INTERVAL = 15 # seconds
 
diff --git a/bin/sup-psych-ify-config-files b/bin/sup-psych-ify-config-files
@@ -0,0 +1,16 @@
+#!/usr/bin/env ruby
+
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
+require "sup"
+require "fileutils"
+
+Redwood.start
+
+fn = Redwood::SOURCE_FN
+FileUtils.cp fn, "#{fn}.syck_bak"
+
+Redwood::SourceManager.load_sources fn
+Redwood::SourceManager.save_sources fn, true
+
+Redwood.finish
diff --git a/bin/sup-recover-sources b/bin/sup-recover-sources
@@ -1,5 +1,7 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'optparse'
 
 $opts = {
diff --git a/bin/sup-sync b/bin/sup-sync
@@ -1,9 +1,11 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'uri'
 require 'rubygems'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 PROGRESS_UPDATE_INTERVAL = 15 # seconds
 
diff --git a/bin/sup-sync-back b/bin/sup-sync-back
@@ -1,10 +1,12 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 require 'uri'
 require 'tempfile'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 fail "not working yet"
 
diff --git a/bin/sup-tweak-labels b/bin/sup-tweak-labels
@@ -1,8 +1,10 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 class Float
   def to_s; sprintf '%.2f', self; end
diff --git a/doc/FAQ.txt b/doc/FAQ.txt
@@ -21,8 +21,8 @@ A: I hate ads, I hate using a mouse, and I hate non-programmability and
 
 Q: Why the console?
 A: Because a keystroke is worth a hundred mouse clicks, as any Unix
-   user knows. Because you don't need web browser. Because you get
-   instantaneous response and a simple interface.
+   user knows. Because you don't need a web browser. Because you get
+   an instantaneous response and a simple interface.
 
 Q: How does Sup deal with spam?
 A: You can manually mark messages as spam, which prevents them from
diff --git a/doc/NewUserGuide.txt b/doc/NewUserGuide.txt
@@ -1,258 +0,0 @@
-Welcome to Sup! Here's how to get started.
-
-First, try running `sup`. Since this is your first time, you'll be
-confronted with a mostly blank screen, and a notice at the bottom that
-you have no new messages. That's because Sup doesn't hasn't loaded
-anything into its index yet, and has no idea where to look for them
-anyways.
-
-If you want to play around a little at this point, you can press 'b'
-to cycle between buffers, ';' to get a list of the open buffers, and
-'x' to kill a buffer. There's probably not too much interesting there,
-but there's a log buffer with some cryptic messages. You can also
-press '?' at any point to get a list of keyboard commands, but in the
-absence of any email, these will be mostly useless. When you get
-bored, press 'q' to quit.
-
-To use Sup for email, we need to load messages into the index. The
-index is where Sup stores all message state (e.g. read or unread, any
-message labels), and all information necessary for searching and for
-threading messages. Sup only knows about messages in its index.
-
-We can add messages to the index by telling Sup about the "source"
-where the messages reside. Sources are things like mbox folders, and
-maildir directories. Sup doesn't duplicate the actual message content
-in the index; it only stores whatever information is necessary for
-searching, threading and labelling. So when you search for messages or
-view your inbox, Sup talks only to the index (stored locally on
-disk). When you view a thread, Sup requests the full content of all
-the messages from the source.
-
-The easiest way to set up all your sources is to run `sup-config`.
-This will interactively walk you through some basic configuration,
-prompt you for all the sources you need, and optionally import
-messages from them.  Sup-config uses two other tools, sup-add and
-sup-sync, to load messages into the index. In the future you may make
-use of these tools directly (see below).
-
-Once you've run sup-config, you're ready to run `sup`. You should see
-the most recent unarchived messages appear in your inbox.
-Congratulations, you've got Sup working!
-
-If you're coming from the world of traditional MUAs, there are a
-couple differences you should be aware of at this point. First, Sup
-has no folders. Instead, you organize and find messages by a
-combination of search and labels (known as "tags" everywhere else in
-the world). Search and labels are an integral part of Sup because in
-Sup, rather than viewing the contents of a folder, you view the
-results of a search. I mentioned above that your inbox is, by
-definition, the set of all messages that aren't archived. This means
-that your inbox is nothing more than the result of the search for all
-messages with the label "inbox". (It's actually slightly more
-complicated---we also omit messages marked as killed, deleted or
-spam.)
-
-You could replicate the folder paradigm easily under this scheme, by
-giving each message exactly one label and only viewing the results of
-simple searches for those labels. But you'd quickly find out that life
-can be easier than that if you just trust the search engine, and use
-labels judiciously for things that are too hard to find with search.
-The idea is that a labeling system that allows arbitrary, user-defined
-labels, supplemented by a quick and easy-to-access search mechanism
-provides all the functionality that folders does, plus much more, at a
-far lower cost to the user.
-
-Now let's take a look at your inbox. You'll see that Sup groups
-messages together into threads: each line in the inbox is a thread,
-and the number in parentheses is the number of messages in that
-thread. (If there's no number, there's just one message in the
-thread.) In Sup, most operations are on threads, not individual
-messages. The idea is that you rarely want to operate on a message
-independent of its context. You typically want to view, archive, kill,
-or label all the messages in a thread at one time.
-
-Use the up and down arrows to highlight a thread. ('j' and 'k' do the
-same thing, and 'J' and 'K' will scroll the whole window. Even the
-left and right arrow keys work.) By default, Sup only loads as many
-threads as it takes to fill the window; if you'd like to load more,
-press 'M'. You can hit tab to cycle between only threads with new
-messages.
-
-Highlight a thread and press enter to view it. You'll notice that all
-messages in the thread are displayed together, laid out graphically by
-their relationship to each other (replies are nested under parents).
-By default, only the new messages in a thread are expanded, and the
-others are hidden. You can toggle an individual message's state by
-highlighting a green line and pressing enter. You can use 'E' to
-expand or collapse all messages or 'N' to expand only the new
-messages. You'll also notice that Sup hides quoted text and
-signatures. If you highlight a particular hidden chunk, you can press
-enter to expand it, or you can press 'o' to toggle every hidden chunk
-in a particular message.
-
-Other useful keyboard commands when viewing a thread are: 'n' and 'p'
-to jump to the next and previous open messages, 'h' to toggle the
-detailed headers for the current message, and enter to expand or
-collapse the current message (when it's on a text region). Enter and
-'n' in combination are useful for scanning through a thread---press
-enter to close the current message and jump to the next open one, and
-'n' to keep it open and jump. If the buffer is misaligned with a
-message, you can press 'z' to highlight it.
-
-This is a lot to remember, but you can always hit '?' to see the full
-list of keyboard commands at any point. There's a lot of useful stuff
-in there---once you learn some, try out some of the others!
-
-Now press 'x' to kill the thread view buffer. You should see the inbox
-again. If you don't, you can cycle through the buffers by pressing
-'b', or you can press ';' to see a list of all buffers and simply
-select the inbox.
-
-There are many operations you can perform on threads beyond viewing
-them. To archive a thread, press 'a'. The thread will disappear from
-your inbox, but will still appear in search results. If someone
-replies an archived thread, it will reappear in your inbox. To kill a
-thread, press '&'. Killed threads will never come back to your inbox,
-even if people reply, but will still be searchable. (This is useful
-for those interminable threads that you really have no immediate
-interest in, but which seem to pop up on every mailing list.)
-
-If a thread is spam, press 'S'. It will disappear and won't come back.
-It won't even appear in search results, unless you explicitly search
-for spam.
-
-You can star a thread by pressing '*'. Starred threads are displayed
-with a little yellow asterisk next to them, but otherwise have no
-special semantics. But you can also search for them easily---we'll see
-how in a moment.
-
-To edit the labels for (all the messages in) a thread, press 'l'. Type
-in the labels as a sequence of space-separated words. To cancel the
-input, press Ctrl-G.
-
-Many of these operations can be applied to a group of threads. Press
-'t' to tag a thread. Tag a couple, then press '=' to apply the next
-command to the set of threads. '=t', of course, will untag all tagged
-messages.
-
-Ok, let's try using labels and search. Press 'L' to do a quick label
-search. You'll be prompted for a label; simply hit enter to bring up
-scrollable list of all the labels you've ever used, along with some
-special labels (Draft, Starred, Sent, Spam, etc.). Highlight a label
-and press enter to view all the messages with that label.
-
-What you just did was actually a specific search. For a general search,
-press '\' (backslash---forward slash is used for in-buffer search,
-following console conventions). Now type in your query (again, Ctrl-G to
-cancel at any point.) You can just type in arbitrary text, which will be
-matched on a per-word basis against the bodies of all email in the
-index, or you can make use of the full Xapian query syntax
-(http://xapian.org/docs/queryparser.html):
-
-- Phrasal queries using double-quotes, e.g.: "three contiguous words"
-- Queries against a particular field using <field name>:<query>,
-  e.g.: label:ruby-talk, or from:matz@ruby-lang.org. (Fields include:
-  body, from, to, and subject.)
-- Force non-occurrence by -, e.g. -body:"hot soup".
-- If you have the chronic gem installed, date queries like
-  "before:today", "on:today", "after:yesterday", "after:(2 days ago)"
-  (parentheses required for multi-word descriptions).
-
-You can combine those all together. For example:
-
-     label:ruby-talk subject:\[ANN\] -rails on:today
-
-Play around with the search, and see the Xapian documentation for
-details on more sophisticated queries (date ranges, "within n words",
-etc.)
-
-At this point, you're well on your way to figuring out all the cool
-things Sup can do. By repeated application of the '?' key, see if you
-can figure out how to:
-
- - List some recent contacts
- - Easily search for all mail from a recent contact
- - Easily search for all mail from several recent contacts
- - Add someone to your address book
- - Postpone a message (i.e., save a draft)
- - Quickly re-edit a just-saved draft message
- - View the raw header of a message
- - Star an individual message, not just a thread
-
-There's one last thing to be aware of when using Sup: how it interacts
-with other email programs. As I described above, Sup stores data about
-messages in the index, but doesn't duplicate the message contents
-themselves. The messages remain on the source. If the index and the
-source every fall out of sync, e.g. due to another email client
-modifying the source, then Sup will be unable to operate on that
-source. For example, for mbox files, Sup stores a byte offset into the
-file for each message. If a message deleted from that file by another
-client, or even marked as read (yeah, mbox sucks), all succeeding
-offsets will be wrong.
-
-That's the bad news. The good news is that Sup is pretty good at being
-able to detect this type of situation, and fixing it is just a matter
-of running `sup-sync --changed` on the source. Sup will even tell you
-how to invoke sup-sync when it detects a problem. This is a
-complication you will almost certainly run in to if you use both Sup
-and another MUA on the same source, so it's good to be aware of it.
-
-Have fun, and email sup-talk@rubyforge.org if you have any problems!
-
-Appendix A: sup-add and sup-sync
----------------------------------
-
-Instead of using sup-config to add a new source, you can manually run
-`sup-add` with a URI pointing to it. The URI should be of the form:
-
-- mbox://path/to/a/filename, for an mbox file on disk.
-- maildir://path/to/a/filename, for a maildir directory on disk.
-
-Before you add the source, you need make three decisions. The first is
-whether you want Sup to regularly poll this source for new messages.
-By default it will, but if this is a source that will never have new
-messages, you can specify `--unusual`. Sup polls only "usual" sources
-when checking for new mail (unless you manually invoke sup-sync).
-
-The second is whether you want messages from the source to be
-automatically archived. An archived message will not show up in your
-inbox, but will be found when you search. (Your inbox in Sup is, by
-definition, the set of all all non-archived messages). Specify
-`--archive` to automatically archive all messages from the source. This
-is useful for sources that contain, for example, high-traffic mailing
-lists that you don't want polluting your inbox.
-
-The final decision is whether you want any labels automatically
-applied to messages from this source. You can use `--labels` to do this.
-
-Now that you've added the source, let's import all the current
-messages from it, by running sup-sync with the source URI. You can
-specify `--archive` to automatically archive all messages in this
-import; typically you'll want to specify this for every source you
-import except your actual inbox. You can also specify `--read` to mark
-all imported messages as read; the default is to preserve the
-read/unread status from the source.
-
-Sup-sync will now load all the messages from the source into the
-index. Depending on the size of the source, this may take a while.
-Don't panic! It's a one-time process.
-
-Appendix B: Automatically labeling incoming email
--------------------------------------------------
-
-One option is to filter incoming email into different sources with
-something like procmail, and have each of these sources auto-apply
-labels by using `sup-add --labels`.
-
-But the better option is to learn Ruby and write a before-add hook.
-This will allow you to apply labels based on whatever crazy logic you
-can come up with. See http://sup.rubyforge.org/wiki/wiki.pl?Hooks for
-examples.
-
-Appendix C: Reading blogs with Sup
-----------------------------------
-
-Really, blog posts should be read like emails are read---you should be
-able to mark them as unread, flag them, label them, etc. Use rss2email
-to transform RSS feeds into emails, direct them all into a source, and
-add that source to Sup. Voila!
diff --git a/lib/sup.rb b/lib/sup.rb
@@ -1,15 +1,12 @@
-require 'rubygems'
+# encoding: utf-8
 
-require 'syck'
+require 'rubygems'
 require 'yaml'
-if YAML.const_defined? :ENGINE
-  YAML::ENGINE.yamler = 'syck'
-end
-
+YAML::ENGINE.yamler = 'psych'
 require 'zlib'
 require 'thread'
 require 'fileutils'
-require 'gettext'
+require 'locale'
 require 'curses'
 require 'rmail'
 begin
@@ -28,18 +25,23 @@ end
 class Module
   def yaml_properties *props
     props = props.map { |p| p.to_s }
-    vars = props.map { |p| "@#{p}" }
-    klass = self
-    path = klass.name.gsub(/::/, "/")
 
-    klass.instance_eval do
-      define_method(:to_yaml_properties) { vars }
-      define_method(:to_yaml_type) { "!#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}" }
+    path = name.gsub(/::/, "/")
+    yaml_tag "!#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}"
+
+    define_method :init_with do |coder|
+      initialize(*coder.map.values_at(*props))
     end
 
-    YAML.add_domain_type("#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}", path) do |type, val|
-      klass.new(*props.map { |p| val[p] })
+    define_method :encode_with do |coder|
+      coder.map = props.inject({}) do |hash, key|
+        hash[key] = instance_variable_get("@#{key}")
+        hash
+      end
     end
+
+    # Legacy
+    Psych.load_tags["!#{Redwood::LEGACY_YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}"] = self
   end
 end
 
@@ -58,7 +60,8 @@ module Redwood
   SEARCH_FN  = File.join(BASE_DIR, "searches.txt")
   LOG_FN     = File.join(BASE_DIR, "log")
 
-  YAML_DOMAIN = "masanjin.net"
+  YAML_DOMAIN = "supmua.org"
+  LEGACY_YAML_DOMAIN = "masanjin.net"
   YAML_DATE = "2006-10-01"
 
   ## record exceptions thrown in threads nicely
@@ -234,36 +237,6 @@ EOM
     end
   end
 
-  ## to be called by entry points in bin/, to ensure that
-  ## their versions match up against the library versions.
-  ##
-  ## this is a perennial source of bug reports from people
-  ## who both use git and have a gem version installed.
-  def check_library_version_against v
-    unless Redwood::VERSION == v
-      $stderr.puts <<EOS
-Error: version mismatch!
-The sup executable is at version #{v.inspect}.
-The sup libraries are at version #{Redwood::VERSION.inspect}.
-
-Your development environment may be picking up code from a
-rubygems installation of sup.
-
-If you're running from git with a commandline like
-
-  ruby -Ilib #{$0}
-
-try this instead:
-
-  RUBY_INVOCATION="ruby -Ilib" ruby -Ilib #{$0}
-
-You can also try `gem uninstall sup` and removing all Sup rubygems.
-
-EOS
-#' duh!
-      abort
-    end
-  end
 
   ## set up default configuration file
   def load_config filename
@@ -298,7 +271,7 @@ EOS
     else
       require 'etc'
       require 'socket'
-      name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first rescue nil
+      name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first.force_encoding($encoding).fix_encoding rescue nil
       name ||= ENV["USER"]
       email = ENV["USER"] + "@" +
         begin
@@ -310,8 +283,8 @@ EOS
       config = {
         :accounts => {
           :default => {
-            :name => name,
-            :email => email,
+            :name => name.fix_encoding,
+            :email => email.fix_encoding,
             :alternates => [],
             :sendmail => "/usr/sbin/sendmail -oem -ti",
             :signature => File.join(ENV["HOME"], ".signature"),
@@ -330,8 +303,7 @@ EOS
   end
 
   module_function :save_yaml_obj, :load_yaml_obj, :start, :finish,
-                  :report_broken_sources, :check_library_version_against,
-                  :load_config, :managers
+                  :report_broken_sources, :load_config, :managers
 end
 
 require 'sup/version'
@@ -340,13 +312,12 @@ require "sup/hook"
 require "sup/time"
 
 ## everything we need to get logging working
-require "sup/logger"
-Redwood::Logger.init.add_sink $stderr
-include Redwood::LogsStuff
+require "sup/logger/singleton"
 
 ## determine encoding and character set
 $encoding = Locale.current.charset
 $encoding = "UTF-8" if $encoding == "utf8"
+$encoding = "UTF-8" if $encoding == "UTF8"
 if $encoding
   debug "using character set encoding #{$encoding.inspect}"
 else
@@ -354,14 +325,24 @@ else
   $encoding = "UTF-8"
 end
 
+# test encoding
+teststr = "test"
+teststr.encode('UTF-8')
+begin
+  teststr.encode($encoding)
+rescue Encoding::ConverterNotFoundError
+  warn "locale encoding is invalid, defaulting to utf-8"
+  $encoding = "UTF-8"
+end
+
 require "sup/buffer"
 require "sup/keymap"
 require "sup/mode"
-require "sup/modes/scroll-mode"
-require "sup/modes/text-mode"
-require "sup/modes/log-mode"
+require "sup/modes/scroll_mode"
+require "sup/modes/text_mode"
+require "sup/modes/log_mode"
 require "sup/update"
-require "sup/message-chunks"
+require "sup/message_chunks"
 require "sup/message"
 require "sup/source"
 require "sup/mbox"
@@ -369,7 +350,7 @@ require "sup/maildir"
 require "sup/person"
 require "sup/account"
 require "sup/thread"
-require "sup/interactive-lock"
+require "sup/interactive_lock"
 require "sup/index"
 require "sup/textfield"
 require "sup/colormap"
@@ -380,31 +361,31 @@ require "sup/draft"
 require "sup/poll"
 require "sup/crypto"
 require "sup/undo"
-require "sup/horizontal-selector"
-require "sup/modes/line-cursor-mode"
-require "sup/modes/help-mode"
-require "sup/modes/edit-message-mode"
-require "sup/modes/edit-message-async-mode"
-require "sup/modes/compose-mode"
-require "sup/modes/resume-mode"
-require "sup/modes/forward-mode"
-require "sup/modes/reply-mode"
-require "sup/modes/label-list-mode"
-require "sup/modes/contact-list-mode"
-require "sup/modes/thread-view-mode"
-require "sup/modes/thread-index-mode"
-require "sup/modes/label-search-results-mode"
-require "sup/modes/search-results-mode"
-require "sup/modes/person-search-results-mode"
-require "sup/modes/inbox-mode"
-require "sup/modes/buffer-list-mode"
-require "sup/modes/poll-mode"
-require "sup/modes/file-browser-mode"
-require "sup/modes/completion-mode"
-require "sup/modes/console-mode"
+require "sup/horizontal_selector"
+require "sup/modes/line_cursor_mode"
+require "sup/modes/help_mode"
+require "sup/modes/edit_message_mode"
+require "sup/modes/edit_message_async_mode"
+require "sup/modes/compose_mode"
+require "sup/modes/resume_mode"
+require "sup/modes/forward_mode"
+require "sup/modes/reply_mode"
+require "sup/modes/label_list_mode"
+require "sup/modes/contact_list_mode"
+require "sup/modes/thread_view_mode"
+require "sup/modes/thread_index_mode"
+require "sup/modes/label_search_results_mode"
+require "sup/modes/search_results_mode"
+require "sup/modes/person_search_results_mode"
+require "sup/modes/inbox_mode"
+require "sup/modes/buffer_list_mode"
+require "sup/modes/poll_mode"
+require "sup/modes/file_browser_mode"
+require "sup/modes/completion_mode"
+require "sup/modes/console_mode"
 require "sup/sent"
 require "sup/search"
-require "sup/modes/search-list-mode"
+require "sup/modes/search_list_mode"
 require "sup/idle"
 
 $:.each do |base|
diff --git a/lib/sup/account.rb b/lib/sup/account.rb
@@ -50,8 +50,9 @@ class AccountManager
       [:name, :sendmail, :signature, :gpgkey].each { |k| hash[k] ||= @default_account.send(k) }
     end
     hash[:alternates] ||= []
+    fail "alternative emails are not an array: #{hash[:alternates]}" unless hash[:alternates].kind_of? Array
 
-    [:name, :signature].each { |x| hash[x].force_encoding Encoding::UTF_8 if hash[x].respond_to? :encoding }
+    [:name, :signature].each { |x| hash[x] ? hash[x].fix_encoding : nil }
 
     a = Account.new hash
     @accounts[a] = true
diff --git a/lib/sup/buffer.rb b/lib/sup/buffer.rb
@@ -1,3 +1,5 @@
+# encoding: utf-8
+
 require 'etc'
 require 'thread'
 
@@ -126,14 +128,11 @@ class Buffer
     @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight])
     s ||= ""
     maxl = @width - x # maximum display width width
-    stringl = maxl    # string "length"
 
     # fill up the line with blanks to overwrite old screen contents
     @w.mvaddstr y, x, " " * maxl unless opts[:no_fill]
 
-    ## the next horribleness is thanks to ruby's lack of widechar support
-    stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
-    @w.mvaddstr y, x, s[0 ... stringl]
+    @w.mvaddstr y, x, s.slice_by_display_length(maxl)
   end
 
   def clear
@@ -267,7 +266,7 @@ EOS
 
   def handle_input c
     if @focus_buf
-      if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
+      if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY.ord
         @focus_buf.mode.cancel_search!
         @focus_buf.mark_dirty
       end
@@ -450,7 +449,7 @@ EOS
 
   def ask_with_completions domain, question, completions, default=nil
     ask domain, question, default do |s|
-      s.force_encoding 'UTF-8' if s.methods.include?(:encoding)
+      s.fix_encoding
       completions.select { |x| x =~ /^#{Regexp::escape s}/iu }.map { |x| [x, x] }
     end
   end
@@ -467,9 +466,9 @@ EOS
           raise "william screwed up completion: #{partial.inspect}"
         end
 
-      prefix.force_encoding 'UTF-8' if prefix.methods.include?(:encoding)
-      target.force_encoding 'UTF-8' if target.methods.include?(:encoding)
-      completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
+      prefix.fix_encoding
+      target.fix_encoding
+      completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.map { |x| [prefix + x, x] }
     end
   end
 
@@ -477,12 +476,12 @@ EOS
     ask domain, question, default do |partial|
       prefix, target = partial.split_on_commas_with_remainder
       target ||= prefix.pop || ""
-      target.force_encoding 'UTF-8' if target.methods.include?(:encoding)
+      target.fix_encoding
 
       prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
-      prefix.force_encoding 'UTF-8' if prefix.methods.include?(:encoding)
+      prefix.fix_encoding
 
-      completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
+      completions.select { |x| x =~ /^#{Regexp::escape target}/iu }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
     end
   end
 
@@ -495,7 +494,7 @@ EOS
         if dir
           [[s.sub(full, dir), "~#{name}"]]
         else
-          users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u|
+          users.select { |u| u =~ /^#{Regexp::escape name}/u }.map do |u|
             [s.sub("~#{name}", "~#{u}"), "~#{u}"]
           end
         end
@@ -554,6 +553,7 @@ EOS
 
     completions = (recent + contacts).flatten.uniq
     completions += HookManager.run("extra-contact-addresses") || []
+
     answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
 
     if answer
@@ -622,7 +622,7 @@ EOS
       tf.deactivate
       draw_screen :sync => false, :status => status, :title => title
     end
-    tf.value.tap { |x| x.force_encoding Encoding::UTF_8 if x && x.respond_to?(:encoding) }
+    tf.value.tap { |x| x }
   end
 
   def ask_getch question, accept=nil
@@ -709,7 +709,7 @@ EOS
     end
 
     Ncurses.mutex.lock unless opts[:sync] == false
-    Ncurses.attrset Colormap.color_for(:none)
+    Ncurses.attrset Colormap.color_for(:text_color)
     adj = @asking ? 2 : 1
     m.each_with_index do |s, i|
       Ncurses.mvaddstr Ncurses.rows - i - adj, 0, s + (" " * [Ncurses.cols - s.length, 0].max)
diff --git a/lib/sup/colormap.rb b/lib/sup/colormap.rb
@@ -26,6 +26,7 @@ class Colormap
   @@instance = nil
 
   DEFAULT_COLORS = {
+    :text => { :fg => "white", :bg => "black" },
     :status => { :fg => "white", :bg => "blue", :attrs => ["bold"] },
     :index_old => { :fg => "white", :bg => "default" },
     :index_new => { :fg => "white", :bg => "default", :attrs => ["bold"] },
diff --git a/lib/sup/contact.rb b/lib/sup/contact.rb
@@ -1,3 +1,5 @@
+# encoding: utf-8
+
 module Redwood
 
 class ContactManager
@@ -54,7 +56,7 @@ class ContactManager
   def is_aliased_contact? person; !@p2a[person].nil? end
 
   def save
-    File.open(@fn, "w") do |f|
+    File.open(@fn, "w:UTF-8") do |f|
       @p2a.sort_by { |(p, a)| [p.full_address, a] }.each do |(p, a)|
         f.puts "#{a || ''}: #{p.full_address}"
       end
diff --git a/lib/sup/crypto.rb b/lib/sup/crypto.rb
@@ -1,6 +1,4 @@
 begin
-  # gpgme broke its API in 2.0, so make sure we have the old version for now.
-  gem 'gpgme', '=1.0.8'
   require 'gpgme'
 rescue LoadError
 end
@@ -64,28 +62,39 @@ EOS
     @gpgme_present =
       begin
         begin
-          GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
+          begin
+            GPGME.check_version({:protocol => GPGME::PROTOCOL_OpenPGP})
+          rescue TypeError
+            GPGME.check_version(nil)
+          end
           true
         rescue GPGME::Error
           false
+        rescue ArgumentError
+          # gpgme 2.0.0 raises this due to the hash->string conversion
+          false
         end
       rescue NameError
         false
       end
 
     unless @gpgme_present
-      @not_working_reason = ['gpgme gem not present', 
+      @not_working_reason = ['gpgme gem not present',
         'Install the gpgme gem in order to use signed and encrypted emails']
       return
     end
 
     # if gpg2 is available, it will start gpg-agent if required
     if (bin = `which gpg2`.chomp) =~ /\S/
-      GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
+      if GPGME.respond_to?('set_engine_info')
+        GPGME.set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
+      else
+        GPGME.gpgme_set_engine_info GPGME::PROTOCOL_OpenPGP, bin, nil
+      end
     else
       # check if the gpg-options hook uses the passphrase_callback
       # if it doesn't then check if gpg agent is present
-      gpg_opts = HookManager.run("gpg-options", 
+      gpg_opts = HookManager.run("gpg-options",
                                {:operation => "sign", :options => {}}) || {}
       if gpg_opts[:passphrase_callback].nil?
         if ENV['GPG_AGENT_INFO'].nil?
@@ -110,22 +119,29 @@ EOS
   end
 
   def have_crypto?; @not_working_reason.nil? end
+  def not_working_reason; @not_working_reason end
 
   def sign from, to, payload
     return unknown_status(@not_working_reason) unless @not_working_reason.nil?
 
     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
     gpg_opts.merge!(gen_sign_user_opts(from))
-    gpg_opts = HookManager.run("gpg-options", 
+    gpg_opts = HookManager.run("gpg-options",
                                {:operation => "sign", :options => gpg_opts}) || gpg_opts
 
     begin
-      sig = GPGME.detach_sign(format_payload(payload), gpg_opts)
+      if GPGME.respond_to?('detach_sign')
+        sig = GPGME.detach_sign(format_payload(payload), gpg_opts)
+      else
+        crypto = GPGME::Crypto.new
+        gpg_opts[:mode] = GPGME::SIG_MODE_DETACH
+        sig = crypto.sign(format_payload(payload), gpg_opts).read
+      end
     rescue GPGME::Error => exc
       raise Error, gpgme_exc_msg(exc.message)
     end
 
-    # if the key (or gpg-agent) is not available GPGME does not complain 
+    # if the key (or gpg-agent) is not available GPGME does not complain
     # but just returns a zero length string. Let's catch that
     if sig.length == 0
       raise Error, gpgme_exc_msg("GPG failed to generate signature: check that gpg-agent is running and your key is available.")
@@ -145,7 +161,7 @@ EOS
 
     gpg_opts = {:protocol => GPGME::PROTOCOL_OpenPGP, :armor => true, :textmode => true}
     if sign
-      gpg_opts.merge!(gen_sign_user_opts(from)) 
+      gpg_opts.merge!(gen_sign_user_opts(from))
       gpg_opts.merge!({:sign => true})
     end
     gpg_opts = HookManager.run("gpg-options",
@@ -153,12 +169,18 @@ EOS
     recipients = to + [from]
     recipients = HookManager.run("gpg-expand-keys", { :recipients => recipients }) || recipients
     begin
-      cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
+      if GPGME.respond_to?('encrypt')
+        cipher = GPGME.encrypt(recipients, format_payload(payload), gpg_opts)
+      else
+        crypto = GPGME::Crypto.new
+        gpg_opts[:recipients] = recipients
+        cipher = crypto.encrypt(format_payload(payload), gpg_opts).read
+      end
     rescue GPGME::Error => exc
       raise Error, gpgme_exc_msg(exc.message)
     end
 
-    # if the key (or gpg-agent) is not available GPGME does not complain 
+    # if the key (or gpg-agent) is not available GPGME does not complain
     # but just returns a zero length string. Let's catch that
     if cipher.length == 0
       raise Error, gpgme_exc_msg("GPG failed to generate cipher text: check that gpg-agent is running and your key is available.")
@@ -262,7 +284,11 @@ EOS
                                {:operation => "decrypt", :options => gpg_opts}) || gpg_opts
     ctx = GPGME::Ctx.new(gpg_opts)
     cipher_data = GPGME::Data.from_str(format_payload(payload))
-    plain_data = GPGME::Data.empty
+    if GPGME::Data.respond_to?('empty')
+      plain_data = GPGME::Data.empty
+    else
+      plain_data = GPGME::Data.empty!
+    end
     begin
       ctx.decrypt_verify(cipher_data, plain_data)
     rescue GPGME::Error => exc
@@ -275,7 +301,7 @@ EOS
     end
     plain_data.seek(0, IO::SEEK_SET)
     output = plain_data.read
-    output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
+    output.transcode(Encoding::ASCII_8BIT, output.encoding)
 
     ## TODO: test to see if it is still necessary to do a 2nd run if verify
     ## fails.
@@ -290,7 +316,7 @@ EOS
       # Look for Charset, they are put before the base64 crypted part
       charsets = payload.body.split("\n").grep(/^Charset:/)
       if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/
-        output = Iconv.easy_decode($encoding, $1, output)
+        output.transcode($encoding, $1)
       end
       msg.body = output
     else
@@ -314,7 +340,7 @@ EOS
       msg = RMail::Parser.read output
       if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
         output = "MIME-Version: 1.0\n" + output
-        output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
+        output.fix_encoding
         msg = RMail::Parser.read output
       end
     end
@@ -330,7 +356,7 @@ private
 
   def gpgme_exc_msg msg
     err_msg = "Exception in GPGME call: #{msg}"
-    info err_msg
+    #info err_msg
     err_msg
   end
 
@@ -362,7 +388,7 @@ private
       else
         first_sig = "Unknown error or empty signature"
       end
-    rescue EOFError 
+    rescue EOFError
       from_key = nil
       first_sig = "No public key available for #{signature.fingerprint}"
     end
diff --git a/lib/sup/draft.rb b/lib/sup/draft.rb
@@ -32,14 +32,17 @@ class DraftLoader < Source
   attr_accessor :dir
   yaml_properties
 
-  def initialize
-    dir = Redwood::DRAFT_DIR
+  def initialize dir=Redwood::DRAFT_DIR
     Dir.mkdir dir unless File.exists? dir
     super DraftManager.source_name, true, false
     @dir = dir
     @cur_offset = 0
   end
 
+  def properly_initialized?
+    !!(@dir && @cur_offset)
+  end
+
   def id; DraftManager.source_id; end
   def to_s; DraftManager.source_name; end
   def uri; DraftManager.source_name; end
diff --git a/lib/sup/hook.rb b/lib/sup/hook.rb
@@ -1,3 +1,5 @@
+require "sup/util"
+
 module Redwood
 
 class HookManager
@@ -17,6 +19,14 @@ class HookManager
       end
     end
 
+    def flash s
+      if BufferManager.instantiated?
+        BufferManager.flash s
+      else
+        log s
+      end
+    end
+
     def log s
       info "hook[#@__name]: #{s}"
     end
diff --git a/lib/sup/horizontal-selector.rb b/lib/sup/horizontal-selector.rb
@@ -1,50 +0,0 @@
-module Redwood
-
-class HorizontalSelector
-  attr_accessor :label, :changed_by_user
-
-  def initialize label, vals, labels, base_color=:horizontal_selector_unselected_color, selected_color=:horizontal_selector_selected_color
-    @label = label
-    @vals = vals
-    @labels = labels
-    @base_color = base_color
-    @selected_color = selected_color
-    @selection = 0
-    @changed_by_user = false
-  end
-
-  def set_to val; @selection = @vals.index(val) end
-
-  def val; @vals[@selection] end
-
-  def line width=nil
-    label =
-      if width
-        sprintf "%#{width}s ", @label
-      else
-        "#{@label} "
-      end
-
-    [[@base_color, label]] +
-      (0 ... @labels.length).inject([]) do |array, i|
-        array + [
-          if i == @selection
-            [@selected_color, @labels[i]]
-          else
-            [@base_color, @labels[i]]
-          end] + [[@base_color, "  "]]
-      end + [[@base_color, ""]]
-  end
-
-  def roll_left
-    @selection = (@selection - 1) % @labels.length
-    @changed_by_user = true
-  end
-
-  def roll_right
-    @selection = (@selection + 1) % @labels.length
-    @changed_by_user = true
-  end
-end
-
-end
diff --git a/lib/sup/horizontal_selector.rb b/lib/sup/horizontal_selector.rb
@@ -0,0 +1,59 @@
+module Redwood
+
+class HorizontalSelector
+  class UnknownValue < StandardError; end
+
+  attr_accessor :label, :changed_by_user
+
+  def initialize label, vals, labels, base_color=:horizontal_selector_unselected_color, selected_color=:horizontal_selector_selected_color
+    @label = label
+    @vals = vals
+    @labels = labels
+    @base_color = base_color
+    @selected_color = selected_color
+    @selection = 0
+    @changed_by_user = false
+  end
+
+  def set_to val
+    raise UnknownValue, val.inspect unless can_set_to? val
+    @selection = @vals.index(val)
+  end
+
+  def can_set_to? val
+    @vals.include? val
+  end
+
+  def val; @vals[@selection] end
+
+  def line width=nil
+    label =
+      if width
+        sprintf "%#{width}s ", @label
+      else
+        "#{@label} "
+      end
+
+    [[@base_color, label]] +
+      (0 ... @labels.length).inject([]) do |array, i|
+        array + [
+          if i == @selection
+            [@selected_color, @labels[i]]
+          else
+            [@base_color, @labels[i]]
+          end] + [[@base_color, "  "]]
+      end + [[@base_color, ""]]
+  end
+
+  def roll_left
+    @selection = (@selection - 1) % @labels.length
+    @changed_by_user = true
+  end
+
+  def roll_right
+    @selection = (@selection + 1) % @labels.length
+    @changed_by_user = true
+  end
+end
+
+end
diff --git a/lib/sup/index.rb b/lib/sup/index.rb
@@ -1,20 +1,27 @@
 ENV["XAPIAN_FLUSH_THRESHOLD"] = "1000"
+ENV["XAPIAN_CJK_NGRAM"] = "1"
 
 require 'xapian'
 require 'set'
 require 'fileutils'
 require 'monitor'
+require 'chronic'
 
-begin
-  require 'chronic'
-  $have_chronic = true
-rescue LoadError => e
-  debug "No 'chronic' gem detected. Install it for date/time query restrictions."
-  $have_chronic = false
-end
+require "sup/util/query"
+require "sup/interactive_lock"
+require "sup/hook"
+require "sup/logger/singleton"
+
+
+if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,15]) < 0
+  fail <<-EOF
+\n
+Xapian version 1.2.15 or higher required.
+If you have xapian-full-alaveteli installed,
+Please remove it by running `gem uninstall xapian-full-alaveteli`
+since it's been replaced by the xapian-ruby gem.
 
-if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,1]) < 0
-	fail "Xapian version 1.2.1 or higher required"
+  EOF
 end
 
 module Redwood
@@ -261,6 +268,11 @@ EOS
     end
   end
 
+  # Search messages. Returns an Enumerator.
+  def find_messages query_expr
+    enum_for :each_message, parse_query(query_expr)
+  end
+
   # wrap all future changes inside a transaction so they're done atomically
   def begin_transaction
     synchronize { @xapian.begin_transaction }
@@ -309,6 +321,48 @@ EOS
 
   class ParseError < StandardError; end
 
+  # Stemmed
+  NORMAL_PREFIX = {
+    'subject' => {:prefix => 'S', :exclusive => false},
+    'body' => {:prefix => 'B', :exclusive => false},
+    'from_name' => {:prefix => 'FN', :exclusive => false},
+    'to_name' => {:prefix => 'TN', :exclusive => false},
+    'name' => {:prefix => %w(FN TN), :exclusive => false},
+    'attachment' => {:prefix => 'A', :exclusive => false},
+    'email_text' => {:prefix => 'E', :exclusive => false},
+    '' => {:prefix => %w(S B FN TN A E), :exclusive => false},
+  }
+
+  # Unstemmed
+  BOOLEAN_PREFIX = {
+    'type' => {:prefix => 'K', :exclusive => true},
+    'from_email' => {:prefix => 'FE', :exclusive => false},
+    'to_email' => {:prefix => 'TE', :exclusive => false},
+    'email' => {:prefix => %w(FE TE), :exclusive => false},
+    'date' => {:prefix => 'D', :exclusive => true},
+    'label' => {:prefix => 'L', :exclusive => false},
+    'source_id' => {:prefix => 'I', :exclusive => true},
+    'attachment_extension' => {:prefix => 'O', :exclusive => false},
+    'msgid' => {:prefix => 'Q', :exclusive => true},
+    'id' => {:prefix => 'Q', :exclusive => true},
+    'thread' => {:prefix => 'H', :exclusive => false},
+    'ref' => {:prefix => 'R', :exclusive => false},
+    'location' => {:prefix => 'J', :exclusive => false},
+  }
+
+  PREFIX = NORMAL_PREFIX.merge BOOLEAN_PREFIX
+
+  COMPL_OPERATORS = %w[AND OR NOT]
+  COMPL_PREFIXES = (
+    %w[
+      from to
+      is has label
+      filename filetypem
+      before on in during after
+      limit
+    ] + NORMAL_PREFIX.keys + BOOLEAN_PREFIX.keys
+  ).map{|p|"#{p}:"} + COMPL_OPERATORS
+
   ## parse a query string from the user. returns a query object
   ## that can be passed to any index method with a 'query'
   ## argument.
@@ -388,27 +442,25 @@ EOS
       end
     end
 
-    if $have_chronic
-      lastdate = 2<<32 - 1
-      firstdate = 0
-      subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
-        field, datestr = $1, ($3 || $4)
-        realdate = Chronic.parse datestr, :guess => false, :context => :past
-        if realdate
-          case field
-          when "after"
-            debug "chronic: translated #{field}:#{datestr} to #{realdate.end}"
-            "date:#{realdate.end.to_i}..#{lastdate}"
-          when "before"
-            debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}"
-            "date:#{firstdate}..#{realdate.end.to_i}"
-          else
-            debug "chronic: translated #{field}:#{datestr} to #{realdate}"
-            "date:#{realdate.begin.to_i}..#{realdate.end.to_i}"
-          end
+    lastdate = 2<<32 - 1
+    firstdate = 0
+    subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
+      field, datestr = $1, ($3 || $4)
+      realdate = Chronic.parse datestr, :guess => false, :context => :past
+      if realdate
+        case field
+        when "after"
+          debug "chronic: translated #{field}:#{datestr} to #{realdate.end}"
+          "date:#{realdate.end.to_i}..#{lastdate}"
+        when "before"
+          debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}"
+          "date:#{firstdate}..#{realdate.end.to_i}"
         else
-          raise ParseError, "can't understand date #{datestr.inspect}"
+          debug "chronic: translated #{field}:#{datestr} to #{realdate}"
+          "date:#{realdate.begin.to_i}..#{realdate.end.to_i}"
         end
+      else
+        raise ParseError, "can't understand date #{datestr.inspect}"
       end
     end
 
@@ -440,7 +492,7 @@ EOS
       raise ParseError, "xapian query parser error: #{e}"
     end
 
-    debug "parsed xapian query: #{xapian_query.description}"
+    debug "parsed xapian query: #{Util::Query.describe(xapian_query)}"
 
     raise ParseError if xapian_query.nil? or xapian_query.empty?
     query[:qobj] = xapian_query
@@ -481,37 +533,6 @@ EOS
 
   private
 
-  # Stemmed
-  NORMAL_PREFIX = {
-    'subject' => {:prefix => 'S', :exclusive => false},
-    'body' => {:prefix => 'B', :exclusive => false},
-    'from_name' => {:prefix => 'FN', :exclusive => false},
-    'to_name' => {:prefix => 'TN', :exclusive => false},
-    'name' => {:prefix => %w(FN TN), :exclusive => false},
-    'attachment' => {:prefix => 'A', :exclusive => false},
-    'email_text' => {:prefix => 'E', :exclusive => false},
-    '' => {:prefix => %w(S B FN TN A E), :exclusive => false},
-  }
-
-  # Unstemmed
-  BOOLEAN_PREFIX = {
-    'type' => {:prefix => 'K', :exclusive => true},
-    'from_email' => {:prefix => 'FE', :exclusive => false},
-    'to_email' => {:prefix => 'TE', :exclusive => false},
-    'email' => {:prefix => %w(FE TE), :exclusive => false},
-    'date' => {:prefix => 'D', :exclusive => true},
-    'label' => {:prefix => 'L', :exclusive => false},
-    'source_id' => {:prefix => 'I', :exclusive => true},
-    'attachment_extension' => {:prefix => 'O', :exclusive => false},
-    'msgid' => {:prefix => 'Q', :exclusive => true},
-    'id' => {:prefix => 'Q', :exclusive => true},
-    'thread' => {:prefix => 'H', :exclusive => false},
-    'ref' => {:prefix => 'R', :exclusive => false},
-    'location' => {:prefix => 'J', :exclusive => false},
-  }
-
-  PREFIX = NORMAL_PREFIX.merge BOOLEAN_PREFIX
-
   MSGID_VALUENO = 0
   THREAD_VALUENO = 1
   DATE_VALUENO = 2
diff --git a/lib/sup/interactive-lock.rb b/lib/sup/interactive_lock.rb
diff --git a/lib/sup/label.rb b/lib/sup/label.rb
@@ -1,3 +1,5 @@
+# encoding: utf-8
+
 module Redwood
 
 class LabelManager
@@ -77,7 +79,7 @@ class LabelManager
 
   def save
     return unless @modified
-    File.open(@fn, "w") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } }
+    File.open(@fn, "w:UTF-8") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } }
     @new_labels = {}
   end
 end
diff --git a/lib/sup/logger.rb b/lib/sup/logger.rb
@@ -1,4 +1,4 @@
-require "sup"
+require "sup/util"
 require 'stringio'
 require 'thread'
 
diff --git a/lib/sup/logger/singleton.rb b/lib/sup/logger/singleton.rb
@@ -0,0 +1,10 @@
+# TODO: this is ugly. It's better to have a application singleton passed
+# down to lower level components instead of including logging methods in
+# class `Object'
+#
+# For now this is what we have to do.
+require "sup/logger"
+Redwood::Logger.init.add_sink $stderr
+class Object
+  include Redwood::LogsStuff
+end
diff --git a/lib/sup/message-chunks.rb b/lib/sup/message-chunks.rb
@@ -1,289 +0,0 @@
-require 'tempfile'
-require 'rbconfig'
-
-## Here we define all the "chunks" that a message is parsed
-## into. Chunks are used by ThreadViewMode to render a message. Chunks
-## are used for both MIME stuff like attachments, for Sup's parsing of
-## the message body into text, quote, and signature regions, and for
-## notices like "this message was decrypted" or "this message contains
-## a valid signature"---basically, anything we want to differentiate
-## at display time.
-##
-## A chunk can be inlineable, expandable, or viewable. If it's
-## inlineable, #color and #lines are called and the output is treated
-## as part of the message text. This is how Text and one-line Quotes
-## and Signatures work.
-##
-## If it's not inlineable but is expandable, #patina_color and
-## #patina_text are called to generate a "patina" (a one-line widget,
-## basically), and the user can press enter to toggle the display of
-## the chunk content, which is generated from #color and #lines as
-## above. This is how Quote, Signature, and most widgets
-## work. Exandable chunks can additionally define #initial_state to be
-## :open if they want to start expanded (default is to start collapsed).
-##
-## If it's not expandable but is viewable, a patina is displayed using
-## #patina_color and #patina_text, but no toggling is allowed. Instead,
-## if #view! is defined, pressing enter on the widget calls view! and
-## (if that returns false) #to_s. Otherwise, enter does nothing. This
-##  is how non-inlineable attachments work.
-##
-## Independent of all that, a chunk can be quotable, in which case it's
-## included as quoted text during a reply. Text, Quotes, and mime-parsed
-## attachments are quotable; Signatures are not.
-
-## monkey-patch time: make temp files have the right extension
-## Backport from Ruby 1.9.2 for versions lower than 1.8.7
-if RUBY_VERSION < '1.8.7'
-  class Tempfile
-    def make_tmpname(prefix_suffix, n)
-      case prefix_suffix
-      when String
-        prefix = prefix_suffix
-        suffix = ""
-      when Array
-        prefix = prefix_suffix[0]
-        suffix = prefix_suffix[1]
-      else
-        raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}"
-      end
-      t = Time.now.strftime("%Y%m%d")
-      path = "#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}"
-      path << "-#{n}" if n
-      path << suffix
-    end
-  end
-end
-
-
-module Redwood
-module Chunk
-  class Attachment
-    HookManager.register "mime-decode", <<EOS
-Decodes a MIME attachment into text form. The text will be displayed
-directly in Sup. For attachments that you wish to use a separate program
-to view (e.g. images), you should use the mime-view hook instead.
-
-Variables:
-   content_type: the content-type of the attachment
-        charset: the charset of the attachment, if applicable
-       filename: the filename of the attachment as saved to disk
-  sibling_types: if this attachment is part of a multipart MIME attachment,
-                 an array of content-types for all attachments. Otherwise,
-                 the empty array.
-Return value:
-  The decoded text of the attachment, or nil if not decoded.
-EOS
-
-    HookManager.register "mime-view", <<EOS
-Views a non-text MIME attachment. This hook allows you to run
-third-party programs for attachments that require such a thing (e.g.
-images). To instead display a text version of the attachment directly in
-Sup, use the mime-decode hook instead.
-
-Note that by default (at least on systems that have a run-mailcap command),
-Sup uses the default mailcap handler for the attachment's MIME type. If
-you want a particular behavior to be global, you may wish to change your
-mailcap instead.
-
-Variables:
-   content_type: the content-type of the attachment
-       filename: the filename of the attachment as saved to disk
-Return value:
-  True if the viewing was successful, false otherwise. If false, calling
-  /usr/bin/run-mailcap will be tried.
-EOS
-#' stupid ruby-mode
-
-    ## raw_content is the post-MIME-decode content. this is used for
-    ## saving the attachment to disk.
-    attr_reader :content_type, :filename, :lines, :raw_content
-    bool_reader :quotable
-
-    def initialize content_type, filename, encoded_content, sibling_types
-      @content_type = content_type.downcase
-      @filename = filename
-      @quotable = false # changed to true if we can parse it through the
-                        # mime-decode hook, or if it's plain text
-      @raw_content =
-        if encoded_content.body
-          encoded_content.decode
-        else
-          "For some bizarre reason, RubyMail was unable to parse this attachment.\n"
-        end
-
-      text = case @content_type
-      when /^text\/plain\b/
-        @raw_content
-      else
-        HookManager.run "mime-decode", :content_type => content_type,
-                        :filename => lambda { write_to_disk },
-                        :charset => encoded_content.charset,
-                        :sibling_types => sibling_types
-      end
-
-      @lines = nil
-      if text
-        text = text.transcode(encoded_content.charset || $encoding)
-        @lines = text.gsub("\r\n", "\n").gsub(/\t/, "        ").gsub(/\r/, "").split("\n")
-        @quotable = true
-      end
-    end
-
-    def color; :none end
-    def patina_color; :attachment_color end
-    def patina_text
-      if expandable?
-        "Attachment: #{filename} (#{lines.length} lines)"
-      else
-        "Attachment: #{filename} (#{content_type}; #{@raw_content.size.to_human_size})"
-      end
-    end
-
-    ## an attachment is exapndable if we've managed to decode it into
-    ## something we can display inline. otherwise, it's viewable.
-    def inlineable?; false end
-    def expandable?; !viewable? end
-    def initial_state; :open end
-    def viewable?; @lines.nil? end
-    def view_default! path
-      case RbConfig::CONFIG['arch']
-        when /darwin/
-          cmd = "open '#{path}'"
-        else
-          cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}'"
-      end
-      debug "running: #{cmd.inspect}"
-      BufferManager.shell_out(cmd)
-      $? == 0
-    end
-
-    def view!
-      path = write_to_disk
-      ret = HookManager.run "mime-view", :content_type => @content_type,
-                                         :filename => path
-      ret || view_default!(path)
-    end
-
-    def write_to_disk
-      file = Tempfile.new(["sup", @filename.gsub("/", "_") || "sup-attachment"])
-      file.print @raw_content
-      file.close
-      file.path
-    end
-
-    ## used when viewing the attachment as text
-    def to_s
-      @lines || @raw_content
-    end
-  end
-
-  class Text
-
-    attr_reader :lines
-    def initialize lines
-      @lines = lines
-      ## trim off all empty lines except one
-      @lines.pop while @lines.length > 1 && @lines[-1] =~ /^\s*$/ && @lines[-2] =~ /^\s*$/
-    end
-
-    def inlineable?; true end
-    def quotable?; true end
-    def expandable?; false end
-    def viewable?; false end
-    def color; :none end
-  end
-
-  class Quote
-    attr_reader :lines
-    def initialize lines
-      @lines = lines
-    end
-
-    def inlineable?; @lines.length == 1 end
-    def quotable?; true end
-    def expandable?; !inlineable? end
-    def viewable?; false end
-
-    def patina_color; :quote_patina_color end
-    def patina_text; "(#{lines.length} quoted lines)" end
-    def color; :quote_color end
-  end
-
-  class Signature
-    attr_reader :lines
-    def initialize lines
-      @lines = lines
-    end
-
-    def inlineable?; @lines.length == 1 end
-    def quotable?; false end
-    def expandable?; !inlineable? end
-    def viewable?; false end
-
-    def patina_color; :sig_patina_color end
-    def patina_text; "(#{lines.length}-line signature)" end
-    def color; :sig_color end
-  end
-
-  class EnclosedMessage
-    attr_reader :lines
-    def initialize from, to, cc, date, subj
-      @from = from ? "unknown sender" : from.full_adress
-      @to = to ? "" : to.map { |p| p.full_address }.join(", ")
-      @cc = cc ? "" : cc.map { |p| p.full_address }.join(", ")
-      if date
-        @date = date.rfc822
-      else
-        @date = ""
-      end
-
-      @subj = subj
-
-      @lines = "\nFrom: #{from}\n"
-      @lines += "To: #{to}\n"
-      if !cc.empty?
-        @lines += "Cc: #{cc}\n"
-      end
-      @lines += "Date: #{date}\n"
-      @lines += "Subject: #{subj}\n\n"
-    end
-
-    def inlineable?; false end
-    def quotable?; false end
-    def expandable?; true end
-    def initial_state; :closed end
-    def viewable?; false end
-
-    def patina_color; :generic_notice_patina_color end
-    def patina_text; "Begin enclosed message sent on #{@date}" end
-
-    def color; :quote_color end
-  end
-
-  class CryptoNotice
-    attr_reader :lines, :status, :patina_text
-
-    def initialize status, description, lines=[]
-      @status = status
-      @patina_text = description
-      @lines = lines
-    end
-
-    def patina_color
-      case status
-      when :valid then :cryptosig_valid_color
-      when :valid_untrusted then :cryptosig_valid_untrusted_color
-      when :invalid then :cryptosig_invalid_color
-      else :cryptosig_unknown_color
-      end
-    end
-    def color; patina_color end
-
-    def inlineable?; false end
-    def quotable?; false end
-    def expandable?; !@lines.empty? end
-    def viewable?; false end
-  end
-end
-end
diff --git a/lib/sup/message.rb b/lib/sup/message.rb
@@ -69,7 +69,9 @@ class Message
     return unless v
     return v unless v.is_a? String
     return unless v.size < MAX_HEADER_VALUE_SIZE # avoid regex blowup on spam
-    Rfc2047.decode_to $encoding, Iconv.easy_decode($encoding, 'ASCII', v)
+    d = v.dup
+    d = d.transcode($encoding, 'ASCII')
+    Rfc2047.decode_to $encoding, d
   end
 
   def parse_header encoded_header
@@ -109,7 +111,9 @@ class Message
       Time.now
     end
 
-    @subj = header["subject"] ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
+    subj = header["subject"]
+    subj = subj ? subj.fix_encoding : nil
+    @subj = subj ? subj.gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
     @to = Person.from_address_list header["to"]
     @cc = Person.from_address_list header["cc"]
     @bcc = Person.from_address_list header["bcc"]
@@ -260,6 +264,8 @@ class Message
       rescue SourceError, SocketError, RMail::EncodingUnsupportedError => e
         warn "problem reading message #{id}"
         [Chunk::Text.new(error_message.split("\n"))]
+
+        debug "could not load message: #{location.inspect}, exception: #{e.inspect}"
       end
   end
 
@@ -524,7 +530,7 @@ private
           ## if there's no charset, use the current encoding as the charset.
           ## this ensures that the body is normalized to avoid non-displayable
           ## characters
-          body = Iconv.easy_decode($encoding, m.charset || $encoding, m.decode)
+          body = m.decode.transcode($encoding, m.charset)
         else
           body = ""
         end
@@ -540,11 +546,13 @@ private
   def inline_gpg_to_chunks body, encoding_to, encoding_from
     lines = body.split("\n")
     gpg = lines.between(GPG_SIGNED_START, GPG_SIGNED_END)
-    if !gpg.empty?
+    # between does not check if GPG_END actually exists
+    # Reference: http://permalink.gmane.org/gmane.mail.sup.devel/641
+    if !gpg.empty? && !lines.index(GPG_END).nil?
       msg = RMail::Message.new
       msg.body = gpg.join("\n")
 
-      body = Iconv.easy_decode(encoding_to, encoding_from, body)
+      body = body.transcode(encoding_to, encoding_from)
       lines = body.split("\n")
       sig = lines.between(GPG_SIGNED_START, GPG_SIG_START)
       startidx = lines.index(GPG_SIGNED_START)
diff --git a/lib/sup/message_chunks.rb b/lib/sup/message_chunks.rb
@@ -0,0 +1,289 @@
+require 'tempfile'
+require 'rbconfig'
+
+## Here we define all the "chunks" that a message is parsed
+## into. Chunks are used by ThreadViewMode to render a message. Chunks
+## are used for both MIME stuff like attachments, for Sup's parsing of
+## the message body into text, quote, and signature regions, and for
+## notices like "this message was decrypted" or "this message contains
+## a valid signature"---basically, anything we want to differentiate
+## at display time.
+##
+## A chunk can be inlineable, expandable, or viewable. If it's
+## inlineable, #color and #lines are called and the output is treated
+## as part of the message text. This is how Text and one-line Quotes
+## and Signatures work.
+##
+## If it's not inlineable but is expandable, #patina_color and
+## #patina_text are called to generate a "patina" (a one-line widget,
+## basically), and the user can press enter to toggle the display of
+## the chunk content, which is generated from #color and #lines as
+## above. This is how Quote, Signature, and most widgets
+## work. Exandable chunks can additionally define #initial_state to be
+## :open if they want to start expanded (default is to start collapsed).
+##
+## If it's not expandable but is viewable, a patina is displayed using
+## #patina_color and #patina_text, but no toggling is allowed. Instead,
+## if #view! is defined, pressing enter on the widget calls view! and
+## (if that returns false) #to_s. Otherwise, enter does nothing. This
+##  is how non-inlineable attachments work.
+##
+## Independent of all that, a chunk can be quotable, in which case it's
+## included as quoted text during a reply. Text, Quotes, and mime-parsed
+## attachments are quotable; Signatures are not.
+
+## monkey-patch time: make temp files have the right extension
+## Backport from Ruby 1.9.2 for versions lower than 1.8.7
+if RUBY_VERSION < '1.8.7'
+  class Tempfile
+    def make_tmpname(prefix_suffix, n)
+      case prefix_suffix
+      when String
+        prefix = prefix_suffix
+        suffix = ""
+      when Array
+        prefix = prefix_suffix[0]
+        suffix = prefix_suffix[1]
+      else
+        raise ArgumentError, "unexpected prefix_suffix: #{prefix_suffix.inspect}"
+      end
+      t = Time.now.strftime("%Y%m%d")
+      path = "#{prefix}#{t}-#{$$}-#{rand(0x100000000).to_s(36)}"
+      path << "-#{n}" if n
+      path << suffix
+    end
+  end
+end
+
+
+module Redwood
+module Chunk
+  class Attachment
+    HookManager.register "mime-decode", <<EOS
+Decodes a MIME attachment into text form. The text will be displayed
+directly in Sup. For attachments that you wish to use a separate program
+to view (e.g. images), you should use the mime-view hook instead.
+
+Variables:
+   content_type: the content-type of the attachment
+        charset: the charset of the attachment, if applicable
+       filename: the filename of the attachment as saved to disk
+  sibling_types: if this attachment is part of a multipart MIME attachment,
+                 an array of content-types for all attachments. Otherwise,
+                 the empty array.
+Return value:
+  The decoded text of the attachment, or nil if not decoded.
+EOS
+
+    HookManager.register "mime-view", <<EOS
+Views a non-text MIME attachment. This hook allows you to run
+third-party programs for attachments that require such a thing (e.g.
+images). To instead display a text version of the attachment directly in
+Sup, use the mime-decode hook instead.
+
+Note that by default (at least on systems that have a run-mailcap command),
+Sup uses the default mailcap handler for the attachment's MIME type. If
+you want a particular behavior to be global, you may wish to change your
+mailcap instead.
+
+Variables:
+   content_type: the content-type of the attachment
+       filename: the filename of the attachment as saved to disk
+Return value:
+  True if the viewing was successful, false otherwise. If false, calling
+  /usr/bin/run-mailcap will be tried.
+EOS
+#' stupid ruby-mode
+
+    ## raw_content is the post-MIME-decode content. this is used for
+    ## saving the attachment to disk.
+    attr_reader :content_type, :filename, :lines, :raw_content
+    bool_reader :quotable
+
+    def initialize content_type, filename, encoded_content, sibling_types
+      @content_type = content_type.downcase
+      @filename = filename
+      @quotable = false # changed to true if we can parse it through the
+                        # mime-decode hook, or if it's plain text
+      @raw_content =
+        if encoded_content.body
+          encoded_content.decode
+        else
+          "For some bizarre reason, RubyMail was unable to parse this attachment.\n"
+        end
+
+      text = case @content_type
+      when /^text\/plain\b/
+        @raw_content
+      else
+        HookManager.run "mime-decode", :content_type => content_type,
+                        :filename => lambda { write_to_disk },
+                        :charset => encoded_content.charset,
+                        :sibling_types => sibling_types
+      end
+
+      @lines = nil
+      if text
+        text = text.transcode(encoded_content.charset || $encoding, text.encoding)
+        @lines = text.gsub("\r\n", "\n").gsub(/\t/, "        ").gsub(/\r/, "").split("\n")
+        @quotable = true
+      end
+    end
+
+    def color; :text_color end
+    def patina_color; :attachment_color end
+    def patina_text
+      if expandable?
+        "Attachment: #{filename} (#{lines.length} lines)"
+      else
+        "Attachment: #{filename} (#{content_type}; #{@raw_content.size.to_human_size})"
+      end
+    end
+
+    ## an attachment is exapndable if we've managed to decode it into
+    ## something we can display inline. otherwise, it's viewable.
+    def inlineable?; false end
+    def expandable?; !viewable? end
+    def initial_state; :open end
+    def viewable?; @lines.nil? end
+    def view_default! path
+      case RbConfig::CONFIG['arch']
+        when /darwin/
+          cmd = "open '#{path}'"
+        else
+          cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}'"
+      end
+      debug "running: #{cmd.inspect}"
+      BufferManager.shell_out(cmd)
+      $? == 0
+    end
+
+    def view!
+      path = write_to_disk
+      ret = HookManager.run "mime-view", :content_type => @content_type,
+                                         :filename => path
+      ret || view_default!(path)
+    end
+
+    def write_to_disk
+      file = Tempfile.new(["sup", @filename.gsub("/", "_") || "sup-attachment"])
+      file.print @raw_content
+      file.close
+      file.path
+    end
+
+    ## used when viewing the attachment as text
+    def to_s
+      @lines || @raw_content
+    end
+  end
+
+  class Text
+
+    attr_reader :lines
+    def initialize lines
+      @lines = lines
+      ## trim off all empty lines except one
+      @lines.pop while @lines.length > 1 && @lines[-1] =~ /^\s*$/ && @lines[-2] =~ /^\s*$/
+    end
+
+    def inlineable?; true end
+    def quotable?; true end
+    def expandable?; false end
+    def viewable?; false end
+    def color; :text_color end
+  end
+
+  class Quote
+    attr_reader :lines
+    def initialize lines
+      @lines = lines
+    end
+
+    def inlineable?; @lines.length == 1 end
+    def quotable?; true end
+    def expandable?; !inlineable? end
+    def viewable?; false end
+
+    def patina_color; :quote_patina_color end
+    def patina_text; "(#{lines.length} quoted lines)" end
+    def color; :quote_color end
+  end
+
+  class Signature
+    attr_reader :lines
+    def initialize lines
+      @lines = lines
+    end
+
+    def inlineable?; @lines.length == 1 end
+    def quotable?; false end
+    def expandable?; !inlineable? end
+    def viewable?; false end
+
+    def patina_color; :sig_patina_color end
+    def patina_text; "(#{lines.length}-line signature)" end
+    def color; :sig_color end
+  end
+
+  class EnclosedMessage
+    attr_reader :lines
+    def initialize from, to, cc, date, subj
+      @from = from ? "unknown sender" : from.full_adress
+      @to = to ? "" : to.map { |p| p.full_address }.join(", ")
+      @cc = cc ? "" : cc.map { |p| p.full_address }.join(", ")
+      if date
+        @date = date.rfc822
+      else
+        @date = ""
+      end
+
+      @subj = subj
+
+      @lines = "\nFrom: #{from}\n"
+      @lines += "To: #{to}\n"
+      if !cc.empty?
+        @lines += "Cc: #{cc}\n"
+      end
+      @lines += "Date: #{date}\n"
+      @lines += "Subject: #{subj}\n\n"
+    end
+
+    def inlineable?; false end
+    def quotable?; false end
+    def expandable?; true end
+    def initial_state; :closed end
+    def viewable?; false end
+
+    def patina_color; :generic_notice_patina_color end
+    def patina_text; "Begin enclosed message sent on #{@date}" end
+
+    def color; :quote_color end
+  end
+
+  class CryptoNotice
+    attr_reader :lines, :status, :patina_text
+
+    def initialize status, description, lines=[]
+      @status = status
+      @patina_text = description
+      @lines = lines
+    end
+
+    def patina_color
+      case status
+      when :valid then :cryptosig_valid_color
+      when :valid_untrusted then :cryptosig_valid_untrusted_color
+      when :invalid then :cryptosig_invalid_color
+      else :cryptosig_unknown_color
+      end
+    end
+    def color; patina_color end
+
+    def inlineable?; false end
+    def quotable?; false end
+    def expandable?; !@lines.empty? end
+    def viewable?; false end
+  end
+end
+end
diff --git a/lib/sup/modes/buffer-list-mode.rb b/lib/sup/modes/buffer_list_mode.rb
diff --git a/lib/sup/modes/completion-mode.rb b/lib/sup/modes/completion-mode.rb
@@ -1,55 +0,0 @@
-module Redwood
-
-class CompletionMode < ScrollMode
-  INTERSTITIAL = "  "
-
-  def initialize list, opts={}
-    @list = list
-    @header = opts[:header]
-    @prefix_len = opts[:prefix_len]
-    @lines = nil
-    super :slip_rows => 1, :twiddles => false
-  end
-
-  def lines
-    update_lines unless @lines
-    @lines.length
-  end
-
-  def [] i
-    update_lines unless @lines
-    @lines[i]
-  end
-
-  def roll; if at_bottom? then jump_to_start else page_down end end
-
-private
-
-  def update_lines
-    width = buffer.content_width
-    max_length = @list.max_of { |s| s.length }
-    num_per = [1, buffer.content_width / (max_length + INTERSTITIAL.length)].max
-    @lines = [@header].compact
-    @list.each_with_index do |s, i|
-      if @prefix_len
-        @lines << [] if i % num_per == 0
-        if @prefix_len < s.length
-          prefix = s[0 ... @prefix_len]
-          suffix = s[(@prefix_len + 1) .. -1]
-          char = s[@prefix_len].chr
-
-          @lines.last += [[:none, sprintf("%#{max_length - suffix.length - 1}s", prefix)],
-                          [:completion_character_color, char],
-                          [:none, suffix + INTERSTITIAL]]
-        else
-          @lines.last += [[:none, sprintf("%#{max_length}s#{INTERSTITIAL}", s)]]
-        end
-      else
-        @lines << "" if i % num_per == 0
-        @lines.last += sprintf "%#{max_length}s#{INTERSTITIAL}", s
-      end
-    end
-  end
-end
-
-end
diff --git a/lib/sup/modes/completion_mode.rb b/lib/sup/modes/completion_mode.rb
@@ -0,0 +1,55 @@
+module Redwood
+
+class CompletionMode < ScrollMode
+  INTERSTITIAL = "  "
+
+  def initialize list, opts={}
+    @list = list
+    @header = opts[:header]
+    @prefix_len = opts[:prefix_len]
+    @lines = nil
+    super :slip_rows => 1, :twiddles => false
+  end
+
+  def lines
+    update_lines unless @lines
+    @lines.length
+  end
+
+  def [] i
+    update_lines unless @lines
+    @lines[i]
+  end
+
+  def roll; if at_bottom? then jump_to_start else page_down end end
+
+private
+
+  def update_lines
+    width = buffer.content_width
+    max_length = @list.max_of { |s| s.length }
+    num_per = [1, buffer.content_width / (max_length + INTERSTITIAL.length)].max
+    @lines = [@header].compact
+    @list.each_with_index do |s, i|
+      if @prefix_len
+        @lines << [] if i % num_per == 0
+        if @prefix_len < s.length
+          prefix = s[0 ... @prefix_len]
+          suffix = s[(@prefix_len + 1) .. -1]
+          char = s[@prefix_len].chr
+
+          @lines.last += [[:text_color, sprintf("%#{max_length - suffix.length - 1}s", prefix)],
+                          [:completion_character_color, char],
+                          [:text_color, suffix + INTERSTITIAL]]
+        else
+          @lines.last += [[:text_color, sprintf("%#{max_length}s#{INTERSTITIAL}", s)]]
+        end
+      else
+        @lines << "" if i % num_per == 0
+        @lines.last += sprintf "%#{max_length}s#{INTERSTITIAL}", s
+      end
+    end
+  end
+end
+
+end
diff --git a/lib/sup/modes/compose-mode.rb b/lib/sup/modes/compose-mode.rb
@@ -1,36 +0,0 @@
-module Redwood
-
-class ComposeMode < EditMessageMode
-  def initialize opts={}
-    header = {}
-    header["From"] = (opts[:from] || AccountManager.default_account).full_address
-    header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
-    header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
-    header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
-    header["Subject"] = opts[:subj] if opts[:subj]
-    header["References"] = opts[:refs].map { |r| "<#{r}>" }.join(" ") if opts[:refs]
-    header["In-Reply-To"] = opts[:replytos].map { |r| "<#{r}>" }.join(" ") if opts[:replytos]
-
-    super :header => header, :body => (opts[:body] || [])
-  end
-
-  def edit_message
-    edited = super
-    BufferManager.kill_buffer self.buffer unless edited
-    edited
-  end
-
-  def self.spawn_nicely opts={}
-    from = opts[:from] || (BufferManager.ask_for_account(:account, "From (default #{AccountManager.default_account.email}): ") or return if $config[:ask_for_from])
-    to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ", [opts[:to_default]]) or return if ($config[:ask_for_to] != false))
-    cc = opts[:cc] || (BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc])
-    bcc = opts[:bcc] || (BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc])
-    subj = opts[:subj] || (BufferManager.ask(:subject, "Subject: ") or return if $config[:ask_for_subject])
-
-    mode = ComposeMode.new :from => from, :to => to, :cc => cc, :bcc => bcc, :subj => subj
-    BufferManager.spawn "New Message", mode
-    mode.edit_message
-  end
-end
-
-end
diff --git a/lib/sup/modes/compose_mode.rb b/lib/sup/modes/compose_mode.rb
@@ -0,0 +1,38 @@
+# encoding: utf-8
+
+module Redwood
+
+class ComposeMode < EditMessageMode
+  def initialize opts={}
+    header = {}
+    header["From"] = (opts[:from] || AccountManager.default_account).full_address
+    header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
+    header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
+    header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
+    header["Subject"] = opts[:subj] if opts[:subj]
+    header["References"] = opts[:refs].map { |r| "<#{r}>" }.join(" ") if opts[:refs]
+    header["In-Reply-To"] = opts[:replytos].map { |r| "<#{r}>" }.join(" ") if opts[:replytos]
+
+    super :header => header, :body => (opts[:body] || [])
+  end
+
+  def edit_message
+    edited = super
+    BufferManager.kill_buffer self.buffer unless edited
+    edited
+  end
+
+  def self.spawn_nicely opts={}
+    from = opts[:from] || (BufferManager.ask_for_account(:account, "From (default #{AccountManager.default_account.email}): ") or return if $config[:ask_for_from])
+    to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ", [opts[:to_default]]) or return if ($config[:ask_for_to] != false))
+    cc = opts[:cc] || (BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc])
+    bcc = opts[:bcc] || (BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc])
+    subj = opts[:subj] || (BufferManager.ask(:subject, "Subject: ") or return if $config[:ask_for_subject])
+
+    mode = ComposeMode.new :from => from, :to => to, :cc => cc, :bcc => bcc, :subj => subj
+    BufferManager.spawn "New Message", mode
+    mode.edit_message
+  end
+end
+
+end
diff --git a/lib/sup/modes/console-mode.rb b/lib/sup/modes/console-mode.rb
@@ -1,114 +0,0 @@
-require 'pp'
-
-module Redwood
-
-class Console
-  def initialize mode
-    @mode = mode
-  end
-
-  def query(query)
-    Enumerator.new(Index.instance, :each_message, Index.parse_query(query))
-  end
-
-  def add_labels(query, *labels)
-    query(query).each { |m| m.labels += labels; m.save Index }
-  end
-
-  def remove_labels(query, *labels)
-    query(query).each { |m| m.labels -= labels; m.save Index }
-  end
-
-  def xapian; Index.instance.instance_variable_get :@xapian; end
-
-  def loglevel; Redwood::Logger.level; end
-  def set_loglevel(level); Redwood::Logger.level = level; end
-
-  def special_methods; methods - Object.methods end
-
-  def puts x; @mode << "#{x.to_s.rstrip}\n" end
-  def p x; puts x.inspect end
-
-  ## files that won't cause problems when reloaded
-  ## TODO expand this list / convert to blacklist
-  RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb)
-
-  def reload
-    old_verbose = $VERBOSE
-    $VERBOSE = nil
-    old_features = $".dup
-    begin
-      fs = $".grep(/^sup\//)
-      fs.reject! { |f| not RELOAD_WHITELIST.member? f }
-      fs.each { |f| $".delete f }
-      fs.each do |f|
-        @mode << "reloading #{f}\n"
-        begin
-          require f
-        rescue LoadError => e
-          raise unless e.message =~ /no such file to load/
-        end
-      end
-    rescue Exception
-      $".clear
-      $".concat old_features
-      raise
-    ensure
-      $VERBOSE = old_verbose
-    end
-    true
-  end
-
-  def clear_hooks
-    HookManager.clear
-    nil
-  end
-end
-
-class ConsoleMode < LogMode
-  register_keymap do |k|
-    k.add :run, "Restart evaluation", 'e'
-  end
-
-  def initialize
-    super "console"
-    @console = Console.new self
-    @binding = @console.instance_eval { binding }
-  end
-
-  def execute cmd
-    begin
-      self << ">> #{cmd}\n"
-      ret = eval cmd, @binding
-      self << "=> #{ret.pretty_inspect}\n"
-    rescue Exception
-      self << "#{$!.class}: #{$!.message}\n"
-      clean_backtrace = []
-      $!.backtrace.each { |l| break if l =~ /console-mode/; clean_backtrace << l }
-      clean_backtrace.each { |l| self << "#{l}\n" }
-    end
-  end
-
-  def prompt
-    BufferManager.ask :console, ">> "
-  end
-
-  def run
-    self << <<EOS
-Sup v#{VERSION} console session started.
-Available extra commands: #{(@console.special_methods) * ", "}
-Ctrl-G stops evaluation; 'e' restarts it.
-
-EOS
-    while true
-      if(cmd = prompt)
-        execute cmd
-      else
-        self << "Console session ended."
-        break
-      end
-    end
-  end
-end
-
-end
diff --git a/lib/sup/modes/console_mode.rb b/lib/sup/modes/console_mode.rb
@@ -0,0 +1,125 @@
+require 'pp'
+
+require "sup/service/label_service"
+
+module Redwood
+
+class Console
+  def initialize mode
+    @mode = mode
+    @label_service = LabelService.new
+  end
+
+  def query(query)
+    Enumerator.new(Index.instance, :each_message, Index.parse_query(query))
+  end
+
+  def add_labels(query, *labels)
+    count = @label_service.add_labels(query, *labels)
+    print_buffer_dirty_msg count
+  end
+
+  def remove_labels(query, *labels)
+    count = @label_service.remove_labels(query, *labels)
+    print_buffer_dirty_msg count
+  end
+
+  def print_buffer_dirty_msg msg_count
+    puts "Scanned #{msg_count} messages."
+    puts "You might want to refresh open buffers with `@` key."
+  end
+  private :print_buffer_dirty_msg
+
+  def xapian; Index.instance.instance_variable_get :@xapian; end
+
+  def loglevel; Redwood::Logger.level; end
+  def set_loglevel(level); Redwood::Logger.level = level; end
+
+  def special_methods; public_methods - Object.methods end
+
+  def puts x; @mode << "#{x.to_s.rstrip}\n" end
+  def p x; puts x.inspect end
+
+  ## files that won't cause problems when reloaded
+  ## TODO expand this list / convert to blacklist
+  RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb)
+
+  def reload
+    old_verbose = $VERBOSE
+    $VERBOSE = nil
+    old_features = $".dup
+    begin
+      fs = $".grep(/^sup\//)
+      fs.reject! { |f| not RELOAD_WHITELIST.member? f }
+      fs.each { |f| $".delete f }
+      fs.each do |f|
+        @mode << "reloading #{f}\n"
+        begin
+          require f
+        rescue LoadError => e
+          raise unless e.message =~ /no such file to load/
+        end
+      end
+    rescue Exception
+      $".clear
+      $".concat old_features
+      raise
+    ensure
+      $VERBOSE = old_verbose
+    end
+    true
+  end
+
+  def clear_hooks
+    HookManager.clear
+    nil
+  end
+end
+
+class ConsoleMode < LogMode
+  register_keymap do |k|
+    k.add :run, "Restart evaluation", 'e'
+  end
+
+  def initialize
+    super "console"
+    @console = Console.new self
+    @binding = @console.instance_eval { binding }
+  end
+
+  def execute cmd
+    begin
+      self << ">> #{cmd}\n"
+      ret = eval cmd, @binding
+      self << "=> #{ret.pretty_inspect}\n"
+    rescue Exception
+      self << "#{$!.class}: #{$!.message}\n"
+      clean_backtrace = []
+      $!.backtrace.each { |l| break if l =~ /console-mode/; clean_backtrace << l }
+      clean_backtrace.each { |l| self << "#{l}\n" }
+    end
+  end
+
+  def prompt
+    BufferManager.ask :console, ">> "
+  end
+
+  def run
+    self << <<EOS
+Sup v#{VERSION} console session started.
+Available extra commands: #{(@console.special_methods) * ", "}
+Ctrl-G stops evaluation; 'e' restarts it.
+
+EOS
+    while true
+      if(cmd = prompt)
+        execute cmd
+      else
+        self << "Console session ended."
+        break
+      end
+    end
+  end
+end
+
+end
diff --git a/lib/sup/modes/contact-list-mode.rb b/lib/sup/modes/contact_list_mode.rb
diff --git a/lib/sup/modes/edit-message-mode.rb b/lib/sup/modes/edit-message-mode.rb
@@ -1,679 +0,0 @@
-require 'tempfile'
-require 'socket' # just for gethostname!
-require 'pathname'
-
-module Redwood
-
-class SendmailCommandFailed < StandardError; end
-
-class EditMessageMode < LineCursorMode
-  DECORATION_LINES = 1
-
-  FORCE_HEADERS = %w(From To Cc Bcc Subject)
-  MULTI_HEADERS = %w(To Cc Bcc)
-  NON_EDITABLE_HEADERS = %w(Message-id Date)
-
-  HookManager.register "signature", <<EOS
-Generates a message signature.
-Variables:
-      header: an object that supports string-to-string hashtable-style access
-              to the raw headers for the message. E.g., header["From"],
-              header["To"], etc.
-  from_email: the email part of the From: line, or nil if empty
-Return value:
-  A string (multi-line ok) containing the text of the signature, or nil to
-  use the default signature, or :none for no signature.
-EOS
-
-  HookManager.register "before-edit", <<EOS
-Modifies message body and headers before editing a new message. Variables
-should be modified in place.
-Variables:
-	header: a hash of headers. See 'signature' hook for documentation.
-	body: an array of lines of body text.
-Return value:
-	none
-EOS
-
-  HookManager.register "mentions-attachments", <<EOS
-Detects if given message mentions attachments the way it is probable
-that there should be files attached to the message.
-Variables:
-	header: a hash of headers. See 'signature' hook for documentation.
-	body: an array of lines of body text.
-Return value:
-	True if attachments are mentioned.
-EOS
-
-  HookManager.register "crypto-mode", <<EOS
-Modifies cryptography settings based on header and message content, before
-editing a new message. This can be used to set, for example, default cryptography
-settings.
-Variables:
-    header: a hash of headers. See 'signature' hook for documentation.
-    body: an array of lines of body text.
-    crypto_selector: the UI element that controls the current cryptography setting.
-Return value:
-     none
-EOS
-
-  HookManager.register "sendmail", <<EOS
-Sends the given mail. If this hook doesn't exist, the sendmail command
-configured for the account is used.
-The message will be saved after this hook is run, so any modification to it
-will be recorded.
-Variables:
-    message: RMail::Message instance of the mail to send
-    account: Account instance matching the From address
-Return value:
-     True if mail has been sent successfully, false otherwise.
-EOS
-
-  attr_reader :status
-  attr_accessor :body, :header
-  bool_reader :edited
-
-  register_keymap do |k|
-    k.add :send_message, "Send message", 'y'
-    k.add :edit_message_or_field, "Edit selected field", 'e'
-    k.add :edit_to, "Edit To:", 't'
-    k.add :edit_cc, "Edit Cc:", 'c'
-    k.add :edit_subject, "Edit Subject", 's'
-    k.add :edit_message, "Edit message", :enter
-    k.add :edit_message_async, "Edit message asynchronously", 'E'
-    k.add :save_as_draft, "Save as draft", 'P'
-    k.add :attach_file, "Attach a file", 'a'
-    k.add :delete_attachment, "Delete an attachment", 'd'
-    k.add :move_cursor_right, "Move selector to the right", :right, 'l'
-    k.add :move_cursor_left, "Move selector to the left", :left, 'h'
-  end
-
-  def initialize opts={}
-    @header = opts.delete(:header) || {}
-    @header_lines = []
-
-    @body = opts.delete(:body) || []
-
-    if opts[:attachments]
-      @attachments = opts[:attachments].values
-      @attachment_names = opts[:attachments].keys
-    else
-      @attachments = []
-      @attachment_names = []
-    end
-
-    begin
-      hostname = File.open("/etc/mailname", "r").gets.chomp
-    rescue
-        nil
-    end
-    hostname = Socket.gethostname if hostname.nil? or hostname.empty?
-
-    @message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{hostname}>"
-    @edited = false
-    @sig_edited = false
-    @selectors = []
-    @selector_label_width = 0
-    @async_mode = nil
-
-    HookManager.run "before-edit", :header => @header, :body => @body
-
-    @account_selector = nil
-    # only show account selector if there is more than one email address
-    if $config[:account_selector] && AccountManager.user_emails.length > 1
-      ## Duplicate e-mail strings to prevent a "can't modify frozen
-      ## object" crash triggered by the String::display_length()
-      ## method in util.rb
-      user_emails_copy = []
-      AccountManager.user_emails.each { |e| user_emails_copy.push e.dup }
-
-      @account_selector =
-        HorizontalSelector.new "Account:", AccountManager.user_emails + [nil], user_emails_copy + ["Customized"]
-
-      if @header["From"] =~ /<?(\S+@(\S+?))>?$/
-        @account_selector.set_to $1
-        @account_user = ""
-      else
-        @account_selector.set_to nil
-        @account_user = @header["From"]
-      end
-
-      add_selector @account_selector
-    end
-
-    @crypto_selector =
-      if CryptoManager.have_crypto?
-        HorizontalSelector.new "Crypto:", [:none] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.keys, ["None"] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.values
-      end
-    add_selector @crypto_selector if @crypto_selector
-
-    if @crypto_selector
-      HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
-    end
-
-    super opts
-    regen_text
-  end
-
-  def lines; @text.length + (@selectors.empty? ? 0 : (@selectors.length + DECORATION_LINES)) end
-
-  def [] i
-    if @selectors.empty?
-      @text[i]
-    elsif i < @selectors.length
-      @selectors[i].line @selector_label_width
-    elsif i == @selectors.length
-      ""
-    else
-      @text[i - @selectors.length - DECORATION_LINES]
-    end
-  end
-
-  ## hook for subclasses. i hate this style of programming.
-  def handle_new_text header, body; end
-
-  def edit_message_or_field
-    lines = DECORATION_LINES + @selectors.size
-    if lines > curpos
-      return
-    elsif (curpos - lines) >= @header_lines.length
-      edit_message
-    else
-      edit_field @header_lines[curpos - lines]
-    end
-  end
-
-  def edit_to; edit_field "To" end
-  def edit_cc; edit_field "Cc" end
-  def edit_subject; edit_field "Subject" end
-
-  def save_message_to_file
-    sig = sig_lines.join("\n")
-    @file = Tempfile.new ["sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}", ".eml"]
-    @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
-    @file.puts
-    @file.puts @body.join("\n")
-    @file.puts sig if ($config[:edit_signature] and !@sig_edited)
-    @file.close
-  end
-
-  def set_sig_edit_flag
-    sig = sig_lines.join("\n")
-    if $config[:edit_signature]
-      pbody = @body.join("\n")
-      blen = pbody.length
-      slen = sig.length
-
-      if blen > slen and pbody[blen-slen..blen] == sig
-        @sig_edited = false
-        @body = pbody[0..blen-slen].split("\n")
-      else
-        @sig_edited = true
-      end
-    end
-  end
-
-  def edit_message
-    old_from = @header["From"] if @account_selector
-
-    begin
-      save_message_to_file
-    rescue SystemCallError => e
-      BufferManager.flash "Can't save message to file: #{e.message}"
-      return
-    end
-
-    editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi"
-
-    mtime = File.mtime @file.path
-    BufferManager.shell_out "#{editor} #{@file.path}"
-    @edited = true if File.mtime(@file.path) > mtime
-
-    return @edited unless @edited
-
-    header, @body = parse_file @file.path
-    @header = header - NON_EDITABLE_HEADERS
-    set_sig_edit_flag
-
-    if @account_selector and @header["From"] != old_from
-      @account_user = @header["From"]
-      @account_selector.set_to nil
-    end
-
-    handle_new_text @header, @body
-    rerun_crypto_selector_hook
-    update
-
-    @edited
-  end
-
-  def edit_message_async
-    begin
-      save_message_to_file
-    rescue SystemCallError => e
-      BufferManager.flash "Can't save message to file: #{e.message}"
-      return
-    end
-
-    @mtime = File.mtime @file.path
-
-    # put up buffer saying you can now edit the message in another
-    # terminal or app, and continue to use sup in the meantime.
-    subject = @header["Subject"] || ""
-    @async_mode = EditMessageAsyncMode.new self, @file.path, subject
-    BufferManager.spawn "Waiting for message \"#{subject}\" to be finished", @async_mode
-
-    # hide ourselves, and wait for signal to resume from async mode ...
-    buffer.hidden = true
-  end
-
-  def edit_message_async_resume being_killed=false
-    buffer.hidden = false
-    @async_mode = nil
-    BufferManager.raise_to_front buffer if !being_killed
-
-    @edited = true if File.mtime(@file.path) > @mtime
-
-    header, @body = parse_file @file.path
-    @header = header - NON_EDITABLE_HEADERS
-    set_sig_edit_flag
-    handle_new_text @header, @body
-    update
-
-    true
-  end
-
-  def killable?
-    if !@async_mode.nil?
-      return false if !@async_mode.killable?
-      if File.mtime(@file.path) > @mtime
-        @edited = true
-        header, @body = parse_file @file.path
-        @header = header - NON_EDITABLE_HEADERS
-        handle_new_text @header, @body
-        update
-      end
-    end
-    !edited? || BufferManager.ask_yes_or_no("Discard message?")
-  end
-
-  def unsaved?; edited? end
-
-  def attach_file
-    fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
-    return unless fn
-    begin
-      Dir[fn].each do |f|
-        @attachments << RMail::Message.make_file_attachment(f)
-        @attachment_names << f
-      end
-      update
-    rescue SystemCallError => e
-      BufferManager.flash "Can't read #{fn}: #{e.message}"
-    end
-  end
-
-  def delete_attachment
-    i = curpos - @attachment_lines_offset - DECORATION_LINES - 2
-    if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachment_names[i]}?")
-      @attachments.delete_at i
-      @attachment_names.delete_at i
-      update
-    end
-  end
-
-protected
-
-  def rerun_crypto_selector_hook
-    if @crypto_selector && !@crypto_selector.changed_by_user
-      HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
-    end
-  end
-
-  def mime_encode string
-    string = [string].pack('M') # basic quoted-printable
-    string.gsub!(/=\n/,'')      # .. remove trailing newline
-    string.gsub!(/_/,'=5F')     # .. encode underscores
-    string.gsub!(/\?/,'=3F')    # .. encode question marks
-    string.gsub!(/ /,'_')       # .. translate space to underscores
-    "=?utf-8?q?#{string}?="
-  end
-
-  def mime_encode_subject string
-    return string if string.ascii_only?
-    mime_encode string
-  end
-
-  RE_ADDRESS = /(.+)( <.*@.*>)/
-
-  # Encode "bælammet mitt <user@example.com>" into
-  # "=?utf-8?q?b=C3=A6lammet_mitt?= <user@example.com>
-  def mime_encode_address string
-    return string if string.ascii_only?
-    string.sub(RE_ADDRESS) { |match| mime_encode($1) + $2 }
-  end
-
-  def move_cursor_left
-    if curpos < @selectors.length
-      @selectors[curpos].roll_left
-      buffer.mark_dirty
-      update if @account_selector
-    else
-      col_left
-    end
-  end
-
-  def move_cursor_right
-    if curpos < @selectors.length
-      @selectors[curpos].roll_right
-      buffer.mark_dirty
-      update if @account_selector
-    else
-      col_right
-    end
-  end
-
-  def add_selector s
-    @selectors << s
-    @selector_label_width = [@selector_label_width, s.label.length].max
-  end
-
-  def update
-    if @account_selector
-      if @account_selector.val.nil?
-        @header["From"] = @account_user
-      else
-        @header["From"] = AccountManager.full_address_for @account_selector.val
-      end
-    end
-
-    regen_text
-    buffer.mark_dirty if buffer
-  end
-
-  def regen_text
-    header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""]
-    @text = header + [""] + @body
-    @text += sig_lines unless @sig_edited
-
-    @attachment_lines_offset = 0
-
-    unless @attachments.empty?
-      @text += [""]
-      @attachment_lines_offset = @text.length
-      @text += (0 ... @attachments.size).map { |i| [[:attachment_color, "+ Attachment: #{@attachment_names[i]} (#{@attachments[i].body.size.to_human_size})"]] }
-    end
-  end
-
-  def parse_file fn
-    File.open(fn) do |f|
-      header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
-      body = f.readlines.map { |l| l.chomp }
-
-      header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
-      header.each { |k, v| header[k] = parse_header k, v }
-
-      [header, body]
-    end
-  end
-
-  def parse_header k, v
-    if MULTI_HEADERS.include?(k)
-      v.split_on_commas.map do |name|
-        (p = ContactManager.contact_for(name)) && p.full_address || name
-      end
-    else
-      v
-    end
-  end
-
-  def format_headers header
-    header_lines = []
-    headers = (FORCE_HEADERS + (header.keys - FORCE_HEADERS)).map do |h|
-      lines = make_lines "#{h}:", header[h]
-      lines.length.times { header_lines << h }
-      lines
-    end.flatten.compact
-    [headers, header_lines]
-  end
-
-  def make_lines header, things
-    case things
-    when nil, []
-      [header + " "]
-    when String
-      [header + " " + things]
-    else
-      if things.empty?
-        [header]
-      else
-        things.map_with_index do |name, i|
-          raise "an array: #{name.inspect} (things #{things.inspect})" if Array === name
-          if i == 0
-            header + " " + name
-          else
-            (" " * (header.display_length + 1)) + name
-          end + (i == things.length - 1 ? "" : ",")
-        end
-      end
-    end
-  end
-
-  def send_message
-    return false if !edited? && !BufferManager.ask_yes_or_no("Message unedited. Really send?")
-    return false if $config[:confirm_no_attachments] && mentions_attachments? && @attachments.size == 0 && !BufferManager.ask_yes_or_no("You haven't added any attachments. Really send?")#" stupid ruby-mode
-    return false if $config[:confirm_top_posting] && top_posting? && !BufferManager.ask_yes_or_no("You're top-posting. That makes you a bad person. Really send?") #" stupid ruby-mode
-
-    from_email =
-      if @header["From"] =~ /<?(\S+@(\S+?))>?$/
-        $1
-      else
-        AccountManager.default_account.email
-      end
-
-    acct = AccountManager.account_for(from_email) || AccountManager.default_account
-    BufferManager.flash "Sending..."
-
-    begin
-      date = Time.now
-      m = build_message date
-
-      if HookManager.enabled? "sendmail"
-    if not HookManager.run "sendmail", :message => m, :account => acct
-          warn "Sendmail hook was not successful"
-          return false
-    end
-      else
-        IO.popen(acct.sendmail, "w") { |p| p.puts m }
-        raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
-      end
-
-      SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) }
-      BufferManager.kill_buffer buffer
-      BufferManager.flash "Message sent!"
-      true
-    rescue SystemCallError, SendmailCommandFailed, CryptoManager::Error => e
-      warn "Problem sending mail: #{e.message}"
-      BufferManager.flash "Problem sending mail: #{e.message}"
-      false
-    end
-  end
-
-  def save_as_draft
-    DraftManager.write_draft { |f| write_message f, false }
-    BufferManager.kill_buffer buffer
-    BufferManager.flash "Saved for later editing."
-  end
-
-  def build_message date
-    m = RMail::Message.new
-    m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
-    m.body = @body.join("\n")
-    m.body += "\n" + sig_lines.join("\n") unless @sig_edited
-    ## body must end in a newline or GPG signatures will be WRONG!
-    m.body += "\n" unless m.body =~ /\n\Z/
-
-    ## there are attachments, so wrap body in an attachment of its own
-    unless @attachments.empty?
-      body_m = m
-      body_m.header["Content-Disposition"] = "inline"
-      m = RMail::Message.new
-
-      m.add_part body_m
-      @attachments.each { |a| m.add_part a }
-    end
-
-    ## do whatever crypto transformation is necessary
-    if @crypto_selector && @crypto_selector.val != :none
-      from_email = Person.from_address(@header["From"]).email
-      to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
-      if m.multipart?
-        m.each_part {|p| p = transfer_encode p}
-      else
-        m = transfer_encode m
-      end
-
-      m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
-    end
-
-    ## finally, set the top-level headers
-    @header.each do |k, v|
-      next if v.nil? || v.empty?
-      m.header[k] =
-        case v
-        when String
-          k.match(/subject/i) ? mime_encode_subject(v) : mime_encode_address(v)
-        when Array
-          v.map { |v| mime_encode_address v }.join ", "
-        end
-    end
-
-    m.header["Date"] = date.rfc2822
-    m.header["Message-Id"] = @message_id
-    m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
-    m.header["Content-Transfer-Encoding"] ||= '8bit'
-    m.header["MIME-Version"] = "1.0" if m.multipart?
-    m
-  end
-
-  ## TODO: remove this. redundant with write_full_message_to.
-  ##
-  ## this is going to change soon: draft messages (currently written
-  ## with full=false) will be output as yaml.
-  def write_message f, full=true, date=Time.now
-    raise ArgumentError, "no pre-defined date: header allowed" if @header["Date"]
-    f.puts format_headers(@header).first
-    f.puts <<EOS
-Date: #{date.rfc2822}
-Message-Id: #{@message_id}
-EOS
-    if full
-      f.puts <<EOS
-Mime-Version: 1.0
-Content-Type: text/plain; charset=us-ascii
-Content-Disposition: inline
-User-Agent: Redwood/#{Redwood::VERSION}
-EOS
-    end
-
-    f.puts
-    f.puts sanitize_body(@body.join("\n"))
-    f.puts sig_lines if full unless $config[:edit_signature]
-  end
-
-protected
-
-  def edit_field field
-    case field
-    when "Subject"
-      text = BufferManager.ask :subject, "Subject: ", @header[field]
-       if text
-         @header[field] = parse_header field, text
-         update
-       end
-    else
-      default = case field
-        when *MULTI_HEADERS
-          @header[field] ||= []
-          @header[field].join(", ")
-        else
-          @header[field]
-        end
-
-      contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
-      if contacts
-        text = contacts.map { |s| s.full_address }.join(", ")
-        @header[field] = parse_header field, text
-
-        if @account_selector and field == "From"
-          @account_user = @header["From"]
-          @account_selector.set_to nil
-        end
-
-        rerun_crypto_selector_hook
-        update
-      end
-    end
-  end
-
-private
-
-  def sanitize_body body
-    body.gsub(/^From /, ">From ")
-  end
-
-  def mentions_attachments?
-    if HookManager.enabled? "mentions-attachments"
-      HookManager.run "mentions-attachments", :header => @header, :body => @body
-    else
-      @body.any? {  |l| l =~ /^[^>]/ && l =~ /\battach(ment|ed|ing|)\b/i }
-    end
-  end
-
-  def top_posting?
-    @body.join("\n") =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/
-  end
-
-  def sig_lines
-    p = Person.from_address(@header["From"])
-    from_email = p && p.email
-
-    ## first run the hook
-    hook_sig = HookManager.run "signature", :header => @header, :from_email => from_email
-
-    return [] if hook_sig == :none
-    return ["", "-- "] + hook_sig.split("\n") if hook_sig
-
-    ## no hook, do default signature generation based on config.yaml
-    return [] unless from_email
-    sigfn = (AccountManager.account_for(from_email) ||
-             AccountManager.default_account).signature
-
-    if sigfn && File.exists?(sigfn)
-      ["", "-- "] + File.readlines(sigfn).map { |l| l.chomp }
-    else
-      []
-    end
-  end
-
-  def transfer_encode msg_part
-    ## return the message unchanged if it's already encoded
-    if (msg_part.header["Content-Transfer-Encoding"] == "base64" ||
-        msg_part.header["Content-Transfer-Encoding"] == "quoted-printable")
-      return msg_part
-    end
-
-    ## encode to quoted-printable for all text/* MIME types,
-    ## use base64 otherwise
-    if msg_part.header["Content-Type"] =~ /text\/.*/
-      msg_part.header["Content-Transfer-Encoding"] = 'quoted-printable'
-      msg_part.body = [msg_part.body].pack('M')
-    else
-      msg_part.header["Content-Transfer-Encoding"] = 'base64'
-      msg_part.body = [msg_part.body].pack('m')
-    end
-    msg_part
-  end
-end
-
-end
diff --git a/lib/sup/modes/edit-message-async-mode.rb b/lib/sup/modes/edit_message_async_mode.rb
diff --git a/lib/sup/modes/edit_message_mode.rb b/lib/sup/modes/edit_message_mode.rb
@@ -0,0 +1,684 @@
+require 'tempfile'
+require 'socket' # just for gethostname!
+require 'pathname'
+
+module Redwood
+
+class SendmailCommandFailed < StandardError; end
+
+class EditMessageMode < LineCursorMode
+  DECORATION_LINES = 1
+
+  FORCE_HEADERS = %w(From To Cc Bcc Subject)
+  MULTI_HEADERS = %w(To Cc Bcc)
+  NON_EDITABLE_HEADERS = %w(Message-id Date)
+
+  HookManager.register "signature", <<EOS
+Generates a message signature.
+Variables:
+      header: an object that supports string-to-string hashtable-style access
+              to the raw headers for the message. E.g., header["From"],
+              header["To"], etc.
+  from_email: the email part of the From: line, or nil if empty
+Return value:
+  A string (multi-line ok) containing the text of the signature, or nil to
+  use the default signature, or :none for no signature.
+EOS
+
+  HookManager.register "before-edit", <<EOS
+Modifies message body and headers before editing a new message. Variables
+should be modified in place.
+Variables:
+	header: a hash of headers. See 'signature' hook for documentation.
+	body: an array of lines of body text.
+Return value:
+	none
+EOS
+
+  HookManager.register "mentions-attachments", <<EOS
+Detects if given message mentions attachments the way it is probable
+that there should be files attached to the message.
+Variables:
+	header: a hash of headers. See 'signature' hook for documentation.
+	body: an array of lines of body text.
+Return value:
+	True if attachments are mentioned.
+EOS
+
+  HookManager.register "crypto-mode", <<EOS
+Modifies cryptography settings based on header and message content, before
+editing a new message. This can be used to set, for example, default cryptography
+settings.
+Variables:
+    header: a hash of headers. See 'signature' hook for documentation.
+    body: an array of lines of body text.
+    crypto_selector: the UI element that controls the current cryptography setting.
+Return value:
+     none
+EOS
+
+  HookManager.register "sendmail", <<EOS
+Sends the given mail. If this hook doesn't exist, the sendmail command
+configured for the account is used.
+The message will be saved after this hook is run, so any modification to it
+will be recorded.
+Variables:
+    message: RMail::Message instance of the mail to send
+    account: Account instance matching the From address
+Return value:
+     True if mail has been sent successfully, false otherwise.
+EOS
+
+  attr_reader :status
+  attr_accessor :body, :header
+  bool_reader :edited
+
+  register_keymap do |k|
+    k.add :send_message, "Send message", 'y'
+    k.add :edit_message_or_field, "Edit selected field", 'e'
+    k.add :edit_to, "Edit To:", 't'
+    k.add :edit_cc, "Edit Cc:", 'c'
+    k.add :edit_subject, "Edit Subject", 's'
+    k.add :edit_message, "Edit message", :enter
+    k.add :edit_message_async, "Edit message asynchronously", 'E'
+    k.add :save_as_draft, "Save as draft", 'P'
+    k.add :attach_file, "Attach a file", 'a'
+    k.add :delete_attachment, "Delete an attachment", 'd'
+    k.add :move_cursor_right, "Move selector to the right", :right, 'l'
+    k.add :move_cursor_left, "Move selector to the left", :left, 'h'
+  end
+
+  def initialize opts={}
+    @header = opts.delete(:header) || {}
+    @header_lines = []
+
+    @body = opts.delete(:body) || []
+
+    if opts[:attachments]
+      @attachments = opts[:attachments].values
+      @attachment_names = opts[:attachments].keys
+    else
+      @attachments = []
+      @attachment_names = []
+    end
+
+    begin
+      hostname = File.open("/etc/mailname", "r").gets.chomp
+    rescue
+        nil
+    end
+    hostname = Socket.gethostname if hostname.nil? or hostname.empty?
+
+    @message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{hostname}>"
+    @edited = false
+    @sig_edited = false
+    @selectors = []
+    @selector_label_width = 0
+    @async_mode = nil
+
+    HookManager.run "before-edit", :header => @header, :body => @body
+
+    @account_selector = nil
+    # only show account selector if there is more than one email address
+    if $config[:account_selector] && AccountManager.user_emails.length > 1
+      ## Duplicate e-mail strings to prevent a "can't modify frozen
+      ## object" crash triggered by the String::display_length()
+      ## method in util.rb
+      user_emails_copy = []
+      AccountManager.user_emails.each { |e| user_emails_copy.push e.dup }
+
+      @account_selector =
+        HorizontalSelector.new "Account:", AccountManager.user_emails + [nil], user_emails_copy + ["Customized"]
+
+      if @header["From"] =~ /<?(\S+@(\S+?))>?$/
+        # TODO: this is ugly. might implement an AccountSelector and handle
+        # special cases more transparently.
+        account_from = @account_selector.can_set_to?($1) ? $1 : nil
+        @account_selector.set_to account_from
+      else
+        @account_selector.set_to nil
+      end
+
+      # A single source of truth might better than duplicating this in both
+      # @account_user and @account_selector.
+      @account_user = @header["From"]
+
+      add_selector @account_selector
+    end
+
+    @crypto_selector =
+      if CryptoManager.have_crypto?
+        HorizontalSelector.new "Crypto:", [:none] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.keys, ["None"] + CryptoManager::OUTGOING_MESSAGE_OPERATIONS.values
+      end
+    add_selector @crypto_selector if @crypto_selector
+
+    if @crypto_selector
+      HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
+    end
+
+    super opts
+    regen_text
+  end
+
+  def lines; @text.length + (@selectors.empty? ? 0 : (@selectors.length + DECORATION_LINES)) end
+
+  def [] i
+    if @selectors.empty?
+      @text[i]
+    elsif i < @selectors.length
+      @selectors[i].line @selector_label_width
+    elsif i == @selectors.length
+      ""
+    else
+      @text[i - @selectors.length - DECORATION_LINES]
+    end
+  end
+
+  ## hook for subclasses. i hate this style of programming.
+  def handle_new_text header, body; end
+
+  def edit_message_or_field
+    lines = DECORATION_LINES + @selectors.size
+    if lines > curpos
+      return
+    elsif (curpos - lines) >= @header_lines.length
+      edit_message
+    else
+      edit_field @header_lines[curpos - lines]
+    end
+  end
+
+  def edit_to; edit_field "To" end
+  def edit_cc; edit_field "Cc" end
+  def edit_subject; edit_field "Subject" end
+
+  def save_message_to_file
+    sig = sig_lines.join("\n")
+    @file = Tempfile.new ["sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}", ".eml"]
+    @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
+    @file.puts
+    @file.puts @body.join("\n")
+    @file.puts sig if ($config[:edit_signature] and !@sig_edited)
+    @file.close
+  end
+
+  def set_sig_edit_flag
+    sig = sig_lines.join("\n")
+    if $config[:edit_signature]
+      pbody = @body.join("\n")
+      blen = pbody.length
+      slen = sig.length
+
+      if blen > slen and pbody[blen-slen..blen] == sig
+        @sig_edited = false
+        @body = pbody[0..blen-slen].split("\n")
+      else
+        @sig_edited = true
+      end
+    end
+  end
+
+  def edit_message
+    old_from = @header["From"] if @account_selector
+
+    begin
+      save_message_to_file
+    rescue SystemCallError => e
+      BufferManager.flash "Can't save message to file: #{e.message}"
+      return
+    end
+
+    editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi"
+
+    mtime = File.mtime @file.path
+    BufferManager.shell_out "#{editor} #{@file.path}"
+    @edited = true if File.mtime(@file.path) > mtime
+
+    return @edited unless @edited
+
+    header, @body = parse_file @file.path
+    @header = header - NON_EDITABLE_HEADERS
+    set_sig_edit_flag
+
+    if @account_selector and @header["From"] != old_from
+      @account_user = @header["From"]
+      @account_selector.set_to nil
+    end
+
+    handle_new_text @header, @body
+    rerun_crypto_selector_hook
+    update
+
+    @edited
+  end
+
+  def edit_message_async
+    begin
+      save_message_to_file
+    rescue SystemCallError => e
+      BufferManager.flash "Can't save message to file: #{e.message}"
+      return
+    end
+
+    @mtime = File.mtime @file.path
+
+    # put up buffer saying you can now edit the message in another
+    # terminal or app, and continue to use sup in the meantime.
+    subject = @header["Subject"] || ""
+    @async_mode = EditMessageAsyncMode.new self, @file.path, subject
+    BufferManager.spawn "Waiting for message \"#{subject}\" to be finished", @async_mode
+
+    # hide ourselves, and wait for signal to resume from async mode ...
+    buffer.hidden = true
+  end
+
+  def edit_message_async_resume being_killed=false
+    buffer.hidden = false
+    @async_mode = nil
+    BufferManager.raise_to_front buffer if !being_killed
+
+    @edited = true if File.mtime(@file.path) > @mtime
+
+    header, @body = parse_file @file.path
+    @header = header - NON_EDITABLE_HEADERS
+    set_sig_edit_flag
+    handle_new_text @header, @body
+    update
+
+    true
+  end
+
+  def killable?
+    if !@async_mode.nil?
+      return false if !@async_mode.killable?
+      if File.mtime(@file.path) > @mtime
+        @edited = true
+        header, @body = parse_file @file.path
+        @header = header - NON_EDITABLE_HEADERS
+        handle_new_text @header, @body
+        update
+      end
+    end
+    !edited? || BufferManager.ask_yes_or_no("Discard message?")
+  end
+
+  def unsaved?; edited? end
+
+  def attach_file
+    fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
+    return unless fn
+    begin
+      Dir[fn].each do |f|
+        @attachments << RMail::Message.make_file_attachment(f)
+        @attachment_names << f
+      end
+      update
+    rescue SystemCallError => e
+      BufferManager.flash "Can't read #{fn}: #{e.message}"
+    end
+  end
+
+  def delete_attachment
+    i = curpos - @attachment_lines_offset - DECORATION_LINES - 2
+    if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachment_names[i]}?")
+      @attachments.delete_at i
+      @attachment_names.delete_at i
+      update
+    end
+  end
+
+protected
+
+  def rerun_crypto_selector_hook
+    if @crypto_selector && !@crypto_selector.changed_by_user
+      HookManager.run "crypto-mode", :header => @header, :body => @body, :crypto_selector => @crypto_selector
+    end
+  end
+
+  def mime_encode string
+    string = [string].pack('M') # basic quoted-printable
+    string.gsub!(/=\n/,'')      # .. remove trailing newline
+    string.gsub!(/_/,'=5F')     # .. encode underscores
+    string.gsub!(/\?/,'=3F')    # .. encode question marks
+    string.gsub!(/ /,'_')       # .. translate space to underscores
+    "=?utf-8?q?#{string}?="
+  end
+
+  def mime_encode_subject string
+    return string if string.ascii_only?
+    mime_encode string
+  end
+
+  RE_ADDRESS = /(.+)( <.*@.*>)/
+
+  # Encode "bælammet mitt <user@example.com>" into
+  # "=?utf-8?q?b=C3=A6lammet_mitt?= <user@example.com>
+  def mime_encode_address string
+    return string if string.ascii_only?
+    string.sub(RE_ADDRESS) { |match| mime_encode($1) + $2 }
+  end
+
+  def move_cursor_left
+    if curpos < @selectors.length
+      @selectors[curpos].roll_left
+      buffer.mark_dirty
+      update if @account_selector
+    else
+      col_left
+    end
+  end
+
+  def move_cursor_right
+    if curpos < @selectors.length
+      @selectors[curpos].roll_right
+      buffer.mark_dirty
+      update if @account_selector
+    else
+      col_right
+    end
+  end
+
+  def add_selector s
+    @selectors << s
+    @selector_label_width = [@selector_label_width, s.label.length].max
+  end
+
+  def update
+    if @account_selector
+      if @account_selector.val.nil?
+        @header["From"] = @account_user
+      else
+        @header["From"] = AccountManager.full_address_for @account_selector.val
+      end
+    end
+
+    regen_text
+    buffer.mark_dirty if buffer
+  end
+
+  def regen_text
+    header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""]
+    @text = header + [""] + @body
+    @text += sig_lines unless @sig_edited
+
+    @attachment_lines_offset = 0
+
+    unless @attachments.empty?
+      @text += [""]
+      @attachment_lines_offset = @text.length
+      @text += (0 ... @attachments.size).map { |i| [[:attachment_color, "+ Attachment: #{@attachment_names[i]} (#{@attachments[i].body.size.to_human_size})"]] }
+    end
+  end
+
+  def parse_file fn
+    File.open(fn) do |f|
+      header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
+      body = f.readlines.map { |l| l.chomp }
+
+      header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
+      header.each { |k, v| header[k] = parse_header k, v }
+
+      [header, body]
+    end
+  end
+
+  def parse_header k, v
+    if MULTI_HEADERS.include?(k)
+      v.split_on_commas.map do |name|
+        (p = ContactManager.contact_for(name)) && p.full_address || name
+      end
+    else
+      v
+    end
+  end
+
+  def format_headers header
+    header_lines = []
+    headers = (FORCE_HEADERS + (header.keys - FORCE_HEADERS)).map do |h|
+      lines = make_lines "#{h}:", header[h]
+      lines.length.times { header_lines << h }
+      lines
+    end.flatten.compact
+    [headers, header_lines]
+  end
+
+  def make_lines header, things
+    case things
+    when nil, []
+      [header + " "]
+    when String
+      [header + " " + things]
+    else
+      if things.empty?
+        [header]
+      else
+        things.map_with_index do |name, i|
+          raise "an array: #{name.inspect} (things #{things.inspect})" if Array === name
+          if i == 0
+            header + " " + name
+          else
+            (" " * (header.display_length + 1)) + name
+          end + (i == things.length - 1 ? "" : ",")
+        end
+      end
+    end
+  end
+
+  def send_message
+    return false if !edited? && !BufferManager.ask_yes_or_no("Message unedited. Really send?")
+    return false if $config[:confirm_no_attachments] && mentions_attachments? && @attachments.size == 0 && !BufferManager.ask_yes_or_no("You haven't added any attachments. Really send?")#" stupid ruby-mode
+    return false if $config[:confirm_top_posting] && top_posting? && !BufferManager.ask_yes_or_no("You're top-posting. That makes you a bad person. Really send?") #" stupid ruby-mode
+
+    from_email =
+      if @header["From"] =~ /<?(\S+@(\S+?))>?$/
+        $1
+      else
+        AccountManager.default_account.email
+      end
+
+    acct = AccountManager.account_for(from_email) || AccountManager.default_account
+    BufferManager.flash "Sending..."
+
+    begin
+      date = Time.now
+      m = build_message date
+
+      if HookManager.enabled? "sendmail"
+        if not HookManager.run "sendmail", :message => m, :account => acct
+              warn "Sendmail hook was not successful"
+              return false
+        end
+      else
+        IO.popen(acct.sendmail, "w") { |p| p.puts m }
+        raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
+      end
+
+      SentManager.write_sent_message(date, from_email) { |f| f.puts sanitize_body(m.to_s) }
+      BufferManager.kill_buffer buffer
+      BufferManager.flash "Message sent!"
+      true
+    rescue SystemCallError, SendmailCommandFailed, CryptoManager::Error => e
+      warn "Problem sending mail: #{e.message}"
+      BufferManager.flash "Problem sending mail: #{e.message}"
+      false
+    end
+  end
+
+  def save_as_draft
+    DraftManager.write_draft { |f| write_message f, false }
+    BufferManager.kill_buffer buffer
+    BufferManager.flash "Saved for later editing."
+  end
+
+  def build_message date
+    m = RMail::Message.new
+    m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
+    m.body = @body.join("\n")
+    m.body += "\n" + sig_lines.join("\n") unless @sig_edited
+    ## body must end in a newline or GPG signatures will be WRONG!
+    m.body += "\n" unless m.body =~ /\n\Z/
+
+    ## there are attachments, so wrap body in an attachment of its own
+    unless @attachments.empty?
+      body_m = m
+      body_m.header["Content-Disposition"] = "inline"
+      m = RMail::Message.new
+
+      m.add_part body_m
+      @attachments.each { |a| m.add_part a }
+    end
+
+    ## do whatever crypto transformation is necessary
+    if @crypto_selector && @crypto_selector.val != :none
+      from_email = Person.from_address(@header["From"]).email
+      to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
+      if m.multipart?
+        m.each_part {|p| p = transfer_encode p}
+      else
+        m = transfer_encode m
+      end
+
+      m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
+    end
+
+    ## finally, set the top-level headers
+    @header.each do |k, v|
+      next if v.nil? || v.empty?
+      m.header[k] =
+        case v
+        when String
+          k.match(/subject/i) ? mime_encode_subject(v) : mime_encode_address(v)
+        when Array
+          v.map { |v| mime_encode_address v }.join ", "
+        end
+    end
+
+    m.header["Date"] = date.rfc2822
+    m.header["Message-Id"] = @message_id
+    m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
+    m.header["Content-Transfer-Encoding"] ||= '8bit'
+    m.header["MIME-Version"] = "1.0" if m.multipart?
+    m
+  end
+
+  ## TODO: remove this. redundant with write_full_message_to.
+  ##
+  ## this is going to change soon: draft messages (currently written
+  ## with full=false) will be output as yaml.
+  def write_message f, full=true, date=Time.now
+    raise ArgumentError, "no pre-defined date: header allowed" if @header["Date"]
+    f.puts format_headers(@header).first
+    f.puts <<EOS
+Date: #{date.rfc2822}
+Message-Id: #{@message_id}
+EOS
+    if full
+      f.puts <<EOS
+Mime-Version: 1.0
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+User-Agent: Redwood/#{Redwood::VERSION}
+EOS
+    end
+
+    f.puts
+    f.puts sanitize_body(@body.join("\n"))
+    f.puts sig_lines if full unless $config[:edit_signature]
+  end
+
+protected
+
+  def edit_field field
+    case field
+    when "Subject"
+      text = BufferManager.ask :subject, "Subject: ", @header[field]
+       if text
+         @header[field] = parse_header field, text
+         update
+       end
+    else
+      default = case field
+        when *MULTI_HEADERS
+          @header[field] ||= []
+          @header[field].join(", ")
+        else
+          @header[field]
+        end
+
+      contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
+      if contacts
+        text = contacts.map { |s| s.full_address }.join(", ")
+        @header[field] = parse_header field, text
+
+        if @account_selector and field == "From"
+          @account_user = @header["From"]
+          @account_selector.set_to nil
+        end
+
+        rerun_crypto_selector_hook
+        update
+      end
+    end
+  end
+
+private
+
+  def sanitize_body body
+    body.gsub(/^From /, ">From ")
+  end
+
+  def mentions_attachments?
+    if HookManager.enabled? "mentions-attachments"
+      HookManager.run "mentions-attachments", :header => @header, :body => @body
+    else
+      @body.any? {  |l| l =~ /^[^>]/ && l =~ /\battach(ment|ed|ing|)\b/i }
+    end
+  end
+
+  def top_posting?
+    @body.join("\n") =~ /(\S+)\s*Excerpts from.*\n(>.*\n)+\s*\Z/
+  end
+
+  def sig_lines
+    p = Person.from_address(@header["From"])
+    from_email = p && p.email
+
+    ## first run the hook
+    hook_sig = HookManager.run "signature", :header => @header, :from_email => from_email
+
+    return [] if hook_sig == :none
+    return ["", "-- "] + hook_sig.split("\n") if hook_sig
+
+    ## no hook, do default signature generation based on config.yaml
+    return [] unless from_email
+    sigfn = (AccountManager.account_for(from_email) ||
+             AccountManager.default_account).signature
+
+    if sigfn && File.exists?(sigfn)
+      ["", "-- "] + File.readlines(sigfn).map { |l| l.chomp }
+    else
+      []
+    end
+  end
+
+  def transfer_encode msg_part
+    ## return the message unchanged if it's already encoded
+    if (msg_part.header["Content-Transfer-Encoding"] == "base64" ||
+        msg_part.header["Content-Transfer-Encoding"] == "quoted-printable")
+      return msg_part
+    end
+
+    ## encode to quoted-printable for all text/* MIME types,
+    ## use base64 otherwise
+    if msg_part.header["Content-Type"] =~ /text\/.*/
+      msg_part.header["Content-Transfer-Encoding"] = 'quoted-printable'
+      msg_part.body = [msg_part.body].pack('M')
+    else
+      msg_part.header["Content-Transfer-Encoding"] = 'base64'
+      msg_part.body = [msg_part.body].pack('m')
+    end
+    msg_part
+  end
+end
+
+end
diff --git a/lib/sup/modes/file-browser-mode.rb b/lib/sup/modes/file_browser_mode.rb
diff --git a/lib/sup/modes/forward-mode.rb b/lib/sup/modes/forward_mode.rb
diff --git a/lib/sup/modes/help-mode.rb b/lib/sup/modes/help_mode.rb
diff --git a/lib/sup/modes/inbox-mode.rb b/lib/sup/modes/inbox-mode.rb
@@ -1,127 +0,0 @@
-require 'sup'
-
-module Redwood
-
-class InboxMode < ThreadIndexMode
-  register_keymap do |k|
-    ## overwrite toggle_archived with archive
-    k.add :archive, "Archive thread (remove from inbox)", 'a'
-    k.add :read_and_archive, "Archive thread (remove from inbox) and mark read", 'A'
-    k.add :refine_search, "Refine search", '|'
-  end
-
-  def initialize
-    super [:inbox, :sent, :draft], { :label => :inbox, :skip_killed => true }
-    raise "can't have more than one!" if defined? @@instance
-    @@instance = self
-  end
-
-  def is_relevant? m; (m.labels & [:spam, :deleted, :killed, :inbox]) == Set.new([:inbox]) end
-
-  def refine_search
-    text = BufferManager.ask :search, "refine inbox with query: "
-    return unless text && text !~ /^\s*$/
-    text = "label:inbox -label:spam -label:deleted " + text
-    SearchResultsMode.spawn_from_query text
-  end
-
-  ## label-list-mode wants to be able to raise us if the user selects
-  ## the "inbox" label, so we need to keep our singletonness around
-  def self.instance; @@instance; end
-  def killable?; false; end
-
-  def archive
-    return unless cursor_thread
-    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
-
-    UndoManager.register "archiving thread" do
-      thread.apply_label :inbox
-      add_or_unhide thread.first
-      Index.save_thread thread
-    end
-
-    cursor_thread.remove_label :inbox
-    hide_thread cursor_thread
-    regen_text
-    Index.save_thread thread
-  end
-
-  def multi_archive threads
-    UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do
-      threads.map do |t|
-        t.apply_label :inbox
-        add_or_unhide t.first
-        Index.save_thread t
-      end
-      regen_text
-    end
-
-    threads.each do |t|
-      t.remove_label :inbox
-      hide_thread t
-    end
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def read_and_archive
-    return unless cursor_thread
-    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
-
-    was_unread = thread.labels.member? :unread
-    UndoManager.register "reading and archiving thread" do
-      thread.apply_label :inbox
-      thread.apply_label :unread if was_unread
-      add_or_unhide thread.first
-      Index.save_thread thread
-    end
-
-    cursor_thread.remove_label :unread
-    cursor_thread.remove_label :inbox
-    hide_thread cursor_thread
-    regen_text
-    Index.save_thread thread
-  end
-
-  def multi_read_and_archive threads
-    old_labels = threads.map { |t| t.labels.dup }
-
-    threads.each do |t|
-      t.remove_label :unread
-      t.remove_label :inbox
-      hide_thread t
-    end
-    regen_text
-
-    UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
-      threads.zip(old_labels).each do |t, l|
-        t.labels = l
-        add_or_unhide t.first
-        Index.save_thread t
-      end
-      regen_text
-    end
-
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def handle_unarchived_update sender, m
-    add_or_unhide m
-  end
-
-  def handle_archived_update sender, m
-    t = thread_containing(m) or return
-    hide_thread t
-    regen_text
-  end
-
-  def handle_idle_update sender, idle_since
-    flush_index
-  end
-
-  def status
-    super + "    #{Index.size} messages in index"
-  end
-end
-
-end
diff --git a/lib/sup/modes/inbox_mode.rb b/lib/sup/modes/inbox_mode.rb
@@ -0,0 +1,127 @@
+require "sup/modes/thread_index_mode"
+
+module Redwood
+
+class InboxMode < ThreadIndexMode
+  register_keymap do |k|
+    ## overwrite toggle_archived with archive
+    k.add :archive, "Archive thread (remove from inbox)", 'a'
+    k.add :read_and_archive, "Archive thread (remove from inbox) and mark read", 'A'
+    k.add :refine_search, "Refine search", '|'
+  end
+
+  def initialize
+    super [:inbox, :sent, :draft], { :label => :inbox, :skip_killed => true }
+    raise "can't have more than one!" if defined? @@instance
+    @@instance = self
+  end
+
+  def is_relevant? m; (m.labels & [:spam, :deleted, :killed, :inbox]) == Set.new([:inbox]) end
+
+  def refine_search
+    text = BufferManager.ask :search, "refine inbox with query: "
+    return unless text && text !~ /^\s*$/
+    text = "label:inbox -label:spam -label:deleted " + text
+    SearchResultsMode.spawn_from_query text
+  end
+
+  ## label-list-mode wants to be able to raise us if the user selects
+  ## the "inbox" label, so we need to keep our singletonness around
+  def self.instance; @@instance; end
+  def killable?; false; end
+
+  def archive
+    return unless cursor_thread
+    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+    UndoManager.register "archiving thread" do
+      thread.apply_label :inbox
+      add_or_unhide thread.first
+      Index.save_thread thread
+    end
+
+    cursor_thread.remove_label :inbox
+    hide_thread cursor_thread
+    regen_text
+    Index.save_thread thread
+  end
+
+  def multi_archive threads
+    UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do
+      threads.map do |t|
+        t.apply_label :inbox
+        add_or_unhide t.first
+        Index.save_thread t
+      end
+      regen_text
+    end
+
+    threads.each do |t|
+      t.remove_label :inbox
+      hide_thread t
+    end
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def read_and_archive
+    return unless cursor_thread
+    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+    was_unread = thread.labels.member? :unread
+    UndoManager.register "reading and archiving thread" do
+      thread.apply_label :inbox
+      thread.apply_label :unread if was_unread
+      add_or_unhide thread.first
+      Index.save_thread thread
+    end
+
+    cursor_thread.remove_label :unread
+    cursor_thread.remove_label :inbox
+    hide_thread cursor_thread
+    regen_text
+    Index.save_thread thread
+  end
+
+  def multi_read_and_archive threads
+    old_labels = threads.map { |t| t.labels.dup }
+
+    threads.each do |t|
+      t.remove_label :unread
+      t.remove_label :inbox
+      hide_thread t
+    end
+    regen_text
+
+    UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
+      threads.zip(old_labels).each do |t, l|
+        t.labels = l
+        add_or_unhide t.first
+        Index.save_thread t
+      end
+      regen_text
+    end
+
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def handle_unarchived_update sender, m
+    add_or_unhide m
+  end
+
+  def handle_archived_update sender, m
+    t = thread_containing(m) or return
+    hide_thread t
+    regen_text
+  end
+
+  def handle_idle_update sender, idle_since
+    flush_index
+  end
+
+  def status
+    super + "    #{Index.size} messages in index"
+  end
+end
+
+end
diff --git a/lib/sup/modes/label-list-mode.rb b/lib/sup/modes/label_list_mode.rb
diff --git a/lib/sup/modes/label-search-results-mode.rb b/lib/sup/modes/label_search_results_mode.rb
diff --git a/lib/sup/modes/line-cursor-mode.rb b/lib/sup/modes/line-cursor-mode.rb
@@ -1,184 +0,0 @@
-module Redwood
-
-## extends ScrollMode to have a line-based cursor.
-class LineCursorMode < ScrollMode
-  register_keymap do |k|
-    ## overwrite scrollmode binding on arrow keys for cursor movement
-    ## but j and k still scroll!
-    k.add :cursor_down, "Move cursor down one line", :down, 'j'
-    k.add :cursor_up, "Move cursor up one line", :up, 'k'
-    k.add :select, "Select this item", :enter
-  end
-
-  attr_reader :curpos
-
-  def initialize opts={}
-    @cursor_top = @curpos = opts.delete(:skip_top_rows) || 0
-    @load_more_callbacks = []
-    @load_more_q = Queue.new
-    @load_more_thread = ::Thread.new do
-      while true
-        e = @load_more_q.pop
-        @load_more_callbacks.each { |c| c.call e }
-        sleep 0.5
-        @load_more_q.pop until @load_more_q.empty?
-      end
-    end
-
-    super opts
-  end
-
-  def cleanup
-    @load_more_thread.kill
-    super
-  end
-
-  def draw
-    super
-    set_status
-  end
-
-protected
-
-  ## callbacks when the cursor is asked to go beyond the bottom
-  def to_load_more &b
-    @load_more_callbacks << b
-  end
-
-  def draw_line ln, opts={}
-    if ln == @curpos
-      super ln, :highlight => true, :debug => opts[:debug]
-    else
-      super
-    end
-  end
-
-  def ensure_mode_validity
-    super
-    raise @curpos.inspect unless @curpos.is_a?(Integer)
-    c = @curpos.clamp topline, botline - 1
-    c = @cursor_top if c < @cursor_top
-    buffer.mark_dirty unless c == @curpos
-    @curpos = c
-  end
-
-  def set_cursor_pos p
-    return if @curpos == p
-    @curpos = p.clamp @cursor_top, lines
-    buffer.mark_dirty
-    set_status
-  end
-
-  ## override search behavior to be cursor-based. this is a stupid
-  ## implementation and should be made better. TODO: improve.
-  def search_goto_line line
-    page_down while line >= botline
-    page_up while line < topline
-    set_cursor_pos line
-  end
-
-  def search_start_line; @curpos end
-
-  def line_down # overwrite scrollmode
-    super
-    call_load_more_callbacks([topline + buffer.content_height - lines, 10].max) if topline + buffer.content_height > lines
-    set_cursor_pos topline if @curpos < topline
-  end
-
-  def line_up # overwrite scrollmode
-    super
-    set_cursor_pos botline - 1 if @curpos > botline - 1
-  end
-
-  def cursor_down
-    call_load_more_callbacks buffer.content_height if @curpos >= lines - [buffer.content_height/2,1].max
-    return false unless @curpos < lines - 1
-
-    if @curpos >= botline - 1
-      page_down
-      set_cursor_pos topline
-    else
-      @curpos += 1
-      unless buffer.dirty?
-        draw_line @curpos - 1
-        draw_line @curpos
-        set_status
-        buffer.commit
-      end
-    end
-    true
-  end
-
-  def cursor_up
-    return false unless @curpos > @cursor_top
-    if @curpos == topline
-      old_topline = topline
-      page_up
-      set_cursor_pos [old_topline - 1, topline].max
-    else
-      @curpos -= 1
-      unless buffer.dirty?
-        draw_line @curpos + 1
-        draw_line @curpos
-        set_status
-        buffer.commit
-      end
-    end
-    true
-  end
-
-  def page_up # overwrite
-    if topline <= @cursor_top
-      set_cursor_pos @cursor_top
-    else
-      relpos = @curpos - topline
-      super
-      set_cursor_pos topline + relpos
-    end
-  end
-
-  ## more complicated than one might think. three behaviors.
-  def page_down
-    ## if we're on the last page, and it's not a full page, just move
-    ## the cursor down to the bottom and assume we can't load anything
-    ## else via the callbacks.
-    if topline > lines - buffer.content_height
-      set_cursor_pos(lines - 1)
-
-    ## if we're on the last page, and it's a full page, try and load
-    ## more lines via the callbacks and then shift the page down
-    elsif topline == lines - buffer.content_height
-      call_load_more_callbacks buffer.content_height
-      super
-
-    ## otherwise, just move down
-    else
-      relpos = @curpos - topline
-      super
-      set_cursor_pos [topline + relpos, lines - 1].min
-    end
-  end
-
-  def jump_to_start
-    super
-    set_cursor_pos @cursor_top
-  end
-
-  def jump_to_end
-    super if topline < (lines - buffer.content_height)
-    set_cursor_pos(lines - 1)
-  end
-
-private
-
-  def set_status
-    l = lines
-    @status = l > 0 ? "line #{@curpos + 1} of #{l}" : ""
-  end
-
-  def call_load_more_callbacks size
-    @load_more_q.push size if $config[:load_more_threads_when_scrolling]
-  end
-end
-
-end
diff --git a/lib/sup/modes/line_cursor_mode.rb b/lib/sup/modes/line_cursor_mode.rb
@@ -0,0 +1,184 @@
+module Redwood
+
+## extends ScrollMode to have a line-based cursor.
+class LineCursorMode < ScrollMode
+  register_keymap do |k|
+    ## overwrite scrollmode binding on arrow keys for cursor movement
+    ## but j and k still scroll!
+    k.add :cursor_down, "Move cursor down one line", :down, 'j'
+    k.add :cursor_up, "Move cursor up one line", :up, 'k'
+    k.add :select, "Select this item", :enter
+  end
+
+  attr_reader :curpos
+
+  def initialize opts={}
+    @cursor_top = @curpos = opts.delete(:skip_top_rows) || 0
+    @load_more_callbacks = []
+    @load_more_q = Queue.new
+    @load_more_thread = ::Thread.new do
+      while true
+        e = @load_more_q.pop
+        @load_more_callbacks.each { |c| c.call e }
+        sleep 0.5
+        @load_more_q.pop until @load_more_q.empty?
+      end
+    end
+
+    super opts
+  end
+
+  def cleanup
+    @load_more_thread.kill
+    super
+  end
+
+  def draw
+    super
+    set_status
+  end
+
+protected
+
+  ## callbacks when the cursor is asked to go beyond the bottom
+  def to_load_more &b
+    @load_more_callbacks << b
+  end
+
+  def draw_line ln, opts={}
+    if ln == @curpos
+      super ln, :highlight => true, :debug => opts[:debug], :color => :text_color
+    else
+      super ln, :color => :text_color
+    end
+  end
+
+  def ensure_mode_validity
+    super
+    raise @curpos.inspect unless @curpos.is_a?(Integer)
+    c = @curpos.clamp topline, botline - 1
+    c = @cursor_top if c < @cursor_top
+    buffer.mark_dirty unless c == @curpos
+    @curpos = c
+  end
+
+  def set_cursor_pos p
+    return if @curpos == p
+    @curpos = p.clamp @cursor_top, lines
+    buffer.mark_dirty
+    set_status
+  end
+
+  ## override search behavior to be cursor-based. this is a stupid
+  ## implementation and should be made better. TODO: improve.
+  def search_goto_line line
+    page_down while line >= botline
+    page_up while line < topline
+    set_cursor_pos line
+  end
+
+  def search_start_line; @curpos end
+
+  def line_down # overwrite scrollmode
+    super
+    call_load_more_callbacks([topline + buffer.content_height - lines, 10].max) if topline + buffer.content_height > lines
+    set_cursor_pos topline if @curpos < topline
+  end
+
+  def line_up # overwrite scrollmode
+    super
+    set_cursor_pos botline - 1 if @curpos > botline - 1
+  end
+
+  def cursor_down
+    call_load_more_callbacks buffer.content_height if @curpos >= lines - [buffer.content_height/2,1].max
+    return false unless @curpos < lines - 1
+
+    if @curpos >= botline - 1
+      page_down
+      set_cursor_pos topline
+    else
+      @curpos += 1
+      unless buffer.dirty?
+        draw_line @curpos - 1
+        draw_line @curpos
+        set_status
+        buffer.commit
+      end
+    end
+    true
+  end
+
+  def cursor_up
+    return false unless @curpos > @cursor_top
+    if @curpos == topline
+      old_topline = topline
+      page_up
+      set_cursor_pos [old_topline - 1, topline].max
+    else
+      @curpos -= 1
+      unless buffer.dirty?
+        draw_line @curpos + 1
+        draw_line @curpos
+        set_status
+        buffer.commit
+      end
+    end
+    true
+  end
+
+  def page_up # overwrite
+    if topline <= @cursor_top
+      set_cursor_pos @cursor_top
+    else
+      relpos = @curpos - topline
+      super
+      set_cursor_pos topline + relpos
+    end
+  end
+
+  ## more complicated than one might think. three behaviors.
+  def page_down
+    ## if we're on the last page, and it's not a full page, just move
+    ## the cursor down to the bottom and assume we can't load anything
+    ## else via the callbacks.
+    if topline > lines - buffer.content_height
+      set_cursor_pos(lines - 1)
+
+    ## if we're on the last page, and it's a full page, try and load
+    ## more lines via the callbacks and then shift the page down
+    elsif topline == lines - buffer.content_height
+      call_load_more_callbacks buffer.content_height
+      super
+
+    ## otherwise, just move down
+    else
+      relpos = @curpos - topline
+      super
+      set_cursor_pos [topline + relpos, lines - 1].min
+    end
+  end
+
+  def jump_to_start
+    super
+    set_cursor_pos @cursor_top
+  end
+
+  def jump_to_end
+    super if topline < (lines - buffer.content_height)
+    set_cursor_pos(lines - 1)
+  end
+
+private
+
+  def set_status
+    l = lines
+    @status = l > 0 ? "line #{@curpos + 1} of #{l}" : ""
+  end
+
+  def call_load_more_callbacks size
+    @load_more_q.push size if $config[:load_more_threads_when_scrolling]
+  end
+end
+
+end
diff --git a/lib/sup/modes/log-mode.rb b/lib/sup/modes/log_mode.rb
diff --git a/lib/sup/modes/person-search-results-mode.rb b/lib/sup/modes/person_search_results_mode.rb
diff --git a/lib/sup/modes/poll-mode.rb b/lib/sup/modes/poll_mode.rb
diff --git a/lib/sup/modes/reply-mode.rb b/lib/sup/modes/reply_mode.rb
diff --git a/lib/sup/modes/resume-mode.rb b/lib/sup/modes/resume_mode.rb
diff --git a/lib/sup/modes/scroll-mode.rb b/lib/sup/modes/scroll-mode.rb
@@ -1,252 +0,0 @@
-module Redwood
-
-class ScrollMode < Mode
-  ## we define topline and botline as the top and bottom lines of any
-  ## content in the currentview.
-
-  ## we left leftcol and rightcol as the left and right columns of any
-  ## content in the current view. but since we're operating in a
-  ## line-centric fashion, rightcol is always leftcol + the buffer
-  ## width. (whereas botline is topline + at most the buffer height,
-  ## and can be == to topline in the case that there's no content.)
-
-  attr_reader :status, :topline, :botline, :leftcol
-
-  register_keymap do |k|
-    k.add :line_down, "Down one line", :down, 'j', 'J', "\C-e"
-    k.add :line_up, "Up one line", :up, 'k', 'K', "\C-y"
-    k.add :col_left, "Left one column", :left, 'h'
-    k.add :col_right, "Right one column", :right, 'l'
-    k.add :page_down, "Down one page", :page_down, ' ', "\C-f"
-    k.add :page_up, "Up one page", :page_up, 'p', :backspace, "\C-b"
-    k.add :half_page_down, "Down one half page", "\C-d"
-    k.add :half_page_up, "Up one half page", "\C-u"
-    k.add :jump_to_start, "Jump to top", :home, '^', '1'
-    k.add :jump_to_end, "Jump to bottom", :end, '$', '0'
-    k.add :jump_to_left, "Jump to the left", '['
-    k.add :search_in_buffer, "Search in current buffer", '/'
-    k.add :continue_search_in_buffer, "Jump to next search occurrence in buffer", BufferManager::CONTINUE_IN_BUFFER_SEARCH_KEY
-  end
-
-  def initialize opts={}
-    @topline, @botline, @leftcol = 0, 0, 0
-    @slip_rows = opts[:slip_rows] || 0 # when we pgup/pgdown,
-                                       # how many lines do we keep?
-    @twiddles = opts.member?(:twiddles) ? opts[:twiddles] : true
-    @search_query = nil
-    @search_line = nil
-    @status = ""
-    super()
-  end
-
-  def rightcol; @leftcol + buffer.content_width; end
-
-  def draw
-    ensure_mode_validity
-    (@topline ... @botline).each { |ln| draw_line ln }
-    ((@botline - @topline) ... buffer.content_height).each do |ln|
-      if @twiddles
-        buffer.write ln, 0, "~", :color => :twiddle_color
-      else
-        buffer.write ln, 0, ""
-      end
-    end
-    @status = "lines #{@topline + 1}:#{@botline}/#{lines}"
-  end
-
-  def in_search?; @search_line end
-  def cancel_search!; @search_line = nil end
-
-  def continue_search_in_buffer
-    unless @search_query
-      BufferManager.flash "No current search!"
-      return
-    end
-
-    start = @search_line || search_start_line
-    line, col = find_text @search_query, start
-    if line.nil? && (start > 0)
-      line, col = find_text @search_query, 0
-      BufferManager.flash "Search wrapped to top!" if line
-    end
-    if line
-      @search_line = line + 1
-      search_goto_pos line, col, col + @search_query.display_length
-      buffer.mark_dirty
-    else
-      BufferManager.flash "Not found!"
-    end
-  end
-
-  def search_in_buffer
-    query = BufferManager.ask :search, "search in buffer: "
-    return if query.nil? || query.empty?
-    @search_query = Regexp.escape query
-    continue_search_in_buffer
-  end
-
-  ## subclasses can override these three!
-  def search_goto_pos line, leftcol, rightcol
-    search_goto_line line
-
-    if rightcol > self.rightcol # if it's occluded...
-      jump_to_col [rightcol - buffer.content_width + 1, 0].max # move right
-    end
-  end
-  def search_start_line; @topline end
-  def search_goto_line line; jump_to_line line end
-
-  def col_jump
-    $config[:col_jump] || 2
-  end
-
-  def col_left
-    return unless @leftcol > 0
-    @leftcol -= col_jump
-    buffer.mark_dirty
-  end
-
-  def col_right
-    @leftcol += col_jump
-    buffer.mark_dirty
-  end
-
-  def jump_to_col col
-    col = col - (col % col_jump)
-    buffer.mark_dirty unless @leftcol == col
-    @leftcol = col
-  end
-
-  def jump_to_left; jump_to_col 0; end
-
-  ## set top line to l
-  def jump_to_line l
-    l = l.clamp 0, lines - 1
-    return if @topline == l
-    @topline = l
-    @botline = [l + buffer.content_height, lines].min
-    buffer.mark_dirty
-  end
-
-  def at_top?; @topline == 0 end
-  def at_bottom?; @botline == lines end
-
-  def line_down; jump_to_line @topline + 1; end
-  def line_up;  jump_to_line @topline - 1; end
-  def page_down; jump_to_line @topline + buffer.content_height - @slip_rows; end
-  def page_up; jump_to_line @topline - buffer.content_height + @slip_rows; end
-  def half_page_down; jump_to_line @topline + buffer.content_height / 2; end
-  def half_page_up; jump_to_line @topline - buffer.content_height / 2; end
-  def jump_to_start; jump_to_line 0; end
-  def jump_to_end; jump_to_line lines - buffer.content_height; end
-
-  def ensure_mode_validity
-    @topline = @topline.clamp 0, [lines - 1, 0].max
-    @botline = [@topline + buffer.content_height, lines].min
-  end
-
-  def resize *a
-    super(*a)
-    ensure_mode_validity
-  end
-
-protected
-
-  def find_text query, start_line
-    regex = /#{query}/i
-    (start_line ... lines).each do |i|
-      case(s = self[i])
-      when String
-        match = s =~ regex
-        return [i, match] if match
-      when Array
-        offset = 0
-        s.each do |color, string|
-          match = string =~ regex
-          if match
-            return [i, offset + match]
-          else
-            offset += string.display_length
-          end
-        end
-      end
-    end
-    nil
-  end
-
-  def draw_line ln, opts={}
-    regex = /(#{@search_query})/i
-    case(s = self[ln])
-    when String
-      if in_search?
-        draw_line_from_array ln, matching_text_array(s, regex), opts
-      else
-        draw_line_from_string ln, s, opts
-      end
-    when Array
-      if in_search?
-        ## seems like there ought to be a better way of doing this
-        array = []
-        s.each do |color, text|
-          if text =~ regex
-            array += matching_text_array text, regex, color
-          else
-            array << [color, text]
-          end
-        end
-        draw_line_from_array ln, array, opts
-      else
-        draw_line_from_array ln, s, opts
-      end
-    else
-      raise "unknown drawable object: #{s.inspect} in #{self} for line #{ln}" # good for debugging
-    end
-
-      ## speed test
-      # str = s.map { |color, text| text }.join
-      # buffer.write ln - @topline, 0, str, :color => :none, :highlight => opts[:highlight]
-      # return
-  end
-
-  def matching_text_array s, regex, oldcolor=:none
-    s.split(regex).map do |text|
-      next if text.empty?
-      if text =~ regex
-        [:search_highlight_color, text]
-      else
-        [oldcolor, text]
-      end
-    end.compact + [[oldcolor, ""]]
-  end
-
-  def draw_line_from_array ln, a, opts
-    xpos = 0
-    a.each_with_index do |(color, text), i|
-      raise "nil text for color '#{color}'" if text.nil? # good for debugging
-      l = text.display_length
-      no_fill = i != a.size - 1
-
-      if xpos + l < @leftcol
-        buffer.write ln - @topline, 0, "", :color => color,
-                     :highlight => opts[:highlight]
-      elsif xpos < @leftcol
-        ## partial
-        buffer.write ln - @topline, 0, text[(@leftcol - xpos) .. -1],
-                     :color => color,
-                     :highlight => opts[:highlight], :no_fill => no_fill
-      else
-        buffer.write ln - @topline, xpos - @leftcol, text,
-                     :color => color, :highlight => opts[:highlight],
-                     :no_fill => no_fill
-      end
-      xpos += l
-    end
-  end
-
-  def draw_line_from_string ln, s, opts
-    buffer.write ln - @topline, 0, s[@leftcol .. -1], :highlight => opts[:highlight]
-  end
-end
-
-end
-
diff --git a/lib/sup/modes/scroll_mode.rb b/lib/sup/modes/scroll_mode.rb
@@ -0,0 +1,252 @@
+module Redwood
+
+class ScrollMode < Mode
+  ## we define topline and botline as the top and bottom lines of any
+  ## content in the currentview.
+
+  ## we left leftcol and rightcol as the left and right columns of any
+  ## content in the current view. but since we're operating in a
+  ## line-centric fashion, rightcol is always leftcol + the buffer
+  ## width. (whereas botline is topline + at most the buffer height,
+  ## and can be == to topline in the case that there's no content.)
+
+  attr_reader :status, :topline, :botline, :leftcol
+
+  register_keymap do |k|
+    k.add :line_down, "Down one line", :down, 'j', 'J', "\C-e"
+    k.add :line_up, "Up one line", :up, 'k', 'K', "\C-y"
+    k.add :col_left, "Left one column", :left, 'h'
+    k.add :col_right, "Right one column", :right, 'l'
+    k.add :page_down, "Down one page", :page_down, ' ', "\C-f"
+    k.add :page_up, "Up one page", :page_up, 'p', :backspace, "\C-b"
+    k.add :half_page_down, "Down one half page", "\C-d"
+    k.add :half_page_up, "Up one half page", "\C-u"
+    k.add :jump_to_start, "Jump to top", :home, '^', '1'
+    k.add :jump_to_end, "Jump to bottom", :end, '$', '0'
+    k.add :jump_to_left, "Jump to the left", '['
+    k.add :search_in_buffer, "Search in current buffer", '/'
+    k.add :continue_search_in_buffer, "Jump to next search occurrence in buffer", BufferManager::CONTINUE_IN_BUFFER_SEARCH_KEY
+  end
+
+  def initialize opts={}
+    @topline, @botline, @leftcol = 0, 0, 0
+    @slip_rows = opts[:slip_rows] || 0 # when we pgup/pgdown,
+                                       # how many lines do we keep?
+    @twiddles = opts.member?(:twiddles) ? opts[:twiddles] : true
+    @search_query = nil
+    @search_line = nil
+    @status = ""
+    super()
+  end
+
+  def rightcol; @leftcol + buffer.content_width; end
+
+  def draw
+    ensure_mode_validity
+    (@topline ... @botline).each { |ln| draw_line ln, :color => :text_color }
+    ((@botline - @topline) ... buffer.content_height).each do |ln|
+      if @twiddles
+        buffer.write ln, 0, "~", :color => :twiddle_color
+      else
+        buffer.write ln, 0, "", :color => :text_color
+      end
+    end
+    @status = "lines #{@topline + 1}:#{@botline}/#{lines}"
+  end
+
+  def in_search?; @search_line end
+  def cancel_search!; @search_line = nil end
+
+  def continue_search_in_buffer
+    unless @search_query
+      BufferManager.flash "No current search!"
+      return
+    end
+
+    start = @search_line || search_start_line
+    line, col = find_text @search_query, start
+    if line.nil? && (start > 0)
+      line, col = find_text @search_query, 0
+      BufferManager.flash "Search wrapped to top!" if line
+    end
+    if line
+      @search_line = line + 1
+      search_goto_pos line, col, col + @search_query.display_length
+      buffer.mark_dirty
+    else
+      BufferManager.flash "Not found!"
+    end
+  end
+
+  def search_in_buffer
+    query = BufferManager.ask :search, "search in buffer: "
+    return if query.nil? || query.empty?
+    @search_query = Regexp.escape query
+    continue_search_in_buffer
+  end
+
+  ## subclasses can override these three!
+  def search_goto_pos line, leftcol, rightcol
+    search_goto_line line
+
+    if rightcol > self.rightcol # if it's occluded...
+      jump_to_col [rightcol - buffer.content_width + 1, 0].max # move right
+    end
+  end
+  def search_start_line; @topline end
+  def search_goto_line line; jump_to_line line end
+
+  def col_jump
+    $config[:col_jump] || 2
+  end
+
+  def col_left
+    return unless @leftcol > 0
+    @leftcol -= col_jump
+    buffer.mark_dirty
+  end
+
+  def col_right
+    @leftcol += col_jump
+    buffer.mark_dirty
+  end
+
+  def jump_to_col col
+    col = col - (col % col_jump)
+    buffer.mark_dirty unless @leftcol == col
+    @leftcol = col
+  end
+
+  def jump_to_left; jump_to_col 0; end
+
+  ## set top line to l
+  def jump_to_line l
+    l = l.clamp 0, lines - 1
+    return if @topline == l
+    @topline = l
+    @botline = [l + buffer.content_height, lines].min
+    buffer.mark_dirty
+  end
+
+  def at_top?; @topline == 0 end
+  def at_bottom?; @botline == lines end
+
+  def line_down; jump_to_line @topline + 1; end
+  def line_up;  jump_to_line @topline - 1; end
+  def page_down; jump_to_line @topline + buffer.content_height - @slip_rows; end
+  def page_up; jump_to_line @topline - buffer.content_height + @slip_rows; end
+  def half_page_down; jump_to_line @topline + buffer.content_height / 2; end
+  def half_page_up; jump_to_line @topline - buffer.content_height / 2; end
+  def jump_to_start; jump_to_line 0; end
+  def jump_to_end; jump_to_line lines - buffer.content_height; end
+
+  def ensure_mode_validity
+    @topline = @topline.clamp 0, [lines - 1, 0].max
+    @botline = [@topline + buffer.content_height, lines].min
+  end
+
+  def resize *a
+    super(*a)
+    ensure_mode_validity
+  end
+
+protected
+
+  def find_text query, start_line
+    regex = /#{query}/i
+    (start_line ... lines).each do |i|
+      case(s = self[i])
+      when String
+        match = s =~ regex
+        return [i, match] if match
+      when Array
+        offset = 0
+        s.each do |color, string|
+          match = string =~ regex
+          if match
+            return [i, offset + match]
+          else
+            offset += string.display_length
+          end
+        end
+      end
+    end
+    nil
+  end
+
+  def draw_line ln, opts={}
+    regex = /(#{@search_query})/i
+    case(s = self[ln])
+    when String
+      if in_search?
+        draw_line_from_array ln, matching_text_array(s, regex), opts
+      else
+        draw_line_from_string ln, s, opts
+      end
+    when Array
+      if in_search?
+        ## seems like there ought to be a better way of doing this
+        array = []
+        s.each do |color, text|
+          if text =~ regex
+            array += matching_text_array text, regex, color
+          else
+            array << [color, text]
+          end
+        end
+        draw_line_from_array ln, array, opts
+      else
+        draw_line_from_array ln, s, opts
+      end
+    else
+      raise "unknown drawable object: #{s.inspect} in #{self} for line #{ln}" # good for debugging
+    end
+
+      ## speed test
+      # str = s.map { |color, text| text }.join
+      # buffer.write ln - @topline, 0, str, :color => :none, :highlight => opts[:highlight]
+      # return
+  end
+
+  def matching_text_array s, regex, oldcolor=:text_color
+    s.split(regex).map do |text|
+      next if text.empty?
+      if text =~ regex
+        [:search_highlight_color, text]
+      else
+        [oldcolor, text]
+      end
+    end.compact + [[oldcolor, ""]]
+  end
+
+  def draw_line_from_array ln, a, opts
+    xpos = 0
+    a.each_with_index do |(color, text), i|
+      raise "nil text for color '#{color}'" if text.nil? # good for debugging
+      l = text.display_length
+      no_fill = i != a.size - 1
+
+      if xpos + l < @leftcol
+        buffer.write ln - @topline, 0, "", :color => color,
+                     :highlight => opts[:highlight]
+      elsif xpos < @leftcol
+        ## partial
+        buffer.write ln - @topline, 0, text[(@leftcol - xpos) .. -1],
+                     :color => color,
+                     :highlight => opts[:highlight], :no_fill => no_fill
+      else
+        buffer.write ln - @topline, xpos - @leftcol, text,
+                     :color => color, :highlight => opts[:highlight],
+                     :no_fill => no_fill
+      end
+      xpos += l
+    end
+  end
+
+  def draw_line_from_string ln, s, opts
+    buffer.write ln - @topline, 0, s[@leftcol .. -1], :highlight => opts[:highlight], :color => opts[:color]
+  end
+end
+
+end
+
diff --git a/lib/sup/modes/search-list-mode.rb b/lib/sup/modes/search_list_mode.rb
diff --git a/lib/sup/modes/search-results-mode.rb b/lib/sup/modes/search_results_mode.rb
diff --git a/lib/sup/modes/text-mode.rb b/lib/sup/modes/text_mode.rb
diff --git a/lib/sup/modes/thread-index-mode.rb b/lib/sup/modes/thread-index-mode.rb
@@ -1,950 +0,0 @@
-require 'set'
-
-module Redwood
-
-## subclasses should implement:
-## - is_relevant?
-
-class ThreadIndexMode < LineCursorMode
-  DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
-  MIN_FROM_WIDTH = 15
-  LOAD_MORE_THREAD_NUM = 20
-
-  HookManager.register "index-mode-size-widget", <<EOS
-Generates the per-thread size widget for each thread.
-Variables:
-  thread: The message thread to be formatted.
-EOS
-
-  HookManager.register "index-mode-date-widget", <<EOS
-Generates the per-thread date widget for each thread.
-Variables:
-  thread: The message thread to be formatted.
-EOS
-
-  HookManager.register "mark-as-spam", <<EOS
-This hook is run when a thread is marked as spam
-Variables:
-  thread: The message thread being marked as spam.
-EOS
-
-  register_keymap do |k|
-    k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
-    k.add_multi "Load all threads (! to confirm) :", '!' do |kk|
-      kk.add :load_all_threads, "Load all threads (may list a _lot_ of threads)", '!'
-    end
-    k.add :cancel_search, "Cancel current search", :ctrl_g
-    k.add :reload, "Refresh view", '@'
-    k.add :toggle_archived, "Toggle archived status", 'a'
-    k.add :toggle_starred, "Star or unstar all messages in thread", '*'
-    k.add :toggle_new, "Toggle new/read status of all messages in thread", 'N'
-    k.add :edit_labels, "Edit or add labels for a thread", 'l'
-    k.add :edit_message, "Edit message (drafts only)", 'e'
-    k.add :toggle_spam, "Mark/unmark thread as spam", 'S'
-    k.add :toggle_deleted, "Delete/undelete thread", 'd'
-    k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
-    k.add :flush_index, "Flush all changes now", '$'
-    k.add :jump_to_next_new, "Jump to next new thread", :tab
-    k.add :reply, "Reply to latest message in a thread", 'r'
-    k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
-    k.add :forward, "Forward latest message in a thread", 'f'
-    k.add :toggle_tagged, "Tag/untag selected thread", 't'
-    k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
-    k.add :tag_matching, "Tag matching threads", 'g'
-    k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
-    k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
-    k.add :undo, "Undo the previous action", 'u'
-  end
-
-  def initialize hidden_labels=[], load_thread_opts={}
-    super()
-    @mutex = Mutex.new # covers the following variables:
-    @threads = []
-    @hidden_threads = {}
-    @size_widget_width = nil
-    @size_widgets = []
-    @date_widget_width = nil
-    @date_widgets = []
-    @tags = Tagger.new self
-
-    ## these guys, and @text and @lines, are not covered
-    @load_thread = nil
-    @load_thread_opts = load_thread_opts
-    @hidden_labels = hidden_labels + LabelManager::HIDDEN_RESERVED_LABELS
-    @date_width = DATE_WIDTH
-
-    @interrupt_search = false
-
-    initialize_threads # defines @ts and @ts_mutex
-    update # defines @text and @lines
-
-    UpdateManager.register self
-
-    @save_thread_mutex = Mutex.new
-
-    @last_load_more_size = nil
-    to_load_more do |size|
-      next if @last_load_more_size == 0
-      load_threads :num => size,
-                   :when_done => lambda { |num| @last_load_more_size = num }
-    end
-  end
-
-  def unsaved?; dirty? end
-  def lines; @text.length; end
-  def [] i; @text[i]; end
-  def contains_thread? t; @threads.include?(t) end
-
-  def reload
-    drop_all_threads
-    UndoManager.clear
-    BufferManager.draw_screen
-    load_threads :num => buffer.content_height
-  end
-
-  ## open up a thread view window
-  def select t=nil, when_done=nil
-    t ||= cursor_thread or return
-
-    Redwood::reporting_thread("load messages for thread-view-mode") do
-      num = t.size
-      message = "Loading #{num.pluralize 'message body'}..."
-      BufferManager.say(message) do |sid|
-        t.each_with_index do |(m, *o), i|
-          next unless m
-          BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1
-          m.load_from_source!
-        end
-      end
-      mode = ThreadViewMode.new t, @hidden_labels, self
-      BufferManager.spawn t.subj, mode
-      BufferManager.draw_screen
-      mode.jump_to_first_open if $config[:jump_to_open_message]
-      BufferManager.draw_screen # lame TODO: make this unnecessary
-      ## the first draw_screen is needed before topline and botline
-      ## are set, and the second to show the cursor having moved
-
-      t.remove_label :unread
-      Index.save_thread t
-
-      update_text_for_line curpos
-      UpdateManager.relay self, :read, t.first
-      when_done.call if when_done
-    end
-  end
-
-  def multi_select threads
-    threads.each { |t| select t }
-  end
-
-  ## these two methods are called by thread-view-modes when the user
-  ## wants to view the previous/next thread without going back to
-  ## index-mode. we update the cursor as a convenience.
-  def launch_next_thread_after thread, &b
-    launch_another_thread thread, 1, &b
-  end
-
-  def launch_prev_thread_before thread, &b
-    launch_another_thread thread, -1, &b
-  end
-
-  def launch_another_thread thread, direction, &b
-    l = @lines[thread] or return
-    target_l = l + direction
-    t = @mutex.synchronize do
-      if target_l >= 0 && target_l < @threads.length
-        @threads[target_l]
-      end
-    end
-
-    if t # there's a next thread
-      set_cursor_pos target_l # move out of mutex?
-      select t, b
-    elsif b # no next thread. call the block anyways
-      b.call
-    end
-  end
-
-  def handle_single_message_labeled_update sender, m
-    ## no need to do anything different here; we don't differentiate
-    ## messages from their containing threads
-    handle_labeled_update sender, m
-  end
-
-  def handle_labeled_update sender, m
-    if(t = thread_containing(m))
-      l = @lines[t] or return
-      update_text_for_line l
-    elsif is_relevant?(m)
-      add_or_unhide m
-    end
-  end
-
-  def handle_simple_update sender, m
-    t = thread_containing(m) or return
-    l = @lines[t] or return
-    update_text_for_line l
-  end
-
-  %w(read unread archived starred unstarred).each do |state|
-    define_method "handle_#{state}_update" do |*a|
-      handle_simple_update(*a)
-    end
-  end
-
-  ## overwrite me!
-  def is_relevant? m; false; end
-
-  def handle_added_update sender, m
-    add_or_unhide m
-    BufferManager.draw_screen
-  end
-
-  def handle_single_message_deleted_update sender, m
-    @ts_mutex.synchronize do
-      return unless @ts.contains? m
-      @ts.remove_id m.id
-    end
-    update
-  end
-
-  def handle_deleted_update sender, m
-    t = @ts_mutex.synchronize { @ts.thread_for m }
-    return unless t
-    hide_thread t
-    update
-  end
-
-  def handle_spammed_update sender, m
-    t = @ts_mutex.synchronize { @ts.thread_for m }
-    return unless t
-    hide_thread t
-    update
-  end
-
-  def handle_undeleted_update sender, m
-    add_or_unhide m
-  end
-
-  def undo
-    UndoManager.undo
-  end
-
-  def update
-    old_cursor_thread = cursor_thread
-    @mutex.synchronize do
-      ## let's see you do THIS in python
-      @threads = @ts.threads.select { |t| !@hidden_threads.member?(t) }.select(&:has_message?).sort_by(&:sort_key)
-      @size_widgets = @threads.map { |t| size_widget_for_thread t }
-      @size_widget_width = @size_widgets.max_of { |w| w.display_length }
-      @date_widgets = @threads.map { |t| date_widget_for_thread t }
-      @date_widget_width = @date_widgets.max_of { |w| w.display_length }
-    end
-    set_cursor_pos @threads.index(old_cursor_thread)||curpos
-
-    regen_text
-  end
-
-  def edit_message
-    return unless(t = cursor_thread)
-    message, *crap = t.find { |m, *o| m.has_label? :draft }
-    if message
-      mode = ResumeMode.new message
-      BufferManager.spawn "Edit message", mode
-    else
-      BufferManager.flash "Not a draft message!"
-    end
-  end
-
-  ## returns an undo lambda
-  def actually_toggle_starred t
-    pos = curpos
-    if t.has_label? :starred # if ANY message has a star
-      t.remove_label :starred # remove from all
-      UpdateManager.relay self, :unstarred, t.first
-      lambda do
-        t.first.add_label :starred
-        UpdateManager.relay self, :starred, t.first
-        regen_text
-      end
-    else
-      t.first.add_label :starred # add only to first
-      UpdateManager.relay self, :starred, t.first
-      lambda do
-        t.remove_label :starred
-        UpdateManager.relay self, :unstarred, t.first
-        regen_text
-      end
-    end
-  end
-
-  def toggle_starred
-    t = cursor_thread or return
-    undo = actually_toggle_starred t
-    UndoManager.register "toggling thread starred status", undo, lambda { Index.save_thread t }
-    update_text_for_line curpos
-    cursor_down
-    Index.save_thread t
-  end
-
-  def multi_toggle_starred threads
-    UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
-      threads.map { |t| actually_toggle_starred t },
-      lambda { threads.each { |t| Index.save_thread t } }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  ## returns an undo lambda
-  def actually_toggle_archived t
-    thread = t
-    pos = curpos
-    if t.has_label? :inbox
-      t.remove_label :inbox
-      UpdateManager.relay self, :archived, t.first
-      lambda do
-        thread.apply_label :inbox
-        update_text_for_line pos
-        UpdateManager.relay self,:unarchived, thread.first
-      end
-    else
-      t.apply_label :inbox
-      UpdateManager.relay self, :unarchived, t.first
-      lambda do
-        thread.remove_label :inbox
-        update_text_for_line pos
-        UpdateManager.relay self, :unarchived, thread.first
-      end
-    end
-  end
-
-  ## returns an undo lambda
-  def actually_toggle_spammed t
-    thread = t
-    if t.has_label? :spam
-      t.remove_label :spam
-      add_or_unhide t.first
-      UpdateManager.relay self, :unspammed, t.first
-      lambda do
-        thread.apply_label :spam
-        self.hide_thread thread
-        UpdateManager.relay self,:spammed, thread.first
-      end
-    else
-      t.apply_label :spam
-      hide_thread t
-      UpdateManager.relay self, :spammed, t.first
-      lambda do
-        thread.remove_label :spam
-        add_or_unhide thread.first
-        UpdateManager.relay self,:unspammed, thread.first
-      end
-    end
-  end
-
-  ## returns an undo lambda
-  def actually_toggle_deleted t
-    if t.has_label? :deleted
-      t.remove_label :deleted
-      add_or_unhide t.first
-      UpdateManager.relay self, :undeleted, t.first
-      lambda do
-        t.apply_label :deleted
-        hide_thread t
-        UpdateManager.relay self, :deleted, t.first
-      end
-    else
-      t.apply_label :deleted
-      hide_thread t
-      UpdateManager.relay self, :deleted, t.first
-      lambda do
-        t.remove_label :deleted
-        add_or_unhide t.first
-        UpdateManager.relay self, :undeleted, t.first
-      end
-    end
-  end
-
-  def toggle_archived
-    t = cursor_thread or return
-    undo = actually_toggle_archived t
-    UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos },
-                         lambda { Index.save_thread t }
-    update_text_for_line curpos
-    Index.save_thread t
-  end
-
-  def multi_toggle_archived threads
-    undos = threads.map { |t| actually_toggle_archived t }
-    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text },
-                         lambda { threads.each { |t| Index.save_thread t } }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def toggle_new
-    t = cursor_thread or return
-    t.toggle_label :unread
-    update_text_for_line curpos
-    cursor_down
-    Index.save_thread t
-  end
-
-  def multi_toggle_new threads
-    threads.each { |t| t.toggle_label :unread }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def multi_toggle_tagged threads
-    @mutex.synchronize { @tags.drop_all_tags }
-    regen_text
-  end
-
-  def join_threads
-    ## this command has no non-tagged form. as a convenience, allow this
-    ## command to be applied to tagged threads without hitting ';'.
-    @tags.apply_to_tagged :join_threads
-  end
-
-  def multi_join_threads threads
-    @ts.join_threads threads or return
-    threads.each { |t| Index.save_thread t }
-    @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
-    update
-  end
-
-  def jump_to_next_new
-    n = @mutex.synchronize do
-      ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
-        (0 ... curpos).find { |i| @threads[i].has_label? :unread }
-    end
-    if n
-      ## jump there if necessary
-      jump_to_line n unless n >= topline && n < botline
-      set_cursor_pos n
-    else
-      BufferManager.flash "No new messages."
-    end
-  end
-
-  def toggle_spam
-    t = cursor_thread or return
-    multi_toggle_spam [t]
-  end
-
-  ## both spam and deleted have the curious characteristic that you
-  ## always want to hide the thread after either applying or removing
-  ## that label. in all thread-index-views except for
-  ## label-search-results-mode, when you mark a message as spam or
-  ## deleted, you want it to disappear immediately; in LSRM, you only
-  ## see deleted or spam emails, and when you undelete or unspam them
-  ## you also want them to disappear immediately.
-  def multi_toggle_spam threads
-    undos = threads.map { |t| actually_toggle_spammed t }
-    threads.each { |t| HookManager.run("mark-as-spam", :thread => t) }
-    UndoManager.register "marking/unmarking  #{threads.size.pluralize 'thread'} as spam",
-                         undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def toggle_deleted
-    t = cursor_thread or return
-    multi_toggle_deleted [t]
-  end
-
-  ## see comment for multi_toggle_spam
-  def multi_toggle_deleted threads
-    undos = threads.map { |t| actually_toggle_deleted t }
-    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
-                         undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def kill
-    t = cursor_thread or return
-    multi_kill [t]
-  end
-
-  def flush_index
-    @flush_id = BufferManager.say "Flushing index..."
-    Index.save_index
-    BufferManager.clear @flush_id
-  end
-
-  ## m-m-m-m-MULTI-KILL
-  def multi_kill threads
-    UndoManager.register "killing/unkilling #{threads.size.pluralize 'threads'}" do
-      threads.each do |t|
-        if t.toggle_label :killed
-          add_or_unhide t.first
-        else
-          hide_thread t
-        end
-      end.each do |t|
-        UpdateManager.relay self, :labeled, t.first
-        Index.save_thread t
-      end
-      regen_text
-    end
-
-    threads.each do |t|
-      if t.toggle_label :killed
-        hide_thread t
-      else
-        add_or_unhide t.first
-      end
-    end.each do |t|
-      # send 'labeled'... this might be more specific
-      UpdateManager.relay self, :labeled, t.first
-      Index.save_thread t
-    end
-
-    killed, unkilled = threads.partition { |t| t.has_label? :killed }.map(&:size)
-    BufferManager.flash "#{killed.pluralize 'thread'} killed, #{unkilled} unkilled"
-    regen_text
-  end
-
-  def cleanup
-    UpdateManager.unregister self
-
-    if @load_thread
-      @load_thread.kill
-      BufferManager.clear @mbid if @mbid
-      sleep 0.1 # TODO: necessary?
-      BufferManager.erase_flash
-    end
-    dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
-    fail "dirty threads remain" unless dirty_threads.empty?
-    super
-  end
-
-  def toggle_tagged
-    t = cursor_thread or return
-    @mutex.synchronize { @tags.toggle_tag_for t }
-    update_text_for_line curpos
-    cursor_down
-  end
-
-  def toggle_tagged_all
-    @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } }
-    regen_text
-  end
-
-  def tag_matching
-    query = BufferManager.ask :search, "tag threads matching (regex): "
-    return if query.nil? || query.empty?
-    query = begin
-      /#{query}/i
-    rescue RegexpError => e
-      BufferManager.flash "error interpreting '#{query}': #{e.message}"
-      return
-    end
-    @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
-    regen_text
-  end
-
-  def apply_to_tagged; @tags.apply_to_tagged; end
-
-  def edit_labels
-    thread = cursor_thread or return
-    speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
-
-    old_labels = thread.labels
-    pos = curpos
-
-    keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
-
-    user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl.sort_by {|x| x.to_s}, @hidden_labels
-    return unless user_labels
-
-    thread.labels = Set.new(keepl) + user_labels
-    user_labels.each { |l| LabelManager << l }
-    update_text_for_line curpos
-
-    UndoManager.register "labeling thread" do
-      thread.labels = old_labels
-      update_text_for_line pos
-      UpdateManager.relay self, :labeled, thread.first
-      Index.save_thread thread
-    end
-
-    UpdateManager.relay self, :labeled, thread.first
-    Index.save_thread thread
-  end
-
-  def multi_edit_labels threads
-    user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
-    return unless user_labels
-
-    user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
-    hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
-    unless hl.empty?
-      BufferManager.flash "'#{hl}' is a reserved label!"
-      return
-    end
-
-    old_labels = threads.map { |t| t.labels.dup }
-
-    threads.each do |t|
-      user_labels.each do |(l, to_remove)|
-        if to_remove
-          t.remove_label l
-        else
-          t.apply_label l
-          LabelManager << l
-        end
-      end
-      UpdateManager.relay self, :labeled, t.first
-    end
-
-    regen_text
-
-    UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
-      threads.zip(old_labels).map do |t, old_labels|
-        t.labels = old_labels
-        UpdateManager.relay self, :labeled, t.first
-        Index.save_thread t
-      end
-      regen_text
-    end
-
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def reply type_arg=nil
-    t = cursor_thread or return
-    m = t.latest_message
-    return if m.nil? # probably won't happen
-    m.load_from_source!
-    mode = ReplyMode.new m, type_arg
-    BufferManager.spawn "Reply to #{m.subj}", mode
-  end
-
-  def reply_all; reply :all; end
-
-  def forward
-    t = cursor_thread or return
-    m = t.latest_message
-    return if m.nil? # probably won't happen
-    m.load_from_source!
-    ForwardMode.spawn_nicely :message => m
-  end
-
-  def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
-    return if @load_thread # todo: wrap in mutex
-    @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do
-      num = load_n_threads n, opts
-      opts[:when_done].call(num) if opts[:when_done]
-      @load_thread = nil
-    end
-  end
-
-  ## TODO: figure out @ts_mutex in this method
-  def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
-    @interrupt_search = false
-    @mbid = BufferManager.say "Searching for threads..."
-
-    ts_to_load = n
-    ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads
-
-    orig_size = @ts.size
-    last_update = Time.now
-    @ts.load_n_threads(ts_to_load, opts) do |i|
-      if (Time.now - last_update) >= 0.25
-        BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid
-        update
-        BufferManager.draw_screen
-        last_update = Time.now
-      end
-      ::Thread.pass
-      break if @interrupt_search
-    end
-    @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } }
-
-    update
-    BufferManager.clear @mbid
-    @mbid = nil
-    BufferManager.draw_screen
-    @ts.size - orig_size
-  end
-  ignore_concurrent_calls :load_n_threads
-
-  def status
-    if (l = lines) == 0
-      "line 0 of 0"
-    else
-      "line #{curpos + 1} of #{l}"
-    end
-  end
-
-  def cancel_search
-    @interrupt_search = true
-  end
-
-  def load_all_threads
-    load_threads :num => -1
-  end
-
-  def load_threads opts={}
-    if opts[:num].nil?
-      n = ThreadIndexMode::LOAD_MORE_THREAD_NUM
-    else
-      n = opts[:num]
-    end
-
-    myopts = @load_thread_opts.merge({ :when_done => (lambda do |num|
-      opts[:when_done].call(num) if opts[:when_done]
-
-      if num > 0
-        BufferManager.flash "Found #{num.pluralize 'thread'}."
-      else
-        BufferManager.flash "No matches."
-      end
-    end)})
-
-    if opts[:background] || opts[:background].nil?
-      load_n_threads_background n, myopts
-    else
-      load_n_threads n, myopts
-    end
-  end
-  ignore_concurrent_calls :load_threads
-
-  def resize rows, cols
-    regen_text
-    super
-  end
-
-protected
-
-  def add_or_unhide m
-    @ts_mutex.synchronize do
-      if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
-        @ts.load_thread_for_message m, @load_thread_opts
-      end
-
-      @hidden_threads.delete @ts.thread_for(m)
-    end
-
-    update
-  end
-
-  def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
-
-  ## used to tag threads by query. this can be made a lot more sophisticated,
-  ## but for right now we'll do the obvious this.
-  def thread_matches? t, query
-    t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
-  end
-
-  def size_widget_for_thread t
-    HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
-  end
-
-  def date_widget_for_thread t
-    HookManager.run("index-mode-date-widget", :thread => t) || default_date_widget_for(t)
-  end
-
-  def cursor_thread; @mutex.synchronize { @threads[curpos] }; end
-
-  def drop_all_threads
-    @tags.drop_all_tags
-    initialize_threads
-    update
-  end
-
-  def hide_thread t
-    @mutex.synchronize do
-      i = @threads.index(t) or return
-      raise "already hidden" if @hidden_threads[t]
-      @hidden_threads[t] = true
-      @threads.delete_at i
-      @size_widgets.delete_at i
-      @date_widgets.delete_at i
-      @tags.drop_tag_for t
-    end
-  end
-
-  def update_text_for_line l
-    return unless l # not sure why this happens, but it does, occasionally
-
-    need_update = false
-
-    @mutex.synchronize do
-      @size_widgets[l] = size_widget_for_thread @threads[l]
-      @date_widgets[l] = date_widget_for_thread @threads[l]
-
-      ## if a widget size has increased, we need to redraw everyone
-      need_update =
-        (@size_widgets[l].size > @size_widget_width) or
-        (@date_widgets[l].size > @date_widget_width)
-    end
-
-    if need_update
-      update
-    else
-      @text[l] = text_for_thread_at l
-      buffer.mark_dirty if buffer
-    end
-  end
-
-  def regen_text
-    threads = @mutex.synchronize { @threads }
-    @text = threads.map_with_index { |t, i| text_for_thread_at i }
-    @lines = threads.map_with_index { |t, i| [t, i] }.to_h
-    buffer.mark_dirty if buffer
-  end
-
-  def authors; map { |m, *o| m.from if m }.compact.uniq; end
-
-  ## preserve author order from the thread
-  def author_names_and_newness_for_thread t, limit=nil
-    new = {}
-    seen = {}
-    authors = t.map do |m, *o|
-      next unless m && m.from
-      new[m.from] ||= m.has_label?(:unread)
-      next if seen[m.from]
-      seen[m.from] = true
-      m.from
-    end.compact
-
-    result = []
-    authors.each do |a|
-      break if limit && result.size >= limit
-      name = if AccountManager.is_account?(a)
-        "me"
-      elsif t.authors.size == 1
-        a.mediumname
-      else
-        a.shortname
-      end
-
-      result << [name, new[a]]
-    end
-
-    if result.size == 1 && (author_and_newness = result.assoc("me"))
-      unless (recipients = t.participants - t.authors).empty?
-        result = recipients.collect do |r|
-          break if limit && result.size >= limit
-          name = (recipients.size == 1) ? r.mediumname : r.shortname
-          ["(#{name})", author_and_newness[1]]
-        end
-      end
-    end
-
-    result
-  end
-
-  AUTHOR_LIMIT = 5
-  def text_for_thread_at line
-    t, size_widget, date_widget = @mutex.synchronize do
-      [@threads[line], @size_widgets[line], @date_widgets[line]]
-    end
-
-    starred = t.has_label? :starred
-
-    ## format the from column
-    cur_width = 0
-    ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT
-    from = []
-    ann.each_with_index do |(name, newness), i|
-      break if cur_width >= from_width
-      last = i == ann.length - 1
-
-      abbrev =
-        if cur_width + name.display_length > from_width
-          name[0 ... (from_width - cur_width - 1)] + "."
-        elsif cur_width + name.display_length == from_width
-          name[0 ... (from_width - cur_width)]
-        else
-          if last
-            name[0 ... (from_width - cur_width)]
-          else
-            name[0 ... (from_width - cur_width - 1)] + ","
-          end
-        end
-
-      cur_width += abbrev.display_length
-
-      if last && from_width > cur_width
-        abbrev += " " * (from_width - cur_width)
-      end
-
-      from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev]
-    end
-
-    dp = t.direct_participants.any? { |p| AccountManager.is_account? p }
-    p = dp || t.participants.any? { |p| AccountManager.is_account? p }
-
-    subj_color =
-      if t.has_label?(:draft)
-        :index_draft_color
-      elsif t.has_label?(:unread)
-        :index_new_color
-      elsif starred
-        :index_starred_color
-      elsif Colormap.sym_is_defined(:index_subject_color)
-        :index_subject_color
-      else
-        :index_old_color
-      end
-
-    size_padding = @size_widget_width - size_widget.display_length
-    size_widget_text = sprintf "%#{size_padding}s%s", "", size_widget
-
-    date_padding = @date_widget_width - date_widget.display_length
-    date_widget_text = sprintf "%#{date_padding}s%s", "", date_widget
-
-    [
-      [:tagged_color, @tags.tagged?(t) ? ">" : " "],
-      [:date_color, date_widget_text],
-      [:starred_color, (starred ? "*" : " ")],
-    ] +
-      from +
-      [
-      [:size_widget_color, size_widget_text],
-      [:to_me_color, t.labels.member?(:attachment) ? "@" : " "],
-      [:to_me_color, dp ? ">" : (p ? '+' : " ")],
-    ] +
-      (t.labels - @hidden_labels).sort_by {|x| x.to_s}.map {
-            |label| [Colormap.sym_is_defined("label_#{label}_color".to_sym) || :label_color, "#{label} "]
-      } +
-      [
-      [subj_color, t.subj + (t.subj.empty? ? "" : " ")],
-      [:snippet_color, t.snippet],
-    ]
-  end
-
-  def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
-
-private
-
-  def default_size_widget_for t
-    case t.size
-    when 1
-      ""
-    else
-      "(#{t.size})"
-    end
-  end
-
-  def default_date_widget_for t
-    t.date.getlocal.to_nice_s
-  end
-
-  def from_width
-    [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
-  end
-
-  def initialize_threads
-    @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
-    @ts_mutex = Mutex.new
-    @hidden_threads = {}
-  end
-end
-
-end
diff --git a/lib/sup/modes/thread-view-mode.rb b/lib/sup/modes/thread-view-mode.rb
@@ -1,897 +0,0 @@
-module Redwood
-
-class ThreadViewMode < LineCursorMode
-  ## this holds all info we need to lay out a message
-  class MessageLayout
-    attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new, :toggled_state
-  end
-
-  class ChunkLayout
-    attr_accessor :state
-  end
-
-  DATE_FORMAT = "%B %e %Y %l:%M%p"
-  INDENT_SPACES = 2 # how many spaces to indent child messages
-
-  HookManager.register "detailed-headers", <<EOS
-Add or remove headers from the detailed header display of a message.
-Variables:
-  message: The message whose headers are to be formatted.
-  headers: A hash of header (name, value) pairs, initialized to the default
-           headers.
-Return value:
-  None. The variable 'headers' should be modified in place.
-EOS
-
-  HookManager.register "bounce-command", <<EOS
-Determines the command used to bounce a message.
-Variables:
-      from: The From header of the message being bounced
-            (eg: likely _not_ your address).
-        to: The addresses you asked the message to be bounced to as an array.
-Return value:
-  A string representing the command to pipe the mail into.  This
-  should include the entire command except for the destination addresses,
-  which will be appended by sup.
-EOS
-
-  HookManager.register "publish", <<EOS
-Executed when a message or a chunk is requested to be published.
-Variables:
-     chunk: Redwood::Message or Redwood::Chunk::* to be published.
-Return value:
-  None.
-EOS
-
-  register_keymap do |k|
-    k.add :toggle_detailed_header, "Toggle detailed header", 'h'
-    k.add :show_header, "Show full message header", 'H'
-    k.add :show_message, "Show full message (raw form)", 'V'
-    k.add :activate_chunk, "Expand/collapse or activate item", :enter
-    k.add :expand_all_messages, "Expand/collapse all messages", 'E'
-    k.add :edit_draft, "Edit draft", 'e'
-    k.add :send_draft, "Send draft", 'y'
-    k.add :edit_labels, "Edit or add labels for a thread", 'l'
-    k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
-    k.add :jump_to_next_open, "Jump to next open message", 'n'
-    k.add :jump_to_next_and_open, "Jump to next message and open", "\C-n"
-    k.add :jump_to_prev_open, "Jump to previous open message", 'p'
-    k.add :jump_to_prev_and_open, "Jump to previous message and open", "\C-p"
-    k.add :align_current_message, "Align current message in buffer", 'z'
-    k.add :toggle_starred, "Star or unstar message", '*'
-    k.add :toggle_new, "Toggle unread/read status of message", 'N'
-#    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
-    k.add :reply, "Reply to a message", 'r'
-    k.add :reply_all, "Reply to all participants of this message", 'G'
-    k.add :forward, "Forward a message or attachment", 'f'
-    k.add :bounce, "Bounce message to other recipient(s)", '!'
-    k.add :alias, "Edit alias/nickname for a person", 'i'
-    k.add :edit_as_new, "Edit message as new", 'D'
-    k.add :save_to_disk, "Save message/attachment to disk", 's'
-    k.add :save_all_to_disk, "Save all attachments to disk", 'A'
-    k.add :publish, "Publish message/attachment using publish-hook", 'P'
-    k.add :search, "Search for messages from particular people", 'S'
-    k.add :compose, "Compose message to person", 'm'
-    k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
-    k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
-    k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
-
-    k.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
-    k.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
-    k.add :toggle_wrap, "Toggle wrapping of text", 'w'
-
-    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
-      kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
-      kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
-      kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
-      kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
-      kk.add :do_nothing_and_kill, "Just kill this buffer", '.'
-    end
-
-    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
-      kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
-      kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
-      kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
-      kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
-      kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n', ','
-    end
-
-    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
-      kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
-      kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
-      kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
-      kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
-      kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n', ']'
-    end
-  end
-
-  ## there are a couple important instance variables we hold to format
-  ## the thread and to provide line-based functionality. @layout is a
-  ## map from Messages to MessageLayouts, and @chunk_layout from
-  ## Chunks to ChunkLayouts.  @message_lines is a map from row #s to
-  ## Message objects.  @chunk_lines is a map from row #s to Chunk
-  ## objects. @person_lines is a map from row #s to Person objects.
-
-  def initialize thread, hidden_labels=[], index_mode=nil
-    super :slip_rows => $config[:slip_rows]
-    @thread = thread
-    @hidden_labels = hidden_labels
-
-    ## used for dispatch-and-next
-    @index_mode = index_mode
-    @dying = false
-
-    @layout = SavingHash.new { MessageLayout.new }
-    @chunk_layout = SavingHash.new { ChunkLayout.new }
-    earliest, latest = nil, nil
-    latest_date = nil
-    altcolor = false
-
-    @thread.each do |m, d, p|
-      next unless m
-      earliest ||= m
-      @layout[m].state = initial_state_for m
-      @layout[m].toggled_state = false
-      @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
-      @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
-      @layout[m].orig_new = m.has_label? :read
-      altcolor = !altcolor
-      if latest_date.nil? || m.date > latest_date
-        latest_date = m.date
-        latest = m
-      end
-    end
-
-    @wrap = true
-
-    @layout[latest].state = :open if @layout[latest].state == :closed
-    @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
-  end
-
-  def toggle_wrap
-    @wrap = !@wrap
-    regen_text
-    buffer.mark_dirty if buffer
-  end
-
-  def draw_line ln, opts={}
-    if ln == curpos
-      super ln, :highlight => true
-    else
-      super
-    end
-  end
-  def lines; @text.length; end
-  def [] i; @text[i]; end
-
-  ## a little hacky---since regen_text can depend on buffer features like the
-  ## content_width, we don't call it in the constructor, and instead call it
-  ## here, which is set before we're responsible for drawing ourself.
-  def buffer= b
-    super
-    regen_text
-  end
-
-  def show_header
-    m = @message_lines[curpos] or return
-    BufferManager.spawn_unless_exists("Full header for #{m.id}") do
-      TextMode.new m.raw_header.ascii
-    end
-  end
-
-  def show_message
-    m = @message_lines[curpos] or return
-    BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
-      TextMode.new m.raw_message.ascii
-    end
-  end
-
-  def toggle_detailed_header
-    m = @message_lines[curpos] or return
-    @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
-    update
-  end
-
-  def reply type_arg=nil
-    m = @message_lines[curpos] or return
-    mode = ReplyMode.new m, type_arg
-    BufferManager.spawn "Reply to #{m.subj}", mode
-  end
-
-  def reply_all; reply :all; end
-
-  def subscribe_to_list
-    m = @message_lines[curpos] or return
-    if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
-      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "subscribe")
-    else
-      BufferManager.flash "Can't find List-Subscribe header for this message."
-    end
-  end
-
-  def unsubscribe_from_list
-    m = @message_lines[curpos] or return
-    if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
-      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "unsubscribe")
-    else
-      BufferManager.flash "Can't find List-Unsubscribe header for this message."
-    end
-  end
-
-  def forward
-    if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
-      ForwardMode.spawn_nicely :attachments => [chunk]
-    elsif(m = @message_lines[curpos])
-      ForwardMode.spawn_nicely :message => m
-    end
-  end
-
-  def bounce
-    m = @message_lines[curpos] or return
-    to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
-
-    defcmd = AccountManager.default_account.bounce_sendmail
-
-    cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
-          when nil, /^$/ then defcmd
-          else hookcmd
-          end + ' ' + to.map { |t| t.email }.join(' ')
-
-    bt = to.size > 1 ? "#{to.size} recipients" : to.to_s
-
-    if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
-      debug "bounce command: #{cmd}"
-      begin
-        IO.popen(cmd, 'w') do |sm|
-          sm.puts m.raw_message
-        end
-        raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
-      rescue SystemCallError, SendmailCommandFailed => e
-        warn "problem sending mail: #{e.message}"
-        BufferManager.flash "Problem sending mail: #{e.message}"
-      end
-    end
-  end
-
-  include CanAliasContacts
-  def alias
-    p = @person_lines[curpos] or return
-    alias_contact p
-    update
-  end
-
-  def search
-    p = @person_lines[curpos] or return
-    mode = PersonSearchResultsMode.new [p]
-    BufferManager.spawn "Search for #{p.name}", mode
-    mode.load_threads :num => mode.buffer.content_height
-  end
-
-  def compose
-    p = @person_lines[curpos]
-    if p
-      ComposeMode.spawn_nicely :to_default => p
-    else
-      ComposeMode.spawn_nicely
-    end
-  end
-
-  def edit_labels
-    old_labels = @thread.labels
-    reserved_labels = old_labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
-    new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels.sort_by {|x| x.to_s}
-
-    return unless new_labels
-    @thread.labels = Set.new(reserved_labels) + new_labels
-    new_labels.each { |l| LabelManager << l }
-    update
-    UpdateManager.relay self, :labeled, @thread.first
-    Index.save_thread @thread
-    UndoManager.register "labeling thread" do
-      @thread.labels = old_labels
-      Index.save_thread @thread
-      UpdateManager.relay self, :labeled, @thread.first
-    end
-  end
-
-  def toggle_starred
-    m = @message_lines[curpos] or return
-    toggle_label m, :starred
-  end
-
-  def toggle_new
-    m = @message_lines[curpos] or return
-    toggle_label m, :unread
-  end
-
-  def toggle_label m, label
-    if m.has_label? label
-      m.remove_label label
-    else
-      m.add_label label
-    end
-    ## TODO: don't recalculate EVERYTHING just to add a stupid little
-    ## star to the display
-    update
-    UpdateManager.relay self, :single_message_labeled, m
-    Index.save_thread @thread
-  end
-
-  ## called when someone presses enter when the cursor is highlighting
-  ## a chunk. for expandable chunks (including messages) we toggle
-  ## open/closed state; for viewable chunks (like attachments) we
-  ## view.
-  def activate_chunk
-    chunk = @chunk_lines[curpos] or return
-    if chunk.is_a? Chunk::Text
-      ## if the cursor is over a text region, expand/collapse the
-      ## entire message
-      chunk = @message_lines[curpos]
-    end
-    layout = if chunk.is_a?(Message)
-      @layout[chunk]
-    elsif chunk.expandable?
-      @chunk_layout[chunk]
-    end
-    if layout
-      layout.state = (layout.state != :closed ? :closed : :open)
-      #cursor_down if layout.state == :closed # too annoying
-      update
-    elsif chunk.viewable?
-      view chunk
-    end
-    if chunk.is_a?(Message) && $config[:jump_to_open_message]
-      jump_to_message chunk
-      jump_to_next_open if layout.state == :closed
-    end
-  end
-
-  def edit_as_new
-    m = @message_lines[curpos] or return
-    mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos)
-    BufferManager.spawn "edit as new", mode
-    mode.edit_message
-  end
-
-  def save_to_disk
-    chunk = @chunk_lines[curpos] or return
-    case chunk
-    when Chunk::Attachment
-      default_dir = $config[:default_attachment_save_dir]
-      default_dir = ENV["HOME"] if default_dir.nil? || default_dir.empty?
-      default_fn = File.expand_path File.join(default_dir, chunk.filename)
-      fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_fn
-      save_to_file(fn) { |f| f.print chunk.raw_content } if fn
-    else
-      m = @message_lines[curpos]
-      fn = BufferManager.ask_for_filename :filename, "Save message to file: "
-      return unless fn
-      save_to_file(fn) do |f|
-        m.each_raw_message_line { |l| f.print l }
-      end
-    end
-  end
-
-  def save_all_to_disk
-    m = @message_lines[curpos] or return
-    default_dir = ($config[:default_attachment_save_dir] || ".")
-    folder = BufferManager.ask_for_filename :filename, "Save all attachments to folder: ", default_dir, true
-    return unless folder
-
-    num = 0
-    num_errors = 0
-    m.chunks.each do |chunk|
-      next unless chunk.is_a?(Chunk::Attachment)
-      fn = File.join(folder, chunk.filename)
-      num_errors += 1 unless save_to_file(fn, false) { |f| f.print chunk.raw_content }
-      num += 1
-    end
-
-    if num == 0
-      BufferManager.flash "Didn't find any attachments!"
-    else
-      if num_errors == 0
-        BufferManager.flash "Wrote #{num.pluralize 'attachment'} to #{folder}."
-      else
-        BufferManager.flash "Wrote #{(num - num_errors).pluralize 'attachment'} to #{folder}; couldn't write #{num_errors} of them (see log)."
-      end
-    end
-  end
-
-  def publish
-    chunk = @chunk_lines[curpos] or return
-    if HookManager.enabled? "publish"
-      HookManager.run "publish", :chunk => chunk
-    else
-      BufferManager.flash "Publishing hook not defined."
-    end
-  end
-
-  def edit_draft
-    m = @message_lines[curpos] or return
-    if m.is_draft?
-      mode = ResumeMode.new m
-      BufferManager.spawn "Edit message", mode
-      BufferManager.kill_buffer self.buffer
-      mode.edit_message
-    else
-      BufferManager.flash "Not a draft message!"
-    end
-  end
-
-  def send_draft
-    m = @message_lines[curpos] or return
-    if m.is_draft?
-      mode = ResumeMode.new m
-      BufferManager.spawn "Send message", mode
-      BufferManager.kill_buffer self.buffer
-      mode.send_message
-    else
-      BufferManager.flash "Not a draft message!"
-    end
-  end
-
-  def jump_to_first_open
-    m = @message_lines[0] or return
-    if @layout[m].state != :closed
-      jump_to_message m#, true
-    else
-      jump_to_next_open #true
-    end
-  end
-
-  def jump_to_next_and_open
-    return continue_search_in_buffer if in_search? # err.. don't know why im doing this
-
-    m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
-    return unless m
-
-    if @layout[m].toggled_state == true
-      @layout[m].state = :closed
-      @layout[m].toggled_state = false
-      update
-    end
-
-    nextm = @layout[m].next
-    if @layout[nextm].state == :closed
-      @layout[nextm].state = :open
-      @layout[nextm].toggled_state = true
-    end
-
-    jump_to_message nextm if nextm
-
-    update if @layout[nextm].toggled_state
-  end
-
-  def jump_to_next_open force_alignment=nil
-    return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
-    m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
-    return unless m
-    while nextm = @layout[m].next
-      break if @layout[nextm].state != :closed
-      m = nextm
-    end
-    jump_to_message nextm, force_alignment if nextm
-  end
-
-  def align_current_message
-    m = @message_lines[curpos] or return
-    jump_to_message m, true
-  end
-
-  def jump_to_prev_and_open force_alignment=nil
-    m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] }
-    return unless m
-
-    if @layout[m].toggled_state == true
-      @layout[m].state = :closed
-      @layout[m].toggled_state = false
-      update
-    end
-
-    nextm = @layout[m].prev
-    if @layout[nextm].state == :closed
-      @layout[nextm].state = :open
-      @layout[nextm].toggled_state = true
-    end
-
-    jump_to_message nextm if nextm
-    update if @layout[nextm].toggled_state
-  end
-
-  def jump_to_prev_open
-    m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
-    return unless m
-    ## jump to the top of the current message if we're in the body;
-    ## otherwise, to the previous message
-
-    top = @layout[m].top
-    if curpos == top
-      while(prevm = @layout[m].prev)
-        break if @layout[prevm].state != :closed
-        m = prevm
-      end
-      jump_to_message prevm if prevm
-    else
-      jump_to_message m
-    end
-  end
-
-  def jump_to_message m, force_alignment=false
-    l = @layout[m]
-
-    ## boundaries of the message
-    message_left = l.depth * INDENT_SPACES
-    message_right = message_left + l.width
-
-    ## calculate leftmost colum
-    left = if force_alignment # force mode: align exactly
-      message_left
-    else # regular: minimize cursor movement
-      ## leftmost and rightmost are boundaries of all valid left-column
-      ## alignments.
-      leftmost = [message_left, message_right - buffer.content_width + 1].min
-      rightmost = message_left
-      leftcol.clamp(leftmost, rightmost)
-    end
-
-    jump_to_line l.top    # move vertically
-    jump_to_col left      # move horizontally
-    set_cursor_pos l.top  # set cursor pos
-  end
-
-  def expand_all_messages
-    @global_message_state ||= :closed
-    @global_message_state = (@global_message_state == :closed ? :open : :closed)
-    @layout.each { |m, l| l.state = @global_message_state }
-    update
-  end
-
-  def collapse_non_new_messages
-    @layout.each { |m, l| l.state = l.orig_new ? :open : :closed }
-    update
-  end
-
-  def expand_all_quotes
-    if(m = @message_lines[curpos])
-      quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 }
-      numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) }
-      newstate = numopen > quotes.length / 2 ? :closed : :open
-      quotes.each { |c| @chunk_layout[c].state = newstate }
-      update
-    end
-  end
-
-  def cleanup
-    @layout = @chunk_layout = @text = nil # for good luck
-  end
-
-  def archive_and_kill; archive_and_then :kill end
-  def spam_and_kill; spam_and_then :kill end
-  def delete_and_kill; delete_and_then :kill end
-  def unread_and_kill; unread_and_then :kill end
-  def do_nothing_and_kill; do_nothing_and_then :kill end
-
-  def archive_and_next; archive_and_then :next end
-  def spam_and_next; spam_and_then :next end
-  def delete_and_next; delete_and_then :next end
-  def unread_and_next; unread_and_then :next end
-  def do_nothing_and_next; do_nothing_and_then :next end
-
-  def archive_and_prev; archive_and_then :prev end
-  def spam_and_prev; spam_and_then :prev end
-  def delete_and_prev; delete_and_then :prev end
-  def unread_and_prev; unread_and_then :prev end
-  def do_nothing_and_prev; do_nothing_and_then :prev end
-
-  def archive_and_then op
-    dispatch op do
-      @thread.remove_label :inbox
-      UpdateManager.relay self, :archived, @thread.first
-      Index.save_thread @thread
-      UndoManager.register "archiving 1 thread" do
-        @thread.apply_label :inbox
-        Index.save_thread @thread
-        UpdateManager.relay self, :unarchived, @thread.first
-      end
-    end
-  end
-
-  def spam_and_then op
-    dispatch op do
-      @thread.apply_label :spam
-      UpdateManager.relay self, :spammed, @thread.first
-      Index.save_thread @thread
-      UndoManager.register "marking 1 thread as spam" do
-        @thread.remove_label :spam
-        Index.save_thread @thread
-        UpdateManager.relay self, :unspammed, @thread.first
-      end
-    end
-  end
-
-  def delete_and_then op
-    dispatch op do
-      @thread.apply_label :deleted
-      UpdateManager.relay self, :deleted, @thread.first
-      Index.save_thread @thread
-      UndoManager.register "deleting 1 thread" do
-        @thread.remove_label :deleted
-        Index.save_thread @thread
-        UpdateManager.relay self, :undeleted, @thread.first
-      end
-    end
-  end
-
-  def unread_and_then op
-    dispatch op do
-      @thread.apply_label :unread
-      UpdateManager.relay self, :unread, @thread.first
-      Index.save_thread @thread
-    end
-  end
-
-  def do_nothing_and_then op
-    dispatch op
-  end
-
-  def dispatch op
-    return if @dying
-    @dying = true
-
-    l = lambda do
-      yield if block_given?
-      BufferManager.kill_buffer_safely buffer
-    end
-
-    case op
-    when :next
-      @index_mode.launch_next_thread_after @thread, &l
-    when :prev
-      @index_mode.launch_prev_thread_before @thread, &l
-    when :kill
-      l.call
-    else
-      raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
-    end
-  end
-  private :dispatch
-
-  def pipe_message
-    chunk = @chunk_lines[curpos]
-    chunk = nil unless chunk.is_a?(Chunk::Attachment)
-    message = @message_lines[curpos] unless chunk
-
-    return unless chunk || message
-
-    command = BufferManager.ask(:shell, "pipe command: ")
-    return if command.nil? || command.empty?
-
-    output = pipe_to_process(command) do |stream|
-      if chunk
-        stream.print chunk.raw_content
-      else
-        message.each_raw_message_line { |l| stream.print l }
-      end
-    end
-
-    if output
-      BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
-    else
-      BufferManager.flash "'#{command}' done!"
-    end
-  end
-
-private
-
-  def initial_state_for m
-    if m.has_label?(:starred) || m.has_label?(:unread)
-      :open
-    else
-      :closed
-    end
-  end
-
-  def update
-    regen_text
-    buffer.mark_dirty if buffer
-  end
-
-  ## here we generate the actual content lines. we accumulate
-  ## everything into @text, and we set @chunk_lines and
-  ## @message_lines, and we update @layout.
-  def regen_text
-    @text = []
-    @chunk_lines = []
-    @message_lines = []
-    @person_lines = []
-
-    prevm = nil
-    @thread.each do |m, depth, parent|
-      unless m.is_a? Message # handle nil and :fake_root
-        @text += chunk_to_lines m, nil, @text.length, depth, parent
-        next
-      end
-      l = @layout[m]
-
-      ## is this still necessary?
-      next unless @layout[m].state # skip discarded drafts
-
-      ## build the patina
-      text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color
-
-      l.top = @text.length
-      l.bot = @text.length + text.length # updated below
-      l.prev = prevm
-      l.next = nil
-      l.depth = depth
-      # l.state we preserve
-      l.width = 0 # updated below
-      @layout[l.prev].next = m if l.prev
-
-      (0 ... text.length).each do |i|
-        @chunk_lines[@text.length + i] = m
-        @message_lines[@text.length + i] = m
-        lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
-      end
-
-      @text += text
-      prevm = m
-      if l.state != :closed
-        m.chunks.each do |c|
-          cl = @chunk_layout[c]
-
-          ## set the default state for chunks
-          cl.state ||=
-            if c.expandable? && c.respond_to?(:initial_state)
-              c.initial_state
-            else
-              :closed
-            end
-
-          text = chunk_to_lines c, cl.state, @text.length, depth
-          (0 ... text.length).each do |i|
-            @chunk_lines[@text.length + i] = c
-            @message_lines[@text.length + i] = m
-            lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
-            l.width = lw if lw > l.width
-          end
-          @text += text
-        end
-        @layout[m].bot = @text.length
-      end
-    end
-  end
-
-  def message_patina_lines m, state, start, parent, prefix, color, star_color
-    prefix_widget = [color, prefix]
-
-    open_widget = [color, (state == :closed ? "+ " : "- ")]
-    new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
-    starred_widget = if m.has_label?(:starred)
-        [star_color, "*"]
-      else
-        [color, " "]
-      end
-    attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
-
-    case state
-    when :open
-      @person_lines[start] = m.from
-      [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
-        [color,
-            "#{m.from ? m.from.mediumname : '?'} to #{m.recipients.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
-
-    when :closed
-      @person_lines[start] = m.from
-      [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
-        [color,
-        "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})  #{m.snippet}"]]]
-
-    when :detailed
-      @person_lines[start] = m.from
-      from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
-          [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
-
-      addressee_lines = []
-      unless m.to.empty?
-        m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
-        addressee_lines += format_person_list "   To: ", m.to
-      end
-      unless m.cc.empty?
-        m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
-        addressee_lines += format_person_list "   Cc: ", m.cc
-      end
-      unless m.bcc.empty?
-        m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
-        addressee_lines += format_person_list "   Bcc: ", m.bcc
-      end
-
-      headers = OrderedHash.new
-      headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"
-      headers["Subject"] = m.subj
-
-      show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
-      unless show_labels.empty?
-        headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
-      end
-      if parent
-        headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}"
-      end
-
-      HookManager.run "detailed-headers", :message => m, :headers => headers
-
-      from_line + (addressee_lines + headers.map { |k, v| "   #{k}: #{v}" }).map { |l| [[color, prefix + "  " + l]] }
-    end
-  end
-
-  def format_person_list prefix, people
-    ptext = people.map { |p| format_person p }
-    pad = " " * prefix.display_length
-    [prefix + ptext.first + (ptext.length > 1 ? "," : "")] +
-      ptext[1 .. -1].map_with_index do |e, i|
-        pad + e + (i == ptext.length - 1 ? "" : ",")
-      end
-  end
-
-  def format_person p
-    p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
-  end
-
-  def maybe_wrap_text lines
-    if @wrap
-      config_width = $config[:wrap_width]
-      if config_width and config_width != 0
-        width = [config_width, buffer.content_width].min
-      else
-        width = buffer.content_width
-      end
-      lines = lines.map { |l| l.chomp.wrap width if l }.flatten
-    end
-    return lines
-  end
-
-  ## todo: check arguments on this overly complex function
-  def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil
-    prefix = " " * INDENT_SPACES * depth
-    case chunk
-    when :fake_root
-      [[[:missing_message_color, "#{prefix}<one or more unreceived messages>"]]]
-    when nil
-      [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
-    when Message
-      message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
-        (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
-
-    else
-      raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
-      if chunk.inlineable?
-        lines = maybe_wrap_text(chunk.lines)
-        lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
-      elsif chunk.expandable?
-        case state
-        when :closed
-          [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]]
-        when :open
-          lines = maybe_wrap_text(chunk.lines)
-          [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
-        end
-      else
-        [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]]
-      end
-    end
-  end
-
-  def view chunk
-    BufferManager.flash "viewing #{chunk.content_type} attachment..."
-    success = chunk.view!
-    BufferManager.erase_flash
-    BufferManager.completely_redraw_screen
-    unless success
-      BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s.ascii, chunk.filename)
-      BufferManager.flash "Couldn't execute view command, viewing as text."
-    end
-  end
-end
-
-end
diff --git a/lib/sup/modes/thread_index_mode.rb b/lib/sup/modes/thread_index_mode.rb
@@ -0,0 +1,950 @@
+require 'set'
+
+module Redwood
+
+## subclasses should implement:
+## - is_relevant?
+
+class ThreadIndexMode < LineCursorMode
+  DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
+  MIN_FROM_WIDTH = 15
+  LOAD_MORE_THREAD_NUM = 20
+
+  HookManager.register "index-mode-size-widget", <<EOS
+Generates the per-thread size widget for each thread.
+Variables:
+  thread: The message thread to be formatted.
+EOS
+
+  HookManager.register "index-mode-date-widget", <<EOS
+Generates the per-thread date widget for each thread.
+Variables:
+  thread: The message thread to be formatted.
+EOS
+
+  HookManager.register "mark-as-spam", <<EOS
+This hook is run when a thread is marked as spam
+Variables:
+  thread: The message thread being marked as spam.
+EOS
+
+  register_keymap do |k|
+    k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
+    k.add_multi "Load all threads (! to confirm) :", '!' do |kk|
+      kk.add :load_all_threads, "Load all threads (may list a _lot_ of threads)", '!'
+    end
+    k.add :cancel_search, "Cancel current search", :ctrl_g
+    k.add :reload, "Refresh view", '@'
+    k.add :toggle_archived, "Toggle archived status", 'a'
+    k.add :toggle_starred, "Star or unstar all messages in thread", '*'
+    k.add :toggle_new, "Toggle new/read status of all messages in thread", 'N'
+    k.add :edit_labels, "Edit or add labels for a thread", 'l'
+    k.add :edit_message, "Edit message (drafts only)", 'e'
+    k.add :toggle_spam, "Mark/unmark thread as spam", 'S'
+    k.add :toggle_deleted, "Delete/undelete thread", 'd'
+    k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
+    k.add :flush_index, "Flush all changes now", '$'
+    k.add :jump_to_next_new, "Jump to next new thread", :tab
+    k.add :reply, "Reply to latest message in a thread", 'r'
+    k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
+    k.add :forward, "Forward latest message in a thread", 'f'
+    k.add :toggle_tagged, "Tag/untag selected thread", 't'
+    k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
+    k.add :tag_matching, "Tag matching threads", 'g'
+    k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
+    k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
+    k.add :undo, "Undo the previous action", 'u'
+  end
+
+  def initialize hidden_labels=[], load_thread_opts={}
+    super()
+    @mutex = Mutex.new # covers the following variables:
+    @threads = []
+    @hidden_threads = {}
+    @size_widget_width = nil
+    @size_widgets = []
+    @date_widget_width = nil
+    @date_widgets = []
+    @tags = Tagger.new self
+
+    ## these guys, and @text and @lines, are not covered
+    @load_thread = nil
+    @load_thread_opts = load_thread_opts
+    @hidden_labels = hidden_labels + LabelManager::HIDDEN_RESERVED_LABELS
+    @date_width = DATE_WIDTH
+
+    @interrupt_search = false
+
+    initialize_threads # defines @ts and @ts_mutex
+    update # defines @text and @lines
+
+    UpdateManager.register self
+
+    @save_thread_mutex = Mutex.new
+
+    @last_load_more_size = nil
+    to_load_more do |size|
+      next if @last_load_more_size == 0
+      load_threads :num => size,
+                   :when_done => lambda { |num| @last_load_more_size = num }
+    end
+  end
+
+  def unsaved?; dirty? end
+  def lines; @text.length; end
+  def [] i; @text[i]; end
+  def contains_thread? t; @threads.include?(t) end
+
+  def reload
+    drop_all_threads
+    UndoManager.clear
+    BufferManager.draw_screen
+    load_threads :num => buffer.content_height
+  end
+
+  ## open up a thread view window
+  def select t=nil, when_done=nil
+    t ||= cursor_thread or return
+
+    Redwood::reporting_thread("load messages for thread-view-mode") do
+      num = t.size
+      message = "Loading #{num.pluralize 'message body'}..."
+      BufferManager.say(message) do |sid|
+        t.each_with_index do |(m, *_), i|
+          next unless m
+          BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1
+          m.load_from_source!
+        end
+      end
+      mode = ThreadViewMode.new t, @hidden_labels, self
+      BufferManager.spawn t.subj, mode
+      BufferManager.draw_screen
+      mode.jump_to_first_open if $config[:jump_to_open_message]
+      BufferManager.draw_screen # lame TODO: make this unnecessary
+      ## the first draw_screen is needed before topline and botline
+      ## are set, and the second to show the cursor having moved
+
+      t.remove_label :unread
+      Index.save_thread t
+
+      update_text_for_line curpos
+      UpdateManager.relay self, :read, t.first
+      when_done.call if when_done
+    end
+  end
+
+  def multi_select threads
+    threads.each { |t| select t }
+  end
+
+  ## these two methods are called by thread-view-modes when the user
+  ## wants to view the previous/next thread without going back to
+  ## index-mode. we update the cursor as a convenience.
+  def launch_next_thread_after thread, &b
+    launch_another_thread thread, 1, &b
+  end
+
+  def launch_prev_thread_before thread, &b
+    launch_another_thread thread, -1, &b
+  end
+
+  def launch_another_thread thread, direction, &b
+    l = @lines[thread] or return
+    target_l = l + direction
+    t = @mutex.synchronize do
+      if target_l >= 0 && target_l < @threads.length
+        @threads[target_l]
+      end
+    end
+
+    if t # there's a next thread
+      set_cursor_pos target_l # move out of mutex?
+      select t, b
+    elsif b # no next thread. call the block anyways
+      b.call
+    end
+  end
+
+  def handle_single_message_labeled_update sender, m
+    ## no need to do anything different here; we don't differentiate
+    ## messages from their containing threads
+    handle_labeled_update sender, m
+  end
+
+  def handle_labeled_update sender, m
+    if(t = thread_containing(m))
+      l = @lines[t] or return
+      update_text_for_line l
+    elsif is_relevant?(m)
+      add_or_unhide m
+    end
+  end
+
+  def handle_simple_update sender, m
+    t = thread_containing(m) or return
+    l = @lines[t] or return
+    update_text_for_line l
+  end
+
+  %w(read unread archived starred unstarred).each do |state|
+    define_method "handle_#{state}_update" do |*a|
+      handle_simple_update(*a)
+    end
+  end
+
+  ## overwrite me!
+  def is_relevant? m; false; end
+
+  def handle_added_update sender, m
+    add_or_unhide m
+    BufferManager.draw_screen
+  end
+
+  def handle_single_message_deleted_update sender, m
+    @ts_mutex.synchronize do
+      return unless @ts.contains? m
+      @ts.remove_id m.id
+    end
+    update
+  end
+
+  def handle_deleted_update sender, m
+    t = @ts_mutex.synchronize { @ts.thread_for m }
+    return unless t
+    hide_thread t
+    update
+  end
+
+  def handle_spammed_update sender, m
+    t = @ts_mutex.synchronize { @ts.thread_for m }
+    return unless t
+    hide_thread t
+    update
+  end
+
+  def handle_undeleted_update sender, m
+    add_or_unhide m
+  end
+
+  def undo
+    UndoManager.undo
+  end
+
+  def update
+    old_cursor_thread = cursor_thread
+    @mutex.synchronize do
+      ## let's see you do THIS in python
+      @threads = @ts.threads.select { |t| !@hidden_threads.member?(t) }.select(&:has_message?).sort_by(&:sort_key)
+      @size_widgets = @threads.map { |t| size_widget_for_thread t }
+      @size_widget_width = @size_widgets.max_of { |w| w.display_length }
+      @date_widgets = @threads.map { |t| date_widget_for_thread t }
+      @date_widget_width = @date_widgets.max_of { |w| w.display_length }
+    end
+    set_cursor_pos @threads.index(old_cursor_thread)||curpos
+
+    regen_text
+  end
+
+  def edit_message
+    return unless(t = cursor_thread)
+    message, *_ = t.find { |m, *o| m.has_label? :draft }
+    if message
+      mode = ResumeMode.new message
+      BufferManager.spawn "Edit message", mode
+    else
+      BufferManager.flash "Not a draft message!"
+    end
+  end
+
+  ## returns an undo lambda
+  def actually_toggle_starred t
+    if t.has_label? :starred # if ANY message has a star
+      t.remove_label :starred # remove from all
+      UpdateManager.relay self, :unstarred, t.first
+      lambda do
+        t.first.add_label :starred
+        UpdateManager.relay self, :starred, t.first
+        regen_text
+      end
+    else
+      t.first.add_label :starred # add only to first
+      UpdateManager.relay self, :starred, t.first
+      lambda do
+        t.remove_label :starred
+        UpdateManager.relay self, :unstarred, t.first
+        regen_text
+      end
+    end
+  end
+
+  def toggle_starred
+    t = cursor_thread or return
+    undo = actually_toggle_starred t
+    UndoManager.register "toggling thread starred status", undo, lambda { Index.save_thread t }
+    update_text_for_line curpos
+    cursor_down
+    Index.save_thread t
+  end
+
+  def multi_toggle_starred threads
+    UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
+      threads.map { |t| actually_toggle_starred t },
+      lambda { threads.each { |t| Index.save_thread t } }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  ## returns an undo lambda
+  def actually_toggle_archived t
+    thread = t
+    pos = curpos
+    if t.has_label? :inbox
+      t.remove_label :inbox
+      UpdateManager.relay self, :archived, t.first
+      lambda do
+        thread.apply_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self,:unarchived, thread.first
+      end
+    else
+      t.apply_label :inbox
+      UpdateManager.relay self, :unarchived, t.first
+      lambda do
+        thread.remove_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self, :unarchived, thread.first
+      end
+    end
+  end
+
+  ## returns an undo lambda
+  def actually_toggle_spammed t
+    thread = t
+    if t.has_label? :spam
+      t.remove_label :spam
+      add_or_unhide t.first
+      UpdateManager.relay self, :unspammed, t.first
+      lambda do
+        thread.apply_label :spam
+        self.hide_thread thread
+        UpdateManager.relay self,:spammed, thread.first
+      end
+    else
+      t.apply_label :spam
+      hide_thread t
+      UpdateManager.relay self, :spammed, t.first
+      lambda do
+        thread.remove_label :spam
+        add_or_unhide thread.first
+        UpdateManager.relay self,:unspammed, thread.first
+      end
+    end
+  end
+
+  ## returns an undo lambda
+  def actually_toggle_deleted t
+    if t.has_label? :deleted
+      t.remove_label :deleted
+      add_or_unhide t.first
+      UpdateManager.relay self, :undeleted, t.first
+      lambda do
+        t.apply_label :deleted
+        hide_thread t
+        UpdateManager.relay self, :deleted, t.first
+      end
+    else
+      t.apply_label :deleted
+      hide_thread t
+      UpdateManager.relay self, :deleted, t.first
+      lambda do
+        t.remove_label :deleted
+        add_or_unhide t.first
+        UpdateManager.relay self, :undeleted, t.first
+      end
+    end
+  end
+
+  def toggle_archived
+    t = cursor_thread or return
+    undo = actually_toggle_archived t
+    UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos },
+                         lambda { Index.save_thread t }
+    update_text_for_line curpos
+    Index.save_thread t
+  end
+
+  def multi_toggle_archived threads
+    undos = threads.map { |t| actually_toggle_archived t }
+    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text },
+                         lambda { threads.each { |t| Index.save_thread t } }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def toggle_new
+    t = cursor_thread or return
+    t.toggle_label :unread
+    update_text_for_line curpos
+    cursor_down
+    Index.save_thread t
+  end
+
+  def multi_toggle_new threads
+    threads.each { |t| t.toggle_label :unread }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def multi_toggle_tagged threads
+    @mutex.synchronize { @tags.drop_all_tags }
+    regen_text
+  end
+
+  def join_threads
+    ## this command has no non-tagged form. as a convenience, allow this
+    ## command to be applied to tagged threads without hitting ';'.
+    @tags.apply_to_tagged :join_threads
+  end
+
+  def multi_join_threads threads
+    @ts.join_threads threads or return
+    threads.each { |t| Index.save_thread t }
+    @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
+    update
+  end
+
+  def jump_to_next_new
+    n = @mutex.synchronize do
+      ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
+        (0 ... curpos).find { |i| @threads[i].has_label? :unread }
+    end
+    if n
+      ## jump there if necessary
+      jump_to_line n unless n >= topline && n < botline
+      set_cursor_pos n
+    else
+      BufferManager.flash "No new messages."
+    end
+  end
+
+  def toggle_spam
+    t = cursor_thread or return
+    multi_toggle_spam [t]
+  end
+
+  ## both spam and deleted have the curious characteristic that you
+  ## always want to hide the thread after either applying or removing
+  ## that label. in all thread-index-views except for
+  ## label-search-results-mode, when you mark a message as spam or
+  ## deleted, you want it to disappear immediately; in LSRM, you only
+  ## see deleted or spam emails, and when you undelete or unspam them
+  ## you also want them to disappear immediately.
+  def multi_toggle_spam threads
+    undos = threads.map { |t| actually_toggle_spammed t }
+    threads.each { |t| HookManager.run("mark-as-spam", :thread => t) }
+    UndoManager.register "marking/unmarking  #{threads.size.pluralize 'thread'} as spam",
+                         undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def toggle_deleted
+    t = cursor_thread or return
+    multi_toggle_deleted [t]
+  end
+
+  ## see comment for multi_toggle_spam
+  def multi_toggle_deleted threads
+    undos = threads.map { |t| actually_toggle_deleted t }
+    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
+                         undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def kill
+    t = cursor_thread or return
+    multi_kill [t]
+  end
+
+  def flush_index
+    @flush_id = BufferManager.say "Flushing index..."
+    Index.save_index
+    BufferManager.clear @flush_id
+  end
+
+  ## m-m-m-m-MULTI-KILL
+  def multi_kill threads
+    UndoManager.register "killing/unkilling #{threads.size.pluralize 'threads'}" do
+      threads.each do |t|
+        if t.toggle_label :killed
+          add_or_unhide t.first
+        else
+          hide_thread t
+        end
+      end.each do |t|
+        UpdateManager.relay self, :labeled, t.first
+        Index.save_thread t
+      end
+      regen_text
+    end
+
+    threads.each do |t|
+      if t.toggle_label :killed
+        hide_thread t
+      else
+        add_or_unhide t.first
+      end
+    end.each do |t|
+      # send 'labeled'... this might be more specific
+      UpdateManager.relay self, :labeled, t.first
+      Index.save_thread t
+    end
+
+    killed, unkilled = threads.partition { |t| t.has_label? :killed }.map(&:size)
+    BufferManager.flash "#{killed.pluralize 'thread'} killed, #{unkilled} unkilled"
+    regen_text
+  end
+
+  def cleanup
+    UpdateManager.unregister self
+
+    if @load_thread
+      @load_thread.kill
+      BufferManager.clear @mbid if @mbid
+      sleep 0.1 # TODO: necessary?
+      BufferManager.erase_flash
+    end
+    dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
+    fail "dirty threads remain" unless dirty_threads.empty?
+    super
+  end
+
+  def toggle_tagged
+    t = cursor_thread or return
+    @mutex.synchronize { @tags.toggle_tag_for t }
+    update_text_for_line curpos
+    cursor_down
+  end
+
+  def toggle_tagged_all
+    @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } }
+    regen_text
+  end
+
+  def tag_matching
+    query = BufferManager.ask :search, "tag threads matching (regex): "
+    return if query.nil? || query.empty?
+    query = begin
+      /#{query}/i
+    rescue RegexpError => e
+      BufferManager.flash "error interpreting '#{query}': #{e.message}"
+      return
+    end
+    @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
+    regen_text
+  end
+
+  def apply_to_tagged; @tags.apply_to_tagged; end
+
+  def edit_labels
+    thread = cursor_thread or return
+    speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
+
+    old_labels = thread.labels
+    pos = curpos
+
+    keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
+
+    user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl.sort_by {|x| x.to_s}, @hidden_labels
+    return unless user_labels
+
+    thread.labels = Set.new(keepl) + user_labels
+    user_labels.each { |l| LabelManager << l }
+    update_text_for_line curpos
+
+    UndoManager.register "labeling thread" do
+      thread.labels = old_labels
+      update_text_for_line pos
+      UpdateManager.relay self, :labeled, thread.first
+      Index.save_thread thread
+    end
+
+    UpdateManager.relay self, :labeled, thread.first
+    Index.save_thread thread
+  end
+
+  def multi_edit_labels threads
+    user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
+    return unless user_labels
+
+    user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
+    hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
+    unless hl.empty?
+      BufferManager.flash "'#{hl}' is a reserved label!"
+      return
+    end
+
+    old_labels = threads.map { |t| t.labels.dup }
+
+    threads.each do |t|
+      user_labels.each do |(l, to_remove)|
+        if to_remove
+          t.remove_label l
+        else
+          t.apply_label l
+          LabelManager << l
+        end
+      end
+      UpdateManager.relay self, :labeled, t.first
+    end
+
+    regen_text
+
+    UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
+      threads.zip(old_labels).map do |t, old_labels|
+        t.labels = old_labels
+        UpdateManager.relay self, :labeled, t.first
+        Index.save_thread t
+      end
+      regen_text
+    end
+
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def reply type_arg=nil
+    t = cursor_thread or return
+    m = t.latest_message
+    return if m.nil? # probably won't happen
+    m.load_from_source!
+    mode = ReplyMode.new m, type_arg
+    BufferManager.spawn "Reply to #{m.subj}", mode
+  end
+
+  def reply_all; reply :all; end
+
+  def forward
+    t = cursor_thread or return
+    m = t.latest_message
+    return if m.nil? # probably won't happen
+    m.load_from_source!
+    ForwardMode.spawn_nicely :message => m
+  end
+
+  def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
+    return if @load_thread # todo: wrap in mutex
+    @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do
+      num = load_n_threads n, opts
+      opts[:when_done].call(num) if opts[:when_done]
+      @load_thread = nil
+    end
+  end
+
+  ## TODO: figure out @ts_mutex in this method
+  def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
+    @interrupt_search = false
+    @mbid = BufferManager.say "Searching for threads..."
+
+    ts_to_load = n
+    ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads
+
+    orig_size = @ts.size
+    last_update = Time.now
+    @ts.load_n_threads(ts_to_load, opts) do |i|
+      if (Time.now - last_update) >= 0.25
+        BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid
+        update
+        BufferManager.draw_screen
+        last_update = Time.now
+      end
+      ::Thread.pass
+      break if @interrupt_search
+    end
+    @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } }
+
+    update
+    BufferManager.clear @mbid
+    @mbid = nil
+    BufferManager.draw_screen
+    @ts.size - orig_size
+  end
+  ignore_concurrent_calls :load_n_threads
+
+  def status
+    if (l = lines) == 0
+      "line 0 of 0"
+    else
+      "line #{curpos + 1} of #{l}"
+    end
+  end
+
+  def cancel_search
+    @interrupt_search = true
+  end
+
+  def load_all_threads
+    load_threads :num => -1
+  end
+
+  def load_threads opts={}
+    if opts[:num].nil?
+      n = ThreadIndexMode::LOAD_MORE_THREAD_NUM
+    else
+      n = opts[:num]
+    end
+
+    myopts = @load_thread_opts.merge({ :when_done => (lambda do |num|
+      opts[:when_done].call(num) if opts[:when_done]
+
+      if num > 0
+        BufferManager.flash "Found #{num.pluralize 'thread'}."
+      else
+        BufferManager.flash "No matches."
+      end
+    end)})
+
+    if opts[:background] || opts[:background].nil?
+      load_n_threads_background n, myopts
+    else
+      load_n_threads n, myopts
+    end
+  end
+  ignore_concurrent_calls :load_threads
+
+  def resize rows, cols
+    regen_text
+    super
+  end
+
+protected
+
+  def add_or_unhide m
+    @ts_mutex.synchronize do
+      if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
+        @ts.load_thread_for_message m, @load_thread_opts
+      end
+
+      @hidden_threads.delete @ts.thread_for(m)
+    end
+
+    update
+  end
+
+  def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
+
+  ## used to tag threads by query. this can be made a lot more sophisticated,
+  ## but for right now we'll do the obvious this.
+  def thread_matches? t, query
+    t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
+  end
+
+  def size_widget_for_thread t
+    HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
+  end
+
+  def date_widget_for_thread t
+    HookManager.run("index-mode-date-widget", :thread => t) || default_date_widget_for(t)
+  end
+
+  def cursor_thread; @mutex.synchronize { @threads[curpos] }; end
+
+  def drop_all_threads
+    @tags.drop_all_tags
+    initialize_threads
+    update
+  end
+
+  def hide_thread t
+    @mutex.synchronize do
+      i = @threads.index(t) or return
+      raise "already hidden" if @hidden_threads[t]
+      @hidden_threads[t] = true
+      @threads.delete_at i
+      @size_widgets.delete_at i
+      @date_widgets.delete_at i
+      @tags.drop_tag_for t
+    end
+  end
+
+  def update_text_for_line l
+    return unless l # not sure why this happens, but it does, occasionally
+
+    need_update = false
+
+    @mutex.synchronize do
+      @size_widgets[l] = size_widget_for_thread @threads[l]
+      @date_widgets[l] = date_widget_for_thread @threads[l]
+
+      ## if a widget size has increased, we need to redraw everyone
+      need_update =
+        (@size_widgets[l].size > @size_widget_width) or
+        (@date_widgets[l].size > @date_widget_width)
+    end
+
+    if need_update
+      update
+    else
+      @text[l] = text_for_thread_at l
+      buffer.mark_dirty if buffer
+    end
+  end
+
+  def regen_text
+    threads = @mutex.synchronize { @threads }
+    @text = threads.map_with_index { |t, i| text_for_thread_at i }
+    @lines = threads.map_with_index { |t, i| [t, i] }.to_h
+    buffer.mark_dirty if buffer
+  end
+
+  def authors; map { |m, *o| m.from if m }.compact.uniq; end
+
+  ## preserve author order from the thread
+  def author_names_and_newness_for_thread t, limit=nil
+    new = {}
+    seen = {}
+    authors = t.map do |m, *o|
+      next unless m && m.from
+      new[m.from] ||= m.has_label?(:unread)
+      next if seen[m.from]
+      seen[m.from] = true
+      m.from
+    end.compact
+
+    result = []
+    authors.each do |a|
+      break if limit && result.size >= limit
+      name = if AccountManager.is_account?(a)
+        "me"
+      elsif t.authors.size == 1
+        a.mediumname
+      else
+        a.shortname
+      end
+
+      result << [name, new[a]]
+    end
+
+    if result.size == 1 && (author_and_newness = result.assoc("me"))
+      unless (recipients = t.participants - t.authors).empty?
+        result = recipients.collect do |r|
+          break if limit && result.size >= limit
+          name = (recipients.size == 1) ? r.mediumname : r.shortname
+          ["(#{name})", author_and_newness[1]]
+        end
+      end
+    end
+
+    result
+  end
+
+  AUTHOR_LIMIT = 5
+  def text_for_thread_at line
+    t, size_widget, date_widget = @mutex.synchronize do
+      [@threads[line], @size_widgets[line], @date_widgets[line]]
+    end
+
+    starred = t.has_label? :starred
+
+    ## format the from column
+    cur_width = 0
+    ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT
+    from = []
+    ann.each_with_index do |(name, newness), i|
+      break if cur_width >= from_width
+      last = i == ann.length - 1
+
+      abbrev =
+        if cur_width + name.display_length > from_width
+          name.slice_by_display_length(from_width - cur_width - 1) + "."
+        elsif cur_width + name.display_length == from_width
+          name.slice_by_display_length(from_width - cur_width)
+        else
+          if last
+            name.slice_by_display_length(from_width - cur_width)
+          else
+            name.slice_by_display_length(from_width - cur_width - 1) + ","
+          end
+        end
+
+      cur_width += abbrev.display_length
+
+      if last && from_width > cur_width
+        abbrev += " " * (from_width - cur_width)
+      end
+
+      from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev]
+    end
+
+    is_me = AccountManager.method(:is_account?)
+    directly_participated = t.direct_participants.any?(&is_me)
+    participated = directly_participated || t.participants.any?(&is_me)
+
+    subj_color =
+      if t.has_label?(:draft)
+        :index_draft_color
+      elsif t.has_label?(:unread)
+        :index_new_color
+      elsif starred
+        :index_starred_color
+      elsif Colormap.sym_is_defined(:index_subject_color)
+        :index_subject_color
+      else
+        :index_old_color
+      end
+
+    size_padding = @size_widget_width - size_widget.display_length
+    size_widget_text = sprintf "%#{size_padding}s%s", "", size_widget
+
+    date_padding = @date_widget_width - date_widget.display_length
+    date_widget_text = sprintf "%#{date_padding}s%s", "", date_widget
+
+    [
+      [:tagged_color, @tags.tagged?(t) ? ">" : " "],
+      [:date_color, date_widget_text],
+      [:starred_color, (starred ? "*" : " ")],
+    ] +
+      from +
+      [
+      [:size_widget_color, size_widget_text],
+      [:to_me_color, t.labels.member?(:attachment) ? "@" : " "],
+      [:to_me_color, directly_participated ? ">" : (participated ? '+' : " ")],
+    ] +
+      (t.labels - @hidden_labels).sort_by {|x| x.to_s}.map {
+            |label| [Colormap.sym_is_defined("label_#{label}_color".to_sym) || :label_color, "#{label} "]
+      } +
+      [
+      [subj_color, t.subj + (t.subj.empty? ? "" : " ")],
+      [:snippet_color, t.snippet],
+    ]
+  end
+
+  def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
+
+private
+
+  def default_size_widget_for t
+    case t.size
+    when 1
+      ""
+    else
+      "(#{t.size})"
+    end
+  end
+
+  def default_date_widget_for t
+    t.date.getlocal.to_nice_s
+  end
+
+  def from_width
+    [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
+  end
+
+  def initialize_threads
+    @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
+    @ts_mutex = Mutex.new
+    @hidden_threads = {}
+  end
+end
+
+end
diff --git a/lib/sup/modes/thread_view_mode.rb b/lib/sup/modes/thread_view_mode.rb
@@ -0,0 +1,901 @@
+module Redwood
+
+class ThreadViewMode < LineCursorMode
+  ## this holds all info we need to lay out a message
+  class MessageLayout
+    attr_accessor :top, :bot, :prev, :next, :depth, :width, :state, :color, :star_color, :orig_new, :toggled_state
+  end
+
+  class ChunkLayout
+    attr_accessor :state
+  end
+
+  DATE_FORMAT = "%B %e %Y %l:%M%p"
+  INDENT_SPACES = 2 # how many spaces to indent child messages
+
+  HookManager.register "detailed-headers", <<EOS
+Add or remove headers from the detailed header display of a message.
+Variables:
+  message: The message whose headers are to be formatted.
+  headers: A hash of header (name, value) pairs, initialized to the default
+           headers.
+Return value:
+  None. The variable 'headers' should be modified in place.
+EOS
+
+  HookManager.register "bounce-command", <<EOS
+Determines the command used to bounce a message.
+Variables:
+      from: The From header of the message being bounced
+            (eg: likely _not_ your address).
+        to: The addresses you asked the message to be bounced to as an array.
+Return value:
+  A string representing the command to pipe the mail into.  This
+  should include the entire command except for the destination addresses,
+  which will be appended by sup.
+EOS
+
+  HookManager.register "publish", <<EOS
+Executed when a message or a chunk is requested to be published.
+Variables:
+     chunk: Redwood::Message or Redwood::Chunk::* to be published.
+Return value:
+  None.
+EOS
+
+  register_keymap do |k|
+    k.add :toggle_detailed_header, "Toggle detailed header", 'h'
+    k.add :show_header, "Show full message header", 'H'
+    k.add :show_message, "Show full message (raw form)", 'V'
+    k.add :activate_chunk, "Expand/collapse or activate item", :enter
+    k.add :expand_all_messages, "Expand/collapse all messages", 'E'
+    k.add :edit_draft, "Edit draft", 'e'
+    k.add :send_draft, "Send draft", 'y'
+    k.add :edit_labels, "Edit or add labels for a thread", 'l'
+    k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
+    k.add :jump_to_next_open, "Jump to next open message", 'n'
+    k.add :jump_to_next_and_open, "Jump to next message and open", "\C-n"
+    k.add :jump_to_prev_open, "Jump to previous open message", 'p'
+    k.add :jump_to_prev_and_open, "Jump to previous message and open", "\C-p"
+    k.add :align_current_message, "Align current message in buffer", 'z'
+    k.add :toggle_starred, "Star or unstar message", '*'
+    k.add :toggle_new, "Toggle unread/read status of message", 'N'
+#    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
+    k.add :reply, "Reply to a message", 'r'
+    k.add :reply_all, "Reply to all participants of this message", 'G'
+    k.add :forward, "Forward a message or attachment", 'f'
+    k.add :bounce, "Bounce message to other recipient(s)", '!'
+    k.add :alias, "Edit alias/nickname for a person", 'i'
+    k.add :edit_as_new, "Edit message as new", 'D'
+    k.add :save_to_disk, "Save message/attachment to disk", 's'
+    k.add :save_all_to_disk, "Save all attachments to disk", 'A'
+    k.add :publish, "Publish message/attachment using publish-hook", 'P'
+    k.add :search, "Search for messages from particular people", 'S'
+    k.add :compose, "Compose message to person", 'm'
+    k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
+    k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
+    k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
+
+    k.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
+    k.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
+    k.add :toggle_wrap, "Toggle wrapping of text", 'w'
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
+      kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
+      kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
+      kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
+      kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
+      kk.add :do_nothing_and_kill, "Just kill this buffer", '.'
+    end
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
+      kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
+      kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
+      kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
+      kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
+      kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n', ','
+    end
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
+      kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
+      kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
+      kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
+      kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
+      kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n', ']'
+    end
+  end
+
+  ## there are a couple important instance variables we hold to format
+  ## the thread and to provide line-based functionality. @layout is a
+  ## map from Messages to MessageLayouts, and @chunk_layout from
+  ## Chunks to ChunkLayouts.  @message_lines is a map from row #s to
+  ## Message objects.  @chunk_lines is a map from row #s to Chunk
+  ## objects. @person_lines is a map from row #s to Person objects.
+
+  def initialize thread, hidden_labels=[], index_mode=nil
+    super :slip_rows => $config[:slip_rows]
+    @thread = thread
+    @hidden_labels = hidden_labels
+
+    ## used for dispatch-and-next
+    @index_mode = index_mode
+    @dying = false
+
+    @layout = SavingHash.new { MessageLayout.new }
+    @chunk_layout = SavingHash.new { ChunkLayout.new }
+    earliest, latest = nil, nil
+    latest_date = nil
+    altcolor = false
+
+    @thread.each do |m, d, p|
+      next unless m
+      earliest ||= m
+      @layout[m].state = initial_state_for m
+      @layout[m].toggled_state = false
+      @layout[m].color = altcolor ? :alternate_patina_color : :message_patina_color
+      @layout[m].star_color = altcolor ? :alternate_starred_patina_color : :starred_patina_color
+      @layout[m].orig_new = m.has_label? :read
+      altcolor = !altcolor
+      if latest_date.nil? || m.date > latest_date
+        latest_date = m.date
+        latest = m
+      end
+    end
+
+    @wrap = true
+
+    @layout[latest].state = :open if @layout[latest].state == :closed
+    @layout[earliest].state = :detailed if earliest.has_label?(:unread) || @thread.size == 1
+  end
+
+  def toggle_wrap
+    @wrap = !@wrap
+    regen_text
+    buffer.mark_dirty if buffer
+  end
+
+  def draw_line ln, opts={}
+    if ln == curpos
+      super ln, :highlight => true
+    else
+      super
+    end
+  end
+  def lines; @text.length; end
+  def [] i; @text[i]; end
+
+  ## a little hacky---since regen_text can depend on buffer features like the
+  ## content_width, we don't call it in the constructor, and instead call it
+  ## here, which is set before we're responsible for drawing ourself.
+  def buffer= b
+    super
+    regen_text
+  end
+
+  def show_header
+    m = @message_lines[curpos] or return
+    BufferManager.spawn_unless_exists("Full header for #{m.id}") do
+      TextMode.new m.raw_header.ascii
+    end
+  end
+
+  def show_message
+    m = @message_lines[curpos] or return
+    BufferManager.spawn_unless_exists("Raw message for #{m.id}") do
+      TextMode.new m.raw_message.ascii
+    end
+  end
+
+  def toggle_detailed_header
+    m = @message_lines[curpos] or return
+    @layout[m].state = (@layout[m].state == :detailed ? :open : :detailed)
+    update
+  end
+
+  def reply type_arg=nil
+    m = @message_lines[curpos] or return
+    mode = ReplyMode.new m, type_arg
+    BufferManager.spawn "Reply to #{m.subj}", mode
+  end
+
+  def reply_all; reply :all; end
+
+  def subscribe_to_list
+    m = @message_lines[curpos] or return
+    if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
+      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "subscribe")
+    else
+      BufferManager.flash "Can't find List-Subscribe header for this message."
+    end
+  end
+
+  def unsubscribe_from_list
+    m = @message_lines[curpos] or return
+    if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
+      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => ($3 || "unsubscribe")
+    else
+      BufferManager.flash "Can't find List-Unsubscribe header for this message."
+    end
+  end
+
+  def forward
+    if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
+      ForwardMode.spawn_nicely :attachments => [chunk]
+    elsif(m = @message_lines[curpos])
+      ForwardMode.spawn_nicely :message => m
+    end
+  end
+
+  def bounce
+    m = @message_lines[curpos] or return
+    to = BufferManager.ask_for_contacts(:people, "Bounce To: ") or return
+
+    defcmd = AccountManager.default_account.bounce_sendmail
+
+    cmd = case (hookcmd = HookManager.run "bounce-command", :from => m.from, :to => to)
+          when nil, /^$/ then defcmd
+          else hookcmd
+          end + ' ' + to.map { |t| t.email }.join(' ')
+
+    bt = to.size > 1 ? "#{to.size} recipients" : to.to_s
+
+    if BufferManager.ask_yes_or_no "Really bounce to #{bt}?"
+      debug "bounce command: #{cmd}"
+      begin
+        IO.popen(cmd, 'w') do |sm|
+          sm.puts m.raw_message
+        end
+        raise SendmailCommandFailed, "Couldn't execute #{cmd}" unless $? == 0
+      rescue SystemCallError, SendmailCommandFailed => e
+        warn "problem sending mail: #{e.message}"
+        BufferManager.flash "Problem sending mail: #{e.message}"
+      end
+    end
+  end
+
+  include CanAliasContacts
+  def alias
+    p = @person_lines[curpos] or return
+    alias_contact p
+    update
+  end
+
+  def search
+    p = @person_lines[curpos] or return
+    mode = PersonSearchResultsMode.new [p]
+    BufferManager.spawn "Search for #{p.name}", mode
+    mode.load_threads :num => mode.buffer.content_height
+  end
+
+  def compose
+    p = @person_lines[curpos]
+    if p
+      ComposeMode.spawn_nicely :to_default => p
+    else
+      ComposeMode.spawn_nicely
+    end
+  end
+
+  def edit_labels
+    old_labels = @thread.labels
+    reserved_labels = old_labels.select { |l| LabelManager::RESERVED_LABELS.include? l }
+    new_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", @thread.labels.sort_by {|x| x.to_s}
+
+    return unless new_labels
+    @thread.labels = Set.new(reserved_labels) + new_labels
+    new_labels.each { |l| LabelManager << l }
+    update
+    UpdateManager.relay self, :labeled, @thread.first
+    Index.save_thread @thread
+    UndoManager.register "labeling thread" do
+      @thread.labels = old_labels
+      Index.save_thread @thread
+      UpdateManager.relay self, :labeled, @thread.first
+    end
+  end
+
+  def toggle_starred
+    m = @message_lines[curpos] or return
+    toggle_label m, :starred
+  end
+
+  def toggle_new
+    m = @message_lines[curpos] or return
+    toggle_label m, :unread
+  end
+
+  def toggle_label m, label
+    if m.has_label? label
+      m.remove_label label
+    else
+      m.add_label label
+    end
+    ## TODO: don't recalculate EVERYTHING just to add a stupid little
+    ## star to the display
+    update
+    UpdateManager.relay self, :single_message_labeled, m
+    Index.save_thread @thread
+  end
+
+  ## called when someone presses enter when the cursor is highlighting
+  ## a chunk. for expandable chunks (including messages) we toggle
+  ## open/closed state; for viewable chunks (like attachments) we
+  ## view.
+  def activate_chunk
+    chunk = @chunk_lines[curpos] or return
+    if chunk.is_a? Chunk::Text
+      ## if the cursor is over a text region, expand/collapse the
+      ## entire message
+      chunk = @message_lines[curpos]
+    end
+    layout = if chunk.is_a?(Message)
+      @layout[chunk]
+    elsif chunk.expandable?
+      @chunk_layout[chunk]
+    end
+    if layout
+      layout.state = (layout.state != :closed ? :closed : :open)
+      #cursor_down if layout.state == :closed # too annoying
+      update
+    elsif chunk.viewable?
+      view chunk
+    end
+    if chunk.is_a?(Message) && $config[:jump_to_open_message]
+      jump_to_message chunk
+      jump_to_next_open if layout.state == :closed
+    end
+  end
+
+  def edit_as_new
+    m = @message_lines[curpos] or return
+    mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos)
+    BufferManager.spawn "edit as new", mode
+    mode.edit_message
+  end
+
+  def save_to_disk
+    chunk = @chunk_lines[curpos] or return
+    case chunk
+    when Chunk::Attachment
+      default_dir = $config[:default_attachment_save_dir]
+      default_dir = ENV["HOME"] if default_dir.nil? || default_dir.empty?
+      default_fn = File.expand_path File.join(default_dir, chunk.filename)
+      fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_fn
+      save_to_file(fn) { |f| f.print chunk.raw_content } if fn
+    else
+      m = @message_lines[curpos]
+      fn = BufferManager.ask_for_filename :filename, "Save message to file: "
+      return unless fn
+      save_to_file(fn) do |f|
+        m.each_raw_message_line { |l| f.print l }
+      end
+    end
+  end
+
+  def save_all_to_disk
+    m = @message_lines[curpos] or return
+    default_dir = ($config[:default_attachment_save_dir] || ".")
+    folder = BufferManager.ask_for_filename :filename, "Save all attachments to folder: ", default_dir, true
+    return unless folder
+
+    num = 0
+    num_errors = 0
+    m.chunks.each do |chunk|
+      next unless chunk.is_a?(Chunk::Attachment)
+      fn = File.join(folder, chunk.filename)
+      num_errors += 1 unless save_to_file(fn, false) { |f| f.print chunk.raw_content }
+      num += 1
+    end
+
+    if num == 0
+      BufferManager.flash "Didn't find any attachments!"
+    else
+      if num_errors == 0
+        BufferManager.flash "Wrote #{num.pluralize 'attachment'} to #{folder}."
+      else
+        BufferManager.flash "Wrote #{(num - num_errors).pluralize 'attachment'} to #{folder}; couldn't write #{num_errors} of them (see log)."
+      end
+    end
+  end
+
+  def publish
+    chunk = @chunk_lines[curpos] or return
+    if HookManager.enabled? "publish"
+      HookManager.run "publish", :chunk => chunk
+    else
+      BufferManager.flash "Publishing hook not defined."
+    end
+  end
+
+  def edit_draft
+    m = @message_lines[curpos] or return
+    if m.is_draft?
+      mode = ResumeMode.new m
+      BufferManager.spawn "Edit message", mode
+      BufferManager.kill_buffer self.buffer
+      mode.edit_message
+    else
+      BufferManager.flash "Not a draft message!"
+    end
+  end
+
+  def send_draft
+    m = @message_lines[curpos] or return
+    if m.is_draft?
+      mode = ResumeMode.new m
+      BufferManager.spawn "Send message", mode
+      BufferManager.kill_buffer self.buffer
+      mode.send_message
+    else
+      BufferManager.flash "Not a draft message!"
+    end
+  end
+
+  def jump_to_first_open
+    m = @message_lines[0] or return
+    if @layout[m].state != :closed
+      jump_to_message m#, true
+    else
+      jump_to_next_open #true
+    end
+  end
+
+  def jump_to_next_and_open
+    return continue_search_in_buffer if in_search? # err.. don't know why im doing this
+
+    m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
+    return unless m
+
+    if @layout[m].toggled_state == true
+      @layout[m].state = :closed
+      @layout[m].toggled_state = false
+      update
+    end
+
+    nextm = @layout[m].next
+    if @layout[nextm].state == :closed
+      @layout[nextm].state = :open
+      @layout[nextm].toggled_state = true
+    end
+
+    jump_to_message nextm if nextm
+
+    update if @layout[nextm].toggled_state
+  end
+
+  def jump_to_next_open force_alignment=nil
+    return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
+    m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
+    return unless m
+    while nextm = @layout[m].next
+      break if @layout[nextm].state != :closed
+      m = nextm
+    end
+    jump_to_message nextm, force_alignment if nextm
+  end
+
+  def align_current_message
+    m = @message_lines[curpos] or return
+    jump_to_message m, true
+  end
+
+  def jump_to_prev_and_open force_alignment=nil
+    m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] }
+    return unless m
+
+    if @layout[m].toggled_state == true
+      @layout[m].state = :closed
+      @layout[m].toggled_state = false
+      update
+    end
+
+    nextm = @layout[m].prev
+    if @layout[nextm].state == :closed
+      @layout[nextm].state = :open
+      @layout[nextm].toggled_state = true
+    end
+
+    jump_to_message nextm if nextm
+    update if @layout[nextm].toggled_state
+  end
+
+  def jump_to_prev_open
+    m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
+    return unless m
+    ## jump to the top of the current message if we're in the body;
+    ## otherwise, to the previous message
+
+    top = @layout[m].top
+    if curpos == top
+      while(prevm = @layout[m].prev)
+        break if @layout[prevm].state != :closed
+        m = prevm
+      end
+      jump_to_message prevm if prevm
+    else
+      jump_to_message m
+    end
+  end
+
+  def jump_to_message m, force_alignment=false
+    l = @layout[m]
+
+    ## boundaries of the message
+    message_left = l.depth * INDENT_SPACES
+    message_right = message_left + l.width
+
+    ## calculate leftmost colum
+    left = if force_alignment # force mode: align exactly
+      message_left
+    else # regular: minimize cursor movement
+      ## leftmost and rightmost are boundaries of all valid left-column
+      ## alignments.
+      leftmost = [message_left, message_right - buffer.content_width + 1].min
+      rightmost = message_left
+      leftcol.clamp(leftmost, rightmost)
+    end
+
+    jump_to_line l.top    # move vertically
+    jump_to_col left      # move horizontally
+    set_cursor_pos l.top  # set cursor pos
+  end
+
+  def expand_all_messages
+    @global_message_state ||= :closed
+    @global_message_state = (@global_message_state == :closed ? :open : :closed)
+    @layout.each { |m, l| l.state = @global_message_state }
+    update
+  end
+
+  def collapse_non_new_messages
+    @layout.each { |m, l| l.state = l.orig_new ? :open : :closed }
+    update
+  end
+
+  def expand_all_quotes
+    if(m = @message_lines[curpos])
+      quotes = m.chunks.select { |c| (c.is_a?(Chunk::Quote) || c.is_a?(Chunk::Signature)) && c.lines.length > 1 }
+      numopen = quotes.inject(0) { |s, c| s + (@chunk_layout[c].state == :open ? 1 : 0) }
+      newstate = numopen > quotes.length / 2 ? :closed : :open
+      quotes.each { |c| @chunk_layout[c].state = newstate }
+      update
+    end
+  end
+
+  def cleanup
+    @layout = @chunk_layout = @text = nil # for good luck
+  end
+
+  def archive_and_kill; archive_and_then :kill end
+  def spam_and_kill; spam_and_then :kill end
+  def delete_and_kill; delete_and_then :kill end
+  def unread_and_kill; unread_and_then :kill end
+  def do_nothing_and_kill; do_nothing_and_then :kill end
+
+  def archive_and_next; archive_and_then :next end
+  def spam_and_next; spam_and_then :next end
+  def delete_and_next; delete_and_then :next end
+  def unread_and_next; unread_and_then :next end
+  def do_nothing_and_next; do_nothing_and_then :next end
+
+  def archive_and_prev; archive_and_then :prev end
+  def spam_and_prev; spam_and_then :prev end
+  def delete_and_prev; delete_and_then :prev end
+  def unread_and_prev; unread_and_then :prev end
+  def do_nothing_and_prev; do_nothing_and_then :prev end
+
+  def archive_and_then op
+    dispatch op do
+      @thread.remove_label :inbox
+      UpdateManager.relay self, :archived, @thread.first
+      Index.save_thread @thread
+      UndoManager.register "archiving 1 thread" do
+        @thread.apply_label :inbox
+        Index.save_thread @thread
+        UpdateManager.relay self, :unarchived, @thread.first
+      end
+    end
+  end
+
+  def spam_and_then op
+    dispatch op do
+      @thread.apply_label :spam
+      UpdateManager.relay self, :spammed, @thread.first
+      Index.save_thread @thread
+      UndoManager.register "marking 1 thread as spam" do
+        @thread.remove_label :spam
+        Index.save_thread @thread
+        UpdateManager.relay self, :unspammed, @thread.first
+      end
+    end
+  end
+
+  def delete_and_then op
+    dispatch op do
+      @thread.apply_label :deleted
+      UpdateManager.relay self, :deleted, @thread.first
+      Index.save_thread @thread
+      UndoManager.register "deleting 1 thread" do
+        @thread.remove_label :deleted
+        Index.save_thread @thread
+        UpdateManager.relay self, :undeleted, @thread.first
+      end
+    end
+  end
+
+  def unread_and_then op
+    dispatch op do
+      @thread.apply_label :unread
+      UpdateManager.relay self, :unread, @thread.first
+      Index.save_thread @thread
+    end
+  end
+
+  def do_nothing_and_then op
+    dispatch op
+  end
+
+  def dispatch op
+    return if @dying
+    @dying = true
+
+    l = lambda do
+      yield if block_given?
+      BufferManager.kill_buffer_safely buffer
+    end
+
+    case op
+    when :next
+      @index_mode.launch_next_thread_after @thread, &l
+    when :prev
+      @index_mode.launch_prev_thread_before @thread, &l
+    when :kill
+      l.call
+    else
+      raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
+    end
+  end
+  private :dispatch
+
+  def pipe_message
+    chunk = @chunk_lines[curpos]
+    chunk = nil unless chunk.is_a?(Chunk::Attachment)
+    message = @message_lines[curpos] unless chunk
+
+    return unless chunk || message
+
+    command = BufferManager.ask(:shell, "pipe command: ")
+    return if command.nil? || command.empty?
+
+    output = pipe_to_process(command) do |stream|
+      if chunk
+        stream.print chunk.raw_content
+      else
+        message.each_raw_message_line { |l| stream.print l }
+      end
+    end
+
+    if output
+      BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
+    else
+      BufferManager.flash "'#{command}' done!"
+    end
+  end
+
+private
+
+  def initial_state_for m
+    if m.has_label?(:starred) || m.has_label?(:unread)
+      :open
+    else
+      :closed
+    end
+  end
+
+  def update
+    regen_text
+    buffer.mark_dirty if buffer
+  end
+
+  ## here we generate the actual content lines. we accumulate
+  ## everything into @text, and we set @chunk_lines and
+  ## @message_lines, and we update @layout.
+  def regen_text
+    @text = []
+    @chunk_lines = []
+    @message_lines = []
+    @person_lines = []
+
+    prevm = nil
+    @thread.each do |m, depth, parent|
+      unless m.is_a? Message # handle nil and :fake_root
+        @text += chunk_to_lines m, nil, @text.length, depth, parent
+        next
+      end
+      l = @layout[m]
+
+      ## is this still necessary?
+      next unless @layout[m].state # skip discarded drafts
+
+      ## build the patina
+      text = chunk_to_lines m, l.state, @text.length, depth, parent, l.color, l.star_color
+
+      l.top = @text.length
+      l.bot = @text.length + text.length # updated below
+      l.prev = prevm
+      l.next = nil
+      l.depth = depth
+      # l.state we preserve
+      l.width = 0 # updated below
+      @layout[l.prev].next = m if l.prev
+
+      (0 ... text.length).each do |i|
+        @chunk_lines[@text.length + i] = m
+        @message_lines[@text.length + i] = m
+        lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
+      end
+
+      @text += text
+      prevm = m
+      if l.state != :closed
+        m.chunks.each do |c|
+          cl = @chunk_layout[c]
+
+          ## set the default state for chunks
+          cl.state ||=
+            if c.expandable? && c.respond_to?(:initial_state)
+              c.initial_state
+            else
+              :closed
+            end
+
+          text = chunk_to_lines c, cl.state, @text.length, depth
+          (0 ... text.length).each do |i|
+            @chunk_lines[@text.length + i] = c
+            @message_lines[@text.length + i] = m
+            lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
+            l.width = lw if lw > l.width
+          end
+          @text += text
+        end
+        @layout[m].bot = @text.length
+      end
+    end
+  end
+
+  def message_patina_lines m, state, start, parent, prefix, color, star_color
+    prefix_widget = [color, prefix]
+
+    open_widget = [color, (state == :closed ? "+ " : "- ")]
+    new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
+    starred_widget = if m.has_label?(:starred)
+        [star_color, "*"]
+      else
+        [color, " "]
+      end
+    attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
+
+    case state
+    when :open
+      @person_lines[start] = m.from
+      [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
+        [color,
+            "#{m.from ? m.from.mediumname : '?'} to #{m.recipients.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
+
+    when :closed
+      @person_lines[start] = m.from
+      [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
+        [color,
+        "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})  #{m.snippet}"]]]
+
+    when :detailed
+      @person_lines[start] = m.from
+      from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
+          [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
+
+      addressee_lines = []
+      unless m.to.empty?
+        m.to.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
+        addressee_lines += format_person_list "   To: ", m.to
+      end
+      unless m.cc.empty?
+        m.cc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
+        addressee_lines += format_person_list "   Cc: ", m.cc
+      end
+      unless m.bcc.empty?
+        m.bcc.each_with_index { |p, i| @person_lines[start + addressee_lines.length + from_line.length + i] = p }
+        addressee_lines += format_person_list "   Bcc: ", m.bcc
+      end
+
+      headers = OrderedHash.new
+      headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"
+      headers["Subject"] = m.subj
+
+      show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
+      unless show_labels.empty?
+        headers["Labels"] = show_labels.map { |x| x.to_s }.sort.join(', ')
+      end
+      if parent
+        headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}"
+      end
+
+      HookManager.run "detailed-headers", :message => m, :headers => headers
+
+      from_line + (addressee_lines + headers.map { |k, v| "   #{k}: #{v}" }).map { |l| [[color, prefix + "  " + l]] }
+    end
+  end
+
+  def format_person_list prefix, people
+    ptext = people.map { |p| format_person p }
+    pad = " " * prefix.display_length
+    [prefix + ptext.first + (ptext.length > 1 ? "," : "")] +
+      ptext[1 .. -1].map_with_index do |e, i|
+        pad + e + (i == ptext.length - 1 ? "" : ",")
+      end
+  end
+
+  def format_person p
+    p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
+  end
+
+  def maybe_wrap_text lines
+    if @wrap
+      config_width = $config[:wrap_width]
+      if config_width and config_width != 0
+        width = [config_width, buffer.content_width].min
+      else
+        width = buffer.content_width
+      end
+      # lines can apparently be both String and Array, convert to Array for map.
+      if lines.kind_of? String
+        lines = lines.lines.to_a
+      end
+      lines = lines.map { |l| l.chomp.wrap width if l }.flatten
+    end
+    return lines
+  end
+
+  ## todo: check arguments on this overly complex function
+  def chunk_to_lines chunk, state, start, depth, parent=nil, color=nil, star_color=nil
+    prefix = " " * INDENT_SPACES * depth
+    case chunk
+    when :fake_root
+      [[[:missing_message_color, "#{prefix}<one or more unreceived messages>"]]]
+    when nil
+      [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
+    when Message
+      message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
+        (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
+
+    else
+      raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
+      if chunk.inlineable?
+        lines = maybe_wrap_text(chunk.lines)
+        lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
+      elsif chunk.expandable?
+        case state
+        when :closed
+          [[[chunk.patina_color, "#{prefix}+ #{chunk.patina_text}"]]]
+        when :open
+          lines = maybe_wrap_text(chunk.lines)
+          [[[chunk.patina_color, "#{prefix}- #{chunk.patina_text}"]]] + lines.map { |line| [[chunk.color, "#{prefix}#{line}"]] }
+        end
+      else
+        [[[chunk.patina_color, "#{prefix}x #{chunk.patina_text}"]]]
+      end
+    end
+  end
+
+  def view chunk
+    BufferManager.flash "viewing #{chunk.content_type} attachment..."
+    success = chunk.view!
+    BufferManager.erase_flash
+    BufferManager.completely_redraw_screen
+    unless success
+      BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s.ascii, chunk.filename)
+      BufferManager.flash "Couldn't execute view command, viewing as text."
+    end
+  end
+end
+
+end
diff --git a/lib/sup/poll.rb b/lib/sup/poll.rb
@@ -22,6 +22,8 @@ Variables:
                    num: the total number of new messages added in this poll
              num_inbox: the number of new messages added in this poll which
                         appear in the inbox (i.e. were not auto-archived).
+             num_total: the total number of messages
+       num_inbox_total: the total number of new messages in the inbox.
 num_inbox_total_unread: the total number of unread messages in the inbox
          from_and_subj: an array of (from email address, subject) pairs
    from_and_subj_inbox: an array of (from email address, subject) pairs for
@@ -33,7 +35,7 @@ EOS
     @mutex = Mutex.new
     @thread = nil
     @last_poll = nil
-    @polling = false
+    @polling = Mutex.new
     @poll_sources = nil
     @mode = nil
     @should_clear_running_totals = false
@@ -43,40 +45,59 @@ EOS
 
   def poll_with_sources
     @mode ||= PollMode.new
-    HookManager.run "before-poll"
 
-    BufferManager.flash "Polling for new messages..."
+    if HookManager.enabled? "before-poll"
+      HookManager.run("before-poll")
+    else
+      BufferManager.flash "Polling for new messages..."
+    end
+
     num, numi, from_and_subj, from_and_subj_inbox, loaded_labels = @mode.poll
     clear_running_totals if @should_clear_running_totals
     @running_totals[:num] += num
     @running_totals[:numi] += numi
     @running_totals[:loaded_labels] += loaded_labels || []
-    if @running_totals[:num] > 0
-      BufferManager.flash "Loaded #{@running_totals[:num].pluralize 'new message'}, #{@running_totals[:numi]} to inbox. Labels: #{@running_totals[:loaded_labels].map{|l| l.to_s}.join(', ')}"
+
+
+    if HookManager.enabled? "after-poll"
+      hook_args = { :num => num, :num_inbox => numi,
+                    :num_total => @running_totals[:num], :num_inbox_total => @running_totals[:numi],
+                    :from_and_subj => from_and_subj, :from_and_subj_inbox => from_and_subj_inbox,
+                    :num_inbox_total_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] } }
+
+      HookManager.run("after-poll", hook_args)
     else
-      BufferManager.flash "No new messages."
+      if @running_totals[:num] > 0
+        BufferManager.flash "Loaded #{@running_totals[:num].pluralize 'new message'}, #{@running_totals[:numi]} to inbox. Labels: #{@running_totals[:loaded_labels].map{|l| l.to_s}.join(', ')}"
+      else
+        BufferManager.flash "No new messages."
+      end
     end
 
-    HookManager.run "after-poll", :num => num, :num_inbox => numi, :from_and_subj => from_and_subj, :from_and_subj_inbox => from_and_subj_inbox, :num_inbox_total_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] }
-
   end
 
   def poll
-    return if @polling
-    @polling = true
-    @poll_sources = SourceManager.usual_sources
-    num, numi = poll_with_sources
-    @polling = false
-    [num, numi]
+    if @polling.try_lock
+      @poll_sources = SourceManager.usual_sources
+      num, numi = poll_with_sources
+      @polling.unlock
+      [num, numi]
+    else
+      debug "poll already in progress."
+      return
+    end
   end
 
   def poll_unusual
-    return if @polling
-    @polling = true
-    @poll_sources = SourceManager.unusual_sources
-    num, numi = poll_with_sources
-    @polling = false
-    [num, numi]
+    if @polling.try_lock
+      @poll_sources = SourceManager.unusual_sources
+      num, numi = poll_with_sources
+      @polling.unlock
+      [num, numi]
+    else
+      debug "poll_unusual already in progress."
+      return
+    end
   end
 
   def start
@@ -142,7 +163,6 @@ EOS
       loaded_labels = loaded_labels - LabelManager::HIDDEN_RESERVED_LABELS - [:inbox, :killed]
       yield "Done polling; loaded #{total_num} new messages total"
       @last_poll = Time.now
-      @polling = false
     end
     [total_num, total_numi, from_and_subj, from_and_subj_inbox, loaded_labels]
   end
@@ -151,42 +171,51 @@ EOS
   ## labels and locations set correctly. The Messages are saved to or removed
   ## from the index after being yielded.
   def poll_from source, opts={}
-    begin
-      source.poll do |sym, args|
-        case sym
-        when :add
-          m = Message.build_from_source source, args[:info]
-          old_m = Index.build_message m.id
-          m.labels += args[:labels]
-          m.labels.delete :inbox  if source.archived?
-          m.labels.delete :unread if source.read?
-          m.labels.delete :unread if m.source_marked_read? # preserve read status if possible
-          m.labels.each { |l| LabelManager << l }
-          m.labels = old_m.labels + (m.labels - [:unread, :inbox]) if old_m
-          m.locations = old_m.locations + m.locations if old_m
-          HookManager.run "before-add-message", :message => m
-          yield :add, m, old_m, args[:progress] if block_given?
-          Index.sync_message m, true
-
-          ## We need to add or unhide the message when it either did not exist
-          ## before at all or when it was updated. We do *not* add/unhide when
-          ## the same message was found at a different location
-          if !old_m or not old_m.locations.member? m.location
-            UpdateManager.relay self, :added, m
-          end
-        when :delete
-          Index.each_message :location => [source.id, args[:info]] do |m|
-            m.locations.delete Location.new(source, args[:info])
-            yield :delete, m, [source,args[:info]], args[:progress] if block_given?
-            Index.sync_message m, false
-            #UpdateManager.relay self, :deleted, m
+    debug "trying to acquire poll lock for: #{source}.."
+    if source.poll_lock.try_lock
+      debug "lock acquired for: #{source}."
+      begin
+        source.poll do |sym, args|
+          case sym
+          when :add
+            m = Message.build_from_source source, args[:info]
+            old_m = Index.build_message m.id
+            m.labels += args[:labels]
+            m.labels.delete :inbox  if source.archived?
+            m.labels.delete :unread if source.read?
+            m.labels.delete :unread if m.source_marked_read? # preserve read status if possible
+            m.labels.each { |l| LabelManager << l }
+            m.labels = old_m.labels + (m.labels - [:unread, :inbox]) if old_m
+            m.locations = old_m.locations + m.locations if old_m
+            HookManager.run "before-add-message", :message => m
+            yield :add, m, old_m, args[:progress] if block_given?
+            Index.sync_message m, true
+
+            ## We need to add or unhide the message when it either did not exist
+            ## before at all or when it was updated. We do *not* add/unhide when
+            ## the same message was found at a different location
+            if !old_m or not old_m.locations.member? m.location
+              UpdateManager.relay self, :added, m
+            end
+          when :delete
+            Index.each_message :location => [source.id, args[:info]] do |m|
+              m.locations.delete Location.new(source, args[:info])
+              yield :delete, m, [source,args[:info]], args[:progress] if block_given?
+              Index.sync_message m, false
+              #UpdateManager.relay self, :deleted, m
+            end
           end
         end
-      end
 
-      source.go_idle
-    rescue SourceError => e
-      warn "problem getting messages from #{source}: #{e.message}"
+      rescue SourceError => e
+        warn "problem getting messages from #{source}: #{e.message}"
+
+      ensure
+        source.go_idle
+        source.poll_lock.unlock
+      end
+    else
+      debug "source #{source} is already being polled."
     end
   end
 
diff --git a/lib/sup/rfc2047.rb b/lib/sup/rfc2047.rb
@@ -16,8 +16,6 @@
 #
 # This file is distributed under the same terms as Ruby.
 
-require 'iconv'
-
 module Rfc2047
   WORD = %r{=\?([!\#$%&'*+-/0-9A-Z\\^\`a-z{|}~]+)\?([BbQq])\?([!->@-~]+)\?=} # :nodoc: 'stupid ruby-mode
   WORDSEQ = %r{(#{WORD.source})\s+(?=#{WORD.source})}
@@ -52,7 +50,7 @@ module Rfc2047
         # WORD.
       end
 
-      Iconv.easy_decode(target, charset, text)
+      text.transcode(target, charset)
     end
   end
 end
diff --git a/lib/sup/sent.rb b/lib/sup/sent.rb
@@ -25,8 +25,13 @@ class SentManager
   end
 
   def write_sent_message date, from_email, &block
-    @source.store_message date, from_email, &block
-    PollManager.poll_from @source
+    ::Thread.new do
+      debug "store the sent message (locking sent source..)"
+      @source.poll_lock.synchronize do
+        @source.store_message date, from_email, &block
+      end
+      PollManager.poll_from @source
+    end
   end
 end
 
diff --git a/lib/sup/service/label_service.rb b/lib/sup/service/label_service.rb
@@ -0,0 +1,45 @@
+require "sup/index"
+
+module Redwood
+  # Provides label tweaking service to the user.
+  # Working as the backend of ConsoleMode.
+  #
+  # Should become the backend of bin/sup-tweak-labels in the future.
+  class LabelService
+    # @param index [Redwood::Index]
+    def initialize index=Index.instance
+      @index = index
+    end
+
+    def add_labels query, *labels
+      run_on_each_message(query) do |m|
+        labels.each {|l| m.add_label l }
+      end
+    end
+
+    def remove_labels query, *labels
+      run_on_each_message(query) do |m|
+        labels.each {|l| m.remove_label l }
+      end
+    end
+
+
+    private
+    def run_on_each_message query, &operation
+      count = 0
+
+      find_messages(query).each do |m|
+        operation.call(m)
+        @index.update_message_state m
+        count += 1
+      end
+
+      @index.save_index
+      count
+    end
+
+    def find_messages query
+      @index.find_messages(query)
+    end
+  end
+end
diff --git a/lib/sup/source.rb b/lib/sup/source.rb
@@ -22,30 +22,23 @@ class Source
   ## read, delete them, or anything else. (Well, it's nice to be able
   ## to delete them, but that is optional.)
   ##
-  ## On the other hand, Sup assumes that you can assign each message a
-  ## unique integer id, such that newer messages have higher ids than
-  ## earlier ones, and that those ids stay constant across sessions
-  ## (in the absence of some other client going in and fucking
-  ## everything up). For example, for mboxes I use the file offset of
-  ## the start of the message. If a source does NOT have that
-  ## capability, e.g. IMAP, then you have to do a little more work to
-  ## simulate it.
+  ## Messages are identified internally based on the message id, and stored
+  ## with an unique document id. Along with the message, source information
+  ## that can contain arbitrary fields (set up by the source) is stored. This
+  ## information will be passed back to the source when a message in the
+  ## index (Sup database) needs to be identified to its source, e.g. when
+  ## re-reading or modifying a unique message.
   ##
   ## To write a new source, subclass this class, and implement:
   ##
-  ## - start_offset
-  ## - end_offset (exclusive!) (or, #done?)
+  ## - initialize
   ## - load_header offset
   ## - load_message offset
   ## - raw_header offset
   ## - raw_message offset
-  ## - check (optional)
+  ## - store_message (optional)
+  ## - poll (loads new messages)
   ## - go_idle (optional)
-  ## - next (or each, if you prefer): should return a message and an
-  ##   array of labels.
-  ##
-  ## ... where "offset" really means unique id. (You can tell I
-  ## started with mbox.)
   ##
   ## All exceptions relating to accessing the source must be caught
   ## and rethrown as FatalSourceErrors or OutOfSyncSourceErrors.
@@ -57,12 +50,11 @@ class Source
   ## Finally, be sure the source is thread-safe, since it WILL be
   ## pummelled from multiple threads at once.
   ##
-  ## Examples for you to look at: mbox/loader.rb, imap.rb, and
-  ## maildir.rb.
+  ## Examples for you to look at: mbox.rb and maildir.rb.
 
   bool_accessor :usual, :archived
   attr_reader :uri
-  attr_accessor :id
+  attr_accessor :id, :poll_lock
 
   def initialize uri, usual=true, archived=false, id=nil
     raise ArgumentError, "id must be an integer: #{id.inspect}" unless id.is_a? Fixnum if id
@@ -71,6 +63,8 @@ class Source
     @usual = usual
     @archived = archived
     @id = id
+
+    @poll_lock = Mutex.new
   end
 
   ## overwrite me if you have a disk incarnation (currently used only for sup-sync-back)
@@ -209,9 +203,9 @@ class SourceManager
     end
   end
 
-  def save_sources fn=Redwood::SOURCE_FN
+  def save_sources fn=Redwood::SOURCE_FN, force=false
     @source_mutex.synchronize do
-      if @sources_dirty
+      if @sources_dirty || force
         Redwood::save_yaml_obj sources, fn, false, true
       end
       @sources_dirty = false
diff --git a/lib/sup/textfield.rb b/lib/sup/textfield.rb
@@ -168,6 +168,16 @@ private
     else # trailing spaces
       v + (" " * (x - @question.length - v.length))
     end
+
+    # ncurses returns a ASCII-8BIT (binary) string, which
+    # bytes presumably are of current charset encoding. we force_encoding
+    # so that the char representation / string is tagged will be the
+    # system locale and also hopefully the terminal/input encoding. an
+    # incorrectly configured terminal encoding (not matching the system
+    # encoding) will produce erronous results, but will also do that for
+    # a log of other programs since it is impossible to detect which is
+    # which and what encoding the inputted byte chars are supposed to have.
+    v.force_encoding($encoding).fix_encoding
   end
 
   def remove_extra_space
diff --git a/lib/sup/util.rb b/lib/sup/util.rb
@@ -1,3 +1,5 @@
+# encoding: utf-8
+
 require 'thread'
 require 'lockfile'
 require 'mime/types'
@@ -5,7 +7,7 @@ require 'pathname'
 require 'set'
 require 'enumerator'
 require 'benchmark'
-require 'iconv'
+require 'unicode'
 
 ## time for some monkeypatching!
 class Symbol
@@ -31,7 +33,7 @@ class Lockfile
   def dump_lock_id lock_id = @lock_id
       "host: %s\npid: %s\nppid: %s\ntime: %s\nuser: %s\npname: %s\n" %
         lock_id.values_at('host','pid','ppid','time','user', 'pname')
-    end
+  end
 
   def lockinfo_on_disk
     h = load_lock_id IO.read(path)
@@ -114,6 +116,25 @@ module RMail
   end
 
   class Header
+
+    # Convert to ASCII before trying to match with regexp
+    class Field
+
+      EXTRACT_FIELD_NAME_RE = /\A([^\x00-\x1f\x7f-\xff :]+):\s*/no
+
+      class << self
+        def parse(field)
+          field = field.dup.to_s
+          field = field.fix_encoding.ascii
+          if field =~ EXTRACT_FIELD_NAME_RE
+            [ $1, $'.chomp ]
+          else
+            [ "", Field.value_strip(field) ]
+          end
+        end
+      end
+    end
+
     ## Be more cautious about invalid content-type headers
     ## the original RMail code calls
     ## value.strip.split(/\s*;\s*/)[0].downcase
@@ -234,14 +255,14 @@ class Object
 end
 
 class String
-  ## nasty multibyte hack for ruby 1.8. if it's utf-8, split into chars using
-  ## the utf8 regex and count those. otherwise, use the byte length.
   def display_length
-    if RUBY_VERSION < '1.9.1' && ($encoding == "UTF-8" || $encoding == "utf8")
-      # scan hack is somewhat slow, worth trying to cache
-      @display_length ||= scan(/./u).size
-    else
-      size
+    @display_length ||= Unicode.width(self, false)
+  end
+
+  def slice_by_display_length len
+    each_char.each_with_object "" do |c, buffer|
+      len -= c.display_length
+      buffer << c if len >= 0
     end
   end
 
@@ -328,20 +349,69 @@ class String
   def wrap len
     ret = []
     s = self
-    while s.length > len
-      cut = s[0 ... len].rindex(/\s/)
+    while s.display_length > len
+      cut = s.slice_by_display_length(len).rindex(/\s/)
       if cut
         ret << s[0 ... cut]
         s = s[(cut + 1) .. -1]
       else
-        ret << s[0 ... len]
-        s = s[len .. -1]
+        ret << s.slice_by_display_length(len)
+        s = s[ret.last.length .. -1]
       end
     end
     ret << s
   end
 
+  # Fix the damn string! make sure it is valid utf-8, then convert to
+  # user encoding.
+  #
+  # Not Ruby 1.8 compatible
+  def fix_encoding
+    # first try to encode to utf-8 from whatever current encoding
+    encode!('UTF-8', :invalid => :replace, :undef => :replace)
+
+    # do this anyway in case string is set to be UTF-8, encoding to
+    # something else (UTF-16 which can fully represent UTF-8) and back
+    # ensures invalid chars are replaced.
+    encode!('UTF-16', 'UTF-8', :invalid => :replace, :undef => :replace)
+    encode!('UTF-8', 'UTF-16', :invalid => :replace, :undef => :replace)
+
+    fail "Could not create valid UTF-8 string out of: '#{self.to_s}'." unless valid_encoding?
+
+    # now convert to $encoding
+    encode!($encoding, :invalid => :replace, :undef => :replace)
+
+    fail "Could not create valid #{$encoding.inspect} string out of: '#{self.to_s}'." unless valid_encoding?
+
+    self
+  end
+
+  # transcode the string if original encoding is know
+  # fix if broken.
+  #
+  # Not Ruby 1.8 compatible
+  def transcode to_encoding, from_encoding
+    begin
+      encode!(to_encoding, from_encoding, :invalid => :replace, :undef => :replace)
+
+      unless valid_encoding?
+        # fix encoding (through UTF-8)
+        encode!('UTF-16', from_encoding, :invalid => :replace, :undef => :replace)
+        encode!(to_encoding, 'UTF-16', :invalid => :replace, :undef => :replace)
+      end
+
+    rescue Encoding::ConverterNotFoundError
+      debug "Encoding converter not found for #{from_encoding.inspect} or #{to_encoding.inspect}, fixing string: '#{self.to_s}', but expect weird characters."
+      fix_encoding
+    end
+
+    fail "Could not create valid #{to_encoding.inspect} string out of: '#{self.to_s}'." unless valid_encoding?
+
+    self
+  end
+
   def normalize_whitespace
+    fix_encoding
     gsub(/\t/, "    ").gsub(/\r/, "")
   end
 
@@ -383,12 +453,8 @@ class String
         out << b.chr
       end
     end
-    out.force_encoding Encoding::UTF_8 if out.respond_to? :force_encoding
-    out
-  end
-
-  def transcode src_encoding=$encoding
-    Iconv.easy_decode $encoding, src_encoding, self
+    out = out.fix_encoding # this should now be an utf-8 string of ascii
+                           # compat chars.
   end
 
   unless method_defined? :ascii_only?
@@ -659,27 +725,3 @@ class FinishLine
   end
 end
 
-class Iconv
-  def self.easy_decode target, orig_charset, text
-    if text.respond_to? :force_encoding
-      text = text.dup
-      text.force_encoding Encoding::BINARY
-    end
-    charset = case orig_charset
-      when /UTF[-_ ]?8/i then "utf-8"
-      when /(iso[-_ ])?latin[-_ ]?1$/i then "ISO-8859-1"
-      when /iso[-_ ]?8859[-_ ]?15/i then 'ISO-8859-15'
-      when /unicode[-_ ]1[-_ ]1[-_ ]utf[-_]7/i then "utf-7"
-      when /^euc$/i then 'EUC-JP' # XXX try them all?
-      when /^(x-unknown|unknown[-_ ]?8bit|ascii[-_ ]?7[-_ ]?bit)$/i then 'ASCII'
-      else orig_charset
-    end
-
-    begin
-      returning(Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]) { |str| str.check }
-    rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::InvalidCharacter, Iconv::IllegalSequence, String::CheckError
-      debug "couldn't transcode text from #{orig_charset} (#{charset}) to #{target} (#{text[0 ... 20].inspect}...): got #{$!.class} (#{$!.message})"
-      text.ascii
-    end
-  end
-end
diff --git a/lib/sup/util/path.rb b/lib/sup/util/path.rb
@@ -0,0 +1,9 @@
+module Redwood
+  module Util
+    module Path
+      def self.expand(path)
+        ::File.expand_path(path)
+      end
+    end
+  end
+end
diff --git a/lib/sup/util/query.rb b/lib/sup/util/query.rb
@@ -0,0 +1,14 @@
+module Redwood
+  module Util
+    module Query
+      class QueryDescriptionError < ArgumentError; end
+
+      def self.describe query
+        d = query.description.force_encoding("UTF-8")
+
+        raise QueryDescriptionError.new(d) unless d.valid_encoding?
+        return d
+      end
+    end
+  end
+end
diff --git a/lib/sup/util/uri.rb b/lib/sup/util/uri.rb
@@ -0,0 +1,15 @@
+require "uri"
+
+require "sup/util/path"
+
+module Redwood
+  module Util
+    module Uri
+      def self.build(components)
+        components = components.dup
+        components[:path] = Path.expand(components[:path])
+        ::URI::Generic.build(components)
+      end
+    end
+  end
+end
diff --git a/lib/sup/version.rb b/lib/sup/version.rb
@@ -1,3 +1,3 @@
 module Redwood
-  VERSION = "git"
+  VERSION = "0.14.0"
 end
diff --git a/release-script.txt b/release-script.txt
@@ -1,17 +0,0 @@
-Just a few simple steps to make a new release.
-
-vi History.txt
-vi ReleaseNotes
-vi www/index.html # and bump version number
-git rank-contributors -n -o > CONTRIBUTORS
-vi CONTRIBUTORS   # and merge
-vi www/index.html # and include CONTRIBUTORS
-# ... git add, commit, etc
-git checkout -b release-<releasename>
-vi lib/sup.rb bin/* # and bump version numbers in all files
-# ... git add, commit, etc
-rake gem
-rake tarball
-gem push pkg/<gem name> # now using gemcutter
-git publish-branch
-rake upload_webpage
diff --git a/sup.gemspec b/sup.gemspec
@@ -5,7 +5,8 @@ require 'sup/version'
 
 # Files
 SUP_EXECUTABLES = %w(sup sup-add sup-config sup-dump sup-import-dump
-  sup-recover-sources sup-sync sup-sync-back sup-tweak-labels)
+  sup-recover-sources sup-sync sup-sync-back sup-tweak-labels
+  sup-psych-ify-config-files)
 SUP_EXTRA_FILES = %w(CONTRIBUTORS README.md LICENSE History.txt ReleaseNotes)
 SUP_FILES =
   SUP_EXTRA_FILES +
@@ -21,7 +22,7 @@ module Redwood
     s.authors = ["William Morgan", "Gaute Hope", "Hamish Downer", "Matthieu Rakotojaona"]
     s.email   = "sup-talk@rubyforge.org"
     s.summary = "A console-based email client with the best features of GMail, mutt and Emacs"
-    s.homepage = "https://github.com/sup-heliotrope/sup/wiki"
+    s.homepage = "http://supmua.org"
     s.description = <<-DESC
       Sup is a console-based email client for people with a lot of email.
 
@@ -33,19 +34,33 @@ module Redwood
       * Automatically tracking recent contacts
 DESC
     s.license = 'GPL-2'
+    # TODO: might want to add index migrating script here, too
+    s.post_install_message = <<-EOF
+SUP: Please run `sup-psych-ify-config-files` to migrate from 0.13 to 0.14.
+
+SUP: Check https://github.com/sup-heliotrope/sup/wiki/Migration-0.13-to-0.14
+     for more detailed up-to-date instructions.
+    EOF
     s.files = SUP_FILES
     s.executables = SUP_EXECUTABLES
 
-    s.add_dependency "xapian-full-alaveteli", "~> 1.2"
-    s.add_dependency "ncursesw-sup", "~> 1.3", ">= 1.3.1"
-    s.add_dependency "rmail", ">= 0.17"
-    s.add_dependency "highline"
-    s.add_dependency "trollop", ">= 1.12"
-    s.add_dependency "lockfile"
-    s.add_dependency "mime-types", "~> 1"
-    s.add_dependency "gettext"
+    s.required_ruby_version = '>= 1.9.2'
+
+    s.add_runtime_dependency "xapian-ruby", "~> 1.2.15"
+    s.add_runtime_dependency "ncursesw-sup", "~> 1.3.1"
+    s.add_runtime_dependency "rmail", ">= 0.17"
+    s.add_runtime_dependency "highline"
+    s.add_runtime_dependency "trollop", ">= 1.12"
+    s.add_runtime_dependency "lockfile"
+    s.add_runtime_dependency "mime-types", "~> 1.0"
+    s.add_runtime_dependency "locale", "~> 2.0"
+    s.add_runtime_dependency "chronic", "~> 0.9.1"
+    s.add_runtime_dependency "unicode", "~> 0.4.4"
 
     s.add_development_dependency "bundler", "~> 1.3"
     s.add_development_dependency "rake"
+    s.add_development_dependency "minitest", "~> 4.7"
+    s.add_development_dependency "rr", "~> 1.0.5"
+    s.add_development_dependency "gpgme", ">= 2.0.2"
   end
 end
diff --git a/test/gnupg_test_home/gpg.conf b/test/gnupg_test_home/gpg.conf
@@ -0,0 +1 @@
+default-key 789E7011
diff --git a/test/gnupg_test_home/pubring.gpg b/test/gnupg_test_home/pubring.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/receiver_pubring.gpg b/test/gnupg_test_home/receiver_pubring.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/receiver_secring.gpg b/test/gnupg_test_home/receiver_secring.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/receiver_trustdb.gpg b/test/gnupg_test_home/receiver_trustdb.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/secring.gpg b/test/gnupg_test_home/secring.gpg
Binary files differ.
diff --git a/test/gnupg_test_home/sup-test-2@foo.bar.asc b/test/gnupg_test_home/sup-test-2@foo.bar.asc
@@ -0,0 +1,20 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v2.0.20 (GNU/Linux)
+
+mI0EUgi0fAEEAOLAcQW96NEUSB7YE/la8X56jGW5BMX3aAixOF8LvOwMBbUK1T+U
+0H2PGIrXVcYyHcPqWRpRahbsIAldBqzffPlzMa+aqJaB1xKkNruxSoIzwPdidZMe
+l0Dxz2FDsoXD0KPyWnAYhGmQyz2MFpZxu2tlYqvwWVW//XGnk/KHvIXbABEBAAG0
+PlN1cCBUZXN0IFJlY2VpdmVyIChUZXN0IHJlY2VpdmVyIGZvciBTdXApIDxzdXAt
+dGVzdC0yQGZvby5iYXI+iL8EEwECACkFAlIItHwCGwMFCQHhM4AHCwkIBwMCAQYV
+CAIJCgsEFgIDAQIeAQIXgAAKCRAsABl+cWpykMMVBADHkQPgTz0CqKKp3k+z3dbm
+ocmI4tYNn1dOkDQqyfoBTfs6L3g4j5OE2UrguntRYyg5oon+uO5d18CQ5dY0sCw/
+o5IwyzTrxI8IocbtZvBdSb+XjLndynGuIQoqaJq9i6n1V4klFHVOna8Q9JstLfRX
+H1d4xPhnvKcaDDx/NV3X/biNBFIItHwBBADBpb43MpkrUWlg7HWJ1ZfOlxnOxrJ3
+Gz9WFNV06UbcZEuFKA/vHRjM6gWzUn5903FLuCWu3eBrq5xQfWipbp187PmocpoG
+skJ6gosLs1fMYRBjv2VbG9xJVKdKJMjqZw5FUpXKAaHr8P9jN6g2STQrbeQ8CVUK
+h7zOWRXAXSKUgwARAQABiKUEGAECAA8FAlIItHwCGwwFCQHhM4AACgkQLAAZfnFq
+cpDV1QQAzcxFXznEX92DjWxWRC7gRHgIsQk9WJnDzjtnDjSWCp3H85qeTZGZrn9W
+NoneV/S5Y7K3Mkceh4rFaANQ3zx4b05y1LFt5N/lPwIe5VB0vcPumtZum2fSGfpK
+nTXvzelcWcm2aGyUSaWvOkntWKEEt1kB5Oq6EtZoRZLMzAxLd7s=
+=aKsV
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/test/gnupg_test_home/trustdb.gpg b/test/gnupg_test_home/trustdb.gpg
Binary files differ.
diff --git a/test/integration/test_label_service.rb b/test/integration/test_label_service.rb
@@ -0,0 +1,18 @@
+require "test_helper"
+
+require "sup/service/label_service"
+
+require "tmpdir"
+
+describe Redwood::LabelService do
+  let(:tmpdir) { Dir.mktmpdir }
+  after do
+    require "fileutils"
+    FileUtils.remove_entry_secure @tmpdir unless @tmpdir.nil?
+  end
+
+  describe "#add_labels" do
+    # Integration tests are hard to write at this moment :(
+    it "add labels to all messages matching the query"
+  end
+end
diff --git a/test/test_crypto.rb b/test/test_crypto.rb
@@ -0,0 +1,109 @@
+# tests for sup's crypto libs
+#
+# Copyright Clint Byrum <clint@ubuntu.com> 2011. All Rights Reserved.
+# Copyright Sup Developers                 2013.
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301, USA.
+
+require 'test_helper'
+require 'sup'
+require 'stringio'
+require 'tmpdir'
+
+module Redwood
+
+class TestCryptoManager < ::Minitest::Unit::TestCase
+
+    def setup
+        @from_email = 'sup-test-1@foo.bar'
+        @to_email   = 'sup-test-2@foo.bar'
+        # Use test gnupg setup
+        @orig_gnupghome = ENV['GNUPGHOME']
+        ENV['GNUPGHOME'] = File.join(File.dirname(__FILE__), 'gnupg_test_home')
+
+        @path = Dir.mktmpdir
+        Redwood::HookManager.init File.join(@path, 'hooks')
+
+        am = {:default=> {:name => "test", :email=> 'sup-test-1@foo.bar'}}
+        Redwood::AccountManager.init am
+
+        Redwood::CryptoManager.init
+
+        if not CryptoManager.have_crypto?
+          warn "No crypto set up, crypto will not be tested. Reason: #{CryptoManager.not_working_reason}"
+        end
+    end
+
+    def teardown
+      CryptoManager.deinstantiate!
+      AccountManager.deinstantiate!
+      HookManager.deinstantiate!
+      FileUtils.rm_r @path
+
+      ENV['GNUPGHOME'] = @orig_gnupghome
+    end
+
+    def test_sign
+        if CryptoManager.have_crypto? then
+            signed = CryptoManager.sign @from_email,@to_email,"ABCDEFG"
+            assert_instance_of RMail::Message, signed
+            assert_equal "ABCDEFG", signed.body[0]
+            assert signed.body[1].body.length > 0 , "signature length must be > 0"
+            assert (signed.body[1].body.include? "-----BEGIN PGP SIGNATURE-----") , "Expecting PGP armored data"
+        end
+    end
+
+    def test_encrypt
+        if CryptoManager.have_crypto? then
+            encrypted = CryptoManager.encrypt @from_email, [@to_email], "ABCDEFG"
+            assert_instance_of RMail::Message, encrypted
+            assert (encrypted.body[1].body.include? "-----BEGIN PGP MESSAGE-----") , "Expecting PGP armored data"
+        end
+    end
+
+    def test_sign_and_encrypt
+        if CryptoManager.have_crypto? then
+            encrypted = CryptoManager.sign_and_encrypt @from_email, [@to_email], "ABCDEFG"
+            assert_instance_of RMail::Message, encrypted
+            assert (encrypted.body[1].body.include? "-----BEGIN PGP MESSAGE-----") , "Expecting PGP armored data"
+        end
+    end
+
+    def test_decrypt
+        if CryptoManager.have_crypto? then
+            encrypted = CryptoManager.encrypt @from_email, [@to_email], "ABCDEFG"
+            assert_instance_of RMail::Message, encrypted
+            assert_instance_of String, (encrypted.body[1].body)
+            decrypted = CryptoManager.decrypt encrypted.body[1], true
+            assert_instance_of Array, decrypted
+            assert_instance_of Chunk::CryptoNotice, decrypted[0]
+            assert_instance_of Chunk::CryptoNotice, decrypted[1]
+            assert_instance_of RMail::Message, decrypted[2]
+            assert_equal "ABCDEFG" , decrypted[2].body
+        end
+    end
+
+    def test_verify
+        if CryptoManager.have_crypto?
+            signed = CryptoManager.sign @from_email, @to_email, "ABCDEFG"
+            assert_instance_of RMail::Message, signed
+            assert_instance_of String, (signed.body[1].body)
+            CryptoManager.verify signed.body[0], signed.body[1], true
+        end
+    end
+end
+
+end
diff --git a/test/test_header_parsing.rb b/test/test_header_parsing.rb
@@ -1,12 +1,12 @@
 #!/usr/bin/ruby
 
-require 'test/unit'
+require 'test_helper'
 require 'sup'
 require 'stringio'
 
 include Redwood
 
-class TestMBoxParsing < Test::Unit::TestCase
+class TestMBoxParsing < Minitest::Unit::TestCase
 
   def setup
     @path = Dir.mktmpdir
@@ -150,10 +150,10 @@ To: a dear friend
 Hello again! Would you like to buy my products?
 EOS
     offset = l.next_offset 0
-    assert_not_nil offset
+    refute_nil offset
 
     offset = l.next_offset offset
-    assert_not_nil offset
+    refute_nil offset
 
     offset = l.next_offset offset
     assert_nil offset
diff --git a/test/test_helper.rb b/test/test_helper.rb
@@ -0,0 +1,7 @@
+require "rubygems" rescue nil
+require 'minitest/autorun'
+require "rr"
+
+class Minitest::Unit::TestCase
+  include ::RR::Adapters::MiniTest
+end
diff --git a/test/test_message.rb b/test/test_message.rb
@@ -1,6 +1,6 @@
 #!/usr/bin/ruby
 
-require 'test/unit'
+require 'test_helper'
 require 'sup'
 require 'stringio'
 
@@ -26,7 +26,7 @@ end
 
 module Redwood
 
-class TestMessage < Test::Unit::TestCase
+class TestMessage < ::Minitest::Unit::TestCase
 
   def setup
     @path = Dir.mktmpdir
@@ -289,7 +289,7 @@ EOS
     from = sup_message.from
     # very basic email address check
     assert_match(/\w+@\w+\.\w{2,4}/, from.email)
-    assert_not_nil(from.name)
+    refute_nil(from.name)
 
   end
 
@@ -327,11 +327,7 @@ EOS
 
     # read the message body chunks: no errors should reach this level
 
-    chunks = nil
-
-    assert_nothing_raised() do
-      chunks = sup_message.load_from_source!
-    end
+    chunks = sup_message.load_from_source!
 
     # the chunks list should be empty
 
@@ -426,10 +422,7 @@ EOS
 
     # read the message body chunks
 
-    assert_nothing_raised() do
-      chunks = sup_message.load_from_source!
-    end
-
+    sup_message.load_from_source!
   end
 
   def test_blank_header_lines
@@ -537,4 +530,3 @@ end
 end
 
 # vim:noai:ts=2:sw=2:
-
diff --git a/test/test_yaml_migration.rb b/test/test_yaml_migration.rb
@@ -0,0 +1,80 @@
+require "test_helper"
+
+require "sup"
+require "psych"
+
+describe "Sup's YAML util" do
+  describe "Module#yaml_properties" do
+    def build_class_with_name name, &b
+      Class.new do
+        meta_cls = class << self; self; end
+        meta_cls.send(:define_method, :name) { name }
+        class_exec(&b) unless b.nil?
+      end
+    end
+
+    after do
+      Psych.load_tags = {}
+      Psych.dump_tags = {}
+    end
+
+    it "defines YAML tag for class" do
+      cls = build_class_with_name 'Cls' do
+        yaml_properties
+      end
+
+      expected_yaml_tag = "!supmua.org,2006-10-01/Cls"
+
+      Psych.load_tags[expected_yaml_tag].must_equal cls
+      Psych.dump_tags[cls].must_equal expected_yaml_tag
+
+    end
+
+    it "Loads legacy YAML format as well" do
+      cls = build_class_with_name 'Cls' do
+        yaml_properties :id
+        attr_accessor :id
+        def initialize id
+          @id = id
+        end
+      end
+
+      Psych.load_tags["!masanjin.net,2006-10-01/Cls"].must_equal cls
+
+      yaml = <<EOF
+--- !masanjin.net,2006-10-01/Cls
+id: ID
+EOF
+      loaded = YAML.load(yaml)
+
+      loaded.id.must_equal 'ID'
+      loaded.must_be_kind_of cls
+    end
+
+    it "Dumps & loads w/ state re-initialized" do
+      cls = build_class_with_name 'Cls' do
+        yaml_properties :id
+        attr_accessor :id
+        attr_reader :flag
+
+        def initialize id
+          @id = id
+          @flag = true
+        end
+      end
+
+      instance = cls.new 'ID'
+
+      dumped = YAML.dump(instance)
+      loaded = YAML.load(dumped)
+
+      dumped.must_equal <<-EOF
+--- !supmua.org,2006-10-01/Cls
+id: ID
+      EOF
+
+      loaded.id.must_equal 'ID'
+      assert loaded.flag
+    end
+  end
+end
diff --git a/test/test_yaml_regressions.rb b/test/test_yaml_regressions.rb
@@ -1,4 +1,4 @@
-require 'test/unit'
+require 'test_helper'
 
 # Requiring 'yaml' before 'sup' in 1.9.x would get Psych loaded first
 # and becoming the default yamler.
@@ -6,7 +6,7 @@ require 'yaml'
 require 'sup'
 
 module Redwood
-  class TestYamlRegressions < Test::Unit::TestCase
+  class TestYamlRegressions < ::Minitest::Unit::TestCase
     def test_yamling_hash
       hsh = {:foo => 42}
       reloaded = YAML.load(hsh.to_yaml)
diff --git a/test/unit/service/test_label_service.rb b/test/unit/service/test_label_service.rb
@@ -0,0 +1,19 @@
+require "test_helper"
+
+require "sup/service/label_service"
+
+describe Redwood::LabelService do
+  describe "#add_labels" do
+    it "add labels to all messages matching the query" do
+      q = 'is:starred'
+      label = 'superstarred'
+      message = mock!.add_label(label).subject
+      index = mock!.find_messages(q){ [message] }.subject
+      mock(index).update_message_state(message)
+      mock(index).save_index
+
+      service = Redwood::LabelService.new(index)
+      service.add_labels q, label
+    end
+  end
+end
diff --git a/test/unit/test_horizontal_selector.rb b/test/unit/test_horizontal_selector.rb
@@ -0,0 +1,40 @@
+require "test_helper" 
+
+require "sup/horizontal_selector"
+
+describe Redwood::HorizontalSelector do
+  let(:values) { %w[foo@example.com bar@example.com] }
+  let(:strange_value) { "strange@example.com" }
+
+  before do
+    @selector = Redwood::HorizontalSelector.new(
+      'Acc:', values, [])
+  end
+
+  it "init w/ the first value selected" do
+    first_value = values.first
+    @selector.val.must_equal first_value
+  end
+
+  it "stores value for selection" do
+    second_value = values[1]
+    @selector.set_to second_value
+    @selector.val.must_equal second_value
+  end
+
+  describe "for unknown value" do
+    it "cannot select unknown value" do
+      @selector.wont_be :can_set_to?, strange_value
+    end
+
+    it "refuses selecting unknown value" do
+      old_value = @selector.val
+
+      assert_raises Redwood::HorizontalSelector::UnknownValue do
+        @selector.set_to strange_value
+      end
+
+      @selector.val.must_equal old_value
+    end
+  end
+end
diff --git a/test/unit/util/test_query.rb b/test/unit/util/test_query.rb
@@ -0,0 +1,37 @@
+# encoding: utf-8
+
+require "test_helper"
+
+require "sup/util/query"
+require "xapian"
+
+describe Redwood::Util::Query do
+  describe ".describe" do
+    it "returns a UTF-8 description of query" do
+      query = Xapian::Query.new "テスト"
+      life = "生活: "
+
+      assert_raises Encoding::CompatibilityError do
+        _ = life + query.description
+      end
+
+      desc = Redwood::Util::Query.describe(query)
+      _ = (life + desc) # No exception thrown
+    end
+
+    it "returns a valid UTF-8 description of bad input" do
+      msg = "asdfa \xc3\x28 åasdf"
+      query = Xapian::Query.new msg
+      life = 'hæi'
+
+      # this is now possibly UTF-8 string with possibly invalid chars
+      assert_raises Redwood::Util::Query::QueryDescriptionError do
+        desc = Redwood::Util::Query.describe (query)
+      end
+
+      assert_raises Encoding::CompatibilityError do
+        _ = life + query.description
+      end
+    end
+  end
+end
diff --git a/test/unit/util/test_string.rb b/test/unit/util/test_string.rb
@@ -0,0 +1,57 @@
+# encoding: utf-8
+
+require "test_helper"
+
+require "sup/util"
+
+describe "Sup's String extension" do
+  describe "#display_length" do
+    let :data do
+      [
+        ['some words', 10,],
+        ['中文', 4,],
+        ['ä', 1,],
+      ]
+    end
+
+    it "calculates display length of a string" do
+      data.each do |(str, length)|
+        str.display_length.must_equal length
+      end
+    end
+  end
+
+  describe "#slice_by_display_length(len)" do
+    let :data do
+      [
+        ['some words', 6, 'some w'],
+        ['中文', 2, '中'],
+        ['älpha', 3, 'älp'],
+      ]
+    end
+
+    it "slices string by display length" do
+      data.each do |(str, length, sliced)|
+        str.slice_by_display_length(length).must_equal sliced
+      end
+    end
+  end
+
+  describe "#wrap" do
+    let :data do
+      [
+        ['some words', 6, ['some', 'words']],
+        ['some words', 80, ['some words']],
+        ['中文', 2, ['中', '文']],
+        ['中文', 5, ['中文']],
+        ['älpha', 3, ['älp', 'ha']],
+      ]
+    end
+
+    it "wraps string by display length" do
+      data.each do |(str, length, wrapped)|
+        str.wrap(length).must_equal wrapped
+      end
+    end
+  end
+end
diff --git a/test/unit/util/test_uri.rb b/test/unit/util/test_uri.rb
@@ -0,0 +1,19 @@
+require "test_helper.rb"
+
+require "sup/util/uri"
+
+describe Redwood::Util::Uri do
+  describe ".build" do
+    it "builds uri from hash" do
+      components = {:path => "/var/mail/foo", :scheme => "mbox"}
+      uri = Redwood::Util::Uri.build(components)
+      uri.to_s.must_equal "mbox:/var/mail/foo"
+    end
+
+    it "expands ~ in path" do
+      components = {:path => "~/foo", :scheme => "maildir"}
+      uri = Redwood::Util::Uri.build(components)
+      uri.to_s.must_equal "maildir:#{ENV["HOME"]}/foo"
+    end
+  end
+end