import React, { useEffect, useMemo, useRef, useState } from 'react'; const initialCredentials = { mode: 'weektab', localPart: '', customUser: '', password: '', customImapHost: 'mail.weektab.org', customImapPort: '993' }; const WEBMAIL_SESSION_KEY = 'weektab_webmail_session_v1'; const WEBMAIL_ACCOUNTS_KEY = 'weektab_webmail_accounts_v1'; const WEBMAIL_DATA_CACHE_KEY = 'weektab_webmail_data_cache_v1'; const DEFAULT_DOMAIN = 'weektab.org'; const DEFAULT_IMAP_HOST = 'mail.weektab.org'; const DEFAULT_IMAP_PORT = '993'; const INBOX_PAGE_SIZE = 50; function extractEmail(value) { const match = String(value || '').match(/<(.*?)>/); if (match?.[1]) return match[1]; return String(value || '').trim(); } function extractMailBackgroundColor(html) { const source = String(html || ''); if (!source) return ''; const pickColor = (text) => { const candidate = String(text || '').trim(); if (!candidate) return ''; if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(candidate)) return candidate; if (/^(rgb|rgba|hsl|hsla)\(/i.test(candidate)) return candidate; if (/^[a-z]+$/i.test(candidate)) return candidate; return ''; }; const readStyleColor = (styleValue) => { const style = String(styleValue || ''); if (!style) return ''; const bgColor = style.match(/background-color\s*:\s*([^;]+)/i)?.[1]; const fromBgColor = pickColor(bgColor); if (fromBgColor) return fromBgColor; const bg = style.match(/background\s*:\s*([^;]+)/i)?.[1]; if (!bg) return ''; const firstToken = String(bg).trim().split(/\s+/)[0]; return pickColor(firstToken); }; const bodyTag = source.match(/]*>/i)?.[0] || ''; if (bodyTag) { const bodyBg = pickColor(bodyTag.match(/bgcolor=["']?([^"'\s>]+)/i)?.[1]); if (bodyBg) return bodyBg; const bodyStyle = bodyTag.match(/style=["']([^"']*)["']/i)?.[1]; const bodyStyleColor = readStyleColor(bodyStyle); if (bodyStyleColor) return bodyStyleColor; } const firstTableTag = source.match(/]*>/i)?.[0] || ''; if (firstTableTag) { const tableBg = pickColor(firstTableTag.match(/bgcolor=["']?([^"'\s>]+)/i)?.[1]); if (tableBg) return tableBg; const tableStyle = firstTableTag.match(/style=["']([^"']*)["']/i)?.[1]; const tableStyleColor = readStyleColor(tableStyle); if (tableStyleColor) return tableStyleColor; } return ''; } export default function Webmail() { const [credentials, setCredentials] = useState(initialCredentials); const [isConnecting, setIsConnecting] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false); const [loginError, setLoginError] = useState(''); const [folders, setFolders] = useState([]); const [activeFolder, setActiveFolder] = useState('INBOX'); const [emails, setEmails] = useState([]); const [isLoadingInbox, setIsLoadingInbox] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [isRefreshingInbox, setIsRefreshingInbox] = useState(false); const [pagingByAccount, setPagingByAccount] = useState({}); const [viewCacheByAccount, setViewCacheByAccount] = useState({}); const [searchQuery, setSearchQuery] = useState(''); const [sortOrder, setSortOrder] = useState('newest'); const [selectedEmailInfo, setSelectedEmailInfo] = useState(null); const [activeMessageData, setActiveMessageData] = useState(null); const [isLoadingMessage, setIsLoadingMessage] = useState(false); const [isEmptyingTrash, setIsEmptyingTrash] = useState(false); const [selectedEmailIds, setSelectedEmailIds] = useState([]); const [isDeletingSelected, setIsDeletingSelected] = useState(false); const [replyText, setReplyText] = useState(''); const [isSending, setIsSending] = useState(false); const [isRestoringSession, setIsRestoringSession] = useState(true); const [isAddingAccount, setIsAddingAccount] = useState(false); const [savedAccounts, setSavedAccounts] = useState([]); const [activeAccountId, setActiveAccountId] = useState(''); const [quotaByAccount, setQuotaByAccount] = useState({}); const [folderSizesByAccount, setFolderSizesByAccount] = useState({}); const inboxRequestTokenRef = useRef(0); const messageRequestTokenRef = useRef(0); const activeFolderRef = useRef('INBOX'); const activeAccountIdRef = useRef(''); const prevSortOrderRef = useRef('newest'); const getAuthFromCredentials = (cred) => { const isCustom = cred.mode === 'custom'; const user = isCustom ? String(cred.customUser || '').trim() : `${String(cred.localPart || '').trim()}@${DEFAULT_DOMAIN}`; const host = isCustom ? String(cred.customImapHost || DEFAULT_IMAP_HOST).trim() : DEFAULT_IMAP_HOST; const port = isCustom ? Number(cred.customImapPort || DEFAULT_IMAP_PORT) : Number(DEFAULT_IMAP_PORT); return { user, password: cred.password, host, port, tls: true }; }; const makeAccountId = (auth) => `${String(auth.user || '').toLowerCase()}|${String(auth.host || '').toLowerCase()}|${String(auth.port || '')}`; const formatBytes = (bytes) => { if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = bytes; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; }; const buildFirstPageView = (emailList, totalCount, direction) => { const sorted = [...(Array.isArray(emailList) ? emailList : [])] .sort((a, b) => Number(a?.id || 0) - Number(b?.id || 0)); const visible = direction === 'oldest' ? sorted.slice(0, INBOX_PAGE_SIZE) : sorted.slice(Math.max(0, sorted.length - INBOX_PAGE_SIZE)); const safeTotal = Number(totalCount || sorted.length || 0); const nextOffset = Math.min(safeTotal, visible.length); const hasMore = nextOffset < safeTotal; return { emails: visible, paging: { total: safeTotal, nextOffset, hasMore, loadedAll: !hasMore, direction, updatedAt: Date.now() } }; }; const persistAccounts = (accounts) => { try { localStorage.setItem(WEBMAIL_ACCOUNTS_KEY, JSON.stringify(accounts)); } catch { // Ignore storage errors. } }; const readDataCache = () => { try { const raw = localStorage.getItem(WEBMAIL_DATA_CACHE_KEY); const parsed = raw ? JSON.parse(raw) : {}; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } }; const writeDataCache = (cache) => { try { localStorage.setItem(WEBMAIL_DATA_CACHE_KEY, JSON.stringify(cache)); } catch { // Ignore storage errors. } }; const getAccountCache = (accountId) => { const cache = readDataCache(); return cache[accountId] || null; }; const upsertAccountCache = ({ accountId, foldersMeta, folderName, folderEmails }) => { if (!accountId) return; const cache = readDataCache(); const existing = cache[accountId] || { folders: [], emailsByFolder: {}, updatedAt: 0 }; const next = { ...existing, folders: Array.isArray(foldersMeta) ? foldersMeta : existing.folders, emailsByFolder: { ...(existing.emailsByFolder || {}), ...(folderName ? { [folderName]: folderEmails } : {}) }, updatedAt: Date.now() }; cache[accountId] = next; writeDataCache(cache); }; const authPayload = useMemo(() => getAuthFromCredentials(credentials), [credentials]); useEffect(() => { activeFolderRef.current = activeFolder; }, [activeFolder]); useEffect(() => { activeAccountIdRef.current = activeAccountId; }, [activeAccountId]); const handleLoginChange = (event) => { const { name, value } = event.target; setCredentials((prev) => ({ ...prev, [name]: value })); }; const postJson = async (url, payload) => { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || `Request failed (${res.status})`); } return data; }; const saveSession = (credState, lastFolder) => { try { localStorage.setItem(WEBMAIL_SESSION_KEY, JSON.stringify({ credentials: credState, lastFolder: lastFolder || 'INBOX' })); } catch { // Ignore storage errors. } }; const clearSession = () => { try { localStorage.removeItem(WEBMAIL_SESSION_KEY); } catch { // Ignore storage errors. } }; const removeAccountCache = (accountId) => { if (!accountId) return; const cache = readDataCache(); if (Object.prototype.hasOwnProperty.call(cache, accountId)) { delete cache[accountId]; writeDataCache(cache); } }; const clearAllAccountCaches = () => { try { localStorage.removeItem(WEBMAIL_DATA_CACHE_KEY); } catch { // Ignore storage errors. } }; const setFolderPaging = (accountId, folderName, nextPaging) => { if (!accountId || !folderName) return; setPagingByAccount((prev) => ({ ...prev, [accountId]: { ...(prev[accountId] || {}), [folderName]: { ...(prev[accountId]?.[folderName] || {}), ...(nextPaging || {}) } } })); }; const getViewCache = (accountId, folderName, direction) => ( viewCacheByAccount?.[accountId]?.[folderName]?.[direction] || null ); const setViewCache = (accountId, folderName, direction, emailsList, paging) => { if (!accountId || !folderName || !direction) return; setViewCacheByAccount((prev) => ({ ...prev, [accountId]: { ...(prev[accountId] || {}), [folderName]: { ...(prev[accountId]?.[folderName] || {}), [direction]: { emails: Array.isArray(emailsList) ? emailsList : [], paging: paging || {} } } } })); }; const upsertAccount = (credState, lastFolder = 'INBOX') => { const auth = getAuthFromCredentials(credState); const accountId = makeAccountId(auth); const account = { id: accountId, email: auth.user, host: auth.host, port: auth.port, lastFolder, credentials: { ...credState } }; setSavedAccounts((prev) => { const next = [...prev]; const index = next.findIndex((a) => a.id === accountId); if (index >= 0) next[index] = account; else next.push(account); persistAccounts(next); return next; }); setActiveAccountId(accountId); }; const fetchInbox = async ( folderName, authOverride = authPayload, persistSession = false, credOverride = credentials, options = {} ) => { const requestToken = ++inboxRequestTokenRef.current; const isRequestCurrent = () => requestToken === inboxRequestTokenRef.current; const { keepSelection = false, silent = false, append = false, fetchAll = false, forceRefresh = false, syncOnly = false, resetToFirstPage = false } = options; const folder = folderName || activeFolder || 'INBOX'; const accountId = makeAccountId(authOverride); const requestDirection = String(sortOrder || 'newest'); const viewCache = getViewCache(accountId, folder, requestDirection); const accountCache = getAccountCache(accountId); const cachedFolderEntry = accountCache?.emailsByFolder?.[folder]; const viewCachedEmails = Array.isArray(viewCache?.emails) ? viewCache.emails : []; const legacyCachedEmails = Array.isArray(cachedFolderEntry) ? cachedFolderEntry : Array.isArray(cachedFolderEntry?.emails) ? cachedFolderEntry.emails : []; const cachedFolderEmails = viewCachedEmails; const currentFolderPaging = viewCache?.paging || pagingByAccount?.[accountId]?.[folder] || {}; const currentOffset = append ? Number(currentFolderPaging.nextOffset || emails.length || 0) : 0; const currentEmails = append ? emails : []; const folderChanged = Boolean(folderName) && String(folderName) !== String(activeFolderRef.current); const shouldResetWindow = !append && (resetToFirstPage || folderChanged); const baseEmailsForSync = cachedFolderEmails; const lastLoadedDirection = String(currentFolderPaging.direction || requestDirection); const canUseIncrementalSync = !append && !fetchAll && cachedFolderEmails.length > 0 && (syncOnly || !forceRefresh) && baseEmailsForSync.length > 0 && lastLoadedDirection === 'newest'; setActiveFolder(folder); if (!append && !forceRefresh && cachedFolderEmails.length > 0) { const initial = shouldResetWindow ? buildFirstPageView(cachedFolderEmails, currentFolderPaging.total || cachedFolderEmails.length, requestDirection) : { emails: cachedFolderEmails, paging: currentFolderPaging }; setEmails(initial.emails); setIsLoadingInbox(false); if (!currentFolderPaging.nextOffset) { const paging = buildFirstPageView(cachedFolderEmails, cachedFolderEmails.length, requestDirection).paging; setFolderPaging(accountId, folder, paging); setViewCache(accountId, folder, requestDirection, cachedFolderEmails, paging); } else if (shouldResetWindow) { setFolderPaging(accountId, folder, initial.paging); setViewCache(accountId, folder, requestDirection, cachedFolderEmails, initial.paging); } } else if (!append && !forceRefresh && viewCachedEmails.length === 0 && legacyCachedEmails.length > 0) { // Legacy storage fallback: show quickly, but do not treat as direction cache. const firstPage = buildFirstPageView(legacyCachedEmails, legacyCachedEmails.length, requestDirection); setEmails(firstPage.emails); } // Show loading only if there is no cached folder list to show immediately. if (!silent && !append && (!cachedFolderEmails.length || forceRefresh)) { setIsLoadingInbox(true); } if (append) setIsLoadingMore(true); if (!keepSelection) { messageRequestTokenRef.current += 1; setSelectedEmailInfo(null); setActiveMessageData(null); setSelectedEmailIds([]); } try { if (canUseIncrementalSync) { const knownIds = baseEmailsForSync .map((item) => Number(item?.id)) .filter((uid) => Number.isFinite(uid) && uid > 0); const maxUid = knownIds.length ? Math.max(...knownIds) : 0; const syncData = await postJson('/api/webmail/inbox', { ...authOverride, folder, sync: true, sinceUid: maxUid, knownUids: knownIds }); const deletedSet = new Set( Array.isArray(syncData?.deletedUids) ? syncData.deletedUids.map((uid) => Number(uid)) : [] ); const merged = baseEmailsForSync.filter((msg) => !deletedSet.has(Number(msg?.id))); const mergedSet = new Set(merged.map((msg) => Number(msg?.id))); const newEmails = Array.isArray(syncData?.newEmails) ? syncData.newEmails : []; for (const msg of newEmails) { const uid = Number(msg?.id); if (!mergedSet.has(uid)) { merged.push(msg); mergedSet.add(uid); } } const total = Number(syncData?.total || merged.length || 0); const firstPageState = shouldResetWindow ? buildFirstPageView(merged, total, lastLoadedDirection) : null; const paging = shouldResetWindow ? firstPageState.paging : { total, nextOffset: merged.length, hasMore: merged.length < total, loadedAll: merged.length >= total, direction: lastLoadedDirection, updatedAt: Date.now() }; const visibleEmails = shouldResetWindow ? firstPageState.emails : merged; if (!isRequestCurrent()) return; setEmails(visibleEmails); setFolderPaging(accountId, folder, paging); setViewCache(accountId, folder, lastLoadedDirection, merged, paging); if (persistSession) { saveSession(credOverride, folder); upsertAccount(credOverride, folder); } upsertAccountCache({ accountId, folderName: folder, folderEmails: merged }); return; } const data = await postJson('/api/webmail/inbox', { ...authOverride, folder, offset: fetchAll ? 0 : currentOffset, limit: INBOX_PAGE_SIZE, fetchAll, direction: requestDirection }); const incomingEmails = Array.isArray(data.emails) ? data.emails : []; const nextEmails = append ? [...currentEmails, ...incomingEmails.filter((msg) => !currentEmails.some((existing) => existing.id === msg.id))] : incomingEmails; const safeTotal = Number(data.total || nextEmails.length || 0); const safeNextOffset = Number(data.nextOffset || nextEmails.length || 0); const hasMore = Boolean(data.hasMore); const loadedAll = fetchAll || safeNextOffset >= safeTotal || !hasMore; const paging = { total: safeTotal, nextOffset: safeNextOffset, hasMore, loadedAll, direction: requestDirection, updatedAt: Date.now() }; const visibleState = shouldResetWindow ? buildFirstPageView(nextEmails, safeTotal, requestDirection) : { emails: nextEmails, paging }; if (!isRequestCurrent()) return; setEmails(visibleState.emails); setFolderPaging(accountId, folder, visibleState.paging); setViewCache(accountId, folder, requestDirection, nextEmails, paging); if (persistSession) { saveSession(credOverride, folder); upsertAccount(credOverride, folder); } upsertAccountCache({ accountId, folderName: folder, folderEmails: nextEmails }); } catch (error) { if (!isRequestCurrent()) return; setLoginError(error.message || 'Failed to load mailbox.'); } finally { if (!isRequestCurrent()) return; if (!silent && !append && (!cachedFolderEmails.length || forceRefresh)) { setIsLoadingInbox(false); } if (append) setIsLoadingMore(false); } }; const connectWithCredentials = async (auth, lastFolder, credOverride = credentials, options = {}) => { const { skipInitialFetchIfCached = false } = options; try { const data = await postJson('/api/webmail/connect', auth); const folderList = Array.isArray(data.folders) ? data.folders : []; const normalized = folderList.map((f) => (typeof f === 'string' ? { name: f, unseen: 0, total: 0 } : f)); const accountId = makeAccountId(auth); const accountCache = getAccountCache(accountId); setFolders(normalized); setIsLoggedIn(true); setIsAddingAccount(false); setActiveAccountId(accountId); const defaultFolder = normalized.find((f) => String(f.name).toUpperCase() === String(lastFolder || '').toUpperCase())?.name || normalized.find((f) => String(f.name).toUpperCase() === 'INBOX')?.name || normalized[0]?.name || 'INBOX'; const cachedDefaultFolderEntry = accountCache?.emailsByFolder?.[defaultFolder]; const cachedDefaultEmails = Array.isArray(cachedDefaultFolderEntry) ? cachedDefaultFolderEntry : Array.isArray(cachedDefaultFolderEntry?.emails) ? cachedDefaultFolderEntry.emails : null; if (cachedDefaultEmails) { const firstPage = buildFirstPageView(cachedDefaultEmails, cachedDefaultEmails.length, sortOrder); setActiveFolder(defaultFolder); setEmails(firstPage.emails); } upsertAccountCache({ accountId, foldersMeta: normalized }); if (skipInitialFetchIfCached && cachedDefaultEmails) { await fetchInbox(defaultFolder, auth, true, credOverride, { keepSelection: true, silent: true, syncOnly: true, resetToFirstPage: true }); } else { await fetchInbox(defaultFolder, auth, true, credOverride, { keepSelection: true, silent: Boolean(cachedDefaultEmails), resetToFirstPage: true }); } saveSession(credOverride, defaultFolder); upsertAccount(credOverride, defaultFolder); return true; } catch (error) { setLoginError(error.message || 'Login failed.'); setIsLoggedIn(false); return false; } }; const connectIMAP = async (event) => { event.preventDefault(); setIsConnecting(true); setLoginError(''); try { const localPart = String(credentials.localPart || '').trim(); const customUser = String(credentials.customUser || '').trim(); const customHost = String(credentials.customImapHost || '').trim(); if (credentials.mode === 'custom') { if (!customUser || !customHost) { setLoginError('Please fill in custom email and IMAP host.'); return; } } else if (!localPart) { setLoginError('Please enter your username.'); return; } await connectWithCredentials(authPayload, undefined, credentials); await fetchQuota(authPayload); await fetchFolderSizes(authPayload); } finally { setIsConnecting(false); } }; const fetchMessage = async (email) => { if (!email?.id) return; const requestToken = ++messageRequestTokenRef.current; const folderAtRequest = activeFolderRef.current; const accountAtRequest = activeAccountIdRef.current; setSelectedEmailInfo(email); setIsLoadingMessage(true); try { const data = await postJson('/api/webmail/message', { ...authPayload, folder: folderAtRequest || 'INBOX', uid: email.id }); if ( requestToken !== messageRequestTokenRef.current || folderAtRequest !== activeFolderRef.current || accountAtRequest !== activeAccountIdRef.current ) return; setActiveMessageData(data); } catch (error) { if ( requestToken !== messageRequestTokenRef.current || folderAtRequest !== activeFolderRef.current || accountAtRequest !== activeAccountIdRef.current ) return; setActiveMessageData({ html: `

${error.message || 'Failed to load message.'}

` }); } finally { if (requestToken !== messageRequestTokenRef.current) return; setIsLoadingMessage(false); } }; const handleOpenMessageWindow = () => { if (!selectedEmailInfo) return; const popup = window.open('about:blank', '_blank', 'width=1200,height=800'); if (!popup) return; const escapeHtml = (value) => String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const subject = escapeHtml(selectedEmailInfo.subject || '(No Subject)'); const sender = escapeHtml(selectedEmailInfo.sender || 'Unknown'); const body = activeMessageData?.html || '

No content.

'; const popupHtml = ` ${subject}

${subject}

From: ${sender}

${body}
`; try { popup.document.open(); popup.document.write(popupHtml); popup.document.close(); } catch { popup.location.href = `data:text/html;charset=utf-8,${encodeURIComponent(popupHtml)}`; } }; const handleDeleteMessage = async () => { if (!selectedEmailInfo?.id) return; const confirmed = window.confirm('Delete this email?'); if (!confirmed) return; try { const uid = Number(selectedEmailInfo.id); await postJson('/api/webmail/delete', { ...authPayload, folder: activeFolder || 'INBOX', uid }); setEmails((prev) => prev.filter((msg) => Number(msg?.id) !== uid)); setSelectedEmailInfo(null); setActiveMessageData(null); setSelectedEmailIds((prev) => prev.filter((id) => Number(id) !== uid)); const accountId = makeAccountId(authPayload); setViewCacheByAccount((prev) => { const accountCache = prev?.[accountId]; if (!accountCache) return prev; const folderCache = accountCache?.[activeFolder]; if (!folderCache) return prev; const nextFolderCache = {}; for (const directionKey of Object.keys(folderCache)) { const directionView = folderCache[directionKey]; const directionEmails = Array.isArray(directionView?.emails) ? directionView.emails : []; const filtered = directionEmails.filter((msg) => Number(msg?.id) !== uid); const total = Math.max(0, Number(directionView?.paging?.total || filtered.length) - 1); const nextOffset = Math.min(Number(directionView?.paging?.nextOffset || filtered.length), filtered.length); const hasMore = nextOffset < total; nextFolderCache[directionKey] = { ...directionView, emails: filtered, paging: { ...(directionView?.paging || {}), total, nextOffset, hasMore, loadedAll: !hasMore } }; } return { ...prev, [accountId]: { ...accountCache, [activeFolder]: nextFolderCache } }; }); await fetchInbox(activeFolder, authPayload, true, credentials, { keepSelection: false, silent: true, syncOnly: true }); } catch (error) { setLoginError(error.message || 'Failed to delete email.'); } }; const handleEmptyTrash = async () => { if (!isTrashFolder) return; const confirmed = window.confirm('Permanently delete all emails in Trash?'); if (!confirmed) return; setIsEmptyingTrash(true); try { await postJson('/api/webmail/empty-trash', { ...authPayload, folder: activeFolder || 'INBOX.Trash' }); setEmails([]); setSelectedEmailInfo(null); setActiveMessageData(null); setSelectedEmailIds([]); const accountId = makeAccountId(authPayload); setViewCacheByAccount((prev) => { const accountCache = prev?.[accountId]; if (!accountCache) return prev; const folderCache = accountCache?.[activeFolder] || {}; const nextFolderCache = {}; for (const key of Object.keys(folderCache)) { nextFolderCache[key] = { ...(folderCache[key] || {}), emails: [], paging: { ...(folderCache[key]?.paging || {}), total: 0, nextOffset: 0, hasMore: false, loadedAll: true } }; } return { ...prev, [accountId]: { ...accountCache, [activeFolder]: nextFolderCache } }; }); } catch (error) { setLoginError(error.message || 'Failed to empty trash.'); } finally { setIsEmptyingTrash(false); } }; const fetchQuota = async (authOverride = authPayload) => { const accountId = makeAccountId(authOverride); try { const data = await postJson('/api/webmail/quota', authOverride); setQuotaByAccount((prev) => ({ ...prev, [accountId]: data?.supported ? { supported: true, usedBytes: Number(data.usedBytes || 0), limitBytes: Number(data.limitBytes || 0), percent: Number.isFinite(data.percent) ? data.percent : 0 } : data?.estimated ? { supported: false, estimated: true, usedBytes: Number(data.usedBytes || 0) } : { supported: false } })); } catch { setQuotaByAccount((prev) => ({ ...prev, [accountId]: { supported: false } })); } }; const fetchFolderSizes = async (authOverride = authPayload) => { const accountId = makeAccountId(authOverride); try { const data = await postJson('/api/webmail/folder-sizes', authOverride); const sizes = data?.sizes && typeof data.sizes === 'object' ? data.sizes : {}; setFolderSizesByAccount((prev) => ({ ...prev, [accountId]: sizes })); } catch { setFolderSizesByAccount((prev) => ({ ...prev, [accountId]: {} })); } }; const sendReply = async () => { if (!replyText.trim() || !selectedEmailInfo) return; setIsSending(true); try { const smtpHost = authPayload.host.replace(/^imap\./i, 'smtp.'); const to = extractEmail(selectedEmailInfo.sender); await postJson('/api/webmail/send', { user: authPayload.user, password: credentials.password, host: smtpHost, port: 465, to, subject: `Re: ${selectedEmailInfo.subject || ''}`, body: `

${replyText.replace(/\n/g, '
')}

` }); setReplyText(''); } catch (error) { setLoginError(error.message || 'Failed to send reply.'); } finally { setIsSending(false); } }; const handleLogout = () => { inboxRequestTokenRef.current += 1; messageRequestTokenRef.current += 1; const currentId = activeAccountId; const remainingAccounts = savedAccounts.filter((a) => a.id !== currentId); removeAccountCache(currentId); persistAccounts(remainingAccounts); setIsLoggedIn(false); setFolders([]); setEmails([]); setSelectedEmailInfo(null); setActiveMessageData(null); setReplyText(''); setCredentials(initialCredentials); setSavedAccounts(remainingAccounts); setActiveAccountId(remainingAccounts[0]?.id || ''); setQuotaByAccount((prev) => { const next = { ...prev }; delete next[currentId]; return next; }); setFolderSizesByAccount((prev) => { const next = { ...prev }; delete next[currentId]; return next; }); setViewCacheByAccount((prev) => { const next = { ...prev }; delete next[currentId]; return next; }); setPagingByAccount((prev) => { const next = { ...prev }; delete next[currentId]; return next; }); setIsAddingAccount(false); clearSession(); }; const handleLogoutAll = () => { inboxRequestTokenRef.current += 1; messageRequestTokenRef.current += 1; clearSession(); clearAllAccountCaches(); persistAccounts([]); setIsLoggedIn(false); setFolders([]); setEmails([]); setSelectedEmailInfo(null); setActiveMessageData(null); setReplyText(''); setCredentials(initialCredentials); setSavedAccounts([]); setActiveAccountId(''); setQuotaByAccount({}); setFolderSizesByAccount({}); setViewCacheByAccount({}); setPagingByAccount({}); setLoginError(''); setIsAddingAccount(false); }; const handleAddAccount = () => { inboxRequestTokenRef.current += 1; messageRequestTokenRef.current += 1; setIsLoggedIn(false); setIsConnecting(false); setLoginError(''); setFolders([]); setEmails([]); setSelectedEmailInfo(null); setActiveMessageData(null); setReplyText(''); setCredentials(initialCredentials); setViewCacheByAccount({}); setIsAddingAccount(true); clearSession(); }; const handleCancelAddAccount = async () => { if (savedAccounts.length === 0) { setIsAddingAccount(false); return; } const targetId = activeAccountId || savedAccounts[0].id; setIsAddingAccount(false); await handleSwitchAccount(targetId); }; const handleSwitchAccount = async (accountId) => { const account = savedAccounts.find((a) => a.id === accountId); if (!account) return; setIsConnecting(true); setLoginError(''); setCredentials(account.credentials); setActiveAccountId(account.id); try { const auth = getAuthFromCredentials(account.credentials); const cached = getAccountCache(account.id); const targetFolder = account.lastFolder || 'INBOX'; if (cached?.folders?.length) { setFolders(cached.folders); } const cachedTargetEntry = cached?.emailsByFolder?.[targetFolder]; const cachedTargetEmails = Array.isArray(cachedTargetEntry) ? cachedTargetEntry : Array.isArray(cachedTargetEntry?.emails) ? cachedTargetEntry.emails : null; if (cachedTargetEmails) { const firstPage = buildFirstPageView(cachedTargetEmails, cachedTargetEmails.length, sortOrder); setActiveFolder(targetFolder); setEmails(firstPage.emails); } await connectWithCredentials(auth, account.lastFolder || 'INBOX', account.credentials); await fetchQuota(auth); await fetchFolderSizes(auth); } finally { setIsConnecting(false); } }; useEffect(() => { let cancelled = false; const restoreSession = async () => { try { const rawAccounts = localStorage.getItem(WEBMAIL_ACCOUNTS_KEY); if (rawAccounts) { const parsedAccounts = JSON.parse(rawAccounts); if (Array.isArray(parsedAccounts)) { setSavedAccounts(parsedAccounts); } } const raw = localStorage.getItem(WEBMAIL_SESSION_KEY); if (!raw) return; const parsed = JSON.parse(raw); const parsedCredentials = parsed?.credentials || {}; let savedCredentials = { ...initialCredentials, ...parsedCredentials }; // Backward compatibility with old saved format (user/imapHost/imapPort). if (!Object.prototype.hasOwnProperty.call(parsedCredentials, 'mode') && parsedCredentials.user) { const oldUser = String(parsedCredentials.user || '').trim(); const oldHost = String(parsedCredentials.imapHost || DEFAULT_IMAP_HOST).trim(); const oldPort = String(parsedCredentials.imapPort || DEFAULT_IMAP_PORT).trim(); const oldPassword = String(parsedCredentials.password || '').trim(); if (oldUser.toLowerCase().endsWith(`@${DEFAULT_DOMAIN}`)) { savedCredentials = { ...initialCredentials, mode: 'weektab', localPart: oldUser.slice(0, oldUser.indexOf('@')), password: oldPassword, customImapHost: DEFAULT_IMAP_HOST, customImapPort: DEFAULT_IMAP_PORT }; } else { savedCredentials = { ...initialCredentials, mode: 'custom', customUser: oldUser, password: oldPassword, customImapHost: oldHost || DEFAULT_IMAP_HOST, customImapPort: oldPort || DEFAULT_IMAP_PORT }; } } const savedIsCustom = savedCredentials.mode === 'custom'; const savedAuth = { user: savedIsCustom ? String(savedCredentials.customUser || '').trim() : `${String(savedCredentials.localPart || '').trim()}@${DEFAULT_DOMAIN}`, password: savedCredentials.password, host: savedIsCustom ? String(savedCredentials.customImapHost || DEFAULT_IMAP_HOST).trim() : DEFAULT_IMAP_HOST, port: savedIsCustom ? Number(savedCredentials.customImapPort || DEFAULT_IMAP_PORT) : Number(DEFAULT_IMAP_PORT), tls: true }; if (!savedAuth.user || !savedAuth.password || !savedAuth.host) { clearSession(); return; } setCredentials(savedCredentials); setActiveAccountId(makeAccountId(savedAuth)); setIsConnecting(true); setLoginError(''); if (!cancelled) { await connectWithCredentials(savedAuth, parsed?.lastFolder || 'INBOX', savedCredentials, { skipInitialFetchIfCached: true }); await fetchQuota(savedAuth); await fetchFolderSizes(savedAuth); } } catch { clearSession(); } finally { if (!cancelled) { setIsConnecting(false); setIsRestoringSession(false); } } }; restoreSession(); return () => { cancelled = true; }; }, []); useEffect(() => { if (!isLoggedIn || !activeAccountId || !activeFolder) return undefined; const intervalId = window.setInterval(() => { fetchInbox(activeFolder, authPayload, true, credentials, { keepSelection: true, silent: true, syncOnly: true }); }, 5 * 60 * 1000); return () => window.clearInterval(intervalId); }, [isLoggedIn, activeAccountId, activeFolder, authPayload, credentials, sortOrder]); const activeFolderPaging = getViewCache(activeAccountId, activeFolder, sortOrder)?.paging || pagingByAccount?.[activeAccountId]?.[activeFolder] || {}; useEffect(() => { if (prevSortOrderRef.current === sortOrder) return; prevSortOrderRef.current = sortOrder; if (!isLoggedIn || !activeAccountId || !activeFolder) return; const cachedView = getViewCache(activeAccountId, activeFolder, sortOrder); if (cachedView?.emails?.length) { const firstPage = buildFirstPageView( cachedView.emails, cachedView.paging?.total || cachedView.emails.length, sortOrder ); setEmails(firstPage.emails); setFolderPaging(activeAccountId, activeFolder, firstPage.paging); setViewCache(activeAccountId, activeFolder, sortOrder, cachedView.emails, firstPage.paging); return; } fetchInbox(activeFolder, authPayload, true, credentials, { keepSelection: true, forceRefresh: true, resetToFirstPage: true }); }, [sortOrder, isLoggedIn, activeAccountId, activeFolder, authPayload, credentials, viewCacheByAccount]); const filteredEmails = useMemo(() => { const q = String(searchQuery || '').trim().toLowerCase(); if (!q) return emails; return emails.filter((email) => { const haystack = [ email?.subject, email?.sender, email?.snippet, email?.preview, email?.text, email?.body ] .filter(Boolean) .join(' ') .toLowerCase(); return haystack.includes(q); }); }, [emails, searchQuery]); const displayedEmails = useMemo(() => { const list = [...filteredEmails]; list.sort((a, b) => { const aId = Number(a?.id || 0); const bId = Number(b?.id || 0); return sortOrder === 'newest' ? bId - aId : aId - bId; }); return list; }, [filteredEmails, sortOrder]); const canShowLoadMore = useMemo(() => { if (isLoadingInbox) return false; if (String(searchQuery || '').trim()) return false; const loadedCount = Number(emails?.length || 0); const totalCount = Number(activeFolderPaging?.total || 0); if (totalCount > loadedCount) return true; return Boolean(activeFolderPaging?.hasMore); }, [isLoadingInbox, searchQuery, emails, activeFolderPaging]); const isTrashFolder = useMemo(() => /trash$/i.test(String(activeFolder || '')), [activeFolder]); const selectedCount = selectedEmailIds.length; const mailBackgroundColor = useMemo( () => extractMailBackgroundColor(activeMessageData?.html), [activeMessageData?.html] ); useEffect(() => { setSelectedEmailIds([]); }, [activeFolder, sortOrder]); const toggleEmailSelection = (emailId) => { const uid = Number(emailId); if (!Number.isFinite(uid)) return; setSelectedEmailIds((prev) => ( prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid] )); }; const getSenderInitial = (sender) => { const clean = String(sender || '').replace(/<.*?>/g, '').trim(); const first = clean.charAt(0).toUpperCase(); return first || '?'; }; const handleDeleteSelected = async () => { if (selectedEmailIds.length === 0) return; const confirmed = window.confirm(`Delete ${selectedEmailIds.length} selected email(s)?`); if (!confirmed) return; setIsDeletingSelected(true); try { const uids = selectedEmailIds .map((id) => Number(id)) .filter((uid) => Number.isFinite(uid) && uid > 0); // Use proven single-delete endpoint per UID for maximum compatibility. await Promise.all( uids.map((uid) => postJson('/api/webmail/delete', { ...authPayload, folder: activeFolder || 'INBOX', uid })) ); const deleteSet = new Set(uids); setEmails((prev) => prev.filter((msg) => !deleteSet.has(Number(msg?.id)))); if (selectedEmailInfo?.id && deleteSet.has(Number(selectedEmailInfo.id))) { setSelectedEmailInfo(null); setActiveMessageData(null); } const accountId = makeAccountId(authPayload); setViewCacheByAccount((prev) => { const accountCache = prev?.[accountId]; if (!accountCache) return prev; const folderCache = accountCache?.[activeFolder]; if (!folderCache) return prev; const nextFolderCache = {}; for (const directionKey of Object.keys(folderCache)) { const directionView = folderCache[directionKey]; const directionEmails = Array.isArray(directionView?.emails) ? directionView.emails : []; const filtered = directionEmails.filter((msg) => !deleteSet.has(Number(msg?.id))); const total = Math.max(0, Number(directionView?.paging?.total || filtered.length) - deleteSet.size); const nextOffset = Math.min(Number(directionView?.paging?.nextOffset || filtered.length), filtered.length); const hasMore = nextOffset < total; nextFolderCache[directionKey] = { ...directionView, emails: filtered, paging: { ...(directionView?.paging || {}), total, nextOffset, hasMore, loadedAll: !hasMore } }; } return { ...prev, [accountId]: { ...accountCache, [activeFolder]: nextFolderCache } }; }); setSelectedEmailIds([]); await fetchInbox(activeFolder, authPayload, true, credentials, { keepSelection: false, silent: true, syncOnly: true }); } catch (error) { setLoginError(error.message || 'Failed to delete selected emails.'); } finally { setIsDeletingSelected(false); } }; const handleLoadMore = async () => { if (!isLoggedIn || !activeFolder) return; if (isLoadingMore || isLoadingInbox) return; if (!activeFolderPaging.hasMore || activeFolderPaging.loadedAll) return; await fetchInbox(activeFolder, authPayload, true, credentials, { keepSelection: true, append: true, silent: true }); }; const handleManualRefresh = async () => { setIsRefreshingInbox(true); try { await fetchInbox(activeFolder, authPayload, true, credentials, { keepSelection: true, syncOnly: true }); await fetchFolderSizes(authPayload); await fetchQuota(authPayload); } finally { setIsRefreshingInbox(false); } }; const activeQuota = quotaByAccount[activeAccountId] || null; const activeFolderSizes = folderSizesByAccount[activeAccountId] || {}; const loginForm = (
{loginError &&
{loginError}
}
{credentials.mode === 'custom' ? ( ) : (
@{DEFAULT_DOMAIN}
)}

CUSTOM E-MAIL

Optional: Use your own mail domain. Then you can set host and port.

{credentials.mode === 'custom' && ( <>
)}
{isAddingAccount && savedAccounts.length > 0 && ( )}

Use your IMAP credentials to sign in.

); const webmailWorkspace = (
{(folders.length ? folders : [{ name: 'INBOX', unseen: 0, total: 0 }]).map((folder, index) => { const folderName = typeof folder === 'string' ? folder : folder.name; const displayFolderName = folderName === 'INBOX' ? 'INBOX' : String(folderName || '').replace(/^INBOX\./i, ''); const unseen = folder.unseen || 0; const total = folder.total || 0; const isActive = activeFolder === folderName; const sizeBytes = Number(activeFolderSizes[folderName] || 0); const sizeLabel = sizeBytes > 0 ? formatBytes(sizeBytes) : ''; return (
fetchInbox(folderName, authPayload, true, credentials, { resetToFirstPage: true, syncOnly: true })} > {displayFolderName} {unseen > 0 && {unseen}} {total > 0 && {total}} {sizeLabel && {sizeLabel}}
); })}
{activeQuota?.supported ? (
Storage {formatBytes(activeQuota.usedBytes)} / {formatBytes(activeQuota.limitBytes)}
) : activeQuota?.estimated ? (
Total mailbox size: {formatBytes(activeQuota.usedBytes)}
) : (
Checking storage usage...
)} {savedAccounts.length > 1 ? (
) : ( )}
setSearchQuery(e.target.value)} />
{activeFolder || 'INBOX'}
{selectedCount > 0 && ( )} {isTrashFolder && selectedCount === 0 && ( )}
{isLoadingInbox ? (
Loading messages...
) : filteredEmails.length === 0 ? (
{emails.length === 0 ? 'No emails found.' : 'No results for your search.'}
) : ( displayedEmails.map((email) => { const isSelected = selectedEmailInfo?.id === email.id; const isChecked = selectedEmailIds.includes(Number(email.id)); const simpleSender = String(email.sender || '').split('<')[0].trim() || email.sender; const senderInitial = getSenderInitial(simpleSender); return (
fetchMessage(email)} > toggleEmailSelection(email.id)} onClick={(e) => e.stopPropagation()} aria-label="Select email" />
{simpleSender || 'Unknown'}
{String(email.time || '').split(',')[0]}
{email.subject || '(No Subject)'}
); }) )} {canShowLoadMore && (
)}
{!selectedEmailInfo ? (

Select a message from the list.

) : ( <>
{(() => { const rawSender = String(selectedEmailInfo.sender || 'Unknown'); const senderMatch = rawSender.match(/^(.*?)\s*<([^>]+)>$/); const senderName = (senderMatch?.[1] || rawSender).trim().replace(/^"(.*)"$/, '$1') || 'Unknown'; const senderEmail = (senderMatch?.[2] || rawSender).trim(); return ( <>

{selectedEmailInfo.subject || '(No Subject)'}

{senderName}
{senderEmail}
{isLoadingMessage ? (
Loading message...
) : activeMessageData?.html ? (
) : (
No content.
)}
); })()}
setReplyText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && sendReply()} />
{isSending ? ( Sending... ) : ( )}
)}
); return ( <> {!isLoggedIn ? (

Webmail

Mailbox and message management

Sign in with your IMAP account to start reading and sending email.

{loginForm}
) : ( webmailWorkspace )} {isRestoringSession && (

Restoring saved session...

)} ); }