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
- Every email the shop sends already ends with a "Manage email preferences" link to the web page `/email-preferences?token=...`; the token comes from the existing `PreferenceLinkTokens.issue(subscriberId)`. The page needs two endpoints that work without signing in: `GET /api/public/email-preferences?token=...` returns that subscriber's three flags as `{"marketing", "productUpdates", "weeklyDigest"}`, and `PUT` on the same URL with that JSON body (all three fields required and non-null, 400 otherwise; values bind with Spring's default JSON coercion) stores them and returns the stored flags.
- A token is `<payload>.<signature>`. `payload` is the unpadded base64url encoding of the ASCII text `<subscriberId>:<expiresAt>` (a positive id, `expiresAt` in Unix seconds, 30 days after issue); `signature` is the unpadded base64url encoding of HMAC-SHA256, keyed with `mail.preference-link.secret`, over the ASCII bytes of the `payload` segment exactly as it appears in the token. The token is the only proof of who is asking: a token acts for a subscriber only if its payload is one `issue()` produced for that subscriber and its signature verifies against that payload with the secret, and it works any number of times until its `expiresAt` second (exclusive).
- Any other token (malformed, a payload that is not exactly what `issue()` produced, a signature that does not verify against its payload, expired, or for a subscriber that no longer exists) gets 404 with no body and changes nothing; it never causes a 500. A missing `token` parameter is 400. The responses contain only the three flags, nothing else about the subscriber.
Files touched
- src/main/java/com/acme/shop/subscriptions/PreferenceLinkTokens.java
- src/main/java/com/acme/shop/subscriptions/EmailPreferences.java
- src/main/java/com/acme/shop/subscriptions/EmailPreferencesService.java
- src/main/java/com/acme/shop/subscriptions/EmailPreferencesController.java
--- 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();
+ }