astrotrain 0.3.1

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