Add metrics, protected headers and pgpkeys actuator endpoint

- metrics (optional Micrometer): per-recipient outcome/policy/reason counter,
  refused counter, key lookup timer below the cache, cache size gauge
- SendReport.RecipientOutcome gains a structured PlaintextReason
- protected headers (RFC 9788, mail.pgp.protected-headers, off by default):
  real headers inside the encrypted part, outer Subject "[...]"
- encrypting keeps the caller's Message-ID
- pgpkeys endpoint (optional Actuator): inspect policy, lookup and key
  validity per address; evict one or all cached lookups
- KeyDiscoveryCustomizer and SecureMailSenderCustomizer extension points

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
Claude committed 2026-10-12 03:33:17 +00:00
1 parent 3bce057eab
commit f63636f8f4
22 files changed
+767 -22

No files matched your search

@@ -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);
}
@@ -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.
* <ul>
* <li>{@code GET pgpkeys} — cache size</li>
* <li>{@code GET pgpkeys/{email}} — policy, key lookup (through the cache) and validation of each key</li>
* <li>{@code DELETE pgpkeys/{email}} / {@code DELETE pgpkeys} — evict one address / everything</li>
* </ul>
*/
@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<String, Object> summary() {
Map<String, Object> 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<KeyReport> 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<String> 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<KeyReport> keys) {
}
public record KeyReport(String fingerprint, List<String> userIds, Instant expires, boolean valid,
String rejectedReason, String rejectedDetail) {
}
}
@@ -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> hostResolver) {
WkdKeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system),
ObjectProvider<HostResolver> hostResolver, ObjectProvider<KeyDiscoveryCustomizer> 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<JavaMailSender> mailSender,
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
PolicyResolver policies) {
return switch (properties.mode()) {
PolicyResolver policies, ObjectProvider<SecureMailSenderCustomizer> 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. */
@@ -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<CachingKeyDiscovery> cache,
CertificateValidator validator, PolicyResolver policies) {
return new PgpKeysEndpoint(discovery, cache.getIfAvailable(), validator, policies);
}
}
@@ -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:
* <ul>
* <li>{@code mail.pgp.recipients{outcome, policy, reason}} — counter per recipient</li>
* <li>{@code mail.pgp.refused} — messages refused by the policy</li>
* <li>{@code mail.pgp.key.lookup{result, source}} — timer for lookups not answered from the cache</li>
* <li>{@code mail.pgp.key.cache.size} — cached addresses</li>
* </ul>
*/
@AutoConfiguration
@ConditionalOnClass(MeterRegistry.class)
@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true)
public class PgpMailMetricsAutoConfiguration {
@Bean
KeyDiscoveryCustomizer pgpKeyDiscoveryMetrics(ObjectProvider<MeterRegistry> registry) {
return discovery -> {
MeterRegistry meterRegistry = registry.getIfAvailable();
return meterRegistry == null ? discovery : new MeteredKeyDiscovery(discovery, meterRegistry);
};
}
@Bean
SecureMailSenderCustomizer pgpSecureMailSenderMetrics(ObjectProvider<MeterRegistry> registry) {
return sender -> {
MeterRegistry meterRegistry = registry.getIfAvailable();
return meterRegistry == null ? sender : new MeteredSecureMailSender(sender, meterRegistry);
};
}
@Bean
MeterBinder pgpKeyCacheMetrics(ObjectProvider<CachingKeyDiscovery> 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));
}
}
@@ -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<String, EncryptionPolicy> recipients,
@DefaultValue Wkd wkd,
@DefaultValue Cache cache,
@@ -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);
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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();
@@ -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));
}
}
}
@@ -17,10 +17,24 @@ public record SendReport(List<RecipientOutcome> 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 {
@@ -1 +1,3 @@
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailMetricsAutoConfiguration
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailEndpointAutoConfiguration
@@ -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 <bob@legacy.org>").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\"");
}
}
@@ -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 <bob@legacy.org>").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 <bob@legacy.org>");
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();
});
}
}
@@ -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;
}
};
}
}