sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 7c18aba4d4e6156056206afb77dea19d1b442a61
parent 36c79336bbd1c3bb7df1efb80bb6415aa54b6e4e
Author: Gaute Hope <eg@gaute.vetsj.com>
Date:   Thu, 12 Feb 2015 19:59:00 +0100

Merge branch 'develop'

Diffstat:
M .gitignore | 3 +++
M .travis.yml | 2 +-
M CONTRIBUTORS | 27 +++++++++++++++------------
M History.txt | 16 ++++++++++++++++
M ReleaseNotes | 7 +++++++
M bin/sup | 34 ++++++++++------------------------
M bin/sup-sync-back-maildir | 2 +-
A contrib/completion/_sup.bash | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M lib/sup.rb | 14 +++++++-------
M lib/sup/colormap.rb | 7 +++++--
M lib/sup/contact.rb | 6 ++++--
M lib/sup/crypto.rb | 36 ++++++++++++++++++++++++++++++++++--
M lib/sup/draft.rb | 14 +++++++-------
M lib/sup/hook.rb | 2 +-
M lib/sup/index.rb | 4 ++--
M lib/sup/label.rb | 2 +-
M lib/sup/maildir.rb | 4 ++--
M lib/sup/mbox.rb | 4 ++--
M lib/sup/message.rb | 6 ++++++
M lib/sup/message_chunks.rb | 6 ++++--
M lib/sup/mode.rb | 57 +++++++++++++++++++++++++++++++--------------------------
M lib/sup/modes/edit_message_mode.rb | 2 +-
M lib/sup/modes/forward_mode.rb | 25 ++++++++++++++++++++++---
M lib/sup/modes/line_cursor_mode.rb | 2 +-
M lib/sup/modes/text_mode.rb | 7 ++++++-
M lib/sup/modes/thread_index_mode.rb | 2 +-
M lib/sup/modes/thread_view_mode.rb | 53 +++++++++++++++++++++++++++++++++++++++++++++++------
M lib/sup/person.rb | 129 ++++++++++++++++++++++++++++++++++++++++++-------------------------------------
M lib/sup/search.rb | 2 +-
M lib/sup/sent.rb | 2 +-
A lib/sup/util/locale_fiddler.rb | 24 ++++++++++++++++++++++++
M sup.gemspec | 7 ++++---
M test/integration/test_maildir.rb | 2 +-
M test/integration/test_mbox.rb | 2 +-
M test/test_crypto.rb | 2 +-
M test/test_header_parsing.rb | 2 +-
M test/test_message.rb | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
M test/test_messages_dir.rb | 20 +-------------------
M test/test_yaml_regressions.rb | 2 +-
A test/unit/fixtures/contacts.txt | 2 ++
A test/unit/test_contact.rb | 34 ++++++++++++++++++++++++++++++++++
A test/unit/test_locale_fiddler.rb | 15 +++++++++++++++
A test/unit/test_person.rb | 38 ++++++++++++++++++++++++++++++++++++++
43 files changed, 609 insertions(+), 216 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -16,4 +16,7 @@ Gemfile.lock
 # generated file for gnupg test
 test/gnupg_test_home/random_seed
 test/gnupg_test_home/trustdb.gpg
+test/gnupg_test_home/.gpg-v21-migrated
+test/gnupg_test_home/private-keys-v1.d
+
 
diff --git a/.travis.yml b/.travis.yml
@@ -3,7 +3,7 @@ language: ruby
 rvm:
   - 2.1.1
   - 2.0.0
-  - 1.9.3
+  - 2.2.0
 
 before_install:
   - sudo apt-get update -qq
diff --git a/CONTRIBUTORS b/CONTRIBUTORS
@@ -9,23 +9,25 @@ Eric Weikl <eric.weikl at the gmx dot nets>
 Paweł Wilk <siefca at the gnu dot orgs>
 Ismo Puustinen <ismo at the iki dot fis>
 Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
+Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
 Michael Stapelberg <michael at the stapelberg dot des>
 Eric Sherman <hyperbolist at the gmail dot coms>
+Zeger-Jan van de Weg <mail at the zjvandeweg dot nls>
 Tero Tilus <tero at the tilus dot nets>
 Ben Walton <bwalton at the artsci.utoronto dot cas>
 Scott Bonds <scott at the ggr dot coms>
 Mike Stipicevic <stipim at the rpi dot edus>
 Martin Bähr <mbaehr at the societyserver dot orgs>
-Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
+Timon Vonk <timonv at the gmail dot coms>
 Clint Byrum <clint at the ubuntu dot coms>
 Wael M. Nasreddine <wael.nasreddine at the gmail 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>
+Per Andersson <avtobiff 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>
-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>
@@ -38,49 +40,50 @@ Markus Klinik <mkl at the lambdanaut dot nets>
 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>
+Jonathan Lassoff <jof at the thejof dot coms>
 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>
-James Taylor <james at the jamestaylor dot orgs>
 Jason Petsod <jason at the petsod dot orgs>
+James Taylor <james at the jamestaylor dot orgs>
 Steve Goldman <sgoldman at the tower-research dot coms>
 Robin Burchell <viroteck at the viroteck dot nets>
 Peter Harkins <ph at the malaprop dot orgs>
 Decklin Foster <decklin at the red-bean dot coms>
+rjg-vB <rthrd at the web dot des>
 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>
+Carl Worth <cworth at the cworth dot orgs>
 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>
 PaulSmecker <paul.smecker at the gmail dot coms>
-Per Andersson <avtobiff at the gmail dot coms>
 Ruthard Baudach <rthrd at the web dot des>
 Kornilios Kourtis <kkourt at the cslab.ece.ntua dot grs>
 Lars Fischer <fischer at the wiwi.uni-siegen dot des>
 madhat2r <MaDhAt2r at the dukefoo dot coms>
-Giorgio Lando <patroclo7 at the gmail dot coms>
 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>
 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>
-Adam Lloyd <adam at the alloy-d dot nets>
 Todd Eisenberger <teisenbe at the andrew.cmu dot edus>
-0xACE <0xACE at the users.noreply.github dot coms>
 MichaelRevell <mikearevell at the gmail dot coms>
+Adam Lloyd <adam at the alloy-d dot nets>
+0xACE <0xACE at the users.noreply.github dot coms>
 Gregor Hoffleit <gregor at the sam.mediasupervision dot des>
-Steven Schmeiser <steven at the schmeiser dot orgs>
+Sharif Olorin <sio at the tesser dot orgs>
 Steven Walter <swalter at the monarch.(none)>
-Jon M. Dugan <jdugan at the es dot nets>
-Horacio Sanson <horacio at the skillupjapan.co dot jps>
+Steven Schmeiser <steven at the schmeiser dot orgs>
 Stefan Lundström <lundst at the snabb.(none)>
 William A. Kennington III <william at the wkennington dot coms>
 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>
 Matthias Vallentin <vallentin at the icir dot orgs>
 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,19 @@
+== 0.21.0 / 2015-02-12
+
+* Key binding to fetch GPG key from keyserver (Matthieu Rakotojaona)
+* Replace occurences of File.exists? with File.exist? (Zeger-Jan van de
+  Weg)
+* You can now unsubscribe from mailinglists using an url, if you have a
+  goto-hook setup (Timon Vonk).
+* Forward attribution can be customized using the forward-attribution
+  hook (Ruthard Baudach)
+* Do a few more checks for buffer not nil in the hope to fix a few
+  random crashes
+* Add bash completion (Per Andersson)
+* Replace dl/import with Fiddle (Timon Vonk)
+* Drop support for ruby 1.9.3
+* Add tests for contact manager and persons (Zeger-Jan van de Weg)
+
 == 0.20.0 / 2014-10-06
 
 * add man-pages (generated from wiki) (Per Andersson)!
diff --git a/ReleaseNotes b/ReleaseNotes
@@ -1,3 +1,10 @@
+Release 0.21.0:
+
+Several small features as well as polishing (including fetching a GPG key with
+a shortcut and unsubscribing from mailinglist using an url). Several old
+deprecated parts of sup have been modernized. Support for Ruby 1.9.3 has been
+dropped. Have a look in History.txt for the details.
+
 Release 0.20.0:
 
 We've got man pages (Mr. Andersson)! We've got OpenBSD support (Scott Bonds)!
diff --git a/bin/sup b/bin/sup
@@ -7,6 +7,7 @@ require 'rubygems'
 require 'ncursesw'
 
 require 'sup/util/ncurses'
+require 'sup/util/locale_fiddler'
 
 no_gpgme = false
 begin
@@ -102,32 +103,17 @@ global_keymap = Keymap.new do |k|
   end
 end
 
-## the following magic enables wide characters when used with a ruby
-## 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.
 require 'rbconfig'
-unless RbConfig::CONFIG['arch'] =~ /openbsd/
-  require 'dl/import'
-  module LibC
-    extend DL.const_defined?(:Importer) ? DL::Importer : DL::Importable
-    setlocale_lib = case RbConfig::CONFIG['arch']
-      when /darwin/; "libc.dylib"
-      when /cygwin/; "cygwin1.dll"
-      when /freebsd/; "libc.so.7"
-      else; "libc.so.6"
-    end
 
-    debug "dynamically loading setlocale() from #{setlocale_lib}"
-    begin
-      dlload setlocale_lib
-      extern "void setlocale(int, const char *)"
-      debug "setting locale..."
-      LibC.setlocale(6, "")  # LC_ALL == 6
-    rescue RuntimeError => e
-      warn "cannot dlload setlocale(); ncurses wide character support probably broken."
-      warn "dlload error was #{e.class}: #{e.message}"
-    end
+unless RbConfig::CONFIG['arch'] =~ /openbsd/
+  debug "dynamically loading setlocale()"
+  begin
+    class LibC; extend LocaleFiddler; end
+    debug "setting locale..."
+    LibC.setlocale(6, "")
+  rescue RuntimeError => e
+    warn "cannot dlload setlocale(); ncurses wide character support probably broken."
+    warn "dlload error was #{e.class}: #{e.message}"
   end
 end
 
diff --git a/bin/sup-sync-back-maildir b/bin/sup-sync-back-maildir
@@ -60,7 +60,7 @@ $config[:sync_back_to_maildir] = true
 
 begin
   sync_performed = []
-  sync_performed = File.readlines(Redwood::SYNC_OK_FN).collect { |e| e.strip }.find_all { |e| not e.empty? } if File.exists? Redwood::SYNC_OK_FN
+  sync_performed = File.readlines(Redwood::SYNC_OK_FN).collect { |e| e.strip }.find_all { |e| not e.empty? } if File.exist? Redwood::SYNC_OK_FN
   sources = []
 
   ## Try to find out sources given in parameters
diff --git a/contrib/completion/_sup.bash b/contrib/completion/_sup.bash
@@ -0,0 +1,102 @@
+# Sup Bash completion
+#
+# * Complete options for all Sup commands.
+# * Disable completion for next option when current option takes an argument.
+# * Complete sources, directories, and files, where applicable.
+
+_sup_cmds() {
+    local cur prev opts sources
+    COMPREPLY=()
+    cur="${COMP_WORDS[COMP_CWORD]}"
+    prev="${COMP_WORDS[COMP_CWORD-1]}"
+    sources="$(sed -n '/uri:/ {s/.*uri:\s*//p}' $HOME/.sup/sources.yaml)"
+
+    case "${1##/*}" in
+        sup-add)
+            opts="--archive -a --unusual -u --sync-back --no-sync-back -s
+                  --labels -l --force-new -f --force-account -o --version -v
+                  --help -h mbox: maildir:"
+
+            case $prev in
+                --labels|-l|--force-account|-o)
+                    COMPREPLY=()
+                    return 0
+                    ;;
+            esac
+            ;;
+        sup-config|sup-dump)
+            opts="--version -v --help -h"
+            ;;
+        sup-import-dump)
+           opts="--verbose -v --ignore-missing -i --warn-missing -w
+                 --abort-missing -a --atomic -t --dry-run -n --version --help
+                 -h"
+            ;;
+        sup)
+            opts="--list-hooks -l --no-threads -n --no-initial-poll -o --search
+                  -s --compose -c --subject -j --version -v --help -h"
+
+            case $prev in
+                --search|-s|--compose|-c|--subject|-j)
+                    COMPREPLY=()
+                    return 0
+                    ;;
+            esac
+            ;;
+        sup-recover-sources)
+            opts="--unusual --archive --scan-num --help -h $sources"
+
+            case $prev in
+                --scan-num)
+                    COMPREPLY=()
+                    return 0
+                    ;;
+            esac
+            ;;
+        sup-sync)
+            opts="--asis --restore --discard --archive -x --read -r
+                  --extra-labels --verbose -v --optimize -o --all-sources
+                  --dry-run -n --version --help -h ${sources}"
+
+
+            case $prev in
+                --restore|--extra-labels)
+                    COMPREPLY=()
+                    return 0
+                    ;;
+            esac
+            ;;
+        sup-sync-back-maildir)
+            maildir_sources="$(echo $sources | tr ' ' '\n' | grep maildir)"
+            opts="--no-confirm -n --no-merge -m --list-sources -l
+                  --unusual-sources-too -u --version -v --help -h
+                  $maildir_sources"
+            ;;
+        sup-tweak-labels)
+            opts="--add -a --remove -r --query -q --verbose -v --very-verbose
+                  -e --all-sources --dry-run -n --no-sync-back -o --version
+                  --help -h $sources"
+
+            case $prev in
+                --add|-a|--remove|-r|--query|-q)
+                    COMPREPLY=()
+                    return 0
+                    ;;
+            esac
+            ;;
+    esac
+
+    COMPREPLY=( $(compgen -W "$opts" -- ${cur}) )
+    return 0
+}
+
+complete -F _sup_cmds sup \
+                      sup-add \
+                      sup-config \
+                      sup-dump \
+                      sup-recover-sources \
+                      sup-sync \
+                      sup-sync-back-maildir \
+                      sup-tweak-labels
+
+complete -F _sup_cmds -o filenames -o plusdirs sup-import-dump
diff --git a/lib/sup.rb b/lib/sup.rb
@@ -105,7 +105,7 @@ module Redwood
       o
     end
 
-    mode = if File.exists? fn
+    mode = if File.exist? fn
       File.stat(fn).mode
     else
       0600
@@ -113,7 +113,7 @@ module Redwood
 
     if backup
       backup_fn = fn + '.bak'
-      if File.exists?(fn) && File.size(fn) > 0
+      if File.exist?(fn) && File.size(fn) > 0
         File.open(backup_fn, "w", mode) do |f|
           File.open(fn, "r") { |old_f| FileUtils.copy_stream old_f, f }
           f.fsync
@@ -139,7 +139,7 @@ module Redwood
   end
 
   def load_yaml_obj fn, compress=false
-    o = if File.exists? fn
+    o = if File.exist? fn
       if compress
         Zlib::GzipReader.open(fn) { |f| YAML::load f }
       else
@@ -180,7 +180,7 @@ module Redwood
     return if bypass_sync_check
 
     if $config[:sync_back_to_maildir]
-      if not File.exists? Redwood::SYNC_OK_FN
+      if not File.exist? Redwood::SYNC_OK_FN
         Redwood.warn_syncback <<EOS
 It appears that the "sync_back_to_maildir" option has been changed
 from false to true since the last execution of sup.
@@ -191,14 +191,14 @@ Should I complain about this again? (Y/n)
 EOS
         File.open(Redwood::SYNC_OK_FN, 'w') {|f| f.write(Redwood::MAILDIR_SYNC_CHECK_SKIPPED) } if STDIN.gets.chomp.downcase == 'n'
       end
-    elsif not $config[:sync_back_to_maildir] and File.exists? Redwood::SYNC_OK_FN
+    elsif not $config[:sync_back_to_maildir] and File.exist? Redwood::SYNC_OK_FN
       File.delete(Redwood::SYNC_OK_FN)
     end
   end
 
   def check_syncback_settings
     # don't check if syncback was never performed
-    return unless File.exists? Redwood::SYNC_OK_FN
+    return unless File.exist? Redwood::SYNC_OK_FN
     active_sync_sources = File.readlines(Redwood::SYNC_OK_FN).collect { |e| e.strip }.find_all { |e| not e.empty? }
     return if active_sync_sources.length == 1 and active_sync_sources[0] == Redwood::MAILDIR_SYNC_CHECK_SKIPPED
     sources = SourceManager.sources
@@ -338,7 +338,7 @@ EOM
       :continuous_scroll => false,
       :always_edit_async => false,
     }
-    if File.exists? filename
+    if File.exist? filename
       config = Redwood::load_yaml_obj filename
       abort "#{filename} is not a valid configuration file (it's a #{config.class}, not a hash)" unless config.is_a?(Hash)
       default_config.merge config
diff --git a/lib/sup/colormap.rb b/lib/sup/colormap.rb
@@ -17,6 +17,9 @@ module Ncurses
 
     ## xterm 24-shade grayscale
     24.times { |x| color! "g#{x}", (16+6*6*6) + x }
+  elsif Ncurses::NUM_COLORS == -1
+    ## Terminal emulator doesn't appear to support colors
+    fail "sup must be run in a terminal with color support, please check your TERM variable."
   end
 end
 
@@ -186,13 +189,13 @@ class Colormap
   ## Try to use the user defined colors, in case of an error fall back
   ## to the default ones.
   def populate_colormap
-    user_colors = if File.exists? Redwood::COLOR_FN
+    user_colors = if File.exist? Redwood::COLOR_FN
       debug "loading user colors from #{Redwood::COLOR_FN}"
       Redwood::load_yaml_obj Redwood::COLOR_FN
     end
 
     ## Set attachment sybmol to sane default for existing colorschemes
-    if user_colors and user_colors.has_key? :to_me 
+    if user_colors and user_colors.has_key? :to_me
       user_colors[:with_attachment] = user_colors[:to_me] unless user_colors.has_key? :with_attachment
     end
 
diff --git a/lib/sup/contact.rb b/lib/sup/contact.rb
@@ -16,7 +16,7 @@ class ContactManager
     @a2p = {} # alias to person
     @e2p = {} # email to person
 
-    if File.exists? fn
+    if File.exist? fn
       IO.foreach(fn) do |l|
         l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}"
         aalias, addr = $1, $2
@@ -29,11 +29,13 @@ class ContactManager
   def contacts_with_aliases; @a2p.values.uniq end
 
   def update_alias person, aalias=nil
+    ## Deleting old data if it exists
     old_aalias = @p2a[person]
-    if(old_aalias != nil and old_aalias != "") # remove old alias
+    if old_aalias
       @a2p.delete old_aalias
       @e2p.delete person.email
     end
+    ## Update with new data
     @p2a[person] = aalias
     unless aalias.nil? || aalias.empty?
       @a2p[aalias] = person
diff --git a/lib/sup/crypto.rb b/lib/sup/crypto.rb
@@ -16,6 +16,9 @@ class CryptoManager
     [:encrypt, "Encrypt only"]
   )
 
+  KEY_PATTERN = /(-----BEGIN PGP PUBLIC KEY BLOCK.*-----END PGP PUBLIC KEY BLOCK)/m
+  KEYSERVER_URL = "http://pool.sks-keyservers.net:11371/pks/lookup"
+
   HookManager.register "gpg-options", <<EOS
 Runs before gpg is called, allowing you to modify the options (most
 likely you would want to add something to certain commands, like
@@ -212,9 +215,10 @@ EOS
     unknown = false
     all_output_lines = []
     all_trusted = true
+    unknown_fingerprint = nil
 
     verify_result.signatures.each do |signature|
-      output_lines, trusted = sig_output_lines signature
+      output_lines, trusted, unknown_fingerprint = sig_output_lines signature
       all_output_lines << output_lines
       all_output_lines.flatten!
       all_trusted &&= trusted
@@ -242,6 +246,8 @@ EOS
       end
     elsif !unknown
       Chunk::CryptoNotice.new(:invalid, summary_line, all_output_lines)
+    elsif unknown_fingerprint
+      Chunk::CryptoNotice.new(:unknown_key, "Unable to determine validity of cryptographic signature", all_output_lines, unknown_fingerprint)
     else
       unknown_status all_output_lines
     end
@@ -351,6 +357,31 @@ EOS
     [notice, sig, msg]
   end
 
+  def retrieve fingerprint
+    require 'net/http'
+    uri = URI($config[:keyserver_url] || KEYSERVER_URL)
+    unless uri.scheme == "http" and not uri.host.nil?
+      return "Invalid url: #{uri}"
+    end
+
+    fingerprint = "0x" + fingerprint unless fingerprint[0..1] == "0x"
+    params = {op: "get", search: fingerprint}
+    uri.query = URI.encode_www_form(params)
+
+    begin
+      res = Net::HTTP.get_response(uri)
+    rescue SocketError # Host doesn't exist or we couldn't connect
+    end
+    return "Couldn't get key from keyserver at this address: #{uri}" unless res.is_a?(Net::HTTPSuccess)
+
+    match = KEY_PATTERN.match(res.body)
+    return "No key found" unless match && match.length > 0
+
+    GPGME::Key.import(match[0])
+
+    return nil
+  end
+
 private
 
   def unknown_status lines=[]
@@ -394,6 +425,7 @@ private
     rescue EOFError
       from_key = nil
       first_sig = "No public key available for #{signature.fingerprint}"
+      unknown_fpr = signature.fingerprint
     end
 
     time_line = "Signature made " + signature.timestamp.strftime("%a %d %b %Y %H:%M:%S %Z") +
@@ -422,7 +454,7 @@ private
       output_lines << HookManager.run("sig-output",
                                {:signature => signature, :from_key => from_key})
     end
-    return output_lines, trusted
+    return output_lines, trusted, unknown_fpr
   end
 
   def key_type key, fpr
diff --git a/lib/sup/draft.rb b/lib/sup/draft.rb
@@ -16,7 +16,7 @@ class DraftManager
   def write_draft
     offset = @source.gen_offset
     fn = @source.fn_for_offset offset
-    File.open(fn, "w") { |f| yield f }
+    File.open(fn, "w:UTF-8") { |f| yield f }
     PollManager.poll_from @source
   end
 
@@ -33,7 +33,7 @@ class DraftLoader < Source
   yaml_properties
 
   def initialize dir=Redwood::DRAFT_DIR
-    Dir.mkdir dir unless File.exists? dir
+    Dir.mkdir dir unless File.exist? dir
     super DraftManager.source_name, true, false
     @dir = dir
     @cur_offset = 0
@@ -62,7 +62,7 @@ class DraftLoader < Source
 
   def gen_offset
     i = 0
-    while File.exists? fn_for_offset(i)
+    while File.exist? fn_for_offset(i)
       i += 1
     end
     i
@@ -75,7 +75,7 @@ class DraftLoader < Source
   end
 
   def load_message offset
-    raise SourceError, "Draft not found" unless File.exists? fn_for_offset(offset)
+    raise SourceError, "Draft not found" unless File.exist? fn_for_offset(offset)
     File.open fn_for_offset(offset) do |f|
       RMail::Mailbox::MBoxReader.new(f).each_message do |input|
         return RMail::Parser.read(input)
@@ -85,7 +85,7 @@ class DraftLoader < Source
 
   def raw_header offset
     ret = ""
-    File.open fn_for_offset(offset) do |f|
+    File.open(fn_for_offset(offset), "r:UTF-8") do |f|
       until f.eof? || (l = f.gets) =~ /^$/
         ret += l
       end
@@ -94,13 +94,13 @@ class DraftLoader < Source
   end
 
   def each_raw_message_line offset
-    File.open(fn_for_offset(offset)) do |f|
+    File.open(fn_for_offset(offset), "r:UTF-8") do |f|
       yield f.gets until f.eof?
     end
   end
 
   def raw_message offset
-    IO.read(fn_for_offset(offset))
+    IO.read(fn_for_offset(offset), :encoding => "UTF-8")
   end
 
   def start_offset; 0; end
diff --git a/lib/sup/hook.rb b/lib/sup/hook.rb
@@ -83,7 +83,7 @@ class HookManager
     @contexts = {}
     @tags = {}
 
-    Dir.mkdir dir unless File.exists? dir
+    Dir.mkdir dir unless File.exist? dir
   end
 
   attr_reader :tags
diff --git a/lib/sup/index.rb b/lib/sup/index.rb
@@ -105,7 +105,7 @@ EOS
 
   def save
     debug "saving index and sources..."
-    FileUtils.mkdir_p @dir unless File.exists? @dir
+    FileUtils.mkdir_p @dir unless File.exist? @dir
     SourceManager.save_sources
     save_index
   end
@@ -116,7 +116,7 @@ EOS
 
   def load_index failsafe=false
     path = File.join(@dir, 'xapian')
-    if File.exists? path
+    if File.exist? path
       @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_OPEN)
       db_version = @xapian.get_metadata 'version'
       db_version = '0' if db_version.empty?
diff --git a/lib/sup/label.rb b/lib/sup/label.rb
@@ -15,7 +15,7 @@ class LabelManager
   def initialize fn
     @fn = fn
     labels =
-      if File.exists? fn
+      if File.exist? fn
         IO.readlines(fn).map { |x| x.chomp.intern }
       else
         []
diff --git a/lib/sup/maildir.rb b/lib/sup/maildir.rb
@@ -68,7 +68,7 @@ class Maildir < Source
           File.safe_link tmp_path, new_path
           stored = true
         ensure
-          File.unlink tmp_path if File.exists? tmp_path
+          File.unlink tmp_path if File.exist? tmp_path
         end
       end #rescue Errno...
     end #Dir.chdir
@@ -201,7 +201,7 @@ class Maildir < Source
   def trashed? id; maildir_data(id)[2].include? "T"; end
 
   def valid? id
-    File.exists? File.join(@dir, id)
+    File.exist? File.join(@dir, id)
   end
 
 private
diff --git a/lib/sup/mbox.rb b/lib/sup/mbox.rb
@@ -115,7 +115,7 @@ class MBox < Source
   end
 
   def store_message date, from_email, &block
-    need_blank = File.exists?(@path) && !File.zero?(@path)
+    need_blank = File.exist?(@path) && !File.zero?(@path)
     File.open(@path, "ab") do |f|
       f.puts if need_blank
       f.puts "From #{from_email} #{date.asctime}"
@@ -180,7 +180,7 @@ class MBox < Source
     time = $1
     begin
       ## hack -- make Time.parse fail when trying to substitute values from Time.now
-      Time.parse time, 0
+      Time.parse time, Time.at(0)
       true
     rescue NoMethodError, ArgumentError
       warn "found invalid date in potential mbox split line, not splitting: #{l.inspect}"
diff --git a/lib/sup/message.rb b/lib/sup/message.rb
@@ -279,6 +279,12 @@ class Message
       end
   end
 
+  def reload_from_source!
+    @chunks = nil
+    load_from_source!
+  end
+
+
   def error_message
     <<EOS
 #@snippet...
diff --git a/lib/sup/message_chunks.rb b/lib/sup/message_chunks.rb
@@ -159,6 +159,7 @@ EOS
         "Attachment: #{filename} (#{content_type}; #{@raw_content.size.to_human_size})"
       end
     end
+    def safe_filename; Shellwords.escape(@filename).gsub("/", "_") end
 
     ## an attachment is exapndable if we've managed to decode it into
     ## something we can display inline. otherwise, it's viewable.
@@ -306,12 +307,13 @@ EOS
   end
 
   class CryptoNotice
-    attr_reader :lines, :status, :patina_text
+    attr_reader :lines, :status, :patina_text, :unknown_fingerprint
 
-    def initialize status, description, lines=[]
+    def initialize status, description, lines=[], unknown_fingerprint=nil
       @status = status
       @patina_text = description
       @lines = lines
+      @unknown_fingerprint = unknown_fingerprint
     end
 
     def patina_color
diff --git a/lib/sup/mode.rb b/lib/sup/mode.rb
@@ -83,7 +83,7 @@ EOS
 ### helper functions
 
   def save_to_file fn, talk=true
-    if File.exists? fn
+    if File.exist? fn
       unless BufferManager.ask_yes_or_no "File \"#{fn}\" exists. Overwrite?"
         info "Not overwriting #{fn}"
         return
@@ -102,37 +102,42 @@ EOS
   end
 
   def pipe_to_process command
-    Open3.popen3(command) do |input, output, error|
-      err, data, * = IO.select [error], [input], nil
-
-      unless err.empty?
-        message = err.first.read
-        if message =~ /^\s*$/
-          warn "error running #{command} (but no error message)"
-          BufferManager.flash "Error running #{command}!"
-        else
-          warn "error running #{command}: #{message}"
-          BufferManager.flash "Error: #{message}"
+    begin
+      Open3.popen3(command) do |input, output, error|
+        err, data, * = IO.select [error], [input], nil
+
+        unless err.empty?
+          message = err.first.read
+          if message =~ /^\s*$/
+            warn "error running #{command} (but no error message)"
+            BufferManager.flash "Error running #{command}!"
+          else
+            warn "error running #{command}: #{message}"
+            BufferManager.flash "Error: #{message}"
+          end
+          return nil, false
         end
-        return
-      end
 
-      data = data.first
-      data.sync = false # buffer input
+        data = data.first
+        data.sync = false # buffer input
 
-      yield data
-      data.close # output will block unless input is closed
+        yield data
+        data.close # output will block unless input is closed
 
-      ## BUG?: shows errors or output but not both....
-      data, * = IO.select [output, error], nil, nil
-      data = data.first
+        ## BUG?: shows errors or output but not both....
+        data, * = IO.select [output, error], nil, nil
+        data = data.first
 
-      if data.eof
-        BufferManager.flash "'#{command}' done!"
-        nil
-      else
-        data.read
+        if data.eof
+          BufferManager.flash "'#{command}' done!"
+          return nil, true
+        else
+          return data.read, true
+        end
       end
+    rescue Errno::ENOENT
+      # If the command is invalid
+      return nil, false
     end
   end
 end
diff --git a/lib/sup/modes/edit_message_mode.rb b/lib/sup/modes/edit_message_mode.rb
@@ -699,7 +699,7 @@ private
     sigfn = (AccountManager.account_for(from_email) ||
              AccountManager.default_account).signature
 
-    if sigfn && File.exists?(sigfn)
+    if sigfn && File.exist?(sigfn)
       ["", "-- "] + File.readlines(sigfn).map { |l| l.chomp }
     else
       []
diff --git a/lib/sup/modes/forward_mode.rb b/lib/sup/modes/forward_mode.rb
@@ -1,6 +1,17 @@
 module Redwood
 
 class ForwardMode < EditMessageMode
+
+  HookManager.register "forward-attribution", <<EOS
+Generates the attribution for the forwarded message
+(["--- Begin forwarded message from John Doe ---",
+  "--- End forwarded message ---"])
+Variables:
+  message: a message object representing the message being replied to
+    (useful values include message.from.mediumname and message.date)
+Return value:
+  A list containing two strings: the text of the begin line and the text of the end line
+EOS
   ## TODO: share some of this with reply-mode
   def initialize opts={}
     header = {
@@ -65,9 +76,17 @@ class ForwardMode < EditMessageMode
 protected
 
   def forward_body_lines m
-    ["--- Begin forwarded message from #{m.from.mediumname} ---"] +
-      m.quotable_header_lines + [""] + m.quotable_body_lines +
-      ["--- End forwarded message ---"]
+    attribution = HookManager.run("forward-attribution", :message => m) || default_attribution(m)
+    attribution[0,1] +
+    m.quotable_header_lines +
+    [""] +
+    m.quotable_body_lines +
+    attribution[1,1]
+  end
+
+  def default_attribution m
+    ["--- Begin forwarded message from #{m.from.mediumname} ---",
+     "--- End forwarded message ---"]
   end
 
   def send_message
diff --git a/lib/sup/modes/line_cursor_mode.rb b/lib/sup/modes/line_cursor_mode.rb
@@ -65,7 +65,7 @@ protected
   def set_cursor_pos p
     return if @curpos == p
     @curpos = p.clamp @cursor_top, lines
-    buffer.mark_dirty
+    buffer.mark_dirty if buffer # not sure why the buffer is gone
     set_status
   end
 
diff --git a/lib/sup/modes/text_mode.rb b/lib/sup/modes/text_mode.rb
@@ -24,10 +24,15 @@ class TextMode < ScrollMode
     command = BufferManager.ask(:shell, "pipe command: ")
     return if command.nil? || command.empty?
 
-    output = pipe_to_process(command) do |stream|
+    output, success = pipe_to_process(command) do |stream|
       @text.each { |l| stream.puts l }
     end
 
+    unless success
+      BufferManager.flash "Invalid command: '#{command}' is not an executable"
+      return
+    end
+
     if output
       BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
     else
diff --git a/lib/sup/modes/thread_index_mode.rb b/lib/sup/modes/thread_index_mode.rb
@@ -1026,7 +1026,7 @@ private
   end
 
   def from_width
-    [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
+    [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max if buffer else MIN_FROM_WIDTH # not sure why the buffer is gone
   end
 
   def initialize_threads
diff --git a/lib/sup/modes/thread_view_mode.rb b/lib/sup/modes/thread_view_mode.rb
@@ -89,6 +89,7 @@ EOS
     k.add :toggle_wrap, "Toggle wrapping of text", 'w'
 
     k.add :goto_uri, "Goto uri under cursor", 'g'
+    k.add :fetch_and_verify, "Fetch the PGP key on poolserver and re-verify message", "v"
 
     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'
@@ -224,10 +225,24 @@ EOS
 
   def unsubscribe_from_list
     m = @message_lines[curpos] or return
-    if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)(\?subject=(.*?))?>/
+    BufferManager.flash "Can't find List-Unsubscribe header for this message." unless m.list_unsubscribe
+
+    if 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."
+    elsif m.list_unsubscribe =~ /<(http.*)?>/
+      unless HookManager.enabled? "goto"
+        BufferManager.flash "You must add a goto.rb hook before you can goto an unsubscribe URI."
+        return
+      end
+
+      begin
+        u = URI.parse($1)
+      rescue URI::InvalidURIError => e
+        BufferManager.flash("Invalid unsubscribe link")
+        return
+      end
+
+      HookManager.run "goto", :uri => Shellwords.escape(u.to_s)
     end
   end
 
@@ -374,7 +389,7 @@ EOS
     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)
+      default_fn = File.expand_path File.join(default_dir, chunk.safe_filename)
       fn = BufferManager.ask_for_filename :filename, "Save attachment to file or directory: ", default_fn, true
 
       # if user selects directory use file name from message
@@ -403,7 +418,7 @@ EOS
     num_errors = 0
     m.chunks.each do |chunk|
       next unless chunk.is_a?(Chunk::Attachment)
-      fn = File.join(folder, chunk.filename)
+      fn = File.join(folder, chunk.safe_filename)
       num_errors += 1 unless save_to_file(fn, false) { |f| f.print chunk.raw_content }
       num += 1
     end
@@ -708,7 +723,7 @@ EOS
     command = BufferManager.ask(:shell, "pipe command: ")
     return if command.nil? || command.empty?
 
-    output = pipe_to_process(command) do |stream|
+    output, success = pipe_to_process(command) do |stream|
       if chunk
         stream.print chunk.raw_content
       else
@@ -716,6 +731,11 @@ EOS
       end
     end
 
+    unless success
+      BufferManager.flash "Invalid command: '#{command}' is not an executable"
+      return
+    end
+
     if output
       BufferManager.spawn "Output of '#{command}'", TextMode.new(output.ascii)
     else
@@ -774,6 +794,27 @@ EOS
     BufferManager.flash "No URI found." unless found
   end
 
+  def fetch_and_verify
+    message = @message_lines[curpos]
+    crypto_chunk = message.chunks.select {|chunk| chunk.is_a?(Chunk::CryptoNotice)}.first
+    return unless crypto_chunk
+    return unless crypto_chunk.unknown_fingerprint
+
+    BufferManager.flash "Retrieving key #{crypto_chunk.unknown_fingerprint} ..."
+
+    error = CryptoManager.retrieve crypto_chunk.unknown_fingerprint
+
+    if error
+      BufferManager.flash "Couldn't retrieve key: #{error.to_s}"
+    else
+      BufferManager.flash "Key #{crypto_chunk.unknown_fingerprint} successfully retrieved !"
+    end
+
+    # Re-trigger gpg verification
+    message.reload_from_source!
+    update
+  end
+
 private
 
   def initial_state_for m
diff --git a/lib/sup/person.rb b/lib/sup/person.rb
@@ -18,11 +18,16 @@ class Person
     @email = email.strip.gsub(/\s+/, " ")
   end
 
-  def to_s; "#@name <#@email>" end
+  def to_s
+    if @name
+      "#@name <#@email>"
+    else
+      @email
+    end
+  end
 
 #   def == o; o && o.email == email; end
 #   alias :eql? :==
-#   def hash; [name, email].hash; end
 
   def shortname
     case @name
@@ -37,26 +42,10 @@ class Person
     end
   end
 
-  def longname
-    if @name && @email
-      "#@name <#@email>"
-    else
-      @email
-    end
-  end
-
   def mediumname; @name || @email; end
 
-  def Person.full_address name, email
-    if name && email
-      if name =~ /[",@]/
-        "#{name.inspect} <#{email}>" # escape quotes
-      else
-        "#{name} <#{email}>"
-      end
-    else
-      email
-    end
+  def longname
+    to_s
   end
 
   def full_address
@@ -79,56 +68,74 @@ class Person
     end.downcase
   end
 
-  ## return "canonical" person using contact manager or create one if
-  ## not found or contact manager not available
-  def self.from_name_and_email name, email
-    ContactManager.instantiated? && ContactManager.person_for(email) || Person.new(name, email)
+  def eql? o; email.eql? o.email end
+  def hash; email.hash end
+
+
+  ## see comments in self.from_address
+  def indexable_content
+    [name, email, email.split(/@/).first].join(" ")
   end
 
-  def self.from_address s
-    return nil if s.nil?
-
-    ## try and parse an email address and name
-    name, email = case s
-      when /(.+?) ((\S+?)@\S+) \3/
-        ## ok, this first match cause is insane, but bear with me.  email
-        ## addresses are stored in the to/from/etc fields of the index in a
-        ## weird format: "name address first-part-of-address", i.e.  spaces
-        ## separating those three bits, and no <>'s. this is the output of
-        ## #indexable_content. here, we reverse-engineer that format to extract
-        ## a valid address.
-        ##
-        ## we store things this way to allow searches on a to/from/etc field to
-        ## match any of those parts. a more robust solution would be to store a
-        ## separate, non-indexed field with the proper headers. but this way we
-        ## save precious bits, and it's backwards-compatible with older indexes.
-        [$1, $2]
-      when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/
-        a, b = $1, $2
-        [a.gsub('\"', '"'), b]
-      when /<((\S+?)@\S+?)>/
-        [$2, $1]
-      when /((\S+?)@\S+)/
-        [$2, $1]
+  class << self
+
+    def full_address name, email
+      if name && email
+        if name =~ /[",@]/
+          "#{name.inspect} <#{email}>" # escape quotes
+        else
+          "#{name} <#{email}>"
+        end
       else
-        [nil, s]
+        email
       end
+    end
 
-    from_name_and_email name, email
-  end
+    ## return "canonical" person using contact manager or create one if
+    ## not found or contact manager not available
+    def from_name_and_email name, email
+      ContactManager.instantiated? && ContactManager.person_for(email) || Person.new(name, email)
+    end
 
-  def self.from_address_list ss
-    return [] if ss.nil?
-    ss.dup.split_on_commas.map { |s| self.from_address s }
-  end
+    def from_address s
+      return nil if s.nil?
+
+      ## try and parse an email address and name
+      name, email = case s
+        when /(.+?) ((\S+?)@\S+) \3/
+          ## ok, this first match cause is insane, but bear with me.  email
+          ## addresses are stored in the to/from/etc fields of the index in a
+          ## weird format: "name address first-part-of-address", i.e.  spaces
+          ## separating those three bits, and no <>'s. this is the output of
+          ## #indexable_content. here, we reverse-engineer that format to extract
+          ## a valid address.
+          ##
+          ## we store things this way to allow searches on a to/from/etc field to
+          ## match any of those parts. a more robust solution would be to store a
+          ## separate, non-indexed field with the proper headers. but this way we
+          ## save precious bits, and it's backwards-compatible with older indexes.
+          [$1, $2]
+        when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/
+          a, b = $1, $2
+          [a.gsub('\"', '"'), b]
+        when /<((\S+?)@\S+?)>/
+          [$2, $1]
+        when /((\S+?)@\S+)/
+          [$2, $1]
+        else
+          [nil, s]
+        end
+
+      from_name_and_email name, email
+    end
+
+    def from_address_list ss
+      return [] if ss.nil?
+      ss.dup.split_on_commas.map { |s| self.from_address s }
+    end
 
-  ## see comments in self.from_address
-  def indexable_content
-    [name, email, email.split(/@/).first].join(" ")
   end
 
-  def eql? o; email.eql? o.email end
-  def hash; email.hash end
 end
 
 end
diff --git a/lib/sup/search.rb b/lib/sup/search.rb
@@ -12,7 +12,7 @@ class SearchManager
   def initialize fn
     @fn = fn
     @searches = {}
-    if File.exists? fn
+    if File.exist? fn
       IO.foreach(fn) do |l|
         l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}"
         @searches[$1] = $2
diff --git a/lib/sup/sent.rb b/lib/sup/sent.rb
@@ -40,7 +40,7 @@ class SentLoader < MBox
 
   def initialize
     @filename = Redwood::SENT_FN
-    File.open(@filename, "w") { } unless File.exists? @filename
+    File.open(@filename, "w") { } unless File.exist? @filename
     super "mbox://" + @filename, true, $config[:archive_sent]
   end
 
diff --git a/lib/sup/util/locale_fiddler.rb b/lib/sup/util/locale_fiddler.rb
@@ -0,0 +1,24 @@
+## the following magic enables wide characters when used with a ruby
+## 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.
+require 'fiddle'
+require 'fiddle/import'
+
+module LocaleFiddler
+  extend Fiddle::Importer
+
+  SETLOCALE_LIB = case RbConfig::CONFIG['arch']
+                  when /darwin/; "libc.dylib"
+                  when /cygwin/; "cygwin1.dll"
+                  when /freebsd/; "libc.so.7"
+                  else; "libc.so.6"
+                  end
+
+  dlload SETLOCALE_LIB
+  extern "char *setlocale(int, char const *)"
+
+  def setlocale(type, string)
+    LocaleFiddler.setlocale(type, string)
+  end
+end
diff --git a/sup.gemspec b/sup.gemspec
@@ -38,7 +38,7 @@ SUP: please note that our old mailing lists have been shut down,
   s.require_paths = ["lib"]
   s.extra_rdoc_files = Dir.glob("man/*")
 
-  s.required_ruby_version = '>= 1.9.3'
+  s.required_ruby_version = '>= 2.0.0'
 
   # this is here to support skipping the xapian-ruby installation on OpenBSD
   # because the xapian-ruby gem doesn't install on OpenBSD, you must install
@@ -62,8 +62,9 @@ SUP: please note that our old mailing lists have been shut down,
 
   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 'minitest', '~> 5.5.1'
+  s.add_development_dependency "rr", "~> 1.1"
   s.add_development_dependency "gpgme", ">= 2.0.2"
+  s.add_development_dependency "pry"
 
 end
diff --git a/test/integration/test_maildir.rb b/test/integration/test_maildir.rb
@@ -1,6 +1,6 @@
 require "test_helper"
 
-class TestMaildir < MiniTest::Unit::TestCase
+class TestMaildir < Minitest::Test
 
   def setup
     @path = Dir.mktmpdir
diff --git a/test/integration/test_mbox.rb b/test/integration/test_mbox.rb
@@ -1,6 +1,6 @@
 require "test_helper"
 
-class TestMbox < MiniTest::Unit::TestCase
+class TestMbox < MiniTest::Test
 
   def setup
     @path = Dir.mktmpdir
diff --git a/test/test_crypto.rb b/test/test_crypto.rb
@@ -25,7 +25,7 @@ require 'tmpdir'
 
 module Redwood
 
-class TestCryptoManager < ::Minitest::Unit::TestCase
+class TestCryptoManager < Minitest::Test
 
     def setup
         @from_email = 'sup-test-1@foo.bar'
diff --git a/test/test_header_parsing.rb b/test/test_header_parsing.rb
@@ -6,7 +6,7 @@ require 'stringio'
 
 include Redwood
 
-class TestMBoxParsing < Minitest::Unit::TestCase
+class TestMBoxParsing < Minitest::Test
 
   def setup
     @path = Dir.mktmpdir
diff --git a/test/test_message.rb b/test/test_message.rb
@@ -6,27 +6,9 @@ require 'stringio'
 
 require 'dummy_source'
 
-# override File.exists? to make it work with StringIO for testing.
-# FIXME: do aliasing to avoid breaking this when sup moves from
-# File.exists? to File.exist?
-
-class File
-
-  def File.exists? file
-    # puts "fake File::exists?"
-
-    if file.is_a?(StringIO)
-      return false
-    end
-    # use the different function
-    File.exist?(file)
-  end
-
-end
-
 module Redwood
 
-class TestMessage < ::Minitest::Unit::TestCase
+class TestMessage < Minitest::Test
 
   def setup
     @path = Dir.mktmpdir
@@ -520,6 +502,82 @@ EOS
 
   end
 
+  def test_malicious_attachment_names
+
+
+    message = <<EOS
+From: Matthieu Rakotojaona <matthieu.rakotojaona@gmail.com>
+To: reply+0007a7cb7174d1d188fcd420fce83e0f68fe03fc7416cdae92cf0000000110ce4efd92a169ce033d18e1 <reply+0007a7cb7174d1d188fcd420fce83e0f68fe03fc7416cdae92cf0000000110ce4efd92a169ce033d18e1@reply.github.com>
+Subject: Re: [sup] Attachment saving and special characters in filenames (#378)
+In-reply-to: <sup-heliotrope/sup/issues/378@github.com>
+References: <sup-heliotrope/sup/issues/378@github.com>
+X-pgp-key: http://otokar.looc2011.eu/static/matthieu.rakotojaona.asc
+Date: Wed, 14 Jan 2015 22:13:37 +0100
+Message-Id: <1421269972-sup-5245@kpad>
+User-Agent: Sup/git
+Content-Transfer-Encoding: 8bit
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="=-1421270017-526778-1064-1628-1-="
+
+
+--=-1421270017-526778-1064-1628-1-=
+Content-Type: text/plain; charset=UTF-8
+Content-Disposition: inline
+
+Excerpts from Felix Kaiser's message of 2015-01-14 16:36:29 +0100:
+> When saving attachments, sup should replace special characters when suggesting a filename to save the attachment to.
+> 
+> I just got an attachment with a name like "foo/2.pdf". sup suggests saving it to /home/fxkr/foo/2.pdf (and fails to save it, of course, if /home/fxkr/foo isn't a directory).
+> 
+> I haven't tested the "Save All" feature, but I hope nothing bad happens when there's an attachment called "../../../../../../../home/fxkr/.bashrc" ;-)
+> 
+> ---
+> Reply to this email directly or view it on GitHub:
+> https://github.com/sup-heliotrope/sup/issues/378
+
+For tests, here's an email with an attachment filename set to
+sup/.travis.yml (really, this time)
+
+-- 
+Matthieu Rakotojaona
+
+--=-1421270017-526778-1064-1628-1-=
+Content-Disposition: attachment; filename="sup/.travis.yml"
+Content-Type: text/x-yaml; name="sup/.travis.yml"
+Content-Transfer-Encoding: 8bit
+
+language: ruby
+
+rvm:
+  - 2.1.1
+  - 2.0.0
+  - 1.9.3
+
+before_install:
+  - sudo apt-get update -qq
+  - sudo apt-get install -qq uuid-dev uuid libncursesw5-dev libncursesw5 gnupg2 pandoc
+  - git submodule update --init --recursive
+
+script: bundle exec rake travis
+
+--=-1421270017-526778-1064-1628-1-=--
+EOS
+
+    source = DummySource.new("sup-test://test_blank_header_lines")
+    source.messages = [ message ]
+    source_info = 0
+
+    sup_message = Message.build_from_source(source, source_info)
+    chunks = sup_message.load_from_source!
+
+    # See if attachment filenames can be safely used for saving.
+    # We do that by verifying that any folder-related character (/ or \)
+    # are not interpreted: the filename must not be interpreted into a
+    # path.
+    fn = chunks[3].safe_filename
+    assert_equal(fn, File.basename(fn))
+
+  end
   # TODO: test different error cases, malformed messages etc.
 
   # TODO: test different quoting styles, see that they are all divided
diff --git a/test/test_messages_dir.rb b/test/test_messages_dir.rb
@@ -6,27 +6,9 @@ require 'stringio'
 
 require 'dummy_source'
 
-# override File.exists? to make it work with StringIO for testing.
-# FIXME: do aliasing to avoid breaking this when sup moves from
-# File.exists? to File.exist?
-
-class File
-
-  def File.exists? file
-    # puts "fake File::exists?"
-
-    if file.is_a?(StringIO)
-      return false
-    end
-    # use the different function
-    File.exist?(file)
-  end
-
-end
-
 module Redwood
 
-class TestMessagesDir < ::Minitest::Unit::TestCase
+class TestMessagesDir < ::Minitest::Test
 
   def setup
     @path = Dir.mktmpdir
diff --git a/test/test_yaml_regressions.rb b/test/test_yaml_regressions.rb
@@ -6,7 +6,7 @@ require 'yaml'
 require 'sup'
 
 module Redwood
-  class TestYamlRegressions < ::Minitest::Unit::TestCase
+  class TestYamlRegressions < ::Minitest::Test
     def test_yamling_hash
       hsh = {:foo => 42}
       reloaded = YAML.load(hsh.to_yaml)
diff --git a/test/unit/fixtures/contacts.txt b/test/unit/fixtures/contacts.txt
@@ -0,0 +1 @@
+RC: Random Contact <random_dude@gmail.com>
+\ No newline at end of file
diff --git a/test/unit/test_contact.rb b/test/unit/test_contact.rb
@@ -0,0 +1,33 @@
+require 'test_helper'
+require 'sup/contact'
+
+module Redwood
+
+class TestContact < Minitest::Test
+  def setup
+    @contact = ContactManager.init(File.expand_path("../fixtures/contacts.txt", __FILE__))
+    @person  = Person.new "Terrible Name", "terrible@name.com"
+  end
+
+  def teardown
+    runner = Redwood.const_get "ContactManager".to_sym
+    runner.deinstantiate!
+  end
+
+  def test_contact_manager
+    assert @contact
+    ## 1 contact is imported from the fixture file.
+    assert_equal 1, @contact.contacts.count
+    assert_equal @contact.contact_for("RC").name, "Random Contact"
+
+    assert_nil @contact.contact_for "TN"
+    @contact.update_alias @person, "TN"
+
+    assert @contact.is_aliased_contact?(@person)
+    assert_equal @person, @contact.contact_for("TN")
+
+    assert_equal "TN", @contact.alias_for(@person)
+  end
+end
+
+end
+\ No newline at end of file
diff --git a/test/unit/test_locale_fiddler.rb b/test/unit/test_locale_fiddler.rb
@@ -0,0 +1,15 @@
+require 'test_helper'
+require 'sup/util/locale_fiddler'
+
+class TestFiddle < ::Minitest::Unit::TestCase
+  # TODO this is a silly test
+  def test_fiddle_set_locale
+    before = LocaleDummy.setlocale(6, nil).to_s
+    after = LocaleDummy.setlocale(6, "").to_s
+    assert(before != after, "Expected locale to be fiddled with")
+  end
+end
+
+class LocaleDummy
+  extend LocaleFiddler
+end
diff --git a/test/unit/test_person.rb b/test/unit/test_person.rb
@@ -0,0 +1,37 @@
+require 'test_helper'
+require 'sup'
+
+module Redwood
+
+class TestPerson < Minitest::Test
+  def setup
+    @person = Person.new("Thomassen, Bob", "bob@thomassen.com")
+    @no_name = Person.new(nil, "alice@alice.com")
+  end
+
+  def test_email_must_be_supplied
+    assert_raises (ArgumentError) { Person.new("Alice", nil) }
+  end
+
+  def test_to_string
+    assert_equal "Thomassen, Bob <bob@thomassen.com>", "#{@person}"
+    assert_equal "alice@alice.com", "#{@no_name}"
+  end
+
+  def test_shortname
+    assert_equal "Bob", @person.shortname
+    assert_equal "alice@alice.com", @no_name.shortname
+  end
+
+  def test_mediumname
+    assert_equal "Thomassen, Bob", @person.mediumname
+    assert_equal "alice@alice.com", @no_name.mediumname
+  end
+
+  def test_fullname
+    assert_equal "\"Thomassen, Bob\" <bob@thomassen.com>", @person.full_address
+    assert_equal "alice@alice.com", @no_name.full_address
+  end
+end
+
+end
+\ No newline at end of file