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,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module SMTP
|
|
5
|
+
# One message, envelope and all. The envelope (#from, #to) is what the
|
|
6
|
+
# conversation said; the headers are what the data claims. They disagree
|
|
7
|
+
# more often than people expect, so both are here and neither is derived
|
|
8
|
+
# from the other.
|
|
9
|
+
class Message
|
|
10
|
+
# @parameter from [String] The envelope sender; "" is the null sender.
|
|
11
|
+
# @parameter helo [String | Nil] The domain the client introduced itself as.
|
|
12
|
+
# @parameter peer [String | Nil] Where the client connected from.
|
|
13
|
+
# @parameter secure [Boolean] Whether the message arrived over TLS.
|
|
14
|
+
def initialize(from:, helo: nil, peer: nil, secure: false)
|
|
15
|
+
@from = from
|
|
16
|
+
@helo = helo
|
|
17
|
+
@peer = peer
|
|
18
|
+
@secure = secure
|
|
19
|
+
@to = []
|
|
20
|
+
@data = +""
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @attribute [String] The envelope sender.
|
|
24
|
+
attr_reader :from
|
|
25
|
+
|
|
26
|
+
# @attribute [Array(String)] The envelope recipients, in the order given.
|
|
27
|
+
attr_reader :to
|
|
28
|
+
|
|
29
|
+
# @attribute [String] The message as it arrived, headers and all.
|
|
30
|
+
attr_reader :data
|
|
31
|
+
|
|
32
|
+
# @attribute [String | Nil] The domain the client introduced itself as.
|
|
33
|
+
attr_reader :helo
|
|
34
|
+
|
|
35
|
+
# @attribute [String | Nil] Where the client connected from.
|
|
36
|
+
attr_reader :peer
|
|
37
|
+
|
|
38
|
+
# @returns [Boolean] Whether the message arrived over TLS.
|
|
39
|
+
def secure? = @secure
|
|
40
|
+
|
|
41
|
+
# The headers, unfolded and downcased.
|
|
42
|
+
# @returns [Hash(String, String)]
|
|
43
|
+
def headers = @headers ||= parse_headers
|
|
44
|
+
|
|
45
|
+
# @returns [String | Nil]
|
|
46
|
+
def subject = headers["subject"]
|
|
47
|
+
|
|
48
|
+
# Everything after the blank line that ends the headers. A message with
|
|
49
|
+
# no blank line in it is all headers and no body (RFC 5322 2.1), which
|
|
50
|
+
# is also what a truncated one looks like.
|
|
51
|
+
#
|
|
52
|
+
# @returns [String]
|
|
53
|
+
def body = split_at_blank_line[1].to_s
|
|
54
|
+
|
|
55
|
+
# @returns [Integer] The size of the message, in bytes.
|
|
56
|
+
def bytesize = data.bytesize
|
|
57
|
+
|
|
58
|
+
# Enables `in [from, [to, *]]`
|
|
59
|
+
def deconstruct = [from, to]
|
|
60
|
+
|
|
61
|
+
# Enables `in {to: [/@example\.test\z/, *], subject: /urgent/i}`
|
|
62
|
+
def deconstruct_keys(_keys) = { from:, to:, data:, helo:, peer:, headers:, subject:, body: }
|
|
63
|
+
|
|
64
|
+
def inspect = "#<#{self.class} from=#{from.inspect} to=#{to.inspect} #{bytesize}B>"
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# RFC 5322 2.2.3: a header field continues onto any following line
|
|
69
|
+
# that starts with whitespace, so unfold those before splitting each
|
|
70
|
+
# on its first colon. Last value wins for a repeated field — #data is
|
|
71
|
+
# right there for anything that needs more than that.
|
|
72
|
+
def parse_headers
|
|
73
|
+
split_at_blank_line.first.to_s.gsub(/\r?\n[ \t]+/, " ").lines.filter_map do |line|
|
|
74
|
+
case line.split(":", 2)
|
|
75
|
+
in [name, value] then [name.strip.downcase, value.strip]
|
|
76
|
+
else nil
|
|
77
|
+
end
|
|
78
|
+
end.to_h
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def split_at_blank_line = data.split(/\r?\n\r?\n/, 2)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
__END__
|
|
87
|
+
|
|
88
|
+
new_message = lambda do |data, **options|
|
|
89
|
+
Protocol::SMTP::Message.new(from: "me@example.com", helo: "client", peer: "127.0.0.1", **options).tap do |message|
|
|
90
|
+
message.data << data
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe "protocol/smtp/message" do
|
|
95
|
+
it "keeps the envelope separate from what the headers claim" do
|
|
96
|
+
message = new_message.call("From: someone-else@example.com\r\n\r\nHello\r\n")
|
|
97
|
+
message.to << "you@example.com"
|
|
98
|
+
|
|
99
|
+
message.from.should == "me@example.com"
|
|
100
|
+
message.headers["from"].should == "someone-else@example.com"
|
|
101
|
+
message.to.should == ["you@example.com"]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "unfolds a continued header (RFC 5322 2.2.3)" do
|
|
105
|
+
message = new_message.call("Subject: a very\r\n long subject\r\nTo: you@example.com\r\n\r\nBody\r\n")
|
|
106
|
+
|
|
107
|
+
message.subject.should == "a very long subject"
|
|
108
|
+
message.headers["to"].should == "you@example.com"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "separates the body at the blank line" do
|
|
112
|
+
new_message.call("Subject: Hi\r\n\r\nline one\r\nline two\r\n").body.should == "line one\r\nline two\r\n"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "treats a message with no blank line as all headers and no body" do
|
|
116
|
+
# Which is also what a truncated one looks like:
|
|
117
|
+
message = new_message.call("just text\r\n")
|
|
118
|
+
|
|
119
|
+
message.headers.should == {}
|
|
120
|
+
message.body.should == ""
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "reports its size in bytes" do
|
|
124
|
+
new_message.call("\u03a9\r\n").bytesize.should == 4
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "is not secure unless it arrived over TLS" do
|
|
128
|
+
new_message.call("").should.not.be.secure
|
|
129
|
+
new_message.call("", secure: true).should.be.secure
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "deconstructs for pattern matching" do
|
|
133
|
+
message = new_message.call("Subject: URGENT\r\n\r\nnow\r\n")
|
|
134
|
+
message.to << "you@example.test"
|
|
135
|
+
urgent = nil
|
|
136
|
+
|
|
137
|
+
case message
|
|
138
|
+
in {to: [/@example\.test\z/, *], subject: /urgent/i}
|
|
139
|
+
urgent = message.peer
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
urgent.should == "127.0.0.1"
|
|
143
|
+
|
|
144
|
+
case message
|
|
145
|
+
in [from, [recipient]]
|
|
146
|
+
from.should == "me@example.com"
|
|
147
|
+
recipient.should == "you@example.test"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protocol
|
|
4
|
+
module SMTP
|
|
5
|
+
# What the server says back. A reply is a code and one or more lines; on
|
|
6
|
+
# the wire every line but the last is joined to its code by a hyphen
|
|
7
|
+
# rather than a space, which is how the client knows more is coming
|
|
8
|
+
# (RFC 5321 4.2.1).
|
|
9
|
+
class Reply
|
|
10
|
+
CRLF = "\r\n"
|
|
11
|
+
|
|
12
|
+
# The replies the state machine itself sends. Anything an application
|
|
13
|
+
# wants to say is its own Reply.
|
|
14
|
+
def self.ok(text = "Ok") = new(250, text)
|
|
15
|
+
def self.rejected(text = "Message rejected") = new(550, text)
|
|
16
|
+
|
|
17
|
+
# A CRLF in the text would end the line early and let whatever follows
|
|
18
|
+
# it pass for a reply of its own. An application that quotes a subject
|
|
19
|
+
# line or an address back at the client — both of which came from the
|
|
20
|
+
# client — would otherwise be handing it a reply stream to write.
|
|
21
|
+
SEPARATORS = /[\r\n]+/
|
|
22
|
+
|
|
23
|
+
# @parameter code [Integer] The three digit reply code.
|
|
24
|
+
# @parameter lines [String | Array(String)] The text of the reply.
|
|
25
|
+
def initialize(code, lines)
|
|
26
|
+
@code = Integer(code)
|
|
27
|
+
@lines = Array(lines).map {|line| line.to_s.gsub(SEPARATORS, " ")}
|
|
28
|
+
|
|
29
|
+
case @lines
|
|
30
|
+
when [] then @lines = [""]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @attribute [Integer] The three digit reply code.
|
|
35
|
+
attr_reader :code
|
|
36
|
+
|
|
37
|
+
# @attribute [Array(String)] The text of the reply, one entry per line.
|
|
38
|
+
attr_reader :lines
|
|
39
|
+
|
|
40
|
+
# @returns [String] The reply as it goes on the wire, terminator excluded.
|
|
41
|
+
def to_s
|
|
42
|
+
lines[0..-2].map { |line| "#{code}-#{line}" }.push("#{code} #{lines.last}").join(CRLF)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @returns [String] The reply's text, lines joined by a space.
|
|
46
|
+
def text = lines.join(" ")
|
|
47
|
+
|
|
48
|
+
# 2xx and 3xx are the codes that let the conversation continue.
|
|
49
|
+
# @returns [Boolean]
|
|
50
|
+
def positive? = code < 400
|
|
51
|
+
|
|
52
|
+
# A 4xx is worth retrying later; a 5xx is not (RFC 5321 4.2.1).
|
|
53
|
+
# @returns [Boolean]
|
|
54
|
+
def transient? = code >= 400 && code < 500
|
|
55
|
+
|
|
56
|
+
# @returns [Boolean]
|
|
57
|
+
def permanent? = code >= 500
|
|
58
|
+
|
|
59
|
+
# Enables `in [250, [text, *]]`
|
|
60
|
+
def deconstruct = [code, lines]
|
|
61
|
+
|
|
62
|
+
# Enables `in {code: 250..299}`
|
|
63
|
+
def deconstruct_keys(_keys) = { code:, lines: }
|
|
64
|
+
|
|
65
|
+
def ==(other)
|
|
66
|
+
other.is_a?(Reply) && other.code == code && other.lines == lines
|
|
67
|
+
end
|
|
68
|
+
alias eql? ==
|
|
69
|
+
|
|
70
|
+
def hash = [code, lines].hash
|
|
71
|
+
|
|
72
|
+
def inspect = "#<#{self.class} #{code} #{text}>"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
__END__
|
|
78
|
+
|
|
79
|
+
describe "protocol/smtp/reply" do
|
|
80
|
+
it "puts a single line reply on one line" do
|
|
81
|
+
reply = Protocol::SMTP::Reply.new(250, "Ok")
|
|
82
|
+
|
|
83
|
+
reply.to_s.should == "250 Ok"
|
|
84
|
+
reply.should.be.positive
|
|
85
|
+
reply.should.not.be.transient
|
|
86
|
+
reply.should.not.be.permanent
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "joins every line but the last to its code with a hyphen (RFC 5321 4.2.1)" do
|
|
90
|
+
reply = Protocol::SMTP::Reply.new(250, ["greets you", "SIZE 100", "8BITMIME"])
|
|
91
|
+
|
|
92
|
+
reply.to_s.should == "250-greets you\r\n250-SIZE 100\r\n250 8BITMIME"
|
|
93
|
+
reply.text.should == "greets you SIZE 100 8BITMIME"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "still produces a valid line with no text at all" do
|
|
97
|
+
Protocol::SMTP::Reply.new(220, nil).to_s.should == "220 "
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "refuses to let the text end the line" do
|
|
101
|
+
# Whatever an application quotes back at a client came from that client:
|
|
102
|
+
reply = Protocol::SMTP::Reply.ok("Queued \r\n550 Injected")
|
|
103
|
+
|
|
104
|
+
reply.to_s.should == "250 Queued 550 Injected"
|
|
105
|
+
reply.lines.length.should == 1
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "classifies 4xx as transient and 5xx as permanent" do
|
|
109
|
+
Protocol::SMTP::Reply.new(451, "Try later").should.be.transient
|
|
110
|
+
Protocol::SMTP::Reply.new(550, "No").should.be.permanent
|
|
111
|
+
Protocol::SMTP::Reply.new(550, "No").should.not.be.positive
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "compares by code and lines" do
|
|
115
|
+
Protocol::SMTP::Reply.ok.should == Protocol::SMTP::Reply.new(250, "Ok")
|
|
116
|
+
Protocol::SMTP::Reply.ok.should.not == Protocol::SMTP::Reply.new(250, "Fine")
|
|
117
|
+
{Protocol::SMTP::Reply.ok => true}[Protocol::SMTP::Reply.new(250, "Ok")].should.be.true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "deconstructs for pattern matching" do
|
|
121
|
+
matched = nil
|
|
122
|
+
|
|
123
|
+
case Protocol::SMTP::Reply.rejected("Spam")
|
|
124
|
+
in {code: 500.., lines: [text]}
|
|
125
|
+
matched = text
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
matched.should == "Spam"
|
|
129
|
+
Protocol::SMTP::Reply.ok.deconstruct.should == [250, ["Ok"]]
|
|
130
|
+
end
|
|
131
|
+
end
|