diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index 302ab3bb9..8d6c60b4c 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -1,12 +1,10 @@ import { Archive2, - Copy, ExclamationCircle, GroupPeople, PencilCompose, Star2, Trash, - ExternalLink, } from '../icons/icons'; import { memo, useCallback, useEffect, useMemo, useRef, type ComponentProps, useState } from 'react'; import { useOptimisticThreadState } from '@/components/mail/optimistic-thread-state'; @@ -938,10 +936,7 @@ export const MailList = memo( ) : (
-
-
-

Auth Items

-
+
{otpEmails.map((otp) => ( ))} diff --git a/apps/mail/lib/otp-detection.ts b/apps/mail/lib/otp-detection.ts deleted file mode 100644 index c8d65a4cb..000000000 --- a/apps/mail/lib/otp-detection.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { ParsedMessage } from '@/types'; - -export interface OTPCode { - id: string; - code: string; - service: string; - threadId: string; - from: string; - subject: string; - receivedAt: Date; - expiresAt?: Date; - isExpired: boolean; -} - -const OTP_PATTERNS = [ - // Codes with explicit context (most specific) - // /Your (?:verification|security|authentication|confirmation|access|login) code is:?\s*([A-Z0-9]{4,8})/i, - // /(?:verification|security|authentication|confirmation|access|login) code:?\s*([A-Z0-9]{4,8})/i, - // /(?:code|OTP|PIN)(?:\s+is)?:?\s*([A-Z0-9]{4,8})/i, - // /Use (?:code|this):?\s*([A-Z0-9]{4,8})/i, - // /Enter:?\s*([A-Z0-9]{4,8})/i, - - // Service-specific patterns - /G-(\d{6})/, // Google format - /(\d{6})\s+is your/i, - /is\s+(\d{4,8})(?!\s*(?:px|em|rem|%|pt|vh|vw))/i, // Exclude CSS units - - // Codes with formatting - /\b(\d{3}[-\s]\d{3})\b/, // 123-456 or 123 456 - /\b(\d{4}[-\s]\d{4})\b/, // 1234-5678 - /\b(\d{2}[-\s]\d{2}[-\s]\d{2})\b/, // 12-34-56 - - // Standalone numeric codes (4-8 digits) - exclude hex colors, dates, times - /(? { - // OTP codes should contain at least one digit - if (!/\d/.test(code)) return false; - - // Exclude purely alphabetic strings (common words) - if (/^[A-Za-z]+$/.test(code)) return false; - - // Exclude years (1900-2099) - if (/^(19|20)\d{2}$/.test(code)) return false; - - // Exclude common timestamp patterns - if (/^\d{2}:\d{2}$/.test(code)) return false; // HH:MM - if (/^\d{6}$/.test(code) && code.match(/^([01]\d|2[0-3])([0-5]\d){2}$/)) return false; // HHMMSS - - // Exclude codes that are all the same digit (e.g., 000000, 111111) - if (/^(\d)\1+$/.test(code)) return false; - - // Exclude sequential numbers (e.g., 123456, 987654) - const digits = code.split('').map(Number); - const isSequential = digits.every( - (digit, i) => i === 0 || digit === digits[i - 1] + 1 || digit === digits[i - 1] - 1, - ); - if (isSequential && code.length >= 4) return false; - - return true; -}; - -const isCodeWithinURL = (text: string, index: number, length: number): boolean => { - const urlRegex = /https?:\/\/[^\s"'<>]+/gi; - let m; - while ((m = urlRegex.exec(text)) !== null) { - const start = m.index; - const end = start + m[0].length; - if (index >= start && index + length <= end) return true; - } - return false; -}; - -const SERVICE_PATTERNS: Record = { - Google: [/google/i, /gmail/i, /youtube/i], - Microsoft: [/microsoft/i, /outlook/i, /office/i, /azure/i], - Amazon: [/amazon/i, /aws/i], - Apple: [/apple/i, /icloud/i], - Facebook: [/facebook/i, /meta/i], - Twitter: [/twitter/i, /x\.com/i], - GitHub: [/github/i], - LinkedIn: [/linkedin/i], - PayPal: [/paypal/i], - Stripe: [/stripe/i], - Discord: [/discord/i], - Slack: [/slack/i], - Notion: [/notion/i], - Vercel: [/vercel/i], - Cloudflare: [/cloudflare/i], -}; - -export const detectOTPFromEmail = (message: ParsedMessage): OTPCode | null => { - if (!message.subject && !message.body) return null; - - const otpKeywords = [ - 'verification code', - 'verify', - 'otp', - 'one-time', - '2fa', - 'two-factor', - 'security code', - 'confirmation code', - 'access code', - 'login code', - ]; - - const content = `${message.subject} ${message.decodedBody}`.toLowerCase(); - const hasOTPKeyword = otpKeywords.some((keyword) => content.includes(keyword)); - - if (!hasOTPKeyword) return null; - - let code: string | null = null; - const bodyText = message.decodedBody || message.body || ''; - - console.log('bodyText', bodyText); - - for (const pattern of OTP_PATTERNS) { - const regex = new RegExp( - pattern.source, - pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g', - ); - let m; - while ((m = regex.exec(bodyText)) !== null) { - if (!m[1]) continue; - if (isCodeWithinURL(bodyText, m.index ?? 0, m[1].length)) continue; - const potentialCode = m[1].replace(/[-\s]/g, ''); - if (isValidOTPCode(potentialCode)) { - code = potentialCode; - break; - } - } - if (code) break; - } - - if (!code) return null; - - let service = 'Unknown Service'; - const fromEmail = message.sender?.email || ''; - const fromName = message.sender?.name || ''; - - for (const [serviceName, patterns] of Object.entries(SERVICE_PATTERNS)) { - if ( - patterns.some( - (pattern) => - pattern.test(fromEmail) || pattern.test(fromName) || pattern.test(message.subject || ''), - ) - ) { - service = serviceName; - break; - } - } - - if (service === 'Unknown Service' && message.sender?.name) { - service = message.sender.name.split(' ')[0]; - } - - const receivedAt = new Date(message.receivedOn); - const expiresAt = new Date(receivedAt.getTime() + 10 * 60 * 1000); // 10 minutes - const isExpired = new Date() > expiresAt; - - return { - id: `${message.id}-otp`, - code, - service, - threadId: message.threadId || message.id, - from: fromEmail, - subject: message.subject || '', - receivedAt, - expiresAt, - isExpired, - }; -}; diff --git a/apps/server/src/lib/otp-detector.ts b/apps/server/src/lib/otp-detector.ts index 36b564d5c..1c20a47bb 100644 --- a/apps/server/src/lib/otp-detector.ts +++ b/apps/server/src/lib/otp-detector.ts @@ -1,30 +1,9 @@ +import { htmlToText } from '../thread-workflow-utils/workflow-utils'; +import { generateObject, generateText } from 'ai'; import type { ParsedMessage } from '../types'; import { openai } from '@ai-sdk/openai'; -import { generateText } from 'ai'; import { env } from '../env'; - -const OTP_PATTERNS = [ - // Service-specific patterns - /G-(\d{6})/, // Google format - /(\d{6})\s+is your/i, - /is\s+(\d{4,8})(?!\s*(?:px|em|rem|%|pt|vh|vw))/i, // Exclude CSS units - - // Codes with formatting - /\b(\d{3}[-\s]\d{3})\b/, // 123-456 or 123 456 - /\b(\d{4}[-\s]\d{4})\b/, // 1234-5678 - /\b(\d{2}[-\s]\d{2}[-\s]\d{2})\b/, // 12-34-56 - - // Standalone numeric codes (4-8 digits) - exclude hex colors, dates, times - /(? { // OTP codes should contain at least one digit @@ -64,24 +43,6 @@ const isCodeWithinURL = (text: string, index: number, length: number): boolean = return false; }; -const SERVICE_PATTERNS: Record = { - Google: [/google/i, /gmail/i, /youtube/i], - Microsoft: [/microsoft/i, /outlook/i, /office/i, /azure/i], - Amazon: [/amazon/i, /aws/i], - Apple: [/apple/i, /icloud/i], - Facebook: [/facebook/i, /meta/i], - Twitter: [/twitter/i, /x\.com/i], - GitHub: [/github/i], - LinkedIn: [/linkedin/i], - PayPal: [/paypal/i], - Stripe: [/stripe/i], - Discord: [/discord/i], - Slack: [/slack/i], - Notion: [/notion/i], - Vercel: [/vercel/i], - Cloudflare: [/cloudflare/i], -}; - interface OTPResult { code: string; service: string; @@ -93,86 +54,6 @@ export interface MagicLinkResult { service: string; } -export const detectOTPFromThread = (thread: { messages: ParsedMessage[] }): OTPResult | null => { - const latestMessage = thread.messages?.[0]; - if (!latestMessage) return null; - - // Check if this looks like an OTP email - const otpKeywords = [ - 'verification code', - 'verify', - 'otp', - 'one-time', - '2fa', - 'two-factor', - 'security code', - 'confirmation code', - 'access code', - 'login code', - ]; - - const content = - `${latestMessage.subject ?? ''} ${latestMessage.decodedBody || latestMessage.body || ''}`.toLowerCase(); - const hasOTPKeyword = otpKeywords.some((keyword) => content.includes(keyword)); - - if (!hasOTPKeyword) return null; - - let code: string | null = null; - const bodyText = latestMessage.decodedBody || latestMessage.body || ''; - - // Try to find OTP code in the body - for (const pattern of OTP_PATTERNS) { - const regex = new RegExp( - pattern.source, - pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g', - ); - let m; - while ((m = regex.exec(bodyText)) !== null) { - if (!m[1]) continue; - if (isCodeWithinURL(bodyText, m.index ?? 0, m[1].length)) continue; - const potentialCode = m[1].replace(/[-\s]/g, ''); - if (isValidOTPCode(potentialCode)) { - code = potentialCode; - break; - } - } - if (code) break; - } - - if (!code) return null; - - let service = 'Unknown Service'; - const fromEmail = latestMessage.sender?.email || ''; - const fromName = latestMessage.sender?.name || ''; - - for (const [serviceName, patterns] of Object.entries(SERVICE_PATTERNS)) { - if ( - patterns.some( - (pattern) => - pattern.test(fromEmail) || - pattern.test(fromName) || - pattern.test(latestMessage.subject || ''), - ) - ) { - service = serviceName; - break; - } - } - - if (service === 'Unknown Service' && latestMessage.sender?.name) { - service = latestMessage.sender.name.split(' ')[0]; - } - - const receivedAt = new Date(latestMessage.receivedOn); - const expiresAt = new Date(receivedAt.getTime() + 10 * 60 * 1000); - - return { - code, - service, - expiresAt, - }; -}; - export const detectOTPFromThreadAI = async (thread: { messages: ParsedMessage[]; }): Promise => { @@ -180,65 +61,97 @@ export const detectOTPFromThreadAI = async (thread: { if (!latestMessage) return null; const subject = latestMessage.subject ?? ''; - const body = latestMessage.decodedBody || latestMessage.body || ''; - const fromEmail = latestMessage.sender?.email || ''; - const fromName = latestMessage.sender?.name || ''; + const body = latestMessage.decodedBody ?? ''; + const fromEmail = latestMessage.sender?.email ?? ''; + const fromName = latestMessage.sender?.name ?? ''; + const title = latestMessage.title ?? ''; - const systemPrompt = ` -You are an assistant that extracts one-time passcodes (OTP) from emails. Strict rules: -- Only return a JSON object with: {"code":"string","service":"string"}. -- If no valid OTP is found, return exactly {}. -- Valid codes are 4-8 digits OR 6-8 alphanumeric (A-Z, 0-9). -- Do not use numbers inside URLs, timestamps, years, hex colors, or sequential/repeated digits. -- Prefer codes explicitly referenced as verification/OTP/security/login/2FA/PIN codes. -`; + const sanitize = await htmlToText(body); - const userPrompt = `Subject: ${subject}\nFrom: ${fromName} <${fromEmail}>\n\nBody:\n${body}`; + const systemPrompt = `You are an OTP extraction specialist. Your task is to identify and extract one-time passcodes (OTP) from email content. + +## Output Format +Return ONLY a JSON object in one of these formats: +- If OTP found: {"code": "string", "service": "string"} +- If no OTP found: {} + +## Valid OTP Patterns +1. **Numeric codes**: 4-8 consecutive digits (e.g., "1234", "567890") +2. **Alphanumeric codes**: 6-8 characters mixing letters (A-Z) and numbers (e.g., "A1B2C3", "X9Y8Z7W6") + +## Service Identification +Extract the service name from: +- Email sender domain (e.g., noreply@github.com → "GitHub") +- Subject line mentions (e.g., "Your Netflix verification code") +- Body content references (e.g., "Sign in to Amazon") + +## Code Context Indicators +Prioritize codes that appear near these keywords: +- "verification code", "OTP", "one-time password", "security code" +- "2FA", "two-factor", "authentication code", "PIN" +- "confirm", "verify", "login code", "access code" +- "expires in", "valid for", "use this code" + +## Exclusion Rules +DO NOT extract: +- Numbers within URLs (e.g., github.com/user/123456) +- Timestamps or dates (e.g., 14:30, 2024, 20241208) +- Year values (1900-2099) +- Hex color codes (#FF5500) +- Order/invoice numbers +- Phone numbers +- Sequential digits (123456, 987654) +- Repeated digits (000000, 111111) +- Version numbers (v1.2.3) +- IP addresses or ports + +## Examples +Input: "Your GitHub verification code is 845291. This code expires in 10 minutes." +Output: {"code": "845291", "service": "GitHub"} + +Input: "Visit example.com/reset/123456 to reset your password" +Output: {} + +Input: "Enter A9B2K7 to complete your Apple ID sign-in" +Output: {"code": "A9B2K7", "service": "Apple"}`; + + const userPrompt = `Subject: ${subject}\nFrom: ${fromName} <${fromEmail}>\n\nTitle: ${title}\n\nBody:\n${sanitize}`; try { - const { text: raw } = await generateText({ + const { object: raw } = await generateObject({ model: openai(env.OPENAI_MODEL || 'gpt-4o'), system: systemPrompt, prompt: userPrompt, - temperature: 0, + schema: z.object({ + code: z.string(), + service: z.string(), + expiresAt: z.string(), + }), + output: 'object', }); - if (!raw || typeof raw !== 'string') return null; - let parsed: any = null; - try { - parsed = JSON.parse(raw.trim()); - } catch { - const match = raw.match(/\{[\s\S]*\}/); - if (match) { - try { - parsed = JSON.parse(match[0]); - } catch { - return null; - } - } else { - return null; - } - } + console.log('[OTP_DETECTOR_AI] [raw]', raw); - if (!parsed || typeof parsed !== 'object' || !parsed.code) return null; - - const potentialCode: string = String(parsed.code).replace(/[-\s]/g, ''); + const potentialCode: string = String(raw.code).replace(/[-\s]/g, ''); + console.log('[OTP_DETECTOR_AI] [potentialCode]', potentialCode); if (!isValidOTPCode(potentialCode)) return null; + console.log('[OTP_DETECTOR_AI] [HERE]'); + const content = `${subject} ${body}`; const idx = content.indexOf(potentialCode); if (idx >= 0 && isCodeWithinURL(content, idx, potentialCode.length)) return null; - let service = - typeof parsed.service === 'string' && parsed.service.trim().length - ? parsed.service.trim() - : 'Unknown Service'; + console.log('[OTP_DETECTOR_AI] [HERE 2]'); + + let service = raw.service ? raw.service.trim() : 'Unknown Service'; if (service === 'Unknown Service' && fromName) { service = fromName.split(' ')[0]; } - const receivedAt = new Date(latestMessage.receivedOn); - const expiresAt = new Date(receivedAt.getTime() + 10 * 60 * 1000); + console.log('[OTP_DETECTOR_AI] [HERE 3]'); + + const expiresAt = new Date(raw.expiresAt); return { code: potentialCode, service, expiresAt }; } catch (error) { @@ -345,6 +258,8 @@ Rules: if (!raw || typeof raw !== 'string') return null; + // TODO: fix this + // eslint-disable-next-line @typescript-eslint/no-explicit-any let parsed: any = null; try { parsed = JSON.parse(raw.trim()); @@ -364,7 +279,7 @@ Rules: if (!parsed || typeof parsed !== 'object' || !parsed.url) return null; const url: string = String(parsed.url); - const urlRegex = /^https?:\/\/[\w\-._~:/?#\[\]@!$&'()*+,;=%]+$/i; + const urlRegex = /^https?:\/\/[\w\-._~:/?#[\]@!$&'()*+,;=%]+$/i; const isAsset = /\.(png|jpe?g|gif|webp|svg|css|js|ico)(\?|$)/i.test(url); if (!urlRegex.test(url) || isAsset) return null; diff --git a/apps/server/src/pipelines.effect.ts b/apps/server/src/pipelines.effect.ts index 4b1f0ea36..bf1e5e71f 100644 --- a/apps/server/src/pipelines.effect.ts +++ b/apps/server/src/pipelines.effect.ts @@ -106,15 +106,7 @@ export const getEmbeddingVector = async (text: string) => { return null; } - const embeddingResponse = await env.AI.run( - '@cf/baai/bge-large-en-v1.5', - { text: text.trim() }, - { - gateway: { - id: 'vectorize-save', - }, - }, - ); + const embeddingResponse = await env.AI.run('@cf/baai/bge-large-en-v1.5', { text: text.trim() }); const embeddingVector = (embeddingResponse as any).data?.[0]; return embeddingVector ?? null; } catch (error) {