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:
parent
7e2a6fe771
commit
7339802f34
334 changed files with 3978 additions and 71 deletions
|
|
@ -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
115
lib/ezmlm/list/author.rb
Normal 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
119
lib/ezmlm/list/message.rb
Normal 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
102
lib/ezmlm/list/thread.rb
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue