Files
Zero/apps/mail/lib/utils.ts
Adam 5723c627ee Remove thread ID tracking and filter suggestion files (#1895)
# 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 description by cubic. -->
---

## Summary by cubic
Removed thread ID tracking from the agent and deleted unused filter suggestion files to simplify code and reduce complexity.

- **Refactors**
  - Removed all code related to thread ID updates and tracking in both client and server.
  - Deleted filter suggestion utilities and types from mail and server libs.

<!-- End of auto-generated description by cubic. -->



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Enhanced inbox search tool to allow specifying the folder and number of results when searching email threads.

* **Bug Fixes**
  * Improved handling of email thread listing to ensure correct retrieval based on search queries and folder selection.

* **Refactor**
  * Removed deprecated email filter suggestion logic and related utilities from both client and server.
  * Cleaned up unused message types and socket communication related to thread ID updates.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-08-03 13:41:08 -07:00

620 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 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++;
}
}
};