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:
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