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
This commit is contained in:
Mahlon E. Smith 2017-02-03 10:52:46 -08:00
parent 3f96c8bb0f
commit 5a524b55bf
3 changed files with 239 additions and 87 deletions

View file

@ -15,7 +15,7 @@ code
This is a ruby interface for interacting with ezmlm-idx, an email list
manager for use with the Qmail MTA. (The -idx provides an extended
feature set over the initial ezmlm environment.)
feature set over the initial ezmlm environment), and messages therein.
http://untroubled.org/ezmlm/
@ -30,6 +30,20 @@ http://untroubled.org/ezmlm/
$ gem install ezmlm
## Limitations
This library is designed to only work with lists stored on disk (the
default), not the SQL backends.
Address space (32 bit vs 64 bit) matters when ezmlm calculates hashes.
If things aren't adding up, make sure this library is running on a
machine with a matching address space as the list itself. (Running this
on a 64bit machine to talk to 32bit listserv isn't going to play well.)
The option set offered with ezmlm-make is not fully ported, just the
most common switches. Patches welcome.
## License
Copyright (c) 2017, Mahlon E. Smith <mahlon@martini.nu>

View file

@ -46,7 +46,7 @@ spec = Gem::Specification.new do |s|
s.description = <<-EOF
This is a ruby interface for interacting with ezmlm-idx, an email list
manager for use with the Qmail MTA. (The -idx provides an extended
feature set over the initial ezmlm environment.)
feature set over the initial ezmlm environment), and the messages contained therein.
EOF
s.required_ruby_version = '>= 2'

View file

@ -10,6 +10,7 @@
#---
require 'pathname'
require 'etc'
require 'ezmlm'
require 'mail'
@ -18,6 +19,20 @@ require 'mail'
###
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.
###
@ -26,43 +41,143 @@ class Ezmlm::List
@listdir = listdir
end
######
public
######
# The Pathname object for the list directory
attr_reader :listdir
### Return the configured name of the list (without the host)
###
def name
return self.config[ 'L' ]
@name = self.read( 'outlocal' ) unless @name
return @name
end
### Return the configured host of the list
###
def host
return self.config[ 'H' ]
@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 number of messages in the list archive
def message_count
numfile = self.listdir + 'num'
return 0 unless numfile.exist?
return Integer( numfile.read[/^(\d+):/, 1] )
### 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
@ -70,95 +185,30 @@ class Ezmlm::List
### 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
### Return the list config as a Hash
def config
unless @config
configfile = self.listdir + 'config'
raise "List config file %p does not exist" % [ configfile ] unless configfile.exist?
@config = configfile.read.scan( /^(\S):([^\n]*)$/m ).inject({}) do |h,pair|
key,val = *pair
h[key] = val
h
end
end
return @config
end
### Return the email address of the list's owner.
def owner
self.config['5']
end
### Fetch an Array of the email addresses for all of the list's subscribers.
def subscribers
subscribers_dir = self.listdir + 'subscribers'
return self.read_subscriber_dir( subscribers_dir )
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
### Returns an Array of email addresses of people responsible for moderating subscription
### of a closed list.
def subscription_moderators
return [] unless self.closed?
modsubfile = self.listdir + 'modsub'
remotefile = self.listdir + 'remote'
subdir = nil
if modsubfile.exist? && modsubfile.read(1) == '/'
subdir = Pathname.new( modsubfile.read.chomp )
elsif remotefile.exist? && remotefile.read(1) == '/'
subdir = Pathname.new( remotefile.read.chomp )
else
subdir = self.listdir + 'mod/subscribers'
end
return self.read_subscriber_dir( subdir )
end
### Returns an Array of email addresses of people responsible for moderating posts
### sent to the list.
def message_moderators
return [] unless self.moderated?
modpostfile = self.listdir + 'modpost'
subdir = nil
if modpostfile.exist? && modpostfile.read(1) == '/'
subdir = Pathname.new( modpostfile.read.chomp )
else
subdir = self.listdir + 'mod/subscribers'
end
return self.read_subscriber_dir( subdir )
end
### Return a TMail::Mail object loaded from the last post to the list. Returns
### 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?
@ -177,24 +227,112 @@ class Ezmlm::List
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( directory )
###
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
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