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

@@ -1,34 +1,22 @@
package pro.thinhha.mailsender.demo;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SendReport;
/**
* Looks up and validates the recipient's key, then sends {@code demo.count} test mails: PGP/MIME-encrypted when a
* valid key was found, otherwise in plaintext (logged as a downgrade).
* <p>
* This calls the building blocks directly; the policy layer and {@code SecureMailSender} (milestones 5–6) will
* replace this wiring.
* Sends {@code demo.count} test mails through {@link SecureMailSender}, which encrypts per recipient according to
* {@code mail.pgp.*} (or hands them to the relay in RELAY mode), and logs what happened to each recipient.
*/
@Component
class TestMailRunner implements ApplicationRunner {
@@ -36,88 +24,35 @@ class TestMailRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
private final DemoProperties properties;
private final KeyDiscovery keyDiscovery;
private final CertificateValidator validator;
private final PgpMimeEncryptor mimeEncryptor;
private final JavaMailSender mailSender;
private final SecureMailSender mailSender;
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, CertificateValidator validator,
PgpMimeEncryptor mimeEncryptor, JavaMailSender mailSender) {
TestMailRunner(DemoProperties properties, SecureMailSender mailSender) {
this.properties = properties;
this.keyDiscovery = keyDiscovery;
this.validator = validator;
this.mimeEncryptor = mimeEncryptor;
this.mailSender = mailSender;
}
@Override
public void run(ApplicationArguments args) throws MessagingException, IOException {
String to = properties.to();
DiscoveryResult discovery = keyDiscovery.discover(to);
Optional<OpenPGPCertificate> key = usableKey(discovery, to);
public void run(ApplicationArguments args) throws MessagingException {
for (int i = 1; i <= properties.count(); i++) {
MimeMessage message = compose(i, discovery, key.isPresent());
if (key.isPresent()) {
message = mimeEncryptor.encrypt(message, List.of(key.get()), List.of(), mailSender::createMimeMessage);
log.info("Sending test mail {}/{} to {} ENCRYPTED to {}", i, properties.count(), to,
fingerprint(key.get()));
} else {
log.warn("Sending test mail {}/{} to {} in PLAINTEXT: no usable key", i, properties.count(), to);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
if (properties.from() != null && !properties.from().isBlank()) {
helper.setFrom(properties.from());
}
mailSender.send(message);
}
}
helper.setTo(properties.to());
helper.setSubject("mailsender test %d/%d".formatted(i, properties.count()));
helper.setText("""
Test mail %d of %d from the mailsender demo, sent %s.
private Optional<OpenPGPCertificate> usableKey(DiscoveryResult discovery, String to) {
switch (discovery) {
case DiscoveryResult.Found found -> {
for (OpenPGPCertificate certificate : found.certificates()) {
ValidationResult result = validator.validate(certificate, to);
switch (result) {
case ValidationResult.Valid valid -> {
log.info("Key for {} found via {}: {}", to, found.source(), fingerprint(certificate));
return Optional.of(valid.certificate());
}
case ValidationResult.Rejected rejected -> log.warn("Key {} for {} rejected: {} ({})",
fingerprint(certificate), to, rejected.reason(), rejected.detail());
}
}
If you can read this in a PGP-capable client that shows it as encrypted,
WKD discovery and PGP/MIME encryption worked.
""".formatted(i, properties.count(), OffsetDateTime.now()));
SendReport report = mailSender.send(message);
for (SendReport.RecipientOutcome outcome : report.recipients()) {
log.info("Test mail {}/{} to {}: {}{}", i, properties.count(), outcome.address(), outcome.outcome(),
outcome.detail() == null ? "" : " (" + outcome.detail() + ")");
}
case DiscoveryResult.NotFound ignored -> log.info("No key published for {}", to);
case DiscoveryResult.Unreachable unreachable ->
log.warn("Key lookup for {} failed: {}", to, unreachable.reason());
}
return Optional.empty();
}
private MimeMessage compose(int index, DiscoveryResult discovery, boolean encrypted) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
if (properties.from() != null && !properties.from().isBlank()) {
helper.setFrom(properties.from());
}
helper.setTo(properties.to());
helper.setSubject("mailsender test %d/%d".formatted(index, properties.count()));
helper.setText("""
Test mail %d of %d from the mailsender demo, sent %s.
Key discovery for %s: %s
This message was sent %s.
""".formatted(index, properties.count(), OffsetDateTime.now(), properties.to(),
describe(discovery), encrypted ? "PGP/MIME-encrypted" : "in plaintext"));
return message;
}
private static String describe(DiscoveryResult result) {
return switch (result) {
case DiscoveryResult.Found found -> "found via " + found.source();
case DiscoveryResult.NotFound ignored -> "no key published";
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
};
}
private static String fingerprint(OpenPGPCertificate certificate) {
return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint());
}
}
@@ -13,6 +13,23 @@ spring:
mail.smtp.connectiontimeout: 10000
mail.smtp.timeout: 10000
mail:
pgp:
mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY
default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC}
mixed-mode: SPLIT
# recipients:
# "[*@partner.example]": REQUIRE
# Readable sent copies: the sender's own PUBLIC key per From address.
# sender-keys:
# "[noreply@example.com]": file:/etc/mail/noreply.pub.asc
relay:
host: ${RELAY_HOST:}
port: ${RELAY_PORT:587}
username: ${RELAY_USERNAME:}
password: ${RELAY_PASSWORD:}
policy-header: X-Encryption-Policy
demo:
to: ${DEMO_TO:test@thinhha.pro}
from: ${DEMO_FROM:${SMTP_USERNAME:}}
@@ -80,6 +80,6 @@ class EncryptedTestMailTest {
in.transferTo(inner);
}
MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner.toByteArray()));
assertThat(entity.getContent().toString()).contains("found via STATIC", "PGP/MIME-encrypted");
assertThat(entity.getContent().toString()).contains("Test mail 1 of 1");
}
}
@@ -54,6 +54,6 @@ class PlaintextTestMailTest {
assertThat(received).hasSize(2);
assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro");
assertThat(received[0].getSubject()).isEqualTo("mailsender test 1/2");
assertThat(GreenMailUtil.getBody(received[0])).contains("no key published", "in plaintext");
assertThat(GreenMailUtil.getBody(received[0])).contains("Test mail 1 of 2");
}
}