feat: add customizable contact double opt-in flow#350
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds 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)
✅ Passed checks (2 passed)
✏️ 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. Comment |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.emojiandbody.propertiesuse truthy checks (lines 50–51), while the DOI fields use!== undefined(lines 52–54). If a caller explicitly sendsemoji: "", the update is skipped. Use!== undefineduniformly 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 viaJSON.parse(JSON.stringify(…)).
DEFAULT_DOUBLE_OPT_IN_CONTENT_JSONis 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.
shouldSendDoubleOptInre-sends for "pending" contacts (not subscribed + no unsubscribe reason) which is a reasonable resend behavior. However, this means everyaddOrUpdateContactcall 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
isPendingConfirmationderivation 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
doubleOptInSubjectis set to""without also settingdoubleOptInEnabled: 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 fordoubleOptInSubjectsimilar 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 usingcreateHmacinstead ofcreateHashfor 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: Hardcodedhello@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 inaddOrUpdateContact.The validation logic is solid, but
confirmDoubleOptInSubscriptiondoesn't emit acontact.updatedwebhook event when a contact transitions from unsubscribed to subscribed — unlikeaddOrUpdateContactwhich emits webhooks for state changes. If webhook consumers need to track subscription confirmations, add webhook emission after the update.Note:
emitContactEventincontact-service.tsis not exported, so you'll need to either export it or create a shared helper function before calling it fromdouble-opt-in-service.ts.
apps/web/src/app/(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsx
Show resolved
Hide resolved
apps/web/src/server/public-api/api/contacts/create-contact-book.ts
Outdated
Show resolved
Hide resolved
ff7ac0b to
366dad7
Compare
There was a problem hiding this comment.
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 | 🟡 MinorTypo:
border-broder→border-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 butcreateContactBookServicealready sets defaults — potential override.
createContactBookService(per the unit test atcontact-book-service.unit.test.tslines 58-67) already persistsdoubleOptInEnabled: true,doubleOptInSubject, anddoubleOptInContentwith defaults. If the caller passesdoubleOptInEnabled: falsehere, the book is first created withdoubleOptInEnabled: true, then updated tofalsein 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
createContactBookServiceto accept optional overrides, so the entire payload can be written in a singlecreate:- 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:mockAddBulkContactJobsandmockLoggerare 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
subscribedfield, "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 everyaddOrUpdateContactcall.The
contactBook.findUniquefetch is necessary for DOI logic but adds a query to every contact add/update path, including bulk imports (each job callsaddOrUpdateContact). 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
updateContactBookMutationhasonSettled→ invalidate (line 59-63) which will revert the optimistic update on failure, but there's noonErrorhandler 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 assertingsendArgs.toto fully verify the email recipient.The test validates
from,subject,html, andteamIdbut skips thetofield. 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 orcontactBookIdmismatch.The service throws
"Contact not found for double opt-in email"when the contact isnullor thecontactBookIddoesn't match (line 77-79 in the service). There's no test exercising this path. Similarly,confirmDoubleOptInSubscriptionthrows"Contact not found"when the contact doesn't exist (line 182-184), which is also untested.
| function updateContent() { | ||
| updateContactBook.mutate({ | ||
| contactBookId: contactBook.id, | ||
| doubleOptInContent: JSON.stringify(json), | ||
| }); | ||
| } | ||
|
|
||
| const debouncedUpdateContent = useDebouncedCallback(updateContent, 1000); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
| 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, | ||
| } | ||
| : {}), | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Search for migrations related to unsubscribeReason
fd "\.sql$" apps/web/prisma/migrations 2>/dev/null | head -20Repository: 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 -lRepository: 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.sqlRepository: 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 2Repository: 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 30Repository: 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 SummaryAdds 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
Confidence Score: 3/5
Last reviewed commit: 8cad578 |
| if (existingContact.subscribed) { | ||
| return existingContact; | ||
| } | ||
|
|
||
| return db.contact.update({ |
There was a problem hiding this comment.
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:
| if (existingContact.subscribed) { | |
| return existingContact; | |
| } | |
| return db.contact.update({ | |
| if (existingContact.subscribed) { | |
| return existingContact; | |
| } | |
| if (existingContact.unsubscribeReason !== null) { | |
| return existingContact; | |
| } | |
| return db.contact.update({ |
| const expectedHash = createDoubleOptInHash(contactId, expiresAtTimestamp); | ||
| if (hash !== expectedHash) { |
There was a problem hiding this comment.
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.
| 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) | |
| ) { |
| const shouldCreatePendingContact = | ||
| contactBook.doubleOptInEnabled && existingContact === null; |
There was a problem hiding this comment.
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:
| const shouldCreatePendingContact = | |
| contactBook.doubleOptInEnabled && existingContact === null; | |
| const shouldCreatePendingContact = | |
| contactBook.doubleOptInEnabled && | |
| existingContact === null && | |
| contact.subscribed !== false; |
There was a problem hiding this comment.
🧹 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
getHashto usecreateHmac.apps/web/src/server/service/double-opt-in-service.unit.test.ts (2)
247-265: Consider adding explicitunsubscribeReasonto mock contacts for clarity.The mock contact on line 249-253 omits
unsubscribeReason, so it'sundefinedat runtime. The service checkexistingContact.unsubscribeReason != nullhappens to work correctly (undefined == nullistruein JS), but explicitly includingunsubscribeReason: nullwould 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.findUniquereturnsnull(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"); });
fe2192a to
70f0832
Compare
There was a problem hiding this comment.
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 | 🟡 MinorUse
~/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 | 🟡 MinorAvoid
isSavingrace between subject blur and debounced content save.
Both calls share one mutation and the sameonSuccesstogglesisSaving(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
📒 Files selected for processing (18)
apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book/migration.sqlapps/web/prisma/schema.prismaapps/web/src/app/(dashboard)/contacts/[contactBookId]/contact-list.tsxapps/web/src/app/(dashboard)/contacts/[contactBookId]/double-opt-in/page.tsxapps/web/src/app/(dashboard)/contacts/[contactBookId]/page.tsxapps/web/src/app/subscribe/page.tsxapps/web/src/lib/constants/double-opt-in.tsapps/web/src/lib/constants/double-opt-in.unit.test.tsapps/web/src/lib/zod/contact-book-schema.tsapps/web/src/server/api/routers/contacts.tsapps/web/src/server/public-api/api/contacts/create-contact-book.tsapps/web/src/server/public-api/api/contacts/update-contact-book.tsapps/web/src/server/service/contact-book-service.tsapps/web/src/server/service/contact-book-service.unit.test.tsapps/web/src/server/service/contact-service.tsapps/web/src/server/service/contact-service.unit.test.tsapps/web/src/server/service/double-opt-in-service.tsapps/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
| <Switch | ||
| checked={ | ||
| contactBookDetailQuery.data?.doubleOptInEnabled ?? false | ||
| } | ||
| onCheckedChange={(checked) => { | ||
| updateContactBookMutation.mutate({ | ||
| contactBookId, | ||
| doubleOptInEnabled: checked, | ||
| }); | ||
| }} |
There was a problem hiding this comment.
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.
| <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.
| 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> |
There was a problem hiding this comment.
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.
| 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.
| 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"; | ||
|
|
There was a problem hiding this comment.
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.
| 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.
- 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.
70f0832 to
3cef365
Compare
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>
Summary
doubleOptInEnabled, customizable subject, and editor content) with a Prisma migration that keeps existing books disabled by default while enabling defaults for newly created books/subscribeconfirms the subscriptionMigration Notes
apps/web/prisma/migrations/20260208000100_add_double_opt_in_to_contact_book/migration.sqlbefore using the new fieldsVerification
node_modulesare 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
Migration
Written for commit 822574f. Summary will update on new commits.
Summary by CodeRabbit
New Features
UI
Bug Fixes
Tests