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/lib/mlist/list.rb ADDED
@@ -0,0 +1,183 @@
1
+ module MList
2
+
3
+ # Represents the interface of the lists that a list manager must answer.
4
+ # This is distinct from the MList::MailList to allow for greater flexibility
5
+ # in processing email coming to a list - that is, whatever you include this
6
+ # into may re-define behavior appropriately.
7
+ #
8
+ # Your 'subscriber' instances MUST respond to :email_address. They may
9
+ # optionally respond to :display_name.
10
+ #
11
+ module List
12
+
13
+ # Answers whether this list is active or not. All lists are active all the
14
+ # time by default.
15
+ #
16
+ def active?
17
+ true
18
+ end
19
+
20
+ # Answers whether the subscriber is blocked from posting or not. This will
21
+ # not be asked when the list is not active (answers _active?_ as false).
22
+ #
23
+ def blocked?(subscriber)
24
+ false
25
+ end
26
+
27
+ # Answers the footer content for this list. Default implementation is very
28
+ # simple.
29
+ #
30
+ def footer_content(message)
31
+ %Q{The "#{label}" mailing list\nPost messages: #{post_url}}
32
+ end
33
+
34
+ # Answer a suitable label for the list, which will be used in various
35
+ # parts of content that is delivered to subscribers, etc.
36
+ #
37
+ def label
38
+ raise 'answer the list label'
39
+ end
40
+
41
+ # Answers the headers that are to be included in the emails delivered for
42
+ # this list. Any entries that have a nil value will not be included in the
43
+ # delivered email.
44
+ #
45
+ def list_headers
46
+ {
47
+ 'list-id' => list_id,
48
+ 'list-archive' => archive_url,
49
+ 'list-subscribe' => subscribe_url,
50
+ 'list-unsubscribe' => unsubscribe_url,
51
+ 'list-owner' => owner_url,
52
+ 'list-help' => help_url,
53
+ 'list-post' => post_url
54
+ }
55
+ end
56
+
57
+ # Answers a unique, never changing value for this list.
58
+ #
59
+ def list_id
60
+ raise 'answer a unique, never changing value'
61
+ end
62
+
63
+ # The web address where an archive of this list may be found, nil if there
64
+ # is no archive.
65
+ #
66
+ def archive_url
67
+ nil
68
+ end
69
+
70
+ # Should the sender of an email be copied in the publication? Defaults to
71
+ # false. If _recipients_ includes the sender email address, it will be
72
+ # removed if this answers false.
73
+ #
74
+ # The sending subscriber is provided to support preferential answering.
75
+ #
76
+ def copy_sender?(subscriber)
77
+ false
78
+ end
79
+
80
+ # The web address of the list help site, nil if this is not supported.
81
+ #
82
+ def help_url
83
+ nil
84
+ end
85
+
86
+ # The email address of the list owner, nil if this is not supported.
87
+ #
88
+ def owner_url
89
+ nil
90
+ end
91
+
92
+ # The email address where posts should be sent. Defaults to the address of
93
+ # the list.
94
+ #
95
+ def post_url
96
+ address
97
+ end
98
+
99
+ # Should the reply-to header be set to the list's address? Defaults to
100
+ # true. If false is returned, the reply-to will be the subscriber address.
101
+ #
102
+ def reply_to_list?
103
+ true
104
+ end
105
+
106
+ # The web url where subscriptions to this list may be created, nil if this
107
+ # is not supported.
108
+ #
109
+ def subscribe_url
110
+ nil
111
+ end
112
+
113
+ # The web url where subscriptions to this list may be deleted, nil if this
114
+ # is not supported.
115
+ #
116
+ def unsubscribe_url
117
+ nil
118
+ end
119
+
120
+ # A list is responsible for answering the recipient subscribers. The
121
+ # answer may or may not include the subscriber; _copy_sender?_ will be
122
+ # invoked and the subscriber will be added or removed from the Array.
123
+ #
124
+ # The sending subscriber is provided if the list would like to utilize it
125
+ # in calculating the recipients.
126
+ #
127
+ def recipients(subscriber)
128
+ subscribers
129
+ end
130
+
131
+ # A list must answer the subscriber who's email address is that of the one
132
+ # provided. The default implementation will pick the first instance that
133
+ # answers subscriber.email_address == email_address. Your implementation
134
+ # should probably select just one record.
135
+ #
136
+ def subscriber(email_address)
137
+ subscribers.detect {|s| s.email_address == email_address}
138
+ end
139
+
140
+ # A list must answer whether there is a subscriber who's email address is
141
+ # that of the one provided. This is checked before the subscriber is
142
+ # requested in order to allow for the lightest weight check possible; that
143
+ # is, your implementation could avoid loading the actual subscriber
144
+ # instance.
145
+ #
146
+ def subscriber?(email_address)
147
+ !subscriber(email_address).nil?
148
+ end
149
+
150
+ # Methods that will be invoked on your implementation of Mlist::List when
151
+ # certain events occur during the processing of email sent to a list.
152
+ #
153
+ module Callbacks
154
+
155
+ # Called when an email is a post to the list by a subscriber whom the
156
+ # list claims is blocked (answers true to _blocked?(subscriber)_). This
157
+ # will not be called if the list is inactive (answers false to
158
+ # _active?_);
159
+ #
160
+ def blocked_subscriber_post(email, subscriber)
161
+ end
162
+
163
+ def bounce(email)
164
+ end
165
+
166
+ # Called when an email is a post to the list while the list is inactive
167
+ # (answers false to _active?_). This will not be called if the email is
168
+ # from a non-subscribed sender. Instead, _non_subscriber_post_ will be
169
+ # called.
170
+ #
171
+ def inactive_post(email)
172
+ end
173
+
174
+ # Called when an email is a post to the list from a non-subscribed
175
+ # sender. This will be called even if the list is inactive.
176
+ #
177
+ def non_subscriber_post(email)
178
+ end
179
+ end
180
+ include Callbacks
181
+
182
+ end
183
+ end
@@ -0,0 +1,277 @@
1
+ module MList
2
+ class MailList < ActiveRecord::Base
3
+ set_table_name 'mlist_mail_lists'
4
+
5
+ # Provides the MailList for a given implementation of MList::List,
6
+ # connecting it to the provided email server for delivering posts.
7
+ #
8
+ def self.find_or_create_by_list(list, outgoing_server)
9
+ if list.is_a?(ActiveRecord::Base)
10
+ mail_list = find_or_create_by_manager_list_identifier_and_manager_list_type_and_manager_list_id(
11
+ list.list_id, list.class.base_class.name, list.id
12
+ )
13
+ else
14
+ mail_list = find_or_create_by_manager_list_identifier(list.list_id)
15
+ mail_list.manager_list = list
16
+ end
17
+ mail_list.outgoing_server = outgoing_server
18
+ mail_list
19
+ end
20
+
21
+ include MList::Util::EmailHelpers
22
+
23
+ belongs_to :manager_list, :polymorphic => true
24
+
25
+ before_destroy :delete_unreferenced_email
26
+ has_many :messages, :class_name => 'MList::Message', :dependent => :delete_all
27
+ has_many :threads, :class_name => 'MList::Thread', :dependent => :delete_all
28
+
29
+ delegate :address, :label, :post_url, :to => :list
30
+
31
+ attr_accessor :outgoing_server
32
+
33
+ # Creates a new MList::Message and delivers it to the subscribers of this
34
+ # list.
35
+ #
36
+ def post(email_or_attributes)
37
+ email = email_or_attributes
38
+ email = MList::EmailPost.new(email_or_attributes) unless email.is_a?(MList::EmailPost)
39
+ process_message messages.build(
40
+ :parent => email.reply_to_message,
41
+ :parent_identifier => email.parent_identifier,
42
+ :mail_list => self,
43
+ :subscriber => email.subscriber,
44
+ :recipients => list.recipients(email.subscriber),
45
+ :email => MList::Email.new(:source => email.to_s)
46
+ ), :search_parent => false, :copy_sender => email.copy_sender
47
+ end
48
+
49
+ # Processes the email received by the MList::Server.
50
+ #
51
+ def process_email(email, subscriber)
52
+ recipients = list.recipients(subscriber)
53
+ process_message messages.build(
54
+ :mail_list => self,
55
+ :subscriber => subscriber,
56
+ :recipients => recipients,
57
+ :email => email
58
+ ), :copy_sender => list.copy_sender?(subscriber)
59
+ end
60
+
61
+ # Answers the provided subject with superfluous 're:' and this list's
62
+ # labels removed.
63
+ #
64
+ # clean_subject('[List Label] Re: The new Chrome Browser from Google') => 'Re: The new Chrome Browser from Google'
65
+ # clean_subject('Re: [List Label] Re: The new Chrome Browser from Google') => 'Re: The new Chrome Browser from Google'
66
+ #
67
+ def clean_subject(string)
68
+ without_label = string.gsub(subject_prefix_regex, '')
69
+ if without_label =~ REGARD_RE
70
+ "Re: #{remove_regard(without_label)}"
71
+ else
72
+ without_label
73
+ end
74
+ end
75
+
76
+ def find_parent_message(email)
77
+ if in_reply_to = email.header_string('in-reply-to')
78
+ message = messages.find(:first,
79
+ :conditions => ['identifier = ?', remove_brackets(in_reply_to)])
80
+ return message if message
81
+ end
82
+
83
+ if email.references
84
+ reference_identifiers = email.references.collect {|rid| remove_brackets(rid)}
85
+ message = messages.find(:first,
86
+ :conditions => ['identifier in (?)', reference_identifiers],
87
+ :order => 'created_at desc')
88
+ return message if message
89
+ end
90
+
91
+ if email.subject =~ REGARD_RE
92
+ message = messages.find(:first,
93
+ :conditions => ['subject = ?', remove_regard(clean_subject(email.subject))],
94
+ :order => 'created_at asc')
95
+ return message if message
96
+ end
97
+ end
98
+
99
+ # The MList::List instance of the list manager.
100
+ #
101
+ def list
102
+ @list ||= manager_list
103
+ end
104
+
105
+ def manager_list_with_dual_type=(list)
106
+ if list.is_a?(ActiveRecord::Base)
107
+ self.manager_list_without_dual_type = list
108
+ @list = list
109
+ else
110
+ self.manager_list_without_dual_type = nil
111
+ @list = list
112
+ end
113
+ end
114
+ alias_method_chain :manager_list=, :dual_type
115
+
116
+ # Distinct footer start marker. It is important to realize that changing
117
+ # this could be problematic.
118
+ #
119
+ FOOTER_BLOCK_START = "-~----~~----~----~----~----~---~~-~----~------~--~-~-"
120
+
121
+ # Distinct footer end marker. It is important to realize that changing
122
+ # this could be problematic.
123
+ #
124
+ FOOTER_BLOCK_END = "--~--~---~-----~--~----~-----~~~----~---~---~--~----~"
125
+
126
+ private
127
+ FOOTER_BLOCK_START_RE = %r[#{FOOTER_BLOCK_START}]
128
+ FOOTER_BLOCK_END_RE = %r[#{FOOTER_BLOCK_END}]
129
+
130
+ # http://mail.python.org/pipermail/mailman-developers/2006-April/018718.html
131
+ def bounce_headers
132
+ # tmail would not correctly quote the label in the sender header, which would break smtp delivery
133
+ {'sender' => "<mlist-#{address}>", 'errors-to' => "#{label} <mlist-#{address}>"}
134
+ end
135
+
136
+ def delete_unreferenced_email
137
+ conditions = %Q{
138
+ mlist_emails.id in (
139
+ select me.id from mlist_emails me left join mlist_messages mm on mm.email_id = me.id
140
+ where mm.mail_list_id = #{id}
141
+ ) AND mlist_emails.id not in (
142
+ select meb.id from mlist_emails meb left join mlist_messages mmb on mmb.email_id = meb.id
143
+ where mmb.mail_list_id != #{id}
144
+ )}
145
+ MList::Email.delete_all(conditions)
146
+ end
147
+
148
+ def strip_list_footers(content)
149
+ if content =~ FOOTER_BLOCK_START_RE
150
+ in_footer_block = false
151
+ content = normalize_new_lines(content)
152
+ content = content.split("\n").reject do |line|
153
+ if in_footer_block
154
+ in_footer_block = line !~ FOOTER_BLOCK_END_RE
155
+ true
156
+ else
157
+ in_footer_block = line =~ FOOTER_BLOCK_START_RE
158
+ end
159
+ end.join("\n").rstrip
160
+ end
161
+ content
162
+ end
163
+
164
+ # http://www.jamesshuggins.com/h/web1/list-email-headers.htm
165
+ def list_headers
166
+ headers = list.list_headers.dup
167
+ headers['x-beenthere'] = address
168
+ headers['x-mlist-version'] = MList.version.to_s
169
+ headers.update(bounce_headers)
170
+ headers.delete_if {|k,v| v.nil?}
171
+ end
172
+
173
+ # Answer headers values which should be stripped from outgoing email.
174
+ #
175
+ def strip_headers
176
+ %w(return-receipt-to domainkey-signature dkim-signature)
177
+ end
178
+
179
+ def process_message(message, options = {})
180
+ raise MList::DoubleDeliveryError.new(message) unless message.new_record?
181
+
182
+ options = {
183
+ :search_parent => true,
184
+ :delivery_time => Time.now,
185
+ :copy_sender => false
186
+ }.merge(options)
187
+
188
+ transaction do
189
+ thread = find_thread(message, options)
190
+ thread.updated_at = options[:delivery_time]
191
+
192
+ delivery = prepare_delivery(message, options)
193
+ thread.messages << message
194
+
195
+ self.updated_at = options[:delivery_time]
196
+ thread.save! && save!
197
+
198
+ outgoing_server.deliver(delivery.tmail)
199
+ end
200
+
201
+ message
202
+ end
203
+
204
+ def prepare_delivery(message, options)
205
+ message.identifier = outgoing_server.generate_message_id
206
+ message.created_at = options[:delivery_time]
207
+ message.subject = clean_subject(message.subject)
208
+
209
+ recipient_addresses = message.recipient_addresses
210
+ sender_address = message.subscriber.email_address
211
+ if options[:copy_sender]
212
+ recipient_addresses << sender_address unless recipient_addresses.include?(sender_address)
213
+ else
214
+ recipient_addresses.delete(sender_address)
215
+ end
216
+
217
+ returning(message.delivery) do |delivery|
218
+ delivery.date ||= options[:delivery_time]
219
+ delivery.message_id = message.identifier
220
+ delivery.mailer = message.mailer
221
+ delivery.headers = list_headers
222
+ strip_headers.each {|e| delivery[e] = nil}
223
+ delivery.subject = list_subject(message.subject)
224
+ delivery.to = address
225
+ delivery.cc = []
226
+ delivery.bcc = recipient_addresses
227
+ delivery.reply_to ||= reply_to_header(message)
228
+ prepare_list_footer(delivery, message)
229
+ end
230
+ end
231
+
232
+ def prepare_list_footer(delivery, message)
233
+ text_plain_part = delivery.text_plain_part
234
+ return unless text_plain_part
235
+
236
+ content = strip_list_footers(text_plain_part.body)
237
+ content << "\n\n" unless content.end_with?("\n\n")
238
+ content << list_footer(message)
239
+ text_plain_part.body = content
240
+ end
241
+
242
+ def list_footer(message)
243
+ content = list.footer_content(message)
244
+ "#{FOOTER_BLOCK_START}\n#{content}\n#{FOOTER_BLOCK_END}"
245
+ end
246
+
247
+ def list_subject(string)
248
+ list_subject = string.dup
249
+ if list_subject =~ REGARD_RE
250
+ "Re: #{subject_prefix} #{remove_regard(list_subject)}"
251
+ else
252
+ "#{subject_prefix} #{list_subject}"
253
+ end
254
+ end
255
+
256
+ def find_thread(message, options)
257
+ message.parent = find_parent_message(message.email) if message.email && options[:search_parent]
258
+ message.parent ? message.parent.thread : threads.build
259
+ end
260
+
261
+ def reply_to_header(message)
262
+ if list.reply_to_list?
263
+ "#{label} #{bracket(address)}"
264
+ else
265
+ subscriber_name_and_address(message.subscriber)
266
+ end
267
+ end
268
+
269
+ def subject_prefix_regex
270
+ @subject_prefix_regex ||= Regexp.new(Regexp.escape(subject_prefix) + ' ')
271
+ end
272
+
273
+ def subject_prefix
274
+ @subject_prefix ||= "[#{label}]"
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,48 @@
1
+ module MList
2
+ module Manager
3
+
4
+ class Database
5
+ include ::MList::Manager
6
+
7
+ def create_list(address, attributes = {})
8
+ attributes = {
9
+ :address => address,
10
+ :label => address.match(/\A(.*?)@/)[1]
11
+ }.merge(attributes)
12
+ List.create!(attributes)
13
+ end
14
+
15
+ def lists(email)
16
+ lists = List.find_all_by_address(email.list_addresses)
17
+ email.list_addresses.map { |a| lists.detect {|l| l.address == a} }.compact
18
+ end
19
+
20
+ def no_lists_found(email)
21
+ # TODO: Move to notifier
22
+ end
23
+
24
+ class List < ActiveRecord::Base
25
+ include ::MList::List
26
+
27
+ has_many :subscribers, :dependent => :delete_all
28
+
29
+ def label
30
+ self[:label]
31
+ end
32
+
33
+ def list_id
34
+ "#{self.class.name}#{id}"
35
+ end
36
+
37
+ def subscribe(address)
38
+ subscribers.find_or_create_by_email_address(address)
39
+ end
40
+ end
41
+
42
+ class Subscriber < ActiveRecord::Base
43
+ belongs_to :list
44
+ end
45
+ end
46
+
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ module MList
2
+ module Manager
3
+
4
+ # Constructs the notices that are sent to list subscribers. Applications
5
+ # may subclass this to customize the content of a notice delivery.
6
+ #
7
+ class Notifier
8
+
9
+ # Answers the delivery that will be sent to a subscriber when an
10
+ # MList::List indicates that the distribution of an email from that
11
+ # subscriber has been blocked.
12
+ #
13
+ def subscriber_blocked(list, email, subscriber)
14
+ delivery = MList::Util::TMailBuilder.new(TMail::Mail.new)
15
+ delivery.write_header('x-mlist-loop', 'notice')
16
+ delivery.write_header('x-mlist-notice', 'subscriber_blocked')
17
+ delivery.to = subscriber.email_address
18
+ delivery.from = "mlist-#{list.address}"
19
+ prepare_subscriber_blocked_content(list, email, subscriber, delivery)
20
+ delivery
21
+ end
22
+
23
+ protected
24
+ def prepare_subscriber_blocked_content(list, email, subscriber, delivery)
25
+ delivery.set_content_type('text/plain')
26
+ delivery.body = %{Although you are a subscriber to this list, your message cannot be posted at this time. Please contact the administrator of the list.}
27
+ end
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ require 'mlist/manager/notifier'
2
+
3
+ module MList
4
+
5
+ # The interface of list managers.
6
+ #
7
+ # A module is provided instead of a base class to allow implementors to
8
+ # subclass whatever they like. Practically speaking, they can create an
9
+ # ActiveRecord subclass.
10
+ #
11
+ module Manager
12
+
13
+ # Answers an enumeration of MList::List implementations to which the given
14
+ # email should be published.
15
+ #
16
+ def lists(email)
17
+ raise 'implement in your list manager'
18
+ end
19
+
20
+ # Answers the MList::Manager::Notifier of this list manager. Includers of
21
+ # this module may initialize the @notifier instance variable with their
22
+ # own implementation/subclass to generate custom content for the different
23
+ # notices.
24
+ #
25
+ def notifier
26
+ @notifier ||= MList::Manager::Notifier.new
27
+ end
28
+ end
29
+
30
+ end