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
This commit is contained in:
parent
3f96c8bb0f
commit
5a524b55bf
3 changed files with 239 additions and 87 deletions
16
README.md
16
README.md
|
|
@ -15,7 +15,7 @@ code
|
|||
|
||||
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 @@ http://untroubled.org/ezmlm/
|
|||
$ 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>
|
||||
|
|
|
|||
2
Rakefile
2
Rakefile
|
|
@ -46,7 +46,7 @@ spec = Gem::Specification.new do |s|
|
|||
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'
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
#---
|
||||
|
||||
require 'pathname'
|
||||
require 'etc'
|
||||
require 'ezmlm'
|
||||
require 'mail'
|
||||
|
||||
|
|
@ -18,6 +19,20 @@ require 'mail'
|
|||
###
|
||||
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 @@ class Ezmlm::List
|
|||
@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
|
||||
def message_count
|
||||
numfile = self.listdir + 'num'
|
||||
return 0 unless numfile.exist?
|
||||
return Integer( numfile.read[/^(\d+):/, 1] )
|
||||
### 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
|
||||
|
|
@ -70,95 +185,30 @@ class Ezmlm::List
|
|||
|
||||
|
||||
### 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 @@ class Ezmlm::List
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue