Multiple changes.
authorMahlon E. Smith <mahlon@laika.com>
Tue, 16 May 2017 13:58:34 -0700
changeset 17 23c7f5c8ee39
parent 16 e135ccae6783
child 18 9aaac749fd9f
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.
.hgignore
README.md
Rakefile
USAGE.md
ext/ezmlm/hash/hash.c
lib/ezmlm.rb
lib/ezmlm/list.rb
lib/ezmlm/list/author.rb
lib/ezmlm/list/message.rb
lib/ezmlm/list/thread.rb
spec/data/testlist/archive/0/index
spec/data/testlist/mailinglist
spec/ezmlm/list/author_spec.rb
spec/ezmlm/list/message_spec.rb
spec/ezmlm/list/thread_spec.rb
spec/ezmlm/list_spec.rb
spec/spec_helpers.rb
--- 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
--- 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
 
--- 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|
--- /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.
+
+
--- 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 );
 }
 
 
--- 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
 #
--- 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( /^(?<message_id>\d+): (?<thread_id>\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( /^(?<message_id>\d+): (?<thread_id>\w+)/ )
+					next unless match
+					thread_id  = match[ :thread_id ]
 
-				match = message[1].match( /^(?<date>[^;]+);(?<author_id>\w+) / )
-				next unless match
-				author_id  = match[ :author_id ]
-				date       = match[ :date ]
+					match = message[1].match( /^(?<date>[^;]+);(?<author_id>\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
 
--- 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
--- 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
--- 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
--- 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
--- /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
--- 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 )
--- 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 )
--- 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 )
--- 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
--- 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'