Gumdrop provides a complete SMTP implementation for both server and client roles. The server supports RFC 5321 (SMTP) and RFC 6409 (Message Submission) with full ESMTP extensions. Like all Gumdrop protocols, the SMTP implementation is event-driven and non-blocking, achieving high performance with minimal resource consumption.
The SMTP server follows an "event-driven first" design philosophy. Rather than allocating a thread per connection—a model that limits scalability to the thousands—Gumdrop multiplexes all SMTP connections across a small pool of worker threads using Java NIO selectors. This approach has been proven in production since 2005 and enables a single server to handle tens of thousands of concurrent connections.
Each SMTP connection maintains a finite state machine that tracks its progress through the protocol:
State transitions are validated strictly according to RFC 5321, with appropriate error responses for out-of-sequence commands.
Message content during the DATA phase is processed as a stream without buffering
the entire message in memory. The protocol handles dot-stuffing, CRLF
normalisation, and termination detection (CRLF.CRLF) incrementally
as data arrives, allowing arbitrarily large messages to be processed efficiently.
Gumdrop separates protocol mechanics from business logic through the
SMTPConnectionHandler interface. The handler receives high-level
events about the SMTP session and makes policy decisions without dealing with
protocol syntax, response codes, or network I/O.
Key handler methods:
connected(SMTPConnectionMetadata) - connection established,
return false to reject earlyhello(extended, clientDomain, HelloCallback) - HELO/EHLO receivedtlsStarted(SMTPConnectionMetadata) - STARTTLS completedauthenticated(user, method) - AUTH succeededmailFrom(senderAddress, MailFromCallback) - sender evaluationrcptTo(recipientAddress, RcptToCallback) - recipient validationstartData(SMTPConnectionMetadata, DataStartCallback) - DATA initiationmessageContent(ByteBuffer) - streaming message dataendData(SMTPConnectionMetadata, DataEndCallback) - message completereset() - RSET command, clear transaction statedisconnected() - connection closedPolicy methods use callbacks rather than return values, enabling asynchronous evaluation. A handler can consult external services—databases, reputation systems, content filters—without blocking the I/O thread. The callback must be invoked on the connection's SelectorLoop thread:
@Override
public void mailFrom(final String sender, final MailFromCallback callback) {
final SelectorLoop loop = connection.getSelectorLoop();
// Offload reputation check to worker thread
executor.submit(new Runnable() {
@Override
public void run() {
final SenderPolicyResult result = reputationService.checkSender(sender);
// Re-dispatch callback to the connection's SelectorLoop thread
loop.invokeLater(new Runnable() {
@Override
public void run() {
callback.mailFromReply(result);
}
});
}
});
}
The SMTP connection automatically sends the appropriate protocol response when the callback is invoked. Invoking the callback on the correct thread ensures thread safety for all subsequent protocol operations.
Handlers receive rich context through SMTPConnectionMetadata:
Handlers are created per-connection via a factory, ensuring thread safety and state isolation:
SMTPServer server = new SMTPServer();
server.setHandlerFactory(new SMTPConnectionHandlerFactory() {
@Override
public SMTPConnectionHandler createHandler() {
return new MyMailHandler(config, dependencies);
}
});
Gumdrop provides a generic SMTPPipeline interface for processing
messages as they stream through the server. This enables pluggable processing
without modifying core SMTP code.
The pipeline receives notifications at key SMTP stages:
public interface SMTPPipeline {
void mailFrom(EmailAddress sender); // MAIL FROM received
void rcptTo(EmailAddress recipient); // RCPT TO received
WritableByteChannel getMessageChannel(); // Called at DATA start
void endData(); // Message complete
void reset(); // Transaction reset
}
When a pipeline is associated with an SMTPConnection:
WritableByteChannel as they arriveendData() is called for final processingThis design supports various use cases:
The WritableByteChannel is a standard Java interface that clearly
expresses streaming semantics:
write(ByteBuffer) - receive message bytesclose() - signal end of streamisOpen() - check channel stateThis allows pipelines to process arbitrarily large messages without buffering the entire content in memory.
The org.bluezoo.gumdrop.smtp.auth package provides
AuthPipeline, an implementation of SMTPPipeline
that performs SPF, DKIM, and DMARC validation.
When associated with a connection, AuthPipeline automatically:
The following diagram shows how data flows through the AuthPipeline at each stage, including the optional user MessageHandler for receiving teed content:
Configure the pipeline with callbacks for the checks you care about:
AuthPipeline.Builder builder = new AuthPipeline.Builder(resolver, clientIP, heloHost);
// SPF callback - invoked during MAIL FROM processing
builder.onSPF(new SPFCallback() {
@Override
public void onResult(SPFResult result, String explanation) {
if (result == SPFResult.FAIL) {
mailFromCallback.mailFromReply(SenderPolicyResult.REJECT);
} else {
mailFromCallback.mailFromReply(SenderPolicyResult.ACCEPT);
}
}
});
// DKIM callback - invoked at end-of-data
builder.onDKIM(new DKIMCallback() {
@Override
public void onResult(DKIMResult result, String domain, String selector) {
// Store result for logging
}
});
// DMARC callback - invoked after DKIM
builder.onDMARC(new DMARCCallback() {
@Override
public void onResult(DMARCResult result, DMARCPolicy policy, String domain) {
// Can now respond to DATA command
if (result == DMARCResult.FAIL && policy == DMARCPolicy.REJECT) {
dataEndCallback.reply(DataEndReply.REJECT);
} else {
dataEndCallback.reply(DataEndReply.ACCEPT);
}
}
});
// Or use combined result callback
builder.onComplete(new AuthResultCallback() {
@Override
public void onResult(AuthResult result) {
if (result.shouldReject()) {
dataEndCallback.reply(DataEndReply.REJECT);
} else {
dataEndCallback.reply(DataEndReply.ACCEPT);
}
}
});
// Associate with connection
connection.setPipeline(builder.build());
To process message content while authentication runs in parallel, register
a MessageHandler:
builder.messageHandler(new MessageHandler() {
@Override
public void header(String name, String value) {
// Receive parsed headers
}
@Override
public void addressHeader(String name, List<EmailAddress> addresses) {
// Receive parsed address headers (From, To, Cc, etc.)
}
@Override
public void bodyContent(ByteBuffer data) {
// Receive decoded body content
}
});
The handler receives events from a composite handler that also feeds the authentication checks. This avoids parsing the message twice while allowing your application to process content.
AuthResult contains individual SPF, DKIM, and DMARC results plus
a combined AuthVerdict:
Convenience methods: result.passed(), result.shouldReject(),
result.shouldQuarantine().
The DKIMSigner class generates DKIM-Signature headers for outbound
messages. It supports both rsa-sha256 (RFC 6376 §3.3) and
ed25519-sha256 (RFC 8463) algorithms, with configurable header and body
canonicalization (simple or relaxed).
DKIMSigner signer = new DKIMSigner(privateKey, "example.com", "sel1");
signer.setAlgorithm("rsa-sha256");
signer.setHeaderCanonicalization("relaxed");
signer.setBodyCanonicalization("relaxed");
signer.setSignedHeaders(Arrays.asList("from", "to", "subject", "date"));
// Feed body lines in wire format
signer.bodyLine(bodyBytes, 0, bodyBytes.length);
signer.endBody();
// Sign headers and get the DKIM-Signature header
String dkimHeader = signer.sign(rawHeaderLines);
// Prepend dkimHeader to the message before sending
Both the signer and verifier support Ed25519-SHA256 signatures. The verifier
correctly handles raw 32-byte public keys in DNS TXT records as specified by
RFC 8463 §4, converting them to Java EdECPublicKeySpec for
verification. For signing, pass an Ed25519 private key and set
the algorithm to "ed25519-sha256".
The DMARCAggregateReport class generates XML aggregate reports
conforming to the schema in RFC 7489 Appendix C. Reports can be sent to
the domain owner's rua= address (now parsed from DMARC records).
DMARCAggregateReport report = new DMARCAggregateReport();
report.setReporterOrgName("receiver.example.com");
report.setReporterEmail("dmarc-reports@receiver.example.com");
report.setReportId("report-12345");
report.setDateRange(beginEpoch, endEpoch);
// Record results for each evaluated message
report.addResult(sourceIP, headerFrom, policy, adkim, aspf,
dmarcResult, spfResult, spfDomain, dkimResult, dkimDomain, selector);
// Write the XML report
report.writeXML(outputStream);
The DMARCValidator parses the rua=,
ruf=, fo=, and rf= tags from DMARC
records. After evaluation, call getLastRua() /
getLastRuf() to obtain report destination URIs, and
getLastFo() to retrieve the failure reporting options.
The DMARCForensicReport class generates per-message failure
reports in the Abuse Reporting Format (ARF, RFC 5965) with DMARC-specific
extensions (RFC 6591). Reports are multipart/report MIME
messages with three parts: a human-readable description, a machine-readable
message/feedback-report, and the original message headers.
// Check if a forensic report should be generated
String fo = validator.getLastFo();
if (DMARCForensicReport.shouldReport(fo, spfResult, dkimResult, dmarcResult)) {
DMARCForensicReport report = new DMARCForensicReport();
report.setReporterDomain("receiver.example.com");
report.setSourceIP(clientIP);
report.setHeaderFrom(fromDomain);
report.setDmarcResult(dmarcResult);
report.setDmarcPolicy(policy);
report.setSpfResult(spfResult);
report.setDkimResult(dkimResult);
report.setOriginalHeaders(messageHeaders);
report.writeMIME(outputStream, boundary);
}
The fo= tag controls when reports are generated:
0 (default) = all mechanisms fail,
1 = any mechanism fails,
d = DKIM fails,
s = SPF fails. Multiple options may be colon-separated.
Gumdrop provides comprehensive policy evaluation through typed result enums that abstract SMTP response codes.
SenderPolicyResult for MAIL FROM evaluation:
RecipientPolicyResult for RCPT TO evaluation:
The SMTP server performs connection-level filtering before any protocol exchange:
Blocked connections are rejected immediately without consuming handler resources.
Gumdrop supports the full range of SMTP AUTH mechanisms:
All mechanisms are fully implemented on the server side, including multi-round
challenge-response exchanges (CRAM-MD5, DIGEST-MD5, SCRAM-SHA-256) and
token-based authentication (OAUTHBEARER with JSON error challenges per
RFC 7628 §3.2.2). If a client sends * during an AUTH exchange,
the server cancels the attempt and responds with 501 (RFC 4954 §4).
Authentication integrates with Gumdrop's centralised
org.bluezoo.gumdrop.auth.Realm facility for unified credential
management across all servers.
The server supports opportunistic TLS upgrade via STARTTLS (RFC 3207). When a keystore is configured, STARTTLS is advertised in EHLO responses. After successful TLS negotiation, the SMTP session resets and the client must re-issue EHLO.
Beyond the standard ESMTP capabilities, Gumdrop supports several extensions for enhanced functionality:
The BDAT command provides an alternative to DATA for transmitting message
content. Instead of using dot-stuffing and CRLF.CRLF termination,
BDAT specifies an exact byte count:
BDAT 1000 <exactly 1000 bytes of content> BDAT 500 LAST <exactly 500 bytes of content>
Benefits of CHUNKING:
The handler receives message content through the same messageContent()
method regardless of whether DATA or BDAT was used—the protocol difference is
handled entirely by SMTPConnection.
BINARYMIME extends CHUNKING to allow raw binary content without any encoding. The sender declares binary content via the BODY parameter:
MAIL FROM:<sender@example.com> BODY=BINARYMIME
When BODY=BINARYMIME is declared, the message MUST be transmitted using BDAT because the DATA command's dot-stuffing could corrupt binary data. The server enforces this—if a client attempts to use DATA after declaring BINARYMIME, the server rejects with a 503 error.
The BODY parameter supports three values:
The handler does not need to know about BODY type—the difference is entirely a transport concern handled by SMTPConnection.
Internationalized Email allows UTF-8 characters in email addresses, including the local-part, domain, and display names. When a client includes the SMTPUTF8 parameter with MAIL FROM, the server accepts addresses like:
MAIL FROM:<用户@例え.jp> SMTPUTF8 RCPT TO:<почта@пример.рф>
The smtputf8 flag is passed to the handler via the
mailFrom() callback, allowing the handler to configure message
parsing appropriately. The MessageParser class provides
setSmtputf8(boolean) for this purpose.
XCLIENT allows authorized mail proxies and content filters to pass original client connection information to the MTA. This is essential when mail passes through intermediary systems that need to preserve the original client's identity for logging, policy evaluation, and authentication checks.
Supported XCLIENT attributes:
After XCLIENT, the session resets to the greeting state and the client must
re-issue EHLO. The overridden values are reflected in
SMTPConnectionMetadata for policy decisions.
XCLIENT is only available to authorized clients. The handler controls
authorization through the isXclientAuthorized(InetAddress) method,
which returns false by default. Enable it only for trusted hosts:
@Override
public boolean isXclientAuthorized(InetAddress clientAddress) {
// Allow localhost and internal mail gateways
return clientAddress.isLoopbackAddress() ||
clientAddress.getHostAddress().startsWith("10.0.1.");
}
DSN allows senders to request notification of message delivery status. The sender can specify when to receive notifications and how much of the original message to include in the notification.
MAIL FROM parameters:
RCPT TO parameters:
Example transaction with DSN:
MAIL FROM:<sender@example.com> RET=HDRS ENVID=msg12345 RCPT TO:<user@example.org> NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;user@example.org
The DSN parameters are accessible through SMTPConnectionMetadata:
getDSNEnvelopeParameters() - returns RET and ENVIDgetDSNRecipientParameters(EmailAddress) - returns NOTIFY and ORCPT for a recipientThe MTA is responsible for generating actual DSN messages based on these parameters when delivery succeeds, fails, or is delayed.
The LIMITS extension allows the server to advertise operational limits upfront in the EHLO response. This helps clients avoid sending commands that would be rejected due to limits.
Advertised limits:
Example EHLO response with LIMITS:
250-LIMITS RCPTMAX=100 MAILMAX=50
Configuration via SMTPServer:
setMaxRecipients(int) - set RCPTMAX limitsetMaxTransactionsPerSession(int) - set MAILMAX limit (0 = unlimited)When limits are exceeded:
Several SMTP extensions allow senders to specify requirements for how messages
should be delivered. These are collected in the DeliveryRequirements
interface, which is passed to the handler's mailFrom() callback:
Indicates that the message must only be transmitted over TLS-protected connections. If TLS cannot be guaranteed for any hop, the message should bounce.
MAIL FROM:<sender@example.com> REQUIRETLS
The server automatically supports REQUIRETLS when TLS is available. If specified
on a non-TLS connection, the server returns 530 5.7.10 REQUIRETLS requires
TLS connection.
Specifies message transfer priority from -9 (lowest) to +9 (highest), with 0 being normal priority. MTAs may use this to prioritize queue processing.
MAIL FROM:<sender@example.com> MT-PRIORITY=5
Requests that the message be held for later delivery. Two forms are supported:
MAIL FROM:<sender@example.com> HOLDFOR=3600 MAIL FROM:<sender@example.com> HOLDUNTIL=2025-12-25T00:00:00Z
HOLDFOR specifies seconds from now; HOLDUNTIL specifies
an ISO 8601 timestamp.
Specifies a delivery deadline. If the message cannot be delivered in time, it should be returned (R) or a delay DSN sent (N):
MAIL FROM:<sender@example.com> BY=86400;R MAIL FROM:<sender@example.com> BY=86400;N
All delivery requirements are available via DeliveryRequirements:
@Override
public void mailFrom(EmailAddress sender, boolean smtputf8,
DeliveryRequirements delivery, MailFromCallback callback) {
if (delivery.isRequireTls()) {
// Must relay over TLS only
}
if (delivery.hasPriority()) {
int priority = delivery.getPriority(); // -9 to +9
}
if (delivery.isFutureRelease()) {
Instant releaseTime = delivery.getReleaseTime();
}
if (delivery.hasDeliverByDeadline()) {
Instant deadline = delivery.getDeliverByDeadline();
boolean returnOnFail = delivery.isDeliverByReturn();
}
if (delivery.hasDsnParameters()) {
DSNReturn ret = delivery.getDsnReturn();
String envid = delivery.getDsnEnvelopeId();
}
callback.mailFromReply(SenderPolicyResult.ACCEPT);
}
SMTP services (org.bluezoo.gumdrop.smtp.SMTPService subclasses) support:
realm – reference to a Realm for authenticationmailbox-factory – reference to a MailboxFactory (for local delivery)max-message-size – maximum message size in bytes (default: ~35MB)max-recipients – maximum recipients per transaction (default: 100)max-transactions-per-session – maximum MAIL FROM commands per session (default: 0/unlimited)auth-required – require authentication (for submission port)
Each <listener> element within the service configures a listener:
port – listening port (default: 25, or 465 for secure)secure – enable implicit TLS (SMTPS)keystore-file – keystore for TLS/STARTTLSkeystore-pass – keystore passwordmax-connections-per-ip – concurrent connection limit per IP (default: 10)allowed-networks – CIDR networks to allow (e.g., "192.168.0.0/16")blocked-networks – CIDR networks to block
<!-- Authentication realm -->
<realm id="mailRealm" class="org.bluezoo.gumdrop.auth.BasicRealm">
<property name="href">mail-users.xml</property>
</realm>
<!-- SMTP service with MX and submission listeners -->
<service class="com.example.MyMailService">
<property name="realm" ref="#mailRealm"/>
<property name="max-message-size">52428800</property>
<!-- MX listener (port 25) -->
<listener class="org.bluezoo.gumdrop.smtp.SMTPListener"
name="mx" port="25"
keystore-file="keystore.p12" keystore-pass="secret"
max-connections-per-ip="20"
blocked-networks="10.0.0.0/8"/>
<!-- Submission listener (port 587, auth required) -->
<listener class="org.bluezoo.gumdrop.smtp.SMTPListener"
name="submission" port="587"
keystore-file="keystore.p12" keystore-pass="secret"
auth-required="true"/>
<!-- UNIX domain socket for local delivery agents -->
<listener class="org.bluezoo.gumdrop.smtp.SMTPListener"
name="local" path="/var/run/smtp.sock"/>
</service>
Gumdrop includes a non-blocking SMTP client that shares the same event-driven architecture as the server. The client is designed for integration into MTA forward delivery components and service-to-service communication.
When an SMTP client connection is initiated from within a Gumdrop server context—such as a mail relay or gateway—it can be assigned to the same SelectorLoop as the originating server connection. This provides the same benefits described for the HTTP client:
This makes the SMTP client particularly well-suited for MTA implementations where inbound mail must be relayed to downstream servers. The relay operation executes entirely within the I/O worker without blocking.
setTrustManager(X509TrustManager)
for outbound TLS; use PinnedCertTrustManager for SHA-256
fingerprint pinningMailFromParams
The client is driven by an SMTPClientHandler that receives
protocol events:
SMTPClient client = new SMTPClient("mail.example.com", 25);
client.connect(new SMTPClientHandler() {
@Override
public void onConnected() {
// TCP connection established, awaiting greeting
}
@Override
public void onGreeting(SMTPResponse greeting, SMTPClientProtocolHandler handler) {
handler.ehlo("myhost.example.com");
}
@Override
public void onReply(SMTPResponse response, SMTPClientProtocolHandler handler) {
// Handle command responses
if (response.isSuccess()) {
// Proceed with next command
}
}
@Override
public void onTLSStarted() {
// TLS negotiation complete, re-issue EHLO
}
@Override
public void onError(SMTPException error) {
// Handle protocol or connection errors
}
@Override
public void onDisconnected() {
// Connection closed
}
});
Message content is sent as a stream with automatic dot-stuffing:
// After MAIL FROM and RCPT TO accepted, DATA command sent conn.data(); // Sends DATA command // In onReply(), when 354 received: ByteBuffer content = getMessageContent(); conn.messageContent(content); // Dot-stuffed automatically conn.endData(); // Sends CRLF.CRLF terminator
The SMTP server integrates with Gumdrop's OpenTelemetry implementation for distributed tracing. When telemetry is enabled, the server creates traces and spans that provide visibility into mail flow.
Session spans capture:
smtp.session_number - session index within connectionsmtp.client_hostname - HELO/EHLO hostnamesmtp.esmtp_mode - EHLO vs HELOsmtp.mail_from - sender addresssmtp.auth.mechanism - authentication method if usedsmtp.auth.user - authenticated usernameTelemetry data is exported to an OpenTelemetry Collector using the OTLP/HTTP protocol. The exporter uses Gumdrop's native HTTP client with SelectorLoop affinity for efficient, non-blocking delivery.
← Back to Main Page | HTTP Server & Client | IMAP Server | POP3 Server | DNS Server | Telemetry
Gumdrop SMTP Server