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