diff --git a/README.md b/README.md
index e240426..fb5bf3a 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,12 @@ mail:
Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/PLAN.md) for all options.
+- `mail.pgp.protected-headers=true` encrypts the real Subject too (RFC 9788); the visible Subject becomes `[...]`.
+- With Micrometer, `mail.pgp.recipients{outcome,policy,reason}`, `mail.pgp.refused`, `mail.pgp.key.lookup` and
+ `mail.pgp.key.cache.size` are recorded.
+- With Actuator, expose `pgpkeys` to inspect an address (`GET /actuator/pgpkeys/{email}`) or evict cached keys
+ (`DELETE /actuator/pgpkeys/{email}`).
+
## Demo app
The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 7e3a2cf..487549d 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -155,10 +155,22 @@ mail.pgp:
relay: { ... } # see RELAY mode
```
-## Observability
-- `mail.pgp.sent{mode=encrypted|plaintext}`, `mail.pgp.downgrade{reason=no_key|unreachable|invalid_key}`
-- `mail.pgp.wkd.lookup{result=found|not_found|unreachable, method=advanced|direct}`
-- Caffeine cache metrics; actuator endpoint `pgpkeys` to inspect/evict a cached address.
+## Observability (M8)
+Micrometer is optional; with a `MeterRegistry` bean these are registered:
+- `mail.pgp.recipients{outcome=encrypted|plaintext|relayed, policy, reason=none|not_found|unreachable|rejected|plaintext_policy}`
+ — a downgrade is `outcome=plaintext, policy=opportunistic`
+- `mail.pgp.refused` — messages refused by the policy
+- `mail.pgp.key.lookup{result=found|not_found|unreachable, source=wkd_advanced|wkd_direct|none}` — timer for lookups
+ not answered from the cache
+- `mail.pgp.key.cache.size`
+
+Actuator is optional; when the `pgpkeys` endpoint is exposed
+(`management.endpoints.web.exposure.include=pgpkeys`):
+- `GET /actuator/pgpkeys` — cache size
+- `GET /actuator/pgpkeys/{email}` — policy, lookup result, and validation of each key
+- `DELETE /actuator/pgpkeys/{email}`, `DELETE /actuator/pgpkeys` — evict cached lookups (key rotation)
+
+Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache) and `SecureMailSenderCustomizer`.
## Milestones
1. **Skeleton** — parent POM, modules, CI. ✅
@@ -179,4 +191,5 @@ mail.pgp:
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** — Micrometer metrics, protected headers, actuator endpoint, docs.
+8. **Hardening** — Micrometer metrics, protected headers (RFC 9788, opt-in), actuator endpoint, docs. ✅
+ Encrypting no longer regenerates the caller's Message-ID.
diff --git a/mailsender-demo/src/main/resources/application.yml b/mailsender-demo/src/main/resources/application.yml
index d8e8734..d2dd7c6 100644
--- a/mailsender-demo/src/main/resources/application.yml
+++ b/mailsender-demo/src/main/resources/application.yml
@@ -18,6 +18,7 @@ mail:
mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY
default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC}
mixed-mode: SPLIT
+ protected-headers: ${MAIL_PGP_PROTECTED_HEADERS:false}
# recipients:
# "[*@partner.example]": REQUIRE
# Readable sent copies: the sender's own PUBLIC key per From address.
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java
index 1dff6f8..c07ed16 100644
--- a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java
@@ -7,6 +7,7 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Enumeration;
+import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
@@ -19,6 +20,7 @@ import jakarta.activation.DataHandler;
import jakarta.mail.Header;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
+import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.util.ByteArrayDataSource;
@@ -28,16 +30,29 @@ import jakarta.mail.util.ByteArrayDataSource;
*
* The whole inner MIME entity (its {@code Content-*} headers and body, including attachments) is encrypted, not
* just the text. Envelope headers (From, To, Cc, Subject, Date, ...) are copied to the outer message unencrypted.
+ *
+ * With protected headers (RFC 9788) the inner entity also carries the real From/To/Cc/Reply-To/Date/Message-ID/
+ * Subject and {@code protected-headers="v1"}, and the outer Subject is replaced by {@value #OBSCURED_SUBJECT}.
+ * Clients without support show {@value #OBSCURED_SUBJECT} as the subject, so this is off by default.
*/
public final class PgpMimeEncryptor {
private static final Session PARSE_SESSION = Session.getInstance(new Properties());
private static final byte[] VERSION_PART = "Version: 1\r\n".getBytes(StandardCharsets.US_ASCII);
+ public static final String OBSCURED_SUBJECT = "[...]";
+ private static final List PROTECTED_HEADERS =
+ List.of("From", "To", "Cc", "Reply-To", "Date", "Message-ID", "Subject");
private final PgpEncryptor encryptor;
+ private final boolean protectHeaders;
public PgpMimeEncryptor(PgpEncryptor encryptor) {
+ this(encryptor, false);
+ }
+
+ public PgpMimeEncryptor(PgpEncryptor encryptor, boolean protectHeaders) {
this.encryptor = Objects.requireNonNull(encryptor, "encryptor");
+ this.protectHeaders = protectHeaders;
}
/**
@@ -51,33 +66,58 @@ public final class PgpMimeEncryptor {
Collection hiddenRecipients, Supplier newMessage)
throws MessagingException, IOException {
MimeMessage source = snapshot(original);
- byte[] ciphertext = encryptor.encrypt(innerEntity(source), recipients, hiddenRecipients);
+ byte[] ciphertext = encryptor.encrypt(innerEntity(source, protectHeaders), recipients, hiddenRecipients);
MimeMessage outer = newMessage.get();
copyEnvelopeHeaders(source, outer);
+ if (protectHeaders && outer.getHeader("Subject") != null) {
+ outer.setHeader("Subject", OBSCURED_SUBJECT);
+ }
outer.setContent(pgpMimeBody(ciphertext));
outer.saveChanges();
return outer;
}
- /** Serializes and re-parses the message so headers are final and the encoded body is available. */
+ /**
+ * Serializes and re-parses the message so headers are final and the encoded body is available. Keeps the
+ * caller's Message-ID, which {@code saveChanges()} would otherwise regenerate.
+ */
private static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException {
+ String messageId = original.getMessageID();
original.saveChanges();
+ if (messageId != null) {
+ original.setHeader("Message-ID", messageId);
+ }
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
original.writeTo(buffer);
return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(buffer.toByteArray()));
}
- /** The {@code Content-*} headers plus the still-encoded body, with CRLF line endings. */
- static byte[] innerEntity(MimeMessage source) throws MessagingException, IOException {
+ /**
+ * The {@code Content-*} headers (plus the protected headers, if enabled) and the still-encoded body, with CRLF
+ * line endings.
+ */
+ static byte[] innerEntity(MimeMessage source, boolean protectHeaders) throws MessagingException, IOException {
ByteArrayOutputStream entity = new ByteArrayOutputStream();
+ if (protectHeaders) {
+ for (String name : PROTECTED_HEADERS) {
+ String[] values = source.getHeader(name);
+ if (values != null) {
+ for (String value : values) {
+ writeHeader(entity, name + ": " + value);
+ }
+ }
+ }
+ }
Enumeration lines = source.getAllHeaderLines();
while (lines.hasMoreElements()) {
String line = lines.nextElement();
- if (isContentHeader(line)) {
- entity.write(line.getBytes(StandardCharsets.UTF_8));
- entity.write('\r');
- entity.write('\n');
+ if (protectHeaders && line.regionMatches(true, 0, "Content-Type:", 0, "Content-Type:".length())) {
+ ContentType type = new ContentType(source.getContentType());
+ type.setParameter("protected-headers", "v1");
+ writeHeader(entity, "Content-Type: " + type);
+ } else if (isContentHeader(line)) {
+ writeHeader(entity, line);
}
}
entity.write('\r');
@@ -88,6 +128,12 @@ public final class PgpMimeEncryptor {
return toCrlf(entity.toByteArray());
}
+ private static void writeHeader(ByteArrayOutputStream entity, String line) throws IOException {
+ entity.write(line.getBytes(StandardCharsets.UTF_8));
+ entity.write('\r');
+ entity.write('\n');
+ }
+
private static void copyEnvelopeHeaders(MimeMessage source, MimeMessage outer) throws MessagingException {
Enumeration headers = source.getAllHeaders();
while (headers.hasMoreElements()) {
diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java
index 987cab5..397733f 100644
--- a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java
@@ -102,6 +102,38 @@ class PgpMimeEncryptorTest {
assertThat(original.getContent()).isEqualTo("plain");
}
+ @Test
+ void protectedHeadersMoveTheSubjectInside() throws Exception {
+ PgpMimeEncryptor protecting = new PgpMimeEncryptor(new PgpEncryptor(), true);
+ MimeMessage original = textMessage("body");
+ original.setHeader("Message-ID", "");
+
+ MimeMessage wire = sendAndReceive(protecting.encrypt(original, List.of(ALICE.toCertificate()), List.of(),
+ () -> new MimeMessage(SESSION)));
+
+ assertThat(wire.getSubject()).isEqualTo(PgpMimeEncryptor.OBSCURED_SUBJECT);
+ assertThat(original.getMessageID()).as("caller's Message-ID is kept").isEqualTo("");
+ byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII);
+ MimeBodyPart inner = new MimeBodyPart(new ByteArrayInputStream(PgpEncryptorTest.decrypt(armored, ALICE)));
+ assertThat(new ContentType(inner.getContentType()).getParameter("protected-headers")).isEqualTo("v1");
+ assertThat(inner.getHeader("Subject")).containsExactly("Quarterly numbers");
+ assertThat(inner.getHeader("To")).containsExactly("alice@example.org");
+ assertThat(inner.getHeader("Message-ID")).containsExactly("");
+ assertThat(inner.getContent()).isEqualTo("body");
+ }
+
+ @Test
+ void headersAreNotProtectedByDefault() throws Exception {
+ MimeMessage wire = sendAndReceive(mimeEncryptor.encrypt(textMessage("body"), List.of(ALICE.toCertificate()),
+ List.of(), () -> new MimeMessage(SESSION)));
+
+ byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII);
+ MimeBodyPart inner = new MimeBodyPart(new ByteArrayInputStream(PgpEncryptorTest.decrypt(armored, ALICE)));
+ assertThat(wire.getSubject()).isEqualTo("Quarterly numbers");
+ assertThat(inner.getHeader("Subject")).isNull();
+ assertThat(new ContentType(inner.getContentType()).getParameter("protected-headers")).isNull();
+ }
+
private static MimeMessage textMessage(String text) throws Exception {
MimeMessage message = new MimeMessage(SESSION);
message.setFrom(new InternetAddress("sender@example.com"));
diff --git a/mailsender-pgp-spring-boot-autoconfigure/pom.xml b/mailsender-pgp-spring-boot-autoconfigure/pom.xml
index b9a80ea..b88e61e 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/pom.xml
+++ b/mailsender-pgp-spring-boot-autoconfigure/pom.xml
@@ -26,6 +26,21 @@
org.springframework
spring-context-support
+
+ io.micrometer
+ micrometer-core
+ true
+
+
+ org.springframework.boot
+ spring-boot-actuator
+ true
+
+
+ org.springframework.boot
+ spring-boot-actuator-autoconfigure
+ true
+
org.springframework.boot
spring-boot-configuration-processor
@@ -47,6 +62,16 @@
greenmail-junit5
test
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+ test
+
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java
new file mode 100644
index 0000000..30190fc
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java
@@ -0,0 +1,12 @@
+package pro.thinhha.mailsender.pgp.autoconfigure;
+
+import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
+
+/**
+ * Wraps the WKD discovery before it is cached, so the wrapper sees real lookups only (e.g. metrics, tracing).
+ */
+@FunctionalInterface
+public interface KeyDiscoveryCustomizer {
+
+ KeyDiscovery customize(KeyDiscovery wkdDiscovery);
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java
new file mode 100644
index 0000000..07c989d
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java
@@ -0,0 +1,121 @@
+package pro.thinhha.mailsender.pgp.autoconfigure;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.bouncycastle.openpgp.api.OpenPGPCertificate;
+import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation;
+import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
+import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
+import org.springframework.boot.actuate.endpoint.annotation.Selector;
+
+import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
+import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
+import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
+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;
+
+/**
+ * {@code /actuator/pgpkeys}: inspect how an address would be handled, and evict cached lookups after a recipient
+ * rotated their key.
+ *
+ * - {@code GET pgpkeys} — cache size
+ * - {@code GET pgpkeys/{email}} — policy, key lookup (through the cache) and validation of each key
+ * - {@code DELETE pgpkeys/{email}} / {@code DELETE pgpkeys} — evict one address / everything
+ *
+ */
+@Endpoint(id = "pgpkeys")
+public class PgpKeysEndpoint {
+
+ private final KeyDiscovery discovery;
+ private final CachingKeyDiscovery cache;
+ private final CertificateValidator validator;
+ private final PolicyResolver policies;
+
+ /** @param cache {@code null} when WKD is disabled */
+ public PgpKeysEndpoint(KeyDiscovery discovery, CachingKeyDiscovery cache, CertificateValidator validator,
+ PolicyResolver policies) {
+ this.discovery = discovery;
+ this.cache = cache;
+ this.validator = validator;
+ this.policies = policies;
+ }
+
+ @ReadOperation
+ public Map summary() {
+ Map summary = new LinkedHashMap<>();
+ summary.put("wkd", cache != null);
+ summary.put("cachedAddresses", cache == null ? 0 : cache.estimatedSize());
+ return summary;
+ }
+
+ @ReadOperation
+ public AddressReport address(@Selector String email) {
+ EncryptionPolicy policy = policies.resolve(email);
+ DiscoveryResult result = discovery.discover(email);
+ List keys = new ArrayList<>();
+ String lookup;
+ String detail = null;
+ switch (result) {
+ case DiscoveryResult.Found found -> {
+ lookup = "found:" + found.source();
+ for (OpenPGPCertificate certificate : found.certificates()) {
+ keys.add(keyReport(certificate, email));
+ }
+ }
+ case DiscoveryResult.NotFound ignored -> lookup = "not_found";
+ case DiscoveryResult.Unreachable unreachable -> {
+ lookup = "unreachable";
+ detail = unreachable.reason();
+ }
+ }
+ boolean encrypt = policy != EncryptionPolicy.PLAINTEXT && keys.stream().anyMatch(k -> k.valid);
+ return new AddressReport(email, policy, lookup, detail, encrypt, keys);
+ }
+
+ @DeleteOperation
+ public void evict(@Selector String email) {
+ if (cache != null) {
+ cache.evict(email);
+ }
+ }
+
+ @DeleteOperation
+ public void evictAll() {
+ if (cache != null) {
+ cache.evictAll();
+ }
+ }
+
+ private KeyReport keyReport(OpenPGPCertificate certificate, String email) {
+ ValidationResult validation = validator.validate(certificate, email);
+ List userIds = certificate.getAllUserIds().stream()
+ .map(OpenPGPCertificate.OpenPGPUserId::getUserId).toList();
+ Instant expires = certificate.getExpirationTime() == null ? null : certificate.getExpirationTime().toInstant();
+ return switch (validation) {
+ case ValidationResult.Valid ignored ->
+ new KeyReport(fingerprint(certificate), userIds, expires, true, null, null);
+ case ValidationResult.Rejected rejected -> new KeyReport(fingerprint(certificate), userIds, expires, false,
+ rejected.reason().name(), rejected.detail());
+ };
+ }
+
+ private static String fingerprint(OpenPGPCertificate certificate) {
+ return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint());
+ }
+
+ /** @param wouldEncrypt whether a message to this address alone would currently be encrypted */
+ public record AddressReport(String address, EncryptionPolicy policy, String lookup, String lookupDetail,
+ boolean wouldEncrypt, List keys) {
+ }
+
+ public record KeyReport(String fingerprint, List userIds, Instant expires, boolean valid,
+ String rejectedReason, String rejectedDetail) {
+ }
+}
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 6deae4e..433c89b 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
@@ -77,9 +77,12 @@ public class PgpMailAutoConfiguration {
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true)
CachingKeyDiscovery pgpCachingWkdDiscovery(PgpMailProperties properties, WkdFetcher fetcher,
- ObjectProvider hostResolver) {
- WkdKeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system),
+ ObjectProvider hostResolver, ObjectProvider customizers) {
+ KeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system),
properties.wkd().fallback());
+ for (KeyDiscoveryCustomizer customizer : customizers.orderedStream().toList()) {
+ wkd = customizer.customize(wkd);
+ }
PgpMailProperties.Cache cache = properties.cache();
return new CachingKeyDiscovery(wkd, new CachingKeyDiscovery.Settings(
cache.ttl(), cache.negativeTtl(), cache.errorTtl(), cache.maxSize()));
@@ -109,8 +112,8 @@ public class PgpMailAutoConfiguration {
@Bean
@ConditionalOnMissingBean
- PgpMimeEncryptor pgpMimeEncryptor(PgpEncryptor encryptor) {
- return new PgpMimeEncryptor(encryptor);
+ PgpMimeEncryptor pgpMimeEncryptor(PgpMailProperties properties, PgpEncryptor encryptor) {
+ return new PgpMimeEncryptor(encryptor, properties.protectedHeaders());
}
@Bean
@@ -159,8 +162,8 @@ public class PgpMailAutoConfiguration {
@ConditionalOnMissingBean
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider mailSender,
ObjectProvider planner, ObjectProvider assembler,
- PolicyResolver policies) {
- return switch (properties.mode()) {
+ PolicyResolver policies, ObjectProvider customizers) {
+ SecureMailSender sender = switch (properties.mode()) {
case BUILT_IN -> {
JavaMailSender delegate = mailSender.getIfAvailable();
if (delegate == null) {
@@ -176,6 +179,10 @@ public class PgpMailAutoConfiguration {
yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader());
}
};
+ for (SecureMailSenderCustomizer customizer : customizers.orderedStream().toList()) {
+ sender = customizer.customize(sender);
+ }
+ return sender;
}
/** Not a bean: a second JavaMailSender would make Boot's own {@code spring.mail} sender back off. */
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java
new file mode 100644
index 0000000..4914dc4
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java
@@ -0,0 +1,29 @@
+package pro.thinhha.mailsender.pgp.autoconfigure;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+
+import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
+import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
+import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
+import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
+
+/** Registers {@link PgpKeysEndpoint} when Actuator is present and the endpoint is enabled and exposed. */
+@AutoConfiguration(after = PgpMailAutoConfiguration.class)
+@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint")
+@ConditionalOnBean(PolicyResolver.class)
+public class PgpMailEndpointAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnAvailableEndpoint
+ PgpKeysEndpoint pgpKeysEndpoint(KeyDiscovery discovery, ObjectProvider cache,
+ CertificateValidator validator, PolicyResolver policies) {
+ return new PgpKeysEndpoint(discovery, cache.getIfAvailable(), validator, policies);
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java
new file mode 100644
index 0000000..8ad0722
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java
@@ -0,0 +1,53 @@
+package pro.thinhha.mailsender.pgp.autoconfigure;
+
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.binder.MeterBinder;
+import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
+import pro.thinhha.mailsender.pgp.metrics.MeteredKeyDiscovery;
+import pro.thinhha.mailsender.pgp.metrics.MeteredSecureMailSender;
+
+/**
+ * Micrometer metrics when a {@link MeterRegistry} bean exists:
+ *
+ * - {@code mail.pgp.recipients{outcome, policy, reason}} — counter per recipient
+ * - {@code mail.pgp.refused} — messages refused by the policy
+ * - {@code mail.pgp.key.lookup{result, source}} — timer for lookups not answered from the cache
+ * - {@code mail.pgp.key.cache.size} — cached addresses
+ *
+ */
+@AutoConfiguration
+@ConditionalOnClass(MeterRegistry.class)
+@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true)
+public class PgpMailMetricsAutoConfiguration {
+
+ @Bean
+ KeyDiscoveryCustomizer pgpKeyDiscoveryMetrics(ObjectProvider registry) {
+ return discovery -> {
+ MeterRegistry meterRegistry = registry.getIfAvailable();
+ return meterRegistry == null ? discovery : new MeteredKeyDiscovery(discovery, meterRegistry);
+ };
+ }
+
+ @Bean
+ SecureMailSenderCustomizer pgpSecureMailSenderMetrics(ObjectProvider registry) {
+ return sender -> {
+ MeterRegistry meterRegistry = registry.getIfAvailable();
+ return meterRegistry == null ? sender : new MeteredSecureMailSender(sender, meterRegistry);
+ };
+ }
+
+ @Bean
+ MeterBinder pgpKeyCacheMetrics(ObjectProvider cache) {
+ return registry -> cache.ifAvailable(c -> Gauge.builder("mail.pgp.key.cache.size", c,
+ CachingKeyDiscovery::estimatedSize)
+ .description("Addresses with a cached key lookup result")
+ .register(registry));
+ }
+}
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 05a1fcf..cadb51a 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
@@ -22,6 +22,8 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
* @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 protectedHeaders move the real Subject (and other headers) into the encrypted part (RFC 9788); the
+ * visible Subject becomes {@code [...]}
* @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
@@ -32,6 +34,7 @@ public record PgpMailProperties(
@DefaultValue("BUILT_IN") Mode mode,
@DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy,
@DefaultValue("SPLIT") MixedMode mixedMode,
+ @DefaultValue("false") boolean protectedHeaders,
Map recipients,
@DefaultValue Wkd wkd,
@DefaultValue Cache cache,
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java
new file mode 100644
index 0000000..fc61254
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java
@@ -0,0 +1,10 @@
+package pro.thinhha.mailsender.pgp.autoconfigure;
+
+import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
+
+/** Wraps the auto-configured {@link SecureMailSender} (e.g. metrics, auditing). */
+@FunctionalInterface
+public interface SecureMailSenderCustomizer {
+
+ SecureMailSender customize(SecureMailSender sender);
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java
new file mode 100644
index 0000000..0b2a1cd
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java
@@ -0,0 +1,41 @@
+package pro.thinhha.mailsender.pgp.metrics;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
+import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
+
+/** Times each real key lookup: {@code mail.pgp.key.lookup{result, source}}. */
+public final class MeteredKeyDiscovery implements KeyDiscovery {
+
+ static final String LOOKUP = "mail.pgp.key.lookup";
+
+ private final KeyDiscovery delegate;
+ private final MeterRegistry registry;
+
+ public MeteredKeyDiscovery(KeyDiscovery delegate, MeterRegistry registry) {
+ this.delegate = Objects.requireNonNull(delegate, "delegate");
+ this.registry = Objects.requireNonNull(registry, "registry");
+ }
+
+ @Override
+ public DiscoveryResult discover(String email) {
+ Timer.Sample sample = Timer.start(registry);
+ DiscoveryResult result = delegate.discover(email);
+ sample.stop(Timer.builder(LOOKUP)
+ .description("Key lookups that were not answered from the cache")
+ .tag("result", switch (result) {
+ case DiscoveryResult.Found ignored -> "found";
+ case DiscoveryResult.NotFound ignored -> "not_found";
+ case DiscoveryResult.Unreachable ignored -> "unreachable";
+ })
+ .tag("source", result instanceof DiscoveryResult.Found found
+ ? found.source().name().toLowerCase(Locale.ROOT)
+ : "none")
+ .register(registry));
+ return result;
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java
new file mode 100644
index 0000000..2d1d8c4
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java
@@ -0,0 +1,78 @@
+package pro.thinhha.mailsender.pgp.metrics;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import org.springframework.mail.SimpleMailMessage;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import jakarta.mail.internet.MimeMessage;
+import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
+import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
+import pro.thinhha.mailsender.pgp.sender.SendReport;
+
+/**
+ * Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason}} and refused messages
+ * {@code mail.pgp.refused}. A plaintext recipient with policy {@code opportunistic} is a downgrade.
+ */
+public final class MeteredSecureMailSender implements SecureMailSender {
+
+ static final String RECIPIENTS = "mail.pgp.recipients";
+ static final String REFUSED = "mail.pgp.refused";
+
+ private final SecureMailSender delegate;
+ private final MeterRegistry registry;
+
+ public MeteredSecureMailSender(SecureMailSender delegate, MeterRegistry registry) {
+ this.delegate = Objects.requireNonNull(delegate, "delegate");
+ this.registry = Objects.requireNonNull(registry, "registry");
+ }
+
+ @Override
+ public MimeMessage createMimeMessage() {
+ return delegate.createMimeMessage();
+ }
+
+ @Override
+ public SendReport send(MimeMessage message) {
+ try {
+ return record(delegate.send(message));
+ } catch (EncryptionPolicyException e) {
+ refused();
+ throw e;
+ }
+ }
+
+ @Override
+ public SendReport send(SimpleMailMessage message) {
+ try {
+ return record(delegate.send(message));
+ } catch (EncryptionPolicyException e) {
+ refused();
+ throw e;
+ }
+ }
+
+ private SendReport record(SendReport report) {
+ for (SendReport.RecipientOutcome recipient : report.recipients()) {
+ Counter.builder(RECIPIENTS)
+ .description("Recipients by delivery outcome")
+ .tag("outcome", lower(recipient.outcome()))
+ .tag("policy", lower(recipient.policy()))
+ .tag("reason", lower(recipient.reason()))
+ .register(registry)
+ .increment();
+ }
+ return report;
+ }
+
+ private void refused() {
+ Counter.builder(REFUSED).description("Messages refused by the encryption policy").register(registry)
+ .increment();
+ }
+
+ private static String lower(Enum> value) {
+ return value.name().toLowerCase(Locale.ROOT);
+ }
+}
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
index c9805ff..c7798b4 100644
--- 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
@@ -59,12 +59,25 @@ public final class BuiltInSecureMailSender extends AbstractSecureMailSender {
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() ? SendReport.PlaintextReason.NONE : reason(d.key()),
d.encrypted() ? null : describe(d.key())))
.toList());
log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report);
return report;
}
+ private static SendReport.PlaintextReason reason(RecipientKey key) {
+ return switch (key) {
+ case RecipientKey.Usable ignored -> SendReport.PlaintextReason.NONE;
+ case RecipientKey.Missing missing -> switch (missing.reason()) {
+ case NOT_FOUND -> SendReport.PlaintextReason.NOT_FOUND;
+ case UNREACHABLE -> SendReport.PlaintextReason.UNREACHABLE;
+ case REJECTED -> SendReport.PlaintextReason.REJECTED;
+ };
+ case RecipientKey.NotLookedUp ignored -> SendReport.PlaintextReason.PLAINTEXT_POLICY;
+ };
+ }
+
private static String describe(RecipientKey key) {
return switch (key) {
case RecipientKey.Usable usable -> "key from " + usable.source();
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
index d20aa9f..a896ccf 100644
--- 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
@@ -65,7 +65,8 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender {
RecipientType type) {
for (InternetAddress address : addresses) {
outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type,
- policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, null));
+ policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, SendReport.PlaintextReason.NONE,
+ null));
}
}
}
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
index 3789548..399fc8f 100644
--- 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
@@ -17,10 +17,24 @@ public record SendReport(List recipients) {
}
/**
- * @param detail why the recipient got plaintext (missing key reason), or {@code null}
+ * @param reason why the recipient got plaintext; {@link PlaintextReason#NONE} otherwise
+ * @param detail human-readable detail for the reason, or {@code null}
*/
public record RecipientOutcome(String address, RecipientType type, EncryptionPolicy policy, Outcome outcome,
- String detail) {
+ PlaintextReason reason, String detail) {
+ }
+
+ public enum PlaintextReason {
+ /** Not sent in plaintext by this application (encrypted, or relayed). */
+ NONE,
+ /** The recipient's policy is PLAINTEXT. */
+ PLAINTEXT_POLICY,
+ /** No key published. */
+ NOT_FOUND,
+ /** The key lookup failed. */
+ UNREACHABLE,
+ /** Keys were found but none was usable. */
+ REJECTED
}
public enum Outcome {
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 6bf1061..51e17c2 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1 +1,3 @@
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
+pro.thinhha.mailsender.pgp.autoconfigure.PgpMailMetricsAutoConfiguration
+pro.thinhha.mailsender.pgp.autoconfigure.PgpMailEndpointAutoConfiguration
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java
new file mode 100644
index 0000000..0f8b4f8
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java
@@ -0,0 +1,56 @@
+package pro.thinhha.mailsender.pgp.autoconfigure;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.pgpainless.PGPainless;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.web.client.RestClient;
+
+/** Addresses contain '@' and '.', so check the selector survives real HTTP path matching. */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
+ "spring.mail.host=localhost",
+ "mail.pgp.wkd.enabled=false",
+ "management.endpoints.web.exposure.include=pgpkeys"
+})
+class PgpKeysEndpointWebTest {
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ static class App {
+ }
+
+ @DynamicPropertySource
+ static void keys(DynamicPropertyRegistry registry) throws Exception {
+ Path key = Files.createTempFile("bob", ".asc");
+ key.toFile().deleteOnExit();
+ Files.writeString(key, PGPainless.getInstance().generateKey()
+ .modernKeyRing("Bob ").toCertificate().toAsciiArmoredString());
+ registry.add("mail.pgp.static-keys[bob@legacy.org]", () -> "file:" + key);
+ }
+
+ @LocalServerPort
+ int port;
+
+ @Test
+ void readsAndEvictsByAddress() {
+ RestClient client = RestClient.create("http://localhost:" + port + "/actuator/pgpkeys");
+
+ String body = client.get().uri("/bob@legacy.org").retrieve().body(String.class);
+ assertThat(body).contains("\"address\":\"bob@legacy.org\"", "\"lookup\":\"found:STATIC\"",
+ "\"wouldEncrypt\":true");
+
+ assertThat(client.delete().uri("/bob@legacy.org").retrieve().toBodilessEntity().getStatusCode())
+ .isEqualTo(HttpStatus.NO_CONTENT);
+ assertThat(client.get().retrieve().body(String.class)).contains("\"cachedAddresses\"");
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java
new file mode 100644
index 0000000..8af9ba9
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java
@@ -0,0 +1,94 @@
+package pro.thinhha.mailsender.pgp.autoconfigure;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+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.FilteredClassLoader;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
+import pro.thinhha.mailsender.pgp.metrics.MeteredSecureMailSender;
+import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
+import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
+
+class PgpMailObservabilityAutoConfigurationTest {
+
+ private final ApplicationContextRunner runner = new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class,
+ PgpMailAutoConfiguration.class, PgpMailMetricsAutoConfiguration.class,
+ PgpMailEndpointAutoConfiguration.class))
+ .withPropertyValues("spring.mail.host=localhost");
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void wrapsSenderWithMetricsWhenRegistryPresent() {
+ runner.withBean(MeterRegistry.class, SimpleMeterRegistry::new)
+ .run(context -> assertThat(context.getBean(SecureMailSender.class))
+ .isInstanceOf(MeteredSecureMailSender.class));
+ }
+
+ @Test
+ void leavesSenderAloneWithoutRegistry() {
+ runner.run(context -> assertThat(context.getBean(SecureMailSender.class))
+ .isInstanceOf(BuiltInSecureMailSender.class));
+ }
+
+ @Test
+ void worksWithoutMicrometerAndActuatorOnClasspath() {
+ runner.withClassLoader(new FilteredClassLoader("io.micrometer", "org.springframework.boot.actuate"))
+ .run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context.getBean(SecureMailSender.class)).isInstanceOf(BuiltInSecureMailSender.class);
+ });
+ }
+
+ @Test
+ void endpointOnlyWhenExposed() {
+ runner.run(context -> assertThat(context).doesNotHaveBean(PgpKeysEndpoint.class));
+ }
+
+ @Test
+ void endpointReportsPolicyKeyAndValidity() throws Exception {
+ Path key = tempDir.resolve("bob.asc");
+ Files.writeString(key, PGPainless.getInstance().generateKey()
+ .modernKeyRing("Bob ").toCertificate().toAsciiArmoredString());
+
+ runner.withPropertyValues(
+ "management.endpoints.web.exposure.include=pgpkeys",
+ "mail.pgp.wkd.enabled=false",
+ "mail.pgp.recipients[*@legacy.org]=REQUIRE",
+ "mail.pgp.static-keys[bob@legacy.org]=file:" + key)
+ .run(context -> {
+ PgpKeysEndpoint endpoint = context.getBean(PgpKeysEndpoint.class);
+
+ PgpKeysEndpoint.AddressReport bob = endpoint.address("bob@legacy.org");
+ assertThat(bob.policy()).isEqualTo(EncryptionPolicy.REQUIRE);
+ assertThat(bob.lookup()).isEqualTo("found:STATIC");
+ assertThat(bob.wouldEncrypt()).isTrue();
+ assertThat(bob.keys()).singleElement().satisfies(k -> {
+ assertThat(k.valid()).isTrue();
+ assertThat(k.userIds()).containsExactly("Bob ");
+ assertThat(k.fingerprint()).hasSize(40);
+ });
+
+ PgpKeysEndpoint.AddressReport carol = endpoint.address("carol@example.org");
+ assertThat(carol.lookup()).isEqualTo("not_found");
+ assertThat(carol.wouldEncrypt()).isFalse();
+
+ assertThat(endpoint.summary()).containsEntry("wkd", false);
+ endpoint.evict("bob@legacy.org");
+ endpoint.evictAll();
+ });
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java
new file mode 100644
index 0000000..b12d55b
--- /dev/null
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java
@@ -0,0 +1,88 @@
+package pro.thinhha.mailsender.pgp.metrics;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mail.SimpleMailMessage;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import jakarta.mail.internet.MimeMessage;
+import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
+import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
+import pro.thinhha.mailsender.pgp.delivery.RecipientType;
+import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
+import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
+import pro.thinhha.mailsender.pgp.sender.SendReport;
+import pro.thinhha.mailsender.pgp.sender.SendReport.Outcome;
+import pro.thinhha.mailsender.pgp.sender.SendReport.PlaintextReason;
+
+class MeteredSecureMailSenderTest {
+
+ private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
+
+ @Test
+ void countsRecipientsByOutcomePolicyAndReason() {
+ SendReport report = new SendReport(List.of(
+ outcome("a@example.org", Outcome.ENCRYPTED, PlaintextReason.NONE),
+ outcome("b@example.org", Outcome.ENCRYPTED, PlaintextReason.NONE),
+ outcome("c@example.org", Outcome.PLAINTEXT, PlaintextReason.NOT_FOUND)));
+
+ new MeteredSecureMailSender(sender(report, null), registry).send(new SimpleMailMessage());
+
+ assertThat(count("encrypted", "none")).isEqualTo(2);
+ assertThat(count("plaintext", "not_found")).isEqualTo(1);
+ }
+
+ @Test
+ void countsRefusals() {
+ SecureMailSender failing = sender(null, new EncryptionPolicyException("no key", List.of("x@y.z")));
+
+ assertThatThrownBy(() -> new MeteredSecureMailSender(failing, registry).send(new SimpleMailMessage()))
+ .isInstanceOf(EncryptionPolicyException.class);
+ assertThat(registry.get(MeteredSecureMailSender.REFUSED).counter().count()).isEqualTo(1);
+ }
+
+ @Test
+ void timesKeyLookupsWithResultAndSource() {
+ new MeteredKeyDiscovery(email -> DiscoveryResult.notFound(), registry).discover("a@example.org");
+
+ assertThat(registry.get(MeteredKeyDiscovery.LOOKUP).tag("result", "not_found").tag("source", "none")
+ .timer().count()).isEqualTo(1);
+ }
+
+ private double count(String outcome, String reason) {
+ return registry.get(MeteredSecureMailSender.RECIPIENTS)
+ .tag("outcome", outcome).tag("policy", "opportunistic").tag("reason", reason)
+ .counter().count();
+ }
+
+ private static SendReport.RecipientOutcome outcome(String address, Outcome outcome, PlaintextReason reason) {
+ return new SendReport.RecipientOutcome(address, RecipientType.TO, EncryptionPolicy.OPPORTUNISTIC, outcome,
+ reason, null);
+ }
+
+ private static SecureMailSender sender(SendReport report, RuntimeException failure) {
+ return new SecureMailSender() {
+ @Override
+ public MimeMessage createMimeMessage() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public SendReport send(MimeMessage message) {
+ return send((SimpleMailMessage) null);
+ }
+
+ @Override
+ public SendReport send(SimpleMailMessage message) {
+ if (failure != null) {
+ throw failure;
+ }
+ return report;
+ }
+ };
+ }
+}