sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit ffa7eb45ec55282ee551cdd83c598c7ddfa24f50
parent ce8d41af569f94b9992fb9fe866e94dc8d5d8da5
Author: William Morgan <wmorgan-sup@masanjin.net>
Date:   Thu, 31 Dec 2009 15:26:46 -0500

Merge branches 'thread-joining-fix', 'no-mailcap-on-darwin', 'label-list-mode-auto-update', 'interactive-crypto', 'label-list-mode-hooks', 'refine-inbox-mode', 'poll-unusual', 'attach-wildcards', 'order-names-by-date' and 'save-all-attachments'

Diffstat:
M bin/sup | 5 +++++
M lib/sup/buffer.rb | 5 +++--
M lib/sup/crypto.rb | 129 +++++++++++++++++++++++++++++++++++++++++++++----------------------------------
M lib/sup/ferret_index.rb | 5 ++++-
M lib/sup/message-chunks.rb | 7 ++++++-
M lib/sup/mode.rb | 15 +++++++++++----
M lib/sup/modes/edit-message-mode.rb | 6 ++++--
M lib/sup/modes/inbox-mode.rb | 8 ++++++++
M lib/sup/modes/label-list-mode.rb | 47 ++++++++++++++++++++++++++++++++++++++++++++---
M lib/sup/modes/thread-index-mode.rb | 42 ++++++++++++++++++++++++------------------
M lib/sup/modes/thread-view-mode.rb | 27 +++++++++++++++++++++++++++
M lib/sup/poll.rb | 23 +++++++++++++++++++----
M lib/sup/source.rb | 1 +
M lib/sup/xapian_index.rb | 2 +-
14 files changed, 231 insertions(+), 91 deletions(-)
diff --git a/bin/sup b/bin/sup
@@ -90,6 +90,7 @@ global_keymap = Keymap.new do |k|
   k.add :search_unread, "Show all unread messages", 'U'
   k.add :list_labels, "List labels", 'L'
   k.add :poll, "Poll for new messages", 'P'
+  k.add :poll_unusual, "Poll for new messages from unusual sources", '{'
   k.add :compose, "Compose new message", 'm', 'c'
   k.add :nothing, "Do nothing", :ctrl_g
   k.add :recall_draft, "Edit most recent draft message", 'R'
@@ -300,6 +301,10 @@ begin
       ComposeMode.spawn_nicely
     when :poll
       reporting_thread("user-invoked poll") { PollManager.poll }
+    when :poll_unusual
+      if BufferManager.ask_yes_or_no "Really poll unusual sources?"
+        reporting_thread("user-invoked unusual poll") { PollManager.poll_unusual }
+      end
     when :recall_draft
       case Index.num_results_for :label => :draft
       when 0
diff --git a/lib/sup/buffer.rb b/lib/sup/buffer.rb
@@ -475,7 +475,7 @@ EOS
     end
   end
 
-  def ask_for_filename domain, question, default=nil
+  def ask_for_filename domain, question, default=nil, allow_directory=false
     answer = ask domain, question, default do |s|
       if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
         full = $1
@@ -500,7 +500,7 @@ EOS
       answer =
         if answer.empty?
           spawn_modal "file browser", FileBrowserMode.new
-        elsif File.directory?(answer)
+        elsif File.directory?(answer) && !allow_directory
           spawn_modal "file browser", FileBrowserMode.new(answer)
         else
           File.expand_path answer
@@ -760,6 +760,7 @@ EOS
   end
 
 private
+
   def default_status_bar buf
     " [#{buf.mode.name}] #{buf.title}   #{buf.mode.status}"
   end
diff --git a/lib/sup/crypto.rb b/lib/sup/crypto.rb
@@ -15,15 +15,14 @@ class CryptoManager
     @mutex = Mutex.new
 
     bin = `which gpg`.chomp
-    @cmd =
-      case bin
-      when /\S/
-        debug "crypto: detected gpg binary in #{bin}"
-        "#{bin} --quiet --batch --no-verbose --logger-fd 1 --use-agent"
-      else
-        debug "crypto: no gpg binary detected"
-        nil
-      end
+    @cmd = case bin
+    when /\S/
+      debug "crypto: detected gpg binary in #{bin}"
+      "#{bin} --quiet --batch --no-verbose --logger-fd 1 --use-agent"
+    else
+      debug "crypto: no gpg binary detected"
+      nil
+    end
   end
 
   def have_crypto?; !@cmd.nil? end
@@ -33,15 +32,19 @@ class CryptoManager
     payload_fn.write format_payload(payload)
     payload_fn.close
 
-    output = run_gpg "--output - --armor --detach-sign --textmode --local-user '#{from}' #{payload_fn.path}"
+    sig_fn = Tempfile.new "redwood.signature"; sig_fn.close
 
-    raise Error, (output || "gpg command failed: #{cmd}") unless $?.success?
+    message = run_gpg "--output #{sig_fn.path} --yes --armor --detach-sign --textmode --local-user '#{from}' #{payload_fn.path}", :interactive => true
+    unless $?.success?
+      info "Error while running gpg: #{message}"
+      raise Error, "GPG command failed. See log for details."
+    end
 
     envelope = RMail::Message.new
     envelope.header["Content-Type"] = 'multipart/signed; protocol=application/pgp-signature; micalg=pgp-sha1'
 
     envelope.add_part payload
-    signature = RMail::Message.make_attachment output, "application/pgp-signature", nil, "signature.asc"
+    signature = RMail::Message.make_attachment IO.read(sig_fn.path), "application/pgp-signature", nil, "signature.asc"
     envelope.add_part signature
     envelope
   end
@@ -51,15 +54,20 @@ class CryptoManager
     payload_fn.write format_payload(payload)
     payload_fn.close
 
+    encrypted_fn = Tempfile.new "redwood.encrypted"; encrypted_fn.close
+
     recipient_opts = (to + [ from ] ).map { |r| "--recipient '<#{r}>'" }.join(" ")
     sign_opts = sign ? "--sign --local-user '#{from}'" : ""
-    gpg_output = run_gpg "--output - --armor --encrypt --textmode #{sign_opts} #{recipient_opts} #{payload_fn.path}"
-    raise Error, (gpg_output || "gpg command failed: #{cmd}") unless $?.success?
+    message = run_gpg "--output #{encrypted_fn.path} --yes --armor --encrypt --textmode #{sign_opts} #{recipient_opts} #{payload_fn.path}", :interactive => true
+    unless $?.success?
+      info "Error while running gpg: #{message}"
+      raise Error, "GPG command failed. See log for details."
+    end
 
     encrypted_payload = RMail::Message.new
     encrypted_payload.header["Content-Type"] = "application/octet-stream"
     encrypted_payload.header["Content-Disposition"] = 'inline; filename="msg.asc"'
-    encrypted_payload.body = gpg_output
+    encrypted_payload.body = IO.read(encrypted_fn.path)
 
     control = RMail::Message.new
     control.header["Content-Type"] = "application/pgp-encrypted"
@@ -111,46 +119,48 @@ class CryptoManager
     payload_fn.write payload.to_s
     payload_fn.close
 
-    output = run_gpg "--decrypt #{payload_fn.path}"
+    output_fn = Tempfile.new "redwood.output"
+    output_fn.close
 
-    if $?.success?
-      decrypted_payload, sig_lines = if output =~ /\A(.*?)((^gpg: .*$)+)\Z/m
-        [$1, $2]
-      else
-        [output, nil]
-      end
+    message = run_gpg "--output #{output_fn.path} --yes --decrypt #{payload_fn.path}", :interactive => true
 
-      sig = if sig_lines # encrypted & signed
-        if sig_lines =~ /^gpg: (Good signature from .*$)/
-          Chunk::CryptoNotice.new :valid, $1, sig_lines.split("\n")
-        else
-          Chunk::CryptoNotice.new :invalid, $1, sig_lines.split("\n")
-        end
-      end
+    unless $?.success?
+      info "Error while running gpg: #{message}"
+      return Chunk::CryptoNotice.new(:invalid, "This message could not be decrypted", message.split("\n"))
+    end
 
-      # This is gross. This decrypted payload could very well be a multipart
-      # element itself, as opposed to a simple payload. For example, a
-      # multipart/signed element, like those generated by Mutt when encrypting
-      # and signing a message (instead of just clearsigning the body).
-      # Supposedly, decrypted_payload being a multipart element ought to work
-      # out nicely because Message::multipart_encrypted_to_chunks() runs the
-      # decrypted message through message_to_chunks() again to get any
-      # children. However, it does not work as intended because these inner
-      # payloads need not carry a MIME-Version header, yet they are fed to
-      # RMail as a top-level message, for which the MIME-Version header is
-      # required. This causes for the part not to be detected as multipart,
-      # hence being shown as an attachment. If we detect this is happening,
-      # we force the decrypted payload to be interpreted as MIME.
-      msg = RMail::Parser.read(decrypted_payload)
-      if msg.header.content_type =~ %r{^multipart/} and not msg.multipart?
-        decrypted_payload = "MIME-Version: 1.0\n" + decrypted_payload
-        msg = RMail::Parser.read(decrypted_payload)
-      end
-      notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
-      [notice, sig, msg]
-    else
-      Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", output.split("\n")
+    output = IO.read output_fn.path
+
+    ## there's probably a better way to do this, but we're using the output to
+    ## look for a valid signature being present.
+
+    sig = case message
+    when /^gpg: (Good signature from .*$)/i
+      Chunk::CryptoNotice.new :valid, $1, message.split("\n")
+    when /^gpg: (Bad signature from .*$)/i
+      Chunk::CryptoNotice.new :invalid, $1, message.split("\n")
     end
+
+    # This is gross. This decrypted payload could very well be a multipart
+    # element itself, as opposed to a simple payload. For example, a
+    # multipart/signed element, like those generated by Mutt when encrypting
+    # and signing a message (instead of just clearsigning the body).
+    # Supposedly, decrypted_payload being a multipart element ought to work
+    # out nicely because Message::multipart_encrypted_to_chunks() runs the
+    # decrypted message through message_to_chunks() again to get any
+    # children. However, it does not work as intended because these inner
+    # payloads need not carry a MIME-Version header, yet they are fed to
+    # RMail as a top-level message, for which the MIME-Version header is
+    # required. This causes for the part not to be detected as multipart,
+    # hence being shown as an attachment. If we detect this is happening,
+    # we force the decrypted payload to be interpreted as MIME.
+    msg = RMail::Parser.read output
+    if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
+      output = "MIME-Version: 1.0\n" + output
+      msg = RMail::Parser.read output
+    end
+    notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
+    [notice, sig, msg]
   end
 
 private
@@ -169,10 +179,19 @@ private
     payload.to_s.gsub(/(^|[^\r])\n/, "\\1\r\n").gsub(/^MIME-Version: .*\r\n/, "")
   end
 
-  def run_gpg args
-    cmd = "#{@cmd} #{args} 2> /dev/null"
-    output = `#{cmd}`
-    output
+  def run_gpg args, opts={}
+    cmd = "#{@cmd} #{args}"
+    if opts[:interactive] && BufferManager.instantiated?
+      output_fn = Tempfile.new "redwood.output"
+      output_fn.close
+      cmd += " > #{output_fn.path} 2> /dev/null"
+      debug "crypto: running: #{cmd}"
+      BufferManager.shell_out cmd
+      IO.read(output_fn.path) rescue "can't read output"
+    else
+      debug "crypto: running: #{cmd}"
+      `#{cmd} 2> /dev/null`
+    end
   end
 end
 end
diff --git a/lib/sup/ferret_index.rb b/lib/sup/ferret_index.rb
@@ -127,8 +127,11 @@ EOS
       :from => (m.from ? m.from.indexable_content : ""),
       :to => (m.to + m.cc + m.bcc).map { |x| x.indexable_content }.join(" "),
 
+      ## always overwrite :refs.
+      ## these might have changed due to manual thread joining.
+      :refs => (m.refs + m.replytos).uniq.join(" "),
+
       :subject => (entry[:subject] || wrap_subj(Message.normalize_subj(m.subj))),
-      :refs => (entry[:refs] || (m.refs + m.replytos).uniq.join(" ")),
     }
 
     @index_mutex.synchronize do
diff --git a/lib/sup/message-chunks.rb b/lib/sup/message-chunks.rb
@@ -132,7 +132,12 @@ EOS
     def initial_state; :open end
     def viewable?; @lines.nil? end
     def view_default! path
-      cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}'"
+      case Config::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
diff --git a/lib/sup/mode.rb b/lib/sup/mode.rb
@@ -74,15 +74,22 @@ EOS
 
 ### helper functions
 
-  def save_to_file fn
+  def save_to_file fn, talk=true
     if File.exists? fn
-      return unless BufferManager.ask_yes_or_no "File exists. Overwrite?"
+      unless BufferManager.ask_yes_or_no "File \"#{fn}\" exists. Overwrite?"
+        info "Not overwriting #{fn}"
+        return
+      end
     end
     begin
       File.open(fn, "w") { |f| yield f }
-      BufferManager.flash "Successfully wrote #{fn}."
+      BufferManager.flash "Successfully wrote #{fn}." if talk
+      true
     rescue SystemCallError, IOError => e
-      BufferManager.flash "Error writing to file: #{e.message}"
+      m = "Error writing file: #{e.message}"
+      info m
+      BufferManager.flash m
+      false
     end
   end
 
diff --git a/lib/sup/modes/edit-message-mode.rb b/lib/sup/modes/edit-message-mode.rb
@@ -162,8 +162,10 @@ EOS
     fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
     return unless fn
     begin
-      @attachments << RMail::Message.make_file_attachment(fn)
-      @attachment_names << fn
+      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}"
diff --git a/lib/sup/modes/inbox-mode.rb b/lib/sup/modes/inbox-mode.rb
@@ -7,6 +7,7 @@ class InboxMode < ThreadIndexMode
     ## 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
@@ -17,6 +18,13 @@ class InboxMode < ThreadIndexMode
 
   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
diff --git a/lib/sup/modes/label-list-mode.rb b/lib/sup/modes/label-list-mode.rb
@@ -8,14 +8,38 @@ class LabelListMode < LineCursorMode
     k.add :toggle_show_unread_only, "Toggle between showing all labels and those with unread mail", 'u'
   end
 
+  HookManager.register "label-list-filter", <<EOS
+Filter the label list, typically to sort.
+Variables:
+  counted: an array of counted labels.
+Return value:
+  An array of counted labels with sort_by output structure.
+EOS
+
+  HookManager.register "label-list-format", <<EOS
+Create the sprintf format string for label-list-mode.
+Variables:
+  width: the maximum label width
+  tmax: the maximum total message count
+  umax: the maximum unread message count
+Return value:
+  A format string for sprintf
+EOS
+
   def initialize
     @labels = []
     @text = []
     @unread_only = false
     super
+    UpdateManager.register self
     regen_text
   end
 
+  def cleanup
+    UpdateManager.unregister self
+    super
+  end
+
   def lines; @text.length end
   def [] i; @text[i] end
 
@@ -34,6 +58,10 @@ class LabelListMode < LineCursorMode
     reload # make sure unread message counts are up-to-date
   end
 
+  def handle_added_update sender, m
+    reload
+  end
+
 protected
 
   def toggle_show_unread_only
@@ -50,14 +78,22 @@ protected
     @text = []
     labels = LabelManager.all_labels
 
-    counts = labels.map do |label|
+    counted = labels.map do |label|
       string = LabelManager.string_for label
       total = Index.num_results_for :label => label
       unread = (label == :unread)? total : Index.num_results_for(:labels => [label, :unread])
       [label, string, total, unread]
-    end.sort_by { |l, s, t, u| s.downcase }
+    end
+
+    if HookManager.enabled? "label-list-filter"
+      counts = HookManager.run "label-list-filter", :counted => counted
+    else
+      counts = counted.sort_by { |l, s, t, u| s.downcase }
+    end
 
     width = counts.max_of { |l, s, t, u| s.length }
+    tmax  = counts.max_of { |l, s, t, u| t }
+    umax  = counts.max_of { |l, s, t, u| u }
 
     if @unread_only
       counts.delete_if { | l, s, t, u | u == 0 }
@@ -78,8 +114,13 @@ protected
         next
       end
 
+      fmt = HookManager.run "label-list-format", :width => width, :tmax => tmax, :umax => umax
+      if !fmt
+        fmt = "%#{width + 1}s %5d %s, %5d unread"
+      end
+
       @text << [[(unread == 0 ? :labellist_old_color : :labellist_new_color),
-          sprintf("%#{width + 1}s %5d %s, %5d unread", string, total, total == 1 ? " message" : "messages", unread)]]
+          sprintf(fmt, string, total, total == 1 ? " message" : "messages", unread)]]
       @labels << [label, unread]
       yield i if block_given?
     end.compact
diff --git a/lib/sup/modes/thread-index-mode.rb b/lib/sup/modes/thread-index-mode.rb
@@ -66,7 +66,7 @@ EOS
     @date_width = DATE_WIDTH
 
     @interrupt_search = false
-    
+
     initialize_threads # defines @ts and @ts_mutex
     update # defines @text and @lines
 
@@ -759,30 +759,36 @@ protected
     @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 = {}
-    authors = Set.new
-    t.each do |m, *o|
-      next unless m
-      break if limit and authors.size >= limit
-
-      name = 
-        if AccountManager.is_account?(m.from)
-          "me"
-        elsif t.authors.size == 1
-          m.from.mediumname
-        else
-          m.from.shortname
-        end
+    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
 
-      new[name] ||= m.has_label?(:unread)
-      authors << name
+      result << [name, new[a]]
     end
 
-    authors.to_a.map { |a| [a, new[a]] }
+    result
   end
 
   AUTHOR_LIMIT = 5
diff --git a/lib/sup/modes/thread-view-mode.rb b/lib/sup/modes/thread-view-mode.rb
@@ -58,6 +58,7 @@ EOS
     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 :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", "("
@@ -341,6 +342,32 @@ EOS
     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 edit_draft
     m = @message_lines[curpos] or return
     if m.is_draft?
diff --git a/lib/sup/poll.rb b/lib/sup/poll.rb
@@ -35,12 +35,11 @@ EOS
     @thread = nil
     @last_poll = nil
     @polling = false
+    @poll_sources = nil
     @mode = nil
   end
 
-  def poll
-    return if @polling
-    @polling = true
+  def poll_with_sources
     @mode ||= PollMode.new
     HookManager.run "before-poll"
 
@@ -54,6 +53,22 @@ EOS
 
     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]
+  end
+
+  def poll_unusual
+    return if @polling
+    @polling = true
+    @poll_sources = SourceManager.unusual_sources
+    num, numi = poll_with_sources
     @polling = false
     [num, numi]
   end
@@ -79,7 +94,7 @@ EOS
     loaded_labels = Set.new
 
     @mutex.synchronize do
-      SourceManager.usual_sources.each do |source|
+      @poll_sources.each do |source|
 #        yield "source #{source} is done? #{source.done?} (cur_offset #{source.cur_offset} >= #{source.end_offset})"
         begin
           yield "Loading from #{source}... " unless source.done? || (source.respond_to?(:has_errors?) && source.has_errors?)
diff --git a/lib/sup/source.rb b/lib/sup/source.rb
@@ -207,6 +207,7 @@ class SourceManager
 
   def source_for uri; sources.find { |s| s.is_source_for? uri }; end
   def usual_sources; sources.find_all { |s| s.usual? }; end
+  def unusual_sources; sources.find_all { |s| !s.usual? }; end
 
   def load_sources fn=Redwood::SOURCE_FN
     source_array = (Redwood::load_yaml_obj(fn) || []).map { |o| Recoverable.new o }
diff --git a/lib/sup/xapian_index.rb b/lib/sup/xapian_index.rb
@@ -114,7 +114,7 @@ EOS
       :cc => (entry[:cc] || m.cc.map { |p| [p.email, p.name] }),
       :bcc => (entry[:bcc] || m.bcc.map { |p| [p.email, p.name] }),
       :subject => m.subj,
-      :refs => (entry[:refs] || m.refs),
+      :refs => m.refs,
       :replytos => (entry[:replytos] || m.replytos),
     }