diff --git a/README.md b/README.md
index cecda2a..e240426 100644
--- a/README.md
+++ b/README.md
@@ -19,12 +19,38 @@ mvn verify
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
| `mailsender-demo` | sample app that sends test mails |
+## Usage
+
+Add `pro.thinhha.mailsender:mailsender-pgp-spring-boot-starter`, configure `spring.mail.*` as usual, and send
+through `SecureMailSender` instead of `JavaMailSender`:
+
+```java
+SendReport report = secureMailSender.send(message); // MimeMessage or SimpleMailMessage
+```
+
+Each recipient's key is looked up via WKD (cached) and validated. The message is then sent PGP/MIME-encrypted to
+recipients with a usable key and in plaintext to the rest (logged as a downgrade), or refused with
+`EncryptionPolicyException` if a `REQUIRE` recipient has no key. `SendReport` lists the outcome per recipient.
+
+```yaml
+mail:
+ pgp:
+ mode: BUILT_IN # or RELAY: hand plaintext to an encrypting gateway (mail.pgp.relay.*)
+ default-policy: OPPORTUNISTIC # REQUIRE | OPPORTUNISTIC | PLAINTEXT
+ mixed-mode: SPLIT # or FAIL
+ recipients:
+ "[*@partner.example]": REQUIRE
+ sender-keys: # your own PUBLIC key, so sent copies stay readable
+ "[noreply@example.com]": file:/etc/mail/noreply.pub.asc
+```
+
+Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/PLAN.md) for all options.
+
## Demo app
-The demo app looks up and validates the recipient's key via WKD, then sends `DEMO_COUNT` test mails
-(default: 1 to `test@thinhha.pro`). With a valid key the mail is PGP/MIME-encrypted (RFC 3156); otherwise it is
-sent in plaintext and a warning is logged. The policy layer (REQUIRE / OPPORTUNISTIC / PLAINTEXT, split
-delivery) comes in milestone 5.
+The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
+the outcome per recipient: PGP/MIME-encrypted (RFC 3156) when a valid WKD key is found, otherwise plaintext.
+Set `MAIL_PGP_MODE=RELAY` and `RELAY_HOST`/`RELAY_PORT`/`RELAY_USERNAME`/`RELAY_PASSWORD` to send via a gateway.
```
mvn -q install -DskipTests
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 2219882..7e3a2cf 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -117,8 +117,9 @@ mail.pgp:
```
- `RelaySecureMailSender` uses its own `JavaMailSender` pointed at the relay (the app's normal
`spring.mail.*` sender is untouched).
-- REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode; they are passed as a header hint
- and enforcement is the gateway's responsibility. Documented and logged at startup.
+- REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode. With `policy-header` set, the strictest
+ policy among the recipients (`require` > `opportunistic` > `plaintext`) is passed as a header hint; enforcement is
+ the gateway's responsibility. Logged at startup.
- Encrypt-to-self is the gateway's responsibility in this mode.
## Configuration (full shape)
@@ -168,10 +169,14 @@ mail.pgp:
PGPainless and Bouncy Castle can disagree on a shortened expiry; the validator uses the earlier date.
4. **Encryptor + PGP/MIME** — round-trip decrypt test, structure test, CRLF canonicalization, attachments. ✅
Verified live: the demo's mail to `test@thinhha.pro` is addressed to its cv25519 subkey `7CEE00AF05A08313`.
-5. **Policy + delivery planner** — table-driven tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc;
- `EnvelopeMimeMessage`.
-6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, metrics, autoconfig switch.
+5. **Policy + delivery planner** — `PolicyResolver`, `RecipientKeyResolver`, `DeliveryPlanner`, `MessageAssembler`,
+ `EnvelopeMimeMessage`; tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc. ✅
+ All copies of one message share its Date and Message-ID; duplicates get one copy; nobody encryptable → one
+ plaintext copy (no split).
+6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, autoconfig switch. ✅
+ GreenMail integration test checks per-mailbox (envelope) delivery, decryption by recipient and sender, REQUIRE
+ refusal with nothing sent, and relay mode with the policy header. Metrics moved to M8.
7. **Demo + E2E** with GreenMail; manual check against a real WKD domain, decrypt in Thunderbird.
Demo app ✅ — encrypts when a valid key is found, otherwise sends plaintext (logged). Verified live:
`test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`.
-8. **Hardening** — protected headers, actuator endpoint, docs.
+8. **Hardening** — Micrometer metrics, protected headers, actuator endpoint, docs.
diff --git a/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java
index 40703b6..7c3c0b4 100644
--- a/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java
+++ b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java
@@ -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).
- *
- * 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 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 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());
}
}
diff --git a/mailsender-demo/src/main/resources/application.yml b/mailsender-demo/src/main/resources/application.yml
index afb0b32..d8e8734 100644
--- a/mailsender-demo/src/main/resources/application.yml
+++ b/mailsender-demo/src/main/resources/application.yml
@@ -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:}}
diff --git a/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java
index 0b802d0..57d71e0 100644
--- a/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java
+++ b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java
@@ -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");
}
}
diff --git a/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java
index 1d37b79..ce2920a 100644
--- a/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java
+++ b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java
@@ -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");
}
}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/Delivery.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/Delivery.java
new file mode 100644
index 0000000..5d2b685
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/Delivery.java
@@ -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 envelope, List 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 envelope) {
+ return new Delivery(envelope, List.of(), false);
+ }
+
+ public boolean encrypted() {
+ return !recipientKeys.isEmpty();
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlan.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlan.java
new file mode 100644
index 0000000..8c13a6e
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlan.java
@@ -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 deliveries, List decisions) {
+
+ public DeliveryPlan {
+ deliveries = List.copyOf(deliveries);
+ decisions = List.copyOf(decisions);
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlanner.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlanner.java
new file mode 100644
index 0000000..a04018e
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlanner.java
@@ -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:
+ *
+ * - A REQUIRE recipient without a usable key → {@link EncryptionPolicyException}, nothing is sent.
+ * - Nobody gets encryption → one plaintext copy to everyone.
+ * - 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.
+ *
+ * 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 to, List cc, List bcc) {
+ Map 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 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 all = List.copyOf(recipients.values());
+ boolean anyEncrypted = all.stream().anyMatch(Recipient::hasKey);
+ List 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 split(List all) {
+ List deliveries = new ArrayList<>();
+ List visible = all.stream().filter(r -> r.type != RecipientType.BCC).toList();
+
+ List visibleEncrypted = visible.stream().filter(Recipient::hasKey).toList();
+ if (!visibleEncrypted.isEmpty()) {
+ deliveries.add(new Delivery(addresses(visibleEncrypted), certificates(visibleEncrypted), false));
+ }
+ List 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 decisions(List all) {
+ return all.stream()
+ .map(r -> new RecipientDecision(r.email(), r.type, r.policy, r.key, r.hasKey()))
+ .toList();
+ }
+
+ private static List addresses(List recipients) {
+ return recipients.stream().map(Recipient::address).toList();
+ }
+
+ private static List certificates(List recipients) {
+ return recipients.stream().map(r -> ((RecipientKey.Usable) r.key).certificate()).toList();
+ }
+
+ private static void add(Map recipients, List 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;
+ }
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicy.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicy.java
new file mode 100644
index 0000000..276340d
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicy.java
@@ -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
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicyException.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicyException.java
new file mode 100644
index 0000000..5376ae8
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicyException.java
@@ -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 addresses;
+
+ public EncryptionPolicyException(String message, List addresses) {
+ super(message + ": " + String.join(", ", addresses));
+ this.addresses = List.copyOf(addresses);
+ }
+
+ /** The recipients that caused the refusal. */
+ public List addresses() {
+ return addresses;
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EnvelopeMimeMessage.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EnvelopeMimeMessage.java
new file mode 100644
index 0000000..be2d02f
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EnvelopeMimeMessage.java
@@ -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();
+ }
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java
new file mode 100644
index 0000000..175e06e
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java
@@ -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 assemble(MimeMessage original, DeliveryPlan plan, Supplier newMessage)
+ throws MessagingException, IOException {
+ List 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 messages = new ArrayList<>();
+ for (Delivery delivery : plan.deliveries()) {
+ MimeMessage message = original;
+ if (delivery.encrypted()) {
+ List visible = new ArrayList<>(selfKeys);
+ List 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 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());
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MixedMode.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MixedMode.java
new file mode 100644
index 0000000..f23ae5b
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MixedMode.java
@@ -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
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolver.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolver.java
new file mode 100644
index 0000000..99cde49
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolver.java
@@ -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 rules;
+
+ public PolicyResolver(EncryptionPolicy defaultPolicy, Map rules) {
+ this.defaultPolicy = Objects.requireNonNull(defaultPolicy, "defaultPolicy");
+ Map 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);
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientDecision.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientDecision.java
new file mode 100644
index 0000000..a89a42c
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientDecision.java
@@ -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;
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKey.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKey.java
new file mode 100644
index 0000000..0cbc583
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKey.java
@@ -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
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKeyResolver.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKeyResolver.java
new file mode 100644
index 0000000..64c2d2f
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKeyResolver.java
@@ -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 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));
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientType.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientType.java
new file mode 100644
index 0000000..6b6b3c3
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientType.java
@@ -0,0 +1,6 @@
+package pro.thinhha.mailsender.pgp.delivery;
+
+/** Header a recipient was addressed in. */
+public enum RecipientType {
+ TO, CC, BCC
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/SenderKeyProvider.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/SenderKeyProvider.java
new file mode 100644
index 0000000..0d46af7
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/SenderKeyProvider.java
@@ -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 keyFor(String fromAddress);
+
+ static SenderKeyProvider none() {
+ return from -> Optional.empty();
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/StaticSenderKeyProvider.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/StaticSenderKeyProvider.java
new file mode 100644
index 0000000..fa6ba25
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/StaticSenderKeyProvider.java
@@ -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 keys;
+
+ public StaticSenderKeyProvider(Map keys, CertificateValidator validator) {
+ Map 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 keyFor(String fromAddress) {
+ return Optional.ofNullable(keys.get(fromAddress.strip().toLowerCase(Locale.ROOT)));
+ }
+
+ public boolean isEmpty() {
+ return keys.isEmpty();
+ }
+}
diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlannerTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlannerTest.java
new file mode 100644
index 0000000..d7adc0e
--- /dev/null
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlannerTest.java
@@ -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 published = new HashMap<>(Map.of(
+ "alice@example.org", found(ALICE),
+ "bob@example.org", found(BOB),
+ "dave@example.org", found(DAVE)));
+ private final Map 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 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();
+ }
+}
diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java
new file mode 100644
index 0000000..22331fd
--- /dev/null
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java
@@ -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("");
+ private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing("");
+ private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing("");
+ 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 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();
+ }
+}
diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolverTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolverTest.java
new file mode 100644
index 0000000..3eeac44
--- /dev/null
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolverTest.java
@@ -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)));
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/pom.xml b/mailsender-pgp-spring-boot-autoconfigure/pom.xml
index 851b9e5..b9a80ea 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/pom.xml
+++ b/mailsender-pgp-spring-boot-autoconfigure/pom.xml
@@ -21,6 +21,11 @@
org.springframework.boot
spring-boot-autoconfigure
+
+
+ org.springframework
+ spring-context-support
+
org.springframework.boot
spring-boot-configuration-processor
@@ -32,6 +37,16 @@
spring-boot-starter-test
test
+
+ org.springframework.boot
+ spring-boot-starter-mail
+ test
+
+
+ com.icegreen
+ greenmail-junit5
+ test
+
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java
index 111954b..6deae4e 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java
@@ -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> 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 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 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 mailSender,
+ ObjectProvider planner, ObjectProvider 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> readAll(String property, Map resources) {
+ Map> certificates = new LinkedHashMap<>();
+ resources.forEach((email, resource) -> certificates.put(email, read(property, email, resource)));
+ return certificates;
+ }
+
+ private static List read(String property, String email, Resource resource) {
try (InputStream in = resource.getInputStream()) {
List 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);
}
}
}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java
index fd04690..05a1fcf 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java
@@ -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 recipients,
@DefaultValue Wkd wkd,
@DefaultValue Cache cache,
- Map staticKeys) {
+ Map staticKeys,
+ Map 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 properties) {
+
+ public Relay {
+ properties = properties == null ? Map.of() : Map.copyOf(properties);
+ }
+ }
}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/AbstractSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/AbstractSecureMailSender.java
new file mode 100644
index 0000000..68d7b84
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/AbstractSecureMailSender.java
@@ -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 recipients(MimeMessage message, Message.RecipientType type) {
+ try {
+ Address[] addresses = message.getRecipients(type);
+ List 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);
+ }
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java
new file mode 100644
index 0000000..c9805ff
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java
@@ -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 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";
+ };
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java
new file mode 100644
index 0000000..d20aa9f
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java
@@ -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 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 outcomes, List addresses,
+ RecipientType type) {
+ for (InternetAddress address : addresses) {
+ outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type,
+ policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, null));
+ }
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SecureMailSender.java
new file mode 100644
index 0000000..a20071c
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SecureMailSender.java
@@ -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);
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java
new file mode 100644
index 0000000..3789548
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java
@@ -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 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
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java
index 0c02c2d..6ed73d9 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java
@@ -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("").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")
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SecureMailSenderIntegrationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SecureMailSenderIntegrationTest.java
new file mode 100644
index 0000000..a1e7699
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SecureMailSenderIntegrationTest.java
@@ -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("");
+ private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing("");
+ private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing("");
+
+ @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);
+ }
+}