diff --git a/app/globals.css b/app/globals.css index 6cf72ed..35ef55a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,7 @@ @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); +@custom-variant can-hover (@media (hover: hover)); @theme inline { --color-background: var(--background); diff --git a/app/layout.tsx b/app/layout.tsx index e7242df..2d50512 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { Suspense } from "react"; import "./globals.css"; import { ConvexClientProvider } from "@/lib/convex"; +import { Toaster } from "@/components/ui/toaster"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -32,6 +33,7 @@ export default function RootLayout({ {children} + ); diff --git a/bun.lock b/bun.lock index b512471..3533458 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "yumail", "dependencies": { "@clerk/clerk-react": "^5.59.6", - "@clerk/nextjs": "^6.36.10", + "@clerk/nextjs": "^6.36.8", "@maily-to/core": "^0.3.4", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", @@ -20,6 +20,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "resend": "^6.7.0", + "sonner": "^2.0.7", "svix": "^1.84.1", "tailwind-merge": "^3.4.0", }, @@ -1625,6 +1626,8 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/components/dashboard/recent-unread-section.tsx b/components/dashboard/recent-unread-section.tsx index 0b4a4ac..588836e 100644 --- a/components/dashboard/recent-unread-section.tsx +++ b/components/dashboard/recent-unread-section.tsx @@ -1,13 +1,15 @@ "use client"; import Link from "next/link"; -import { Mail, Check, Sparkles } from "lucide-react"; +import { Mail, Check, Sparkles, ScanText } from "lucide-react"; +import { toast } from "sonner"; import { Preloaded, usePreloadedQuery, useMutation } from "convex/react"; import { api } from "@/convex/_generated/api"; import { Button } from "@/components/ui/button"; import { EmailAvatar } from "@/components/email-avatar"; import { EmptyState } from "@/components/empty-state"; import { getDisplayName } from "@/lib/utils"; +import { detectVerificationCode } from "@/lib/verificationCodeDetector"; export function RecentUnreadSection({ preloadedUnread, @@ -24,6 +26,46 @@ export function RecentUnreadSection({ return { month, day }; }; + // Detect and copy verification code directly + const handleDetectAndCopy = async (subject: string) => { + const code = detectVerificationCode(subject); + + if (!code) { + toast.error("No verification code found", { + description: "No code detected in the email subject", + duration: 2000, + }); + return; + } + + try { + // Copy to clipboard + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(code); + } else { + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = code; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + } + + toast.success(`Code ${code} copied to clipboard`, { + duration: 2000, + }); + } catch (error) { + console.error("Failed to copy code:", error); + toast.error("Failed to copy code", { + description: "Unable to copy to clipboard", + duration: 3000, + }); + } + }; + if (unreadEmails.length === 0) { return (
@@ -61,18 +103,36 @@ export function RecentUnreadSection({ {email.subject}
- + + + ); diff --git a/components/email-list.tsx b/components/email-list.tsx index b8752c2..2a92116 100644 --- a/components/email-list.tsx +++ b/components/email-list.tsx @@ -6,7 +6,9 @@ import { MailOpen, Trash2, Circle, + ScanText, } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { DeliveryStatusBadge } from "@/components/delivery-status-badge"; import { EmailAvatar } from "@/components/email-avatar"; @@ -14,6 +16,7 @@ import { formatRelativeTime, getDisplayName, cn } from "@/lib/utils"; import { useMutation } from "convex/react"; import { api } from "@/convex/_generated/api"; import { Doc, Id } from "@/convex/_generated/dataModel"; +import { detectVerificationCode } from "@/lib/verificationCodeDetector"; type ConvexEmail = Doc<"emails">; @@ -43,6 +46,46 @@ export function EmailList({ emails, showSender = true, emptyMessage }: EmailList } }; + // Detect and copy verification code directly + const handleDetectAndCopy = async (subject: string) => { + const code = detectVerificationCode(subject); + + if (!code) { + toast.error("No verification code found", { + description: "No code detected in the email subject", + duration: 2000, + }); + return; + } + + try { + // Copy to clipboard + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(code); + } else { + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = code; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + } + + toast.success(`Code ${code} copied to clipboard`, { + duration: 2000, + }); + } catch (error) { + console.error("Failed to copy code:", error); + toast.error("Failed to copy code", { + description: "Unable to copy to clipboard", + duration: 3000, + }); + } + }; + if (emails.length === 0) { return (
@@ -131,38 +174,57 @@ export function EmailList({ emails, showSender = true, emptyMessage }: EmailList
e.preventDefault()} > {email.folder === "inbox" && ( - + <> + + {/* Verification code detection and copy button */} + + )}
diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx new file mode 100644 index 0000000..1abe20f --- /dev/null +++ b/components/ui/toaster.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Toaster as Sonner } from "sonner"; + +/** + * Toaster Component + * + * Wraps sonner toast library with project-specific styling. + * Provides accessible toast notifications with proper ARIA announcements. + */ +export function Toaster() { + return ( + + ); +} diff --git a/lib/verificationCodeDetector.test.ts b/lib/verificationCodeDetector.test.ts new file mode 100644 index 0000000..e2c0f5e --- /dev/null +++ b/lib/verificationCodeDetector.test.ts @@ -0,0 +1,36 @@ +/** + * Unit tests for verification code detection + */ + +import { describe, it, expect } from "vitest"; +import { detectVerificationCode } from "./verificationCodeDetector"; + +describe("detectVerificationCode", () => { + it("should detect code at the beginning of subject", () => { + const result = detectVerificationCode("123456 is your Google verification code"); + console.log("Result:", result); + expect(result).toBe("123456"); + }); + + it("should detect code at the end of subject", () => { + const result = detectVerificationCode("Your verification code is 789012"); + console.log("Result:", result); + expect(result).toBe("789012"); + }); + + it("should detect alphanumeric code", () => { + const result = detectVerificationCode("Your OTP is A3B9X7"); + console.log("Result:", result); + expect(result).toBe("A3B9X7"); + }); + + it("should return null when no keyword present", () => { + const result = detectVerificationCode("Order #123456 shipped"); + expect(result).toBeNull(); + }); + + it("should return null when no code present", () => { + const result = detectVerificationCode("Your verification code will arrive soon"); + expect(result).toBeNull(); + }); +}); diff --git a/lib/verificationCodeDetector.ts b/lib/verificationCodeDetector.ts new file mode 100644 index 0000000..f82714b --- /dev/null +++ b/lib/verificationCodeDetector.ts @@ -0,0 +1,187 @@ +/** + * Verification Code Detector + * + * Detects verification codes (OTP) in email subjects using keyword context + * and regex pattern matching. Based on industry best practices from: + * - SMS OTP form standards (web.dev) + * - Common patterns from Google, Facebook, Amazon + * - TOTP/HOTP RFC standards + * + * Supports: + * - Numeric codes (4-8 digits) - most common + * - Alphanumeric codes (4-8 chars with mixed case) + * - Various keyword contexts and formats + */ + +// Verification keywords (case-insensitive) +// Based on common patterns from major services +const KEYWORDS = [ + 'verification code', + 'verify', + 'code', + 'otp', + 'one-time password', + 'one-time', + 'passcode', + 'security code', + 'authentication code', + 'authenticate', + 'confirm', + 'confirmation', + '2fa', + 'two-factor', + 'two-step', + 'login code', + 'access code', +] as const; + +// Regex patterns for code detection +const PATTERNS = { + // Prefixed codes with colon: "code: 123456" or "OTP: A3B9X7" + // Common in Gmail, Facebook, Amazon verification emails + prefixed: /(?:code|otp|password|pin):\s*([A-Za-z0-9]{4,8})\b/i, + + // Numeric codes: 123456 (4-8 digits) + // Most common format - 6 digits is standard (Google, Facebook) + // SMS Retriever API uses \d{6} pattern + numeric: /\b\d{4,8}\b/g, + + // Alphanumeric codes with at least one digit and one letter + // Format: A3B9X7 - prevents matching common words + // Used by some banking and enterprise services + alphanumeric: /\b(?=.*\d)(?=.*[A-Z])[A-Z0-9]{4,8}\b/gi, +} as const; + +// Create keyword pattern once at module level (performance optimization) +const keywordPattern = new RegExp( + `\\b(${KEYWORDS.join('|').replace(/\s+/g, '\\s+')})\\b`, + 'i' +); + +// Common English words that might match alphanumeric pattern +// These should be filtered out to prevent false positives +const COMMON_WORDS = new Set([ + 'your', 'this', 'that', 'here', 'with', 'from', 'have', 'will', + 'about', 'into', 'more', 'after', 'they', 'when', 'then', 'also', + 'some', 'time', 'very', 'what', 'only', 'been', 'were', 'said', + 'each', 'tell', 'does', 'over', 'such', 'must', 'well', 'back', +]); + +/** + * Detects verification code in email subject using context-based matching + * + * Algorithm: + * 1. Validate input and check for keyword presence + * 2. Try prefixed pattern first (most specific: "code: 123456") + * 3. Try numeric pattern (most common: 6-digit codes) + * 4. Fall back to alphanumeric with word filtering + * + * @param subject - Email subject line to scan + * @returns Detected verification code or null if none found + * + * Performance: O(n) where n is subject length + * Limits: Max 500 chars to prevent regex performance issues + */ +export function detectVerificationCode(subject: string | null | undefined): string | null { + // Early validation (prevent errors and performance issues) + if (!subject || typeof subject !== 'string') { + return null; + } + + // Safety limit: Most email subjects are < 200 chars + // RFC 2822 recommends max 78 chars per line + if (subject.length > 500) { + return null; + } + + // Check for keyword presence (required for context) + // This reduces false positives significantly + const hasKeyword = keywordPattern.test(subject); + if (!hasKeyword) { + return null; + } + + // Priority 1: Try prefixed pattern (most specific) + // Matches: "code: 123456", "OTP: A3B9X7", "password: 789012" + const prefixMatch = subject.match(PATTERNS.prefixed); + if (prefixMatch && prefixMatch[1]) { + return prefixMatch[1]; + } + + // Priority 2: Try numeric pattern (most common format) + // Matches: 4-8 digit sequences like "123456" + // Google, Facebook, Amazon typically use 6 digits + const numericMatch = subject.match(PATTERNS.numeric); + if (numericMatch && numericMatch.length > 0) { + // Return first match (most likely to be the code) + return numericMatch[0]; + } + + // Priority 3: Fall back to alphanumeric pattern + // Matches: Mixed alphanumeric like "A3B9X7" + // Less common but used by some banking/enterprise services + const alphanumMatch = subject.match(PATTERNS.alphanumeric); + if (alphanumMatch && alphanumMatch.length > 0) { + // Filter out common English words that might match pattern + for (const match of alphanumMatch) { + if (!COMMON_WORDS.has(match.toLowerCase())) { + return match.toUpperCase(); // Normalize to uppercase + } + } + } + + return null; +} + +/** + * Check if an email subject likely contains a verification code + * Lightweight check without full pattern matching + * + * Use case: Pre-filter emails before running full detection + * + * @param subject - Email subject line + * @returns true if subject contains verification keywords + */ +export function hasVerificationKeywords(subject: string | null | undefined): boolean { + if (!subject || typeof subject !== 'string') { + return false; + } + return keywordPattern.test(subject); +} + +/** + * Get confidence score for detected code (0-100) + * Higher score = more likely to be a valid verification code + * + * Factors: + * - Pattern match type (prefixed > numeric > alphanumeric) + * - Code length (6 digits is most common) + * - Keyword proximity + * + * @param subject - Email subject line + * @param detectedCode - The code that was detected + * @returns Confidence score 0-100 + */ +export function getConfidenceScore( + subject: string, + detectedCode: string +): number { + let score = 50; // Base score + + // Prefixed codes are most reliable + if (PATTERNS.prefixed.test(subject)) { + score += 30; + } + + // 6-digit codes are standard (Google, Facebook, etc.) + if (/^\d{6}$/.test(detectedCode)) { + score += 20; + } + + // Pure numeric codes are more common than alphanumeric + if (/^\d+$/.test(detectedCode)) { + score += 10; + } + + return Math.min(score, 100); +} diff --git a/package.json b/package.json index 75e83c3..6f9f0e0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "resend": "^6.7.0", + "sonner": "^2.0.7", "svix": "^1.84.1", "tailwind-merge": "^3.4.0" },