You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@whimsical.apache.org by se...@apache.org on 2019/01/09 15:23:48 UTC

[whimsy] 01/01: www/moderation/

This is an automated email from the ASF dual-hosted git repository.

sebb pushed a commit to branch mod-gui
in repository https://gitbox.apache.org/repos/asf/whimsy.git

commit 918930fabae657bb2ac464549662f7c916532847
Author: Sebb <se...@apache.org>
AuthorDate: Wed Jan 9 15:22:49 2019 +0000

    www/moderation/
    
    Initial publish
---
 www/moderation/desk/.gitignore                     |   2 +
 www/moderation/desk/Gemfile                        |  27 ++
 www/moderation/desk/README                         |  70 ++++
 www/moderation/desk/Rakefile                       |  54 +++
 www/moderation/desk/TODO.txt                       |  20 ++
 www/moderation/desk/config.rb                      |   8 +
 www/moderation/desk/config.ru                      |  11 +
 www/moderation/desk/defines.rb                     |   7 +
 www/moderation/desk/deliver.rb                     |  89 +++++
 www/moderation/desk/helpers.rb                     |  37 +++
 www/moderation/desk/models/attachment.rb           |  87 +++++
 www/moderation/desk/models/events.rb               |  72 ++++
 www/moderation/desk/models/mailbox.rb              | 282 ++++++++++++++++
 www/moderation/desk/models/message.rb              | 365 +++++++++++++++++++++
 www/moderation/desk/models/safetemp.rb             |  28 ++
 .../desk/public/assets/bootstrap-min.css           |   6 +
 www/moderation/desk/public/assets/bootstrap-min.js |   7 +
 www/moderation/desk/public/assets/jquery-min.js    |   4 +
 www/moderation/desk/public/assets/vue.min.js       |   6 +
 www/moderation/desk/public/secmail.css             | 114 +++++++
 www/moderation/desk/public/transparent.png         | Bin 0 -> 1006 bytes
 www/moderation/desk/server.rb                      | 198 +++++++++++
 www/moderation/desk/templates/reject-off-topic.erb |   7 +
 www/moderation/desk/views/actions/email.json.rb    |  72 ++++
 www/moderation/desk/views/app.js.rb                |  10 +
 www/moderation/desk/views/body.html.rb             | 124 +++++++
 www/moderation/desk/views/danger.html.rb           |  21 ++
 www/moderation/desk/views/headers.html.rb          |   7 +
 www/moderation/desk/views/http.js.rb               | 135 ++++++++
 www/moderation/desk/views/main.html.rb             |  14 +
 www/moderation/desk/views/messages.html.rb         |  27 ++
 www/moderation/desk/views/messages.js.rb           | 321 ++++++++++++++++++
 www/moderation/desk/views/status.js.rb             |  53 +++
 www/moderation/desk/views/vue-config.js.rb         |  10 +
 34 files changed, 2295 insertions(+)

diff --git a/www/moderation/desk/.gitignore b/www/moderation/desk/.gitignore
new file mode 100644
index 0000000..169b1cf
--- /dev/null
+++ b/www/moderation/desk/.gitignore
@@ -0,0 +1,2 @@
+Gemfile.lock
+officers-secretary
diff --git a/www/moderation/desk/Gemfile b/www/moderation/desk/Gemfile
new file mode 100644
index 0000000..b7893aa
--- /dev/null
+++ b/www/moderation/desk/Gemfile
@@ -0,0 +1,27 @@
+source 'https://rubygems.org'
+
+root = '../../../..'
+version_file = File.expand_path("#{root}/asf.version", __FILE__)
+if File.exist? version_file
+  # for deployment and local testing
+  asf_version = File.read(version_file).chomp
+  gem 'whimsy-asf', asf_version, path: File.expand_path(root, __FILE__)
+else
+  # for docker purposes (atleast for now)
+  gem 'whimsy-asf'
+end
+
+gem 'mail'
+gem 'rake'
+gem 'zip'
+gem 'sinatra', '~> 2.0'
+gem 'sanitize'
+gem 'wunderbar'
+gem 'ruby2js', '>= 2.1.18'
+gem 'execjs'
+gem 'listen', ('~> 3.0.7' if RUBY_VERSION =~ /^2\.[01]/)
+gem 'escape'
+
+group :demo do
+  gem 'puma'
+end
diff --git a/www/moderation/desk/README b/www/moderation/desk/README
new file mode 100644
index 0000000..2950859
--- /dev/null
+++ b/www/moderation/desk/README
@@ -0,0 +1,70 @@
+This is based loosely on the secretary/workbench (q.v.)
+
+To set up for testing:
+
+create archive directory /srv/mail/moderation
+Note that the files in it must be readable by the web server, and the .yml files must be writable.
+
+Populate the database by running some sample mails through
+www/moderation/desk/deliver.rb
+This should create yyyymmdd.yml and yyyymmdd.mail/*
+(ensure *.yml are writable by webserver afterwards)
+
+Add the following to the web server setup:
+
+	Alias /moderation/desk/ /srv/whimsy/www/moderation/desk/public
+	
+	<Location /moderation/desk>
+	  PassengerBaseURI /moderation/desk
+	  PassengerAppRoot /srv/whimsy/www/moderation/desk
+	  Options -MultiViews
+	  CheckSpelling Off
+	  # SetEnv HTTPS on
+	</Location>
+
+Navigate to http://localhost/moderation/desk
+
+Design Notes
+============
+The Secretary Workbench has to deal with a few emails per day and a few users.
+It uses a file per month for the indexes, and a directoy per month for the raw mails.
+
+There are about as many moderation emails per day as Secretary emails per month.
+Also ezmlm will not process responses after about 10 days.
+The initial implementation therefore uses 1 file per day.
+The file access is confined to the mailbox.rb file, so the storage could be changed
+if necessary - e.g. a database. The mailbox.rb file is also responsible for access control.
+
+The existing moderation process involves replying to a mail. Whilst this only takes a couple
+of clicks (except perhaps for rejects), the volume of mails can be very large, so it is
+important to minimise navigation and user actions, especially for the commonest cases.
+
+The initial approach is to show a list of emails (oldest at the top) with buttons for
+Accept/Allow/Reject/Mark Spam. Apart from Reject, no further input is needed.
+As soon as an email has been dealt with, it should disappear from all the screens.
+
+This reduces the workload for moderators, because each message only needs to be dealt with once.
+
+Spam
+====
+By far the largest number of mails are spam; a significant proportion of these are very similar.
+At present each message would have to be dealt with separately (though only by one person). It would be
+good if similar mails could be automatically marked. The challenge is to guard against false positives.
+One idea (yet to be implemented) is to count the number of mails marked as spam that have the same:
+From: Return-Path: Subject: and possibly other headers.
+When a few mails have been seen, other mails with the same headers can potentially be flagged as
+likely spam. However, care must be taken not to mark too many mails - e.g. some subjects may be
+used by valid mails. It might be best to gather some more data before proceeding with automation.
+
+[Even without this, the workload should be considerably reduced. Also the greatest proportion of
+spam is unfortunately not readily detectable] 
+
+
+Authentication
+==============
+TBA
+
+Filtering
+=========
+It's best if moderators are familiar with the subject matter of the lists that they moderate,
+so they should only be shown mails for their area. The simplest might be to use PMC membership.
\ No newline at end of file
diff --git a/www/moderation/desk/Rakefile b/www/moderation/desk/Rakefile
new file mode 100644
index 0000000..d5d0564
--- /dev/null
+++ b/www/moderation/desk/Rakefile
@@ -0,0 +1,54 @@
+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 'create /srv/mail with the appropriate permissions'
+file '/srv/mail' do
+  begin
+    mkdir_p '/srv/mail'
+
+    require 'etc'
+    if Etc.getpwent.uid == 0
+      user = Etc.getpwnam(Etc.getlogin)
+      chown user.uid, user.gid, '/srv/mail'
+    end
+  rescue Errno::EACCES
+    sh 'sudo rake /srv/mail'
+  end
+end
+
+desc 'WebServer that provides an interface to explore emails'
+task :server => :bundle do
+  ENV['RACK_ENV']='development'
+
+  require 'bundler/setup'
+  require 'wunderbar'
+  module Wunderbar::Listen
+    EXCLUDE = [ARCHIVE]
+  end
+
+  require 'wunderbar/listen'
+end
+
+desc 'remove all parsed yaml files'
+task :clean do
+  rm_rf Dir["#{ARCHIVE}/*.yml"]
+end
diff --git a/www/moderation/desk/TODO.txt b/www/moderation/desk/TODO.txt
new file mode 100644
index 0000000..5aea0bd
--- /dev/null
+++ b/www/moderation/desk/TODO.txt
@@ -0,0 +1,20 @@
+Authorisation
+- started; hooks added
+
+Filtering of lists
+- users may not want to see all the lists to which they are entitled
+- hooks added
+
+Implement Actions:
+- accept and allow
+- single accept
+- reject with reply e.g. as
+ * off topic
+ * confidential material
+ [Started]
+- reply to subscriber confirm asking to update LDAP
+
+Implement message templates for rejection etc
+
+Better GUI layout
+= e.g. fixed header with scrolling message list beneath
\ No newline at end of file
diff --git a/www/moderation/desk/config.rb b/www/moderation/desk/config.rb
new file mode 100644
index 0000000..2632c9c
--- /dev/null
+++ b/www/moderation/desk/config.rb
@@ -0,0 +1,8 @@
+#
+# Where to find the archive
+#
+
+ARCHIVE = '/srv/mail/moderation'
+
+MBOX_RE='\d{8}' # yyyymmdd
+HASH_RE='[a-f0-9]+' # perhaps fix length?
diff --git a/www/moderation/desk/config.ru b/www/moderation/desk/config.ru
new file mode 100644
index 0000000..c3157de
--- /dev/null
+++ b/www/moderation/desk/config.ru
@@ -0,0 +1,11 @@
+require File.expand_path('../server.rb', __FILE__)
+
+require 'whimsy/asf/rack'
+
+use ASF::HTTPS_workarounds
+use ASF::Auth::MembersAndOfficers
+use ASF::AutoGC
+
+use ASF::DocumentRoot
+
+run Sinatra::Application
diff --git a/www/moderation/desk/defines.rb b/www/moderation/desk/defines.rb
new file mode 100644
index 0000000..521c22b
--- /dev/null
+++ b/www/moderation/desk/defines.rb
@@ -0,0 +1,7 @@
+# values shared between Javascript and server code
+
+ACCEPT=':Accept'
+ACCEPTALLOW=':AcceptAllow'
+# does not make much sense to allow on its own
+REJECT=':Reject'
+MARKSPAM=':Spam'
\ No newline at end of file
diff --git a/www/moderation/desk/deliver.rb b/www/moderation/desk/deliver.rb
new file mode 100644
index 0000000..e574922
--- /dev/null
+++ b/www/moderation/desk/deliver.rb
@@ -0,0 +1,89 @@
+#
+# Process email as it is received
+#
+
+#Dir.chdir File.dirname(File.expand_path(__FILE__))
+
+require_relative 'models/mailbox'
+require 'mail'
+require_relative 'config.rb'
+
+# read and parse email
+STDIN.binmode
+original = STDIN.read
+hash = Message.hash(original)
+
+fail = nil
+mailbox = nil
+
+mbox=Mailbox.mboxname(Time.now)  # for mails that don't have the stamp
+
+begin
+  mail = Mail.read_from_string(original)
+  parts = mail.parts
+  subj = mail.subject || ''
+  # other methods give to, cc etc as arrays
+  # Date is an object, not sure how to ge 
+  
+  if parts.length == 2 and parts[1].content_type == 'message/rfc822' and subj.start_with? 'MODERATE for '
+    hdrs = Mailbox.headers(mail)
+    # e.g. Subject: MODERATE for dev@community.apache.org
+    list, dom = subj.sub(/^MODERATE for /,'').split'@'
+    # e.g. reply-To: dev-accept-1545301944.94902.xxxxxxxx@community.apache.org
+    rp = hdrs['Reply-To']
+    timestamp = rp[/-(\d+\.\d+)\./, 1]
+    headers = {
+      allow: hdrs['Cc'], # parser uses these standard names, regardless of input capitalisation
+      accept: rp,
+      timestamp: timestamp,
+      reject: hdrs['From'],
+      list: list,
+      domain: dom,
+      date: hdrs['Date'],
+    }
+    mbox=Mailbox.mboxname(timestamp.to_f)
+    # construct wrapper message
+    mailbox = Mailbox.new(mbox)
+    # change the hash to change the name of the saved mail
+    message = Message.new(mailbox, "#{hash}.orig", nil, original)
+    
+    # write message to disk
+    File.umask(0002)
+  #  skip message.write_headers as don't want the headers in a separate file
+    message.write_email
+    # extract the message for main mailbox
+    email = parts[1].body.raw_source
+  elsif subj.start_with? 'CONFIRM subscribe to '
+    headers = Hash.new
+    email = original
+  else
+    $stderr.puts "Unexpected moderation message in #{hash} with subject: #{subj}"
+    headers = Hash.new
+    email = original
+  end 
+  WANTED=%w{Date From To Reply-To Message-ID Subject Return-Path Sender References In-Reply-To}
+  headers.merge! Message.parse(email).select{ |k,v| Symbol === k or WANTED.include? k }
+rescue => e
+  fail = e
+  headers = {
+    exception: e.to_s,
+    backtrace: e.backtrace[0],
+    message: 'See procmail.log for full details'
+  }
+end
+
+# construct message
+mailbox = Mailbox.new(mbox) unless mailbox # reuse if exists
+message = Message.new(mailbox, hash, headers, email)
+
+# write message to disk
+File.umask(0002)
+message.write_headers
+message.write_email
+
+# Now fail if there was an error
+if fail
+  require 'time'
+  $stderr.puts "WARNING: #{Time.now.utc.iso8601}: error processing email with hash: #{hash}"
+  raise fail
+end
diff --git a/www/moderation/desk/helpers.rb b/www/moderation/desk/helpers.rb
new file mode 100644
index 0000000..ce77ad1
--- /dev/null
+++ b/www/moderation/desk/helpers.rb
@@ -0,0 +1,37 @@
+helpers do
+  # replace inline images (cid:) with references to attachments
+  def fixup_images(node)
+    if Wunderbar::Node === node
+      if node.name == 'img'
+        src = node.attrs['src'] 
+        if src 
+          if src.to_s.start_with? 'cid:'
+            src.value = src.to_s.sub('cid:', '')
+          else # src.to_s.start_with? 'http' # Don't allow access to remote images
+            src.value='../../transparent.png'
+          end
+        end
+      else
+        fixup_images(node.search('img'))
+      end
+    elsif Array === node
+      node.each {|child| fixup_images(child)}
+    end
+  end
+end
+
+
+class Wunderbar::JsonBuilder
+  #
+  # extract/verify project (set @pmc and @podling)
+  #
+
+  # update the status of a message
+  def _status(status_text)
+    message = Mailbox.find(@message)
+    message.headers[:secmail] ||= {}
+    message.headers[:secmail][:status] = status_text
+    message.write_headers
+    _headers message.headers
+  end
+end
diff --git a/www/moderation/desk/models/attachment.rb b/www/moderation/desk/models/attachment.rb
new file mode 100644
index 0000000..f5643ad
--- /dev/null
+++ b/www/moderation/desk/models/attachment.rb
@@ -0,0 +1,87 @@
+class Attachment
+  IMAGE_TYPES = %w(.gif, .jpg, .jpeg, .png)
+  attr_reader :headers
+
+  def initialize(message, headers, part)
+    @message = message
+    @headers = headers
+    @part = part
+  end
+
+  def name
+    headers[:name] || @part.filename
+  end
+
+  def content_type
+    type = headers[:mime] || @part.content_type
+
+    if type == 'application/octet-stream' or type == 'text/plain'
+      type = 'text/plain' if name.end_with? '.sig'
+      type = 'text/plain' if name.end_with? '.asc'
+      type = 'application/pdf' if name.end_with? '.pdf'
+      type = 'image/gif' if name.end_with? '.gif'
+      type = 'image/jpeg' if name.end_with? '.jpg'
+      type = 'image/jpeg' if name.end_with? '.jpeg'
+      type = 'image/png' if name.end_with? '.png'
+    end
+
+    type = "image/#{$1}" if type =~ /^application\/(jpeg|gif|png)$/
+
+    type
+  end
+
+  def body
+    headers[:content] || @part.body
+  end
+
+  def safe_name
+    name = self.name.dup
+    name.gsub! /^\W/, ''
+    name.gsub! /[^\w.]/, '_'
+    name.untaint
+  end
+
+  def as_file
+    file = SafeTempFile.new([safe_name, '.pdf'])
+    file.write(body)
+    file.rewind
+    file
+  end
+
+  def as_pdf
+    ext = File.extname(name).downcase
+    ext = '.pdf' if content_type.end_with? '/pdf'
+    ext.untaint if ext =~ /^\.\w+$/
+
+    file = SafeTempFile.new([safe_name, ext])
+    file.write(body)
+    file.rewind
+
+    return file if ext == '.pdf'
+
+    if IMAGE_TYPES.include? ext or content_type.start_with? 'image/'
+      pdf = SafeTempFile.new([safe_name, '.pdf'])
+      img2pdf = File.expand_path('../img2pdf', __dir__.untaint).untaint
+      system img2pdf, '--output', pdf.path, file.path
+      file.unlink
+      raise "Failed to convert #{self.name} to PDF" unless File.size? pdf.path
+      return pdf
+    end
+
+    return file
+  end
+
+  # write a file out to svn
+  def write_svn(repos, file, path=nil)
+    filename = File.join(repos, file)
+    filename = File.join(filename, path || safe_name) if Dir.exist? filename
+
+    raise Errno::EEXIST.new(file) if File.exist? filename
+    File.write filename, body, encoding: Encoding::BINARY
+
+    system 'svn', 'add', filename
+    system 'svn', 'propset', 'svn:mime-type', content_type.untaint, filename
+
+    filename
+  end
+end
diff --git a/www/moderation/desk/models/events.rb b/www/moderation/desk/models/events.rb
new file mode 100644
index 0000000..52a8656
--- /dev/null
+++ b/www/moderation/desk/models/events.rb
@@ -0,0 +1,72 @@
+require 'listen'
+require 'thread'
+
+class Events
+  @@list = []
+
+  def initialize
+    @@list.push self
+
+    @events = Queue.new
+
+    @listener = Listen.to ARCHIVE do |modified, added, removed|
+      (modified + added).each do |file|
+         next unless file.end_with? '.yml'
+         mbox = Mailbox.new(File.basename(file))
+#         $stderr.puts "Event #{mbox}"
+         @events.push({messages: mbox.client_headers})
+      end
+    end
+
+    @listener.start
+
+    @closed = false
+
+    # As some TCP/IP implementations will close idle sockets after as little
+    # as 30 seconds, sent out a heartbeat every 25 seconds.  Due to limitations
+    # of some versions of Ruby (2.0, 2.1), this is lowered to every 5 seconds
+    # in development mode to allow for quicker restarting after a trap/signal.
+    Thread.new do
+      loop do
+        sleep(ENV['RACK_ENV'] == 'development' ? 5 : 25)
+        break if @closed
+        @events.push(:heartbeat)
+      end
+
+      @events.push(:exit)
+      @listener.stop
+    end
+  end
+
+  def pop
+    @events.pop
+  end
+
+  def close
+    @@list.delete self
+    @events.clear
+    @closed = true
+
+    begin
+      @events.push :exit
+    rescue ThreadError
+      # some versions of Ruby don't allow queue operations in traps
+    end
+  end
+
+  def self.shutdown
+    @@list.dup.each {|event| event.close}
+  end
+end
+
+# puma uses SIGUSR2
+restart_usr2 ||= trap 'SIGUSR2' do
+  restart_usr2.call if Proc === restart_usr2
+  Events.shutdown
+end
+
+# thin uses SIGHUP
+restart_hup ||= trap 'SIGHUP' do
+  restart_hup.call if Proc === restart_hup
+  Events.shutdown
+end
diff --git a/www/moderation/desk/models/mailbox.rb b/www/moderation/desk/models/mailbox.rb
new file mode 100644
index 0000000..e7fcf1a
--- /dev/null
+++ b/www/moderation/desk/models/mailbox.rb
@@ -0,0 +1,282 @@
+#
+# Encapsulate access to mailboxes
+#
+
+# It may be necessary to use a database rather than files, so try to avoid exposing
+# any of the internal storage details
+
+require 'zlib'
+require 'zip'
+require 'stringio'
+require 'yaml'
+
+require_relative '../config.rb'
+
+require_relative 'message.rb'
+
+class Mailbox
+
+  #
+  # Initialize a mailbox
+  #
+  def initialize(name)
+    name = File.basename(name, '.yml')
+
+    if name =~ /^#{MBOX_RE}$/
+      @name = name.untaint
+      @mbox = Dir["#{ARCHIVE}/#{@name}", "#{ARCHIVE}/#{@name}.gz"].first.untaint
+    else
+      @name = name.split('.').first
+      @mbox = "#{ARCHIVE}/#{name}"
+    end
+  end
+
+  # centralise the name generation
+  def self.mboxname(timestamp=nil)
+    # If nil, return the current (last) box (could perhaps be the first)
+    Time.at(timestamp||Time.now).gmtime.strftime('%Y%m%d') 
+  end
+
+  #
+  # convenience interface to update status
+  #
+  def self.status!(name, hash, newstatus)
+    success = false
+    Mailbox.new(name).update do |headers|
+      target = headers[hash]
+      return unless Mailbox.message_visible?(target) # TODO should this throw?
+      if target
+        # TODO don't allow same message to be counted twice (perhaps store ids in spam lists)
+        # TODO capture spammy headers if marking as spam
+        # TODO skip update if no record found or no change made
+#        if newstatus == :spam
+#        end
+        target[:status] = newstatus
+        success = true
+      end
+    end
+    success # let caller know
+  end
+
+  #
+  # Allow update of entry using hash
+  #
+  def self.patch!(name, hash, updates)
+    success = false
+    Mailbox.new(name).update do |headers|
+      target = headers[hash]
+      return unless Mailbox.message_visible?(target) # TODO should this throw?
+      if target
+        # special processing for entries which use symbols as keys
+        # (allow for :status not present)
+        [target.keys,:status].flatten.each do |key|
+          if Symbol === key and updates.has_key? key.to_s
+            target[key] = updates.delete(key.to_s) # apply the change and drop from input
+          end
+        end
+  
+        target.merge! updates # anything else can just be merged
+        success = true
+      end
+    end
+    success # let caller know
+  end
+
+  def write_email(hash, email)
+    Dir.mkdir dir, 0755 unless Dir.exist? dir
+    File.write File.join(dir, hash), email, encoding: Encoding::BINARY
+  end
+
+  def write_headers(hash, headers)
+    update do |yaml|
+      # TODO check if hash exists and throw if so?
+      # (would need to override this for testing)
+      yaml[hash] = headers
+    end
+  end
+
+  #
+  # encapsulate updates to a mailbox
+  # TODO would like to make this private/protected to avoid external updates
+  def update
+    File.open(yaml_file, File::RDWR|File::CREAT, 0644) do |file| 
+      file.flock(File::LOCK_EX)
+      mbox = YAML.load(file.read) || {} rescue {}
+      yield mbox # TODO allow block to cancel update
+      file.rewind
+      file.write YAML.dump(mbox)
+      file.truncate(file.pos)
+    end
+  end
+
+  #
+  # Find a message by id e.g. yyyymmdd/abcdefgh (for use by GUI)
+  # Returns nil if not found or no access
+  def self.find(id)
+    return unless id
+    # Allow leading and trailing slash
+    id.match(%r{^/?(#{MBOX_RE})/(#{HASH_RE})/?$}) do |m|
+      mbox, hash = m.captures
+      Mailbox.new(mbox.untaint).find(hash.untaint)
+    end
+  end
+
+  
+  #
+  # Find headers by id e.g. yyyymmdd/abcdefgh (for use by GUI)
+  # Returns nil if not found or no access
+  def self.hdrs(id)
+    return unless id
+    # Allow leading and trailing slash
+    id.match(%r{^/?(#{MBOX_RE})/(#{HASH_RE})/?$}) do |m|
+      mbox, hash = m.captures
+      Mailbox.new(mbox.untaint).headers(hash.untaint)
+    end
+  end
+  
+  #
+  # Find message headers
+  #
+  def headers(hash)
+    headers = YAML.load_file(yaml_file) rescue return
+    target = headers[hash]
+    return unless Mailbox.message_visible? target # don't allow access to private data
+    target
+  end
+
+  #
+  # Find a message
+  #
+  def find(hash)
+    headers = YAML.load_file(yaml_file) rescue {}
+    return unless Mailbox.message_visible? headers[hash] # don't allow access to private data
+
+    file = File.join(dir, hash) 
+    # TODO why check the dir?
+    if Dir.exist? dir and File.exist? file
+      email = File.read(file, encoding: Encoding::BINARY)
+    end
+
+    Message.new(self, hash, headers[hash], email) if email
+  end
+  
+
+  #
+  # Find the source message; return the raw data
+  #
+  def orig(hash)
+    headers = YAML.load_file(yaml_file) rescue {}
+    return unless Mailbox.message_visible? headers[hash] # don't allow access to private data
+ 
+    file = File.join(dir, hash + '.orig') 
+    File.read(file, encoding: Encoding::BINARY) if File.exist? file
+  end
+
+  # Is the list wanted by the caller?
+  def list_wanted?(message)
+    true
+  end
+
+  # Is the message visible to the caller?
+  # e.g. private and security lists are generally not visible to all
+  def self.message_visible?(message)
+    message and not %w(private security).include? message[:list] # TODO this is just a test
+  end
+
+  def message_active?(status)
+    status == nil or status == ''
+  end
+
+  #
+  # return headers (client view)
+  #
+  def client_headers
+    # fetch a list of headers for all messages in the mailbox
+    messages = YAML.load_file(yaml_file) rescue {}
+    headers = messages.to_a.select do |id, message|
+      message_active?(message[:status]) && Mailbox.message_visible?(message) && list_wanted?(message)
+    end
+
+    # extract relevant fields from the headers
+    headers.map! do |id, message|
+      {
+        id: id,
+        timestamp: message[:timestamp],
+        list: message[:list],
+        domain: message[:domain],
+        allow: message[:allow],
+        accept: message[:accept],
+        reject: message[:reject],
+        return_path: message['Return-Path'],
+        from: message['From'],
+        subject: message['Subject'],
+        status: message[:status],
+        date: message['Date'] || '',
+      }
+    end
+
+    # Look for next box (currently previous day)
+    # TODO no need to do this for events where a box has been updated
+    nextmbox = nil
+    # find the most recent 10 daily files
+    available = Dir["#{ARCHIVE}/*.yml"].map{|f| File.basename(f, '.yml')}.select{|f| f =~ %r{^#{MBOX_RE}$}}.sort.reverse[0..9]
+    index = available.find_index {|e| e < @name} # next oldest date from current
+    if index
+        nextmbox = available[index].untaint
+    end
+
+    {
+      nextmbox: nextmbox,
+      source: @name, # Same for all headers
+      headers: headers,
+    }
+  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
+
+  private # these methods expose details of the storage
+
+  #
+  # name of associated yaml file
+  #
+  def yaml_file
+    "#{ARCHIVE}/#{@name}.yml"
+  end
+  
+  #
+  # name of associated directory
+  #
+  def dir
+    "#{ARCHIVE}/#{@name}.mail"
+  end
+
+end
diff --git a/www/moderation/desk/models/message.rb b/www/moderation/desk/models/message.rb
new file mode 100644
index 0000000..78814ea
--- /dev/null
+++ b/www/moderation/desk/models/message.rb
@@ -0,0 +1,365 @@
+#
+# Encapsulate access to messages
+#
+
+require 'digest'
+require 'mail'
+require 'time'
+
+require_relative 'attachment.rb'
+
+class Message
+  attr_reader :headers
+
+  SIG_MIMES = %w(application/pkcs7-signature application/pgp-signature)
+
+  #
+  # create a new message
+  #
+  def initialize(mailbox, hash, headers, email)
+    @hash = hash
+    @mailbox = mailbox
+    @headers = headers
+    @email = email
+  end
+
+  #
+  # find an attachment
+  #
+  def find(name)
+    name = name[1..-2] if name =~ /^<.*>$/ # drop enclosing <> if present
+    name = name[2..-1] if name.start_with? './'
+    name = name.dup.force_encoding('utf-8')
+
+    headers = (@headers[:attachments] || []).find do |attach|
+      attach[:name] == name or URI.decode(attach[:name]) == name or
+        attach['Content-ID'].to_s == '<' + name + '>'
+    end
+
+    if headers
+      part = mail.attachments.find do |attach| 
+        attach.filename == name or URI.decode(attach.filename) == name or
+         attach['Content-ID'].to_s == '<' + name + '>'
+      end
+      Attachment.new(self, headers, part)
+    end
+  end
+
+  #
+  # accessors
+  #
+
+  def mail
+    @mail ||= Mail.new(@email)
+  end
+
+  def raw
+    @email
+  end
+
+  def id
+    @headers['Message-ID']
+  end
+
+  def date
+    mail[:date]
+  end
+
+  def from
+    mail[:from]
+  end
+
+  def return_path
+    mail.return_path
+  end
+
+  def to
+    mail[:to]
+  end
+
+  def cc
+    @headers[:cc]
+  end
+
+  def cc=(value)
+    value=value.split("\n") if String === value
+    @headers[:cc]=value
+  end
+
+  def bcc
+    @headers[:bcc]
+  end
+
+  def bcc=(value)
+    value=value.split("\n") if String === value
+    @headers[:bcc]=value
+  end
+
+  def subject
+    mail.subject
+  end
+
+  def html_part
+    mail.html_part
+  end
+
+  def text_part
+    mail.text_part
+  end
+
+  def self.attachments(headers)
+    headers[:attachments] || []
+  end
+
+  def attachments
+    Message.attachments(@headers)
+  end
+
+  #
+  # attachment operations: update, replace, delete
+  #
+
+  def update_attachment name, values
+    attachment = find(name)
+    if attachment
+      attachment.headers.merge! values
+      write_headers
+    end
+  end
+
+  def replace_attachment name, values
+    attachment = find(name)
+    if attachment
+      index = @headers[:attachments].find_index(attachment.headers)
+      @headers[:attachments][index, 1] = Array(values)
+      write_headers
+    end
+  end
+
+  def delete_attachment name
+    attachment = find(name)
+    if attachment
+      @headers[:attachments].delete attachment.headers
+      @headers[:status] = :deleted if @headers[:attachments].empty?
+      write_headers
+    end
+  end
+
+  #
+  # write updated headers to disk
+  #
+  def write_headers
+    @mailbox.write_headers(@hash, @headers)
+  end
+
+  #
+  # write email to disk
+  #
+  def write_email
+    @mailbox.write_email(@hash, @email)
+  end
+
+  #
+  # write one or more attachments to directory containing an svn checkout
+  #
+  def write_svn(repos, filename, *attachments)
+    # drop all nil and empty values
+    attachments = attachments.flatten.reject {|name| name.to_s.empty?}
+
+    # if last argument is a Hash, treat it as name/value pairs
+    attachments += attachments.pop.to_a if Hash === attachments.last
+
+    if attachments.flatten.length == 1
+      ext = File.extname(attachments.first).downcase.untaint
+      find(attachments.first).write_svn(repos, filename + ext)
+    else
+      # validate filename
+      unless filename =~ /\A[a-zA-Z][-.\w]+\z/
+        raise IOError.new("invalid filename: #{filename}")
+      end
+
+      # create directory, if necessary
+      dest = File.join(repos, filename).untaint
+      unless File.exist? dest
+        Dir.mkdir dest 
+        Kernel.system 'svn', 'add', dest
+      end
+
+      # write out selected attachment
+      attachments.each do |attachment, basename|
+        find(attachment).write_svn(repos, filename, basename)
+      end
+
+      dest
+    end
+  end
+
+  #
+  # Construct a reply message, and in the process merge the email
+  # address from the original message (from, to, cc) with any additional
+  # address provided on the call (to, cc, bcc).  Remove any duplicates
+  # that may occur not only due to the merge, but also comparing across
+  # field types (for example, don't cc an address listed on the to field).
+  #
+  # Finally, canonicalize (format) the email addresses and ensure that
+  # the results aren't marked ask tainted, as the Ruby SMTP library will
+  # refuse to send to tainted addresses, and in the secretary mail application
+  # the addresses are expected to come from the mail archive and the
+  # secretary, both of which can be trusted.
+  #
+  def reply(fields)
+    mail = Mail.new
+
+    # fill in the from address
+    mail.from = fields[:from]
+
+    # fill in the reply to headers
+    mail.in_reply_to = self.id
+    mail.references = self.id
+
+    # fill in the subject from the original email
+    if self.subject =~ /^re:\s/i
+      mail.subject = self.subject
+    elsif self.subject
+      mail.subject = 'Re: ' + self.subject
+    elsif fields[:subject]
+      mail.subject = fields[:subject]
+    end
+
+    # fill in the subject from the original email
+    mail.body = fields[:body]
+
+    # gather up the to, cc, and bcc addresses
+    to = []
+    cc = []
+    bcc = []
+
+    # process 'to' addresses on method call
+    if fields[:to]
+      Array(fields[:to]).compact.each do |addr|
+        addr = Message.liberal_email_parser(addr) if addr.is_a? String
+        next if to.any? {|a| a.address = addr.address}
+        to << addr
+      end
+    end
+
+    # process 'from' addresses from original email
+    self.from.addrs.each do |addr|
+      next if to.any? {|a| a.address == addr.address}
+      if fields[:to]
+        next if cc.any? {|a| a.address == addr.address}
+        cc << addr
+      else
+        to << addr
+      end
+    end
+
+    # process 'to' addresses from original email
+    if self.to
+      self.to.addrs.each do |addr|
+        next if to.any? {|a| a.address == addr.address}
+        next if cc.any? {|a| a.address == addr.address}
+        cc << addr
+      end
+    end
+
+    # process 'cc' addresses from original email
+    if self.cc
+      self.cc.each do |addr|
+        addr = Message.liberal_email_parser(addr) if addr.is_a? String
+        next if to.any? {|a| a.address == addr.address}
+        next if cc.any? {|a| a.address == addr.address}
+        cc << addr
+      end
+    end
+
+    # process 'cc' addresses on method call
+    if fields[:cc]
+      Array(fields[:cc]).compact.each do |addr|
+        addr = Message.liberal_email_parser(addr) if addr.is_a? String
+        next if to.any? {|a| a.address == addr.address}
+        next if cc.any? {|a| a.address == addr.address}
+        cc << addr
+      end
+    end
+
+    # process 'bcc' addresses on method call
+    if fields[:bcc]
+      Array(fields[:bcc]).compact.each do |addr|
+        addr = Message.liberal_email_parser(addr) if addr.is_a? String
+        next if to.any? {|a| a.address == addr.address}
+        next if cc.any? {|a| a.address == addr.address}
+        next if bcc.any? {|a| a.address == addr.address}
+        bcc << addr
+      end
+    end
+
+    # reformat and untaint email addresses
+    mail[:to] = to.map {|addr| addr.format.dup.untaint}
+    mail[:cc] = cc.map {|addr| addr.format.dup.untaint} unless cc.empty?
+    mail[:bcc] = bcc.map {|addr| addr.format.dup.untaint} unless bcc.empty?
+
+    # return the resulting email
+    mail
+  end
+
+  # get the message ID
+  def self.getmid(hdrs)
+    mid = hdrs[/^Message-ID:.*/i]
+    if mid =~ /^Message-ID:\s*$/i # no mid on the first line
+      # capture the next line and join them together
+      mid = hdrs[/^Message-ID:.*\r?\n .*/i].sub(/\r?\n/,'')
+    end
+    mid
+  end
+
+  #
+  # What to use as a hash for mail
+  #
+  def self.hash(message)
+    Digest::SHA1.hexdigest(getmid(message) || message)[0..9]
+  end
+
+  #
+  # parse a message, returning headers
+  #
+  def self.parse(message)
+    mail = Mail.read_from_string(message)
+
+    headers = Mailbox.headers(mail)
+
+    # add in attachments
+    if mail.attachments.length > 0
+
+      attachments = mail.attachments.map do |attach|
+        # replace generic octet-stream with a more specific one
+        mime = attach.mime_type
+        if mime == 'application/octet-stream'
+          filename = attach.filename.downcase
+          mime = 'application/pdf' if filename.end_with? '.pdf'
+          mime = 'application/png' if filename.end_with? '.png'
+          mime = 'application/gif' if filename.end_with? '.gif'
+          mime = 'application/jpeg' if filename.end_with? '.jpg'
+          mime = 'application/jpeg' if filename.end_with? '.jpeg'
+        end
+
+        description = {
+          name: attach.filename,
+          length: attach.body.to_s.length,
+          mime: mime
+        }
+
+        if description[:name].empty? and attach['Content-ID']
+          description[:name] = attach['Content-ID'].to_s
+        end
+
+        description.merge(Mailbox.headers(attach))
+      end
+
+      headers[:attachments] = attachments
+    end
+
+    headers
+  end
+
+end
diff --git a/www/moderation/desk/models/safetemp.rb b/www/moderation/desk/models/safetemp.rb
new file mode 100644
index 0000000..24bc7e9
--- /dev/null
+++ b/www/moderation/desk/models/safetemp.rb
@@ -0,0 +1,28 @@
+#
+# Tempfile in Ruby 2.3.0 has the unfortunate behavior of returning
+# an unsafe path and even blowing up when unlink is called in a $SAFE
+# environment.  This avoids those two problems, while forwarding all all other
+# method calls.
+#
+
+require 'tempfile'
+
+class SafeTempFile
+  def initialize *args
+    args << {} unless args.last.instance_of? Hash
+    args.last[:encoding] = Encoding::BINARY
+    @tempfile = Tempfile.new *args
+  end
+
+  def path
+    @tempfile.path.untaint
+  end
+
+  def unlink
+    File.unlink path
+  end
+
+  def method_missing symbol, *args
+    @tempfile.send symbol, *args
+  end
+end
diff --git a/www/moderation/desk/public/assets/bootstrap-min.css b/www/moderation/desk/public/assets/bootstrap-min.css
new file mode 100644
index 0000000..ed3905e
--- /dev/null
+++ b/www/moderation/desk/public/assets/bootstrap-min.css
@@ -0,0 +1,6 @@
+/*!
+ * Bootstrap v3.3.7 (http://getbootstrap.com)
+ * Copyright 2011-2016 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr [...]
+/*# sourceMappingURL=bootstrap.min.css.map */
\ No newline at end of file
diff --git a/www/moderation/desk/public/assets/bootstrap-min.js b/www/moderation/desk/public/assets/bootstrap-min.js
new file mode 100644
index 0000000..9bcd2fc
--- /dev/null
+++ b/www/moderation/desk/public/assets/bootstrap-min.js
@@ -0,0 +1,7 @@
+/*!
+ * Bootstrap v3.3.7 (http://getbootstrap.com)
+ * Copyright 2011-2016 Twitter, Inc.
+ * Licensed under the MIT license
+ */
+if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1||b[0]>3)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:" [...]
+this.activeTarget=b,this.clear();var c=this.selector+'[data-target="'+b+'"],'+this.selector+'[href="'+b+'"]',d=a(c).parents("li").addClass("active");d.parent(".dropdown-menu").length&&(d=d.closest("li.dropdown").addClass("active")),d.trigger("activate.bs.scrollspy")},b.prototype.clear=function(){a(this.selector).parentsUntil(this.options.target,".active").removeClass("active")};var d=a.fn.scrollspy;a.fn.scrollspy=c,a.fn.scrollspy.Constructor=b,a.fn.scrollspy.noConflict=function(){return  [...]
\ No newline at end of file
diff --git a/www/moderation/desk/public/assets/jquery-min.js b/www/moderation/desk/public/assets/jquery-min.js
new file mode 100644
index 0000000..644d35e
--- /dev/null
+++ b/www/moderation/desk/public/assets/jquery-min.js
@@ -0,0 +1,4 @@
+/*! jQuery v3.2.1 | (c) JS Foundation and other contributors | jquery.org/license */
+!function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElem [...]
+a.removeEventListener("load",S),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",S),a.addEventListener("load",S));var T=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)T(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h<i;h++)b(a[h],c,g?d: [...]
+null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&B(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(L);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),lb={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=mb[b]||r.find.attr;mb[b]=function(a,b,d){var e,f,g=b.toLowerCase();r [...]
diff --git a/www/moderation/desk/public/assets/vue.min.js b/www/moderation/desk/public/assets/vue.min.js
new file mode 100644
index 0000000..836793b
--- /dev/null
+++ b/www/moderation/desk/public/assets/vue.min.js
@@ -0,0 +1,6 @@
+/*!
+ * Vue.js v2.5.13
+ * (c) 2014-2017 Evan You
+ * Released under the MIT License.
+ */
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):t.Vue=e()}(this,function(){"use strict";function t(t){return void 0===t||null===t}function e(t){return void 0!==t&&null!==t}function n(t){return!0===t}function r(t){return"string"==typeof t||"number"==typeof t||"symbol"==typeof t||"boolean"==typeof t}function i(t){return null!==t&&"object"==typeof t}function o(t){return"[object Object]"===Nn.call(t)}funct [...]
\ No newline at end of file
diff --git a/www/moderation/desk/public/secmail.css b/www/moderation/desk/public/secmail.css
new file mode 100644
index 0000000..344d537
--- /dev/null
+++ b/www/moderation/desk/public/secmail.css
@@ -0,0 +1,114 @@
+header h1, header h3 {
+  margin-top: 0;
+}
+
+#messages table {
+  margin-left: 8px;
+}
+
+#messages td {
+  padding-right: 7px;
+  padding-left: 7px;
+}
+
+#messages button {
+  margin: 5px;
+}
+
+.similar td {
+  padding-right: 7px;
+}
+
+.selected {
+  background-color: yellow;
+}
+
+.deleted {
+  opacity: 0.5;
+}
+
+.doctype label {
+  display: block
+}
+
+.doctype input[type=radio] {
+  width: 3em
+}
+
+#parts h4 {
+  padding: 10px 1em;
+  background-color: #d9e9f7;
+}
+
+table.form {
+  width: 100%;
+  margin-left: 1em;
+  margin-top: 2em;
+}
+
+.form input, .form textarea, .form select {
+  width: 100%;
+}
+
+.form input:invalid, .form select:invalid, .form textarea:invalid {
+  border: 1px solid red;
+}
+
+.form th {
+  max-width: 4em;
+  height: 2.5em;
+  font-weight: normal;
+}
+
+form .btn {
+  display: block;
+  margin: auto;
+  margin-top: 0.5em;
+}
+
+#attachments li.attachment {
+  width: 100%
+}
+
+div.buttons {
+  text-align: center;
+  margin: 1em 0;
+}
+
+ul#editPart {
+  padding: 0 1.5em;
+}
+
+#editPart li {
+  list-style: none;
+  padding: 8;
+  margin: 16px 0;
+}
+
+.busy, .busy input, .busy input:disabled, .busy .contextMenu li:hover {
+  cursor: wait;
+}
+
+.divider {
+  height: 2px;
+  width:100%;
+  margin: 9px 0;
+  padding: 0 4px;
+  background-color: #ccc;
+  }
+
+.spinner {
+  position: absolute;
+  z-index: -1;
+  right: 0;
+  top: 0;
+}
+
+pre.bg-info {
+  white-space: pre-wrap;
+  word-break: normal;
+}
+
+#parts table {
+  margin-top: 14px; 
+}
diff --git a/www/moderation/desk/public/transparent.png b/www/moderation/desk/public/transparent.png
new file mode 100644
index 0000000..6a69b19
Binary files /dev/null and b/www/moderation/desk/public/transparent.png differ
diff --git a/www/moderation/desk/server.rb b/www/moderation/desk/server.rb
new file mode 100644
index 0000000..db0e3f6
--- /dev/null
+++ b/www/moderation/desk/server.rb
@@ -0,0 +1,198 @@
+#
+# Simple web server that routes requests to views based on URLs.
+#
+
+require 'wunderbar/sinatra'
+require 'wunderbar/bootstrap'
+require 'wunderbar/vue'
+require 'ruby2js/es2017/strict'
+require 'ruby2js/filter/functions'
+require 'ruby2js/filter/require'
+#require 'erb'
+#require 'sanitize'
+require 'escape'
+
+require_relative 'helpers'
+require_relative 'models/mailbox'
+require_relative 'models/safetemp'
+require_relative 'models/events'
+
+
+# monkey patch mail gem to work around a regression introduced in 2.7.0:
+# https://github.com/mikel/mail/pull/1168
+module Mail
+  class Message
+    def raw_source=(value)
+      @raw_source = ::Mail::Utilities.to_crlf(value)
+    end
+  end
+
+  module Utilities
+    def self.safe_for_line_ending_conversion?(string)
+      if RUBY_VERSION >= '1.9'
+        string.ascii_only? or 
+          (string.encoding != Encoding::BINARY and string.valid_encoding?)
+      else
+        string.ascii_only?
+      end
+    end
+  end
+end
+
+require 'whimsy/asf'
+ASF::Mail.configure
+
+set :show_exceptions, true
+
+disable :logging # suppress log of requests to stderr/error.log
+
+get '/' do
+  # Ensure trailing slash is present
+  redirect to('/') if env['REQUEST_URI'] == env['SCRIPT_NAME']
+  _html :main
+end
+
+# initial list of messages
+get '/messages' do # must agree with src in main.html
+
+  @mbox = Mailbox.mboxname()
+  @messages = Mailbox.new(@mbox).client_headers
+
+  @cssmtime = File.mtime('public/secmail.css').to_i
+  @appmtime = Wunderbar::Asset.convert(File.join(settings.views, 'app.js.rb')).mtime.to_i
+  _html :messages # must agree with views/*.html.rb
+end
+
+# alias for root directory
+get '/index.html' do
+  call env.merge('PATH_INFO' => '/')
+end
+
+# initial list of messages
+get %r{/(#{MBOX_RE})/messages} do |mbox|
+
+  @mbox = mbox
+  @messages = Mailbox.new(@mbox).client_headers
+
+  @cssmtime = File.mtime('public/secmail.css').to_i
+  @appmtime = Wunderbar::Asset.convert(File.join(settings.views, 'app.js.rb')).mtime.to_i
+  _html :messages # must agree with views/*.html.rb
+end
+
+# support for fetching next lot of messages
+get %r{/(#{MBOX_RE})} do |mbox|
+  @mbox = mbox
+  _json Mailbox.new(@mbox).client_headers
+end
+
+# retrieve a single message (same as body now)
+get %r{/(#{MBOX_RE})/(#{HASH_RE})/} do |mbox, hash|
+  @message = Mailbox.new(mbox).find(hash)
+  return [404, {}, 'Message not found or is not accessible'] unless @message
+  @attachments = @message.attachments
+  @headers = @message.headers.dup
+  @headers.delete :attachments
+  @cssmtime = File.mtime('public/secmail.css').to_i
+  _html :body #:message # must agree with views/*.html.rb
+end
+
+# posted actions
+post '/actions/:file' do
+  _json :"actions/#{params[:file]}"
+end
+
+# update a single message status (:Accept, :Reject, :Spam etc)
+patch %r{/(#{MBOX_RE})/(#{HASH_RE})/} do |mbox, hash|
+
+  updates = JSON.parse(request.env['rack.input'].read)
+
+  success = Mailbox.patch!(mbox, hash, updates)
+
+  return [404, {}, 'Message not found or is not accessible or could not be updated'] unless success
+
+  [204, {}, '']
+end
+
+# message body for a single message
+get %r{/(#{MBOX_RE})/(#{HASH_RE})/_body_} do |mbox, hash|
+  @message = Mailbox.new(mbox).find(hash)
+  return [404, {}, 'Message not found or is not accessible'] unless @message
+  @attachments = @message.attachments
+  @headers = @message.headers.dup
+  @headers.delete :attachments
+  @cssmtime = File.mtime('public/secmail.css').to_i
+  _html :body # uses view/body.html.rb
+end
+
+# header data for a single message
+get %r{/(#{MBOX_RE})/(#{HASH_RE})/_headers_} do |mbox, hash|
+  @headers = Mailbox.new(mbox).headers(hash)
+  return [404, {}, 'Message not found or is not accessible'] unless @headers
+  _html :headers # uses view/headers.html.rb
+end
+
+# raw data for a single message
+get %r{/(#{MBOX_RE})/(#{HASH_RE})/_raw_} do |mbox, hash|
+  message = Mailbox.new(mbox).find(hash)
+  return [404, {}, 'Message not found or is not accessible'] unless message
+  [200, {'Content-Type' => 'text/plain'}, message.raw]
+end
+
+# original data for a single message (testing)
+get %r{/(#{MBOX_RE})/(#{HASH_RE})/_orig_} do |mbox, hash|
+  message = Mailbox.new(mbox).orig(hash)
+  return [404, {}, 'Message not found or is not accessible'] unless message
+  [200, {'Content-Type' => 'text/plain'}, message]
+end
+
+# intercede for potentially dangerous message attachments
+get %r{/(#{MBOX_RE})/(#{HASH_RE})/_danger_/(.*?)} do |mbox, hash, name|
+  message = Mailbox.new(mbox).find(hash)
+  return [404, {}, 'Message not found or is not accessible'] unless message
+
+  @part = message.find(URI.decode(name))
+  return [404, {}, 'Attachment not found'] unless @part
+
+  _html :danger
+end
+
+# a specific attachment for a message (e.g. CID)
+# WARNING catches anything not handled above!
+get %r{/(#{MBOX_RE})/(#{HASH_RE})/(.*?)} do |mbox, hash, name|
+  message = Mailbox.new(mbox).find(hash)
+  return [404, {}, 'Message not found or is not accessible'] unless message
+  
+  part = message.find(URI.decode(name))
+  return [404, {}, 'Attachment not found'] unless part
+
+  [200, {'Content-Type' => part.content_type}, part.body.to_s]
+end
+
+# event stream for server sent events (a.k.a EventSource)
+get '/events', provides: 'text/event-stream' do
+  events = Events.new
+
+  stream :keep_open do |out|
+    out.callback {events.close}
+
+    loop do
+      event = events.pop
+
+      if Hash === event or Array === event
+        out << "data: #{JSON.dump(event)}\n\n"
+      elsif event == :heartbeat
+        out << ":\n"
+      elsif event == :exit
+        out.close
+        break
+      else
+        out << "data: #{event.inspect}\n\n"
+      end
+    end
+  end
+end
+
+# catch everything else
+get %r{/(.+)} do |req|
+  [500, {}, "I don't understand the request: #{req}"]
+end
diff --git a/www/moderation/desk/templates/reject-off-topic.erb b/www/moderation/desk/templates/reject-off-topic.erb
new file mode 100644
index 0000000..082777e
--- /dev/null
+++ b/www/moderation/desk/templates/reject-off-topic.erb
@@ -0,0 +1,7 @@
+%%% Start comment
+Sorry, but the email is off-topic for this mailing list (<%= @mailing_list %>)
+
+Please etc.
+
+<%= @sig %>
+%%% End comment
\ No newline at end of file
diff --git a/www/moderation/desk/views/actions/email.json.rb b/www/moderation/desk/views/actions/email.json.rb
new file mode 100644
index 0000000..dbc16bc
--- /dev/null
+++ b/www/moderation/desk/views/actions/email.json.rb
@@ -0,0 +1,72 @@
+require 'erb'
+
+require_relative '../../defines'
+
+# TODO method this belongs elsewhere
+def template(name)
+  path = File.expand_path("../../../templates/#{name}.erb", __FILE__.untaint)
+  ERB.new(File.read(path.untaint).untaint).result(binding)
+end
+
+
+# extract message headers
+headers = Mailbox.hdrs(@id)
+
+return {error: 'not found', id: @id} unless headers
+  
+allow = headers[:allow]
+accept = headers[:accept]
+reject = headers[:reject]
+
+body = ''
+case @action
+  # These values must agree with messages.js.rb
+  when ':Accept'
+    to = accept
+  when ':AcceptAllow'
+    to = [accept, allow]
+  when ':Reject'
+    to = reject
+    body = template('reject-off-topic') # TODO
+  else
+    return {error: 'action missing or unknown', action: @action}
+end
+#
+# obtain per-user information
+user = env.user
+person = ASF::Person.find(user)
+
+from = "#{person.public_name} <#{...@apache.org>".untaint
+
+#
+#########################################################################
+##                            build email                               #
+#########################################################################
+#
+# build new message
+mail = Mail.new
+mail.subject = "#{@action} #{@id}"
+mail.to = to
+mail.from = from
+
+mail.text_part = body
+
+#  # deliver mail
+#  complete do
+#    mail.deliver!
+#  end
+
+{
+  id: @id,
+  action: @action,
+#  headers: headers.inspect,
+#  allow: allow,
+#  accept: accept,
+#  reject: reject,
+#  from: from,
+#  [:@_scope, :@_target, :@file, :@selected, :@name, :@default_layout, :@preferred_extension, :@app, :@template_cache, :@request, :@response]"
+#  vars: self.instance_variables.inspect,
+#  request: @request.inspect, # Sinatra
+#  response: @response.inspect, # Sinatra
+  mail: mail.to_s,
+}
\ No newline at end of file
diff --git a/www/moderation/desk/views/app.js.rb b/www/moderation/desk/views/app.js.rb
new file mode 100644
index 0000000..8657fb3
--- /dev/null
+++ b/www/moderation/desk/views/app.js.rb
@@ -0,0 +1,10 @@
+# This creates the app.js script from all the other *.js.rb files
+
+require_relative 'vue-config'
+
+require_relative 'http'
+require_relative 'status'
+
+require_relative 'messages'
+
+require_relative '../defines'
\ No newline at end of file
diff --git a/www/moderation/desk/views/body.html.rb b/www/moderation/desk/views/body.html.rb
new file mode 100644
index 0000000..c980c59
--- /dev/null
+++ b/www/moderation/desk/views/body.html.rb
@@ -0,0 +1,124 @@
+#
+# View the email content
+#
+
+_html do
+  _link rel: 'stylesheet', type: 'text/css', 
+    href: "../../secmail.css?#{@cssmtime}"
+
+  #
+  # Selected headers
+  #
+  _table do
+    if @headers[:list]
+      _tr do
+        _td 'Mailing-list:'
+        _td @headers[:list] + '@' + @headers[:domain]
+      end
+    end
+    _tr do
+      _td 'From:'
+      _td @message.from
+    end
+
+    _tr do
+      _td 'Return-Path: '
+      _td @message.return_path
+    end
+
+    _tr do
+      _td 'To:'
+      _td @message.to
+    end
+
+    if @message.cc and not @message.cc.empty?
+      _tr do
+        _td 'Cc:'
+        _td @message.cc.join(', ')
+      end
+    end
+
+    _tr do
+      _td 'Subject:'
+      _td @message.subject || '(empty)'
+    end
+    @attachments.each do |att|
+      attname = att[:name]
+      attid = att['Content-ID']
+      if attname =~ /\.(pdf|txt|jpeg|jpg|gif|png)$/i
+        link = "#{attid}"
+      else
+        link = "_danger_/#{attid}"
+      end
+      _tr do
+        _td 'Attachment: '
+        _td do
+          _a attname, href: link, target: 'content'
+        end
+      end
+    end
+  end
+
+  _p
+
+  #
+  # Try various ways to display the body
+  #
+  if @message.html_part
+    _div do # N.B. this is needed for HTML output
+      container = @message.html_part
+      body = container.body.to_s
+
+      # Debug
+      _.comment! "html_part: body.encoding=#{body.encoding} container.charset=#{container.charset}"
+    
+      if body.encoding == Encoding::BINARY and container.charset
+        body.force_encoding(container.charset) rescue nil
+      end
+
+      nodes = _{body.encode('utf-8', invalid: :replace, :undef => :replace)}
+
+      fixup_images(nodes)
+    end
+  elsif @message.text_part
+    container = @message.text_part
+    body = container.body.to_s
+
+    # Debug
+    _.comment! "text_part: body.encoding=#{body.encoding} container.charset=#{container.charset}"
+  
+    if body.encoding == Encoding::BINARY and container.charset
+      body.force_encoding(container.charset) rescue nil
+    end
+
+    _pre.bg_info body.encode('utf-8', invalid: :replace, :undef => :replace)
+  else # must be a non-multi part mail
+    container = @message.mail
+    body = container.body.to_s
+    
+    # Debug
+    _.comment! "body.encoding=#{body.encoding} container.charset=#{container.charset} container.mime_type=#{container.mime_type}"
+    
+    if body.encoding == Encoding::BINARY and container.charset
+      body.force_encoding(container.charset) rescue nil
+    end
+    
+    if container.mime_type == 'text/plain'
+
+      _pre.bg_info body.encode('utf-8', invalid: :replace, :undef => :replace)
+
+    elsif container.mime_type == 'text/html'
+
+      _div do # N.B. this is needed for HTML output
+        nodes = _{body.encode('utf-8', invalid: :replace, :undef => :replace)}
+    
+        fixup_images(nodes)
+      end
+
+    else
+
+      _p "(Cannot handle mime-type #{mime_type})"
+
+    end
+  end
+end
diff --git a/www/moderation/desk/views/danger.html.rb b/www/moderation/desk/views/danger.html.rb
new file mode 100644
index 0000000..3d6006d
--- /dev/null
+++ b/www/moderation/desk/views/danger.html.rb
@@ -0,0 +1,21 @@
+_html do
+  _h1.bg_danger 'Potentially Dangerous Content'
+
+  _table.table.table_bordered do
+    _tbody do
+      @part.headers.each do |name, value|
+        next if name == :mime
+        _tr do
+          _td name.to_s
+          if name == :name
+            _td do
+              _a value, href: "../#{value}"
+            end
+          else
+            _td value
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/www/moderation/desk/views/headers.html.rb b/www/moderation/desk/views/headers.html.rb
new file mode 100644
index 0000000..aa499ae
--- /dev/null
+++ b/www/moderation/desk/views/headers.html.rb
@@ -0,0 +1,7 @@
+#
+# Dump headers
+#
+
+_html do
+  _pre YAML.dump(@headers)
+end
diff --git a/www/moderation/desk/views/http.js.rb b/www/moderation/desk/views/http.js.rb
new file mode 100644
index 0000000..7fd7c06
--- /dev/null
+++ b/www/moderation/desk/views/http.js.rb
@@ -0,0 +1,135 @@
+#
+# 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)
+    return Promise.new do |resolve, reject|
+      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
+          begin
+            if xhr.status == 200
+              data = JSON.parse(xhr.responseText) 
+              if data.exception
+                reject(data.exception)
+              else
+                resolve(data)
+              end
+            else
+              HTTP._reject(xhr, reject)
+            end
+          rescue => e
+            reject(e)
+          end
+        end
+      end
+
+      xhr.send(JSON.stringify(data))
+    end
+  end
+
+  # "AJAX" style patch request to the server, with a callback
+  def self.patch(target, data)
+    return Promise.new do |resolve, reject|
+      xhr = XMLHttpRequest.new()
+      xhr.open('PATCH', target, true)
+      xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
+
+      def xhr.onreadystatechange()
+        if xhr.readyState == 4
+          begin
+            if xhr.status == 200
+              data = JSON.parse(xhr.responseText) 
+              if data.exception
+                reject(data.exception)
+              else
+                resolve(data)
+              end
+            elsif xhr.status == 204
+              resolve()
+            else
+              HTTP._reject(xhr, reject)
+            end
+          rescue => e
+            reject(e)
+          end
+        end
+      end
+
+      xhr.send(JSON.stringify(data))
+    end
+  end
+
+  # "AJAX" style get request to the server, with a callback
+  def self.get(target, type)
+    console.log 'GET ' + target + ' ' + type
+    return Promise.new do |resolve, reject|
+      xhr = XMLHttpRequest.new()
+
+      def xhr.onreadystatechange()
+        if xhr.readyState == 4
+          begin
+            if xhr.status == 200
+              if type == :json
+                data = xhr.response || JSON.parse(xhr.responseText) 
+              else
+                data = xhr.responseText
+              end
+
+              resolve data
+            else
+              HTTP._reject(xhr, reject)
+            end
+          rescue => e
+            reject e
+          end
+        end
+      end
+
+      if target =~ /^https?:/
+        xhr.open('GET', target, true)
+        xhr.setRequestHeader("Accept", "application/json") if type == :json
+      else
+        xhr.open('GET', target, true)
+      end
+
+      xhr.responseType = type
+      xhr.send()
+    end
+  end
+
+  # common rejection logic
+  def self._reject(xhr, reject)
+    if not xhr.status
+      reject "Server unavailable"
+    elsif xhr.status == 404
+      reject "Not found"
+    else
+      console.log xhr.response
+      if not xhr.response
+        reject "Exception - #{xhr.statusText}"
+      elsif xhr.response.exception
+        reject "Exception\n#{xhr.response.exception}"
+      else
+        text = xhr.responseText
+        begin
+          json = JSON.parse(text)
+          text = "Exception: #{json.exception}" if json.exception
+        rescue => e
+        end
+        reject text
+      end
+    end
+  rescue => e
+    reject e
+  end
+end
diff --git a/www/moderation/desk/views/main.html.rb b/www/moderation/desk/views/main.html.rb
new file mode 100644
index 0000000..64464f1
--- /dev/null
+++ b/www/moderation/desk/views/main.html.rb
@@ -0,0 +1,14 @@
+#
+# Layout for main page
+# LH: list of messages
+# RH: detail of selected message
+#
+
+_html do
+  _title 'ASF Moderation Helper'
+
+  _frameset cols: '45%, *' do
+    _frame src: 'messages' # list of clickable messages must agree with server.rb
+    _frame name: 'content' # name must agree with link target in message.js.rb
+  end
+end
diff --git a/www/moderation/desk/views/messages.html.rb b/www/moderation/desk/views/messages.html.rb
new file mode 100644
index 0000000..d2c94a3
--- /dev/null
+++ b/www/moderation/desk/views/messages.html.rb
@@ -0,0 +1,27 @@
+# Listing of messages
+
+_html do
+  if ENV["RACK_BASE_URI"].to_s + '/' == _.env['REQUEST_URI']
+    # not sure why Passenger/rack is eating the trailing slash here.
+    # add it back in.
+    _base href: _.env['REQUEST_URI']
+  end
+
+  _title 'ASF Moderation Helper'
+  _link rel: 'stylesheet', type: 'text/css', href: "secmail.css?#{@cssmtime}"
+
+  _header_ do
+    _h1.bg_success do
+      _a 'ASF Moderation Helper', href: '.', target: '_top'
+    end
+  end
+#  _ __FILE__
+
+  _div_.messages! # must agree with below (and CSS)
+
+  _script src: "./app.js?#{@appmtime}"
+  _.render '#messages' do # must agree with above
+    _Messages mbox: @mbox, messages: @messages
+  end
+
+end
diff --git a/www/moderation/desk/views/messages.js.rb b/www/moderation/desk/views/messages.js.rb
new file mode 100644
index 0000000..bfb8cc9
--- /dev/null
+++ b/www/moderation/desk/views/messages.js.rb
@@ -0,0 +1,321 @@
+#
+# List page showing messages
+#
+
+class Messages < Vue
+  def initialize
+    console.log "initialise"
+    @selected = nil
+    @messages = []
+    @checking = false
+    @fetched = false
+    @nextmbox = nil
+  end
+
+  def render
+    console.log "render"
+    if not @messages
+      _p.container_fluid 'All documents have been processed.'
+    else
+      _p "Count: #{@messages.length}"
+
+      # The name values must agree with action scripts such as email.json.rb
+      _button.btn.btn_info 'Accept and Allow', onClick: self.mark, name: ACCEPTALLOW, disabled: !@selected
+      _button.btn.btn_info 'Accept only',      onClick: self.mark, name: ACCEPT, disabled: !@selected
+      _button.btn.btn_info 'Reject',           onClick: self.mark, name: REJECT, disabled: !@selected
+      _button.btn.btn_info 'This is Spam',     onClick: self.mark, name: MARKSPAM, disabled: !@selected
+      _button.btn.btn_info 'Unmark Spam',      onClick: self.undo, disabled: Status.undoStack.empty?
+      # TODO accept subscription request - or just re-use Accept?
+      # COuld use Reject as well to tell the user why not accepting the request
+      # Also want to be able to ignore/delete the request
+
+      _table.table do
+
+        _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 # should be temporary until next response
+            color = 'selected' if message.href == @selected
+
+            row_options = {
+              :class => color,
+              on: {click: self.selectRow, doubleClick: self.nav}
+            }
+
+            _tr row_options do
+              _td :id => message.href do # Id needed for mouse selection
+                _a message.subject, href: "#{message.href}_body_", target: 'content'
+#                _ message.subject
+                _br
+                _ message.from
+                _br
+                _a message.return_path, href: "#{message.href}_headers_", target: 'content'
+#                _ message.return_path
+              end
+              _td do
+                _ "#{message.list}@#{message.domain}"
+                _br
+                _ Date.new(message.timestamp*1000.to_i).toISOString() if message.timestamp
+                # href must == message.href otherwise selection does not work
+                # Note: frame has URL of href
+                # target must agree with name in main.html.rb
+                _br
+                _a message.href, href: "#{message.href}_raw_", target: 'content'
+              end 
+            end
+          end
+        end
+      end
+    end
+
+    unless Status.undoStack.empty?
+      _button.btn.btn_info 'undo delete', onClick: self.undo
+    end
+  end
+
+  # initialize; store passed messages
+  def beforeMount()
+    Status.emptyStack()
+    @nextmbox = @@messages.nextmbox if @@messages
+    self.merge @@messages if @@messages
+    console.log "beforeMount next #{@nextmbox}"
+  end
+
+  def fetch_mbox(&block)
+    console.log "fetch_mbox> #{@nextmbox}"
+    HTTP.get(@nextmbox, :json).then {|response|
+      @nextmbox = response.nextmbox
+
+      # add messages to list
+      self.merge response
+
+      # if block provided, call it
+      block() if block and block.is_a? Function
+    }.catch {|error|
+      console.log error
+      alert error
+    }
+    console.log "fetch_mbox< #{@nextmbox}"
+  end
+
+  def handle_response()
+    console.log "handle_response next #{@nextmbox} max #{@max_fetch}"
+    @max_fetch -= 1
+    if @nextmbox and @max_fetch > 0
+      fetch_mbox() do handle_response() end
+    else 
+      # select oldest message
+      self.selectRow Status.selected || @messages.first
+    end
+  end
+
+  # on initial load, subscribe to keyboard and
+  # server side events, and initialize selected item.
+  def mounted()
+    console.log "mounted next #{@nextmbox}"
+    @max_fetch = 15 # prevent excess fetches
+    fetch_mbox() do handle_response() end if @nextmbox
+
+    window.onkeydown = self.keydown
+
+    # when events are received, update messages
+    events = EventSource.new('events')
+    events.addEventListener :message do |event|
+      messages = JSON.parse(event.data).messages
+      if messages
+        console.log "Message event, source: #{messages.source} count: #{messages.headers.length}"
+      else
+        console.log event
+      end
+      self.merge messages if messages
+    end
+
+    # close connection on exit
+    window.addEventListener :unload do |event|
+      events.close()
+    end
+
+    # select row
+    console.log "mounted selected #{Status.selected}"
+    self.selectRow Status.selected if @messages.length > 0
+  end
+
+  # when content changes, ensure selected message is visible
+  def updated()
+    if @selected
+      selected = document.querySelector("td[id='#{@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
+
+  # merge new messages into the list
+  def merge(messages)
+    source = messages.source
+    headers = messages.headers
+    console.log "merge: " + source + " Count: " + headers.length
+    # Drop all entries from the same source file
+    temp = @messages.select { |k| not k.href.start_with? source+"/"}
+    headers.each do |hdr|
+      hdr[:href] = source + "/" + hdr[:id] + "/" # construct the href
+      hdr[:id].delete # no longer needed
+      # Where to insert the new message (ascending order - oldest first as they may expire)
+      index = temp.find_index do |old| 
+        old.timestamp > hdr.timestamp
+      end
+      if index == -1 # not found, i.e. new time is > all entries, so add at end
+        temp << hdr
+      else # found a newer entry at index, want to insert ahead of it
+        temp.splice index, 0, hdr
+      end
+    end
+    @messages = temp
+    Vue.forceUpdate() unless messages.empty?
+  end
+
+  # update @selected, given either a DOM event or a message
+  def selectRow(object)
+    hasLink = nil # did we click a link?
+#    console.log "SelectRow #{object} #{typeof(object)}"
+    if not object
+#      console.log "A"
+      href = nil
+    elsif typeof(object) == 'string'
+#      console.log "B"
+      href = object
+    elsif object.respond_to? :currentTarget
+#      console.log "C #{object.srcElement.href}"
+      hasLink = object.srcElement.href
+      href = object.currentTarget.querySelector('td').getAttribute('id')
+    elsif object.respond_to? :href
+#      console.log "D"
+      href = object.href
+    else
+#      console.log "E"
+      href = object
+    end
+
+    # ensure selected message is not deleted
+    index = @messages.find_index {|m| m.href == href}
+    index -= 1 while index >= 0 and @messages[index].status == :deleted
+    # else find first non-deleted entry
+    index = @messages.find_index {|m| m.status != :deleted} if index == -1
+
+    previous = @selected
+    @selected = Status.selected = (index >= 0 ? @messages[index].href : nil)
+#    console.log "SelectRow href #{href} index #{index} previous #{previous} selected #{@selected} S.s #{Status.selected}"
+    if @selected # display the message details
+      # don't try to display if we have just clicked a link
+      parent.content.location=@selected unless hasLink
+    else
+#      parent.message.document.body.textContent='' TODO
+    end
+  end
+
+  # navigate
+  def nav(event)
+    self.selectRow(event)
+    window.location.href = @selected
+    window.getSelection().removeAllRanges()
+    event.preventDefault()
+  end
+
+  def send_email(data, &block)
+    console.log "send_email > #{data.inspect}"
+    HTTP.post('actions/email', data).then {|response|
+      console.log "send_email < #{response.inspect}"
+      alert response[:mail]
+      block() if block
+    }.catch {|error|
+      alert error
+    }
+  end
+
+  def mark(event)
+    name=event.srcElement.name
+    selected = @selected
+    if selected
+      event.preventDefault()
+      # mark item as delete pending
+      index = @messages.find_index {|m| m.href == selected}
+      @messages[index].status = :deleted if index >= 0
+      # move selected pointer to next message
+      if index >= 0 and index < @messages.length - 1
+        self.selectRow @messages[index+1]
+      elsif index > 0 and index == @messages.length - 1
+        self.selectRow @messages[index-1]
+      else
+        self.selectRow nil
+      end
+
+      unless name == MARKSPAM
+        send_email({id: selected, action: name}) {
+          alert "Now patch"
+        }
+      else
+        # TEMP don't delete
+        HTTP.patch(selected, status: name).then {
+          @messages[index].status = :deleted if index >= 0
+          Status.pushDeleted selected if name == MARKSPAM
+          self.selectRow @selected # selected above
+          Vue.forceUpdate()
+        }.catch {|error|
+          alert error
+        }
+      end
+    else
+      alert "Please select a row"
+    end
+  end
+
+  def undo(event)
+    message = Status.popStack()
+    selected = @messages.find {|m| m.href == message}
+    if selected
+      selected.status = :deletePending
+    end
+    # send request to server to remove delete status
+    HTTP.patch(message, status: nil).then {
+      Vue.forceUpdate()
+      self.selectRow message
+    }.catch {|error|
+      alert error
+    }
+  end
+
+  # handle keyboard events
+  def keydown(event)
+    if event.keyCode == 38 # up
+      index = @messages.find_index {|m| m.href == @selected}
+      self.selectRow @messages[index-1] if index > 0
+      event.preventDefault()
+
+    elsif event.keyCode == 40 # down
+      index = @messages.find_index {|m| m.href == @selected} + 1
+      while index < @messages.length and @messages[index].status == :deleted
+        index += 1
+      end
+      self.selectRow @messages[index] if index < @messages.length
+      event.preventDefault()
+
+    elsif event.keyCode == 'Z'.ord
+      if event.ctrlKey or event.metaKey
+        unless Status.undoStack.empty?
+          self.undo()
+          event.preventDefault()
+        end
+      end
+    else
+    end
+  end
+end
diff --git a/www/moderation/desk/views/status.js.rb b/www/moderation/desk/views/status.js.rb
new file mode 100644
index 0000000..d856a9b
--- /dev/null
+++ b/www/moderation/desk/views/status.js.rb
@@ -0,0 +1,53 @@
+#
+# Encapsulate memory of selected item and delete stack
+#
+
+
+STORAGE_NAME = 'modmail'
+class Status
+  def self.modmail
+    return {} if not defined? sessionStorage
+    JSON.parse(sessionStorage.getItem(STORAGE_NAME) || '{}')
+  end
+
+  def self.undoStack
+    modmail = Status.modmail
+    return modmail.undoStack || []
+  end
+
+  def self.selected
+    Status.modmail.selected
+  end
+
+  def self.selected=(value)
+#    console.log "Status set selected: #{value}"
+    modmail = Status.modmail
+    modmail.selected=value
+    sessionStorage.setItem(STORAGE_NAME, JSON.stringify(modmail))
+  end
+
+  def self.pushDeleted(value)
+#    console.log "pushDeleted #{value}"
+    value = value[/\w+\/\w+\/?$/].sub(/\/?$/, '/')
+    modmail = Status.modmail
+    modmail.undoStack ||= []
+    modmail.undoStack << value
+    sessionStorage.setItem(STORAGE_NAME, JSON.stringify(modmail))
+  end
+
+  def self.popStack()
+    modmail = Status.modmail
+    modmail.undoStack ||= []
+    item = modmail.undoStack.pop()
+    sessionStorage.setItem(STORAGE_NAME, JSON.stringify(modmail))
+#    console.log "popStack: #{item}"
+    return item
+  end
+
+  def self.emptyStack()
+    modmail = Status.modmail
+    modmail.undoStack = []
+    sessionStorage.setItem(STORAGE_NAME, JSON.stringify(modmail))
+  end
+
+end
diff --git a/www/moderation/desk/views/vue-config.js.rb b/www/moderation/desk/views/vue-config.js.rb
new file mode 100644
index 0000000..b2e9ae5
--- /dev/null
+++ b/www/moderation/desk/views/vue-config.js.rb
@@ -0,0 +1,10 @@
+# Filter out "data property already declared as a prop" warnings
+Vue.config.warnHandler = proc do |msg, vm, trace|
+  return if msg =~ /^The data property "\w+" is already declared as a prop\./
+  console.error "[Vue warn]: " + msg + trace if defined? console
+end
+
+# reraise uncapturable errors asynchronously to enable easier debugging
+Vue.config.errorHandler = proc do |err, vm, info|
+  setTimeout(0) { raise err }
+end