Everything is workin!

- Add a corpus of test messages to the spec data, with
   the script that generated them.
 - Add native objects for Authors, Messages, and Threads.
 - Tests!
This commit is contained in:
Mahlon E. Smith 2017-05-12 11:09:36 -07:00
parent 7e2a6fe771
commit 7339802f34
334 changed files with 3978 additions and 71 deletions

View file

@ -10,9 +10,9 @@
#---
require 'pathname'
require 'time'
require 'etc'
require 'ezmlm'
require 'mail'
require 'ezmlm' unless defined?( Ezmlm )
### A Ruby interface to an ezmlm-idx mailing list directory
@ -21,13 +21,7 @@ class Ezmlm::List
# Quick address space detection, to (hopefully)
# match the overflow size on this machine.
#
ADDRESS_SPACE = case [ 'i' ].pack( 'p' ).size
when 4
32
when 8
64
end
ADDRESS_SPACE = [ 'i' ].pack( 'p' ).size * 8
# Valid subdirectories/sections for subscriptions.
SUBSCRIPTION_DIRS = %w[ deny mod digest allow ]
@ -247,10 +241,11 @@ class Ezmlm::List
self.unlink( 'threaded' )
end
end
alias_method :threaded, :threaded=
### Returns +true+ if the list is configured to respond
### to remote mangement requests.
### to remote management requests.
###
def public?
return ( self.listdir + 'public' ).exist?
@ -265,9 +260,10 @@ class Ezmlm::List
self.unlink( 'public' )
end
end
alias_method :public, :public=
### Returns +true+ if the list is not configured to respond
### to remote mangement requests.
### to remote management requests.
###
def private?
return ! self.public?
@ -278,6 +274,7 @@ class Ezmlm::List
def private=( enable=false )
self.public = ! enable
end
alias_method :private, :private=
### Returns +true+ if the list supports remote administration
@ -296,6 +293,7 @@ class Ezmlm::List
self.unlink( 'remote' )
end
end
alias_method :remote_subscriptions, :remote_subscriptions=
### Returns +true+ if list subscription requests require moderator
@ -314,7 +312,7 @@ class Ezmlm::List
self.unlink( 'modsub' )
end
end
alias_method :moderated_subscriptions, :moderated_subscriptions=
### Returns +true+ if message moderation is enabled.
###
@ -324,7 +322,7 @@ class Ezmlm::List
### Disable or enable message moderation.
###
### This has special meaning when combined with user_post_only setting.
### This has special meaning when combined with user_posts_only setting.
### Lists act as unmoderated for subscribers, and posts from unknown
### addresses go to moderation.
###
@ -337,6 +335,7 @@ class Ezmlm::List
self.unlink( 'noreturnposts' ) if self.user_posts_only?
end
end
alias_method :moderated, :moderated=
### Returns +true+ if posting is only allowed by moderators.
@ -354,6 +353,7 @@ class Ezmlm::List
self.unlink( 'modpostonly' )
end
end
alias_method :moderator_posts_only, :moderator_posts_only=
### Returns +true+ if posting is only allowed by subscribers.
@ -378,7 +378,7 @@ class Ezmlm::List
self.unlink( 'noreturnposts' ) if self.moderated?
end
end
alias_method :user_posts_only, :user_posts_only=
### Returns +true+ if message archival is enabled.
@ -398,6 +398,7 @@ class Ezmlm::List
self.unlink( 'indexed' )
end
end
alias_method :archive, :archive=
### Returns +true+ if the message archive is accessible only to
### moderators.
@ -415,6 +416,7 @@ class Ezmlm::List
self.unlink( 'modgetonly' )
end
end
alias_method :private_archive, :private_archive=
### Returns +true+ if the message archive is accessible to anyone.
###
@ -422,6 +424,13 @@ class Ezmlm::List
return ! self.private_archive?
end
### Disable or enable private access to the archive.
###
def public_archive=( enable=true )
self.private_archive = ! enable
end
alias_method :public_archive, :public_archive=
### Returns +true+ if the message archive is accessible only to
### list subscribers.
###
@ -438,6 +447,7 @@ class Ezmlm::List
self.unlink( 'subgetonly' )
end
end
alias_method :guarded_archive, :guarded_archive=
### Returns +true+ if message digests are enabled.
@ -455,6 +465,7 @@ class Ezmlm::List
self.unlink( 'digested' )
end
end
alias_method :digest, :digest=
### If the list is digestable, trigger the digest after this amount
### of message body since the latest digest, in kbytes.
@ -531,6 +542,7 @@ class Ezmlm::List
self.touch( 'nosubconfirm' )
end
end
alias_method :confirm_subscriptions, :confirm_subscriptions=
### Returns +true+ if the list requires unsubscriptions to be
### confirmed. AKA "jump" mode.
@ -549,6 +561,7 @@ class Ezmlm::List
self.touch( 'nounsubconfirm' )
end
end
alias_method :confirm_unsubscriptions, :confirm_unsubscriptions=
### Returns +true+ if the list requires regular message postings
@ -567,6 +580,7 @@ class Ezmlm::List
self.unlink( 'confirmpost' )
end
end
alias_method :confirm_postings, :confirm_postings=
### Returns +true+ if the list allows moderators to
@ -586,6 +600,7 @@ class Ezmlm::List
self.unlink( 'modcanlist' )
end
end
alias_method :allow_remote_listing, :allow_remote_listing=
### Returns +true+ if the list automatically manages
@ -604,6 +619,7 @@ class Ezmlm::List
self.touch( 'nowarn' )
end
end
alias_method :bounce_warnings, :bounce_warnings=
### Return the maximum message size, in bytes. Messages larger than
@ -617,7 +633,7 @@ class Ezmlm::List
end
### Set the maximum message size, in bytes. Messages larger than
### this size will be rejected.
### this size will be rejected. Defaults to 300kb.
###
### See: ezmlm-reject(1)
###
@ -630,6 +646,7 @@ class Ezmlm::List
end
### Return the number of messages in the list archive.
###
def message_count
@ -637,20 +654,74 @@ class Ezmlm::List
return count ? Integer( count ) : 0
end
### Returns the last message to the list as a Mail::Message, if
### archiving was enabled.
### Returns an individual message if archiving was enabled.
###
def last_post
num = self.message_count
return if num.zero?
def message( message_id )
raise "Archiving is not enabled." unless self.archived?
raise "Message archive is empty." if self.message_count.zero?
return Ezmlm::List::Message.new( self, message_id )
end
hashdir = num / 100
message = "%02d" % [ num % 100 ]
### Lazy load each message ID as a Ezmlm::List::Message,
### yielding it to the block.
###
def each_message
( 1 .. self.message_count ).each do |id|
yield self.message( id )
end
end
post = self.listdir + 'archive' + hashdir.to_s + message.to_s
return unless post.exist?
return Mail.read( post.to_s )
### Return a Thread object for the given +thread_id+.
###
def thread( thread_id )
raise "Archiving is not enabled." unless self.archived?
return Ezmlm::List::Thread.new( self, thread_id )
end
### Return an Author object for the given +author_id+.
###
def author( author_id )
raise "Archiving is not enabled." unless self.archived?
return Ezmlm::List::Author.new( self, author_id )
end
### Parse all thread indexes into a single array that can be used
### as a lookup table.
###
### These are not expanded into objects, use #message, #thread,
### and #author to do so.
###
def index
raise "Archiving is not enabled." unless self.archived?
archivedir = listdir + 'archive'
idx = ( 0 .. self.message_count / 100 ).each_with_object( [] ) do |dir, acc|
index = archivedir + dir.to_s + 'index'
next unless index.exist?
index.each_line.lazy.slice_before( /^\d+:/ ).each do |message|
match = message[0].match( /^(?<message_id>\d+): (?<thread_id>\w+)/ )
next unless match
thread_id = match[ :thread_id ]
match = message[1].match( /^(?<date>[^;]+);(?<author_id>\w+) / )
next unless match
author_id = match[ :author_id ]
date = match[ :date ]
metadata = {
date: Time.parse( date ),
thread: thread_id,
author: author_id
}
acc << metadata
end
end
return idx
end
@ -670,7 +741,7 @@ class Ezmlm::List
h = 5381
over = 2 ** ADDRESS_SPACE
addr = 'T' + addr
addr = 'T' + addr.downcase
addr.each_char do |c|
h = ( h + ( h << 5 ) ) ^ c.ord
h = h % over if h > over # emulate integer overflow
@ -679,7 +750,7 @@ class Ezmlm::List
end
### Given an email address, return the ascii character.
### Given an email address, return the ascii hash prefix.
###
def hashchar( addr )
return ( self.subhash(addr) + 64 ).chr

115
lib/ezmlm/list/author.rb Normal file
View file

@ -0,0 +1,115 @@
#!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4:
#
# A collection of messages authored from a unique user.
#
# Note that Ezmlm uses the "real name" part of an address
# to identify an author.
#
# author = Ezmlm::List::Author.new( list, 'acgcbmbmeapgpfckcdol' )
# author.name #=> "Help - navigate on interface?"
# author.first.date.to_s #=> "2017-05-07T14:55:05-07:00"
#
#
# == Version
#
# $Id$
#
#---
require 'pathname'
require 'ezmlm' unless defined?( Ezmlm )
### A collection of messages for a specific author.
###
class Ezmlm::List::Author
include Enumerable
### Instantiate a new list of messages given
### a +list+ and a +author_id+.
###
def initialize( list, author_id )
raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir )
raise ArgumentError, "Malformed Author ID." unless author_id =~ /^\w{20}$/
raise "Thread indexing is not enabled." unless list.threaded?
@list = list
@id = author_id
@messages = nil
self.load_index
end
# The list object this message is stored in.
attr_reader :list
# The author's identifier.
attr_reader :id
# The author's name.
attr_reader :name
# An array of messages this author has sent.
attr_reader :messages
# An array of threads this author has participated in.
attr_reader :threads
### Enumerable API: Lazy load each message ID as a
### Ezmlm::List::Message, yielding it to the block.
###
def each
self.load_index # refresh for any updates since object was created
self.messages.each do |id|
yield Ezmlm::List::Message.new( self.list, id )
end
end
### Lazy load each thread ID as a Ezmlm::List::Thread, yielding it to the block.
###
def each_thread
self.load_index # refresh for any updates since object was created
self.threads.each do |id|
yield Ezmlm::List::Thread.new( self.list, id )
end
end
#########
protected
#########
### Parse the author index into an array of Messages.
###
def load_index
@messages = []
@threads = []
path = self.author_path
raise "Unknown author: %p" % [ self.id ] unless path.exist?
path.each_line.with_index do |line, i|
if i.zero?
@name = line.match( /^\w+ (.+)/ )[1]
else
match = line.match( /^(\d+):\d+:(\w+) / ) or next
self.messages << match[1].to_i
self.threads << match[2]
end
end
end
### Return the path on disk for the author index.
###
def author_path
prefix = self.id[ 0 .. 1 ]
hash = self.id[ 2 .. -1 ]
return self.list.listdir + 'archive' + 'authors' + prefix + hash
end
end # class Ezmlm::List::Author

119
lib/ezmlm/list/message.rb Normal file
View file

@ -0,0 +1,119 @@
#!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4:
#
# An individual list message.
#
# message = Ezmlm::List::Message.new( list, 24 )
# message.thread #=> (a thread object this message is part of)
# message.from #=> ["jalon.hermann@example.com"]
# puts message.to_s #=> (raw email)
#
# This class passes all heavy lifting to the Mail::Message library.
# Please see it for specifics on usage.
#
# == Version
#
# $Id$
#
#---
require 'pathname'
require 'ezmlm' unless defined?( Ezmlm )
require 'mail'
### A Ruby interface to an individual list message.
###
class Ezmlm::List::Message
### Instantiate a new messag from a +list+ and a +message_number+.
###
def initialize( list, message_number=0 )
raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir )
raise ArgumentError, "Invalid message number (impossible)" if message_number < 1
raise ArgumentError, "Invalid message number (out of list bounds)" if message_number > list.message_count
@list = list
@id = message_number
@post = self.load_message
end
# The list object this message is stored in.
attr_reader :list
# The list message delivery identifier.
attr_reader :id
# The Mail::Message object for this post.
attr_reader :post
### Return the thread object this message is
### a member of.
###
def thread
unless @thread_id
idx = self.list.index
@thread_id = idx[ self.id - 1 ][ :thread ]
end
return Ezmlm::List::Thread.new( self.list, @thread_id )
end
### Return the author object this message is
### a member of.
###
def author
unless @author_id
idx = self.list.index
@author_id = idx[ self.id - 1 ][ :author ]
end
return Ezmlm::List::Author.new( self.list, @author_id )
end
### Render the message as a string.
###
def to_s
return self.post.to_s
end
### Provide implicit arrays (Mail::Message does not.)
###
def to_ary
return [ self.post ]
end
### Pass all unknown methods to the underlying Mail::Message object.
###
def method_missing( meth, *args )
return self.post.method( meth ).call( *args )
end
#########
protected
#########
### Parse the message into a Mail::Message.
###
def load_message
path = self.message_path
raise "Unable to determine message path: %p" % [ path ] unless path.exist?
return Mail.read( path.to_s )
end
### Return the path on disk for the message.
###
def message_path
hashdir = self.id / 100
message = "%02d" % [ self.id % 100 ]
return self.list.listdir + 'archive' + hashdir.to_s + message.to_s
end
end # class Ezmlm::List::Message

102
lib/ezmlm/list/thread.rb Normal file
View file

@ -0,0 +1,102 @@
#!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4:
#
# A collection of messages for a specific archive thread.
#
# thread = Ezmlm::List::Thread.new( list, 'acgcbmbmeapgpfckcdol' )
# thread.subject #=> "Help - navigate on interface?"
# thread.first.date.to_s #=> "2017-05-07T14:55:05-07:00"
#
#
# == Version
#
# $Id$
#
#---
require 'pathname'
require 'ezmlm' unless defined?( Ezmlm )
### A collection of messages for a specific archive thread.
###
class Ezmlm::List::Thread
include Enumerable
### Instantiate a new thread of messages given
### a +list+ and a +thread_id+.
###
def initialize( list, thread_id )
raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir )
raise ArgumentError, "Malformed Thread ID." unless thread_id =~ /^\w{20}$/
raise "Thread indexing is not enabled." unless list.threaded?
@list = list
@id = thread_id
@subject = nil
@messages = nil
self.load_thread
end
# The list object this message is stored in.
attr_reader :list
# The thread's identifier.
attr_reader :id
# The subject line of the thread.
attr_reader :subject
# An array of member messages.
attr_reader :messages
# An array of member authors.
attr_reader :authors
### Enumerable API: Lazy load each message ID as a
### Ezmlm::List::Message, yielding it to the block.
###
def each
self.load_thread # refresh for any thread updates since object was created
self.messages.each do |id|
yield Ezmlm::List::Message.new( self.list, id )
end
end
#########
protected
#########
### Parse the subject index into an array of Messages.
###
def load_thread
@messages = []
@authors = []
path = self.thread_path
raise "Unknown thread: %p" % [ self.id ] unless path.exist?
path.each_line.with_index do |line, i|
if i.zero?
@subject = line.match( /^\w+ (.+)/ )[1]
else
match = line.match( /^(\d+):\d+:(\w+) / ) or next
self.messages << match[1].to_i
self.authors << match[2]
end
end
end
### Return the path on disk for the thread index.
###
def thread_path
prefix = self.id[ 0 .. 1 ]
hash = self.id[ 2 .. -1 ]
return self.list.listdir + 'archive' + 'subjects' + prefix + hash
end
end # class Ezmlm::List::Thread