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

[whimsy] branch mod-gui created (now 918930f)

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

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


      at 918930f  www/moderation/

This branch includes the following new commits:

     new 918930f  www/moderation/

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



[whimsy] 01/01: www/moderation/

Posted by se...@apache.org.
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