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 +61 -32
- data/lib/tuktuk/bounce.rb +32 -0
- data/lib/tuktuk/tuktuk.rb +17 -37
- data/lib/tuktuk/version.rb +2 -2
- data/spec/deliver_spec.rb +32 -8
- metadata +2 -1
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
|
-
|
13
|
+
require 'tuktuk'
|
14
14
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
15
|
+
message = {
|
16
|
+
:from => 'you@username.com',
|
17
|
+
:to => 'user@yoursite.com',
|
18
|
+
:body => 'Hello there',
|
19
|
+
:subject => 'Hiya'
|
20
|
+
}
|
21
21
|
|
22
|
-
|
22
|
+
response, email = Tuktuk.deliver(message)
|
23
23
|
```
|
24
24
|
|
25
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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)
|
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
|
-
:
|
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
|
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.
|
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
|
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
|
-
|
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
|
-
|
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 =
|
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
|
-
|
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
|
-
|
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
|
|
data/lib/tuktuk/version.rb
CHANGED
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
|
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 =
|
73
|
-
@hard_email_bounce =
|
74
|
-
@soft_server_bounce =
|
75
|
-
@hard_server_bounce =
|
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
|
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
|
+
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
|