sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit ca5d97440fd2774475103278b6bfd7683089ef48
parent f8a5da3d272a7d835a05b683049d9ebd793266ce
Author: Gaute Hope <eg@gaute.vetsj.com>
Date:   Fri, 21 Mar 2014 15:51:10 +0100

Merge branch 'develop'

Diffstat:
M .travis.yml | 3 ++-
M CONTRIBUTORS | 29 ++++++++++++++++-------------
M History.txt | 8 ++++++++
M README.md | 3 ---
M ReleaseNotes | 10 ++++++++++
M bin/sup-psych-ify-config-files | 5 +++++
D bin/sup-sync-back-mbox | 181 -------------------------------------------------------------------------------
M contrib/completion/_sup.zsh | 4 ++--
M lib/sup/interactive_lock.rb | 79 +++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
M lib/sup/mbox.rb | 2 --
M lib/sup/message_chunks.rb | 40 ++++++++++++++++++----------------------
M lib/sup/modes/contact_list_mode.rb | 2 +-
M lib/sup/modes/inbox_mode.rb | 42 ------------------------------------------
M lib/sup/modes/thread_index_mode.rb | 42 ++++++++++++++++++++++++++++++++++++++++++
M lib/sup/modes/thread_view_mode.rb | 9 +++++++++
M lib/sup/person.rb | 2 +-
M lib/sup/source.rb | 2 +-
M lib/sup/util.rb | 8 ++++++++
M sup.gemspec | 4 ++--
A test/messages/missing-line.eml | 9 +++++++++
M test/test_messages_dir.rb | 35 +++++++++++++++++++++++++++++++++++
M test/test_yaml_migration.rb | 5 +++++
22 files changed, 221 insertions(+), 303 deletions(-)
diff --git a/.travis.yml b/.travis.yml
@@ -1,6 +1,8 @@
 language: ruby
 
 rvm:
+  - 2.1.1
+  - 2.1.0
   - 2.0.0
   - 1.9.3
 
@@ -9,4 +11,3 @@ before_install:
   - sudo apt-get install -qq uuid-dev uuid libncursesw5-dev libncursesw5 gnupg2
 
 script: bundle exec rake travis
-
diff --git a/CONTRIBUTORS b/CONTRIBUTORS
@@ -18,10 +18,12 @@ 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>
-Mark Alexander <marka at the pobox dot coms>
+Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
 Ingmar Vanhassel <ingmar at the exherbo dot orgs>
+Mark Alexander <marka at the pobox dot coms>
 Edward Z. Yang <ezyang at the mit dot edus>
-Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
+Timon Vonk <timonv at the gmail dot coms>
+julien@macbook <julien.stechele at the gmail dot coms>
 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>
@@ -29,13 +31,14 @@ 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>
 Bo Borgerson <gigabo at the gmail dot coms>
+Atte Kojo <atte.kojo at the reaktor dot fis>
 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>
-Adeodato Simó <dato at the net.com.org dot ess>
+Jonathan Lassoff <jof at the thejof dot coms>
 Markus Klinik <markus.klinik at the gmx dot des>
+Grant Hollingworth <grant at the antiflux dot orgs>
 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>
 James Taylor <james at the jamestaylor dot orgs>
@@ -46,8 +49,8 @@ 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>
 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>
+Andrew Pimlott <andrew at the pimlott dot nets>
 Matías Aguirre <matiasaguirre at the gmail dot coms>
 Kornilios Kourtis <kkourt at the cslab.ece.ntua dot grs>
 Lars Fischer <fischer at the wiwi.uni-siegen dot des>
@@ -58,18 +61,18 @@ Alvaro Herrera <alvherre at the alvh.no-ip dot orgs>
 Steven Lawrance <stl at the koffein dot nets>
 Jonah <Jonah at the GoodCoffee dot cas>
 ian <itaylor at the uark dot edus>
-Todd Eisenberger <teisenbe at the andrew.cmu dot edus>
+MichaelRevell <mikearevell at the gmail dot coms>
 Per Andersson <avtobiff at the gmail dot coms>
+Todd Eisenberger <teisenbe at the andrew.cmu dot edus>
+Gregor Hoffleit <gregor at the sam.mediasupervision dot des>
 Adam Lloyd <adam at the alloy-d dot nets>
-MichaelRevell <mikearevell at the gmail dot coms>
 0xACE <0xACE at the users.noreply.github dot coms>
-Gregor Hoffleit <gregor at the sam.mediasupervision dot des>
 Steven Walter <swalter at the monarch.(none)>
-Atte Kojo <atte.kojo at the reaktor dot fis>
-Stefan Lundström <lundst at the snabb.(none)>
+Jon M. Dugan <jdugan at the es dot nets>
+Horacio Sanson <horacio at the skillupjapan.co dot jps>
 Matthias Vallentin <vallentin at the icir dot orgs>
 akojo <atte.kojo at the gmail dot coms>
-Horacio Sanson <horacio at the skillupjapan.co dot jps>
-Jon M. Dugan <jdugan at the es dot nets>
+William A. Kennington III <william at the wkennington dot coms>
+Stefan Lundström <lundst at the snabb.(none)>
 Johannes Larsen <johs.a.larsen at the gmail dot coms>
 Kirill Smelkov <kirr at the landau.phys.spbu dot rus>
diff --git a/History.txt b/History.txt
@@ -1,3 +1,11 @@
+== 0.16.0 / 2014-03-21
+
+* sup-sync-back-mbox removed.
+* safer mime-view attachment file name handling
+* show thread labels in thread-view-mode
+* remove lock file if there is no sup alive
+* deprecate migration script on ruby > 2.1
+
 == 0.15.4 / 2014-02-06
 
 * Various bugfixes
diff --git a/README.md b/README.md
@@ -20,9 +20,6 @@ Features:
 
 Current limitations:
 
-* [Ruby 2.0 support][ruby20] is very fresh, consider it experimental. Patches
-  are welcome
-
 * Sup does in general not play nicely with other mail clients, not all
   changes can be synced back to the mail source. Refer to [Maildir Syncback][maildir-syncback]
   in the wiki for this recently included feature. Maildir Syncback
diff --git a/ReleaseNotes b/ReleaseNotes
@@ -1,3 +1,13 @@
+Release 0.16.0:
+
+Removed unfinished and abandoned sup-sync-back-mbox.
+
+Safer mime-view attachment file name handling, a temp file name is used
+while the extension is only used if it is alphanumeric.
+
+The migration script for YAML documents is now deprecated for ruby > 2.1
+and will be removed in the future.
+
 Release 0.15.4:
 
 Bugfixes.
diff --git a/bin/sup-psych-ify-config-files b/bin/sup-psych-ify-config-files
@@ -5,6 +5,11 @@ $:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
 require "sup"
 require "fileutils"
 
+if RUBY_VERSION >= "2.1"
+  puts "YAML migration is deprecated by Ruby 2.1 and newer."
+  exit
+end
+
 Redwood.start
 
 fn = Redwood::SOURCE_FN
diff --git a/bin/sup-sync-back-mbox b/bin/sup-sync-back-mbox
@@ -1,181 +0,0 @@
-#!/usr/bin/env ruby
-
-$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
-
-require 'rubygems'
-require 'uri'
-require 'tempfile'
-require 'trollop'
-require "sup"
-
-fail "not working yet"
-
-## save a message 'm' to an open file pointer 'fp'
-def save m, fp
-  m.source.each_raw_message_line(m.source_info) { |l| fp.print l }
-end
-def die msg
-  $stderr.puts "Error: #{msg}"
-  exit(-1)
-end
-def has_any_from_source_with_label? index, source, label
-  query = { :source_id => source.id, :label => label, :limit => 1, :load_spam => true, :load_deleted => true, :load_killed => true }
-  index.num_results_for(query) != 0
-end
-
-opts = Trollop::options do
-  version "sup-sync-back-mbox (sup #{Redwood::VERSION})"
-  banner <<EOS
-Drop or move messages from Sup sources that are marked as deleted or
-spam in the Sup index.
-
-Currently only works with mbox sources.
-
-Usage:
-  sup-sync-back-mbox [options] <source>*
-
-where <source>* is zero or more source URIs. If no sources are given,
-sync back all usual sources.
-
-You almost certainly want to run sup-sync --changed after this command.
-Running this does not change the index.
-
-Options include:
-EOS
-  opt :drop_deleted, "Drop deleted messages.", :default => false, :short => "d"
-  opt :move_deleted, "Move deleted messages to a local mbox file.", :type => String, :short => :none
-  opt :drop_spam, "Drop spam messages.", :default => false, :short => "s"
-  opt :move_spam, "Move spam messages to a local mbox file.", :type => String, :short => :none
-
-  opt :with_dotlockfile, "Specific dotlockfile location (mbox files only).", :default => "/usr/bin/dotlockfile", :short => :none
-  opt :dont_use_dotlockfile, "Don't use dotlockfile to lock mbox files. Dangerous if other processes modify them concurrently.", :default => false, :short => :none
-
-  opt :verbose, "Print message ids as they're processed."
-  opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n"
-  opt :version, "Show version information", :short => :none
-
-  conflicts :drop_deleted, :move_deleted
-  conflicts :drop_spam, :move_spam
-end
-
-unless opts[:drop_deleted] || opts[:move_deleted] || opts[:drop_spam] || opts[:move_spam]
-  puts <<EOS
-Nothing to do. Please specify at least one of --drop-deleted, --move-deleted,
---drop-spam, or --move-spam.
-EOS
-
-  exit
-end
-
-Redwood::start
-index = Redwood::Index.init
-index.lock_interactively or exit
-
-deleted_fp, spam_fp = nil
-unless opts[:dry_run]
-  deleted_fp = File.open(opts[:move_deleted], "a") if opts[:move_deleted]
-  spam_fp = File.open(opts[:move_spam], "a") if opts[:move_spam]
-end
-
-dotlockfile = opts[:with_dotlockfile] || "/usr/bin/dotlockfile"
-
-begin
-  index.load
-
-  sources = ARGV.map do |uri|
-    s = Redwood::SourceManager.source_for(uri) or die "unknown source: #{uri}. Did you add it with sup-add first?"
-    s.is_a?(Redwood::MBox) or die "#{uri} is not an mbox source."
-    s
-  end
-
-  if sources.empty?
-    sources = Redwood::SourceManager.usual_sources.select { |s| s.is_a? Redwood::MBox }
-  end
-
-  unless sources.all? { |s| s.file_path.nil? } || File.executable?(dotlockfile) || opts[:dont_use_dotlockfile]
-    die <<EOS
-can't execute dotlockfile binary: #{dotlockfile}. Specify --with-dotlockfile
-if it's in a nonstandard location, or, if you want to live dangerously, try
---dont-use-dotlockfile
-EOS
-  end
-
-  modified_sources = []
-  sources.each do |source|
-    $stderr.puts "Scanning #{source}..."
-
-    unless ((opts[:drop_deleted] || opts[:move_deleted]) && has_any_from_source_with_label?(index, source, :deleted)) || ((opts[:drop_spam] || opts[:move_spam]) && has_any_from_source_with_label?(index, source, :spam))
-      $stderr.puts "Nothing to do from this source; skipping"
-      next
-    end
-
-    source.reset!
-    num_dropped = num_moved = num_scanned = 0
-
-    out_fp = Tempfile.new "sup-sync-back-mbox-#{source.id}"
-    Redwood::PollManager.each_message_from source do |m|
-      num_scanned += 1
-
-      if(m_old = index.build_message(m.id))
-        labels = m_old.labels
-
-        if labels.member? :deleted
-          if opts[:drop_deleted]
-            puts "Dropping deleted message #{source}##{m.source_info}" if opts[:verbose]
-            num_dropped += 1
-          elsif opts[:move_deleted] && labels.member?(:deleted)
-            puts "Moving deleted message #{source}##{m.source_info}" if opts[:verbose]
-            save m, deleted_fp unless opts[:dry_run]
-            num_moved += 1
-          end
-
-        elsif labels.member? :spam
-          if opts[:drop_spam]
-            puts "Dropping spam message #{source}##{m.source_info}" if opts[:verbose]
-            num_dropped += 1
-          elsif opts[:move_spam] && labels.member?(:spam)
-            puts "Moving spam message #{source}##{m.source_info}" if opts[:verbose]
-            save m, spam_fp unless opts[:dry_run]
-            num_moved += 1
-          end
-        else
-          save m, out_fp unless opts[:dry_run]
-        end
-      else
-        save m, out_fp unless opts[:dry_run]
-      end
-    end
-    $stderr.puts "Scanned #{num_scanned}, dropped #{num_dropped}, moved #{num_moved} messages from #{source}."
-    modified_sources << source if num_dropped > 0 || num_moved > 0
-    out_fp.close unless opts[:dry_run]
-
-    unless opts[:dry_run] || (num_dropped == 0 && num_moved == 0)
-      deleted_fp.flush if deleted_fp
-      spam_fp.flush if spam_fp
-      unless opts[:dont_use_dotlockfile]
-        puts "Locking #{source.file_path}..."
-        system "#{opts[:dotlockfile]} -l #{source.file_path}"
-        puts "Writing #{source.file_path}..."
-        FileUtils.cp out_fp.path, source.file_path
-        puts "Unlocking #{source.file_path}..."
-        system "#{opts[:dotlockfile]} -u #{source.file_path}"
-      end
-    end
-  end
-
-  unless opts[:dry_run]
-    deleted_fp.close if deleted_fp
-    spam_fp.close if spam_fp
-  end
-
-  $stderr.puts "Done."
-  unless modified_sources.empty?
-    $stderr.puts "You should now run: sup-sync --changed #{modified_sources.join(' ')}"
-  end
-rescue Exception => e
-  File.open("sup-exception-log.txt", "w") { |f| f.puts e.backtrace }
-  raise
-ensure
-  Redwood::finish
-  index.unlock
-end
diff --git a/contrib/completion/_sup.zsh b/contrib/completion/_sup.zsh
@@ -1,8 +1,8 @@
-#compdef sup sup-add sup-config sup-dump sup-sync sup-sync-back-mbox sup-tweak-labels sup-recover-sources
+#compdef sup sup-add sup-config sup-dump sup-sync sup-tweak-labels sup-recover-sources
 # vim: set et sw=2 sts=2 ts=2 ft=zsh :
 
 # TODO: sources completion: maildir://some/dir, mbox://some/file, ...
-#       for sup-add, sup-sync, sup-sync-back-mbox, sup-tweak-labels
+#       for sup-add, sup-sync, sup-tweak-labels
 
 (( ${+functions[_sup_cmd]} )) ||
 _sup_cmd()
diff --git a/lib/sup/interactive_lock.rb b/lib/sup/interactive_lock.rb
@@ -25,49 +25,64 @@ module InteractiveLock
     begin
       Index.lock
     rescue Index::LockError => e
-      stream.puts <<EOS
-Error: the index is locked by another process! User '#{e.user}' on
-host '#{e.host}' is running #{e.pname} with pid #{e.pid}.
-The process was alive as of at least #{time_ago_in_words e.mtime} ago.
+      begin
+        Process.kill 0, e.pid.to_i # 0 signal test the existence of PID
+        stream.puts <<EOS
+  Error: the index is locked by another process! User '#{e.user}' on
+  host '#{e.host}' is running #{e.pname} with pid #{e.pid}.
+  The process was alive as of at least #{time_ago_in_words e.mtime} ago.
 
 EOS
-      stream.print "Should I ask that process to kill itself (y/n)? "
-      stream.flush
-
-      success = if $stdin.gets =~ /^\s*y(es)?\s*$/i
-        stream.puts "Ok, trying to kill process..."
-
-        begin
+        stream.print "Should I ask that process to kill itself (y/n)? "
+        stream.flush
+        if $stdin.gets =~ /^\s*y(es)?\s*$/i
           Process.kill "TERM", e.pid.to_i
           sleep DELAY
-        rescue Errno::ESRCH # no such process
-          stream.puts "Hm, I couldn't kill it."
+          stream.puts "Let's try that again."
+          begin
+            Index.lock
+          rescue Index::LockError => e
+            stream.puts "I couldn't lock the index. The lockfile might just be stale."
+            stream.print "Should I just remove it and continue? (y/n) "
+            stream.flush
+            if $stdin.gets =~ /^\s*y(es)?\s*$/i
+              begin
+                FileUtils.rm e.path
+              rescue Errno::ENOENT
+                stream.puts "The lockfile doesn't exists. We continue."
+              end
+              stream.puts "Let's try that one more time."
+              begin
+                Index.lock
+              rescue Index::LockError => e
+                stream.puts "I couldn't unlock the index."
+                return false
+              end
+              return true
+            end
+          end
         end
-
-        stream.puts "Let's try that again."
+      rescue Errno::ESRCH # no such process
+        stream.puts "I couldn't lock the index. The lockfile might just be stale."
+        begin
+          FileUtils.rm e.path
+        rescue Errno::ENOENT
+          stream.puts "The lockfile doesn't exists. We continue."
+        end
+        stream.puts "Let's try that one more time."
         begin
+          sleep DELAY
           Index.lock
         rescue Index::LockError => e
-          stream.puts "I couldn't lock the index. The lockfile might just be stale."
-          stream.print "Should I just remove it and continue? (y/n) "
-          stream.flush
-
-          if $stdin.gets =~ /^\s*y(es)?\s*$/i
-            FileUtils.rm e.path
-
-            stream.puts "Let's try that one more time."
-            begin
-              Index.lock
-              true
-            rescue Index::LockError => e
-            end
-          end
+          stream.puts "I couldn't unlock the index."
+          return false
         end
+        return true
       end
-
-      stream.puts "Sorry, couldn't unlock the index." unless success
-      success
+      stream.puts "Sorry, couldn't unlock the index."
+      return false
     end
+    return true
   end
 end
 
diff --git a/lib/sup/mbox.rb b/lib/sup/mbox.rb
@@ -119,8 +119,6 @@ class MBox < Source
   ## we're just moving messages around on disk, than reading things
   ## into memory with raw_message.
   ##
-  ## i hoped never to have to move shit around on disk but
-  ## sup-sync-back-mbox has to do it.
   def each_raw_message_line offset
     @mutex.synchronize do
       ensure_open
diff --git a/lib/sup/message_chunks.rb b/lib/sup/message_chunks.rb
@@ -60,8 +60,6 @@ end
 module Redwood
 module Chunk
   class Attachment
-    ## please see note in write_to_disk on important usage
-    ## of quotes to avoid remote command injection.
     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
@@ -79,8 +77,6 @@ Return value:
 EOS
 
 
-    ## please see note in write_to_disk on important usage
-    ## of quotes to avoid remote command injection.
     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.
@@ -132,8 +128,6 @@ EOS
       when /^text\/plain\b/
         @raw_content
       else
-        ## please see note in write_to_disk on important usage
-        ## of quotes to avoid remote command injection.
         HookManager.run "mime-decode", :content_type => @content_type,
                         :filename => lambda { write_to_disk },
                         :charset => encoded_content.charset,
@@ -171,8 +165,6 @@ EOS
     def initial_state; :open end
     def viewable?; @lines.nil? end
     def view_default! path
-      ## please see note in write_to_disk on important usage
-      ## of quotes to avoid remote command injection.
       case RbConfig::CONFIG['arch']
         when /darwin/
           cmd = "open #{path}"
@@ -185,28 +177,32 @@ EOS
     end
 
     def view!
-      ## please see note in write_to_disk on important usage
-      ## of quotes to avoid remote command injection.
-      write_to_disk do |file|
-
-        @@view_tempfiles.push file # make sure the tempfile is not garbage collected before sup stops
-
+      write_to_disk do |path|
         ret = HookManager.run "mime-view", :content_type => @content_type,
-                                           :filename => file.path
-        ret || view_default!(file.path)
+                                           :filename => path
+        ret || view_default!(path)
       end
     end
 
-    ## note that the path returned from write_to_disk is
-    ## Shellwords.escaped and is intended to be used without single
-    ## or double quotes. the use of either opens sup up for remote
-    ## code injection through the file name.
     def write_to_disk
       begin
-        file = Tempfile.new(["sup", Shellwords.escape(@filename.gsub("/", "_")) || "sup-attachment"])
+        # Add the original extension to the generated tempfile name only if the
+        # extension is "safe" (won't be interpreted by the shell).  Since
+        # Tempfile.new always generates safe file names this should prevent
+        # attacking the user with funny attachment file names.
+        tempname = if (File.extname @filename) =~ /^\.[[:alnum:]]+$/ then
+                     ["sup-attachment", File.extname(@filename)]
+                   else
+                     "sup-attachment"
+                   end
+
+        file = Tempfile.new(tempname)
         file.print @raw_content
         file.flush
-        yield file if block_given?
+
+        @@view_tempfiles.push file # make sure the tempfile is not garbage collected before sup stops
+
+        yield file.path if block_given?
         return file.path
       ensure
         file.close
diff --git a/lib/sup/modes/contact_list_mode.rb b/lib/sup/modes/contact_list_mode.rb
@@ -130,7 +130,7 @@ protected
   def text_for_contact p
     aalias = ContactManager.alias_for(p) || ""
     [[:tagged_color, @tags.tagged?(p) ? ">" : " "],
-     [:none, sprintf("%-#{@awidth}s %-#{@nwidth}s %s", aalias, p.name, p.email)]]
+     [:text_color, sprintf("%-#{@awidth}s %-#{@nwidth}s %s", aalias, p.name, p.email)]]
   end
 
   def regen_text
diff --git a/lib/sup/modes/inbox_mode.rb b/lib/sup/modes/inbox_mode.rb
@@ -6,7 +6,6 @@ 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
 
@@ -64,47 +63,6 @@ class InboxMode < ThreadIndexMode
     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
diff --git a/lib/sup/modes/thread_index_mode.rb b/lib/sup/modes/thread_index_mode.rb
@@ -33,6 +33,7 @@ EOS
     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 :read_and_archive, "Archive thread (remove from inbox) and mark read", 'A'
     k.add :cancel_search, "Cancel current search", :ctrl_g
     k.add :reload, "Refresh view", '@'
     k.add :toggle_archived, "Toggle archived status", 'a'
@@ -732,6 +733,47 @@ EOS
   end
   ignore_concurrent_calls :load_threads
 
+  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 resize rows, cols
     regen_text
     super
diff --git a/lib/sup/modes/thread_view_mode.rb b/lib/sup/modes/thread_view_mode.rb
@@ -692,6 +692,15 @@ EOS
     end
   end
 
+
+  def status
+    user_labels = @thread.labels.to_a.map do |l|
+      l.to_s if LabelManager.user_defined_labels.member?(l)
+    end.compact.join(",")
+    user_labels = (user_labels.empty? and "" or "<#{user_labels}>")
+    [user_labels, super].join(" -- ")
+  end
+
 private
 
   def initial_state_for m
diff --git a/lib/sup/person.rb b/lib/sup/person.rb
@@ -15,7 +15,7 @@ class Person
       name.gsub('\\\\', '\\')
     end
 
-    @email = email.strip.gsub(/\s+/, " ").downcase
+    @email = email.strip.gsub(/\s+/, " ")
   end
 
   def to_s; "#@name <#@email>" end
diff --git a/lib/sup/source.rb b/lib/sup/source.rb
@@ -68,7 +68,7 @@ class Source
     @poll_lock = Monitor.new
   end
 
-  ## overwrite me if you have a disk incarnation (currently used only for sup-sync-back-mbox)
+  ## overwrite me if you have a disk incarnation
   def file_path; nil end
 
   def to_s; @uri.to_s; end
diff --git a/lib/sup/util.rb b/lib/sup/util.rb
@@ -267,6 +267,14 @@ end
 class String
   def display_length
     @display_length ||= Unicode.width(self.fix_encoding!, false)
+
+    # if Unicode.width fails and returns -1, fall back to
+    # regular String#length, see pull-request: #256.
+    if @display_length < 0
+      @display_length = self.length
+    end
+
+    @display_length
   end
 
   def slice_by_display_length len
diff --git a/sup.gemspec b/sup.gemspec
@@ -5,8 +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-maildir sup-sync-back-mbox
-  sup-tweak-labels sup-psych-ify-config-files)
+  sup-recover-sources sup-sync sup-sync-back-maildir sup-tweak-labels
+  sup-psych-ify-config-files)
 SUP_EXTRA_FILES = %w(CONTRIBUTORS README.md LICENSE History.txt ReleaseNotes)
 SUP_FILES =
   SUP_EXTRA_FILES +
diff --git a/test/messages/missing-line.eml b/test/messages/missing-line.eml
@@ -0,0 +1,9 @@
+From: foo@aol.com
+To: foo@test.com
+Subject: Encoding bug
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+This is =91 a test: the first line seems to disappear from the mail body but is
+still visible in the thread view.
+
diff --git a/test/test_messages_dir.rb b/test/test_messages_dir.rb
@@ -105,6 +105,41 @@ class TestMessagesDir < ::Minitest::Unit::TestCase
     # lines should contain an error message
     assert (lines.join.include? "An error occurred while loading this message."), "This message should not load successfully"
   end
+
+  def test_missing_line
+    message = ''
+    File.open 'test/messages/missing-line.eml' do |f|
+      message = f.read
+    end
+
+    source = DummySource.new("sup-test://test_messages")
+    source.messages = [ message ]
+    source_info = 0
+
+    sup_message = Message.build_from_source(source, source_info)
+    sup_message.load_from_source!
+
+    from = sup_message.from
+    # "from" is just a simple person item
+
+    assert_equal("foo@aol.com", from.email)
+    #assert_equal("Fake Sender", from.name)
+
+    subj = sup_message.subj
+    assert_equal("Encoding bug", subj)
+
+    chunks = sup_message.load_from_source!
+    indexable_chunks = sup_message.indexable_chunks
+
+    # there should be only one chunk
+    #assert_equal(1, chunks.length)
+
+    lines = chunks[0].lines
+
+    badline = lines[0]
+    assert (badline.display_length > 0), "The length of this line should greater than 0: #{badline}"
+
+  end
 end
 
 end
diff --git a/test/test_yaml_migration.rb b/test/test_yaml_migration.rb
@@ -3,6 +3,7 @@ require "test_helper"
 require "sup"
 require "psych"
 
+if RUBY_VERSION < "2.1"
 describe "Sup's YAML util" do
   describe "Module#yaml_properties" do
     def build_class_with_name name, &b
@@ -78,3 +79,7 @@ id: ID
     end
   end
 end
+
+else
+  puts "Some YAML tests are skipped on Ruby 2.1.0 and newer."
+end