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 = (
);
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)}
>
{senderInitial}
{email.avatar && (
{
e.currentTarget.style.display = 'none';
}}
/>
)}
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)'}
{getSenderInitial(senderName)}
{selectedEmailInfo?.avatar && (
{
e.currentTarget.style.display = 'none';
}}
/>
)}
{senderName}
{senderEmail}
{isLoadingMessage ? (
Loading message...
) : activeMessageData?.html ? (
) : (
No content.
)}
>
);
})()}
>
)}
);
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...
)}
>
);
}