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

@@ -0,0 +1,34 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.internet.InternetAddress;
/**
* One copy of the message to send.
*
* @param envelope SMTP recipients of this copy (headers still show the original To/Cc)
* @param recipientKeys certificates to encrypt to; empty for a plaintext copy
* @param hiddenRecipients true to encrypt without revealing key IDs (Bcc copies)
*/
public record Delivery(List<InternetAddress> envelope, List<OpenPGPCertificate> recipientKeys,
boolean hiddenRecipients) {
public Delivery {
envelope = List.copyOf(envelope);
recipientKeys = List.copyOf(recipientKeys);
if (envelope.isEmpty()) {
throw new IllegalArgumentException("A delivery needs at least one envelope recipient");
}
}
static Delivery plaintext(List<InternetAddress> envelope) {
return new Delivery(envelope, List.of(), false);
}
public boolean encrypted() {
return !recipientKeys.isEmpty();
}
}
@@ -0,0 +1,12 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
/** The copies to send and the per-recipient decisions behind them. */
public record DeliveryPlan(List<Delivery> deliveries, List<RecipientDecision> decisions) {
public DeliveryPlan {
deliveries = List.copyOf(deliveries);
decisions = List.copyOf(decisions);
}
}
@@ -0,0 +1,140 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.internet.InternetAddress;
/**
* Turns the recipients of one message into deliveries:
* <ul>
* <li>A REQUIRE recipient without a usable key → {@link EncryptionPolicyException}, nothing is sent.</li>
* <li>Nobody gets encryption → one plaintext copy to everyone.</li>
* <li>Otherwise one encrypted copy for To/Cc recipients with keys, one plaintext copy for the other To/Cc
* recipients, and an own copy per Bcc recipient (encrypted with hidden key IDs when possible).
* With {@link MixedMode#FAIL}, a mix of encrypted and plaintext recipients is refused instead.</li>
* </ul>
* An address listed more than once receives one copy, under its first header.
*/
public final class DeliveryPlanner {
private final PolicyResolver policies;
private final RecipientKeyResolver keys;
private final MixedMode mixedMode;
public DeliveryPlanner(PolicyResolver policies, RecipientKeyResolver keys, MixedMode mixedMode) {
this.policies = Objects.requireNonNull(policies, "policies");
this.keys = Objects.requireNonNull(keys, "keys");
this.mixedMode = Objects.requireNonNull(mixedMode, "mixedMode");
}
public DeliveryPlan plan(List<InternetAddress> to, List<InternetAddress> cc, List<InternetAddress> bcc) {
Map<String, Recipient> recipients = new LinkedHashMap<>();
add(recipients, to, RecipientType.TO);
add(recipients, cc, RecipientType.CC);
add(recipients, bcc, RecipientType.BCC);
if (recipients.isEmpty()) {
throw new IllegalArgumentException("Message has no recipients");
}
List<String> missingRequired = new ArrayList<>();
for (Recipient recipient : recipients.values()) {
recipient.policy = policies.resolve(recipient.email());
recipient.key = recipient.policy == EncryptionPolicy.PLAINTEXT
? new RecipientKey.NotLookedUp()
: keys.resolve(recipient.email());
if (recipient.policy == EncryptionPolicy.REQUIRE && !recipient.hasKey()) {
missingRequired.add(recipient.email());
}
}
if (!missingRequired.isEmpty()) {
throw new EncryptionPolicyException("Encryption required but no usable key", missingRequired);
}
List<Recipient> all = List.copyOf(recipients.values());
boolean anyEncrypted = all.stream().anyMatch(Recipient::hasKey);
List<String> plaintext = all.stream().filter(r -> !r.hasKey()).map(Recipient::email).toList();
if (!anyEncrypted) {
return new DeliveryPlan(
List.of(Delivery.plaintext(all.stream().map(Recipient::address).toList())),
decisions(all));
}
if (mixedMode == MixedMode.FAIL && !plaintext.isEmpty()) {
throw new EncryptionPolicyException("Mixed encrypted/plaintext recipients not allowed", plaintext);
}
return new DeliveryPlan(split(all), decisions(all));
}
private static List<Delivery> split(List<Recipient> all) {
List<Delivery> deliveries = new ArrayList<>();
List<Recipient> visible = all.stream().filter(r -> r.type != RecipientType.BCC).toList();
List<Recipient> visibleEncrypted = visible.stream().filter(Recipient::hasKey).toList();
if (!visibleEncrypted.isEmpty()) {
deliveries.add(new Delivery(addresses(visibleEncrypted), certificates(visibleEncrypted), false));
}
List<Recipient> visiblePlain = visible.stream().filter(r -> !r.hasKey()).toList();
if (!visiblePlain.isEmpty()) {
deliveries.add(Delivery.plaintext(addresses(visiblePlain)));
}
for (Recipient bcc : all) {
if (bcc.type == RecipientType.BCC) {
deliveries.add(bcc.hasKey()
? new Delivery(List.of(bcc.address), certificates(List.of(bcc)), true)
: Delivery.plaintext(List.of(bcc.address)));
}
}
return deliveries;
}
private static List<RecipientDecision> decisions(List<Recipient> all) {
return all.stream()
.map(r -> new RecipientDecision(r.email(), r.type, r.policy, r.key, r.hasKey()))
.toList();
}
private static List<InternetAddress> addresses(List<Recipient> recipients) {
return recipients.stream().map(Recipient::address).toList();
}
private static List<OpenPGPCertificate> certificates(List<Recipient> recipients) {
return recipients.stream().map(r -> ((RecipientKey.Usable) r.key).certificate()).toList();
}
private static void add(Map<String, Recipient> recipients, List<InternetAddress> addresses, RecipientType type) {
for (InternetAddress address : addresses) {
recipients.putIfAbsent(address.getAddress().toLowerCase(Locale.ROOT), new Recipient(address, type));
}
}
private static final class Recipient {
final InternetAddress address;
final RecipientType type;
EncryptionPolicy policy;
RecipientKey key;
Recipient(InternetAddress address, RecipientType type) {
this.address = address;
this.type = type;
}
InternetAddress address() {
return address;
}
String email() {
return address.getAddress();
}
/** Will this recipient get an encrypted copy? PLAINTEXT recipients never do. */
boolean hasKey() {
return key instanceof RecipientKey.Usable;
}
}
}
@@ -0,0 +1,11 @@
package pro.thinhha.mailsender.pgp.delivery;
/** What to do for a recipient, depending on whether a usable key exists. */
public enum EncryptionPolicy {
/** Encrypt; refuse to send if no usable key exists. */
REQUIRE,
/** Encrypt when a usable key exists, otherwise send in plaintext (logged as a downgrade). */
OPPORTUNISTIC,
/** Always send in plaintext; no key lookup. */
PLAINTEXT
}
@@ -0,0 +1,19 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
/** The message cannot be sent without violating the encryption policy. Nothing was sent. */
public class EncryptionPolicyException extends RuntimeException {
private final List<String> addresses;
public EncryptionPolicyException(String message, List<String> addresses) {
super(message + ": " + String.join(", ", addresses));
this.addresses = List.copyOf(addresses);
}
/** The recipients that caused the refusal. */
public List<String> addresses() {
return addresses;
}
}
@@ -0,0 +1,35 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
import jakarta.mail.Address;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
/**
* A copy of a message whose SMTP envelope is limited to some recipients while the To/Cc headers stay intact.
* {@code JavaMailSenderImpl} (like {@code Transport.send}) uses {@link #getAllRecipients()} as the envelope.
*/
public final class EnvelopeMimeMessage extends MimeMessage {
private final Address[] envelope;
public EnvelopeMimeMessage(MimeMessage source, List<? extends Address> envelope) throws MessagingException {
super(source);
this.envelope = envelope.toArray(Address[]::new);
removeHeader("Bcc");
}
@Override
public Address[] getAllRecipients() {
return envelope.clone();
}
/** Keeps the source's Message-ID; the default would generate a new one on every save. */
@Override
protected void updateMessageID() throws MessagingException {
if (getHeader("Message-ID") == null) {
super.updateMessageID();
}
}
}
@@ -0,0 +1,66 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.Address;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
/** Builds the concrete messages for a {@link DeliveryPlan}. */
public final class MessageAssembler {
private final PgpMimeEncryptor mimeEncryptor;
private final SenderKeyProvider senderKeys;
public MessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
this.mimeEncryptor = Objects.requireNonNull(mimeEncryptor, "mimeEncryptor");
this.senderKeys = Objects.requireNonNull(senderKeys, "senderKeys");
}
/**
* @param newMessage creates empty messages bound to the sending session, e.g.
* {@code JavaMailSender::createMimeMessage}
*/
public List<MimeMessage> assemble(MimeMessage original, DeliveryPlan plan, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException {
List<OpenPGPCertificate> selfKeys = senderKey(original);
// All copies are the same message to their readers: same Date and Message-ID.
if (original.getSentDate() == null) {
original.setSentDate(new Date());
}
original.saveChanges();
String messageId = original.getMessageID();
List<MimeMessage> messages = new ArrayList<>();
for (Delivery delivery : plan.deliveries()) {
MimeMessage message = original;
if (delivery.encrypted()) {
List<OpenPGPCertificate> visible = new ArrayList<>(selfKeys);
List<OpenPGPCertificate> hidden = new ArrayList<>();
(delivery.hiddenRecipients() ? hidden : visible).addAll(delivery.recipientKeys());
message = mimeEncryptor.encrypt(original, visible, hidden, newMessage);
}
EnvelopeMimeMessage copy = new EnvelopeMimeMessage(message, delivery.envelope());
copy.setHeader("Message-ID", messageId);
messages.add(copy);
}
return messages;
}
private List<OpenPGPCertificate> senderKey(MimeMessage original) throws MessagingException {
Address[] from = original.getFrom();
if (from == null || from.length == 0 || !(from[0] instanceof InternetAddress sender)) {
return List.of();
}
return senderKeys.keyFor(sender.getAddress()).map(List::of).orElse(List.of());
}
}
@@ -0,0 +1,9 @@
package pro.thinhha.mailsender.pgp.delivery;
/** What to do when some recipients of one message will get it encrypted and others in plaintext. */
public enum MixedMode {
/** Send an encrypted copy to recipients with keys and a plaintext copy to the rest. */
SPLIT,
/** Refuse to send. */
FAIL
}
@@ -0,0 +1,44 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Resolves the {@link EncryptionPolicy} for an address: an exact address rule wins over a {@code *@domain} rule,
* which wins over the default. Matching ignores case.
*/
public final class PolicyResolver {
private final EncryptionPolicy defaultPolicy;
private final Map<String, EncryptionPolicy> rules;
public PolicyResolver(EncryptionPolicy defaultPolicy, Map<String, EncryptionPolicy> rules) {
this.defaultPolicy = Objects.requireNonNull(defaultPolicy, "defaultPolicy");
Map<String, EncryptionPolicy> normalized = new HashMap<>();
rules.forEach((pattern, policy) -> {
String key = normalize(pattern);
if (!key.contains("@")) {
throw new IllegalArgumentException(
"Policy rule must be an address or *@domain, got: " + pattern);
}
normalized.put(key, Objects.requireNonNull(policy, pattern));
});
this.rules = Map.copyOf(normalized);
}
public EncryptionPolicy resolve(String email) {
String address = normalize(email);
EncryptionPolicy exact = rules.get(address);
if (exact != null) {
return exact;
}
String domain = address.substring(address.lastIndexOf('@') + 1);
return rules.getOrDefault("*@" + domain, defaultPolicy);
}
private static String normalize(String value) {
return value.strip().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,24 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Objects;
/**
* How one recipient will receive the message.
*
* @param encrypted whether the copy this recipient receives is encrypted
*/
public record RecipientDecision(String address, RecipientType type, EncryptionPolicy policy, RecipientKey key,
boolean encrypted) {
public RecipientDecision {
Objects.requireNonNull(address, "address");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(policy, "policy");
Objects.requireNonNull(key, "key");
}
/** An OPPORTUNISTIC recipient that gets plaintext because no usable key exists. */
public boolean isDowngrade() {
return !encrypted && policy == EncryptionPolicy.OPPORTUNISTIC;
}
}
@@ -0,0 +1,37 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
/** Whether a recipient has a usable (discovered and validated) key. */
public sealed interface RecipientKey {
record Usable(OpenPGPCertificate certificate, DiscoverySource source) implements RecipientKey {
public Usable {
Objects.requireNonNull(certificate, "certificate");
Objects.requireNonNull(source, "source");
}
}
record Missing(Reason reason, String detail) implements RecipientKey {
public Missing {
Objects.requireNonNull(reason, "reason");
}
}
/** No lookup was made because the recipient's policy is {@link EncryptionPolicy#PLAINTEXT}. */
record NotLookedUp() implements RecipientKey {
}
enum Reason {
/** No key published. */
NOT_FOUND,
/** The lookup failed (timeout, server error, malformed response). */
UNREACHABLE,
/** Keys were found but none passed validation. */
REJECTED
}
}
@@ -0,0 +1,46 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
/** Discovers and validates a recipient's key; the first certificate that passes validation is used. */
public final class RecipientKeyResolver {
private final KeyDiscovery discovery;
private final CertificateValidator validator;
public RecipientKeyResolver(KeyDiscovery discovery, CertificateValidator validator) {
this.discovery = Objects.requireNonNull(discovery, "discovery");
this.validator = Objects.requireNonNull(validator, "validator");
}
public RecipientKey resolve(String email) {
return switch (discovery.discover(email)) {
case DiscoveryResult.Found found -> firstValid(found, email);
case DiscoveryResult.NotFound ignored -> new RecipientKey.Missing(RecipientKey.Reason.NOT_FOUND, null);
case DiscoveryResult.Unreachable u -> new RecipientKey.Missing(RecipientKey.Reason.UNREACHABLE, u.reason());
};
}
private RecipientKey firstValid(DiscoveryResult.Found found, String email) {
List<String> rejections = new ArrayList<>();
for (OpenPGPCertificate certificate : found.certificates()) {
switch (validator.validate(certificate, email)) {
case ValidationResult.Valid valid -> {
return new RecipientKey.Usable(valid.certificate(), found.source());
}
case ValidationResult.Rejected rejected ->
rejections.add(rejected.reason() + " (" + rejected.detail() + ")");
}
}
return new RecipientKey.Missing(RecipientKey.Reason.REJECTED, String.join("; ", rejections));
}
}
@@ -0,0 +1,6 @@
package pro.thinhha.mailsender.pgp.delivery;
/** Header a recipient was addressed in. */
public enum RecipientType {
TO, CC, BCC
}
@@ -0,0 +1,20 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
/**
* The sender's own public key, added as an extra recipient so the sender can read the sent copy. Applications that
* need readable sent copies must provide a key for each From address; without one the copy is unreadable for the
* sender.
*/
@FunctionalInterface
public interface SenderKeyProvider {
Optional<OpenPGPCertificate> keyFor(String fromAddress);
static SenderKeyProvider none() {
return from -> Optional.empty();
}
}
@@ -0,0 +1,38 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
/** Sender keys from configuration, validated when constructed so a bad key fails at startup, not on send. */
public final class StaticSenderKeyProvider implements SenderKeyProvider {
private final Map<String, OpenPGPCertificate> keys;
public StaticSenderKeyProvider(Map<String, OpenPGPCertificate> keys, CertificateValidator validator) {
Map<String, OpenPGPCertificate> normalized = new HashMap<>();
keys.forEach((address, certificate) -> {
if (validator.validate(certificate, address) instanceof ValidationResult.Rejected rejected) {
throw new IllegalArgumentException("Sender key for " + address + " is not usable: "
+ rejected.reason() + " (" + rejected.detail() + ")");
}
normalized.put(address.strip().toLowerCase(Locale.ROOT), certificate);
});
this.keys = Map.copyOf(normalized);
}
@Override
public Optional<OpenPGPCertificate> keyFor(String fromAddress) {
return Optional.ofNullable(keys.get(fromAddress.strip().toLowerCase(Locale.ROOT)));
}
public boolean isEmpty() {
return keys.isEmpty();
}
}
@@ -0,0 +1,167 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import jakarta.mail.internet.InternetAddress;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class DeliveryPlannerTest {
private static final OpenPGPCertificate ALICE = cert("alice@example.org");
private static final OpenPGPCertificate BOB = cert("bob@example.org");
private static final OpenPGPCertificate DAVE = cert("dave@example.org");
private final Map<String, DiscoveryResult> published = new HashMap<>(Map.of(
"alice@example.org", found(ALICE),
"bob@example.org", found(BOB),
"dave@example.org", found(DAVE)));
private final Map<String, EncryptionPolicy> rules = new HashMap<>();
private int lookups;
@Test
void everyoneWithKeysGetsOneEncryptedCopy() {
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("alice@example.org"), list("bob@example.org"), list());
assertThat(plan.deliveries()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isTrue();
assertThat(d.hiddenRecipients()).isFalse();
assertThat(d.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("alice@example.org", "bob@example.org");
assertThat(d.recipientKeys()).containsExactly(ALICE, BOB);
});
assertThat(plan.decisions()).allMatch(RecipientDecision::encrypted);
}
@Test
void nobodyWithKeysGetsOnePlaintextCopyIncludingBcc() {
DeliveryPlan plan = planner(MixedMode.FAIL)
.plan(list("carol@example.org"), list(), list("erin@example.org"));
assertThat(plan.deliveries()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isFalse();
assertThat(d.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("carol@example.org", "erin@example.org");
});
assertThat(plan.decisions()).allMatch(RecipientDecision::isDowngrade);
}
@Test
void mixedRecipientsAreSplit() {
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(
list("alice@example.org", "carol@example.org"), list(), list("dave@example.org", "erin@example.org"));
assertThat(plan.deliveries()).hasSize(4);
Delivery encrypted = plan.deliveries().get(0);
assertThat(encrypted.encrypted()).isTrue();
assertThat(encrypted.envelope()).extracting(InternetAddress::getAddress).containsExactly("alice@example.org");
Delivery plain = plan.deliveries().get(1);
assertThat(plain.encrypted()).isFalse();
assertThat(plain.envelope()).extracting(InternetAddress::getAddress).containsExactly("carol@example.org");
Delivery bccWithKey = plan.deliveries().get(2);
assertThat(bccWithKey.encrypted()).isTrue();
assertThat(bccWithKey.hiddenRecipients()).isTrue();
assertThat(bccWithKey.recipientKeys()).containsExactly(DAVE);
assertThat(bccWithKey.envelope()).extracting(InternetAddress::getAddress).containsExactly("dave@example.org");
Delivery bccWithoutKey = plan.deliveries().get(3);
assertThat(bccWithoutKey.encrypted()).isFalse();
assertThat(bccWithoutKey.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("erin@example.org");
}
@Test
void mixedRecipientsAreRefusedInFailMode() {
assertThatThrownBy(() -> planner(MixedMode.FAIL).plan(
list("alice@example.org", "carol@example.org"), list(), list()))
.isInstanceOfSatisfying(EncryptionPolicyException.class,
e -> assertThat(e.addresses()).containsExactly("carol@example.org"));
}
@Test
void requiredRecipientWithoutKeyRefusesWholeMessage() {
rules.put("*@example.org", EncryptionPolicy.REQUIRE);
assertThatThrownBy(() -> planner(MixedMode.SPLIT).plan(
list("alice@example.org", "carol@example.org"), list(), list()))
.isInstanceOfSatisfying(EncryptionPolicyException.class,
e -> assertThat(e.addresses()).containsExactly("carol@example.org"));
}
@Test
void plaintextPolicySkipsLookupAndGetsPlaintextCopy() {
rules.put("bob@example.org", EncryptionPolicy.PLAINTEXT);
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("alice@example.org", "bob@example.org"), list(), list());
assertThat(lookups).isEqualTo(1);
assertThat(plan.deliveries()).hasSize(2);
RecipientDecision bob = plan.decisions().get(1);
assertThat(bob.key()).isInstanceOf(RecipientKey.NotLookedUp.class);
assertThat(bob.encrypted()).isFalse();
assertThat(bob.isDowngrade()).isFalse();
}
@Test
void invalidKeyCountsAsMissing() {
published.put("carol@example.org", found(cert("mallory@example.org")));
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("carol@example.org"), list(), list());
assertThat(plan.decisions()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isFalse();
assertThat(d.key()).isInstanceOfSatisfying(RecipientKey.Missing.class,
m -> assertThat(m.reason()).isEqualTo(RecipientKey.Reason.REJECTED));
});
}
@Test
void duplicateAddressGetsOneCopy() {
DeliveryPlan plan = planner(MixedMode.SPLIT)
.plan(list("alice@example.org"), list("Alice@Example.org"), list("alice@example.org"));
assertThat(plan.decisions()).singleElement().extracting(RecipientDecision::type).isEqualTo(RecipientType.TO);
assertThat(plan.deliveries()).singleElement()
.satisfies(d -> assertThat(d.envelope()).hasSize(1));
}
private DeliveryPlanner planner(MixedMode mode) {
KeyDiscovery discovery = email -> {
lookups++;
return published.getOrDefault(email.toLowerCase(), DiscoveryResult.notFound());
};
return new DeliveryPlanner(new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, rules),
new RecipientKeyResolver(discovery, new CertificateValidator()), mode);
}
private static List<InternetAddress> list(String... addresses) {
return java.util.Arrays.stream(addresses).map(a -> {
try {
return new InternetAddress(a);
} catch (jakarta.mail.internet.AddressException e) {
throw new IllegalArgumentException(e);
}
}).toList();
}
private static DiscoveryResult found(OpenPGPCertificate certificate) {
return new DiscoveryResult.Found(List.of(certificate), DiscoverySource.WKD_ADVANCED);
}
private static OpenPGPCertificate cert(String email) {
return PGPainless.getInstance().generateKey().modernKeyRing("<" + email + ">").toCertificate();
}
}
@@ -0,0 +1,93 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptorTest;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class MessageAssemblerTest {
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("<sender@example.com>");
private static final Session SESSION = Session.getInstance(new Properties());
@Test
void buildsEnvelopeLimitedCopiesThatShareHeaders() throws Exception {
MimeMessage original = new MimeMessage(SESSION);
original.setFrom("sender@example.com");
original.setRecipients(Message.RecipientType.TO, "alice@example.org, carol@example.org");
original.setRecipients(Message.RecipientType.BCC, "dave@example.org");
original.setSubject("Split");
original.setText("secret");
DeliveryPlan plan = new DeliveryPlan(List.of(
new Delivery(List.of(new InternetAddress("alice@example.org")), List.of(ALICE.toCertificate()), false),
Delivery.plaintext(List.of(new InternetAddress("carol@example.org"))),
new Delivery(List.of(new InternetAddress("dave@example.org")), List.of(DAVE.toCertificate()), true)),
List.of());
SenderKeyProvider senderKeys = new StaticSenderKeyProvider(
Map.of("Sender@Example.com", SENDER.toCertificate()), new CertificateValidator());
List<MimeMessage> messages = new MessageAssembler(new PgpMimeEncryptor(new PgpEncryptor()), senderKeys)
.assemble(original, plan, () -> new MimeMessage(SESSION));
assertThat(messages).hasSize(3);
assertThat(messages).extracting(MimeMessage::getMessageID).doesNotContainNull().containsOnly(
messages.get(0).getMessageID());
for (MimeMessage message : messages) {
assertThat(message.getHeader("Bcc")).isNull();
assertThat(message.getRecipients(Message.RecipientType.TO)).hasSize(2);
assertThat(message.getSubject()).isEqualTo("Split");
}
MimeMessage toAlice = messages.get(0);
assertThat(toAlice.getAllRecipients()).extracting(Object::toString).containsExactly("alice@example.org");
assertThat(toAlice.isMimeType("multipart/encrypted")).isTrue();
byte[] armored = encryptedPart(toAlice);
assertThat(new String(PgpEncryptorTest.decrypt(armored, ALICE))).contains("secret");
assertThat(new String(PgpEncryptorTest.decrypt(armored, SENDER))).as("readable sent copy").contains("secret");
MimeMessage toCarol = messages.get(1);
assertThat(toCarol.getAllRecipients()).extracting(Object::toString).containsExactly("carol@example.org");
assertThat(toCarol.getContent()).isEqualTo("secret");
MimeMessage toDave = messages.get(2);
assertThat(toDave.getAllRecipients()).extracting(Object::toString).containsExactly("dave@example.org");
assertThat(new String(PgpEncryptorTest.decrypt(encryptedPart(toDave), DAVE))).contains("secret");
}
@Test
void staticSenderKeyProviderRejectsUnusableKey() {
org.assertj.core.api.Assertions.assertThatIllegalArgumentException().isThrownBy(() ->
new StaticSenderKeyProvider(Map.of("other@example.com", SENDER.toCertificate()),
new CertificateValidator()));
assertThat(SenderKeyProvider.none().keyFor("x@y.z")).isEqualTo(Optional.empty());
}
private static byte[] encryptedPart(MimeMessage message) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
message.writeTo(out);
MimeMessage parsed = new MimeMessage(SESSION, new ByteArrayInputStream(out.toByteArray()));
return ((Multipart) parsed.getContent()).getBodyPart(1).getInputStream().readAllBytes();
}
}
@@ -0,0 +1,35 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.util.Map;
import org.junit.jupiter.api.Test;
class PolicyResolverTest {
private final PolicyResolver resolver = new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, Map.of(
"*@partner.com", EncryptionPolicy.REQUIRE,
"Press@Partner.com", EncryptionPolicy.PLAINTEXT,
"*@newsletter.example", EncryptionPolicy.PLAINTEXT));
@Test
void exactAddressWinsOverDomainWinsOverDefault() {
assertThat(resolver.resolve("press@partner.com")).isEqualTo(EncryptionPolicy.PLAINTEXT);
assertThat(resolver.resolve("CEO@PARTNER.COM")).isEqualTo(EncryptionPolicy.REQUIRE);
assertThat(resolver.resolve("someone@newsletter.example")).isEqualTo(EncryptionPolicy.PLAINTEXT);
assertThat(resolver.resolve("alice@example.org")).isEqualTo(EncryptionPolicy.OPPORTUNISTIC);
}
@Test
void domainRuleDoesNotMatchSubdomains() {
assertThat(resolver.resolve("a@sub.partner.com")).isEqualTo(EncryptionPolicy.OPPORTUNISTIC);
}
@Test
void rejectsRuleWithoutAt() {
assertThatIllegalArgumentException().isThrownBy(() ->
new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, Map.of("partner.com", EncryptionPolicy.REQUIRE)));
}
}