1801 lines
61 KiB
React
1801 lines
61 KiB
React
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(/<body[^>]*>/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(/<table[^>]*>/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: `<p style="color:#b91c1c;">${error.message || 'Failed to load message.'}</p>`
|
|
});
|
|
} 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, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
const subject = escapeHtml(selectedEmailInfo.subject || '(No Subject)');
|
|
const sender = escapeHtml(selectedEmailInfo.sender || 'Unknown');
|
|
const body = activeMessageData?.html || '<p style="font-family:system-ui,sans-serif;color:#6b7280;">No content.</p>';
|
|
|
|
const popupHtml = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>${subject}</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<style>
|
|
body { margin: 0; background: #f5f6f8; color: #111827; font-family: "Segoe UI", Arial, sans-serif; }
|
|
.wrap { max-width: 980px; margin: 20px auto; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
|
|
.head { padding: 18px 22px; border-bottom: 1px solid #e5e7eb; }
|
|
.subj { margin: 0 0 8px; font-size: 22px; line-height: 1.2; }
|
|
.meta { margin: 0; color: #6b7280; font-size: 13px; }
|
|
.body { padding: 22px; }
|
|
.body img { max-width: 100%; height: auto; }
|
|
a { color: #2563eb; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="head">
|
|
<h1 class="subj">${subject}</h1>
|
|
<p class="meta">From: ${sender}</p>
|
|
</div>
|
|
<div class="body">${body}</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
|
|
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: `<p>${replyText.replace(/\n/g, '<br>')}</p>`
|
|
});
|
|
|
|
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 = (
|
|
<form className="login-form login-form--billing" onSubmit={connectIMAP}>
|
|
{loginError && <div className="login-error">{loginError}</div>}
|
|
|
|
<div className="form-group">
|
|
<label className="form-label">{credentials.mode === 'custom' ? 'Email' : 'Username'}</label>
|
|
{credentials.mode === 'custom' ? (
|
|
<input
|
|
type="email"
|
|
name="customUser"
|
|
required
|
|
value={credentials.customUser}
|
|
onChange={handleLoginChange}
|
|
placeholder="name@yourdomain.tld"
|
|
/>
|
|
) : (
|
|
<div className="email-fixed-domain">
|
|
<input
|
|
type="text"
|
|
name="localPart"
|
|
required
|
|
value={credentials.localPart}
|
|
onChange={handleLoginChange}
|
|
placeholder="e.g. max.mustermann"
|
|
/>
|
|
<span className="email-domain">@{DEFAULT_DOMAIN}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label">Password</label>
|
|
<input
|
|
type="password"
|
|
name="password"
|
|
required
|
|
value={credentials.password}
|
|
onChange={handleLoginChange}
|
|
placeholder="Password"
|
|
/>
|
|
</div>
|
|
|
|
<div className="custom-email-box">
|
|
<div className="custom-email-box__header">
|
|
<h3>CUSTOM E-MAIL</h3>
|
|
<button
|
|
type="button"
|
|
className={`custom-email-toggle ${credentials.mode === 'custom' ? 'active' : ''}`}
|
|
onClick={() => setCredentials((prev) => ({
|
|
...prev,
|
|
mode: prev.mode === 'custom' ? 'weektab' : 'custom'
|
|
}))}
|
|
>
|
|
{credentials.mode === 'custom' ? 'Active' : 'Enable'}
|
|
</button>
|
|
</div>
|
|
|
|
<p className="custom-email-box__hint">
|
|
Optional: Use your own mail domain. Then you can set host and port.
|
|
</p>
|
|
|
|
{credentials.mode === 'custom' && (
|
|
<>
|
|
<div className="form-group">
|
|
<label className="form-label">CUSTOM IMAP Host</label>
|
|
<input
|
|
type="text"
|
|
name="customImapHost"
|
|
required
|
|
value={credentials.customImapHost}
|
|
onChange={handleLoginChange}
|
|
placeholder="imap.yourdomain.tld"
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label">CUSTOM Port</label>
|
|
<input
|
|
type="number"
|
|
name="customImapPort"
|
|
required
|
|
value={credentials.customImapPort}
|
|
onChange={handleLoginChange}
|
|
placeholder="993"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<button type="submit" className="login-btn" disabled={isConnecting}>
|
|
{isConnecting ? 'Connecting...' : 'Sign in'}
|
|
</button>
|
|
|
|
{isAddingAccount && savedAccounts.length > 0 && (
|
|
<button type="button" className="cancel-account-btn" onClick={handleCancelAddAccount}>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
|
|
<p className="login-hint">Use your IMAP credentials to sign in.</p>
|
|
</form>
|
|
);
|
|
|
|
const webmailWorkspace = (
|
|
<div className="webmail-app webmail-app--fullscreen">
|
|
<div className="missive-sidebar">
|
|
<div className="sidebar-header">
|
|
<button
|
|
className={`add-btn ${isRefreshingInbox ? 'syncing' : ''}`}
|
|
onClick={handleManualRefresh}
|
|
disabled={isRefreshingInbox}
|
|
title="Refresh"
|
|
>
|
|
<i className="ri-refresh-line" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="sidebar-scroll">
|
|
{(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 (
|
|
<div
|
|
key={`${folderName}-${index}`}
|
|
className={`sidebar-item ${isActive ? 'active' : ''}`}
|
|
onClick={() => fetchInbox(folderName, authPayload, true, credentials, {
|
|
resetToFirstPage: true,
|
|
syncOnly: true
|
|
})}
|
|
>
|
|
<i className="ri-folder-2-fill" />
|
|
<span className="label" style={{ textTransform: 'capitalize' }}>{displayFolderName}</span>
|
|
{unseen > 0 && <span className="sidebar-badge badge-blue">{unseen}</span>}
|
|
{total > 0 && <span className="sidebar-badge badge-gray">{total}</span>}
|
|
{sizeLabel && <span className="sidebar-folder-size">{sizeLabel}</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
</div>
|
|
|
|
<div className="sidebar-account-switcher">
|
|
<div className="account-switcher-header">
|
|
<label htmlFor="account-switcher">Email account</label>
|
|
<button
|
|
type="button"
|
|
className="add-account-icon-btn"
|
|
onClick={handleAddAccount}
|
|
title="Add new email"
|
|
aria-label="Add new email"
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
<select
|
|
id="account-switcher"
|
|
value={activeAccountId}
|
|
onChange={(e) => handleSwitchAccount(e.target.value)}
|
|
>
|
|
{savedAccounts.length === 0 && <option value="">No accounts</option>}
|
|
{savedAccounts.map((account) => (
|
|
<option key={account.id} value={account.id}>
|
|
{account.email}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{activeQuota?.supported ? (
|
|
<div className="account-quota">
|
|
<div className="account-quota__text">
|
|
Storage {formatBytes(activeQuota.usedBytes)} / {formatBytes(activeQuota.limitBytes)}
|
|
</div>
|
|
<div className="account-quota__bar" aria-hidden="true">
|
|
<span style={{ width: `${Math.max(0, Math.min(100, activeQuota.percent || 0))}%` }} />
|
|
</div>
|
|
</div>
|
|
) : activeQuota?.estimated ? (
|
|
<div className="account-quota">
|
|
<div className="account-quota__text">
|
|
Total mailbox size: {formatBytes(activeQuota.usedBytes)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="account-quota account-quota--muted">Checking storage usage...</div>
|
|
)}
|
|
{savedAccounts.length > 1 ? (
|
|
<div className="logout-grid">
|
|
<button type="button" className="logout-btn" onClick={handleLogout}>
|
|
Log out
|
|
</button>
|
|
<button type="button" className="logout-all-btn" onClick={handleLogoutAll}>
|
|
Logout all
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button type="button" className="logout-btn logout-btn--single" onClick={handleLogout}>
|
|
Log out
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="missive-list">
|
|
<div className="list-search">
|
|
<i className="ri-search-line" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search emails"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="list-sort-btn"
|
|
onClick={() => setSortOrder((prev) => (prev === 'newest' ? 'oldest' : 'newest'))}
|
|
title={sortOrder === 'newest' ? 'Sorted: newest first' : 'Sorted: oldest first'}
|
|
aria-label={sortOrder === 'newest' ? 'Sorted: newest first' : 'Sorted: oldest first'}
|
|
>
|
|
<i className={sortOrder === 'newest' ? 'ri-sort-desc' : 'ri-sort-asc'} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="list-section-header">
|
|
<span>{activeFolder || 'INBOX'}</span>
|
|
<div className="list-section-actions">
|
|
{selectedCount > 0 && (
|
|
<button
|
|
type="button"
|
|
className="header-delete-btn"
|
|
onClick={handleDeleteSelected}
|
|
disabled={isDeletingSelected}
|
|
>
|
|
{isDeletingSelected ? 'Deleting...' : `Delete (${selectedCount})`}
|
|
</button>
|
|
)}
|
|
{isTrashFolder && selectedCount === 0 && (
|
|
<button
|
|
type="button"
|
|
className="empty-trash-btn"
|
|
onClick={handleEmptyTrash}
|
|
disabled={isEmptyingTrash}
|
|
>
|
|
{isEmptyingTrash ? 'Emptying...' : 'Empty trash'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="list-scroll">
|
|
{isLoadingInbox ? (
|
|
<div className="loading-state">Loading messages...</div>
|
|
) : filteredEmails.length === 0 ? (
|
|
<div className="loading-state">
|
|
{emails.length === 0 ? 'No emails found.' : 'No results for your search.'}
|
|
</div>
|
|
) : (
|
|
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 (
|
|
<div
|
|
key={email.id}
|
|
className={`list-item ${isSelected ? 'active' : ''} ${isChecked ? 'checked' : ''}`}
|
|
onClick={() => fetchMessage(email)}
|
|
>
|
|
<span className="email-sender-avatar" aria-hidden="true">
|
|
<span className="email-sender-avatar__fallback">{senderInitial}</span>
|
|
{email.avatar && (
|
|
<img
|
|
src={email.avatar}
|
|
alt=""
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
onError={(e) => {
|
|
e.currentTarget.style.display = 'none';
|
|
}}
|
|
/>
|
|
)}
|
|
</span>
|
|
<input
|
|
type="checkbox"
|
|
className="email-select-checkbox"
|
|
checked={isChecked}
|
|
onChange={() => toggleEmailSelection(email.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
aria-label="Select email"
|
|
/>
|
|
<div className="item-top">
|
|
<div className="item-sender">
|
|
<span className="name">{simpleSender || 'Unknown'}</span>
|
|
</div>
|
|
<span className="item-time">{String(email.time || '').split(',')[0]}</span>
|
|
</div>
|
|
<div className="item-subject">
|
|
<span>{email.subject || '(No Subject)'}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
{canShowLoadMore && (
|
|
<div className="list-load-more-wrap">
|
|
<button
|
|
type="button"
|
|
className="list-load-more-btn"
|
|
onClick={handleLoadMore}
|
|
disabled={isLoadingMore}
|
|
>
|
|
{isLoadingMore ? 'Loading...' : 'Load more'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="missive-reading">
|
|
{!selectedEmailInfo ? (
|
|
<div className="empty-reading-state">
|
|
<p>Select a message from the list.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="reading-toolbar">
|
|
<div className="toolbar-left">
|
|
<button
|
|
type="button"
|
|
className="toolbar-action-btn"
|
|
title="Open in new window"
|
|
aria-label="Open in new window"
|
|
onClick={handleOpenMessageWindow}
|
|
>
|
|
<i className="ri-external-link-line" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="toolbar-action-btn toolbar-action-btn--danger"
|
|
title="Delete email"
|
|
aria-label="Delete email"
|
|
onClick={handleDeleteMessage}
|
|
>
|
|
<i className="ri-delete-bin-line" />
|
|
</button>
|
|
</div>
|
|
<div className="toolbar-center" />
|
|
<div className="toolbar-right">
|
|
<i className="ri-reply-line" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="reading-content">
|
|
{(() => {
|
|
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 (
|
|
<>
|
|
<div className="mail-meta-card">
|
|
<h2 className="mail-meta-card__title">{selectedEmailInfo.subject || '(No Subject)'}</h2>
|
|
<div className="msg-header">
|
|
<div className="msg-sender-info">
|
|
<span className="msg-sender-avatar" aria-hidden="true">
|
|
<span className="msg-sender-avatar__fallback">
|
|
{getSenderInitial(senderName)}
|
|
</span>
|
|
{selectedEmailInfo?.avatar && (
|
|
<img
|
|
src={selectedEmailInfo.avatar}
|
|
alt=""
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
onError={(e) => {
|
|
e.currentTarget.style.display = 'none';
|
|
}}
|
|
/>
|
|
)}
|
|
</span>
|
|
<div className="msg-names">
|
|
<div className="msg-name">{senderName}</div>
|
|
<div className="msg-email">{senderEmail}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mail-body-panel" style={mailBackgroundColor ? { backgroundColor: mailBackgroundColor } : undefined}>
|
|
<div className={`msg-body ${activeMessageData?.isPlainText ? 'msg-body--plain' : ''}`}>
|
|
{isLoadingMessage ? (
|
|
<div style={{ color: '#888', padding: '16px' }}>Loading message...</div>
|
|
) : activeMessageData?.html ? (
|
|
<div dangerouslySetInnerHTML={{ __html: activeMessageData.html }} />
|
|
) : (
|
|
<div style={{ color: '#888', padding: '16px' }}>No content.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
<div className="reading-footer">
|
|
<div className="chat-input-wrapper">
|
|
<input
|
|
type="text"
|
|
placeholder="Write a reply..."
|
|
value={replyText}
|
|
onChange={(e) => setReplyText(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && sendReply()}
|
|
/>
|
|
<div className="chat-actions">
|
|
{isSending ? (
|
|
<span style={{ fontSize: '12px' }}>Sending...</span>
|
|
) : (
|
|
<i className="ri-send-plane-2-line" onClick={sendReply} style={{ color: '#4e5cf4', cursor: 'pointer' }} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{!isLoggedIn ? (
|
|
<div className="webmail-login-page">
|
|
<div className="webmail-login-panel">
|
|
<p className="webmail-login-kicker">Webmail</p>
|
|
<h1 className="webmail-login-title">Mailbox and message management</h1>
|
|
<p className="webmail-login-subtitle">Sign in with your IMAP account to start reading and sending email.</p>
|
|
<div className="webmail-login-form-wrap">
|
|
{loginForm}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
webmailWorkspace
|
|
)}
|
|
|
|
{isRestoringSession && (
|
|
<p className="loading-state" style={{ marginTop: '12px' }}>Restoring saved session...</p>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|