]> git.cworth.org Git - sup/commitdiff
Merge branch 'reply-all-keybindings'
authorWilliam Morgan <wmorgan-sup@masanjin.net>
Tue, 8 Sep 2009 19:26:07 +0000 (15:26 -0400)
committerWilliam Morgan <wmorgan-sup@masanjin.net>
Tue, 8 Sep 2009 19:26:07 +0000 (15:26 -0400)
bin/sup
bin/sup-config
bin/sup-sync
lib/sup.rb
lib/sup/crypto.rb
lib/sup/hook.rb
lib/sup/mbox/loader.rb
lib/sup/message.rb
lib/sup/sent.rb
lib/sup/util.rb
lib/sup/xapian_index.rb

diff --git a/bin/sup b/bin/sup
index bbb6c1711a7d5f580530a95859b4853649bf69c9..605c55342b11b1cdfd4e8fc9bfd64a98ba154129 100755 (executable)
--- a/bin/sup
+++ b/bin/sup
@@ -168,6 +168,9 @@ begin
   lmode.on_kill { Logger.clear! }
   Logger.add_sink lmode
   Logger.force_message "Welcome to Sup! Log level is set to #{Logger.level}."
+  if Logger::LEVELS.index(Logger.level) > 0
+    Logger.force_message "For more verbose logging, restart with SUP_LOG_LEVEL=#{Logger::LEVELS[Logger::LEVELS.index(Logger.level)-1]}."
+  end
 
   debug "initializing inbox buffer"
   imode = InboxMode.new
index bd55fc1825fad84b6cf6cc8c0185d52effbc5adf..b37e0b2edce0417e8ac43ad1959be8c97460cf13 100755 (executable)
@@ -17,15 +17,14 @@ Usage:
 
 Options:
 EOS
-end #' stupid ruby-mode
+end
 
 def axe q, default=nil
-  ans = 
-    if default && !default.empty?
-      ask "#{q} (enter for \"#{default}\"): "
-    else
-      ask "#{q}: "
-    end
+  ans = if default && !default.empty?
+    ask "#{q} (enter for \"#{default}\"): "
+  else
+    ask "#{q}: "
+  end
   ans.empty? ? default : ans
 end
 
@@ -54,64 +53,62 @@ def add_source
   while true do
     say "Ok, now for the details."
 
-    default_labels, components = 
-      case type
-      when :mbox
-        $last_fn ||= ENV["MAIL"]
-        fn = axe "What's the full path to the mbox file?", $last_fn #"srm
-        return if fn.nil? || fn.empty?
-
-        $last_fn = fn
-        [Redwood::MBox::Loader.suggest_labels_for(fn),
-         { :scheme => "mbox", :path => fn }]
-      when :maildir
-        $last_fn ||= ENV["MAIL"]
-        fn = axe "What's the full path to the maildir directory?", $last_fn #"srm
-        return if fn.nil? || fn.empty?
-
-        $last_fn = fn
-        [Redwood::Maildir.suggest_labels_for(fn),
-         { :scheme => "maildir", :path => fn }]
-      when :mboxssh
-        $last_server ||= "localhost"
-        srv = axe "What machine is the mbox file located on?", $last_server 
-        return if srv.nil? || srv.empty?
-        $last_server = srv
-
-        fn = axe "What's the path to the mbox file?", $last_fn #" stupid ruby-mode
-        return if fn.nil? || fn.empty?
-        $last_fn = fn
-        fn = "/#{fn}" # lame
-        [Redwood::MBox::SSHLoader.suggest_labels_for(fn),
-         { :scheme => "mbox+ssh", :host => srv, :path => fn }]
-      when :imap, :imaps
-        $last_server ||= "localhost"
-        srv = axe "What is the IMAP server (host, or host:port notation)?", $last_server
-        return if srv.nil? || srv.empty?
-        $last_server = srv
-
-        $last_folder ||= "INBOX"
-        fn = axe "What's the folder path?", $last_folder #"srm 
-        return if fn.nil? || fn.empty?
-        $last_folder = fn
-
-        fn = "/#{fn}" # lame
-        if srv =~ /^(\S+):(\d+)$/
-          host, port = $1, $2.to_i
-        else
-          host, port = srv, nil
-        end
-        [Redwood::IMAP.suggest_labels_for(fn),
-         { :scheme => type.to_s, :host => host, :port => port, :path => fn }]
-      end
-    
-    uri = 
-      begin
-        URI::Generic.build components
-      rescue URI::Error => e
-        say "Whoopsie! I couldn't build a URI from that: #{e.message}"
-        if axe_yes("Try again?") then next else return end
+    default_labels, components = case type
+    when :mbox
+      $last_fn ||= ENV["MAIL"]
+      fn = axe "What's the full path to the mbox file?", $last_fn
+      return if fn.nil? || fn.empty?
+
+      $last_fn = fn
+      [Redwood::MBox::Loader.suggest_labels_for(fn),
+       { :scheme => "mbox", :path => fn }]
+    when :maildir
+      $last_fn ||= ENV["MAIL"]
+      fn = axe "What's the full path to the maildir directory?", $last_fn
+      return if fn.nil? || fn.empty?
+
+      $last_fn = fn
+      [Redwood::Maildir.suggest_labels_for(fn),
+       { :scheme => "maildir", :path => fn }]
+    when :mboxssh
+      $last_server ||= "localhost"
+      srv = axe "What machine is the mbox file located on?", $last_server
+      return if srv.nil? || srv.empty?
+      $last_server = srv
+
+      fn = axe "What's the path to the mbox file?", $last_fn
+      return if fn.nil? || fn.empty?
+      $last_fn = fn
+      fn = "/#{fn}" # lame
+      [Redwood::MBox::SSHLoader.suggest_labels_for(fn),
+       { :scheme => "mbox+ssh", :host => srv, :path => fn }]
+    when :imap, :imaps
+      $last_server ||= "localhost"
+      srv = axe "What is the IMAP server (host, or host:port notation)?", $last_server
+      return if srv.nil? || srv.empty?
+      $last_server = srv
+
+      $last_folder ||= "INBOX"
+      fn = axe "What's the folder path?", $last_folder
+      return if fn.nil? || fn.empty?
+      $last_folder = fn
+
+      fn = "/#{fn}"
+      if srv =~ /^(\S+):(\d+)$/
+        host, port = $1, $2.to_i
+      else
+        host, port = srv, nil
       end
+      [Redwood::IMAP.suggest_labels_for(fn),
+       { :scheme => type.to_s, :host => host, :port => port, :path => fn }]
+    end
+
+    uri = begin
+      URI::Generic.build components
+    rescue URI::Error => e
+      say "Whoopsie! I couldn't build a URI from that: #{e.message}"
+      if axe_yes("Try again?") then next else return end
+    end
 
     say "I'm going to add this source: #{uri}"
     unless axe("Does that look right?", "y") =~ /^y|yes$/i
@@ -123,13 +120,12 @@ def add_source
 
     labels_str = axe("Enter any labels to be automatically added to all messages from this source, separated by spaces (or 'none')", default_labels.join(","))
 
-    labels =
-      if labels_str =~ /^\s*none\s*$/i
-        nil
-      else
-        labels_str.split(/\s+/)
-      end
-    
+    labels = if labels_str =~ /^\s*none\s*$/i
+      nil
+    else
+      labels_str.split(/\s+/)
+    end
+
     cmd = build_cmd "sup-add"
     cmd += " --unusual" unless usual
     cmd += " --archive" if archive
@@ -164,14 +160,13 @@ nary a click of the mouse!
 Just answer these simple questions and you'll be on your way.
 
 EOS
-#' stupid ruby-mode
 
 account = $config[:accounts][:default]
 
 name = axe "What's your name?", account[:name]
-email = axe "What's your (primary) email address?", account[:email] #'srm
+email = axe "What's your (primary) email address?", account[:email]
 
-say "Ok, your header will look like this:"
+say "Ok, your from header will look like this:"
 say "  From: #{name} <#{email}>"
 
 say "\nDo you have any alternate email addresses that also receive email?"
@@ -187,7 +182,6 @@ $config[:accounts][:default][:alternates] = alts
 $config[:accounts][:default][:signature] = sigfn
 $config[:editor] = editor
 
-
 done = false
 until done
   say "\nNow, we'll tell Sup where to find all your email."
@@ -222,14 +216,12 @@ else
   choose do |menu|
     menu.prompt = "Store my sent mail in? "
 
+    menu.choice('Default (an mbox in ~/.sup, aka sup://sent)') { $config[:sent_source] = 'sup://sent'} unless have_sup_sent
+
     valid_sents = Redwood::SourceManager.sources.each do |s|
       have_sup_sent = true if s.to_s.eql?('sup://sent')
-
       menu.choice(s.to_s) { $config[:sent_source] = s.to_s } if s.respond_to? :store_message
     end
-
-    menu.choice('Default (sup://sent)') { $config[:sent_source] = 'sup://sent'} unless have_sup_sent
-
   end
 end
 
@@ -239,12 +231,12 @@ say "Ok, I've saved you up a nice lil' #{Redwood::CONFIG_FN}."
 
 say <<EOS
 
-Ok. The final step is to import all your messages into the Sup index.
+The final step is to import all your messages into the Sup index.
 Depending on how many messages are in the sources, this could take
 quite a while.
 
 EOS
-#'
+
 if axe_yes "Run sup-sync to import all messages now?"
   while true
     cmd = build_cmd("sup-sync") + " --all-sources"
index 2aa00c3720bcd0dbbce60499e5d14a70e01ae418..003a72d9c320e97d80687effb750654e0bc337aa 100755 (executable)
@@ -174,7 +174,12 @@ begin
       ## 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] && old_m && (old_m.labels != restored_state[m.id])
+        num_restored += 1
         [:update_message_state, restored_state[m.id]]
+      when (op == :restore) && restored_state[m.id] && !old_m
+        num_restored += 1
+        m.labels = restored_state[m.id]
+        :add_message
       when op == :discard
         if old_m && (old_m.labels != m.labels)
           [:update_message_state, m.labels]
index 16c2b3f3bbf802d6e713714a48aecbde41ad8dc2..aa8079c488edd0976eb966d1aa8f7dac956dd866 100644 (file)
@@ -88,8 +88,10 @@ module Redwood
   def save_yaml_obj o, fn, safe=false
     o = if o.is_a?(Array)
       o.map { |x| (x.respond_to?(:before_marshal) && x.before_marshal) || x }
+    elsif o.respond_to? :before_marshal
+      o.before_marshal
     else
-      o.respond_to?(:before_marshal) && o.before_marshal
+      o
     end
 
     if safe
@@ -193,6 +195,7 @@ end
 ## set up default configuration file
 if File.exists? Redwood::CONFIG_FN
   $config = Redwood::load_yaml_obj Redwood::CONFIG_FN
+  abort "#{Redwood::CONFIG_FN} is not a valid configuration file (it's a #{$config.class}, not a hash)" unless $config.is_a?(Hash)
 else
   require 'etc'
   require 'socket'
index 7f044b99428d0beb80bfb22fa1f4cda6bc127011..772b8b696c4f4f41682fe2e51f4656bc66603254 100644 (file)
@@ -129,10 +129,9 @@ class CryptoManager
       end
 
       notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
-      [RMail::Parser.read(decrypted_payload), sig, notice]
+      [notice, sig, RMail::Parser.read(decrypted_payload)]
     else
-      notice = Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", output.split("\n")
-      [nil, nil, notice]
+      Chunk::CryptoNotice.new :invalid, "This message could not be decrypted", output.split("\n")
     end
   end
 
index 0c411626c43d1ad31e94493860f919b2ebf631e5..a2e88983cb3d7030abd845b83804828b9eb7b96b 100644 (file)
@@ -1,33 +1,11 @@
 module Redwood
 
 class HookManager
-  ## there's probably a better way to do this, but to evaluate a hook
-  ## with a bunch of pre-set "local variables" i define a function
-  ## per variable and then instance_evaluate the code.
-  ##
-  ## how does rails do it, when you pass :locals into a partial?
-  ##
-  ## i don't bother providing setters, since i'm pretty sure the
-  ## charade will fall apart pretty quickly with respect to scoping.
-  ## "fail-fast", we'll call it.
   class HookContext
     def initialize name
       @__say_id = nil
       @__name = name
-      @__locals = {}
-    end
-
-    attr_writer :__locals
-
-    def method_missing m, *a
-      case @__locals[m]
-      when Proc
-        @__locals[m] = @__locals[m].call(*a) # only call the proc once
-      when nil
-        super
-      else
-        @__locals[m]
-      end
+      @__cache = {}
     end
 
     def say s
@@ -60,12 +38,24 @@ class HookManager
       HookManager.tags[tag] = value
     end
 
-    def __binding 
-      binding
-    end
-
-    def __cleanup
+    def __run __hook, __filename, __locals
+      __binding = binding
+      __lprocs, __lvars = __locals.partition { |k, v| v.is_a?(Proc) }
+      eval __lvars.map { |k, v| "#{k} = __locals[#{k.inspect}];" }.join, __binding
+      ## we also support closures for delays evaluation. unfortunately
+      ## we have to do this via method calls, so you don't get all the
+      ## semantics of a regular variable. not ideal.
+      __lprocs.each do |k, v|
+        self.class.instance_eval do
+          define_method k do
+            @__cache[k] ||= v.call
+          end
+        end
+      end
+      ret = eval __hook, __binding, __filename
       BufferManager.clear @__say_id if @__say_id
+      @__cache = {}
+      ret
     end
   end
 
@@ -86,18 +76,16 @@ class HookManager
   def run name, locals={}
     hook = hook_for(name) or return
     context = @contexts[hook] ||= HookContext.new(name)
-    context.__locals = locals
 
     result = nil
     begin
-      result = context.instance_eval @hooks[name], fn_for(name)
+      result = context.__run hook, fn_for(name), locals
     rescue Exception => e
       log "error running hook: #{e.message}"
       log e.backtrace.join("\n")
       @hooks[name] = nil # disable it
       BufferManager.flash "Error running hook: #{e.message}" if BufferManager.instantiated?
     end
-    context.__cleanup
     result
   end
 
@@ -127,15 +115,14 @@ private
 
   def hook_for name
     unless @hooks.member? name
-      @hooks[name] =
-        begin
-          returning IO.read(fn_for(name)) do
-            log "read '#{name}' from #{fn_for(name)}"
-          end
-        rescue SystemCallError => e
-          #log "disabled hook for '#{name}': #{e.message}"
-          nil
+      @hooks[name] = begin
+        returning IO.read(fn_for(name)) do
+          debug "read '#{name}' from #{fn_for(name)}"
         end
+      rescue SystemCallError => e
+        #debug "disabled hook for '#{name}': #{e.message}"
+        nil
+      end
     end
 
     @hooks[name]
index 030759483289f2ee6bd115b818e9dfecb5a986de..a11bf9541e6efed049754cf85442c6cdbd714c8b 100644 (file)
@@ -9,6 +9,8 @@ class Loader < Source
   include SerializeLabelsNicely
   yaml_properties :uri, :cur_offset, :usual, :archived, :id, :labels
 
+  attr_reader :labels
+
   ## uri_or_fp is horrific. need to refactor.
   def initialize uri_or_fp, start_offset=0, usual=true, archived=false, id=nil, labels=nil
     @mutex = Mutex.new
@@ -169,7 +171,7 @@ class Loader < Source
     end
 
     self.cur_offset = next_offset
-    [returned_offset, (@labels + [:unread])]
+    [returned_offset, (labels + [:unread])]
   end
 end
 
index ed27d3dccfba056410bd90225604479f8e788797..4a7d761583e93521ed18fc057a503a92918cc639 100644 (file)
@@ -127,6 +127,31 @@ class Message
     @list_unsubscribe = header["list-unsubscribe"]
   end
 
+  ## Expected index entry format:
+  ## :message_id, :subject => String
+  ## :date => Time
+  ## :refs, :replytos => Array of String
+  ## :from => Person
+  ## :to, :cc, :bcc => Array of Person
+  def load_from_index! entry
+    @id = entry[:message_id]
+    @from = entry[:from]
+    @date = entry[:date]
+    @subj = entry[:subject]
+    @to = entry[:to]
+    @cc = entry[:cc]
+    @bcc = entry[:bcc]
+    @refs = (@refs + entry[:refs]).uniq
+    @replytos = entry[:replytos]
+
+    @replyto = nil
+    @list_address = nil
+    @recipient_email = nil
+    @source_marked_read = false
+    @list_subscribe = nil
+    @list_unsubscribe = nil
+  end
+
   def add_ref ref
     @refs << ref
     @dirty = true
@@ -166,11 +191,13 @@ class Message
 
   def has_label? t; @labels.member? t; end
   def add_label l
+    l = l.to_sym
     return if @labels.member? l
     @labels << l
     @dirty = true
   end
   def remove_label l
+    l = l.to_sym
     return unless @labels.member? l
     @labels.delete l
     @dirty = true
@@ -182,6 +209,7 @@ class Message
 
   def labels= l
     raise ArgumentError, "not a set" unless l.is_a?(Set)
+    raise ArgumentError, "not a set of labels" unless l.all? { |ll| ll.is_a?(Symbol) }
     return if @labels == l
     @labels = l
     @dirty = true
@@ -382,9 +410,13 @@ private
       return
     end
 
-    decryptedm, sig, notice = CryptoManager.decrypt payload
-    children = message_to_chunks(decryptedm, true) if decryptedm
-    [notice, sig, children].flatten.compact
+    notice, sig, decryptedm = CryptoManager.decrypt payload
+    if decryptedm # managed to decrypt
+      children = message_to_chunks(decryptedm, true)
+      [notice, sig, children]
+    else
+      [notice]
+    end
   end
 
   ## takes a RMail::Message, breaks it into Chunk:: classes.
@@ -405,11 +437,15 @@ private
 
       chunks
     elsif m.header.content_type == "message/rfc822"
-      payload = RMail::Parser.read(m.body)
-      from = payload.header.from.first
-      from_person = from ? Person.from_address(from.format) : nil
-      [Chunk::EnclosedMessage.new(from_person, payload.to_s)] +
-        message_to_chunks(payload, encrypted)
+      if m.body
+        payload = RMail::Parser.read(m.body)
+        from = payload.header.from.first
+        from_person = from ? Person.from_address(from.format) : nil
+        [Chunk::EnclosedMessage.new(from_person, payload.to_s)] +
+          message_to_chunks(payload, encrypted)
+      else
+        [Chunk::EnclosedMessage.new(nil, "")]
+      end
     else
       filename =
         ## first, paw through the headers looking for a filename
index 9203dd666816a770d8537c18c6281ae8f17ed14a..87ca6c672a02522092887cf632a0f53e43f7b5ad 100644 (file)
@@ -29,6 +29,7 @@ class SentManager
 
     PollManager.each_message_from(@source) do |m|
       m.remove_label :unread
+      m.add_label :sent
       PollManager.add_new_message m
     end
   end
index 068ce6bad904c9012bdcd5d8883ac48a68e59976..f99e1c17c79a3add8d5da43901c6b161d5c7170d 100644 (file)
@@ -177,7 +177,7 @@ class String
   ## nasty multibyte hack for ruby 1.8. if it's utf-8, split into chars using
   ## the utf8 regex and count those. otherwise, use the byte length.
   def display_length
-    if $encoding == "UTF-8"
+    if $encoding == "UTF-8" || $encoding == "utf8"
       scan(/./u).size
     else
       size
index dbf66431bc263ada5420b9a981f766c9ca5675d4..1395601367ef49fdebb9ef5019a82a07a0c83ca3 100644 (file)
@@ -1,5 +1,4 @@
 require 'xapian'
-require 'gdbm'
 require 'set'
 
 module Redwood
@@ -9,6 +8,7 @@ module Redwood
 # for searching due to precomputing thread membership.
 class XapianIndex < BaseIndex
   STEM_LANGUAGE = "english"
+  INDEX_VERSION = '1'
 
   ## 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
@@ -23,13 +23,18 @@ class XapianIndex < BaseIndex
   end
 
   def load_index
-    @entries = MarshalledGDBM.new File.join(@dir, "entries.db")
-    @docids = MarshalledGDBM.new File.join(@dir, "docids.db")
-    @thread_members = MarshalledGDBM.new File.join(@dir, "thread_members.db")
-    @thread_ids = MarshalledGDBM.new File.join(@dir, "thread_ids.db")
-    @assigned_docids = GDBM.new File.join(@dir, "assigned_docids.db")
-
-    @xapian = Xapian::WritableDatabase.new(File.join(@dir, "xapian"), Xapian::DB_CREATE_OR_OPEN)
+    path = File.join(@dir, 'xapian')
+    if File.exists? path
+      @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_OPEN)
+      db_version = @xapian.get_metadata 'version'
+      db_version = '0' if db_version.empty?
+      if 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)."
+      end
+    else
+      @xapian = Xapian::WritableDatabase.new(path, Xapian::DB_CREATE)
+      @xapian.set_metadata 'version', INDEX_VERSION
+    end
     @term_generator = Xapian::TermGenerator.new()
     @term_generator.stemmer = Xapian::Stem.new(STEM_LANGUAGE)
     @enquire = Xapian::Enquire.new @xapian
@@ -48,43 +53,35 @@ class XapianIndex < BaseIndex
   end
 
   def contains_id? id
-    synchronize { @entries.member? id }
+    synchronize { find_docid(id) && true }
   end
 
   def source_for_id id
-    synchronize { @entries[id][:source_id] }
+    synchronize { get_entry(id)[:source_id] }
   end
 
   def delete id
-    synchronize { @xapian.delete_document @docids[id] }
+    synchronize { @xapian.delete_document mkterm(:msgid, id) }
   end
 
   def build_message id
-    entry = synchronize { @entries[id] }
+    entry = synchronize { get_entry id }
     return unless entry
 
     source = SourceManager[entry[:source_id]]
     raise "invalid source #{entry[:source_id]}" unless source
 
-    mk_addrs = lambda { |l| l.map { |e,n| "#{n} <#{e}>" } * ', ' }
-    mk_refs = lambda { |l| l.map { |r| "<#{r}>" } * ' ' }
-    fake_header = {
-      'message-id' => entry[:message_id],
-      'date' => Time.at(entry[:date]),
-      'subject' => entry[:subject],
-      'from' => mk_addrs[[entry[:from]]],
-      'to' => mk_addrs[entry[:to]],
-      'cc' => mk_addrs[entry[:cc]],
-      'bcc' => mk_addrs[entry[:bcc]],
-      'reply-tos' => mk_refs[entry[:replytos]],
-      'references' => mk_refs[entry[:refs]],
-     }
-
-      m = Message.new :source => source, :source_info => entry[:source_info],
-                  :labels => entry[:labels],
-                  :snippet => entry[:snippet]
-      m.parse_header fake_header
-      m
+    m = Message.new :source => source, :source_info => entry[:source_info],
+                    :labels => entry[:labels], :snippet => entry[:snippet]
+
+    mk_person = lambda { |x| Person.new(*x.reverse!) }
+    entry[:from] = mk_person[entry[:from]]
+    entry[:to].map!(&mk_person)
+    entry[:cc].map!(&mk_person)
+    entry[:bcc].map!(&mk_person)
+
+    m.load_from_index! entry
+    m
   end
 
   def add_message m; sync_message m end
@@ -92,7 +89,7 @@ class XapianIndex < BaseIndex
   def update_message_state m; sync_message m end
 
   def sync_message m, opts={}
-    entry = synchronize { @entries[m.id] }
+    entry = synchronize { get_entry m.id }
     snippet = m.snippet
     entry ||= {}
     labels = m.labels
@@ -117,9 +114,7 @@ class XapianIndex < BaseIndex
     labels.each { |l| LabelManager << l }
 
     synchronize do
-      index_message m, opts
-      union_threads([m.id] + m.refs + m.replytos)
-      @entries[m.id] = d
+      index_message m, d, opts
     end
     true
   end
@@ -152,8 +147,26 @@ class XapianIndex < BaseIndex
   def each_message_in_thread_for m, opts={}
     # TODO thread by subject
     # TODO handle killed threads
-    ids = synchronize { @thread_members[@thread_ids[m.id]] } || []
-    ids.select { |id| contains_id? id }.each { |id| yield id, lambda { build_message id } }
+    return unless doc = find_doc(m.id)
+    queue = doc.value(THREAD_VALUENO).split(',')
+    msgids = [m.id]
+    seen_threads = Set.new
+    seen_messages = Set.new [m.id]
+    while not queue.empty?
+      thread_id = queue.pop
+      next if seen_threads.member? thread_id
+      return false if thread_killed? thread_id
+      seen_threads << thread_id
+      docs = term_docids(mkterm(:thread, thread_id)).map { |x| @xapian.document x }
+      docs.each do |doc|
+        msgid = doc.value MSGID_VALUENO
+        next if seen_messages.member? msgid
+        msgids << msgid
+        seen_messages << msgid
+        queue.concat doc.value(THREAD_VALUENO).split(',')
+      end
+    end
+    msgids.each { |id| yield id, lambda { build_message id } }
     true
   end
 
@@ -302,11 +315,16 @@ class XapianIndex < BaseIndex
     'label' => 'L',
     'source_id' => 'I',
     'attachment_extension' => 'O',
+    'msgid' => 'Q',
+    'thread' => 'H',
+    'ref' => 'R',
   }
 
   PREFIX = NORMAL_PREFIX.merge BOOLEAN_PREFIX
 
-  DATE_VALUENO = 0
+  MSGID_VALUENO = 0
+  THREAD_VALUENO = 1
+  DATE_VALUENO = 2
 
   MAX_TERM_LENGTH = 245
 
@@ -322,14 +340,50 @@ class XapianIndex < BaseIndex
   def assign_docid m, truncated_date
     t = (truncated_date.to_i - MIDDLE_DATE.to_i).to_f
     docid = (DOCID_SCALE - DOCID_SCALE/(Math::E**(-(t/TIME_SCALE)) + 1)).to_i
+    while docid > 0 and docid_exists? docid
+      docid -= 1
+    end
+    docid > 0 ? docid : nil
+  end
+
+  # XXX is there a better way?
+  def docid_exists? docid
     begin
-      while @assigned_docids.member? [docid].pack("N")
-        docid -= 1
-      end
-    rescue
+      @xapian.doclength docid
+      true
+    rescue RuntimeError #Xapian::DocNotFoundError
+      raise unless $!.message =~ /DocNotFoundError/
+      false
     end
-    @assigned_docids[[docid].pack("N")] = ''
-    docid
+  end
+
+  def term_docids term
+    @xapian.postlist(term).map { |x| x.docid }
+  end
+
+  def find_docid id
+    docids = term_docids(mkterm(:msgid,id))
+    fail unless docids.size <= 1
+    docids.first
+  end
+
+  def find_doc id
+    return unless docid = find_docid(id)
+    @xapian.document docid
+  end
+
+  def get_id docid
+    return unless doc = @xapian.document(docid)
+    doc.value MSGID_VALUENO
+  end
+
+  def get_entry id
+    return unless doc = find_doc(id)
+    Marshal.load doc.data
+  end
+
+  def thread_killed? thread_id
+    not run_query(Q.new(Q::OP_AND, mkterm(:thread, thread_id), mkterm(:label, :Killed)), 0, 1).empty?
   end
 
   def synchronize &b
@@ -345,7 +399,7 @@ class XapianIndex < BaseIndex
 
   def run_query_ids xapian_query, offset, limit
     matchset = run_query xapian_query, offset, limit
-    matchset.matches.map { |r| r.document.data }
+    matchset.matches.map { |r| r.document.value MSGID_VALUENO }
   end
 
   Q = Xapian::Query
@@ -376,7 +430,7 @@ class XapianIndex < BaseIndex
     end
   end
 
-  def index_message m, opts
+  def index_message m, entry, opts
     terms = []
     text = []
 
@@ -399,6 +453,7 @@ class XapianIndex < BaseIndex
     terms << mkterm(:date,m.date) if m.date
     m.labels.each { |t| terms << mkterm(:label,t) }
     terms << mkterm(:type, 'mail')
+    terms << mkterm(:msgid, m.id)
     terms << mkterm(:source_id, m.source.id)
     m.attachments.each do |a|
       a =~ /\.(\w+)$/ or next
@@ -406,6 +461,23 @@ class XapianIndex < BaseIndex
       terms << t
     end
 
+    ## Thread membership
+    children = term_docids(mkterm(:ref, m.id)).map { |docid| @xapian.document docid }
+    parent_ids = m.refs + m.replytos
+    parents = parent_ids.map { |id| find_doc id }.compact
+    thread_members = SavingHash.new { [] }
+    (children + parents).each do |doc2|
+      thread_ids = doc2.value(THREAD_VALUENO).split ','
+      thread_ids.each { |thread_id| thread_members[thread_id] << doc2 }
+    end
+
+    thread_ids = thread_members.empty? ? [m.id] : thread_members.keys
+
+    thread_ids.each { |thread_id| terms << mkterm(:thread, thread_id) }
+    parent_ids.each do |ref|
+      terms << mkterm(:ref, ref)
+    end
+
     # Full text search content
     text << [subject_text, PREFIX['subject']]
     text << [subject_text, PREFIX['body']]
@@ -429,17 +501,29 @@ class XapianIndex < BaseIndex
       Xapian.sortable_serialise 0
     end
 
-    doc = Xapian::Document.new
-    docid = @docids[m.id] || assign_docid(m, truncated_date)
+    docid = nil
+    unless doc = find_doc(m.id)
+      doc = Xapian::Document.new
+      if not docid = assign_docid(m, truncated_date)
+        # Could be triggered by spam
+        Redwood::log "warning: docid underflow, dropping #{m.id.inspect}"
+        return
+      end
+    else
+      doc.clear_terms
+      doc.clear_values
+      docid = doc.docid
+    end
 
     @term_generator.document = doc
     text.each { |text,prefix| @term_generator.index_text text, 1, prefix }
     terms.each { |term| doc.add_term term if term.length <= MAX_TERM_LENGTH }
+    doc.add_value MSGID_VALUENO, m.id
+    doc.add_value THREAD_VALUENO, (thread_ids * ',')
     doc.add_value DATE_VALUENO, date_value
-    doc.data = m.id
+    doc.data = Marshal.dump entry
 
     @xapian.replace_document docid, doc
-    @docids[m.id] = docid
   end
 
   # Construct a Xapian term
@@ -462,48 +546,13 @@ class XapianIndex < BaseIndex
       PREFIX['source_id'] + args[0].to_s.downcase
     when :attachment_extension
       PREFIX['attachment_extension'] + args[0].to_s.downcase
+    when :msgid, :ref, :thread
+      PREFIX[type.to_s] + args[0][0...(MAX_TERM_LENGTH-1)]
     else
       raise "Invalid term type #{type}"
     end
   end
 
-  # Join all the given message-ids into a single thread
-  def union_threads ids
-    seen_threads = Set.new
-    related = Set.new
-
-    # Get all the ids that will be in the new thread
-    ids.each do |id|
-      related << id
-      thread_id = @thread_ids[id]
-      if thread_id && !seen_threads.member?(thread_id)
-        thread_members = @thread_members[thread_id]
-        related.merge thread_members
-        seen_threads << thread_id
-      end
-    end
-
-    # Pick a leader and move all the others to its thread
-    a = related.to_a
-    best, *rest = a.sort_by { |x| x.hash }
-    @thread_members[best] = a
-    @thread_ids[best] = best
-    rest.each do |x|
-      @thread_members.delete x
-      @thread_ids[x] = best
-    end
-  end
 end
 
 end
-
-class MarshalledGDBM < GDBM
-  def []= k, v
-    super k, Marshal.dump(v)
-  end
-
-  def [] k
-    v = super k
-    v ? Marshal.load(v) : nil
-  end
-end