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

+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-demo</artifactId>
<description>Demo app: looks up the recipient's WKD key and sends test e-mails</description>
<dependencies>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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");
}
}