Unsubscribe from the newsletter with one click
Java · Spring Boot · beginner · modification
Adds the endpoint behind the newsletter's one-click unsubscribe. `POST /api/public/newsletter/unsubscribe?token=...` marks the subscriber that owns the token as unsubscribed. `UnsubscribeService` does it with one conditional `UPDATE` on the token, then tells a token that was already used apart from one that does not exist; `SubscriberRepository` gains that update and an `existsByUnsubscribeToken` check. The controller validates the token's shape and maps the result to 200 or 404.
Shop backend on Spring Boot 3.3.5 (Java 21), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16 at its default isolation (read committed); Spring MVC 6.1 validates constraint annotations on controller parameters without `@Validated` and answers a violation with 400. Existing and unchanged: the `Subscriber` entity (`id`, unique `email`, `status` stored as a string: `PENDING`, `CONFIRMED` or `UNSUBSCRIBED`, nullable `unsubscribedAt`) and its `unsubscribeToken`, a non-null, unique 43-character string made at signup from 32 `SecureRandom` bytes with `Base64.getUrlEncoder().withoutPadding()` and never changed; the `Clock` bean (`Clock.systemUTC()`). The security config (unchanged) lets anonymous visitors call `/api/public/**` and the `/error` page, does not apply CSRF protection to `/api/public/**` (those routes use no session or cookie), and requires a signed-in session everywhere else. The newsletter sender, already merged for the same release, sends every issue with `List-Unsubscribe: <https://shop.example/api/public/newsletter/unsubscribe?token=...>` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and with a footer link to the static page `https://shop.example/unsubscribe?token=...`, which loads no third-party resources, sends `Referrer-Policy: no-referrer`, and only POSTs to the endpoint when the reader clicks its button. The send job picks `CONFIRMED` subscribers when it starts. Callers of the endpoint (the page and mailbox providers) send `Accept: */*` or no `Accept` header. Subscriber rows are never deleted. The reverse proxy's access log records paths without their query strings. Errors use Spring Boot's default error body with `server.error.include-message` at its default (`never`). Signing up again is a separate existing flow, out of scope.
Requirements
- New `POST /api/public/newsletter/unsubscribe?token=<token>` needs no login. For a known token it sets that subscriber's status to `UNSUBSCRIBED`, records `unsubscribedAt` from the application's `Clock`, and answers 200 with `{"status": "unsubscribed"}`. It is called by the button on the shop's unsubscribe page (a POST with no body) and by mailbox providers' one-click unsubscribe (RFC 8058: a POST to the `List-Unsubscribe` URL with the form body `List-Unsubscribe=One-Click`), so it must accept both.
- Unsubscribing twice is harmless: a token whose subscriber is already `UNSUBSCRIBED` answers the same 200 and keeps the first `unsubscribedAt`. A `PENDING` subscriber (never confirmed) is unsubscribed like a `CONFIRMED` one. Requests for the same token that arrive at the same time all answer 200.
- A well-formed token that matches no subscriber answers 404. A missing token, or one that is not exactly 43 characters of `A-Z`, `a-z`, `0-9`, `-` and `_`, answers 400. Neither changes anything.
- A `GET` (or `HEAD`) of the same URL never unsubscribes anyone: mail security scanners fetch links found in emails. It answers 405.
- Only the subscriber the token belongs to changes. The response never contains the email address or any other subscriber data.
Files touched
- src/main/java/com/acme/shop/newsletter/SubscriberRepository.java
- src/main/java/com/acme/shop/newsletter/UnsubscribeService.java
- src/main/java/com/acme/shop/newsletter/UnsubscribeController.java
--- src/main/java/com/acme/shop/newsletter/SubscriberRepository.java
package com.acme.shop.newsletter;
+import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface SubscriberRepository extends JpaRepository<Subscriber, Long> {
List<Subscriber> findByStatus(SubscriberStatus status);
+
+ boolean existsByUnsubscribeToken(String unsubscribeToken);
+
+ @Modifying
+ @Query("""
+ update Subscriber s
+ set s.status = com.acme.shop.newsletter.SubscriberStatus.UNSUBSCRIBED,
+ s.unsubscribedAt = :now
+ where s.unsubscribeToken = :token
+ and s.status <> com.acme.shop.newsletter.SubscriberStatus.UNSUBSCRIBED
+ """)
+ int unsubscribeByToken(@Param("token") String token, @Param("now") Instant now);
}
--- src/main/java/com/acme/shop/newsletter/UnsubscribeService.java
+package com.acme.shop.newsletter;