mlist 0.1.9

Sign up to get free protection for your applications and to get access to all the features.
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