import { useEffect, useMemo, useRef, useState } from "react"; import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react"; import { useLocation } from "react-router"; import { ActionToolbar, ActionBlockerHint, Button, CountBadge, DataGridPaginationBar, DocumentationHelpLink, Dialog, DismissibleAlert, ExplorerTree, FormField, FormSection, IconButton, LoadingIndicator, MessageDisplayPanel, WorkspaceActionBar, WorkspaceFrame, hasAnyScope, formatDateTime, i18nMessage, useGuardedNavigate, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; import { bootstrapMailbox, createMailAddressContact, getMailboxMessage, listMailAddressWriteTargets, listMailboxMessages, listMailboxFolders, listMailServerProfiles, type MailAddressWriteTarget, type MailImapFolderResponse, type MailMailboxMessageDetail, type MailMailboxMessageSummary, type MailMailboxProtocol, type MailServerProfile } from "../../api/mail"; import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders"; import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay"; import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch"; import { mailboxHeaderAddresses } from "./mailAddressIntegration"; const MAILBOX_DOCUMENTATION = { topicId: "mail.workflow.read-mailbox", documentationType: "user" } as const; // Each context read revalidates the current principal and explicit Reload must // not reuse Core's short-lived GET response/in-flight promise cache. Mail's // bounded provider index remains available unless the refresh flag is set. const MAILBOX_READ_OPTIONS = { cache: "no-store" } as const; export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { const navigate = useGuardedNavigate(); const location = useLocation(); const [profiles, setProfiles] = useState([]); const [selectedProfileId, setSelectedProfileId] = useState(""); const [folders, setFolders] = useState([]); const [selectedFolder, setSelectedFolder] = useState("INBOX"); const [selectedFolderGroup, setSelectedFolderGroup] = useState | null>(null); const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState(""); const [expandedFolders, setExpandedFolders] = useState>(() => new Set()); const [messages, setMessages] = useState([]); const [messageTotalCount, setMessageTotalCount] = useState(null); const [messageProvenance, setMessageProvenance] = useState(null); const [messagePage, setMessagePage] = useState(1); const [messagePageSize, setMessagePageSize] = useState(10); const [messageQuery, setMessageQuery] = useState(""); const [selectedMessage, setSelectedMessage] = useState(null); const [selectedMessageKeyState, setSelectedMessageKeyState] = useState(""); const [pendingMessageKey, setPendingMessageKey] = useState(""); const [loadingProfiles, setLoadingProfiles] = useState(true); const [loadingFolders, setLoadingFolders] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessage, setLoadingMessage] = useState(false); const [error, setError] = useState(""); const [folderError, setFolderError] = useState(""); const [messageError, setMessageError] = useState(""); const [detailError, setDetailError] = useState(""); const [mailToolsOpen, setMailToolsOpen] = useState(false); const [reloadingMailbox, setReloadingMailbox] = useState(false); const profileRequestRef = useRef(0); const mailboxReloadRequestRef = useRef(0); const selectedProfileIdRef = useRef(selectedProfileId); selectedProfileIdRef.current = selectedProfileId; const authorityKey = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.user.id, auth.active_tenant?.id ?? auth.tenant.id]); const authorityRef = useRef(authorityKey); authorityRef.current = authorityKey; const revealedFolderRef = useRef(""); const selectedMessageKeyRef = useRef(""); const folderRequestRef = useRef(0); const messageListRequestRef = useRef(0); const messageDetailRequestRef = useRef(0); const loadedMessagePageRef = useRef<{ profileId: string; folder: string; page: number; pageSize: number } | null>(null); const mailboxPageCursorsRef = useRef>({}); const skipNextMessageLoadRef = useRef(false); const launchRequestRef = useRef(parseMailboxLaunch(location.search)); const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null; const mailboxProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile)), [profiles]); const selectedMailboxProtocol = mailboxProtocolForProfile(selectedProfile) ?? "imap"; const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]); const selectedFolderNodeId = useMemo(() => selectedFolderGroup?.id ?? findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder, selectedFolderGroup]); const filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]); const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize)); const shellBusy = loadingProfiles || loadingFolders || loadingMessages || reloadingMailbox; const noMailboxProfiles = !loadingProfiles && mailboxProfiles.length === 0; const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId; const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : ""); const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady); const syncState = mailboxSyncState(messageProvenance); const folderEmptyText = folderError || (noMailboxProfiles ? "No IMAP- or JMAP-enabled Mail profiles are available." : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26"); const messageEmptyText = messageError || (selectedFolderGroup ? "i18n:govoplan-mail.folder_group_selection" : !selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d"); const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342"); const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094"; const profileReloadBlocker = shellBusy ? "i18n:govoplan-mail.mailbox_refresh_in_progress" : ""; const folderReloadBlocker = !selectedProfileId ? "Select an IMAP- or JMAP-enabled Mail profile before refreshing folders." : shellBusy ? "Wait for the current mailbox refresh to finish." : ""; const messageReloadBlocker = !selectedProfileId ? "Select an IMAP- or JMAP-enabled Mail profile before refreshing messages." : !selectedFolder || !foldersReady ? "Select a loaded mailbox folder before refreshing messages." : shellBusy ? "Messages are already loading." : ""; useEffect(() => { selectProfile(""); setProfiles([]); setMailToolsOpen(false); void loadProfiles(); return () => { profileRequestRef.current += 1; folderRequestRef.current += 1; messageListRequestRef.current += 1; messageDetailRequestRef.current += 1; mailboxReloadRequestRef.current += 1; }; }, [authorityKey]); useEffect(() => { const request = parseMailboxLaunch(location.search); launchRequestRef.current = request; if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return; const usable = profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile)); const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId) ? request.profileId : selectedProfileId || usable[0]?.id || ""; if (!targetProfileId) return; if (targetProfileId !== selectedProfileId) { selectProfile(targetProfileId); return; } void loadMailboxBootstrap(targetProfileId); }, [location.search]); useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]); useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]); useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]); useEffect(() => { if (!selectedProfileId || !selectedFolder || !foldersReady) return; if (skipNextMessageLoadRef.current) { skipNextMessageLoadRef.current = false; return; } void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize); }, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]); useEffect(() => { if (selectedMailboxProtocol !== "jmap" || !selectedProfileId || !selectedFolder || !foldersReady) return; const requestAuthority = authorityKey; const profileId = selectedProfileId; const listRequest = messageListRequestRef.current; const handle = window.setTimeout(() => { if (requestAuthority !== authorityRef.current || profileId !== selectedProfileIdRef.current || listRequest !== messageListRequestRef.current) return; setMessagePage(1); mailboxPageCursorsRef.current = {}; void loadMessages(selectedProfileId, selectedFolder, 1, messagePageSize); }, 250); return () => window.clearTimeout(handle); }, [messageQuery, selectedFolderGroup]); useEffect(() => { const request = launchRequestRef.current; if (!request || !foldersReady || loadingMessages) return; const targetProfileMatches = !request.profileId || request.profileId === selectedProfileId; const targetFolder = mailboxLaunchFolder(request, selectedProfile); if (!targetProfileMatches || (targetFolder && targetFolder !== selectedFolder)) return; const target = request.messageUid ? messages.find((message) => message.uid === request.messageUid) : null; launchRequestRef.current = null; if (target) void openMessage(target); }, [foldersReady, loadingMessages, messages, selectedFolder, selectedProfile, selectedProfileId]); useEffect(() => { const handlePreviewShortcut = (event: KeyboardEvent) => { if (mailToolsOpen || isEditableTarget(event.target)) return; if (event.key === "Escape") { if (selectedMessage || selectedMessageKey || pendingMessageKey || detailError) { event.preventDefault(); messageDetailRequestRef.current += 1; setLoadingMessage(false); setSelectedMessage(null); setSelectedMessageKeyState(""); selectedMessageKeyRef.current = ""; setPendingMessageKey(""); setDetailError(""); } return; } if (shellBusy || loadingMessage || filteredMessages.length === 0) return; const selectedIndex = filteredMessages.findIndex((message) => mailboxMessageKey(message.folder || selectedFolder, message.uid) === selectedMessageKey); let nextIndex: number | null = null; if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0;else if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0;else if (event.key === "Home") nextIndex = 0;else if (event.key === "End") nextIndex = filteredMessages.length - 1; if (nextIndex === null) return; event.preventDefault(); const nextMessage = filteredMessages[nextIndex]; if (nextMessage) void openMessage(nextMessage); }; window.addEventListener("keydown", handlePreviewShortcut); return () => window.removeEventListener("keydown", handlePreviewShortcut); }, [detailError, filteredMessages, loadingMessage, mailToolsOpen, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]); useEffect(() => { const revealKey = `${selectedProfileId}:${selectedFolder}`; if (revealedFolderRef.current === revealKey) return; const ancestorIds = folderAncestorIds(folderTree, selectedFolder); if (ancestorIds.length === 0) return; revealedFolderRef.current = revealKey; setExpandedFolders((current) => { const next = new Set(current); ancestorIds.forEach((id) => next.add(id)); return next; }); }, [folderTree, selectedFolder, selectedProfileId]); async function loadProfiles() { const requestId = ++profileRequestRef.current; const requestAuthority = authorityRef.current; setLoadingProfiles(true); setError(""); try { const loaded = await listMailServerProfiles(settings, false, undefined, MAILBOX_READ_OPTIONS); if (requestId !== profileRequestRef.current || requestAuthority !== authorityRef.current) return null; const usable = loaded.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile)); const requestedProfileId = parseMailboxLaunch(location.search).profileId; setProfiles(loaded); const currentId = selectedProfileIdRef.current; const next = usable.find((profile) => profile.id === currentId) ?? usable.find((profile) => profile.id === requestedProfileId) ?? usable[0] ?? null; const changed = (next?.id ?? "") !== currentId; if (changed) selectProfile(next?.id ?? ""); return { selected: next, changed }; } catch (err) { if (requestId !== profileRequestRef.current || requestAuthority !== authorityRef.current) return null; setError(errorText(err)); return null; } finally { if (requestId === profileRequestRef.current && requestAuthority === authorityRef.current) setLoadingProfiles(false); } } async function reloadMailbox() { const requestId = ++mailboxReloadRequestRef.current; setReloadingMailbox(true); try { const result = await loadProfiles(); if (!result || requestId !== mailboxReloadRequestRef.current || result.changed || !result.selected) return; if (selectedFolderGroup) await refreshFolderCatalogue(result.selected); else await loadMailboxBootstrap(result.selected.id, true, result.selected); } finally { if (requestId === mailboxReloadRequestRef.current) setReloadingMailbox(false); } } async function refreshFolderCatalogue(profile = selectedProfile) { if (!profile) return; const requestId = ++folderRequestRef.current; const requestAuthority = authorityRef.current; setLoadingFolders(true); setFolderError(""); setError(""); try { const response = await listMailboxFolders(settings, profile.id, true, true, mailboxProtocolForProfile(profile) ?? "imap", MAILBOX_READ_OPTIONS); if (requestId !== folderRequestRef.current || requestAuthority !== authorityRef.current || profile.id !== selectedProfileIdRef.current) return; if (!response.ok) throw new Error(response.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e"); setFolders(response.folders ?? []); setFoldersLoadedForProfile(profile.id); } catch (err) { if (requestId !== folderRequestRef.current || requestAuthority !== authorityRef.current || profile.id !== selectedProfileIdRef.current) return; setFolderError(errorText(err)); setError(errorText(err)); } finally { if (requestId === folderRequestRef.current && requestAuthority === authorityRef.current) setLoadingFolders(false); } } async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false, profileOverride?: MailServerProfile) { if (!profileId) return; const targetProfile = profileOverride ?? profiles.find((profile) => profile.id === profileId) ?? null; const mailboxProtocol = mailboxProtocolForProfile(targetProfile) ?? "imap"; const profileInbox = mailboxProtocol === "imap" ? targetProfile?.imap?.folder_mappings?.inbox || "" : ""; const requestedLaunch = launchRequestRef.current; const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId) ? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null) : null; const requestedFolder = requestedLaunchFolder || (foldersLoadedForProfile === profileId && selectedFolder ? selectedFolder : profileInbox || "INBOX"); const folderRequestId = ++folderRequestRef.current; const messageRequestId = ++messageListRequestRef.current; const requestAuthority = authorityRef.current; const isCurrent = () => folderRequestId === folderRequestRef.current && messageRequestId === messageListRequestRef.current && requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current; const preserve = refresh && foldersLoadedForProfile === profileId; // A refreshed JMAP query starts a new cursor chain; IMAP offsets can retain // the current page without silently losing the user's mailbox context. const requestedPage = preserve && mailboxProtocol !== "jmap" ? messagePage : 1; const rememberedMessageKey = preserve ? selectedMessageKeyRef.current : ""; const detailRequestId = ++messageDetailRequestRef.current; setLoadingMessage(false); setPendingMessageKey(""); setLoadingFolders(true); setLoadingMessages(true); if (!preserve) { setFoldersLoadedForProfile(""); setMessageTotalCount(null); setMessageProvenance(null); setMessagePage(1); setSelectedMessage(null); setSelectedMessageKeyState(""); selectedMessageKeyRef.current = ""; } setFolderError(""); setMessageError(""); setDetailError(""); setError(""); try { const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, (requestedPage - 1) * messagePageSize, refresh, mailboxProtocol, MAILBOX_READ_OPTIONS); if (!isCurrent()) return; if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e"); const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }]; let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX"; if (!loadedFolders.some((folder) => folder.name === nextFolder)) { const detectedInbox = response.folders.detected_folder_mappings?.inbox; nextFolder = detectedInbox && loadedFolders.some((folder) => folder.name === detectedInbox) ? detectedInbox : loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX"; } let messageResponse = mailboxProtocol === "jmap" && messageQuery.trim() ? await listMailboxMessages(settings, profileId, nextFolder, messagePageSize, 0, null, refresh, mailboxProtocol, messageQuery, MAILBOX_READ_OPTIONS) : response.messages; if (!isCurrent()) return; const total = messageResponse.total_count ?? messageResponse.messages?.length ?? 0; const nextPage = Math.min(requestedPage, Math.max(1, Math.ceil(total / messagePageSize))); if (nextPage !== requestedPage) { messageResponse = await listMailboxMessages(settings, profileId, nextFolder, messagePageSize, (nextPage - 1) * messagePageSize, null, refresh, mailboxProtocol, null, MAILBOX_READ_OPTIONS); if (!isCurrent()) return; } const loadedMessages = messageResponse.messages ?? []; const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder); const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize, mailboxProtocol === "jmap" ? messageQuery : ""); mailboxPageCursorsRef.current = { [`${cursorKey}:${nextPage}`]: null, [`${cursorKey}:${nextPage + 1}`]: messageResponse.next_cursor ?? null }; skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== nextPage; loadedMessagePageRef.current = { profileId, folder: nextFolder, page: nextPage, pageSize: messagePageSize }; setFolders(foldersWithCounts); if (!preserve) setExpandedFolders(new Set()); setSelectedFolderGroup(null); setSelectedFolder(nextFolder); setMessagePage(nextPage); setFoldersLoadedForProfile(profileId); setMessages(loadedMessages); setMessageTotalCount(total); setMessageProvenance(mailboxProvenance(messageResponse)); const retainedMessage = loadedMessages.find((message) => mailboxMessageKey(message.folder || nextFolder, message.uid) === rememberedMessageKey); // Explicit preview navigation or Escape during a refresh wins over the // remembered selection; completing a read must not undo the user's action. if (detailRequestId === messageDetailRequestRef.current && selectedMessageKeyRef.current === rememberedMessageKey) { if (retainedMessage) await openMessage(retainedMessage, profileId, mailboxProtocol, true); else if (rememberedMessageKey) { setSelectedMessage(null); setSelectedMessageKeyState(""); selectedMessageKeyRef.current = ""; } } } catch (err) { if (!isCurrent()) return; const message = errorText(err); skipNextMessageLoadRef.current = false; setFolderError(message); setMessageError(message); setError(message); if (!preserve) { loadedMessagePageRef.current = null; setFolders([]); setFoldersLoadedForProfile(""); setMessages([]); setMessageTotalCount(null); setMessageProvenance(null); setSelectedMessage(null); setSelectedMessageKeyState(""); selectedMessageKeyRef.current = ""; setPendingMessageKey(""); } } finally { if (folderRequestId === folderRequestRef.current && requestAuthority === authorityRef.current) setLoadingFolders(false); if (messageRequestId === messageListRequestRef.current && requestAuthority === authorityRef.current) setLoadingMessages(false); } } async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) { if (!profileId || !folder) return; const mailboxProtocol = mailboxProtocolForProfile(profiles.find((profile) => profile.id === profileId) ?? null) ?? "imap"; const requestId = ++messageListRequestRef.current; const requestAuthority = authorityRef.current; const isCurrent = () => requestId === messageListRequestRef.current && requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current; const offset = (Math.max(1, page) - 1) * pageSize; const cursorKey = mailboxCursorKey(profileId, folder, pageSize, mailboxProtocol === "jmap" ? messageQuery : ""); const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null; setLoadingMessages(true); if (!refresh) setMessageProvenance(null); setMessageError(""); setDetailError(""); setError(""); try { const response = await listMailboxMessages( settings, profileId, folder, pageSize, offset, cursor, refresh, mailboxProtocol, mailboxProtocol === "jmap" ? messageQuery : null, MAILBOX_READ_OPTIONS ); if (!isCurrent()) return; const loaded = response.messages ?? []; const total = response.total_count ?? loaded.length; loadedMessagePageRef.current = { profileId, folder, page, pageSize }; if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null; mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null; setMessages(loaded); setMessageTotalCount(total); setMessageProvenance(mailboxProvenance(response)); setFolderMessageCount(folder, total); const rememberedKey = selectedMessageKeyRef.current; if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) { messageDetailRequestRef.current += 1; setLoadingMessage(false); setSelectedMessage(null); setSelectedMessageKeyState(""); setPendingMessageKey(""); selectedMessageKeyRef.current = ""; } } catch (err) { if (!isCurrent()) return; const message = errorText(err); setMessageError(message); setError(message); const committed = loadedMessagePageRef.current; if (committed?.profileId === profileId && committed.folder === folder && (committed.page !== page || committed.pageSize !== pageSize)) { // Retained rows must keep their actual page/size labels after a failed // pagination read; restoring controls is not a second provider request. skipNextMessageLoadRef.current = true; setMessagePage(committed.page); setMessagePageSize(committed.pageSize); } // A failed read is not an empty mailbox. Keep the last loaded index and // selection visible until a successful refresh or an explicit switch. } finally { if (isCurrent()) setLoadingMessages(false); } } async function openMessage(message: MailMailboxMessageSummary, profileId = selectedProfileId, protocol = selectedMailboxProtocol, preservePreview = false) { if (!profileId) return; const folderName = message.folder || selectedFolder; const requestId = ++messageDetailRequestRef.current; const requestAuthority = authorityRef.current; const isCurrent = () => requestId === messageDetailRequestRef.current && requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current; const nextKey = mailboxMessageKey(folderName, message.uid); setSelectedMessageKeyState(nextKey); selectedMessageKeyRef.current = nextKey; setPendingMessageKey(nextKey); if (!preservePreview) setSelectedMessage(null); setDetailError(""); setLoadingMessage(true); setError(""); try { const response = await getMailboxMessage(settings, profileId, folderName, message.uid, protocol, MAILBOX_READ_OPTIONS); if (!isCurrent()) return; setSelectedMessage(response.message); setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid)); setPendingMessageKey(""); } catch (err) { if (!isCurrent()) return; const messageText = errorText(err); setDetailError(messageText); setError(messageText); setPendingMessageKey(""); } finally { if (isCurrent()) setLoadingMessage(false); } } function selectProfile(profileId: string) { loadedMessagePageRef.current = null; mailboxReloadRequestRef.current += 1; folderRequestRef.current += 1; messageListRequestRef.current += 1; messageDetailRequestRef.current += 1; setSelectedProfileId(profileId); selectedProfileIdRef.current = profileId; setReloadingMailbox(false); setSelectedFolderGroup(null); setFolders([]); setFoldersLoadedForProfile(""); setMessages([]); setMessageTotalCount(null); setMessageProvenance(null); setMessagePage(1); setSelectedFolder("INBOX"); setMessageQuery(""); setError(""); setFolderError(""); setMessageError(""); setDetailError(""); revealedFolderRef.current = ""; setSelectedMessage(null); setSelectedMessageKeyState(""); selectedMessageKeyRef.current = ""; setPendingMessageKey(""); setExpandedFolders(new Set()); setLoadingFolders(false); setLoadingMessages(false); setLoadingMessage(false); mailboxPageCursorsRef.current = {}; skipNextMessageLoadRef.current = false; } function openFolderNode(node: MailFolderNode) { if (node.folderName) { setSelectedFolderGroup(null); if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else { loadedMessagePageRef.current = null; messageListRequestRef.current += 1; messageDetailRequestRef.current += 1; setLoadingMessage(false); setMessages([]); setMessageTotalCount(null); setMessageProvenance(null); setSelectedFolder(node.folderName); setMessagePage(1); setSelectedMessage(null); setSelectedMessageKeyState(""); selectedMessageKeyRef.current = ""; setPendingMessageKey(""); setDetailError(""); } return; } // Synthetic parent folders have no provider mailbox to open. Selecting // their label must not toggle expansion or fetch an invented folder name. loadedMessagePageRef.current = null; messageListRequestRef.current += 1; messageDetailRequestRef.current += 1; setSelectedFolderGroup({ id: node.id, label: node.label }); setSelectedFolder(""); setMessages([]); setMessageTotalCount(0); setMessageProvenance(null); setMessagePage(1); setSelectedMessage(null); setSelectedMessageKeyState(""); selectedMessageKeyRef.current = ""; setPendingMessageKey(""); setLoadingMessage(false); setLoadingMessages(false); setMessageError(""); setDetailError(""); skipNextMessageLoadRef.current = false; } function toggleFolderNode(node: MailFolderNode) { setExpandedFolders((current) => { const next = new Set(current); if (next.has(node.id)) next.delete(node.id);else next.add(node.id); return next; }); } function setFolderMessageCount(folderName: string, count: number) { setFolders((current) => current.map((folder) => folder.name === folderName ? { ...folder, message_count: count } : folder)); } function changeMessagePage(page: number) { const nextPage = Math.min(messagePageCount, Math.max(1, page)); if (nextPage !== messagePage) setMessagePage(nextPage); } function changeMessagePageSize(pageSize: number) { setMessagePageSize(pageSize); setMessagePage(1); } return ( {error && {error}} } variant="workspace" scope="workspace" label="i18n:govoplan-mail.mail_actions.c08b5f08" refreshable reloadAction={{ onReload: () => void reloadMailbox(), loading: shellBusy, state: error ? "reload-failed" : "current", helpContextId: "mail.mailbox.reload", helpModuleId: "mail", helpTopicId: "mail.workflow.read-mailbox" }} contextActions={<> } />
{noMailboxProfiles && Mail profiles" }} documentation={MAILBOX_DOCUMENTATION} /> }
{messageCountLabel} {selectedFolder} {messageQuery && {filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4} {syncState && } {shellBusy && i18n:govoplan-mail.working.049ac820} {loadingMessage && {i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}}
i18n:govoplan-mail.subject.8d183dbd i18n:govoplan-mail.date.eb9a4bc1 i18n:govoplan-mail.size.b7152342
{filteredMessages.length === 0 &&
{messageEmptyText}
} {filteredMessages.map((message) => { const key = mailboxMessageKey(message.folder || selectedFolder, message.uid); const selected = key === selectedMessageKey; const loadingSelected = loadingMessage && key === pendingMessageKey; const read = isMailboxMessageRead(message.flags); return (
void openMessage(message)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); void openMessage(message); } }}>
{read ?
{formatDateTime(message.date, { fallback: "-" })} {read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"} {message.attachment_count ? : null} {formatBytes(message.size_bytes)}
); })}
i18n:govoplan-mail.preview.f1fbb2b4
({ filename: attachment.filename, contentType: attachment.content_type, sizeBytes: attachment.size_bytes }))} emptyText={previewEmptyText} />
{shellBusy &&
{i18nMessage(loadingLabel)}
}
setMailToolsOpen(false)} footer={}>
); } function MailboxContactActions({ settings, message }: { settings: ApiSettings; message: MailMailboxMessageDetail | null; }) { const [available, setAvailable] = useState(false); const [loaded, setLoaded] = useState(false); const [targets, setTargets] = useState([]); const [selectedTargetId, setSelectedTargetId] = useState(""); const [creatingEmail, setCreatingEmail] = useState(""); const [addedEmails, setAddedEmails] = useState>(() => new Set()); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); useEffect(() => { let active = true; setLoaded(false); void listMailAddressWriteTargets(settings) .then((response) => { if (!active) return; const writable = response.targets.filter((target) => target.allowed); setAvailable(response.available); setTargets(response.targets); setSelectedTargetId((current) => writable.some((target) => target.address_book_id === current) ? current : writable[0]?.address_book_id || ""); setError(""); }) .catch((loadError) => { if (!active) return; setAvailable(false); setTargets([]); setError(loadError instanceof Error ? loadError.message : String(loadError)); }) .finally(() => { if (active) setLoaded(true); }); return () => { active = false; }; }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => { setAddedEmails(new Set()); setError(""); setSuccess(""); }, [message?.folder, message?.uid]); if (!message || !loaded || !available) return null; const writableTargets = targets.filter((target) => target.allowed); const blockedTargets = targets.filter((target) => !target.allowed); const addresses = uniqueMailboxAddresses([ ...mailboxHeaderAddresses(message.from_header), ...mailboxHeaderAddresses(message.to_header), ...mailboxHeaderAddresses(message.cc_header) ]); async function addContact(address: { name?: string | null; email: string }) { if (!selectedTargetId || creatingEmail) return; setCreatingEmail(address.email); setError(""); setSuccess(""); try { const result = await createMailAddressContact(settings, { address_book_id: selectedTargetId, display_name: address.name || address.email, email: address.email }); setAddedEmails((current) => new Set(current).add(address.email)); setSuccess(i18nMessage("i18n:govoplan-mail.contact_added", { value0: result.display_name })); } catch (createError) { setError(createError instanceof Error ? createError.message : String(createError)); } finally { setCreatingEmail(""); } } return (

i18n:govoplan-mail.address_book_actions

{error ? {error} : null} {success ? {success} : null} {writableTargets.length > 0 ? ( ) : (

i18n:govoplan-mail.no_writable_address_book

)}
{addresses.map((address) => { const added = addedEmails.has(address.email); return ( ); })}
{blockedTargets.length > 0 ? (
i18n:govoplan-mail.unavailable_address_books
    {blockedTargets.map((target) => (
  • {target.address_book_label || target.address_book_id}: {target.message}
  • ))}
) : null}
); } function uniqueMailboxAddresses(addresses: T[]): T[] { const seen = new Set(); return addresses.filter((address) => { const email = address.email.toLocaleLowerCase(); if (seen.has(email)) return false; seen.add(email); return true; }); } function mailboxMessageKey(folder: string, uid: string): string { return `${folder || "INBOX"}::${uid}`; } function mailboxCursorKey(profileId: string, folder: string, pageSize: number, query: string): string { return `${profileId}::${folder || "INBOX"}::${pageSize}::${query.trim()}`; } function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance { return { fromCache: response.from_cache === true, refreshing: response.refreshing === true, indexedAt: response.indexed_at ?? null }; } function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] { const normalized = query.trim().toLocaleLowerCase(); if (!normalized) return messages; return messages.filter((message) => [ message.subject, message.from_header, message.to_header, message.cc_header, message.date, message.body_preview]. some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized))); } function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string { if (!ready) return "i18n:govoplan-mail.no_folder_loaded.889bdb1b"; if (loading) return "i18n:govoplan-mail.loading_messages.4294022c"; if (totalCount !== null && totalCount > visibleCount) return i18nMessage("i18n:govoplan-mail.value_of_value_messages.981a1618", { value0: visibleCount, value1: totalCount }); const count = totalCount ?? visibleCount; return i18nMessage(count === 1 ? "i18n:govoplan-mail.value_message" : "i18n:govoplan-mail.value_messages", { value0: count }); } function folderFlagLabel(flags: string[]): string { const labels = flags.map(displayFolderFlag).filter((label): label is string => Boolean(label)); return Array.from(new Set(labels)).join(" "); } function displayFolderFlag(flag: string): string | null { const normalized = flag.replace(/^\\/, "").toLowerCase(); switch (normalized) { case "sent": return "i18n:govoplan-mail.sent.35f49dcf"; case "drafts": return "i18n:govoplan-mail.drafts.22a31d86"; case "trash": return "i18n:govoplan-mail.trash.e3bf62bb"; case "archive": return "i18n:govoplan-mail.archive.2621c6fd"; case "junk": return "i18n:govoplan-mail.junk.86c7d94c"; case "flagged": return "i18n:govoplan-mail.flagged.f8db8a17"; case "haschildren": case "hasnochildren": case "noselect": return null; default: return null; } } function transportLabel(profile: MailServerProfile): string { const jmap = preferredJmapServer(profile); if (jmap) { const sessionUrl = "session_url" in jmap.config ? jmap.config.session_url : null; return `JMAP · ${String(sessionUrl || "configured endpoint")}`; } const imap = profile.imap; if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3"; return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim(); } function preferredJmapServer(profile: MailServerProfile | null): NonNullable[number] | null { if (!profile) return null; const servers = (profile.servers ?? []).filter((server) => server.protocol === "jmap" && server.is_active); return servers.find((server) => server.is_default) ?? servers[0] ?? null; } function mailboxProtocolForProfile(profile: MailServerProfile | null): MailMailboxProtocol | null { if (!profile) return null; if (preferredJmapServer(profile)) return "jmap"; if (profile.imap || (profile.servers ?? []).some((server) => server.protocol === "imap" && server.is_active)) return "imap"; return null; } function formatBytes(value?: number | null): string { if (!value) return "-"; if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value }); if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-mail.bytes_kb", { value0: (value / 1024).toFixed(1) }); return i18nMessage("i18n:govoplan-mail.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) }); } function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName); }