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,544 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connection"
4
+ require_relative "message"
5
+ require_relative "reply"
6
+
7
+ module Protocol
8
+ module SMTP
9
+ # The server side of an SMTP conversation: RFC 5321's command/reply state
10
+ # machine over a stream.
11
+ #
12
+ # server.write_greeting
13
+ #
14
+ # while message = server.read_message
15
+ # server.write_reply(Protocol::SMTP::Reply.ok("queued"))
16
+ # end
17
+ #
18
+ # #read_message answers every command the protocol itself owns and hands
19
+ # back each complete message; the reply to the message is the caller's to
20
+ # write. Who drives that loop, what an application is allowed to return,
21
+ # and when the stream is closed are all somebody else's business —
22
+ # async-smtp's, for a socket on a reactor.
23
+ class Server < Connection
24
+ DEFAULT_MAXIMUM_MESSAGE_SIZE = 20 * 1024 * 1024
25
+
26
+ # @parameter stream [IO | IO::Stream::Buffered | StringIO] The stream to talk over.
27
+ # @parameter domain [String] The domain this server announces itself as.
28
+ # @parameter peer [String | Nil] Where the client connected from, for the message.
29
+ # @parameter maximum_message_size [Integer] Refuse a message larger than this.
30
+ # @parameter starttls [Proc | Nil] Given the current stream, returns an
31
+ # encrypted one. Advertises STARTTLS when present (RFC 3207).
32
+ def initialize(
33
+ stream,
34
+ domain: "localhost",
35
+ peer: nil,
36
+ maximum_message_size: DEFAULT_MAXIMUM_MESSAGE_SIZE,
37
+ starttls: nil,
38
+ **options
39
+ )
40
+ super(stream, **options)
41
+
42
+ @domain = domain
43
+ @peer = peer
44
+ @maximum_message_size = maximum_message_size
45
+ @starttls = starttls
46
+ @secure = false
47
+ @state = :command
48
+ @helo = nil
49
+ @message = nil
50
+ end
51
+
52
+ # @attribute [String] The domain this server announces itself as.
53
+ attr_reader :domain
54
+
55
+ # @attribute [String | Nil] Where the client connected from.
56
+ attr_reader :peer
57
+
58
+ # @attribute [String | Nil] The domain the client introduced itself as.
59
+ attr_reader :helo
60
+
61
+ # @attribute [Message | Nil] The transaction in progress, if any.
62
+ attr_reader :message
63
+
64
+ # @attribute [Integer] The largest message this server will accept.
65
+ attr_reader :maximum_message_size
66
+
67
+ # @returns [Boolean] Whether the connection was upgraded to TLS.
68
+ def secure? = @secure
69
+
70
+ # In SMTP the server talks first.
71
+ #
72
+ # @parameter reply [Reply] What to greet the client with.
73
+ def write_greeting(reply = Reply.new(220, "#{@domain} ESMTP"))
74
+ write_reply(reply)
75
+ end
76
+
77
+ # Read lines, answering each command the state machine owns, until
78
+ # either a message is complete or the client is finished.
79
+ #
80
+ # @returns [Message | Nil] The next complete message, or nil when the
81
+ # client quit or the stream ended.
82
+ def read_message
83
+ message = nil
84
+
85
+ while message.nil? && !closed? && (line = read_line)
86
+ receive(line).then do |result|
87
+ case result
88
+ when Message then message = result
89
+ when Reply then write_reply(result)
90
+ end
91
+ end
92
+ end
93
+
94
+ message
95
+ end
96
+
97
+ # The reply to a message, which is the one reply in the conversation the
98
+ # protocol has no opinion about.
99
+ #
100
+ # @parameter reply [Reply | Nil] Nil says nothing, for a caller that has
101
+ # already answered.
102
+ def write_reply(reply)
103
+ case reply
104
+ when nil then nil
105
+ else write_line(reply.to_s)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def receive(line)
112
+ case @state
113
+ when :data then collect(line)
114
+ when :discard then discard(line)
115
+ else dispatch(line)
116
+ end
117
+ end
118
+
119
+ def dispatch(line)
120
+ line.partition(" ").then do |verb, _, rest|
121
+ command(verb.upcase, rest.strip)
122
+ end
123
+ end
124
+
125
+ # RFC 5321 4.3.2 is a table of which command may follow which; these
126
+ # are its rows.
127
+ def command(verb, argument)
128
+ case [verb, argument]
129
+ in ["EHLO", domain] then process_ehlo(domain)
130
+ in ["HELO", domain] then process_helo(domain)
131
+ in ["STARTTLS", _] then process_starttls
132
+ in ["MAIL", /\AFROM:/i => arg] then mail_from(address(arg))
133
+ in ["MAIL", _] then Reply.new(501, "Syntax: MAIL FROM:<address>")
134
+ in ["RCPT", /\ATO:/i => arg] then rcpt_to(address(arg))
135
+ in ["RCPT", _] then Reply.new(501, "Syntax: RCPT TO:<address>")
136
+ in ["DATA", _] then data
137
+ in ["RSET", _] then rset
138
+ in ["NOOP", _] then Reply.ok
139
+ in ["QUIT", _] then quit
140
+ in ["AUTH" | "VRFY" | "EXPN" | "HELP", _] then Reply.new(502, "Command not implemented")
141
+ else Reply.new(500, "Unknown command")
142
+ end
143
+ end
144
+
145
+ def process_ehlo(domain)
146
+ case domain
147
+ when "" then Reply.new(501, "Syntax: EHLO domain")
148
+ else
149
+ reset(domain)
150
+ Reply.new(250, ["#{@domain} greets #{domain}", *advertise])
151
+ end
152
+ end
153
+
154
+ # SIZE (RFC 1870) so a client can give up before sending, 8BITMIME
155
+ # (RFC 6152) because #data is bytes either way, and STARTTLS
156
+ # (RFC 3207) only while an upgrade is actually possible.
157
+ def advertise
158
+ ["SIZE #{@maximum_message_size}", "8BITMIME"].tap do |extensions|
159
+ case !@secure && !@starttls.nil?
160
+ when true then extensions << "STARTTLS"
161
+ end
162
+ end
163
+ end
164
+
165
+ def process_helo(domain)
166
+ case domain
167
+ when "" then Reply.new(501, "Syntax: HELO domain")
168
+ else
169
+ reset(domain)
170
+ Reply.new(250, @domain)
171
+ end
172
+ end
173
+
174
+ # RFC 3207 4.2: the 220 goes out in the clear, the handshake happens
175
+ # over the bare stream, and everything the client said before it is
176
+ # forgotten — it has to EHLO again.
177
+ def process_starttls
178
+ case @starttls
179
+ when nil then Reply.new(454, "TLS not available")
180
+ else
181
+ write_reply(Reply.new(220, "Ready to start TLS"))
182
+ @stream = @starttls.call(@stream)
183
+ @secure = true
184
+ reset(nil)
185
+ nil
186
+ end
187
+ end
188
+
189
+ # RFC 5321 4.1.1.2: MAIL FROM begins a transaction and clears the
190
+ # buffers, so a second one mid-transaction replaces the first rather
191
+ # than erroring — which is what a client that retries expects.
192
+ def mail_from(from)
193
+ case @helo
194
+ when nil then Reply.new(503, "HELO/EHLO first")
195
+ else
196
+ @message = Message.new(from: from, helo: @helo, peer: @peer, secure: @secure)
197
+ Reply.ok
198
+ end
199
+ end
200
+
201
+ def rcpt_to(to)
202
+ case @message
203
+ when nil then Reply.new(503, "MAIL is required before RCPT")
204
+ else
205
+ @message.to << to
206
+ Reply.ok
207
+ end
208
+ end
209
+
210
+ def data
211
+ case @message&.to
212
+ in nil | [] then Reply.new(503, "RCPT is required before DATA")
213
+ else
214
+ @state = :data
215
+ Reply.new(354, "End data with <CR><LF>.<CR><LF>")
216
+ end
217
+ end
218
+
219
+ def rset
220
+ reset(@helo)
221
+ Reply.ok
222
+ end
223
+
224
+ def quit
225
+ shutdown
226
+ Reply.new(221, "Bye")
227
+ end
228
+
229
+ # RFC 5321 4.5.2: a line of a single dot ends the message, and a
230
+ # leading dot on any other line was stuffed by the client on the way
231
+ # out — it comes back off here.
232
+ def collect(line)
233
+ case line
234
+ when "." then finish
235
+ else append(line)
236
+ end
237
+ end
238
+
239
+ # nil while the message is still coming: mid-DATA there is nothing to
240
+ # say. Past the limit there is nothing to say either — the client is
241
+ # still sending, and reading its body as commands would answer every
242
+ # line of it with a 500. Drop the rest and hold the 552 until the
243
+ # terminating dot (RFC 1870 6.2).
244
+ def append(line)
245
+ @message.data << line.sub(/\A\./, "") << CRLF
246
+
247
+ case @message.bytesize > @maximum_message_size
248
+ when true then @state = :discard
249
+ end
250
+
251
+ nil
252
+ end
253
+
254
+ def discard(line)
255
+ case line
256
+ when "."
257
+ reset(@helo)
258
+ Reply.new(552, "Message exceeds #{@maximum_message_size} bytes")
259
+ end
260
+ end
261
+
262
+ # The message, with the transaction it arrived in wound up: the reply
263
+ # to it is written later, by whoever asked for it.
264
+ def finish
265
+ @message.tap do
266
+ reset(@helo)
267
+ end
268
+ end
269
+
270
+ def reset(helo)
271
+ @helo = helo
272
+ @message = nil
273
+ @state = :command
274
+ end
275
+
276
+ # "FROM:<me@example.com> SIZE=42" -> "me@example.com". The SIZE
277
+ # parameter is there because EHLO advertised the extension; an address
278
+ # has no spaces in it, so the first token after the verb is all of it.
279
+ # A null sender (<>) lands here as "".
280
+ def address(argument)
281
+ argument.sub(/\A[A-Za-z]+:\s*/, "").split(/\s/).first.to_s.delete("<>")
282
+ end
283
+ end
284
+ end
285
+ end
286
+
287
+ __END__
288
+
289
+ require "duplex"
290
+
291
+ # Drive a scripted conversation to its end — the loop async-smtp runs — and
292
+ # hand back the stream it happened over, plus the messages it produced.
293
+ converse = lambda do |*script, reply: Protocol::SMTP::Reply.ok("queued"), **options|
294
+ stream = Protocol::SMTP::Duplex.new(script.map {|line| "#{line}\r\n"}.join)
295
+ server = Protocol::SMTP::Server.new(stream, domain: "mail.example.com", **options)
296
+ messages = []
297
+
298
+ server.write_greeting
299
+
300
+ while message = server.read_message
301
+ messages << message
302
+ server.write_reply(reply)
303
+ end
304
+
305
+ [stream, messages, server]
306
+ end
307
+
308
+ describe "protocol/smtp/server" do
309
+ it "answers each command of a transaction in order, and hands over one message" do
310
+ stream, messages = converse.call(
311
+ "EHLO client.example.com",
312
+ "MAIL FROM:<me@example.com>",
313
+ "RCPT TO:<you@example.com>",
314
+ "DATA",
315
+ "Subject: Hello",
316
+ "",
317
+ "Body text",
318
+ ".",
319
+ "QUIT",
320
+ )
321
+
322
+ stream.codes.should == [220, 250, 250, 250, 354, 250, 221]
323
+
324
+ messages.length.should == 1
325
+ messages.first.from.should == "me@example.com"
326
+ messages.first.to.should == ["you@example.com"]
327
+ messages.first.subject.should == "Hello"
328
+ messages.first.body.should == "Body text\r\n"
329
+ messages.first.helo.should == "client.example.com"
330
+ end
331
+
332
+ it "leaves the stream open for whoever owns it to close" do
333
+ stream, = converse.call("QUIT")
334
+
335
+ stream.should.not.be.closed
336
+ end
337
+
338
+ it "advertises its extensions on EHLO" do
339
+ stream, = converse.call("EHLO client.example.com")
340
+
341
+ stream.lines.should == [
342
+ "220 mail.example.com ESMTP",
343
+ "250-mail.example.com greets client.example.com",
344
+ "250-SIZE #{Protocol::SMTP::Server::DEFAULT_MAXIMUM_MESSAGE_SIZE}",
345
+ "250 8BITMIME",
346
+ ]
347
+ end
348
+
349
+ it "treats a greeting with no domain as a syntax error" do
350
+ converse.call("EHLO", "HELO").first.codes.should == [220, 501, 501]
351
+ end
352
+
353
+ it "refuses each command until its turn (RFC 5321 4.3.2)" do
354
+ stream, = converse.call(
355
+ "MAIL FROM:<me@example.com>",
356
+ "EHLO client",
357
+ "RCPT TO:<you@example.com>",
358
+ "DATA",
359
+ "MAIL FROM:<me@example.com>",
360
+ "DATA",
361
+ )
362
+
363
+ stream.codes.should == [220, 503, 250, 503, 503, 250, 503]
364
+ end
365
+
366
+ it "answers an unknown or unimplemented command without ending the conversation" do
367
+ stream, = converse.call("WHAT", "VRFY someone", "EXPN list", "HELP", "AUTH PLAIN abc", "NOOP", "MAIL", "RCPT")
368
+
369
+ stream.codes.should == [220, 500, 502, 502, 502, 502, 250, 501, 501]
370
+ end
371
+
372
+ it "abandons the transaction on RSET but keeps the greeting" do
373
+ stream, messages = converse.call(
374
+ "HELO client",
375
+ "MAIL FROM:<me@example.com>",
376
+ "RSET",
377
+ "RCPT TO:<you@example.com>",
378
+ "MAIL FROM:<other@example.com>",
379
+ "RCPT TO:<you@example.com>",
380
+ "DATA",
381
+ ".",
382
+ )
383
+
384
+ stream.codes.should == [220, 250, 250, 250, 503, 250, 250, 354, 250]
385
+ messages.first.from.should == "other@example.com"
386
+ messages.first.helo.should == "client"
387
+ end
388
+
389
+ it "starts the transaction over on a re-issued MAIL FROM (RFC 5321 4.1.1.2)" do
390
+ stream, messages = converse.call(
391
+ "HELO client",
392
+ "MAIL FROM:<first@example.com>",
393
+ "RCPT TO:<you@example.com>",
394
+ "MAIL FROM:<second@example.com>",
395
+ "RCPT TO:<other@example.com>",
396
+ "DATA",
397
+ ".",
398
+ )
399
+
400
+ stream.codes.should == [220, 250, 250, 250, 250, 250, 354, 250]
401
+ messages.first.from.should == "second@example.com"
402
+ messages.first.to.should == ["other@example.com"]
403
+ end
404
+
405
+ it "takes the address out of a command and ignores its parameters" do
406
+ _, messages = converse.call(
407
+ "HELO client",
408
+ "MAIL FROM:<me@example.com> SIZE=42 BODY=8BITMIME",
409
+ "RCPT TO:<one@example.com> NOTIFY=NEVER",
410
+ "RCPT TO:<two@example.com>",
411
+ "DATA",
412
+ ".",
413
+ )
414
+
415
+ messages.first.from.should == "me@example.com"
416
+ messages.first.to.should == ["one@example.com", "two@example.com"]
417
+ end
418
+
419
+ it "accepts a null sender, as a bounce requires" do
420
+ stream, messages = converse.call("HELO client", "MAIL FROM:<>", "RCPT TO:<you@example.com>", "DATA", ".")
421
+
422
+ stream.codes.should == [220, 250, 250, 250, 354, 250]
423
+ messages.first.from.should == ""
424
+ end
425
+
426
+ it "treats the verb as case insensitive (RFC 5321 2.4)" do
427
+ stream, = converse.call("ehlo client", "mail from:<me@example.com>", "Rcpt To:<you@example.com>", "data", ".")
428
+
429
+ stream.codes.should == [220, 250, 250, 250, 354, 250]
430
+ end
431
+
432
+ it "unstuffs a leading dot from the body (RFC 5321 4.5.2)" do
433
+ _, messages = converse.call(
434
+ "HELO client",
435
+ "MAIL FROM:<me@example.com>",
436
+ "RCPT TO:<you@example.com>",
437
+ "DATA",
438
+ "..hidden",
439
+ "...two",
440
+ "regular",
441
+ ".",
442
+ )
443
+
444
+ messages.first.data.should == ".hidden\r\n..two\r\nregular\r\n"
445
+ end
446
+
447
+ it "keeps reading an over-sized body and refuses it at the terminating dot" do
448
+ # Answering mid-DATA would reply to the rest of the message as if it were
449
+ # commands (RFC 1870 6.2):
450
+ stream, messages = converse.call(
451
+ "HELO client",
452
+ "MAIL FROM:<me@example.com>",
453
+ "RCPT TO:<you@example.com>",
454
+ "DATA",
455
+ "x" * 100,
456
+ "MAIL FROM:<not-a-command@example.com>",
457
+ ".",
458
+ "NOOP",
459
+ maximum_message_size: 64,
460
+ )
461
+
462
+ stream.codes.should == [220, 250, 250, 250, 354, 552, 250]
463
+ messages.should.be.empty
464
+ end
465
+
466
+ it "advertises STARTTLS, upgrades the stream, and forgets the transaction" do
467
+ upgraded = Protocol::SMTP::Duplex.new("EHLO client\r\nQUIT\r\n")
468
+
469
+ stream, _, server = converse.call(
470
+ "EHLO client",
471
+ "MAIL FROM:<me@example.com>",
472
+ "STARTTLS",
473
+ starttls: proc {upgraded},
474
+ )
475
+
476
+ stream.lines.should == [
477
+ "220 mail.example.com ESMTP",
478
+ "250-mail.example.com greets client",
479
+ "250-SIZE #{Protocol::SMTP::Server::DEFAULT_MAXIMUM_MESSAGE_SIZE}",
480
+ "250-8BITMIME",
481
+ "250 STARTTLS",
482
+ "250 Ok",
483
+ "220 Ready to start TLS",
484
+ ]
485
+
486
+ # Everything after the upgrade went over the new stream, which no longer
487
+ # offers STARTTLS, and the client had to introduce itself again:
488
+ upgraded.lines.should == [
489
+ "250-mail.example.com greets client",
490
+ "250-SIZE #{Protocol::SMTP::Server::DEFAULT_MAXIMUM_MESSAGE_SIZE}",
491
+ "250 8BITMIME",
492
+ "221 Bye",
493
+ ]
494
+
495
+ server.should.be.secure
496
+ end
497
+
498
+ it "neither advertises nor allows TLS it cannot do" do
499
+ stream, = converse.call("EHLO client", "STARTTLS")
500
+
501
+ stream.codes.should == [220, 250, 454]
502
+ stream.lines.should.not.include "250 STARTTLS"
503
+ end
504
+
505
+ it "ends the conversation at the end of the stream" do
506
+ converse.call("HELO client", "MAIL FROM:<me@example.com>").first.codes.should == [220, 250, 250]
507
+ end
508
+
509
+ it "refuses an over-long command line rather than guessing" do
510
+ stream = Protocol::SMTP::Duplex.new("HELO #{"x" * 100}\r\n")
511
+ server = Protocol::SMTP::Server.new(stream, maximum_line_length: 32)
512
+ server.write_greeting
513
+
514
+ lambda { server.read_message }.should.raise(Protocol::SMTP::LineLengthError)
515
+ end
516
+
517
+ it "writes exactly the reply the caller answered with" do
518
+ stream, = converse.call(
519
+ "HELO client",
520
+ "MAIL FROM:<me@example.com>",
521
+ "RCPT TO:<you@example.com>",
522
+ "DATA",
523
+ ".",
524
+ reply: Protocol::SMTP::Reply.rejected("Spam"),
525
+ )
526
+
527
+ stream.lines.last.should == "550 Spam"
528
+ end
529
+
530
+ it "says nothing for a caller with nothing to say" do
531
+ # The reply to a message is not the protocol's to invent:
532
+ stream, = converse.call(
533
+ "HELO client",
534
+ "MAIL FROM:<me@example.com>",
535
+ "RCPT TO:<you@example.com>",
536
+ "DATA",
537
+ ".",
538
+ "NOOP",
539
+ reply: nil,
540
+ )
541
+
542
+ stream.codes.should == [220, 250, 250, 250, 354, 250]
543
+ end
544
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module SMTP
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "smtp/version"
4
+
5
+ require_relative "smtp/client"
6
+ require_relative "smtp/connection"
7
+ require_relative "smtp/error"
8
+ require_relative "smtp/message"
9
+ require_relative "smtp/reply"
10
+ require_relative "smtp/server"
11
+
12
+ # @namespace
13
+ module Protocol
14
+ # Abstractions for the SMTP protocol: the command/reply state machine
15
+ # (RFC 5321) and the message it assembles (RFC 5322), for both sides of the
16
+ # conversation. No sockets and no concurrency — async-smtp
17
+ # binds this to a real endpoint.
18
+ #
19
+ # @namespace
20
+ module SMTP
21
+ end
22
+ end