mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
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:
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:}}
|
||||
|
||||
+1
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
+1
-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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user