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