Add encryption policy, split delivery and SecureMailSender

- delivery: PolicyResolver (address > *@domain > default), RecipientKeyResolver
  (discovery + validation), DeliveryPlanner (REQUIRE refusal, SPLIT/FAIL,
  own copy per Bcc with hidden key IDs), MessageAssembler with sender keys
  for readable sent copies, EnvelopeMimeMessage for per-copy SMTP envelopes
- sender: SecureMailSender with BUILT_IN and RELAY implementations and a
  per-recipient SendReport; relay passes the strictest policy as a header hint
- autoconfigure: mode, default-policy, mixed-mode, recipients, sender-keys
  and relay properties; relay sender is not a bean so spring.mail stays intact
- demo: sends through SecureMailSender

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
Claude committed 2026-10-12 03:27:40 +00:00
1 parent a788f7a48a
commit 3bce057eab
34 files changed
+1562 -114

No files matched your search

@@ -21,6 +21,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<!-- JavaMailSender -->
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
@@ -32,6 +37,16 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -7,8 +7,11 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -17,8 +20,17 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.util.StringUtils;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlanner;
import pro.thinhha.mailsender.pgp.delivery.MessageAssembler;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.delivery.RecipientKeyResolver;
import pro.thinhha.mailsender.pgp.delivery.SenderKeyProvider;
import pro.thinhha.mailsender.pgp.delivery.StaticSenderKeyProvider;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.Certificates;
import pro.thinhha.mailsender.pgp.discovery.CompositeKeyDiscovery;
@@ -29,24 +41,28 @@ import pro.thinhha.mailsender.pgp.discovery.StaticKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
import pro.thinhha.mailsender.pgp.discovery.WkdKeyDiscovery;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
/**
* Key discovery (pinned keys first, then WKD behind a cache), certificate validation and PGP/MIME encryption beans.
* The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed separately so
* applications can evict entries.
* Wires {@link SecureMailSender} and its parts: key discovery (pinned keys first, then WKD behind a cache),
* validation, policy, delivery planning and PGP/MIME encryption. Every bean backs off if the application defines
* its own. The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed
* separately so applications can evict entries.
*/
@AutoConfiguration
@AutoConfiguration(afterName = "org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration")
@EnableConfigurationProperties(PgpMailProperties.class)
@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true)
public class PgpMailAutoConfiguration {
private static final Logger log = LoggerFactory.getLogger(PgpMailAutoConfiguration.class);
@Bean
@ConditionalOnMissingBean
StaticKeyDiscovery pgpStaticKeyDiscovery(PgpMailProperties properties) {
Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>();
properties.staticKeys().forEach((email, resource) -> certificates.put(email, read(email, resource)));
return new StaticKeyDiscovery(certificates);
return new StaticKeyDiscovery(readAll("mail.pgp.static-keys", properties.staticKeys()));
}
@Bean
@@ -97,15 +113,105 @@ public class PgpMailAutoConfiguration {
return new PgpMimeEncryptor(encryptor);
}
private static List<OpenPGPCertificate> read(String email, Resource resource) {
@Bean
@ConditionalOnMissingBean
PolicyResolver pgpPolicyResolver(PgpMailProperties properties) {
return new PolicyResolver(properties.defaultPolicy(), properties.recipients());
}
@Bean
@ConditionalOnMissingBean
RecipientKeyResolver pgpRecipientKeyResolver(KeyDiscovery discovery, CertificateValidator validator) {
return new RecipientKeyResolver(discovery, validator);
}
@Bean
@ConditionalOnMissingBean
DeliveryPlanner pgpDeliveryPlanner(PgpMailProperties properties, PolicyResolver policies,
RecipientKeyResolver keys) {
return new DeliveryPlanner(policies, keys, properties.mixedMode());
}
@Bean
@ConditionalOnMissingBean
SenderKeyProvider pgpSenderKeyProvider(PgpMailProperties properties, CertificateValidator validator) {
Map<String, OpenPGPCertificate> keys = new LinkedHashMap<>();
readAll("mail.pgp.sender-keys", properties.senderKeys()).forEach((address, certificates) -> {
if (certificates.size() != 1) {
throw new IllegalStateException(
"mail.pgp.sender-keys[" + address + "] must contain exactly one certificate");
}
keys.put(address, certificates.getFirst());
});
if (keys.isEmpty() && properties.mode() == PgpMailProperties.Mode.BUILT_IN) {
log.info("No mail.pgp.sender-keys configured: encrypted mail will not be readable by its sender");
}
return new StaticSenderKeyProvider(keys, validator);
}
@Bean
@ConditionalOnMissingBean
MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
return new MessageAssembler(mimeEncryptor, senderKeys);
}
@Bean
@ConditionalOnMissingBean
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender,
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
PolicyResolver policies) {
return switch (properties.mode()) {
case BUILT_IN -> {
JavaMailSender delegate = mailSender.getIfAvailable();
if (delegate == null) {
throw new IllegalStateException(
"mail.pgp.mode=BUILT_IN needs a JavaMailSender bean; set spring.mail.host");
}
yield new BuiltInSecureMailSender(delegate, planner.getObject(), assembler.getObject());
}
case RELAY -> {
PgpMailProperties.Relay relay = properties.relay();
log.info("mail.pgp.mode=RELAY: key discovery, encryption and policy enforcement are delegated to "
+ "the gateway at {}:{}", relay.host(), relay.port());
yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader());
}
};
}
/** Not a bean: a second JavaMailSender would make Boot's own {@code spring.mail} sender back off. */
private static JavaMailSenderImpl relaySender(PgpMailProperties.Relay relay) {
if (!StringUtils.hasText(relay.host())) {
throw new IllegalStateException("mail.pgp.mode=RELAY requires mail.pgp.relay.host");
}
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(relay.host());
sender.setPort(relay.port());
sender.setUsername(relay.username());
sender.setPassword(relay.password());
Properties javaMail = new Properties();
javaMail.setProperty("mail.smtp.auth", String.valueOf(StringUtils.hasText(relay.username())));
javaMail.setProperty("mail.smtp.starttls.enable", String.valueOf(relay.starttls()));
javaMail.setProperty("mail.smtp.starttls.required", String.valueOf(relay.starttls()));
javaMail.putAll(relay.properties());
sender.setJavaMailProperties(javaMail);
return sender;
}
private static Map<String, List<OpenPGPCertificate>> readAll(String property, Map<String, Resource> resources) {
Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>();
resources.forEach((email, resource) -> certificates.put(email, read(property, email, resource)));
return certificates;
}
private static List<OpenPGPCertificate> read(String property, String email, Resource resource) {
try (InputStream in = resource.getInputStream()) {
List<OpenPGPCertificate> certificates = Certificates.parse(in.readAllBytes());
if (certificates.isEmpty()) {
throw new IllegalStateException("mail.pgp.static-keys[" + email + "]: no certificate in " + resource);
throw new IllegalStateException(property + "[" + email + "]: no certificate in " + resource);
}
return certificates;
} catch (IOException e) {
throw new UncheckedIOException("mail.pgp.static-keys[" + email + "]: cannot read " + resource, e);
throw new UncheckedIOException(property + "[" + email + "]: cannot read " + resource, e);
}
}
}
@@ -8,6 +8,8 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.core.io.Resource;
import org.springframework.util.unit.DataSize;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.MixedMode;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
/**
@@ -16,18 +18,38 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
* Map keys that are e-mail addresses must use bracket notation so Spring keeps the {@code @} and {@code .}
* characters, e.g. {@code mail.pgp.static-keys[bob@legacy.org]=file:/etc/keys/bob.asc}.
*
* @param enabled master switch
* @param staticKeys pinned certificates per address, checked before WKD
* @param enabled master switch
* @param mode encrypt in this application or hand mail to an encrypting relay
* @param defaultPolicy policy for recipients without a rule
* @param mixedMode what to do when only some recipients can get encrypted mail
* @param recipients policy rules per address or {@code *@domain}
* @param staticKeys pinned recipient certificates per address, checked before WKD
* @param senderKeys the sender's own public key per From address, so sent copies stay readable
*/
@ConfigurationProperties("mail.pgp")
public record PgpMailProperties(
@DefaultValue("true") boolean enabled,
@DefaultValue("BUILT_IN") Mode mode,
@DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy,
@DefaultValue("SPLIT") MixedMode mixedMode,
Map<String, EncryptionPolicy> recipients,
@DefaultValue Wkd wkd,
@DefaultValue Cache cache,
Map<String, Resource> staticKeys) {
Map<String, Resource> staticKeys,
Map<String, Resource> senderKeys,
@DefaultValue Relay relay) {
public PgpMailProperties {
recipients = recipients == null ? Map.of() : Map.copyOf(recipients);
staticKeys = staticKeys == null ? Map.of() : Map.copyOf(staticKeys);
senderKeys = senderKeys == null ? Map.of() : Map.copyOf(senderKeys);
}
public enum Mode {
/** Discover keys and encrypt in this application, send through the application's {@code JavaMailSender}. */
BUILT_IN,
/** Send plaintext to an encrypting gateway configured under {@code mail.pgp.relay}. */
RELAY
}
public record Wkd(
@@ -49,4 +71,25 @@ public record PgpMailProperties(
@DefaultValue("5m") Duration errorTtl,
@DefaultValue("10000") long maxSize) {
}
/**
* SMTP connection to the encrypting gateway ({@code mode=RELAY}). The hop carries plaintext, so STARTTLS is
* required by default.
*
* @param policyHeader header that carries the strictest recipient policy as a hint for the gateway; empty for none
* @param properties extra JavaMail properties, e.g. {@code mail.smtp.ssl.trust}
*/
public record Relay(
String host,
@DefaultValue("587") int port,
String username,
String password,
@DefaultValue("true") boolean starttls,
String policyHeader,
Map<String, String> properties) {
public Relay {
properties = properties == null ? Map.of() : Map.copyOf(properties);
}
}
}
@@ -0,0 +1,41 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.ArrayList;
import java.util.List;
import org.springframework.mail.MailParseException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.MimeMailMessage;
import jakarta.mail.Address;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
abstract class AbstractSecureMailSender implements SecureMailSender {
@Override
public SendReport send(SimpleMailMessage simpleMessage) {
MimeMailMessage message = new MimeMailMessage(createMimeMessage());
simpleMessage.copyTo(message);
return send(message.getMimeMessage());
}
static List<InternetAddress> recipients(MimeMessage message, Message.RecipientType type) {
try {
Address[] addresses = message.getRecipients(type);
List<InternetAddress> result = new ArrayList<>();
if (addresses != null) {
for (Address address : addresses) {
if (address instanceof InternetAddress internet) {
result.add(internet);
}
}
}
return result;
} catch (MessagingException e) {
throw new MailParseException("Cannot read " + type + " recipients", e);
}
}
}
@@ -0,0 +1,76 @@
package pro.thinhha.mailsender.pgp.sender;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.mail.MailPreparationException;
import org.springframework.mail.javamail.JavaMailSender;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlan;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlanner;
import pro.thinhha.mailsender.pgp.delivery.MessageAssembler;
import pro.thinhha.mailsender.pgp.delivery.RecipientDecision;
import pro.thinhha.mailsender.pgp.delivery.RecipientKey;
/** Encrypts in this application: plan per recipient, build the copies, send them over one SMTP connection. */
public final class BuiltInSecureMailSender extends AbstractSecureMailSender {
private static final Logger log = LoggerFactory.getLogger(BuiltInSecureMailSender.class);
private final JavaMailSender delegate;
private final DeliveryPlanner planner;
private final MessageAssembler assembler;
public BuiltInSecureMailSender(JavaMailSender delegate, DeliveryPlanner planner, MessageAssembler assembler) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.planner = Objects.requireNonNull(planner, "planner");
this.assembler = Objects.requireNonNull(assembler, "assembler");
}
@Override
public MimeMessage createMimeMessage() {
return delegate.createMimeMessage();
}
@Override
public SendReport send(MimeMessage message) {
DeliveryPlan plan = planner.plan(
recipients(message, Message.RecipientType.TO),
recipients(message, Message.RecipientType.CC),
recipients(message, Message.RecipientType.BCC));
List<MimeMessage> copies;
try {
copies = assembler.assemble(message, plan, delegate::createMimeMessage);
} catch (MessagingException | IOException e) {
throw new MailPreparationException("Could not build encrypted message", e);
}
plan.decisions().stream().filter(RecipientDecision::isDowngrade).forEach(d ->
log.warn("Sending to {} in PLAINTEXT (policy {}): {}", d.address(), d.policy(), describe(d.key())));
delegate.send(copies.toArray(MimeMessage[]::new));
SendReport report = new SendReport(plan.decisions().stream()
.map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(),
d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT,
d.encrypted() ? null : describe(d.key())))
.toList());
log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report);
return report;
}
private static String describe(RecipientKey key) {
return switch (key) {
case RecipientKey.Usable usable -> "key from " + usable.source();
case RecipientKey.Missing missing ->
missing.detail() == null ? missing.reason().toString() : missing.reason() + ": " + missing.detail();
case RecipientKey.NotLookedUp ignored -> "plaintext policy";
};
}
}
@@ -0,0 +1,71 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.springframework.mail.MailPreparationException;
import org.springframework.mail.javamail.JavaMailSender;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
/**
* Hands the plaintext message to an encrypting gateway (for example a GnuPG-based filter using
* {@code --auto-key-locate wkd}). Key discovery, encryption and policy enforcement happen there; this sender can
* only pass the strictest recipient policy along as a header hint.
*/
public final class RelaySecureMailSender extends AbstractSecureMailSender {
private final JavaMailSender relay;
private final PolicyResolver policies;
private final String policyHeader;
/** @param policyHeader header carrying the policy hint, or {@code null} for none */
public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader) {
this.relay = Objects.requireNonNull(relay, "relay");
this.policies = Objects.requireNonNull(policies, "policies");
this.policyHeader = policyHeader == null || policyHeader.isBlank() ? null : policyHeader;
}
@Override
public MimeMessage createMimeMessage() {
return relay.createMimeMessage();
}
@Override
public SendReport send(MimeMessage message) {
List<SendReport.RecipientOutcome> outcomes = new ArrayList<>();
addOutcomes(outcomes, recipients(message, Message.RecipientType.TO), RecipientType.TO);
addOutcomes(outcomes, recipients(message, Message.RecipientType.CC), RecipientType.CC);
addOutcomes(outcomes, recipients(message, Message.RecipientType.BCC), RecipientType.BCC);
if (policyHeader != null && !outcomes.isEmpty()) {
// Enum order is strictest first: REQUIRE, OPPORTUNISTIC, PLAINTEXT.
EncryptionPolicy strictest = outcomes.stream().map(SendReport.RecipientOutcome::policy)
.min(Comparator.naturalOrder()).orElseThrow();
try {
message.setHeader(policyHeader, strictest.name().toLowerCase(Locale.ROOT));
} catch (MessagingException e) {
throw new MailPreparationException("Could not set " + policyHeader, e);
}
}
relay.send(message);
return new SendReport(outcomes);
}
private void addOutcomes(List<SendReport.RecipientOutcome> outcomes, List<InternetAddress> addresses,
RecipientType type) {
for (InternetAddress address : addresses) {
outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type,
policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, null));
}
}
}
@@ -0,0 +1,25 @@
package pro.thinhha.mailsender.pgp.sender;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
/**
* Sends mail with OpenPGP encryption according to the configured policy. Build the message as you would for
* {@code JavaMailSender} (plaintext); the sender decides per recipient whether it goes out encrypted.
*/
public interface SecureMailSender {
MimeMessage createMimeMessage();
/**
* @throws EncryptionPolicyException if the policy forbids sending (nothing is sent)
* @throws MailException on preparation or SMTP failures
*/
SendReport send(MimeMessage message);
/** @see #send(MimeMessage) */
SendReport send(SimpleMailMessage message);
}
@@ -0,0 +1,34 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.List;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
/** What happened to each recipient of a sent message. */
public record SendReport(List<RecipientOutcome> recipients) {
public SendReport {
recipients = List.copyOf(recipients);
}
public boolean allEncrypted() {
return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED);
}
/**
* @param detail why the recipient got plaintext (missing key reason), or {@code null}
*/
public record RecipientOutcome(String address, RecipientType type, EncryptionPolicy policy, Outcome outcome,
String detail) {
}
public enum Outcome {
/** Sent PGP/MIME-encrypted by this application. */
ENCRYPTED,
/** Sent in plaintext by this application. */
PLAINTEXT,
/** Handed to the encrypting relay, which decides. */
RELAYED
}
}
@@ -11,7 +11,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.pgpainless.PGPainless;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.mail.javamail.JavaMailSender;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
@@ -20,12 +22,16 @@ import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class PgpMailAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class));
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class, PgpMailAutoConfiguration.class))
.withPropertyValues("spring.mail.host=localhost");
@TempDir
Path tempDir;
@@ -70,6 +76,48 @@ class PgpMailAutoConfigurationTest {
.run(context -> assertThat(context).hasFailed());
}
@Test
void builtInModeWrapsTheApplicationsMailSender() {
runner.run(context -> assertThat(context.getBean(SecureMailSender.class))
.isInstanceOf(BuiltInSecureMailSender.class));
}
@Test
void builtInModeWithoutMailSenderFailsWithHint() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class))
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("spring.mail.host"));
}
@Test
void relayModeKeepsTheApplicationsMailSender() {
runner.withPropertyValues("mail.pgp.mode=relay", "mail.pgp.relay.host=gateway.internal")
.run(context -> {
assertThat(context.getBean(SecureMailSender.class)).isInstanceOf(RelaySecureMailSender.class);
assertThat(context).hasSingleBean(JavaMailSender.class);
assertThat(context.getBean(JavaMailSender.class)).extracting("host").isEqualTo("localhost");
});
}
@Test
void relayModeRequiresHost() {
runner.withPropertyValues("mail.pgp.mode=RELAY")
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("mail.pgp.relay.host"));
}
@Test
void senderKeyMustMatchItsAddress() throws IOException {
Path key = tempDir.resolve("sender.asc");
Files.writeString(key, PGPainless.getInstance().generateKey()
.modernKeyRing("<someone-else@example.com>").toCertificate().toAsciiArmoredString());
runner.withPropertyValues("mail.pgp.sender-keys[noreply@example.com]=file:" + key)
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("USER_ID_MISMATCH"));
}
@Test
void disabledCreatesNothing() {
runner.withPropertyValues("mail.pgp.enabled=false")
@@ -0,0 +1,170 @@
package pro.thinhha.mailsender.pgp.sender;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.pgpainless.PGPainless;
import org.pgpainless.decryption_verification.ConsumerOptions;
import org.pgpainless.decryption_verification.DecryptionStream;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.mail.SimpleMailMessage;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetupTest;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
/** End to end through the auto-configured {@link SecureMailSender} into GreenMail. WKD is off; keys are pinned. */
class SecureMailSenderIntegrationTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("<alice@example.org>");
private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing("<dave@example.org>");
private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing("<app@example.com>");
@TempDir
Path keys;
private GreenMail greenMail;
private ApplicationContextRunner runner;
@BeforeEach
void setUp() throws Exception {
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
greenMail.start();
runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class,
PgpMailAutoConfiguration.class))
.withPropertyValues(
"spring.mail.host=127.0.0.1",
"spring.mail.port=" + greenMail.getSmtp().getPort(),
"mail.pgp.wkd.enabled=false",
"mail.pgp.static-keys[alice@example.org]=file:" + write("alice", ALICE),
"mail.pgp.static-keys[dave@example.org]=file:" + write("dave", DAVE),
"mail.pgp.sender-keys[app@example.com]=file:" + write("app", SENDER),
"mail.pgp.recipients[*@partner.example]=REQUIRE");
}
@AfterEach
void tearDown() {
greenMail.stop();
}
@Test
void splitsMixedRecipientsAndReportsEachOne() {
runner.run(context -> {
SecureMailSender sender = context.getBean(SecureMailSender.class);
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "carol@example.org");
message.setBcc("dave@example.org");
message.setSubject("Report");
message.setText("top secret");
SendReport report = sender.send(message);
assertThat(report.recipients()).extracting(SendReport.RecipientOutcome::address,
SendReport.RecipientOutcome::outcome).containsExactly(
org.assertj.core.groups.Tuple.tuple("alice@example.org", SendReport.Outcome.ENCRYPTED),
org.assertj.core.groups.Tuple.tuple("carol@example.org", SendReport.Outcome.PLAINTEXT),
org.assertj.core.groups.Tuple.tuple("dave@example.org", SendReport.Outcome.ENCRYPTED));
assertThat(report.recipients().get(1).detail()).isEqualTo("NOT_FOUND");
MimeMessage alice = inbox("alice@example.org");
MimeMessage carol = inbox("carol@example.org");
MimeMessage dave = inbox("dave@example.org");
assertThat(greenMail.getReceivedMessages()).hasSize(3);
assertThat(alice.isMimeType("multipart/encrypted")).isTrue();
assertThat(decrypt(alice, ALICE)).contains("top secret");
assertThat(decrypt(alice, SENDER)).as("sender can read sent copy").contains("top secret");
assertThat(carol.isMimeType("text/plain")).isTrue();
assertThat(carol.getContent().toString()).contains("top secret");
assertThat(decrypt(dave, DAVE)).contains("top secret");
for (MimeMessage received : List.of(alice, carol, dave)) {
assertThat(received.getHeader("Bcc")).isNull();
assertThat(received.getRecipients(Message.RecipientType.TO)).hasSize(2);
assertThat(received.getMessageID()).isEqualTo(alice.getMessageID());
}
});
}
@Test
void refusesRequiredRecipientWithoutKeyAndSendsNothing() {
runner.run(context -> {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "ceo@partner.example");
message.setText("x");
assertThatThrownBy(() -> context.getBean(SecureMailSender.class).send(message))
.isInstanceOf(EncryptionPolicyException.class).hasMessageContaining("ceo@partner.example");
assertThat(greenMail.getReceivedMessages()).isEmpty();
});
}
@Test
void relayModeSendsPlaintextWithPolicyHint() {
runner.withPropertyValues(
"mail.pgp.mode=RELAY",
"mail.pgp.relay.host=127.0.0.1",
"mail.pgp.relay.port=" + greenMail.getSmtp().getPort(),
"mail.pgp.relay.starttls=false",
"mail.pgp.relay.policy-header=X-Encryption-Policy")
.run(context -> {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "ceo@partner.example");
message.setText("via gateway");
SendReport report = context.getBean(SecureMailSender.class).send(message);
assertThat(report.recipients()).allMatch(r -> r.outcome() == SendReport.Outcome.RELAYED);
MimeMessage received = greenMail.getReceivedMessages()[0];
assertThat(received.getHeader("X-Encryption-Policy")).containsExactly("require");
assertThat(received.getContent().toString()).contains("via gateway");
});
}
private Path write(String name, OpenPGPKey key) throws Exception {
Path file = keys.resolve(name + ".asc");
Files.writeString(file, key.toCertificate().toAsciiArmoredString());
return file;
}
/** The single message delivered to this address's mailbox, i.e. via the SMTP envelope. */
private MimeMessage inbox(String address) throws Exception {
var user = greenMail.getUserManager().getUserByEmail(address);
assertThat(user).as("mailbox for " + address).isNotNull();
var stored = greenMail.getManagers().getImapHostManager().getInbox(user).getMessages();
assertThat(stored).hasSize(1);
return stored.getFirst().getMimeMessage();
}
private static String decrypt(MimeMessage message, OpenPGPKey key) throws Exception {
byte[] armored = ((Multipart) message.getContent()).getBodyPart(1).getInputStream().readAllBytes();
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(key))) {
in.transferTo(out);
}
return out.toString(java.nio.charset.StandardCharsets.UTF_8);
}
}