From dd4737cfe9f604dd85a15f24229985d09f9ecbbf Mon Sep 17 00:00:00 2001
From: Dries Augustyns
Date: Thu, 1 Jan 2026 10:56:59 +0100
Subject: [PATCH 1/5] chore: Include types as dep for shared
---
.github/workflows/ci.yml | 4 ++--
packages/shared/package.json | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3f0037b..1417f6c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -103,7 +103,7 @@ jobs:
EOF
- name: Build shared packages
- run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
+ run: yarn build --filter="@plunk/shared" --filter="@plunk/db" --filter="@plunk/types"
- name: Generate Prisma Client
run: yarn workspace @plunk/db db:generate
@@ -188,7 +188,7 @@ jobs:
EOF
- name: Build shared packages
- run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
+ run: yarn build --filter="@plunk/shared" --filter="@plunk/db" --filter="@plunk/types"
- name: Run linter
run: yarn lint
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 6d3a158..0979293 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -14,6 +14,7 @@
"typescript": "^5.7.2"
},
"dependencies": {
+ "@plunk/types": "*",
"zod": "^3.23.8"
},
"exports": {
From 3a299cae98e664bffcde558137c6c1c60b103d39 Mon Sep 17 00:00:00 2001
From: Dries Augustyns
Date: Thu, 1 Jan 2026 10:58:39 +0100
Subject: [PATCH 2/5] chore: Include types as dep for shared
---
yarn.lock | 1 +
1 file changed, 1 insertion(+)
diff --git a/yarn.lock b/yarn.lock
index 034f8cb..e20d307 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3104,6 +3104,7 @@ __metadata:
resolution: "@plunk/shared@workspace:packages/shared"
dependencies:
"@plunk/db": "npm:*"
+ "@plunk/types": "npm:*"
"@plunk/typescript-config": "npm:*"
"@types/node": "npm:^24.10.0"
typescript: "npm:^5.7.2"
From 76786b2eaee78470172b2be6294111e30adb8b30 Mon Sep 17 00:00:00 2001
From: Dries Augustyns
Date: Thu, 1 Jan 2026 11:02:31 +0100
Subject: [PATCH 3/5] chore: Include types as dep for shared
---
.github/workflows/ci.yml | 4 ++--
packages/types/package.json | 1 +
yarn.lock | 1 +
3 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1417f6c..3fede5d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -103,7 +103,7 @@ jobs:
EOF
- name: Build shared packages
- run: yarn build --filter="@plunk/shared" --filter="@plunk/db" --filter="@plunk/types"
+ run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Generate Prisma Client
run: yarn workspace @plunk/db db:generate
@@ -188,7 +188,7 @@ jobs:
EOF
- name: Build shared packages
- run: yarn build --filter="@plunk/shared" --filter="@plunk/db" --filter="@plunk/types"
+ run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Run linter
run: yarn lint
diff --git a/packages/types/package.json b/packages/types/package.json
index 998f6d0..cd714fc 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -8,6 +8,7 @@
"build": "tsc"
},
"devDependencies": {
+ "@plunk/db": "*",
"@plunk/typescript-config": "*",
"@types/node": "^24.10.0",
"@types/react": "^19.2.7",
diff --git a/yarn.lock b/yarn.lock
index e20d307..5e5774d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3116,6 +3116,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@plunk/types@workspace:packages/types"
dependencies:
+ "@plunk/db": "npm:*"
"@plunk/typescript-config": "npm:*"
"@types/node": "npm:^24.10.0"
"@types/react": "npm:^19.2.7"
From da7f3e5718b1c702b57d190b6cc3bf7c2b92dc87 Mon Sep 17 00:00:00 2001
From: Dries Augustyns
Date: Thu, 1 Jan 2026 14:08:08 +0100
Subject: [PATCH 4/5] chore: Remove comments
---
CLAUDE.md | 8 +++-
apps/api/src/controllers/Campaigns.ts | 1 -
apps/api/src/controllers/Contacts.ts | 1 -
apps/api/src/controllers/Uploads.ts | 1 -
.../jobs/__tests__/email-processor.test.ts | 6 ---
.../__tests__/requestLogger.test.ts | 8 +---
apps/api/src/services/AnalyticsService.ts | 2 -
apps/api/src/services/S3Service.ts | 1 -
apps/api/src/services/SegmentService.ts | 1 -
.../src/services/WorkflowExecutionService.ts | 1 -
apps/web/src/components/ActivityFeed.tsx | 3 +-
apps/web/src/lib/hooks/useAnalytics.ts | 6 +--
apps/web/src/lib/hooks/useContacts.ts | 3 +-
apps/web/src/pages/analytics/index.tsx | 18 +++-----
apps/web/src/pages/contacts/index.tsx | 3 +-
apps/web/src/pages/manage/[id].tsx | 3 +-
apps/web/src/pages/segments/[id].tsx | 3 +-
apps/web/src/pages/workflows/[id].tsx | 3 +-
packages/types/src/prisma/helpers.ts | 46 -------------------
19 files changed, 22 insertions(+), 96 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ce313fb..65b9299 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -156,4 +156,10 @@ Required for builds and deployment (see turbo.json and .env.example):
allows the same Docker image to be used across different environments by simply changing environment variables at
runtime
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
- client-side access
\ No newline at end of file
+ client-side access
+
+## Plugins
+There are two plugins installed for you to use.
+
+- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
+- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding.
\ No newline at end of file
diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts
index e9d836e..8628d25 100644
--- a/apps/api/src/controllers/Campaigns.ts
+++ b/apps/api/src/controllers/Campaigns.ts
@@ -23,7 +23,6 @@ export class Campaigns {
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body);
- // Validate audience-specific fields
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts
index ba89952..382b2d2 100644
--- a/apps/api/src/controllers/Contacts.ts
+++ b/apps/api/src/controllers/Contacts.ts
@@ -14,7 +14,6 @@ const upload = multer({
fileSize: 5 * 1024 * 1024, // 5MB max file size
},
fileFilter: (_req, file, cb) => {
- // Only accept CSV files
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
cb(null, true);
} else {
diff --git a/apps/api/src/controllers/Uploads.ts b/apps/api/src/controllers/Uploads.ts
index 650123a..d0ff856 100644
--- a/apps/api/src/controllers/Uploads.ts
+++ b/apps/api/src/controllers/Uploads.ts
@@ -13,7 +13,6 @@ const upload = multer({
fileSize: 10 * 1024 * 1024, // 10MB max file size
},
fileFilter: (_req, file, cb) => {
- // Only accept image files
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
if (allowedMimeTypes.includes(file.mimetype)) {
diff --git a/apps/api/src/jobs/__tests__/email-processor.test.ts b/apps/api/src/jobs/__tests__/email-processor.test.ts
index 5342e36..881b762 100644
--- a/apps/api/src/jobs/__tests__/email-processor.test.ts
+++ b/apps/api/src/jobs/__tests__/email-processor.test.ts
@@ -29,12 +29,6 @@ describe('Email Processor', () => {
status: EmailStatus.PENDING,
});
- // Mock the email processor logic
- // In a real implementation, you would:
- // 1. Create job tester
- // 2. Mock SES service
- // 3. Process the job
- // 4. Verify status changes
// Simulate processing
await prisma.email.update({
diff --git a/apps/api/src/middleware/__tests__/requestLogger.test.ts b/apps/api/src/middleware/__tests__/requestLogger.test.ts
index bd48f36..816ad63 100644
--- a/apps/api/src/middleware/__tests__/requestLogger.test.ts
+++ b/apps/api/src/middleware/__tests__/requestLogger.test.ts
@@ -16,7 +16,6 @@ describe('Request Logger Middleware', () => {
projectId = project.id;
userId = user.id;
- // Mock request object
req = {
method: 'POST',
path: '/v1/send',
@@ -334,12 +333,7 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
- // TODO: Add assertion to verify request was NOT logged when disabled
- // const loggedRequest = await prisma.apiRequest.findUnique({
- // where: {id: 'test-request-id-123'},
- // });
- // expect(loggedRequest).toBeNull();
-
+
// Restore original value
if (originalEnv !== undefined) {
process.env.REQUEST_LOGGING = originalEnv;
diff --git a/apps/api/src/services/AnalyticsService.ts b/apps/api/src/services/AnalyticsService.ts
index 06aefeb..be96fef 100644
--- a/apps/api/src/services/AnalyticsService.ts
+++ b/apps/api/src/services/AnalyticsService.ts
@@ -68,8 +68,6 @@ export class AnalyticsService {
return JSON.parse(cached);
}
- // Raw SQL query for efficient daily aggregation
- // Using raw SQL because Prisma's groupBy is less efficient for date truncation
const result = await prisma.$queryRaw<
{
date: Date;
diff --git a/apps/api/src/services/S3Service.ts b/apps/api/src/services/S3Service.ts
index 0962747..a36d89b 100644
--- a/apps/api/src/services/S3Service.ts
+++ b/apps/api/src/services/S3Service.ts
@@ -46,7 +46,6 @@ export async function initializeBucket(): Promise {
let bucketExists = true;
try {
- // Check if bucket exists
await s3Client.send(
new HeadBucketCommand({
Bucket: S3_BUCKET,
diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts
index 317e330..d246a68 100644
--- a/apps/api/src/services/SegmentService.ts
+++ b/apps/api/src/services/SegmentService.ts
@@ -9,7 +9,6 @@ import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js';
-// Re-export types for use in other services
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
/**
diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts
index d2eda32..0d85483 100644
--- a/apps/api/src/services/WorkflowExecutionService.ts
+++ b/apps/api/src/services/WorkflowExecutionService.ts
@@ -20,7 +20,6 @@ import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
-// Type aliases for workflow execution context
type StepConfig = Prisma.JsonValue;
type StepResult = Record;
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
diff --git a/apps/web/src/components/ActivityFeed.tsx b/apps/web/src/components/ActivityFeed.tsx
index b909ba8..6752f5e 100644
--- a/apps/web/src/components/ActivityFeed.tsx
+++ b/apps/web/src/components/ActivityFeed.tsx
@@ -27,8 +27,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
return date.toISOString();
}, [dateRangeDays]);
- // Fetch activities
- const fetchActivities = useCallback(
+ const fetchActivities = useCallback(
async (cursor?: string) => {
try {
if (cursor) {
diff --git a/apps/web/src/lib/hooks/useAnalytics.ts b/apps/web/src/lib/hooks/useAnalytics.ts
index dc89665..4899885 100644
--- a/apps/web/src/lib/hooks/useAnalytics.ts
+++ b/apps/web/src/lib/hooks/useAnalytics.ts
@@ -50,8 +50,7 @@ export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
}, [days, options.startDate, options.endDate]);
/* eslint-enable react-hooks/purity */
- // Fetch activity stats
- const {
+ const {
data: stats,
error: statsError,
isLoading: statsLoading,
@@ -61,8 +60,7 @@ export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
});
- // Fetch time series data (if endpoint exists)
- const {
+ const {
data: timeSeries,
error: timeSeriesError,
isLoading: timeSeriesLoading,
diff --git a/apps/web/src/lib/hooks/useContacts.ts b/apps/web/src/lib/hooks/useContacts.ts
index f206b52..d38507f 100644
--- a/apps/web/src/lib/hooks/useContacts.ts
+++ b/apps/web/src/lib/hooks/useContacts.ts
@@ -46,8 +46,7 @@ export function useContactFields() {
},
);
- // Extract just the field names as strings
- const fieldNames = (data?.fields || []).map(f => f.field);
+ const fieldNames = (data?.fields || []).map(f => f.field);
return {
fields: fieldNames,
diff --git a/apps/web/src/pages/analytics/index.tsx b/apps/web/src/pages/analytics/index.tsx
index d161b4c..d725be4 100644
--- a/apps/web/src/pages/analytics/index.tsx
+++ b/apps/web/src/pages/analytics/index.tsx
@@ -72,8 +72,7 @@ export default function AnalyticsPage() {
return {startDate: start.toISOString(), endDate: end.toISOString()};
}, [days]);
- // Fetch campaign stats from API
- const {data: campaignStats} = useSWR<{
+ const {data: campaignStats} = useSWR<{
total: number;
active: number;
completed: number;
@@ -85,8 +84,7 @@ export default function AnalyticsPage() {
dedupingInterval: 10000,
});
- // Fetch top events from API
- const {data: topEvents} = useSWR<
+ const {data: topEvents} = useSWR<
{
name: string;
count: number;
@@ -98,8 +96,7 @@ export default function AnalyticsPage() {
dedupingInterval: 10000,
});
- // Fetch top campaigns from API
- const {data: topCampaigns} = useSWR<
+ const {data: topCampaigns} = useSWR<
{
id: string;
subject: string;
@@ -115,8 +112,7 @@ export default function AnalyticsPage() {
dedupingInterval: 10000,
});
- // Process time series data for charts
- const chartData = useMemo(() => {
+ const chartData = useMemo(() => {
if (timeSeries && timeSeries.length > 0) {
return timeSeries.map(point => ({
date: new Date(point.date).toLocaleDateString('en-US', {month: 'short', day: 'numeric'}),
@@ -131,13 +127,11 @@ export default function AnalyticsPage() {
return [];
}, [timeSeries]);
- // Check if we have any real data
- const hasData = useMemo(() => {
+ const hasData = useMemo(() => {
return chartData.some(point => point.emails > 0 || point.opens > 0 || point.clicks > 0);
}, [chartData]);
- // Calculate cumulative totals
- const cumulativeTotals = useMemo(() => {
+ const cumulativeTotals = useMemo(() => {
return chartData.reduce(
(acc, day) => ({
emails: acc.emails + (day.emails || 0),
diff --git a/apps/web/src/pages/contacts/index.tsx b/apps/web/src/pages/contacts/index.tsx
index 5126e70..0bde92e 100644
--- a/apps/web/src/pages/contacts/index.tsx
+++ b/apps/web/src/pages/contacts/index.tsx
@@ -67,8 +67,7 @@ export default function ContactsPage() {
{revalidateOnFocus: false},
);
- // Update contacts when data changes
- useEffect(() => {
+ useEffect(() => {
if (data) {
setContacts(data.data);
if (!cursor) {
diff --git a/apps/web/src/pages/manage/[id].tsx b/apps/web/src/pages/manage/[id].tsx
index c21534c..b9fe525 100644
--- a/apps/web/src/pages/manage/[id].tsx
+++ b/apps/web/src/pages/manage/[id].tsx
@@ -33,8 +33,7 @@ export default function Manage() {
const data = await network.fetch('GET', `/contacts/public/${id}`);
setContact(data);
- // Load translations for the project's language
- const t = await createTranslator(data.language || 'en');
+ const t = await createTranslator(data.language || 'en');
setTranslator(t);
setError(null);
diff --git a/apps/web/src/pages/segments/[id].tsx b/apps/web/src/pages/segments/[id].tsx
index 8270a94..895001b 100644
--- a/apps/web/src/pages/segments/[id].tsx
+++ b/apps/web/src/pages/segments/[id].tsx
@@ -57,8 +57,7 @@ export default function SegmentDetailPage() {
const [isComputing, setIsComputing] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
- // Initialize form when segment loads
- useEffect(() => {
+ useEffect(() => {
if (segment) {
setName(segment.name);
setDescription(segment.description || '');
diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx
index 0f05bc2..d073ef9 100644
--- a/apps/web/src/pages/workflows/[id].tsx
+++ b/apps/web/src/pages/workflows/[id].tsx
@@ -1556,8 +1556,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
const [name, setName] = useState(step.name);
const [isSubmitting, setIsSubmitting] = useState(false);
- // Get icon and colors for this step type
- const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch;
+ const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch;
const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280';
const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6';
diff --git a/packages/types/src/prisma/helpers.ts b/packages/types/src/prisma/helpers.ts
index 61cf4a6..182d301 100644
--- a/packages/types/src/prisma/helpers.ts
+++ b/packages/types/src/prisma/helpers.ts
@@ -7,58 +7,12 @@
import {Prisma} from '@plunk/db';
-/**
- * Safely convert a value to Prisma.InputJsonValue for storing in JSON fields
- *
- * This helper provides better type safety than direct casting while acknowledging
- * that Prisma cannot validate the JSON structure at compile time.
- *
- * @template T - The type being stored (for documentation purposes)
- * @param value - The value to convert to Prisma JSON format
- * @returns The value as Prisma.InputJsonValue
- *
- * @example
- * ```typescript
- * // Filter condition (complex nested object)
- * const condition: FilterCondition = { logic: 'AND', groups: [...] };
- * await prisma.segment.create({
- * data: {
- * condition: toPrismaJson(condition)
- * }
- * });
- *
- * // Simple object
- * const headers = { 'X-Custom': 'value' };
- * await prisma.email.create({
- * data: {
- * headers: toPrismaJson(headers)
- * }
- * });
- * ```
- */
export function toPrismaJson(value: T | null | undefined): Prisma.InputJsonValue {
// Prisma.InputJsonValue accepts: string | number | boolean | null | JsonObject | JsonArray
// We trust that T is JSON-serializable at runtime (including null)
return value as unknown as Prisma.InputJsonValue;
}
-/**
- * Safely convert Prisma.JsonValue to a typed value when reading from JSON fields
- *
- * IMPORTANT: This does NOT perform runtime validation. It's a type-safe way to
- * document what type you expect, but the caller must validate if needed.
- *
- * @template T - The expected type
- * @param value - The JSON value from Prisma
- * @returns The value as type T
- *
- * @example
- * ```typescript
- * const segment = await prisma.segment.findUnique({ where: { id } });
- * const condition = fromPrismaJson(segment.condition);
- * // condition is now typed as FilterCondition (but not validated)
- * ```
- */
export function fromPrismaJson(value: Prisma.JsonValue): T {
return value as unknown as T;
}
From 457c829b2d59debc41ac69f907f758dd5ded1c1a Mon Sep 17 00:00:00 2001
From: Dries Augustyns
Date: Thu, 1 Jan 2026 14:26:26 +0100
Subject: [PATCH 5/5] feat: Add cooldown to resend verification email
---
apps/web/src/pages/auth/verify-email.tsx | 62 +++++++++++++++++++++---
1 file changed, 56 insertions(+), 6 deletions(-)
diff --git a/apps/web/src/pages/auth/verify-email.tsx b/apps/web/src/pages/auth/verify-email.tsx
index a6bb54f..03a9a88 100644
--- a/apps/web/src/pages/auth/verify-email.tsx
+++ b/apps/web/src/pages/auth/verify-email.tsx
@@ -16,6 +16,8 @@ export default function VerifyEmail() {
const [errorMessage, setErrorMessage] = useState('');
const [isResending, setIsResending] = useState(false);
const [resendMessage, setResendMessage] = useState('');
+ const [cooldownExpiry, setCooldownExpiry] = useState(null);
+ const [remainingSeconds, setRemainingSeconds] = useState(0);
const processedToken = useRef(undefined);
useEffect(() => {
@@ -67,6 +69,45 @@ export default function VerifyEmail() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, token]);
+ // Initialize cooldown from localStorage on mount
+ useEffect(() => {
+ const storedExpiry = localStorage.getItem('plunk:email-verification-cooldown');
+ if (storedExpiry) {
+ const expiryTime = parseInt(storedExpiry, 10);
+ // Validate: not NaN, in the future, and within reasonable range (< 1 hour from now)
+ if (!isNaN(expiryTime) && expiryTime > Date.now() && expiryTime < Date.now() + 3600000) {
+ setCooldownExpiry(expiryTime);
+ } else {
+ // Clean up invalid/expired cooldown
+ localStorage.removeItem('plunk:email-verification-cooldown');
+ }
+ }
+ }, []);
+
+ // Countdown timer effect
+ useEffect(() => {
+ if (!cooldownExpiry) {
+ setRemainingSeconds(0);
+ return;
+ }
+
+ // Update immediately
+ const updateRemaining = () => {
+ const remaining = Math.max(0, Math.ceil((cooldownExpiry - Date.now()) / 1000));
+ setRemainingSeconds(remaining);
+
+ if (remaining === 0) {
+ setCooldownExpiry(null);
+ localStorage.removeItem('plunk:email-verification-cooldown');
+ }
+ };
+
+ updateRemaining();
+ const interval = setInterval(updateRemaining, 1000);
+
+ return () => clearInterval(interval);
+ }, [cooldownExpiry]);
+
async function handleResend() {
setIsResending(true);
setResendMessage('');
@@ -75,11 +116,20 @@ export default function VerifyEmail() {
if (response.success) {
setResendMessage('Verification email sent! Please check your inbox.');
+ // Set 60-second cooldown
+ const expiryTime = Date.now() + 60000;
+ setCooldownExpiry(expiryTime);
+ localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
} else {
setResendMessage('Failed to send verification email. Please try again.');
}
- } catch {
- setResendMessage('Failed to send verification email. Please try again.');
+ } catch (error) {
+ // Show error message but still apply cooldown to prevent spam
+ setResendMessage(error instanceof Error ? error.message : 'Failed to send verification email. Please try again.');
+ // Apply cooldown even on error to prevent retry spam
+ const expiryTime = Date.now() + 60000;
+ setCooldownExpiry(expiryTime);
+ localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
} finally {
setIsResending(false);
}
@@ -119,8 +169,8 @@ export default function VerifyEmail() {
-