You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@whimsical.apache.org by Sam Ruby <ru...@apache.org> on 2015/12/13 16:14:56 UTC

[whimsy.git] [36/37] Commit afc1aef: prep for merge

Commit afc1aefa9fdb0637901968949fd5d5c56dcc6d7e:
    prep for merge


Branch: refs/heads/secmail
Author: Sam Ruby <ru...@intertwingly.net>
Committer: Sam Ruby <ru...@intertwingly.net>
Pusher: rubys <ru...@apache.org>

------------------------------------------------------------
.gitignore                                                   | +++ ---
www/secmail/Gemfile                                          |  
www/secmail/README                                           |  
www/secmail/Rakefile                                         |  
www/secmail/config.rb                                        |  
www/secmail/config.ru                                        |  
www/secmail/mailbox.rb                                       |  
www/secmail/parsemail.rb                                     |  
www/secmail/public/secmail.css                               |  
www/secmail/server.rb                                        |  
www/secmail/views/app.js.rb                                  |  
www/secmail/views/body.html.rb                               |  
www/secmail/views/headers.html.rb                            |  
www/secmail/views/http.js.rb                                 |  
www/secmail/views/index.html.rb                              |  
www/secmail/views/index.js.rb                                |  
www/secmail/views/index.json.rb                              |  
www/secmail/views/message.html.rb                            |  
www/secmail/views/parts.html.rb                              |  
www/secmail/views/parts.js.rb                                |  
------------------------------------------------------------
6 changes: 3 additions, 3 deletions.
------------------------------------------------------------


diff --git a/.gitignore b/.gitignore
index 9eaab43..bc007ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,3 @@
-Gemfile.lock
-officers-secretary
-assets
+www/secmail/Gemfile.lock
+www/secmail/officers-secretary
+www/secmail/public/assets
diff --git a/Gemfile b/Gemfile
deleted file mode 100644
index 5bd4995..0000000
--- a/Gemfile
+++ /dev/null
@@ -1,16 +0,0 @@
-source 'https://rubygems.org'
-
-gem 'mail'
-gem 'rake'
-gem 'zip'
-gem 'whimsy-asf'
-gem 'sinatra'
-gem 'sanitize'
-gem 'wunderbar', '~> 1.0.10'
-gem 'ruby2js', '~> 2.0.10'
-gem 'execjs'
-
-group :demo do
-  gem 'listen'
-  gem 'puma'
-end
diff --git a/README b/README
deleted file mode 100644
index 0d100e0..0000000
--- a/README
+++ /dev/null
@@ -1,65 +0,0 @@
-This directory contains a script that fetches and parsed secretary emails
-and a server that will enable exploration of those emails.
-
-Usage:
-
-  rake fetch
-  rake server
-
-Notes:
-
-  First fetch and parse will take approximately an hour, even with a relatively
-  fast machine and internet connection.  Subsequent fetches will take as
-  little as ten seconds or less.  (For the impatient: replace 'fetch' with
-  'fetch1' and you will only get the latest month.  At some later point,
-  running 'rake fetch' will fetch the remaining months as well as any new
-  emails that have arrived in the current month).
-
-  Secretary email archive currently requires about approximately 11 Gigabytes.
-
-Overview of files:
-
-  Gemfile:            Ruby configuration (installation of gems)
-  Rakefile:           Command line configuration (like 'make')
-  config.rb:          Customizations
-  config.ru:          Rack (webserver) configuration
-  mailbox.rb:         Encapsulate interface to wb server
-  officers-secretary: local copy of mailboxes and indexes (in YAML format)
-  parsemail.rb:       Fetch and parse emails
-  server.rb:          Web interface to emails
-  views:              HTML templates (in Wunderbar format)
-
-Overview of control flow:
-
-  server.rb:          Matches HTTP requests to methods and paths, and runs
-                      the associated code.  This code will either return a
-                      result directly (rare) or invoke a view using a method
-                      name starting with an underscore.  For more
-                      information, see:
-
-                      http://www.sinatrarb.com/documentation.html
-                      https://github.com/rubys/wunderbar/#readme
-
-  views:              Files in the views directory have two extensions, the
-                      first identifies the target type (html, json, js), the
-                      second indicates the language of the view (rb).
-
-    html views:       Method names that start with an underscore generate HTML.
-                      This HTML may pull in scripts, stylesheets, and have
-                      inline code that renders other views.  
-
-    js views:         This code is converted from Ruby to JavaScript.
-                      This conversion is aware of React.js and will perform
-                      additional React.js mappings when it encounters classes
-                      that derive from "React".  See
-
-                      https://facebook.github.io/react/docs/component-specs.html
-                      https://github.com/rubys/ruby2js#readme
-
-                      Note: in this application app.js.rb pulls in all of the
-                      other javascript files and returns the result as a
-                      single file.
-
-    json views:       The last statement identifies an object (typically a
-                      hash or array) that will be converted to JSON and sent
-                      back as a response.
diff --git a/Rakefile b/Rakefile
deleted file mode 100644
index 33e2fc8..0000000
--- a/Rakefile
+++ /dev/null
@@ -1,42 +0,0 @@
-require_relative 'config'
-
-verbose false
-
-task :default do
-  puts 'Usage:'
-  sh 'rake', '-T'
-end
-
-file 'Gemfile.lock' => 'Gemfile' do
-  sh 'bundle update'
-  touch 'Gemfile.lock'
-end
-
-desc 'install dependencies'
-task :bundle => 'Gemfile.lock'
-
-desc 'Parse emails'
-task :parse => :bundle do
-  ruby 'parsemail.rb'
-end
-
-desc 'Fetch and parse emails'
-task :fetch => :bundle do
-  ruby 'parsemail.rb', '--fetch'
-end
-
-desc 'Fetch parse latest month only'
-task :fetch1 => :bundle do
-  ruby 'parsemail.rb', '--fetch1'
-end
-
-desc 'WebServer that provides an interface to explore emails'
-task :server => :bundle do
-  ENV['RACK_ENV']='production'
-  require 'wunderbar/listen'
-end
-
-desc 'remove all parsed yaml files'
-task :clean do
-  rm_rf Dir["#{ARCHIVE}/*.yml"]
-end
diff --git a/config.rb b/config.rb
deleted file mode 100644
index 39d6199..0000000
--- a/config.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-#
-# Where to find the archive
-#
-
-SOURCE = 'minotaur.apache.org:/home/apmail/private-arch/officers-secretary'
-
-#
-# Where to save the archive locally
-#
-
-ARCHIVE = File.basename(SOURCE)
-
diff --git a/config.ru b/config.ru
deleted file mode 100644
index cd11ae0..0000000
--- a/config.ru
+++ /dev/null
@@ -1,5 +0,0 @@
-require File.expand_path('../server.rb', __FILE__)
-
-require 'whimsy/asf/rack'
-
-run Sinatra::Application
diff --git a/mailbox.rb b/mailbox.rb
deleted file mode 100644
index d914f12..0000000
--- a/mailbox.rb
+++ /dev/null
@@ -1,148 +0,0 @@
-#
-# Encapsulate access to mailboxes
-#
-
-require 'digest'
-require 'zlib'
-require 'zip'
-require 'stringio'
-require 'mail'
-
-require_relative 'config.rb'
-
-class Mailbox
-  #
-  # Initialize a mailbox
-  #
-  def initialize(name)
-    name = File.basename(name, '.yml') if name.end_with? '.yml'
-
-    if name =~ /^\d+$/
-      @filename = Dir["#{ARCHIVE}/#{name}", "#{ARCHIVE}/#{name}.gz"].first.
-        untaint
-    else
-      @filename = name
-    end
-  end
-
-  #
-  # encapsulate updates to a mailbox
-  #
-  def self.update(name)
-    yaml = Mailbox.new(name).yaml_file
-    File.open(yaml, File::RDWR|File::CREAT, 0644) do |file| 
-      file.flock(File::LOCK_EX)
-      mbox = YAML.load(file.read) || {} rescue {}
-      yield mbox
-      file.rewind
-      file.write YAML.dump(mbox)
-      file.truncate(file.pos)
-    end
-  end
-
-  #
-  # Determine whether or not the mailbox exists
-  #
-  def exist?
-    @filename and File.exist?(@filename)
-  end
-
-  #
-  # Read a mailbox and split it into messages
-  #
-  def messages
-    return @messages if @messages
-    return [] unless exist?
-
-    mbox = File.read(@filename)
-
-    if @filename.end_with? '.gz'
-      stream = StringIO.new(mbox)
-      reader = Zlib::GzipReader.new(stream)
-      mbox = reader.read
-      reader.close
-      stream.close rescue nil
-    end
-
-    mbox.force_encoding Encoding::ASCII_8BIT
-
-    # split into individual messages
-    @messages = mbox.split(/^From .*/)
-    @messages.shift
-
-    @messages
-  end
-
-  #
-  # Find a message
-  #
-  def find(hash)
-    message = messages.find {|message| Mailbox.hash(message) == hash}
-    Mail.new(message) if message
-  end
-
-  #
-  # iterate through messages
-  #
-  def each(&block)
-    messages.each(&block)
-  end
-
-  #
-  # name of associated yaml file
-  #
-  def yaml_file
-    source = File.basename(@filename, '.gz').untaint
-    "#{ARCHIVE}/#{source}.yml"
-  end
-
-  #
-  # return headers
-  #
-  def headers
-    source = File.basename(@filename, '.gz').untaint
-    messages = YAML.load_file(yaml_file) rescue {}
-    messages.delete :mtime
-    messages.each do |key, value|
-      value[:source]=source
-    end
-  end
-
-  #
-  # What to use as a hash for mail
-  #
-  def self.hash(message)
-    Digest::SHA1.hexdigest(message[/^Message-ID:.*/i] || message)[0..9]
-  end
-
-  #
-  # common header logic for messages and attachments
-  #
-  def self.headers(part)
-    # extract all fields from the mail (recovering from bad encoding issues)
-    fields = part.header_fields.map do |field|
-      begin
-        next [field.name, field.to_s] if field.to_s.valid_encoding?
-      rescue
-      end
-
-      if field.value and field.value.valid_encoding?
-        [field.name, field.value]
-      else
-        [field.name, field.value.inspect]
-      end
-    end
-
-    # group fields by name
-    fields = fields.group_by(&:first).map do |name, values|
-      if values.length == 1
-        [name, values.first.last]
-      else
-        [name, values.map(&:last)]
-      end
-    end
-
-    # return fields as a Hash
-    Hash[fields]
-  end
-end
diff --git a/parsemail.rb b/parsemail.rb
deleted file mode 100644
index c2821d9..0000000
--- a/parsemail.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-#!/usr/bin/ruby
-
-#
-# Parse (and optionally fetch) officer-secretary emails for later
-# processing.
-#
-# Care is taken to recover from improperly formed emails, including:
-#   * Malformed message ids
-#   * Improper encoding
-#   * Invalid from addresses
-#
-
-require 'yaml'
-require 'time'
-
-require_relative 'mailbox'
-
-database = File.basename(SOURCE)
-
-Dir.chdir File.dirname(File.expand_path(__FILE__))
-
-if ARGV.include? '--fetch' or not Dir.exist? database
-  system "rsync -av --no-motd --delete --exclude='*.yml' #{SOURCE}/ #{ARCHIVE}/"
-elsif ARGV.include? '--fetch1'
-  month = Time.now.strftime('%Y%m')
-  system "rsync -av --no-motd #{SOURCE}/#{month} #{ARCHIVE}/"
-end
-
-# scan each mailbox for updates
-width = 0
-Dir[File.join(database, '2*')].sort.each do |name|
-  # skip YAML files, update output showing latest file being processed
-  next if name.end_with? '.yml'
-  next if ARGV.include? '--fetch1'  and not name.include? "/#{month}"
-  print "#{name.ljust(width)}\r"
-  width = name.length
-  
-  # test read the YAML file to see if the mbox needs to be parsed
-  yaml = File.join(database, File.basename(name)[/\d+/] + '.yml')
-  mbox = YAML.load_file(yaml) || {} rescue {}
-  next if mbox[:mtime] == File.mtime(name)
-
-  # open the YAML file for real (locking it this time)
-  Mailbox.update(name) do |mbox|
-    mbox[:mtime] = File.mtime(name)
-
-    # read (and unzip) the mailbox
-    messages = Mailbox.new(name)
-
-    # process each
-    messages.each do |message|
-      # compute id, skip if already processed
-      id = Mailbox.hash(message)
-      next if mbox[id]
-      mail = Mail.read_from_string(message)
-
-      # parse from address
-      begin
-        from = Mail::Address.new(mail[:from].value).display_name
-      rescue Exception
-        from = mail[:from].value
-      end
-
-      # determine who should be copied on any responses
-      cc = []
-      cc = mail[:to].value.split(/,\s*/)  if mail[:to]
-      cc += mail[:cc].value.split(/,\s*/) if mail[:cc]
-
-      # remove secretary and anybody on the to field from the cc list
-      cc.reject! do |email|
-        begin
-          address = Mail::Address.new(email).address
-          return true if address == 'secretary@apache.org'
-          return true if mail.from_addrs.include? address
-        rescue Exception
-          true
-        end
-      end
-
-      # start an entry for this mail
-      mbox[id] = {
-        from: mail.from_addrs.first,
-        name: from,
-        time: (mail.date.to_time.gmtime.iso8601 rescue nil),
-        cc: cc
-      }
-
-      # add in header fields
-      mbox[id].merge! Mailbox.headers(mail)
-
-      # add in attachments
-      if mail.attachments.length > 0
-        attachments = mail.attachments.map do |attach|
-          description = {
-            name: attach.filename,
-            length: attach.body.to_s.length,
-            mime: attach.mime_type
-          }
-
-          if description[:name].empty? and attach['Content-ID']
-            description[:name] = attach['Content-ID'].to_s
-          end
-
-          description.merge(Mailbox.headers(attach))
-        end
-
-        mbox[id][:attachments] = attachments
-      end
-    end
-  end
-end
-
-puts
diff --git a/public/secmail.css b/public/secmail.css
deleted file mode 100644
index c904fe5..0000000
--- a/public/secmail.css
+++ /dev/null
@@ -1,46 +0,0 @@
-#index td:nth-child(2), #index th:nth-child(2) {
-  padding-right: 7px;
-  padding-left: 7px;
-}
-
-.selected {
-  background-color: yellow;
-}
-
-.deleted {
-  opacity: 0.5;
-}
-
-.contextMenu {
-  position: absolute;
-  z-index: 9999999;
-  border: solid 1px #CCC;
-  background: #EEE;
-  padding: 0px;
-  margin: 0px;
-  display: none;
-  background-repeat: no-repeat;
-}
-
-ul.contextMenu {
-  padding: 8px;
-}
-
-.contextMenu li {
-  list-style: none;
-  padding: 0px;
-  margin: 0px;
-}
-
-.contextMenu a {
-  color: #333;
-  text-decoration: none;
-  display: block;
-  line-height: 20px;
-  height: 20px;
-  background-position: 6px center;
-  background-repeat: no-repeat;
-  outline: none;
-  padding: 1px 5px;
-  padding-left: 28px;
-}
diff --git a/server.rb b/server.rb
deleted file mode 100644
index 403db29..0000000
--- a/server.rb
+++ /dev/null
@@ -1,106 +0,0 @@
-#
-# Simple web server that routes requests to views based on URLs.
-#
-
-require 'wunderbar/sinatra'
-require 'wunderbar/bootstrap'
-require 'wunderbar/react'
-require 'ruby2js/filter/functions'
-require 'ruby2js/filter/require'
-require 'sanitize'
-
-require_relative 'mailbox'
-
-# list of messages
-get '/' do
-  # determine latest month for which there are messages
-  @mbox = File.basename(Dir["#{ARCHIVE}/*.yml"].sort.last, '.yml')
-  _html :index
-end
-
-# support for fetching previous month's worth of messages
-post '/' do
-  _json :index
-end
-
-# retrieve a single message
-get %r{^/(\d+)/(\w+)/$} do |month, hash|
-  @message = Mailbox.new(month).headers[hash]
-  pass unless @message
-  _html :message
-end
-
-# mark a single message as deleted
-delete %r{^/(\d+)/(\w+)/$} do |month, hash|
-  success = false
-
-  Mailbox.update(month) do |headers|
-    if headers[hash]
-      headers[hash][:status] = :deleted
-      success = true
-    end
-  end
-
-  pass unless success
-  _json success: true
-end
-
-# update a single message
-patch %r{^/(\d+)/(\w+)/$} do |month, hash|
-  success = false
-
-  Mailbox.update(month) do |headers|
-    if headers[hash]
-      updates = JSON.parse(request.env['rack.input'].read)
-
-      # special processing for entries with symbols as keys
-      headers[hash].each do |key, value|
-        if Symbol === key and updates.has_key? key.to_s
-          headers[hash][key] = updates.delete(key.to_s)
-        end
-      end
-
-      headers[hash].merge! updates
-      success = true
-    end
-  end
-
-  pass unless success
-  [204, {}, '']
-end
-
-# list of parts for a single message
-get %r{^/(\d+)/(\w+)/_index_$} do |month, hash|
-  @message = Mailbox.new(month).headers[hash]
-  pass unless @message
-  @attachments = @message[:attachments]
-  _html :parts
-end
-
-# message body for a single message
-get %r{^/(\d+)/(\w+)/_body_$} do |month, hash|
-  @message = Mailbox.new(month).find(hash)
-  pass unless @message
-  _html :body
-end
-
-# header data for a single message
-get %r{^/(\d+)/(\w+)/_headers_$} do |month, hash|
-  @headers = Mailbox.new(month).headers[hash]
-  pass unless @headers
-  _html :headers
-end
-
-# a specific attachment for a message
-get %r{^/(\d+)/(\w+)/(.*?)$} do |month, hash, name|
-  message = Mailbox.new(month).find(hash)
-  pass unless message
-
-  part = message.attachments.find do |attach| 
-    attach.filename == name or attach['Content-ID'].to_s == name
-  end
-
-  pass unless part
-
-  [200, {'Content-Type' => part.content_type}, part.body.to_s]
-end
diff --git a/views/app.js.rb b/views/app.js.rb
deleted file mode 100644
index 5906d9c..0000000
--- a/views/app.js.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-require_relative 'http'
-
-require_relative 'index'
-require_relative 'parts'
diff --git a/views/body.html.rb b/views/body.html.rb
deleted file mode 100644
index 1b8608c..0000000
--- a/views/body.html.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-#
-# View the email content, without attachments
-#
-
-_html do
-  #
-  # Selected headers
-  #
-  _table do
-    _tr do
-      _td 'From:'
-      _td @message[:from]
-    end
-
-    _tr do
-      _td 'To:'
-      _td @message[:to]
-    end
-
-    if @message[:cc]
-      _tr do
-        _td 'Cc:'
-        _td @message[:cc]
-      end
-    end
-
-    _tr do
-      _td 'Subject:'
-      _td @message.subject || '(empty)'
-    end
-  end
-
-  _p
-
-  #
-  # Try various ways to display the body
-  #
-  if @message.html_part
-    _div do
-      body = @message.html_part.body.to_s
-
-      if body.to_s.encoding == Encoding::BINARY and @message.html_part.charset
-        body.force_encoding(@message.html_part.charset)
-      end
-
-      _{body.encode('utf-8', invalid: :replace, undef: :replace)}
-    end
-  elsif @message.text_part
-    body = @message.text_part.body.to_s
-
-    if body.to_s.encoding == Encoding::BINARY and @message.text_part.charset
-      body.force_encoding(@message.text_part.charset)
-    end
-
-    _pre body.encode('utf-8', invalid: :replace, undef: :replace)
-  end
-end
diff --git a/views/headers.html.rb b/views/headers.html.rb
deleted file mode 100644
index aa499ae..0000000
--- a/views/headers.html.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-#
-# Dump headers
-#
-
-_html do
-  _pre YAML.dump(@headers)
-end
diff --git a/views/http.js.rb b/views/http.js.rb
deleted file mode 100644
index d552344..0000000
--- a/views/http.js.rb
+++ /dev/null
@@ -1,143 +0,0 @@
-#
-# Encapsulations for asynchronous HTTP requests.  Uses older XMLHttpRequest
-# API over fetch as fetch isn't widely supported yet:
-# https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#Browser_compatibility
-#
-
-
-class HTTP
-  # "AJAX" style post request to the server, with a callback
-  def self.post(target, data, &block)
-    xhr = XMLHttpRequest.new()
-    xhr.open('POST', target, true)
-    xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
-    xhr.responseType = 'text'
-
-    def xhr.onreadystatechange()
-      if xhr.readyState == 4
-        data = nil
-
-        begin
-          if xhr.status == 200
-            data = JSON.parse(xhr.responseText) 
-            alert "Exception\n#{data.exception}" if data.exception
-          else
-            HTTP._log(xhr)
-          end
-        rescue => e
-          console.log(e)
-        end
-
-        block(data)
-      end
-    end
-
-    xhr.send(JSON.stringify(data))
-  end
-
-  # "AJAX" style patch request to the server, with a callback
-  def self.patch(target, data, &block)
-    xhr = XMLHttpRequest.new()
-    xhr.open('PATCH', target, true)
-    xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
-    xhr.responseType = 'text'
-
-    def xhr.onreadystatechange()
-      if xhr.readyState == 4
-        data = nil
-
-        begin
-          if xhr.status == 200
-            data = JSON.parse(xhr.responseText) 
-            alert "Exception\n#{data.exception}" if data.exception
-          else
-            HTTP._log(xhr)
-          end
-        rescue => e
-          console.log(e)
-        end
-
-        block(data)
-      end
-    end
-
-    xhr.send(JSON.stringify(data))
-  end
-
-  # "AJAX" style delete request to the server, with a callback
-  def self.delete(target, &block)
-    xhr = XMLHttpRequest.new()
-    xhr.open('DELETE', target, true)
-
-    def xhr.onreadystatechange()
-      if xhr.readyState == 4
-
-        begin
-          if xhr.status == 404
-            alert "Not Found: #{target}"
-          else
-            HTTP._log(xhr)
-          end
-        rescue => e
-          console.log(e)
-        end
-
-        block()
-      end
-    end
-
-    xhr.send()
-  end
-
-  # "AJAX" style get request to the server, with a callback
-  def self.get(target, type, &block)
-    xhr = XMLHttpRequest.new()
-
-    def xhr.onreadystatechange()
-      if xhr.readyState == 4
-        data = nil
-
-        begin
-          if xhr.status == 200
-            if type == :json
-              data = xhr.response || JSON.parse(xhr.responseText) 
-            else
-              data = xhr.responseText
-            end
-          else
-            HTTP._log(xhr)
-          end
-        rescue => e
-          console.log(e)
-        end
-
-        block(data)
-      end
-    end
-
-    if target =~ /^https?:/
-      xhr.open('GET', target, true)
-      xhr.setRequestHeader("Accept", "application/json") if type == :json
-    else
-      xhr.open('GET', "../#{type}/#{target}", true)
-    end
-    xhr.responseType = type
-    xhr.send()
-  end
-
-  # common logging
-  def self._log(xhr)
-    if xhr.status == 404
-      alert "Not Found: #{target}"
-    elsif xhr.status >= 400
-      console.log(xhr.response)
-      if not xhr.response
-        alert "Exception - #{xhr.statusText}"
-      elsif xhr.response.exception
-        alert "Exception\n#{xhr.response.exception}"
-      else
-        alert "Exception\n#{JSON.parse(xhr.responseText).exception}"
-      end
-    end
-  end
-end
diff --git a/views/index.html.rb b/views/index.html.rb
deleted file mode 100644
index 51ecb2f..0000000
--- a/views/index.html.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-_html do
-  _link rel: 'stylesheet', type: 'text/css', href: 'secmail.css'
-
-  _div.index!
-
-  _script src: 'app.js'
-  _.render '#index' do
-    _Index mbox: @mbox
-  end
-end
diff --git a/views/index.js.rb b/views/index.js.rb
deleted file mode 100644
index 1268941..0000000
--- a/views/index.js.rb
+++ /dev/null
@@ -1,167 +0,0 @@
-#
-# Index page showing unprocessed messages with attachments
-#
-
-class Index < React
-  def initialize
-    @selected = nil
-    @messages = []
-    @undoStack = []
-  end
-
-  def render
-    _table do
-      _thead do
-        _tr do
-          _th 'Timestamp'
-          _th 'From'
-          _th 'Subject'
-        end
-      end
-
-      _tbody do
-        @messages.each do |message|
-
-          # determine the 'color' to use for the row
-          color = nil
-          color = 'deleted' if message.status == :deletePending
-          color = 'hidden' if message.status == :deleted
-          color = 'selected' if message.href == @selected
-
-          _tr class: color, onClick: self.selectRow, onDoubleClick: self.nav do
-            _td do
-              _a message.time, href: "#{message.href}"
-            end 
-            _td message.from
-            _td message.subject
-          end
-        end
-      end
-    end
-
-    _input.btn.btn_primary type: 'submit', value: 'fetch previous month',
-      onClick: self.fetch_month
-
-    unless @undoStack.empty?
-      _input.btn.btn_info type: 'submit', value: 'undo delete',
-        onClick: self.undo
-    end
-  end
-
-  # initialize latest mailbox (year+month)
-  def componentWillMount()
-    @latest = @@mbox
-  end
-
-  # on initial load, fetch latest mailbox and subscribe to keyboard events
-  def componentDidMount()
-    self.fetch_month()
-    window.onkeydown = self.keydown
-  end
-
-  # when content changes, ensure selected message is visible
-  def componentDidUpdate()
-    if @selected
-      selected = document.querySelector("a[href='#{@selected}']")
-      if selected
-        rect = selected.getBoundingClientRect()
-        if
-          rect.top < 0 or rect.left < 0 or 
-          rect.bottom > window.innerHeight or rect.right > window.innerWidth
-        then
-          selected.scrollIntoView()
-        end
-      end
-    end
-  end
-
-  # fetch a month's worth of messages
-  def fetch_month()
-    HTTP.post('', mbox: @latest) do |response|
-      # update latest mbox
-      @latest = response.mbox if response.mbox
-
-      # add messages to list
-      @messages = @messages.concat(*response.messages)
-
-      # select oldest message
-      @selected = @messages.last.href
-    end
-  end
-
-  # select
-  def selectRow(event)
-    @selected = event.currentTarget.querySelector('a').getAttribute('href')
-  end
-
-  # navigate
-  def nav(event)
-    @selected = event.currentTarget.querySelector('a').getAttribute('href')
-    window.location.href = @selected
-    window.getSelection().removeAllRanges()
-    event.preventDefault()
-  end
-
-  def undo(event)
-    @selected = @undoStack.pop()
-    selected = @messages.find {|m| return m.href == @selected}
-    if selected
-      selected.status = :deletePending
-
-      # send request to server to remove delete status
-      HTTP.patch(@selected, status: nil) do
-        delete selected.status
-        self.forceUpdate()
-      end
-    end
-  end
-
-  # handle keyboard events
-  def keydown(event)
-    if event.keyCode == 38 # up
-      index = @messages.findIndex {|m| return m.href == @selected}
-      @selected = @messages[index-1].href if index > 0
-      event.preventDefault()
-
-    elsif event.keyCode == 40 # down
-      index = @messages.findIndex {|m| return m.href == @selected}
-      @selected = @messages[index+1].href if index < @messages.length-1
-      event.preventDefault()
-
-    elsif event.keyCode == 13 or event.keyCode == 39 # enter/return or right
-      selected = @messages.find {|m| return m.href == @selected}
-      window.location.href = selected.href if selected
-
-    elsif event.keyCode == 8 or event.keyCode == 46 # backspace or delete
-      event.preventDefault()
-      # mark item as delete pending
-      selected = @selected
-      index = @messages.findIndex {|m| return m.href == selected}
-      @messages[index].status = :deletePending if index >= 0
-
-      # move selected pointer
-      if index > 0
-        @selected = @messages[index-1].href
-      elsif index < @messages.length - 1
-        @selected = @messages[index+1].href
-      else
-        @selected = nil
-      end
-
-      # send request to server to perform delete
-      HTTP.delete(selected) do
-        index = @messages.findIndex {|m| return m.href == selected}
-        @messages[index].status = :deleted if index >= 0
-        @undoStack << selected
-        self.forceUpdate()
-      end
-
-    elsif event.keyCode == 'Z'.ord
-      if event.ctrlKey or event.metaKey
-        self.undo() unless @undoStack.empty?
-      end
-    else
-      console.log "keydown: #{event.keyCode}"
-    end
-  end
-end
diff --git a/views/index.json.rb b/views/index.json.rb
deleted file mode 100644
index f373acf..0000000
--- a/views/index.json.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-if @mbox =~ /^\d+$/
-  # find indicated mailbox in the list of available mailboxes
-  available = Dir["#{ARCHIVE}/*.yml"].sort
-  index = available.find_index "#{ARCHIVE}/#{@mbox}.yml"
-
-  # if found, process it
-  if index
-    # fetch a list of headers for all messages in the maibox with attachments
-    headers = Mailbox.new(@mbox).headers.to_a.select do |id, message|
-      message[:attachments] and not message[:status] == :deleted
-    end
-
-    # extract relevant fields from the headers
-    headers.map! do |id, message|
-      {
-        time: message[:time],
-        href: "#{message[:source]}/#{id}/",
-        from: message[:from],
-        subject: message['Subject']
-      }
-    end
-
-    # select previous mailbox
-    mbox = available[index-1].untaint
-
-    # return mailbox name and messages
-    {
-      mbox: File.basename(mbox, '.yml'),
-      messages: headers.sort_by {|message| message[:time]}.reverse
-    }
-  end
-end
diff --git a/views/message.html.rb b/views/message.html.rb
deleted file mode 100644
index 079e4f4..0000000
--- a/views/message.html.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-#
-# Layout for viewing an individual message
-#
-
-_html do
-  _title 'ASF Secretary Workbench'
-
-  _frameset cols: '20%, 70%' do
-    _frame src: '_index_'
-    _frame name: 'content', src: '_body_'
-  end
-end
diff --git a/views/parts.html.rb b/views/parts.html.rb
deleted file mode 100644
index a55d1d2..0000000
--- a/views/parts.html.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-#
-# Display the list of parts for a given message
-#
-
-_html do
-  _link rel: 'stylesheet', type: 'text/css', href: '../../secmail.css'
-
-  _ul_ do
-    _li! {_a 'text', href: '_body_', target: 'content'}
-    _li! {_a 'headers', href: '_headers_', target: 'content'}
-  end
-
-  _div.attachments!
-
-  _script src: '../../app.js'
-  _.render '#attachments' do
-    _Parts attachments: @attachments
-  end
-end
diff --git a/views/parts.js.rb b/views/parts.js.rb
deleted file mode 100644
index 5851fa8..0000000
--- a/views/parts.js.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-class Parts < React
-  def initialize
-    @selected = nil
-  end
-
-  def render
-    _ul @@attachments do |attachment|
-      _li onContextMenu: self.menu do
-        _a attachment.name, href: attachment.name, target: 'content'
-      end
-    end
-
-    _ul.contextMenu ref: 'menu' do
-      _li 'burst'
-      _li 'file'
-      _li 'delete'
-    end
-  end
-
-  def componentDidMount()
-    $menu.style.display = :none
-    window.onmousedown = self.click
-  end
-
-  # position and show context menu
-  def menu(event)
-    @selected = event.currentTarget.textContent
-    $menu.style.left = event.clientX + 'px'
-    $menu.style.top = event.clientY + 'px'
-    $menu.style.position = :absolute
-    $menu.style.display = :block
-    event.preventDefault()
-  end
-
-  # hide context menu whenever a click is received outside the menu
-  def click(event)
-    target = event.target
-    while target
-      return if target.class == 'contextMenu'
-      target = target.parentNode
-    end
-    $menu.style.display = :none
-  end
-end
diff --git a/www/secmail/Gemfile b/www/secmail/Gemfile
new file mode 100644
index 0000000..5bd4995
--- /dev/null
+++ b/www/secmail/Gemfile
@@ -0,0 +1,16 @@
+source 'https://rubygems.org'
+
+gem 'mail'
+gem 'rake'
+gem 'zip'
+gem 'whimsy-asf'
+gem 'sinatra'
+gem 'sanitize'
+gem 'wunderbar', '~> 1.0.10'
+gem 'ruby2js', '~> 2.0.10'
+gem 'execjs'
+
+group :demo do
+  gem 'listen'
+  gem 'puma'
+end
diff --git a/www/secmail/README b/www/secmail/README
new file mode 100644
index 0000000..0d100e0
--- /dev/null
+++ b/www/secmail/README
@@ -0,0 +1,65 @@
+This directory contains a script that fetches and parsed secretary emails
+and a server that will enable exploration of those emails.
+
+Usage:
+
+  rake fetch
+  rake server
+
+Notes:
+
+  First fetch and parse will take approximately an hour, even with a relatively
+  fast machine and internet connection.  Subsequent fetches will take as
+  little as ten seconds or less.  (For the impatient: replace 'fetch' with
+  'fetch1' and you will only get the latest month.  At some later point,
+  running 'rake fetch' will fetch the remaining months as well as any new
+  emails that have arrived in the current month).
+
+  Secretary email archive currently requires about approximately 11 Gigabytes.
+
+Overview of files:
+
+  Gemfile:            Ruby configuration (installation of gems)
+  Rakefile:           Command line configuration (like 'make')
+  config.rb:          Customizations
+  config.ru:          Rack (webserver) configuration
+  mailbox.rb:         Encapsulate interface to wb server
+  officers-secretary: local copy of mailboxes and indexes (in YAML format)
+  parsemail.rb:       Fetch and parse emails
+  server.rb:          Web interface to emails
+  views:              HTML templates (in Wunderbar format)
+
+Overview of control flow:
+
+  server.rb:          Matches HTTP requests to methods and paths, and runs
+                      the associated code.  This code will either return a
+                      result directly (rare) or invoke a view using a method
+                      name starting with an underscore.  For more
+                      information, see:
+
+                      http://www.sinatrarb.com/documentation.html
+                      https://github.com/rubys/wunderbar/#readme
+
+  views:              Files in the views directory have two extensions, the
+                      first identifies the target type (html, json, js), the
+                      second indicates the language of the view (rb).
+
+    html views:       Method names that start with an underscore generate HTML.
+                      This HTML may pull in scripts, stylesheets, and have
+                      inline code that renders other views.  
+
+    js views:         This code is converted from Ruby to JavaScript.
+                      This conversion is aware of React.js and will perform
+                      additional React.js mappings when it encounters classes
+                      that derive from "React".  See
+
+                      https://facebook.github.io/react/docs/component-specs.html
+                      https://github.com/rubys/ruby2js#readme
+
+                      Note: in this application app.js.rb pulls in all of the
+                      other javascript files and returns the result as a
+                      single file.
+
+    json views:       The last statement identifies an object (typically a
+                      hash or array) that will be converted to JSON and sent
+                      back as a response.
diff --git a/www/secmail/Rakefile b/www/secmail/Rakefile
new file mode 100644
index 0000000..33e2fc8
--- /dev/null
+++ b/www/secmail/Rakefile
@@ -0,0 +1,42 @@
+require_relative 'config'
+
+verbose false
+
+task :default do
+  puts 'Usage:'
+  sh 'rake', '-T'
+end
+
+file 'Gemfile.lock' => 'Gemfile' do
+  sh 'bundle update'
+  touch 'Gemfile.lock'
+end
+
+desc 'install dependencies'
+task :bundle => 'Gemfile.lock'
+
+desc 'Parse emails'
+task :parse => :bundle do
+  ruby 'parsemail.rb'
+end
+
+desc 'Fetch and parse emails'
+task :fetch => :bundle do
+  ruby 'parsemail.rb', '--fetch'
+end
+
+desc 'Fetch parse latest month only'
+task :fetch1 => :bundle do
+  ruby 'parsemail.rb', '--fetch1'
+end
+
+desc 'WebServer that provides an interface to explore emails'
+task :server => :bundle do
+  ENV['RACK_ENV']='production'
+  require 'wunderbar/listen'
+end
+
+desc 'remove all parsed yaml files'
+task :clean do
+  rm_rf Dir["#{ARCHIVE}/*.yml"]
+end
diff --git a/www/secmail/config.rb b/www/secmail/config.rb
new file mode 100644
index 0000000..39d6199
--- /dev/null
+++ b/www/secmail/config.rb
@@ -0,0 +1,12 @@
+#
+# Where to find the archive
+#
+
+SOURCE = 'minotaur.apache.org:/home/apmail/private-arch/officers-secretary'
+
+#
+# Where to save the archive locally
+#
+
+ARCHIVE = File.basename(SOURCE)
+
diff --git a/www/secmail/config.ru b/www/secmail/config.ru
new file mode 100644
index 0000000..cd11ae0
--- /dev/null
+++ b/www/secmail/config.ru
@@ -0,0 +1,5 @@
+require File.expand_path('../server.rb', __FILE__)
+
+require 'whimsy/asf/rack'
+
+run Sinatra::Application
diff --git a/www/secmail/mailbox.rb b/www/secmail/mailbox.rb
new file mode 100644
index 0000000..d914f12
--- /dev/null
+++ b/www/secmail/mailbox.rb
@@ -0,0 +1,148 @@
+#
+# Encapsulate access to mailboxes
+#
+
+require 'digest'
+require 'zlib'
+require 'zip'
+require 'stringio'
+require 'mail'
+
+require_relative 'config.rb'
+
+class Mailbox
+  #
+  # Initialize a mailbox
+  #
+  def initialize(name)
+    name = File.basename(name, '.yml') if name.end_with? '.yml'
+
+    if name =~ /^\d+$/
+      @filename = Dir["#{ARCHIVE}/#{name}", "#{ARCHIVE}/#{name}.gz"].first.
+        untaint
+    else
+      @filename = name
+    end
+  end
+
+  #
+  # encapsulate updates to a mailbox
+  #
+  def self.update(name)
+    yaml = Mailbox.new(name).yaml_file
+    File.open(yaml, File::RDWR|File::CREAT, 0644) do |file| 
+      file.flock(File::LOCK_EX)
+      mbox = YAML.load(file.read) || {} rescue {}
+      yield mbox
+      file.rewind
+      file.write YAML.dump(mbox)
+      file.truncate(file.pos)
+    end
+  end
+
+  #
+  # Determine whether or not the mailbox exists
+  #
+  def exist?
+    @filename and File.exist?(@filename)
+  end
+
+  #
+  # Read a mailbox and split it into messages
+  #
+  def messages
+    return @messages if @messages
+    return [] unless exist?
+
+    mbox = File.read(@filename)
+
+    if @filename.end_with? '.gz'
+      stream = StringIO.new(mbox)
+      reader = Zlib::GzipReader.new(stream)
+      mbox = reader.read
+      reader.close
+      stream.close rescue nil
+    end
+
+    mbox.force_encoding Encoding::ASCII_8BIT
+
+    # split into individual messages
+    @messages = mbox.split(/^From .*/)
+    @messages.shift
+
+    @messages
+  end
+
+  #
+  # Find a message
+  #
+  def find(hash)
+    message = messages.find {|message| Mailbox.hash(message) == hash}
+    Mail.new(message) if message
+  end
+
+  #
+  # iterate through messages
+  #
+  def each(&block)
+    messages.each(&block)
+  end
+
+  #
+  # name of associated yaml file
+  #
+  def yaml_file
+    source = File.basename(@filename, '.gz').untaint
+    "#{ARCHIVE}/#{source}.yml"
+  end
+
+  #
+  # return headers
+  #
+  def headers
+    source = File.basename(@filename, '.gz').untaint
+    messages = YAML.load_file(yaml_file) rescue {}
+    messages.delete :mtime
+    messages.each do |key, value|
+      value[:source]=source
+    end
+  end
+
+  #
+  # What to use as a hash for mail
+  #
+  def self.hash(message)
+    Digest::SHA1.hexdigest(message[/^Message-ID:.*/i] || message)[0..9]
+  end
+
+  #
+  # common header logic for messages and attachments
+  #
+  def self.headers(part)
+    # extract all fields from the mail (recovering from bad encoding issues)
+    fields = part.header_fields.map do |field|
+      begin
+        next [field.name, field.to_s] if field.to_s.valid_encoding?
+      rescue
+      end
+
+      if field.value and field.value.valid_encoding?
+        [field.name, field.value]
+      else
+        [field.name, field.value.inspect]
+      end
+    end
+
+    # group fields by name
+    fields = fields.group_by(&:first).map do |name, values|
+      if values.length == 1
+        [name, values.first.last]
+      else
+        [name, values.map(&:last)]
+      end
+    end
+
+    # return fields as a Hash
+    Hash[fields]
+  end
+end
diff --git a/www/secmail/parsemail.rb b/www/secmail/parsemail.rb
new file mode 100644
index 0000000..c2821d9
--- /dev/null
+++ b/www/secmail/parsemail.rb
@@ -0,0 +1,113 @@
+#!/usr/bin/ruby
+
+#
+# Parse (and optionally fetch) officer-secretary emails for later
+# processing.
+#
+# Care is taken to recover from improperly formed emails, including:
+#   * Malformed message ids
+#   * Improper encoding
+#   * Invalid from addresses
+#
+
+require 'yaml'
+require 'time'
+
+require_relative 'mailbox'
+
+database = File.basename(SOURCE)
+
+Dir.chdir File.dirname(File.expand_path(__FILE__))
+
+if ARGV.include? '--fetch' or not Dir.exist? database
+  system "rsync -av --no-motd --delete --exclude='*.yml' #{SOURCE}/ #{ARCHIVE}/"
+elsif ARGV.include? '--fetch1'
+  month = Time.now.strftime('%Y%m')
+  system "rsync -av --no-motd #{SOURCE}/#{month} #{ARCHIVE}/"
+end
+
+# scan each mailbox for updates
+width = 0
+Dir[File.join(database, '2*')].sort.each do |name|
+  # skip YAML files, update output showing latest file being processed
+  next if name.end_with? '.yml'
+  next if ARGV.include? '--fetch1'  and not name.include? "/#{month}"
+  print "#{name.ljust(width)}\r"
+  width = name.length
+  
+  # test read the YAML file to see if the mbox needs to be parsed
+  yaml = File.join(database, File.basename(name)[/\d+/] + '.yml')
+  mbox = YAML.load_file(yaml) || {} rescue {}
+  next if mbox[:mtime] == File.mtime(name)
+
+  # open the YAML file for real (locking it this time)
+  Mailbox.update(name) do |mbox|
+    mbox[:mtime] = File.mtime(name)
+
+    # read (and unzip) the mailbox
+    messages = Mailbox.new(name)
+
+    # process each
+    messages.each do |message|
+      # compute id, skip if already processed
+      id = Mailbox.hash(message)
+      next if mbox[id]
+      mail = Mail.read_from_string(message)
+
+      # parse from address
+      begin
+        from = Mail::Address.new(mail[:from].value).display_name
+      rescue Exception
+        from = mail[:from].value
+      end
+
+      # determine who should be copied on any responses
+      cc = []
+      cc = mail[:to].value.split(/,\s*/)  if mail[:to]
+      cc += mail[:cc].value.split(/,\s*/) if mail[:cc]
+
+      # remove secretary and anybody on the to field from the cc list
+      cc.reject! do |email|
+        begin
+          address = Mail::Address.new(email).address
+          return true if address == 'secretary@apache.org'
+          return true if mail.from_addrs.include? address
+        rescue Exception
+          true
+        end
+      end
+
+      # start an entry for this mail
+      mbox[id] = {
+        from: mail.from_addrs.first,
+        name: from,
+        time: (mail.date.to_time.gmtime.iso8601 rescue nil),
+        cc: cc
+      }
+
+      # add in header fields
+      mbox[id].merge! Mailbox.headers(mail)
+
+      # add in attachments
+      if mail.attachments.length > 0
+        attachments = mail.attachments.map do |attach|
+          description = {
+            name: attach.filename,
+            length: attach.body.to_s.length,
+            mime: attach.mime_type
+          }
+
+          if description[:name].empty? and attach['Content-ID']
+            description[:name] = attach['Content-ID'].to_s
+          end
+
+          description.merge(Mailbox.headers(attach))
+        end
+
+        mbox[id][:attachments] = attachments
+      end
+    end
+  end
+end
+
+puts
diff --git a/www/secmail/public/secmail.css b/www/secmail/public/secmail.css
new file mode 100644
index 0000000..c904fe5
--- /dev/null
+++ b/www/secmail/public/secmail.css
@@ -0,0 +1,46 @@
+#index td:nth-child(2), #index th:nth-child(2) {
+  padding-right: 7px;
+  padding-left: 7px;
+}
+
+.selected {
+  background-color: yellow;
+}
+
+.deleted {
+  opacity: 0.5;
+}
+
+.contextMenu {
+  position: absolute;
+  z-index: 9999999;
+  border: solid 1px #CCC;
+  background: #EEE;
+  padding: 0px;
+  margin: 0px;
+  display: none;
+  background-repeat: no-repeat;
+}
+
+ul.contextMenu {
+  padding: 8px;
+}
+
+.contextMenu li {
+  list-style: none;
+  padding: 0px;
+  margin: 0px;
+}
+
+.contextMenu a {
+  color: #333;
+  text-decoration: none;
+  display: block;
+  line-height: 20px;
+  height: 20px;
+  background-position: 6px center;
+  background-repeat: no-repeat;
+  outline: none;
+  padding: 1px 5px;
+  padding-left: 28px;
+}
diff --git a/www/secmail/server.rb b/www/secmail/server.rb
new file mode 100644
index 0000000..403db29
--- /dev/null
+++ b/www/secmail/server.rb
@@ -0,0 +1,106 @@
+#
+# Simple web server that routes requests to views based on URLs.
+#
+
+require 'wunderbar/sinatra'
+require 'wunderbar/bootstrap'
+require 'wunderbar/react'
+require 'ruby2js/filter/functions'
+require 'ruby2js/filter/require'
+require 'sanitize'
+
+require_relative 'mailbox'
+
+# list of messages
+get '/' do
+  # determine latest month for which there are messages
+  @mbox = File.basename(Dir["#{ARCHIVE}/*.yml"].sort.last, '.yml')
+  _html :index
+end
+
+# support for fetching previous month's worth of messages
+post '/' do
+  _json :index
+end
+
+# retrieve a single message
+get %r{^/(\d+)/(\w+)/$} do |month, hash|
+  @message = Mailbox.new(month).headers[hash]
+  pass unless @message
+  _html :message
+end
+
+# mark a single message as deleted
+delete %r{^/(\d+)/(\w+)/$} do |month, hash|
+  success = false
+
+  Mailbox.update(month) do |headers|
+    if headers[hash]
+      headers[hash][:status] = :deleted
+      success = true
+    end
+  end
+
+  pass unless success
+  _json success: true
+end
+
+# update a single message
+patch %r{^/(\d+)/(\w+)/$} do |month, hash|
+  success = false
+
+  Mailbox.update(month) do |headers|
+    if headers[hash]
+      updates = JSON.parse(request.env['rack.input'].read)
+
+      # special processing for entries with symbols as keys
+      headers[hash].each do |key, value|
+        if Symbol === key and updates.has_key? key.to_s
+          headers[hash][key] = updates.delete(key.to_s)
+        end
+      end
+
+      headers[hash].merge! updates
+      success = true
+    end
+  end
+
+  pass unless success
+  [204, {}, '']
+end
+
+# list of parts for a single message
+get %r{^/(\d+)/(\w+)/_index_$} do |month, hash|
+  @message = Mailbox.new(month).headers[hash]
+  pass unless @message
+  @attachments = @message[:attachments]
+  _html :parts
+end
+
+# message body for a single message
+get %r{^/(\d+)/(\w+)/_body_$} do |month, hash|
+  @message = Mailbox.new(month).find(hash)
+  pass unless @message
+  _html :body
+end
+
+# header data for a single message
+get %r{^/(\d+)/(\w+)/_headers_$} do |month, hash|
+  @headers = Mailbox.new(month).headers[hash]
+  pass unless @headers
+  _html :headers
+end
+
+# a specific attachment for a message
+get %r{^/(\d+)/(\w+)/(.*?)$} do |month, hash, name|
+  message = Mailbox.new(month).find(hash)
+  pass unless message
+
+  part = message.attachments.find do |attach| 
+    attach.filename == name or attach['Content-ID'].to_s == name
+  end
+
+  pass unless part
+
+  [200, {'Content-Type' => part.content_type}, part.body.to_s]
+end
diff --git a/www/secmail/views/app.js.rb b/www/secmail/views/app.js.rb
new file mode 100644
index 0000000..5906d9c
--- /dev/null
+++ b/www/secmail/views/app.js.rb
@@ -0,0 +1,4 @@
+require_relative 'http'
+
+require_relative 'index'
+require_relative 'parts'
diff --git a/www/secmail/views/body.html.rb b/www/secmail/views/body.html.rb
new file mode 100644
index 0000000..1b8608c
--- /dev/null
+++ b/www/secmail/views/body.html.rb
@@ -0,0 +1,57 @@
+#
+# View the email content, without attachments
+#
+
+_html do
+  #
+  # Selected headers
+  #
+  _table do
+    _tr do
+      _td 'From:'
+      _td @message[:from]
+    end
+
+    _tr do
+      _td 'To:'
+      _td @message[:to]
+    end
+
+    if @message[:cc]
+      _tr do
+        _td 'Cc:'
+        _td @message[:cc]
+      end
+    end
+
+    _tr do
+      _td 'Subject:'
+      _td @message.subject || '(empty)'
+    end
+  end
+
+  _p
+
+  #
+  # Try various ways to display the body
+  #
+  if @message.html_part
+    _div do
+      body = @message.html_part.body.to_s
+
+      if body.to_s.encoding == Encoding::BINARY and @message.html_part.charset
+        body.force_encoding(@message.html_part.charset)
+      end
+
+      _{body.encode('utf-8', invalid: :replace, undef: :replace)}
+    end
+  elsif @message.text_part
+    body = @message.text_part.body.to_s
+
+    if body.to_s.encoding == Encoding::BINARY and @message.text_part.charset
+      body.force_encoding(@message.text_part.charset)
+    end
+
+    _pre body.encode('utf-8', invalid: :replace, undef: :replace)
+  end
+end
diff --git a/www/secmail/views/headers.html.rb b/www/secmail/views/headers.html.rb
new file mode 100644
index 0000000..aa499ae
--- /dev/null
+++ b/www/secmail/views/headers.html.rb
@@ -0,0 +1,7 @@
+#
+# Dump headers
+#
+
+_html do
+  _pre YAML.dump(@headers)
+end
diff --git a/www/secmail/views/http.js.rb b/www/secmail/views/http.js.rb
new file mode 100644
index 0000000..d552344
--- /dev/null
+++ b/www/secmail/views/http.js.rb
@@ -0,0 +1,143 @@
+#
+# Encapsulations for asynchronous HTTP requests.  Uses older XMLHttpRequest
+# API over fetch as fetch isn't widely supported yet:
+# https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#Browser_compatibility
+#
+
+
+class HTTP
+  # "AJAX" style post request to the server, with a callback
+  def self.post(target, data, &block)
+    xhr = XMLHttpRequest.new()
+    xhr.open('POST', target, true)
+    xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
+    xhr.responseType = 'text'
+
+    def xhr.onreadystatechange()
+      if xhr.readyState == 4
+        data = nil
+
+        begin
+          if xhr.status == 200
+            data = JSON.parse(xhr.responseText) 
+            alert "Exception\n#{data.exception}" if data.exception
+          else
+            HTTP._log(xhr)
+          end
+        rescue => e
+          console.log(e)
+        end
+
+        block(data)
+      end
+    end
+
+    xhr.send(JSON.stringify(data))
+  end
+
+  # "AJAX" style patch request to the server, with a callback
+  def self.patch(target, data, &block)
+    xhr = XMLHttpRequest.new()
+    xhr.open('PATCH', target, true)
+    xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
+    xhr.responseType = 'text'
+
+    def xhr.onreadystatechange()
+      if xhr.readyState == 4
+        data = nil
+
+        begin
+          if xhr.status == 200
+            data = JSON.parse(xhr.responseText) 
+            alert "Exception\n#{data.exception}" if data.exception
+          else
+            HTTP._log(xhr)
+          end
+        rescue => e
+          console.log(e)
+        end
+
+        block(data)
+      end
+    end
+
+    xhr.send(JSON.stringify(data))
+  end
+
+  # "AJAX" style delete request to the server, with a callback
+  def self.delete(target, &block)
+    xhr = XMLHttpRequest.new()
+    xhr.open('DELETE', target, true)
+
+    def xhr.onreadystatechange()
+      if xhr.readyState == 4
+
+        begin
+          if xhr.status == 404
+            alert "Not Found: #{target}"
+          else
+            HTTP._log(xhr)
+          end
+        rescue => e
+          console.log(e)
+        end
+
+        block()
+      end
+    end
+
+    xhr.send()
+  end
+
+  # "AJAX" style get request to the server, with a callback
+  def self.get(target, type, &block)
+    xhr = XMLHttpRequest.new()
+
+    def xhr.onreadystatechange()
+      if xhr.readyState == 4
+        data = nil
+
+        begin
+          if xhr.status == 200
+            if type == :json
+              data = xhr.response || JSON.parse(xhr.responseText) 
+            else
+              data = xhr.responseText
+            end
+          else
+            HTTP._log(xhr)
+          end
+        rescue => e
+          console.log(e)
+        end
+
+        block(data)
+      end
+    end
+
+    if target =~ /^https?:/
+      xhr.open('GET', target, true)
+      xhr.setRequestHeader("Accept", "application/json") if type == :json
+    else
+      xhr.open('GET', "../#{type}/#{target}", true)
+    end
+    xhr.responseType = type
+    xhr.send()
+  end
+
+  # common logging
+  def self._log(xhr)
+    if xhr.status == 404
+      alert "Not Found: #{target}"
+    elsif xhr.status >= 400
+      console.log(xhr.response)
+      if not xhr.response
+        alert "Exception - #{xhr.statusText}"
+      elsif xhr.response.exception
+        alert "Exception\n#{xhr.response.exception}"
+      else
+        alert "Exception\n#{JSON.parse(xhr.responseText).exception}"
+      end
+    end
+  end
+end
diff --git a/www/secmail/views/index.html.rb b/www/secmail/views/index.html.rb
new file mode 100644
index 0000000..51ecb2f
--- /dev/null
+++ b/www/secmail/views/index.html.rb
@@ -0,0 +1,10 @@
+_html do
+  _link rel: 'stylesheet', type: 'text/css', href: 'secmail.css'
+
+  _div.index!
+
+  _script src: 'app.js'
+  _.render '#index' do
+    _Index mbox: @mbox
+  end
+end
diff --git a/www/secmail/views/index.js.rb b/www/secmail/views/index.js.rb
new file mode 100644
index 0000000..1268941
--- /dev/null
+++ b/www/secmail/views/index.js.rb
@@ -0,0 +1,167 @@
+#
+# Index page showing unprocessed messages with attachments
+#
+
+class Index < React
+  def initialize
+    @selected = nil
+    @messages = []
+    @undoStack = []
+  end
+
+  def render
+    _table do
+      _thead do
+        _tr do
+          _th 'Timestamp'
+          _th 'From'
+          _th 'Subject'
+        end
+      end
+
+      _tbody do
+        @messages.each do |message|
+
+          # determine the 'color' to use for the row
+          color = nil
+          color = 'deleted' if message.status == :deletePending
+          color = 'hidden' if message.status == :deleted
+          color = 'selected' if message.href == @selected
+
+          _tr class: color, onClick: self.selectRow, onDoubleClick: self.nav do
+            _td do
+              _a message.time, href: "#{message.href}"
+            end 
+            _td message.from
+            _td message.subject
+          end
+        end
+      end
+    end
+
+    _input.btn.btn_primary type: 'submit', value: 'fetch previous month',
+      onClick: self.fetch_month
+
+    unless @undoStack.empty?
+      _input.btn.btn_info type: 'submit', value: 'undo delete',
+        onClick: self.undo
+    end
+  end
+
+  # initialize latest mailbox (year+month)
+  def componentWillMount()
+    @latest = @@mbox
+  end
+
+  # on initial load, fetch latest mailbox and subscribe to keyboard events
+  def componentDidMount()
+    self.fetch_month()
+    window.onkeydown = self.keydown
+  end
+
+  # when content changes, ensure selected message is visible
+  def componentDidUpdate()
+    if @selected
+      selected = document.querySelector("a[href='#{@selected}']")
+      if selected
+        rect = selected.getBoundingClientRect()
+        if
+          rect.top < 0 or rect.left < 0 or 
+          rect.bottom > window.innerHeight or rect.right > window.innerWidth
+        then
+          selected.scrollIntoView()
+        end
+      end
+    end
+  end
+
+  # fetch a month's worth of messages
+  def fetch_month()
+    HTTP.post('', mbox: @latest) do |response|
+      # update latest mbox
+      @latest = response.mbox if response.mbox
+
+      # add messages to list
+      @messages = @messages.concat(*response.messages)
+
+      # select oldest message
+      @selected = @messages.last.href
+    end
+  end
+
+  # select
+  def selectRow(event)
+    @selected = event.currentTarget.querySelector('a').getAttribute('href')
+  end
+
+  # navigate
+  def nav(event)
+    @selected = event.currentTarget.querySelector('a').getAttribute('href')
+    window.location.href = @selected
+    window.getSelection().removeAllRanges()
+    event.preventDefault()
+  end
+
+  def undo(event)
+    @selected = @undoStack.pop()
+    selected = @messages.find {|m| return m.href == @selected}
+    if selected
+      selected.status = :deletePending
+
+      # send request to server to remove delete status
+      HTTP.patch(@selected, status: nil) do
+        delete selected.status
+        self.forceUpdate()
+      end
+    end
+  end
+
+  # handle keyboard events
+  def keydown(event)
+    if event.keyCode == 38 # up
+      index = @messages.findIndex {|m| return m.href == @selected}
+      @selected = @messages[index-1].href if index > 0
+      event.preventDefault()
+
+    elsif event.keyCode == 40 # down
+      index = @messages.findIndex {|m| return m.href == @selected}
+      @selected = @messages[index+1].href if index < @messages.length-1
+      event.preventDefault()
+
+    elsif event.keyCode == 13 or event.keyCode == 39 # enter/return or right
+      selected = @messages.find {|m| return m.href == @selected}
+      window.location.href = selected.href if selected
+
+    elsif event.keyCode == 8 or event.keyCode == 46 # backspace or delete
+      event.preventDefault()
+      # mark item as delete pending
+      selected = @selected
+      index = @messages.findIndex {|m| return m.href == selected}
+      @messages[index].status = :deletePending if index >= 0
+
+      # move selected pointer
+      if index > 0
+        @selected = @messages[index-1].href
+      elsif index < @messages.length - 1
+        @selected = @messages[index+1].href
+      else
+        @selected = nil
+      end
+
+      # send request to server to perform delete
+      HTTP.delete(selected) do
+        index = @messages.findIndex {|m| return m.href == selected}
+        @messages[index].status = :deleted if index >= 0
+        @undoStack << selected
+        self.forceUpdate()
+      end
+
+    elsif event.keyCode == 'Z'.ord
+      if event.ctrlKey or event.metaKey
+        self.undo() unless @undoStack.empty?
+      end
+    else
+      console.log "keydown: #{event.keyCode}"
+    end
+  end
+end
diff --git a/www/secmail/views/index.json.rb b/www/secmail/views/index.json.rb
new file mode 100644
index 0000000..f373acf
--- /dev/null
+++ b/www/secmail/views/index.json.rb
@@ -0,0 +1,32 @@
+if @mbox =~ /^\d+$/
+  # find indicated mailbox in the list of available mailboxes
+  available = Dir["#{ARCHIVE}/*.yml"].sort
+  index = available.find_index "#{ARCHIVE}/#{@mbox}.yml"
+
+  # if found, process it
+  if index
+    # fetch a list of headers for all messages in the maibox with attachments
+    headers = Mailbox.new(@mbox).headers.to_a.select do |id, message|
+      message[:attachments] and not message[:status] == :deleted
+    end
+
+    # extract relevant fields from the headers
+    headers.map! do |id, message|
+      {
+        time: message[:time],
+        href: "#{message[:source]}/#{id}/",
+        from: message[:from],
+        subject: message['Subject']
+      }
+    end
+
+    # select previous mailbox
+    mbox = available[index-1].untaint
+
+    # return mailbox name and messages
+    {
+      mbox: File.basename(mbox, '.yml'),
+      messages: headers.sort_by {|message| message[:time]}.reverse
+    }
+  end
+end
diff --git a/www/secmail/views/message.html.rb b/www/secmail/views/message.html.rb
new file mode 100644
index 0000000..079e4f4
--- /dev/null
+++ b/www/secmail/views/message.html.rb
@@ -0,0 +1,12 @@
+#
+# Layout for viewing an individual message
+#
+
+_html do
+  _title 'ASF Secretary Workbench'
+
+  _frameset cols: '20%, 70%' do
+    _frame src: '_index_'
+    _frame name: 'content', src: '_body_'
+  end
+end
diff --git a/www/secmail/views/parts.html.rb b/www/secmail/views/parts.html.rb
new file mode 100644
index 0000000..a55d1d2
--- /dev/null
+++ b/www/secmail/views/parts.html.rb
@@ -0,0 +1,19 @@
+#
+# Display the list of parts for a given message
+#
+
+_html do
+  _link rel: 'stylesheet', type: 'text/css', href: '../../secmail.css'
+
+  _ul_ do
+    _li! {_a 'text', href: '_body_', target: 'content'}
+    _li! {_a 'headers', href: '_headers_', target: 'content'}
+  end
+
+  _div.attachments!
+
+  _script src: '../../app.js'
+  _.render '#attachments' do
+    _Parts attachments: @attachments
+  end
+end
diff --git a/www/secmail/views/parts.js.rb b/www/secmail/views/parts.js.rb
new file mode 100644
index 0000000..5851fa8
--- /dev/null
+++ b/www/secmail/views/parts.js.rb
@@ -0,0 +1,44 @@
+class Parts < React
+  def initialize
+    @selected = nil
+  end
+
+  def render
+    _ul @@attachments do |attachment|
+      _li onContextMenu: self.menu do
+        _a attachment.name, href: attachment.name, target: 'content'
+      end
+    end
+
+    _ul.contextMenu ref: 'menu' do
+      _li 'burst'
+      _li 'file'
+      _li 'delete'
+    end
+  end
+
+  def componentDidMount()
+    $menu.style.display = :none
+    window.onmousedown = self.click
+  end
+
+  # position and show context menu
+  def menu(event)
+    @selected = event.currentTarget.textContent
+    $menu.style.left = event.clientX + 'px'
+    $menu.style.top = event.clientY + 'px'
+    $menu.style.position = :absolute
+    $menu.style.display = :block
+    event.preventDefault()
+  end
+
+  # hide context menu whenever a click is received outside the menu
+  def click(event)
+    target = event.target
+    while target
+      return if target.class == 'contextMenu'
+      target = target.parentNode
+    end
+    $menu.style.display = :none
+  end
+end