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/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