protocol-smtp 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +201 -0
- data/README.md +127 -0
- data/lib/protocol/smtp/client.rb +426 -0
- data/lib/protocol/smtp/connection.rb +153 -0
- data/lib/protocol/smtp/error.rb +33 -0
- data/lib/protocol/smtp/message.rb +150 -0
- data/lib/protocol/smtp/reply.rb +131 -0
- data/lib/protocol/smtp/server.rb +544 -0
- data/lib/protocol/smtp/version.rb +7 -0
- data/lib/protocol/smtp.rb +22 -0
- metadata +144 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "connection"
|
|
4
|
+
require_relative "reply"
|
|
5
|
+
|
|
6
|
+
module Protocol
|
|
7
|
+
module SMTP
|
|
8
|
+
# The client side of the conversation: send a command, read the reply it
|
|
9
|
+
# answers with.
|
|
10
|
+
#
|
|
11
|
+
# client = Protocol::SMTP::Client.new(stream)
|
|
12
|
+
# client.deliver(from: "me@example.com", to: "you@example.com", body: message)
|
|
13
|
+
# client.quit
|
|
14
|
+
#
|
|
15
|
+
# Every command returns its Reply rather than raising on one, because
|
|
16
|
+
# which codes are fatal depends on what you are doing — #deliver, which
|
|
17
|
+
# has to get a whole transaction through in order, is the one that insists.
|
|
18
|
+
class Client < Connection
|
|
19
|
+
# RFC 5321 4.2: three digits, then a space on the last line of a reply
|
|
20
|
+
# and a hyphen on every line before it. The text is optional.
|
|
21
|
+
REPLY_LINE = /\A(?<code>\d{3})(?<continued>[ \-]?)(?<text>.*)\z/m
|
|
22
|
+
|
|
23
|
+
# The mechanisms #authenticate knows how to perform, best first.
|
|
24
|
+
MECHANISMS = ["PLAIN", "LOGIN"].freeze
|
|
25
|
+
|
|
26
|
+
# In SMTP the server talks first; this is what it said.
|
|
27
|
+
# @returns [Reply]
|
|
28
|
+
def greeting = @greeting ||= read_reply
|
|
29
|
+
|
|
30
|
+
# @parameter domain [String] The domain to introduce ourselves as.
|
|
31
|
+
# @returns [Reply] The server's reply, whose lines are its extensions.
|
|
32
|
+
def ehlo(domain)
|
|
33
|
+
greeting
|
|
34
|
+
|
|
35
|
+
command("EHLO #{domain}").tap do |reply|
|
|
36
|
+
case reply.positive?
|
|
37
|
+
when true then @extensions = parse_extensions(reply)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @parameter domain [String] The domain to introduce ourselves as.
|
|
43
|
+
# @returns [Reply]
|
|
44
|
+
def helo(domain)
|
|
45
|
+
greeting
|
|
46
|
+
command("HELO #{domain}").tap { @extensions = {} }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# EHLO, falling back to HELO for a server that does not know it
|
|
50
|
+
# (RFC 5321 2.2.1). The extension list is empty in that case.
|
|
51
|
+
#
|
|
52
|
+
# @parameter domain [String] The domain to introduce ourselves as.
|
|
53
|
+
# @returns [Reply]
|
|
54
|
+
def hello(domain)
|
|
55
|
+
ehlo(domain).then do |reply|
|
|
56
|
+
case reply.positive?
|
|
57
|
+
when true then reply
|
|
58
|
+
else helo(domain)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# What the last EHLO advertised: an upper case keyword for each
|
|
64
|
+
# extension, mapped to the rest of its line.
|
|
65
|
+
#
|
|
66
|
+
# @returns [Hash(String, String)]
|
|
67
|
+
def extensions = @extensions ||= {}
|
|
68
|
+
|
|
69
|
+
# @returns [Boolean] Whether the server offered to upgrade to TLS.
|
|
70
|
+
def starttls? = extensions.key?("STARTTLS")
|
|
71
|
+
|
|
72
|
+
# @returns [Array(String)] The mechanisms the server offered, upper case.
|
|
73
|
+
def mechanisms = extensions.fetch("AUTH", "").upcase.split
|
|
74
|
+
|
|
75
|
+
# @returns [Integer | Nil] The largest message the server will take.
|
|
76
|
+
def maximum_message_size
|
|
77
|
+
extensions["SIZE"].then do |size|
|
|
78
|
+
case size
|
|
79
|
+
when nil, "" then nil
|
|
80
|
+
else Integer(size, exception: false)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Ask to upgrade the connection (RFC 3207). On a 220 the caller has to
|
|
86
|
+
# replace {Connection#stream} with the encrypted one and then start over
|
|
87
|
+
# with a fresh EHLO — the extension list before and after an upgrade are
|
|
88
|
+
# not the same thing, which is the point of doing it.
|
|
89
|
+
#
|
|
90
|
+
# @returns [Reply]
|
|
91
|
+
def starttls = command("STARTTLS")
|
|
92
|
+
|
|
93
|
+
# @returns [Reply]
|
|
94
|
+
def mail_from(address) = command("MAIL FROM:<#{address}>")
|
|
95
|
+
|
|
96
|
+
# @returns [Reply]
|
|
97
|
+
def rcpt_to(address) = command("RCPT TO:<#{address}>")
|
|
98
|
+
|
|
99
|
+
# @returns [Reply]
|
|
100
|
+
def reset = command("RSET")
|
|
101
|
+
|
|
102
|
+
# @returns [Reply]
|
|
103
|
+
def noop = command("NOOP")
|
|
104
|
+
|
|
105
|
+
# Say goodbye and read the 221. The stream stays open: whoever opened it
|
|
106
|
+
# closes it.
|
|
107
|
+
#
|
|
108
|
+
# @returns [Reply]
|
|
109
|
+
def quit
|
|
110
|
+
command("QUIT").tap { shutdown }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# RFC 4616: the credentials go in one base64 blob, NUL separated, with an
|
|
114
|
+
# empty authorisation identity in front.
|
|
115
|
+
#
|
|
116
|
+
# @returns [Reply]
|
|
117
|
+
def auth_plain(username, password)
|
|
118
|
+
command("AUTH PLAIN #{encode("\0#{username}\0#{password}")}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The same credentials, one 334 challenge at a time. Only for servers
|
|
122
|
+
# that offer LOGIN and not PLAIN; the challenge text is ignorable.
|
|
123
|
+
#
|
|
124
|
+
# @returns [Reply]
|
|
125
|
+
def auth_login(username, password)
|
|
126
|
+
expect(command("AUTH LOGIN"), 334)
|
|
127
|
+
expect(command(encode(username)), 334)
|
|
128
|
+
command(encode(password))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Authenticate using the best mechanism the server offered.
|
|
132
|
+
#
|
|
133
|
+
# @parameter username [String]
|
|
134
|
+
# @parameter password [String]
|
|
135
|
+
# @returns [Reply]
|
|
136
|
+
# @raises [AuthenticationError] If no offered mechanism is implemented.
|
|
137
|
+
def authenticate(username, password)
|
|
138
|
+
(MECHANISMS & mechanisms).first.then do |mechanism|
|
|
139
|
+
case mechanism
|
|
140
|
+
when "PLAIN" then auth_plain(username, password)
|
|
141
|
+
when "LOGIN" then auth_login(username, password)
|
|
142
|
+
else
|
|
143
|
+
raise AuthenticationError, "No supported mechanism in #{mechanisms.inspect}!"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# DATA, then the message, then the terminating dot. A body line that
|
|
149
|
+
# starts with a dot gets another one so it cannot be mistaken for that
|
|
150
|
+
# terminator (RFC 5321 4.5.2).
|
|
151
|
+
#
|
|
152
|
+
# @parameter body [String] The message, headers and all.
|
|
153
|
+
# @returns [Reply] What the server made of it.
|
|
154
|
+
def data(body)
|
|
155
|
+
expect(command("DATA"), 354)
|
|
156
|
+
|
|
157
|
+
body.each_line do |line|
|
|
158
|
+
write_line(line.chomp.sub(/\A\./, ".."))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
write_line(".")
|
|
162
|
+
read_reply
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# One whole transaction on a connection that has already introduced
|
|
166
|
+
# itself, refusing to carry on past a reply that means it cannot
|
|
167
|
+
# succeed. This is the half of #deliver worth repeating: a session can
|
|
168
|
+
# carry any number of transactions, and only one EHLO.
|
|
169
|
+
#
|
|
170
|
+
# @parameter from [String] The envelope sender.
|
|
171
|
+
# @parameter to [String | Array(String)] The envelope recipients.
|
|
172
|
+
# @parameter body [String] The message, headers and all.
|
|
173
|
+
# @returns [Reply] The reply to the message itself.
|
|
174
|
+
# @raises [ReplyError] If any step of the transaction was refused.
|
|
175
|
+
def transaction(from:, to:, body:)
|
|
176
|
+
expect(mail_from(from), 250)
|
|
177
|
+
|
|
178
|
+
Array(to).each do |address|
|
|
179
|
+
expect(rcpt_to(address), 250)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
expect(data(body), 250)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Introduce ourselves and send one message.
|
|
186
|
+
#
|
|
187
|
+
# @parameter from [String] The envelope sender.
|
|
188
|
+
# @parameter to [String | Array(String)] The envelope recipients.
|
|
189
|
+
# @parameter body [String] The message, headers and all.
|
|
190
|
+
# @parameter domain [String] The domain to introduce ourselves as.
|
|
191
|
+
# @returns [Reply] The reply to the message itself.
|
|
192
|
+
# @raises [ReplyError] If any step of the transaction was refused.
|
|
193
|
+
def deliver(from:, to:, body:, domain: "localhost")
|
|
194
|
+
expect(hello(domain), 250)
|
|
195
|
+
|
|
196
|
+
transaction(from: from, to: to, body: body)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @parameter line [String] The command line, without its terminator.
|
|
200
|
+
# @returns [Reply]
|
|
201
|
+
def command(line)
|
|
202
|
+
write_line(line)
|
|
203
|
+
read_reply
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A multi-line reply repeats its code on every line, with a hyphen
|
|
207
|
+
# instead of a space until the last one (RFC 5321 4.2.1).
|
|
208
|
+
#
|
|
209
|
+
# @returns [Reply]
|
|
210
|
+
def read_reply
|
|
211
|
+
code = nil
|
|
212
|
+
lines = []
|
|
213
|
+
continued = true
|
|
214
|
+
|
|
215
|
+
while continued
|
|
216
|
+
parse_reply_line(read_line).then do |parsed|
|
|
217
|
+
code = parsed[0]
|
|
218
|
+
lines << parsed[1]
|
|
219
|
+
continued = parsed[2]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
Reply.new(code, lines)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def parse_reply_line(line)
|
|
229
|
+
case line
|
|
230
|
+
when nil then raise ClosedError, "Connection closed while reading a reply!"
|
|
231
|
+
when REPLY_LINE
|
|
232
|
+
[Integer($~[:code]), $~[:text], $~[:continued] == "-"]
|
|
233
|
+
else
|
|
234
|
+
raise InvalidReplyError, "Invalid reply line: #{line.inspect}!"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The first line of an EHLO reply is a greeting, not an extension; the
|
|
239
|
+
# rest are "KEYWORD arguments" (RFC 5321 4.1.1.1).
|
|
240
|
+
def parse_extensions(reply)
|
|
241
|
+
reply.lines.drop(1).to_h do |line|
|
|
242
|
+
line.strip.split(" ", 2).then do |keyword, arguments|
|
|
243
|
+
[keyword.to_s.upcase, arguments.to_s]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def encode(string) = [string].pack("m0")
|
|
249
|
+
|
|
250
|
+
def expect(reply, code)
|
|
251
|
+
case reply.code
|
|
252
|
+
when code then reply
|
|
253
|
+
else raise ReplyError, reply
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
__END__
|
|
261
|
+
|
|
262
|
+
require "duplex"
|
|
263
|
+
|
|
264
|
+
# A client whose server has already said everything it is going to say.
|
|
265
|
+
scripted = lambda do |*script|
|
|
266
|
+
stream = Protocol::SMTP::Duplex.new(script.map {|line| "#{line}\r\n"}.join)
|
|
267
|
+
|
|
268
|
+
[Protocol::SMTP::Client.new(stream), stream]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
describe "protocol/smtp/client" do
|
|
272
|
+
it "reads what the server said first, once" do
|
|
273
|
+
client, = scripted.call("220 mail.example.com ESMTP")
|
|
274
|
+
|
|
275
|
+
client.greeting.code.should == 220
|
|
276
|
+
client.greeting.text.should == "mail.example.com ESMTP"
|
|
277
|
+
client.greeting.should.be.identical_to client.greeting
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it "reads a multi-line reply as one reply, and its lines as extensions" do
|
|
281
|
+
client, = scripted.call(
|
|
282
|
+
"220 mail.example.com ESMTP",
|
|
283
|
+
"250-mail.example.com greets client",
|
|
284
|
+
"250-SIZE 35651584",
|
|
285
|
+
"250-AUTH PLAIN LOGIN",
|
|
286
|
+
"250-STARTTLS",
|
|
287
|
+
"250 8BITMIME",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
reply = client.ehlo("client")
|
|
291
|
+
reply.code.should == 250
|
|
292
|
+
reply.lines.length.should == 5
|
|
293
|
+
|
|
294
|
+
client.extensions.keys.should == ["SIZE", "AUTH", "STARTTLS", "8BITMIME"]
|
|
295
|
+
client.should.be.starttls
|
|
296
|
+
client.mechanisms.should == ["PLAIN", "LOGIN"]
|
|
297
|
+
client.maximum_message_size.should == 35_651_584
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it "falls back to HELO for a server that does not know EHLO (RFC 5321 2.2.1)" do
|
|
301
|
+
client, stream = scripted.call("220 mail.example.com ESMTP", "500 Unknown command", "250 mail.example.com")
|
|
302
|
+
|
|
303
|
+
client.hello("client").code.should == 250
|
|
304
|
+
stream.lines.should == ["EHLO client", "HELO client"]
|
|
305
|
+
client.extensions.should == {}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it "refuses to guess at anything that is not a reply" do
|
|
309
|
+
client, = scripted.call("not a reply at all")
|
|
310
|
+
|
|
311
|
+
lambda { client.greeting }.should.raise(Protocol::SMTP::InvalidReplyError)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it "reports a peer that went away mid-reply" do
|
|
315
|
+
client, = scripted.call("250-first")
|
|
316
|
+
|
|
317
|
+
lambda { client.read_reply }.should.raise(Protocol::SMTP::ClosedError)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
it "sends a whole transaction in order, then the body and its terminator" do
|
|
321
|
+
client, stream = scripted.call(
|
|
322
|
+
"220 mail.example.com ESMTP",
|
|
323
|
+
"250-mail.example.com greets client",
|
|
324
|
+
"250 8BITMIME",
|
|
325
|
+
"250 Ok",
|
|
326
|
+
"250 Ok",
|
|
327
|
+
"250 Ok",
|
|
328
|
+
"354 End data with <CR><LF>.<CR><LF>",
|
|
329
|
+
"250 Queued",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
reply = client.deliver(
|
|
333
|
+
from: "me@example.com",
|
|
334
|
+
to: ["one@example.com", "two@example.com"],
|
|
335
|
+
body: "Subject: Hi\r\n\r\nBody\r\n",
|
|
336
|
+
domain: "client",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
reply.code.should == 250
|
|
340
|
+
stream.lines.should == [
|
|
341
|
+
"EHLO client",
|
|
342
|
+
"MAIL FROM:<me@example.com>",
|
|
343
|
+
"RCPT TO:<one@example.com>",
|
|
344
|
+
"RCPT TO:<two@example.com>",
|
|
345
|
+
"DATA",
|
|
346
|
+
"Subject: Hi",
|
|
347
|
+
"",
|
|
348
|
+
"Body",
|
|
349
|
+
".",
|
|
350
|
+
]
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it "runs a transaction on a session that has already introduced itself" do
|
|
354
|
+
client, stream = scripted.call("250 Ok", "250 Ok", "354 Go", "250 Queued")
|
|
355
|
+
|
|
356
|
+
client.transaction(from: "me@example.com", to: "you@example.com", body: "Hi\r\n").code.should == 250
|
|
357
|
+
stream.lines.should == ["MAIL FROM:<me@example.com>", "RCPT TO:<you@example.com>", "DATA", "Hi", "."]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it "stuffs a leading dot so the body cannot end the message (RFC 5321 4.5.2)" do
|
|
361
|
+
client, stream = scripted.call("354 Go ahead", "250 Queued")
|
|
362
|
+
|
|
363
|
+
client.data(".\r\n.hidden\r\ntext\r\n")
|
|
364
|
+
stream.lines.should == ["DATA", "..", "..hidden", "text", "."]
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it "stops a transaction rather than sending a body nobody will take" do
|
|
368
|
+
client, stream = scripted.call(
|
|
369
|
+
"220 mail.example.com ESMTP",
|
|
370
|
+
"250 mail.example.com greets client",
|
|
371
|
+
"250 Ok",
|
|
372
|
+
"550 No such user",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
error = lambda do
|
|
376
|
+
client.deliver(from: "me@example.com", to: "nobody@example.com", body: "Hi", domain: "client")
|
|
377
|
+
end.should.raise(Protocol::SMTP::ReplyError)
|
|
378
|
+
|
|
379
|
+
error.reply.code.should == 550
|
|
380
|
+
stream.lines.should.not.include "DATA"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
it "sends AUTH PLAIN credentials as one base64 blob (RFC 4616)" do
|
|
384
|
+
client, stream = scripted.call("235 Authenticated")
|
|
385
|
+
|
|
386
|
+
client.auth_plain("user", "pass").code.should == 235
|
|
387
|
+
stream.lines.should == ["AUTH PLAIN #{["\0user\0pass"].pack("m0")}"]
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
it "answers each AUTH LOGIN challenge in turn" do
|
|
391
|
+
client, stream = scripted.call("334 VXNlcm5hbWU6", "334 UGFzc3dvcmQ6", "235 Authenticated")
|
|
392
|
+
|
|
393
|
+
client.auth_login("user", "pass").code.should == 235
|
|
394
|
+
stream.lines.should == ["AUTH LOGIN", ["user"].pack("m0"), ["pass"].pack("m0")]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
it "picks a mechanism the server offered" do
|
|
398
|
+
client, stream = scripted.call("220 ESMTP", "250-greets", "250 AUTH LOGIN", "334 x", "334 y", "235 Ok")
|
|
399
|
+
client.ehlo("client")
|
|
400
|
+
|
|
401
|
+
client.authenticate("user", "pass").code.should == 235
|
|
402
|
+
stream.lines.should.include "AUTH LOGIN"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
it "says so rather than sending credentials nothing can carry" do
|
|
406
|
+
client, = scripted.call("220 ESMTP", "250-greets", "250 AUTH GSSAPI")
|
|
407
|
+
client.ehlo("client")
|
|
408
|
+
|
|
409
|
+
lambda { client.authenticate("user", "pass") }.should.raise(Protocol::SMTP::AuthenticationError)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
it "asks for STARTTLS and leaves the upgrade to the caller" do
|
|
413
|
+
client, stream = scripted.call("220 Ready to start TLS")
|
|
414
|
+
|
|
415
|
+
client.starttls.code.should == 220
|
|
416
|
+
stream.lines.should == ["STARTTLS"]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
it "ends the conversation on QUIT but leaves the stream to its owner" do
|
|
420
|
+
client, stream = scripted.call("221 Bye")
|
|
421
|
+
|
|
422
|
+
client.quit.code.should == 221
|
|
423
|
+
client.should.be.closed
|
|
424
|
+
stream.should.not.be.closed
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module Protocol
|
|
6
|
+
module SMTP
|
|
7
|
+
# The line plumbing both sides share: read a line, write a line, know when
|
|
8
|
+
# the conversation is over. Works over any stream that answers
|
|
9
|
+
# #gets(separator, limit), #write, #flush and #close — an IO, an
|
|
10
|
+
# IO::Stream, a StringIO. No sockets and no concurrency live here; that is
|
|
11
|
+
# async-smtp's half.
|
|
12
|
+
class Connection
|
|
13
|
+
CRLF = "\r\n"
|
|
14
|
+
LF = "\n"
|
|
15
|
+
|
|
16
|
+
# RFC 5321 4.5.3.1.4 and 4.5.3.1.6: 512 octets for a command line and
|
|
17
|
+
# 1000 for a data line, both counting the CRLF. The larger covers both,
|
|
18
|
+
# and is counted the way the RFC counts it — terminator included.
|
|
19
|
+
DEFAULT_MAXIMUM_LINE_LENGTH = 1000
|
|
20
|
+
|
|
21
|
+
# @parameter stream [IO | IO::Stream::Buffered | StringIO] The stream to talk over.
|
|
22
|
+
# @parameter maximum_line_length [Integer] Refuse a line longer than this.
|
|
23
|
+
def initialize(stream, maximum_line_length: DEFAULT_MAXIMUM_LINE_LENGTH)
|
|
24
|
+
@stream = stream
|
|
25
|
+
@maximum_line_length = maximum_line_length
|
|
26
|
+
@open = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @attribute [IO | IO::Stream::Buffered | StringIO] The stream in use. It
|
|
30
|
+
# is replaced in place by a TLS upgrade (RFC 3207), which is why it is
|
|
31
|
+
# writable: the conversation continues over the new stream.
|
|
32
|
+
attr_accessor :stream
|
|
33
|
+
|
|
34
|
+
# @attribute [Integer] The longest line this connection will read.
|
|
35
|
+
attr_reader :maximum_line_length
|
|
36
|
+
|
|
37
|
+
# @returns [Boolean] Whether the conversation is over.
|
|
38
|
+
def closed? = !@open
|
|
39
|
+
|
|
40
|
+
# Stop reading after the line in hand, without touching the stream yet:
|
|
41
|
+
# QUIT still has a 221 to write before the socket can go.
|
|
42
|
+
def shutdown
|
|
43
|
+
@open = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Finish the conversation and close the underlying stream.
|
|
47
|
+
def close
|
|
48
|
+
shutdown
|
|
49
|
+
@stream.close
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Every line is CRLF-terminated per the RFC, but reading to the LF and
|
|
53
|
+
# chomping takes either, so a peer that forgets the CR is still served
|
|
54
|
+
# rather than left hanging.
|
|
55
|
+
#
|
|
56
|
+
# @returns [String | Nil] The line, without its terminator, or nil at the
|
|
57
|
+
# end of the stream.
|
|
58
|
+
# @raises [LineLengthError] If the peer sent a line past the limit.
|
|
59
|
+
# @raises [ClosedError] If the peer went away mid-line.
|
|
60
|
+
def read_line
|
|
61
|
+
# gets stops at the limit whether or not the separator turned up, so a
|
|
62
|
+
# line that reached the limit without one is over-long by definition.
|
|
63
|
+
@stream.gets(LF, @maximum_line_length).then do |line|
|
|
64
|
+
case line
|
|
65
|
+
when nil then nil
|
|
66
|
+
else complete(line)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @parameter line [String] The line to write, without its terminator.
|
|
72
|
+
def write_line(line)
|
|
73
|
+
@stream.write("#{line}#{CRLF}")
|
|
74
|
+
@stream.flush
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# A line that came back without its terminator either ran past the
|
|
80
|
+
# limit or the peer went away mid-line; those are different failures.
|
|
81
|
+
def complete(line)
|
|
82
|
+
case
|
|
83
|
+
when line.end_with?(LF) then line.chomp
|
|
84
|
+
when line.bytesize >= @maximum_line_length
|
|
85
|
+
raise LineLengthError, "Line longer than #{@maximum_line_length} bytes!"
|
|
86
|
+
else
|
|
87
|
+
raise ClosedError, "Connection closed mid-line!"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
__END__
|
|
95
|
+
|
|
96
|
+
require "duplex"
|
|
97
|
+
|
|
98
|
+
# A connection over a scripted stream, with a small limit so the length rules
|
|
99
|
+
# are testable without thousand-byte lines.
|
|
100
|
+
open_connection = lambda do |input, limit: 32|
|
|
101
|
+
stream = Protocol::SMTP::Duplex.new(input)
|
|
102
|
+
|
|
103
|
+
[Protocol::SMTP::Connection.new(stream, maximum_line_length: limit), stream]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe "protocol/smtp/connection" do
|
|
107
|
+
it "reads a line without its terminator, CRLF or bare LF" do
|
|
108
|
+
open_connection.call("HELO example.com\r\n").first.read_line.should == "HELO example.com"
|
|
109
|
+
|
|
110
|
+
# A peer that forgets the CR is served rather than left hanging:
|
|
111
|
+
open_connection.call("NOOP\n").first.read_line.should == "NOOP"
|
|
112
|
+
|
|
113
|
+
# And the end of the stream is not a line at all:
|
|
114
|
+
open_connection.call("").first.read_line.should.be.nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "counts the line length limit the way RFC 5321 4.5.3.1 does, terminator included" do
|
|
118
|
+
open_connection.call("#{"x" * 30}\r\n").first.read_line.should == "x" * 30
|
|
119
|
+
|
|
120
|
+
lambda do
|
|
121
|
+
open_connection.call("#{"x" * 31}\r\n").first.read_line
|
|
122
|
+
end.should.raise(Protocol::SMTP::LineLengthError)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "tells an over-long line apart from a peer that went away mid-line" do
|
|
126
|
+
lambda do
|
|
127
|
+
open_connection.call("NOOP").first.read_line
|
|
128
|
+
end.should.raise(Protocol::SMTP::ClosedError)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "writes lines with CRLF" do
|
|
132
|
+
connection, stream = open_connection.call("")
|
|
133
|
+
connection.write_line("250 Ok")
|
|
134
|
+
|
|
135
|
+
stream.output.should == "250 Ok\r\n"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "shuts the conversation down without touching the stream" do
|
|
139
|
+
# QUIT still has a 221 to write before the socket can go:
|
|
140
|
+
connection, stream = open_connection.call("")
|
|
141
|
+
connection.shutdown
|
|
142
|
+
|
|
143
|
+
connection.should.be.closed
|
|
144
|
+
stream.should.not.be.closed
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "closes the stream when asked to" do
|
|
148
|
+
connection, stream = open_connection.call("")
|
|
149
|
+
connection.close
|
|
150
|
+
|
|
151
|
+
stream.should.be.closed
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module SMTP
|
|
5
|
+
# The base class for every error this gem raises.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# The peer sent a line longer than the protocol allows, which means it is
|
|
9
|
+
# not going to stop on its own — the connection is done.
|
|
10
|
+
class LineLengthError < Error; end
|
|
11
|
+
|
|
12
|
+
# The peer went away mid-conversation.
|
|
13
|
+
class ClosedError < Error; end
|
|
14
|
+
|
|
15
|
+
# The peer sent something that is not a reply at all.
|
|
16
|
+
class InvalidReplyError < Error; end
|
|
17
|
+
|
|
18
|
+
# The server answered with a code the client cannot continue from.
|
|
19
|
+
class ReplyError < Error
|
|
20
|
+
# @parameter reply [Reply] The reply that ended the transaction.
|
|
21
|
+
def initialize(reply)
|
|
22
|
+
@reply = reply
|
|
23
|
+
super("Unexpected reply: #{reply}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @attribute [Reply] The reply that ended the transaction.
|
|
27
|
+
attr_reader :reply
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The server offers no authentication mechanism this client implements.
|
|
31
|
+
class AuthenticationError < Error; end
|
|
32
|
+
end
|
|
33
|
+
end
|