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:
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),
}