mlist 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. data/CHANGELOG +59 -0
  2. data/README +204 -0
  3. data/Rakefile +27 -0
  4. data/TODO +36 -0
  5. data/VERSION.yml +4 -0
  6. data/lib/mlist/email.rb +69 -0
  7. data/lib/mlist/email_post.rb +126 -0
  8. data/lib/mlist/email_server/base.rb +33 -0
  9. data/lib/mlist/email_server/default.rb +31 -0
  10. data/lib/mlist/email_server/fake.rb +16 -0
  11. data/lib/mlist/email_server/pop.rb +28 -0
  12. data/lib/mlist/email_server/smtp.rb +24 -0
  13. data/lib/mlist/email_server.rb +2 -0
  14. data/lib/mlist/email_subscriber.rb +6 -0
  15. data/lib/mlist/list.rb +183 -0
  16. data/lib/mlist/mail_list.rb +277 -0
  17. data/lib/mlist/manager/database.rb +48 -0
  18. data/lib/mlist/manager/notifier.rb +31 -0
  19. data/lib/mlist/manager.rb +30 -0
  20. data/lib/mlist/message.rb +150 -0
  21. data/lib/mlist/server.rb +62 -0
  22. data/lib/mlist/thread.rb +98 -0
  23. data/lib/mlist/util/email_helpers.rb +155 -0
  24. data/lib/mlist/util/header_sanitizer.rb +71 -0
  25. data/lib/mlist/util/quoting.rb +70 -0
  26. data/lib/mlist/util/tmail_builder.rb +42 -0
  27. data/lib/mlist/util/tmail_methods.rb +138 -0
  28. data/lib/mlist/util.rb +12 -0
  29. data/lib/mlist.rb +46 -0
  30. data/lib/pop_ssl.rb +999 -0
  31. data/rails/init.rb +22 -0
  32. data/spec/fixtures/schema.rb +94 -0
  33. data/spec/integration/date_formats_spec.rb +12 -0
  34. data/spec/integration/mlist_spec.rb +232 -0
  35. data/spec/integration/pop_email_server_spec.rb +22 -0
  36. data/spec/integration/proof_spec.rb +74 -0
  37. data/spec/matchers/equal_tmail.rb +53 -0
  38. data/spec/matchers/have_address.rb +48 -0
  39. data/spec/matchers/have_header.rb +104 -0
  40. data/spec/models/email_post_spec.rb +100 -0
  41. data/spec/models/email_server/base_spec.rb +11 -0
  42. data/spec/models/email_spec.rb +54 -0
  43. data/spec/models/mail_list_spec.rb +469 -0
  44. data/spec/models/message_spec.rb +109 -0
  45. data/spec/models/thread_spec.rb +83 -0
  46. data/spec/models/util/email_helpers_spec.rb +47 -0
  47. data/spec/models/util/header_sanitizer_spec.rb +19 -0
  48. data/spec/models/util/quoting_spec.rb +96 -0
  49. data/spec/spec_helper.rb +76 -0
  50. metadata +103 -0
data/rails/init.rb ADDED
@@ -0,0 +1,22 @@
1
+ # Provides the mechinism to support applications that want to observe MList
2
+ # models.
3
+ #
4
+ # ActiveRecord observers are reloaded at each request in development mode.
5
+ # They will be registered with the MList models each time. Since the MList
6
+ # models are required once at initialization, there will always only be one
7
+ # instance of the model class, and therefore, many instances of the observer
8
+ # class registered with it; all but the most recent are invalid, since they
9
+ # were undefined when the dispatcher reloaded the application.
10
+ #
11
+ # Should we ever have observers in MList, this will likely need more careful
12
+ # attention.
13
+ #
14
+ unless Rails.configuration.cache_classes
15
+ class << ActiveRecord::Base
16
+ def instantiate_observers_with_mlist_observers
17
+ subclasses.each(&:delete_observers)
18
+ instantiate_observers_without_mlist_observers
19
+ end
20
+ alias_method_chain :instantiate_observers, :mlist_observers
21
+ end
22
+ end
@@ -0,0 +1,94 @@
1
+ ActiveRecord::Schema.define(:version => 20081126181722) do
2
+
3
+ # All MList required tables are prefixed with 'mlist_' to ease integration
4
+ # into other systems' databases.
5
+
6
+
7
+ # The table in which email content is stored.
8
+ #
9
+ create_table :mlist_emails, :force => true do |t|
10
+ t.column :source, :text
11
+ t.column :created_at, :datetime
12
+ end
13
+
14
+ # The table in which MList will store MList::Messages.
15
+ #
16
+ # The identifier is the 'message-id' header value of the finally delivered
17
+ # email.
18
+ #
19
+ # An MList::Message will store a reference to your application's subscriber
20
+ # instance if it is an ActiveRecord subclass. That subclass must respond to
21
+ # :email_address. If your subscriber is just a string, it is assumed to be
22
+ # an email address. Either way, that email address will be stored with the
23
+ # MList::Message, providing a way for you to associate messages by
24
+ # subscriber_address. This is less ideal, as you may allow subscribers to
25
+ # change their email addresses over time.
26
+ #
27
+ create_table :mlist_messages, :force => true do |t|
28
+ t.column :mail_list_id, :integer
29
+ t.column :thread_id, :integer
30
+ t.column :email_id, :integer
31
+ t.column :identifier, :string
32
+ t.column :parent_identifier, :string
33
+ t.column :parent_id, :integer
34
+ t.column :mailer, :string
35
+ t.column :subject, :string
36
+ t.column :subscriber_address, :string
37
+ t.column :subscriber_type, :string
38
+ t.column :subscriber_id, :integer
39
+ t.column :created_at, :datetime
40
+ end
41
+ add_index :mlist_messages, :mail_list_id
42
+ add_index :mlist_messages, :thread_id
43
+ add_index :mlist_messages, :identifier
44
+ add_index :mlist_messages, :parent_identifier
45
+ add_index :mlist_messages, :parent_id
46
+ add_index :mlist_messages, :subject
47
+ add_index :mlist_messages, :subscriber_address
48
+ add_index :mlist_messages, [:subscriber_type, :subscriber_id]
49
+
50
+ # Every MList::Message is associated with an MList::Thread.
51
+ #
52
+ create_table :mlist_threads, :force => true do |t|
53
+ t.column :mail_list_id, :integer
54
+ t.column :messages_count, :integer
55
+ t.timestamps
56
+ end
57
+ add_index :mlist_threads, :mail_list_id
58
+
59
+ # The table in which MList will store MList::MailLists.
60
+ #
61
+ # The manager_list_identifier column stores the MList::List#list_id value.
62
+ # This is a connection to the application's implementation of MList::List.
63
+ # These identifiers must be unique and never change for an MList::List.
64
+ #
65
+ # An MList::MailList will store a reference to your application's
66
+ # MList::List instance if it is an ActiveRecord subclass.
67
+ #
68
+ create_table :mlist_mail_lists, :force => true do |t|
69
+ t.column :manager_list_identifier, :string
70
+ t.column :manager_list_type, :string
71
+ t.column :manager_list_id, :integer
72
+ t.column :messages_count, :integer
73
+ t.column :threads_count, :integer
74
+ t.timestamps
75
+ end
76
+ add_index :mlist_mail_lists, :manager_list_identifier
77
+ add_index :mlist_mail_lists, [:manager_list_identifier, :manager_list_type, :manager_list_id],
78
+ :name => :index_mlist_mail_lists_on_manager_association
79
+
80
+
81
+ # Database list manager tables, used for testing purposes.
82
+ #
83
+ create_table :lists, :force => true do |t|
84
+ t.column :address, :string
85
+ t.column :label, :string
86
+ t.column :created_at, :datetime
87
+ end
88
+
89
+ create_table :subscribers, :force => true do |t|
90
+ t.column :list_id, :integer
91
+ t.column :email_address, :string
92
+ t.column :created_at, :datetime
93
+ end
94
+ end
@@ -0,0 +1,12 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe MList, 'date formats' do
4
+ specify 'mlist_reply_timestamp should handle single digit days and months' do
5
+ Time.local(2009, 2, 3, 7).to_s(:mlist_reply_timestamp).should == 'Tue, Feb 3, 2009 at 7:00 AM'
6
+ end
7
+
8
+ specify 'mlist_reply_timestamp should handle double digit days and months' do
9
+ Time.local(2009, 2, 13, 11).to_s(:mlist_reply_timestamp).should == 'Fri, Feb 13, 2009 at 11:00 AM'
10
+ Time.local(2009, 2, 13, 14).to_s(:mlist_reply_timestamp).should == 'Fri, Feb 13, 2009 at 2:00 PM'
11
+ end
12
+ end
@@ -0,0 +1,232 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ require 'mlist/manager/database'
4
+
5
+ describe MList do
6
+ def forward_email(tmail)
7
+ simple_matcher('forward email') do |email_server|
8
+ lambda do
9
+ lambda do
10
+ email_server.receive(tmail)
11
+ end.should_not change(email_server.deliveries, :size)
12
+ end.should_not store_message
13
+ end
14
+ end
15
+
16
+ def store_message
17
+ simple_matcher('store message') do |block|
18
+ thread_count, message_count = MList::Thread.count, MList::Message.count
19
+ block.call
20
+ MList::Thread.count == thread_count + 1 && MList::Message.count == message_count + 1
21
+ end
22
+ end
23
+
24
+ def start_new_thread(tmail)
25
+ simple_matcher('start new thread') do |email_server|
26
+ delivery_count = email_server.deliveries.size
27
+ thread_count = MList::Thread.count
28
+ message_count = MList::Message.count
29
+ email_server.receive(tmail)
30
+ email_server.deliveries.size > delivery_count &&
31
+ MList::Thread.count == (thread_count + 1) &&
32
+ MList::Message.count == (message_count + 1)
33
+ end
34
+ end
35
+
36
+ dataset do
37
+ @list_manager = MList::Manager::Database.new
38
+ @list_one = @list_manager.create_list('list_one@example.com')
39
+ @list_one.subscribe('adam@nomail.net')
40
+ @list_one.subscribe('tom@example.com')
41
+ @list_one.subscribe('dick@example.com')
42
+
43
+ @list_two = @list_manager.create_list('list_two@example.com')
44
+ @list_two.subscribe('adam@nomail.net')
45
+ @list_two.subscribe('jane@example.com')
46
+
47
+ @list_three = @list_manager.create_list('empty@example.com')
48
+ @list_three.subscribe('adam@nomail.net')
49
+ end
50
+
51
+ before do
52
+ @email_server = MList::EmailServer::Fake.new
53
+ @server = MList::Server.new(
54
+ :list_manager => @list_manager,
55
+ :email_server => @email_server
56
+ )
57
+
58
+ # TODO Move this stuff to Dataset
59
+ ActiveRecord::Base.connection.increment_open_transactions
60
+ ActiveRecord::Base.connection.begin_db_transaction
61
+ end
62
+
63
+ after do
64
+ if ActiveRecord::Base.connection.open_transactions != 0
65
+ ActiveRecord::Base.connection.rollback_db_transaction
66
+ ActiveRecord::Base.connection.decrement_open_transactions
67
+ end
68
+ ActiveRecord::Base.clear_active_connections!
69
+ end
70
+
71
+ it 'should have threads and mail_lists updated_at set to last message receive time' do
72
+ now = Time.now
73
+ stub(Time).now {now}
74
+ @email_server.receive(tmail_fixture('single_list'))
75
+ MList::MailList.last.updated_at.to_s.should == now.to_s
76
+ MList::Thread.last.updated_at.to_s.should == now.to_s
77
+
78
+ later = 5.days.from_now
79
+ stub(Time).now {later}
80
+ @email_server.receive(tmail_fixture('single_list_reply'))
81
+ MList::MailList.last.updated_at.to_s.should == later.to_s
82
+ MList::Thread.last.updated_at.to_s.should == later.to_s
83
+ end
84
+
85
+ it 'should associate manager lists to mlist mail lists when they are ActiveRecord instances' do
86
+ @email_server.receive(tmail_fixture('single_list'))
87
+ mail_list = MList::MailList.last
88
+ mail_list.manager_list.should == @list_one
89
+ mail_list.manager_list_identifier.should == @list_one.list_id
90
+ end
91
+
92
+ it 'should not forward mail that has been on this server before' do
93
+ @email_server.should_not forward_email(tmail_fixture('x-beenthere'))
94
+ end
95
+
96
+ it 'should store message/thread even when there are no recipient subscribers' do
97
+ tmail = tmail_fixture('single_list')
98
+ tmail.to = @list_three.address
99
+ @email_server.should start_new_thread(tmail)
100
+ end
101
+
102
+ it 'should not forward mail from non-subscriber and notify manager list' do
103
+ tmail = tmail_fixture('single_list')
104
+ tmail.from = 'unknown@example.com'
105
+ stub(@list_manager).lists(is_a(MList::Email)) { [@list_one] }
106
+ mock(@list_one).non_subscriber_post(is_a(MList::Email))
107
+ @email_server.should_not forward_email(tmail)
108
+ end
109
+
110
+ it 'should not forward mail from non-subscriber when inactive and notify as non-subscriber' do
111
+ tmail = tmail_fixture('single_list')
112
+ tmail.from = 'unknown@example.com'
113
+ do_not_call(@list_one).active?
114
+ stub(@list_manager).lists(is_a(MList::Email)) { [@list_one] }
115
+ mock(@list_one).non_subscriber_post(is_a(MList::Email))
116
+ @email_server.should_not forward_email(tmail)
117
+ end
118
+
119
+ it 'should not forward mail from blocked subscriber, notify the subscriber using list manager notifier' do
120
+ subscriber = MList::Manager::Database::Subscriber.find_by_email_address('adam@nomail.net')
121
+ tmail = tmail_fixture('single_list')
122
+ mock(@list_one).active? {true}
123
+ mock(@list_one).blocked?(subscriber) { true }
124
+ stub(@list_manager).lists(is_a(MList::Email)) { [@list_one] }
125
+ lambda do
126
+ @email_server.receive(tmail)
127
+ response_tmail = @email_server.deliveries.last
128
+ response_tmail.header_string('from').should == 'mlist-list_one@example.com'
129
+ response_tmail.header_string('to').should == 'adam@nomail.net'
130
+ response_tmail.header_string('x-mlist-loop').should == 'notice'
131
+ response_tmail.header_string('x-mlist-notice').should == 'subscriber_blocked'
132
+ end.should_not store_message
133
+ end
134
+
135
+ it 'should not forward mail to inactive list and notify manager list' do
136
+ tmail = tmail_fixture('single_list')
137
+ mock(@list_one).active? { false }
138
+ stub(@list_manager).lists(is_a(MList::Email)) { [@list_one] }
139
+ mock(@list_one).inactive_post(is_a(MList::Email))
140
+ @email_server.should_not forward_email(tmail)
141
+ end
142
+
143
+ it 'should report unrecognized email to list manager' do
144
+ tmail = tmail_fixture('single_list')
145
+ stub(@list_manager).lists(is_a(MList::Email)) { [] }
146
+ mock(@list_manager).no_lists_found(is_a(MList::Email))
147
+ @email_server.should_not forward_email(tmail)
148
+ end
149
+
150
+ it 'should report bounces to the list manager' do
151
+ stub(@list_manager).lists(is_a(MList::Email)) { [@list_one] }
152
+ mock(@list_one).bounce(is_a(MList::Email))
153
+ @email_server.should_not forward_email(tmail_fixture('bounces/1'))
154
+ end
155
+
156
+ describe 'single list' do
157
+ before do
158
+ @tmail_post = tmail_fixture('single_list')
159
+ @email_server.receive(@tmail_post)
160
+ end
161
+
162
+ it 'should forward emails that are sent to a mailing list' do
163
+ @email_server.deliveries.size.should == 1
164
+ email = @email_server.deliveries.first
165
+ email.should have_address(:to, 'list_one@example.com')
166
+ # bcc fields are not in the headers of delivered emails
167
+ email.should have_address(:'reply-to', 'list_one@example.com')
168
+ end
169
+
170
+ it 'should start a new thread for a new email' do
171
+ thread = MList::Thread.last
172
+ thread.messages.first.email.tmail.should equal_tmail(@tmail_post)
173
+ end
174
+
175
+ it 'should add to an existing thread when reply email' do
176
+ message = MList::Message.last
177
+ reply_tmail = tmail_fixture('single_list_reply', 'in-reply-to' => "<#{message.identifier}>")
178
+ @email_server.receive(reply_tmail)
179
+ thread = MList::Thread.last
180
+ thread.messages.size.should be(2)
181
+ end
182
+
183
+ it 'should associate parent message when reply email' do
184
+ message = MList::Message.last
185
+ @email_server.receive(tmail_fixture('single_list_reply', 'in-reply-to' => "<#{message.identifier}>"))
186
+ reply = MList::Message.last
187
+ reply.parent_identifier.should == message.identifier
188
+ reply.parent.should == message
189
+ end
190
+
191
+ it 'should not associate a parent when not a reply' do
192
+ tmail = tmail_fixture('single_list')
193
+ tmail['message-id'] = 'asdfasdfj'
194
+ tmail['subject'] = 'other thing'
195
+ @email_server.should start_new_thread(tmail)
196
+ end
197
+
198
+ it 'should store subscriber address with messages' do
199
+ MList::Message.last.subscriber_address.should == 'adam@nomail.net'
200
+ end
201
+
202
+ it 'should associate subscriber to messages when they are ActiveRecord instances' do
203
+ MList::Message.last.subscriber.should == MList::Manager::Database::Subscriber.find_by_email_address('adam@nomail.net')
204
+ end
205
+ end
206
+
207
+ describe 'multiple lists' do
208
+ before do
209
+ @tmail_post = tmail_fixture('multiple_lists')
210
+ @email_server.receive(@tmail_post)
211
+ end
212
+
213
+ it 'should forward emails that are sent to a mailing list' do
214
+ @email_server.deliveries.size.should == 2
215
+
216
+ email = @email_server.deliveries.first
217
+ email.should have_address(:to, 'list_one@example.com')
218
+ # bcc fields are not in the headers of delivered emails
219
+ email.should have_address(:'reply-to', 'list_one@example.com')
220
+
221
+ email = @email_server.deliveries.last
222
+ email.should have_address(:to, 'list_two@example.com')
223
+ # bcc fields are not in the headers of delivered emails
224
+ email.should have_address(:'reply-to', 'list_two@example.com')
225
+ end
226
+
227
+ it 'should start a new thread for each list, both referencing the same email' do
228
+ threads = MList::Thread.find(:all)
229
+ threads[0].messages.first.email.should == threads[1].messages.first.email
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,22 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+ require 'mlist/email_server/pop'
3
+
4
+ describe MList::EmailServer::Pop, 'execute' do
5
+ before do
6
+ @mails = []
7
+ @pop = Object.new
8
+ stub(@pop).mails { @mails }
9
+ stub(@pop).start do |username, password, block|
10
+ block.call @pop
11
+ end
12
+ stub(Net::POP3).new { @pop }
13
+ @pop_server = MList::EmailServer::Pop.new({})
14
+ end
15
+
16
+ it 'should delete email after successfully receiving' do
17
+ message = OpenStruct.new(:pop => email_fixture('single_list'))
18
+ mock(message).delete
19
+ @mails << message
20
+ @pop_server.execute
21
+ end
22
+ end
@@ -0,0 +1,74 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ require 'mlist/manager/database'
4
+
5
+ describe MList do
6
+ include MList::Util::EmailHelpers
7
+
8
+ dataset do
9
+ subscriber_addresses = %w(adam@nomail.net adam@example.net)
10
+
11
+ @list_manager = MList::Manager::Database.new
12
+ Dir[email_fixtures_path('integration/list*')].each do |list_path|
13
+ list = @list_manager.create_list("#{File.basename(list_path)}@example.com")
14
+ subscriber_addresses.each {|a| list.subscribe(a)}
15
+ end
16
+ end
17
+
18
+ before do
19
+ @email_server = MList::EmailServer::Fake.new
20
+ @server = MList::Server.new(
21
+ :list_manager => @list_manager,
22
+ :email_server => @email_server
23
+ )
24
+
25
+ # TODO Move this stuff to Dataset
26
+ ActiveRecord::Base.connection.increment_open_transactions
27
+ ActiveRecord::Base.connection.begin_db_transaction
28
+ end
29
+
30
+ after do
31
+ if ActiveRecord::Base.connection.open_transactions != 0
32
+ ActiveRecord::Base.connection.rollback_db_transaction
33
+ ActiveRecord::Base.connection.decrement_open_transactions
34
+ end
35
+ ActiveRecord::Base.clear_active_connections!
36
+ end
37
+
38
+ it 'should place messages in threads and threads in lists' do
39
+ Dir[email_fixtures_path('integration/list*')].each do |list_path|
40
+ list = MList::Manager::Database::List.find_by_address(File.basename(list_path) + '@example.com')
41
+ Dir[File.join(list_path, 'thread*')].each do |thread_path|
42
+ email_paths = Dir[File.join(thread_path, '*.eml')]
43
+ top_post_path = email_paths.shift
44
+ top_post_tmail = TMail::Mail.load(top_post_path)
45
+ stub(@email_server).generate_message_id { remove_brackets(top_post_tmail.message_id) }
46
+ @email_server.should start_thread(top_post_tmail)
47
+ email_paths.each do |email_path|
48
+ tmail = TMail::Mail.load(email_path)
49
+ stub(@email_server).generate_message_id { remove_brackets(tmail.message_id) }
50
+ expected = email_path =~ %r{\d+\.eml\Z} ? :should : :should_not
51
+ @email_server.send expected, accept_message(tmail)
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ def accept_message(tmail)
58
+ simple_matcher("to receive message from #{tmail.header_string('from')}") do |email_server|
59
+ message_count_start = MList::Message.count
60
+ email_server.receive(tmail)
61
+ MList::Message.count == message_count_start + 1 && MList::Message.last.identifier == remove_brackets(@email_server.deliveries.last.header_string('message-id'))
62
+ end
63
+ end
64
+
65
+ def start_thread(tmail)
66
+ simple_matcher("to begin thread from #{tmail.header_string('from')}") do |email_server|
67
+ message_count_start = MList::Message.count
68
+ thread_count_start = MList::Thread.count
69
+ email_server.receive(tmail)
70
+ MList::Message.count == message_count_start + 1
71
+ MList::Thread.count == thread_count_start + 1
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,53 @@
1
+ module Spec
2
+ module Matchers
3
+
4
+ class EqualTmail
5
+ def initialize(expected)
6
+ @expected = expected
7
+ end
8
+
9
+ def matches?(tmail)
10
+ @given = tmail
11
+ headers_match?
12
+ end
13
+
14
+ def failure_message
15
+ @failure_message
16
+ end
17
+
18
+ private
19
+ def comparable_value(value)
20
+ case value
21
+ when Array
22
+ value.collect {|e| e.to_s.strip}.join(" ").strip
23
+ when String
24
+ value.strip
25
+ end
26
+ end
27
+
28
+ def headers_match?
29
+ missing = @expected.header.collect { |name, value| @given[name].nil? ? name : nil }.compact
30
+ extra = @given.header.collect { |name, value| @expected[name].nil? ? name : nil }.compact
31
+ if extra.empty? && missing.empty?
32
+ unequal = []
33
+ @expected.header.each do |name, value|
34
+ expected_value, given_value = comparable_value(value), comparable_value(@given[name])
35
+ unless expected_value == given_value
36
+ unequal << "expected header #{name.inspect} to be #{expected_value.inspect} but was #{given_value.inspect}"
37
+ end
38
+ end
39
+ @failure_message = unequal.join("\n") unless unequal.empty?
40
+ else
41
+ @failure_message = "expected tmail instances to be equal but headers were not"
42
+ @failure_message << "\nmissing in given: #{missing.inspect}" unless missing.empty?
43
+ @failure_message << "\nextra in given: #{extra.inspect}" unless extra.empty?
44
+ end
45
+ @failure_message.nil?
46
+ end
47
+ end
48
+
49
+ def equal_tmail(tmail)
50
+ EqualTmail.new(tmail)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,48 @@
1
+ module Spec
2
+ module Matchers
3
+
4
+ class HaveAddress
5
+ def initialize(field, expected)
6
+ @field, @expected = field, expected
7
+ end
8
+
9
+ def matches?(email)
10
+ @actual = addresses(email)
11
+ missing = expected_addresses.reject {|e| @actual.include?(e)}
12
+ extra = @actual.reject {|e| expected_addresses.include?(e)}
13
+ extra.empty? && missing.empty?
14
+ end
15
+
16
+ def failure_message
17
+ "expected #{@field} address to contain #{expected_addresses.inspect} but was #{@actual.inspect}"
18
+ end
19
+
20
+ def negative_failure_message
21
+ "expected #{@field} address not to contain #{expected_addresses.inspect} but it did"
22
+ end
23
+
24
+ private
25
+ def addresses(email)
26
+ email[@field.to_s].addrs.collect(&:address) rescue []
27
+ end
28
+
29
+ def expected_addresses
30
+ case @expected
31
+ when Array
32
+ @expected.collect { |a| extract_address(a) }
33
+ when String
34
+ [extract_address(@expected)]
35
+ end
36
+ end
37
+
38
+ def extract_address(string)
39
+ address = string.sub(/.*?<(.*?)>/, '\1')
40
+ address if address =~ /\A([^@\s]+)@(localhost|(?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i
41
+ end
42
+ end
43
+
44
+ def have_address(field, expected)
45
+ HaveAddress.new(field, expected)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,104 @@
1
+ module Spec
2
+ module Matchers
3
+
4
+ class HaveHeader
5
+ def initialize(name, expected)
6
+ @name, @expected = name, expected
7
+ end
8
+
9
+ def matches?(email)
10
+ @given = email
11
+
12
+ if @expected.blank?
13
+ if !@given[@name.downcase]
14
+ @failure_message = "expected header #{@name.inspect} to be present but it was not"
15
+ end
16
+ else
17
+ missing = expected_values.reject {|e| header_values.include?(e)}
18
+ extra = header_values.reject {|e| expected_values.include?(e)}
19
+ unless extra.empty? && missing.empty?
20
+ @failure_message = "expected header #{@name.inspect} to be equal but was not"
21
+ @failure_message << "\nmissing in given: #{missing.inspect}" unless missing.empty?
22
+ @failure_message << "\nextra in given: #{extra.inspect}" unless extra.empty?
23
+ end
24
+ end
25
+
26
+ @failure_message.nil?
27
+ end
28
+
29
+ def failure_message
30
+ @failure_message
31
+ end
32
+
33
+ def negative_failure_message
34
+ if @expected.blank? && @given[@name.downcase]
35
+ "expected header #{@name.inspect} to not be present but was"
36
+ else
37
+ "expected header #{@name.inspect} to not be equal but it was"
38
+ end
39
+ end
40
+
41
+ private
42
+ def comparable_value(value)
43
+ case value
44
+ when Array
45
+ value.collect {|e| e.to_s.strip}.join(" ").strip
46
+ when String
47
+ value.strip
48
+ end
49
+ end
50
+
51
+ def expected_values
52
+ case @expected
53
+ when Array
54
+ @expected.collect { |e| comparable_value(e) }
55
+ when String
56
+ [comparable_value(@expected)]
57
+ end
58
+ end
59
+
60
+ def header_values
61
+ values = []
62
+ @given.each_header do |k,v|
63
+ values << v.to_s.strip if k.downcase == @name.downcase
64
+ end
65
+ values
66
+ end
67
+ end
68
+
69
+ class HaveHeaders
70
+ def initialize(expected)
71
+ @matchers = case expected
72
+ when Array
73
+ expected.collect {|e| HaveHeader.new(e, nil)}
74
+ when Hash
75
+ expected.collect {|k,v| HaveHeader.new(k,v)}
76
+ end
77
+ end
78
+
79
+ def matches?(given)
80
+ @matched, @failed = [], []
81
+ @matchers.each do |matcher|
82
+ (matcher.matches?(given) ? @matched : @failed) << matcher
83
+ end
84
+ @failed.empty?
85
+ end
86
+
87
+ def failure_message
88
+ @failed.collect(&:failure_message).join("\n")
89
+ end
90
+
91
+ def negative_failure_message
92
+ raise 'have_headers cannot be used in a negative'
93
+ end
94
+ end
95
+
96
+ def have_header(name, expected = nil)
97
+ HaveHeader.new(name, expected)
98
+ end
99
+
100
+ def have_headers(expected)
101
+ HaveHeaders.new(expected)
102
+ end
103
+ end
104
+ end