One-click email preferences from the mail footer

Java · Spring Boot · intermediate · modification

Adds the backend for the one-click preferences page linked from every mail footer, so subscribers can change what they receive without logging in. `PreferenceLinkTokens` gains `readSubscriberId`, the counterpart of `issue`, which turns a footer token back into the subscriber id, or empty for a token it cannot use. `EmailPreferencesService` resolves the subscriber from the token and reads or updates the three flags in a transaction, and `EmailPreferencesController` exposes `GET` and `PUT /api/public/email-preferences` with a validated `EmailPreferences` record. Any token it cannot use comes back as the same 404.

Shop backend on Spring Boot 3.3 (Java 21), Spring Data JPA (Hibernate 6) on PostgreSQL, Spring Security. The existing `SecurityConfig` lets `/api/public/**` through without a session and without CSRF (no session cookie is read on those paths; every public endpoint carries its own proof); everything else under `/api/**` needs a signed-in session. Existing and unchanged: the `Subscriber` entity (`id` from an identity column, `email`, and the booleans `marketing`, `productUpdates`, `weeklyDigest`) with `SubscriberRepository extends JpaRepository<Subscriber, Long>`; a `Clock` bean (`Clock.systemUTC()`); the mail templates that call `issue()` for each recipient. `mail.preference-link.secret` is 32 random bytes, base64-encoded, injected from the environment and never committed; there is one secret, no rotation. Tokens travel in the query string, as every link in the shop's emails does; the web page sends them on to these endpoints over HTTPS. Bean Validation is already in use, and a missing request parameter or an invalid body answers 400 through Spring's default handling.

Requirements

Files touched

--- src/main/java/com/acme/shop/subscriptions/PreferenceLinkTokens.java
 import java.time.Duration;
 import java.util.Base64;
+import java.util.Optional;
 import javax.crypto.Mac;
 import javax.crypto.spec.SecretKeySpec;
     static final Duration TTL = Duration.ofDays(30);
     private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
+    private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
 
     private final SecretKeySpec key;
     }
 
+    /** The subscriber a footer link was issued for, or empty if the token is malformed or expired. */
+    public Optional<Long> readSubscriberId(String token) {
+        String[] parts = token.split("\\.", -1);
+        if (parts.length != 2) {
+            return Optional.empty();
+        }
+        try {
+            String payload = new String(DECODER.decode(parts[0]), StandardCharsets.US_ASCII);
+            String[] fields = payload.split(":", -1);
+            if (fields.length != 2) {
+                return Optional.empty();
+            }
+            long subscriberId = Long.parseLong(fields[0]);
+            long expiresAt = Long.parseLong(fields[1]);
+            if (subscriberId <= 0 || clock.instant().getEpochSecond() >= expiresAt) {
+                return Optional.empty();
+            }

Review this PR

Java practice