Files
Zero/apps/mail/lib/utils.ts
amrit 4c3753e3f8 feat: ability to snooze emails (#1477)
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
Co-authored-by: Adam <13007539+MrgSub@users.noreply.github.com>
2025-07-20 18:25:32 -07:00

627 lines
18 KiB
TypeScript

import { isToday, isThisMonth, differenceInCalendarMonths } from 'date-fns';
import { getBrowserTimezone } from './timezones';
import { formatInTimeZone } from 'date-fns-tz';
import { MAX_URL_LENGTH } from './constants';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
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',
SNOOZED: 'snoozed',
} as const;
export const LABELS = {
SPAM: 'SPAM',
INBOX: 'INBOX',
UNREAD: 'UNREAD',
IMPORTANT: 'IMPORTANT',
SENT: 'SENT',
TRASH: 'TRASH',
SNOOZED: 'SNOOZED',
} as const;
export const FOLDER_NAMES = [
'inbox',
'spam',
'bin',
'unread',
'starred',
'important',
'sent',
'draft',
'snoozed',
];
export const FOLDER_TAGS: Record<string, string[]> = {
[FOLDERS.SPAM]: [LABELS.SPAM],
[FOLDERS.INBOX]: [LABELS.INBOX],
[FOLDERS.ARCHIVE]: [],
[FOLDERS.SENT]: [LABELS.SENT],
[FOLDERS.BIN]: [LABELS.TRASH],
[FOLDERS.SNOOZED]: [LABELS.SNOOZED],
};
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 parseAndValidateDate = (dateString: string): Date | null => {
try {
// Handle empty input
if (!dateString) {
return null;
}
// Parse the date string to a Date object
const dateObj = new Date(dateString);
// Check if the date is valid
if (isNaN(dateObj.getTime())) {
console.error('Invalid date', dateString);
return null;
}
return dateObj;
} catch (error) {
console.error('Error parsing date', error);
return null;
}
};
/**
* Helper function to determine if a separate time display is needed
* Returns false for emails from today or within last 12 hours since formatDate already shows time for these
*/
export const shouldShowSeparateTime = (dateString: string | undefined): boolean => {
if (!dateString) return false;
const dateObj = parseAndValidateDate(dateString);
if (!dateObj) return false;
const now = new Date();
// Don't show separate time if email is from today
if (isToday(dateObj)) return false;
// Don't show separate time if email is within the last 12 hours
const hoursDifference = (now.getTime() - dateObj.getTime()) / (1000 * 60 * 60);
if (hoursDifference <= 12) return false;
// Show separate time for older emails
return true;
};
/**
* Formats a date with different formatting logic based on parameters
* Overloaded to handle both mail date formatting and notes date formatting
*/
export function formatDate(dateInput: string | Date | number): string {
if (typeof dateInput === 'number') {
dateInput = new Date(dateInput).toISOString();
}
// Notes formatting logic (when date is a Date object)
if (dateInput instanceof Date) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : (dateInput as Date);
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// Original mail formatting logic
const dateObj = parseAndValidateDate(dateInput as string);
if (!dateObj) {
return '';
}
try {
const timezone = getBrowserTimezone();
const now = new Date();
// 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 formatTime = (date: string) => {
const dateObj = parseAndValidateDate(date);
if (!dateObj) {
return '';
}
try {
const timezone = getBrowserTimezone();
// Always return the time in h:mm a format
return formatInTimeZone(dateObj, timezone, 'h:mm a');
} catch (error) {
console.error('Error formatting time', 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 = `<strong>${text}</strong>`;
if (json.italic) text = `<em>${text}</em>`;
if (json.underline) text = `<u>${text}</u>`;
if (json.code) text = `<code>${text}</code>`;
return text;
}
// Handle paragraph
if (json.type === 'paragraph') {
return `<p>${convertJSONToHTML(json.children)}</p>`;
}
// Handle headings
if (json.type?.startsWith('heading-')) {
const level = json.type.split('-')[1];
return `<h${level}>${convertJSONToHTML(json.children)}</h${level}>`;
}
// Handle lists
if (json.type === 'bulleted-list') {
return `<ul>${convertJSONToHTML(json.children)}</ul>`;
}
if (json.type === 'numbered-list') {
return `<ol>${convertJSONToHTML(json.children)}</ol>`;
}
if (json.type === 'list-item') {
return `<li>${convertJSONToHTML(json.children)}</li>`;
}
// Handle links
if (json.type === 'link') {
return `<a href="${json.url}">${convertJSONToHTML(json.children)}</a>`;
}
// Handle images
if (json.type === 'image') {
return `<img src="${json.url}" alt="${json.alt || ''}" />`;
}
// Handle blockquote
if (json.type === 'block-quote') {
return `<blockquote>${convertJSONToHTML(json.children)}</blockquote>`;
}
// Handle code blocks
if (json.type === 'code-block') {
return `<pre><code>${convertJSONToHTML(json.children)}</code></pre>`;
}
// 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 getEmailLogo = (email: string) => {
if (!import.meta.env.VITE_PUBLIC_IMAGE_API_URL) return '';
return import.meta.env.VITE_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) => `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body style="margin: 0; padding: 0;">
${content}
</body></html>`;
export const constructReplyBody = (
formattedMessage: string,
originalDate: string,
originalSender: Sender | undefined,
otherRecipients: Sender[],
) => {
const senderName = originalSender?.name || originalSender?.email || 'Unknown Sender';
const recipientEmails = otherRecipients.map((r) => r.email).join(', ');
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<div style="">
${formattedMessage}
</div>
<div style="padding-left: 16px; border-left: 3px solid #e2e8f0; color: #64748b;">
<div style="font-size: 12px;">
On ${originalDate}, ${senderName} ${recipientEmails ? `&lt;${recipientEmails}&gt;` : ''} wrote:
</div>
</div>
</div>
`;
};
export const constructForwardBody = (
formattedMessage: string,
originalDate: string,
originalSender: Sender | undefined,
otherRecipients: Sender[],
) => {
const senderName = originalSender?.name || originalSender?.email || 'Unknown Sender';
const recipientEmails = otherRecipients.map((r) => r.email).join(', ');
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<div style="">
${formattedMessage}
</div>
<div style="margin-top: 20px; border-top: 1px solid #e2e8f0; padding-top: 20px;">
<div style="font-size: 12px; color: #64748b; margin-bottom: 10px;">
---------- Forwarded message ----------<br/>
From: ${senderName} ${originalSender?.email ? `&lt;${originalSender.email}&gt;` : ''}<br/>
Date: ${originalDate}<br/>
Subject: ${originalSender?.subject || 'No Subject'}<br/>
To: ${recipientEmails || 'No Recipients'}
</div>
</div>
</div>
`;
};
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();
// 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 <T>(
operation: () => Promise<T>,
maxRetries = 3,
initialDelay = 1000,
maxDelay = 10000,
): Promise<T> => {
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++;
}
}
};