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.
@@ -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