Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
* The validation occurs after DKIM signing and checks:
* <ul>
* <li><b>DKIM</b>: Verifies that the selector used in DKIM-Signature header has a valid DNS record</li>
* <li><b>SPF</b>: Ensures the domain's SPF record authorizes the configured server IPs</li>
* <li><b>SPF</b>: Ensures the domain's SPF record includes TMail's SPF configuration via include mechanism</li>
* <li><b>DMARC</b>: Validates that a DMARC policy exists with minimum quarantine level</li>
* </ul>
* </p>
Expand All @@ -60,7 +60,7 @@
* <li><b>validateDkim</b>: Enable DKIM validation (default: true)</li>
* <li><b>validateSpf</b>: Enable SPF validation (default: true)</li>
* <li><b>validateDmarc</b>: Enable DMARC validation (default: true)</li>
* <li><b>spfAuthorizedIps</b>: Comma-separated list of IPs that must be authorized in SPF (e.g., "192.0.2.10,198.51.100.5")</li>
* <li><b>spfInclude</b>: SPF include domain that must be present in customer's SPF (e.g., "_spf.tmail.com")</li>
* <li><b>dmarcMinPolicy</b>: Minimum DMARC policy required (quarantine or reject, default: quarantine)</li>
* </ul>
* </p>
Expand All @@ -74,7 +74,7 @@
* <validateDkim>true</validateDkim>
* <validateSpf>true</validateSpf>
* <validateDmarc>true</validateDmarc>
* <spfAuthorizedIps>192.0.2.10,198.51.100.5</spfAuthorizedIps>
* <spfInclude>_spf.tmail.com</spfInclude>
* <dmarcMinPolicy>quarantine</dmarcMinPolicy>
* </mailet>
* }
Expand All @@ -91,7 +91,7 @@ public class DomainDnsValidator extends GenericMailet {
private static final String VALIDATE_DKIM_PARAM = "validateDkim";
private static final String VALIDATE_SPF_PARAM = "validateSpf";
private static final String VALIDATE_DMARC_PARAM = "validateDmarc";
private static final String SPF_AUTHORIZED_IPS_PARAM = "spfAuthorizedIps";
private static final String SPF_INCLUDE_PARAM = "spfInclude";
private static final String DMARC_MIN_POLICY_PARAM = "dmarcMinPolicy";

private static final String DKIM_SIGNATURE_HEADER = "DKIM-Signature";
Expand All @@ -118,11 +118,11 @@ public void init() throws MessagingException {
validateDmarc = Boolean.parseBoolean(getInitParameter(VALIDATE_DMARC_PARAM, "true"));

if (validateSpf) {
String spfAuthorizedIps = getInitParameter(SPF_AUTHORIZED_IPS_PARAM);
if (Strings.isNullOrEmpty(spfAuthorizedIps)) {
throw new MessagingException("spfAuthorizedIps parameter is required when validateSpf is enabled");
String spfInclude = getInitParameter(SPF_INCLUDE_PARAM);
if (Strings.isNullOrEmpty(spfInclude)) {
throw new MessagingException("spfInclude parameter is required when validateSpf is enabled");
}
spfValidator = new SpfDnsValidator(dnsService, spfAuthorizedIps);
spfValidator = new SpfDnsValidator(dnsService, spfInclude);
}

if (validateDkim) {
Expand All @@ -145,59 +145,52 @@ public String getMailetInfo() {

@Override
public void service(Mail mail) throws MessagingException {
try {
MimeMessage message = mail.getMessage();
MimeMessage message = mail.getMessage();

// Extract DKIM signature information
Optional<DkimSignatureInfo> dkimInfo = extractDkimSignatureInfo(message);
// Extract DKIM signature information
Optional<DkimSignatureInfo> dkimInfo = extractDkimSignatureInfo(message);

if (!dkimInfo.isPresent()) {
String errorMsg = "No DKIM-Signature header found. Email must be DKIM signed before validation.";
LOGGER.warn("Mail {} rejected: {}", mail.getName(), errorMsg);
mail.setErrorMessage(errorMsg);
mail.setState(Mail.ERROR);
return;
}
if (!dkimInfo.isPresent()) {
String errorMsg = "No DKIM-Signature header found. Email must be DKIM signed before validation.";
LOGGER.warn("Mail {} rejected: {}", mail.getName(), errorMsg);
mail.setErrorMessage(errorMsg);
mail.setState(Mail.ERROR);
return;
}

String domain = dkimInfo.get().domain;
String selector = dkimInfo.get().selector;
String domain = dkimInfo.get().domain;
String selector = dkimInfo.get().selector;

LOGGER.debug("Validating DNS records for domain: {}, selector: {}", domain, selector);
LOGGER.debug("Validating DNS records for domain: {}, selector: {}", domain, selector);

// Validate DKIM DNS record
if (validateDkim) {
Optional<String> dkimError = dkimValidator.validate(domain, selector);
if (dkimError.isPresent()) {
handleValidationFailure(mail, "DKIM", dkimError.get());
return;
}
// Validate DKIM DNS record
if (validateDkim) {
Optional<DnsValidationFailure.DkimValidationFailure> dkimError = dkimValidator.validate(domain, selector);
if (dkimError.isPresent()) {
handleValidationFailure(mail, "DKIM", dkimError.get().message());
return;
}
}

// Validate SPF record
if (validateSpf) {
Optional<String> spfError = spfValidator.validate(domain);
if (spfError.isPresent()) {
handleValidationFailure(mail, "SPF", spfError.get());
return;
}
// Validate SPF record
if (validateSpf) {
Optional<DnsValidationFailure.SpfValidationFailure> spfError = spfValidator.validate(domain);
if (spfError.isPresent()) {
handleValidationFailure(mail, "SPF", spfError.get().message());
return;
}
}

// Validate DMARC record
if (validateDmarc) {
Optional<String> dmarcError = dmarcValidator.validate(domain);
if (dmarcError.isPresent()) {
handleValidationFailure(mail, "DMARC", dmarcError.get());
return;
}
// Validate DMARC record
if (validateDmarc) {
Optional<DnsValidationFailure.DmarcValidationFailure> dmarcError = dmarcValidator.validate(domain);
if (dmarcError.isPresent()) {
handleValidationFailure(mail, "DMARC", dmarcError.get().message());
return;
}

LOGGER.info("DNS validation passed for domain: {}", domain);

} catch (Exception e) {
LOGGER.error("Error during DNS validation for mail {}", mail.getName(), e);
mail.setErrorMessage("DNS validation failed: " + e.getMessage());
mail.setState(Mail.ERROR);
}

LOGGER.info("DNS validation passed for domain: {}", domain);
}

@VisibleForTesting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@
import java.util.Optional;

import org.apache.james.dnsservice.api.DNSService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.annotations.VisibleForTesting;

Expand All @@ -36,9 +34,6 @@
* </p>
*/
public class DkimDnsValidator {
private static final Logger LOGGER = LoggerFactory.getLogger(DkimDnsValidator.class);
private static final String DKIM_RECORD_PREFIX = "v=DKIM1";

private final DNSService dnsService;

public DkimDnsValidator(DNSService dnsService) {
Expand All @@ -50,38 +45,34 @@ public DkimDnsValidator(DNSService dnsService) {
*
* @param domain the domain to validate
* @param selector the DKIM selector
* @return Optional error message if validation fails, empty if validation succeeds
* @return Optional validation failure if validation fails, empty if validation succeeds
*/
public Optional<String> validate(String domain, String selector) {
public Optional<DnsValidationFailure.DkimValidationFailure> validate(String domain, String selector) {
String dkimRecordName = buildDkimRecordName(selector, domain);

try {
Collection<String> txtRecords = dnsService.findTXTRecords(dkimRecordName);

if (txtRecords == null || txtRecords.isEmpty()) {
String error = String.format("No DKIM record found at %s", dkimRecordName);
LOGGER.warn(error);
return Optional.of(error);
return Optional.of(new DnsValidationFailure.DkimValidationFailure(
String.format("No DKIM record found at %s", dkimRecordName)));
}

// Check if at least one record is a valid DKIM record
boolean hasValidDkimRecord = txtRecords.stream()
.anyMatch(this::isValidDkimRecord);

if (!hasValidDkimRecord) {
String error = String.format("DKIM record at %s does not contain valid DKIM signature (must start with v=DKIM1)",
dkimRecordName);
LOGGER.warn(error);
return Optional.of(error);
return Optional.of(new DnsValidationFailure.DkimValidationFailure(
String.format("DKIM record at %s does not contain valid DKIM signature (must start with v=DKIM1)",
dkimRecordName)));
}

LOGGER.debug("DKIM validation passed for {}", dkimRecordName);
return Optional.empty();

} catch (Exception e) {
String error = String.format("Failed to query DKIM record at %s: %s", dkimRecordName, e.getMessage());
LOGGER.error(error, e);
return Optional.of(error);
return Optional.of(new DnsValidationFailure.DkimValidationFailure(
String.format("Failed to query DKIM record at %s: %s", dkimRecordName, e.getMessage())));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@
import java.util.regex.Pattern;

import org.apache.james.dnsservice.api.DNSService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.annotations.VisibleForTesting;

Expand All @@ -38,7 +36,6 @@
* </p>
*/
public class DmarcDnsValidator {
private static final Logger LOGGER = LoggerFactory.getLogger(DmarcDnsValidator.class);
private static final String DMARC_PREFIX = "v=DMARC1";
private static final String DMARC_SUBDOMAIN = "_dmarc.";
private static final Pattern DMARC_POLICY_PATTERN = Pattern.compile("p=([^;\\s]+)");
Expand Down Expand Up @@ -81,18 +78,17 @@ public DmarcDnsValidator(DNSService dnsService, String minimumPolicyStr) {
* Validates the DMARC DNS record for the given domain.
*
* @param domain the domain to validate
* @return Optional error message if validation fails, empty if validation succeeds
* @return Optional validation failure if validation fails, empty if validation succeeds
*/
public Optional<String> validate(String domain) {
public Optional<DnsValidationFailure.DmarcValidationFailure> validate(String domain) {
String dmarcRecordName = buildDmarcRecordName(domain);

try {
Collection<String> txtRecords = dnsService.findTXTRecords(dmarcRecordName);

if (txtRecords == null || txtRecords.isEmpty()) {
String error = String.format("No DMARC record found at %s", dmarcRecordName);
LOGGER.warn(error);
return Optional.of(error);
return Optional.of(new DnsValidationFailure.DmarcValidationFailure(
String.format("No DMARC record found at %s", dmarcRecordName)));
}

// Find DMARC record
Expand All @@ -101,35 +97,30 @@ public Optional<String> validate(String domain) {
.findFirst();

if (!dmarcRecord.isPresent()) {
String error = String.format("No valid DMARC record found at %s (must start with v=DMARC1)", dmarcRecordName);
LOGGER.warn(error);
return Optional.of(error);
return Optional.of(new DnsValidationFailure.DmarcValidationFailure(
String.format("No valid DMARC record found at %s (must start with v=DMARC1)", dmarcRecordName)));
}

// Extract and validate policy
Optional<DmarcPolicy> policy = extractPolicy(dmarcRecord.get());

if (!policy.isPresent()) {
String error = String.format("DMARC record at %s does not contain a valid policy (p=). Record: %s",
dmarcRecordName, dmarcRecord.get());
LOGGER.warn(error);
return Optional.of(error);
return Optional.of(new DnsValidationFailure.DmarcValidationFailure(
String.format("DMARC record at %s does not contain a valid policy (p=). Record: %s",
dmarcRecordName, dmarcRecord.get())));
}

if (!policy.get().isStricterOrEqualTo(minimumPolicy)) {
String error = String.format("DMARC policy for domain %s is too lenient. Required: %s, Found: %s. Record: %s",
domain, minimumPolicy.name().toLowerCase(), policy.get().name().toLowerCase(), dmarcRecord.get());
LOGGER.warn(error);
return Optional.of(error);
return Optional.of(new DnsValidationFailure.DmarcValidationFailure(
String.format("DMARC policy for domain %s is too lenient. Required: %s, Found: %s. Record: %s",
domain, minimumPolicy.name().toLowerCase(), policy.get().name().toLowerCase(), dmarcRecord.get())));
}

LOGGER.debug("DMARC validation passed for domain {} with policy {}", domain, policy.get());
return Optional.empty();

} catch (Exception e) {
String error = String.format("Failed to query DMARC record at %s: %s", dmarcRecordName, e.getMessage());
LOGGER.error(error, e);
return Optional.of(error);
return Optional.of(new DnsValidationFailure.DmarcValidationFailure(
String.format("Failed to query DMARC record at %s: %s", dmarcRecordName, e.getMessage())));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/********************************************************************
* As a subpart of Twake Mail, this file is edited by Linagora. *
* *
* https://twake-mail.com/ *
* https://linagora.com *
* *
* This file is subject to The Affero Gnu Public License *
* version 3. *
* *
* https://www.gnu.org/licenses/agpl-3.0.en.html *
* *
* This program is distributed in the hope that it will be *
* useful, but WITHOUT ANY WARRANTY; without even the implied *
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR *
* PURPOSE. See the GNU Affero General Public License for *
* more details. *
********************************************************************/

package com.linagora.tmail.mailet.dns;

/**
* Represents DNS validation failures for email authentication records.
*/
public sealed interface DnsValidationFailure permits
DnsValidationFailure.DkimValidationFailure,
DnsValidationFailure.SpfValidationFailure,
DnsValidationFailure.DmarcValidationFailure {

String message();

/**
* DKIM DNS validation failure.
*/
record DkimValidationFailure(String message) implements DnsValidationFailure {}

/**
* SPF DNS validation failure.
*/
record SpfValidationFailure(String message) implements DnsValidationFailure {}

/**
* DMARC DNS validation failure.
*/
record DmarcValidationFailure(String message) implements DnsValidationFailure {}
}
Loading