Skip to content

Fix newsletter confirmation: handle already-confirmed idempotently and protect confirmed subscriber preferences#441

Merged
rvanmaanen merged 4 commits into
mainfrom
copilot/fix-confirmation-link-issue
May 29, 2026
Merged

Fix newsletter confirmation: handle already-confirmed idempotently and protect confirmed subscriber preferences#441
rvanmaanen merged 4 commits into
mainfrom
copilot/fix-confirmation-link-issue

Conversation

Copilot AI commented May 29, 2026

Copy link
Copy Markdown
Contributor

Confirmation links showed "Invalid or expired" after a valid click due to Blazor SSR pre-rendering the page (calling the API) and then the interactive client calling it again on a fresh component instance. Also, anyone could silently overwrite a confirmed subscriber's preferences by POSTing to /api/newsletter/subscribe with their email.

Changes

ConfirmSubscriptionResult enum (new)

  • Tri-state result: Confirmed / AlreadyConfirmed / InvalidToken
  • Replaces the bool return across INewsletterSubscriberRepository, INewsletterService, and the endpoint

Repository — ConfirmSubscriberAsync

  • When UPDATE … WHERE is_confirmed = FALSE matches 0 rows, does a follow-up SELECT to distinguish AlreadyConfirmed from InvalidToken (subscriber not found / unsubscribed)

API endpoint — ConfirmAsync

  • Confirmed and AlreadyConfirmed both return HTTP 200 with distinct messages; InvalidToken returns 400
return result switch
{
    ConfirmSubscriptionResult.Confirmed       => Results.Ok(new { message = "Your subscription is confirmed." }),
    ConfirmSubscriptionResult.AlreadyConfirmed => Results.Ok(new { message = "Your subscription has already been confirmed." }),
    _                                          => Results.BadRequest("Invalid or expired confirmation link.")
};

Repository — UpsertSubscriberAsync

  • SQL DO UPDATE SET now uses CASE WHEN is_confirmed = FALSE guards so a re-subscribe request never overwrites an already-confirmed subscriber's display_name or preferences

Web client / Blazor page

  • ConfirmNewsletterAsync returns Task<string> (the message from the response body) instead of Task
  • NewsletterConfirm.razor displays the API-provided message rather than a hardcoded string

Copilot AI changed the title [WIP] Fix invalid or expired confirmation link for newsletter subscription Fix newsletter confirmation: handle already-confirmed idempotently and protect confirmed subscriber preferences May 29, 2026
Copilot AI requested a review from rvanmaanen May 29, 2026 09:01
@rvanmaanen
rvanmaanen marked this pull request as ready for review May 29, 2026 09:04
Copilot AI review requested due to automatic review settings May 29, 2026 09:04
@rvanmaanen
rvanmaanen requested a review from rajbos as a code owner May 29, 2026 09:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes newsletter confirmation being non-idempotent (breaking in Blazor SSR prerender + hydration) and closes a security gap where re-subscribing could overwrite preferences for already-confirmed subscribers.

Changes:

  • Introduces ConfirmSubscriptionResult (Confirmed / AlreadyConfirmed / InvalidToken) and propagates it through Core interfaces, Infrastructure service/repository, and API confirm endpoint.
  • Makes confirmation idempotent at the repository level by distinguishing “already confirmed” vs “invalid token/not found”.
  • Prevents overwriting display_name / preferences for confirmed subscribers on UpsertSubscriberAsync, and updates Web/API tests accordingly.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/TechHub.Infrastructure.Tests/Repositories/NewsletterSubscriberRepositoryTests.cs Adds coverage for new confirm result states and confirmed-subscriber no-overwrite behavior.
tests/TechHub.Api.Tests/Endpoints/NewsletterEndpointsTests.cs Adds integration tests for confirm responses/messages and preference overwrite protection via subscribe endpoint.
src/TechHub.Web/Services/TechHubApiClient.cs Updates confirm call to return the API-provided message.
src/TechHub.Web/Services/ITechHubApiClient.cs Updates interface to return the confirmation message string.
src/TechHub.Web/Components/Pages/NewsletterConfirm.razor Displays API-provided confirmation message instead of a hardcoded one.
src/TechHub.Infrastructure/Services/Newsletter/NewsletterService.cs Changes confirm method to return ConfirmSubscriptionResult and propagates invalid token/secret cases.
src/TechHub.Infrastructure/Repositories/NewsletterSubscriberRepository.cs Makes confirm idempotent and guards UPSERT updates so confirmed subscribers aren’t overwritten.
src/TechHub.Core/Models/ConfirmSubscriptionResult.cs Adds new enum modeling the tri-state confirmation result.
src/TechHub.Core/Interfaces/INewsletterSubscriberRepository.cs Updates confirm contract to return ConfirmSubscriptionResult.
src/TechHub.Core/Interfaces/INewsletterService.cs Updates confirm contract to return ConfirmSubscriptionResult.
src/TechHub.Api/Endpoints/NewsletterEndpoints.cs Returns 200 for both Confirmed/AlreadyConfirmed with distinct messages; 400 for InvalidToken.

Comment on lines 50 to 53
try
{
await Api.ConfirmNewsletterAsync(Email, Token);
_message = "Your subscription is confirmed. Welcome to TechHub newsletters!";
_message = await Api.ConfirmNewsletterAsync(Email, Token);
_success = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot can you fix this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the latest commit. NewsletterConfirm.razor now uses PersistentComponentState: during SSR prerender the result (_message + _success) is persisted to the HTML payload, and on interactive hydration the new component instance restores from that state in OnInitializedAsync instead of calling the API a second time. The old _handled guard and OnParametersSetAsync are replaced with the standard OnInitializedAsync + IDisposable pattern used by other pages in the project.

@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

⏰ PR Preview Environment — Nightly Teardown

The preview environment was automatically torn down as part of the nightly cleanup (23:00 Brussels time).

To resume testing:

  • Push a new commit to this branch to automatically re-deploy (full CI + new images), or
  • Trigger the Redeploy PR Environment workflow manually to redeploy using the last-built image (no new commit needed).

Copilot AI review requested due to automatic review settings May 29, 2026 10:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Comment on lines +40 to +42
protected override async Task OnInitializedAsync()
{
if (_handled)
_persistSubscription = ApplicationState.RegisterOnPersisting(PersistState);
@rvanmaanen
rvanmaanen merged commit fa6067f into main May 29, 2026
18 checks passed
@rvanmaanen
rvanmaanen deleted the copilot/fix-confirmation-link-issue branch May 29, 2026 10:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid or expired confirmation link after accepting newsletter subscription

3 participants