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
--- a/README.md Wed Feb 01 15:35:35 2017 -0800
+++ b/README.md Fri Feb 03 10:52:46 2017 -0800
@@ -15,7 +15,7 @@
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 @@
$ 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>
--- a/Rakefile Wed Feb 01 15:35:35 2017 -0800
+++ b/Rakefile Fri Feb 03 10:52:46 2017 -0800
@@ -46,7 +46,7 @@
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'
--- a/lib/ezmlm/list.rb Wed Feb 01 15:35:35 2017 -0800
+++ b/lib/ezmlm/list.rb Fri Feb 03 10:52:46 2017 -0800
@@ -10,6 +10,7 @@
#---
require 'pathname'
+require 'etc'
require 'ezmlm'
require 'mail'
@@ -18,6 +19,20 @@
###
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 @@
@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
+ ### 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
- numfile = self.listdir + 'num'
- return 0 unless numfile.exist?
- return Integer( numfile.read[/^(\d+):/, 1] )
+ 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 @@
### 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 @@
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