Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This is particularly useful in SaaS environments where customers send emails thr

The mailet ensures that:

* *DKIM*: The DKIM selector used in the email signature has a valid DNS record published
* *DKIM*: The DKIM selector used in the email signature has a valid DNS record published and uses one of the allowed keys.
* *SPF*: The domain's SPF record authorizes your server IPs to send emails on behalf of the domain
* *DMARC*: The domain has a DMARC policy with at least the minimum required strictness level

Expand Down Expand Up @@ -36,25 +36,21 @@ This mailet should be placed in the *transport processor*, after `DKIMSign` and
|Default
|Description

|validateDkim
|true
|Enable DKIM DNS validation
|acceptedDkimKeys
| if unspecified disable DKIM checks
|Coma separated list of DKIM public keys allowed.

|validateSpf
|true
|Enable SPF DNS validation

|validateDmarc
|true
|Enable DMARC DNS validation

|spfAuthorizedIps
|(required if validateSpf=true)
|Comma-separated list of IP addresses that must be authorized in the domain's SPF record. These are typically your TMail server's public IP addresses. Supports both single IPs (192.0.2.10) and CIDR ranges (192.0.2.0/24).
|spfInclude
| if unspecified disable SPF checks
| SPF include that must be specified in the record.

|dmarcMinPolicy
|quarantine
| if unspecified disable DMARD checks
|Minimum DMARC policy required. Valid values: `none`, `quarantine`, `reject`

|validationFailureProcessor
| error
|Processor to send invalid emails to.
|===

=== Example Configuration
Expand All @@ -73,11 +69,10 @@ This mailet should be placed in the *transport processor*, after `DKIMSign` and

<!-- Validate DNS records -->
<mailet match="All" class="com.linagora.tmail.mailet.DomainDnsValidator">
<validateDkim>true</validateDkim>
<validateSpf>true</validateSpf>
<validateDmarc>true</validateDmarc>
<spfAuthorizedIps>192.0.2.10,198.51.100.5</spfAuthorizedIps>
<acceptedDkimKeys>MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwc8ksmQvN3Qk8rOehLdu4xTzBz5E9WjX3V8fK9zLutw4F5mvh0qFQeX1nVqYQ1oHXv8z9pHvh2cWqg8zP/0DPW8nG+9PRcZ8mDFeG9Oa2CE8vNQXGvG+y9bS5QIDAQAB</acceptedDkimKeys>
<spfInclude>_spf.twake.app</spfInclude>
<dmarcMinPolicy>quarantine</dmarcMinPolicy>
<validationFailureProcessor>misscounfiguredDomainError</validationFailureProcessor>
</mailet>

<!-- Send emails -->
Expand All @@ -96,6 +91,7 @@ The mailet:
1. Extracts the `s=` (selector) and `d=` (domain) parameters from the `DKIM-Signature` header
2. Queries DNS for `<selector>._domainkey.<domain>` TXT record
3. Verifies the record exists and starts with `v=DKIM1`
4. Verifies that the domain uses a allowed DKIM key.

*Example*: For `s=s1; d=example.com`, expects a TXT record at `s1._domainkey.example.com`:

Expand All @@ -110,22 +106,7 @@ The mailet:

1. Queries DNS for the domain's TXT records
2. Locates the SPF record (starts with `v=spf1`)
3. Verifies all IPs listed in `spfAuthorizedIps` are present in the SPF record
4. Additional IPs in the SPF record are tolerated (customers can authorize their own servers too)

*Example*: For `spfAuthorizedIps=192.0.2.10,198.51.100.5`, expects:

[source]
----
example.com. IN TXT "v=spf1 ip4:192.0.2.10 ip4:198.51.100.5 ~all"
----

Or with CIDR notation:

[source]
----
example.com. IN TXT "v=spf1 ip4:192.0.2.0/24 ip4:198.51.100.0/24 ~all"
----
3. Ensure the SPF record include the configured value

=== DMARC Validation

Expand Down Expand Up @@ -161,9 +142,9 @@ _dmarc.example.com. IN TXT "v=DMARC1; p=none"

When validation fails, the email is:

* Set to `ERROR` state
* Set to `validationFailureProcessor` state
* Given an error message describing the failure
* Typically bounced back to the sender with an explanation
* Typically, bounced back to the sender with an explanation

Error messages include:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

package com.linagora.tmail.mailet;

import java.util.Collection;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
Expand All @@ -26,16 +27,22 @@
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;

import org.apache.james.core.Domain;
import org.apache.james.core.MaybeSender;
import org.apache.james.dnsservice.api.DNSService;
import org.apache.mailet.Mail;
import org.apache.mailet.ProcessingState;
import org.apache.mailet.base.GenericMailet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.linagora.tmail.mailet.dns.DkimDnsValidator;
import com.linagora.tmail.mailet.dns.DmarcDnsValidator;
import com.linagora.tmail.mailet.dns.DnsValidationFailure;
import com.linagora.tmail.mailet.dns.SpfDnsValidator;

/**
Expand All @@ -48,7 +55,7 @@
* <p>
* 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>DKIM</b>: Verifies that the selector used in DKIM-Signature header has a valid DNS record with one of the accepted public key.</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>
Expand All @@ -57,11 +64,10 @@
* <p>
* Configuration parameters:
* <ul>
* <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>acceptedDkimKeys</b>: DKIM keys we consider as accepted for our service.</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>
* <li><b>validationFailureProcessor</b>: Processor to send invalid emails to.</li>
* </ul>
* </p>
*
Expand All @@ -71,10 +77,8 @@
* <pre>
* {@code
* <mailet match="All" class="com.linagora.tmail.mailet.DomainDnsValidator">
* <validateDkim>true</validateDkim>
* <validateSpf>true</validateSpf>
* <validateDmarc>true</validateDmarc>
* <spfInclude>_spf.tmail.com</spfInclude>
* <acceptedDkimKeys>MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwc8ksmQvN3Qk8rOehLdu4xTzBz5E9WjX3V8fK9zLutw4F5mvh0qFQeX1nVqYQ1oHXv8z9pHvh2cWqg8zP/0DPW8nG+9PRcZ8mDFeG9Oa2CE8vNQXGvG+y9bS5QIDAQAB</acceptedDkimKeys>
* <dmarcMinPolicy>quarantine</dmarcMinPolicy>
* </mailet>
* }
Expand All @@ -88,23 +92,24 @@
public class DomainDnsValidator extends GenericMailet {
private static final Logger LOGGER = LoggerFactory.getLogger(DomainDnsValidator.class);

private static final String VALIDATE_DKIM_PARAM = "validateDkim";
private static final String VALIDATE_SPF_PARAM = "validateSpf";
private static final String VALIDATE_DMARC_PARAM = "validateDmarc";
@VisibleForTesting
record DkimSignatureInfo(String domain, String selector) {

}

private static final String SPF_INCLUDE_PARAM = "spfInclude";
private static final String DKIM_KEY_PARAM = "acceptedDkimKeys";
private static final String DMARC_MIN_POLICY_PARAM = "dmarcMinPolicy";

private static final String DKIM_SIGNATURE_HEADER = "DKIM-Signature";
private static final Pattern DKIM_SELECTOR_PATTERN = Pattern.compile("s=([^;\\s]+)");
private static final Pattern DKIM_DOMAIN_PATTERN = Pattern.compile("d=([^;\\s]+)");

private final DNSService dnsService;
private boolean validateDkim;
private boolean validateSpf;
private boolean validateDmarc;
private SpfDnsValidator spfValidator;
private DkimDnsValidator dkimValidator;
private DmarcDnsValidator dmarcValidator;
private String validationFailureProcessor = Mail.ERROR;

@Inject
public DomainDnsValidator(DNSService dnsService) {
Expand All @@ -113,29 +118,24 @@ public DomainDnsValidator(DNSService dnsService) {

@Override
public void init() throws MessagingException {
validateDkim = Boolean.parseBoolean(getInitParameter(VALIDATE_DKIM_PARAM, "true"));
validateSpf = Boolean.parseBoolean(getInitParameter(VALIDATE_SPF_PARAM, "true"));
validateDmarc = Boolean.parseBoolean(getInitParameter(VALIDATE_DMARC_PARAM, "true"));

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

if (validateDkim) {
dkimValidator = new DkimDnsValidator(dnsService);
String dkimKeys = getInitParameter(DKIM_KEY_PARAM);
if (!Strings.isNullOrEmpty(dkimKeys)) {
dkimValidator = new DkimDnsValidator(dnsService,
Splitter.on(',')
.splitToList(dkimKeys));
}

if (validateDmarc) {
String dmarcMinPolicy = getInitParameter(DMARC_MIN_POLICY_PARAM, "quarantine");
String dmarcMinPolicy = getInitParameter(DMARC_MIN_POLICY_PARAM);
if (!Strings.isNullOrEmpty(dmarcMinPolicy)) {
dmarcValidator = new DmarcDnsValidator(dnsService, dmarcMinPolicy);
}

LOGGER.info("DomainDnsValidator initialized - validateDkim: {}, validateSpf: {}, validateDmarc: {}",
validateDkim, validateSpf, validateDmarc);
validationFailureProcessor = getInitParameter("validationFailureProcessor", Mail.ERROR);
}

@Override
Expand All @@ -146,34 +146,36 @@ public String getMailetInfo() {
@Override
public void service(Mail mail) throws MessagingException {
MimeMessage message = mail.getMessage();
MaybeSender maybeSender = mail.getMaybeSender();

// 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);
if (maybeSender.isNullSender()) {
LOGGER.debug("Skip DNS validation for {} has it is sent by <> sender", mail.getName());
return;
}

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

LOGGER.debug("Validating DNS records for domain: {}, selector: {}", domain, selector);
Domain domain = maybeSender.get().getDomain();

// Validate DKIM DNS record
if (validateDkim) {
if (dkimValidator != null) {
// Extract DKIM signature information
Optional<DkimSignatureInfo> dkimInfo = extractDkimSignatureInfo(message);
if (dkimInfo.isEmpty()) {
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 selector = dkimInfo.get().selector;
Optional<DnsValidationFailure.DkimValidationFailure> dkimError = dkimValidator.validate(domain, selector);
LOGGER.debug("Validating DNS records for domain: {}, selector: {}", domain, selector);
if (dkimError.isPresent()) {
handleValidationFailure(mail, "DKIM", dkimError.get().message());
return;
}
}

// Validate SPF record
if (validateSpf) {
if (spfValidator != null) {
Optional<DnsValidationFailure.SpfValidationFailure> spfError = spfValidator.validate(domain);
if (spfError.isPresent()) {
handleValidationFailure(mail, "SPF", spfError.get().message());
Expand All @@ -182,15 +184,15 @@ public void service(Mail mail) throws MessagingException {
}

// Validate DMARC record
if (validateDmarc) {
if (dmarcValidator != null) {
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);
LOGGER.debug("DNS validation passed for domain: {}", domain);
}

@VisibleForTesting
Expand Down Expand Up @@ -226,17 +228,11 @@ private void handleValidationFailure(Mail mail, String recordType, String errorM
String fullError = String.format("%s validation failed: %s", recordType, errorMessage);
LOGGER.warn("Mail {} rejected: {}", mail.getName(), fullError);
mail.setErrorMessage(fullError);
mail.setState(Mail.ERROR);
mail.setState(validationFailureProcessor);
}

@VisibleForTesting
static class DkimSignatureInfo {
final String domain;
final String selector;

DkimSignatureInfo(String domain, String selector) {
this.domain = domain;
this.selector = selector;
}
@Override
public Collection<ProcessingState> requiredProcessingState() {
return ImmutableList.of(new ProcessingState(validationFailureProcessor));
}
}
Loading