mailsmtp 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e3b608d7e843c5cc556588340f9cb6446398479fac9fb8b327256388ad5b254
4
- data.tar.gz: e84314276f9336f5d0a8dcf91f4c260aa4ee0d4943a87f098a920078511c138d
3
+ metadata.gz: 57ea364c7b9a8ef21f22f61233d07a6c6f765f097351765d51fe807dea5dd66a
4
+ data.tar.gz: b5d59eb1ee5152050d0b3f79001263f5ab1632c46acdc8dbd3a6f99489b07ddf
5
5
  SHA512:
6
- metadata.gz: 240d4561b11ef3d475d8514d8bb220147d3f29fbba4f72a01b0c692907f72b02686e5218561bf1cd41052f7181f3ec9d0791c7f4bbcc5a3d39006873f85b0ab6
7
- data.tar.gz: 5e4565c687ee1f6d4835d6fbddcfd213d701656d97e69e66f48ed9b07007fc12bab82d92a127cf8e7367ed60be18d4d5ced1867e7b771265c03b6c78817e1434
6
+ metadata.gz: f7319c0c6aeaa73664bd5e47317a6f4bdb3ce64667ae417861da5a32983afebee8824cc3256c0e2f8357713788ec18c3444410d10ff76c873691cd2b5e379fb2
7
+ data.tar.gz: cee5dfc590c8ec1dc0c00661c84bba351bb0acc3dabb47e5965b81c805e19a965145c0344e79e659d4c2a08429fe5956f6bf4cfb9829de7c37281fd963c52921
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Refuse a SASL PLAIN authzid the application did not grant, and reject identities containing CR or LF
6
+ - Only a String or Symbol returned from `authenticate` now sets `session.authorization_id`
7
+ - Neuter non-printables and RFC 5322 header specials in the HELO/EHLO name
8
+ - Added `MailSmtp::ConnectionLost` so a peer hang-up logs as a disconnect, not an error
9
+ - Require the PROXY preamble before any other command from a peer in `proxy_hosts`
10
+ - Read `MAIL FROM SIZE=` as decimal, so `SIZE=08` no longer raises and `SIZE=0100` is not octal
11
+ - Validate `MailSmtp::Error` subclasses missing `status` on `#response`, not at definition
12
+
3
13
  ## 0.2.1
4
14
 
5
15
  Documentation
@@ -23,4 +33,4 @@ Documentation
23
33
  - Added PROXY protocol v1 support
24
34
  - Added TLS hot-swap via `#tls=`
25
35
  - Added `session.tls_version` and `session.tls_cipher` after STARTTLS
26
- - Declared `base64` and `logger` as runtime dependencies
36
+ - Declared `base64` and `logger` as runtime dependencies
data/README.md CHANGED
@@ -63,7 +63,7 @@ class MyServer < MailSmtp::Server
63
63
 
64
64
  def authenticate(session, username:, password:, authorization_id: "")
65
65
  raise MailSmtp::AuthenticationFailed unless username == "user" && password == "secret"
66
- username # becomes session.authorization_id when non-empty
66
+ username # granted as session.authorization_id
67
67
  end
68
68
 
69
69
  def mail_from(session, address)
@@ -146,7 +146,7 @@ Raising a `MailSmtp::Error` subclass sends that SMTP reply to the client. Other
146
146
  | `disconnected` | `(session)` | — |
147
147
  | `helo` | `(session, name)` | after STARTTLS, `session.tls_version` / `session.tls_cipher` are set |
148
148
  | `proxy` | `(session, proxy_data)` | hash replaces PROXY data |
149
- | `authenticate` | `(session, username:, password:, authorization_id:)` | value becomes `authorization_id` (else username); raise `AuthenticationFailed` |
149
+ | `authenticate` | `(session, username:, password:, authorization_id:)` | returned String grants that `authorization_id`, else the username is used; raise `AuthenticationFailed` |
150
150
  | `auth_mechanisms` | `(session)` | |
151
151
  | `mail_from` | `(session, address)` | returned string is stored as envelope from |
152
152
  | `rcpt_to` | `(session, address)` | returned string is appended to envelope to |
@@ -87,7 +87,7 @@ module MailSmtp
87
87
  # like a clean end-of-message (apps that rescue EOFError would accept a
88
88
  # truncated body and the engine would map that to LocalError / 451).
89
89
  raise EOFError, "end of data" if @eof
90
- raise ServiceUnavailable, "Connection lost during DATA"
90
+ raise ConnectionLost, "Connection lost during DATA"
91
91
  end
92
92
 
93
93
  chunk = @buffer.slice!(0, maxlen)
@@ -8,26 +8,11 @@ module MailSmtp
8
8
  @enhanced = enhanced
9
9
  end
10
10
 
11
- def inherited(subclass)
12
- # +class Foo < Error+ emits TracePoint :end; Class.new(Error) goes through
13
- # Class#initialize and only finishes on that method's :c_return (after any block).
14
- if caller_locations(1, 1).first&.label == "Class#initialize"
15
- TracePoint.trace(:c_return) do |tp|
16
- next unless tp.method_id == :initialize &&
17
- tp.defined_class == Class &&
18
- tp.return_value.equal?(subclass)
19
- tp.disable
20
- subclass.assert_status!
21
- end
22
- else
23
- TracePoint.trace(:end) do |tp|
24
- next unless tp.self.equal?(subclass)
25
- tp.disable
26
- subclass.assert_status!
27
- end
28
- end
29
- end
30
-
11
+ # A missing status is caught by +#response+, the only place the values are
12
+ # ever needed. Validating it eagerly at definition time instead would mean
13
+ # enabling a process-global TracePoint on every subclass which traces
14
+ # every thread, and is stranded permanently if the class body raises before
15
+ # the matching event arrives.
31
16
  def assert_status!
32
17
  if code.nil? || text.nil? || enhanced.nil?
33
18
  raise "MailSmtp::Error subclass #{self} must declare status"
@@ -8,6 +8,12 @@ module MailSmtp
8
8
  status 421, "Service too busy or not available, closing transmission channel", enhanced: "4.3.2"
9
9
  end
10
10
 
11
+ # 421 — the peer is already gone before any reply could reach it, e.g. a
12
+ # mid-DATA disconnect. Subclasses +ServiceUnavailable+ so existing rescues
13
+ # still catch it, but the engine logs it as a routine disconnect, not an error.
14
+ class ConnectionLost < ServiceUnavailable
15
+ end
16
+
11
17
  # 451 — the app hit an unexpected error handling an otherwise-valid message
12
18
  class LocalError < Error
13
19
  status 451, "Requested action aborted: local error in processing", enhanced: "4.3.0"
@@ -54,6 +54,25 @@ module MailSmtp
54
54
  # up to 12288 octets — not the 512-octet SMTP command-line ceiling.
55
55
  MAX_AUTH_LINE_LENGTH = 12_288
56
56
 
57
+ # RFC 5322 header specials: parens (comments), backslash/quote (quoted
58
+ # strings), angle brackets and "@" (addr-spec), ";" (Received date
59
+ # delimiter), space (token boundary). Postfix neuters exactly this set.
60
+ HELO_SPECIALS = ' <>()\\";@'.freeze
61
+ # Everything outside printable US-ASCII, plus HELO_SPECIALS. Not
62
+ # fixed-encoding, so it applies to a BINARY subject byte by byte.
63
+ HELO_UNSAFE = /[^\x20-\x7e]|[#{Regexp.escape(HELO_SPECIALS)}]/
64
+ # For an SMTPUTF8 HELO: well-formed UTF-8 survives except control/format
65
+ # characters (\p{Cc}\p{Cf} — bidi overrides, zero-width joiners), non-ASCII
66
+ # spaces and line separators (\p{Zs}\p{Zl}\p{Zp} — could smuggle whitespace
67
+ # or newlines past the ASCII rule), and unassigned/private-use codepoints
68
+ # (\p{Cn}\p{Co}).
69
+ #
70
+ # Fixed-encoding UTF-8, so unlike HELO_UNSAFE it raises
71
+ # Encoding::CompatibilityError on a BINARY subject with high bytes — only
72
+ # apply it to a valid_encoding? UTF-8 string (see +neuter_helo+).
73
+ HELO_UNSAFE_UTF8 =
74
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}\p{Cn}\p{Co}]|[#{Regexp.escape(HELO_SPECIALS)}]/
75
+
57
76
  MAX_FAILED_ACCEPTS = 10
58
77
  FAILED_ACCEPT_BACKOFF = 0.5
59
78
 
@@ -194,7 +213,8 @@ module MailSmtp
194
213
  # Override to customize the opt-in Received header (+add_received: true+).
195
214
  def received_header(session)
196
215
  protocol = session.encrypted? ? (session.authenticated? ? "ESMTPSA" : "ESMTPS") : "ESMTP"
197
- helo = session.helo.to_s.empty? ? "unknown" : session.helo
216
+ helo = session.helo.to_s
217
+ helo = helo.empty? ? "unknown" : ascii_only_helo(session, helo)
198
218
  "Received: from #{helo} (unknown [#{session.remote_ip}]) " \
199
219
  "by #{session.helo_response} with #{protocol}; #{Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S +0000")}\r\n"
200
220
  end
@@ -233,6 +253,10 @@ module MailSmtp
233
253
  def helo(session, name)
234
254
  end
235
255
 
256
+ # Verify +username+/+password+ and raise +AuthenticationFailed+ if they do
257
+ # not check out. +authorization_id+ is the SASL PLAIN authzid — an
258
+ # unverified claim by the client, safely ignored by default. To honor it,
259
+ # verify the account may act as it and return that identity.
236
260
  def authenticate(session, username:, password:, authorization_id: "")
237
261
  raise AuthenticationFailed
238
262
  end
@@ -611,8 +635,12 @@ module MailSmtp
611
635
  respond(io, session, reply(221, "Service closing transmission channel")) unless session.close_after_reply
612
636
  rescue StopConnection, StopService
613
637
  raise
614
- rescue EOFError => e
615
- log(session, Logger::DEBUG, "Connection lost due abort by client! (EOFError)", err: e)
638
+ # A hang-up (FIN, or an RST from closing without draining our reply) is
639
+ # routine, not an error skip the 421. Must precede the
640
+ # ServiceUnavailable rescue below, which ConnectionLost extends.
641
+ # ETIMEDOUT is excluded: retransmit exhaustion is a real network fault.
642
+ rescue EOFError, ConnectionLost, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED => e
643
+ log(session, Logger::DEBUG, "Connection lost due abort by client! (#{e.class})", err: e)
616
644
  rescue ServiceUnavailable => e
617
645
  session.exceptions += 1
618
646
  log(session, Logger::ERROR, loggable_error(e), err: e)
@@ -640,6 +668,38 @@ module MailSmtp
640
668
  @connections_mutex.synchronize { @connection_ips.values.count(remote_ip) }
641
669
  end
642
670
 
671
+ # RFC 5321 §4.1.1.1 constrains a HELO name, but refusing junk outright
672
+ # rejects too much mail — replace what's unsafe instead. CR/LF are already
673
+ # stripped (stops header injection); this stops header *forgery*, where an
674
+ # unbalanced "(" comments out the rest of a generated Received line for
675
+ # any consumer that parses those comments as data. Substitution is 1:1 so
676
+ # distinct names never collapse together.
677
+ def neuter_helo(name)
678
+ # Non-ASCII survives only when the server offers SMTPUTF8 (server-wide,
679
+ # since HELO precedes MAIL FROM) and the bytes are valid UTF-8;
680
+ # otherwise fall back to the ASCII-only rule.
681
+ if @smtputf8
682
+ utf8 = name.dup.force_encoding(Encoding::UTF_8)
683
+ return utf8.gsub(HELO_UNSAFE_UTF8, "?") if utf8.valid_encoding?
684
+ end
685
+
686
+ name.gsub(HELO_UNSAFE, "?")
687
+ end
688
+
689
+ # Header bodies are US-ASCII (RFC 5322 §2.2) unless this particular
690
+ # message negotiated SMTPUTF8 (RFC 6532) — known only here, with the
691
+ # envelope in hand. Re-checks the encoding rather than trusting
692
+ # +session.helo+ to still be what +neuter_helo+ produced, since it's a
693
+ # public accessor and +received_header+ is an override point.
694
+ def ascii_only_helo(session, helo)
695
+ if session.envelope.encoding_utf8
696
+ utf8 = helo.dup.force_encoding(Encoding::UTF_8)
697
+ return utf8 if utf8.valid_encoding?
698
+ end
699
+
700
+ helo.b.gsub(HELO_UNSAFE, "?")
701
+ end
702
+
643
703
  # Message lines lose only their terminator. Bare LF is rejected when
644
704
  # +forbid_bare_newline+ is set (default); see +read_data_line!+.
645
705
  def strip_line_terminator(line)
@@ -652,8 +712,12 @@ module MailSmtp
652
712
  def loggable_command(session, line)
653
713
  if auth_value_phase?(session.phase)
654
714
  "[redacted credential]"
655
- else
715
+ elsif line.match?(/\AAUTH\s/i)
656
716
  line.sub(/\A(AUTH\s+\S+)\s+\S.*\z/i, '\1 [redacted]')
717
+ else
718
+ # Every command runs through here whether or not DEBUG is enabled, so
719
+ # the traffic that carries no credential skips the rewrite entirely.
720
+ line
657
721
  end
658
722
  end
659
723
 
@@ -688,6 +752,10 @@ module MailSmtp
688
752
  end
689
753
  rescue Timeout::Error, IO::TimeoutError
690
754
  raise ServiceUnavailable, "Write timed out"
755
+ rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNABORTED
756
+ # The peer went away mid-reply. Nothing further can be delivered, so do
757
+ # not let this surface as a server-side error and provoke a second write.
758
+ raise ConnectionLost, "Connection lost while writing"
691
759
  end
692
760
 
693
761
  # Success and informational replies. Pass +enhanced: nil+ for replies that
@@ -701,6 +769,14 @@ module MailSmtp
701
769
  end
702
770
 
703
771
  def process_command_line(session, line)
772
+ # An allowlisted peer skips the per-IP cap until PROXY names the real
773
+ # client, but nothing forces PROXY to actually arrive — require it
774
+ # before anything else (PROXY v1: a receiver must not guess whether the
775
+ # header is there), rather than leaving an indefinite exemption.
776
+ if proxy_allowed?(session.remote_ip) && !session.proxy && !line.match?(/\APROXY(\s+)/i)
777
+ raise ServiceUnavailable, "Abort connection while PROXY required from #{session.remote_ip}"
778
+ end
779
+
704
780
  case line
705
781
  when /\A(HELO|EHLO)(\s+.*)?\z/i
706
782
  process_helo(session, line)
@@ -748,6 +824,7 @@ module MailSmtp
748
824
  match = line.match(/\A(HELO|EHLO)(?:\s+(.*))?\z/i)
749
825
  name = match[2].to_s.strip
750
826
  raise InvalidParameters if name.empty?
827
+ name = neuter_helo(name)
751
828
 
752
829
  # RFC 5321 §4.1.4: a later EHLO/HELO resets state exactly as RSET.
753
830
  session.reset if session.phase != :helo
@@ -892,7 +969,9 @@ module MailSmtp
892
969
  # refused with 555 (RFC 1869 §6.1) — never silently dropped.
893
970
  def apply_mail_from_extensions!(session, address)
894
971
  if (declared = address[/\sSIZE=(\d+)\b/i, 1])
895
- declared = Integer(declared)
972
+ # Base 10 explicitly: Integer("0100") would otherwise read as octal and
973
+ # under-report the declared size, and Integer("08") would raise.
974
+ declared = Integer(declared, 10)
896
975
  session.envelope.declared_size = declared
897
976
  if @max_message_size && declared > @max_message_size
898
977
  raise MessageTooLarge, "Declared #{declared} bytes, limit is #{@max_message_size}"
@@ -966,10 +1045,17 @@ module MailSmtp
966
1045
  reader = PrefixedReader.new(received_header(session), reader) if @add_received
967
1046
 
968
1047
  error = nil
1048
+ aborting = false
969
1049
  begin
970
1050
  receiving_started(session)
971
1051
  receive(session, reader)
1052
+ rescue ConnectionLost
1053
+ # Nothing left to drain toward, and no reply to line up behind the body.
1054
+ aborting = true
1055
+ raise
972
1056
  rescue ServiceUnavailable
1057
+ # The app is refusing with a 421 — keep draining to the terminating "."
1058
+ # so a sender still writing the body isn't hit with a write-side reset.
973
1059
  raise
974
1060
  rescue Error => e
975
1061
  error = e
@@ -977,7 +1063,7 @@ module MailSmtp
977
1063
  log(session, Logger::ERROR, loggable_error(e), err: e)
978
1064
  error = LocalError.new
979
1065
  ensure
980
- reader.drain
1066
+ reader.drain unless aborting
981
1067
  session.reset
982
1068
  # RFC 5321 §4.5.3.1.7: after 552 for SIZE, the server may close.
983
1069
  # Same when drain hit the absolute ceiling with no "." in sight.
@@ -1022,7 +1108,7 @@ module MailSmtp
1022
1108
  IO.select(nil, [ io.to_io ], nil, IO_WAIT_TIMEOUT)
1023
1109
  end
1024
1110
 
1025
- raise ServiceUnavailable, "Connection lost during DATA" if io.closed?
1111
+ raise ConnectionLost, "Connection lost during DATA" if io.closed?
1026
1112
  raise ServiceUnavailable, "Server shutting down" if shutdown?
1027
1113
  next
1028
1114
  end
@@ -1043,7 +1129,7 @@ module MailSmtp
1043
1129
  rescue BufferOverrun
1044
1130
  raise ServiceUnavailable, "Input buffer overrun"
1045
1131
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED
1046
- raise ServiceUnavailable, "Connection lost during DATA"
1132
+ raise ConnectionLost, "Connection lost during DATA"
1047
1133
  end
1048
1134
 
1049
1135
  def monotonic_time
@@ -1109,14 +1195,48 @@ module MailSmtp
1109
1195
  raise InvalidParameters
1110
1196
  end
1111
1197
 
1112
- def authorize(session, authorization_id, authentication_id, authentication)
1113
- return_value = authenticate(session,
1198
+ # The SASL PLAIN authzid is a claim by the client, not a fact (RFC 4954
1199
+ # §4): "victim\0attacker\0password" asks us to treat a login as +victim+.
1200
+ # The application grants it by returning that identity from
1201
+ # +authenticate+; any other return (nil, true, a record) falls back to
1202
+ # the account that proved the password. The raw claim stays available as
1203
+ # +session.requested_authorization_id+.
1204
+ def authorize(session, requested_authorization_id, authentication_id, authentication)
1205
+ # base64 can carry bytes the command grammar never allows through;
1206
+ # CR/LF here would forge header lines when an application logs who
1207
+ # authenticated.
1208
+ if [ requested_authorization_id, authentication_id ].any? { |value| value.to_s.match?(/[\r\n]/) }
1209
+ raise InvalidParameters
1210
+ end
1211
+
1212
+ granted = authenticate(session,
1114
1213
  username: authentication_id,
1115
1214
  password: authentication,
1116
- authorization_id: authorization_id)
1117
- authorization_id = return_value if return_value
1215
+ authorization_id: requested_authorization_id)
1216
+ # A non-empty String or Symbol is the application naming the identity —
1217
+ # authoritative, and may differ from the client's claim (canonicalized
1218
+ # case, an alias resolved to its primary address).
1219
+ granted = granted.to_s if granted.is_a?(String) || granted.is_a?(Symbol)
1220
+ granted_explicitly = granted.is_a?(String) && !granted.empty?
1221
+ # The grant gets the same CR/LF check as the claim: an
1222
+ # application-assembled identity can carry it just as easily.
1223
+ raise LocalError if granted_explicitly && granted.match?(/[\r\n]/)
1224
+
1225
+ authorization_id = granted_explicitly ? granted : authentication_id
1226
+
1227
+ # Recorded even when the login is about to be refused, so an application
1228
+ # can audit who was impersonated.
1229
+ session.requested_authorization_id = requested_authorization_id
1230
+
1231
+ # Refuse only when the client claimed a different identity and the
1232
+ # application didn't grant one — asking to be yourself is allowed (RFC
1233
+ # 4616 §2). Compared as bytes: both sides came out of base64 as BINARY.
1234
+ requested = requested_authorization_id.to_s
1235
+ if !requested.empty? && !granted_explicitly && requested.b != authentication_id.to_s.b
1236
+ raise AuthenticationFailed
1237
+ end
1118
1238
 
1119
- session.authorization_id = authorization_id.to_s.empty? ? authentication_id : authorization_id
1239
+ session.authorization_id = authorization_id
1120
1240
  session.authentication_id = authentication_id
1121
1241
  session.authenticated_at = Time.now.utc
1122
1242
  reply(235, "OK", enhanced: "2.7.0")
@@ -1189,9 +1309,12 @@ module MailSmtp
1189
1309
  # already gone
1190
1310
  end
1191
1311
 
1192
- # DATA always accepts following body lines in the buffer. PROXY may be
1193
- # followed immediately by HELO/EHLO before the extension finishes negotiating
1194
- # the real client IP (typical load-balancer / PROXY clients).
1312
+ # Body lines are not covered here: once DATA is accepted, +read_data_line!+
1313
+ # consumes the buffer directly and never consults this. Sending the DATA
1314
+ # verb itself with the body already behind it is still refused, because the
1315
+ # client is required to wait for the 354. PROXY may be followed immediately
1316
+ # by HELO/EHLO before the extension finishes negotiating the real client IP
1317
+ # (typical load-balancer / PROXY clients).
1195
1318
  def pipelining_allowed?(session)
1196
1319
  @pipelining || (proxy_enabled? && session.phase == :helo)
1197
1320
  end
@@ -19,7 +19,8 @@ module MailSmtp
19
19
  attr_accessor :phase,
20
20
  :local_ip, :remote_ip, :helo, :helo_response, :local_response,
21
21
  :connected_at, :encrypted_at, :authenticated_at,
22
- :authorization_id, :authentication_id, :auth_login_username,
22
+ :authorization_id, :authentication_id, :requested_authorization_id,
23
+ :auth_login_username,
23
24
  :exceptions, :auth_failures, :proxy, :close_after_reply,
24
25
  :tls_version, :tls_cipher
25
26
  attr_reader :envelope
@@ -49,6 +50,7 @@ module MailSmtp
49
50
  @authenticated_at = nil
50
51
  @authorization_id = nil
51
52
  @authentication_id = nil
53
+ @requested_authorization_id = nil
52
54
  @auth_login_username = nil
53
55
  end
54
56
 
@@ -1,3 +1,3 @@
1
1
  module MailSmtp
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
data/mailsmtp.gemspec CHANGED
@@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = [ "Simon Lev" ]
9
9
 
10
10
  spec.summary = "Boring SMTP server for Ruby."
11
- spec.description = "Answers one question: how do I receive emails in Ruby? Implements the SMTP protocol so you can plug in your own logic for auth, accepting mail, and storage."
11
+ spec.description = "Answers one question: how do I receive emails in Ruby? Implements the SMTP protocol so you can plug in your own logic for auth, accepting mail, and storage. An SMTP server library for building an MX or submission listener."
12
12
 
13
13
  spec.homepage = "https://github.com/mailpiece/mailsmtp"
14
14
  spec.license = "MIT"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailsmtp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Lev
@@ -38,7 +38,8 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
40
  description: 'Answers one question: how do I receive emails in Ruby? Implements the
41
- SMTP protocol so you can plug in your own logic for auth, accepting mail, and storage.'
41
+ SMTP protocol so you can plug in your own logic for auth, accepting mail, and storage.
42
+ An SMTP server library for building an MX or submission listener.'
42
43
  executables: []
43
44
  extensions: []
44
45
  extra_rdoc_files: []