Scaffold PGP mail modules with WKD key discovery and demo app

- Maven multi-module build on JDK 27 and Spring Boot 4.1.1 (BOM import)
- core: WKD discovery (advanced -> direct fallback), HTTP fetcher with
  timeouts, no redirects and a size limit, Caffeine cache with per-outcome
  TTLs capped at key expiry, pinned keys, composite chain
- autoconfigure: mail.pgp.* properties and discovery beans
- demo: sends test mails (plaintext for now) and reports the WKD result
- docs/PLAN.md: design, decisions and milestones

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:13:23 +00:00
1 parent 7701ffe034
commit 213df4c34e
35 files changed
+1885

No files matched your search

@@ -0,0 +1,14 @@
package io.github.sneakybugmaster.mailsender.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@@ -0,0 +1,16 @@
package io.github.sneakybugmaster.mailsender.demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* @param to recipient of the test mails
* @param from sender address; most SMTP providers require it to match the authenticated account
* @param count number of test mails to send
*/
@ConfigurationProperties("demo")
public record DemoProperties(
@DefaultValue("test@thinhha.pro") String to,
String from,
@DefaultValue("1") int count) {
}
@@ -0,0 +1,76 @@
package io.github.sneakybugmaster.mailsender.demo;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.stream.Collectors;
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 io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoveryResult;
import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
/**
* Looks up the recipient's key via WKD, then sends {@code demo.count} test mails describing what was found.
* <p>
* Encryption is not wired in yet (milestones 3–6), so the mails go out in plaintext.
*/
@Component
class TestMailRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
private final DemoProperties properties;
private final KeyDiscovery keyDiscovery;
private final JavaMailSender mailSender;
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, JavaMailSender mailSender) {
this.properties = properties;
this.keyDiscovery = keyDiscovery;
this.mailSender = mailSender;
}
@Override
public void run(ApplicationArguments args) throws MessagingException {
DiscoveryResult discovery = keyDiscovery.discover(properties.to());
String summary = describe(discovery);
log.info("Key discovery for {}: {}", properties.to(), summary);
for (int i = 1; i <= properties.count(); i++) {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
if (properties.from() != null) {
helper.setFrom(properties.from());
}
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.
Key discovery for %s: %s
This message was sent in plaintext: PGP/MIME encryption is not implemented yet.
""".formatted(i, properties.count(), OffsetDateTime.now(), properties.to(), summary));
mailSender.send(message);
log.info("Sent test mail {}/{} to {}", i, properties.count(), properties.to());
}
}
private static String describe(DiscoveryResult result) {
return switch (result) {
case DiscoveryResult.Found found -> "found via " + found.source() + ", fingerprint(s) "
+ found.certificates().stream()
.map(c -> HexFormat.of().withUpperCase().formatHex(c.getFingerprint()))
.collect(Collectors.joining(", "));
case DiscoveryResult.NotFound ignored -> "no key published";
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
};
}
}
@@ -0,0 +1,23 @@
spring:
main:
web-application-type: none
mail:
host: ${SMTP_HOST:localhost}
port: ${SMTP_PORT:587}
username: ${SMTP_USERNAME:}
password: ${SMTP_PASSWORD:}
properties:
mail.smtp.auth: ${SMTP_AUTH:true}
mail.smtp.starttls.enable: ${SMTP_STARTTLS:true}
mail.smtp.starttls.required: ${SMTP_STARTTLS:true}
mail.smtp.connectiontimeout: 10000
mail.smtp.timeout: 10000
demo:
to: ${DEMO_TO:test@thinhha.pro}
from: ${DEMO_FROM:${SMTP_USERNAME:}}
count: ${DEMO_COUNT:1}
logging:
level:
io.github.sneakybugmaster.mailsender: DEBUG
@@ -0,0 +1,59 @@
package io.github.sneakybugmaster.mailsender.demo;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.GreenMailUtil;
import com.icegreen.greenmail.util.ServerSetupTest;
import jakarta.mail.internet.MimeMessage;
@SpringBootTest(properties = {
"spring.mail.properties.mail.smtp.auth=false",
"spring.mail.properties.mail.smtp.starttls.enable=false",
"spring.mail.properties.mail.smtp.starttls.required=false",
"spring.mail.username=",
"demo.from=demo@mailsender.test",
"demo.count=2",
// keep the test offline: no real WKD lookup
"mail.pgp.wkd.enabled=false"
})
class TestMailRunnerTest {
// Started before the Spring context, because the runner sends while the context starts.
static GreenMail greenMail;
@BeforeAll
static void startSmtp() {
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
greenMail.start();
}
@AfterAll
static void stopSmtp() {
greenMail.stop();
}
@DynamicPropertySource
static void smtp(DynamicPropertyRegistry registry) {
registry.add("spring.mail.host", () -> "127.0.0.1");
registry.add("spring.mail.port", () -> greenMail.getSmtp().getPort());
}
@Test
void sendsTestMailsToConfiguredRecipient() throws Exception {
MimeMessage[] received = greenMail.getReceivedMessages();
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");
}
}