async-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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40a65a5dc56580cfe74ada5400b51f0c8f6dbf376a92b34ca6a44f4ba7136038
4
+ data.tar.gz: 0564f70b16bf1e69f36f006f4879c7ae107424bbd19e1e5a7bfb72cd222e92a0
5
+ SHA512:
6
+ metadata.gz: 1c2f7535cc990b79ef155ddbb5ef9e4dc590e4d17e70493048b48cc0fed059e9ed08e24cde824ff9d814d20469a2ae4a291f8400760d50f88d5e0fdcead42e74
7
+ data.tar.gz: 3325618dd0e94245c794472d95e2284be9580aca35c1e370554e3c79be293b7168a0e801b34b5cb43f1dab0836f92b1ae6d17393c5876bc9fe7f25f8d51738fb
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to async-smtp are documented in this file. The format
4
+ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-09-13
9
+
10
+ ### Added
11
+
12
+ - `Async::SMTP::Server`: accepts on an `IO::Endpoint`, one task per
13
+ connection, and drives the `Protocol::SMTP::Server` conversation — the
14
+ greeting, each message to the handler, its reply back, and the socket closed
15
+ at the end. A handler may answer with a `Reply` or a `String`; nothing, or a
16
+ raised exception, becomes a `451` and the connection carries on.
17
+ - `Async::SMTP::Client`: sets the session up once — greeting, `EHLO`,
18
+ `STARTTLS`, `EHLO` again — and runs each `#deliver` as one transaction on
19
+ it, with `#authenticate` for submission.
20
+ - `STARTTLS` (RFC 3207) on both sides, reusing the endpoint's own TLS
21
+ configuration rather than inventing a second way to spell it.
22
+ - `Async::SMTP::Endpoint`: `.for(host, port, secure:)` and
23
+ `.parse("smtps://host")`, which knows the `smtp`, `submission` and `smtps`
24
+ schemes and their ports.
data/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # async-smtp
2
+
3
+ An asynchronous SMTP server and client: [protocol-smtp](../protocol-smtp)'s
4
+ state machine bound to an endpoint and run on the [async][async] reactor, one
5
+ task per connection. What async-http is to protocol-http.
6
+
7
+ [async]: https://github.com/socketry/async
8
+
9
+ ## Server
10
+
11
+ ```ruby
12
+ require "async/smtp"
13
+
14
+ Async do
15
+ endpoint = Async::SMTP::Endpoint.for("127.0.0.1", 1025)
16
+
17
+ Async::SMTP::Server.for(endpoint, domain: "mail.example.com") do |message|
18
+ $stderr.puts "#{message.from} -> #{message.to.join(', ')} (#{message.bytesize} bytes)"
19
+ Protocol::SMTP::Reply.ok("queued")
20
+ end.run
21
+ end
22
+ ```
23
+
24
+ The handler is called with a `Protocol::SMTP::Message` and answers with a
25
+ `Protocol::SMTP::Reply`, or a `String` for the text of a `250`. Answering with
26
+ nothing, or raising, gets the client a `451` and the failure a log entry — the
27
+ connection survives either way. One task per connection: a slow handler holds
28
+ up its own client and nobody else's.
29
+
30
+ protocol-smtp owns the conversation; what lives here is the socket, the task,
31
+ the loop that drives `read_message`, the application at the end of it, and
32
+ closing the socket afterwards.
33
+
34
+ ## Client
35
+
36
+ ```ruby
37
+ Async do
38
+ Async::SMTP::Client.open(Async::SMTP::Endpoint.for("127.0.0.1", 1025)) do |client|
39
+ client.deliver(
40
+ from: "me@example.com",
41
+ to: "you@example.com",
42
+ body: "Subject: Hello\r\n\r\nHi.\r\n",
43
+ )
44
+ end
45
+ end
46
+ ```
47
+
48
+ One connection per client: SMTP transactions are stateful, so two senders
49
+ sharing a socket would interleave their envelopes. The session is set up once
50
+ — greeting, `EHLO`, `STARTTLS`, `EHLO` again — and each `#deliver` after that
51
+ is one transaction on it.
52
+
53
+ ## TLS
54
+
55
+ `STARTTLS` is taken up whenever the server offers it, so submission to a real
56
+ provider is the same two calls plus credentials:
57
+
58
+ ```ruby
59
+ endpoint = Async::SMTP::Endpoint.for("smtp.example.com", Async::SMTP::Endpoint::SUBMISSION_PORT)
60
+
61
+ Async::SMTP::Client.open(endpoint) do |client|
62
+ raise "in the clear!" unless client.secure?
63
+
64
+ client.authenticate(username, password)
65
+ client.deliver(from: from, to: to, body: body)
66
+ end
67
+ ```
68
+
69
+ Pass `starttls: false` to stay in the clear, or `ssl_context:` to verify
70
+ against something other than the system store. Implicit TLS (port 465) is a
71
+ property of the endpoint instead — `Endpoint.parse("smtps://...")` — and needs
72
+ no upgrade.
73
+
74
+ A server offers `STARTTLS` only when given a context to offer it with:
75
+
76
+ ```ruby
77
+ Async::SMTP::Server.for(endpoint, ssl_context: context, &handler).run
78
+ ```
79
+
80
+ Both halves discard everything said before the handshake, as RFC 3207 4.2
81
+ requires: the client re-`EHLO`s, and the server forgets any transaction in
82
+ progress.
83
+
84
+ ## Endpoints
85
+
86
+ `Async::SMTP::Endpoint.for(host, port = 25)` and
87
+ `Async::SMTP::Endpoint.parse("smtps://mail.example.com")` both return an
88
+ `IO::Endpoint`, so anything that ecosystem already does — binding a socket
89
+ ahead of time, wrapping it in SSL, listing several addresses — works here
90
+ unchanged. `parse` knows the three schemes that matter: `smtp` (25),
91
+ `submission` (587) and `smtps` (465, encrypted from the first byte).
92
+
93
+ ## Workers
94
+
95
+ `Server#run` returns the `Async::Task` it is running in, so the usual
96
+ `async-container` pattern applies if you want more than one process accepting
97
+ from the same socket:
98
+
99
+ ```ruby
100
+ bound = Async::SMTP::Endpoint.for("0.0.0.0", 25).bound
101
+
102
+ Async::Container.best_container_class.new.run(count: 4) do
103
+ Async { Async::SMTP::Server.for(bound, &handler).run }
104
+ end
105
+ ```
106
+
107
+ ## What it does not do
108
+
109
+ No server-side `AUTH`, no connection pool (a pool of stateful transactions is
110
+ not a pool), no queueing or retries. Those belong to the mail system built on
111
+ top, not to the transport.
112
+
113
+ ## Tests
114
+
115
+ They live in the `__END__` section of the file they test, and run with
116
+ [scampi](https://rubygems.org/gems/scampi). The ones that matter here go over
117
+ a real socket, TLS handshake included:
118
+
119
+ ``` shell
120
+ bin/test # everything
121
+ bin/test lib/async/smtp/client.rb # one file
122
+ ```
123
+
124
+ ## Releasing
125
+
126
+ Inside the devshell, and via `bundle exec` so the library actually loads and
127
+ its deprecations get scanned:
128
+
129
+ ``` shell
130
+ bin/test # 1. green suite
131
+ bundle exec gem kit bump minor # 2. bump the version
132
+ bundle exec gem kit changelog --write # 3. write the entry
133
+ git commit -am "Release ..." # 4. the bump and the entry, one commit
134
+ bundle exec gem kit release # 5. gates, build, push
135
+ bundle exec gem kit tag --push # 6. tag it
136
+ ```
137
+
138
+ Steps 2 and 5 are gates: a deprecation due at the new version, or a missing
139
+ changelog section, stops them.
140
+
141
+ ## License
142
+
143
+ MIT.
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "console"
4
+ require "io/endpoint/ssl_endpoint"
5
+ require "io/stream"
6
+ require "protocol/smtp/client"
7
+
8
+ require_relative "endpoint"
9
+
10
+ module Async
11
+ module SMTP
12
+ # Connects to an endpoint and hands you the conversation.
13
+ #
14
+ # Async do
15
+ # Async::SMTP::Client.open(Async::SMTP::Endpoint.parse("smtp://localhost:1025")) do |client|
16
+ # client.deliver(from: "me@example.com", to: "you@example.com", body: message)
17
+ # end
18
+ # end
19
+ #
20
+ # One connection per client, because SMTP transactions are stateful: two
21
+ # senders sharing a socket would interleave their envelopes. The session
22
+ # is set up once — greeting, EHLO, STARTTLS, EHLO again — and every
23
+ # message after that is one transaction on it.
24
+ class Client
25
+ # Connect, yield the client, and close it afterwards.
26
+ #
27
+ # @parameter endpoint [IO::Endpoint::Generic] Where to connect.
28
+ # @parameter options [Hash] Passed to {initialize}.
29
+ # @yields {|client| ...}
30
+ # @parameter client [Client]
31
+ def self.open(endpoint, **options)
32
+ new(endpoint, **options).then do |client|
33
+ begin
34
+ yield(client)
35
+ ensure
36
+ client.close
37
+ end
38
+ end
39
+ end
40
+
41
+ # @parameter endpoint [IO::Endpoint::Generic] Where to connect.
42
+ # @parameter domain [String] The domain to introduce ourselves as.
43
+ # @parameter starttls [Boolean] Upgrade the connection when the server
44
+ # offers it (RFC 3207). On by default: a submission server that
45
+ # advertises it expects to be taken up on it.
46
+ # @parameter ssl_context [OpenSSL::SSL::SSLContext | Nil] The context for
47
+ # that upgrade; the endpoint's own TLS options are used otherwise.
48
+ # @parameter options [Hash] Passed to Protocol::SMTP::Client.
49
+ def initialize(endpoint, domain: "localhost", starttls: true, ssl_context: nil, **options)
50
+ @endpoint = endpoint
51
+ @domain = domain
52
+ @starttls = starttls
53
+ @ssl_context = ssl_context
54
+ @options = options
55
+
56
+ @peer = nil
57
+ @connection = nil
58
+ end
59
+
60
+ # @attribute [IO::Endpoint::Generic] Where this client connects.
61
+ attr_reader :endpoint
62
+
63
+ # @attribute [String] The domain this client introduces itself as.
64
+ attr_reader :domain
65
+
66
+ # The session, connected and introduced. Idempotent: every call after the
67
+ # first returns the same conversation.
68
+ #
69
+ # @returns [Protocol::SMTP::Client]
70
+ def connection
71
+ @connection ||= start
72
+ end
73
+
74
+ # Whether the conversation is encrypted, which is only knowable once
75
+ # there is a conversation — so this connects if nothing else has yet.
76
+ #
77
+ # @returns [Boolean]
78
+ def secure?
79
+ connection
80
+
81
+ @secure == true
82
+ end
83
+
84
+ # What the server's EHLO advertised.
85
+ # @returns [Hash(String, String)]
86
+ def extensions = connection.extensions
87
+
88
+ # @parameter username [String]
89
+ # @parameter password [String]
90
+ # @returns [Protocol::SMTP::Reply]
91
+ # @raises [Protocol::SMTP::AuthenticationError] If nothing offered is implemented.
92
+ def authenticate(username, password)
93
+ connection.authenticate(username, password)
94
+ end
95
+
96
+ # Send one message. The session is reused, so several calls are several
97
+ # transactions on one connection, which is what an SMTP server expects.
98
+ #
99
+ # @parameter from [String] The envelope sender.
100
+ # @parameter to [String | Array(String)] The envelope recipients.
101
+ # @parameter body [String] The message, headers and all.
102
+ # @returns [Protocol::SMTP::Reply] The reply to the message itself.
103
+ # @raises [Protocol::SMTP::ReplyError] If the transaction was refused.
104
+ def deliver(from:, to:, body:)
105
+ connection.transaction(from: from, to: to, body: body)
106
+ end
107
+
108
+ # Say QUIT, if there is anyone to say it to, and close the socket.
109
+ # protocol-smtp never closes a stream it did not open, so that half is
110
+ # here too.
111
+ def close
112
+ quit
113
+ disconnect
114
+ end
115
+
116
+ private
117
+
118
+ def quit
119
+ @connection&.quit
120
+ rescue ::Protocol::SMTP::Error, IOError, SystemCallError => error
121
+ # The server hung up before its 221; there is nothing left to say,
122
+ # but the socket is still ours to close.
123
+ Console.debug(self) {"Connection ended before QUIT: #{error.message}"}
124
+ end
125
+
126
+ def disconnect
127
+ @connection&.close
128
+ rescue IOError, SystemCallError => error
129
+ Console.debug(self) {"Connection closed abruptly: #{error.message}"}
130
+ ensure
131
+ @connection = nil
132
+ @peer = nil
133
+ end
134
+
135
+ def start
136
+ @peer = @endpoint.connect
137
+ @secure = @endpoint.is_a?(::IO::Endpoint::SSLEndpoint)
138
+
139
+ ::Protocol::SMTP::Client.new(wrap(@peer), **@options).tap do |client|
140
+ client.hello(@domain)
141
+ upgrade(client)
142
+ end
143
+ end
144
+
145
+ # RFC 3207: the upgrade happens over the bare socket, and everything
146
+ # the server said before it is void — so EHLO again and keep the
147
+ # extension list from the encrypted half of the conversation.
148
+ def upgrade(client)
149
+ case @starttls && !@secure && client.starttls?
150
+ when true
151
+ client.starttls.then do |reply|
152
+ case reply.positive?
153
+ when true
154
+ client.stream = wrap(secure(@peer))
155
+ @secure = true
156
+ client.hello(@domain)
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ # Reuse the endpoint's own TLS configuration — ssl_context, ssl_params,
163
+ # hostname and all — rather than inventing a second way to spell it.
164
+ def secure(peer)
165
+ ::IO::Endpoint::SSLEndpoint.new(@endpoint, ssl_context: @ssl_context).then do |endpoint|
166
+ endpoint.make_socket(peer).tap do |socket|
167
+ # Without SNI a shared-hosting server sends the wrong
168
+ # certificate, and OpenSSL has nothing to verify the name against.
169
+ case endpoint.hostname
170
+ when nil then nil
171
+ else socket.hostname = endpoint.hostname
172
+ end
173
+
174
+ socket.connect
175
+ end
176
+ end
177
+ end
178
+
179
+ def wrap(peer) = ::IO::Stream::Buffered.wrap(peer)
180
+ end
181
+ end
182
+ end
183
+
184
+ __END__
185
+
186
+ require "certificate"
187
+ require "session"
188
+
189
+ # A handler that keeps what it was given and accepts everything.
190
+ collect = lambda do |messages|
191
+ proc do |message|
192
+ messages << message
193
+ Protocol::SMTP::Reply.ok("queued")
194
+ end
195
+ end
196
+
197
+ describe "async/smtp/client" do
198
+ it "sets the session up once and runs each delivery as a transaction on it" do
199
+ messages = []
200
+
201
+ Session.serve(collect.call(messages)) do |client|
202
+ 2.times do |index|
203
+ client.deliver(from: "me@example.test", to: "you@example.test", body: "Subject: #{index}\r\n\r\n.\r\n")
204
+ end
205
+
206
+ client.extensions.keys.should.include "SIZE"
207
+ end
208
+
209
+ messages.map(&:subject).should == ["0", "1"]
210
+ # The dot the client stuffed came back off:
211
+ messages.first.body.should == ".\r\n"
212
+ end
213
+
214
+ it "introduces itself, so the server knows who it is talking to" do
215
+ messages = []
216
+
217
+ Session.serve(collect.call(messages)) do |client|
218
+ client.deliver(from: "me@example.test", to: "you@example.test", body: "Subject: Hello\r\n\r\nBody.\r\n")
219
+ end
220
+
221
+ messages.first.helo.should == "client.example.test"
222
+ messages.first.peer.should == "127.0.0.1"
223
+ messages.first.should.not.be.secure
224
+ end
225
+
226
+ it "raises what the server refused" do
227
+ messages = []
228
+
229
+ Session.serve(collect.call(messages), server_options: {maximum_message_size: 64}) do |client|
230
+ error = lambda do
231
+ client.deliver(from: "me@example.test", to: "you@example.test", body: "#{"x" * 200}\r\n")
232
+ end.should.raise(Protocol::SMTP::ReplyError)
233
+
234
+ error.reply.code.should == 552
235
+
236
+ # And reads what the server advertised, so a client can give up first:
237
+ client.connection.maximum_message_size.should == 64
238
+ end
239
+
240
+ messages.should.be.empty
241
+ end
242
+
243
+ it "upgrades the connection with STARTTLS and delivers over it" do
244
+ messages = []
245
+ options = {
246
+ server_options: {ssl_context: Certificate.server_context},
247
+ client_options: {ssl_context: Certificate.client_context},
248
+ }
249
+
250
+ Session.serve(collect.call(messages), **options) do |client|
251
+ client.should.be.secure
252
+
253
+ reply = client.deliver(
254
+ from: "me@example.test",
255
+ to: "you@example.test",
256
+ body: "Subject: Secret\r\n\r\nBody.\r\n",
257
+ )
258
+
259
+ reply.code.should == 250
260
+
261
+ # The extension list is the one from after the upgrade, which no longer
262
+ # offers STARTTLS:
263
+ client.extensions.should.not.include "STARTTLS"
264
+ end
265
+
266
+ messages.first.subject.should == "Secret"
267
+ messages.first.should.be.secure
268
+ end
269
+
270
+ it "carries on in the clear when told not to upgrade" do
271
+ messages = []
272
+ options = {
273
+ server_options: {ssl_context: Certificate.server_context},
274
+ client_options: {starttls: false},
275
+ }
276
+
277
+ Session.serve(collect.call(messages), **options) do |client|
278
+ client.should.not.be.secure
279
+ client.extensions.should.include "STARTTLS"
280
+ client.deliver(from: "me@example.test", to: "you@example.test", body: "Hi\r\n").code.should == 250
281
+ end
282
+
283
+ messages.first.should.not.be.secure
284
+ end
285
+
286
+ it "closes the socket protocol-smtp would not close itself" do
287
+ stream = nil
288
+
289
+ Session.serve(collect.call([])) do |client|
290
+ client.deliver(from: "me@example.test", to: "you@example.test", body: "Hi\r\n")
291
+ stream = client.connection.stream
292
+ end
293
+
294
+ stream.should.be.closed
295
+ end
296
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/endpoint/host_endpoint"
4
+ require "io/endpoint/ssl_endpoint"
5
+ require "uri"
6
+
7
+ module Async
8
+ module SMTP
9
+ # Where a server listens, or a client connects to. Thin sugar over
10
+ # IO::Endpoint: SMTP has no scheme negotiation to do, just a host and a
11
+ # port, with 25 the default that matters, 587 the one people actually
12
+ # configure, and 465 the one that is encrypted before a byte is sent.
13
+ module Endpoint
14
+ # The port an MTA listens on for mail from other MTAs.
15
+ DEFAULT_PORT = 25
16
+
17
+ # The submission port (RFC 6409), which is what a client normally wants.
18
+ SUBMISSION_PORT = 587
19
+
20
+ # Implicit TLS (RFC 8314): encrypted from the first byte, no STARTTLS.
21
+ SECURE_PORT = 465
22
+
23
+ # The schemes #parse understands, and the port each implies.
24
+ PORTS = {
25
+ "smtp" => DEFAULT_PORT,
26
+ "submission" => SUBMISSION_PORT,
27
+ "smtps" => SECURE_PORT,
28
+ }.freeze
29
+
30
+ # The schemes that are encrypted before the conversation starts.
31
+ SECURE_SCHEMES = ["smtps"].freeze
32
+
33
+ module_function
34
+
35
+ # @parameter host [String] The host to connect to, or bind to.
36
+ # @parameter port [Integer] The port.
37
+ # @parameter secure [Boolean] Wrap the connection in TLS immediately,
38
+ # rather than leaving it to STARTTLS.
39
+ # @parameter options [Hash] Passed to IO::Endpoint, which is where
40
+ # ssl_context, ssl_params, hostname, timeout and the rest live.
41
+ # @returns [IO::Endpoint::Generic]
42
+ def for(host, port = DEFAULT_PORT, secure: false, **options)
43
+ case secure
44
+ when true then ::IO::Endpoint.ssl(host, port, hostname: host, **options)
45
+ else ::IO::Endpoint.tcp(host, port, **options)
46
+ end
47
+ end
48
+
49
+ # "smtps://mail.example.com" -> an endpoint for it, on port 465, with
50
+ # TLS. An unknown scheme is treated as plain SMTP on port 25, because a
51
+ # host name on its own is the common case.
52
+ #
53
+ # @parameter url [String] The URL to parse.
54
+ # @returns [IO::Endpoint::Generic]
55
+ def parse(url, **options)
56
+ URI.parse(url).then do |uri|
57
+ self.for(
58
+ uri.host,
59
+ uri.port || PORTS.fetch(uri.scheme, DEFAULT_PORT),
60
+ secure: SECURE_SCHEMES.include?(uri.scheme),
61
+ **options,
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ __END__
70
+
71
+ describe "async/smtp/endpoint" do
72
+ it "defaults to the port an MTA listens on, and takes any other" do
73
+ Async::SMTP::Endpoint.for("mail.example.com").to_s.should.include "25"
74
+ Async::SMTP::Endpoint.for("mail.example.com", 2525).to_s.should.include "2525"
75
+ end
76
+
77
+ it "wraps the connection in TLS when asked" do
78
+ endpoint = Async::SMTP::Endpoint.for("mail.example.com", Async::SMTP::Endpoint::SECURE_PORT, secure: true)
79
+
80
+ endpoint.should.be.kind_of IO::Endpoint::SSLEndpoint
81
+ endpoint.hostname.should == "mail.example.com"
82
+ end
83
+
84
+ it "maps each scheme it knows to its port" do
85
+ Async::SMTP::Endpoint.parse("smtp://mail.example.com").to_s.should.include "25"
86
+ Async::SMTP::Endpoint.parse("submission://mail.example.com").to_s.should.include "587"
87
+ Async::SMTP::Endpoint.parse("smtps://mail.example.com").to_s.should.include "465"
88
+
89
+ # An explicit port wins:
90
+ Async::SMTP::Endpoint.parse("smtp://mail.example.com:2525").to_s.should.include "2525"
91
+ end
92
+
93
+ it "only makes smtps secure, because only smtps is encrypted from the first byte" do
94
+ Async::SMTP::Endpoint.parse("smtps://mail.example.com").should.be.kind_of IO::Endpoint::SSLEndpoint
95
+ Async::SMTP::Endpoint.parse("smtp://mail.example.com").should.not.be.kind_of IO::Endpoint::SSLEndpoint
96
+ Async::SMTP::Endpoint.parse("submission://mail.example.com").should.not.be.kind_of IO::Endpoint::SSLEndpoint
97
+ end
98
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "console"
5
+ require "io/endpoint"
6
+ require "io/stream"
7
+ require "protocol/smtp/server"
8
+
9
+ module Async
10
+ module SMTP
11
+ # An SMTP server that accepts connections on an endpoint and hands each
12
+ # complete message to an application handler — async-http's Server, for a
13
+ # protocol that isn't HTTP. The conversation itself belongs to
14
+ # protocol-smtp; what lives here is the socket, the task it runs in, the
15
+ # loop that drives it, and the application at the end of that loop.
16
+ #
17
+ # endpoint = Async::SMTP::Endpoint.for("127.0.0.1", 1025)
18
+ # Async::SMTP::Server.for(endpoint) { |message| Protocol::SMTP::Reply.ok }.run
19
+ #
20
+ # The handler is called with a Protocol::SMTP::Message and answers with a
21
+ # Protocol::SMTP::Reply. One task per connection: a slow handler holds up
22
+ # its own client and nobody else's.
23
+ class Server
24
+ # Create a server using a block as the handler.
25
+ #
26
+ # @parameter endpoint [IO::Endpoint::Generic] Where to listen.
27
+ # @parameter options [Hash] Passed to {initialize}.
28
+ def self.for(endpoint, **options, &block)
29
+ new(block, endpoint, **options)
30
+ end
31
+
32
+ # @parameter app [Proc] Called with each complete message.
33
+ # @parameter endpoint [IO::Endpoint::Generic] Where to listen.
34
+ # @parameter domain [String] The domain this server announces itself as.
35
+ # @parameter ssl_context [OpenSSL::SSL::SSLContext | Nil] Offer STARTTLS
36
+ # using this context (RFC 3207). An endpoint that is already encrypted
37
+ # needs none of this.
38
+ # @parameter options [Hash] Passed to Protocol::SMTP::Server.
39
+ def initialize(app, endpoint, domain: "localhost", ssl_context: nil, **options)
40
+ @app = app
41
+ @endpoint = endpoint
42
+ @domain = domain
43
+ @ssl_context = ssl_context
44
+ @options = options
45
+ end
46
+
47
+ # @attribute [Proc] The handler each message goes to.
48
+ attr_reader :app
49
+
50
+ # @attribute [IO::Endpoint::Generic] Where this server listens.
51
+ attr_reader :endpoint
52
+
53
+ # @attribute [String] The domain this server announces itself as.
54
+ attr_reader :domain
55
+
56
+ # @returns [Hash] A JSON-compatible representation of this server.
57
+ def as_json(...)
58
+ {
59
+ endpoint: @endpoint.to_s,
60
+ domain: @domain,
61
+ secure: !@ssl_context.nil?,
62
+ }
63
+ end
64
+
65
+ # @returns [String] A JSON string representation of this server.
66
+ def to_json(...) = as_json.to_json(...)
67
+
68
+ # Accept one connection and talk to it until it quits or goes away.
69
+ # Matches async-http's signature, which is what Endpoint#accept yields.
70
+ #
71
+ # @parameter peer [IO] The connected peer.
72
+ # @parameter address [Addrinfo] Where it connected from.
73
+ def accept(peer, address, task: Task.current)
74
+ connection = connection(peer, address)
75
+
76
+ Console.debug(self) {"Incoming connection from #{address.inspect}."}
77
+
78
+ connection.write_greeting
79
+
80
+ while message = connection.read_message
81
+ connection.write_reply(reply_for(message))
82
+ end
83
+ rescue ::Protocol::SMTP::Error, IOError, SystemCallError => error
84
+ # The peer's problem, not ours: log it and let this task end.
85
+ Console.debug(self) {"Connection from #{address.inspect} ended: #{error.message}"}
86
+ ensure
87
+ connection&.close
88
+ end
89
+
90
+ # @returns [Async::Task] The task the server is running in.
91
+ def run
92
+ Async do |task|
93
+ @endpoint.accept(&method(:accept))
94
+
95
+ # Wait for the connections that are still being served:
96
+ task.children&.each(&:wait)
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # The one reply in the conversation that is the application's. A Reply
103
+ # is written as it stands; a String is the text of a 250, because
104
+ # "queued" is the whole of what most handlers want to say. Anything
105
+ # else — nothing at all, or an exception — is this server's problem
106
+ # rather than the client's, so it gets a 4xx and the client may try
107
+ # again later.
108
+ def reply_for(message)
109
+ @app.call(message).then do |reply|
110
+ case reply
111
+ when ::Protocol::SMTP::Reply then reply
112
+ when nil then ::Protocol::SMTP::Reply.new(451, "Handler did not answer")
113
+ else ::Protocol::SMTP::Reply.ok(reply.to_s)
114
+ end
115
+ end
116
+ rescue => error
117
+ Console.error(self, "Handler failed!", error)
118
+
119
+ ::Protocol::SMTP::Reply.new(451, "Internal error")
120
+ end
121
+
122
+ def connection(peer, address)
123
+ ::Protocol::SMTP::Server.new(
124
+ ::IO::Stream::Buffered.wrap(peer),
125
+ domain: @domain,
126
+ peer: peer_address(address),
127
+ starttls: starttls(peer),
128
+ **@options,
129
+ )
130
+ end
131
+
132
+ # The upgrade happens over the bare socket the buffered stream was
133
+ # wrapping, which is why the raw peer is captured here: the 220 has
134
+ # already gone out in the clear, and anything the client sent before
135
+ # the handshake is discarded along with the old stream (RFC 3207 4.2).
136
+ def starttls(peer)
137
+ case @ssl_context
138
+ when nil then nil
139
+ else
140
+ proc do |stream|
141
+ stream.flush
142
+
143
+ ::OpenSSL::SSL::SSLSocket.new(peer, @ssl_context).then do |socket|
144
+ socket.sync_close = true
145
+ socket.accept
146
+
147
+ ::IO::Stream::Buffered.wrap(socket)
148
+ end
149
+ end
150
+ end
151
+ end
152
+
153
+ # Addrinfo for a TCP endpoint; a unix socket has no ip_address.
154
+ def peer_address(address)
155
+ case address.respond_to?(:ip_address)
156
+ when true then address.ip_address
157
+ else address.to_s
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ __END__
165
+
166
+ require "session"
167
+
168
+ describe "async/smtp/server" do
169
+ it "drives the conversation and hands each message to the handler" do
170
+ messages = []
171
+ handler = proc {|message| messages << message; Protocol::SMTP::Reply.ok("queued")}
172
+
173
+ Session.serve(handler) do |client|
174
+ reply = client.deliver(
175
+ from: "me@example.test",
176
+ to: "you@example.test",
177
+ body: "Subject: Hello\r\n\r\nBody.\r\n",
178
+ )
179
+
180
+ reply.code.should == 250
181
+ reply.text.should == "queued"
182
+ end
183
+
184
+ messages.length.should == 1
185
+ messages.first.from.should == "me@example.test"
186
+ messages.first.to.should == ["you@example.test"]
187
+ messages.first.subject.should == "Hello"
188
+ end
189
+
190
+ it "sends a handler's refusal as it stands" do
191
+ Session.serve(proc {Protocol::SMTP::Reply.rejected("No thanks")}) do |client|
192
+ error = lambda do
193
+ client.deliver(from: "me@example.test", to: "you@example.test", body: "Hi\r\n")
194
+ end.should.raise(Protocol::SMTP::ReplyError)
195
+
196
+ error.reply.code.should == 550
197
+ end
198
+ end
199
+
200
+ it "makes a handler's string the text of a 250" do
201
+ Session.serve(proc {"queued as 42"}) do |client|
202
+ reply = client.deliver(from: "me@example.test", to: "you@example.test", body: "Hi\r\n")
203
+
204
+ reply.code.should == 250
205
+ reply.text.should == "queued as 42"
206
+ end
207
+ end
208
+
209
+ it "answers 451 for a handler that says nothing, because the client may try again" do
210
+ Session.serve(proc {nil}) do |client|
211
+ error = lambda do
212
+ client.deliver(from: "me@example.test", to: "you@example.test", body: "Hi\r\n")
213
+ end.should.raise(Protocol::SMTP::ReplyError)
214
+
215
+ error.reply.code.should == 451
216
+ end
217
+ end
218
+
219
+ it "keeps the connection when a handler raises, rather than dropping the client" do
220
+ Session.serve(proc {raise "boom"}) do |client|
221
+ error = lambda do
222
+ client.deliver(from: "me@example.test", to: "you@example.test", body: "Hi\r\n")
223
+ end.should.raise(Protocol::SMTP::ReplyError)
224
+
225
+ error.reply.code.should == 451
226
+
227
+ # The conversation survived the failure:
228
+ client.connection.noop.code.should == 250
229
+ end
230
+ end
231
+
232
+ it "accepts on the endpoint until its task is stopped" do
233
+ messages = []
234
+ handler = proc {|message| messages << message; Protocol::SMTP::Reply.ok("queued")}
235
+
236
+ Sync do
237
+ bound = IO::Endpoint.tcp("127.0.0.1", 0).bound
238
+
239
+ begin
240
+ endpoint = bound.local_address_endpoint
241
+ task = Async::SMTP::Server.for(bound, domain: "mail.example.test", &handler).run
242
+
243
+ Async::SMTP::Client.open(endpoint) do |client|
244
+ body = "Subject: Run\r\n\r\n"
245
+
246
+ client.deliver(from: "me@example.test", to: "you@example.test", body: body).code.should == 250
247
+ end
248
+ ensure
249
+ task&.stop
250
+ bound.close
251
+ end
252
+ end
253
+
254
+ messages.first.subject.should == "Run"
255
+ end
256
+
257
+ it "describes itself as JSON" do
258
+ endpoint = Async::SMTP::Endpoint.for("127.0.0.1", 2525)
259
+ server = Async::SMTP::Server.for(endpoint, domain: "mail.example.test") {nil}
260
+
261
+ server.as_json.should == {endpoint: endpoint.to_s, domain: "mail.example.test", secure: false}
262
+ end
263
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module SMTP
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/async/smtp.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "smtp/version"
4
+
5
+ require_relative "smtp/client"
6
+ require_relative "smtp/endpoint"
7
+ require_relative "smtp/server"
8
+
9
+ # @namespace
10
+ module Async
11
+ # An asynchronous SMTP server and client: protocol-smtp's state machine
12
+ # bound to an endpoint, one task per connection. What async-http is to
13
+ # protocol-http.
14
+ #
15
+ # @namespace
16
+ module SMTP
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,196 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: async-smtp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan K
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: gem_kit
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: async
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: io-endpoint
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.18'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.18'
54
+ - !ruby/object:Gem::Dependency
55
+ name: io-stream
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.14'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.14'
68
+ - !ruby/object:Gem::Dependency
69
+ name: protocol-smtp
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: lefthook
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.1'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.1'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '13.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '13.0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rubocop
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.60'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '1.60'
124
+ - !ruby/object:Gem::Dependency
125
+ name: scampi
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '1.0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '1.0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: gem_kit-release
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '0.3'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '0.3'
152
+ description: |
153
+ Binds protocol-smtp's state machine to an endpoint and runs it on the
154
+ async reactor, one task per connection — what async-http is to
155
+ protocol-http.
156
+ email:
157
+ - nathankidd@hey.com
158
+ executables: []
159
+ extensions: []
160
+ extra_rdoc_files: []
161
+ files:
162
+ - CHANGELOG.md
163
+ - LICENSE
164
+ - README.md
165
+ - lib/async/smtp.rb
166
+ - lib/async/smtp/client.rb
167
+ - lib/async/smtp/endpoint.rb
168
+ - lib/async/smtp/server.rb
169
+ - lib/async/smtp/version.rb
170
+ homepage: https://github.com/n-at-han-k/async-smtp
171
+ licenses:
172
+ - MIT
173
+ metadata:
174
+ homepage_uri: https://github.com/n-at-han-k/async-smtp
175
+ source_code_uri: https://github.com/n-at-han-k/async-smtp
176
+ documentation_uri: https://github.com/n-at-han-k/async-smtp
177
+ changelog_uri: https://github.com/n-at-han-k/async-smtp/blob/main/CHANGELOG.md
178
+ rubygems_mfa_required: 'true'
179
+ rdoc_options: []
180
+ require_paths:
181
+ - lib
182
+ required_ruby_version: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ version: 3.2.0
187
+ required_rubygems_version: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - ">="
190
+ - !ruby/object:Gem::Version
191
+ version: '0'
192
+ requirements: []
193
+ rubygems_version: 3.7.2
194
+ specification_version: 4
195
+ summary: An asynchronous SMTP server.
196
+ test_files: []