sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 45c3433c036446455e63142d2d2db4e37557a260
parent 9be79c3141bd56bf8af0c6c877d51b9ae0911b83
Author: Rich Lane <rlane@club.cc.cmu.edu>
Date:   Thu, 15 Jul 2010 19:22:17 -0700

Merge remote branch 'origin/maildir'

Conflicts:
	bin/sup-dump
	lib/sup/message.rb
	lib/sup/modes/thread-index-mode.rb
	lib/sup/server.rb
	lib/sup/source.rb

Diffstat:
M bin/sup-add | 4 ++--
M bin/sup-config | 2 +-
M bin/sup-dump | 33 +++++++++++++++++----------------
M bin/sup-recover-sources | 2 +-
M bin/sup-sync | 189 ++++++++++++++++++++++++-------------------------------------------------------
M bin/sup-sync-back | 6 ++++--
M lib/sup/draft.rb | 31 ++++++++++++++-----------------
M lib/sup/index.rb | 39 +++++++++++++++++++++++++++------------
M lib/sup/logger.rb | 2 +-
M lib/sup/maildir.rb | 166 ++++++++++++++++++++++++++-----------------------------------------------------
M lib/sup/mbox.rb | 105 +++++++++++++++++++++++++++++++------------------------------------------------
M lib/sup/message.rb | 126 ++++++++++++++++++++++++++++++++++++++++++-------------------------------------
M lib/sup/modes/console-mode.rb | 5 ++++-
M lib/sup/poll.rb | 104 ++++++++++++++++++++++++++++++++++++-------------------------------------------
M lib/sup/sent.rb | 19 ++++++++-----------
M lib/sup/server.rb | 9 +++------
M lib/sup/source.rb | 47 ++++++++++++++---------------------------------
M lib/sup/util.rb | 42 ++++++++----------------------------------
M test/test_header_parsing.rb | 4 ++--
19 files changed, 374 insertions(+), 561 deletions(-)
diff --git a/bin/sup-add b/bin/sup-add
@@ -97,9 +97,9 @@ begin
     source = 
       case parsed_uri.scheme
       when "maildir"
-        Redwood::Maildir.new uri, nil, !$opts[:unusual], $opts[:archive], nil, labels
+        Redwood::Maildir.new uri, !$opts[:unusual], $opts[:archive], nil, labels
       when "mbox"
-        Redwood::MBox::Loader.new uri, nil, !$opts[:unusual], $opts[:archive], nil, labels
+        Redwood::MBox.new uri, !$opts[:unusual], $opts[:archive], nil, labels
       when nil
         Trollop::die "Sources must be specified with an URI"
       else
diff --git a/bin/sup-config b/bin/sup-config
@@ -57,7 +57,7 @@ def add_source
       return if fn.nil? || fn.empty?
 
       $last_fn = fn
-      [Redwood::MBox::Loader.suggest_labels_for(fn),
+      [Redwood::MBox.suggest_labels_for(fn),
        { :scheme => "mbox", :path => fn }]
     when :maildir
       $last_fn ||= ENV["MAIL"]
diff --git a/bin/sup-dump b/bin/sup-dump
@@ -1,17 +1,19 @@
 #!/usr/bin/env ruby
 
 require 'rubygems'
+require 'xapian'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+
+BASE_DIR = ENV["SUP_BASE"] || File.join(ENV["HOME"], ".sup")
 
 $opts = Trollop::options do
-  version "sup-dump (sup #{Redwood::VERSION})"
+  version "sup-dump"
   banner <<EOS
 Dumps all message state from the sup index to standard out. You can
 later use sup-sync --restored --restore <filename> to recover the index.
 
-This tool is primarily useful in the event that a Xapian upgrade breaks
-the index format.
+This tool is primarily useful in the event that a Sup upgrade breaks index
+format compatibility.
 
 Usage:
   sup-dump > <filename>
@@ -19,17 +21,16 @@ Usage:
 EOS
 end
 
-$config = Redwood::load_config Redwood::CONFIG_FN
-index = Redwood::Index.init
-Redwood::SourceManager.init
-index.load
-Redwood::SentManager.init $config[:sent_source] || 'sup://sent'
-if(s = Redwood::SourceManager.source_for Redwood::SentManager.source_uri)
-  Redwood::SentManager.source = s
-else
-  Redwood::SourceManager.add_source Redwood::SentManager.default_source
-end
+xapian = Xapian::Database.new File.join(BASE_DIR, 'xapian')
+version = xapian.get_metadata 'rescue-version'
+version = '0' if version.empty?
 
-index.each_message :load_spam => true, :load_deleted => true, :load_killed => true do |m|
-  puts "#{m.id} (#{m.labels.to_a.sort_by { |l| l.to_s } * ' '})"
+case version
+when '0'
+  xapian.postlist('Kmail').each do |x|
+    entry = Marshal.load(xapian.document(x.docid).data)
+    puts "#{entry[:message_id]} (#{entry[:labels].sort_by { |l| l.to_s } * ' '})"
+  end
+else
+  abort "this sup-dump version doesn't understand your index"
 end
diff --git a/bin/sup-recover-sources b/bin/sup-recover-sources
@@ -58,7 +58,7 @@ ARGV.each do |fn|
   next if Redwood::SourceManager.source_for fn
 
   ## TODO: merge this code with the same snippet in import
-  source = Redwood::MBox::Loader.new(fn, nil, !$opts[:unusual], $opts[:archive])
+  source = Redwood::MBox.new(fn, nil, !$opts[:unusual], $opts[:archive])
 
   source_ids = Hash.new 0
   count = 0
diff --git a/bin/sup-sync b/bin/sup-sync
@@ -53,16 +53,6 @@ where <source>* is zero or more source URIs. If no sources are given,
 sync from all usual sources. Supported source URI schemes can be seen
 by running "sup-add --help".
 
-Options controlling WHICH messages sup-sync operates on:
-EOS
-  opt :new, "Operate on new messages only. Don't scan over the entire source. (Default.)", :short => :none
-  opt :changed, "Scan over the entire source for messages that have been deleted, altered, or moved from another source."
-  opt :restored, "Operate only on those messages included in a dump file as specified by --restore which have changed state."
-  opt :all, "Operate on all messages in the source, regardless of newness or changedness."
-  opt :start_at, "For --changed, --restored and --all, start at a particular offset.", :type => :int
-
-text <<EOS
-
 Options controlling HOW message state is altered:
 EOS
   opt :asis, "If the message is already in the index, preserve its state. Otherwise, use default source state. (Default.)", :short => :none
@@ -82,16 +72,9 @@ EOS
   opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n"
   opt :version, "Show version information", :short => :none
 
-  conflicts :changed, :all, :new, :restored
   conflicts :asis, :restore, :discard
 end
-Trollop::die :restored, "requires --restore" if opts[:restored] unless opts[:restore]
-if opts[:start_at]
-  Trollop::die :start_at, "must be non-negative" if opts[:start_at] < 0
-  Trollop::die :start_at, "requires either --changed, --restored or --all" unless opts[:changed] || opts[:restored] || opts[:all]
-end
 
-target = [:new, :changed, :all, :restored].find { |x| opts[x] } || :new
 op = [:asis, :restore, :discard].find { |x| opts[x] } || :asis
 
 Redwood::start
@@ -132,136 +115,80 @@ begin
     end
   end
 
-  ## for all target specifications except for only-new messages, reset the
-  ## source to the beginning (or to the user-specified starting point.)
-  unless target == :new
-    if opts[:start_at]
-      Trollop::die :start_at, "can only be used on one source" unless sources.size == 1
-      sources.first.seek_to! opts[:start_at]
-      sources.first.correct_offset! if sources.first.respond_to?(:correct_offset!)
-    else
-      sources.each { |s| s.reset! }
-    end
-  end
-
   sources.each do |source|
     puts "Scanning #{source}..."
     num_added = num_updated = num_scanned = num_restored = 0
     last_info_time = start_time = Time.now
 
-    Redwood::PollManager.each_message_from source do |m|
-      num_scanned += 1
-      seen[m.id] = true
-      old_m = index.build_message m.id
-
-      case target
-      when :changed
-        ## skip this message if we're operating only on changed messages, the
-        ## message is in the index, and it's unchanged from what the source is
-        ## reporting.
-        next if old_m && old_m.locations.member?([m.source, m.source_info])
-      when :restored
-        ## skip if we're operating on restored messages, and this one
-        ## ain't (or we wouldn't be making a change)
-        next unless old_m && restored_state[m.id] && restored_state[m.id] != old_m.labels
-      when :new
-        ## nothing to do; we'll consider all messages starting at the start offset, which
-        ## hasn't been changed.
-      when :all
-        ## nothing to do; we'll consider all messages starting at the start offset, which
-        ## was reset to the beginning above.
-      end
-
-      ## tweak source labels according to commandline arguments if necessary
-      m.labels.delete :inbox if opts[:archive]
-      m.labels.delete :unread if opts[:read]
-      m.labels += opts[:extra_labels].to_set_of_symbols(",")
-
-      ## decide what to do based on message labels and the operation we're performing
-      dothis, new_labels = case
-      when (op == :restore) && restored_state[m.id]
-        if old_m && (old_m.labels != restored_state[m.id])
-          num_restored += 1
-          [:update_message_state, restored_state[m.id]]
-        elsif old_m.nil?
-          num_restored += 1
-          m.labels = restored_state[m.id]
-          :add_message
-        else
-          # labels are the same; don't do anything
-        end
-      when op == :discard
-        if old_m && (old_m.labels != m.labels)
-          [:update_message_state, m.labels]
-        else
-          # labels are the same; don't do anything
-        end
-      else
-        ## duplicate behavior of poll mode: if index_state is non-nil, this is a newer
-        ## version of an older message, so merge in any new labels except :unread and
-        ## :inbox.
-        ##
-        ## TODO: refactor such that this isn't duplicated
-        if old_m
-          m.labels = old_m.labels + (m.labels - [:unread, :inbox])
-          :update_message
+    Redwood::PollManager.poll_from source do |action,m,old_m|
+      if action == :delete
+        puts "Deleting #{m.id}"
+      elsif action == :add
+        num_scanned += 1
+        seen[m.id] = true
+
+        ## tweak source labels according to commandline arguments if necessary
+        m.labels.delete :inbox if opts[:archive]
+        m.labels.delete :unread if opts[:read]
+        m.labels += opts[:extra_labels].to_set_of_symbols(",")
+
+        ## decide what to do based on message labels and the operation we're performing
+        dothis = case
+        when (op == :restore) && restored_state[m.id]
+          if old_m && (old_m.labels != restored_state[m.id])
+            num_restored += 1
+            m.labels = restored_state[m.id]
+            :update_message_state
+          elsif old_m.nil?
+            num_restored += 1
+            m.labels = restored_state[m.id]
+            :add_message
+          else
+            # labels are the same; don't do anything
+          end
+        when op == :discard
+          if old_m && (old_m.labels != m.labels)
+            :update_message_state
+          else
+            # labels are the same; don't do anything
+          end
         else
-          :add_message
+          if old_m
+            :update_message
+          else
+            :add_message
+          end
         end
-      end
 
-      m.locations = old_m.locations + m.locations if old_m
-
-      ## now, actually do the operation
-      case dothis
-      when :add_message
-        puts "Adding new message #{source}##{m.source_info} with labels #{m.labels}" if opts[:verbose]
-        index.add_message m unless opts[:dry_run]
-        num_added += 1
-      when :update_message
-        puts "Updating message #{source}##{m.source_info}; labels #{old_m.labels} => #{m.labels}; offset #{old_m.source_info} => #{m.source_info}" if opts[:verbose]
-        index.update_message m unless opts[:dry_run]
-        num_updated += 1
-      when :update_message_state
-        puts "Changing flags for #{source}##{m.source_info} from #{m.labels} to #{new_labels}" if opts[:verbose]
-        m.labels = new_labels
-        index.update_message_state m unless opts[:dry_run]
-        num_updated += 1
-      end
+        ## now, actually do the operation
+        case dothis
+        when :add_message
+          puts "Adding new message #{source}##{m.source_info} with labels #{m.labels}" if opts[:verbose]
+          num_added += 1
+        when :update_message
+          puts "Updating message #{source}##{m.source_info}; labels #{old_m.labels} => #{m.labels}; offset #{old_m.source_info} => #{m.source_info}" if opts[:verbose]
+          num_updated += 1
+        when :update_message_state
+          puts "Changing flags for #{source}##{m.source_info} from #{old_m.labels} to #{m.labels}" if opts[:verbose]
+          num_updated += 1
+        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)
-        printf "## read %dm (~%.0f%%) @ %.1fm/s. %s elapsed, ~%s remaining, offset #{source.cur_offset}\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s
+        if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL
+          last_info_time = Time.now
+          elapsed = last_info_time - start_time
+          pctdone = 0.0 * 100.0
+          remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone)
+          printf "## read %dm (~%.0f%%) @ %.1fm/s. %s elapsed, ~%s remaining\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s
+        end
+      else fail
       end
+      next if opts[:dry_run]
     end
 
     puts "Scanned #{num_scanned}, added #{num_added}, updated #{num_updated} messages from #{source}."
     puts "Restored state on #{num_restored} (#{100.0 * num_restored / num_scanned}%) messages." if num_restored > 0
   end
 
-  ## delete any messages in the index that claim they're from one of
-  ## these sources, but that we didn't see.
-  if (target == :all || target == :changed)
-    puts "Deleting missing messages from the index..."
-    num_del, num_scanned = 0, 0
-    sources.each do |source|
-      raise "no source id for #{source}" unless source.id
-      index.each_message :source_id => source.id, :load_spam => true, :load_deleted => true, :load_killed => true do |m|
-        num_scanned += 1
-        unless seen[m.id]
-          next unless m.source_info >= opts[:start_at] if opts[:start_at]
-          puts "Deleting #{m.id}" if opts[:verbose]
-          index.delete m.id unless opts[:dry_run]
-          num_del += 1
-        end
-      end
-    end
-    puts "Deleted #{num_del} / #{num_scanned} messages"
-  end
-
   index.save
 
   if opts[:optimize]
diff --git a/bin/sup-sync-back b/bin/sup-sync-back
@@ -6,6 +6,8 @@ require 'tempfile'
 require 'trollop'
 require "sup"; Redwood::check_library_version_against "git"
 
+fail "not working yet"
+
 ## save a message 'm' to an open file pointer 'fp'
 def save m, fp
   m.source.each_raw_message_line(m.source_info) { |l| fp.print l }
@@ -80,12 +82,12 @@ begin
 
   sources = ARGV.map do |uri|
     s = Redwood::SourceManager.source_for(uri) or die "unknown source: #{uri}. Did you add it with sup-add first?"
-    s.is_a?(Redwood::MBox::Loader) or die "#{uri} is not an mbox source."
+    s.is_a?(Redwood::MBox) or die "#{uri} is not an mbox source."
     s
   end
 
   if sources.empty?
-    sources = Redwood::SourceManager.usual_sources.select { |s| s.is_a? Redwood::MBox::Loader }
+    sources = Redwood::SourceManager.usual_sources.select { |s| s.is_a? Redwood::MBox }
   end
 
   unless sources.all? { |s| s.file_path.nil? } || File.executable?(dotlockfile) || opts[:dont_use_dotlockfile]
diff --git a/lib/sup/draft.rb b/lib/sup/draft.rb
@@ -11,20 +11,13 @@ class DraftManager
 
   def self.source_name; "sup://drafts"; end
   def self.source_id; 9999; end
-  def new_source; @source = Recoverable.new DraftLoader.new; end
+  def new_source; @source = DraftLoader.new; end
 
   def write_draft
     offset = @source.gen_offset
     fn = @source.fn_for_offset offset
     File.open(fn, "w") { |f| yield f }
-
-    my_message = nil
-    PollManager.each_message_from(@source) do |m|
-      PollManager.add_new_message m
-      my_message = m
-    end
-
-    my_message
+    PollManager.poll_from @source
   end
 
   def discard m
@@ -37,31 +30,35 @@ end
 
 class DraftLoader < Source
   attr_accessor :dir
-  yaml_properties :cur_offset
+  yaml_properties
 
-  def initialize cur_offset=0
+  def initialize
     dir = Redwood::DRAFT_DIR
     Dir.mkdir dir unless File.exists? dir
-    super DraftManager.source_name, cur_offset, true, false
+    super DraftManager.source_name, true, false
     @dir = dir
+    @cur_offset = 0
   end
 
   def id; DraftManager.source_id; end
   def to_s; DraftManager.source_name; end
   def uri; DraftManager.source_name; end
 
-  def each
+  def poll
     ids = get_ids
     ids.each do |id|
-      if id >= cur_offset
-        self.cur_offset = id + 1
-        yield [id, [:draft, :inbox]]
+      if id >= @cur_offset
+        @cur_offset = id + 1
+        yield :add,
+          :info => id,
+          :labels => [:draft, :inbox],
+          :progress => 0.0
       end
     end
   end
 
   def gen_offset
-    i = cur_offset
+    i = 0
     while File.exists? fn_for_offset(i)
       i += 1
     end
diff --git a/lib/sup/index.rb b/lib/sup/index.rb
@@ -22,7 +22,7 @@ class Index
   include InteractiveLock
 
   STEM_LANGUAGE = "english"
-  INDEX_VERSION = '3'
+  INDEX_VERSION = '4'
 
   ## dates are converted to integers for xapian, and are used for document ids,
   ## so we must ensure they're reasonably valid. this typically only affect
@@ -105,15 +105,16 @@ EOS
       @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_OPEN)
       db_version = @xapian.get_metadata 'version'
       db_version = '0' if db_version.empty?
-      if db_version == '1' || db_version == '2'
+      if false
         info "Upgrading index format #{db_version} to #{INDEX_VERSION}"
         @xapian.set_metadata 'version', INDEX_VERSION
       elsif db_version != INDEX_VERSION
-        fail "This Sup version expects a v#{INDEX_VERSION} index, but you have an existing v#{db_version} index. Please downgrade to your previous version and dump your labels before upgrading to this version (then run sup-sync --restore)."
+        fail "This Sup version expects a v#{INDEX_VERSION} index, but you have an existing v#{db_version} index. Please run sup-dump to save your labels, move #{path} out of the way, and run sup-sync --restore."
       end
     else
       @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_CREATE)
       @xapian.set_metadata 'version', INDEX_VERSION
+      @xapian.set_metadata 'rescue-version', '0'
     end
     @enquire = Xapian::Enquire.new @xapian
     @enquire.weighting_scheme = Xapian::BoolWeight.new
@@ -197,7 +198,7 @@ EOS
     locations = entry[:locations].map do |source_id,source_info|
       source = SourceManager[source_id]
       raise "invalid source #{source_id}" unless source
-      [source, source_info]
+      Location.new source, source_info
     end
 
     m = Message.new :locations => locations,
@@ -265,6 +266,26 @@ EOS
     synchronize { get_entry(id)[:source_id] }
   end
 
+  ## Yields each tearm in the index that starts with prefix
+  def each_prefixed_term prefix
+    term = @xapian._dangerous_allterms_begin prefix
+    lastTerm = @xapian._dangerous_allterms_end prefix
+    until term.equals lastTerm
+      yield term.term
+      term.next
+    end
+    nil
+  end
+
+  ## Yields (in lexicographical order) the source infos of all locations from
+  ## the given source with the given source_info prefix
+  def each_source_info source_id, prefix='', &b
+    prefix = mkterm :location, source_id, prefix
+    each_prefixed_term prefix do |x|
+      yield x[prefix.length..-1]
+    end
+  end
+
   class ParseError < StandardError; end
 
   ## parse a query string from the user. returns a query object
@@ -583,7 +604,7 @@ EOS
 
     entry = {
       :message_id => m.id,
-      :locations => m.locations.map { |source,source_info| [source.id, source_info] },
+      :locations => m.locations.map { |x| [x.source.id, x.info] },
       :date => truncate_date(m.date),
       :snippet => snippet,
       :labels => m.labels.to_a,
@@ -746,13 +767,7 @@ end
 
 class Xapian::Document
   def entry
-    entry = Marshal.load data
-    if entry[:source_id]
-      entry[:locations] = [[entry[:source_id], entry[:source_info]]]
-      entry.delete :source_id
-      entry.delete :source_info
-    end
-    entry
+    Marshal.load data
   end
 
   def entry=(x)
diff --git a/lib/sup/logger.rb b/lib/sup/logger.rb
@@ -54,7 +54,7 @@ private
       when "error"; "ERROR: "
       else ""
     end
-    "[#{time.to_s}] #{prefix}#{msg}\n"
+    "[#{time.to_s}] #{prefix}#{msg.rstrip}\n"
   end
 
   ## actually distribute the message
diff --git a/lib/sup/maildir.rb b/lib/sup/maildir.rb
@@ -3,20 +3,14 @@ require 'uri'
 
 module Redwood
 
-## Maildir doesn't provide an ordered unique id, which is what Sup
-## requires to be really useful. So we must maintain, in memory, a
-## mapping between Sup "ids" (timestamps, essentially) and the
-## pathnames on disk.
-
 class Maildir < Source
   include SerializeLabelsNicely
-  SCAN_INTERVAL = 30 # seconds
   MYHOSTNAME = Socket.gethostname
 
   ## remind me never to use inheritance again.
-  yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels, :mtimes
-  def initialize uri, last_date=nil, usual=true, archived=false, id=nil, labels=[], mtimes={}
-    super uri, last_date, usual, archived, id
+  yaml_properties :uri, :usual, :archived, :id, :labels
+  def initialize uri, usual=true, archived=false, id=nil, labels=[]
+    super uri, usual, archived, id
     uri = URI(Source.expand_filesystem_uri(uri))
 
     raise ArgumentError, "not a maildir URI" unless uri.scheme == "maildir"
@@ -25,28 +19,14 @@ class Maildir < Source
 
     @dir = uri.path
     @labels = Set.new(labels || [])
-    @ids = []
-    @ids_to_fns = {}
-    @last_scan = nil
     @mutex = Mutex.new
-    #the mtime from the subdirs in the maildir with the unix epoch as default.
-    #these are used to determine whether scanning the directory for new mail
-    #is a worthwhile effort
-    @mtimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }.merge(mtimes || {})
-    @dir_ids = { 'cur' => [], 'new' => [] }
+    @mtimes = { 'cur' => Time.at(0), 'new' => Time.at(0) }
   end
 
   def file_path; @dir end
   def self.suggest_labels_for path; [] end
   def is_source_for? uri; super || (URI(Source.expand_filesystem_uri(uri)) == URI(self.uri)); end
 
-  def check
-    scan_mailbox
-    return unless start_offset
-
-    start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
-  end
-
   def store_message date, from_email, &block
     stored = false
     new_fn = new_maildir_basefn + ':2,S'
@@ -76,7 +56,6 @@ class Maildir < Source
   end
 
   def each_raw_message_line id
-    scan_mailbox
     with_file_for(id) do |f|
       until f.eof?
         yield f.gets
@@ -85,17 +64,14 @@ class Maildir < Source
   end
 
   def load_header id
-    scan_mailbox
     with_file_for(id) { |f| parse_raw_email_header f }
   end
 
   def load_message id
-    scan_mailbox
     with_file_for(id) { |f| RMail::Parser.read f }
   end
 
   def raw_header id
-    scan_mailbox
     ret = ""
     with_file_for(id) do |f|
       until f.eof? || (l = f.gets) =~ /^$/
@@ -106,106 +82,75 @@ class Maildir < Source
   end
 
   def raw_message id
-    scan_mailbox
     with_file_for(id) { |f| f.read }
   end
 
-  def scan_mailbox opts={}
-    return unless @ids.empty? || opts[:rescan]
-    return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
-
-    initial_poll = @ids.empty?
-
-    debug "scanning maildir #@dir..."
-    begin
-      @mtimes.each_key do |d|
-	subdir = File.join(@dir, d)
-	raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
-
-	mtime = File.mtime subdir
-
-	#only scan the dir if the mtime is more recent (or we haven't polled
-	#since startup)
-	if @mtimes[d] < mtime || initial_poll
-	  @mtimes[d] = mtime
-	  @dir_ids[d] = []
-	  Dir[File.join(subdir, '*')].map do |fn|
-	    id = make_id fn
-	    @dir_ids[d] << id
-	    @ids_to_fns[id] = fn
-	  end
-	else
-	  debug "no poll on #{d}.  mtime on indicates no new messages."
-	end
+  ## XXX use less memory
+  def poll
+    @mtimes.each do |d,prev_mtime|
+      subdir = File.join @dir, d
+      debug "polling maildir #{subdir}"
+      raise FatalSourceError, "#{subdir} not a directory" unless File.directory? subdir
+      mtime = File.mtime subdir
+      next if prev_mtime >= mtime
+      @mtimes[d] = mtime
+
+      old_ids = benchmark(:maildir_read_index) { Enumerator.new(Index.instance, :each_source_info, self.id, "#{d}/").to_a }
+      new_ids = benchmark(:maildir_read_dir) { Dir.glob("#{subdir}/*").map { |x| File.basename x }.sort }
+      added = new_ids - old_ids
+      deleted = old_ids - new_ids
+      debug "#{old_ids.size} in index, #{new_ids.size} in filesystem"
+      debug "#{added.size} added, #{deleted.size} deleted"
+
+      added.each do |id|
+        yield :add,
+          :info => File.join(d,id),
+          :labels => @labels + maildir_labels(id) + [:inbox],
+          :progress => 0.0
       end
-      @ids = @dir_ids.values.flatten.uniq.sort!
-    rescue SystemCallError, IOError => e
-      raise FatalSourceError, "Problem scanning Maildir directories: #{e.message}."
-    end
-    
-    debug "done scanning maildir"
-    @last_scan = Time.now
-  end
-  synchronized :scan_mailbox
 
-  def each
-    scan_mailbox
-    return unless start_offset
-
-    start = @ids.index(cur_offset || start_offset) or raise OutOfSyncSourceError, "Unknown message id #{cur_offset || start_offset}." # couldn't find the most recent email
-
-    start.upto(@ids.length - 1) do |i|         
-      id = @ids[i]
-      self.cur_offset = id
-      yield id, @labels + (seen?(id) ? [] : [:unread]) + (trashed?(id) ? [:deleted] : []) + (flagged?(id) ? [:starred] : [])
+      deleted.each do |id|
+        yield :delete,
+          :info => File.join(d,id),
+          :progress => 0.0
+      end
     end
+    nil
   end
 
-  def start_offset
-    scan_mailbox
-    @ids.first
-  end
-
-  def end_offset
-    scan_mailbox :rescan => true
-    @ids.last + 1
+  def maildir_labels id
+    (seen?(id) ? [] : [:unread]) +
+      (trashed?(id) ?  [:deleted] : []) +
+      (flagged?(id) ? [:starred] : [])
   end
 
-  def pct_done; 100.0 * (@ids.index(cur_offset) || 0).to_f / (@ids.length - 1).to_f; end
+  def draft? id; maildir_data(id)[2].include? "D"; end
+  def flagged? id; maildir_data(id)[2].include? "F"; end
+  def passed? id; maildir_data(id)[2].include? "P"; end
+  def replied? id; maildir_data(id)[2].include? "R"; end
+  def seen? id; maildir_data(id)[2].include? "S"; end
+  def trashed? id; maildir_data(id)[2].include? "T"; end
 
-  def draft? msg; maildir_data(msg)[2].include? "D"; end
-  def flagged? msg; maildir_data(msg)[2].include? "F"; end
-  def passed? msg; maildir_data(msg)[2].include? "P"; end
-  def replied? msg; maildir_data(msg)[2].include? "R"; end
-  def seen? msg; maildir_data(msg)[2].include? "S"; end
-  def trashed? msg; maildir_data(msg)[2].include? "T"; end
+  def mark_draft id; maildir_mark_file id, "D" unless draft? id; end
+  def mark_flagged id; maildir_mark_file id, "F" unless flagged? id; end
+  def mark_passed id; maildir_mark_file id, "P" unless passed? id; end
+  def mark_replied id; maildir_mark_file id, "R" unless replied? id; end
+  def mark_seen id; maildir_mark_file id, "S" unless seen? id; end
+  def mark_trashed id; maildir_mark_file id, "T" unless trashed? id; end
 
-  def mark_draft msg; maildir_mark_file msg, "D" unless draft? msg; end
-  def mark_flagged msg; maildir_mark_file msg, "F" unless flagged? msg; end
-  def mark_passed msg; maildir_mark_file msg, "P" unless passed? msg; end
-  def mark_replied msg; maildir_mark_file msg, "R" unless replied? msg; end
-  def mark_seen msg; maildir_mark_file msg, "S" unless seen? msg; end
-  def mark_trashed msg; maildir_mark_file msg, "T" unless trashed? msg; end
-
-  def filename_for_id id; @ids_to_fns[id] end
+  def valid? id
+    File.exists? File.join(@dir, id)
+  end
 
 private
 
-  def make_id fn
-    #doing this means 1 syscall instead of 2 (File.mtime, File.size).
-    #makes a noticeable difference on nfs.
-    stat = File.stat(fn)
-    # use 7 digits for the size. why 7? seems nice.
-    sprintf("%d%07d", stat.mtime, stat.size % 10000000).to_i
-  end
-
   def new_maildir_basefn
     Kernel::srand()
     "#{Time.now.to_i.to_s}.#{$$}#{Kernel.rand(1000000)}.#{MYHOSTNAME}"
   end
 
   def with_file_for id
-    fn = @ids_to_fns[id] or raise OutOfSyncSourceError, "No such id: #{id.inspect}."
+    fn = File.join(@dir, id)
     begin
       File.open(fn, 'rb') { |f| yield f }
     rescue SystemCallError, IOError => e
@@ -213,10 +158,9 @@ private
     end
   end
 
-  def maildir_data msg
-    fn = File.basename @ids_to_fns[msg]
-    fn =~ %r{^([^:]+):([12]),([DFPRST]*)$}
-    [($1 || fn), ($2 || "2"), ($3 || "")]
+  def maildir_data id
+    id =~ %r{^([^:]+):([12]),([DFPRST]*)$}
+    [($1 || id), ($2 || "2"), ($3 || "")]
   end
 
   ## not thread-safe on msg
diff --git a/lib/sup/mbox.rb b/lib/sup/mbox.rb
@@ -8,12 +8,12 @@ class MBox < Source
   BREAK_RE = /^From \S+ (.+)$/
 
   include SerializeLabelsNicely
-  yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels
+  yaml_properties :uri, :usual, :archived, :id, :labels
 
   attr_reader :labels
 
   ## uri_or_fp is horrific. need to refactor.
-  def initialize uri_or_fp, start_offset=nil, usual=true, archived=false, id=nil, labels=nil
+  def initialize uri_or_fp, usual=true, archived=false, id=nil, labels=nil
     @mutex = Mutex.new
     @labels = Set.new((labels || []) - LabelManager::RESERVED_LABELS)
 
@@ -30,8 +30,7 @@ class MBox < Source
       @path = uri_or_fp.path
     end
 
-    start_offset ||= 0
-    super uri_or_fp, start_offset, usual, archived, id
+    super uri_or_fp, usual, archived, id
   end
 
   def file_path; @path end
@@ -47,23 +46,10 @@ class MBox < Source
     end
   end
 
-  def check
-    if (cur_offset ||= start_offset) > end_offset
-      raise OutOfSyncSourceError, "mbox file is smaller than last recorded message offset. Messages have probably been deleted by another client."
-    end
-  end
-
-  def start_offset; 0; end
-  def end_offset; File.size @f; end
-
   def load_header offset
     header = nil
     @mutex.synchronize do
       @f.seek offset
-      l = @f.gets
-      unless MBox::is_break_line? l
-        raise OutOfSyncSourceError, "mismatch in mbox file offset #{offset.inspect}: #{l.inspect}." 
-      end
       header = parse_raw_email_header @f
     end
     header
@@ -76,8 +62,9 @@ class MBox < Source
         ## 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)
+        until @f.eof? || MBox::is_break_line?(l = @f.gets)
+          string << l
+        end
         RMail::Parser.read string
       rescue RMail::Parser::Error => e
         raise FatalSourceError, "error parsing mbox file: #{e.message}"
@@ -85,18 +72,6 @@ class MBox < Source
     end
   end
 
-  ## scan forward until we're at the valid start of a message
-  def correct_offset!
-    @mutex.synchronize do
-      @f.seek cur_offset
-      string = ""
-      until @f.eof? || MBox::is_break_line?(l = @f.gets)
-        string << l
-      end
-      self.cur_offset += string.length
-    end
-  end
-
   def raw_header offset
     ret = ""
     @mutex.synchronize do
@@ -132,48 +107,50 @@ class MBox < Source
   def each_raw_message_line offset
     @mutex.synchronize do
       @f.seek offset
-      yield @f.gets
       until @f.eof? || MBox::is_break_line?(l = @f.gets)
         yield l
       end
     end
   end
 
-  def next
-    returned_offset = nil
-    next_offset = cur_offset
+  def pct_done
+    0.0
+  end
+
+  def default_labels
+    [:inbox, :unread]
+  end
 
-    begin
-      @mutex.synchronize do
-        @f.seek cur_offset
-
-        ## cur_offset could be at one of two places here:
-
-        ## 1. before a \n and a mbox separator, if it was previously at
-        ##    EOF and a new message was added; or,
-        ## 2. at the beginning of an mbox separator (in all other
-        ##    cases).
-
-        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
-        else # case 2
-          returned_offset = cur_offset
-          ## we've already skipped past the BREAK_RE, so just go
-        end
+  def poll
+    offset = first_new_message
+    end_offset = File.size @f
+    while offset and offset < end_offset
+      yield :add,
+        :info => offset,
+        :labels => (labels + default_labels),
+        :progress => 0.0
+      offset = next_offset offset
+    end
+  end
 
-        while(line = @f.gets)
-          break if MBox::is_break_line? line
-          next_offset = @f.tell
-        end
-      end
-    rescue SystemCallError, IOError => e
-      raise FatalSourceError, "Error reading #{@f.path}: #{e.message}"
+  def next_offset offset
+    @mutex.synchronize do
+      @f.seek offset
+      nil while line = @f.gets and not MBox::is_break_line? line
+      offset = @f.tell
+      offset != File.size(@f) ? offset : nil
     end
+  end
+
+  ## TODO optimize this by iterating over allterms list backwards or
+  ## storing source_info negated
+  def last_indexed_message
+    benchmark(:mbox_read_index) { Enumerator.new(Index.instance, :each_source_info, self.id).map(&:to_i).max }
+  end
 
-    self.cur_offset = next_offset
-    [returned_offset, (labels + [:unread])]
+  ## offset of first new message or nil
+  def first_new_message
+    next_offset(last_indexed_message || 0)
   end
 
   def self.is_break_line? l
@@ -190,7 +167,7 @@ class MBox < Source
   end
 
   class Loader < self
-    yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels
+    yaml_properties :uri, :usual, :archived, :id, :labels
   end
 end
 end
diff --git a/lib/sup/message.rb b/lib/sup/message.rb
@@ -9,9 +9,6 @@ module Redwood
 ## i would like, for example, to be able to add in a ruby-talk
 ## 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 catches all source exceptions. if the underlying source
-## throws an error, it is caught and handled.
 
 class Message
   SNIPPET_LEN = 80
@@ -179,7 +176,7 @@ class Message
 
   attr_reader :snippet
   def is_list_message?; !@list_address.nil?; end
-  def is_draft?; source.is_a? DraftLoader; end
+  def is_draft?; @labels.member? :draft; end
   def draft_filename
     raise "not a draft" unless is_draft?
     source.fn_for_offset source_info
@@ -233,87 +230,59 @@ class Message
     @chunks
   end
 
+  def location
+    @locations.find { |x| x.valid? } || raise(OutOfSyncSourceError.new)
+  end
+
   def source
-    fail if @locations.empty?
-    @locations.last[0]
+    location.source
   end
 
   def source_info
-    fail if @locations.empty?
-    @locations.last[1]
+    location.info
   end
 
   ## this is called when the message body needs to actually be loaded.
   def load_from_source!
     @chunks ||=
-      if source.respond_to?(:has_errors?) && source.has_errors?
-        [Chunk::Text.new(error_message(source.error.message).split("\n"))]
-      else
-        begin
-          ## we need to re-read the header because it contains information
-          ## that we don't store in the index. actually i think it's just
-          ## the mailing list address (if any), so this is kinda overkill.
-          ## i could just store that in the index, but i think there might
-          ## be other things like that in the future, and i'd rather not
-          ## bloat the index.
-          ## actually, it's also the differentiation between to/cc/bcc,
-          ## so i will keep this.
-          rmsg = source.load_message(source_info)
-          parse_header rmsg.header
-          message_to_chunks rmsg
-        rescue SourceError, SocketError, RMail::EncodingUnsupportedError => e
-          warn "problem getting messages from #{source}: #{e.message}"
-          ## we need force_to_top here otherwise this window will cover
-          ## up the error message one
-          source.error ||= e
-          Redwood::report_broken_sources :force_to_top => true
-          [Chunk::Text.new(error_message(e.message).split("\n"))]
-        end
+      begin
+        ## we need to re-read the header because it contains information
+        ## that we don't store in the index. actually i think it's just
+        ## the mailing list address (if any), so this is kinda overkill.
+        ## i could just store that in the index, but i think there might
+        ## be other things like that in the future, and i'd rather not
+        ## bloat the index.
+        ## actually, it's also the differentiation between to/cc/bcc,
+        ## so i will keep this.
+        rmsg = location.parsed_message
+        parse_header rmsg.header
+        message_to_chunks rmsg
+      rescue SourceError, SocketError, RMail::EncodingUnsupportedError => e
+        warn "problem reading message #{id}"
+        [Chunk::Text.new(error_message.split("\n"))]
       end
   end
 
-  def error_message msg
+  def error_message
     <<EOS
 #@snippet...
 
 ***********************************************************************
- An error occurred while loading this message. It is possible that
- the source has changed, or (in the case of remote sources) is down.
- You can check the log for errors, though hopefully an error window
- should have popped up at some point.
-
- The message location was:
- #{source}##{source_info}
+ An error occurred while loading this message.
 ***********************************************************************
-
-The error message was:
-  #{msg}
 EOS
   end
 
-  ## wrap any source methods that might throw sourceerrors
-  def with_source_errors_handled
-    begin
-      yield
-    rescue SourceError => e
-      warn "problem getting messages from #{source}: #{e.message}"
-      source.error ||= e
-      Redwood::report_broken_sources :force_to_top => true
-      error_message e.message
-    end
-  end
-
   def raw_header
-    with_source_errors_handled { source.raw_header source_info }
+    location.raw_header
   end
 
   def raw_message
-    with_source_errors_handled { source.raw_message source_info }
+    location.raw_message
   end
 
-  ## much faster than raw_message
   def each_raw_message_line &b
-    with_source_errors_handled { source.each_raw_message_line(source_info, &b) }
+    location.each_raw_message_line &b
   end
 
   ## returns all the content from a message that will be indexed
@@ -355,7 +324,7 @@ EOS
   end
 
   def self.build_from_source source, source_info
-    m = Message.new :locations => [[source, source_info]]
+    m = Message.new :locations => [Location.new(source, source_info)]
     m.load_from_source!
     m
   end
@@ -696,4 +665,43 @@ private
   end
 end
 
+class Location
+  attr_reader :source
+  attr_reader :info
+
+  def initialize source, info
+    @source = source
+    @info = info
+  end
+
+  def raw_header
+    source.raw_header info
+  end
+
+  def raw_message
+    source.raw_message info
+  end
+
+  ## much faster than raw_message
+  def each_raw_message_line &b
+    source.each_raw_message_line info, &b
+  end
+
+  def parsed_message
+    source.load_message info
+  end
+
+  def valid?
+    source.valid? info
+  end
+
+  def == o
+    o.source.id == source.id and o.info == info
+  end
+
+  def hash
+    [source.id, info].hash
+  end
+end
+
 end
diff --git a/lib/sup/modes/console-mode.rb b/lib/sup/modes/console-mode.rb
@@ -8,7 +8,7 @@ class Console
   end
 
   def query(query)
-    Enumerator.new(Index, :each_message, Index.parse_query(query))
+    Enumerator.new(Index.instance, :each_message, Index.parse_query(query))
   end
 
   def add_labels(query, *labels)
@@ -26,6 +26,9 @@ class Console
 
   def special_methods; methods - Object.methods end
 
+  def puts x; @mode << "#{x.to_s.rstrip}\n" end
+  def p x; puts x.inspect end
+
   ## files that won't cause problems when reloaded
   ## TODO expand this list / convert to blacklist
   RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb)
diff --git a/lib/sup/poll.rb b/lib/sup/poll.rb
@@ -101,44 +101,37 @@ EOS
 
     @mutex.synchronize do
       @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?)
+          yield "Loading from #{source}... "
         rescue SourceError => e
           warn "problem getting messages from #{source}: #{e.message}"
-          Redwood::report_broken_sources :force_to_top => true
           next
         end
 
         num = 0
         numi = 0
-        each_message_from source do |m|
-          old_m = Index.build_message m.id
-          if old_m
-            if not old_m.locations.member? [source, m.source_info]
-              ## here we merge labels between new and old versions, but we don't let the new
-              ## message add :unread or :inbox labels. (they can exist in the old version,
-              ## just not be added.)
-              new_labels = old_m.labels + (m.labels - [:unread, :inbox])
-              yield "Message at #{m.source_info} is an updated of an old message. Updating labels from #{m.labels.to_a * ','} => #{new_labels.to_a * ','}"
-              m.labels = new_labels
-              m.locations = old_m.locations + m.locations
-              Index.update_message m
+        poll_from source do |action,m,old_m|
+          if action == :delete
+            yield "Deleting #{m.id}"
+          elsif action == :add
+            if old_m
+              if not old_m.locations.member? m.location
+                yield "Message at #{m.source_info} is an updated of an old message. Updating labels from #{old_m.labels.to_a * ','} => #{m.labels.to_a * ','}"
+              else
+                yield "Skipping already-imported message at #{m.source_info}"
+              end
             else
-              yield "Skipping already-imported message at #{m.source_info}"
-            end
-          else
-            yield "Found new message at #{m.source_info} with labels #{m.labels.to_a * ','}"
-            add_new_message m
-            loaded_labels.merge m.labels
-            num += 1
-            from_and_subj << [m.from && m.from.longname, m.subj]
-            if (m.labels & [:inbox, :spam, :deleted, :killed]) == Set.new([:inbox])
-              from_and_subj_inbox << [m.from && m.from.longname, m.subj]
-              numi += 1
+              yield "Found new message at #{m.source_info} with labels #{m.labels.to_a * ','}"
+              loaded_labels.merge m.labels
+              num += 1
+              from_and_subj << [m.from && m.from.longname, m.subj]
+              if (m.labels & [:inbox, :spam, :deleted, :killed]) == Set.new([:inbox])
+                from_and_subj_inbox << [m.from && m.from.longname, m.subj]
+                numi += 1
+              end
             end
+          else fail
           end
-          m
         end
         yield "Found #{num} messages, #{numi} to inbox." unless num == 0
         total_num += num
@@ -153,44 +146,41 @@ EOS
     [total_num, total_numi, from_and_subj, from_and_subj_inbox, loaded_labels]
   end
 
-  ## like Source#each, but yields successive Message objects, which have their
-  ## labels and offsets set correctly.
-  ##
-  ## this is the primary mechanism for iterating over messages from a source.
-  def each_message_from source, opts={}
+  ## like Source#poll, but yields successive Message objects, which have their
+  ## labels and locations set correctly. The Messages are saved to or removed
+  ## from the index after being yielded.
+  def poll_from source, opts={}
     begin
-      return if source.done? || source.has_errors?
-
-      source.each do |offset, source_labels|
-        if source.has_errors?
-          warn "error loading messages from #{source}: #{source.error.message}"
-          return
+      source.poll do |sym, args|
+        case sym
+        when :add
+          m = Message.build_from_source source, args[:info]
+          old_m = Index.build_message m.id
+          m.labels += args[:labels]
+          m.labels.delete :inbox  if source.archived?
+          m.labels.delete :unread if source.read?
+          m.labels.delete :unread if m.source_marked_read? # preserve read status if possible
+          m.labels.each { |l| LabelManager << l }
+          m.labels = old_m.labels + (m.labels - [:unread, :inbox]) if old_m
+          m.locations = old_m.locations + m.locations if old_m
+          HookManager.run "before-add-message", :message => m
+          yield :add, m, old_m if block_given?
+          Index.sync_message m, true
+          UpdateManager.relay self, :added, m
+        when :delete
+          Index.each_message :location => [source.id, args[:info]] do |m|
+            m.locations.delete Location.new(source, args[:info])
+            yield :delete, m, [source,args[:info]] if block_given?
+            Index.sync_message m, false
+            #UpdateManager.relay self, :deleted, m
+          end
         end
-
-        m = Message.build_from_source source, offset
-        m.labels += source_labels + (source.archived? ? [] : [:inbox])
-        m.labels.delete :unread if m.source_marked_read? # preserve read status if possible
-        m.labels.each { |l| LabelManager << l }
-
-        HookManager.run "before-add-message", :message => m
-        yield m
       end
     rescue SourceError => e
       warn "problem getting messages from #{source}: #{e.message}"
-      Redwood::report_broken_sources :force_to_top => true
     end
   end
 
-  ## TODO: see if we can do this within PollMode rather than by calling this
-  ## method.
-  ##
-  ## a wrapper around Index.add_message that calls the proper hooks,
-  ## does the gui callback stuff, etc.
-  def add_new_message m
-    Index.add_message m
-    UpdateManager.relay self, :added, m
-  end
-
   def handle_idle_update sender, idle_since; @should_clear_running_totals = false; end
   def handle_unidle_update sender, idle_since; @should_clear_running_totals = true; clear_running_totals; end
   def clear_running_totals; @running_totals = {:num => 0, :numi => 0, :loaded_labels => Set.new}; end
diff --git a/lib/sup/sent.rb b/lib/sup/sent.rb
@@ -19,29 +19,24 @@ class SentManager
   end
 
   def default_source
-    @source = Recoverable.new SentLoader.new
+    @source = SentLoader.new
     @source_uri = @source.uri
     @source
   end
 
   def write_sent_message date, from_email, &block
     @source.store_message date, from_email, &block
-
-    PollManager.each_message_from(@source) do |m|
-      m.remove_label :unread
-      m.add_label :sent
-      PollManager.add_new_message m
-    end
+    PollManager.poll_from @source
   end
 end
 
-class SentLoader < MBox::Loader
-  yaml_properties :cur_offset
+class SentLoader < MBox
+  yaml_properties
 
-  def initialize cur_offset=0
+  def initialize
     @filename = Redwood::SENT_FN
     File.open(@filename, "w") { } unless File.exists? @filename
-    super "mbox://" + @filename, cur_offset, true, true
+    super "mbox://" + @filename, true, true
   end
 
   def file_path; @filename end
@@ -51,6 +46,8 @@ class SentLoader < MBox::Loader
 
   def id; 9998; end
   def labels; [:inbox, :sent]; end
+  def default_labels; []; end
+  def read?; true; end
 end
 
 end
diff --git a/lib/sup/server.rb b/lib/sup/server.rb
@@ -97,13 +97,10 @@ private
     SentManager.source.store_message Time.now, "test@example.com" do |io|
       io.write raw
     end
-    m2 = nil
-    PollManager.each_message_from(SentManager.source) do |m|
-      PollManager.add_new_message m
-      m2 = m
+    PollManager.poll_from SentManager.source do |sym,m,old_m|
+      next unless sym == :add
+      m.labels = labels
     end
-    m2.labels = Set.new(labels.map(&:to_sym))
-    @index.update_message_state m2
     nil
   end
 
diff --git a/lib/sup/source.rb b/lib/sup/source.rb
@@ -59,51 +59,37 @@ class Source
   ## Examples for you to look at: mbox/loader.rb, imap.rb, and
   ## maildir.rb.
 
-  ## let's begin!
-  ##
-  ## dirty? means cur_offset has changed, so the source info needs to
-  ## be re-saved to sources.yaml.
-  bool_reader :dirty
   bool_accessor :usual, :archived
-  attr_reader :uri, :cur_offset
+  attr_reader :uri
   attr_accessor :id
 
-  def initialize uri, initial_offset=nil, usual=true, archived=false, id=nil
+  def initialize uri, usual=true, archived=false, id=nil
     raise ArgumentError, "id must be an integer: #{id.inspect}" unless id.is_a? Fixnum if id
 
     @uri = uri
-    @cur_offset = initial_offset
     @usual = usual
     @archived = archived
     @id = id
-    @dirty = false
   end
 
   ## overwrite me if you have a disk incarnation (currently used only for sup-sync-back)
   def file_path; nil end
 
   def to_s; @uri.to_s; end
-  def seek_to! o; self.cur_offset = o; end
-  def reset!; seek_to! start_offset; end
   def == o; o.uri == uri; end
-  def done?; start_offset.nil? || (self.cur_offset ||= start_offset) >= end_offset; end
   def is_source_for? uri; uri == @uri; end
 
-  ## check should throw a FatalSourceError or an OutOfSyncSourcError
-  ## if it can detect a problem. it is called when the sup starts up
-  ## to proactively notify the user of any source problems.
-  def check; end
+  def read?; false; end
 
-  ## yields successive offsets and labels, starting at #cur_offset.
-  ##
-  ## when implementing a source, you can overwrite either #each or #next. the
-  ## default #each just calls next over and over.
-  def each
-    self.cur_offset ||= start_offset
-    until done?
-      offset, labels = self.next
-      yield offset, labels
-    end
+  ## Yields values of the form [Symbol, Hash]
+  ## add: info, labels, progress
+  ## delete: info, progress
+  def poll
+    unimplemented
+  end
+
+  def valid? info
+    true
   end
 
   ## utility method to read a raw email header from an IO stream and turn it
@@ -155,11 +141,6 @@ protected
   def Source.expand_filesystem_uri uri
     uri.gsub "~", File.expand_path("~")
   end
-
-  def cur_offset= o
-    @cur_offset = o
-    @dirty = true
-  end
 end
 
 ## if you have a @labels instance variable, include this
@@ -211,7 +192,7 @@ class SourceManager
   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 }
+    source_array = Redwood::load_yaml_obj(fn) || []
     @source_mutex.synchronize do
       @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten]
       @sources_dirty = false
@@ -220,7 +201,7 @@ class SourceManager
 
   def save_sources fn=Redwood::SOURCE_FN
     @source_mutex.synchronize do
-      if @sources_dirty || @sources.any? { |id, s| s.dirty? }
+      if @sources_dirty
         bakfn = fn + ".bak"
         if File.exists? fn
           File.chmod 0600, fn
diff --git a/lib/sup/util.rb b/lib/sup/util.rb
@@ -4,6 +4,7 @@ require 'mime/types'
 require 'pathname'
 require 'set'
 require 'enumerator'
+require 'benchmark'
 
 ## time for some monkeypatching!
 class Symbol
@@ -184,6 +185,13 @@ class Object
       EOF
     end
   end
+
+  def benchmark s, &b
+    ret = nil
+    times = Benchmark.measure { ret = b.call }
+    debug "benchmark #{s}: #{times}"
+    ret
+  end
 end
 
 class String
@@ -599,40 +607,6 @@ module Singleton
   end
 end
 
-## wraps an object. if it throws an exception, keeps a copy.
-class Recoverable
-  def initialize o
-    @o = o
-    @error = nil
-    @mutex = Mutex.new
-  end
-
-  attr_accessor :error
-
-  def clear_error!; @error = nil; end
-  def has_errors?; !@error.nil?; end
-
-  def method_missing m, *a, &b; __pass m, *a, &b end
-
-  def id; __pass :id; end
-  def to_s; __pass :to_s; end
-  def to_yaml x; __pass :to_yaml, x; end
-  def is_a? c; @o.is_a? c; end
-
-  def respond_to?(m, include_private=false)
-    @o.respond_to?(m, include_private)
-  end
-
-  def __pass m, *a, &b
-    begin
-      @o.send(m, *a, &b)
-    rescue Exception => e
-      @error ||= e
-      raise
-    end
-  end
-end
-
 ## acts like a hash with an initialization block, but saves any
 ## newly-created value even upon lookup.
 ##
diff --git a/test/test_header_parsing.rb b/test/test_header_parsing.rb
@@ -106,7 +106,7 @@ EOS
   end
 
   def test_from_line_splitting
-    l = MBox::Loader.new StringIO.new(<<EOS)
+    l = MBox.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
@@ -132,7 +132,7 @@ EOS
   end
 
   def test_more_from_line_splitting
-    l = MBox::Loader.new StringIO.new(<<EOS)
+    l = MBox.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