Multiple changes.
- Start converting from from the old 'config' file format where
applicable.
- Port the ezmlm address hashing algorithm for fast email lookups
- Add subscription and unsubscription for primary and behavioral dirs
- Add a safety check for writes to the list directory
#!/usr/bin/ruby
# vim: set nosta noet ts=4 sw=4:
#
# A Ruby interface to a single Ezmlm-idx mailing list directory.
#
# == Version
#
# $Id$
#
#---
require 'pathname'
require 'etc'
require 'ezmlm'
require 'mail'
### A Ruby interface to an ezmlm-idx mailing list directory
###
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
# Valid subdirectories/sections for subscriptions.
SUBSCRIPTION_DIRS = %w[ deny mod digest allow ]
### Create a new Ezmlm::List object for the specified +listdir+, which should be
### an ezmlm-idx mailing list directory.
###
def initialize( listdir )
listdir = Pathname.new( listdir ) unless listdir.is_a?( Pathname )
@listdir = listdir
end
# The Pathname object for the list directory
attr_reader :listdir
### Return the configured name of the list (without the host)
###
def name
@name = self.read( 'outlocal' ) unless @name
return @name
end
### Return the configured host of the list
###
def host
@host = self.read( 'outhost' ) unless @host
return @host
end
### Return the configured address of the list (in list@host form)
###
def address
return "%s@%s" % [ self.name, self.host ]
end
alias_method :fullname, :address
### Return the email address of the list's owner.
###
def owner
owner = self.read( 'owner' )
return owner =~ /@/ ? owner : nil
end
### Return the number of messages in the list archive.
###
def message_count
count = self.read( 'archnum' )
return count ? Integer( count ) : 0
end
### Fetch a sorted Array of the email addresses for all of the list's
### subscribers.
###
def subscribers
return self.read_subscriber_dir
end
### Returns an Array of email addresses of people responsible for
### moderating subscription of a closed list.
###
def moderators
return self.read_subscriber_dir( 'mod' )
end
### Subscribe +addr+ to the list as a Moderator.
###
def add_moderator( *addr )
return self.subscribe( *addr, section: 'mod' )
end
### Remove +addr+ from the list as a Moderator.
###
def remove_moderator( *addr )
return self.unsubscribe( *addr, section: 'mod' )
end
### Returns +true+ if +address+ is a subscriber to this list.
###
def include?( addr )
addr.downcase!
file = self.subscription_dir + self.hashchar( addr )
return false unless file.exist?
return file.read.scan( /T([^\0]+)\0/ ).flatten.include?( addr )
end
### Subscribe +addr+ to the list within +section+.
###
def subscribe( *addr, section: nil )
addr.each do |address|
next unless address.index( '@' )
address.downcase!
file = self.subscription_dir( section ) + self.hashchar( address )
self.with_safety do
if file.exist?
addresses = file.read.scan( /T([^\0]+)\0/ ).flatten
addresses << address
file.open( 'w' ) do |f|
f.print addresses.uniq.sort.map{|a| "T#{a}\0" }.join
end
else
file.open( 'w' ) do |f|
f.print "T%s\0" % [ address ]
end
end
end
end
end
### Unsubscribe +addr+ from the list within +section+.
###
def unsubscribe( *addr, section: nil )
addr.each do |address|
address.downcase!
file = self.subscribers_dir( section ) + self.hashchar( address )
self.with_safety do
next unless file.exist?
addresses = file.read.scan( /T([^\0]+)\0/ ).flatten
addresses = addresses - [ address ]
if addresses.empty?
file.unlink
else
file.open( 'w' ) do |f|
f.print addresses.uniq.sort.map{|a| "T#{a}\0" }.join
end
end
end
end
end
=begin
### Return the Date parsed from the last post to the list.
###
def last_message_date
mail = self.last_post or return nil
return mail.date
end
### Return the author of the last post to the list.
###
def last_message_author
mail = self.last_post or return nil
return mail.from
end
### Returns +true+ if subscription to the list is moderated.
###
def closed?
return (self.listdir + 'modsub').exist? || (self.listdir + 'remote').exist?
end
### Returns +true+ if posting to the list is moderated.
###
def moderated?
return (self.listdir + 'modpost').exist?
end
### Return a Mail::Message object loaded from the last post to the list. Returns
### +nil+ if there are no archived posts.
###
def last_post
archivedir = self.listdir + 'archive'
return nil unless archivedir.exist?
# Find the last numbered directory under the archive dir
last_archdir = Pathname.glob( archivedir + '[0-9]*' ).
sort_by {|pn| Integer(pn.basename.to_s) }.last
return nil unless last_archdir
# Find the last numbered file under the last numbered directory we found
# above.
last_post_path = Pathname.glob( last_archdir + '[0-9]*' ).
sort_by {|pn| pn.basename.to_s }.last
raise RuntimeError, "unexpectedly empty archive directory '%s'" % [ last_archdir ] \
unless last_post_path
require 'pry'
binding.pry
last_post = TMail::Mail.load( last_post_path.to_s )
end
=end
#########
protected
#########
### Hash an email address, using the ezmlm algorithm for
### fast user lookups. Returns the hashed integer.
###
### Older ezmlm didn't lowercase addresses, anything within the last
### decade did. We're not going to worry about compatibility there.
###
### (See subhash.c in the ezmlm source.)
###
def subhash( addr )
h = 5381
over = 2 ** ADDRESS_SPACE
addr = 'T' + addr
addr.each_char do |c|
h = ( h + ( h << 5 ) ) ^ c.ord
h = h % over if h > over # emulate integer overflow
end
return h % 53
end
### Given an email address, return the ascii character.
###
def hashchar( addr )
return ( self.subhash(addr) + 64 ).chr
end
### Just return the contents of the provided +file+, rooted
### in the list directory.
###
def read( file )
file = self.listdir + file unless file.is_a?( Pathname )
return file.read.chomp
rescue
nil
end
### Return a Pathname to a subscription directory.
###
def subscription_dir( section=nil )
section = nil if section && ! SUBSCRIPTION_DIRS.include?( section )
if section
return self.listdir + section + 'subscribers'
else
return self.listdir + 'subscribers'
end
end
### Read the hashed subscriber email addresses from the specified +directory+ and return them in
### an Array.
###
def read_subscriber_dir( section=nil )
directory = self.subscription_dir( section )
rval = []
Pathname.glob( directory + '*' ) do |hashfile|
rval.push( hashfile.read.scan(/T([^\0]+)\0/) )
end
return rval.flatten.sort
end
### Return a Pathname object for the list owner's home directory.
###
def homedir
user = Etc.getpwuid( self.listdir.stat.uid )
return Pathname( user.dir )
end
### Safely make modifications to a file within a list directory.
###
### Mail can come in at any time. Make changes within a list
### atomic -- if an incoming message hits when a sticky
### is set, it is deferred to the Qmail queue.
###
### - Set sticky bit on the list directory owner's homedir
### - Make changes with the block
### - Unset sticky (just back to what it was previously)
###
### All writes should be wrapped in this method.
###
def with_safety( &block )
home = self.homedir
mode = home.stat.mode
home.chmod( mode | 01000 ) # enable sticky
yield
ensure
home.chmod( mode )
end
end # class Ezmlm::List