mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-28 14:56:48 +00:00
# READ CAREFULLY THEN REMOVE Remove bullet points that are not relevant. PLEASE REFRAIN FROM USING AI TO WRITE YOUR CODE AND PR DESCRIPTION. IF YOU DO USE AI TO WRITE YOUR CODE PLEASE PROVIDE A DESCRIPTION AND REVIEW IT CAREFULLY. MAKE SURE YOU UNDERSTAND THE CODE YOU ARE SUBMITTING USING AI. - Pull requests that do not follow these guidelines will be closed without review or comment. - If you use AI to write your PR description your pr will be close without review or comment. - If you are unsure about anything, feel free to ask for clarification. ## Description Please provide a clear description of your changes. --- ## Type of Change Please delete options that are not relevant. - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) - [ ] 💥 Breaking change (fix or feature with breaking changes) - [ ] 📝 Documentation update - [ ] 🎨 UI/UX improvement - [ ] 🔒 Security enhancement - [ ] ⚡ Performance improvement ## Areas Affected Please check all that apply: - [ ] Email Integration (Gmail, IMAP, etc.) - [ ] User Interface/Experience - [ ] Authentication/Authorization - [ ] Data Storage/Management - [ ] API Endpoints - [ ] Documentation - [ ] Testing Infrastructure - [ ] Development Workflow - [ ] Deployment/Infrastructure ## Testing Done Describe the tests you've done: - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] Manual testing performed - [ ] Cross-browser testing (if UI changes) - [ ] Mobile responsiveness verified (if UI changes) ## Security Considerations For changes involving data or authentication: - [ ] No sensitive data is exposed - [ ] Authentication checks are in place - [ ] Input validation is implemented - [ ] Rate limiting is considered (if applicable) ## Checklist - [ ] I have read the [CONTRIBUTING](https://github.com/Mail-0/Zero/blob/staging/.github/CONTRIBUTING.md) document - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in complex areas - [ ] I have updated the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix/feature works - [ ] All tests pass locally - [ ] Any dependent changes are merged and published ## Additional Notes Add any other context about the pull request here. ## Screenshots/Recordings Add screenshots or recordings here if applicable. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a new localization system replacing translation hooks with direct message function calls. * Added localization project configuration and Inlang plugins for improved message formatting. * **Bug Fixes** * Enhanced pluralization handling across all supported languages for accurate message display. * **Refactor** * Unified translation and locale management across components by removing hook-based translations. * Removed obsolete navigation and email signature settings pages. * Simplified query and server provider logic, removing connection-specific and internationalization context code. * Disabled server-side rendering in the mail app. * Removed Cloudflare Worker request handler and related environment augmentations. * **Chores** * Updated dependencies and scripts, adding Inlang CLI and removing unused packages. * Cleaned up configuration files including Vite, Wrangler, and i18n settings. * Added `.gitignore` for localization cache. * Updated static asset handling and environment configurations for Cloudflare deployments. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
626 lines
18 KiB
TypeScript
626 lines
18 KiB
TypeScript
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 { 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',
|
|
} 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<string, string[]> = {
|
|
[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 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[],
|
|
quotedMessage?: string,
|
|
) => {
|
|
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 ? `<${recipientEmails}>` : ''} wrote:
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
export const constructForwardBody = (
|
|
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 `
|
|
<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 ? `<${originalSender.email}>` : ''}<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();
|
|
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 <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++;
|
|
}
|
|
}
|
|
};
|