www/secretary/workbench/models/mailbox.rb (173 lines of code) (raw):
#
# Encapsulate access to mailboxes
#
# N.B. this module is included by the deliver script, so needs to be quick to load
require 'zlib'
require 'zip'
require 'stringio'
require 'yaml'
require_relative '../config'
require_relative 'message'
class Mailbox
#
# fetch a/some/all mailboxes
#
def self.fetch(mailboxes=nil)
options = %w(-av --no-motd)
if mailboxes.nil?
options += %w(--delete --exclude=*.yml --exclude=*.mail)
source = "#{SOURCE}/"
elsif mailboxes.is_a? Array
host, path = SOURCE.split(':', 2)
files = mailboxes.map {|name| "#{path}/#{name}*"}
source = "#{host}:#{files.join(' ')}"
else
source = "#{SOURCE}/#{mailboxes}*"
end
Dir.mkdir ARCHIVE unless Dir.exist? ARCHIVE
system 'rsync', *options, source, "#{ARCHIVE}/"
end
# Get list of mailboxes
def self.allmailboxes
current = Date.today.strftime('%Y%m.yml') # exclude future-dated entries
Dir.entries(ARCHIVE).select{|name| name =~ %r{^\d{6}\.yml$} && name <= current}.map{|n| File.basename n,'.yml'}.sort
end
# get prev and next entries (or nil)
def self.prev_next(current)
active = self.allmailboxes
index = active.find_index current
prv = nxt = nil
if index
prv = active[index - 1] if index > 0
nxt = active[index + 1] if index < active.length
end
return prv, nxt
end
#
# Initialize a mailbox
#
def initialize(name)
name = File.basename(name, '.yml')
if name =~ /^\d+$/
@name = name
@mbox = Dir[File.join(ARCHIVE, @name), File.join(ARCHIVE, "#{@name}.gz")].first
else
@name = name.split('.').first
@mbox = File.join ARCHIVE, name
end
end
#
# convenience interface to update
#
def self.update(name, &block)
Mailbox.new(name).update(&block)
end
#
# encapsulate updates to a mailbox
#
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
file.rewind
file.write YAML.dump(mbox)
file.truncate(file.pos)
end
end
#
# Read a mailbox and split it into messages
#
def messages
return @messages if @messages
return [] unless @mbox and File.exist?(@mbox)
mbox = File.read(@mbox)
if @mbox.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 self.find(message)
month, hash = message.match(%r{/(\d+)/(\w+)}).captures
Mailbox.new(month).find(hash)
end
#
# Revert a message
#
def self.revert(message)
month, hash = message.match(%r{/(\d+)/(\w+)}).captures
mailbox = Mailbox.new(month)
email = File.read(File.join(mailbox.dir, hash), encoding: Encoding::BINARY)
headers = Message.parse(email)
message = Message.new(mailbox, hash, headers, email)
message.write_headers
message
end
#
# Find a message
#
def find(hash)
headers = YAML.load_file(yaml_file) rescue {}
if Dir.exist? dir and File.exist? File.join(dir, hash)
email = File.read(File.join(dir, hash), encoding: Encoding::BINARY)
else
email = messages.find {|message| Message.hash(message) == hash}
end
Message.new(self, hash, headers[hash], email) if email
end
#
# iterate through messages
#
def each(&block)
messages.each(&block)
end
#
# name of associated yaml file
#
def yaml_file
File.join ARCHIVE, "#{@name}.yml"
end
#
# name of associated directory
#
def dir
File.join ARCHIVE, "#{@name}.mail"
end
#
# return headers (server view)
#
def headers
messages = YAML.load_file(yaml_file) || {} rescue {}
messages.delete :mtime # TODO: is this needed?
messages.each do |_key, value|
value[:source] = @name
end
end
#
# return headers (client view; only shows messages with attachments)
# If :listall is true, then include all entries with an attachments key (even if empty)
# (these are messages that originally had attachments)
def client_headers(listall: false)
if listall
# get all headers which ever had attachments
headers = self.headers.to_a.reject do |_id, message|
message[:attachments].nil?
end
else
# want all active headers
headers = self.headers.to_a.reject do |_id, message|
# This does not return attachments with status :deleted
# also drops irrelevant attachments
Message.attachments(message).empty?
end
end
# extract relevant fields from the headers
headers.map! do |id, message|
entry = {
time: message[:time] || '',
href: "#{message[:source]}/#{id}/",
from: message['From'].to_s.fix_encoding,
date: message['Date'] || '',
subject: (message['Subject'] || '(empty)').to_s.fix_encoding,
status: message[:status]
}
if message[:secmail]
entry[:secmail] = message[:secmail]
end
entry
end
# return messages sorted in reverse chronological order
# N.B. this currently has no effect, as the messages need to be sorted by the GUI
# (see ../README for details)
headers.sort_by {|message| message[:time]}.reverse
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&.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
#
# parse a mailbox, updating YAML
#
def parse
return unless @mbox
mbox = YAML.load_file(yaml_file) || {} rescue {}
return if mbox[:mtime] == File.mtime(@mbox)
# open the YAML file for real (locking it this time)
self.update do |mbox|
mbox[:mtime] = File.mtime(@mbox)
# process each message in the mailbox
self.each do |message|
# compute id, skip if already processed
id = Message.hash(message)
mbox[id] ||= Message.parse(message)
end
end
end
end