How to Use TLS Server Connections
A TLS server accepts TCP transports and turns them into protected application streams. You provide its certificate and private key, choose whether clients must authenticate, and retain each connection until its work has finished. This page explains how those pieces fit together on an event loop, how to limit concurrent work, and how to shut down without losing ownership of active connections.
The excerpts use a mail receiver to illustrate accepting, authenticating, and closing connections. Its TLS session owns the connection independently of the message parser, a pattern you can use with your own application protocol.
From Listener to TLS Session
A TcpListener listens on a local endpoint.
Each onConnection callback receives a pending
TcpConnectionRequest representing one accepted transport.
Create a TlsServerConnection for that request and pass it to
accept() with the server’s TLS options.
The listener remains available for later connections while each TLS source handles its own handshake and traffic.
Keep the listener and each TLS connection on their owning event loops; callbacks and connection operations run there. A session object can retain one connection and its application state until finalization. Store the session before starting acceptance, as the collection in this example does:
network/MailReceiver/src/MailReceiverApp.hpp
std::map<std::size_t, std::shared_ptr<MailSession>> _sessions; ///< Owns sessions through onFinal.
void acceptConnection(el::TcpConnectionRequestPtr request) {
if (_stopping || (_once && !_sessions.empty())) {
request->reject();
return;
}
const auto id = ++_nextSessionId;
auto session = std::make_shared<MailSession>(events(), _mailLog, [this, id](const bool delivered) -> void {
// Defer destruction until the session's final callback has returned.
events()->invoke([this, id, delivered]() -> void { releaseSession(id, delivered); });
});
_sessions.emplace(id, session);
auto options = el::TlsServerAcceptOptions{_handshakeQuota};
options.setConfigurationLabel(_settings.tlsProfile).setConfigurationMappings(_settings.mappings);
session->accept(std::move(request), std::move(options));
}
Retaining the session keeps its connection and protocol state available throughout the asynchronous exchange.
When a session owns the source invoking its callback, defer destruction until that callback has returned.
The events()->invoke() call above schedules removal from the collection after finalization.
Install the TLS callbacks before passing the request to accept() so they can handle both handshake progress and
early failures:
network/MailReceiver/src/MailSession.hpp
el::TlsServerConnectionPtr _connection; ///< TLS source owned until finalization.
void accept(el::TcpConnectionRequestPtr request, el::TlsServerAcceptOptions options) {
installHandlers();
_connection->accept(std::move(request), std::move(options));
}
A request is an ownership decision, not an application stream you can read from directly. Accept it once, or reject it when the service cannot take more work. During shutdown, reject new requests while allowing accepted connections to finish. For a multi-loop server, arrange for the TLS source and its operations to belong to the intended session loop.
Providing the Server Identity
Before accepting TLS connections, register a server profile containing a certificate chain and its matching private key. The chain starts with the server certificate, followed by any intermediates needed by clients. A file-based identity can be registered through the standard TLS configuration section:
*[TLS.Server]
Label: "tls/mail"
Certificate Chain File: "server-certificates/server.pem"
Signing Key File: "server-certificates/server-key.pem"
TlsConfigurationParser loads the standard TLS section, resolves certificate paths relative to the
configuration file, and checks the identity before registration.
Register the resulting profiles in your application’s cryptology configuration, then select the default server profile
with setConfigurationLabel() on TlsServerAcceptOptions.
The TLS profile topic explains file identities, native identities, key
passwords, and trust settings in detail.
Unlike an ordinary client, a server cannot start from system trust alone: it needs an identity to present.
Load configuration before listening so configuration errors are visible at startup.
If you later reload profiles, an accepted connection keeps the profile snapshot selected when accept() began;
subsequent accepts use the replacement.
Completing the Handshake Before Application Work
TCP acceptance is followed by TLS negotiation.
onClientHello exposes the requested server name, offered ALPN identifiers, and selected profile and parameters.
Use it to observe the client’s request or reject a connection that your application policy cannot serve.
It is not evidence of an authenticated client identity.
network/MailReceiver/src/MailSession.hpp
/// The receiver retains this session until a deferred notification following onFinal.
class MailSession : public std::enable_shared_from_this<MailSession> {
el::EventsPtr _events; ///< Owns the queue used to start application work after the checkpoint.
void installHandlers() {
_connection->events()
.onTransportConnected([this]() -> void { _log->trace("Accepted TCP transport; waiting for TLS."_el); })
.onClientHello([this]() -> void { inspectClientHello(); })
.onHandshakeCompleted([this]() -> void {
// Let the handshake checkpoint return before sending application bytes.
_events->invoke([self = shared_from_this()]() -> void {
if (self->_connection->state() == el::ConnectionState::Active) {
self->beginConversation();
}
});
})
.onData([this](el::ByteBlock data) -> void { receiveCommands(data); })
.onWritable([this]() -> void { flushOutput(); })
.onClosed([this](const el::ConnectionCloseContext &) -> void {
_closedCleanly = true;
_log->trace("TLS close_notify exchange completed."_el);
})
.onError([this](const el::NetworkErrorContext &error) -> void {
_log->error(
error.title(),
": "_el,
error.description(),
" (phase "_el,
static_cast<int>(error.phase()),
", reason "_el,
static_cast<int>(error.reason()),
")"_el);
})
.onFinal([this]() -> void { _finished(_logged && _closedCleanly); });
}
Each handshake checkpoint continues automatically after its callback returns.
Calling abort() there prevents the next transition.
Keep callbacks short: certificate verification and private-key operations already run on background workers, and their
waiting time counts toward the handshake deadline.
Cancellation or timeout prevents a late background result from restarting the exchange.
When your protocol sends first, schedule that work from onHandshakeCompleted with events()->invoke().
This lets the handshake checkpoint return before the first application send.
Retain the session in the queued callback and check that the connection is still active, because shutdown may occur
before the queued work runs.
The excerpt above uses this pattern to call beginConversation(), which checks peer authentication and sends the
first application bytes:
network/MailReceiver/src/MailSession.hpp
void beginConversation() {
if (_connection->isPeerAuthenticated()) {
_log->info(
"Client certificate authenticated; path contains "_el,
_connection->peerCertificatePath().count(),
" certificates."_el);
} else {
_log->trace("TLS ready; client did not present an authenticated certificate."_el);
}
sendReply("220 localhost TLS mail receiver\r\n"_el);
}
$ network/mail_receiver --once --timeout 8 --verbose
Listening on 127.0.0.1:8465
Accepted TCP transport; waiting for TLS.
Client requested server name localhost
Selected server profile tls/mail with cipher TLS_AES_256_GCM_SHA384
TLS ready; client did not present an authenticated certificate.
S: 220 localhost TLS mail receiver
S: 250 localhost
S: 250 Sender accepted
S: 250 Recipient accepted
S: 354 End with a line containing a single dot
Received mail from sender@example.test for reader@example.test
Date: 6 Nov 2026 10:34:29 +0000
From: "Alex" <sender@example.test>
To: "Sam" <reader@example.test>
The new observation notes are ready.
See you at the telescope tonight!
S: 250 Message received
S: 221 Bye
TLS close_notify exchange completed.
Stopping the mail receiver.
How to run the demo
Build the mail_receiver and mail_sender targets.
Their executables and configuration assets are placed in cmake-build-debug/demo-apps/network.
Start the receiver with the command shown above, then run mail_sender in another terminal.
The --once option makes this example finish after one successful exchange; --timeout bounds the wait if no
sender arrives.
The trace output shows when the transport is accepted, the TLS profile is selected, and application traffic begins.
The received message is ordinary application output.
Without --verbose, the trace section is disabled.
An optional Main.Log File appends the log to a file as well as writing to the console.
Relative log paths start beside the selected configuration file.
Without --once, the receiver keeps serving and accepts one message on each connection.
The supplied configuration listens on loopback.
Once the handshake completes, onData delivers application bytes and send() queues protected output.
TLS record boundaries are not message boundaries: a callback can deliver part of a message or several messages at once.
Retain incomplete input between deliveries and let your application protocol determine when a message is complete.
Bounding Connections and Handshakes
An established connection and an incomplete handshake consume different resources. Use a TCP connection quota to bound accepted transports, and a separate TLS handshake quota to bound the work needed to establish new protected streams.
network/MailReceiver/src/MailReceiverApp.hpp
void startListener() {
_connectionQuota = el::ConnectionQuota::create(el::ItemCount{16});
_handshakeQuota = el::ConnectionQuota::create(el::ItemCount{4});
_listener = events()->get<el::Network>().createTcpListener();
_listener->events()
.onListening(
[this]() -> void { _mailLog->info("Listening on "_el, _listener->localEndpoint()->toString()); })
.onConnection([this](el::TcpConnectionRequestPtr request) -> void { acceptConnection(std::move(request)); })
.onError([this](const el::NetworkErrorContext &error) -> void {
_mailLog->error(
error.title(),
": "_el,
error.description(),
" (phase "_el,
static_cast<int>(error.phase()),
", reason "_el,
static_cast<int>(error.reason()),
")"_el);
_exitCode = el::ExitCode::failure();
stopServer();
})
.onFinal([this]() -> void {
_listenerFinished = true;
finishIfStopped();
});
_listener->start(_settings.endpoint, el::TcpListenerOptions{}.setConnectionQuota(_connectionQuota));
if (_timeoutSeconds > 0) {
_timeout = events()->createTimer([this]() -> void { stopServer(); });
_timeout->startOnce(el::TimeDelta::seconds(_timeoutSeconds));
}
}
Pass the TCP quota to TcpListenerOptions and the handshake quota to each connection’s TlsServerAcceptOptions.
Retain the quota objects so subsequent accepts use the same capacity limits.
A handshake lease is released when the handshake finishes, while the transport continues to count toward the TCP limit
until finalization.
Sharing either quota object with another listener combines that limit across the listeners.
Separate objects give independent limits.
When the TCP quota is full, the listener does not deliver another request and suspends acceptance until capacity becomes
available.
When the handshake quota is full, TLS acceptance reports ResourceLimitExceeded in the Accepting phase.
Handle such operational failures through the session’s error callback and release its owner at finalization.
Do not make one rejected connection stop an otherwise healthy service.
Flow Control and Bounded Input
A client may send faster than your application can process, or read responses slower than you can produce them. Bound your protocol’s retained input and output as well as the TLS and TCP buffers. TLS buffer limits bound what the connection retains, but they do not bound a message assembled across callbacks. Enforce your protocol’s size limits as you accumulate input, and abort only the affected connection when a message is malformed or too large.
For output, each call to send() accepts at most 16 KiB atomically.
A WouldBlock result leaves the block with the caller; onWritable tells you when to retry.
For a request-response protocol, you can also call pauseReceiving() while responses wait for output capacity.
This prevents further requests from adding work to a blocked output queue.
The following excerpt combines that pause with retaining and retrying the unaccepted block:
network/MailReceiver/src/MailSession.hpp
PendingOutput _output; ///< Replies waiting for send capacity.
void flushOutput() {
while (!_output.isEmpty()) {
const auto block = _output.nextBlock();
const auto status = _connection->send(block);
if (status.wouldBlock()) {
_connection->pauseReceiving();
return;
}
if (status.isClosed()) {
_connection->abort();
return;
}
_output.accepted(block.length());
}
if (_closing) {
_connection->close();
} else {
_connection->resumeReceiving();
}
}
After send() accepts a block, remove it from your pending output.
Once the queue drains, call resumeReceiving() to continue processing requests, or close() if the exchange is
complete.
Pausing delivery also pauses the transport, but does not remove bytes already retained by TLS or your parser.
Continue enforcing application limits for the input currently being processed.
Selecting Profiles with SNI
When several services share an endpoint, SNI lets a client request a host name during the handshake.
Map that name to a TLS configuration label in TlsServerAcceptOptions.
Keep a default profile for clients that send no recognized name.
The mapping is exact after host-name canonicalization; it is not a wildcard routing rule.
Each TlsConfigurationMapping associates a host name with a
registered profile label.
Pass those mappings to the accept options before accepting the request:
auto mapping = el::TlsConfigurationMapping{
el::HostName::fromStringOrThrow("localhost"_el), "tls/mail/identified"_el};
options.setConfigurationMappings({mapping});
All mapped profiles and the default are resolved together when accept() starts.
Selection chooses a complete server profile, including its identity and client-authentication policy.
At onClientHello, serverName() exposes the requested name and matchedConfigurationLabel() identifies the
selected profile, as the following excerpt demonstrates:
network/MailReceiver/src/MailSession.hpp
void inspectClientHello() {
if (const auto name = _connection->serverName()) {
_log->trace("Client requested server name "_el, name->toString());
}
_log->trace(
"Selected server profile "_el,
*_connection->matchedConfigurationLabel(),
" with cipher "_el,
_connection->cipherSuite()->toString());
}
$ network/mail_receiver --config mail_receiver_sni.elcl --once --timeout 8 --verbose
Listening on 127.0.0.1:8465
Accepted TCP transport; waiting for TLS.
Client requested server name localhost
Selected server profile tls/mail/identified with cipher TLS_AES_256_GCM_SHA384
Client certificate authenticated; path contains 2 certificates.
S: 220 localhost TLS mail receiver
S: 250 localhost
S: 250 Sender accepted
S: 250 Recipient accepted
S: 354 End with a line containing a single dot
Received mail from sender@example.test for reader@example.test
Date: 6 Nov 2026 10:34:30 +0000
From: "Alex" <sender@example.test>
To: "Sam" <reader@example.test>
The new observation notes are ready.
See you at the telescope tonight!
S: 250 Message received
S: 221 Bye
TLS close_notify exchange completed.
Stopping the mail receiver.
How to run the SNI demo
The mail_receiver_sni.elcl configuration maps localhost to tls/mail/identified, which requires a client
certificate; the default tls/mail profile does not.
Start the receiver with the command shown above, then run mail_sender --config mail_sender_identity.elcl from
the directory containing the executables and configurations.
The selected label in the trace makes the routing decision visible.
To exercise default-profile selection, connect to 127.0.0.1 with the sender’s --smtp-host override instead of
using localhost.
The supplied server certificate covers both identities, so this changes SNI selection without weakening certificate
verification.
For protocols using ALPN, configure the server’s supported identifiers through setAlpnProtocols() and inspect
negotiatedAlpn() after selection.
ALPN chooses the application protocol; SNI chooses the server profile.
Authenticating Clients
Server authentication protects the client from connecting to an impostor.
Client-certificate authentication gives the server a verified identity for the connecting peer as well.
Configure the selected server profile with trusted client authorities and a required or optional authentication policy.
For example, these fields in a TLS.Server profile require a client certificate trusted by the specified CA:
Client Authentication: "required"
Trust Anchors File: "server-certificates/ca.pem"
With required authentication, a missing or unacceptable client identity fails the handshake.
With optional authentication, a client may omit its certificate, but a presented certificate must still validate.
After onHandshakeCompleted, isPeerAuthenticated() tells you whether the client authenticated with a certificate.
peerCertificatePath() contains its validated path, starting with the client certificate.
Check that result before starting work that requires an authenticated client.
You can associate the certificate with an application account and check its permissions before allowing privileged
operations.
TLS proves identity and possession of a private key; your application determines what that identity may do.
How to run the client-authentication demos
The supplied mail_receiver_required.elcl requires client authentication for every connection.
Use mail_sender_identity.elcl with it; the sender’s ordinary configuration is rejected because it has no identity.
The mail_receiver_optional.elcl variant admits clients without certificates but still rejects invalid certificates
when presented.
For an anonymous client, isPeerAuthenticated() is false and the certificate path is empty.
A completed handshake alone is therefore not a client identity.
Finishing Sessions and Stopping the Service
Finish application-level replies before requesting TLS closure.
close() drains output already accepted by TLS, exchanges close_notify, and closes the transport.
An orderly peer close is answered automatically.
A peer that closes TCP without its TLS notification produces a truncation error instead of successful closure.
onClosed reports orderly closure and its origin, followed by onFinal.
Failures produce onError followed by onFinal; an explicit abort also finalizes the source.
Use onFinal to schedule release of the session owner on every termination path, as in the acceptance example.
Stopping the service has an additional ownership requirement: its event loop must continue running while the listener and sessions finish. Stop accepting first, ask sessions to end, and quit only after their final callbacks have been processed.
network/MailReceiver/src/MailReceiverApp.hpp
void stopServer() {
if (_stopping) {
return;
}
_stopping = true;
_mailLog->info("Stopping the mail receiver."_el);
_listener->close();
for (const auto &[id, session] : _sessions) {
session->stop();
}
_shutdownDeadline = events()->createTimer([this]() -> void {
for (const auto &[id, session] : _sessions) {
session->abort();
}
});
_shutdownDeadline->startOnce(el::TimeDelta::seconds(2));
finishIfStopped();
}
void finishIfStopped() {
if (_stopping && _listenerFinished && _sessions.empty()) {
quit(_exitCode);
}
}
Choose which application work to finish during shutdown, then bound how long you wait for it.
In this excerpt, the timer gives sessions two seconds to finalize before remaining connections are aborted.
Your shutdown budget is separate from the TLS handshake, idle, and close deadlines; choose it to suit the work you allow
to finish.
For normal sessions, TlsServerAcceptOptions defaults to a
30-second handshake deadline, a five-minute idle deadline, and a ten-second graceful-close deadline.
Only accepted nonempty application sends and delivered nonempty application data reset the idle deadline.
Choose these limits for the work your service performs, while keeping finalization on the live owner loop.
Complete demo sources
network/MailReceiver/src/MailReceiverApp.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "MailSession.hpp"
#include "ReceiverSettings.hpp"
#include "../../MailCommon/src/MailApplication.hpp"
#include <DemoTerminal.hpp>
#include <erbsland/Application.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/ConnectionQuota.hpp>
#include <erbsland/CryptologyConfiguration.hpp>
#include <erbsland/EventTimer.hpp>
#include <erbsland/ExitCode.hpp>
#include <erbsland/ItemCount.hpp>
#include <erbsland/Network.hpp>
#include <erbsland/NetworkErrorContext.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/OptionType.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringFormat.hpp>
#include <erbsland/StringList.hpp>
#include <erbsland/TcpConnectionRequest.hpp>
#include <erbsland/TcpListener.hpp>
#include <erbsland/TcpListenerOptions.hpp>
#include <erbsland/TimeDelta.hpp>
#include <erbsland/TlsRole.hpp>
#include <erbsland/TlsServerAcceptOptions.hpp>
#include <map>
#include <memory>
#include <vector>
namespace demo {
using namespace el::text::literals;
/// Accept TLS mail sessions while retaining their state through asynchronous finalization.
class MailReceiverApp final : public MailApplication {
public:
using MailApplication::MailApplication;
protected: // implement el::Application
void initialize() override {
DemoTerminal::enable(*this);
info().setApplicationName("TLS Mail Receiver"_el);
}
void registerCommandLineOptions(const el::OptionsPtr &options) override {
options->setHelpDescription(
"Receive one ASCII message per implicit-TLS connection and write it to the log."_el);
addCommonOptions(options);
addTextOption(options, "address"_el, "Override the local IP address."_el);
addTextOption(options, "port"_el, "Override the local port; zero chooses an available port."_el);
addTextOption(options, "tls_profile"_el, "Select the default TLS server profile."_el);
addTextOption(options, "log_file"_el, "Append messages to this file as well as the console."_el);
addTextOption(options, "timeout"_el, "Stop accepting after this positive number of seconds."_el);
options->addOption({"--once"_el, "once"_el})
.setType(el::OptionType::Flag)
.setHelpTitle("Stop after one successful message and clean connection closure."_el);
}
void handleCommandLineOptions(const el::OptionValuesPtr &values) override {
const auto configuration = MailConfiguration{values, "mail_receiver.elcl"_el};
_settings = ReceiverSettings::load(configuration);
setupLogging(values, _settings.logFile);
cryptologyConfiguration().replaceTlsConfigurations(configuration.tlsProfiles());
validateServerProfiles();
_once = values->getFlag("once"_el);
if (values->value("timeout"_el)) {
_timeoutSeconds = values->getText("timeout"_el).toIntegerOrThrow<int>();
if (_timeoutSeconds <= 0 || _timeoutSeconds > 86400) {
throw el::ApplicationError{"Timeout must be between 1 and 86400 seconds."_el};
}
}
events()->invoke([this]() -> void { startListener(); });
}
void cleanup() noexcept override {
if (_timeout) {
_timeout->stop();
}
if (_shutdownDeadline) {
_shutdownDeadline->stop();
}
if (_listener) {
_listener->abort();
}
for (const auto &[id, session] : _sessions) {
session->abort();
}
}
private:
/// Resolve every configured server role before the listener becomes available.
void validateServerProfiles() {
auto labels = el::StringList{_settings.tlsProfile};
for (const auto &mapping : _settings.mappings) {
labels.append(mapping.configurationLabel());
}
for (const auto &profile : cryptologyConfiguration().resolveTlsConfigurations(labels, el::TlsRole::Server)) {
if (!profile.configuration()->server() || !profile.configuration()->server()->identity()) {
throw el::ApplicationError{
el::StringFormat{"TLS server profile '{}' has no identity."_el}.build(profile.requestedLabel())};
}
}
}
/// Bound accepted sockets and incomplete handshakes independently.
void startListener() {
_connectionQuota = el::ConnectionQuota::create(el::ItemCount{16});
_handshakeQuota = el::ConnectionQuota::create(el::ItemCount{4});
_listener = events()->get<el::Network>().createTcpListener();
_listener->events()
.onListening(
[this]() -> void { _mailLog->info("Listening on "_el, _listener->localEndpoint()->toString()); })
.onConnection([this](el::TcpConnectionRequestPtr request) -> void { acceptConnection(std::move(request)); })
.onError([this](const el::NetworkErrorContext &error) -> void {
_mailLog->error(
error.title(),
": "_el,
error.description(),
" (phase "_el,
static_cast<int>(error.phase()),
", reason "_el,
static_cast<int>(error.reason()),
")"_el);
_exitCode = el::ExitCode::failure();
stopServer();
})
.onFinal([this]() -> void {
_listenerFinished = true;
finishIfStopped();
});
_listener->start(_settings.endpoint, el::TcpListenerOptions{}.setConnectionQuota(_connectionQuota));
if (_timeoutSeconds > 0) {
_timeout = events()->createTimer([this]() -> void { stopServer(); });
_timeout->startOnce(el::TimeDelta::seconds(_timeoutSeconds));
}
}
/// Create and retain one TLS session for each accepted TCP request.
void acceptConnection(el::TcpConnectionRequestPtr request) {
if (_stopping || (_once && !_sessions.empty())) {
request->reject();
return;
}
const auto id = ++_nextSessionId;
auto session = std::make_shared<MailSession>(events(), _mailLog, [this, id](const bool delivered) -> void {
// Defer destruction until the session's final callback has returned.
events()->invoke([this, id, delivered]() -> void { releaseSession(id, delivered); });
});
_sessions.emplace(id, session);
auto options = el::TlsServerAcceptOptions{_handshakeQuota};
options.setConfigurationLabel(_settings.tlsProfile).setConfigurationMappings(_settings.mappings);
session->accept(std::move(request), std::move(options));
}
void releaseSession(const std::size_t id, const bool delivered) {
_sessions.erase(id);
if (_once && delivered) {
stopServer();
}
finishIfStopped();
}
/// Stop admission first, then finish sessions while their event loop is still running.
void stopServer() {
if (_stopping) {
return;
}
_stopping = true;
_mailLog->info("Stopping the mail receiver."_el);
_listener->close();
for (const auto &[id, session] : _sessions) {
session->stop();
}
_shutdownDeadline = events()->createTimer([this]() -> void {
for (const auto &[id, session] : _sessions) {
session->abort();
}
});
_shutdownDeadline->startOnce(el::TimeDelta::seconds(2));
finishIfStopped();
}
void finishIfStopped() {
if (_stopping && _listenerFinished && _sessions.empty()) {
quit(_exitCode);
}
}
private:
ReceiverSettings _settings; ///< Listener and profile configuration.
el::TcpListenerPtr _listener; ///< Accepts transport requests on the main loop.
el::ConnectionQuotaPtr _connectionQuota; ///< Bounds all accepted TCP sockets.
el::ConnectionQuotaPtr _handshakeQuota; ///< Bounds incomplete TLS handshakes.
std::map<std::size_t, std::shared_ptr<MailSession>> _sessions; ///< Owns sessions through onFinal.
el::EventTimerPtr _timeout; ///< Optional service lifetime.
el::EventTimerPtr _shutdownDeadline; ///< Bounds draining during service shutdown.
std::size_t _nextSessionId{0}; ///< Stable identifier for deferred removal.
int _timeoutSeconds{0}; ///< Zero means no automatic stop.
bool _once{false}; ///< Stop after one completed exchange.
bool _stopping{false}; ///< Admission has ended.
bool _listenerFinished{false}; ///< Listener has delivered onFinal.
el::ExitCode _exitCode; ///< Final service result.
};
}
network/MailReceiver/src/MailSession.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "MailTransaction.hpp"
#include "../../MailCommon/src/PendingOutput.hpp"
#include "../../MailCommon/src/SmtpLineBuffer.hpp"
#include <erbsland/ApplicationError.hpp>
#include <erbsland/ByteBlock.hpp>
#include <erbsland/ConnectionCloseContext.hpp>
#include <erbsland/ConnectionState.hpp>
#include <erbsland/Events.hpp>
#include <erbsland/LogStream.hpp>
#include <erbsland/Network.hpp>
#include <erbsland/NetworkErrorContext.hpp>
#include <erbsland/String.hpp>
#include <erbsland/TcpConnectionRequest.hpp>
#include <erbsland/TlsServerAcceptOptions.hpp>
#include <erbsland/TlsServerConnection.hpp>
#include <functional>
#include <memory>
namespace demo {
using namespace el::text::literals;
/// Own one accepted TLS connection and one SMTP transaction on the listener's event loop.
/// The receiver retains this session until a deferred notification following onFinal.
class MailSession : public std::enable_shared_from_this<MailSession> {
public:
/// Create a session and its inactive TLS connection.
/// @param events The owning event collection.
/// @param log The shared diagnostic stream.
/// @param finished Notification after finalization; true means a message and clean TLS closure.
MailSession(const el::EventsPtr &events, el::LogStreamPtr log, std::function<void(bool)> finished) :
_events{events},
_connection{events->get<el::Network>().createTlsServerConnection()},
_log{std::move(log)},
_finished{std::move(finished)} {}
public: // lifetime
/// Install callbacks before adopting the accepted transport.
/// @param request Ownership of one pending TCP request.
/// @param options Default/SNI profiles and shared handshake quota.
void accept(el::TcpConnectionRequestPtr request, el::TlsServerAcceptOptions options) {
installHandlers();
_connection->accept(std::move(request), std::move(options));
}
/// End a completed transaction gracefully; discard incomplete work on shutdown.
void stop() {
if (_transaction.isComplete()) {
_closing = true;
flushOutput();
} else {
_connection->abort();
}
}
/// Abort pending work during application cleanup.
void abort() noexcept { _connection->abort(); }
private:
void installHandlers() {
_connection->events()
.onTransportConnected([this]() -> void { _log->trace("Accepted TCP transport; waiting for TLS."_el); })
.onClientHello([this]() -> void { inspectClientHello(); })
.onHandshakeCompleted([this]() -> void {
// Let the handshake checkpoint return before sending application bytes.
_events->invoke([self = shared_from_this()]() -> void {
if (self->_connection->state() == el::ConnectionState::Active) {
self->beginConversation();
}
});
})
.onData([this](el::ByteBlock data) -> void { receiveCommands(data); })
.onWritable([this]() -> void { flushOutput(); })
.onClosed([this](const el::ConnectionCloseContext &) -> void {
_closedCleanly = true;
_log->trace("TLS close_notify exchange completed."_el);
})
.onError([this](const el::NetworkErrorContext &error) -> void {
_log->error(
error.title(),
": "_el,
error.description(),
" (phase "_el,
static_cast<int>(error.phase()),
", reason "_el,
static_cast<int>(error.reason()),
")"_el);
})
.onFinal([this]() -> void { _finished(_logged && _closedCleanly); });
}
/// Inspect the profile selected by the client's requested server name.
void inspectClientHello() {
if (const auto name = _connection->serverName()) {
_log->trace("Client requested server name "_el, name->toString());
}
_log->trace(
"Selected server profile "_el,
*_connection->matchedConfigurationLabel(),
" with cipher "_el,
_connection->cipherSuite()->toString());
}
/// Start the application protocol only after the TLS handshake has completed.
void beginConversation() {
if (_connection->isPeerAuthenticated()) {
_log->info(
"Client certificate authenticated; path contains "_el,
_connection->peerCertificatePath().count(),
" certificates."_el);
} else {
_log->trace("TLS ready; client did not present an authenticated certificate."_el);
}
sendReply("220 localhost TLS mail receiver\r\n"_el);
}
/// Process bounded SMTP lines; invalid client input affects only this session.
void receiveCommands(const el::ByteBlock &data) {
if (_closing) {
return;
}
try {
_lines.receive(data, [this](const el::String &line) -> bool {
const auto reply = _transaction.receive(line);
if (_transaction.isComplete() && !_logged) {
logMessage();
}
if (_transaction.isClosing()) {
_closing = true;
}
if (!reply.isEmpty()) {
sendReply(reply);
}
return !_closing;
});
} catch (const el::ApplicationError &error) {
_log->warn(error.toString());
_closing = true;
_connection->abort();
}
}
void logMessage() {
_log->info("Received mail from "_el, _transaction.sender(), " for "_el, _transaction.recipient());
_log->info(_transaction.message().replacedAll("\r\n"_el, "\n"_el));
_logged = true;
}
void sendReply(const el::String &reply) {
_log->trace("S: "_el, reply.trimmed());
_output.append(reply);
flushOutput();
}
/// Drain accepted output before requesting TLS closure.
void flushOutput() {
while (!_output.isEmpty()) {
const auto block = _output.nextBlock();
const auto status = _connection->send(block);
if (status.wouldBlock()) {
_connection->pauseReceiving();
return;
}
if (status.isClosed()) {
_connection->abort();
return;
}
_output.accepted(block.length());
}
if (_closing) {
_connection->close();
} else {
_connection->resumeReceiving();
}
}
private:
el::EventsPtr _events; ///< Owns the queue used to start application work after the checkpoint.
el::TlsServerConnectionPtr _connection; ///< TLS source owned until finalization.
el::LogStreamPtr _log; ///< Shared diagnostic stream.
std::function<void(bool)> _finished; ///< Defers removal through the application.
MailTransaction _transaction; ///< One bounded SMTP transaction.
SmtpLineBuffer _lines; ///< Incomplete command or DATA line.
PendingOutput _output; ///< Replies waiting for send capacity.
bool _logged{false}; ///< The complete message has been logged.
bool _closing{false}; ///< Close once all replies have been accepted.
bool _closedCleanly{false}; ///< Peer exchanged TLS closure notifications.
};
}