sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 8aabeffd660303ec24411b33a22f0f7b43e23a01
parent 7edbb7ee676b4789296bd733b28b60f669724f88
Author: Rich Lane <rlane@club.cc.cmu.edu>
Date:   Sat, 22 May 2010 13:14:41 -0700

Merge branch 'master' into maildir

Diffstat:
M bin/sup | 3 +++
M bin/sup-cmd | 121 ++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
A bin/sup-server | 43 +++++++++++++++++++++++++++++++++++++++++++
M lib/sup.rb | 3 ++-
M lib/sup/buffer.rb | 17 +++++++++++------
A lib/sup/client.rb | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
D lib/sup/connection.rb | 60 ------------------------------------------------------------
M lib/sup/crypto.rb | 100 ++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
M lib/sup/hook.rb | 2 +-
M lib/sup/message.rb | 50 +++++++++++++++++++++++++++++++++++++++++++++++---
M lib/sup/modes/compose-mode.rb | 3 ++-
M lib/sup/modes/edit-message-mode.rb | 4 +++-
A lib/sup/protocol.rb | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A lib/sup/server.rb | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M lib/sup/util.rb | 5 +++++
A protocol.md | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
16 files changed, 725 insertions(+), 165 deletions(-)
diff --git a/bin/sup b/bin/sup
@@ -86,6 +86,7 @@ global_keymap = Keymap.new do |k|
   k.add :nothing, "Do nothing", :ctrl_g
   k.add :recall_draft, "Edit most recent draft message", 'R'
   k.add :show_inbox, "Show the Inbox buffer", 'I'
+  k.add :clear_hooks, "Clear all hooks", 'H'
   k.add :show_console, "Show the Console buffer", '~'
 
   ## Submap for less often used keybindings
@@ -330,6 +331,8 @@ begin
       end
     when :show_inbox
       BufferManager.raise_to_front ibuf
+    when :clear_hooks
+      HookManager.clear
     when :show_console
       b, new = bm.spawn_unless_exists("Console", :system => true) { ConsoleMode.new }
       b.mode.run
diff --git a/bin/sup-cmd b/bin/sup-cmd
@@ -2,6 +2,7 @@
 require 'rubygems'
 require 'trollop'
 require 'sup'
+require 'sup/client'
 require 'pp'
 require 'yaml'
 include Redwood
@@ -10,7 +11,7 @@ SUB_COMMANDS = %w(query count label add)
 global_opts = Trollop::options do
   #version = "sup-cmd (sup #{Redwood::VERSION})"
   banner <<EOS
-Interact with a Sup index.
+Connect to a running sup-server.
 
 Usage:
   sup-cmd [global options] command [options]
@@ -20,6 +21,8 @@ Usage:
   Global options:
 EOS
 
+  opt :host, "server address", :type => :string, :default => 'localhost', :short => 'o'
+  opt :port, "server port", :type => :int, :default => 4300
   opt :verbose
 
   stop_on SUB_COMMANDS
@@ -50,61 +53,79 @@ else
   Trollop::die "unrecognized command #{cmd.inspect}"
 end
 
-def get_query
-  text = ARGV.first or fail "query argument required"
-  Redwood::Index.parse_query text
-end
-
-Redwood.start
-Index.init
-Index.lock_interactively or exit
-begin
-  if(s = Redwood::SourceManager.source_for SentManager.source_uri)
-    SentManager.source = s
-  else
-    Redwood::SourceManager.add_source SentManager.default_source
+class SupCmd < Redwood::Client
+  def initialize cmd, args, opts
+    @cmd = cmd
+    @opts = opts
+    @args = args
+    super()
   end
 
-  Index.load
-  c = Redwood::Connection.new
-
-case cmd
-when "query"
-  c.query get_query, cmd_opts[:offset], cmd_opts[:limit], cmd_opts[:raw] do |result|
-    puts YAML.dump(result['summary'])
-    puts YAML.dump(result['raw']) if cmd_opts[:raw]
-  end
-when "count"
-  puts c.count(get_query)
-when "label"
-  c.label get_query, cmd_opts[:remove_labels].split(','), cmd_opts[:add_labels].split(',')
-when "add"
-  ARGF.binmode
-  labels = cmd_opts[:labels].split(',')
-  get_message = lambda do
-    return ARGF.gets(nil) unless cmd_opts[:mbox]
-    str = ""
-    l = ARGF.gets
-    str << l until ARGF.closed? || ARGF.eof? || MBox::is_break_line?(l = ARGF.gets)
-    str.empty? ? nil : str
+  def get_query
+    @args.first or fail "query argument required"
   end
-  i_s = i = 0
-  t = Time.now
-  while raw = get_message[]
-    i += 1
-    t_d = Time.now - t
-    if t_d >= 5
-      i_d = i - i_s
-      puts "indexed #{i} messages (#{i_d/t_d} m/s)" if global_opts[:verbose]
+
+  def connection_established
+    case @cmd
+    when "query"
+      query get_query, @opts[:offset], @opts[:limit], @opts[:raw] do |result|
+        if result
+          puts YAML.dump(result['summary'])
+          puts YAML.dump(result['raw']) if @opts[:raw]
+        else
+          close_connection
+        end
+      end
+    when "count"
+      count(get_query) do |x|
+        puts x
+        close_connection
+      end
+    when "label"
+      label get_query, @opts[:remove_labels].split(','), @opts[:add_labels].split(',') do
+        close_connection
+      end
+    when "add"
+      ARGF.binmode
+      labels = @opts[:labels].split(',')
+      get_message = lambda do
+        return ARGF.gets(nil) unless @opts[:mbox]
+        str = ""
+        l = ARGF.gets
+        str << l until ARGF.closed? || ARGF.eof? || MBox::is_break_line?(l = ARGF.gets)
+        str.empty? ? nil : str
+      end
+      i_s = i = 0
       t = Time.now
-      i_s = i
+      while raw = get_message[]
+        i += 1
+        t_d = Time.now - t
+        if t_d >= 5
+          i_d = i - i_s
+          puts "indexed #{i} messages (#{i_d/t_d} m/s)" if global_opts[:verbose]
+          t = Time.now
+          i_s = i
+        end
+        add raw, labels do
+          close_connection
+        end
+      end
+    else
+      fail "#{@cmd} command unimplemented"
+      close_connection
     end
-    c.add raw, labels
   end
-else
-  fail "#{cmd} command unimplemented"
+
+  def unbind
+    EM.stop
+  end
 end
 
-ensure
-  Index.unlock
+
+EM.run do
+  EM.connect global_opts[:host], global_opts[:port],
+             SupCmd, cmd, ARGV, cmd_opts.merge(global_opts)
 end
+
+exit 0
+
diff --git a/bin/sup-server b/bin/sup-server
@@ -0,0 +1,43 @@
+#!/usr/bin/env ruby
+require 'rubygems'
+require 'trollop'
+require 'sup'
+require 'sup/server'
+require 'pp'
+require 'yaml'
+include Redwood
+
+global_opts = Trollop::options do
+  #version = "sup-cmd (sup #{Redwood::VERSION})"
+  banner <<EOS
+Interact with a Sup index.
+
+Usage:
+  sup-server [options]
+EOS
+
+  opt :host, "address to listen on", :type => :string, :default => 'localhost', :short => 'o'
+  opt :port, "port to listen on", :type => :int, :default => 4300
+  opt :verbose
+end
+
+Redwood.start
+Index.init
+Index.lock_interactively or exit
+begin
+  if(s = Redwood::SourceManager.source_for SentManager.source_uri)
+    SentManager.source = s
+  else
+    Redwood::SourceManager.add_source SentManager.default_source
+  end
+
+  Index.load
+
+  EM.run do
+    EM.start_server global_opts[:host], global_opts[:port], Redwood::Server
+    EM.next_tick { puts "ready" }
+  end
+
+ensure
+  Index.unlock
+end
diff --git a/lib/sup.rb b/lib/sup.rb
@@ -254,6 +254,7 @@ else
     :editor => ENV["EDITOR"] || "/usr/bin/vim -f -c 'setlocal spell spelllang=en_us' -c 'set filetype=mail'",
     :thread_by_subject => false,
     :edit_signature => false,
+    :ask_for_from => false,
     :ask_for_to => true,
     :ask_for_cc => true,
     :ask_for_bcc => false,
@@ -292,6 +293,7 @@ include Redwood::LogsStuff
 
 ## determine encoding and character set
   $encoding = Locale.current.charset
+  $encoding = "UTF-8" if $encoding == "utf8"
   if $encoding
     debug "using character set encoding #{$encoding.inspect}"
   else
@@ -350,7 +352,6 @@ require "sup/sent"
 require "sup/search"
 require "sup/modes/search-list-mode"
 require "sup/idle"
-require "sup/connection"
 
 $:.each do |base|
   d = File.join base, "sup/share/modes/"
diff --git a/lib/sup/buffer.rb b/lib/sup/buffer.rb
@@ -130,15 +130,13 @@ class Buffer
     s ||= ""
     maxl = @width - x # maximum display width width
     stringl = maxl    # string "length"
+
+    # fill up the line with blanks to overwrite old screen contents
+    @w.mvaddstr y, x, " " * maxl unless opts[:no_fill]
+
     ## the next horribleness is thanks to ruby's lack of widechar support
     stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl
     @w.mvaddstr y, x, s[0 ... stringl]
-    unless opts[:no_fill]
-      l = s.display_length
-      unless l >= maxl
-        @w.mvaddstr(y, x + l, " " * (maxl - l))
-      end
-    end
   end
 
   def clear
@@ -559,6 +557,13 @@ EOS
     end
   end
 
+  def ask_for_account domain, question
+    default = AccountManager.default_account.email
+    completions = AccountManager.user_emails
+    answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
+    AccountManager.account_for Person.from_address(answer).email if answer
+  end
+
   ## for simplicitly, we always place the question at the very bottom of the
   ## screen
   def ask domain, question, default=nil, &block
diff --git a/lib/sup/client.rb b/lib/sup/client.rb
@@ -0,0 +1,72 @@
+require 'sup/protocol'
+
+class Redwood::Client < EM::P::RedwoodClient
+  def initialize *a
+    @next_tag = 1
+    @cbs = {}
+    super *a
+  end
+
+  def mktag &b
+    @next_tag.tap do |x|
+      @cbs[x] = b
+      @next_tag += 1
+    end
+  end
+
+  def rmtag tag
+    @cbs.delete tag
+  end
+
+  def query qstr, offset, limit, raw, &b
+    tag = mktag do |type,tag,args|
+      if type == 'message'
+        b.call args
+      else
+        fail unless type == 'done'
+        b.call nil
+        rmtag tag
+      end
+    end
+    send_message 'query', tag,
+                 'query' => qstr,
+                 'offset' => offset,
+                 'limit' => limit,
+                 'raw' => raw
+  end
+
+  def count qstr, &b
+    tag = mktag do |type,tag,args|
+      b.call args['count']
+      rmtag tag
+    end
+    send_message 'count', tag,
+                 'query' => qstr
+  end
+
+  def label qstr, add, remove, &b
+    tag = mktag do |type,tag,args|
+      b.call
+      rmtag tag
+    end
+    send_message 'label', tag,
+                 'query' => qstr,
+                 'add' => add,
+                 'remove' => remove
+  end
+
+  def add raw, labels, &b
+    tag = mktag do |type,tag,args|
+      b.call
+      rmtag tag
+    end
+    send_message 'add', tag,
+                 'raw' => raw,
+                 'labels' => labels
+  end
+
+  def receive_message type, tag, args
+    cb = @cbs[tag] or fail "invalid tag #{tag.inspect}"
+    cb[type, tag, args]
+  end
+end
diff --git a/lib/sup/connection.rb b/lib/sup/connection.rb
@@ -1,60 +0,0 @@
-module Redwood
-
-## Hacky implementation of the sup-server API using existing Sup code
-class Connection
-  def result_from_message m, raw
-    mkperson = lambda { |p| { :email => p.email, :name => p.name } }
-    {
-      'summary' => {
-        'message_id' => m.id,
-        'date' => m.date,
-        'from' => mkperson[m.from],
-        'to' => m.to.map(&mkperson),
-        'cc' => m.cc.map(&mkperson),
-        'bcc' => m.bcc.map(&mkperson),
-        'subject' => m.subj,
-        'refs' => m.refs,
-        'replytos' => m.replytos,
-        'labels' => m.labels.map(&:to_s),
-      },
-      'raw' => raw ? m.raw_message : nil,
-    }
-  end
-
-  def query query, offset, limit, raw
-    c = 0
-    Index.each_message query do |m|
-      next if c < offset
-      break if c >= offset + limit if limit
-      yield result_from_message(m, raw)
-      c += 1
-    end
-    nil
-  end
-
-  def count query
-    Index.num_results_for query
-  end
-
-  def label query, remove_labels, add_labels
-    Index.each_message query do |m|
-      remove_labels.each { |l| m.remove_label l }
-      add_labels.each { |l| m.add_label l }
-      Index.update_message_state m
-    end
-    nil
-  end
-
-  def add raw, labels
-    SentManager.source.store_message Time.now, "test@example.com" do |io|
-      io.write raw
-    end
-    PollManager.poll_from SentManager.source do |sym,m,old_m|
-      next unless sym == :add
-      m.labels = labels
-    end
-    nil
-  end
-end
-
-end
diff --git a/lib/sup/crypto.rb b/lib/sup/crypto.rb
@@ -97,22 +97,11 @@ EOS
     encrypt from, to, payload, true
   end
 
-  def verify payload, signature # both RubyMail::Message objects
-    return unknown_status(cant_find_binary) unless @cmd
-
-    payload_fn = Tempfile.new "redwood.payload"
-    payload_fn.write format_payload(payload)
-    payload_fn.close
-
-    signature_fn = Tempfile.new "redwood.signature"
-    signature_fn.write signature.decode
-    signature_fn.close
-
-    output = run_gpg "--verify #{signature_fn.path} #{payload_fn.path}"
+  def verified_ok? output, rc
     output_lines = output.split(/\n/)
 
     if output =~ /^gpg: (.* signature from .*$)/
-      if $? == 0
+      if rc == 0
         Chunk::CryptoNotice.new :valid, $1, output_lines
       else
         Chunk::CryptoNotice.new :invalid, $1, output_lines
@@ -122,8 +111,30 @@ EOS
     end
   end
 
+  def verify payload, signature, detached=true # both RubyMail::Message objects
+    return unknown_status(cant_find_binary) unless @cmd
+
+    if detached
+      payload_fn = Tempfile.new "redwood.payload"
+      payload_fn.write format_payload(payload)
+      payload_fn.close
+    end
+
+    signature_fn = Tempfile.new "redwood.signature"
+    signature_fn.write signature.decode
+    signature_fn.close
+
+    if detached
+      output = run_gpg "--verify #{signature_fn.path} #{payload_fn.path}"
+    else
+      output = run_gpg "--verify #{signature_fn.path}"
+    end
+
+    self.verified_ok? output, $?
+  end
+
   ## returns decrypted_message, status, desc, lines
-  def decrypt payload # a RubyMail::Message object
+  def decrypt payload, armor=false # a RubyMail::Message object
     return unknown_status(cant_find_binary) unless @cmd
 
     payload_fn = Tempfile.new "redwood.payload"
@@ -133,7 +144,7 @@ EOS
     output_fn = Tempfile.new "redwood.output"
     output_fn.close
 
-    message = run_gpg "--output #{output_fn.path} --yes --decrypt #{payload_fn.path}", :interactive => true
+    message = run_gpg "--output #{output_fn.path} --skip-verify --yes --decrypt #{payload_fn.path}", :interactive => true
 
     unless $?.success?
       info "Error while running gpg: #{message}"
@@ -143,34 +154,39 @@ EOS
     output = IO.read output_fn.path
     output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
 
-    ## there's probably a better way to do this, but we're using the output to
-    ## look for a valid signature being present.
-
-    sig = case message
-    when /^gpg: (Good signature from .*$)/i
-      Chunk::CryptoNotice.new :valid, $1, message.split("\n")
-    when /^gpg: (Bad signature from .*$)/i
-      Chunk::CryptoNotice.new :invalid, $1, message.split("\n")
-    end
-
-    # This is gross. This decrypted payload could very well be a multipart
-    # element itself, as opposed to a simple payload. For example, a
-    # multipart/signed element, like those generated by Mutt when encrypting
-    # and signing a message (instead of just clearsigning the body).
-    # Supposedly, decrypted_payload being a multipart element ought to work
-    # out nicely because Message::multipart_encrypted_to_chunks() runs the
-    # decrypted message through message_to_chunks() again to get any
-    # children. However, it does not work as intended because these inner
-    # payloads need not carry a MIME-Version header, yet they are fed to
-    # RMail as a top-level message, for which the MIME-Version header is
-    # required. This causes for the part not to be detected as multipart,
-    # hence being shown as an attachment. If we detect this is happening,
-    # we force the decrypted payload to be interpreted as MIME.
-    msg = RMail::Parser.read output
-    if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
-      output = "MIME-Version: 1.0\n" + output
-      output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
+    ## check for a valid signature in an extra run because gpg aborts if the
+    ## signature cannot be verified (but it is still able to decrypt)
+    sigoutput = run_gpg "#{payload_fn.path}"
+    sig = self.verified_ok? sigoutput, $?
+
+    if armor
+      msg = RMail::Message.new
+      # Look for Charset, they are put before the base64 crypted part
+      charsets = payload.body.split("\n").grep(/^Charset:/)
+      if !charsets.empty? and charsets[0] =~ /^Charset: (.+)$/
+        output = Iconv.easy_decode($encoding, $1, output)
+      end
+      msg.body = output
+    else
+      # This is gross. This decrypted payload could very well be a multipart
+      # element itself, as opposed to a simple payload. For example, a
+      # multipart/signed element, like those generated by Mutt when encrypting
+      # and signing a message (instead of just clearsigning the body).
+      # Supposedly, decrypted_payload being a multipart element ought to work
+      # out nicely because Message::multipart_encrypted_to_chunks() runs the
+      # decrypted message through message_to_chunks() again to get any
+      # children. However, it does not work as intended because these inner
+      # payloads need not carry a MIME-Version header, yet they are fed to
+      # RMail as a top-level message, for which the MIME-Version header is
+      # required. This causes for the part not to be detected as multipart,
+      # hence being shown as an attachment. If we detect this is happening,
+      # we force the decrypted payload to be interpreted as MIME.
       msg = RMail::Parser.read output
+      if msg.header.content_type =~ %r{^multipart/} && !msg.multipart?
+        output = "MIME-Version: 1.0\n" + output
+        output.force_encoding Encoding::ASCII_8BIT if output.respond_to? :force_encoding
+        msg = RMail::Parser.read output
+      end
     end
     notice = Chunk::CryptoNotice.new :valid, "This message has been decrypted for display"
     [notice, sig, msg]
diff --git a/lib/sup/hook.rb b/lib/sup/hook.rb
@@ -112,7 +112,7 @@ EOS
 
   def enabled? name; !hook_for(name).nil? end
 
-  def clear; @hooks.clear; end
+  def clear; @hooks.clear; BufferManager.flash "Hooks cleared" end
   def clear_one k; @hooks.delete k; end
 
 private
diff --git a/lib/sup/message.rb b/lib/sup/message.rb
@@ -23,7 +23,13 @@ class Message
 
   QUOTE_PATTERN = /^\s{0,4}[>|\}]/
   BLOCK_QUOTE_PATTERN = /^-----\s*Original Message\s*----+$/
-  SIG_PATTERN = /(^-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
+  SIG_PATTERN = /(^(- )*-- ?$)|(^\s*----------+\s*$)|(^\s*_________+\s*$)|(^\s*--~--~-)|(^\s*--\+\+\*\*==)/
+
+  GPG_SIGNED_START = "-----BEGIN PGP SIGNED MESSAGE-----"
+  GPG_SIGNED_END = "-----END PGP SIGNED MESSAGE-----"
+  GPG_START = "-----BEGIN PGP MESSAGE-----"
+  GPG_END = "-----END PGP MESSAGE-----"
+  GPG_SIG_END = "-----BEGIN PGP SIGNATURE-----"
 
   MAX_SIG_DISTANCE = 15 # lines from the end
   DEFAULT_SUBJECT = ""
@@ -491,8 +497,46 @@ private
         ## if there's no charset, use the current encoding as the charset.
         ## this ensures that the body is normalized to avoid non-displayable
         ## characters
-        body = Iconv.easy_decode($encoding, m.charset || $encoding, m.decode) if m.body
-        text_to_chunks((body || "").normalize_whitespace.split("\n"), encrypted)
+        if m.body
+          body = Iconv.easy_decode($encoding, m.charset || $encoding, m.decode)
+        else
+          body = ""
+        end
+
+        ## Check for inline-PGP
+        chunks = inline_gpg_to_chunks body.split("\n")
+        return chunks if chunks
+
+        text_to_chunks(body.normalize_whitespace.split("\n"), encrypted)
+      end
+    end
+  end
+
+  ## looks for gpg signed (but not encrypted) inline  messages inside the
+  ## message body (there is no extra header for inline GPG) or for encrypted
+  ## (and possible signed) inline GPG messages
+  def inline_gpg_to_chunks lines
+    gpg = lines.between(GPG_SIGNED_START, GPG_SIGNED_END)
+    if !gpg.empty?
+      msg = RMail::Message.new
+      msg.body = gpg.join("\n")
+
+      sig = lines.between(GPG_SIGNED_START, GPG_SIG_END)
+      payload = RMail::Message.new
+      payload.body = sig[1, sig.size-2].join("\n")
+      return [CryptoManager.verify(nil, msg, false), message_to_chunks(payload)].flatten.compact
+    end
+
+    gpg = lines.between(GPG_START, GPG_END)
+    if !gpg.empty?
+      msg = RMail::Message.new
+      msg.body = gpg.join("\n")
+      notice, sig, decryptedm = CryptoManager.decrypt msg, true
+      if decryptedm # managed to decrypt
+        children = message_to_chunks(decryptedm, true)
+        return [notice, sig].compact + children
+      else
+        return [notice]
       end
     end
   end
diff --git a/lib/sup/modes/compose-mode.rb b/lib/sup/modes/compose-mode.rb
@@ -21,12 +21,13 @@ class ComposeMode < EditMessageMode
   end
 
   def self.spawn_nicely opts={}
+    from = opts[:from] || (BufferManager.ask_for_account(:account, "From: ") or return if $config[:ask_for_from])
     to = opts[:to] || (BufferManager.ask_for_contacts(:people, "To: ", [opts[:to_default]]) or return if ($config[:ask_for_to] != false))
     cc = opts[:cc] || (BufferManager.ask_for_contacts(:people, "Cc: ") or return if $config[:ask_for_cc])
     bcc = opts[:bcc] || (BufferManager.ask_for_contacts(:people, "Bcc: ") or return if $config[:ask_for_bcc])
     subj = opts[:subj] || (BufferManager.ask(:subject, "Subject: ") or return if $config[:ask_for_subject])
     
-    mode = ComposeMode.new :from => opts[:from], :to => to, :cc => cc, :bcc => bcc, :subj => subj
+    mode = ComposeMode.new :from => from, :to => to, :cc => cc, :bcc => bcc, :subj => subj
     BufferManager.spawn "New Message", mode
     mode.edit_message
   end
diff --git a/lib/sup/modes/edit-message-mode.rb b/lib/sup/modes/edit-message-mode.rb
@@ -382,6 +382,8 @@ protected
     if @crypto_selector && @crypto_selector.val != :none
       from_email = Person.from_address(@header["From"]).email
       to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
+      m.header["Content-Transfer-Encoding"] = 'base64'
+      m.body = [m.body].pack('m')
 
       m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
     end
@@ -401,7 +403,7 @@ protected
     m.header["Date"] = date.rfc2822
     m.header["Message-Id"] = @message_id
     m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
-    m.header["Content-Transfer-Encoding"] = '8bit'
+    m.header["Content-Transfer-Encoding"] ||= '8bit'
     m
   end
 
diff --git a/lib/sup/protocol.rb b/lib/sup/protocol.rb
@@ -0,0 +1,147 @@
+require 'eventmachine'
+require 'socket'
+require 'stringio'
+require 'yajl'
+
+class EM::P::Redwood < EM::Connection
+  VERSION = 1
+  ENCODINGS = %w(marshal json)
+
+  def initialize *args
+    @state = :negotiating
+    @version_buf = ""
+    super
+  end
+
+  def receive_data data
+    if @state == :negotiating
+      @version_buf << data
+      if i = @version_buf.index("\n")
+        l = @version_buf.slice!(0..i)
+        receive_version *parse_version(l.strip)
+        x = @version_buf
+        @version_buf = nil
+        @state = :established
+        connection_established
+        receive_data x
+      end
+    else
+      @filter.decode(data).each { |msg| receive_message *msg }
+    end
+  end
+
+  def connection_established
+    puts "client connection established"
+  end
+
+  def send_version encodings, extensions
+    fail if encodings.empty?
+    send_data "Redwood #{VERSION} #{encodings * ','} #{extensions.empty? ? :none : (extensions * ',')}\n"
+  end
+
+  def send_message type, tag, params={}
+    fail "attempted to send message during negotiation" unless @state == :established
+    send_data @filter.encode([type,tag,params])
+  end
+
+  def receive_version l
+    fail "unimplemented"
+  end
+
+  def receive_message type, params
+    fail "unimplemented"
+  end
+
+private
+
+  def parse_version l
+    l =~ /^Redwood\s+(\d+)\s+([\w,]+)\s+([\w,]+)$/ or fail "unexpected banner #{l.inspect}"
+    version, encodings, extensions = $1.to_i, $2, $3
+    encodings = encodings.split ','
+    extensions = extensions.split ','
+    extensions = [] if extensions == ['none']
+    fail unless version == VERSION
+    fail if encodings.empty?
+    [encodings, extensions]
+  end
+
+  def create_filter encoding
+    case encoding
+    when 'json' then JSONFilter.new
+    when 'marshal' then MarshalFilter.new
+    else fail "unknown encoding #{encoding.inspect}"
+    end
+  end
+
+  class JSONFilter
+    def initialize
+      @parser = Yajl::Parser.new :check_utf8 => false
+    end
+
+    def decode chunk
+      parsed = []
+      @parser.on_parse_complete = lambda { |o| parsed << o }
+      @parser << chunk
+      parsed
+    end
+
+    def encode *os
+      os.inject('') { |s, o| s << Yajl::Encoder.encode(o) }
+    end
+  end
+
+  class MarshalFilter
+    def initialize
+      @buf = ''
+      @state = :prefix
+      @size = 0
+    end
+
+    def decode chunk
+      received = []
+      @buf << chunk
+
+      begin
+        if @state == :prefix
+          break unless @buf.size >= 4
+          prefix = @buf.slice!(0...4)
+          @size = prefix.unpack('N')[0]
+          @state = :data
+        end
+
+        fail unless @state == :data
+        break if @buf.size < @size
+        received << Marshal.load(@buf.slice!(0...@size))
+        @state = :prefix
+      end until @buf.empty?
+
+      received
+    end
+
+    def encode o
+      data = Marshal.dump o
+      [data.size].pack('N') + data
+    end
+  end
+end
+
+class EM::P::RedwoodServer < EM::P::Redwood
+  def post_init
+    send_version ENCODINGS, []
+  end
+
+  def receive_version encodings, extensions
+    fail unless encodings.size == 1
+    fail unless ENCODINGS.member? encodings.first
+    @filter = create_filter encodings.first
+  end
+end
+
+class EM::P::RedwoodClient < EM::P::Redwood
+  def receive_version encodings, extensions
+    encoding = (ENCODINGS & encodings).first
+    fail unless encoding
+    @filter = create_filter encoding
+    send_version [encoding], []
+  end
+end
diff --git a/lib/sup/server.rb b/lib/sup/server.rb
@@ -0,0 +1,92 @@
+require 'sup/protocol'
+
+class Redwood::Server < EM::P::RedwoodServer
+  def receive_message type, tag, params
+    if respond_to? :"request_#{type}"
+      send :"request_#{type}", tag, params
+    else
+      fail "bad request type #{type}"
+    end
+  end
+
+  def request_query tag, a
+    q = Redwood::Index.parse_query a['query']
+    query q, a['offset'], a['limit'], a['raw'] do |r|
+      send_message 'message', tag, r
+    end
+    send_message 'done', tag
+  end
+
+  def request_count tag, a
+    q = Redwood::Index.parse_query a['query']
+    c = count q
+    send_message 'count', tag, 'count' => c
+  end
+
+  def request_label tag, a
+    q = Redwood::Index.parse_query a['query']
+    label q, a['add'], a['remove']
+    send_message 'done', tag
+  end
+
+  def request_add tag, a
+    add a['raw'], a['labels']
+    send_message 'done', tag
+  end
+
+private
+
+  def result_from_message m, raw
+    mkperson = lambda { |p| { :email => p.email, :name => p.name } }
+    {
+      'summary' => {
+        'message_id' => m.id,
+        'date' => m.date,
+        'from' => mkperson[m.from],
+        'to' => m.to.map(&mkperson),
+        'cc' => m.cc.map(&mkperson),
+        'bcc' => m.bcc.map(&mkperson),
+        'subject' => m.subj,
+        'refs' => m.refs,
+        'replytos' => m.replytos,
+        'labels' => m.labels.map(&:to_s),
+      },
+      'raw' => raw ? m.raw_message : nil,
+    }
+  end
+
+  def query query, offset, limit, raw
+    c = 0
+    Index.each_message query do |m|
+      next if c < offset
+      break if c >= offset + limit if limit
+      yield result_from_message(m, raw)
+      c += 1
+    end
+    nil
+  end
+
+  def count query
+    Index.num_results_for query
+  end
+
+  def label query, remove_labels, add_labels
+    Index.each_message query do |m|
+      remove_labels.each { |l| m.remove_label l }
+      add_labels.each { |l| m.add_label l }
+      Index.update_message_state m
+    end
+    nil
+  end
+
+  def add raw, labels
+    SentManager.source.store_message Time.now, "test@example.com" do |io|
+      io.write raw
+    end
+    PollManager.poll_from SentManager.source do |sym,m,old_m|
+      next unless sym == :add
+      m.labels = labels
+    end
+    nil
+  end
+end
diff --git a/lib/sup/util.rb b/lib/sup/util.rb
@@ -468,6 +468,11 @@ module Enumerable
   def max_of
     map { |e| yield e }.max
   end
+
+  ## returns all the entries which are equal to startline up to endline
+  def between startline, endline
+    select { |l| true if l == startline .. l == endline }
+  end
 end
 
 unless Object.const_defined? :Enumerator
diff --git a/protocol.md b/protocol.md
@@ -0,0 +1,168 @@
+Redwood Protocol
+================
+
+The server begins by sending a line of the form `Redwood <ver> <encodings>
+<extensions>`, where `ver` is the major protocol version (1), encodings is a
+comma-separated list of supported message encodings (e.g. `json,bert,marshal`),
+and `extensions` is a comma-separated list of protocol extensions. The server
+must advertise at least one encoding. A zero-length list of extensions is
+represented by `none`. The client replies in the same format, with the
+restrictions that the protocol version must match, `encodings` and `extensions`
+must be subsets of what the server advertised, and there must be exactly 1
+encoding specified.
+
+Requests and responses are represented as `[type, params]`, where `type`
+is a lowercase string corresponding to one of the message types specified
+below and `params` is a dictionary with string keys.
+
+Requests
+--------
+
+There may be zero or more replies to a request. Multiple requests may be
+issued concurrently. There is an implicit, optional, opaque `tag` parameter to
+every request which will be returned in all replies to the request to
+aid clients in keeping multiple requests in flight. `tag` may be an
+arbitrary datastructure and for the purposes of Cancel defaults to nil.
+
+### Query
+Send a Message response for each hit on `query` starting at `offset`
+and sending a maximum of `limit` Messages responses. `raw` controls
+whether the raw message text is included in the response.
+
+#### Parameters
+*   `query`: Query
+*   `offset`: int
+*   `limit`: int
+*   `raw`: boolean
+
+#### Responses
+*   multiple Message
+*   one Done after all Messages
+
+
+### Count
+Send a count reply with the number of hits for `query`.
+
+#### Parameters
+*   `query`: Query
+
+#### Responses
+*   one Count
+
+
+### Label
+Modify the labels on all messages matching `query`. First removes the
+labels in `remove` then adds those in `add`.
+
+#### Parameters
+*   `query`: Query
+*   `add`: string list
+*   `remove`: string list
+
+#### Responses
+*   one Done
+
+
+### Add
+Add a message to the database. `raw` is the normal RFC 2822 message text.
+
+#### Parameters
+*   `raw`: string
+*   `labels`: string list
+
+#### Responses
+*   one Done
+
+
+### Stream
+Sends a Message response whenever a new message that matches `query` is
+added with the Add request. This request will not terminate until a
+corresponding Cancel request is sent.
+
+#### Parameters
+*   `query`: Query
+
+#### Responses
+multiple Message
+
+
+### Cancel
+Cancels all active requests with tag `target`. This is only required to
+be implemented for the Stream request.
+
+#### Parameters
+*   `target`: string
+
+#### Responses
+one Done
+
+
+
+Responses
+---------
+
+### Done
+Signifies that a request has completed successfully.
+
+
+### Message
+Represents a query result. If `raw` is present it is the raw message
+text that was previously a parameter to the Add request.
+
+#### Parameters
+*   `summary`: Summary
+*   `raw`: optional string
+
+
+### Count
+`count` is the number of messages matched.
+
+#### Parameters
+*   `count`: int
+
+
+### Error
+
+#### Parameters
+*   `type`: string
+*   `message`: string
+
+
+
+Datatypes
+---------
+
+### Query
+Recursive prefix-notation datastructure describing a boolean condition.
+Where `a` and `b` are Queries and `field` and `value` are strings, a
+Query can be any of the following:
+
+*   `[:and, a, b, ...]`
+*   `[:or, a, b, ...]`
+*   `[:not, a, b]`
+*   `[:term, field, value]`
+
+
+### Summary
+*   `message_id`: string
+*   `date`: time
+*   `from`: Person
+*   `to`, `cc`, `bcc`: Person list
+*   `subject`: string
+*   `refs`: string list
+*   `replytos`: string list
+*   `labels`: string list
+
+
+### Person
+*   `name`: string
+*   `email`: string
+
+
+TODO
+----
+
+*   Protocol negotiation
+   -   Version
+   -   Compression (none, gzip, ...)
+*   Specify string encodings