tuktuk 0.4.6 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -10,54 +10,83 @@ response status codes -- like bounces, 5xx -- within your application.
10
10
  Plus, it supports DKIM out of the box.
11
11
 
12
12
  ``` ruby
13
- require 'tuktuk'
13
+ require 'tuktuk'
14
14
 
15
- email = {
16
- :from => 'you@username.com',
17
- :to => 'user@yoursite.com',
18
- :body => 'Hello there',
19
- :subject => 'Hiya'
20
- }
15
+ message = {
16
+ :from => 'you@username.com',
17
+ :to => 'user@yoursite.com',
18
+ :body => 'Hello there',
19
+ :subject => 'Hiya'
20
+ }
21
21
 
22
- Tuktuk.deliver(email)
22
+ response, email = Tuktuk.deliver(message)
23
23
  ```
24
24
 
25
- To enable DKIM:
25
+ The `response` is either a Net::SMTP::Response object, or a Bounce exception (HardBounce or SoftBounce, depending on the cause). `email` is a [mail](https://github.com/mikel/mail) object. So, to handle bounces you'd do:
26
26
 
27
27
  ``` ruby
28
- require 'tuktuk'
29
-
30
- Tuktuk.options = {
31
- :dkim => {
32
- :domain => 'yoursite.com',
33
- :selector => 'mailer',
34
- :private_key => IO.read('ssl/yoursite.com.key')
35
- }
36
- }
28
+ [...]
37
29
 
38
- email = {
39
- :from => 'you@username.com',
40
- :to => 'user@yoursite.com',
41
- :body => 'Hello there',
42
- :subject => 'Hiya'
43
- }
30
+ response, email = Tuktuk.deliver(message)
44
31
 
45
- Tuktuk.deliver(email)
32
+ if response.is_a?(Bounce)
33
+ puts 'Email bounced. Type: ' + response.class.name # => HardBounce or SoftBounce
34
+ else
35
+ puts 'Email delivered!'
36
+ end
46
37
  ```
47
38
 
48
- Additional options:
39
+ With Tuktuk, you can also deliver multiple messages at once. Depending on the max_workers config parameter, Tuktuk will either connect sequentially to the target MX servers, or do it in parallel by spawning threads. You need to pass an array of emails, and you'll receive an array of [response, email] elements, just as above.
49
40
 
50
41
  ``` ruby
51
- Tuktuk.options = {
52
- :log_to => 'log/mailer.log',
53
- :max_attempts => 5,
54
- :retry_sleep => 10,
55
- :dkim => { ... }
42
+ messages = [ { ... }, { ... }, { ... }, { ... } ] # array of messages
43
+
44
+ result = Tuktuk.deliver_many(messages)
45
+
46
+ result.each do |response, email|
47
+
48
+ if response.is_a?(Bounce)
49
+ puts 'Email bounced. Type: ' + response.class.name
50
+ else
51
+ puts 'Email delivered!'
52
+ end
53
+
54
+ end
55
+ ```
56
+
57
+ Now, if you want to enable DKIM (and you should):
58
+
59
+ ``` ruby
60
+ require 'tuktuk'
61
+
62
+ Tuktuk.options = {
63
+ :dkim => {
64
+ :domain => 'yoursite.com',
65
+ :selector => 'mailer',
66
+ :private_key => IO.read('ssl/yoursite.com.key')
56
67
  }
68
+ }
69
+
70
+ message = { ... }
71
+
72
+ response, email = Tuktuk.deliver(message)
73
+ ```
74
+
75
+ For DKIM to work, you need to set up a TXT record in your domain's DNS.
76
+
77
+ Additional Tuktuk options:
78
+
79
+ ``` ruby
80
+ Tuktuk.options = {
81
+ :log_to => 'log/mailer.log',
82
+ :helo_domain => 'mydomain.com',
83
+ :max_workers => 'auto', # spawns a new thread for each domain
84
+ :dkim => { ... }
85
+ }
57
86
  ```
58
87
 
59
88
  That's all.
60
89
 
61
90
  --
62
91
 
63
- (c) 2012 Fork Limited. MIT license.
92
+ (c) 2013 Fork Limited. MIT license.
@@ -0,0 +1,32 @@
1
+ class Bounce < RuntimeError
2
+
3
+ HARD_BOUNCE_CODES = [
4
+ 501, # Bad address syntax (eg. "i.user.@hotmail.com")
5
+ 504, # mailbox is disabled
6
+ 511, # sorry, no mailbox here by that name (#5.1.1 - chkuser)
7
+ 540, # recipient's email account has been suspended.
8
+ 550, # Requested action not taken: mailbox unavailable
9
+ 552, # Spam Message Rejected -- Requested mail action aborted: exceeded storage allocation
10
+ 554, # Recipient address rejected: Policy Rejection- Abuse. Go away -- This user doesn't have a yahoo.com account
11
+ 563, # ERR_MSG_REJECT_BLACKLIST, message has blacklisted content and thus I reject it
12
+ 571 # Delivery not authorized, message refused
13
+ ]
14
+
15
+ def self.type(e)
16
+ if e.is_a?(Net::SMTPFatalError) and code = e.to_s[0..2] and HARD_BOUNCE_CODES.include? code.to_i
17
+ HardBounce.new(e)
18
+ else
19
+ SoftBounce.new(e) # either soft mailbox bounce or server bounce
20
+ end
21
+ end
22
+
23
+ def code
24
+ if str = to_s[0..2] and str.gsub(/[^0-9]/, '') != ''
25
+ str.to_i
26
+ end
27
+ end
28
+
29
+ end
30
+
31
+ class HardBounce < Bounce; end
32
+ class SoftBounce < Bounce; end
data/lib/tuktuk/tuktuk.rb CHANGED
@@ -3,25 +3,20 @@ require 'dkim'
3
3
  require 'logger'
4
4
  require 'work_queue'
5
5
 
6
- %w(package cache dns).each { |lib| require "tuktuk/#{lib}" }
6
+ %w(package cache dns bounce).each { |lib| require "tuktuk/#{lib}" }
7
7
  require 'tuktuk/version' unless defined?(Tuktuk::VERSION)
8
8
 
9
9
  DEFAULTS = {
10
- :retry_sleep => 10,
11
- :max_attempts => 3,
10
+ :helo_domain => nil,
12
11
  :max_workers => 0,
13
12
  :read_timeout => 20,
14
13
  :open_timeout => 20,
15
- :helo_domain => nil,
16
14
  :verify_ssl => true,
17
15
  :log_to => nil # $stdout,
18
16
  }
19
17
 
20
18
  module Tuktuk
21
19
 
22
- class DNSError < RuntimeError; end
23
- class MissingFieldsError < ArgumentError; end
24
-
25
20
  class << self
26
21
 
27
22
  def cache
@@ -29,7 +24,7 @@ module Tuktuk
29
24
  end
30
25
 
31
26
  def deliver(message, opts = {})
32
- raise 'Please pass a valid message object.' unless messages.is_a?(Hash)
27
+ raise 'Please pass a valid message object.' unless messages[:to]
33
28
  self.options = opts if opts.any?
34
29
  mail = Package.new(message)
35
30
  response = lookup_and_deliver(mail)
@@ -37,7 +32,7 @@ module Tuktuk
37
32
  end
38
33
 
39
34
  def deliver_many(messages, opts = {})
40
- raise 'Please pass an array of messages.' unless messages.is_a?(Array) and messages.any?
35
+ raise 'Please pass an array of messages.' unless messages.any?
41
36
  self.options = opts if opts.any?
42
37
  messages_by_domain = reorder_by_domain(messages)
43
38
  lookup_and_deliver_many(messages_by_domain)
@@ -70,21 +65,6 @@ module Tuktuk
70
65
  @logger ||= Logger.new(config[:log_to])
71
66
  end
72
67
 
73
- def success(to)
74
- logger.info("#{to} - Successfully sent!")
75
- end
76
-
77
- def error(mail, to, error, attempt)
78
- if attempt < config[:max_attempts] and (error.is_a?(EOFError) || error.is_a?(Timeout::Error))
79
- logger.info "#{to} - Got #{error.class.name} error. Retrying after #{config[:retry_sleep]} secs..."
80
- sleep config[:retry_sleep]
81
- lookup_and_deliver(mail, attempt+1)
82
- else
83
- logger.error("#{to} - Couldn't send after #{attempt} attempts: #{error.message} [#{error.class.name}]")
84
- raise error
85
- end
86
- end
87
-
88
68
  def get_domain(email_address)
89
69
  email_address && email_address.to_s[/@([a-z0-9\._-]+)/i, 1]
90
70
  end
@@ -115,15 +95,16 @@ module Tuktuk
115
95
 
116
96
  def lookup_and_deliver(mail, attempt = 1)
117
97
  if mail.destinations.empty?
118
- raise MissingFieldsError, "No destinations found! You need to pass a :to field."
98
+ raise "No destinations found! You need to pass a :to field."
119
99
  end
120
100
 
121
101
  response = nil
122
102
  mail.destinations.each do |to|
123
103
 
124
104
  domain = get_domain(to)
125
- servers = smtp_servers_for_domain(domain)
126
- error(mail, to, DNSError.new("No MX records for domain #{domain}"), attempt) && next if servers.empty?
105
+ unless servers = smtp_servers_for_domain(domain)
106
+ return HardBounce.new("588 No MX records for domain #{domain}")
107
+ end
127
108
 
128
109
  last_error = nil
129
110
  servers.each do |server|
@@ -134,7 +115,7 @@ module Tuktuk
134
115
  last_error = e
135
116
  end
136
117
  end
137
- error(mail, to, last_error, attempt) if last_error
118
+ return Bounce.type(last_error) if last_error
138
119
  end
139
120
  response
140
121
  end
@@ -187,7 +168,7 @@ module Tuktuk
187
168
  total = mails.count
188
169
 
189
170
  unless servers = smtp_servers_for_domain(domain)
190
- err = DNSError.new("No MX Records for domain #{domain}")
171
+ err = HardBounce.new("588 No MX Records for domain #{domain}")
191
172
  mails.each { |mail| responses.push [err, mail] }
192
173
  return responses
193
174
  end
@@ -197,13 +178,15 @@ module Tuktuk
197
178
  responses.push [resp, mail]
198
179
  mails.delete(mail) # remove it from list, to avoid duplicate delivery
199
180
  end
200
- logger.info "#{responses.count}/#{total} mails processed on #{domain}."
181
+ logger.info "#{responses.count}/#{total} mails processed on #{domain}'s MX: #{server}."
201
182
  break if responses.count == total
202
183
  end
203
184
 
204
185
  # if we still have emails in queue, mark them with the last error which prevented delivery
205
- if mails.any? and @last_error
206
- mails.each { |m| responses.push [@last_error, m] }
186
+ if mails.any? and @last_error
187
+ bounce = Bounce.type(@last_error)
188
+ logger.info "#{mails.count} mails still pending. Marking as #{bounce.class}..."
189
+ mails.each { |m| responses.push [bounce, m] }
207
190
  end
208
191
 
209
192
  responses
@@ -221,14 +204,12 @@ module Tuktuk
221
204
  logger.info "#{to} - [SENT] #{response.message.strip}"
222
205
  end
223
206
 
224
- success(to)
225
207
  response
226
208
  end
227
209
 
228
210
  def send_many_now(server, mails)
229
211
  logger.info "Delivering #{mails.count} mails at #{server}..."
230
212
  responses = {}
231
- timeout_error = nil
232
213
 
233
214
  server = 'localhost' if ENV['DEBUG']
234
215
  socket = init_connection(server)
@@ -238,11 +219,10 @@ module Tuktuk
238
219
  resp = smtp.send_message(get_raw_mail(mail), get_from(mail), mail.to)
239
220
  smtp.send(:getok, 'RSET') if server['hotmail'] # fix for '503 Sender already specified'
240
221
  rescue Net::SMTPFatalError => e # error code 5xx, except for 500, like: 550 Mailbox not found
241
- resp = e
222
+ resp = Bounce.type(e)
242
223
  end
243
224
  responses[mail] = resp
244
- status = resp.is_a?(Net::SMTP::Response) ? 'SENT' : 'ERROR'
245
- logger.info "#{mail.to} [#{status}] #{responses[mail].message.strip}" # both error and response have this method
225
+ logger.info "#{mail.to} [#{responses[mail].class}] #{responses[mail].message.strip}" # both error and response have this method
246
226
  end
247
227
  end
248
228
 
@@ -1,7 +1,7 @@
1
1
  module Tuktuk
2
2
  MAJOR = 0
3
- MINOR = 4
4
- PATCH = 6
3
+ MINOR = 5
4
+ PATCH = 0
5
5
 
6
6
  VERSION = [MAJOR, MINOR, PATCH].join('.')
7
7
  end
data/spec/deliver_spec.rb CHANGED
@@ -65,14 +65,14 @@ describe 'deliver many' do
65
65
  describe 'when delivering to domain' do
66
66
 
67
67
  before do
68
- @mock_smtp.stub!(:start).and_yield()
68
+ @mock_smtp.stub(:start).and_yield('foo')
69
69
  @emails = [email, email, email]
70
70
 
71
71
  @success = mock('Net::SMTP::Response')
72
- @soft_email_bounce = Net::SMTPFatalError.new('503 Sender already specified')
73
- @hard_email_bounce = Net::SMTPFatalError.new('505 Mailbox not found')
74
- @soft_server_bounce = Net::SMTPServerBusy.new('Be back in a sec')
75
- @hard_server_bounce = Tuktuk::DNSError.new('No MX records found.')
72
+ @soft_email_bounce = SoftBounce.new('503 Sender already specified')
73
+ @hard_email_bounce = HardBounce.new('505 Mailbox not found')
74
+ @soft_server_bounce = SoftBounce.new('Be back in a sec')
75
+ @hard_server_bounce = HardBounce.new('No MX records found.')
76
76
  end
77
77
 
78
78
  describe 'when domain exists' do
@@ -85,7 +85,7 @@ describe 'deliver many' do
85
85
 
86
86
  before do
87
87
  @servers = ['mx1.domain.com', 'mx2.domain.com', 'mx3.domain.com']
88
- Tuktuk.stub!(:smtp_servers_for_domain).and_return(@servers)
88
+ Tuktuk.stub(:smtp_servers_for_domain).and_return(@servers)
89
89
  end
90
90
 
91
91
  it 'starts by delivering to first one' do
@@ -130,18 +130,23 @@ describe 'deliver many' do
130
130
  describe 'and first server is down' do
131
131
 
132
132
  before do
133
- Tuktuk.stub(:init_connection).and_return(@mock_smtp)
134
- Tuktuk.stub(:init_connection).with('mx1.domain.com').and_raise('Unable to connect.')
135
133
  @responses = []
136
134
  @emails.each { |e| @responses.push [e, @success] }
137
135
  end
138
136
 
139
137
  it 'does not raise error' do
138
+ Tuktuk.should_receive(:init_connection).once.with('mx1.domain.com').and_raise('Unable to connect.')
140
139
  lambda do
141
140
  Tuktuk.send(:lookup_and_deliver_by_domain, 'domain.com', @emails)
142
141
  end.should_not raise_error(RuntimeError)
143
142
  end
144
143
 
144
+ it 'returns empty responses' do
145
+ Tuktuk.should_receive(:init_connection).once.with('mx1.domain.com').and_raise('Unable to connect.')
146
+ responses = Tuktuk.send(:send_many_now, 'mx1.domain.com', @emails)
147
+ responses.should be_empty
148
+ end
149
+
145
150
  it 'tries to connect to second server' do
146
151
  Tuktuk.should_receive(:send_many_now).once.with('mx1.domain.com', @emails).and_return([])
147
152
  Tuktuk.should_receive(:send_many_now).once.with('mx2.domain.com', @emails).and_return(@responses)
@@ -166,6 +171,25 @@ describe 'deliver many' do
166
171
  Tuktuk.should_not_receive(:send_many_now).with('mx3.domain.com')
167
172
  Tuktuk.send(:lookup_and_deliver_by_domain, 'domain.com', @emails)
168
173
  end
174
+
175
+ describe 'and other servers are down' do
176
+
177
+ before do
178
+ # TODO: for some reason the :init_connection on line 138 is affecting this
179
+ # this test should pass when running on its own
180
+ # Tuktuk.should_receive(:init_connection).once.with('mx1.domain.com').and_return(@mock_smtp)
181
+ # Tuktuk.should_receive(:init_connection).once.with('mx2.domain.com').and_raise('Unable to connect.')
182
+ # Tuktuk.should_receive(:init_connection).once.with('mx3.domain.com').and_raise('Unable to connect.')
183
+ end
184
+
185
+ it 'should not mark first email as bounced' do
186
+ Tuktuk.should_receive(:send_many_now).and_return([@first], [], [])
187
+ responses = Tuktuk.send(:lookup_and_deliver_by_domain, 'domain.com', @emails)
188
+ responses[1][0].should be_a(Bounce) if responses[1]
189
+ responses[0][0].should_not be_a(Bounce)
190
+ end
191
+
192
+ end
169
193
 
170
194
  end
171
195
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tuktuk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.6
4
+ version: 0.5.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -103,6 +103,7 @@ files:
103
103
  - README.md
104
104
  - Rakefile
105
105
  - lib/tuktuk.rb
106
+ - lib/tuktuk/bounce.rb
106
107
  - lib/tuktuk/cache.rb
107
108
  - lib/tuktuk/dns.rb
108
109
  - lib/tuktuk/package.rb