import { format, isToday, isThisMonth, differenceInCalendarMonths } from 'date-fns'; import { getBrowserTimezone } from './timezones'; import { formatInTimeZone } from 'date-fns-tz'; import { MAX_URL_LENGTH } from './constants'; import { filterSuggestions } from './filter'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import type { JSONContent } from 'novel'; import type { Sender } from '@/types'; import LZString from 'lz-string'; export const FOLDERS = { SPAM: 'spam', INBOX: 'inbox', ARCHIVE: 'archive', BIN: 'bin', DRAFT: 'draft', SENT: 'sent', } as const; export const LABELS = { SPAM: 'SPAM', INBOX: 'INBOX', UNREAD: 'UNREAD', IMPORTANT: 'IMPORTANT', SENT: 'SENT', TRASH: 'TRASH', } as const; export const FOLDER_NAMES = [ 'inbox', 'spam', 'bin', 'unread', 'starred', 'important', 'sent', 'draft', ]; export const FOLDER_TAGS: Record = { [FOLDERS.SPAM]: [LABELS.SPAM], [FOLDERS.INBOX]: [LABELS.INBOX], [FOLDERS.ARCHIVE]: [], [FOLDERS.SENT]: [LABELS.SENT], [FOLDERS.BIN]: [LABELS.TRASH], }; export const getFolderTags = (folder: string): string[] => { return FOLDER_TAGS[folder] || []; }; export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); export const compressText = (text: string): string => { const compressed = LZString.compressToEncodedURIComponent(text); return compressed.slice(0, MAX_URL_LENGTH); }; export const decompressText = (compressed: string): string => { return LZString.decompressFromEncodedURIComponent(compressed) || ''; }; export const getCookie = (key: string): string | null => { const cookies = Object.fromEntries( document.cookie.split('; ').map((v) => v.split(/=(.*)/s).map(decodeURIComponent)), ); return cookies?.[key] ?? null; }; export const formatDate = (date: string) => { try { // Handle empty or invalid input if (!date) { return ''; } const timezone = getBrowserTimezone(); // Parse the date string to a Date object const dateObj = new Date(date); const now = new Date(); // Check if the date is valid if (isNaN(dateObj.getTime())) { console.error('Invalid date', date); return ''; } // If it's today, always show the time if (isToday(dateObj)) { return formatInTimeZone(dateObj, timezone, 'h:mm a'); } // Calculate hours difference between now and the email date const hoursDifference = (now.getTime() - dateObj.getTime()) / (1000 * 60 * 60); // If it's not today but within the last 12 hours, show the time if (hoursDifference <= 12) { return formatInTimeZone(dateObj, timezone, 'h:mm a'); } // If it's this month or last month, show the month and day if (isThisMonth(dateObj) || differenceInCalendarMonths(now, dateObj) === 1) { return formatInTimeZone(dateObj, timezone, 'MMM dd'); } // Otherwise show the date in MM/DD/YY format return formatInTimeZone(dateObj, timezone, 'MM/dd/yy'); } catch (error) { console.error('Error formatting date', error); return ''; } }; export const cleanEmailAddress = (email: string = '') => { return email.replace(/[<>]/g, '').trim(); }; export const truncateFileName = (name: string, maxLength = 15) => { if (name.length <= maxLength) return name; const extIndex = name.lastIndexOf('.'); if (extIndex !== -1 && name.length - extIndex <= 5) { return `${name.slice(0, maxLength - 5)}...${name.slice(extIndex)}`; } return `${name.slice(0, maxLength)}...`; }; export type FilterSuggestion = { filter: string; description: string; icon: React.ReactNode; prefix: string; }; export const extractFilterValue = (filter: string): string => { if (!filter || !filter.includes(':')) return ''; const colonIndex = filter.indexOf(':'); const value = filter.substring(colonIndex + 1); return value || ''; }; export const defaultPageSize = 20; export function createSectionId(title: string) { return title.toLowerCase().replace(/[^a-z0-9]+/g, '-'); } export const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; export const getFileIcon = (mimeType: string): string => { if (mimeType === 'application/pdf') return '📄'; if (mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') return '📊'; if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') return '📝'; if (mimeType.includes('image')) return ''; // Empty for images as they're handled separately return '📎'; // Default icon }; export const convertJSONToHTML = (json: any): string => { if (!json) return ''; // Handle different types if (typeof json === 'string') return json; if (typeof json === 'number' || typeof json === 'boolean') return json.toString(); if (json === null) return ''; // Handle arrays if (Array.isArray(json)) { return json.map((item) => convertJSONToHTML(item)).join(''); } // Handle objects (assuming they might have specific email content structure) if (typeof json === 'object') { // Check if it's a text node if (json.type === 'text') { let text = json.text || ''; // Apply formatting if present if (json.bold) text = `${text}`; if (json.italic) text = `${text}`; if (json.underline) text = `${text}`; if (json.code) text = `${text}`; return text; } // Handle paragraph if (json.type === 'paragraph') { return `

${convertJSONToHTML(json.children)}

`; } // Handle headings if (json.type?.startsWith('heading-')) { const level = json.type.split('-')[1]; return `${convertJSONToHTML(json.children)}`; } // Handle lists if (json.type === 'bulleted-list') { return ``; } if (json.type === 'numbered-list') { return `
    ${convertJSONToHTML(json.children)}
`; } if (json.type === 'list-item') { return `
  • ${convertJSONToHTML(json.children)}
  • `; } // Handle links if (json.type === 'link') { return `${convertJSONToHTML(json.children)}`; } // Handle images if (json.type === 'image') { return `${json.alt || ''}`; } // Handle blockquote if (json.type === 'block-quote') { return `
    ${convertJSONToHTML(json.children)}
    `; } // Handle code blocks if (json.type === 'code-block') { return `
    ${convertJSONToHTML(json.children)}
    `; } // If it has children property, process it if (json.children) { return convertJSONToHTML(json.children); } // Process all other properties return Object.values(json) .map((value) => convertJSONToHTML(value)) .join(''); } return ''; }; export const createAIJsonContent = (text: string): JSONContent => { // Try to identify common sign-off patterns with a more comprehensive regex const signOffPatterns = [ /\b((?:Best regards|Regards|Sincerely|Thanks|Thank you|Cheers|Best|All the best|Yours truly|Yours sincerely|Kind regards|Cordially)(?:,)?)\s*\n+\s*([A-Za-z][A-Za-z\s.]*)$/i, ]; let mainContent = text; let signatureLines: string[] = []; // Extract sign-off if found for (const pattern of signOffPatterns) { const match = text.match(pattern); if (match) { // Find the index where the sign-off starts const signOffIndex = text.lastIndexOf(match[0]); if (signOffIndex > 0) { // Split the content mainContent = text.substring(0, signOffIndex).trim(); // Split the signature part into separate lines const signature = text.substring(signOffIndex).trim(); signatureLines = signature .split(/\n+/) .map((line) => line.trim()) .filter(Boolean); break; } } } // If no signature was found with regex but there are newlines at the end, // check if the last lines could be a signature if (signatureLines.length === 0) { const allLines = text.split(/\n+/); if (allLines.length > 1) { // Check if last 1-3 lines might be a signature (short lines at the end) const potentialSigLines = allLines .slice(-3) .filter( (line) => line.trim().length < 60 && !line.trim().endsWith('?') && !line.trim().endsWith('.'), ); if (potentialSigLines.length > 0) { signatureLines = potentialSigLines; mainContent = allLines .slice(0, allLines.length - potentialSigLines.length) .join('\n') .trim(); } } } // Split the main content into paragraphs const paragraphs = mainContent .split(/\n\s*\n/) .map((p) => p.trim()) .filter(Boolean); if (paragraphs.length === 0 && signatureLines.length === 0) { // If no paragraphs and no signature were found, treat the whole text as one paragraph paragraphs.push(text); } // Create a content array with appropriate spacing between paragraphs const content = []; paragraphs.forEach((paragraph, index) => { // Add the content paragraph content.push({ type: 'paragraph', content: [{ type: 'text', text: paragraph }], }); // Add an empty paragraph between main paragraphs if (index < paragraphs.length - 1) { content.push({ type: 'paragraph', }); } }); // If we found a signature, add it with proper spacing if (signatureLines.length > 0) { // Add spacing before the signature if there was content if (paragraphs.length > 0) { content.push({ type: 'paragraph', }); } // Add each line of the signature as a separate paragraph signatureLines.forEach((line) => { content.push({ type: 'paragraph', content: [{ type: 'text', text: line }], }); }); } return { type: 'doc', content: content, }; }; export const getEmailLogo = (email: string) => { if (!process.env.NEXT_PUBLIC_IMAGE_API_URL) return ''; return process.env.NEXT_PUBLIC_IMAGE_API_URL + email; }; export const generateConversationId = (): string => { return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; }; export const contentToHTML = (content: string) => ` ${content} `; export const constructReplyBody = ( formattedMessage: string, originalDate: string, originalSender: Sender | undefined, otherRecipients: Sender[], quotedMessage?: string, ) => { const senderName = originalSender?.name || originalSender?.email || 'Unknown Sender'; const recipientEmails = otherRecipients.map((r) => r.email).join(', '); return `
    ${formattedMessage}
    On ${originalDate}, ${senderName} ${recipientEmails ? `<${recipientEmails}>` : ''} wrote:
    ${quotedMessage || ''}
    `; }; export const getMainSearchTerm = (searchQuery: string): string => { // Don't highlight terms if this is a date-based search const datePatterns = [ /emails?\s+from\s+(\w+)\s+(\d{4})/i, // "emails from [month] [year]" /emails?\s+from\s+(\w+)/i, // "emails from [month]" /emails?\s+from\s+(\d{4})/i, // "emails from [year]" /emails?\s+from\s+last\s+(\w+)/i, // "emails from last [time period]" /emails?\s+from\s+(\d+)\s+(\w+)\s+ago/i, // "emails from [X] [time period] ago" ]; // If it's a date-based search, don't highlight anything for (const pattern of datePatterns) { if (searchQuery.match(pattern)) { return ''; } } // Handle other natural language queries const naturalLanguageMatches = { 'emails from': /emails?\s+from\s+(\w+)/i, 'mail from': /mail\s+from\s+(\w+)/i, from: /\bfrom\s+(\w+)/i, to: /\bto\s+(\w+)/i, about: /\babout\s+(\w+)/i, regarding: /\bregarding\s+(\w+)/i, }; // Try to match natural language patterns for (const [, pattern] of Object.entries(naturalLanguageMatches)) { const match = searchQuery.match(pattern); if (match && match[1]) { return match[1]; } } // If no natural language match, remove search operators and date-related terms const cleanedQuery = searchQuery .replace(/\b(from|to|subject|has|in|after|before):\s*/gi, '') .replace(/\b(is|has):\s*/gi, '') .replace( /\b(january|february|march|april|may|june|july|august|september|october|november|december)\b/gi, '', ) .replace(/\b\d{4}\b/g, '') // Remove 4-digit years .replace(/["']/g, '') .trim(); // Split by spaces and get the first meaningful term const terms = cleanedQuery.split(/\s+/); return terms[0] || ''; }; export function parseNaturalLanguageSearch(query: string): string { // Common search patterns const patterns = [ // From pattern { regex: /^from\s+([^:\s]+)/i, transform: (match: string[]) => `from:${match[1]}`, }, // To pattern { regex: /^to\s+([^:\s]+)/i, transform: (match: string[]) => `to:${match[1]}`, }, // Subject pattern { regex: /^subject\s+([^:\s]+)/i, transform: (match: string[]) => `subject:${match[1]}`, }, // Has attachment pattern { regex: /^has\s+(attachment|file)/i, transform: () => 'has:attachment', }, // Is pattern (unread, read, starred) { regex: /^is\s+(unread|read|starred)/i, transform: (match: string[]) => `is:${match[1]}`, }, ]; // Check if query matches any pattern for (const pattern of patterns) { const match = query.match(pattern.regex); if (match) { return pattern.transform(match); } } return query; } export function parseNaturalLanguageDate(query: string): { from?: Date; to?: Date } | null { const now = new Date(); const currentYear = now.getFullYear(); const currentMonth = now.getMonth(); // Common date patterns const patterns = [ // "emails from [month] [year]" { regex: /(?:emails?|mail)\s+from\s+(\w+)\s+(\d{4})/i, transform: (match: string[]) => { const monthNames = [ 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december', ]; const monthIndex = monthNames.findIndex((m) => m.toLowerCase().startsWith(match[1]?.toLowerCase() ?? ''), ); if (monthIndex === -1) return null; const year = parseInt(match[2] ?? currentYear.toString()); const from = new Date(year, monthIndex, 1); const to = new Date(year, monthIndex + 1, 0); // Last day of the month return { from, to }; }, }, // "emails from [month]" (assumes current year) { regex: /(?:emails?|mail)\s+from\s+(\w+)/i, transform: (match: string[]) => { const monthNames = [ 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december', ]; const monthIndex = monthNames.findIndex((m) => m.toLowerCase().startsWith(match[1]?.toLowerCase() ?? ''), ); if (monthIndex === -1) return null; const from = new Date(currentYear, monthIndex, 1); const to = new Date(currentYear, monthIndex + 1, 0); // Last day of the month return { from, to }; }, }, ]; // Check if query matches any pattern for (const pattern of patterns) { const match = query.match(pattern.regex); if (match) { const result = pattern.transform(match); if (result) { return result; } } } return null; } export const categorySearchValues = [ 'is:important NOT is:sent NOT is:draft', 'NOT is:draft (is:inbox OR (is:sent AND to:me))', 'is:personal NOT is:sent NOT is:draft', 'is:updates NOT is:sent NOT is:draft', 'is:promotions NOT is:sent NOT is:draft', 'is:unread NOT is:sent NOT is:draft', ]; export const cleanSearchValue = (q: string): string => { const escapedValues = categorySearchValues.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ); return q .replace(new RegExp(escapedValues.join('|'), 'g'), '') .replace(/\s+/g, ' ') .trim(); }; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const withExponentialBackoff = async ( operation: () => Promise, maxRetries = 3, initialDelay = 1000, maxDelay = 10000, ): Promise => { let retries = 0; let delayMs = initialDelay; while (true) { try { return await operation(); } catch (error: any) { if (retries >= maxRetries) { throw error; } const isRateLimit = error?.code === 429 || error?.errors?.[0]?.reason === 'rateLimitExceeded' || error?.errors?.[0]?.reason === 'userRateLimitExceeded'; if (!isRateLimit) { throw error; } await delay(delayMs); delayMs = Math.min(delayMs * 2 + Math.random() * 1000, maxDelay); retries++; } } };