Skip to content

feat: add customizable contact double opt-in flow#350

Merged
KMKoushik merged 11 commits intomainfrom
useSend-double-opt-in
Feb 28, 2026
Merged

feat: add customizable contact double opt-in flow#350
KMKoushik merged 11 commits intomainfrom
useSend-double-opt-in

Conversation

@KMKoushik
Copy link
Copy Markdown
Member

@KMKoushik KMKoushik commented Feb 7, 2026

Summary

  • add first-class double opt-in settings to contact books (doubleOptInEnabled, customizable subject, and editor content) with a Prisma migration that keeps existing books disabled by default while enabling defaults for newly created books
  • implement end-to-end double opt-in contact flow: new contacts in DOI-enabled books are added as pending, confirmation emails are sent with signed expiring links, and /subscribe confirms the subscription
  • add dashboard management UX for double opt-in on contact books, including toggle controls and a dedicated editor page reusing the existing email editor; also show pending confirmation status in contact list
  • extend tRPC/public API contact book schemas and update endpoints so DOI settings can be managed programmatically

Migration Notes

  • apply apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book/migration.sql before using the new fields

Verification

  • not run in this environment (node_modules are missing, so lint/test commands cannot execute)

Summary by cubic

Adds a customizable double opt‑in flow for contacts with secure, expiring confirmation links, plus an editor to control the email, subject, and From address. New books default to double opt‑in; you can resend confirmations, and confirmations use verified domains and never re‑subscribe opted‑out contacts.

  • New Features

    • Data: ContactBook adds doubleOptInEnabled, doubleOptInFrom, doubleOptInSubject, doubleOptInContent; new books default enabled; templates must include {{doubleOptInUrl}}.
    • Dashboard: toggle to require confirmation; editor at /contacts/[id]/double-opt-in with Subject and From, autosave, and {{doubleOptInUrl}} validation; contact list shows “Pending” only when double opt‑in is enabled and includes “Resend confirmation” for pending contacts.
    • Flow/security: signed 7‑day links; confirmation uses an explicit POST to /subscribe with timing‑safe hash comparison; hardened searchParams parsing and sanitized error messaging; verified domain required for From; won’t re‑subscribe explicitly unsubscribed contacts; contact upsert isn’t blocked if the confirmation email fails to send.
    • API: tRPC/public API accept all double opt‑in fields; create+update are atomic in a single transaction.
    • Docs/OpenAPI: added a Double Opt‑In guide; OpenAPI spec exposes doubleOptIn fields on contactBook requests/responses.
  • Migration

    • Apply apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book and 20260228100000_add_double_opt_in_from_to_contact_book; backfills unsubscribeReason for legacy unsubscribed contacts.
    • Existing books stay disabled; new books default enabled with default subject/content; ensure a verified sending domain before enabling double opt‑in.

Written for commit 822574f. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Double opt-in flow with expiring confirmation links; configurable default subject and content; subscription confirmation page
  • UI

    • Editor to customize confirmation email with autosave/persistence
    • Contact list shows Subscribed / Pending confirmation / Unsubscribed; added Created At and action controls
    • Toggle to enable/disable double opt-in on contact book page
  • Bug Fixes

    • Backfilled unsubscribe reason for previously unsubscribed contacts
  • Tests

    • Comprehensive unit tests for contact, contact-book, and double-opt-in flows

@vercel
Copy link
Copy Markdown

vercel bot commented Feb 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
unsend-marketing Ready Ready Preview, Comment Feb 28, 2026 1:33pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds end-to-end double opt‑in support: a SQL migration and Prisma/schema changes add three ContactBook fields (doubleOptInEnabled, doubleOptInSubject, doubleOptInContent); Zod schemas, public API request shapes, and service signatures are extended; a new double‑opt‑in service implements hash generation, confirmation URL creation, email rendering/sending, and confirmation handling; contact creation/update flows and unsubscribeReason handling are updated; UI adds a toggle, an editor page, contact-list pending state, and a server subscribe route; unit tests cover defaults and behavior.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main feature addition: a customizable contact double opt-in flow, which is the primary focus of this PR across database, backend, and UI changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Feb 7, 2026

Deploying usesend with  Cloudflare Pages  Cloudflare Pages

Latest commit: 822574f
Status:⚡️  Build in progress...

View logs

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

3 issues found across 14 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="apps/web/src/server/service/contact-service.ts">

<violation number="1" location="apps/web/src/server/service/contact-service.ts:64">
P2: This condition resends double opt-in emails for existing pending contacts, so any update/re-import will trigger another confirmation email. Consider limiting the send to new contact creation (or an explicit resend action) to avoid spamming.</violation>
</file>

<file name="apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx">

<violation number="1" location="apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx:241">
P2: `isPendingConfirmation` uses a null `unsubscribeReason` as the pending marker, which will mislabel legacy unsubscribed contacts (existing rows have null `unsubscribeReason`) as Pending. This changes the meaning of the status without a reliable pending flag.</violation>
</file>

<file name="apps/web/src/server/service/contact-book-service.ts">

<violation number="1" location="apps/web/src/server/service/contact-book-service.ts:25">
P2: `getContactBooks` no longer returns `doubleOptInContent` because the new select list omits it, so public API consumers cannot read the stored double opt-in editor content. Include `doubleOptInContent` in the select list to keep the API response consistent with the contact book schema.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In
`@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx:
- Around line 90-101: The hook-level onError in updateContactBook
(api.contacts.updateContactBook.useMutation) is causing duplicate toast errors
because the inline onError passed to updateContactBook.mutate is also firing;
pick one strategy: either remove the onError from the useMutation options and
ensure every mutate call (e.g., the inline mutate at the debounced content save
and the other mutate site) supplies its own onError that calls
toast.error(error.message) and setIsSaving(false), or keep the shared onError in
useMutation and remove the inline onError handlers while moving per-call revert
logic into the shared onError (inspecting context if needed); update the code
around the mutate call and the useMutation instantiation (symbols:
updateContactBook, mutate) accordingly so only one error handler shows a toast
and setIsSaving(false) is always called.

In `@apps/web/src/app/subscribe/page.tsx`:
- Around line 10-13: The code unsafely casts searchParams entries
(params.contactId, params.expiresAt, params.hash) to string which allows
string[] through; update the extraction to normalize to a single string by
checking for arrays and taking the first element (e.g., use Array.isArray on
params.contactId/params.expiresAt/params.hash and pick [0] when true) before the
truthy check so contactId, expiresAt, and hash are guaranteed plain strings when
used elsewhere (refer to the variables params, contactId, expiresAt, hash in the
current function).

In `@apps/web/src/lib/constants/double-opt-in.ts`:
- Around line 24-28: The template currently inserts "{{doubleOptInUrl}}" as a
text node (see apps/web/src/lib/constants/double-opt-in.ts) which remains
unclickable because linkValues only affects link marks in the renderer
(renderer.tsx) and replaceTemplateTokens produces plain text; fix by converting
that template fragment into a link node instead of a text node (create a node
with type "link" or the schema's link mark and set its href to
"{{doubleOptInUrl}}" so renderer/linkValues will apply), or else implement
post-render linkification where replaceTemplateTokens output is scanned for URLs
and wrapped with anchor tags before final output; update the code handling
template node creation or the post-processing path (the code that calls
replaceTemplateTokens / renderer.tsx path) so recipients receive a proper <a
href> link rather than raw text.

In `@apps/web/src/server/public-api/api/contacts/create-contact-book.ts`:
- Around line 46-68: The create/update sequence is not atomic: call
createContactBookService then updateContactBook separately, risking partial
writes; wrap both operations in a single Prisma transaction (db.$transaction) so
either both succeed or both roll back. Inside the transaction callback, invoke
createContactBookService (or its underlying Prisma create) and, if optional
fields are present, invoke updateContactBook (or the underlying Prisma update)
using the created contact book's id, then return the combined result (ensuring
the same transaction client is used for both operations).

In `@apps/web/src/server/service/contact-service.ts`:
- Around line 98-104: The double-opt-in email send is awaited after the contact
upsert so a send failure can bubble up even though the contact was already
persisted; wrap the sendDoubleOptInConfirmationEmail call (inside the
shouldSendDoubleOptIn branch) in a try/catch, log the error with contextual
identifiers (savedContact.id, contactBookId, teamId/contactBook.teamId) and
swallow the exception so contact creation succeeds—do not rethrow; optionally
return or set a flag on the response indicating the email send failed for later
retry.

In `@apps/web/src/server/service/double-opt-in-service.ts`:
- Around line 21-34: The replaceTemplateTokens function currently skips
replacements when a variable is an empty string because it uses a truthy check
(if (!replacement)); change that check to only skip when the replacement is
truly undefined (e.g., replacement === undefined) so empty-string values are
applied and tokens like {{firstName}} become empty strings instead of remaining
in the output; keep the escapedKey/tokenRegex logic and the reduce flow intact
and ensure you still use the variables: Record<string, string | undefined>
parameter to distinguish absent vs empty values.
🧹 Nitpick comments (8)
apps/web/src/server/public-api/api/contacts/create-contact-book.ts (1)

49-55: Inconsistent presence checks: truthy vs. !== undefined.

body.emoji and body.properties use truthy checks (lines 50–51), while the DOI fields use !== undefined (lines 52–54). If a caller explicitly sends emoji: "", the update is skipped. Use !== undefined uniformly for consistency.

Proposed fix
     if (
-      body.emoji ||
-      body.properties ||
+      body.emoji !== undefined ||
+      body.properties !== undefined ||
       body.doubleOptInEnabled !== undefined ||
       body.doubleOptInSubject !== undefined ||
       body.doubleOptInContent !== undefined
     ) {
apps/web/src/lib/constants/double-opt-in.ts (1)

53-54: Roundabout deep-clone via JSON.parse(JSON.stringify(…)).

DEFAULT_DOUBLE_OPT_IN_CONTENT_JSON is already an in-memory object. Parsing the stringified version works as a deep clone but is less obvious. Consider returning a structured clone or directly referencing the object:

 export function getDefaultDoubleOptInContent() {
-  return JSON.parse(DEFAULT_DOUBLE_OPT_IN_CONTENT) as Record<string, any>;
+  return structuredClone(DEFAULT_DOUBLE_OPT_IN_CONTENT_JSON) as Record<string, any>;
 }
apps/web/src/server/service/contact-service.ts (1)

64-71: Double opt-in condition looks correct but has a subtle edge case worth noting.

shouldSendDoubleOptIn re-sends for "pending" contacts (not subscribed + no unsubscribe reason) which is a reasonable resend behavior. However, this means every addOrUpdateContact call for a pending contact (e.g. bulk CSV re-import) will fire another confirmation email. Consider whether this is intentional or whether a rate-limit / cooldown guard is needed to prevent spamming the same contact.

apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx (1)

240-308: Three-state contact status display looks good overall.

The isPendingConfirmation derivation aligns with the server-side logic (subscribed === false && unsubscribeReason === null). The status badges and tooltip for unsubscribe reason are well done.

One minor UX gap: the status filter dropdown (lines 182-199) only offers "Subscribed" / "Unsubscribed" — there's no option to filter for "Pending" contacts. Pending contacts would appear under "All" or arguably under "Unsubscribed" (since subscribed=false), which may confuse users of DOI-enabled books. Consider adding a "Pending" filter option.

apps/web/src/server/service/contact-book-service.ts (1)

96-125: Update normalization logic is solid; minor gap with empty subject.

The empty content → default fallback (lines 98-103) and the conditional defaulting when enabling DOI (lines 105-121) are well thought out.

One gap: if doubleOptInSubject is set to "" without also setting doubleOptInEnabled: true, the empty subject passes through to the DB. The UI normalizes empty subjects on blur, but the public API doesn't. Consider adding a parallel empty-string normalization for doubleOptInSubject similar to lines 98-103.

Suggested normalization
   const updateData = { ...data };
 
   if (
     data.doubleOptInContent !== undefined &&
     !data.doubleOptInContent.trim()
   ) {
     updateData.doubleOptInContent = DEFAULT_DOUBLE_OPT_IN_CONTENT;
   }
 
+  if (
+    data.doubleOptInSubject !== undefined &&
+    !data.doubleOptInSubject.trim()
+  ) {
+    updateData.doubleOptInSubject = DEFAULT_DOUBLE_OPT_IN_SUBJECT;
+  }
+
   if (data.doubleOptInEnabled === true) {
apps/web/src/server/service/double-opt-in-service.ts (3)

15-19: Consider using createHmac instead of createHash for the signing mechanism.

The current approach concatenates the secret into the hash input, which works but is structurally weaker than using HMAC (which is specifically designed for message authentication). Using createHmac('sha256', secret).update(message) is the standard pattern for signed tokens.

Proposed fix
 function createDoubleOptInHash(contactId: string, expiresAt: number) {
-  return createHash("sha256")
-    .update(`${contactId}-${expiresAt}-${env.NEXTAUTH_SECRET}`)
+  return createHmac("sha256", env.NEXTAUTH_SECRET)
+    .update(`${contactId}-${expiresAt}`)
     .digest("hex");
 }

Update the import accordingly:

-import { createHash } from "crypto";
+import { createHmac } from "crypto";

85-102: Hardcoded hello@ sender prefix — consider making this configurable or aligning with team preferences.

Line 147 uses hello@${domain.name} as the sender. If the team has a preferred sender name/address, this may not match their branding. For now this is acceptable as a default, but worth revisiting.


154-197: Emit a webhook event when subscription is confirmed to match the pattern in addOrUpdateContact.

The validation logic is solid, but confirmDoubleOptInSubscription doesn't emit a contact.updated webhook event when a contact transitions from unsubscribed to subscribed — unlike addOrUpdateContact which emits webhooks for state changes. If webhook consumers need to track subscription confirmations, add webhook emission after the update.

Note: emitContactEvent in contact-service.ts is not exported, so you'll need to either export it or create a shared helper function before calling it from double-opt-in-service.ts.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx (1)

219-219: ⚠️ Potential issue | 🟡 Minor

Typo: border-broderborder-border.

This class name typo means the border won't render on this container.

Fix
-        <div className="flex flex-col rounded-xl border border-broder shadow">
+        <div className="flex flex-col rounded-xl border border-border shadow">
🤖 Fix all issues with AI agents
In
`@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx:
- Around line 103-110: The race is caused by both the subject onBlur handler and
the editor's debounced updateContent calling updateContactBook.mutate (shared
updateContactBook), so make them use separate mutation instances or a shared
in-flight counter; e.g., create updateSubjectMutation and updateContentMutation
(use the same success/error side-effects if needed) and call
updateSubjectMutation.mutate from the subject onBlur handler and
updateContentMutation.mutate from updateContent/debouncedUpdateContent, or
alternatively implement an isSavingCounter that increments before each mutate
call and decrements in onSettled to derive isSaving = isSavingCounter > 0; apply
the same fix for the other occurrence at the subject/content saves around lines
189-197.

In `@apps/web/src/server/service/double-opt-in-service.ts`:
- Around line 173-176: Replace the plain equality check with a timing-safe
comparison: import Node's crypto, convert both expectedHash (from
createDoubleOptInHash(contactId, expiresAtTimestamp)) and the incoming hash to
Buffers (e.g., Buffer.from(expectedHash, 'utf8') and Buffer.from(hash, 'utf8')),
and call crypto.timingSafeEqual on them; handle the case where buffer lengths
differ by throwing the same "Invalid confirmation link" error (since
timingSafeEqual throws for unequal lengths), so do a length check and throw the
error if lengths differ, otherwise use crypto.timingSafeEqual to decide whether
to throw.
🧹 Nitpick comments (8)
apps/web/src/server/public-api/api/contacts/create-contact-book.ts (1)

17-24: Schema accepts DOI fields but createContactBookService already sets defaults — potential override.

createContactBookService (per the unit test at contact-book-service.unit.test.ts lines 58-67) already persists doubleOptInEnabled: true, doubleOptInSubject, and doubleOptInContent with defaults. If the caller passes doubleOptInEnabled: false here, the book is first created with doubleOptInEnabled: true, then updated to false in a second call. This works, but it's wasteful and the window between create and update leaves the book in an inconsistent state.

Consider passing the optional DOI fields through to the create call so only one DB write is needed.

Proposed approach

Extend createContactBookService to accept optional overrides, so the entire payload can be written in a single create:

-  const contactBook = await createContactBookService(team.id, body.name);
+  const contactBook = await createContactBookService(team.id, body.name, {
+    emoji: body.emoji,
+    properties: body.properties,
+    doubleOptInEnabled: body.doubleOptInEnabled,
+    doubleOptInSubject: body.doubleOptInSubject,
+    doubleOptInContent: body.doubleOptInContent,
+  });
+
+  return c.json({
+    ...contactBook,
+    properties: contactBook.properties as Record<string, string>,
+  });

This removes the need for the conditional update block entirely.

apps/web/src/server/service/contact-service.unit.test.ts (1)

57-63: mockAddBulkContactJobs and mockLogger are not reset between tests.

While these aren't currently asserted on, for consistency and to prevent future test pollution, consider resetting all mocks:

Suggested fix
  beforeEach(() => {
    mockDb.contactBook.findUnique.mockReset();
    mockDb.contact.findUnique.mockReset();
    mockDb.contact.upsert.mockReset();
    mockWebhookEmit.mockReset();
    mockSendDoubleOptInConfirmationEmail.mockReset();
+   mockAddBulkContactJobs.mockReset();
+   mockLogger.warn.mockReset();
+   mockLogger.error.mockReset();
  });

Or simply use vi.clearAllMocks() / vi.resetAllMocks() to cover everything uniformly.

apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx (1)

182-199: No "Pending" filter in the status dropdown.

The status filter offers only "All", "Subscribed", and "Unsubscribed", but the contact list now displays a distinct "Pending" state. Users with DOI enabled may want to filter specifically for pending contacts. Since the backend query filters on a boolean subscribed field, "Pending" contacts (subscribed=false, unsubscribeReason=null) would appear under "Unsubscribed" — which could be confusing given the UI distinguishes them visually.

Consider adding a "Pending" filter or at minimum renaming "Unsubscribed" to something more inclusive like "Not subscribed" to reduce confusion.

apps/web/src/server/service/contact-service.ts (1)

25-37: New DB query on every addOrUpdateContact call.

The contactBook.findUnique fetch is necessary for DOI logic but adds a query to every contact add/update path, including bulk imports (each job calls addOrUpdateContact). For high-volume bulk imports this could become a bottleneck since the same contact book is queried repeatedly.

Consider caching the contact book DOI status at the batch level if bulk performance becomes an issue.

apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx (1)

43-64: No user-facing error feedback when the DOI toggle mutation fails.

The updateContactBookMutation has onSettled → invalidate (line 59-63) which will revert the optimistic update on failure, but there's no onError handler to show a toast or other feedback. The user would see the switch flip back without explanation.

Suggested improvement
  const updateContactBookMutation = api.contacts.updateContactBook.useMutation({
    onMutate: async (data) => {
      // ... existing optimistic update ...
    },
+   onError: (error) => {
+     toast.error(error.message);
+   },
    onSettled: () => {
      utils.contacts.getContactBookDetails.invalidate({
        contactBookId: contactBookId,
      });
    },
  });

You'll need to add import { toast } from "@usesend/ui/src/toaster"; at the top.

Also applies to: 213-218

apps/web/src/server/service/double-opt-in-service.ts (1)

15-18: Consider using HMAC instead of plain SHA-256 with concatenated secret.

createHash("sha256").update(contactId-expiresAt-secret) works, but HMAC is the standard primitive for keyed message authentication. It's also resilient to length-extension attacks and avoids ambiguity from delimiter-based concatenation.

♻️ Proposed change
-import { createHash } from "crypto";
+import { createHmac } from "crypto";
 function createDoubleOptInHash(contactId: string, expiresAt: number) {
-  return createHash("sha256")
-    .update(`${contactId}-${expiresAt}-${env.NEXTAUTH_SECRET}`)
+  return createHmac("sha256", env.NEXTAUTH_SECRET)
+    .update(`${contactId}-${expiresAt}`)
     .digest("hex");
 }
apps/web/src/server/service/double-opt-in-service.unit.test.ts (2)

125-157: Consider asserting sendArgs.to to fully verify the email recipient.

The test validates from, subject, html, and teamId but skips the to field. A one-liner addition would complete the coverage.

Proposed addition
     expect(sendArgs.from).toBe("hello@example.com");
+    expect(sendArgs.to).toBe("alice@example.com");
     expect(sendArgs.subject).toBe("Confirm Alice");

53-269: Missing test: contact not found or contactBookId mismatch.

The service throws "Contact not found for double opt-in email" when the contact is null or the contactBookId doesn't match (line 77-79 in the service). There's no test exercising this path. Similarly, confirmDoubleOptInSubscription throws "Contact not found" when the contact doesn't exist (line 182-184), which is also untested.

Comment on lines +103 to +110
function updateContent() {
updateContactBook.mutate({
contactBookId: contactBook.id,
doubleOptInContent: JSON.stringify(json),
});
}

const debouncedUpdateContent = useDebouncedCallback(updateContent, 1000);
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.

⚠️ Potential issue | 🟡 Minor

Potential isSaving race between subject blur-save and debounced content-save.

Both the subject's onBlur handler and the editor's debounced updateContent call updateContactBook.mutate, sharing the same onSuccess/onError which toggle isSaving. If the user edits content and then immediately blurs the subject field, two mutations can be in flight, and the first to complete will set isSaving(false) while the second is still pending — causing the saving indicator to disappear prematurely.

Consider tracking in-flight mutations with a counter, or use separate mutation instances for subject and content saves.

Also applies to: 189-197

🤖 Prompt for AI Agents
In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx
around lines 103 - 110, The race is caused by both the subject onBlur handler
and the editor's debounced updateContent calling updateContactBook.mutate
(shared updateContactBook), so make them use separate mutation instances or a
shared in-flight counter; e.g., create updateSubjectMutation and
updateContentMutation (use the same success/error side-effects if needed) and
call updateSubjectMutation.mutate from the subject onBlur handler and
updateContentMutation.mutate from updateContent/debouncedUpdateContent, or
alternatively implement an isSavingCounter that increments before each mutate
call and decrements in onSettled to derive isSaving = isSavingCounter > 0; apply
the same fix for the other occurrence at the subject/content saves around lines
189-197.

@KMKoushik
Copy link
Copy Markdown
Member Author

@greptileai

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/web/src/app/subscribe/page.tsx`:
- Around line 50-64: The catch block in page.tsx currently renders raw
error.message (see the catch handling that returns the Confirmation Failed UI),
which can leak internal details; replace this by mapping known safe messages
("Invalid confirmation link", "Confirmation link has expired", "Contact not
found") to user-friendly text and otherwise render a generic fallback like "An
unexpected error occurred. Please try again later."; implement the mapping logic
inside the catch (or a small helper function referenced there) and use that
sanitized message instead of error.message when rendering the <p> element.

In `@apps/web/src/server/service/contact-service.ts`:
- Around line 86-107: Migration 20250317104401_add_unsubscribe_reason only added
the unsubscribeReason column but didn’t backfill legacy rows, which breaks logic
relying on !existingContact.subscribed && existingContact.unsubscribeReason ===
null; update the migration to run an UPDATE that sets unsubscribeReason =
'UNSUBSCRIBED' for all rows where subscribed = false AND unsubscribeReason IS
NULL so existing unsubscribed contacts are detected correctly; ensure this
backfill runs after the column is added and before the migration completes
(refer to the migration file name 20250317104401_add_unsubscribe_reason and the
app logic referencing unsubscribeReason, shouldCreatePendingContact,
subscribedValue, and existingContact.subscribed).

Comment on lines +86 to 107
subscribed: shouldCreatePendingContact
? false
: (contact.subscribed ?? true),
unsubscribeReason: shouldCreatePendingContact
? null
: contact.subscribed === false
? UnsubscribeReason.UNSUBSCRIBED
: null,
},
update: {
firstName: contact.firstName,
lastName: contact.lastName,
properties: contact.properties ?? {},
...(subscribedValue !== undefined ? { subscribed: subscribedValue } : {}),
...(subscribedValue !== undefined
? {
subscribed: subscribedValue,
unsubscribeReason: subscribedValue
? null
: UnsubscribeReason.UNSUBSCRIBED,
}
: {}),
},
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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for migrations related to unsubscribeReason
fd "\.sql$" apps/web/prisma/migrations 2>/dev/null | head -20

Repository: usesend/useSend

Length of output: 1557


🏁 Script executed:

# Also search for any migration files that might contain unsubscribeReason
rg "unsubscribeReason" apps/web/prisma/migrations --type sql -l

Repository: usesend/useSend

Length of output: 227


🏁 Script executed:

# Check the contact-service.ts file for context around lines 86-107
cat -n apps/web/src/server/service/contact-service.ts | sed -n '75,120p'

Repository: usesend/useSend

Length of output: 1580


🏁 Script executed:

# Examine the migration file for unsubscribeReason
cat -n apps/web/prisma/migrations/20250317104401_add_unsubscribe_reason/migration.sql

Repository: usesend/useSend

Length of output: 283


🏁 Script executed:

# Look for the definition of shouldCreatePendingContact in contact-service.ts
rg "shouldCreatePendingContact" apps/web/src/server/service/contact-service.ts -B 5 -A 2

Repository: usesend/useSend

Length of output: 689


🏁 Script executed:

# Check the schema.prisma to understand the Contact model structure
rg "model Contact" apps/web/prisma/schema.prisma -A 30

Repository: usesend/useSend

Length of output: 1533


The migration is incomplete and requires a backfill for legacy unsubscribed contacts.

The 20250317104401_add_unsubscribe_reason migration only adds the unsubscribeReason column but does not include a backfill UPDATE statement. This is critical because the code relies on the pending detection logic (!existingContact.subscribed && existingContact.unsubscribeReason === null) to identify unsubscribed contacts that need double opt-in handling. Existing contacts with subscribed = false created before this migration will retain unsubscribeReason = null, breaking the detection logic.

The migration must include a backfill statement:

UPDATE "Contact" SET "unsubscribeReason" = 'UNSUBSCRIBED' WHERE "subscribed" = false AND "unsubscribeReason" IS NULL;
🤖 Prompt for AI Agents
In `@apps/web/src/server/service/contact-service.ts` around lines 86 - 107,
Migration 20250317104401_add_unsubscribe_reason only added the unsubscribeReason
column but didn’t backfill legacy rows, which breaks logic relying on
!existingContact.subscribed && existingContact.unsubscribeReason === null;
update the migration to run an UPDATE that sets unsubscribeReason =
'UNSUBSCRIBED' for all rows where subscribed = false AND unsubscribeReason IS
NULL so existing unsubscribed contacts are detected correctly; ensure this
backfill runs after the column is added and before the migration completes
(refer to the migration file name 20250317104401_add_unsubscribe_reason and the
app logic referencing unsubscribeReason, shouldCreatePendingContact,
subscribedValue, and existingContact.subscribed).

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Feb 16, 2026

Greptile Summary

Adds end-to-end double opt-in (DOI) flow for contact books: new contacts in DOI-enabled books are created as pending, sent a signed 7-day confirmation email, and confirmed via /subscribe. Includes Prisma migration, configurable email templates with an editor UI, tRPC/public API extensions, and comprehensive unit tests.

  • Logic issue in confirmation flow: confirmDoubleOptInSubscription can re-subscribe contacts who explicitly unsubscribed (or were marked as bounced/complained) within the 7-day link window, because it doesn't check unsubscribeReason before re-subscribing. This bypasses the re-subscribe protection in addOrUpdateContact.
  • Pending state overrides explicit intent: When DOI is enabled, contacts added with explicit subscribed: false via the API are still treated as "pending confirmation" and sent a DOI email, since shouldCreatePendingContact only checks for existingContact === null and DOI being enabled.
  • Security best practice: Hash comparison in confirmDoubleOptInSubscription uses !== instead of crypto.timingSafeEqual. Low risk since the hash is delivered via email, but worth aligning with standard practice.
  • Migration is well-structured: existing books default to DOI disabled, new books default to enabled; backfill correctly marks legacy unsubscribed contacts.
  • Error handling is solid: email send failures are caught and logged without blocking contact creation.

Confidence Score: 3/5

  • This PR is mostly safe but has a logic issue where confirmation links can override explicit unsubscribes, which should be addressed before merging.
  • Score of 3 reflects that while the overall architecture and implementation are well-structured with good test coverage, there are two logic issues: (1) confirmDoubleOptInSubscription can re-subscribe contacts who explicitly unsubscribed within the link window, and (2) DOI overrides explicit subscribed=false on new contacts. Neither is catastrophic but both affect correctness of subscription state management.
  • Pay close attention to apps/web/src/server/service/double-opt-in-service.ts (confirmation logic) and apps/web/src/server/service/contact-service.ts (pending contact creation logic).

Last reviewed commit: 8cad578

Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

18 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +186 to +190
if (existingContact.subscribed) {
return existingContact;
}

return db.contact.update({
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Confirmation can override explicit unsubscribes

If a contact is created as pending (DOI flow), then explicitly unsubscribes before clicking the confirmation link, this function will still re-subscribe them because it only checks existingContact.subscribed — it doesn't check unsubscribeReason. Within the 7-day link window, a user who unsubscribed (or was marked as bounced/complained) could be re-subscribed against their will.

Consider checking unsubscribeReason before re-subscribing:

Suggested change
if (existingContact.subscribed) {
return existingContact;
}
return db.contact.update({
if (existingContact.subscribed) {
return existingContact;
}
if (existingContact.unsubscribeReason !== null) {
return existingContact;
}
return db.contact.update({

Comment on lines +173 to +174
const expectedHash = createDoubleOptInHash(contactId, expiresAtTimestamp);
if (hash !== expectedHash) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Use timingSafeEqual for hash comparison

Using !== for comparing HMAC/hash digests is susceptible to timing side-channel attacks. While the risk is low here (the hash is delivered via email), using crypto.timingSafeEqual is the standard security best practice for any secret comparison.

Suggested change
const expectedHash = createDoubleOptInHash(contactId, expiresAtTimestamp);
if (hash !== expectedHash) {
const expectedHash = createDoubleOptInHash(contactId, expiresAtTimestamp);
const hashBuffer = Buffer.from(hash, "utf-8");
const expectedBuffer = Buffer.from(expectedHash, "utf-8");
if (
hashBuffer.length !== expectedBuffer.length ||
!require("crypto").timingSafeEqual(hashBuffer, expectedBuffer)
) {

Comment on lines +70 to +71
const shouldCreatePendingContact =
contactBook.doubleOptInEnabled && existingContact === null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

DOI overrides explicit subscribed: false on new contacts

When DOI is enabled and a new contact is added with subscribed: false (e.g., via API), shouldCreatePendingContact is still true because it only checks contactBook.doubleOptInEnabled && existingContact === null. This means the contact is treated as "pending confirmation" and sent a DOI email, even though the caller explicitly indicated the contact should be unsubscribed.

Consider differentiating between callers who explicitly set subscribed: false and the default DOI pending state:

Suggested change
const shouldCreatePendingContact =
contactBook.doubleOptInEnabled && existingContact === null;
const shouldCreatePendingContact =
contactBook.doubleOptInEnabled &&
existingContact === null &&
contact.subscribed !== false;

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
apps/web/src/server/service/double-opt-in-service.ts (1)

15-19: Consider using HMAC instead of plain SHA-256 for token signing.

createHash("sha256").update(\...-${secret}`)` works here but HMAC is the standard primitive for keyed message authentication. It's purpose-built to prevent subtle pitfalls (e.g., length-extension, key/message ambiguity).

♻️ Suggested change
-import { createHash, timingSafeEqual } from "crypto";
+import { createHmac, timingSafeEqual } from "crypto";

 function createDoubleOptInHash(contactId: string, expiresAt: number) {
-  return createHash("sha256")
-    .update(`${contactId}-${expiresAt}-${env.NEXTAUTH_SECRET}`)
+  return createHmac("sha256", env.NEXTAUTH_SECRET)
+    .update(`${contactId}-${expiresAt}`)
     .digest("hex");
 }

Note: this would also require updating the test helper getHash to use createHmac.

apps/web/src/server/service/double-opt-in-service.unit.test.ts (2)

247-265: Consider adding explicit unsubscribeReason to mock contacts for clarity.

The mock contact on line 249-253 omits unsubscribeReason, so it's undefined at runtime. The service check existingContact.unsubscribeReason != null happens to work correctly (undefined == null is true in JS), but explicitly including unsubscribeReason: null would make the test intention clearer and mirror the actual Prisma return shape.

     const contact = {
       id: "contact_1",
       email: "alice@example.com",
       subscribed: true,
+      unsubscribeReason: null,
     };

217-245: Missing test: contact not found after valid hash verification.

There's no test covering the path where the hash/expiry are valid but db.contact.findUnique returns null (e.g., contact was deleted after the confirmation link was sent). This would exercise the "Contact not found" error path on line 188 of the service.

💡 Suggested test
it("throws when contact is not found after valid hash check", async () => {
  const expiresAt = Date.now() + 60_000;
  mockDb.contact.findUnique.mockResolvedValue(null);

  await expect(
    confirmDoubleOptInSubscription({
      contactId: "contact_1",
      expiresAt: String(expiresAt),
      hash: getHash("contact_1", expiresAt),
    }),
  ).rejects.toThrow("Contact not found");
});

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/server/service/contact-service.ts (1)

1-10: ⚠️ Potential issue | 🟡 Minor

Use ~/ alias for src imports in this file.

The new import uses a relative path; this file should use ~/ for all src imports. Consider updating the whole import block for consistency.

🔧 Suggested update
-import { db } from "../db";
-import { ContactQueueService } from "./contact-queue-service";
-import { WebhookService } from "./webhook-service";
-import { logger } from "../logger/log";
-import { sendDoubleOptInConfirmationEmail } from "./double-opt-in-service";
+import { db } from "~/server/db";
+import { ContactQueueService } from "~/server/service/contact-queue-service";
+import { WebhookService } from "~/server/service/webhook-service";
+import { logger } from "~/server/logger/log";
+import { sendDoubleOptInConfirmationEmail } from "~/server/service/double-opt-in-service";

As per coding guidelines: apps/web/src/**/*.{ts,tsx}: Use the /alias for src imports in apps/web (e.g., import { x } from '/utils/x').

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/contact-service.ts` around lines 1 - 10, The
imports in contact-service.ts use relative paths for project sources; update the
src imports to use the ~/ alias so they match project guidelines: replace the
relative imports for db, ContactQueueService, WebhookService, logger, and
sendDoubleOptInConfirmationEmail with their corresponding ~/... imports (keep
external packages like `@prisma/client` and `@usesend/lib` as-is) so symbols db,
ContactQueueService, WebhookService, logger, and
sendDoubleOptInConfirmationEmail are imported via the ~/ alias.
♻️ Duplicate comments (1)
apps/web/src/app/(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx (1)

90-183: ⚠️ Potential issue | 🟡 Minor

Avoid isSaving race between subject blur and debounced content save.
Both calls share one mutation and the same onSuccess toggles isSaving(false), so the indicator can clear while another save is still in flight. Consider separate mutations or an in‑flight counter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx
around lines 90 - 183, The isSaving flag currently races because both
updateContactBook (used by updateContent/debouncedUpdateContent) and the subject
onBlur call share the same mutation and its onSuccess sets setIsSaving(false);
change to track concurrent saves instead: replace the boolean isSaving with an
in-flight counter (e.g., saveCount state) that increments before each mutate
call (in updateContent/debouncedUpdateContent and in the onBlur subject save)
and decrements in both onSuccess and onError handlers, and derive the UI
indicator from saveCount > 0; alternatively create a separate mutation (e.g.,
updateContactBookContent vs updateContactBookSubject) so each mutation manages
its own saving state—update all references to isSaving, updateContactBook.mutate
calls, updateContent, debouncedUpdateContent, and the subject onBlur save
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/page.tsx:
- Around line 209-218: The UI currently optimistically toggles the Switch using
contactBookDetailQuery and updateContactBookMutation but provides no user
feedback if the server update fails; add an onError handler to the mutation call
(or configure updateContactBookMutation with an onError option) that surfaces
the failure (e.g., toast or inline error state) and, if you applied an
optimistic update, ensure you rollback to the prior value on error; reference
the Switch component, contactBookDetailQuery.data?.doubleOptInEnabled,
updateContactBookMutation.mutate, contactBookId and the doubleOptInEnabled
payload when implementing the onError feedback and rollback.

In `@apps/web/src/app/subscribe/page.tsx`:
- Around line 52-60: The UI always states the contact is subscribed even though
confirmDoubleOptInSubscription can return an unsubscribed contact; update the
subscribe page rendering in page.tsx to check the returned contact's
subscription state (e.g., contact.status or contact.unsubscribed /
contact.isSubscribed as provided by confirmDoubleOptInSubscription) and
conditionally render a different message when the contact is explicitly
unsubscribed (e.g., "This email has previously unsubscribed and was not
resubscribed") instead of claiming subscription; locate the usage around the JSX
that currently renders {contact.email} is now subscribed and add logic to
display the correct message and avoid implying a resubscribe.

In `@apps/web/src/server/service/double-opt-in-service.ts`:
- Around line 1-12: Replace the relative internal imports with the src alias:
change import { db } from "../db" to import { db } from "~/server/db", change
import { logger } from "../logger/log" to import { logger } from
"~/server/logger/log", and change import { sendEmail } from "./email-service" to
import { sendEmail } from "~/server/service/email-service"; leave external and
already-aliased imports (e.g., env, constants, EmailRenderer) untouched.

---

Outside diff comments:
In `@apps/web/src/server/service/contact-service.ts`:
- Around line 1-10: The imports in contact-service.ts use relative paths for
project sources; update the src imports to use the ~/ alias so they match
project guidelines: replace the relative imports for db, ContactQueueService,
WebhookService, logger, and sendDoubleOptInConfirmationEmail with their
corresponding ~/... imports (keep external packages like `@prisma/client` and
`@usesend/lib` as-is) so symbols db, ContactQueueService, WebhookService, logger,
and sendDoubleOptInConfirmationEmail are imported via the ~/ alias.

---

Duplicate comments:
In
`@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx:
- Around line 90-183: The isSaving flag currently races because both
updateContactBook (used by updateContent/debouncedUpdateContent) and the subject
onBlur call share the same mutation and its onSuccess sets setIsSaving(false);
change to track concurrent saves instead: replace the boolean isSaving with an
in-flight counter (e.g., saveCount state) that increments before each mutate
call (in updateContent/debouncedUpdateContent and in the onBlur subject save)
and decrements in both onSuccess and onError handlers, and derive the UI
indicator from saveCount > 0; alternatively create a separate mutation (e.g.,
updateContactBookContent vs updateContactBookSubject) so each mutation manages
its own saving state—update all references to isSaving, updateContactBook.mutate
calls, updateContent, debouncedUpdateContent, and the subject onBlur save
accordingly.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fe2192a and 70f0832.

📒 Files selected for processing (18)
  • apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book/migration.sql
  • apps/web/prisma/schema.prisma
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx
  • apps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsx
  • apps/web/src/app/subscribe/page.tsx
  • apps/web/src/lib/constants/double-opt-in.ts
  • apps/web/src/lib/constants/double-opt-in.unit.test.ts
  • apps/web/src/lib/zod/contact-book-schema.ts
  • apps/web/src/server/api/routers/contacts.ts
  • apps/web/src/server/public-api/api/contacts/create-contact-book.ts
  • apps/web/src/server/public-api/api/contacts/update-contact-book.ts
  • apps/web/src/server/service/contact-book-service.ts
  • apps/web/src/server/service/contact-book-service.unit.test.ts
  • apps/web/src/server/service/contact-service.ts
  • apps/web/src/server/service/contact-service.unit.test.ts
  • apps/web/src/server/service/double-opt-in-service.ts
  • apps/web/src/server/service/double-opt-in-service.unit.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/web/src/server/service/contact-service.unit.test.ts
  • apps/web/src/server/service/contact-book-service.unit.test.ts
  • apps/web/prisma/schema.prisma
  • apps/web/src/server/public-api/api/contacts/update-contact-book.ts

Comment on lines +209 to +218
<Switch
checked={
contactBookDetailQuery.data?.doubleOptInEnabled ?? false
}
onCheckedChange={(checked) => {
updateContactBookMutation.mutate({
contactBookId,
doubleOptInEnabled: checked,
});
}}
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.

⚠️ Potential issue | 🟡 Minor

Add error feedback for failed toggle.
If enabling double opt‑in fails (e.g., unverified domain), the optimistic UI will silently revert after invalidation. Surfacing the error improves UX.

Suggested fix
+import { toast } from "@usesend/ui/src/toaster";
...
               <Switch
                 checked={
                   contactBookDetailQuery.data?.doubleOptInEnabled ?? false
                 }
                 onCheckedChange={(checked) => {
-                  updateContactBookMutation.mutate({
-                    contactBookId,
-                    doubleOptInEnabled: checked,
-                  });
+                  updateContactBookMutation.mutate(
+                    {
+                      contactBookId,
+                      doubleOptInEnabled: checked,
+                    },
+                    {
+                      onError: (error) => {
+                        toast.error(error.message);
+                      },
+                    },
+                  );
                 }}
                 className="data-[state=checked]:bg-success"
               />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Switch
checked={
contactBookDetailQuery.data?.doubleOptInEnabled ?? false
}
onCheckedChange={(checked) => {
updateContactBookMutation.mutate({
contactBookId,
doubleOptInEnabled: checked,
});
}}
<Switch
checked={
contactBookDetailQuery.data?.doubleOptInEnabled ?? false
}
onCheckedChange={(checked) => {
updateContactBookMutation.mutate(
{
contactBookId,
doubleOptInEnabled: checked,
},
{
onError: (error) => {
toast.error(error.message);
},
},
);
}}
className="data-[state=checked]:bg-success"
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/contacts/[contactBookId]/page.tsx around lines
209 - 218, The UI currently optimistically toggles the Switch using
contactBookDetailQuery and updateContactBookMutation but provides no user
feedback if the server update fails; add an onError handler to the mutation call
(or configure updateContactBookMutation with an onError option) that surfaces
the failure (e.g., toast or inline error state) and, if you applied an
optimistic update, ensure you rollback to the prior value on error; reference
the Switch component, contactBookDetailQuery.data?.doubleOptInEnabled,
updateContactBookMutation.mutate, contactBookId and the doubleOptInEnabled
payload when implementing the onError feedback and rollback.

Comment on lines +52 to +60
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
<h1 className="text-2xl font-semibold text-center">
Subscription Confirmed
</h1>
<p className="text-sm text-muted-foreground text-center">
{contact.email} is now subscribed and will receive future emails.
</p>
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.

⚠️ Potential issue | 🟠 Major

Don’t show “subscribed” for explicitly unsubscribed contacts.
confirmDoubleOptInSubscription can return an unsubscribed contact without changing state, but the UI always claims they’re subscribed. This misrepresents the outcome and conflicts with the “no resubscribe” rule.

Suggested fix
-    return (
+    const isUnsubscribed =
+      !contact.subscribed && contact.unsubscribeReason != null;
+
+    return (
       <div className="min-h-screen flex items-center justify-center p-6">
         <div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
-          <h1 className="text-2xl font-semibold text-center">
-            Subscription Confirmed
-          </h1>
+          <h1 className="text-2xl font-semibold text-center">
+            {isUnsubscribed ? "Subscription Failed" : "Subscription Confirmed"}
+          </h1>
           <p className="text-sm text-muted-foreground text-center">
-            {contact.email} is now subscribed and will receive future emails.
+            {isUnsubscribed
+              ? `${contact.email} is unsubscribed and cannot be re-subscribed.`
+              : `${contact.email} is now subscribed and will receive future emails.`}
           </p>
         </div>
       </div>
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
<h1 className="text-2xl font-semibold text-center">
Subscription Confirmed
</h1>
<p className="text-sm text-muted-foreground text-center">
{contact.email} is now subscribed and will receive future emails.
</p>
const isUnsubscribed =
!contact.subscribed && contact.unsubscribeReason != null;
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="max-w-md w-full space-y-4 p-8 shadow rounded-xl border">
<h1 className="text-2xl font-semibold text-center">
{isUnsubscribed ? "Subscription Failed" : "Subscription Confirmed"}
</h1>
<p className="text-sm text-muted-foreground text-center">
{isUnsubscribed
? `${contact.email} is unsubscribed and cannot be re-subscribed.`
: `${contact.email} is now subscribed and will receive future emails.`}
</p>
</div>
</div>
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/subscribe/page.tsx` around lines 52 - 60, The UI always
states the contact is subscribed even though confirmDoubleOptInSubscription can
return an unsubscribed contact; update the subscribe page rendering in page.tsx
to check the returned contact's subscription state (e.g., contact.status or
contact.unsubscribed / contact.isSubscribed as provided by
confirmDoubleOptInSubscription) and conditionally render a different message
when the contact is explicitly unsubscribed (e.g., "This email has previously
unsubscribed and was not resubscribed") instead of claiming subscription; locate
the usage around the JSX that currently renders {contact.email} is now
subscribed and add logic to display the correct message and avoid implying a
resubscribe.

Comment on lines +1 to +12
import { DomainStatus } from "@prisma/client";
import { createHash, timingSafeEqual } from "crypto";
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
import { env } from "~/env";
import {
DEFAULT_DOUBLE_OPT_IN_CONTENT,
DEFAULT_DOUBLE_OPT_IN_SUBJECT,
} from "~/lib/constants/double-opt-in";
import { db } from "../db";
import { logger } from "../logger/log";
import { sendEmail } from "./email-service";

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.

⚠️ Potential issue | 🟡 Minor

Switch relative imports to ~/ aliases.

This new file should use the src alias consistently for internal modules.

🔧 Suggested update
-import { db } from "../db";
-import { logger } from "../logger/log";
-import { sendEmail } from "./email-service";
+import { db } from "~/server/db";
+import { logger } from "~/server/logger/log";
+import { sendEmail } from "~/server/service/email-service";

As per coding guidelines: apps/web/src/**/*.{ts,tsx}: Use the /alias for src imports in apps/web (e.g., import { x } from '/utils/x').

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { DomainStatus } from "@prisma/client";
import { createHash, timingSafeEqual } from "crypto";
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
import { env } from "~/env";
import {
DEFAULT_DOUBLE_OPT_IN_CONTENT,
DEFAULT_DOUBLE_OPT_IN_SUBJECT,
} from "~/lib/constants/double-opt-in";
import { db } from "../db";
import { logger } from "../logger/log";
import { sendEmail } from "./email-service";
import { DomainStatus } from "@prisma/client";
import { createHash, timingSafeEqual } from "crypto";
import { EmailRenderer } from "@usesend/email-editor/src/renderer";
import { env } from "~/env";
import {
DEFAULT_DOUBLE_OPT_IN_CONTENT,
DEFAULT_DOUBLE_OPT_IN_SUBJECT,
} from "~/lib/constants/double-opt-in";
import { db } from "~/server/db";
import { logger } from "~/server/logger/log";
import { sendEmail } from "~/server/service/email-service";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/double-opt-in-service.ts` around lines 1 - 12,
Replace the relative internal imports with the src alias: change import { db }
from "../db" to import { db } from "~/server/db", change import { logger } from
"../logger/log" to import { logger } from "~/server/logger/log", and change
import { sendEmail } from "./email-service" to import { sendEmail } from
"~/server/service/email-service"; leave external and already-aliased imports
(e.g., env, constants, EmailRenderer) untouched.

KMKoushik and others added 5 commits February 28, 2026 17:11
- Make pending status conditional on doubleOptInEnabled flag
- Backfill legacy unsubscribeReason for reliable pending detection
- Add doubleOptInContent to contact book listing select
- Fix duplicate toast on DOI editor subject save failure
- Harden searchParams parsing against string[] values
- Make default DOI template use link mark for clickable URL
- Make public API create+update atomic via transaction
- Prevent contact upsert failure when DOI email send fails
- Fix empty string template variable replacement

Co-authored-by: opencode <opencode@anthropic.com>
Preserve explicit unsubscribe intent in DOI flows and prevent confirmation links from re-subscribing opted-out contacts. Also sanitize subscribe-page error messaging and use timing-safe hash comparison for link verification.
Add a user guide for the double opt-in feature covering setup, contact
statuses, email customization, template variables, and best practices.
Update the OpenAPI spec to include doubleOptIn fields in all contactBook
request/response schemas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@KMKoushik KMKoushik merged commit e3e9635 into main Feb 28, 2026
5 of 6 checks passed
@KMKoushik KMKoushik deleted the useSend-double-opt-in branch February 28, 2026 13:34
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.

1 participant