lib/ezmlm/list.rb
author Mahlon E. Smith <mahlon@martini.nu>
Fri, 03 Feb 2017 10:52:46 -0800
changeset 13 a03c08c289e9
parent 12 3cc813140c80
child 14 cba9fb39bcdb
permissions -rw-r--r--
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