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 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 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/ http://untroubled.org/ezmlm/
@ -30,6 +30,20 @@ http://untroubled.org/ezmlm/
$ gem install 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 ## License
Copyright (c) 2017, Mahlon E. Smith <mahlon@martini.nu> Copyright (c) 2017, Mahlon E. Smith <mahlon@martini.nu>

View file

@ -46,7 +46,7 @@ spec = Gem::Specification.new do |s|
s.description = <<-EOF s.description = <<-EOF
This is a ruby interface for interacting with ezmlm-idx, an email list 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 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 EOF
s.required_ruby_version = '>= 2' s.required_ruby_version = '>= 2'

View file

@ -10,6 +10,7 @@
#--- #---
require 'pathname' require 'pathname'
require 'etc'
require 'ezmlm' require 'ezmlm'
require 'mail' require 'mail'
@ -18,6 +19,20 @@ require 'mail'
### ###
class Ezmlm::List 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 ### Create a new Ezmlm::List object for the specified +listdir+, which should be
### an ezmlm-idx mailing list directory. ### an ezmlm-idx mailing list directory.
### ###
@ -26,43 +41,143 @@ class Ezmlm::List
@listdir = listdir @listdir = listdir
end end
######
public
######
# The Pathname object for the list directory # The Pathname object for the list directory
attr_reader :listdir attr_reader :listdir
### Return the configured name of the list (without the host) ### Return the configured name of the list (without the host)
###
def name def name
return self.config[ 'L' ] @name = self.read( 'outlocal' ) unless @name
return @name
end end
### Return the configured host of the list ### Return the configured host of the list
###
def host def host
return self.config[ 'H' ] @host = self.read( 'outhost' ) unless @host
return @host
end end
### Return the configured address of the list (in list@host form) ### Return the configured address of the list (in list@host form)
###
def address def address
return "%s@%s" % [ self.name, self.host ] return "%s@%s" % [ self.name, self.host ]
end end
alias_method :fullname, :address alias_method :fullname, :address
### Return the number of messages in the list archive ### Return the email address of the list's owner.
def message_count ###
numfile = self.listdir + 'num' def owner
return 0 unless numfile.exist? owner = self.read( 'owner' )
return Integer( numfile.read[/^(\d+):/, 1] ) return owner =~ /@/ ? owner : nil
end 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. ### Return the Date parsed from the last post to the list.
###
def last_message_date def last_message_date
mail = self.last_post or return nil mail = self.last_post or return nil
return mail.date return mail.date
@ -70,95 +185,30 @@ class Ezmlm::List
### Return the author of the last post to the list. ### Return the author of the last post to the list.
###
def last_message_author def last_message_author
mail = self.last_post or return nil mail = self.last_post or return nil
return mail.from return mail.from
end 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. ### Returns +true+ if subscription to the list is moderated.
###
def closed? def closed?
return (self.listdir + 'modsub').exist? || (self.listdir + 'remote').exist? return (self.listdir + 'modsub').exist? || (self.listdir + 'remote').exist?
end end
### Returns +true+ if posting to the list is moderated. ### Returns +true+ if posting to the list is moderated.
###
def moderated? def moderated?
return (self.listdir + 'modpost').exist? return (self.listdir + 'modpost').exist?
end end
### Returns an Array of email addresses of people responsible for moderating subscription ### Return a Mail::Message object loaded from the last post to the list. Returns
### 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
### +nil+ if there are no archived posts. ### +nil+ if there are no archived posts.
###
def last_post def last_post
archivedir = self.listdir + 'archive' archivedir = self.listdir + 'archive'
return nil unless archivedir.exist? return nil unless archivedir.exist?
@ -177,24 +227,112 @@ class Ezmlm::List
raise RuntimeError, "unexpectedly empty archive directory '%s'" % [ last_archdir ] \ raise RuntimeError, "unexpectedly empty archive directory '%s'" % [ last_archdir ] \
unless last_post_path unless last_post_path
require 'pry'
binding.pry
last_post = TMail::Mail.load( last_post_path.to_s ) last_post = TMail::Mail.load( last_post_path.to_s )
end end
=end
######### #########
protected 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 ### Read the hashed subscriber email addresses from the specified +directory+ and return them in
### an Array. ### an Array.
def read_subscriber_dir( directory ) ###
def read_subscriber_dir( section=nil )
directory = self.subscription_dir( section )
rval = [] rval = []
Pathname.glob( directory + '*' ) do |hashfile| Pathname.glob( directory + '*' ) do |hashfile|
rval.push( hashfile.read.scan(/T([^\0]+)\0/) ) rval.push( hashfile.read.scan(/T([^\0]+)\0/) )
end 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
end # class Ezmlm::List end # class Ezmlm::List