lib/ezmlm/list.rb
changeset 13 a03c08c289e9
parent 12 3cc813140c80
child 14 cba9fb39bcdb
equal deleted inserted replaced
12:3cc813140c80 13:a03c08c289e9
     8 #  $Id$
     8 #  $Id$
     9 #
     9 #
    10 #---
    10 #---
    11 
    11 
    12 require 'pathname'
    12 require 'pathname'
       
    13 require 'etc'
    13 require 'ezmlm'
    14 require 'ezmlm'
    14 require 'mail'
    15 require 'mail'
    15 
    16 
    16 
    17 
    17 ### A Ruby interface to an ezmlm-idx mailing list directory
    18 ### A Ruby interface to an ezmlm-idx mailing list directory
    18 ###
    19 ###
    19 class Ezmlm::List
    20 class Ezmlm::List
    20 
    21 
       
    22 	# Quick address space detection, to (hopefully)
       
    23 	# match the overflow size on this machine.
       
    24 	#
       
    25 	ADDRESS_SPACE = case [ 'i' ].pack( 'p' ).size
       
    26 					when 4
       
    27 						32
       
    28 					when 8
       
    29 						64
       
    30 					end
       
    31 
       
    32 	# Valid subdirectories/sections for subscriptions.
       
    33 	SUBSCRIPTION_DIRS = %w[ deny mod digest allow ]
       
    34 
       
    35 
    21 	### Create a new Ezmlm::List object for the specified +listdir+, which should be
    36 	### Create a new Ezmlm::List object for the specified +listdir+, which should be
    22 	### an ezmlm-idx mailing list directory.
    37 	### an ezmlm-idx mailing list directory.
    23 	###
    38 	###
    24 	def initialize( listdir )
    39 	def initialize( listdir )
    25 		listdir = Pathname.new( listdir ) unless listdir.is_a?( Pathname )
    40 		listdir = Pathname.new( listdir ) unless listdir.is_a?( Pathname )
    26 		@listdir = listdir
    41 		@listdir = listdir
    27 	end
    42 	end
    28 
    43 
    29 
       
    30 	######
       
    31 	public
       
    32 	######
       
    33 
       
    34 	# The Pathname object for the list directory
    44 	# The Pathname object for the list directory
    35 	attr_reader :listdir
    45 	attr_reader :listdir
    36 
    46 
    37 
    47 
    38 	### Return the configured name of the list (without the host)
    48 	### Return the configured name of the list (without the host)
       
    49 	###
    39 	def name
    50 	def name
    40 		return self.config[ 'L' ]
    51 		@name = self.read( 'outlocal' ) unless @name
       
    52 		return @name
    41 	end
    53 	end
    42 
    54 
    43 
    55 
    44 	### Return the configured host of the list
    56 	### Return the configured host of the list
       
    57 	###
    45 	def host
    58 	def host
    46 		return self.config[ 'H' ]
    59 		@host = self.read( 'outhost' ) unless @host
       
    60 		return @host
    47 	end
    61 	end
    48 
    62 
    49 
    63 
    50 	### Return the configured address of the list (in list@host form)
    64 	### Return the configured address of the list (in list@host form)
       
    65 	###
    51 	def address
    66 	def address
    52 		return "%s@%s" % [ self.name, self.host ]
    67 		return "%s@%s" % [ self.name, self.host ]
    53 	end
    68 	end
    54 	alias_method :fullname, :address
    69 	alias_method :fullname, :address
    55 
    70 
    56 
    71 
    57 	### Return the number of messages in the list archive
    72 	### Return the email address of the list's owner.
       
    73 	###
       
    74 	def owner
       
    75 		owner = self.read( 'owner' )
       
    76 		return owner =~ /@/ ? owner : nil
       
    77 	end
       
    78 
       
    79 
       
    80 	### Return the number of messages in the list archive.
       
    81 	###
    58 	def message_count
    82 	def message_count
    59 		numfile = self.listdir + 'num'
    83 		count = self.read( 'archnum' )
    60 		return 0 unless numfile.exist?
    84 		return count ? Integer( count ) : 0
    61 		return Integer( numfile.read[/^(\d+):/, 1] )
    85 	end
    62 	end
    86 
    63 
    87 
    64 
    88 	### Fetch a sorted Array of the email addresses for all of the list's
       
    89 	### subscribers.
       
    90 	###
       
    91 	def subscribers
       
    92 		return self.read_subscriber_dir
       
    93 	end
       
    94 
       
    95 
       
    96 	### Returns an Array of email addresses of people responsible for
       
    97 	### moderating subscription of a closed list.
       
    98 	###
       
    99 	def moderators
       
   100 		return self.read_subscriber_dir( 'mod' )
       
   101 	end
       
   102 
       
   103 
       
   104 	### Subscribe +addr+ to the list as a Moderator.
       
   105 	###
       
   106 	def add_moderator( *addr )
       
   107 		return self.subscribe( *addr, section: 'mod' )
       
   108 	end
       
   109 
       
   110 
       
   111 	### Remove +addr+ from the list as a Moderator.
       
   112 	###
       
   113 	def remove_moderator( *addr )
       
   114 		return self.unsubscribe( *addr, section: 'mod' )
       
   115 	end
       
   116 
       
   117 
       
   118 	### Returns +true+ if +address+ is a subscriber to this list.
       
   119 	###
       
   120 	def include?( addr )
       
   121 		addr.downcase!
       
   122 		file = self.subscription_dir + self.hashchar( addr )
       
   123 		return false unless file.exist?
       
   124 		return file.read.scan( /T([^\0]+)\0/ ).flatten.include?( addr )
       
   125 	end
       
   126 
       
   127 
       
   128 	### Subscribe +addr+ to the list within +section+.
       
   129 	###
       
   130 	def subscribe( *addr, section: nil )
       
   131 		addr.each do |address|
       
   132 			next unless address.index( '@' )
       
   133 			address.downcase!
       
   134 
       
   135 			file = self.subscription_dir( section ) + self.hashchar( address )
       
   136 			self.with_safety do
       
   137 				if file.exist?
       
   138 					addresses = file.read.scan( /T([^\0]+)\0/ ).flatten
       
   139 					addresses << address
       
   140 					file.open( 'w' ) do |f|
       
   141 						f.print addresses.uniq.sort.map{|a| "T#{a}\0" }.join
       
   142 					end
       
   143 
       
   144 				else
       
   145 					file.open( 'w' ) do |f|
       
   146 						f.print "T%s\0" % [ address ]
       
   147 					end
       
   148 				end
       
   149 			end
       
   150 		end
       
   151 	end
       
   152 
       
   153 
       
   154 	### Unsubscribe +addr+ from the list within +section+.
       
   155 	###
       
   156 	def unsubscribe( *addr, section: nil )
       
   157 		addr.each do |address|
       
   158 			address.downcase!
       
   159 
       
   160 			file = self.subscribers_dir( section ) + self.hashchar( address )
       
   161 			self.with_safety do
       
   162 				next unless file.exist?
       
   163 				addresses = file.read.scan( /T([^\0]+)\0/ ).flatten
       
   164 				addresses = addresses - [ address ]
       
   165 
       
   166 				if addresses.empty?
       
   167 					file.unlink
       
   168 				else
       
   169 					file.open( 'w' ) do |f|
       
   170 						f.print addresses.uniq.sort.map{|a| "T#{a}\0" }.join
       
   171 					end
       
   172 				end
       
   173 			end
       
   174 		end
       
   175 	end
       
   176 
       
   177 
       
   178 =begin
    65 	### Return the Date parsed from the last post to the list.
   179 	### Return the Date parsed from the last post to the list.
       
   180 	###
    66 	def last_message_date
   181 	def last_message_date
    67 		mail = self.last_post or return nil
   182 		mail = self.last_post or return nil
    68 		return mail.date
   183 		return mail.date
    69 	end
   184 	end
    70 
   185 
    71 
   186 
    72 	### Return the author of the last post to the list.
   187 	### Return the author of the last post to the list.
       
   188 	###
    73 	def last_message_author
   189 	def last_message_author
    74 		mail = self.last_post or return nil
   190 		mail = self.last_post or return nil
    75 		return mail.from
   191 		return mail.from
    76 	end
   192 	end
    77 
   193 
    78 
   194 
    79 	### Return the list config as a Hash
       
    80 	def config
       
    81 		unless @config
       
    82 			configfile = self.listdir + 'config'
       
    83 			raise "List config file %p does not exist" % [ configfile ] unless configfile.exist?
       
    84 
       
    85 			@config = configfile.read.scan( /^(\S):([^\n]*)$/m ).inject({}) do |h,pair|
       
    86 				key,val = *pair
       
    87 				h[key] = val
       
    88 				h
       
    89 			end
       
    90 		end
       
    91 
       
    92 		return @config
       
    93 	end
       
    94 
       
    95 
       
    96 	### Return the email address of the list's owner.
       
    97 	def owner
       
    98 		self.config['5']
       
    99 	end
       
   100 
       
   101 
       
   102 	### Fetch an Array of the email addresses for all of the list's subscribers.
       
   103 	def subscribers
       
   104 		subscribers_dir = self.listdir + 'subscribers'
       
   105 		return self.read_subscriber_dir( subscribers_dir )
       
   106 	end
       
   107 
       
   108 
       
   109 	### Returns +true+ if subscription to the list is moderated.
   195 	### Returns +true+ if subscription to the list is moderated.
       
   196 	###
   110 	def closed?
   197 	def closed?
   111 		return (self.listdir + 'modsub').exist? || (self.listdir + 'remote').exist?
   198 		return (self.listdir + 'modsub').exist? || (self.listdir + 'remote').exist?
   112 	end
   199 	end
   113 
   200 
   114 
   201 
   115 	### Returns +true+ if posting to the list is moderated.
   202 	### Returns +true+ if posting to the list is moderated.
       
   203 	###
   116 	def moderated?
   204 	def moderated?
   117 		return (self.listdir + 'modpost').exist?
   205 		return (self.listdir + 'modpost').exist?
   118 	end
   206 	end
   119 
   207 
   120 
   208 
   121 	### Returns an Array of email addresses of people responsible for moderating subscription
   209 	### Return a Mail::Message object loaded from the last post to the list. Returns
   122 	### of a closed list.
       
   123 	def subscription_moderators
       
   124 		return [] unless self.closed?
       
   125 
       
   126 		modsubfile = self.listdir + 'modsub'
       
   127 		remotefile = self.listdir + 'remote'
       
   128 
       
   129 		subdir = nil
       
   130 		if modsubfile.exist? && modsubfile.read(1) == '/'
       
   131 			subdir = Pathname.new( modsubfile.read.chomp )
       
   132 		elsif remotefile.exist? && remotefile.read(1) == '/'
       
   133 			subdir = Pathname.new( remotefile.read.chomp )
       
   134 		else
       
   135 			subdir = self.listdir + 'mod/subscribers'
       
   136 		end
       
   137 
       
   138 		return self.read_subscriber_dir( subdir )
       
   139 	end
       
   140 
       
   141 
       
   142 	### Returns an Array of email addresses of people responsible for moderating posts
       
   143 	### sent to the list.
       
   144 	def message_moderators
       
   145 		return [] unless self.moderated?
       
   146 
       
   147 		modpostfile = self.listdir + 'modpost'
       
   148 		subdir = nil
       
   149 
       
   150 		if modpostfile.exist? && modpostfile.read(1) == '/'
       
   151 			subdir = Pathname.new( modpostfile.read.chomp )
       
   152 		else
       
   153 			subdir = self.listdir + 'mod/subscribers'
       
   154 		end
       
   155 
       
   156 		return self.read_subscriber_dir( subdir )
       
   157 	end
       
   158 
       
   159 
       
   160 	### Return a TMail::Mail object loaded from the last post to the list. Returns
       
   161 	### +nil+ if there are no archived posts.
   210 	### +nil+ if there are no archived posts.
       
   211 	###
   162 	def last_post
   212 	def last_post
   163 		archivedir = self.listdir + 'archive'
   213 		archivedir = self.listdir + 'archive'
   164 		return nil unless archivedir.exist?
   214 		return nil unless archivedir.exist?
   165 
   215 
   166 		# Find the last numbered directory under the archive dir
   216 		# Find the last numbered directory under the archive dir
   175 			sort_by {|pn| pn.basename.to_s }.last
   225 			sort_by {|pn| pn.basename.to_s }.last
   176 
   226 
   177 		raise RuntimeError, "unexpectedly empty archive directory '%s'" % [ last_archdir ] \
   227 		raise RuntimeError, "unexpectedly empty archive directory '%s'" % [ last_archdir ] \
   178 			unless last_post_path
   228 			unless last_post_path
   179 
   229 
       
   230 				require 'pry'
       
   231 				binding.pry
   180 		last_post = TMail::Mail.load( last_post_path.to_s )
   232 		last_post = TMail::Mail.load( last_post_path.to_s )
   181 	end
   233 	end
   182 
   234 =end
   183 
   235 
   184 
   236 
   185 	#########
   237 	#########
   186 	protected
   238 	protected
   187 	#########
   239 	#########
   188 
   240 
       
   241 	### Hash an email address, using the ezmlm algorithm for
       
   242 	### fast user lookups.  Returns the hashed integer.
       
   243 	###
       
   244 	### Older ezmlm didn't lowercase addresses, anything within the last
       
   245 	### decade did.  We're not going to worry about compatibility there.
       
   246 	###
       
   247 	### (See subhash.c in the ezmlm source.)
       
   248 	###
       
   249 	def subhash( addr )
       
   250 		h = 5381
       
   251 		over = 2 ** ADDRESS_SPACE
       
   252 
       
   253 		addr = 'T' + addr
       
   254 		addr.each_char do |c|
       
   255 			h = ( h + ( h << 5 ) ) ^ c.ord
       
   256 			h = h % over if h > over # emulate integer overflow
       
   257 		end
       
   258 		return h % 53
       
   259 	end
       
   260 
       
   261 
       
   262 	### Given an email address, return the ascii character.
       
   263 	###
       
   264 	def hashchar( addr )
       
   265 		return ( self.subhash(addr) + 64 ).chr
       
   266 	end
       
   267 
       
   268 
       
   269 	### Just return the contents of the provided +file+, rooted
       
   270 	### in the list directory.
       
   271 	###
       
   272 	def read( file )
       
   273 		file = self.listdir + file unless file.is_a?( Pathname )
       
   274 		return file.read.chomp
       
   275 	rescue
       
   276 		nil
       
   277 	end
       
   278 
       
   279 
       
   280 	### Return a Pathname to a subscription directory.
       
   281 	###
       
   282 	def subscription_dir( section=nil )
       
   283 		section = nil if section && ! SUBSCRIPTION_DIRS.include?( section )
       
   284 
       
   285 		if section
       
   286 			return self.listdir + section + 'subscribers'
       
   287 		else
       
   288 			return self.listdir + 'subscribers'
       
   289 		end
       
   290 	end
       
   291 
       
   292 
   189 	### Read the hashed subscriber email addresses from the specified +directory+ and return them in
   293 	### Read the hashed subscriber email addresses from the specified +directory+ and return them in
   190 	### an Array.
   294 	### an Array.
   191 	def read_subscriber_dir( directory )
   295 	###
       
   296 	def read_subscriber_dir( section=nil )
       
   297 		directory = self.subscription_dir( section )
   192 		rval = []
   298 		rval = []
   193 		Pathname.glob( directory + '*' ) do |hashfile|
   299 		Pathname.glob( directory + '*' ) do |hashfile|
   194 			rval.push( hashfile.read.scan(/T([^\0]+)\0/) )
   300 			rval.push( hashfile.read.scan(/T([^\0]+)\0/) )
   195 		end
   301 		end
   196 
   302 
   197 		return rval.flatten
   303 		return rval.flatten.sort
       
   304 	end
       
   305 
       
   306 
       
   307 	### Return a Pathname object for the list owner's home directory.
       
   308 	###
       
   309 	def homedir
       
   310 		user = Etc.getpwuid( self.listdir.stat.uid )
       
   311 		return Pathname( user.dir )
       
   312 	end
       
   313 
       
   314 
       
   315 	### Safely make modifications to a file within a list directory.
       
   316 	###
       
   317 	### Mail can come in at any time.  Make changes within a list
       
   318 	### atomic -- if an incoming message hits when a sticky
       
   319 	### is set, it is deferred to the Qmail queue.
       
   320 	###
       
   321 	###   - Set sticky bit on the list directory owner's homedir
       
   322 	###   - Make changes with the block
       
   323 	###   - Unset sticky (just back to what it was previously)
       
   324 	###
       
   325 	### All writes should be wrapped in this method.
       
   326 	###
       
   327 	def with_safety( &block )
       
   328 		home = self.homedir
       
   329 		mode = home.stat.mode
       
   330 
       
   331 		home.chmod( mode | 01000 ) # enable sticky
       
   332 		yield
       
   333 
       
   334 	ensure
       
   335 		home.chmod( mode )
   198 	end
   336 	end
   199 
   337 
   200 end # class Ezmlm::List
   338 end # class Ezmlm::List
   201 
   339