Multiple changes.
authorMahlon E. Smith <mahlon@martini.nu>
Fri, 03 Feb 2017 10:52:46 -0800
changeset 13 a03c08c289e9
parent 12 3cc813140c80
child 14 cba9fb39bcdb
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
README.md
Rakefile
lib/ezmlm/list.rb
--- 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