#!/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 ### Returns +true+ if +address+ is a subscriber to this list. ### def include?( addr, section: nil ) addr.downcase! file = self.subscription_dir( section ) + self.hashchar( addr ) return false unless file.exist? return file.read.scan( /T([^\0]+)\0/ ).flatten.include?( addr ) end alias_method :is_subscriber?, :include? ### Fetch a sorted Array of the email addresses for all of the list's ### subscribers. ### def subscribers return self.read_subscriber_dir 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 alias_method :add_subscriber, :subscribe ### Unsubscribe +addr+ from the list within +section+. ### def unsubscribe( *addr, section: nil ) addr.each do |address| address.downcase! file = self.subscription_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 alias_method :remove_subscriber, :unsubscribe ### 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 ### Returns +true+ if +address+ is a moderator. ### def is_moderator?( addr ) return self.include?( addr, section: '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 an Array of email addresses denied access ### to the list. ### def blacklisted return self.read_subscriber_dir( 'deny' ) end ### Returns +true+ if +address+ is disallowed from participating. ### def is_blacklisted?( addr ) return self.include?( addr, section: 'deny' ) end ### Blacklist +addr+ from the list. ### def add_blacklisted( *addr ) return self.subscribe( *addr, section: 'deny' ) end ### Remove +addr+ from the blacklist. ### def remove_blacklisted( *addr ) return self.unsubscribe( *addr, section: 'deny' ) end ### Returns an Array of email addresses that act like ### regular subscribers for user-post only lists. ### def allowed return self.read_subscriber_dir( 'allow' ) end ### Returns +true+ if +address+ is given the same benefits as a ### regular subscriber for user-post only lists. ### def is_allowed?( addr ) return self.include?( addr, section: 'allow' ) end ### Add +addr+ to allow posting to user-post only lists, ### when +addr+ isn't a subscriber. ### def add_allowed( *addr ) return self.subscribe( *addr, section: 'allow' ) end ### Remove +addr+ from the allowed list. ### def remove_allowed( *addr ) return self.unsubscribe( *addr, section: 'allow' ) end ### Returns +true+ if message threading is enabled. ### def threaded? return ( self.listdir + 'threaded' ).exist? end ### Disable or enable message threading. ### ### This automatically builds message indexes and thread ### information on an incoming message. ### def threaded=( enable=true ) if enable self.touch( 'threaded' ) else self.unlink( 'threaded' ) end end ### Returns +true+ if the list is configured to respond ### to remote mangement requests. ### def public? return ( self.listdir + 'public' ).exist? end ### Disable or enable remote management requests. ### def public=( enable=true ) if enable self.touch( 'public' ) else self.unlink( 'public' ) end end ### Returns +true+ if the list is not configured to respond ### to remote mangement requests. ### def private? return ! self.public? end ### Disable or enable remote management requests. ### def private=( enable=false ) self.public = ! enable end ### Returns +true+ if the list supports remote administration ### subscribe/unsubscribe requests from moderators. ### def remote_subscriptions? return ( self.listdir + 'remote' ).exist? end ### Disable or enable remote subscription requests. ### def remote_subscriptions=( enable=false ) if enable self.touch( 'remote' ) else self.unlink( 'remote' ) end end ### Returns +true+ if list subscription requests require moderator ### approval. ### def moderated_subscriptions? return ( self.listdir + 'modsub' ).exist? end ### Disable or enable subscription moderation. ### def moderated_subscriptions=( enable=false ) if enable self.touch( 'modsub' ) else self.unlink( 'modsub' ) end end ### Returns +true+ if message moderation is enabled. ### def moderated? return ( self.listdir + 'modpost' ).exist? end ### Disable or enable message moderation. ### ### This has special meaning when combined with user_post_only setting. ### Lists act as unmoderated for subscribers, and posts from unknown ### addresses go to moderation. ### def moderated=( enable=false ) if enable self.touch( 'modpost' ) self.touch( 'noreturnposts' ) if self.user_posts_only? else self.unlink( 'modpost' ) self.unlink( 'noreturnposts' ) if self.user_posts_only? end end ### Returns +true+ if posting is only allowed by moderators. ### def moderator_posts_only? return ( self.listdir + 'modpostonly' ).exist? end ### Disable or enable moderation only posts. ### def moderator_posts_only=( enable=false ) if enable self.touch( 'modpostonly' ) else self.unlink( 'modpostonly' ) end end ### Returns +true+ if posting is only allowed by subscribers. ### def user_posts_only? return ( self.listdir + 'subpostonly' ).exist? end ### Disable or enable user only posts. ### This is easily defeated, moderated lists are preferred. ### ### This has special meaning for moderated lists. Lists act as ### unmoderated for subscribers, and posts from unknown addresses ### go to moderation. ### def user_posts_only=( enable=false ) if enable self.touch( 'subpostonly' ) self.touch( 'noreturnposts' )if self.moderated? else self.unlink( 'subpostonly' ) self.unlink( 'noreturnposts' ) if self.moderated? end end ### Returns +true+ if message archival is enabled. ### def archived? return ( self.listdir + 'archived' ).exist? || ( self.listdir + 'indexed' ).exist? end ### Disable or enable message archiving (and indexing.) ### def archive=( enable=true ) if enable self.touch( 'archived' ) self.touch( 'indexed' ) else self.unlink( 'archived' ) self.unlink( 'indexed' ) end end ### Returns +true+ if the message archive is accessible only to ### moderators. ### def private_archive? return ( self.listdir + 'modgetonly' ).exist? end ### Disable or enable private access to the archive. ### def private_archive=( enable=true ) if enable self.touch( 'modgetonly' ) else self.unlink( 'modgetonly' ) end end ### Returns +true+ if the message archive is accessible to anyone. ### def public_archive? return ! self.private_archive? end ### Returns +true+ if the message archive is accessible only to ### list subscribers. ### def guarded_archive? return ( self.listdir + 'subgetonly' ).exist? end ### Disable or enable loimited access to the archive. ### def guarded_archive=( enable=true ) if enable self.touch( 'subgetonly' ) else self.unlink( 'subgetonly' ) end end ### Returns +true+ if message digests are enabled. ### def digested? return ( self.listdir + 'digested' ).exist? end ### Disable or enable message digesting. ### def digest=( enable=true ) if enable self.touch( 'digested' ) else self.unlink( 'digested' ) end end ### If the list is digestable, trigger the digest after this amount ### of message body since the latest digest, in kbytes. ### ### See: ezmlm-tstdig(1) ### def digest_kbytesize size = self.read( 'digsize' ).to_i return size.zero? ? 64 : size end ### If the list is digestable, trigger the digest after this amount ### of message body since the latest digest, in kbytes. ### ### See: ezmlm-tstdig(1) ### def digest_kbytesize=( size=64 ) self.write( 'digsize' ) {|f| f.puts size.to_i } end ### If the list is digestable, trigger the digest after this many ### messages have accumulated since the latest digest. ### ### See: ezmlm-tstdig(1) ### def digest_count count = self.read( 'digcount' ).to_i return count.zero? ? 30 : count end ### If the list is digestable, trigger the digest after this many ### messages have accumulated since the latest digest. ### ### See: ezmlm-tstdig(1) ### def digest_count=( count=30 ) self.write( 'digcount' ) {|f| f.puts count.to_i } end ### If the list is digestable, trigger the digest after this much ### time has passed since the last digest, in hours. ### ### See: ezmlm-tstdig(1) ### def digest_timeout hours = self.read( 'digtime' ).to_i return hours.zero? ? 48 : hours end ### If the list is digestable, trigger the digest after this much ### time has passed since the last digest, in hours. ### ### See: ezmlm-tstdig(1) ### def digest_timeout=( hours=48 ) self.write( 'digtime' ) {|f| f.puts hours.to_i } end ### Returns +true+ if the list requires subscriptions to be ### confirmed. AKA "help" mode if disabled. ### def confirm_subscriptions? return ! ( self.listdir + 'nosubconfirm' ).exist? end ### Disable or enable subscription confirmation. ### AKA "help" mode if disabled. ### def confirm_subscriptions=( enable=true ) if enable self.unlink( 'nosubconfirm' ) else self.touch( 'nosubconfirm' ) end end ### Returns +true+ if the list requires unsubscriptions to be ### confirmed. AKA "jump" mode. ### def confirm_unsubscriptions? return ! ( self.listdir + 'nounsubconfirm' ).exist? end ### Disable or enable unsubscription confirmation. ### AKA "jump" mode. ### def confirm_unsubscriptions=( enable=true ) if enable self.unlink( 'nounsubconfirm' ) else self.touch( 'nounsubconfirm' ) end end ### Returns +true+ if the list requires regular message postings ### to be confirmed by the original sender. ### def confirm_postings? return ( self.listdir + 'confirmpost' ).exist? end ### Disable or enable message confirmation. ### def confirm_postings=( enable=false ) if enable self.touch( 'confirmpost' ) else self.unlink( 'confirmpost' ) end end ### Returns +true+ if the list allows moderators to ### fetch a subscriber list remotely. ### def allow_remote_listing? return ( self.listdir + 'modcanlist' ).exist? end ### Disable or enable the ability for moderators to ### remotely fetch a subscriber list. ### def allow_remote_listing=( enable=false ) if enable self.touch( 'modcanlist' ) else self.unlink( 'modcanlist' ) end end ### Returns +true+ if the list automatically manages ### bouncing subscriber addresses. ### def bounce_warnings? return ! ( self.listdir + 'nowarn' ).exist? end ### Disable or enable automatic bounce probes and warnings. ### def bounce_warnings=( enable=true ) if enable self.unlink( 'nowarn' ) else self.touch( 'nowarn' ) end end ### Return the maximum message size, in bytes. Messages larger than ### this size will be rejected. ### ### See: ezmlm-reject(1) ### def maximum_message_size size = self.read( 'msgsize' ) return size ? size.split( ':' ).first.to_i : 0 end ### Set the maximum message size, in bytes. Messages larger than ### this size will be rejected. ### ### See: ezmlm-reject(1) ### def maximum_message_size=( size=307200 ) if size.to_i.zero? self.unlink( 'msgsize' ) else self.write( 'msgsize' ) {|f| f.puts "#{size.to_i}:0" } end end ### Return the number of messages in the list archive. ### def message_count count = self.read( 'archnum' ) return count ? Integer( count ) : 0 end ### Returns the last message to the list as a Mail::Message, if ### archiving was enabled. ### def last_post num = self.message_count return if num.zero? hashdir = num / 100 message = "%02d" % [ num % 100 ] post = self.listdir + 'archive' + hashdir.to_s + message.to_s return unless post.exist? return Mail.read( post.to_s ) 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 ### Overwrite +file+ safely, yielding the open filehandle to the ### block. Set the new file to correct ownership and permissions. ### def write( file, &block ) file = self.listdir + file unless file.is_a?( Pathname ) self.with_safety do file.open( 'w' ) do |f| yield( f ) end stat = self.listdir.stat file.chown( stat.uid, stat.gid ) file.chmod( 0600 ) end end ### Simply create an empty file, safely. ### def touch( file ) self.write( file ) {} end ### Delete +file+ safely. ### def unlink( file ) file = self.listdir + file unless file.is_a?( Pathname ) return unless file.exist? self.with_safety do file.unlink end end ### Return a Pathname to a subscription directory. ### def subscription_dir( section=nil ) if section unless SUBSCRIPTION_DIRS.include?( section ) raise "Invalid subscription dir: %s, must be one of: %s" % [ section, SUBSCRIPTION_DIRS.join( ', ' ) ] end 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