# HG changeset patch # User Mahlon E. Smith # Date 1494968314 25200 # Node ID 23c7f5c8ee394032406b8ac39d697b9f43c92a67 # Parent e135ccae6783300d059f5cb62ee6fc615000ea5e Multiple changes. - Remove the runtime dependency on rake-compiler. - Use #rb_str_new instead of #rb_str_new2, the hash character array isn't null terminated. - Add various safeguards for object instantiations. - Remove the 'threaded' options for messages, folding them into 'archived'. If archiving is enabled, so is threading. - Return nil for lookups from the list object instead of raising exceptions. - Open subject indexes with the proper encodings (thanks Michael Granger!) - Allow touching and unlinking files to operate on multiple paths at once, within a single safety() wrap. diff -r e135ccae6783 -r 23c7f5c8ee39 .hgignore --- a/.hgignore Fri May 12 16:17:41 2017 -0700 +++ b/.hgignore Tue May 16 13:58:34 2017 -0700 @@ -2,3 +2,4 @@ pkg/* docs/* tmp/* +lib/ezmlm/hash.so diff -r e135ccae6783 -r 23c7f5c8ee39 README.md --- a/README.md Fri May 12 16:17:41 2017 -0700 +++ b/README.md Tue May 16 13:58:34 2017 -0700 @@ -37,10 +37,6 @@ $ gem install ezmlm -## Usage - - .... - ## TODO diff -r e135ccae6783 -r 23c7f5c8ee39 Rakefile --- a/Rakefile Fri May 12 16:17:41 2017 -0700 +++ b/Rakefile Tue May 16 13:58:34 2017 -0700 @@ -59,7 +59,7 @@ s.required_ruby_version = '>= 2.1' s.add_dependency 'mail', "~> 2.6" - s.add_dependency 'rake-compiler', "~> 1.0" + s.add_development_dependency 'rake-compiler', "~> 1.0" end Gem::PackageTask.new( spec ) do |pkg| diff -r e135ccae6783 -r 23c7f5c8ee39 USAGE.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/USAGE.md Tue May 16 13:58:34 2017 -0700 @@ -0,0 +1,126 @@ + +Usage +======= + +Here's a quick rundown of how to use this library. For specifics, see +the generated RDoc. + + +Examples +-------- + + +*Print the list address for all lists in a directory*: + + Ezmlm.each_list( '/lists' ) do |list| + puts list.address + end + + +*Check if I'm subscribed to a list, and if so, unsubscribe*: + +(You don't really have to check first, subscribe and unsubscribe are +idempotent.) + + list = Ezmlm::List.new( '/lists/waffle-lovers' ) + + if list.include?( 'mahlon@martini.nu' ) + list.unsubscribe( 'mahlon@martini.nu' ) + end + + puts "The list now has %d subscribers!" % [ list.subscribers.size ] + + +*Iterate over the subscriber list*: + + list.subscribers.each do |subscriber| + # ... + end + + +*Make the list moderated, and add a moderator*: + + list.moderated = true + list.add_moderator( 'mahlon@martini.nu' ) + list.moderated? #=> true + +All other list behavior tunables operate in a similar fashion, see RDoc +for details. + + +*Archiving!* + +All of the archival pieces take advantage of Ezmlm-IDX extensions. +If you want to use these features, you'll want to enable archiving +and indexing for your lists, using the -a and -i flags to ezmlm-make. +(Enabling archiving with this library also enables indexing and thread +indexes, I assume that since you're using ezmlm-idx, you want these +enhancements!) + + list.archived? #=> false + list.archived = true + list.archived? #=> true + +If your list(s) already had archiving enabled (the default to +ezmlm-make) but not indexing, you can manually run ezmlm-archive to +rebuild the necessary files - afterwards, they are kept up to date +automatically. + + +*How many messages are in the archive?*: + + list.message_count #=> 123 + + +*Fetch message number 100 from the archive*: + + message = list.message( 100 ) or abort "No such message." + + puts message.subject + puts message.body.to_s # Print just the body of the message. + puts message.to_s # Print the entire, unparsed message. + + thread = message.thread # Returns an Ezmlm::List::Thread object + author = message.author # Returns an Ezmlm::List::Author object + +As a general rule, methods called on the Ezmlm::List object return nil +if they are unable to perform the requested task. Instantiating the +underlying objects directly raise with a specific error. The following +are equivalent, but behave differently: + + message = list.message( 10000 ) # nonexistent message, returns nil + message = Ezmlm::List::Message.new( list, 10000 ) # Raises a RuntimeError + +Message objects act as "Mail" objects from the excellent library from +Mikel Lindsaar (https://github.com/mikel/mail). See its documentation +for specifics. + + +*Iterate over messages in a specific thread*: + +Messages know what thread they belong to. Once you have a thread object +from a message, it is an enumerable. Iterate or sort on it using +standard Ruby methods. + + thread.each do |message| + # ... + end + +Threads are also aware of who participated in the conversation, via the +'authors' and 'each_author' methods. + + +*Iterate over messages from a specific author:* + +Messages know who authored them. Once you have an author object from a +message, it is an enumerable. Iterate or sort on it using standard Ruby +methods. + + author.each do |message| + # ... + end + +An Author object is also aware of all threads the author participated +in, via the 'threads' and 'each_thread' methods. + + diff -r e135ccae6783 -r 23c7f5c8ee39 ext/ezmlm/hash/hash.c --- a/ext/ezmlm/hash/hash.c Fri May 12 16:17:41 2017 -0700 +++ b/ext/ezmlm/hash/hash.c Tue May 16 13:58:34 2017 -0700 @@ -148,12 +148,12 @@ Check_Type( email, T_STRING ); - email = rb_str_plus( rb_str_new2("<"), email); + email = rb_str_plus( rb_str_new2("<"), email ); input = StringValueCStr( email ); makehash( input, strlen(input), hash ); - return rb_str_new2( hash ); + return rb_str_new( hash, 20 ); } diff -r e135ccae6783 -r 23c7f5c8ee39 lib/ezmlm.rb --- a/lib/ezmlm.rb Fri May 12 16:17:41 2017 -0700 +++ b/lib/ezmlm.rb Tue May 16 13:58:34 2017 -0700 @@ -1,7 +1,14 @@ -#!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: + + +# A Ruby interface to the ezmlm-idx mailing list system. # -# A Ruby interface to the ezmlm-idx mailing list system. +# Ezmlm.find_directories( '/lists' ) #=> [ Ezmlm::List, Ezmlm::List ] +# +# Ezmlm.each_list( '/lists' ) do |list| +# puts "\"%s\" <%s>" % [ list.name, list.address ] +# end +# # # == Version # diff -r e135ccae6783 -r 23c7f5c8ee39 lib/ezmlm/list.rb --- a/lib/ezmlm/list.rb Fri May 12 16:17:41 2017 -0700 +++ b/lib/ezmlm/list.rb Tue May 16 13:58:34 2017 -0700 @@ -1,7 +1,11 @@ #!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: + + +# A Ruby interface to a single Ezmlm-idx mailing list directory. # -# A Ruby interface to a single Ezmlm-idx mailing list directory. +# list = Ezmlm::List.new( '/path/to/listdir' ) +# # # == Version # @@ -28,6 +32,9 @@ ### def initialize( listdir ) listdir = Pathname.new( listdir ) unless listdir.is_a?( Pathname ) + unless listdir.directory? && ( listdir + 'mailinglist' ).exist? + raise ArgumentError, "%p doesn't appear to be an ezmlm-idx list." % [ listdir.to_s ] + end @listdir = listdir end @@ -219,27 +226,6 @@ 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 - alias_method :threaded, :threaded= - - ### Returns +true+ if the list is configured to respond ### to remote management requests. ### @@ -380,21 +366,23 @@ ### Returns +true+ if message archival is enabled. ### def archived? - return ( self.listdir + 'archived' ).exist? || ( self.listdir + 'indexed' ).exist? + test = %w[ archived indexed threaded ].each_with_object( [] ) do |f, acc| + acc << self.listdir + f + end + + return test.all?( &:exist? ) end - ### Disable or enable message archiving (and indexing.) + ### Disable or enable message archiving (and indexing/threading.) ### - def archive=( enable=true ) + def archived=( enable=true ) if enable - self.touch( 'archived' ) - self.touch( 'indexed' ) + self.touch( 'archived', 'indexed', 'threaded' ) else - self.unlink( 'archived' ) - self.unlink( 'indexed' ) + self.unlink( 'archived', 'indexed', 'threaded' ) end end - alias_method :archive, :archive= + alias_method :archived, :archived= ### Returns +true+ if the message archive is accessible only to ### moderators. @@ -653,9 +641,8 @@ ### Returns an individual message if archiving was enabled. ### def message( message_id ) - raise "Archiving is not enabled." unless self.archived? raise "Message archive is empty." if self.message_count.zero? - return Ezmlm::List::Message.new( self, message_id ) + return Ezmlm::List::Message.new( self, message_id ) rescue nil end ### Lazy load each message ID as a Ezmlm::List::Message, @@ -671,8 +658,7 @@ ### Return a Thread object for the given +thread_id+. ### def thread( thread_id ) - raise "Archiving is not enabled." unless self.archived? - return Ezmlm::List::Thread.new( self, thread_id ) + return Ezmlm::List::Thread.new( self, thread_id ) rescue nil end @@ -680,9 +666,8 @@ ### could also be an email address. ### def author( author_id ) - raise "Archiving is not enabled." unless self.archived? author_id = Ezmlm::Hash.address(author_id) if author_id.index( '@' ) - return Ezmlm::List::Author.new( self, author_id ) + return Ezmlm::List::Author.new( self, author_id ) rescue nil end @@ -700,22 +685,25 @@ index = archivedir + dir.to_s + 'index' next unless index.exist? - index.each_line.lazy.slice_before( /^\d+:/ ).each do |message| - match = message[0].match( /^(?\d+): (?\w+)/ ) - next unless match - thread_id = match[ :thread_id ] + index.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh| + fh.each_line.lazy.slice_before( /^\d+:/ ).each do |message| + + match = message[0].match( /^(?\d+): (?\w+)/ ) + next unless match + thread_id = match[ :thread_id ] - match = message[1].match( /^(?[^;]+);(?\w+) / ) - next unless match - author_id = match[ :author_id ] - date = match[ :date ] + match = message[1].match( /^(?[^;]+);(?\w+) / ) + next unless match + author_id = match[ :author_id ] + date = match[ :date ] - metadata = { - date: Time.parse( date ), - thread: thread_id, - author: author_id - } - acc << metadata + metadata = { + date: Time.parse( date ), + thread: thread_id, + author: author_id + } + acc << metadata + end end end @@ -757,18 +745,25 @@ ### Simply create an empty file, safely. ### - def touch( file ) - self.write( file ) {} + def touch( *file ) + self.with_safety do + Array( file ).flatten.each do |f| + f = self.listdir + f unless f.is_a?( Pathname ) + f.open( 'w' ) {} + end + end end ### Delete +file+ safely. ### - def unlink( file ) - file = self.listdir + file unless file.is_a?( Pathname ) - return unless file.exist? + def unlink( *file ) self.with_safety do - file.unlink + Array( file ).flatten.each do |f| + f = self.listdir + f unless f.is_a?( Pathname ) + next unless f.exist? + f.unlink + end end end diff -r e135ccae6783 -r 23c7f5c8ee39 lib/ezmlm/list/author.rb --- a/lib/ezmlm/list/author.rb Fri May 12 16:17:41 2017 -0700 +++ b/lib/ezmlm/list/author.rb Tue May 16 13:58:34 2017 -0700 @@ -1,6 +1,7 @@ #!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: -# + + # A collection of messages authored from a unique user. # # Note that Ezmlm uses the "real name" part of an address @@ -27,12 +28,12 @@ include Enumerable ### Instantiate a new list of messages given - ### a +list+ and a +author_id+. + ### a +list+ and an +author_id+. ### def initialize( list, author_id ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Malformed Author ID." unless author_id =~ /^\w{20}$/ - raise "Thread indexing is not enabled." unless list.threaded? + raise "Archiving is not enabled." unless list.archived? @list = list @id = author_id @@ -67,6 +68,7 @@ yield Ezmlm::List::Message.new( self.list, id ) end end + alias_method :each_message, :each ### Lazy load each thread ID as a Ezmlm::List::Thread, yielding it to the block. @@ -92,13 +94,15 @@ path = self.author_path raise "Unknown author: %p" % [ self.id ] unless path.exist? - path.each_line.with_index do |line, i| - if i.zero? - @name = line.match( /^\w+ (.+)/ )[1] - else - match = line.match( /^(\d+):\d+:(\w+) / ) or next - self.messages << match[1].to_i - self.threads << match[2] + path.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh| + fh.each_line.with_index do |line, i| + if i.zero? + @name = line.match( /^\w+ (.+)/ )[1] + else + match = line.match( /^(\d+):\d+:(\w+) / ) or next + self.messages << match[1].to_i + self.threads << match[2] + end end end end diff -r e135ccae6783 -r 23c7f5c8ee39 lib/ezmlm/list/message.rb --- a/lib/ezmlm/list/message.rb Fri May 12 16:17:41 2017 -0700 +++ b/lib/ezmlm/list/message.rb Tue May 16 13:58:34 2017 -0700 @@ -1,6 +1,7 @@ #!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: -# + + # An individual list message. # # message = Ezmlm::List::Message.new( list, 24 ) @@ -31,6 +32,7 @@ def initialize( list, message_number=0 ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Invalid message number (impossible)" if message_number < 1 + raise "Archiving is not enabled." unless list.archived? raise ArgumentError, "Invalid message number (out of list bounds)" if message_number > list.message_count @list = list diff -r e135ccae6783 -r 23c7f5c8ee39 lib/ezmlm/list/thread.rb --- a/lib/ezmlm/list/thread.rb Fri May 12 16:17:41 2017 -0700 +++ b/lib/ezmlm/list/thread.rb Tue May 16 13:58:34 2017 -0700 @@ -1,6 +1,7 @@ #!/usr/bin/ruby # vim: set nosta noet ts=4 sw=4: -# + + # A collection of messages for a specific archive thread. # # thread = Ezmlm::List::Thread.new( list, 'acgcbmbmeapgpfckcdol' ) @@ -29,7 +30,7 @@ def initialize( list, thread_id ) raise ArgumentError, "Unknown list object." unless list.respond_to?( :listdir ) raise ArgumentError, "Malformed Thread ID." unless thread_id =~ /^\w{20}$/ - raise "Thread indexing is not enabled." unless list.threaded? + raise "Archiving is not enabled." unless list.archived? @list = list @id = thread_id @@ -65,6 +66,18 @@ yield Ezmlm::List::Message.new( self.list, id ) end end + alias_method :each_message, :each + + + ### Lazy load each author ID as a Ezmlm::List::Author, yielding it + ### to the block. + ### + def each_author + self.load_thread # refresh for any thread updates since object was created + self.authors.each do |id| + yield Ezmlm::List::Author.new( self.list, id ) + end + end ######### @@ -79,13 +92,15 @@ path = self.thread_path raise "Unknown thread: %p" % [ self.id ] unless path.exist? - path.each_line.with_index do |line, i| - if i.zero? - @subject = line.match( /^\w+ (.+)/ )[1] - else - match = line.match( /^(\d+):\d+:(\w+) / ) or next - self.messages << match[1].to_i - self.authors << match[2] + path.open( 'r', encoding: Encoding::ISO8859_1 ) do |fh| + fh.each_line.with_index do |line, i| + if i.zero? + @subject = line.match( /^\w+ (.+)/ )[1] + else + match = line.match( /^(\d+):\d+:(\w+) / ) or next + self.messages << match[1].to_i + self.authors << match[2] + end end end end diff -r e135ccae6783 -r 23c7f5c8ee39 spec/data/testlist/archive/0/index --- a/spec/data/testlist/archive/0/index Fri May 12 16:17:41 2017 -0700 +++ b/spec/data/testlist/archive/0/index Tue May 16 13:58:34 2017 -0700 @@ -20,7 +20,7 @@ 7 May 2017 21:55:05 -0000;gclofnmfhpbehngoppdg Amalia Purdy 11: bhfejoliggjidmkbclnn Parsing panel for a hard drive????? 7 May 2017 21:55:05 -0000;decddajmifhkgodaginh Sally Pagac -12: fbhfcpngckkjbhlfjooh Trying to override SCSI on the microchip. +12: fbhfcpngckkjbhlfjooh Trying to override SCSI’s on the microchip. 7 May 2017 21:55:05 -0000;kdlcjeacpilheebkcbaf Wayne Friesen 13: mipieokohgoiigideadf Generating circuit for a application????? 7 May 2017 21:55:05 -0000;ojjhjlapnejjlbcplabi Jena Smitham diff -r e135ccae6783 -r 23c7f5c8ee39 spec/data/testlist/mailinglist --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/spec/data/testlist/mailinglist Tue May 16 13:58:34 2017 -0700 @@ -0,0 +1,1 @@ +contact testlist-help@lists.laika.com; run by ezmlm diff -r e135ccae6783 -r 23c7f5c8ee39 spec/ezmlm/list/author_spec.rb --- a/spec/ezmlm/list/author_spec.rb Fri May 12 16:17:41 2017 -0700 +++ b/spec/ezmlm/list/author_spec.rb Tue May 16 13:58:34 2017 -0700 @@ -28,11 +28,11 @@ }.to raise_error( ArgumentError, /unknown list/i ) end - it 'raises error if thread indexing is disabled' do - expect( list ).to receive( :threaded? ).and_return( false ) + it 'raises error if thread archiving is disabled' do + expect( list ).to receive( :archived? ).and_return( false ) expect { described_class.new( list, author_id ) - }.to raise_error( RuntimeError, /indexing is not enabled/i ) + }.to raise_error( RuntimeError, /archiving is not enabled/i ) end it 'raises error if passed a malformed author ID' do @@ -43,7 +43,7 @@ it 'raises error when unable to read index file' do allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) - expect( list ).to receive( :threaded? ).and_return( true ) + expect( list ).to receive( :archived? ).and_return( true ) expect { described_class.new( list, author_id ) }.to raise_error( RuntimeError, /unknown author/i ) diff -r e135ccae6783 -r 23c7f5c8ee39 spec/ezmlm/list/message_spec.rb --- a/spec/ezmlm/list/message_spec.rb Fri May 12 16:17:41 2017 -0700 +++ b/spec/ezmlm/list/message_spec.rb Tue May 16 13:58:34 2017 -0700 @@ -44,6 +44,7 @@ it 'raises error when unable to read message' do allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) expect( list ).to receive( :message_count ).and_return( 1 ) + expect( list ).to receive( :archived? ).and_return( true ) expect { described_class.new( list, 1 ) }.to raise_error( RuntimeError, /unable to determine message path/i ) diff -r e135ccae6783 -r 23c7f5c8ee39 spec/ezmlm/list/thread_spec.rb --- a/spec/ezmlm/list/thread_spec.rb Fri May 12 16:17:41 2017 -0700 +++ b/spec/ezmlm/list/thread_spec.rb Tue May 16 13:58:34 2017 -0700 @@ -29,10 +29,10 @@ end it 'raises error if thread indexing is disabled' do - expect( list ).to receive( :threaded? ).and_return( false ) + expect( list ).to receive( :archived? ).and_return( false ) expect { described_class.new( list, thread_id ) - }.to raise_error( RuntimeError, /indexing is not enabled/i ) + }.to raise_error( RuntimeError, /archiving is not enabled/i ) end it 'raises error if passed a malformed thread ID' do @@ -43,7 +43,7 @@ it 'raises error when unable to read thread file' do allow( list ).to receive( :listdir ).and_return( Pathname('/nope') ) - expect( list ).to receive( :threaded? ).and_return( true ) + expect( list ).to receive( :archived? ).and_return( true ) expect { described_class.new( list, thread_id ) }.to raise_error( RuntimeError, /unknown thread/i ) diff -r e135ccae6783 -r 23c7f5c8ee39 spec/ezmlm/list_spec.rb --- a/spec/ezmlm/list_spec.rb Fri May 12 16:17:41 2017 -0700 +++ b/spec/ezmlm/list_spec.rb Tue May 16 13:58:34 2017 -0700 @@ -113,16 +113,6 @@ end - it 'returns the current threading state' do - expect( list.threaded? ).to be_truthy - end - - it 'can set the threading state' do - list.threaded = false - expect( list.threaded? ).to be_falsey - end - - it 'returns the current public/private state' do expect( list.public? ).to be_truthy expect( list.private? ).to be_falsey @@ -200,9 +190,9 @@ it 'can set archival status' do expect( list.archived? ).to be_truthy - list.archive = false + list.archived = false expect( list.archived? ).to be_falsey - list.archive = true + list.archived = true expect( list.archived? ).to be_truthy end @@ -336,6 +326,10 @@ expect( list.thread('cadgeokhhaieijmndokb') ).to be_a( Ezmlm::List::Thread ) end + it 'returns nil when fetching an invalid thread' do + expect( list.thread('whatever') ).to be_nil + end + it 'fetches author objects upon request' do expect( list.author('ojjhjlapnejjlbcplabi') ).to be_a( Ezmlm::List::Author ) @@ -346,13 +340,19 @@ expect( list.author('yvette@example.net').name ).to eq( author.name ) end + it 'returns nil when fetching an invalid author' do + expect( list.author('whatever') ).to be_nil + end + context 'fetching messages' do - it 'raises an error if archiving is disabled' do + it 'returns nil if archiving is disabled' do expect( list ).to receive( :archived? ).and_return( false ) - expect { - list.message( 1 ) - }.to raise_error( RuntimeError, /archiving is not enabled/i ) + expect( list.message(1) ).to be_nil + end + + it 'returns nil when fetching an invalid message id' do + expect( list.message(2389234) ).to be_nil end it 'raises an error if the message archive is empty' do diff -r e135ccae6783 -r 23c7f5c8ee39 spec/spec_helpers.rb --- a/spec/spec_helpers.rb Fri May 12 16:17:41 2017 -0700 +++ b/spec/spec_helpers.rb Tue May 16 13:58:34 2017 -0700 @@ -2,7 +2,6 @@ require 'simplecov' if ENV['COVERAGE'] require 'rspec' -require 'loggability/spechelpers' require 'fileutils' require_relative '../lib/ezmlm'