SmtpProperties.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.configuration;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.time.Duration;
import java.util.Arrays;
/**
* Bindings for {@code entrystore.smtp.*}, consumed by {@code MailConfiguration} to build the
* {@code JavaMailSender} and by {@code EmailSender} for the unconfigured-SMTP guard and the default
* From/Bcc/Reply-To addresses.
*
* <p><b>The transport-security key.</b> {@code entrystore.smtp.security} is canonical, but
* {@code entrystore.properties_example} documented {@code entrystore.smtp.ssl} instead — a key nothing
* ever read — up to and including 5.6.2, and still does in the Spring Boot module's unreleased copies.
* A deployment configured from one of those files was therefore sending plaintext SMTP with no warning
* at all. 5.7.0 through 5.8.0 documented the canonical key correctly ({@code 26fb63af}, April 2025).
* Both spellings bind; {@code security} wins whenever it resolves, and {@code ssl} is read only as a
* fallback. {@link #effectiveSecurity()} is the only accessor that answers "what transport security is
* actually applied" — {@link #security()} and {@link #ssl()} return the raw configured strings and must
* not be compared against directly.
*
* <p>Both keys are parsed leniently, and identically. Neither was validated before 6.1: {@code ssl}
* was never read at all, and {@code security} — present as {@code Settings.SMTP_SECURITY} at {@code 4.1},
* the earliest tag in this repository — was compared with {@code equalsIgnoreCase}, so any unrecognised
* value in <em>either</em> key silently meant plaintext.
*
* <p><b>An unrecognised value does not resolve to plaintext.</b> Binding stays lenient, so startup is
* never aborted over mail configuration — that would turn a mail-only misconfiguration into a total
* outage of the REST API. But leniency is not the same as failing open: a value this class cannot
* resolve leaves {@link #securityIsUnresolved()} true, and {@code EmailSender} refuses to send at all
* rather than sending in the clear under a configuration the operator believes is encrypted. The
* offending value is reported at ERROR here, and {@code MailConfiguration} warns when a configured host
* ends up on plaintext without the operator having declared it.
*
* <p>Recognised spellings are canonicalised the way Spring Boot's relaxed enum binding canonicalises —
* letters and digits only, lowercased — so {@code STARTTLS}, {@code start-tls} and the
* {@code ENTRYSTORE_SMTP_SECURITY=START_TLS} environment-variable form all resolve alike.
*
* <p>The timeouts and {@code check-server-identity} are configurable as of 6.1; the defaults here are
* the values EntryStore 6.0 compiled in, so an existing deployment sees no change.
*/
@ConfigurationProperties(prefix = "entrystore.smtp")
public record SmtpProperties(
String host,
@DefaultValue("25") int port,
String security,
String ssl,
String username,
String password,
@DefaultValue("true") boolean checkServerIdentity,
@DefaultValue("5s") Duration connectionTimeout,
@DefaultValue("5s") Duration readTimeout,
@DefaultValue("5s") Duration writeTimeout,
@DefaultValue("3") int maxSendAttempts,
Addresses email
) {
private static final Logger log = LoggerFactory.getLogger(SmtpProperties.class);
private static final String SECURITY_KEY = "entrystore.smtp.security";
private static final String SSL_KEY = "entrystore.smtp.ssl";
private static final String USERNAME_KEY = "entrystore.smtp.username";
private static final String PASSWORD_KEY = "entrystore.smtp.password";
/** Upper bound on send attempts: the loop has no backoff and can run on a request thread. */
private static final int MAX_SEND_ATTEMPTS_CEILING = 10;
/**
* Worst-case per-send blocking above which the timeout / attempt-count combination is reported.
* The compiled-in 6.0 defaults come to 135s (see {@link #worstCaseSendDuration}), so a deployment
* that changes nothing stays silent.
*/
private static final Duration BLOCKING_BUDGET_WARN_THRESHOLD = Duration.ofSeconds(180);
/**
* Blocking reads one SMTP conversation performs, used to scale {@code read-timeout} in the
* worst-case estimate. {@code mail.smtp.timeout} bounds an individual read, not the exchange: the
* greeting, EHLO, MAIL FROM, RCPT TO, DATA, the end-of-data dot and QUIT each block on their own
* response, so charging the read timeout once per attempt understated the figure several-fold.
*/
private static final int BLOCKING_READS_PER_SEND = 7;
private static final String LEGAL_SECURITY_VALUES = "starttls|ssl|off";
public SmtpProperties {
if (email == null) {
email = new Addresses(null, null, null);
}
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("entrystore.smtp.port must be between 1 and 65535, got " + port);
}
if (hasText(username) != hasText(password)) {
// Report and carry on rather than abort, matching the pre-6.1 behaviour: a half-set pair
// still delivered mail anonymously. A missing credential fails closed on its own, so there
// is nothing to gain from taking the whole application down over it.
//
// The components are deliberately left as bound. hasCredentials() already requires both, so
// nothing reaches the sender either way, and keeping them means /actuator/configprops still
// shows the key as bound-and-discarded rather than as unbound — which would point an
// operator at a binding problem instead of at the empty value they actually set.
log.error("{} and {} must both be set; only one is, so SMTP authentication is disabled",
USERNAME_KEY, PASSWORD_KEY);
}
requirePositive(connectionTimeout, "entrystore.smtp.connection-timeout");
requirePositive(readTimeout, "entrystore.smtp.read-timeout");
requirePositive(writeTimeout, "entrystore.smtp.write-timeout");
if (maxSendAttempts < 1 || maxSendAttempts > MAX_SEND_ATTEMPTS_CEILING) {
throw new IllegalArgumentException("entrystore.smtp.max-send-attempts must be between 1 and "
+ MAX_SEND_ATTEMPTS_CEILING + ", got " + maxSendAttempts);
}
// Reported once here, at bind time, so effectiveSecurity() can stay pure and be called freely.
reportTransportSecurity(host, security, ssl);
reportBlockingBudget(connectionTimeout, readTimeout, writeTimeout, maxSendAttempts);
}
/**
* What the two transport-security keys resolve to, and which key decided it. The single source of
* the precedence rule: {@link #effectiveSecurity()}, {@link #securityIsUnresolved()} and
* {@link #reportTransportSecurity} all read it, so the rule cannot drift between the diagnostics
* and the value actually applied.
*
* @param value the resolved setting, {@link SmtpSecurity#OFF} when neither key is set and when
* neither resolves
* @param unresolved true when a key was set but no key resolved, i.e. plaintext was never declared
*/
private record Resolution(SmtpSecurity value, boolean unresolved) {}
private static Resolution resolve(@Nullable String security, @Nullable String ssl) {
SmtpSecurity canonical = parseSecurity(security);
if (canonical != null) {
return new Resolution(canonical, false);
}
SmtpSecurity alias = parseSecurity(ssl);
if (alias != null) {
return new Resolution(alias, false);
}
return new Resolution(SmtpSecurity.OFF, hasText(security) || hasText(ssl));
}
/**
* Reports what was configured for the two transport-security keys. Only value problems are
* reported here; the deprecation of {@code entrystore.smtp.ssl} and the "configured host is on
* plaintext" warning belong to {@code MailConfiguration}, which knows whether the resolved value
* was declared or merely defaulted.
*
* <p>Silent when no host is configured: a stale value in a deployment that never sends mail would
* otherwise log ERROR on every boot, which ERROR-based alerting cannot tell apart from a live relay
* whose transport security is broken.
*/
private static void reportTransportSecurity(@Nullable String host, @Nullable String security,
@Nullable String ssl) {
if (!hasText(host)) {
return;
}
SmtpSecurity canonical = parseSecurity(security);
SmtpSecurity alias = parseSecurity(ssl);
if (hasText(security) && canonical == null) {
log.error("{}='{}' is not one of {} and cannot be applied; no mail will be sent while it stays "
+ "set, rather than being sent unencrypted.",
SECURITY_KEY, security, LEGAL_SECURITY_VALUES);
}
// Only reported when the canonical key did not already decide the outcome, so the migration
// path the documentation prescribes — add `security` next to a stale `ssl` — stays quiet.
if (canonical == null && hasText(ssl) && alias == null) {
log.error("{}='{}' is not one of {} and cannot be applied; no mail will be sent while it stays "
+ "set. Set {} instead.", SSL_KEY, ssl, LEGAL_SECURITY_VALUES, SECURITY_KEY);
}
if (canonical != null && alias != null && alias != canonical) {
log.warn("{}={} and the deprecated {}={} disagree; {} wins. Remove {}.",
SECURITY_KEY, security, SSL_KEY, ssl, SECURITY_KEY, SSL_KEY);
}
}
/**
* Warns when the timeouts and attempt count multiply out to a long block. Each key is individually
* bounded, but their product is not: {@code connection-timeout=60s} with {@code max-send-attempts=10}
* passes every other check and still blocks for minutes.
*
* <p>Reported rather than rejected. Aborting the bind would make a mail-only misconfiguration a
* startup outage, which is the failure mode the credential handling above deliberately avoids.
*/
private static void reportBlockingBudget(Duration connectionTimeout, Duration readTimeout,
Duration writeTimeout, int maxSendAttempts) {
Duration worstCase = worstCaseSendDuration(connectionTimeout, readTimeout, writeTimeout, maxSendAttempts);
if (worstCase.compareTo(BLOCKING_BUDGET_WARN_THRESHOLD) > 0) {
log.warn("entrystore.smtp.max-send-attempts={} combined with the configured timeouts "
+ "({}/{}/{} connect/read/write) lets a single send block for at least {}s — the "
+ "read timeout bounds each of the {} or so reads in one SMTP exchange, not the "
+ "exchange — plus DNS resolution, which no timeout covers. Signup, POST /message "
+ "and both password-change confirmations send on the request thread; password-reset "
+ "mail occupies a passwordResetExecutor slot for the same duration.",
maxSendAttempts, connectionTimeout, readTimeout, writeTimeout, worstCase.toSeconds(),
BLOCKING_READS_PER_SEND);
}
}
/**
* Parses either transport-security key, returning null when it is unset or unrecognised. Callers
* distinguish the two cases with {@link #hasText}; the diagnostics are emitted once at bind time by
* {@link #reportTransportSecurity}, so this stays side-effect-free.
*
* <p>Canonicalised the way Spring Boot's {@code LenientObjectToEnumConverterFactory} canonicalises,
* so every spelling that reached this enum while it was bound as an enum still resolves. Matching
* the constant name exactly would silently demote {@code ENTRYSTORE_SMTP_SECURITY=START_TLS} — the
* screaming-snake form relaxed binding accepts for every other key — to plaintext.
*/
private static @Nullable SmtpSecurity parseSecurity(@Nullable String value) {
if (!hasText(value)) {
return null;
}
String normalized = canonicalize(value);
return Arrays.stream(SmtpSecurity.values())
.filter(candidate -> canonicalize(candidate.name()).equals(normalized))
.findFirst()
.orElse(null);
}
private static String canonicalize(String value) {
StringBuilder canonical = new StringBuilder(value.length());
value.codePoints()
.filter(Character::isLetterOrDigit)
.map(Character::toLowerCase)
.forEach(canonical::appendCodePoint);
return canonical.toString();
}
private static void requirePositive(Duration value, String key) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(key + " must be positive, got " + value);
}
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
private static Duration worstCaseSendDuration(Duration connectionTimeout, Duration readTimeout,
Duration writeTimeout, int maxSendAttempts) {
return connectionTimeout
.plus(readTimeout.multipliedBy(BLOCKING_READS_PER_SEND))
.plus(writeTimeout)
.multipliedBy(maxSendAttempts);
}
/**
* The transport security actually applied: the canonical {@code entrystore.smtp.security} whenever
* it resolves, otherwise the deprecated {@code entrystore.smtp.ssl}, otherwise
* {@link SmtpSecurity#OFF}. This — not {@link #security()} or {@link #ssl()}, which return the raw
* configured strings — is what every call site must read.
*
* <p>{@link SmtpSecurity#OFF} here does not by itself mean "plaintext is fine": pair it with
* {@link #securityIsUnresolved()}, which separates a declared or defaulted plaintext relay from a
* value that could not be applied at all.
*/
public SmtpSecurity effectiveSecurity() {
return resolve(security, ssl).value();
}
/**
* True when a transport-security key is set but neither key resolves, so plaintext was never
* declared — only arrived at by failing to understand the configuration. {@code EmailSender}
* refuses to send in that state: an operator who wrote {@code security=tls} believes the connection
* is encrypted, and quietly relaying password-reset links in the clear is the worse outcome.
*/
public boolean securityIsUnresolved() {
return resolve(security, ssl).unresolved();
}
/**
* True when plaintext is what the operator asked for, as opposed to what was left unset. This is
* what lets {@code MailConfiguration}'s "transport security is off" warning be silenced by
* {@code entrystore.smtp.security=off}, exactly as that warning advertises.
*/
public boolean plaintextIsDeclared() {
Resolution resolution = resolve(security, ssl);
return resolution.value() == SmtpSecurity.OFF && !resolution.unresolved()
&& (hasText(security) || hasText(ssl));
}
/**
* Worst-case blocking for one {@code sendMessage} call, excluding DNS resolution — which
* {@code mail.smtp.connectiontimeout} does not cover, so the real worst case is higher still.
* Package-private: this exists for the startup diagnostic and the test that pins its arithmetic,
* not as an API.
*/
Duration worstCaseSendDuration() {
return worstCaseSendDuration(connectionTimeout, readTimeout, writeTimeout, maxSendAttempts);
}
/** True when the deprecated {@code entrystore.smtp.ssl} key is present, whatever its value. */
public boolean usesDeprecatedSslKey() {
return hasText(ssl);
}
/**
* Both credentials must be present, matching the pre-6.1 condition that decided between an
* authenticating and an anonymous session. A half-set pair is reported at bind time and left bound;
* requiring both here is what keeps it from reaching the sender, so this is false whenever
* authentication is not fully configured.
*/
public boolean hasCredentials() {
return hasText(username) && hasText(password);
}
/** False when no SMTP host is configured, in which case no send is attempted at all. */
public boolean isConfigured() {
return hasText(host);
}
/**
* Bindings for {@code entrystore.smtp.email.*}. {@code from} and {@code bcc} fall back to the
* deprecated {@code entrystore.auth.email.*} keys — see {@code DeprecatedEmailAddressProperties}.
*
* <p>Deliberately carries no address-format invariant, unlike the fail-fast checks above for port and
* the timeouts. The deprecated fallback means the effective value is only known once both sources have
* been resolved, and rejecting a malformed address here would abort startup over a mail-only
* misconfiguration. {@code EmailSender}'s constructor validates each resolved address instead, logging
* an ERROR that names the key it actually came from.
*/
public record Addresses(String from, String bcc, String replyTo) {}
public enum SmtpSecurity {
/** Plaintext SMTP. */
OFF,
/** Upgrade an established plaintext connection, requiring the upgrade to succeed. */
STARTTLS,
/** Implicit TLS from connect (SMTPS). */
SSL
}
}