Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(api): send verification email upon public status page email subscription (#2571) - #2588

Open
Rodrigoue9 wants to merge 7 commits into
openstatusHQ:mainfrom
Rodrigoue9:fix/bounty-2571
Open

fix(api): send verification email upon public status page email subscription (#2571)#2588
Rodrigoue9 wants to merge 7 commits into
openstatusHQ:mainfrom
Rodrigoue9:fix/bounty-2571

Conversation

@Rodrigoue9

@Rodrigoue9 Rodrigoue9 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Resolves #2571 by invoking sendEmailVerification when a visitor subscribes to a public status page via email.

Problem

Previously, when a visitor submitted an email subscription on a public status page, statusPage.subscribe upserted a pending subscriber record and returned a token, but never dispatched the verification email. The subscriber was left in a pending state without receiving the /verify/<token> link.

Solution

  • Imported and invoked sendEmailVerification from @openstatus/subscriptions in statusPage.subscribe.
  • Constructed the verification link pointing to ${baseUrl}/verify/${token} using the page custom domain, NEXT_PUBLIC_APP_URL, or slug fallback.

Closes #2571

Review in cubic

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

@Rodrigoue9 is attempting to deploy a commit to the OpenStatus Team on Vercel.

A member of the Team first needs to authorize it.

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/api/src/router/statusPage.ts">

<violation number="1" location="packages/api/src/router/statusPage.ts:1264">
P1: A visitor now receives two subscription confirmation emails. This PR sends the verification email directly in `statusPage.subscribe`, but the status-page frontend (`header.tsx`) still calls `emailRouter.sendPageSubscriptionVerification` in the subscribe mutation's `onSuccess`, and that procedure also resends the same verification email for the same subscriber/token. Since `sendPageSubscription` doesn't set a Resend idempotency key, both emails are delivered. Inline the server-side send here and drop the frontend follow-up call (or the reverse), so only one email is sent.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/api/src/router/statusPage.ts Outdated
const verifyUrl = `${baseUrl}/verify/${subscription.token}`;

try {
await sendEmailVerification(

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.

P1: A visitor now receives two subscription confirmation emails. This PR sends the verification email directly in statusPage.subscribe, but the status-page frontend (header.tsx) still calls emailRouter.sendPageSubscriptionVerification in the subscribe mutation's onSuccess, and that procedure also resends the same verification email for the same subscriber/token. Since sendPageSubscription doesn't set a Resend idempotency key, both emails are delivered. Inline the server-side send here and drop the frontend follow-up call (or the reverse), so only one email is sent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/api/src/router/statusPage.ts, line 1264:

<comment>A visitor now receives two subscription confirmation emails. This PR sends the verification email directly in `statusPage.subscribe`, but the status-page frontend (`header.tsx`) still calls `emailRouter.sendPageSubscriptionVerification` in the subscribe mutation's `onSuccess`, and that procedure also resends the same verification email for the same subscriber/token. Since `sendPageSubscription` doesn't set a Resend idempotency key, both emails are delivered. Inline the server-side send here and drop the frontend follow-up call (or the reverse), so only one email is sent.</comment>

<file context>
@@ -1252,6 +1253,34 @@ export const statusPageRouter = createTRPCRouter({
+      const verifyUrl = `${baseUrl}/verify/${subscription.token}`;
+
+      try {
+        await sendEmailVerification(
+          {
+            id: subscription.id,
</file context>

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread PR_DESCRIPTION_DRAFT.md Outdated

@cubic-dev-ai cubic-dev-ai Bot 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.

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/api/src/router/public/subscribe.ts">

<violation number="1" location="packages/api/src/router/public/subscribe.ts:16">
P2: `shouldDispatchVerificationEmail` is dead code: nothing outside its own test file references it. The `subscribe` route in statusPage.ts already guards duplicate sends via `hasPendingSubscriber` and calls `sendEmailVerification` directly, so this export never affects the feature. Remove the unused function (and its test) or wire it into the dispatch path before merging.</violation>

<violation number="2" location="packages/api/src/router/public/subscribe.ts:23">
P3: At exactly 60 seconds after the previous send, this predicate still blocks dispatch even though the documented cooldown has elapsed. Use `>=` so retries are allowed at the cooldown boundary.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/api/src/router/public/subscribe.ts Outdated
Comment thread packages/api/src/router/public/subscribe.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/api/src/router/statusPage.ts">

<violation number="1" location="packages/api/src/router/statusPage.ts:1270">
P1: When `sendEmailVerification` rejects, this mutation returns an error after the pending row has been committed. `hasPendingSubscriber` then blocks the subscriber from retrying for seven days, so expire or remove the pending row on send failure, or dispatch through a durable outbox before exposing the pending state.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

: `https://${_page.slug}.openstatus.dev`;
const verifyUrl = `${baseUrl}/verify/${subscription.token}`;

await sendEmailVerification(

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.

P1: When sendEmailVerification rejects, this mutation returns an error after the pending row has been committed. hasPendingSubscriber then blocks the subscriber from retrying for seven days, so expire or remove the pending row on send failure, or dispatch through a durable outbox before exposing the pending state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/api/src/router/statusPage.ts, line 1270:

<comment>When `sendEmailVerification` rejects, this mutation returns an error after the pending row has been committed. `hasPendingSubscriber` then blocks the subscriber from retrying for seven days, so expire or remove the pending row on send failure, or dispatch through a durable outbox before exposing the pending state.</comment>

<file context>
@@ -1253,33 +1253,36 @@ export const statusPageRouter = createTRPCRouter({
-      } catch (err) {
-        console.error("Failed to send subscription verification email:", err);
-      }
+      await sendEmailVerification(
+        {
+          id: subscription.id,
</file context>

@thibaultleouay

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Claude finished @thibaultleouay's task in 3m 47s —— View job


Review

Todo list

  • Gather context (PR diff, files, prior cubic review threads)
  • Review packages/api/src/router/statusPage.ts changes
  • Review apps/status-page/src/components/nav/header.tsx changes
  • Check for the double-email issue raised by cubic-dev-ai
  • Check error-handling/pending-subscriber issue raised by cubic-dev-ai
  • Post final review

Summary

The core fix works: statusPage.subscribe now calls sendEmailVerification server-side (packages/api/src/router/statusPage.ts:1270-1285), and the frontend's redundant follow-up call (emailRouter.sendPageSubscriptionVerification in header.tsx's onSuccess) has been removed — I confirmed no leftover references to sendPageSubscriptionMutation, isTRPCClientError, or toast remain in header.tsx. The earlier "two emails" bug that cubic flagged is resolved. I also checked FormSubscribeEmail (apps/status-page/src/components/forms/form-subscribe-email.tsx:59-80) — it already wraps onSubscribe in toast.promise with isTRPCClientError handling, so dropping the duplicate error handling in header.tsx is not a regression.

One issue from cubic's last review pass appears to still be open on current HEAD (c192a22f):

🔴 Pending-subscriber row isn't cleaned up if the verification email send fails

packages/api/src/router/statusPage.ts:1270

await sendEmailVerification({ ... }, verifyUrl);

This await is unguarded — the try/catch that previously wrapped it was removed in commit c192a22f ("validate subscription email flow"). By the time this line runs, upsertSelfSignupSubscriber (packages/services/src/page-subscriber/upsert.ts:186-198) has already inserted a pending row with a 7‑day expiresAt (VERIFICATION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000). If sendEmailVerification throws, the mutation errors out to the visitor, but hasPendingSubscriber (packages/services/src/page-subscriber/has-pending.ts:29) will then block that email/page pair from retrying for up to 7 days, since it only checks for an unexpired, unverified row — not whether a verification email was ever actually sent.

Nuance worth noting: I traced sendEmailVerificationEmailClient.sendPageSubscription (packages/emails/src/client.tsx:315-341), and that method already wraps its own render()/resend.emails.send() call in a try/catch that logs and swallows errors rather than rethrowing. So in practice, today, this await will only actually throw on getEmailClient() failing (e.g. missing RESEND_API_KEY) or a missing subscription.email — not on a transient Resend API error. That makes the real-world blast radius narrower than it looks, but relying on an internal implementation detail of a different package (that it never rethrows) is fragile — if that swallow-and-log behavior ever changes, this route silently starts locking out subscribers for a week on any transient send failure.

Suggest restoring explicit handling around the send (log-and-continue like before, or proactively expire/delete the pending row on failure) so a config or send error can't strand a visitor. Would also be worth a regression test for "subscribe when sendEmailVerification rejects" — I didn't find existing coverage for that path in statusPage.e2e.test.ts.

🟡 Minor: NEXT_PUBLIC_APP_URL fallback is dead/unused elsewhere

packages/api/src/router/statusPage.ts:1263-1267

const baseUrl = _page.customDomain
  ? `https://${_page.customDomain}`
  : process.env.NEXT_PUBLIC_APP_URL
    ? `${process.env.NEXT_PUBLIC_APP_URL}`
    : `https://${_page.slug}.openstatus.dev`;

NEXT_PUBLIC_APP_URL isn't referenced anywhere else in the repo (not in any .env.example, not read by any other file), so this branch is effectively dead and will always fall through to the slug URL today. The identical customDomain ? https://... : https://${slug}.openstatus.dev pattern (without this extra branch) already exists in three other places: packages/emails/src/client.tsx:191-193, packages/subscriptions/src/channels/webhook.ts:36, and packages/subscriptions/src/channels/slack-blocks.ts:34. Not a bug, but consider dropping the unused env branch or factoring a shared helper instead of a fourth near-duplicate.

🟡 Minor: leftover public endpoint

packages/api/src/router/email/index.ts:26 (emailRouter.sendPageSubscriptionVerification) is no longer called from the frontend now that the send happens inline in subscribe, but it's still exposed as a public mutation that independently re-sends the same email given a valid {id, token}. It's gated by requiring the subscriber's token, so not an open abuse vector, but it's now duplicate/dead functionality worth a follow-up cleanup if nothing else depends on it.

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.

[Self Hosted]: Email status page subscriptions create pending subscriber but do not send verification email

2 participants