astrotrain 0.3.1

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 (57) hide show
  1. data/.gitignore +26 -0
  2. data/LICENSE +20 -0
  3. data/README +47 -0
  4. data/Rakefile +122 -0
  5. data/VERSION +1 -0
  6. data/astrotrain.gemspec +129 -0
  7. data/config/sample.rb +12 -0
  8. data/lib/astrotrain.rb +53 -0
  9. data/lib/astrotrain/api.rb +52 -0
  10. data/lib/astrotrain/logged_mail.rb +46 -0
  11. data/lib/astrotrain/mapping.rb +157 -0
  12. data/lib/astrotrain/mapping/http_post.rb +18 -0
  13. data/lib/astrotrain/mapping/jabber.rb +28 -0
  14. data/lib/astrotrain/mapping/transport.rb +55 -0
  15. data/lib/astrotrain/message.rb +330 -0
  16. data/lib/astrotrain/tmail.rb +58 -0
  17. data/lib/astrotrain/worker.rb +65 -0
  18. data/lib/vendor/rest-client/README.rdoc +104 -0
  19. data/lib/vendor/rest-client/Rakefile +84 -0
  20. data/lib/vendor/rest-client/bin/restclient +65 -0
  21. data/lib/vendor/rest-client/foo.diff +66 -0
  22. data/lib/vendor/rest-client/lib/rest_client.rb +188 -0
  23. data/lib/vendor/rest-client/lib/rest_client/net_http_ext.rb +23 -0
  24. data/lib/vendor/rest-client/lib/rest_client/payload.rb +185 -0
  25. data/lib/vendor/rest-client/lib/rest_client/request_errors.rb +75 -0
  26. data/lib/vendor/rest-client/lib/rest_client/resource.rb +103 -0
  27. data/lib/vendor/rest-client/rest-client.gemspec +18 -0
  28. data/lib/vendor/rest-client/spec/base.rb +5 -0
  29. data/lib/vendor/rest-client/spec/master_shake.jpg +0 -0
  30. data/lib/vendor/rest-client/spec/payload_spec.rb +71 -0
  31. data/lib/vendor/rest-client/spec/request_errors_spec.rb +44 -0
  32. data/lib/vendor/rest-client/spec/resource_spec.rb +52 -0
  33. data/lib/vendor/rest-client/spec/rest_client_spec.rb +219 -0
  34. data/test/api_test.rb +28 -0
  35. data/test/fixtures/apple_multipart.txt +100 -0
  36. data/test/fixtures/bad_content_type.txt +27 -0
  37. data/test/fixtures/basic.txt +14 -0
  38. data/test/fixtures/custom.txt +15 -0
  39. data/test/fixtures/fwd.txt +0 -0
  40. data/test/fixtures/gb2312_encoding.txt +16 -0
  41. data/test/fixtures/gb2312_encoding_invalid.txt +15 -0
  42. data/test/fixtures/html.txt +16 -0
  43. data/test/fixtures/iso-8859-1.txt +13 -0
  44. data/test/fixtures/mapped.txt +13 -0
  45. data/test/fixtures/multipart.txt +213 -0
  46. data/test/fixtures/multipart2.txt +213 -0
  47. data/test/fixtures/multiple.txt +13 -0
  48. data/test/fixtures/multiple_delivered_to.txt +14 -0
  49. data/test/fixtures/multiple_with_body_recipients.txt +15 -0
  50. data/test/fixtures/reply.txt +16 -0
  51. data/test/fixtures/utf-8.txt +13 -0
  52. data/test/logged_mail_test.rb +67 -0
  53. data/test/mapping_test.rb +129 -0
  54. data/test/message_test.rb +440 -0
  55. data/test/test_helper.rb +57 -0
  56. data/test/transport_test.rb +111 -0
  57. metadata +225 -0
@@ -0,0 +1,46 @@
1
+ module Astrotrain
2
+ # Logs details of each incoming message.
3
+ class LoggedMail
4
+ include DataMapper::Resource
5
+
6
+ class << self
7
+ attr_accessor :log_path, :log_processed
8
+ end
9
+
10
+ # Enabling this will save records for every processed email, not just the errored emails.
11
+ self.log_processed = false
12
+ self.log_path = File.join(Astrotrain.root, 'messages')
13
+
14
+ property :id, Serial
15
+ property :mapping_id, Integer, :index => true
16
+ property :sender, String, :index => true, :size => 255, :length => 1..255
17
+ property :recipient, String, :index => true, :size => 255, :length => 1..255
18
+ property :subject, String, :index => true, :size => 255, :length => 1..255
19
+ property :mail_file, String, :size => 255, :length => 1..255
20
+ property :created_at, DateTime
21
+ property :delivered_at, DateTime
22
+ property :error_message, Text
23
+
24
+ belongs_to :mapping
25
+
26
+ def self.from(message, file = nil)
27
+ logged = new
28
+ begin
29
+ logged.sender = Message.parse_email_addresses(message.sender).first
30
+ logged.subject = message.subject
31
+ logged.mail_file = file if file
32
+ end
33
+ if !block_given? || yield(logged)
34
+ begin
35
+ logged.save
36
+ if logged.delivered_at && File.exist?(logged.mail_file.to_s)
37
+ FileUtils.rm_rf logged.mail_file
38
+ end
39
+ rescue
40
+ puts $!.inspect
41
+ end
42
+ end
43
+ logged
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,157 @@
1
+ module Astrotrain
2
+ class Mapping
3
+ include DataMapper::Resource
4
+
5
+ class << self
6
+ attr_accessor :default_domain
7
+ attr_accessor :transports
8
+ end
9
+
10
+ self.transports = {"HTTP Post" => 'http_post', "Jabber" => 'jabber'}
11
+ self.default_domain = 'astrotrain.com'
12
+
13
+ property :id, Serial
14
+ property :email_user, String, :size => 255, :length => 1..255, :index => :email, :format => /^[\w\.\_\%\+\-]*\*?$/
15
+ property :email_domain, String, :size => 255, :lenght => 1..255, :index => :email, :format => /^[\w\-\_\.]+$/, :default => lambda { default_domain }
16
+ property :destination, String, :size => 255, :length => 1..255
17
+ property :transport, String, :size => 255, :set => transports.values, :default => 'http_post'
18
+ property :separator, String, :size => 255
19
+
20
+ validates_is_unique :email_user, :scope => :email_domain
21
+ validates_format :destination, :as => /^(https?:)\/\/[^\/]+\/?/i, :if => :destination_uses_url?
22
+ validates_format :destination, :as => :email_address, :if => :destination_uses_email?
23
+
24
+ has n, :logged_mails, :order => [:created_at.desc]
25
+
26
+ # returns a mapping for the given array of email addresses
27
+ def self.match(email_addresses)
28
+ email_addresses.each do |email_address|
29
+ email_address.strip!
30
+ email_address.downcase!
31
+ name, domain = email_address.split("@")
32
+ if mapping = match_by_address(name, domain) || match_by_wildcard(name, domain)
33
+ return [mapping, email_address]
34
+ end
35
+ end
36
+ nil
37
+ end
38
+
39
+ # Processes a given message. It finds a mapping, creates a LoggedMail record,
40
+ # and attempts to process the message.
41
+ def self.process(message, file = nil)
42
+ LoggedMail.from(message, file) do |logged|
43
+ save_logged = begin
44
+ mapping, recipient = match(message.recipients)
45
+ if mapping
46
+ logged.recipient = recipient
47
+ logged.mapping = mapping
48
+ mapping.process(message, recipient)
49
+ logged.delivered_at = Time.now.utc
50
+ end
51
+ LoggedMail.log_processed # save successfully processed messages?
52
+ rescue
53
+ logged.error_message = "#{$!.message}\n#{$!.backtrace.join("\n")}"
54
+ end
55
+ Astrotrain.callback(:post_processing, message, mapping, logged)
56
+ save_logged
57
+ end
58
+ end
59
+
60
+ # Processes a given message and recipient against the mapping's transport.
61
+ def process(message, recipient)
62
+ Astrotrain.callback(:pre_processing, message, self)
63
+ Transport.process(message, self, recipient)
64
+ end
65
+
66
+ # returns true if the email matches this mapping. Wildcards in the name are allowed.
67
+ # A mapping with foo*@bar.com will match foo@bar.com and food@bar.com, but not foo@baz.com.
68
+ def match?(name, domain)
69
+ email_domain == domain && name =~ email_user_regex
70
+ end
71
+
72
+ def destination_uses_url?
73
+ transport == 'http_post'
74
+ end
75
+
76
+ def destination_uses_email?
77
+ transport == 'jabber'
78
+ end
79
+
80
+ def full_email
81
+ "#{email_user}@#{email_domain}"
82
+ end
83
+
84
+ # Looks for the mapping's separator in the message body and pulls only the content
85
+ # above it. Assuming a separator of '===='...
86
+ #
87
+ # This will be kept
88
+ #
89
+ # On Thu, Sep 3, 2009 at 12:34 AM... (this will be removed)
90
+ # ====
91
+ #
92
+ # > Everything here will be removed.
93
+ #
94
+ def find_reply_from(body)
95
+ return if separator.blank?
96
+ return '' if body.blank?
97
+ lines = body.split("\n")
98
+ delim_line = found_empty = nil
99
+
100
+ (lines.size - 1).downto(0) do |i|
101
+ line = lines[i]
102
+ if !delim_line && line.include?(separator)
103
+ delim_line = i
104
+ elsif delim_line && !found_empty
105
+ delim_line = i
106
+ found_empty = line.strip.blank?
107
+ elsif delim_line && found_empty
108
+ if date_reply_line?(line) || line.strip.blank?
109
+ delim_line = i
110
+ else
111
+ break
112
+ end
113
+ end
114
+ end
115
+
116
+ if delim_line
117
+ body = if delim_line.zero?
118
+ []
119
+ elsif lines.size >= delim_line
120
+ lines[0..delim_line-1]
121
+ else
122
+ lines
123
+ end.join("\n")
124
+ elsif body.frozen?
125
+ body = body.dup
126
+ end
127
+ body.strip!
128
+ body
129
+ end
130
+
131
+ protected
132
+ def self.match_by_address(name, domain)
133
+ first(:email_user => name, :email_domain => domain)
134
+ end
135
+
136
+ def self.match_by_wildcard(name, domain)
137
+ wildcards = all(:email_domain => domain, :email_user.like => "%*")
138
+ wildcards.sort! { |x, y| y.email_user.size <=> x.email_user.size }
139
+ wildcards.detect { |w| w.match?(name, domain) }
140
+ end
141
+
142
+ DATE_LANGUATE_REGEXES = [/^on\b.*wrote\b?:$/i, /^am\b.*schrieb [\w\d\s]+:$/i, /^le\b.*a écrit\b?:$/i]
143
+ def date_reply_line?(line)
144
+ DATE_LANGUATE_REGEXES.any? { |re| line =~ re }
145
+ end
146
+
147
+ def email_user_regex
148
+ @email_user_regex ||= begin
149
+ if email_user['*']
150
+ /^#{email_user.sub /\*/, '(.*)'}$/
151
+ else
152
+ /^#{email_user}$/
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,18 @@
1
+ module Astrotrain
2
+ class Mapping
3
+ class HttpPost < Transport
4
+ def process
5
+ return unless Transport.processing
6
+ RestClient.post @mapping.destination, fields.merge(:emails => fields[:emails].join(","))
7
+ end
8
+
9
+ def fields
10
+ super
11
+ @message.attachments.each_with_index do |att, index|
12
+ @fields[:"attachments_#{index}"] = att
13
+ end
14
+ @fields
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,28 @@
1
+ begin
2
+ require 'xmpp4r-simple'
3
+ module Astrotrain
4
+ class Mapping
5
+ # This is experimental. No attachments are supported.
6
+ class Jabber < Transport
7
+ class << self
8
+ attr_accessor :login, :password
9
+ end
10
+
11
+ def process
12
+ return unless Transport.processing
13
+ connection.deliver(@mapping.destination, content)
14
+ end
15
+
16
+ def connection
17
+ @connection ||= ::Jabber::Simple.new(self.class.login, self.class.password)
18
+ end
19
+
20
+ def content
21
+ @content ||= "From: %s\nTo: %s\nSubject: %s\nEmails: %s\n%s" % [fields[:from], fields[:to], fields[:subject], fields[:emails] * ", ", fields[:body]]
22
+ end
23
+ end
24
+ end
25
+ end
26
+ rescue LoadError
27
+ puts "Install xmpp4r-simple for Jabber support."
28
+ end
@@ -0,0 +1,55 @@
1
+ module Astrotrain
2
+ class Mapping
3
+ class Transport
4
+ class << self
5
+ attr_accessor :processing
6
+ end
7
+
8
+ # Enable this turn on processing.
9
+ self.processing = false
10
+
11
+ attr_reader :message, :mapping
12
+
13
+ # process a given message against the mapping. The mapping transport is checked,
14
+ # and the appropirate transport class handles the request.
15
+ def self.process(message, mapping, recipient)
16
+ case mapping.transport
17
+ when 'http_post' then HttpPost.process(message, mapping, recipient)
18
+ when 'jabber' then Jabber.process(message, mapping, recipient)
19
+ end
20
+ end
21
+
22
+ def initialize(message, mapping, recipient)
23
+ message.body = mapping.find_reply_from(message.body)
24
+ @message = message
25
+ @mapping = mapping
26
+ @recipient = recipient
27
+ end
28
+
29
+ def process
30
+ raise UnimplementedError
31
+ end
32
+
33
+ def fields
34
+ @fields ||= begin
35
+ all_emails = @message.recipients - [@recipient]
36
+ f = {:subject => @message.subject, :to => @recipient, :from => @message.sender, :body => @message.body, :emails => all_emails, :html => @message.html}
37
+ @message.headers.each do |key, value|
38
+ f["headers[#{key}]"] = value
39
+ end
40
+ f
41
+ end
42
+ end
43
+
44
+ # defines custom #process class methods that instantiate the class and calls a #process instance method
45
+ def self.inherited(child)
46
+ super
47
+ class << child
48
+ def process(message, mapping, recipient)
49
+ new(message, mapping, recipient).process
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,330 @@
1
+ require 'digest/sha1'
2
+ require 'fileutils'
3
+ require 'tempfile'
4
+ require 'set'
5
+
6
+ module Astrotrain
7
+ # Wrapper around a TMail object
8
+ class Message
9
+ attr_accessor :body
10
+ attr_reader :mail, :attachments
11
+
12
+ class << self
13
+ attr_reader :queue_path, :archive_path
14
+ attr_accessor :recipient_header_order, :skipped_headers
15
+ end
16
+
17
+ def self.queue_path=(path)
18
+ if path
19
+ path = File.expand_path(path)
20
+ FileUtils.mkdir_p path
21
+ end
22
+ @queue_path = path
23
+ end
24
+
25
+ def self.archive_path=(path)
26
+ if path
27
+ path = File.expand_path(path)
28
+ FileUtils.mkdir_p path
29
+ end
30
+ @archive_path = path
31
+ end
32
+
33
+ self.skipped_headers = Set.new %w(date from subject delivered-to x-original-to received)
34
+ self.recipient_header_order = %w(original_to delivered_to to)
35
+ self.queue_path = File.join(Astrotrain.root, 'queue')
36
+
37
+ # Dumps the raw text into the queue_path. Not really recommended, since you should
38
+ # set the queue_path to the directory your incoming emails are dumped into.
39
+ def self.queue(raw)
40
+ filename = nil
41
+ digest = Digest::SHA1.hexdigest(raw)
42
+ while filename.nil? || File.exist?(filename)
43
+ filename = File.join(queue_path, Digest::SHA1.hexdigest(digest + rand.to_s))
44
+ end
45
+ File.open filename, 'wb' do |f|
46
+ f.write raw
47
+ end
48
+ filename
49
+ end
50
+
51
+ # Parses the given raw email text and processes it with a matching Mapping.
52
+ def self.receive(raw, file = nil)
53
+ message = parse(raw)
54
+ Astrotrain.callback(:pre_mapping, message)
55
+ Mapping.process(message, file)
56
+ message
57
+ end
58
+
59
+ # Processes the given file. It parses it by reading the contents, and optionally
60
+ # archives or removes the original file.
61
+ def self.receive_file(path)
62
+ raw = IO.read(path)
63
+ logged_path = path
64
+ if archive_path
65
+ daily_archive_path = archive_path / Time.now.year.to_s / Time.now.month.to_s / Time.now.day.to_s
66
+ FileUtils.mkdir_p(daily_archive_path)
67
+ logged_path = daily_archive_path / File.basename(path)
68
+ FileUtils.mv path, logged_path if path != logged_path
69
+ end
70
+ receive(raw, logged_path)
71
+ end
72
+
73
+ # Parses the raw email headers into a Astrotrain::Message instance.
74
+ def self.parse(raw)
75
+ new Mail.parse(raw)
76
+ end
77
+
78
+ def self.parse_email_addresses(value)
79
+ emails = value.split(",")
80
+ collection = []
81
+ emails.each do |addr|
82
+ addr.strip!
83
+ next if addr.blank?
84
+ header = parse_email_address(addr.to_s)
85
+ collection << unescape(header[:email]) if !header[:email].blank?
86
+ end
87
+ collection
88
+ end
89
+
90
+ def self.parse_email_address(email)
91
+ return {} if email.blank?
92
+ begin
93
+ header = TMail::Address.parse(email)
94
+ {:name => header.name, :email => header.address}
95
+ rescue SyntaxError, TMail::SyntaxError
96
+ email = email.scan(/\<([^\>]+)\>/)[0]
97
+ if email.blank?
98
+ return {:name => nil, :email => nil}
99
+ else
100
+ email = email[0]
101
+ retry
102
+ end
103
+ end
104
+ end
105
+
106
+ # Stolen from Rack/Camping, remove the "+" => " " translation
107
+ def self.unescape(s)
108
+ s.gsub!(/((?:%[0-9a-fA-F]{2})+)/n){
109
+ [$1.delete('%')].pack('H*')
110
+ }
111
+ s
112
+ end
113
+
114
+ def initialize(mail)
115
+ @mail = mail
116
+ @mapping = nil
117
+ @attachments = []
118
+ @recipients = {}
119
+ end
120
+
121
+ # Gets the recipients of an email using the To/Delivered-To/X-Original-To headers.
122
+ # It's not always straightforward which email we want when dealing with filters
123
+ # and forward rules.
124
+ def recipients(order = nil)
125
+ if !@recipients.key?(order)
126
+ order = self.class.recipient_header_order if order.blank?
127
+ recipients = []
128
+
129
+ parse_email_headers recipients_from_body, recipients
130
+ order.each do |key|
131
+ parse_email_headers(send("recipients_from_#{key}"), recipients)
132
+ end
133
+
134
+ recipients.flatten!
135
+ recipients.uniq!
136
+ @recipients[order] = recipients
137
+ else
138
+ @recipients[order]
139
+ end
140
+ end
141
+
142
+ def recipients_from_to
143
+ @recipient_from_to ||= [@mail['to'].to_s]
144
+ end
145
+
146
+ def recipients_from_delivered_to
147
+ @recipient_from_delivered_to ||= begin
148
+ delivered = @mail['Delivered-To']
149
+ if delivered.respond_to?(:first)
150
+ delivered.map! { |a| a.to_s }
151
+ else
152
+ [delivered.to_s]
153
+ end
154
+ end
155
+ end
156
+
157
+ def recipients_from_original_to
158
+ @recipient_from_original_to ||= [@mail['X-Original-To'].to_s]
159
+ end
160
+
161
+ def recipients_from_body
162
+ @recipients_from_body ||= body.scan(/<[\w\.\_\%\+\-]+@[\w\-\_\.]+>/)
163
+ end
164
+
165
+ def sender
166
+ @sender ||= TMail::Unquoter.unquote_and_convert_to(@mail['from'].to_s, "utf-8")
167
+ end
168
+
169
+ def subject
170
+ @mail.subject
171
+ rescue Iconv::InvalidCharacter
172
+ @mail.quoted_subject
173
+ end
174
+
175
+ def body
176
+ @body ||= begin
177
+ process_message_body
178
+ @body
179
+ end
180
+ end
181
+
182
+ def html
183
+ @html ||= begin
184
+ process_message_body
185
+ @html
186
+ end
187
+ end
188
+
189
+ def raw
190
+ @mail.port.to_s
191
+ end
192
+
193
+ def header(key)
194
+ @headers ||= {}
195
+ if !@headers.key?(key)
196
+ @headers[key] = if self.class.skipped_headers.include?(key)
197
+ nil
198
+ else
199
+ header = @mail.header[key]
200
+ begin
201
+ header.to_s
202
+ rescue
203
+ header.raw_body
204
+ end
205
+ end
206
+ end
207
+ @headers[key]
208
+ end
209
+
210
+ def headers
211
+ @headers ||= begin
212
+ h = {}
213
+ @mail.header.each do |key, value|
214
+ header_value = header(key)
215
+ h[key] = header_value if header_value
216
+ end
217
+ h
218
+ end
219
+ end
220
+
221
+ class Attachment
222
+ def initialize(part)
223
+ @part = part
224
+ @is_read = false
225
+ end
226
+
227
+ def content_type
228
+ @part.content_type
229
+ end
230
+
231
+ def filename
232
+ @filename ||= @part.type_param("name") || @part.disposition_param('filename')
233
+ end
234
+
235
+ # For IO API compatibility when used with Rest-Client
236
+ def close
237
+ end
238
+
239
+ alias path filename
240
+
241
+ def read(value = nil)
242
+ if read?
243
+ nil
244
+ else
245
+ @is_read = true
246
+ data
247
+ end
248
+ end
249
+
250
+ def read?
251
+ @is_read == true
252
+ end
253
+
254
+ def data
255
+ @part.body
256
+ end
257
+
258
+ def attached?
259
+ !filename.nil?
260
+ end
261
+
262
+ def ==(other)
263
+ super || (filename == other.filename && content_type == other.content_type)
264
+ end
265
+
266
+ def inspect
267
+ %(#<Message::Attachment filename=#{filename.inspect} content_type=#{content_type.inspect}>)
268
+ end
269
+ end
270
+
271
+ protected
272
+ def process_message_body
273
+ if @mail.multipart?
274
+ @attachments.clear
275
+ @body, @html = [], []
276
+ scan_parts(@mail)
277
+ @body = @body.join("\n")
278
+ @html = @html.join("\n")
279
+ else
280
+ @body = @mail.body
281
+ @html = ''
282
+ end
283
+ if !@mail.charset
284
+ @body = convert_to_utf8(@body)
285
+ @html = convert_to_utf8(@html)
286
+ end
287
+ end
288
+
289
+ def scan_parts(message)
290
+ message.parts.each do |part|
291
+ if part.multipart?
292
+ scan_parts(part)
293
+ else
294
+ case part.content_type
295
+ when 'text/plain'
296
+ @body << part.body
297
+ when 'text/html'
298
+ @html << part.body
299
+ else
300
+ att = Attachment.new(part)
301
+ @attachments << att if att.attached?
302
+ end
303
+ end
304
+ end
305
+ end
306
+
307
+ def parse_email_headers(values, collection)
308
+ values.each do |value|
309
+ if !value.blank?
310
+ collection.push *self.class.parse_email_addresses(value)
311
+ end
312
+ end
313
+ end
314
+
315
+ # Attempts to run iconv conversions in common charsets to UTF-8. Needed for
316
+ # those crappy emails that don't properly specify a charset in the headers.
317
+ ICONV_CONVERSIONS = %w(utf-8 ISO-8859-1 ISO-8859-2 ISO-8859-3 ISO-8859-4 ISO-8859-5 ISO-8859-6 ISO-8859-7 ISO-8859-8 ISO-8859-9
318
+ ISO-8859-15 GB2312)
319
+ def convert_to_utf8(s)
320
+ ICONV_CONVERSIONS.each do |from|
321
+ begin
322
+ return Iconv.iconv(ICONV_CONVERSIONS[0], from, s).to_s
323
+ rescue Iconv::IllegalSequence
324
+ ensure
325
+ s
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end