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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +201 -0
- data/README.md +143 -0
- data/lib/async/smtp/client.rb +296 -0
- data/lib/async/smtp/endpoint.rb +98 -0
- data/lib/async/smtp/server.rb +263 -0
- data/lib/async/smtp/version.rb +7 -0
- data/lib/async/smtp.rb +18 -0
- metadata +196 -0
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
|
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: []
|