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,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,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
|