]> git.cworth.org Git - sup/commitdiff
Merge commit 'origin/utf8-fixes'
authorWilliam Morgan <wmorgan-sup@masanjin.net>
Thu, 28 May 2009 14:56:05 +0000 (10:56 -0400)
committerWilliam Morgan <wmorgan-sup@masanjin.net>
Thu, 28 May 2009 14:56:05 +0000 (10:56 -0400)
25 files changed:
Manifest.txt
README.txt
bin/sup-sync
doc/NewUserGuide.txt
lib/sup.rb
lib/sup/colormap.rb
lib/sup/draft.rb
lib/sup/imap.rb
lib/sup/index.rb
lib/sup/maildir.rb
lib/sup/mbox.rb
lib/sup/mbox/loader.rb
lib/sup/message.rb
lib/sup/modes/compose-mode.rb
lib/sup/modes/edit-message-mode.rb
lib/sup/modes/inbox-mode.rb
lib/sup/modes/thread-index-mode.rb
lib/sup/poll.rb
lib/sup/source.rb
lib/sup/undo.rb [new file with mode: 0644]
lib/sup/util.rb
test/dummy_source.rb
test/test_header_parsing.rb [new file with mode: 0644]
test/test_mbox_parsing.rb [deleted file]
test/test_message.rb

index 6ecfe670d07649cf288cfadcfe78f3ce71cd4731..be633d776e2b1e5165edeac4d7631cf714471207 100644 (file)
@@ -71,5 +71,6 @@ lib/sup/suicide.rb
 lib/sup/tagger.rb
 lib/sup/textfield.rb
 lib/sup/thread.rb
+lib/sup/undo.rb
 lib/sup/update.rb
 lib/sup/util.rb
index 9a4490acabe3da4e986d470e46bea0b0da3c49e4..0437de9f0c91b87b300c99077a88d7df82162100 100644 (file)
@@ -80,7 +80,7 @@ Current limitations which will be fixed:
 - Unix-centrism in MIME attachment handling and in sendmail
   invocation.
 
-- Several obvious missing features, like undo, filters / saved
+- Several obvious missing features, like filters / saved
   searches, message annotations, etc.
 
 == SYNOPSYS:
index 4a0482a9951e6967999ecf5b56e262edf3f77304..9c342d25693f908e26825c3d7c4348e0b5ba84ee 100755 (executable)
@@ -184,6 +184,14 @@ begin
         ## nothin! use default source labels
       end
 
+      if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL
+        last_info_time = Time.now
+        elapsed = last_info_time - start_time
+        pctdone = source.respond_to?(:pct_done) ? source.pct_done : 100.0 * (source.cur_offset.to_f - source.start_offset).to_f / (source.end_offset - source.start_offset).to_f
+        remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone)
+        $stderr.printf "## read %dm (about %.0f%%) @ %.1fm/s. %s elapsed, about %s remaining\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s
+      end
+
       if index_state.nil?
         puts "Adding message #{source}##{offset} from #{m.from} with state {#{m.labels * ', '}}" if opts[:verbose]
         num_added += 1
index 5f9c3baad61c3a9a41265cd0709c221a508ae936..6ec8be9457ab6fd006b3c50672d3377a43769151 100644 (file)
@@ -1,6 +1,6 @@
 Welcome to Sup! Here's how to get started.
 
-First, try running 'sup'. Since this is your first time, you'll be
+First, try running `sup`. Since this is your first time, you'll be
 confronted with a mostly blank screen, and a notice at the bottom that
 you have no new messages. That's because Sup doesn't hasn't loaded
 anything into its index yet, and has no idea where to look for them
@@ -28,21 +28,21 @@ talks only to the index (stored locally on disk). When you view a
 thread, Sup requests the full content of all the messages from the
 source.
 
-The easiest way to set up all your sources is to run "sup-config".
+The easiest way to set up all your sources is to run `sup-config`.
 This will interactively walk you through some basic configuration,
 prompt you for all the sources you need, and optionally import
 messages from them. Sup-config uses two other tools, sup-add and
 sup-sync, to load messages into the index. In the future you may make
 use of these tools directly (see below).
 
-Once you've run sup-config, you're ready to run 'sup'. You should see
+Once you've run sup-config, you're ready to run `sup`. You should see
 the most recent unarchived messages appear in your inbox.
 Congratulations, you've got Sup working!
 
 If you're coming from the world of traditional MUAs, there are a
 couple differences you should be aware of at this point. First, Sup
 has no folders. Instead, you organize and find messages by a
-combination of search and labels (knowns as 'tags' everywhere else in
+combination of search and labels (known as "tags" everywhere else in
 the world). Search and labels are an integral part of Sup because in
 Sup, rather than viewing the contents of a folder, you view the
 results of a search. I mentioned above that your inbox is, by
@@ -136,7 +136,7 @@ special labels (Draft, Starred, Sent, Spam, etc.). Highlight a label
 and press enter to view all the messages with that label.
 
 What you just did was actually a specific search. For a general search,
-press "\" (backslash---forward slash is used for in-buffer search,
+press '\' (backslash---forward slash is used for in-buffer search,
 following console conventions). Now type in your query (again, Ctrl-G to
 cancel at any point.) You can just type in arbitrary text, which will be
 matched on a per-word basis against the bodies of all email in the
@@ -183,18 +183,18 @@ offsets will be wrong.
 
 That's the bad news. The good news is that Sup is pretty good at being
 able to detect this type of situation, and fixing it is just a matter
-of running sup-sync --changed on the source. Sup will even tell you
+of running `sup-sync --changed` on the source. Sup will even tell you
 how to invoke sup-sync when it detects a problem. This is a
 complication you will almost certainly run in to if you use both Sup
 and another MUA on the same source, so it's good to be aware of it.
 
 Have fun, and let me know if you have any problems!
 
-Appending A: sup-add and sup-sync
+Appendix A: sup-add and sup-sync
 ---------------------------------
 
 Instead of using sup-config to add a new source, you can manually run
-'sup-add' with a URI pointing to it. The URI should be of the form:
+`sup-add` with a URI pointing to it. The URI should be of the form:
 - mbox://path/to/a/filename, for an mbox file on disk.
 - maildir://path/to/a/filename, for a maildir directory on disk.
 - imap://imap.server/folder for an unsecure IMAP folder.
index 88eae7fc5037da797616b0301d8005d36eb1b6b4..96510b2e2d7419629a0a3fd3e9d96eafca29c36e 100644 (file)
@@ -110,6 +110,7 @@ module Redwood
     Redwood::PollManager.new
     Redwood::SuicideManager.new Redwood::SUICIDE_FN
     Redwood::CryptoManager.new
+    Redwood::UndoManager.new
   end
 
   def finish
@@ -266,6 +267,7 @@ require "sup/tagger"
 require "sup/draft"
 require "sup/poll"
 require "sup/crypto"
+require "sup/undo"
 require "sup/horizontal-selector"
 require "sup/modes/line-cursor-mode"
 require "sup/modes/help-mode"
index fe60f61362a5d65cd3abe22d4fcfbf8bc21d26c2..38787cddc41374a9f9adf583160627a5dfb448e0 100644 (file)
@@ -11,7 +11,7 @@ class Colormap
                    Curses::COLOR_YELLOW, Curses::COLOR_BLUE,
                    Curses::COLOR_MAGENTA, Curses::COLOR_CYAN,
                    Curses::COLOR_WHITE, Curses::COLOR_DEFAULT]
-  NUM_COLORS = 15
+  NUM_COLORS = (CURSES_COLORS.size - 1) * (CURSES_COLORS.size - 1)
 
   DEFAULT_COLORS = {
     :status => { :fg => "white", :bg => "blue", :attrs => ["bold"] },
index 35fac30ee8cc0915632958abb6579c6ede54ebfa..32266b5374eb3d67b336926a28e8fe75eecf30ce 100644 (file)
@@ -79,9 +79,7 @@ class DraftLoader < Source
   def fn_for_offset o; File.join(@dir, o.to_s); end
 
   def load_header offset
-    File.open fn_for_offset(offset) do |f|
-      return MBox::read_header(f)
-    end
+    File.open(fn_for_offset(offset)) { |f| parse_raw_email_header f }
   end
   
   def load_message offset
index 4eb13f4ed619d3259df4894eec4a8b66c11c326e..7508c2c7b3e64d4fd39f7a8467a380d68128f6d0 100644 (file)
@@ -93,7 +93,7 @@ class IMAP < Source
   def == o; o.is_a?(IMAP) && o.uri == self.uri && o.username == self.username; end
 
   def load_header id
-    MBox::read_header StringIO.new(raw_header(id))
+    parse_raw_email_header StringIO.new(raw_header(id))
   end
 
   def load_message id
index 7642e08b2916d202693041185c88ec9e74491a7b..0dae1e0933568e902f39247458d30870c47a26e9 100644 (file)
@@ -177,31 +177,31 @@ EOS
     end
   end
 
-  ## Syncs the message to the index: deleting if it's already there,
-  ## and adding either way. Index state will be determined by m.labels.
+  ## Syncs the message to the index, replacing any previous version.  adding
+  ## either way. Index state will be determined by the message's #labels
+  ## accessor.
   ##
-  ## docid and entry can be specified if they're already known.
-  def sync_message m, docid=nil, entry=nil, opts={}
-    docid, entry = load_entry_for_id m.id unless docid && entry
+  ## if need_load is false, docid and entry are assumed to be set to the
+  ## result of load_entry_for_id (which can be nil).
+  def sync_message m, need_load=true, docid=nil, entry=nil, opts={}
+    docid, entry = load_entry_for_id m.id if need_load
 
     raise "no source info for message #{m.id}" unless m.source && m.source_info
     @index_mutex.synchronize do
       raise "trying to delete non-corresponding entry #{docid} with index message-id #{@index[docid][:message_id].inspect} and parameter message id #{m.id.inspect}" if docid && @index[docid][:message_id] != m.id
     end
 
-    source_id = 
-      if m.source.is_a? Integer
-        m.source
-      else
-        m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
-      end
+    source_id = if m.source.is_a? Integer
+      m.source
+    else
+      m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
+    end
 
-    snippet = 
-      if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
-        ""
-      else
-        m.snippet
-      end
+    snippet = if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
+      ""
+    else
+      m.snippet
+    end
 
     ## write the new document to the index. if the entry already exists in the
     ## index, reuse it (which avoids having to reload the entry from the source,
@@ -261,15 +261,14 @@ EOS
       :refs => (entry[:refs] || (m.refs + m.replytos).uniq.join(" ")),
     }
 
-    @index_mutex.synchronize  do
+    @index_mutex.synchronize do
       @index.delete docid if docid
       @index.add_document d
     end
 
-    docid, entry = load_entry_for_id m.id
-    ## this hasn't been triggered in a long time. TODO: decide whether it's still a problem.
-    raise "just added message #{m.id.inspect} but couldn't find it in a search" unless docid
-    true
+    ## this hasn't been triggered in a long time.
+    ## docid, entry = load_entry_for_id m.id
+    ## raise "just added message #{m.id.inspect} but couldn't find it in a search" unless docid
   end
 
   def save_index fn=File.join(@dir, "ferret")
@@ -389,7 +388,7 @@ EOS
       Redwood::log "thread for #{m.id} is killed, ignoring"
       false
     else
-      Redwood::log "ran #{num_queries} queries to build thread of #{messages.size + 1} messages for #{m.id}: #{m.subj}" if num_queries > 0
+      Redwood::log "ran #{num_queries} queries to build thread of #{messages.size} messages for #{m.id}: #{m.subj}" if num_queries > 0
       messages.each { |mid, builder| yield mid, builder }
       true
     end
@@ -414,9 +413,11 @@ EOS
         "references" => doc[:refs].split.map { |x| "<#{x}>" }.join(" "),
       }
 
-      Message.new :source => source, :source_info => doc[:source_info].to_i,
+      m = Message.new :source => source, :source_info => doc[:source_info].to_i,
                   :labels => doc[:label].symbolistize,
-                  :snippet => doc[:snippet], :header => fake_header
+                  :snippet => doc[:snippet]
+      m.parse_header fake_header
+      m
     end
   end
 
index 3d584f76d6054d2c5ffaa10954ea752ed98c808c..a9ae05c71ec61befa861001fd5aca7dd7c3c0a41 100644 (file)
@@ -56,7 +56,7 @@ class Maildir < Source
 
   def load_header id
     scan_mailbox
-    with_file_for(id) { |f| MBox::read_header f }
+    with_file_for(id) { |f| parse_raw_email_header f }
   end
 
   def load_message id
index 8497a37f11a6f6a00bce35953e4a43d5c96103e7..0d941b1d8ae341b86bcf6303e752c3c4bb1c36e5 100644 (file)
@@ -5,77 +5,21 @@ require "sup/rfc2047"
 
 module Redwood
 
-## some utility functions. actually these are not mbox-specific at all
-## and should be moved somewhere else.
-##
-## TODO: move functionality to somewhere better, like message.rb
 module MBox
-  BREAK_RE = /^From \S+/
-  HEADER_RE = /\s*(.*?)\s*/
-
-  def read_header f
-    header = {}
-    last = nil
-
-    ## i do it in this weird way because i am trying to speed things up
-    ## when scanning over large mbox files.
-    while(line = f.gets)
-      case line
-      ## these three can occur multiple times, and we want the first one
-      when /^(Delivered-To):#{HEADER_RE}$/i,
-        /^(X-Original-To):#{HEADER_RE}$/i,
-        /^(Envelope-To):#{HEADER_RE}$/i: header[last = $1] ||= $2
-
-      when /^(From):#{HEADER_RE}$/i,
-        /^(To):#{HEADER_RE}$/i,
-        /^(Cc):#{HEADER_RE}$/i,
-        /^(Bcc):#{HEADER_RE}$/i,
-        /^(Subject):#{HEADER_RE}$/i,
-        /^(Date):#{HEADER_RE}$/i,
-        /^(References):#{HEADER_RE}$/i,
-        /^(In-Reply-To):#{HEADER_RE}$/i,
-        /^(Reply-To):#{HEADER_RE}$/i,
-        /^(List-Post):#{HEADER_RE}$/i,
-        /^(List-Subscribe):#{HEADER_RE}$/i,
-        /^(List-Unsubscribe):#{HEADER_RE}$/i,
-        /^(Status):#{HEADER_RE}$/i,
-        /^(X-\S+):#{HEADER_RE}$/: header[last = $1] = $2
-      when /^(Message-Id):#{HEADER_RE}$/i: header[mid_field = last = $1] = $2
-
-      when /^\r*$/: break
-      when /^\S+:/: last = nil # some other header we don't care about
-      else
-        header[last] += " " + line.chomp.gsub(/^\s+/, "") if last
-      end
-    end
-
-    if mid_field && header[mid_field] && header[mid_field] =~ /<(.*?)>/
-      header[mid_field] = $1
+  BREAK_RE = /^From \S+ (.+)$/
+
+  def is_break_line? l
+    l =~ BREAK_RE or return false
+    time = $1
+    begin
+      ## hack -- make Time.parse fail when trying to substitute values from Time.now
+      Time.parse time, 0
+      true
+    rescue NoMethodError
+      Redwood::log "found invalid date in potential mbox split line, not splitting: #{l.inspect}"
+      false
     end
-
-    header.each do |k, v|
-      next unless Rfc2047.is_encoded? v
-      header[k] =
-        begin
-          Rfc2047.decode_to $encoding, v
-        rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
-          Redwood::log "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
-          v
-        end
-    end
-    header
   end
-  
-  ## never actually called
-  def read_body f
-    body = []
-    f.each_line do |l|
-      break if l =~ BREAK_RE
-      body << l.chomp
-    end
-    body
-  end
-
-  module_function :read_header, :read_body
+  module_function :is_break_line?
 end
 end
index 65d0bd102a2612339770945965fc15d71050901d..ebb2aed675e171397b99e877023e24611cf119c7 100644 (file)
@@ -9,7 +9,7 @@ class Loader < Source
   attr_accessor :labels
 
   ## uri_or_fp is horrific. need to refactor.
-  def initialize uri_or_fp, start_offset=nil, usual=true, archived=false, id=nil, labels=[]
+  def initialize uri_or_fp, start_offset=0, usual=true, archived=false, id=nil, labels=[]
     @mutex = Mutex.new
     @labels = ((labels || []) - LabelManager::RESERVED_LABELS).uniq.freeze
 
@@ -56,10 +56,10 @@ class Loader < Source
     @mutex.synchronize do
       @f.seek offset
       l = @f.gets
-      unless l =~ BREAK_RE
+      unless MBox::is_break_line? l
         raise OutOfSyncSourceError, "mismatch in mbox file offset #{offset.inspect}: #{l.inspect}." 
       end
-      header = MBox::read_header @f
+      header = parse_raw_email_header @f
     end
     header
   end
@@ -68,13 +68,12 @@ class Loader < Source
     @mutex.synchronize do
       @f.seek offset
       begin
-        RMail::Mailbox::MBoxReader.new(@f).each_message do |input|
-          m = RMail::Parser.read(input)
-          if m.body && m.body.is_a?(String)
-            m.body.gsub!(/^>From /, "From ")
-          end
-          return m
-        end
+        ## don't use RMail::Mailbox::MBoxReader because it doesn't properly ignore
+        ## "From" at the start of a message body line.
+        string = ""
+        l = @f.gets
+        string << l until @f.eof? || MBox::is_break_line?(l = @f.gets)
+        RMail::Parser.read string
       rescue RMail::Parser::Error => e
         raise FatalSourceError, "error parsing mbox file: #{e.message}"
       end
@@ -98,7 +97,7 @@ class Loader < Source
     @mutex.synchronize do
       @f.seek offset
       until @f.eof? || (l = @f.gets) =~ /^\r*$/
-        ret += l
+        ret << l
       end
     end
     ret
@@ -106,7 +105,7 @@ class Loader < Source
 
   def raw_message offset
     ret = ""
-    each_raw_message_line(offset) { |l| ret += l }
+    each_raw_message_line(offset) { |l| ret << l }
     ret
   end
 
@@ -120,7 +119,7 @@ class Loader < Source
     @mutex.synchronize do
       @f.seek offset
       yield @f.gets
-      until @f.eof? || (l = @f.gets) =~ BREAK_RE
+      until @f.eof? || MBox::is_break_line?(l = @f.gets)
         yield l
       end
     end
@@ -141,7 +140,7 @@ class Loader < Source
         ## 2. at the beginning of an mbox separator (in all other
         ##    cases).
 
-        l = @f.gets or raise "next while at EOF"
+        l = @f.gets or return nil
         if l =~ /^\s*$/ # case 1
           returned_offset = @f.tell
           @f.gets # now we're at a BREAK_RE, so skip past it
@@ -151,7 +150,7 @@ class Loader < Source
         end
 
         while(line = @f.gets)
-          break if line =~ BREAK_RE
+          break if MBox::is_break_line? line
           next_offset = @f.tell
         end
       end
index b3c001e5cc64db42b49a6c070f7b270492d897b1..6dd1f7dfa3fa67ed9dd70a39460fa0d08ec73537 100644 (file)
@@ -13,8 +13,8 @@ class MessageFormatError < StandardError; end
 ## specific module that would detect and link to /ruby-talk:\d+/
 ## sequences in the text of an email. (how sweet would that be?)
 ##
-## this class cathces all source exceptions. if the underlying source throws
-## an error, it is caught and handled.
+## this class catches all source exceptions. if the underlying source
+## throws an error, it is caught and handled.
 
 class Message
   SNIPPET_LEN = 80
@@ -50,7 +50,7 @@ class Message
     @snippet = opts[:snippet]
     @snippet_contains_encrypted_content = false
     @have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
-    @labels = [] + (opts[:labels] || [])
+    @labels = (opts[:labels] || []).to_set_of_symbols
     @dirty = false
     @encrypted = false
     @chunks = nil
@@ -60,49 +60,42 @@ class Message
     ## why.
     @refs = []
 
-    parse_header(opts[:header] || @source.load_header(@source_info))
+    #parse_header(opts[:header] || @source.load_header(@source_info))
   end
 
   def parse_header header
-    header.keys.each { |k| header[k.downcase] = header[k] } # canonicalize
-
-    fakeid = nil
-    fakename = nil
-
-    @id =
-      if header["message-id"]
-        sanitize_message_id header["message-id"]
-      else
-        fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
-      end
+    @id = if header["message-id"]
+      mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"]
+      sanitize_message_id mid
+    else
+      id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
+      from = header["from"]
+      #Redwood::log "faking non-existent message-id for message from #{from}: #{id}"
+      id
+    end
     
-    @from =
-      if header["from"]
-        Person.from_address header["from"]
-      else
-        fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
-        Person.from_address fakename
-      end
-
-    Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
-    Redwood::log "faking from for message #@id: #{fakename}" if fakename
-
-    date = header["date"]
-    @date =
-      case date
-      when Time
-        date
-      when String
-        begin
-          Time.parse date
-        rescue ArgumentError => e
-          Redwood::log "faking date header for #{@id} due to error parsing date #{header['date'].inspect}: #{e.message}"
-          Time.now
-        end
-      else
-        Redwood::log "faking date header for #{@id}"
+    @from = Person.from_address(if header["from"]
+      header["from"]
+    else
+      name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
+      #Redwood::log "faking non-existent sender for message #@id: #{name}"
+      name
+    end)
+
+    @date = case(date = header["date"])
+    when Time
+      date
+    when String
+      begin
+        Time.parse date
+      rescue ArgumentError => e
+        #Redwood::log "faking mangled date header for #{@id} (orig #{header['date'].inspect} gave error: #{e.message})"
         Time.now
       end
+    else
+      #Redwood::log "faking non-existent date header for #{@id}"
+      Time.now
+    end
 
     @subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
     @to = Person.from_address_list header["to"]
@@ -130,7 +123,6 @@ class Message
     @list_subscribe = header["list-subscribe"]
     @list_unsubscribe = header["list-unsubscribe"]
   end
-  private :parse_header
 
   def add_ref ref
     @refs << ref
@@ -172,7 +164,7 @@ class Message
   def has_label? t; @labels.member? t; end
   def add_label t
     return if @labels.member? t
-    @labels.push t
+    @labels = (@labels + [t]).to_set_of_symbols
     @dirty = true
   end
   def remove_label t
@@ -186,7 +178,7 @@ class Message
   end
 
   def labels= l
-    @labels = l
+    @labels = l.to_set_of_symbols
     @dirty = true
   end
 
@@ -198,7 +190,7 @@ class Message
   ## this is called when the message body needs to actually be loaded.
   def load_from_source!
     @chunks ||=
-      if @source.has_errors?
+      if @source.respond_to?(:has_errors?) && @source.has_errors?
         [Chunk::Text.new(error_message(@source.error.message).split("\n"))]
       else
         begin
@@ -372,6 +364,7 @@ private
     [notice, sig, children].flatten.compact
   end
 
+  ## takes a RMail::Message, breaks it into Chunk:: classes.
   def message_to_chunks m, encrypted=false, sibling_types=[]
     if m.multipart?
       chunks =
index e728fe5bb597cd832b84eaee2d6b11e8114822a0..04d5922381b728de2034c9dbda3b8089d78d7ccc 100644 (file)
@@ -5,7 +5,6 @@ class ComposeMode < EditMessageMode
     header = {}
     header["From"] = (opts[:from] || AccountManager.default_account).full_address
     header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
-    header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
     header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
     header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
     header["Subject"] = opts[:subj] if opts[:subj]
index 08b9fd259742cff252f837eece867f8337a953a7..d7bd41c692f55a7fe7f23f0ebb608e506f938f06 100644 (file)
@@ -13,7 +13,7 @@ class EditMessageMode < LineCursorMode
 
   FORCE_HEADERS = %w(From To Cc Bcc Subject)
   MULTI_HEADERS = %w(To Cc Bcc)
-  NON_EDITABLE_HEADERS = %w(Message-Id Date)
+  NON_EDITABLE_HEADERS = %w(Message-id Date)
 
   HookManager.register "signature", <<EOS
 Generates a message signature.
@@ -238,7 +238,7 @@ protected
 
   def parse_file fn
     File.open(fn) do |f|
-      header = MBox::read_header f
+      header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
       body = f.readlines.map { |l| l.chomp }
 
       header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
index 559892d5655f07b80a1186e114950156220a13c5..d8daeb9c4cbe19ad1fc2644aa1d5c84a4e382f23 100644 (file)
@@ -26,12 +26,27 @@ class InboxMode < ThreadIndexMode
 
   def archive
     return unless cursor_thread
+    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+    UndoManager.register "archiving thread" do
+      thread.apply_label :inbox
+      add_or_unhide thread.first
+    end
+
     cursor_thread.remove_label :inbox
     hide_thread cursor_thread
     regen_text
   end
 
   def multi_archive threads
+    UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do
+      threads.map do |t|
+        t.apply_label :inbox
+        add_or_unhide t.first
+      end
+      regen_text
+    end
+
     threads.each do |t|
       t.remove_label :inbox
       hide_thread t
@@ -41,6 +56,14 @@ class InboxMode < ThreadIndexMode
 
   def read_and_archive
     return unless cursor_thread
+    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+    UndoManager.register "reading and archiving thread" do
+      thread.apply_label :inbox
+      thread.apply_label :unread
+      add_or_unhide thread.first
+    end
+
     cursor_thread.remove_label :unread
     cursor_thread.remove_label :inbox
     hide_thread cursor_thread
@@ -48,12 +71,23 @@ class InboxMode < ThreadIndexMode
   end
 
   def multi_read_and_archive threads
+    old_labels = threads.map { |t| t.labels.dup }
+
     threads.each do |t|
       t.remove_label :unread
       t.remove_label :inbox
       hide_thread t
     end
     regen_text
+
+    UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
+      threads.zip(old_labels).each do |t, l|
+        t.labels = l
+        add_or_unhide t.first
+      end
+      regen_text
+    end
+
   end
 
   def handle_unarchived_update sender, m
index 66c9123a5e002be18d096170b00a7b96a3ee8c7b..5714ad851d785b806a9fa93d292bd9c6ce8e7cd7 100644 (file)
@@ -44,6 +44,7 @@ EOS
     k.add :tag_matching, "Tag matching threads", 'g'
     k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
     k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
+    k.add :undo, "Undo the previous action", 'u'
   end
 
   def initialize hidden_labels=[], load_thread_opts={}
@@ -86,6 +87,7 @@ EOS
 
   def reload
     drop_all_threads
+    UndoManager.clear
     BufferManager.draw_screen
     load_threads :num => buffer.content_height
   end
@@ -211,6 +213,10 @@ EOS
     add_or_unhide m
   end
 
+  def undo
+    UndoManager.undo
+  end
+
   def update
     @mutex.synchronize do
       ## let's see you do THIS in python
@@ -233,66 +239,122 @@ EOS
     end
   end
 
+  ## returns an undo lambda
   def actually_toggle_starred t
+    pos = curpos
     if t.has_label? :starred # if ANY message has a star
       t.remove_label :starred # remove from all
       UpdateManager.relay self, :unstarred, t.first
+      lambda do
+        t.first.add_label :starred
+        UpdateManager.relay self, :starred, t.first
+        regen_text
+      end
     else
       t.first.add_label :starred # add only to first
       UpdateManager.relay self, :starred, t.first
+      lambda do
+        t.remove_label :starred
+        UpdateManager.relay self, :unstarred, t.first
+        regen_text
+      end
     end
   end  
 
   def toggle_starred 
     t = cursor_thread or return
-    actually_toggle_starred t
+    undo = actually_toggle_starred t
+    UndoManager.register "toggling thread starred status", undo
     update_text_for_line curpos
     cursor_down
   end
 
   def multi_toggle_starred threads
-    threads.each { |t| actually_toggle_starred t }
+    UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
+      threads.map { |t| actually_toggle_starred t }
     regen_text
   end
 
+  ## returns an undo lambda
   def actually_toggle_archived t
+    thread = t
+    pos = curpos
     if t.has_label? :inbox
       t.remove_label :inbox
       UpdateManager.relay self, :archived, t.first
+      lambda do
+        thread.apply_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self,:unarchived, thread.first
+      end
     else
       t.apply_label :inbox
       UpdateManager.relay self, :unarchived, t.first
+      lambda do
+        thread.remove_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self, :unarchived, thread.first
+      end
     end
   end
 
+  ## returns an undo lambda
   def actually_toggle_spammed t
+    thread = t
     if t.has_label? :spam
       t.remove_label :spam
+      add_or_unhide t.first
       UpdateManager.relay self, :unspammed, t.first
+      lambda do
+        thread.apply_label :spam
+        self.hide_thread thread
+        UpdateManager.relay self,:spammed, thread.first
+      end
     else
       t.apply_label :spam
+      hide_thread t
       UpdateManager.relay self, :spammed, t.first
+      lambda do
+        thread.remove_label :spam
+        add_or_unhide thread.first
+        UpdateManager.relay self,:unspammed, thread.first
+      end
     end
   end
 
+  ## returns an undo lambda
   def actually_toggle_deleted t
     if t.has_label? :deleted
       t.remove_label :deleted
+      add_or_unhide t.first
       UpdateManager.relay self, :undeleted, t.first
+      lambda do
+        t.apply_label :deleted
+        hide_thread t
+        UpdateManager.relay self, :deleted, t.first
+      end
     else
       t.apply_label :deleted
+      hide_thread t
       UpdateManager.relay self, :deleted, t.first
+      lambda do
+        t.remove_label :deleted
+        add_or_unhide t.first
+        UpdateManager.relay self, :undeleted, t.first
+      end
     end
   end
 
   def toggle_archived 
     t = cursor_thread or return
-    actually_toggle_archived t
+    undo = actually_toggle_archived t
+    UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos }
     update_text_for_line curpos
   end
 
   def multi_toggle_archived threads
-    threads.each { |t| actually_toggle_archived t }
+    undos = threads.map { |t| actually_toggle_archived t }
+    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text }
     regen_text
   end
 
@@ -353,10 +415,9 @@ EOS
   ## see deleted or spam emails, and when you undelete or unspam them
   ## you also want them to disappear immediately.
   def multi_toggle_spam threads
-    threads.each do |t|
-      actually_toggle_spammed t
-      hide_thread t 
-    end
+    undos = threads.map { |t| actually_toggle_spammed t }
+    UndoManager.register "marking/unmarking  #{threads.size.pluralize 'thread'} as spam",
+                         undos, lambda { regen_text }
     regen_text
   end
 
@@ -367,10 +428,9 @@ EOS
 
   ## see comment for multi_toggle_spam
   def multi_toggle_deleted threads
-    threads.each do |t|
-      actually_toggle_deleted t
-      hide_thread t 
-    end
+    undos = threads.map { |t| actually_toggle_deleted t }
+    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
+                         undos, lambda { regen_text }
     regen_text
   end
 
@@ -379,13 +439,23 @@ EOS
     multi_kill [t]
   end
 
+  ## m-m-m-m-MULTI-KILL
   def multi_kill threads
+    UndoManager.register "killing #{threads.size.pluralize 'thread'}" do
+      threads.each do |t|
+        t.remove_label :killed
+        add_or_unhide t.first
+      end
+      regen_text
+    end
+
     threads.each do |t|
       t.apply_label :killed
       hide_thread t
     end
+
     regen_text
-    BufferManager.flash "#{threads.size.pluralize 'Thread'} killed."
+    BufferManager.flash "#{threads.size.pluralize 'thread'} killed."
   end
 
   def save background=true
@@ -454,6 +524,10 @@ EOS
   def edit_labels
     thread = cursor_thread or return
     speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
+
+    old_labels = thread.labels
+    pos = curpos
+
     keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
 
     user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl, @hidden_labels
@@ -462,6 +536,13 @@ EOS
     thread.labels = keepl + user_labels
     user_labels.each { |l| LabelManager << l }
     update_text_for_line curpos
+
+    UndoManager.register "labeling thread #{thread.first.id}" do
+      thread.labels = old_labels
+      update_text_for_line pos
+      UpdateManager.relay self, :labeled, thread.first
+    end
+
     UpdateManager.relay self, :labeled, thread.first
   end
 
@@ -471,21 +552,33 @@ EOS
 
     user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
     hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
-    if hl.empty?
-      threads.each do |t|
-        user_labels.each do |(l, to_remove)|
-          if to_remove
-            t.remove_label l
-          else
-            t.apply_label l
-          end
+    unless hl.empty?
+      BufferManager.flash "'#{hl}' is a reserved label!"
+      return
+    end
+
+    old_labels = threads.map { |t| t.labels.dup }
+
+    threads.each do |t|
+      user_labels.each do |(l, to_remove)|
+        if to_remove
+          t.remove_label l
+        else
+          t.apply_label l
+          LabelManager << l
         end
       end
-      user_labels.each { |(l,_)| LabelManager << l }
-    else
-      BufferManager.flash "'#{hl}' is a reserved label!"
     end
+
     regen_text
+
+    UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
+      threads.zip(old_labels).map do |t, old_labels|
+        t.labels = old_labels
+        UpdateManager.relay self, :labeled, t.first
+      end
+      regen_text
+    end
   end
 
   def reply
@@ -689,7 +782,7 @@ protected
 
     date = t.date.to_nice_s
 
-    starred = t.has_label?(:starred)
+    starred = t.has_label? :starred
 
     ## format the from column
     cur_width = 0
index 3a8b57fcda94dd78edb864e3ba7c7d0a2024ffe9..fb4abb2526771422579020407bb0ba671391485a 100644 (file)
@@ -86,7 +86,7 @@ EOS
       Index.usual_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.has_errors?
+          yield "Loading from #{source}... " unless source.done? || (source.respond_to?(:has_errors?) && source.has_errors?)
         rescue SourceError => e
           Redwood::log "problem getting messages from #{source}: #{e.message}"
           Redwood::report_broken_sources :force_to_top => true
@@ -149,6 +149,8 @@ EOS
 
         begin
           m = Message.new :source => source, :source_info => offset, :labels => labels
+          m.load_from_source!
+
           if m.source_marked_read?
             m.remove_label :unread
             labels.delete :unread
@@ -157,7 +159,7 @@ EOS
           docid, entry = Index.load_entry_for_id m.id
           HookManager.run "before-add-message", :message => m
           m = yield(m, offset, entry) or next if block_given?
-          Index.sync_message m, docid, entry, opts
+          times = Index.sync_message m, false, docid, entry, opts
           UpdateManager.relay self, :added, m unless entry
         rescue MessageFormatError => e
           Redwood::log "ignoring erroneous message at #{source}##{offset}: #{e.message}"
index 6510aae8a738dba0b3d81acb617312a1e1334bec..91cd71f4da2461034719cced8990b243f8cf2364 100644 (file)
@@ -99,7 +99,49 @@ class Source
     end
   end
 
+  ## read a raw email header from a filehandle (or anything that responds to
+  ## #gets), and turn it into a hash of key-value pairs.
+  ##
+  ## WARNING! THIS IS A SPEED-CRITICAL SECTION. Everything you do here will have
+  ## a significant effect on Sup's processing speed of email from ALL sources.
+  ## Little things like string interpolation, regexp interpolation, += vs <<,
+  ## all have DRAMATIC effects. BE CAREFUL WHAT YOU DO!
+  def self.parse_raw_email_header f
+    header = {}
+    last = nil
+
+    while(line = f.gets)
+      case line
+      ## these three can occur multiple times, and we want the first one
+      when /^(Delivered-To|X-Original-To|Envelope-To):\s*(.*?)\s*$/i; header[last = $1.downcase] ||= $2
+      ## mark this guy specially. not sure why i care.
+      when /^([^:\s]+):\s*(.*?)\s*$/i; header[last = $1.downcase] = $2
+      when /^\r*$/; break
+      else
+        if last
+          header[last] << " " unless header[last].empty?
+          header[last] << line.strip
+        end
+      end
+    end
+
+    %w(subject from to cc bcc).each do |k|
+      v = header[k] or next
+      next unless Rfc2047.is_encoded? v
+      header[k] = begin
+        Rfc2047.decode_to $encoding, v
+      rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
+        #Redwood::log "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
+        v
+      end
+    end
+    header
+  end
+
 protected
+
+  ## convenience function
+  def parse_raw_email_header f; self.class.parse_raw_email_header f end
   
   def Source.expand_filesystem_uri uri
     uri.gsub "~", File.expand_path("~")
diff --git a/lib/sup/undo.rb b/lib/sup/undo.rb
new file mode 100644 (file)
index 0000000..5a93c31
--- /dev/null
@@ -0,0 +1,39 @@
+module Redwood
+
+## Implements a single undo list for the Sup instance
+##
+## The basic idea is to keep a list of lambdas to undo
+## things. When an action is called (such as 'archive'),
+## a lambda is registered with UndoManager that will
+## undo the archival action
+
+class UndoManager
+  include Singleton
+
+  def initialize
+    @@actionlist = []
+    self.class.i_am_the_instance self
+  end
+
+  def register desc, *actions, &b
+    actions = [*actions.flatten]
+    actions << b if b
+    raise ArgumentError, "need at least one action" unless actions.length > 0
+    @@actionlist.push :desc => desc, :actions => actions
+  end
+
+  def undo
+    unless @@actionlist.empty?
+      actionset = @@actionlist.pop
+      actionset[:actions].each { |action| action.call }
+      BufferManager.flash "undid #{actionset[:desc]}"
+    else
+      BufferManager.flash "nothing more to undo!"
+    end
+  end
+
+  def clear
+    @@actionlist = []
+  end
+end
+end
index 02575c0fdd0403e727f829692c7c1d9ffadbcde4..b479908e1263dc1384537c72c2d0c9fcaf4c9146 100644 (file)
@@ -405,6 +405,10 @@ class Array
 
   def last= e; self[-1] = e end
   def nonempty?; !empty? end
+
+  def to_set_of_symbols
+    map { |x| x.is_a?(Symbol) ? x : x.intern }.uniq
+  end
 end
 
 class Time
index f3afa31e51a648da767c56e18cfb6fcaca635fe8..83790c5dcaeb26ff716bb7b87537373eb8a79def 100644 (file)
@@ -26,7 +26,7 @@ class DummySource < Source
   end
 
   def load_header offset
-    MBox::read_header StringIO.new(raw_header(offset))
+    Source.parse_raw_email_header StringIO.new(raw_header(offset))
   end
   
   def load_message offset
@@ -53,13 +53,6 @@ class DummySource < Source
       yield f.gets
     end
   end
-
-  # FIXME: this one was not mentioned in the source documentation, but
-  # it's still required
-  def has_errors?
-
-  end
-
 end
 
 end
diff --git a/test/test_header_parsing.rb b/test/test_header_parsing.rb
new file mode 100644 (file)
index 0000000..91cf7c7
--- /dev/null
@@ -0,0 +1,157 @@
+#!/usr/bin/ruby
+
+require 'test/unit'
+require 'sup'
+require 'stringio'
+
+include Redwood
+
+class TestMBoxParsing < Test::Unit::TestCase
+  def setup
+  end
+
+  def teardown
+  end
+
+  def test_normal_headers
+    h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+To: Sally <sally@sally.com>
+EOS
+
+    assert_equal "Bob <bob@bob.com>", h["from"]
+    assert_equal "Sally <sally@sally.com>", h["to"]
+    assert_nil h["message-id"]
+  end
+
+  def test_multiline
+    h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+Subject: one two three
+  four five six
+To: Sally <sally@sally.com>
+References: <seven>
+  <eight>
+Seven: Eight
+EOS
+
+    assert_equal "one two three four five six", h["subject"]
+    assert_equal "Sally <sally@sally.com>", h["to"]
+    assert_equal "<seven> <eight>", h["references"]
+  end
+
+  def test_ignore_spacing
+    variants = [
+      "Subject:one two  three   end\n",
+      "Subject:    one two  three   end\n",
+      "Subject:   one two  three   end    \n",
+    ]
+    variants.each do |s|
+      h = Source.parse_raw_email_header StringIO.new(s)
+      assert_equal "one two  three   end", h["subject"]
+    end
+  end
+
+  def test_message_id_ignore_spacing
+    variants = [
+      "Message-Id:     <one@bob.com>       \n",
+      "Message-Id:<one@bob.com>       \n",
+    ]
+    variants.each do |s|
+      h = Source.parse_raw_email_header StringIO.new(s)
+      assert_equal "<one@bob.com>", h["message-id"]
+    end
+  end
+
+  def test_blank_lines
+    h = Source.parse_raw_email_header StringIO.new("")
+    assert_equal nil, h["message-id"]
+  end
+
+  def test_empty_headers
+    variants = [
+      "Message-Id:       \n",
+      "Message-Id:\n",
+    ]
+    variants.each do |s|
+      h = Source.parse_raw_email_header StringIO.new(s)
+      assert_equal "", h["message-id"]
+    end
+  end
+
+  def test_detect_end_of_headers
+    h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+
+To: a dear friend
+EOS
+  assert_equal "Bob <bob@bob.com>", h["from"]
+  assert_nil h["to"]
+
+  h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+\r
+To: a dear friend
+EOS
+  assert_equal "Bob <bob@bob.com>", h["from"]
+  assert_nil h["to"]
+
+  h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+\r\n\r
+To: a dear friend
+EOS
+  assert_equal "Bob <bob@bob.com>", h["from"]
+  assert_nil h["to"]
+  end
+
+  def test_from_line_splitting
+    l = MBox::Loader.new StringIO.new(<<EOS)
+From sup-talk-bounces@rubyforge.org Mon Apr 27 12:56:18 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello there friend. How are you?
+
+From sea to shining sea
+
+From bob@bob.com I get only spam.
+
+From bob@bob.com   
+
+From bob@bob.com
+
+(that second one has spaces at the endj
+
+This is the end of the email.
+EOS
+    offset, labels = l.next
+    assert_equal 0, offset
+    offset, labels = l.next
+    assert_nil offset
+  end
+
+  def test_more_from_line_splitting
+    l = MBox::Loader.new StringIO.new(<<EOS)
+From sup-talk-bounces@rubyforge.org Mon Apr 27 12:56:18 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello there friend. How are you?
+
+From bob@bob.com Mon Apr 27 12:56:19 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello again! Would you like to buy my products?
+EOS
+    offset, labels = l.next
+    assert_not_nil offset
+
+    offset, labels = l.next
+    assert_not_nil offset
+
+    offset, labels = l.next
+    assert_nil offset
+  end
+end
diff --git a/test/test_mbox_parsing.rb b/test/test_mbox_parsing.rb
deleted file mode 100644 (file)
index 32687e5..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-#!/usr/bin/ruby
-
-require 'test/unit'
-require 'sup'
-require 'stringio'
-
-include Redwood
-
-class TestMBoxParsing < Test::Unit::TestCase
-  def setup
-  end
-
-  def teardown
-  end
-
-  def test_normal_headers
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-To: Sally <sally@sally.com>
-EOS
-
-    assert_equal "Bob <bob@bob.com>", h["From"]
-    assert_equal "Sally <sally@sally.com>", h["To"]
-    assert_nil h["Message-Id"]
-  end
-
-  ## this is shitty behavior in retrospect, but it's built in now.
-  def test_message_id_stripping
-    h = MBox.read_header StringIO.new("Message-Id: <one@bob.com>\n")
-    assert_equal "one@bob.com", h["Message-Id"]
-
-    h = MBox.read_header StringIO.new("Message-Id: one@bob.com\n")
-    assert_equal "one@bob.com", h["Message-Id"]
-  end
-
-  def test_multiline
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-Subject: one two three
-  four five six
-To: Sally <sally@sally.com>
-References: seven
-  eight
-Seven: Eight
-EOS
-
-    assert_equal "one two three four five six", h["Subject"]
-    assert_equal "Sally <sally@sally.com>", h["To"]
-    assert_equal "seven eight", h["References"]
-  end
-
-  def test_ignore_spacing
-    variants = [
-      "Subject:one two  three   end\n",
-      "Subject:    one two  three   end\n",
-      "Subject:   one two  three   end    \n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "one two  three   end", h["Subject"]
-    end
-  end
-
-  def test_message_id_ignore_spacing
-    variants = [
-      "Message-Id:     <one@bob.com>       \n",
-      "Message-Id:      one@bob.com        \n",
-      "Message-Id:<one@bob.com>       \n",
-      "Message-Id:one@bob.com       \n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "one@bob.com", h["Message-Id"]
-    end
-  end
-
-  def test_blank_lines
-    h = MBox.read_header StringIO.new("")
-    assert_equal nil, h["Message-Id"]
-  end
-
-  def test_empty_headers
-    variants = [
-      "Message-Id:       \n",
-      "Message-Id:\n",
-    ]
-    variants.each do |s|
-      h = MBox.read_header StringIO.new(s)
-      assert_equal "", h["Message-Id"]
-    end
-  end
-
-  def test_detect_end_of_headers
-    h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-
-  h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-
-  h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r\n\r
-To: a dear friend
-EOS
-  assert_equal "Bob <bob@bob.com>", h["From"]
-  assert_nil h["To"]
-  end
-end
index e38ac5064ca999f8b95b6d70c93f9b05ed8796f4..0a7db454febd8c93238dacaa89590f6110222cc6 100644 (file)
@@ -511,7 +511,7 @@ EOS
 
     # Look at another header field whose first line was blank.
     list_unsubscribe = sup_message.list_unsubscribe
-    assert_equal(" <http://mailman2.widget.com/mailman/listinfo/monitor-list>, " +
+    assert_equal("<http://mailman2.widget.com/mailman/listinfo/monitor-list>, " +
                  "<mailto:monitor-list-request@widget.com?subject=unsubscribe>",
                  list_unsubscribe)