# mailsender — OpenPGP (WKD + PGP/MIME) for Spring Boot A Spring Boot starter that lets an application keep using `JavaMailSender`-style sending while mail to recipients with a published OpenPGP key goes out encrypted. The application can either encrypt in-process ("built-in") or hand mail to an encrypting relay/gateway. ## Decisions | Topic | Decision | |---|---| | Build | Maven, multi-module | | Java | JDK 27 (`maven.compiler.release=27`) | | Spring Boot | 4.1.1 (imported as BOM, not as parent, so consumers are not forced onto our parent) | | Crypto | PGPainless 2.0.x (on Bouncy Castle 1.84, `OpenPGPCertificate` API) | | WKD hashing/URLs | `org.pgpainless:wkd-java` 0.1.3 (`WKDAddress`). Note: there is no `pgpainless-wkd` artifact on Maven Central; `wkd-java` provides the hashing/URL building. Fetching is ours (JDK `HttpClient`) so we control timeouts, size limits, redirects and fallback. | | Default policy | `OPPORTUNISTIC` | | Mixed recipients | `SPLIT` | | Readable sent copies | Only if the client configures its own public key for the sender address. No key → sent copy is not decryptable by the sender (logged once at INFO). | | Encryption mode | Client chooses `BUILT_IN` or `RELAY` via `mail.pgp.mode`. A custom `SecureMailSender` bean replaces both. | ## Modules ``` mailsender/ ├── pom.xml parent; imports spring-boot-dependencies 4.1.1 ├── mailsender-pgp-core/ plain Java, no Spring │ discovery/ KeyDiscovery, DiscoveryResult, WkdKeyDiscovery, WkdFetcher, │ HttpWkdFetcher, HostResolver, CachingKeyDiscovery, │ StaticKeyDiscovery, CompositeKeyDiscovery │ validation/ CertificateValidator, ValidationResult (M3) │ crypto/ PgpEncryptor (M4) │ mime/ PgpMimeBuilder, EncryptedMultipart, EnvelopeMimeMessage (M4) │ policy/ EncryptionPolicy, PolicyResolver, RecipientPlan, │ DeliveryPlanner (M5) ├── mailsender-pgp-spring-boot-autoconfigure/ │ PgpMailProperties, PgpMailAutoConfiguration, │ SecureMailSender (+ BuiltInSecureMailSender, RelaySecureMailSender) (M6) ├── mailsender-pgp-spring-boot-starter/ dependencies only └── mailsender-demo/ sample app + GreenMail E2E (M7) ``` ## Pipeline (BUILT_IN mode) ``` SecureMailSender.send(message) └─ collect To/Cc/Bcc └─ for each recipient: PolicyResolver → policy; KeyDiscovery (cached) → cert?; CertificateValidator └─ DeliveryPlanner → 1..n deliveries: encrypted group (To/Cc with valid keys) → 1 encrypted message plaintext group (OPPORTUNISTIC/PLAINTEXT, no key) → 1 plaintext message (WARN + metric) each Bcc recipient → own copy (encrypted if key) any REQUIRE recipient without key → EncryptionRequiredException, nothing sent └─ per encrypted delivery: inner MIME → canonical CRLF bytes → encrypt to recipients (+ sender's self key if configured) → PGP/MIME (RFC 3156) wrapper └─ JavaMailSender.send(EnvelopeMimeMessage) — returns SendReport ``` ### Key discovery (M2) - `WKDAddress.fromEmail(addr)` builds both URLs (lowercased local part → SHA-1 → z-base-32, `?l=`). - Advanced method first. Fall back to direct per the WKD draft only when the `openpgpkey.` host does not resolve (`fallback: ON_DNS_FAILURE`, default). `ON_ANY_FAILURE` is available for the many domains with wildcard DNS whose advanced host answers 404 or has a broken TLS cert. - HTTP: HTTPS only, no redirects, connect/read timeouts, max response size. - Result is a sealed type: `Found(certs, method, uri)`, `NotFound`, `Unreachable(reason)`. The policy layer treats `Unreachable` like `NotFound` for OPPORTUNISTIC recipients but logs it separately so outages are visible. - `CachingKeyDiscovery` (Caffeine): positive TTL (24h, capped at certificate expiry), negative TTL (1h), error TTL (5m). An entry can be evicted by address (key rotation). - `StaticKeyDiscovery`: client-pinned keys, checked before WKD via `CompositeKeyDiscovery`. ### Validation (M3) A certificate is usable for `addr` only if: not revoked/expired; has a valid encryption-capable (communications) subkey; has a valid UID containing `addr` (domain case-insensitive); meets the algorithm policy (e.g. RSA ≥ 2048). Rejections carry a reason for logs/metrics. ### Encryption + PGP/MIME (M4) - PGPainless `generateMessage()` with `EncryptionOptions.encryptCommunications()`, ASCII armor on. - Inner entity = the original body (text/html/attachments) with its own `Content-Type` headers, serialized with `writeTo()`, CRLF-canonicalized, then encrypted. - Outer entity: `multipart/encrypted; protocol="application/pgp-encrypted"` with part 1 `application/pgp-encrypted` = `Version: 1`, part 2 `application/octet-stream; name="encrypted.asc"`. - `EncryptedMultipart` subclasses `MimeMultipart` to set the content type itself, otherwise `saveChanges()` drops the `protocol` parameter. - Protected headers (RFC 9788) opt-in: `mail.pgp.protected-headers=true` moves the real Subject inside and sets the outer one to `...`. ### Split delivery + envelope (M5) `SPLIT` copies keep the original `To`/`Cc` headers so every recipient sees the full conversation, but each copy must only be delivered to its subgroup. `JavaMailSenderImpl` uses `MimeMessage.getAllRecipients()` as the SMTP envelope, so `EnvelopeMimeMessage` overrides that method to return only the subgroup. Bcc recipients always get their own copy (avoids leaking them in headers or key IDs). ### Sent copies (encrypt-to-self) ```yaml mail.pgp.sender-keys: "[noreply@acme.com]": classpath:keys/noreply.pub.asc "[billing@acme.com]": file:/etc/mail/billing.pub.asc ``` Or a `SenderKeyProvider` bean. Keys are validated at startup (fail fast if unusable). If the `From` address has no configured key, nothing is added and the sent copy cannot be decrypted by the sender. ## RELAY mode (M6) The application sends plaintext to an encrypting gateway (e.g. a GnuPG-based MTA filter using `--auto-key-locate wkd`, or a commercial mail gateway); key discovery and encryption happen there. ```yaml mail.pgp: mode: RELAY relay: host: gateway.internal port: 587 username: app password: ${RELAY_PASSWORD} starttls: required # the hop to the gateway carries plaintext — must be TLS policy-header: X-Encryption-Policy # optional; per-recipient policy hint for the gateway ``` - `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. - Encrypt-to-self is the gateway's responsibility in this mode. ## Configuration (full shape) Map keys that are addresses must be written in brackets (`"[bob@legacy.org]"`); otherwise Spring's relaxed binding strips the `@` and `.` characters. ```yaml mail.pgp: enabled: true mode: BUILT_IN # BUILT_IN | RELAY default-policy: OPPORTUNISTIC # REQUIRE | OPPORTUNISTIC | PLAINTEXT mixed-mode: SPLIT # SPLIT | FAIL protected-headers: false recipients: # exact address or *@domain "[ceo@example.org]": REQUIRE "[*@partner.com]": REQUIRE "[*@newsletter.example]": PLAINTEXT static-keys: "[bob@legacy.org]": file:/etc/keys/bob.asc sender-keys: {} wkd: enabled: true fallback: ON_DNS_FAILURE # ON_DNS_FAILURE | ON_ANY_FAILURE connect-timeout: 3s read-timeout: 5s max-response-size: 256KB cache: ttl: 24h negative-ttl: 1h error-ttl: 5m max-size: 10000 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. ## Milestones 1. **Skeleton** — parent POM, modules, CI. ✅ 2. **WKD discovery + cache** — `WkdKeyDiscovery`, `HttpWkdFetcher`, `CachingKeyDiscovery`, static/composite discovery, properties + autoconfig for discovery beans. Tests: z-base-32 vector, advanced/direct fallback matrix, size limit, no redirects, cache TTLs. ✅ 3. **Validation** — generated test keys: expired, revoked, sign-only, wrong UID, valid. 4. **Encryptor + PGP/MIME** — round-trip decrypt test, golden structure test, CRLF canonicalization. 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. 7. **Demo + E2E** with GreenMail; manual check against a real WKD domain, decrypt in Thunderbird. Demo app scaffolded early (sends plaintext test mails + reports WKD result) ✅; verified live: `test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`. 8. **Hardening** — protected headers, actuator endpoint, docs.