diff --git a/README.md b/README.md index 3f61dec..9aae9d1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,15 @@ A modern, high-performance webmail application built with the latest React ecosy > 💬 **Join our community on Discord:** [discord.gg/29zTxvque](https://discord.gg/29zTxvque) — ask questions, report bugs, share ideas, and get involved. +## 🔗 Related Projects + +| Project | Description | +| --- | --- | +| [Docs](https://webmail.yukthi.com/) | Documentation site | +| [WebMail-API](https://github.com/Yukthi-Systems/WebMail-API) | Backend API | +| [WebMail-BIMI-API](https://github.com/Yukthi-Systems/WebMail-BIMI-API) | BIMI API | +| [WebMail-RMQ-Worker](https://github.com/Yukthi-Systems/WebMail-RMQ-Worker) | RabbitMQ worker | + ## 📸 Screenshots

diff --git a/lint.md b/lint.md new file mode 100644 index 0000000..dd2d888 --- /dev/null +++ b/lint.md @@ -0,0 +1,81 @@ +\E:\Apps\yukthi\Webmail-ui-OSS\src\components\common\MinimizedModalContext.tsx + 82:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\common\TourTips.tsx + 28:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\common\header\index.tsx + 209:6 warning React Hook useEffect has a missing dependency: 'userSettings.ui'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\common\header\search\index.tsx + 242:6 warning React Hook useEffect has missing dependencies: 'PER_PAGE', 'filters', 'query', and 'setSearchState'. Either include them or remove the dependency array react-hooks/exhaustive-deps + 251:6 warning React Hook useEffect has a missing dependency: 'initialFilters'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 343:6 warning React Hook useEffect has a missing dependency: 'initialFilters'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 348:6 warning React Hook useEffect has a missing dependency: 'handleClearAll'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\composer\AttachmentUploader.tsx + 48:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + 64:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\composer\FolderSelectField.tsx + 121:6 warning React Hook useMemo has a missing dependency: 'renderDropdownItems'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\composer\index.tsx + 56:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + 200:6 warning React Hook useEffect has a missing dependency: 'messageId'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 282:6 warning React Hook useEffect has missing dependencies: 'setComposerData' and 'setDraftMessageId'. Either include them or remove the dependency array react-hooks/exhaustive-deps + 313:5 warning React Hook useCallback has a missing dependency: 'setDraftMessageId'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 462:6 warning React Hook useCallback has missing dependencies: 'saveDraft' and 'setDraftMessageId'. Either include them or remove the dependency array react-hooks/exhaustive-deps + 674:5 warning React Hook useCallback has missing dependencies: 'saveDraft', 'undoTime', and 'userSettings?.general?.reply_to'. Either include them or remove the dependency array react-hooks/exhaustive-deps + 759:6 warning React Hook useCallback has a missing dependency: 'toast'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 819:6 warning React Hook useCallback has missing dependencies: 'closeComposer' and 'saveDraft'. Either include them or remove the dependency array react-hooks/exhaustive-deps + 845:6 warning React Hook useCallback has a missing dependency: 'closeComposer'. Either include it or remove the dependency array react-hooks/exhaustive-deps + 955:6 warning React Hook useEffect has a missing dependency: 'setComposerData'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\mailbox\ComposeButton.tsx + 23:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\mailbox\CustomFolder.tsx + 49:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\mailbox\EmailAttachments.tsx + 584:6 warning React Hook useEffect has a missing dependency: 'blobUrls'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\mailbox\EmailComposer.tsx + 544:6 warning React Hook useEffect has missing dependencies: 'email', 'isInitialized', 'resetComposerData', and 'setComposerData'. Either include them or remove the dependency array react-hooks/exhaustive-deps + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\mailbox\EmailList.tsx + 358:6 warning React Hook useEffect has missing dependencies: 'currentFolderDetail', 'folder', 'folderDetails', 'queryClient', and 'setFolderDetails'. Either include them or remove the dependency array + react-hooks/exhaustive-deps + 514:6 warning React Hook useEffect has a missing dependency: 'isMobile'. Either include it or remove the dependency array + react-hooks/exhaustive-deps + 604:6 warning React Hook useMemo has missing dependencies: 'folder', 'isFolderThread', 'searchState.isActive', 'simpleEmailArray', and 'threadedView'. Either include them or remove the dependency array + react-hooks/exhaustive-deps + 611:6 warning React Hook useEffect has a missing dependency: 'setViewingEmailFlag'. Either include it or remove the dependency array + react-hooks/exhaustive-deps + 776:6 warning React Hook useEffect has a missing dependency: 'toast'. Either include it or remove the dependency array + react-hooks/exhaustive-deps + 782:6 warning React Hook useEffect has a missing dependency: 'handleBackToList'. Either include it or remove the dependency array + react-hooks/exhaustive-deps + 782:48 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked + react-hooks/exhaustive-deps + 795:6 warning React Hook useEffect has a missing dependency: 'isMobile'. Either include it or remove the dependency array + react-hooks/exhaustive-deps + 815:6 warning React Hook useEffect has missing dependencies: 'folder', 'isMobile', and 'layout'. Either include them or remove the dependency array + react-hooks/exhaustive-deps + 1532:6 warning React Hook useEffect has a missing dependency: 'handleBackToList'. Either include it or remove the dependency array + react-hooks/exhaustive-deps + 1743:5 warning React Hook useCallback has missing dependencies: 'handleBackToList', 'handleEditAsNew', 'handleForward', 'handleForwardAsAttachment', 'handleReply', 'handleReplyAll', 'handleSaveAsContactAction', 'handleSendDraft', 'handleSingleEmailDelete', 'handleSingleEmailMarkAsFlagged', 'handleSingleEmailMarkAsRead', and 'layout'. Either include them or remove the dependency array react-hooks/exhaustive-deps + 1781:6 warning React Hook useEffect has a missing dependency: 'handleRefresh'. Either include it or remove the dependency array + react-hooks/exhaustive-deps + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\mailbox\MoveEmail.tsx + 87:6 warning React Hook React.useMemo has a missing dependency: 'transformFolderNode'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +E:\Apps\yukthi\Webmail-ui-OSS\src\components\ui\ToastComponent.tsx + 62:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components + +E:\Apps\yukthi\Webmail-ui-OSS\src\routes\$slug.tsx + 75:6 warning React Hook useEffect has missing dependencies: 'navigate' and 'slug'. Either include them or remove the dependency array react-hooks/exhaustive-deps + +✖ 39 problems (0 errors, 39 warnings) \ No newline at end of file diff --git a/src/api/admin-domain.ts b/src/api/admin-domain.ts index 49f68c1..b92fcb0 100644 --- a/src/api/admin-domain.ts +++ b/src/api/admin-domain.ts @@ -16,7 +16,7 @@ */ import { webmailStore } from '../store.ts'; -import { apiKeyAtom, csrfTokenAtom } from '../state/auth'; +import { apiKeyAtom } from '../state/auth'; import { API_URL } from './config.ts'; export type Domain = { @@ -57,7 +57,21 @@ const getHeaders = () => { }; }; -export const getDomainList = async (page = 1, size = 20, query?: string): Promise => { +export interface DomainListResponse { + data: Domain[]; + total_count: number; + current_page?: number; + total_pages?: number; + has_next?: boolean; + has_previous?: boolean; + page_size?: number; +} + +export const getDomainList = async ( + page = 1, + size = 20, + query?: string +): Promise => { const url = new URL(`${API_URL}/user/admin/domains`); url.searchParams.set('page', page.toString()); url.searchParams.set('size', size.toString()); diff --git a/src/api/auth.ts b/src/api/auth.ts index fd8c760..3952957 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -29,6 +29,10 @@ export type LoginResponse = { isVersionTwoUser: boolean; }; +export class LoginError extends Error { + status?: number; +} + export const login = async (credentials: { email: string; domain: string; @@ -45,7 +49,7 @@ export const login = async (credentials: { if (!res.ok) { const error = await res.json(); - const err = new Error(error.message || 'Login failed') as any; + const err = new LoginError(error.message || 'Login failed'); err.status = res.status; throw err; } diff --git a/src/api/config.ts b/src/api/config.ts index b067f8d..bd4ba73 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -18,7 +18,7 @@ // TODO: Update the api url from the environment variable or configuration file import { API_CONFIG } from '../constants/config'; -import { resetLayoutCache } from '../routes/_baselayout'; +import { resetLayoutCache } from '../utils/resetLayoutCache'; import { getCompanySlugFromPath } from '../utils/routeUtils'; export const API_URL = API_CONFIG.baseURL; diff --git a/src/api/contacts.ts b/src/api/contacts.ts index 2779e7a..86a3ad4 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -119,6 +119,12 @@ export const deleteContact = async (contact_id: string | number) => { return await res.json(); }; +export class BulkContactError extends Error { + status?: number; + data?: unknown; + details?: unknown; +} + export const createBulkContact = async (contactData: CreateContactData[]) => { const csrfToken = webmailStore.get(csrfTokenAtom); @@ -137,10 +143,10 @@ export const createBulkContact = async (contactData: CreateContactData[]) => { if (!res.ok) { // Changed from res.status !== 201 const errorMessage = data.message || data.error || 'Unable to create contacts'; - const error = new Error(errorMessage); - (error as any).status = res.status; - (error as any).data = data; - (error as any).details = data.details; + const error = new BulkContactError(errorMessage); + error.status = res.status; + error.data = data; + error.details = data.details; console.error('Throwing error:', error); throw error; diff --git a/src/api/fetchWrapper.ts b/src/api/fetchWrapper.ts index c054313..82b5794 100644 --- a/src/api/fetchWrapper.ts +++ b/src/api/fetchWrapper.ts @@ -47,7 +47,12 @@ export const fetchWithAuth = async (url: string, options: RequestInit = {}) => { return res; }; -export const fetchListWithAuth = async (url: string, options: any) => { +export const fetchListWithAuth = async ( + url: string, + // `timeout` isn't a real fetch() option — browsers silently ignore it — but a + // caller passes it, so it's kept in the type to avoid changing behavior here. + options: RequestInit & { timeout?: number } = {} +) => { const res = await fetch(url, { credentials: 'include', ...options, diff --git a/src/api/mailbox.ts b/src/api/mailbox.ts index 7ba5324..2881cf3 100644 --- a/src/api/mailbox.ts +++ b/src/api/mailbox.ts @@ -19,6 +19,8 @@ import { csrfTokenAtom } from '../state/auth'; import { webmailStore } from '../store.ts'; import { API_URL } from './config.ts'; import { fetchListWithAuth, fetchWithAuth } from './fetchWrapper.ts'; +import type { FolderQuota } from '../state/folders.ts'; +import type { EmailLike } from '../utils/emailThreading.ts'; export type CustomFolders = string[]; @@ -176,7 +178,7 @@ export const foldersFullPath = async (): Promise => { return body; }; -export const foldersQuota = async (path: string): Promise => { +export const foldersQuota = async (): Promise<{ quota: FolderQuota }> => { const csrfToken = webmailStore.get(csrfTokenAtom); const res = await fetchWithAuth(`${API_URL}/folder/quota?folder_path=INBOX`, { method: 'GET', @@ -202,7 +204,7 @@ export const emails = async ( full_headers: boolean = true ): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedFolder = sanitizeFolderPath(folder); + const sanitizedFolder = sanitizeFolderPath(folder); const params = new URLSearchParams(); params.append('folder_path', sanitizedFolder); params.append('full_headers', full_headers.toString()); @@ -229,7 +231,7 @@ export const emailRaw = async ( ): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); const params = new URLSearchParams(); - let sanitizedFolder = sanitizeFolderPath(folderPath); + const sanitizedFolder = sanitizeFolderPath(folderPath); params.append('folder_path', sanitizedFolder); params.append('mark_as_read', String(markAsRead)); @@ -257,7 +259,7 @@ export const emailFetchByIds = async ({ }: { folderPath: string; messageIds: string[]; -}): Promise => { +}): Promise<{ emails: EmailLike[] }> => { if (!folderPath || messageIds.length === 0) { throw new Error('folderPath and messageIds are required'); } @@ -295,8 +297,8 @@ export const moveEmail = async ( body: number[] ): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedSource = sanitizeFolderPath(sourceFolder); - let sanitizedDest = sanitizeFolderPath(destFolder); + const sanitizedSource = sanitizeFolderPath(sourceFolder); + const sanitizedDest = sanitizeFolderPath(destFolder); const res = await fetchWithAuth( `${API_URL}/email/move?folder_path=${path}&source_folder=${sanitizedSource}&dest_folder=${sanitizedDest}`, { @@ -368,7 +370,7 @@ export const deleteEmail = async (path: string, body: number[]): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedFolder = sanitizeFolderPath(path); + const sanitizedFolder = sanitizeFolderPath(path); const res = await fetchWithAuth(`${API_URL}/email/mark/read?folder_path=${sanitizedFolder}`, { method: 'PUT', credentials: 'include', @@ -389,7 +391,7 @@ export const markReadEmail = async (path: string, body: number[]): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedFolder = sanitizeFolderPath(path); + const sanitizedFolder = sanitizeFolderPath(path); const res = await fetchWithAuth(`${API_URL}/email/mark/unseen?folder_path=${sanitizedFolder}`, { method: 'PATCH', credentials: 'include', @@ -410,7 +412,7 @@ export const unmarkReadEmail = async (path: string, body: number[]): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedFolder = sanitizeFolderPath(path); + const sanitizedFolder = sanitizeFolderPath(path); const res = await fetchWithAuth(`${API_URL}/email/mark/flagged?folder_path=${sanitizedFolder}`, { method: 'PUT', credentials: 'include', @@ -431,7 +433,7 @@ export const markFlaggedEmail = async (path: string, body: number[]): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedFolder = sanitizeFolderPath(path); + const sanitizedFolder = sanitizeFolderPath(path); const res = await fetchWithAuth( `${API_URL}/email/mark/unflagged?folder_path=${sanitizedFolder}`, { @@ -477,7 +479,7 @@ export const createEmailFolder = async (path: string): Promise => export const deleteEmailFolder = async (path: string): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedFolder = sanitizeFolderPath(path); + const sanitizedFolder = sanitizeFolderPath(path); const res = await fetchWithAuth(`${API_URL}/folder/path?folder_path=${sanitizedFolder}`, { method: 'DELETE', credentials: 'include', @@ -497,8 +499,8 @@ export const deleteEmailFolder = async (path: string): Promise => export const editEmailFolder = async (oldpath: string, newpath: string): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedOldFolder = sanitizeFolderPath(oldpath); - let sanitizedNewFolder = sanitizeFolderPath(newpath); + const sanitizedOldFolder = sanitizeFolderPath(oldpath); + const sanitizedNewFolder = sanitizeFolderPath(newpath); const res = await fetchWithAuth( `${API_URL}/folder/path?old_folder_path=${sanitizedOldFolder}&new_folder_path=${sanitizedNewFolder}`, { @@ -527,7 +529,6 @@ export const folderUidValidity = async ( message: string; }> => { const csrfToken = webmailStore.get(csrfTokenAtom); - let sanitizedFolder = sanitizeFolderPath(folderPath); const res = await fetchWithAuth(`${API_URL}/folder/uid-validity?folder_path=${folderPath}`, { method: 'GET', credentials: 'include', diff --git a/src/api/search.ts b/src/api/search.ts index d973b3f..2d13bd4 100644 --- a/src/api/search.ts +++ b/src/api/search.ts @@ -21,6 +21,7 @@ import { webmailStore } from '../store'; import { API_URL, authCheck } from './config'; import { fetchWithAuth } from './fetchWrapper'; import { sanitizeFolderPath } from './mailbox'; +import type { SimplifiedEmail } from '../utils/email'; // export interface SearchRequest { // folder?: string; @@ -75,7 +76,17 @@ export interface SearchResponse { has_more: boolean; } -export const searchEmails = async (searchData: SearchRequest): Promise => { +// The real response shape (confirmed by every current consumer's `.data.data` / +// `.data.total_count` access) — SearchResponse above doesn't match what the +// endpoint actually returns and appears to be stale/aspirational. +export interface SearchApiResponse { + data: { + data: SimplifiedEmail[]; + total_count: number; + }; +} + +export const searchEmails = async (searchData: SearchRequest): Promise => { const csrfToken = webmailStore.get(csrfTokenAtom); const res = await fetchWithAuth(`${API_URL}/email/search/all`, { diff --git a/src/api/sieve.ts b/src/api/sieve.ts index e939e3a..0316b15 100644 --- a/src/api/sieve.ts +++ b/src/api/sieve.ts @@ -30,9 +30,11 @@ export interface ListScriptsApiResponse { scripts: SieveScriptList; } -// API uses tuple arrays, not objects -export type SieveFilterCondition = any[]; // e.g., ["Subject", ":matches", "Junk*"] -export type SieveFilterAction = any[]; // e.g., ["fileinto", "Junks"] +// API uses tuple arrays, not objects. Element types vary per condition/action +// kind (strings for field/operator/value, but e.g. size comparisons can carry +// numbers), so these stay loosely typed rather than guessing a fixed tuple shape. +export type SieveFilterCondition = unknown[]; // e.g., ["Subject", ":matches", "Junk*"] +export type SieveFilterAction = unknown[]; // e.g., ["fileinto", "Junks"] export interface SieveFilter { name: string; @@ -90,7 +92,7 @@ export interface UpdateFilterRequest { export interface SieveApiResponse { success: boolean; message?: string; - data?: any; + data?: unknown; } // List all scripts diff --git a/src/components/admin/domain/BrandingManager.tsx b/src/components/admin/domain/BrandingManager.tsx index 69710b9..846ed4b 100644 --- a/src/components/admin/domain/BrandingManager.tsx +++ b/src/components/admin/domain/BrandingManager.tsx @@ -16,10 +16,8 @@ */ import { useState, useRef } from 'react'; -import { useAtomValue } from 'jotai'; import { FiUpload, FiX, FiSearch, FiSave, FiEye } from 'react-icons/fi'; -import { apiKeyAtom } from '../../../state/auth'; -import { useToast } from '../../ui/ToastComponent'; +import { useToast } from '../../../hooks/useToast'; import { API_CONFIG } from '../../../constants/config'; import { BgImageService } from '../../../utils/bimiService'; @@ -109,7 +107,6 @@ function ImageUpload({ } export default function BrandingManager() { - const apiKey = useAtomValue(apiKeyAtom); const toast = useToast(); const [slug, setSlug] = useState(''); diff --git a/src/components/admin/domain/BulkEdit.tsx b/src/components/admin/domain/BulkEdit.tsx index d80efb1..700f242 100644 --- a/src/components/admin/domain/BulkEdit.tsx +++ b/src/components/admin/domain/BulkEdit.tsx @@ -25,7 +25,6 @@ import { FiAlertCircle, FiCheckCircle, FiDownload, - FiLoader, FiInfo, FiChevronDown, FiChevronUp, @@ -166,8 +165,9 @@ function parseFile(file: File): Promise<{ rows: ParsedRow[]; missingHeaders: str const rows = normalised.map((row, i) => validateRow(row, i + 2)); resolve({ rows, missingHeaders: [] }); - } catch (err: any) { - reject(new Error(`Failed to parse file: ${err.message}`)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + reject(new Error(`Failed to parse file: ${message}`)); } }; @@ -280,8 +280,8 @@ export default function DomainBulkEditModal({ isOpen, onClose }: Props) { } setRows(parsed); setPhase('parsed'); - } catch (err: any) { - setParseError(err.message); + } catch (err) { + setParseError(err instanceof Error ? err.message : String(err)); } }, []); @@ -333,7 +333,7 @@ export default function DomainBulkEditModal({ isOpen, onClose }: Props) { successes++; resolve(); }, - onError: (err: any) => { + onError: (err) => { errors.push({ domain: row.domain, rowIndex: row._rowIndex, diff --git a/src/components/admin/domain/BulkImport.tsx b/src/components/admin/domain/BulkImport.tsx index 4962dda..cb19a55 100644 --- a/src/components/admin/domain/BulkImport.tsx +++ b/src/components/admin/domain/BulkImport.tsx @@ -25,7 +25,6 @@ import { FiAlertCircle, FiCheckCircle, FiDownload, - FiLoader, FiInfo, FiChevronDown, FiChevronUp, @@ -166,8 +165,9 @@ function parseFile(file: File): Promise<{ rows: ParsedRow[]; missingHeaders: str const rows = normalised.map((row, i) => validateRow(row, i + 2)); // +2 for header + 1-based resolve({ rows, missingHeaders: [] }); - } catch (err: any) { - reject(new Error(`Failed to parse file: ${err.message}`)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + reject(new Error(`Failed to parse file: ${message}`)); } }; @@ -227,23 +227,6 @@ function ProgressRing({ pct }: { pct: number }) { ); } -function ValidationBadge({ count, type }: { count: number; type: 'error' | 'ok' }) { - if (type === 'error') { - return ( - - - {count} invalid - - ); - } - return ( - - - {count} valid - - ); -} - // ─── Main Modal ─────────────────────────────────────────────────────────────── interface Props { @@ -294,8 +277,8 @@ export default function DomainBulkImportModal({ isOpen, onClose }: Props) { } setRows(parsed); setPhase('parsed'); - } catch (err: any) { - setParseError(err.message); + } catch (err) { + setParseError(err instanceof Error ? err.message : String(err)); } }, []); @@ -327,7 +310,7 @@ export default function DomainBulkImportModal({ isOpen, onClose }: Props) { setCurrentDomain(row.domain); try { - await new Promise((resolve, reject) => { + await new Promise((resolve) => { createMutation.mutate( { domain: row.domain, @@ -339,13 +322,13 @@ export default function DomainBulkImportModal({ isOpen, onClose }: Props) { sieve_port: row.sieve_port, is_active: row.is_active, is_v2_user: row.is_v2_user, - } as any, + }, { onSuccess: () => { successes++; resolve(); }, - onError: (err: any) => { + onError: (err) => { errors.push({ domain: row.domain, rowIndex: row._rowIndex, diff --git a/src/components/admin/domain/DomainDetailsModal.tsx b/src/components/admin/domain/DomainDetailsModal.tsx index f08ba75..3653ece 100644 --- a/src/components/admin/domain/DomainDetailsModal.tsx +++ b/src/components/admin/domain/DomainDetailsModal.tsx @@ -22,7 +22,7 @@ import type { Domain } from '../../../api/admin-domain'; interface DomainDetailsModalProps { isOpen: boolean; onClose: () => void; - data: any; + data: Domain | null; } const DomainDetailsModal = ({ isOpen, onClose, data }: DomainDetailsModalProps) => { diff --git a/src/components/admin/domain/DomainForm.tsx b/src/components/admin/domain/DomainForm.tsx index 8686427..cf81922 100644 --- a/src/components/admin/domain/DomainForm.tsx +++ b/src/components/admin/domain/DomainForm.tsx @@ -24,9 +24,9 @@ import { FiArrowLeft } from 'react-icons/fi'; import { Flex, Switch, Text } from '@radix-ui/themes'; import { Controller } from 'react-hook-form'; import { useCreateDomain, useUpdateDomain, useGetDomain } from '../../../hooks/useAdminDomain'; -import { useToast } from '../../ui/ToastComponent'; import { apiKeyAtom } from '../../../state/auth'; import { useAtomValue } from 'jotai'; +import type { CreateDomainData } from '../../../api/admin-domain'; const schema = yup.object().shape({ domain: yup.string().required('Domain name is required'), @@ -66,7 +66,6 @@ function DomainForm() { const { data: domainData, isLoading } = useGetDomain(params.domain); const createMutation = useCreateDomain(); const updateMutation = useUpdateDomain(); - const toast = useToast(); const { register, @@ -116,7 +115,7 @@ function DomainForm() { } ); } else { - createMutation.mutate(data as any, { + createMutation.mutate(data as unknown as CreateDomainData, { onSuccess: () => { navigate({ to: '/1219/admin/domain' }); }, @@ -382,7 +381,15 @@ function DomainForm() { ); } -const FormField = ({ label, error, children }: any) => ( +const FormField = ({ + label, + error, + children, +}: { + label: string; + error?: string; + children: React.ReactNode; +}) => (

- ); -}); + ); + } +); SlashCommandList.displayName = 'SlashCommandList'; diff --git a/src/components/composer/contentEditor/SlashCommands.ts b/src/components/composer/contentEditor/SlashCommands.ts index 3aeb5e2..595e786 100644 --- a/src/components/composer/contentEditor/SlashCommands.ts +++ b/src/components/composer/contentEditor/SlashCommands.ts @@ -15,22 +15,14 @@ * . */ -import { Extension } from '@tiptap/core'; -import Suggestion from '@tiptap/suggestion'; +import { Extension, type Editor, type Range } from '@tiptap/core'; +import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion'; import { PluginKey } from '@tiptap/pm/state'; import { ReactRenderer } from '@tiptap/react'; import tippy, { type Instance } from 'tippy.js'; import React from 'react'; -import { SlashCommandList, type CommandItem } from './SlashCommandList'; -import { - FaHeading, - FaListUl, - FaListOl, - FaTable, - FaImage, - FaQuoteRight, - FaCode, -} from 'react-icons/fa6'; +import { SlashCommandList, type CommandItem, type SlashCommandListRef } from './SlashCommandList'; +import { FaListUl, FaListOl, FaTable } from 'react-icons/fa6'; const getSuggestionItems = ({ query }: { query: string }): CommandItem[] => { const items: CommandItem[] = [ @@ -94,7 +86,7 @@ export const SlashCommands = Extension.create({ return { suggestion: { char: '/', - command: ({ editor, range, props }: any) => { + command: ({ editor, range, props }: { editor: Editor; range: Range; props: CommandItem }) => { // Execute the item's command props.command({ editor, range }); }, @@ -111,11 +103,11 @@ export const SlashCommands = Extension.create({ command: this.options.suggestion.command, items: getSuggestionItems, render: () => { - let component: ReactRenderer; + let component: ReactRenderer; let popup: Instance[]; return { - onStart: (props: any) => { + onStart: (props: SuggestionProps) => { component = new ReactRenderer(SlashCommandList, { props, editor: props.editor, @@ -126,7 +118,7 @@ export const SlashCommands = Extension.create({ } popup = tippy('body', { - getReferenceClientRect: props.clientRect, + getReferenceClientRect: props.clientRect as () => DOMRect, appendTo: () => document.body, content: component.element, showOnCreate: true, @@ -136,7 +128,7 @@ export const SlashCommands = Extension.create({ }); }, - onUpdate(props: any) { + onUpdate(props: SuggestionProps) { component.updateProps(props); if (!props.clientRect) { @@ -144,17 +136,17 @@ export const SlashCommands = Extension.create({ } popup[0].setProps({ - getReferenceClientRect: props.clientRect, + getReferenceClientRect: props.clientRect as () => DOMRect, }); }, - onKeyDown(props: any) { + onKeyDown(props: SuggestionKeyDownProps) { if (props.event.key === 'Escape') { popup[0].hide(); return true; } - return component.ref?.onKeyDown(props); + return component.ref?.onKeyDown({ event: props.event }) ?? false; }, onExit() { diff --git a/src/components/composer/contentEditor/index.tsx b/src/components/composer/contentEditor/index.tsx index 52cad93..c8963ae 100644 --- a/src/components/composer/contentEditor/index.tsx +++ b/src/components/composer/contentEditor/index.tsx @@ -27,11 +27,11 @@ import { TableCell } from '@tiptap/extension-table-cell'; import TableContextMenu from './TableContextMenu'; import Image from '@tiptap/extension-image'; import Link from '@tiptap/extension-link'; -import { Node, mergeAttributes, Mark } from '@tiptap/core'; +import { Node, mergeAttributes, Mark, type CommandProps } from '@tiptap/core'; import { TextStyle } from '@tiptap/extension-text-style'; import Color from '@tiptap/extension-color'; import Blockquote from '@tiptap/extension-blockquote'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useAtomValue } from 'jotai'; import { userSettingsAtom } from '../../../state/settings'; import { OrderedList } from '@tiptap/extension-ordered-list'; @@ -93,32 +93,32 @@ const CustomTextStyle = TextStyle.extend({ ...this.parent?.(), setFontSize: (fontSize: string) => - ({ chain }: any) => + ({ chain }: CommandProps) => chain().setMark('textStyle', { fontSize }).run(), unsetFontSize: () => - ({ chain }: any) => + ({ chain }: CommandProps) => chain().setMark('textStyle', { fontSize: null }).run(), setBackgroundColor: (backgroundColor: string) => - ({ chain }: any) => + ({ chain }: CommandProps) => chain().setMark('textStyle', { backgroundColor }).run(), unsetBackgroundColor: () => - ({ chain }: any) => + ({ chain }: CommandProps) => chain().setMark('textStyle', { backgroundColor: null }).run(), setFontFamily: (fontFamily: string) => - ({ chain }: any) => + ({ chain }: CommandProps) => chain().setMark('textStyle', { fontFamily }).run(), unsetFontFamily: () => - ({ chain }: any) => + ({ chain }: CommandProps) => chain().setMark('textStyle', { fontFamily: null }).run(), }; }, diff --git a/src/components/composer/index.tsx b/src/components/composer/index.tsx index 73ca6f1..2f4beb0 100644 --- a/src/components/composer/index.tsx +++ b/src/components/composer/index.tsx @@ -15,36 +15,31 @@ * . */ -import { Box, Button, Flex, Grid, Separator, Dialog, Text, AlertDialog } from '@radix-ui/themes'; -import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { Box, Button, Flex, Dialog, Text, AlertDialog } from '@radix-ui/themes'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { composerDataAtom, composerOpenAtom, resetComposerDataAtom, + draftMessageIdAtom, type EmailAddress, + type ComposedEmailData, } from '../../state/composer'; import { FaDeleteLeft, FaFloppyDisk, FaPaperPlane } from 'react-icons/fa6'; import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; -import { v4 as uuidv4 } from 'uuid'; import RecipientField, { type RecipientFieldHandle } from './RecipientField'; import SubjectField from './SubjectField'; import ContentEditor from './contentEditor'; -import AttachmentUploader, { - toBase64, - MAX_TOTAL_SIZE, - MAX_INDIVIDUAL_FILE_SIZE, - formatFileSize, -} from './AttachmentUploader'; +import AttachmentUploader from './AttachmentUploader'; +import { toBase64, MAX_TOTAL_SIZE, MAX_INDIVIDUAL_FILE_SIZE, formatFileSize } from './attachmentUtils'; import { useDropzone } from 'react-dropzone'; import { FaPaperclip } from 'react-icons/fa6'; import { formatComposedEmailData } from '../../utils/composedDataFormat'; import EmailPriorityField from './EmailPriorityField'; import { useDraftMail, useSendMail } from '../../hooks/useComposer'; -import FolderSelectField from './FolderSelectField'; -import DialogWrapper from '../common/Dialoge'; import { emailAddress } from '../../state/emailAddress'; import { userSettingsAtom } from '../../state/settings'; -import { useToast } from '../ui/ToastComponent'; +import { useToast } from '../../hooks/useToast'; import TemplateSelector from './TempelateSelector'; import { generateMessageId, sendMailV2 } from '../../api/composer'; import { userDetailsAtom } from '../../state/userDetails'; @@ -55,14 +50,12 @@ import { SEND_DEFAULT } from '../../constants/constant'; import { useIsMobile } from '../../hooks/use-mobile'; import { getEditorDimensions } from '../../utils/dimensions'; -export const draftMessageIdAtom = atom(null); - export type EmailPriority = 'normal' | 'high' | 'low'; // Types for localStorage data interface LocalStorageDraft { messageId: string; - composerData: any; + composerData: ComposedEmailData; priority: EmailPriority; folder: string; timestamp: number; @@ -72,12 +65,12 @@ interface LocalStorageDraft { const DRAFT_STORAGE_KEY = 'email-composer-draft'; // Debounce hook with proper typing -const useDebounce = (callback: (...args: any[]) => void, delay: number) => { +const useDebounce = (callback: (...args: Args) => void, delay: number) => { const timeoutRef = useRef(null); const cancelRef = useRef<() => void>(() => {}); const debouncedFunction = useCallback( - (...args: any[]) => { + (...args: Args) => { if (timeoutRef.current) { window.clearTimeout(timeoutRef.current); } @@ -122,7 +115,7 @@ const Composer = () => { const [fullViewEnabled, setFullViewEnabled] = useState(false); const [draftMessageId, setDraftMessageId] = useAtom(draftMessageIdAtom); const [messageId, setMessageId] = useState(''); - const [sentMessageId, setSentMessageId] = useState(''); + const [, setSentMessageId] = useState(''); const [undoTime, setUndoTime] = useState(5000); const [saveDraft, setSaveDraft] = useState('Drafts'); const [folder, setFolder] = useState('Sent'); @@ -147,7 +140,7 @@ const Composer = () => { // Add state for close confirmation dialog const [showCloseConfirm, setShowCloseConfirm] = useState(false); - const [pendingCloseAction, setPendingCloseAction] = useState<(() => void) | null>(null); + const [, setPendingCloseAction] = useState<(() => void) | null>(null); // Add state to track if there are unsaved changes // const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); @@ -171,7 +164,10 @@ const Composer = () => { // Track if we've already auto-saved to prevent duplicate saves const hasAutoSavedRef = useRef(false); // Track pending email to be sent - const pendingEmailRef = useRef(null); + const pendingEmailRef = useRef<{ + mailData: ReturnType; + currentMsgId: string; + } | null>(null); // Track if we're in the undo period const isUndoPeriodRef = useRef(false); // Track auto-save attempts to prevent too many API calls @@ -278,11 +274,18 @@ const Composer = () => { isSendingRef.current = false; isManualDraftSaveRef.current = false; } - }, [openComposer, userDetails?.domain, isQuotaExceeded, toast]); + }, [ + openComposer, + userDetails?.domain, + isQuotaExceeded, + toast, + setComposerData, + setDraftMessageId, + ]); // Save to localStorage - UPDATED to use current messageId const saveToLocalStorage = useCallback( - (data: any, currentPriority: EmailPriority, currentFolder: string) => { + (data: ComposedEmailData, currentPriority: EmailPriority, currentFolder: string) => { // Don't save if quota is exceeded if (isQuotaExceeded) { return; @@ -309,7 +312,7 @@ const Composer = () => { console.error('Error saving to localStorage:', error); } }, - [userDetails?.domain, isQuotaExceeded] + [userDetails?.domain, isQuotaExceeded, setDraftMessageId] ); // Remove from localStorage @@ -452,7 +455,7 @@ const Composer = () => { onSuccess: () => { autoSaveCountRef.current++; }, - onError: (err: any) => { + onError: (err) => { console.error('Failed to auto-save draft:', err); }, }); @@ -475,9 +478,7 @@ const Composer = () => { ({ isDraft, folderPath, - successTitle, successDescription, - errorTitle, allowUndo = false, dataOverrides = {}, }: { @@ -487,7 +488,7 @@ const Composer = () => { successDescription: string; errorTitle: string; allowUndo?: boolean; - dataOverrides?: any; + dataOverrides?: Partial; }) => { // Check if trying to save draft when quota is exceeded if (isDraft && isQuotaExceeded) { @@ -610,13 +611,14 @@ const Composer = () => { setDraftMessageId(null); setSentMessageId(''); isSendingRef.current = false; - } catch (err: any) { + } catch (err) { isSendInFlightRef.current = false; toast.dismiss(loadingId); window.removeEventListener('beforeunload', handleBeforeUnload); toast.error({ description: - err?.message || 'An error occurred while sending mail. Please try again.', + (err instanceof Error && err.message) || + 'An error occurred while sending mail. Please try again.', }); setSentMessageId(''); setOpenComposer(true); @@ -651,11 +653,10 @@ const Composer = () => { hasAutoSavedRef.current = false; autoSaveCountRef.current = 0; }, - onError: (err: any) => { + onError: (err) => { if (loadingId) toast.dismiss(loadingId); toast.error({ - description: - err?.message || 'An error occurred while sending mail. Please try again.', + description: err?.message || 'An error occurred while sending mail. Please try again.', }); // On error, clear the sent message ID if (!isDraft) { @@ -693,7 +694,7 @@ const Composer = () => { // Function to actually send the email (after confirmation) const sendEmailConfirmed = useCallback( - (overrides: any = {}) => { + (overrides: Partial = {}) => { // Always generate a NEW Message-ID for sending, regardless of draft status const sendMessageId = generateMessageId(userDetails?.domain || ''); setSentMessageId(sendMessageId); @@ -742,7 +743,7 @@ const Composer = () => { } // 4. Prepare data overrides - const overrides: any = {}; + const overrides: Partial = {}; if (pendingTo) overrides.to = effectiveTo; if (pendingCc) overrides.cc = [...(composerData.cc || []), pendingCc]; if (pendingBcc) overrides.bcc = [...(composerData.bcc || []), pendingBcc]; @@ -757,7 +758,14 @@ const Composer = () => { // Send immediately if message has content sendEmailConfirmed(overrides); } - }, [isMessageEmpty, sendEmailConfirmed, composerData.to, composerData.cc, composerData.bcc]); + }, [ + isMessageEmpty, + sendEmailConfirmed, + composerData.to, + composerData.cc, + composerData.bcc, + toast, + ]); const handleDraftButtonClick = useCallback(() => { // Don't save draft if quota is exceeded @@ -800,7 +808,7 @@ const Composer = () => { removeFromLocalStorage(); // 4. Prepare data overrides - const overrides: any = {}; + const overrides: Partial = {}; if (pendingTo) overrides.to = effectiveTo; if (pendingCc) overrides.cc = [...(composerData.cc || []), pendingCc]; if (pendingBcc) overrides.bcc = [...(composerData.bcc || []), pendingBcc]; @@ -933,15 +941,19 @@ const Composer = () => { // Initialize composer data with signature useEffect(() => { if (openComposer && signatureHtml && !composerData.html) { - setComposerData((prev: any) => { - const from = prev.from || {}; - const sanitizeString = (value: any): string => { + setComposerData((prev) => { + const from = prev.from || ({} as Partial); + const sanitizeString = (value: unknown): string => { return typeof value === 'string' ? value.trim() : ''; }; return { ...prev, from: { - address: sanitizeString(from?.address) || currentEmail || '', + // currentEmail is an EmailAddress object, not a string — if + // sanitizeString(from?.address) is empty this assigns the whole + // object here (objects are always truthy), not a string. Pre-existing, + // preserved as-is; see CLAUDE.md. + address: (sanitizeString(from?.address) || currentEmail || '') as string, name: sanitizeString(from?.name) || 'Unknown Sender', }, html: prev.html || signatureHtml, @@ -949,7 +961,7 @@ const Composer = () => { }; }); } - }, [openComposer, signatureHtml, currentEmail, composerData.html]); + }, [openComposer, signatureHtml, currentEmail, composerData.html, setComposerData]); const handleTemplateSelect = useCallback( (templateData: { subject: string; html: string }) => { @@ -965,7 +977,7 @@ const Composer = () => { [signatureHtml, updateComposerData] ); - const handleToemailChange = (emailAddresses: any) => { + const handleToemailChange = (emailAddresses: EmailAddress[]) => { updateComposerData({ to: emailAddresses }); }; @@ -982,7 +994,7 @@ const Composer = () => { const onDrop = useCallback( async (acceptedFiles: File[]) => { const currentTotalSize = (composerData.attachments || []).reduce( - (sum: number, file: any) => sum + (file.size || 0), + (sum, file) => sum + (file.size || 0), 0 ); const newFilesTotalSize = acceptedFiles.reduce((sum, file) => sum + file.size, 0); @@ -1017,7 +1029,7 @@ const Composer = () => { })) ); - setComposerData((prev: any) => ({ + setComposerData((prev) => ({ ...prev, attachments: [...(prev.attachments || []), ...base64Attachments], })); @@ -1026,7 +1038,7 @@ const Composer = () => { description: `Added ${validFiles.length} file(s)`, duration: 3000, }); - } catch (error) { + } catch { toast.error({ description: 'Failed to process files.' }); } }, @@ -1073,7 +1085,10 @@ const Composer = () => { onClick={handleDraftButtonClick} className={`text-[var(--gray-11)] hover:text-[var(--gray-12)] hover:bg-[var(--gray-3)] ${ isQuotaExceeded || - (!composerData.to?.length && !(toRef.current as any)?.inputValue) + // RecipientFieldHandle doesn't actually expose `inputValue` (only `flush`), + // so this always evaluates truthy — pre-existing no-op, not fixed here. + (!composerData.to?.length && + !(toRef.current as unknown as { inputValue?: string })?.inputValue) ? 'opacity-50 cursor-not-allowed' : '' }`} diff --git a/src/components/contacts/BulkCreateView.tsx b/src/components/contacts/BulkCreateView.tsx index 1210cde..37de3c8 100644 --- a/src/components/contacts/BulkCreateView.tsx +++ b/src/components/contacts/BulkCreateView.tsx @@ -15,12 +15,12 @@ * . */ -import { Flex, TextField, Button, Box, Table, Dialog } from '@radix-ui/themes'; -import { FaPlus, FaTrash, FaTimes, FaInfoCircle } from 'react-icons/fa'; +import { Flex, TextField, Button, Table, Dialog } from '@radix-ui/themes'; +import { FaPlus, FaTrash, FaTimes } from 'react-icons/fa'; import DropdownWrapper from '../common/DropdownWrapper'; import type { CreateContactData } from '../../utils/contact'; import { useState, useEffect } from 'react'; -import { FaEllipsis, FaEllipsisVertical } from 'react-icons/fa6'; +import { FaEllipsisVertical } from 'react-icons/fa6'; interface BulkCreateViewProps { open: boolean; @@ -92,7 +92,7 @@ export const BulkCreateView = ({ // Phone validation if ( contact.phone && - !/^[\+]?[1-9][\d]{0,15}$/.test(contact.phone.replace(/[\s\-\(\)]/g, '')) + !/^[+]?[1-9][\d]{0,15}$/.test(contact.phone.replace(/[\s\-()]/g, '')) ) { contactErrors.phone = 'Invalid phone format'; } diff --git a/src/components/contacts/DeleteConfirm.tsx b/src/components/contacts/DeleteConfirm.tsx index 730a126..4f5a853 100644 --- a/src/components/contacts/DeleteConfirm.tsx +++ b/src/components/contacts/DeleteConfirm.tsx @@ -15,7 +15,7 @@ * . */ -import { Dialog, Button, Flex, Box } from '@radix-ui/themes'; +import { Dialog, Button, Flex } from '@radix-ui/themes'; import { FaTrash, FaExclamationTriangle, FaTimes } from 'react-icons/fa'; import type { Contact } from '../../utils/contact'; diff --git a/src/components/contacts/index.tsx b/src/components/contacts/index.tsx index 7ff5724..2ba1c34 100644 --- a/src/components/contacts/index.tsx +++ b/src/components/contacts/index.tsx @@ -38,7 +38,7 @@ import DropdownWrapper from '../common/DropdownWrapper'; import type { Contact, ContactsResponse, CreateContactData } from '../../utils/contact'; import CSVImportModal from './CSVImport'; import { deleteContact, getContacts } from '../../api/contacts'; -import { useToast } from '../ui/ToastComponent'; +import { useToast } from '../../hooks/useToast'; import { exportToCSV, exportToVCard, @@ -74,15 +74,11 @@ function ContactPage() { { name: '', email: '', phone: '', notes: '' }, ]); - const { data, isPending, refetch, error, isError } = useContacts(page, perPage); + const { data, isPending, refetch, isError } = useContacts(page, perPage); const { mutate: createSingleContact, isPending: isLoadingCreateSingleContact } = useCreateContact(); const { mutate: editSingleContact, isPending: isLoadingEditSingleContact } = useEditContact(); - const { - mutate: createBulkContact, - isPending: isLoadingBulkCreate, - error: bulkCreateError, - } = useCreateBulkContact(); + const { mutate: createBulkContact, isPending: isLoadingBulkCreate } = useCreateBulkContact(); const { mutate: deleteSingleContact, isPending: isLoadingDelete } = useDeleteContact(); const contactsData = data as ContactsResponse; @@ -122,7 +118,11 @@ function ContactPage() { async (pageNum: number) => { const response = await getContacts(pageNum, 100); return { - data: response.data as any, + // api/contacts.ts's Contact (nullable phone/notes/contact_id) differs + // slightly from utils/contact.ts's Contact (non-nullable) — pre-existing + // mismatch between the two Contact shapes, bridged via cast rather than + // reconciled here. + data: response.data as unknown as Contact[], total_pages: response.total_pages, }; }, @@ -275,10 +275,14 @@ function ContactPage() { description: `Successfully created ${validContacts.length} contact${validContacts.length > 1 ? 's' : ''}`, }); }, - onError: (error: any) => { + onError: (error) => { + // error is a plain Error here, so .response is never actually present — + // pre-existing defensive fallback for a shape this API doesn't produce. + const response = (error as unknown as { response?: { data?: { message?: string; error?: string } } }) + .response; const errorMessage = - error?.response?.data?.message || - error?.response?.data?.error || + response?.data?.message || + response?.data?.error || error?.message || 'Failed to create contacts. Please try again.'; @@ -306,12 +310,14 @@ function ContactPage() { description: `Successfully imported contact${contacts.length > 1 ? 's' : ''}.`, }); }, - onError: (error: any) => { + onError: (error) => { console.error('CSV import failed:', error); toast.dismiss(loadingId); + const response = (error as unknown as { response?: { data?: { message?: string; error?: string } } }) + .response; const errorMessage = - error?.response?.data?.message || - error?.response?.data?.error || + response?.data?.message || + response?.data?.error || error?.message || 'Failed to import contacts from CSV. Please try again.'; diff --git a/src/components/help/index.tsx b/src/components/help/index.tsx index 22d21af..deee9d5 100644 --- a/src/components/help/index.tsx +++ b/src/components/help/index.tsx @@ -16,11 +16,11 @@ */ import React from 'react'; -import { Heading, Text, Badge } from '@radix-ui/themes'; +import { Heading, Badge } from '@radix-ui/themes'; +import type { IconType } from 'react-icons'; import { HiOutlineUsers, HiOutlineVideoCamera, - HiOutlineCog6Tooth, HiOutlineFolder, HiOutlinePencilSquare, HiOutlineLightBulb, @@ -105,7 +105,7 @@ const FeatureCard = ({ title, description, }: { - icon: any; + icon: IconType; title: string; description: string; }) => ( diff --git a/src/components/login/Login.tsx b/src/components/login/Login.tsx index c91b0e1..41cd23d 100644 --- a/src/components/login/Login.tsx +++ b/src/components/login/Login.tsx @@ -22,7 +22,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; import background from '../../assets/sign-in.svg'; import { useNavigate } from '@tanstack/react-router'; -import { useToast } from '../ui/ToastComponent'; +import { useToast } from '../../hooks/useToast'; import { useState, useEffect, useRef } from 'react'; import { userDetailsAtom } from '../../state/userDetails'; import { useAtom } from 'jotai'; @@ -207,12 +207,12 @@ const Login = ({ branding }: { branding?: LoginBranding }) => { recaptcha_token: recaptchaToken, }, { - onSuccess: (_response: any) => { + onSuccess: () => { setUserDetails({ email: data.email, domain }); recaptchaRef.current?.reset(); setRecaptchaToken(null); }, - onError: (error: any) => { + onError: (error) => { setIsLoading(false); // Reset reCAPTCHA on error diff --git a/src/components/login/LoginNoRecaptcha.tsx b/src/components/login/LoginNoRecaptcha.tsx index ee74f49..983b975 100644 --- a/src/components/login/LoginNoRecaptcha.tsx +++ b/src/components/login/LoginNoRecaptcha.tsx @@ -22,7 +22,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; import background from '../../assets/sign-in.svg'; import { useNavigate, Link } from '@tanstack/react-router'; // Added Link import -import { useToast } from '../ui/ToastComponent'; +import { useToast } from '../../hooks/useToast'; import { useState } from 'react'; import { userDetailsAtom } from '../../state/userDetails'; import { useAtom } from 'jotai'; @@ -30,7 +30,6 @@ import { MdEmail, MdMailOutline } from 'react-icons/md'; import { FaEye, FaEyeSlash, - FaGlobe, FaShieldAlt, FaFileAlt, FaTable, @@ -141,7 +140,7 @@ const Login = () => { navigate({ to: '/' }); setUserDetails({ email: data.email, domain }); }, - onError: (error: any) => { + onError: (error) => { setIsLoading(false); toast.error({ description: error.message || 'Failed to submit form. Please try again.', diff --git a/src/components/mailbox/CreateFolder.tsx b/src/components/mailbox/CreateFolder.tsx index fe49cc4..56145ac 100644 --- a/src/components/mailbox/CreateFolder.tsx +++ b/src/components/mailbox/CreateFolder.tsx @@ -24,7 +24,7 @@ import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; import { FormProvider, useForm } from 'react-hook-form'; import { useCreateEmailFolder } from '../../hooks/useEmails'; -import { useToast } from '../ui/ToastComponent'; +import { useToast } from '../../hooks/useToast'; import { useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; // Added useEffect @@ -64,7 +64,7 @@ function CreateFolder() { setOpen(false); methods.reset(); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error.message || 'Failed to create folder. Please try again.', diff --git a/src/components/mailbox/EmailHoverCard.tsx b/src/components/mailbox/EmailList/EmailCard/EmailHoverCard.tsx similarity index 96% rename from src/components/mailbox/EmailHoverCard.tsx rename to src/components/mailbox/EmailList/EmailCard/EmailHoverCard.tsx index fcdff71..afe6617 100644 --- a/src/components/mailbox/EmailHoverCard.tsx +++ b/src/components/mailbox/EmailList/EmailCard/EmailHoverCard.tsx @@ -18,8 +18,8 @@ // src/components/email/EmailHoverCard.tsx import { useState } from 'react'; import { FaPaperclip, FaChevronDown, FaChevronUp, FaCode } from 'react-icons/fa'; -import BIMIAvatar from '../common/BimiAvatar'; -import { useUserTimezone } from '../../hooks/useTimezone'; +import BIMIAvatar from '../../../common/BimiAvatar'; +import { useUserTimezone } from '../../../../hooks/useTimezone'; interface Recipient { name: string; @@ -39,7 +39,9 @@ interface EmailHoverCardProps { isUnread: boolean; isFlagged: boolean; position?: 'top' | 'bottom'; - emailHeaders?: any; + // Callers pass various concrete email-shaped objects (no common index + // signature), so this is intentionally loose and cast once internally. + emailHeaders?: unknown; onMouseEnter?: () => void; onMouseLeave?: () => void; } @@ -56,10 +58,11 @@ const EmailHoverCard = ({ isUnread, isFlagged, position = 'bottom', - emailHeaders = {}, + emailHeaders: emailHeadersProp = {}, onMouseEnter, onMouseLeave, }: EmailHoverCardProps) => { + const emailHeaders = emailHeadersProp as Record; const [expandedSection, setExpandedSection] = useState(null); const [showHeaders, setShowHeaders] = useState(false); @@ -186,7 +189,7 @@ const EmailHoverCard = ({ importantHeaders.forEach((key) => { if (emailHeaders[key]) { - headers.push({ key, value: emailHeaders[key] }); + headers.push({ key, value: emailHeaders[key] as string }); } }); diff --git a/src/components/mailbox/EmailCard.tsx b/src/components/mailbox/EmailList/EmailCard/index.tsx similarity index 96% rename from src/components/mailbox/EmailCard.tsx rename to src/components/mailbox/EmailList/EmailCard/index.tsx index 6d82d24..9984d8c 100644 --- a/src/components/mailbox/EmailCard.tsx +++ b/src/components/mailbox/EmailList/EmailCard/index.tsx @@ -16,22 +16,23 @@ */ import { useState, useRef, useEffect } from 'react'; -import { FaPaperclip, FaTrash, FaFlag, FaEnvelope } from 'react-icons/fa'; +import { FaPaperclip, FaTrash, FaFlag } from 'react-icons/fa'; import { MdDriveFileMoveOutline, MdMarkEmailRead, MdMarkEmailUnread } from 'react-icons/md'; import { decodeWords } from 'postal-mime'; -import BIMIAvatar from '../common/BimiAvatar'; +import BIMIAvatar from '../../../common/BimiAvatar'; import { Checkbox, ContextMenu } from '@radix-ui/themes'; -import { parseEmail } from '../../utils/emailPerser'; -import { useUserTimezone } from '../../hooks/useTimezone'; +import { parseEmail } from '../../../../utils/emailPerser'; +import { useUserTimezone } from '../../../../hooks/useTimezone'; import { useAtomValue } from 'jotai'; -import { userSettingsAtom } from '../../state/settings'; +import { userSettingsAtom } from '../../../../state/settings'; import EmailHoverCard from './EmailHoverCard'; -import { useEmailPrefetch } from '../../hooks/useEmailRaw'; -import { getMessageId, splitAddressList } from '../../utils/emailUtils'; +import { useEmailPrefetch } from '../../../../hooks/useEmailRaw'; +import { getMessageId, splitAddressList } from '../../../../utils/emailUtils'; import { useQueryClient } from '@tanstack/react-query'; -import { useEmailParser } from '../../hooks/useEmailParser'; -import { isFolderThreadEnabled, shouldApplyThreading } from '../../utils/emailListUtils'; -import type { SimplifiedEmail } from '../../utils/email'; +import { useEmailParser } from '../../../../hooks/useEmailParser'; +import { isFolderThreadEnabled, shouldApplyThreading } from '../../../../utils/emailListUtils'; +import type { SimplifiedEmail } from '../../../../utils/email'; +import type { EmailLike } from '../../../../utils/emailThreading'; interface EmailCardProps { email: SimplifiedEmail; @@ -96,7 +97,7 @@ const EmailCard = ({ const [showEmailTooltip, setShowEmailTooltip] = useState(false); const [tooltipPosition, setTooltipPosition] = useState<'bottom' | 'top'>('bottom'); const tooltipTriggerRef = useRef(null); - const longPressTimerRef = useRef(null); + const longPressTimerRef = useRef | null>(null); const touchStartTimeRef = useRef(0); const dragImageRef = useRef(null); const touchStartYRef = useRef(0); @@ -146,7 +147,7 @@ const EmailCard = ({ const toRecipients = parseRecipients(email.To); const ccRecipients = email.Cc ? parseRecipients(email.Cc) : []; - const bccRecipients: any[] = []; + const bccRecipients: ReturnType = []; // Status Checks const emailSeen = email?.FLAGS?.includes('\\Seen') || false; @@ -161,9 +162,10 @@ const EmailCard = ({ const isFolderThread = isFolderThreadEnabled(folderThreadView, folder, folder); const threadingActive = shouldApplyThreading(threadedView, isFolderThread, folder); - const isThread = !!(email as any)['Thread-View']; - const threadCount = ((email as any)['Thread-Emails-Count'] as number) || 1; - const threadUnreadCount = ((email as any)['Thread-Unread-Count'] as number) || 0; + const emailWithThreadFields = email as unknown as EmailLike; + const isThread = !!emailWithThreadFields['Thread-View']; + const threadCount = (emailWithThreadFields['Thread-Emails-Count'] as number) || 1; + const threadUnreadCount = (emailWithThreadFields['Thread-Unread-Count'] as number) || 0; const threadHasUnread = isThread && threadUnreadCount > 0; const showThreadPill = threadingActive && isThread && threadCount > 1; @@ -194,7 +196,7 @@ const EmailCard = ({ }, 300); return () => clearInterval(pollInterval); - }, [showEmailTooltip, prefetchedRawEmail]); + }, [showEmailTooltip, prefetchedRawEmail, email.id, folder, messageId, queryClient]); // Cleanup all timers on unmount useEffect(() => { @@ -397,7 +399,7 @@ const EmailCard = ({ if (!email.attachments || email.attachments.length === 0) return ''; const names = email.attachments .slice(0, 3) - .map((att:any) => att.filename) + .map((att) => att.filename) .join(', '); if (email.attachments.length > 3) return `${names} + ${email.attachments.length - 3} more`; return names; @@ -406,7 +408,7 @@ const EmailCard = ({ const getHoverAttachmentNames = () => { if (hoverParsedEmail?.attachments?.length) { return hoverParsedEmail.attachments - .map((a: any) => a.filename || a.name || 'Unnamed') + .map((a) => a.filename || (a as unknown as { name?: string }).name || 'Unnamed') .join(', '); } return getAttachmentNames(); @@ -561,7 +563,7 @@ const EmailCard = ({ longPressTimerRef.current = null; } if (touchDuration < 500 && !touchMovedRef.current && !isSelectionMode) - handleCardClick(e as any); + handleCardClick(e as unknown as React.MouseEvent); touchMovedRef.current = false; }; @@ -793,7 +795,7 @@ const EmailCard = ({ onSelectionChange?.(email.id, checked as boolean, index, false); }} onClick={(e) => { - if ((e as any).shiftKey && onSelectionChange) { + if (e.shiftKey && onSelectionChange) { e.stopPropagation(); onSelectionChange(email.id, !isSelected, index, true); } diff --git a/src/components/mailbox/EmailComposer.tsx b/src/components/mailbox/EmailList/EmailComposer.tsx similarity index 84% rename from src/components/mailbox/EmailComposer.tsx rename to src/components/mailbox/EmailList/EmailComposer.tsx index 4f34b7a..f12671b 100644 --- a/src/components/mailbox/EmailComposer.tsx +++ b/src/components/mailbox/EmailList/EmailComposer.tsx @@ -17,36 +17,38 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { FaPaperPlane, FaDeleteLeft, FaFloppyDisk } from 'react-icons/fa6'; -import type { Email } from '../../api/mailbox'; +import type { Email } from '../../../api/mailbox'; +import type { Email as ParsedPostalEmail, Address as PostalMimeAddress } from 'postal-mime'; import { useParams } from '@tanstack/react-router'; -import { useEmailRaw } from '../../hooks/useEmailRaw'; +import { useEmailRaw } from '../../../hooks/useEmailRaw'; import PostalMime, { decodeWords } from 'postal-mime'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { useSendMail, useDraftMail } from '../../hooks/useComposer'; +import { useSendMail, useDraftMail } from '../../../hooks/useComposer'; import { emailComposerDataAtom, resetEmailComposerDataAtom, emailComposerOpenAtom, emailComposerKeepMountedAtom, -} from '../../state/emailComposer'; -import { emailAddress } from '../../state/emailAddress'; -import RecipientField, { type RecipientFieldHandle } from '../composer/RecipientField'; -import SubjectField from '../composer/SubjectField'; -import ContentEditor from '../composer/contentEditor'; -import AttachmentUploader, { +} from '../../../state/emailComposer'; +import { emailAddress } from '../../../state/emailAddress'; +import RecipientField, { type RecipientFieldHandle } from '../../composer/RecipientField'; +import SubjectField from '../../composer/SubjectField'; +import ContentEditor from '../../composer/contentEditor'; +import AttachmentUploader from '../../composer/AttachmentUploader'; +import { toBase64, MAX_TOTAL_SIZE, MAX_INDIVIDUAL_FILE_SIZE, formatFileSize, -} from '../composer/AttachmentUploader'; +} from '../../composer/attachmentUtils'; import { useDropzone } from 'react-dropzone'; import { FaPaperclip } from 'react-icons/fa6'; -import EmailPriorityField from '../composer/EmailPriorityField'; -import { generateMessageId, sendMailV2 } from '../../api/composer'; -import { useDeleteMail } from '../../hooks/useEmails'; -import { useToast } from '../ui/ToastComponent'; -import { userSettingsAtom } from '../../state/settings'; -import { folderQuotaAtom } from '../../state/folders'; +import EmailPriorityField from '../../composer/EmailPriorityField'; +import { generateMessageId, sendMailV2, type ComposerRequest } from '../../../api/composer'; +import { useDeleteMail } from '../../../hooks/useEmails'; +import { useToast } from '../../../hooks/useToast'; +import { userSettingsAtom } from '../../../state/settings'; +import { folderQuotaAtom } from '../../../state/folders'; // Import Utilities import { @@ -58,16 +60,33 @@ import { processIncomingHtml, type EmailPriority, type EmailHeaders, -} from '../../utils/replyForwardHelper'; -import { FaTimes } from 'react-icons/fa'; -import CustomModal from '../composer/CustomModal'; -import { parseEmail } from '../../utils/emailPerser'; -import { escapeHtml } from '../../utils/emailPrint'; -import { userDetailsAtom } from '../../state/userDetails'; -import { SEND_DEFAULT } from '../../constants/constant'; -import { getMessageId, normalizeFieldNames } from '../../utils/emailUtils'; -import { getEditorDimensions } from '../../utils/dimensions'; -import { useIsMobile } from '../../hooks/use-mobile'; + type Address, + type EmailAttachmentPayload, +} from '../../../utils/replyForwardHelper'; +import CustomModal from '../../composer/CustomModal'; +import { parseEmail } from '../../../utils/emailPerser'; +import { userDetailsAtom } from '../../../state/userDetails'; +import { SEND_DEFAULT } from '../../../constants/constant'; +import { getMessageId, normalizeFieldNames } from '../../../utils/emailUtils'; +import { getEditorDimensions } from '../../../utils/dimensions'; +import { useIsMobile } from '../../../hooks/use-mobile'; +import type { ComposerEmail } from '../../../state/emailComposer'; +import type { EmailAddress as RecipientEmailAddress } from '../../../state/composer'; + +// Recipients built here (from parseEmailAddresses / RecipientField) use the +// {address, name} shape, not the {email, name} shape ComposerEmail declares for +// to/cc/bcc — a pre-existing mismatch, preserved as-is (see CLAUDE.md). +interface ComposerBasicData { + to: Address[]; + cc: Address[]; + bcc: Address[]; + subject: string; + html: string; + text: string; + attachments: EmailAttachmentPayload[]; + headers: EmailHeaders; + from_id: { email: string; name: string }; +} interface EmailComposerProps { email: Email | null; @@ -78,11 +97,14 @@ interface EmailComposerProps { } // Updated useDebounce hook with cancel function -const useDebounce = (callback: (...args: any[]) => void, delay: number) => { +const useDebounce = ( + callback: (...args: TArgs) => void, + delay: number +) => { const timeoutRef = useRef(null); const debouncedFunction = useCallback( - (...args: any[]) => { + (...args: TArgs) => { if (timeoutRef.current) { window.clearTimeout(timeoutRef.current); } @@ -101,7 +123,7 @@ const useDebounce = (callback: (...args: any[]) => void, delay: number) => { return [debouncedFunction, cancel] as const; }; -const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailComposerProps) => { +const EmailComposer = ({ email, mode, onClose, onSend }: EmailComposerProps) => { const toast = useToast(); const { folder } = useParams({ strict: false }); const [composerData, setComposerData] = useAtom(emailComposerDataAtom); @@ -139,7 +161,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo // ── Undo-send state & refs ────────────────────────────────────────────────── const [undoTime, setUndoTime] = useState(5000); - const pendingEmailRef = useRef(null); + const pendingEmailRef = useRef | null>(null); const isUndoPeriodRef = useRef(false); const isSendInFlightRef = useRef(false); // ─────────────────────────────────────────────────────────────────────────── @@ -237,7 +259,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo try { resetComposerData(); - const basicData: any = { + const basicData: ComposerBasicData = { to: [], cc: [], bcc: [], @@ -270,7 +292,10 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo subject: decodeWords(email.Subject) || '', html: html, text: parsed.text || '', - attachments: regularAttachments, + // regularAttachments is backend/snake_case-shaped (mime_type, data, content_id), + // not the composer's own EmailAttachment shape — pre-existing mismatch, unrelated + // to this pass, preserved as-is. + attachments: regularAttachments as unknown as typeof prev.attachments, from_id: { email: currentEmail.address || '', name: currentEmail.name || '', @@ -282,8 +307,8 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo return; } - let parsed: any = null; - let allAttachments: any[] = []; + let parsed: ParsedPostalEmail | null = null; + let allAttachments: EmailAttachmentPayload[] = []; if (rawEmail) { try { @@ -311,15 +336,22 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo if (mode === 'draft') { if (parsed) { - const toAddressArray = (address: any): { name: string; address: string }[] => { + const toAddressArray = ( + address: PostalMimeAddress[] | PostalMimeAddress | undefined + ): Address[] => { if (!address) return []; if (Array.isArray(address)) { return address.map((addr) => ({ name: addr.name || '', - address: addr.address || '', + address: (addr as { address?: string }).address || '', })); } - return [{ name: address.name || '', address: address.address || '' }]; + return [ + { + name: address.name || '', + address: (address as { address?: string }).address || '', + }, + ]; }; basicData.to = toAddressArray(parsed.to); @@ -355,8 +387,8 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo primaryRecipients = [parseEmailAddresses(email.From || '')[0]].filter(Boolean); } - const toAddresses = parseEmailAddresses(normalizedEmails?.to || ''); - const ccAddresses = parseEmailAddresses(normalizedEmails?.cc || ''); + const toAddresses = parseEmailAddresses((normalizedEmails?.to as string) || ''); + const ccAddresses = parseEmailAddresses((normalizedEmails?.cc as string) || ''); const currentAddress = typeof currentEmail === 'string' ? currentEmail : currentEmail?.address; @@ -383,7 +415,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo basicData.to = []; if (parsed && parsed.html) { - const { html, regularAttachments } = processIncomingHtml(parsed.html, allAttachments); + const { regularAttachments } = processIncomingHtml(parsed.html, allAttachments); basicData.attachments = regularAttachments; } else if (allAttachments.length > 0) { basicData.attachments = allAttachments; @@ -395,16 +427,19 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo name: currentEmail.name || '', }; - setComposerData((prev) => ({ - ...prev, - ...basicData, - })); + setComposerData( + (prev) => + ({ + ...prev, + ...basicData, + }) as unknown as ComposerEmail + ); if (mode !== 'draft' && mode !== 'new') { const originalDate = formatEmailDate(email.Date || ''); const originalFrom = parseEmail(email.From) || ''; - const originalTo = parseEmail(normalizedEmails.to) || ''; + const originalTo = parseEmail(normalizedEmails.to as string) || ''; const originalSubject = decodeWords(email.Subject) || ''; const borderColors = { @@ -517,6 +552,8 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo userSettings, rawEmail, isLoadingRaw, + setComposerData, + resetComposerData, ]); const saveToDraft = useCallback(() => { @@ -534,17 +571,20 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo folder_path: saveDraft || 'Drafts', priority, isDraft: true, - }) as any; + }); - draftMutate(mailData, { + // formatComposedEmailData returns html/text fields; ComposerRequest expects + // body_html/body_text. This autosave path (unlike handleMailAction) never + // renames them — a pre-existing mismatch, preserved as-is (see CLAUDE.md). + draftMutate(mailData as unknown as ComposerRequest, { onSuccess: () => { hasAutoSavedRef.current = true; }, - onError: (err: any) => { + onError: (err) => { console.error('error', err); }, }); - }, [composerData, quotedHtml, priority, draftMutate, isQuotaExceeded, saveDraft]); + }, [quotedHtml, priority, draftMutate, isQuotaExceeded, saveDraft]); const [debouncedSaveToDraft, cancelDebouncedSaveToDraft] = useDebounce(saveToDraft, 15000); @@ -563,7 +603,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo errorTitle: string; setSending?: (value: boolean) => void; allowUndo?: boolean; - dataOverrides?: any; + dataOverrides?: Record; }) => { if (isDraft && isQuotaExceeded) { toast.error({ @@ -625,11 +665,11 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo }, }; - const mailData = formatComposedEmailData(finalValue, { + const mailData: Record = formatComposedEmailData(finalValue, { folder_path: isDraft ? saveDraft || 'Drafts' : sendPath || folder_path, priority: priority, isDraft, - }) as any; + }); const mutateFn = isDraft ? draftMutate : sendMutate; @@ -659,7 +699,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo : originalMessageId; if (draftMsgId) { mailData.draft_saved = true; - mailData.draft_folder_name = (email as any).folderPath || saveDraft || 'Drafts'; + mailData.draft_folder_name = email.folderPath || saveDraft || 'Drafts'; mailData.draft_message_id = draftMsgId; } } else if (hasAutoSavedRef.current) { @@ -730,7 +770,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo isSendInFlightRef.current = true; const loadingId = toast.loading({ description: 'Sending…' }); try { - await sendMailV2(mailToSend); + await sendMailV2(mailToSend as unknown as ComposerRequest); isSendInFlightRef.current = false; toast.dismiss(loadingId); window.removeEventListener('beforeunload', handleBeforeUnload); @@ -744,12 +784,14 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo hasAutoSavedRef.current = false; emailIdRef.current = null; setIsInitialized(false); - } catch (err: any) { + } catch (err) { isSendInFlightRef.current = false; toast.dismiss(loadingId); window.removeEventListener('beforeunload', handleBeforeUnload); toast.error({ - description: err?.message || 'An error occurred while sending mail. Please try again.', + description: + (err instanceof Error && err.message) || + 'An error occurred while sending mail. Please try again.', }); // Reopen the composer — data is still intact since the component stayed mounted setKeepMounted(false); @@ -761,7 +803,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo } // ─────────────────────────────────────────────────────────────────────── - mutateFn(mailData, { + mutateFn(mailData as unknown as ComposerRequest, { onSuccess: () => { if (isDraft) { isManualSaveRef.current = false; @@ -779,7 +821,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo emailIdRef.current = null; setIsInitialized(false); }, - onError: (err: any) => { + onError: (err) => { if (isDraft) { isManualSaveRef.current = false; } @@ -799,9 +841,21 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo const pendingCc = ccRef.current?.flush(); const pendingBcc = bccRef.current?.flush(); - const effectiveTo: any = [...(composerData.to || []), ...(pendingTo ? [pendingTo] : [])]; - const effectiveCc: any = [...(composerData.cc || []), ...(pendingCc ? [pendingCc] : [])]; - const effectiveBcc: any = [...(composerData.bcc || []), ...(pendingBcc ? [pendingBcc] : [])]; + // composerData.to/cc/bcc are declared as {email, name} (ComposerEmail) but + // RecipientField actually stores/emits {address, name} at runtime — see the + // ComposerBasicData note above. Cast once here to the shape actually used below. + const effectiveTo: RecipientEmailAddress[] = [ + ...((composerData.to || []) as unknown as RecipientEmailAddress[]), + ...(pendingTo ? [pendingTo] : []), + ]; + const effectiveCc: RecipientEmailAddress[] = [ + ...((composerData.cc || []) as unknown as RecipientEmailAddress[]), + ...(pendingCc ? [pendingCc] : []), + ]; + const effectiveBcc: RecipientEmailAddress[] = [ + ...((composerData.bcc || []) as unknown as RecipientEmailAddress[]), + ...(pendingBcc ? [pendingBcc] : []), + ]; if (effectiveTo.length === 0) { toast.error({ description: 'Please add at least one recipient.' }); @@ -810,9 +864,9 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo // ── Validate all recipient addresses ───────────────────────────────────── const allRecipients = [ - ...effectiveTo.map((r: any) => ({ ...r, field: 'To' })), - ...effectiveCc.map((r: any) => ({ ...r, field: 'Cc' })), - ...effectiveBcc.map((r: any) => ({ ...r, field: 'Bcc' })), + ...effectiveTo.map((r) => ({ ...r, field: 'To' })), + ...effectiveCc.map((r) => ({ ...r, field: 'Cc' })), + ...effectiveBcc.map((r) => ({ ...r, field: 'Bcc' })), ]; for (const recipient of allRecipients) { @@ -829,7 +883,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo } // ───────────────────────────────────────────────────────────────────────── - const overrides: any = {}; + const overrides: Record = {}; if (pendingTo) overrides.to = effectiveTo; if (pendingCc) overrides.cc = effectiveCc; if (pendingBcc) overrides.bcc = effectiveBcc; @@ -853,7 +907,9 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo const pendingBcc = bccRef.current?.flush(); // Calculate effective recipients for validation - const effectiveTo: any = [...(composerData.to || [])]; + const effectiveTo: RecipientEmailAddress[] = [ + ...((composerData.to || []) as unknown as RecipientEmailAddress[]), + ]; if (pendingTo) effectiveTo.push(pendingTo); if (isQuotaExceeded) { @@ -877,7 +933,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo hasAutoSavedRef.current = true; - const overrides: any = {}; + const overrides: Record = {}; if (pendingTo) overrides.to = effectiveTo; if (pendingCc) overrides.cc = [...(composerData.cc || []), pendingCc]; if (pendingBcc) overrides.bcc = [...(composerData.bcc || []), pendingBcc]; @@ -908,10 +964,11 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo setIsInitialized(false); }; - const updateComposerData = (fields: any) => { - setComposerData((prev) => ({ ...prev, ...fields })); + const updateComposerData = (fields: Record) => { + setComposerData((prev) => ({ ...prev, ...fields }) as unknown as ComposerEmail); - if (!isManualSaveRef.current && !isQuotaExceeded && fields?.text && fields.text.length > 5) { + const text = (fields as { text?: string }).text; + if (!isManualSaveRef.current && !isQuotaExceeded && text && text.length > 5) { debouncedSaveToDraft(); } }; @@ -919,14 +976,17 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo useEffect(() => { if (mode === 'new' && !isInitialized) { setComposerData((prev) => { - const from: any = prev.from_id || {}; + const from = (prev.from_id || {}) as { address?: string; name?: string }; return { ...prev, + // ComposerEmail has no `from` field (only `from_id`) — this is a + // pre-existing dead assignment, nothing reads `composerData.from`. + // Preserved as-is (see CLAUDE.md). from: { address: from?.address?.trim() ? from.address : '', name: from.name?.trim() ? from.name : 'Unknown Sender', }, - }; + } as unknown as ComposerEmail; }); setIsInitialized(true); } @@ -951,7 +1011,10 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo const onDrop = useCallback( async (acceptedFiles: File[]) => { const currentTotalSize = (composerData.attachments || []).reduce( - (sum: number, file: any) => sum + (file.size || 0), + // ComposerEmail declares attachments as {filename, mimeType, content}, but + // entries actually pushed below are {filename, mime_type, data, size} — + // a pre-existing shape mismatch, preserved as-is (see CLAUDE.md). + (sum: number, file) => sum + ((file as unknown as { size?: number }).size || 0), 0 ); const newFilesTotalSize = acceptedFiles.reduce((sum, file) => sum + file.size, 0); @@ -986,16 +1049,19 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo })) ); - setComposerData((prev: any) => ({ - ...prev, - attachments: [...(prev.attachments || []), ...base64Attachments], - })); + setComposerData( + (prev) => + ({ + ...prev, + attachments: [...(prev.attachments || []), ...base64Attachments], + }) as unknown as ComposerEmail + ); toast.success({ description: `Added ${validFiles.length} file(s)`, duration: 3000, }); - } catch (error) { + } catch { toast.error({ description: 'Failed to process files.', }); @@ -1051,7 +1117,8 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo onClick={handleSaveDraft} className={`flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors ${ isQuotaExceeded || - (!composerData.to?.length && !(toRef.current as any)?.inputValue) || + (!composerData.to?.length && + !(toRef.current as unknown as { inputValue?: string })?.inputValue) || isDrafting ? 'opacity-50 cursor-not-allowed text-[var(--gray-9)]' : 'text-[var(--gray-11)] hover:text-[var(--gray-12)] hover:bg-[var(--gray-3)]' @@ -1156,7 +1223,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo ref={toRef} label="To" placeholder="Recipients email address" - value={(composerData as any).to || []} + value={(composerData.to as unknown as RecipientEmailAddress[]) || []} onChange={(emailAddresses) => updateComposerData({ to: emailAddresses })} /> @@ -1180,7 +1247,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo ref={ccRef} label="Cc" placeholder="Enter cc email address" - value={(composerData as any).cc || []} + value={(composerData.cc as unknown as RecipientEmailAddress[]) || []} onChange={(emailAddresses) => updateComposerData({ cc: emailAddresses })} /> )} @@ -1190,7 +1257,7 @@ const EmailComposer = ({ email, mode, onClose, onSend, onSendDraft }: EmailCompo ref={bccRef} label="Bcc" placeholder="Enter bcc email address" - value={(composerData as any).bcc || []} + value={(composerData.bcc as unknown as RecipientEmailAddress[]) || []} onChange={(emailAddresses) => updateComposerData({ bcc: emailAddresses })} /> )} diff --git a/src/components/mailbox/EmailEmptyState.tsx b/src/components/mailbox/EmailList/EmailEmptyState.tsx similarity index 94% rename from src/components/mailbox/EmailEmptyState.tsx rename to src/components/mailbox/EmailList/EmailEmptyState.tsx index e092fc7..3bddb74 100644 --- a/src/components/mailbox/EmailEmptyState.tsx +++ b/src/components/mailbox/EmailList/EmailEmptyState.tsx @@ -53,6 +53,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr title: 'Your inbox is empty', description: 'All caught up! No new messages to display.', tip: 'New emails will appear here when they arrive.', + isError: false, }; } @@ -62,6 +63,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr title: 'No sent emails', description: "You haven't sent any emails yet.", tip: 'Compose and send your first email to see it here.', + isError: false, }; } @@ -71,6 +73,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr title: 'Trash is empty', description: 'No deleted emails in trash.', tip: 'Deleted emails will be stored here for 30 days.', + isError: false, }; } @@ -80,6 +83,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr title: 'No draft emails', description: "You don't have any saved drafts.", tip: 'Start composing an email and save it as draft.', + isError: false, }; } @@ -89,6 +93,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr title: 'No spam emails', description: 'Your spam folder is clean.', tip: 'Suspected spam emails will appear here.', + isError: false, }; } @@ -98,6 +103,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr title: 'No archived emails', description: "You haven't archived any emails yet.", tip: 'Archive important emails to keep your inbox organized.', + isError: false, }; } @@ -107,6 +113,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr title: 'Folder is empty', description: `No emails in ${folder}.`, tip: 'Move or filter emails to this folder to organize them.', + isError: false, }; }; @@ -120,7 +127,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr
{content.icon} @@ -134,7 +141,7 @@ const EmailEmptyState = ({ folder = 'INBOX', error, onRetry }: EmailEmptyStatePr
{/* Retry Button (Only for errors) */} - {(content as any).isError && onRetry && ( + {content.isError && onRetry && (
- 1 / ${slideFiles.length} - -
-
- ${slideHtmlParts.join('')} -
-
- ${slideHtmlParts - .map( - ( - _, - i - ) => `
-
Slide ${i + 1}
-
` - ) - .join('')} -
- - -`; -}; const escHtml = (str: string): string => str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); @@ -837,7 +657,7 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps }, []); const base64ToFile = useCallback( - (attachment: any): File | null => { + (attachment: EmailAttachment): File | null => { const mime = normalizeMimeType(attachment.mimeType, attachment.filename); const blob = base64ToBlob(attachment.content, mime); if (!blob) return null; @@ -849,7 +669,7 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps // ─── Download / Copy / Share ─────────────────────────────────────────────── const handleDownload = useCallback( - (attachment: any) => { + (attachment: EmailAttachment) => { const mime = normalizeMimeType(attachment.mimeType, attachment.filename); const blob = base64ToBlob(attachment.content, mime); if (!blob) return; @@ -866,7 +686,7 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps ); const handleCopyToClipboard = useCallback( - async (attachment: any) => { + async (attachment: EmailAttachment) => { const file = base64ToFile(attachment); if (!file) { toast.error({ description: 'Failed to prepare file' }); @@ -879,11 +699,11 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps toast.error({ description: 'Failed to copy file' }); } }, - [base64ToFile] + [base64ToFile, toast] ); const handleShare = useCallback( - async (attachment: any) => { + async (attachment: EmailAttachment) => { const file = base64ToFile(attachment); if (!file) { toast.error({ description: 'Failed to prepare file' }); @@ -903,7 +723,7 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps handleDownload(attachment); toast.success({ description: `📁 Downloaded "${attachment.filename}"` }); }, - [base64ToFile, canShare, handleDownload] + [base64ToFile, canShare, handleDownload, toast] ); // ─── Preview capability ──────────────────────────────────────────────────── @@ -921,7 +741,7 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps // ─── Blob URL creation ───────────────────────────────────────────────────── const createBlobUrl = useCallback( - (attachment: any, index: number): string | null => { + (attachment: EmailAttachment, index: number): string | null => { if (blobUrls.has(index)) return blobUrls.get(index)!; const mime = normalizeMimeType(attachment.mimeType, attachment.filename); const blob = base64ToBlob(attachment.content, mime); @@ -936,7 +756,7 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps // ─── Office + EML HTML rendering ────────────────────────────────────────── const processOfficePreview = useCallback( - async (attachment: any, index: number) => { + async (attachment: EmailAttachment, index: number) => { if (officeHtml.has(index)) return; const mime = normalizeMimeType(attachment.mimeType, attachment.filename); @@ -956,8 +776,9 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps next.delete(index); return next; }); - } catch (e: any) { - setOfficeError((prev) => new Map(prev).set(index, e?.message || 'Preview failed')); + } catch (e) { + const message = e instanceof Error ? e.message : 'Preview failed'; + setOfficeError((prev) => new Map(prev).set(index, message)); } finally { setLoadingIndex(null); } @@ -1004,7 +825,7 @@ const EmailAttachments = ({ attachments, emailHtml = '' }: EmailAttachmentsProps // ─── Render preview content ──────────────────────────────────────────────── - const renderPreviewContent = (attachment: any, index: number) => { + const renderPreviewContent = (attachment: EmailAttachment, index: number) => { const mime = normalizeMimeType(attachment.mimeType, attachment.filename); // EML / Office files rendered as HTML diff --git a/src/components/mailbox/EmailHtmlContent.tsx b/src/components/mailbox/EmailList/EmailViewer/EmailTabs/EmailHtmlContent.tsx similarity index 97% rename from src/components/mailbox/EmailHtmlContent.tsx rename to src/components/mailbox/EmailList/EmailViewer/EmailTabs/EmailHtmlContent.tsx index bf63f95..e05f612 100644 --- a/src/components/mailbox/EmailHtmlContent.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/EmailTabs/EmailHtmlContent.tsx @@ -18,12 +18,19 @@ import { Button } from '@radix-ui/themes'; import { useRef, useState, useEffect, useCallback } from 'react'; import { FiAlertTriangle, FiEye, FiEyeOff, FiLock } from 'react-icons/fi'; -import { sanitizeHTMLContent } from '../../utils/sanitizeHTMLContent'; +import { sanitizeHTMLContent } from '../../../../../utils/sanitizeHTMLContent'; import LinkPreviewTooltip from './LinkPreviewTooltip'; +interface HtmlContentAttachment { + filename?: string; + contentId?: string; + content?: string; + mimeType?: string; +} + interface EmailHtmlContentProps { htmlContent: string; - attachments?: any[]; + attachments?: HtmlContentAttachment[]; allowExternalContent: boolean; onToggleExternalContent: (allow: boolean) => void; } diff --git a/src/components/mailbox/EmailTextContent.tsx b/src/components/mailbox/EmailList/EmailViewer/EmailTabs/EmailTextContent.tsx similarity index 96% rename from src/components/mailbox/EmailTextContent.tsx rename to src/components/mailbox/EmailList/EmailViewer/EmailTabs/EmailTextContent.tsx index 9c9be06..7298fac 100644 --- a/src/components/mailbox/EmailTextContent.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/EmailTabs/EmailTextContent.tsx @@ -16,7 +16,7 @@ */ import { useState, useRef, useEffect } from 'react'; -import { FiChevronDown, FiChevronUp, FiCopy, FiCheck } from 'react-icons/fi'; +import { FiCopy, FiCheck } from 'react-icons/fi'; import LinkPreviewTooltip from './LinkPreviewTooltip'; interface EmailTextContentProps { @@ -90,7 +90,7 @@ const renderRichText = (text: string) => { }; const EmailTextContent = ({ textContent, maxHeight = 500 }: EmailTextContentProps) => { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded] = useState(false); const [showExpandButton, setShowExpandButton] = useState(false); const [isCopied, setIsCopied] = useState(false); const contentRef = useRef(null); @@ -102,10 +102,6 @@ const EmailTextContent = ({ textContent, maxHeight = 500 }: EmailTextContentProp } }, [textContent, maxHeight]); - const toggleExpand = () => { - setIsExpanded(!isExpanded); - }; - const handleCopyText = async () => { try { await navigator.clipboard.writeText(textContent); diff --git a/src/components/mailbox/ICSViewer.tsx b/src/components/mailbox/EmailList/EmailViewer/EmailTabs/ICSViewer.tsx similarity index 93% rename from src/components/mailbox/ICSViewer.tsx rename to src/components/mailbox/EmailList/EmailViewer/EmailTabs/ICSViewer.tsx index 0b0722d..7bbf805 100644 --- a/src/components/mailbox/ICSViewer.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/EmailTabs/ICSViewer.tsx @@ -41,6 +41,23 @@ interface ICSViewerProps { }) => void; } +interface EventAttendee { + name: string; + email: string; + status: string; +} + +interface EventInfo { + summary: string; + description: string; + location: string; + start?: Date; + end?: Date; + organizerEmail: string; + existingStatus: string | null; + attendees: EventAttendee[]; +} + function buildReplyICS( originalIcs: string, attendeeEmail: string, @@ -83,7 +100,7 @@ export const ICSViewer = ({ icsData, currentUserEmail, onSendReply }: ICSViewerP const [rsvpStatus, setRsvpStatus] = useState<'ACCEPTED' | 'DECLINED' | 'TENTATIVE' | null>(null); const [sending, setSending] = useState(false); - let event: any = null; + let event: EventInfo | null = null; let method = 'REQUEST'; let organizerEmail = ''; @@ -99,12 +116,12 @@ export const ICSViewer = ({ icsData, currentUserEmail, onSendReply }: ICSViewerP organizerEmail = organizerRaw.replace(/^mailto:/i, ''); // Find current user's existing RSVP status - let existingStatus: any = null; + let existingStatus: string | null = null; if (currentUserEmail) { - vevent.getAllProperties('attendee').forEach((a: any) => { - const email = (a.getFirstValue() || '').replace(/^mailto:/i, ''); + vevent.getAllProperties('attendee').forEach((a: ICAL.Property) => { + const email = String(a.getFirstValue() || '').replace(/^mailto:/i, ''); if (email.toLowerCase() === currentUserEmail.toLowerCase()) { - existingStatus = a.getParameter('partstat') || null; + existingStatus = (a.getParameter('partstat') as string) || null; } }); } @@ -120,10 +137,10 @@ export const ICSViewer = ({ icsData, currentUserEmail, onSendReply }: ICSViewerP end: icalEvent.endDate?.toJSDate(), organizerEmail, existingStatus, - attendees: vevent.getAllProperties('attendee').map((a: any) => ({ - name: a.getParameter('cn') || '', - email: (a.getFirstValue() || '').replace(/^mailto:/i, ''), - status: a.getParameter('partstat') || 'NEEDS-ACTION', + attendees: vevent.getAllProperties('attendee').map((a: ICAL.Property) => ({ + name: (a.getParameter('cn') as string) || '', + email: String(a.getFirstValue() || '').replace(/^mailto:/i, ''), + status: (a.getParameter('partstat') as string) || 'NEEDS-ACTION', })), }; } catch { @@ -295,7 +312,7 @@ export const ICSViewer = ({ icsData, currentUserEmail, onSendReply }: ICSViewerP Attendees ({event.attendees.length})

- {event.attendees.map((a: any, i: number) => ( + {event.attendees.map((a, i: number) => ( { +const getIcsData = (parsedEmail: ParsedEmailForTabs): string | null => { const calendarPart = parsedEmail?.parts?.find( - (p: any) => p.mimeType === 'text/calendar' || p.contentType?.includes('text/calendar') + (p) => p.mimeType === 'text/calendar' || p.contentType?.includes('text/calendar') ); const icsAttachment = parsedEmail?.attachments?.find( - (a: any) => + (a) => a.filename?.endsWith('.ics') || a.mimeType === 'text/calendar' || a.contentType?.includes('text/calendar') @@ -68,14 +90,13 @@ const getIcsData = (parsedEmail: ParsedEmail): string | null => { return raw; }; -const EmailTabs = ({ parsedEmail, rawEmail }: EmailTabsProps) => { +const EmailTabs = ({ parsedEmail }: EmailTabsProps) => { const icsData = getIcsData(parsedEmail); const defaultTab = parsedEmail.html ? 'html' : parsedEmail.text ? 'text' : 'attachments'; const [allowExternalContent, setAllowExternalContent] = useState(false); const userDetails = useAtomValue(userDetailsAtom); const currentEmail = useAtomValue(emailAddress); - const userSettings = useAtomValue(userSettingsAtom); const { mutate: sendMutate } = useSendMail(); const toast = useToast(); @@ -134,9 +155,9 @@ const EmailTabs = ({ parsedEmail, rawEmail }: EmailTabsProps) => { } ); - sendMutate(mailData as any, { + sendMutate(mailData as unknown as ComposerRequest, { onSuccess: () => toast.success({ description: 'RSVP response sent to organizer.' }), - onError: (err: any) => toast.error({ description: err?.message || 'Failed to send RSVP.' }), + onError: (err) => toast.error({ description: err?.message || 'Failed to send RSVP.' }), }); }; @@ -177,7 +198,10 @@ const EmailTabs = ({ parsedEmail, rawEmail }: EmailTabsProps) => { )} - + diff --git a/src/components/mailbox/RecipientSection.tsx b/src/components/mailbox/EmailList/EmailViewer/RecipientSection.tsx similarity index 97% rename from src/components/mailbox/RecipientSection.tsx rename to src/components/mailbox/EmailList/EmailViewer/RecipientSection.tsx index a1511fd..8a37700 100644 --- a/src/components/mailbox/RecipientSection.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/RecipientSection.tsx @@ -17,8 +17,8 @@ import { useState, useRef, useLayoutEffect } from 'react'; import { ContextMenu } from '@radix-ui/themes'; -import { getInitialName } from '../../utils/emailUtils'; -import { useToast } from '../ui/ToastComponent'; +import { getInitialName } from '../../../../utils/emailUtils'; +import { useToast } from '../../../../hooks/useToast'; interface Recipient { name: string; diff --git a/src/components/mailbox/SingleEmailView.tsx b/src/components/mailbox/EmailList/EmailViewer/SingleEmailView.tsx similarity index 86% rename from src/components/mailbox/SingleEmailView.tsx rename to src/components/mailbox/EmailList/EmailViewer/SingleEmailView.tsx index 0b507db..0937e0d 100644 --- a/src/components/mailbox/SingleEmailView.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/SingleEmailView.tsx @@ -16,25 +16,26 @@ */ // src/components/email/SingleEmailView.tsx -import { Separator, Popover, Button } from '@radix-ui/themes'; -import { FaCalendarAlt, FaEnvelope, FaInfoCircle, FaTimes, FaFlag } from 'react-icons/fa'; -import BIMIAvatar from '../common/BimiAvatar'; +import { Popover, Button } from '@radix-ui/themes'; +import { FaCalendarAlt, FaInfoCircle, FaTimes, FaFlag } from 'react-icons/fa'; +import BIMIAvatar from '../../../common/BimiAvatar'; import EmailLoadingState from './EmailLoadingState'; import EmailParsingState from './EmailParsingState'; import EmailErrorState from './EmailErrorState'; import EmailNoDataState from './EmailNoDataState'; -import EmailTabs from './EmailTabs'; +import EmailTabs, { type ParsedEmailForTabs } from './EmailTabs'; import { RecipientSection } from './RecipientSection'; -import { parseEmail } from '../../utils/emailPerser'; -import { decodeWords } from 'postal-mime'; -import { parseMultipleEmails, getInitialName, normalizeFieldNames } from '../../utils/emailUtils'; +import { parseEmail } from '../../../../utils/emailPerser'; +import { decodeWords, type Email as ParsedPostalEmail, type Attachment } from 'postal-mime'; +import { parseMultipleEmails, normalizeFieldNames } from '../../../../utils/emailUtils'; +import type { EmailLike } from '../../../../utils/emailThreading'; interface SingleEmailViewProps { rawEmail: string | undefined; isLoading: boolean; isParsing: boolean; parseError: string | null; - parsedEmail: any; + parsedEmail: ParsedPostalEmail | null; headers: Record; subject: string; senderEmail: string; @@ -44,9 +45,9 @@ interface SingleEmailViewProps { folder?: string; splitView?: boolean; onBack?: () => void; - onDraftSend?: (email: any) => void; + onDraftSend?: (email: EmailLike) => void; onContentLoaded?: (content: string) => void; - onAttachmentsLoaded?: (attachments: any[]) => void; + onAttachmentsLoaded?: (attachments: Attachment[]) => void; messageId: string; folderPath?: string; formatUserDateNice: (date: string) => string; @@ -70,8 +71,6 @@ export const SingleEmailView = ({ splitView, onBack, onDraftSend, - onContentLoaded, - onAttachmentsLoaded, messageId, folderPath, formatUserDateNice, @@ -80,9 +79,9 @@ export const SingleEmailView = ({ }: SingleEmailViewProps) => { const normalizedHeaders = normalizeFieldNames(headers); - const toRecipients = parseMultipleEmails(normalizedHeaders.to || ''); - const ccRecipients = parseMultipleEmails(normalizedHeaders.cc || ''); - const bccRecipients = parseMultipleEmails(normalizedHeaders.bcc || ''); + const toRecipients = parseMultipleEmails((normalizedHeaders.to as string) || ''); + const ccRecipients = parseMultipleEmails((normalizedHeaders.cc as string) || ''); + const bccRecipients = parseMultipleEmails((normalizedHeaders.bcc as string) || ''); const { name: senderName, email: senderEmailParsed } = parseEmail(senderEmail); const handleEditDraft = () => { @@ -200,7 +199,7 @@ export const SingleEmailView = ({ {!isLoading && !isParsing && !parseError && parsedEmail && ( )} diff --git a/src/components/mailbox/EmailActions.tsx b/src/components/mailbox/EmailList/EmailViewer/ThreadView/ThreadEmailCard/EmailActions.tsx similarity index 77% rename from src/components/mailbox/EmailActions.tsx rename to src/components/mailbox/EmailList/EmailViewer/ThreadView/ThreadEmailCard/EmailActions.tsx index 67b8bb0..d41acdd 100644 --- a/src/components/mailbox/EmailActions.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/ThreadView/ThreadEmailCard/EmailActions.tsx @@ -37,16 +37,17 @@ import { MdPersonAdd, MdSaveAs, } from 'react-icons/md'; -import DropdownWrapper from '../common/DropdownWrapper'; +import DropdownWrapper from '../../../../../common/DropdownWrapper'; import { FaEllipsisVertical, FaRotateRight } from 'react-icons/fa6'; +import type { EmailLike } from '../../../../../../utils/emailThreading'; interface EmailActionsProps { - email: any; - onReply: (email: any) => void; - onEditAsNew: (email: any) => void; - onSaveAsTemplate: (email: any) => void; - onReplyAll: (email: any) => void; - onForward: (email: any) => void; + email: EmailLike; + onReply: (email: EmailLike) => void; + onEditAsNew: (email: EmailLike) => void; + onSaveAsTemplate: (email: EmailLike) => void; + onReplyAll: (email: EmailLike) => void; + onForward: (email: EmailLike) => void; onForwardAsAttachment?: () => void; onDelete: (emailId: string) => void; onMove?: () => void; @@ -56,10 +57,10 @@ interface EmailActionsProps { handleSingleEmailMarkAsRead?: (emailId: string, action: boolean) => void; reFetchMails?: () => void; - onPrint?: (email: any) => void; - onDownload?: (email: any) => void; - onViewInWindow?: (email: any) => void; - onViewInRaw?: (email: any) => void; + onPrint?: (email: EmailLike) => void; + onDownload?: (email: EmailLike) => void; + onViewInWindow?: (email: EmailLike) => void; + onViewInRaw?: (email: EmailLike) => void; onSaveAsContact?: () => void; } @@ -77,47 +78,14 @@ export const EmailActions = ({ showDelete = true, handleSingleEmailMarkAsFlagged, handleSingleEmailMarkAsRead, - reFetchMails, onPrint, onDownload, onViewInWindow, onViewInRaw, onSaveAsContact, }: EmailActionsProps) => { - const isFlagged = email?.FLAGS.includes('\\Flagged'); - const isRead = email?.FLAGS.includes('\\Seen'); - - const ActionButton = ({ - icon: Icon, - label, - onClick, - variant = 'default', - }: { - icon: React.ElementType; - label: string; - onClick: (e: React.MouseEvent) => void; - variant?: 'default' | 'danger'; - }) => ( - - - - ); - - const Divider = () =>
; + const isFlagged = !!email?.FLAGS?.includes('\\Flagged'); + const isRead = !!email?.FLAGS?.includes('\\Seen'); const moreItems = [ { @@ -185,13 +153,13 @@ export const EmailActions = ({ key: 'mark-read', label: isRead ? 'Mark as Unread' : 'Mark as Read', icon: isRead ? MdMarkEmailUnread : MdMarkEmailRead, - onSelect: () => handleSingleEmailMarkAsRead?.(email?.id, !isRead), + onSelect: () => handleSingleEmailMarkAsRead?.(String(email?.id ?? ''), !isRead), }, { key: 'flag', label: isFlagged ? 'Remove Flag' : 'Add Flag', icon: isFlagged ? LuFlagOff : FaRegFlag, - onSelect: () => handleSingleEmailMarkAsFlagged?.(email?.id, isFlagged), + onSelect: () => handleSingleEmailMarkAsFlagged?.(String(email?.id ?? ''), isFlagged), }, // View / export group { key: 'sep-more', label: '', separator: true }, @@ -205,7 +173,7 @@ export const EmailActions = ({ label: 'Delete', icon: FaTrash, color: 'red', - onSelect: () => onDelete(email.id), + onSelect: () => onDelete(String(email.id)), }, ] : []), diff --git a/src/components/mailbox/ThreadEmailCard.tsx b/src/components/mailbox/EmailList/EmailViewer/ThreadView/ThreadEmailCard/index.tsx similarity index 84% rename from src/components/mailbox/ThreadEmailCard.tsx rename to src/components/mailbox/EmailList/EmailViewer/ThreadView/ThreadEmailCard/index.tsx index 520873d..5314fb1 100644 --- a/src/components/mailbox/ThreadEmailCard.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/ThreadView/ThreadEmailCard/index.tsx @@ -17,12 +17,12 @@ import { Separator, Popover, Button } from '@radix-ui/themes'; import { useEffect, useRef, useState, useCallback } from 'react'; -import { useEmailRaw } from '../../hooks/useEmailRaw'; -import EmailLoadingState from './EmailLoadingState'; -import EmailParsingState from './EmailParsingState'; -import EmailErrorState from './EmailErrorState'; -import EmailNoDataState from './EmailNoDataState'; -import EmailTabs from './EmailTabs'; +import { useEmailRaw } from '../../../../../../hooks/useEmailRaw'; +import EmailLoadingState from '../../EmailLoadingState'; +import EmailParsingState from '../../EmailParsingState'; +import EmailErrorState from '../../EmailErrorState'; +import EmailNoDataState from '../../EmailNoDataState'; +import EmailTabs, { type ParsedEmailForTabs } from '../../EmailTabs'; import PostalMime, { decodeWords } from 'postal-mime'; import { FaCalendarAlt, @@ -33,19 +33,19 @@ import { FaRegFolder, FaFlag, } from 'react-icons/fa'; -import BIMIAvatar from '../common/BimiAvatar'; +import BIMIAvatar from '../../../../../common/BimiAvatar'; import { Link, useParams } from '@tanstack/react-router'; -import { parseEmail } from '../../utils/emailPerser'; -import { useUserTimezone } from '../../hooks/useTimezone'; +import { parseEmail } from '../../../../../../utils/emailPerser'; +import { useUserTimezone } from '../../../../../../hooks/useTimezone'; import { EmailActions } from './EmailActions'; -import { RecipientSection } from './RecipientSection'; +import { RecipientSection } from '../../RecipientSection'; import { extractHeaders, getMessageId, normalizeFieldNames, parseMultipleEmails, -} from '../../utils/emailUtils'; -import { useToast } from '../ui/ToastComponent'; +} from '../../../../../../utils/emailUtils'; +import { useToast } from '../../../../../../hooks/useToast'; import { useCopyMail, useMoveMail, @@ -53,16 +53,17 @@ import { useUnseenMail, useFlaggedMail, useUnFlaggedMail, -} from '../../hooks/useEmails'; +} from '../../../../../../hooks/useEmails'; import { useAtomValue } from 'jotai'; -import { folderQuotaAtom } from '../../state/folders'; -import { useEmailCacheUpdater } from '../../hooks/useEmailCacheUpdater'; -import { useUpdateFolderUnreadCount } from '../../hooks/useFolders'; -import FolderDialog from './MoveEmail'; -import { printEmail, viewEmailInWindow, viewEmailRaw } from '../../utils/emailPrint'; -import type { Email } from '../../api/mailbox'; -import { userDetailsAtom } from '../../state/userDetails'; -import { useCreateEmailTemplate, useTemplateActions } from '../../hooks/useTempelate'; +import { folderQuotaAtom } from '../../../../../../state/folders'; +import { useEmailCacheUpdater } from '../../../../../../hooks/useEmailCacheUpdater'; +import { useUpdateFolderUnreadCount } from '../../../../../../hooks/useFolders'; +import FolderDialog from '../../../MoveEmail'; +import { printEmail, viewEmailInWindow, viewEmailRaw } from '../../../../../../utils/emailPrint'; +import { userDetailsAtom } from '../../../../../../state/userDetails'; +import { useCreateEmailTemplate, useTemplateActions } from '../../../../../../hooks/useTempelate'; +import type { EmailLike } from '../../../../../../utils/emailThreading'; +import type { Email as ParsedEmail } from 'postal-mime'; interface Folder { id: string; @@ -74,15 +75,15 @@ interface Folder { } interface ThreadEmailCardProps { - threadEmail: any; + threadEmail: EmailLike; folderPath: string; isCurrentEmail: boolean; onContentLoaded?: (content: string) => void; foundedIn: string; - onReply?: (email: any) => void; - onForward?: (email: any) => void; - onForwardAsAttachment?: (email: any, rawContent: string) => void; - onReplyAll?: (email: any) => void; + onReply?: (email: EmailLike) => void; + onForward?: (email: EmailLike) => void; + onForwardAsAttachment?: (email: EmailLike, rawContent: string) => void; + onReplyAll?: (email: EmailLike) => void; handleSingleEmailDelete?: (emailId: string) => void; onBack?: () => void; FilteredListLength?: (emailId: string) => void; @@ -90,7 +91,7 @@ interface ThreadEmailCardProps { handleSingleEmailMarkAsRead?: (emailId: string, action: boolean) => void; reFetchMails?: () => void; onSaveAsContact?: () => void; - onEditAsNew?: (email: any) => void; + onEditAsNew?: (email: EmailLike) => void; } const ThreadEmailCard = ({ @@ -104,10 +105,7 @@ const ThreadEmailCard = ({ onForwardAsAttachment, onReplyAll = () => {}, handleSingleEmailDelete = () => {}, - onBack = () => {}, FilteredListLength = () => {}, - handleSingleEmailMarkAsFlagged = () => {}, - handleSingleEmailMarkAsRead = () => {}, reFetchMails = () => {}, onEditAsNew = () => {}, onSaveAsContact, @@ -144,31 +142,31 @@ const ThreadEmailCard = ({ data: rawEmail, isLoading, refetch, - } = useEmailRaw(threadEmail.id.toString(), folderPath, getMessageId(threadEmail) || '', false); + } = useEmailRaw(String(threadEmail.id), folderPath, getMessageId(threadEmail) || '', false); const { patchEmailFlags } = useEmailCacheUpdater(folderPath); const updateFolderUnreadCount = useUpdateFolderUnreadCount(folderPath); - const [parsedEmail, setParsedEmail] = useState(null); + const [parsedEmail, setParsedEmail] = useState(null); const [parseError, setParseError] = useState(null); const [isParsing, setIsParsing] = useState(false); - const [headers, setHeaders] = useState>({}); + const [, setHeaders] = useState>({}); const lastParsedEmailId = useRef(''); const [isHeaderPopoverOpen, setIsHeaderPopoverOpen] = useState(false); - const userDetails = useAtomValue(userDetailsAtom) || ''; - const { name: senderName, email: senderEmailParsed } = parseEmail(threadEmail.From); + const userDetails = useAtomValue(userDetailsAtom); + const { name: senderName, email: senderEmailParsed } = parseEmail(threadEmail.From as string); const normalizedHeaders = normalizeFieldNames(threadEmail); - const toRecipients = parseMultipleEmails(normalizedHeaders.to || ''); - const ccRecipients = parseMultipleEmails(normalizedHeaders.cc || ''); - const bccRecipients = parseMultipleEmails(normalizedHeaders.bcc || ''); + const toRecipients = parseMultipleEmails((normalizedHeaders.to as string) || ''); + const ccRecipients = parseMultipleEmails((normalizedHeaders.cc as string) || ''); + const bccRecipients = parseMultipleEmails((normalizedHeaders.bcc as string) || ''); const { formatUserDateNice } = useUserTimezone(); const createTemplateMutation = useCreateEmailTemplate(); - const handleDelete = async (id: any) => { + const handleDelete = async (id: string) => { handleSingleEmailDelete(id); }; @@ -196,11 +194,11 @@ const ThreadEmailCard = ({ [onContentLoaded] ); - const handleSaveAsTemplate = async (email: Email) => { + const handleSaveAsTemplate = async (email: EmailLike) => { try { // 1. Fetch full details via the raw API (as required by your backend) const { subject, body } = await prepareTemplateFromCache( - email.id.toString(), + String(email.id), email.folderPath || 'INBOX' ); @@ -219,7 +217,7 @@ const ThreadEmailCard = ({ toast.success({ description: 'Saved! View it in Settings > Templates', }); - } catch (err) { + } catch { toast.error({ description: 'Failed to process email for template', }); @@ -311,16 +309,16 @@ const ThreadEmailCard = ({ path: actualFolderPath, sourceFolder: actualFolderPath, destFolder: folder?.path || folderName || '', - body: [threadEmail?.id], + body: [Number(threadEmail?.id)], }, { onSuccess: () => { setDialogOpen(false); setCopyDialogOpen(false); setSelectedFolder(''); - FilteredListLength(threadEmail?.id); + FilteredListLength(String(threadEmail?.id)); }, - onError: (error: any) => { + onError: () => { if (action === 'copy' && isQuotaExceeded) { toast.error({ description: 'Failed to copy: Storage quota exceeded.', @@ -351,14 +349,14 @@ const ThreadEmailCard = ({ setCopyDialogOpen(true); }; - const handleThreadPrint = (email: any) => { + const handleThreadPrint = (email: EmailLike) => { // Use parsedEmail state we already have in this component const content = parsedEmail?.html || parsedEmail?.text || ''; const attachments = parsedEmail?.attachments || []; printEmail(email, content, attachments); }; - const handleThreadDownload = async (email: any) => { + const handleThreadDownload = async (email: EmailLike) => { try { // If we don't have rawEmail in state yet, we refetch it const content = rawEmail || (await refetch()).data; @@ -368,11 +366,11 @@ const ThreadEmailCard = ({ const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${email.Subject.replace(/[^a-z0-9]/gi, '_')}.eml`; + a.download = `${(email.Subject || 'email').replace(/[^a-z0-9]/gi, '_')}.eml`; document.body.appendChild(a); a.click(); document.body.removeChild(a); - } catch (err) { + } catch { toast.error({ description: 'Failed to download email' }); } }; @@ -390,8 +388,8 @@ const ThreadEmailCard = ({ } }; - const isFlagged = threadEmail?.FLAGS.includes('\\Flagged'); - const readStatus = threadEmail?.FLAGS.includes('\\Seen'); + const isFlagged = threadEmail?.FLAGS?.includes('\\Flagged'); + const readStatus = threadEmail?.FLAGS?.includes('\\Seen'); const isSent = folderPath.toLowerCase() === 'sent' || foundedIn?.toLowerCase() === 'sent'; const isTrash = folderPath.toLowerCase() === 'trash' || foundedIn?.toLowerCase() === 'trash'; @@ -410,13 +408,18 @@ const ThreadEmailCard = ({ seenMutate( { path: actualFolderPath, body: [emailIdNum] }, { - onSuccess: (res: any) => { + // mutate's declared response type (EmailFolders) doesn't match what the + // backend actually sends back ({ message }) — pre-existing API-layer + // mismatch, preserved via cast rather than "fixed" here. + onSuccess: (res) => { toast.dismiss(loadingId); - toast.success({ description: res?.message || 'Marked as read.' }); + toast.success({ + description: (res as unknown as { message?: string })?.message || 'Marked as read.', + }); patchEmailFlags([emailIdNum], '\\Seen'); if (wasUnread) updateFolderUnreadCount(-1); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error?.message || 'Failed to mark as read.' }); }, @@ -426,13 +429,16 @@ const ThreadEmailCard = ({ unseenMutate( { path: actualFolderPath, body: [emailIdNum] }, { - onSuccess: (res: any) => { + onSuccess: (res) => { toast.dismiss(loadingId); - toast.success({ description: res?.message || 'Marked as unread.' }); + toast.success({ + description: + (res as unknown as { message?: string })?.message || 'Marked as unread.', + }); patchEmailFlags([emailIdNum], undefined, '\\Seen'); if (!wasUnread) updateFolderUnreadCount(1); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error?.message || 'Failed to mark as unread.' }); }, @@ -449,12 +455,14 @@ const ThreadEmailCard = ({ unflagMutate( { path: actualFolderPath, body: [emailIdNum] }, { - onSuccess: (res: any) => { + onSuccess: (res) => { toast.dismiss(loadingId); - toast.success({ description: res?.message || 'Email unflagged.' }); + toast.success({ + description: (res as unknown as { message?: string })?.message || 'Email unflagged.', + }); patchEmailFlags([emailIdNum], undefined, '\\Flagged'); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error?.message || 'Failed to unflag email.' }); }, @@ -464,12 +472,14 @@ const ThreadEmailCard = ({ flagMutate( { path: actualFolderPath, body: [emailIdNum] }, { - onSuccess: (res: any) => { + onSuccess: (res) => { toast.dismiss(loadingId); - toast.success({ description: res?.message || 'Email flagged.' }); + toast.success({ + description: (res as unknown as { message?: string })?.message || 'Email flagged.', + }); patchEmailFlags([emailIdNum], '\\Flagged'); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error?.message || 'Failed to flag email.' }); }, @@ -613,7 +623,7 @@ const ThreadEmailCard = ({ className={`text-sm ${readStatus ? 'font-medium' : ''} text-[var(--gray-11)] mb-2 line-clamp-2 md:line-clamp-1 cursor-pointer`} onClick={toggleExpanded} > - {decodeWords(threadEmail.Subject) || '(No Subject)'} + {decodeWords(threadEmail.Subject || '') || '(No Subject)'}
{/* Row 3: Date + Email Headers popover */} @@ -671,7 +681,7 @@ const ThreadEmailCard = ({ {!isLoading && !isParsing && !parseError && parsedEmail && ( )} diff --git a/src/components/mailbox/ThreadLoadingState.tsx b/src/components/mailbox/EmailList/EmailViewer/ThreadView/ThreadLoadingState.tsx similarity index 100% rename from src/components/mailbox/ThreadLoadingState.tsx rename to src/components/mailbox/EmailList/EmailViewer/ThreadView/ThreadLoadingState.tsx diff --git a/src/components/mailbox/ThreadView.tsx b/src/components/mailbox/EmailList/EmailViewer/ThreadView/index.tsx similarity index 89% rename from src/components/mailbox/ThreadView.tsx rename to src/components/mailbox/EmailList/EmailViewer/ThreadView/index.tsx index 12eabe6..6bf23d9 100644 --- a/src/components/mailbox/ThreadView.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/ThreadView/index.tsx @@ -21,13 +21,13 @@ import { FaEnvelope, FaTimes } from 'react-icons/fa'; import { BiRefresh } from 'react-icons/bi'; import ThreadEmailCard from './ThreadEmailCard'; import ThreadLoadingState from './ThreadLoadingState'; -import { LuMessageCircleWarning } from 'react-icons/lu'; import { useAtomValue } from 'jotai'; -import { userSettingsAtom } from '../../state/settings'; +import { userSettingsAtom } from '../../../../../state/settings'; import { useMemo } from 'react'; +import type { EmailLike } from '../../../../../utils/emailThreading'; interface ThreadViewProps { - listofThreadEmails: any[]; + listofThreadEmails: EmailLike[]; messageId: string; folderPath?: string; date: string; @@ -35,11 +35,11 @@ interface ThreadViewProps { splitView?: boolean; onBack?: () => void; formatUserDateNice: (date: string) => string; - onReply?: (email: any) => void; - onForward?: (email: any) => void; - onForwardAsAttachment?: (email: any, rawContent: string) => void; - onEditAsNew?: (email: any) => void; - onReplyAll?: (email: any) => void; + onReply?: (email: EmailLike) => void; + onForward?: (email: EmailLike) => void; + onForwardAsAttachment?: (email: EmailLike, rawContent: string) => void; + onEditAsNew?: (email: EmailLike) => void; + onReplyAll?: (email: EmailLike) => void; handleSingleEmailDelete?: (emailId: string) => void; isLoadingInitial: boolean; FilteredListLength?: (emailId: string) => void; @@ -76,6 +76,21 @@ export const ThreadView = ({ const userSettings = useAtomValue(userSettingsAtom); const sortOrder = userSettings?.email?.thread_sort_order || 'desc'; + // Must run unconditionally (rules-of-hooks) — moved above the isLoadingInitial + // early return below, which previously made this hook call conditional. + const sortedEmails = useMemo(() => { + return [...listofThreadEmails].sort((a, b) => { + const timeA = new Date(a.Date as string).getTime() || 0; + const timeB = new Date(b.Date as string).getTime() || 0; + + if (sortOrder === 'desc') { + return timeB - timeA; + } else { + return timeA - timeB; + } + }); + }, [listofThreadEmails, sortOrder]); + if (isLoadingInitial) { return (
@@ -94,19 +109,6 @@ export const ThreadView = ({ ); } - const sortedEmails = useMemo(() => { - return [...listofThreadEmails].sort((a, b) => { - const timeA = new Date(a.Date).getTime() || 0; - const timeB = new Date(b.Date).getTime() || 0; - - if (sortOrder === 'desc') { - return timeB - timeA; - } else { - return timeA - timeB; - } - }); - }, [listofThreadEmails, sortOrder]); - return (
) : (
- {sortedEmails.map((threadEmail: any, index: number) => ( + {sortedEmails.map((threadEmail, index: number) => (
string; splitView?: boolean; diff --git a/src/components/mailbox/EmailViewer.tsx b/src/components/mailbox/EmailList/EmailViewer/index.tsx similarity index 85% rename from src/components/mailbox/EmailViewer.tsx rename to src/components/mailbox/EmailList/EmailViewer/index.tsx index 9a40635..1b53434 100644 --- a/src/components/mailbox/EmailViewer.tsx +++ b/src/components/mailbox/EmailList/EmailViewer/index.tsx @@ -17,25 +17,27 @@ // src/components/email/EmailViewer.tsx import { useEffect, useMemo, useRef, useState } from 'react'; -import { useEmailRaw } from '../../hooks/useEmailRaw'; -import { flagAtom } from '../../state/flags'; +import { useEmailRaw } from '../../../../hooks/useEmailRaw'; +import { flagAtom } from '../../../../state/flags'; import { useAtom, useAtomValue } from 'jotai'; -import { useUserTimezone } from '../../hooks/useTimezone'; -import { folderDetailsAtom } from '../../state/folders'; -import { useUpdateFolderUnreadCount } from '../../hooks/useFolders'; -import { useEmailCacheUpdater } from '../../hooks/useEmailCacheUpdater'; -import { userSettingsAtom } from '../../state/settings'; +import { useUserTimezone } from '../../../../hooks/useTimezone'; +import { folderDetailsAtom } from '../../../../state/folders'; +import { useUpdateFolderUnreadCount } from '../../../../hooks/useFolders'; +import { useEmailCacheUpdater } from '../../../../hooks/useEmailCacheUpdater'; +import { userSettingsAtom } from '../../../../state/settings'; import { useParams } from '@tanstack/react-router'; import { ThreadView } from './ThreadView'; import { SingleEmailView } from './SingleEmailView'; -import { useEmailParser } from '../../hooks/useEmailParser'; -import { extractIds, getMessageId, normalizeFieldNames } from '../../utils/emailUtils'; +import { useEmailParser } from '../../../../hooks/useEmailParser'; +import { extractIds, getMessageId } from '../../../../utils/emailUtils'; import { Separator } from '@radix-ui/themes'; import { FaEnvelope } from 'react-icons/fa'; import { decodeWords } from 'postal-mime'; -import { useDeleteMail, useMoveMail } from '../../hooks/useEmails'; -import { useToast } from '../ui/ToastComponent'; -import { useThreadEmails, useThreadMutations } from '../../hooks/useThreadEmails'; +import { useDeleteMail, useMoveMail } from '../../../../hooks/useEmails'; +import { useToast } from '../../../../hooks/useToast'; +import { useThreadEmails, useThreadMutations } from '../../../../hooks/useThreadEmails'; +import type { EmailLike } from '../../../../utils/emailThreading'; +import type { Attachment } from 'postal-mime'; interface EmailViewerProps { messageId: string; @@ -46,16 +48,16 @@ interface EmailViewerProps { onContentLoaded?: (content: string) => void; splitView?: boolean; onBack?: () => void; - onDraftSend?: (email: any) => void; - email?: any; + onDraftSend?: (email: EmailLike) => void; + email?: EmailLike; flagged?: string[]; - onAttachmentsLoaded?: (attachments: any[]) => void; - onReply?: (email: any) => void; - onEditAsNew?: (email: any) => void; + onAttachmentsLoaded?: (attachments: Attachment[]) => void; + onReply?: (email: EmailLike) => void; + onEditAsNew?: (email: EmailLike) => void; onSaveAsContact?: () => void; - onForward?: (email: any) => void; - onForwardAsAttachment?: (email: any, rawContent: string) => void; - onReplyAll?: (email: any) => void; + onForward?: (email: EmailLike) => void; + onForwardAsAttachment?: (email: EmailLike, rawContent: string) => void; + onReplyAll?: (email: EmailLike) => void; handleSingleEmailDelete?: (emailId: string) => void; handleSingleEmailMarkAsFlagged?: (emailId: string, action: boolean) => void; handleSingleEmailMarkAsRead?: (emailId: string, action: boolean) => void; @@ -78,7 +80,6 @@ const EmailViewer = ({ onForwardAsAttachment, onEditAsNew, onReplyAll, - handleSingleEmailDelete, handleSingleEmailMarkAsFlagged, handleSingleEmailMarkAsRead, onSaveAsContact, @@ -99,7 +100,7 @@ const EmailViewer = ({ const [folderDetails] = useAtom(folderDetailsAtom); const [isHeaderPopoverOpen, setIsHeaderPopoverOpen] = useState(false); const { folder } = useParams({ strict: false }); - const [viewingEmailFlag] = useAtom(flagAtom) as [string[], any]; + const [viewingEmailFlag] = useAtom(flagAtom); const updateFolderUnreadCount = useUpdateFolderUnreadCount(folderPath || 'INBOX'); const { patchEmailFlags } = useEmailCacheUpdater(folderPath || 'INBOX'); @@ -136,7 +137,6 @@ const EmailViewer = ({ }); const threadedView = userSettings?.email?.mail_thead_view || 'all threads'; - const folderThreadView = userSettings?.folders || {}; const flagged = '\\Flagged'; @@ -151,10 +151,11 @@ const EmailViewer = ({ // No folder-name restriction — threading works in Inbox, Sent, [Gmail]/Sent Mail, // custom folders, etc. const isFolderThread = useMemo(() => { + const folderThreadView = userSettings?.folders || {}; const folderKey = folder?.toLowerCase() || ''; const value = folderThreadView?.[folderKey]?.list_thread_view ?? 'threads'; return value === 'threads'; - }, [folder, folderThreadView]); + }, [folder, userSettings]); // ------------------------------------------------------------------ // FLAWLESS THREAD LOGIC @@ -164,8 +165,8 @@ const EmailViewer = ({ const requiredMessageIds = useMemo(() => { if (!email) return []; - const references = email['References'] ? extractIds(email['References']) : []; - const threadRefs = email['Thread-Reference'] || []; + const references = email['References'] ? extractIds(email['References'] as string) : []; + const threadRefs = (email['Thread-Reference'] as string[]) || []; const currentId = getMessageId(email); // Combine all relevant IDs @@ -181,14 +182,14 @@ const EmailViewer = ({ // folders like [Gmail]/Sent Mail without any hardcoded name checks. const sentFolderName = useMemo(() => { if (!Array.isArray(folderDetails)) return 'Sent'; - const sentFolders = folderDetails.filter((f: any) => f.flags?.includes('Sent')); - const exactSent = sentFolders.find((f: any) => f.folder_name === 'Sent'); + const sentFolders = folderDetails.filter((f) => f.flags?.includes('Sent')); + const exactSent = sentFolders.find((f) => f.folder_name === 'Sent'); return exactSent?.folder_name || sentFolders[0]?.folder_name || 'Sent'; }, [folderDetails]); const inboxFolderName = useMemo(() => { if (!Array.isArray(folderDetails)) return 'INBOX'; - const inboxFolder = folderDetails.find((f: any) => f.flags?.includes('Inbox')); + const inboxFolder = folderDetails.find((f) => f.flags?.includes('Inbox')); return inboxFolder?.folder_name || 'INBOX'; }, [folderDetails]); @@ -200,7 +201,6 @@ const EmailViewer = ({ data: listofThreadEmails = [], isLoading: isThreadLoading, isFetching: isThreadFetching, - refetch: refetchThreads, error: threadError, } = useThreadEmails( requiredMessageIds, @@ -211,8 +211,10 @@ const EmailViewer = ({ ); // 5. Thread mutation helpers for optimistic updates - const { optimisticallyRemove, optimisticallyRestore, invalidateThread, invalidateFolder } = - useThreadMutations(folderPath || 'INBOX', requiredMessageIds); + const { optimisticallyRemove, optimisticallyRestore, invalidateThread } = useThreadMutations( + folderPath || 'INBOX', + requiredMessageIds + ); // 6. Determine if we are effectively threaded // Show threaded view only if we have finished loading and actually found multiple messages. @@ -233,9 +235,9 @@ const EmailViewer = ({ // DELETE & MOVE OPERATIONS WITH OPTIMISTIC UPDATES // ------------------------------------------------------------------ - function FilteredListLength(filteredId: any) { + function FilteredListLength(filteredId: string) { const emailIdNum = Number(filteredId); - const remaining = listofThreadEmails.filter((i: any) => Number(i.id) !== emailIdNum); + const remaining = listofThreadEmails.filter((i) => Number(i.id) !== emailIdNum); if (remaining.length === 0) { onBack?.(); @@ -245,11 +247,11 @@ const EmailViewer = ({ } } - function deletedThreadMessage(deleteId: any) { + function deletedThreadMessage(deleteId: string) { const emailIdNum = Number(deleteId); // Find the email for potential restoration - const emailToDelete = listofThreadEmails.find((e: any) => Number(e.id) === emailIdNum); + const emailToDelete = listofThreadEmails.find((e) => Number(e.id) === emailIdNum); const actualFolderPath = emailToDelete?.folderPath || folderPath || folder || 'INBOX'; if (actualFolderPath.toLowerCase() === 'trash') { @@ -262,14 +264,15 @@ const EmailViewer = ({ body: [emailIdNum], }, { - onSuccess: (res: any) => { - toast.success({ description: res?.message || 'Email permanently deleted.' }); + onSuccess: (res) => { + const message = (res as unknown as { message?: string })?.message; + toast.success({ description: message || 'Email permanently deleted.' }); // Invalidate to ensure consistency invalidateThread(); // Check if we should close the view - const remaining = listofThreadEmails.filter((i: any) => Number(i.id) !== emailIdNum); + const remaining = listofThreadEmails.filter((i) => Number(i.id) !== emailIdNum); if (remaining.length === 0) onBack?.(); }, onError: (error) => { @@ -337,7 +340,7 @@ const EmailViewer = ({ // Confirm the optimistic update invalidateThread(); - const remaining = listofThreadEmails.filter((i: any) => Number(i.id) !== emailIdNum); + const remaining = listofThreadEmails.filter((i) => Number(i.id) !== emailIdNum); if (remaining.length === 0) onBack?.(); }, onError: (error) => { diff --git a/src/components/mailbox/MoveEmail.tsx b/src/components/mailbox/EmailList/MoveEmail.tsx similarity index 94% rename from src/components/mailbox/MoveEmail.tsx rename to src/components/mailbox/EmailList/MoveEmail.tsx index 7189124..606881f 100644 --- a/src/components/mailbox/MoveEmail.tsx +++ b/src/components/mailbox/EmailList/MoveEmail.tsx @@ -25,10 +25,10 @@ import { FaCheck, FaTimes, } from 'react-icons/fa'; -import { buildFolderTree, type FolderNode } from '../../utils/folderTree'; +import { buildFolderTree, type FolderNode } from '../../../utils/folderTree'; import { useParams } from '@tanstack/react-router'; import { useAtomValue } from 'jotai'; -import { folderDetailsAtom } from '../../state/folders'; +import { folderDetailsAtom } from '../../../state/folders'; interface Folder { id: string; @@ -46,16 +46,14 @@ interface FolderDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onFolderSelect: (folder: Folder) => void; - currentFolder?: Folder | any | null; + // Callers each have their own locally-typed "folder" shape (this file's Folder + // is one of at least three slightly different versions across the app) — only + // `.path` is ever read from it here, so accept anything with that shape. + currentFolder?: { path?: string } | string | null; title?: string; isQuotaExceeded?: boolean; } -interface FoldersResponse { - message: string; - folders: Array; -} - const FolderDialog: React.FC = ({ open, onOpenChange, @@ -110,8 +108,12 @@ const FolderDialog: React.FC = ({ }, [open]); const isSourceFolder = (folder: Folder) => { + // currentFolder is sometimes passed as a plain path string (not a Folder) — + // .path on a string is always undefined, same as before this was typed. + const currentFolderObjPath = + currentFolder && typeof currentFolder === 'object' ? currentFolder.path : undefined; return ( - (currentFolder && folder.path === currentFolder.path) || + (currentFolder && folder.path === currentFolderObjPath) || (currentFolderPath && folder.path === currentFolderPath) ); }; diff --git a/src/components/mailbox/EmailList.tsx b/src/components/mailbox/EmailList/index.tsx similarity index 89% rename from src/components/mailbox/EmailList.tsx rename to src/components/mailbox/EmailList/index.tsx index 24c37a9..e2bdf4b 100644 --- a/src/components/mailbox/EmailList.tsx +++ b/src/components/mailbox/EmailList/index.tsx @@ -24,58 +24,58 @@ import { useSeenMail, useUnFlaggedMail, useUnseenMail, -} from '../../hooks/useEmails'; +} from '../../../hooks/useEmails'; import { useParams } from '@tanstack/react-router'; import { useQueryClient } from '@tanstack/react-query'; import EmailCard from './EmailCard'; import EmailToolbar from './EmailToolbar'; import EmailViewer from './EmailViewer'; -import Toast from '../Toast'; -import { type Email, emailRaw } from '../../api/mailbox'; -import { useEmailPrefetch, useEmailRaw } from '../../hooks/useEmailRaw'; +import { type Email, emailRaw } from '../../../api/mailbox'; +import { useEmailRaw } from '../../../hooks/useEmailRaw'; import EmailComposer from './EmailComposer'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { selectedEmailAtom } from '../../state/emailAddress'; -import { useToast } from '../ui/ToastComponent'; -import ResizablePanel from '../common/ResizeblePanel'; +import { selectedEmailAtom } from '../../../state/emailAddress'; +import { useToast } from '../../../hooks/useToast'; +import ResizablePanel from '../../common/ResizeblePanel'; import EmailEmptyState from './EmailEmptyState'; import EmailLoadingSkeleton from './EmailSkeleton'; -import { composerOpenAtom } from '../../state/composer'; -import { useKeyboardNavigation } from '../../hooks/useKeyboardShortcuts'; +import { useKeyboardNavigation } from '../../../hooks/useKeyboardShortcuts'; import { emailComposerDataAtom, emailComposerKeepMountedAtom, emailComposerOpenAtom, resetEmailComposerDataAtom, -} from '../../state/emailComposer'; -import { rawEmailCacheKey } from '../../hooks/useEmailRaw'; -import { userSettingsAtom } from '../../state/settings'; -import { panelSizesAtom } from '../../state/resizable'; -import { flagAtom } from '../../state/flags'; -import { printEmail, viewEmailInWindow, viewEmailRaw } from '../../utils/emailPrint'; -import { userDetailsAtom } from '../../state/userDetails'; -import { usePanelSizes } from '../../hooks/usePanelSizes'; -import { searchStateAtom } from '../../state/search'; -import SearchResultsBanner from '../common/header/SearchResultBanner'; -import { useSearchEmails } from '../../hooks/useSearch'; -import type { SearchRequest } from '../../api/search'; -import { convertToBytes, mapComparator } from '../common/header/search/utils'; -import { useCreateEmailTemplate, useTemplateActions } from '../../hooks/useTempelate'; + type EmailAttachment as ComposerAttachment, +} from '../../../state/emailComposer'; +import { rawEmailCacheKey } from '../../../hooks/useEmailRaw'; +import { userSettingsAtom } from '../../../state/settings'; +import { flagAtom } from '../../../state/flags'; +import { printEmail, viewEmailInWindow, viewEmailRaw } from '../../../utils/emailPrint'; +import { userDetailsAtom } from '../../../state/userDetails'; +import { usePanelSizes } from '../../../hooks/usePanelSizes'; +import { searchStateAtom } from '../../../state/search'; +import SearchResultsBanner from '../../common/header/SearchResultBanner'; +import { useSearchEmails } from '../../../hooks/useSearch'; +import type { SearchRequest } from '../../../api/search'; +import { convertToBytes, mapComparator } from '../../common/header/search/utils'; +import { useCreateEmailTemplate, useTemplateActions } from '../../../hooks/useTempelate'; import FolderDialog from './MoveEmail'; -import type { CreateContactData } from '../../utils/contact'; -import { useCreateBulkContact } from '../../hooks/useContacts'; -import { scrapeContactsFromEmails } from '../../utils/contactScrapper'; -import { BulkCreateView } from '../contacts/BulkCreateView'; -import { getMessageId } from '../../utils/emailUtils'; -import CustomModal from '../composer/CustomModal'; -import { useIsMobile } from '../../hooks/use-mobile'; -import { folderDetailsAtom } from '../../state/folders'; +import type { CreateContactData } from '../../../utils/contact'; +import { useCreateBulkContact } from '../../../hooks/useContacts'; +import { scrapeContactsFromEmails } from '../../../utils/contactScrapper'; +import { BulkCreateView } from '../../contacts/BulkCreateView'; +import { getMessageId } from '../../../utils/emailUtils'; +import CustomModal from '../../composer/CustomModal'; +import { useIsMobile } from '../../../hooks/use-mobile'; +import { folderDetailsAtom } from '../../../state/folders'; import { useUpdateFolderUnreadCount, useUpdateAnyFolderUnreadCount, useFolderUidValidity, -} from '../../hooks/useFolders'; -import { useEmailCacheUpdater } from '../../hooks/useEmailCacheUpdater'; +} from '../../../hooks/useFolders'; +import { useEmailCacheUpdater } from '../../../hooks/useEmailCacheUpdater'; +import type { EmailLike } from '../../../utils/emailThreading'; +import type { Attachment } from 'postal-mime'; interface EmailListProps { onRegisterClearCallback?: (callback: () => void) => void; @@ -85,22 +85,41 @@ interface EmailListProps { onFocusFolders?: () => void; } -type StringInput = string | string[] | null | undefined; +// ... Keep your applyThreading function exactly as is ... +interface ThreadInfo { + ids: string[]; + latestId: string; + latestDate: number; + count: number; +} -export function createSafeStringSet(...inputs: StringInput[]): any { - return new Set( - inputs.filter(Boolean).flatMap((input: any) => { - if (Array.isArray(input)) { - return input.filter((v) => v && v.trim() !== ''); - } - return input.trim() !== '' ? [input] : []; - }) - ); +// Deliberately doesn't extend EmailLike: EmailLike's index signature would force +// every concrete caller-side type (e.g. api/mailbox.ts's `Email`) to be cast before +// it could satisfy the generic constraint. All fields are optional so any real +// email shape (Email, EmailLike, etc.) satisfies this structurally with no cast. +interface ThreadableEmail { + Date?: string; + FLAGS?: string[]; + References?: string; + 'In-Reply-To'?: string; } -// ... Keep your applyThreading function exactly as is ... -function applyThreading(apiResponse: any): any[] { - const emails = apiResponse.emails || apiResponse; +interface ThreadFields { + 'Thread-View'?: boolean; + 'Thread-Reference'?: string[]; + 'Thread-Count'?: number; + 'Inbox-Visible'?: boolean; + 'Thread-Latest'?: string; + 'Thread-Position'?: string; + 'Thread-HasUnread'?: boolean; + 'Thread-Emails-Count'?: number; + 'Thread-Unread-Count'?: number; +} + +function applyThreading( + apiResponse: T[] | { emails?: T[] } +): (T & ThreadFields)[] { + const emails = Array.isArray(apiResponse) ? apiResponse : apiResponse?.emails; if (!Array.isArray(emails)) return []; const normalizeId = (id = '') => id.replace(/[<>]/g, '').trim(); @@ -110,7 +129,7 @@ function applyThreading(apiResponse: any): any[] { return matches ? matches.map((v) => normalizeId(v)) : []; }; // const getMessageId = (email: any) => normalizeId(email['Message-Id'] || email['Message-ID']); - const getEmailDate = (email: any): number => { + const getEmailDate = (email: T): number => { try { return new Date(email.Date || 0).getTime(); } catch { @@ -118,7 +137,7 @@ function applyThreading(apiResponse: any): any[] { } }; - const emailByMessageId = new Map(); + const emailByMessageId = new Map(); const emailConnections = new Map>(); emails.forEach((email) => { @@ -182,15 +201,7 @@ function applyThreading(apiResponse: any): any[] { } } - const threadInfo = new Map< - string, - { - ids: string[]; - latestId: string; - latestDate: number; - count: number; - } - >(); + const threadInfo = new Map(); threads.forEach((ids, threadId) => { if (ids.length === 0) return; @@ -223,11 +234,11 @@ function applyThreading(apiResponse: any): any[] { 'Thread-HasUnread': email.FLAGS && !email.FLAGS.includes('\\Seen'), 'Thread-Emails-Count': 1, 'Thread-Unread-Count': email.FLAGS && !email.FLAGS.includes('\\Seen') ? 1 : 0, - }; + } as T & ThreadFields; } - let threadData = null; - for (const [threadId, info] of threadInfo) { + let threadData: ThreadInfo | null = null; + for (const [, info] of threadInfo) { if (info.ids.includes(messageId)) { threadData = info; break; @@ -246,7 +257,7 @@ function applyThreading(apiResponse: any): any[] { 'Thread-HasUnread': email.FLAGS && !email.FLAGS.includes('\\Seen'), 'Thread-Emails-Count': 1, 'Thread-Unread-Count': email.FLAGS && !email.FLAGS.includes('\\Seen') ? 1 : 0, - }; + } as T & ThreadFields; } const isThreaded = threadData.count > 1; @@ -272,7 +283,7 @@ function applyThreading(apiResponse: any): any[] { 'Thread-HasUnread': unreadCount > 0, 'Thread-Emails-Count': threadData.count, 'Thread-Unread-Count': unreadCount, - }; + } as T & ThreadFields; }); } @@ -319,7 +330,7 @@ const EmailList = ({ const currentFolderDetail = useMemo(() => { if (!Array.isArray(folderDetails)) return null; - return folderDetails.find((f: any) => f.folder_name === (folder || 'INBOX')); + return folderDetails.find((f) => f.folder_name === (folder || 'INBOX')); }, [folderDetails, folder]); // On initial load, if UID validity data arrives and differs from stored status, invalidate email cache @@ -335,7 +346,7 @@ const EmailList = ({ // Always sync atom so next visit sees the current values and doesn't re-fetch unnecessarily if (currentFolderDetail && Array.isArray(folderDetails)) { setFolderDetails( - folderDetails.map((f: any) => + folderDetails.map((f) => f.folder_name === (folder || 'INBOX') ? { ...f, status: liveUidValidity } : f ) ); @@ -349,7 +360,6 @@ const EmailList = ({ // Regular emails fetch const { data: regularData, - refetch, isFetching: isRegularFetching, error: regularError, } = useEmails(folder || 'INBOX', regularPage, PER_PAGE); @@ -392,17 +402,16 @@ const EmailList = ({ const [viewingEmail, setViewingEmail] = useState(null); const [viewingEmailFolder, setViewingEmailFolder] = useState(undefined); const [viewingEmailFlag, setViewingEmailFlag] = useAtom(flagAtom); - const [showErrorToast, setShowErrorToast] = useState(false); + const [, setShowErrorToast] = useState(false); const [selectedEmail, setSelectedEmail] = useAtom(selectedEmailAtom); - const [showSuccessToast, setShowSuccessToast] = useState(false); - const [toastMessage, setToastMessage] = useState(''); + const [, setToastMessage] = useState(''); const [currentEmailContent, setCurrentEmailContent] = useState(''); const [replyingEmail, setReplyingEmail] = useState(null); const [replyingAllEmail, setReplyingAllEmail] = useState(null); const [forwardingEmail, setForwardingEmail] = useState(null); const [sendDraftEmail, setSendDraftEmail] = useState(null); const [isSelectionMode, setIsSelectionMode] = useState(false); - const userDetails = useAtomValue(userDetailsAtom) || ''; + const userDetails = useAtomValue(userDetailsAtom); const [isBulkContactOpen, setIsBulkContactOpen] = useState(false); const [contactsToCreate, setContactsToCreate] = useState[]>([]); const { mutate: createBulkContact, isPending: isSavingContacts } = useCreateBulkContact(); @@ -423,8 +432,8 @@ const EmailList = ({ return layout === 'vertical-split' ? 'down' : 'left'; }); const [currentEmailIndex, setCurrentEmailIndex] = useState(0); - const [currentAttachments, setCurrentAttachments] = useState([]); - const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); + const [currentAttachments, setCurrentAttachments] = useState([]); + const [, setShowKeyboardHelp] = useState(false); const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(false); const [composerOpen, setComposerOpen] = useAtom(emailComposerOpenAtom); const composerKeepMounted = useAtomValue(emailComposerKeepMountedAtom); @@ -439,18 +448,19 @@ const EmailList = ({ const { panelSizes, updatePanelSize } = usePanelSizes(); const [undoTime, setUndoTime] = useState(5000); const [lastSelectedIndex, setLastSelectedIndex] = useState(null); - const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true); + const [autoRefreshEnabled] = useState(true); const autoRefreshIntervalRef = useRef(null); const emailListScrollRef = useRef(null); const savedScrollPositionRef = useRef(0); - const setSearchStateOnly = useSetAtom(searchStateAtom); - const { prefetchEmailContent } = useEmailPrefetch(); const createTemplateMutation = useCreateEmailTemplate(); const { prepareTemplateFromCache } = useTemplateActions(); // CHANGE 5: Get data based on mode - const emails = - searchState.isActive && searchData?.data ? searchData.data.data : regularData?.emails || {}; + // Cast preserves an existing quirk: when regularData is undefined this falls + // back to `{}`, not `[]` — not changing that behavior here, just the type. + const emails = ( + searchState.isActive && searchData?.data ? searchData.data.data : regularData?.emails || {} + ) as Email[]; const total_count = searchState.isActive && searchData?.data @@ -507,14 +517,18 @@ const EmailList = ({ new Map() ); - const simpleEmailArray = Array.isArray(emails) ? emails : Object.values(emails || {}); + const simpleEmailArray: Email[] = Array.isArray(emails) + ? emails + : (Object.values(emails || {}) as Email[]); - function getFilteredThreadedList(threadedEmails: any[]): any[] { + function getFilteredThreadedList( + threadedEmails: (Email & ThreadFields)[] + ): (Email & ThreadFields)[] { return threadedEmails .filter((email) => email['Inbox-Visible'] === true) .map((email) => ({ ...email, - 'Thread-Emails-Count': email['Thread-Reference']?.length || 1, + 'Thread-Emails-Count': (email['Thread-Reference'] as string[] | undefined)?.length || 1, 'Thread-Unread-Count': email['Thread-Unread-Count'] || 0, })) .sort((a, b) => { @@ -524,7 +538,7 @@ const EmailList = ({ }); } - function getListOfEmail(threadedEmails: any[]): any[] { + function getListOfEmail(threadedEmails: (Email & ThreadFields)[]): (Email & ThreadFields)[] { return ( threadedEmails.sort((a, b) => { const dateA = new Date(a.Date || 0).getTime(); @@ -551,7 +565,7 @@ const EmailList = ({ return; } - const scraped = scrapeContactsFromEmails(emailsToProcess, userDetails.email); + const scraped = scrapeContactsFromEmails(emailsToProcess, userDetails?.email || ''); setContactsToCreate(scraped); setIsBulkContactOpen(true); }; @@ -565,7 +579,7 @@ const EmailList = ({ toast.success({ description: `Successfully added ${valid.length} contacts` }); queryClient.invalidateQueries({ queryKey: ['contacts'] }); }, - onError: (err: any) => { + onError: (err) => { toast.error({ description: err?.message || 'Failed to save contacts' }); }, }); @@ -573,7 +587,7 @@ const EmailList = ({ const emailArray = useMemo(() => { if (searchState.isActive) { - return simpleEmailArray.sort((a: any, b: any) => { + return simpleEmailArray.sort((a, b) => { const dateA = new Date(a.Date || 0).getTime(); const dateB = new Date(b.Date || 0).getTime(); return dateB - dateA; @@ -591,10 +605,10 @@ const EmailList = ({ useEffect(() => { if (emailArray) { - const email_flag = emailArray.find((email: any) => email?.id === viewingEmail?.id); + const email_flag = emailArray.find((email) => email?.id === viewingEmail?.id); setViewingEmailFlag(email_flag?.FLAGS || []); } - }, [viewingEmail, emails, emailArray]); + }, [viewingEmail, emails, emailArray, setViewingEmailFlag]); const totalSelectedCount = emailArray.length; const checkedCount = checkedEmails.length; @@ -608,7 +622,7 @@ const EmailList = ({ setIsMoveDialogOpen(true); }; - const handleFolderSelect = (newFol: any) => { + const handleFolderSelect = (newFol: { name: string; path: string }) => { const loadingId = toast.loading({ description: `Moving to ${newFol.name}…` }); moveMutate( { @@ -621,7 +635,7 @@ const EmailList = ({ onSuccess: () => { // Count unread emails being moved (emailToMove is still the closure value here) const unreadMoved = emailArray.filter( - (e: any) => emailToMove.includes(Number(e.id)) && !e.FLAGS?.includes('\\Seen') + (e) => emailToMove.includes(Number(e.id)) && !e.FLAGS?.includes('\\Seen') ).length; // Clear selection and close dialog before invalidating the query so the @@ -648,7 +662,7 @@ const EmailList = ({ queryKey: ['folder', folder, 'page', currentPage, 'perPage', PER_PAGE], }); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error?.message || 'Failed to move email.' }); }, @@ -759,7 +773,7 @@ const EmailList = ({ description: error.message || 'An error occurred while fetching emails. Please try again.', }); } - }, [error]); + }, [error, toast]); useEffect(() => { if (searchState.isActive) { @@ -831,14 +845,15 @@ const EmailList = ({ body: emailsToActOn, }, { - onSuccess: (res: any) => { + onSuccess: (res) => { handleDeselectAll(); if (isViewingEmailAffected) { handleBackToList(); } toast.dismiss(loadingId); toast.success({ - description: res?.message || 'Email permanently deleted.', + description: + (res as unknown as { message?: string })?.message || 'Email permanently deleted.', }); queryClient.invalidateQueries({ queryKey: ['folder', folder, 'page', currentPage, 'perPage', PER_PAGE], @@ -927,11 +942,12 @@ const EmailList = ({ body: [emailIdNum], }, { - onSuccess: (res: any) => { + onSuccess: (res) => { if (isViewingDeletedEmail) handleBackToList(); toast.dismiss(loadingId); toast.success({ - description: res?.message || 'Email permanently deleted.', + description: + (res as unknown as { message?: string })?.message || 'Email permanently deleted.', }); queryClient.invalidateQueries({ @@ -1022,11 +1038,13 @@ const EmailList = ({ body: targetIds, }, { - onSuccess: (res: any) => { + onSuccess: (res) => { handleDeselectAll(); toast.dismiss(loadingId); toast.success({ - description: res?.message || `Marked as ${isSeen ? 'unread' : 'read'}.`, + description: + (res as unknown as { message?: string })?.message || + `Marked as ${isSeen ? 'unread' : 'read'}.`, }); patchEmailFlags(targetIds, isSeen ? undefined : '\\Seen', isSeen ? '\\Seen' : undefined); @@ -1048,10 +1066,11 @@ const EmailList = ({ body: [emailIdNum], }, { - onSuccess: (res: any) => { + onSuccess: (res) => { toast.dismiss(loadingId); toast.success({ - description: res?.message || 'Email marked as unread.', + description: + (res as unknown as { message?: string })?.message || 'Email marked as unread.', }); patchEmailFlags([emailIdNum], '\\Seen'); @@ -1072,10 +1091,11 @@ const EmailList = ({ body: [emailIdNum], }, { - onSuccess: (res: any) => { + onSuccess: (res) => { toast.dismiss(loadingId); toast.success({ - description: res?.message || 'Email marked as unread.', + description: + (res as unknown as { message?: string })?.message || 'Email marked as unread.', }); patchEmailFlags([emailIdNum], undefined, '\\Seen'); @@ -1104,11 +1124,12 @@ const EmailList = ({ body: emailsToActOn, }, { - onSuccess: (res: any) => { + onSuccess: (res) => { handleDeselectAll(); toast.dismiss(loadingId); toast.success({ - description: res?.message || 'Email unmarked as flagged.', + description: + (res as unknown as { message?: string })?.message || 'Email unmarked as flagged.', }); patchEmailFlags(emailsToActOn, undefined, '\\Flagged'); }, @@ -1127,11 +1148,12 @@ const EmailList = ({ body: emailsToActOn, }, { - onSuccess: (res: any) => { + onSuccess: (res) => { handleDeselectAll(); toast.dismiss(loadingId); toast.success({ - description: res?.message || 'Email flagged as flagged.', + description: + (res as unknown as { message?: string })?.message || 'Email flagged as flagged.', }); patchEmailFlags(emailsToActOn, '\\Flagged'); }, @@ -1158,11 +1180,12 @@ const EmailList = ({ body: [emailIdNum], }, { - onSuccess: (res: any) => { + onSuccess: (res) => { toast.dismiss(loadingId); handleDeselectAll(); toast.success({ - description: res?.message || 'Email unmarked as flagged.', + description: + (res as unknown as { message?: string })?.message || 'Email unmarked as flagged.', }); patchEmailFlags([emailIdNum], undefined, '\\Flagged'); }, @@ -1181,11 +1204,12 @@ const EmailList = ({ body: [emailIdNum], }, { - onSuccess: (res: any) => { + onSuccess: (res) => { toast.dismiss(loadingId); handleDeselectAll(); toast.success({ - description: res?.message || 'Email flagged as flagged.', + description: + (res as unknown as { message?: string })?.message || 'Email flagged as flagged.', }); patchEmailFlags([emailIdNum], '\\Flagged'); }, @@ -1207,7 +1231,7 @@ const EmailList = ({ // Update the atom with the new status if (currentFolderDetail) { - const newDetails = folderDetails.map((f: any) => + const newDetails = folderDetails.map((f) => f.folder_name === (folder || 'INBOX') ? { ...f, status: response } : f ); setFolderDetails(newDetails); @@ -1360,8 +1384,8 @@ const EmailList = ({ setComposerOpen(true); }; - const handleForwardAsAttachment = async (targetEmail?: any, rawContent?: string) => { - const emailToForward = targetEmail || viewingEmail; + const handleForwardAsAttachment = async (targetEmail?: EmailLike, rawContent?: string) => { + const emailToForward = targetEmail || (viewingEmail as unknown as EmailLike | null); if (!emailToForward) return; try { // Thread card already provides rawContent — skip network entirely. @@ -1369,7 +1393,7 @@ const EmailList = ({ // so queryClient.fetchQuery returns the cached string without a network call. let raw = rawContent; if (!raw) { - const id = emailToForward.id.toString(); + const id = String(emailToForward.id); const folderPath = viewingEmailFolder || folder || 'INBOX'; // Mirror the stableEmailKey logic in EmailViewer.tsx const stableKey = @@ -1400,8 +1424,16 @@ const EmailList = ({ ...prev, subject: `Fwd: ${subject}`, html: '


', + // Backend/snake_case-shaped attachment (mime_type, data), not ComposerEmail's + // declared camelCase EmailAttachment (mimeType, content) — same pre-existing + // mismatch already flagged for this composer atom (see CLAUDE.md). attachments: [ - { filename, mime_type: 'message/rfc822', data: base64, size: uint8.length } as any, + { + filename, + mime_type: 'message/rfc822', + data: base64, + size: uint8.length, + } as unknown as ComposerAttachment, ], })); @@ -1557,7 +1589,7 @@ const EmailList = ({ toast.success({ description: 'Saved! View it in Settings > Templates', }); - } catch (err) { + } catch { toast.error({ description: 'Failed to process email for template', }); @@ -1693,17 +1725,17 @@ const EmailList = ({ date={viewingEmail!.Date} onContentLoaded={handleEmailContentLoaded} onDraftSend={() => viewingEmail && handleSendDraft(viewingEmail)} - email={viewingEmail} + email={viewingEmail as unknown as EmailLike} flagged={viewingEmailFlag} onAttachmentsLoaded={setCurrentAttachments} - onReply={handleReply} - onReplyAll={handleReplyAll} - onForward={handleForward} + onReply={handleReply as unknown as (email: EmailLike) => void} + onReplyAll={handleReplyAll as unknown as (email: EmailLike) => void} + onForward={handleForward as unknown as (email: EmailLike) => void} onForwardAsAttachment={handleForwardAsAttachment} handleSingleEmailDelete={handleSingleEmailDelete} handleSingleEmailMarkAsFlagged={handleSingleEmailMarkAsFlagged} handleSingleEmailMarkAsRead={handleSingleEmailMarkAsRead} - onEditAsNew={handleEditAsNew} + onEditAsNew={handleEditAsNew as unknown as (email: EmailLike) => void} onSaveAsContact={handleSaveAsContactAction} />
diff --git a/src/components/mailbox/ComposeButton.tsx b/src/components/mailbox/Folders/ComposeButton.tsx similarity index 95% rename from src/components/mailbox/ComposeButton.tsx rename to src/components/mailbox/Folders/ComposeButton.tsx index c153ff3..2d4b619 100644 --- a/src/components/mailbox/ComposeButton.tsx +++ b/src/components/mailbox/Folders/ComposeButton.tsx @@ -20,16 +20,6 @@ import { useEffect } from 'react'; export type ComposeButtonStyle = 'default' | 'compact' | 'minimal'; -export const COMPOSE_BUTTON_OPTIONS: { - value: ComposeButtonStyle; - label: string; - description: string; -}[] = [ - { value: 'default', label: 'Default', description: 'Solid with shadow' }, - { value: 'compact', label: 'Compact', description: 'Solid, no shadow' }, - { value: 'minimal', label: 'Minimal', description: 'Ghost style' }, -]; - const variantConfig = { default: { expanded: diff --git a/src/components/mailbox/FolderRow.tsx b/src/components/mailbox/Folders/CustomFolder/FolderRow.tsx similarity index 89% rename from src/components/mailbox/FolderRow.tsx rename to src/components/mailbox/Folders/CustomFolder/FolderRow.tsx index 9eaa2b0..eb6e62d 100644 --- a/src/components/mailbox/FolderRow.tsx +++ b/src/components/mailbox/Folders/CustomFolder/FolderRow.tsx @@ -17,11 +17,20 @@ import { Button, Flex, Box } from '@radix-ui/themes'; import { FaChevronDown, FaChevronRight } from 'react-icons/fa6'; -import FolderItem from './FolderItem'; -import DropdownWrapper, { type DropdownItem } from '../common/DropdownWrapper'; +import FolderItem from '../FolderItem'; +import DropdownWrapper, { type DropdownItem } from '../../../common/DropdownWrapper'; import type { IconType } from 'react-icons/lib'; import { FaEllipsisH } from 'react-icons/fa'; +// The only real caller (CustomFolder.tsx) passes a FolderNode here, which has +// no show_unread_count/show_label fields — those checks below always fall +// through to their `!== false` default. Kept as-is, just typed honestly. +interface FolderPopsShape { + unread_count?: number; + show_unread_count?: boolean; + show_label?: boolean; +} + interface FolderRowProps { folderPath: string; displayName: string; @@ -37,7 +46,7 @@ interface FolderRowProps { onDrop?: (folderPath: string) => void; // Hover/Interaction states managed by parent or internal if simple className?: string; - folderPops?: any; // For passing specific folder config like counts/labels + folderPops?: FolderPopsShape; // For passing specific folder config like counts/labels isSidebarCollapsed?: boolean; } @@ -57,7 +66,6 @@ const FolderRow = ({ isSidebarCollapsed = false, }: FolderRowProps) => { const isSystemFolder = folderPops?.unread_count === -1; - const isOver = false; // Drag handlers specifically for the row wrapper const handleDragOver = (e: React.DragEvent) => { diff --git a/src/components/mailbox/CustomFolder.tsx b/src/components/mailbox/Folders/CustomFolder/index.tsx similarity index 92% rename from src/components/mailbox/CustomFolder.tsx rename to src/components/mailbox/Folders/CustomFolder/index.tsx index 029a2aa..39229ed 100644 --- a/src/components/mailbox/CustomFolder.tsx +++ b/src/components/mailbox/Folders/CustomFolder/index.tsx @@ -19,15 +19,16 @@ import { useState } from 'react'; import { Button, Flex, Text, TextField } from '@radix-ui/themes'; import { FaTrash, FaPlus } from 'react-icons/fa6'; import { useForm } from 'react-hook-form'; -import type { FolderNode } from '../../utils/folderTree'; -import type { DropdownItem } from '../common/DropdownWrapper'; -import DialogWrapper from '../common/Dialoge'; +import type { FolderNode } from '../../../../utils/folderTree'; +import type { DropdownItem } from '../../../common/DropdownWrapper'; +import DialogWrapper from '../../../common/Dialoge'; import FolderRow from './FolderRow'; import { FaEdit } from 'react-icons/fa'; -import { MAX_FOLDER_DEPTH } from '../../constants/constant'; +import { MAX_FOLDER_DEPTH } from '../../../../constants/constant'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; -import { sortFoldersAscending } from '../../utils/folderUtils'; +import { sortFoldersAscending } from '../../../../utils/folderUtils'; +import { folderSchema } from '../folderSchema'; const cleanFolderName = (folderName: string): string => { return folderName.replace(/^"(.+)"$/, '$1').replace(/\\"/g, '"'); @@ -46,18 +47,6 @@ interface CustomFolderProps { isDragging?: boolean; } -export const folderSchema = yup.object({ - newFolderName: yup - .string() - .required('Folder name is required') - .min(1, 'Name is too short') - .max(30, 'Name is too long') - .matches( - /^[a-zA-Z0-9](?:[a-zA-Z0-9\s-_]*[a-zA-Z0-9])?$/, - 'Only letters, numbers, spaces, hyphens, and underscores are allowed. Cannot start or end with a space/symbol.' - ), -}); - type FolderFormData = yup.InferType; const CustomFolder = ({ diff --git a/src/components/mailbox/DefaultFolder.tsx b/src/components/mailbox/Folders/DefaultFolder.tsx similarity index 92% rename from src/components/mailbox/DefaultFolder.tsx rename to src/components/mailbox/Folders/DefaultFolder.tsx index f1d51b8..988fde5 100644 --- a/src/components/mailbox/DefaultFolder.tsx +++ b/src/components/mailbox/Folders/DefaultFolder.tsx @@ -15,21 +15,23 @@ * . */ -import type { EmailFolder } from '../../api/mailbox'; +import type { EmailFolder } from '../../../api/mailbox'; import type { IconType } from 'react-icons/lib'; -import type { FolderNode } from '../../utils/folderTree'; +import type { FolderNode } from '../../../utils/folderTree'; +import type { UserSettings } from '../../../api/user'; import FolderItem from './FolderItem'; -import CustomFolder, { folderSchema } from './CustomFolder'; +import CustomFolder from './CustomFolder'; +import { folderSchema } from './folderSchema'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { Button, Flex, TextField } from '@radix-ui/themes'; -import DialogWrapper from '../common/Dialoge'; +import DialogWrapper from '../../common/Dialoge'; import { FaPlus, FaChevronDown, FaChevronRight } from 'react-icons/fa6'; -import DropdownWrapper, { type DropdownItem } from '../common/DropdownWrapper'; +import DropdownWrapper, { type DropdownItem } from '../../common/DropdownWrapper'; import { FaEllipsisH } from 'react-icons/fa'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; -import { sortFoldersAscending } from '../../utils/folderUtils'; +import { sortFoldersAscending } from '../../../utils/folderUtils'; interface FolderProps { folder: EmailFolder; @@ -39,7 +41,7 @@ interface FolderProps { isCollapsed?: boolean; onDrop?: (folderPath: string) => void; isDragging?: boolean; - folderPops?: any; + folderPops?: Partial; folderNode?: FolderNode; onAddFolder?: (parentFolder: string, newFolderName: string) => void; onEdit?: (oldName: string, newName: string) => void; diff --git a/src/components/mailbox/FolderItem.tsx b/src/components/mailbox/Folders/FolderItem.tsx similarity index 99% rename from src/components/mailbox/FolderItem.tsx rename to src/components/mailbox/Folders/FolderItem.tsx index 3ae2773..617365d 100644 --- a/src/components/mailbox/FolderItem.tsx +++ b/src/components/mailbox/Folders/FolderItem.tsx @@ -50,7 +50,6 @@ const FolderItem = ({ px = '3', onDrop, isDragging = false, - discription = '', showCount = true, showLabel = true, leftSlot, @@ -72,7 +71,7 @@ const FolderItem = ({ const decodedDisplayName = useMemo(() => { try { return decodeURIComponent(displayName); - } catch (e) { + } catch { // Fallback to original if decoding fails (e.g. malformed URI) return displayName; } diff --git a/src/components/mailbox/Folders/folderSchema.ts b/src/components/mailbox/Folders/folderSchema.ts new file mode 100644 index 0000000..e303df4 --- /dev/null +++ b/src/components/mailbox/Folders/folderSchema.ts @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Yukthi Systems Private Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 3 along with this program. If not, see + * . + */ + +import * as yup from 'yup'; + +export const folderSchema = yup.object({ + newFolderName: yup + .string() + .required('Folder name is required') + .min(1, 'Name is too short') + .max(30, 'Name is too long') + .matches( + /^[a-zA-Z0-9](?:[a-zA-Z0-9\s-_]*[a-zA-Z0-9])?$/, + 'Only letters, numbers, spaces, hyphens, and underscores are allowed. Cannot start or end with a space/symbol.' + ), +}); diff --git a/src/components/mailbox/Folders.tsx b/src/components/mailbox/Folders/index.tsx similarity index 92% rename from src/components/mailbox/Folders.tsx rename to src/components/mailbox/Folders/index.tsx index fb934e3..4632a30 100644 --- a/src/components/mailbox/Folders.tsx +++ b/src/components/mailbox/Folders/index.tsx @@ -22,31 +22,29 @@ import { FaFileLines, FaTriangleExclamation, FaTrash, - FaPen, FaPlus, FaRotateRight, } from 'react-icons/fa6'; import DefaultFolder from './DefaultFolder'; import CustomFolder from './CustomFolder'; -import { composerOpenAtom, createFolderOpenAtom } from '../../state/composer'; +import { composerOpenAtom, createFolderOpenAtom } from '../../../state/composer'; import { useSetAtom, useAtom, useAtomValue } from 'jotai'; import { useCreateEmailFolder, useDeleteEmailFolder, useEditEmailFolder, -} from '../../hooks/useEmails'; -import { useToast } from '../ui/ToastComponent'; +} from '../../../hooks/useEmails'; +import { useToast } from '../../../hooks/useToast'; import { useQueryClient } from '@tanstack/react-query'; -import { useFoldersFullPath } from '../../hooks/useFolders'; -import { viewContactModel } from '../../state/contact'; -import { sidebarCollapsedAtom, sidebarHoveredAtom, sidebarPinnedAtom } from '../../state/sidebar'; -import { buildFolderTree, type FolderNode } from '../../utils/folderTree'; +import { useFoldersFullPath } from '../../../hooks/useFolders'; +import { sidebarCollapsedAtom, sidebarHoveredAtom, sidebarPinnedAtom } from '../../../state/sidebar'; +import { buildFolderTree, type FolderNode } from '../../../utils/folderTree'; import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'; -import { folderDetailsAtom, folderQuotaAtom } from '../../state/folders'; -import StorageQuota from '../common/StorageQuota'; -import { userSettingsAtom } from '../../state/settings'; +import { folderDetailsAtom, folderQuotaAtom } from '../../../state/folders'; +import StorageQuota from '../../common/StorageQuota'; +import { userSettingsAtom } from '../../../state/settings'; import ComposeButton, { type ComposeButtonStyle } from './ComposeButton'; -import { sortFoldersAscending } from '../../utils/folderUtils'; +import { sortFoldersAscending } from '../../../utils/folderUtils'; interface FoldersProps { onFolderClick?: () => void; @@ -54,11 +52,30 @@ interface FoldersProps { isDragging?: boolean; } +// The initial value (default/order) and the value computed once folderData +// arrives (unread_count/visible) are different shapes — all fields optional +// to fit both. +interface DefaultFolderState { + name: string; + path: string; + default?: boolean; + order?: number; + unread_count?: number; + visible?: boolean; +} + +interface DefaultFoldersMap { + inbox: DefaultFolderState; + drafts: DefaultFolderState; + sent: DefaultFolderState; + spam: DefaultFolderState; + trash: DefaultFolderState; +} + const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( ({ onFolderClick, onDropOnFolder, isDragging = false }, ref) => { const setOpenComposer = useSetAtom(composerOpenAtom); const setOpen = useSetAtom(createFolderOpenAtom); - const setViewContact = useSetAtom(viewContactModel); const toast = useToast(); const queryClient = useQueryClient(); @@ -80,12 +97,12 @@ const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( // UI State const [isMobile, setIsMobile] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const mouseLeaveTimeoutRef = useRef(null); + const mouseLeaveTimeoutRef = useRef | null>(null); const [isHoveringCollapsed, setIsHoveringCollapsed] = useState(false); const { drafts, inbox, sent, spam, trash } = userSettings?.folders ?? {}; - const hoverExpandTimerRef = useRef(null); - const [defaultFolders, setDefaultFolders] = useState({ + const hoverExpandTimerRef = useRef | null>(null); + const [defaultFolders, setDefaultFolders] = useState({ inbox: { name: 'Inbox', path: 'INBOX', default: true, order: 0 }, drafts: { name: 'Drafts', path: 'Drafts', default: true, order: 2 }, sent: { name: 'Sent', path: 'Sent', default: true, order: 2 }, @@ -233,7 +250,7 @@ const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( }; const resolveFolderWithFlag = (flag: string, preferredName: string) => { - const withFlag = folders.filter((f) => f.flags.includes(flag)); + const withFlag = folders.filter((f) => f.flags?.includes(flag)); if (withFlag.length === 1) return withFlag[0]; if (withFlag.length > 1) { return withFlag.find((f) => f.folder_name === preferredName) || withFlag[0]; @@ -288,7 +305,7 @@ const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( setDefaultFolders(updatedDefaults); - const defaultPaths = Object.values(updatedDefaults).map((d: any) => d.path); + const defaultPaths = Object.values(updatedDefaults).map((d) => d.path); const visibleFolders = folders.filter((f) => { return isFolderVisible(f.folder_name, f.delimiter); @@ -327,7 +344,7 @@ const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( toast.success({ description: 'Folder deleted.' }); queryClient.invalidateQueries({ queryKey: ['foldersFullPath'] }); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error.message || 'Failed to delete folder.' }); }, @@ -336,7 +353,7 @@ const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( }; const handleEdit = (oldPath: string, newName: string) => { - const delimiter = folderData.find((f: any) => f.folder_name === oldPath)?.delimiter || '.'; + const delimiter = folderData.find((f) => f.folder_name === oldPath)?.delimiter || '.'; const parentPath = oldPath.substring(0, oldPath.lastIndexOf(delimiter)); const newPath = parentPath ? `${parentPath}${delimiter}${newName}` : newName; @@ -349,7 +366,7 @@ const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( toast.success({ description: 'Folder renamed.' }); queryClient.invalidateQueries({ queryKey: ['foldersFullPath'] }); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error.message || 'Failed to rename folder.' }); }, @@ -358,7 +375,7 @@ const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( }; const handleCreateFolder = (parentPath: string, newFolderName: string) => { - const delimiter = folderData.find((f: any) => f.folder_name === parentPath)?.delimiter || '.'; + const delimiter = folderData.find((f) => f.folder_name === parentPath)?.delimiter || '.'; const fullPath = `${parentPath}${delimiter}${newFolderName}`; const loadingId = toast.loading({ description: 'Creating folder…' }); @@ -370,7 +387,7 @@ const Folders = forwardRef<{ focusFirstFolder: () => void }, FoldersProps>( toast.success({ description: 'Folder created.' }); queryClient.invalidateQueries({ queryKey: ['foldersFullPath'] }); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error.message || 'Failed to create folder.' }); }, diff --git a/src/components/mailbox/MobileBottomNav.tsx b/src/components/mailbox/MobileBottomNav.tsx index 88c9a2a..e172bd5 100644 --- a/src/components/mailbox/MobileBottomNav.tsx +++ b/src/components/mailbox/MobileBottomNav.tsx @@ -65,7 +65,7 @@ const MobileBottomNav = ({ onFolderClick }: MobileBottomNavProps) => { // Get unread counts const getUnreadCount = (folderName: string) => { if (!folderData) return 0; - const folder = folderData.find((f: any) => f.folder_name === folderName); + const folder = folderData.find((f) => f.folder_name === folderName); return folder?.unread_count || 0; }; diff --git a/src/components/mailbox/composeButtonOptions.ts b/src/components/mailbox/composeButtonOptions.ts new file mode 100644 index 0000000..cf6a678 --- /dev/null +++ b/src/components/mailbox/composeButtonOptions.ts @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 Yukthi Systems Private Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * version 3 along with this program. If not, see + * . + */ + +import type { ComposeButtonStyle } from './Folders/ComposeButton'; + +export const COMPOSE_BUTTON_OPTIONS: { + value: ComposeButtonStyle; + label: string; + description: string; +}[] = [ + { value: 'default', label: 'Default', description: 'Solid with shadow' }, + { value: 'compact', label: 'Compact', description: 'Solid, no shadow' }, + { value: 'minimal', label: 'Minimal', description: 'Ghost style' }, +]; diff --git a/src/components/mailbox/index.tsx b/src/components/mailbox/index.tsx index 855e76e..123b583 100644 --- a/src/components/mailbox/index.tsx +++ b/src/components/mailbox/index.tsx @@ -25,22 +25,21 @@ import { viewContactModel } from '../../state/contact'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { composerOpenAtom } from '../../state/composer'; import { userSettingsAtom } from '../../state/settings'; -import { sidebarCollapsedAtom, sidebarHoveredAtom } from '../../state/sidebar'; +import { sidebarCollapsedAtom } from '../../state/sidebar'; import { useMoveMail } from '../../hooks/useEmails'; import { useQueryClient } from '@tanstack/react-query'; import { useUpdateAnyFolderUnreadCount } from '../../hooks/useFolders'; -import { useToast } from '../ui/ToastComponent'; +import { useToast } from '../../hooks/useToast'; import { useParams } from '@tanstack/react-router'; import { useWindowSize } from '../../hooks/useWindowSize'; import MobileBottomNav from './MobileBottomNav'; -import { panelSizesAtom } from '../../state/resizable'; import ResizablePanel from '../common/ResizeblePanel'; import { useKeyboardNavigation } from '../../hooks/useKeyboardShortcuts'; import { usePanelSizes } from '../../hooks/usePanelSizes'; +import type { EmailLike } from '../../utils/emailThreading'; const Mailbox = () => { const sidebarCollapsed = useAtomValue(sidebarCollapsedAtom); - const sidebarHovered = useAtomValue(sidebarHoveredAtom); const [clearSelectedEmail, setClearSelectedEmail] = useState<(() => void) | null>(null); // useRef instead of useState — storing a function in useState triggers React's // functional-update trap (it calls fn(prevState) instead of storing fn). @@ -54,7 +53,7 @@ const Mailbox = () => { const queryClient = useQueryClient(); const toast = useToast(); const updateAnyFolderUnreadCount = useUpdateAnyFolderUnreadCount(); - const { isMobile, isDesktop } = useWindowSize(); + const { isDesktop } = useWindowSize(); const foldersRef = useRef<{ focusFirstFolder: () => void }>(null); const [draggedEmails, setDraggedEmails] = useState([]); const [isDragging, setIsDragging] = useState(false); @@ -105,14 +104,16 @@ const Mailbox = () => { { onSuccess: () => { // Compute unread delta from cached email pages before invalidating - const cachedPages = queryClient.getQueriesData({ queryKey: ['folder', folder] }); + const cachedPages = queryClient.getQueriesData<{ emails?: EmailLike[] }>({ + queryKey: ['folder', folder], + }); let unreadMoved = 0; for (const [, data] of cachedPages) { const raw = data?.emails; if (!raw) continue; - const emails: any[] = Array.isArray(raw) ? raw : Object.values(raw); + const emails: EmailLike[] = Array.isArray(raw) ? raw : Object.values(raw); unreadMoved += emails.filter( - (e: any) => draggedEmails.includes(Number(e.id)) && !e.FLAGS?.includes('\\Seen') + (e) => draggedEmails.includes(Number(e.id)) && !e.FLAGS?.includes('\\Seen') ).length; } @@ -133,7 +134,7 @@ const Mailbox = () => { queryClient.invalidateQueries({ queryKey: ['folder', folder] }); }, - onError: (error: any) => { + onError: (error) => { toast.dismiss(loadingId); toast.error({ description: error.message || 'Failed to move emails', @@ -172,8 +173,6 @@ const Mailbox = () => { // If it's collapsed, we use a static layout where hover actions float over content. const useResizableLayout = show_sidebar && isDesktop && !sidebarCollapsed; - const isExpanded = !sidebarCollapsed || sidebarHovered; - return ( <>
diff --git a/src/components/settings/filters/CreateScriptDialoge.tsx b/src/components/settings/filters/CreateScriptDialoge.tsx index a6dfc75..c5a8e60 100644 --- a/src/components/settings/filters/CreateScriptDialoge.tsx +++ b/src/components/settings/filters/CreateScriptDialoge.tsx @@ -56,7 +56,11 @@ export const CreateScriptDialog: React.FC = ({ setIsLoadingContent(true); try { - const data: any = await getScriptRaw(scriptName); + // getScriptRaw resolves to the raw script string directly, not + // { raw_data }, so `.raw_data` is always undefined here — this copy + // feature has never actually populated content. Preserved as-is (see + // CLAUDE.md), not silently fixed to `setContent(data)`. + const data = (await getScriptRaw(scriptName)) as unknown as { raw_data?: string }; setContent(data.raw_data || ''); } catch (error) { console.error('Failed to load filter set content:', error); diff --git a/src/components/settings/filters/FiltersList.tsx b/src/components/settings/filters/FiltersList.tsx index f4fd54f..6e05ae5 100644 --- a/src/components/settings/filters/FiltersList.tsx +++ b/src/components/settings/filters/FiltersList.tsx @@ -17,7 +17,6 @@ import React from 'react'; import { BsPlus, BsPencil, BsTrash, BsSlashCircle, BsExclamationTriangle } from 'react-icons/bs'; -import type { SieveFilter } from '../../../api/sieve'; import * as Switch from '@radix-ui/react-switch'; interface FiltersListProps { @@ -69,7 +68,7 @@ export const FiltersList: React.FC = ({ const formatFilterName = (name: string): string => { if (!name) return ''; - return name.replace(/[\[\]"]/g, ''); + return name.replace(/[[\]"]/g, ''); }; const isVacationFilter = (name: string) => name.includes('[vacation]'); diff --git a/src/components/settings/filters/RenderFields.tsx b/src/components/settings/filters/RenderFields.tsx index e78b645..884e53a 100644 --- a/src/components/settings/filters/RenderFields.tsx +++ b/src/components/settings/filters/RenderFields.tsx @@ -24,7 +24,8 @@ import type { DynamicField } from './rulesConfig'; import type { ActionField } from './actionsConfig'; import { folderDetailsAtom } from '../../../state/folders'; import { NestedFolderSelect } from '../../common/NestedFolderSelect'; -import type { ValidationError } from 'yup'; +import type { ValidationError } from './validation'; +import type { UIRule } from './filterTransform'; // Helper Component for Error Text const ErrorText = ({ error }: { error?: string }) => { @@ -36,18 +37,18 @@ interface RulesFieldRendererProps { fields: DynamicField[]; ruleId: string; pathPrefix: string; - rules: any[]; - updateRuleValue: (id: string, path: string, value: any) => void; - errors: any; + rules: UIRule[]; + updateRuleValue: (id: string, path: string, value: string) => void; + errors: ValidationError; onClearError: (key: string) => void; } interface ActionsFieldRendererProps { fields: ActionField[]; actionId: string; - values: { [key: string]: any }; - updateActionValue: (id: string, name: string, value: any) => void; - errors: any; + values: { [key: string]: unknown }; + updateActionValue: (id: string, name: string, value: unknown) => void; + errors: ValidationError; onClearError: (key: string) => void; } @@ -70,7 +71,7 @@ export const RulesFieldRenderer: React.FC = ({ const fieldIndex = startIndex + idx; const path = `${pathPrefix}${fieldIndex}`; const fieldKey = `${ruleId}-${path}`; - const error: any = errors[fieldKey]; + const error = errors[fieldKey]; const currentValue = rules.find((r) => r.id === ruleId)?.values[path]; @@ -79,7 +80,7 @@ export const RulesFieldRenderer: React.FC = ({ ? field.options[0].value : currentValue || ''; - const handleChange = (val: any) => { + const handleChange = (val: string) => { updateRuleValue(ruleId, path, val); onClearError(fieldKey); }; @@ -213,11 +214,12 @@ export const ActionsFieldRenderer: React.FC = ({
{fields.map((field) => { const currentValue = values[field.name]; + const currentStringValue = (currentValue as string) || ''; const fieldKey = `${actionId}-${field.name}`; const error = errors[fieldKey]; const isRequired = field.required !== false; - const handleChange = (val: any) => { + const handleChange = (val: unknown) => { updateActionValue(actionId, field.name, val); onClearError(fieldKey); }; @@ -238,7 +240,7 @@ export const ActionsFieldRenderer: React.FC = ({ = ({ const defaultValue = field.type === 'select' && !currentValue && selectOptions[0] ? selectOptions[0].value - : currentValue || ''; + : currentStringValue; switch (field.type) { case 'select': @@ -284,7 +286,7 @@ export const ActionsFieldRenderer: React.FC = ({ handleChange(e.target.value)} placeholder={field.placeholder} className={inputClass} @@ -301,7 +303,7 @@ export const ActionsFieldRenderer: React.FC = ({ handleChange(e.target.value)} placeholder={field.placeholder} className={inputClass} @@ -317,7 +319,7 @@ export const ActionsFieldRenderer: React.FC = ({ {field.label} {isRequired && *}