Release govoplan-mail v0.1.27: stabilize credentials, folder encoding and transport progress
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-09-08 01:32:44 +02:00
parent c62c7783d6
commit 480c18c67c
27 changed files with 2875 additions and 1005 deletions
+289 -120
View File
@@ -1,17 +1,22 @@
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 { ToolbarGroup, ActionToolbar,
import { ActionToolbar,
ActionBlockerHint,
Button,
CountBadge,
DataGridPaginationBar,
DocumentationHelpLink,
Dialog,
DismissibleAlert,
ExplorerTree,
FormField,
FormSection,
IconButton,
LoadingIndicator,
MessageDisplayPanel,
WorkspaceActionBar,
WorkspaceFrame,
hasAnyScope,
formatDateTime,
i18nMessage,
@@ -25,6 +30,7 @@ import {
getMailboxMessage,
listMailAddressWriteTargets,
listMailboxMessages,
listMailboxFolders,
listMailServerProfiles,
type MailAddressWriteTarget,
type MailImapFolderResponse,
@@ -43,6 +49,11 @@ const MAILBOX_DOCUMENTATION = {
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();
@@ -50,6 +61,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
const [selectedFolder, setSelectedFolder] = useState("INBOX");
const [selectedFolderGroup, setSelectedFolderGroup] = useState<Pick<MailFolderNode, "id" | "label"> | null>(null);
const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
@@ -69,10 +81,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
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<Record<string, string | null>>({});
const skipNextMessageLoadRef = useRef(false);
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
@@ -81,34 +104,46 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
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(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
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;
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 || (!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 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 = loadingProfiles ? "Mail profiles are already loading." : "";
const profileReloadBlocker = shellBusy ? "i18n:govoplan-mail.mailbox_refresh_in_progress" : "";
const folderReloadBlocker = !selectedProfileId
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing folders."
: loadingFolders || loadingMessages
: 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."
: loadingMessages
: shellBusy
? "Messages are already loading."
: "";
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
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;
@@ -137,13 +172,17 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}, [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]);
}, [messageQuery, selectedFolderGroup]);
useEffect(() => {
const request = launchRequestRef.current;
@@ -160,10 +199,12 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
useEffect(() => {
const handlePreviewShortcut = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
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 = "";
@@ -187,55 +228,86 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
window.addEventListener("keydown", handlePreviewShortcut);
return () => window.removeEventListener("keydown", handlePreviewShortcut);
}, [detailError, filteredMessages, loadingMessage, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]);
}, [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]);
}, [folderTree, selectedFolder, selectedProfileId]);
async function loadProfiles() {
const requestId = ++profileRequestRef.current;
const requestAuthority = authorityRef.current;
setLoadingProfiles(true);
setError("");
try {
const loaded = await listMailServerProfiles(settings);
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);
setSelectedProfileId((current) =>
requestedProfileId && usable.some((profile) => profile.id === requestedProfileId)
? requestedProfileId
: current && usable.some((profile) => profile.id === current)
? current
: usable[0]?.id ?? ""
);
if (usable.length === 0) {
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
}
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 {
setLoadingProfiles(false);
if (requestId === profileRequestRef.current && requestAuthority === authorityRef.current) setLoadingProfiles(false);
}
}
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = 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 = profiles.find((profile) => profile.id === profileId) ?? null;
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;
@@ -247,28 +319,38 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
: profileInbox || "INBOX");
const folderRequestId = ++folderRequestRef.current;
const messageRequestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1;
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);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
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, 0, refresh, mailboxProtocol);
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
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: [] }];
const loadedMessages = response.messages.messages ?? [];
const total = response.messages.total_count ?? loadedMessages.length;
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
const detectedInbox = response.folders.detected_folder_mappings?.inbox;
@@ -276,39 +358,67 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
? 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, "");
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize, mailboxProtocol === "jmap" ? messageQuery : "");
mailboxPageCursorsRef.current = {
[`${cursorKey}:1`]: null,
[`${cursorKey}:2`]: response.messages.next_cursor ?? null
[`${cursorKey}:${nextPage}`]: null,
[`${cursorKey}:${nextPage + 1}`]: messageResponse.next_cursor ?? null
};
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== 1;
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== nextPage;
loadedMessagePageRef.current = { profileId, folder: nextFolder, page: nextPage, pageSize: messagePageSize };
setFolders(foldersWithCounts);
setExpandedFolders(new Set());
if (!preserve) setExpandedFolders(new Set());
setSelectedFolderGroup(null);
setSelectedFolder(nextFolder);
setMessagePage(nextPage);
setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages);
setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(response.messages));
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 (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
if (!isCurrent()) return;
const message = errorText(err);
skipNextMessageLoadRef.current = false;
setFolderError(message);
setMessageError(message);
setError(message);
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
if (!preserve) {
loadedMessagePageRef.current = null;
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
}
} finally {
if (folderRequestId === folderRequestRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current) setLoadingMessages(false);
if (folderRequestId === folderRequestRef.current && requestAuthority === authorityRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current && requestAuthority === authorityRef.current) setLoadingMessages(false);
}
}
@@ -316,11 +426,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
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);
setMessageProvenance(null);
if (!refresh) setMessageProvenance(null);
setMessageError("");
setDetailError("");
setError("");
@@ -334,11 +446,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
cursor,
refresh,
mailboxProtocol,
mailboxProtocol === "jmap" ? messageQuery : null
mailboxProtocol === "jmap" ? messageQuery : null,
MAILBOX_READ_OPTIONS
);
if (requestId !== messageListRequestRef.current) return;
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);
@@ -347,66 +461,87 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
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 (requestId !== messageListRequestRef.current) return;
if (!isCurrent()) return;
const message = errorText(err);
setMessageError(message);
setError(message);
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
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 (requestId === messageListRequestRef.current) setLoadingMessages(false);
if (isCurrent()) setLoadingMessages(false);
}
}
async function openMessage(message: MailMailboxMessageSummary) {
if (!selectedProfileId) return;
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);
setSelectedMessage(null);
if (!preservePreview) setSelectedMessage(null);
setDetailError("");
setLoadingMessage(true);
setError("");
try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid, selectedMailboxProtocol);
if (requestId !== messageDetailRequestRef.current) return;
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 (requestId !== messageDetailRequestRef.current) return;
if (!isCurrent()) return;
const messageText = errorText(err);
setDetailError(messageText);
setError(messageText);
setPendingMessageKey("");
} finally {
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
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 = "";
@@ -421,8 +556,16 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
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);
@@ -433,7 +576,26 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
return;
}
toggleFolderNode(node);
// 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) {
@@ -460,9 +622,34 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
<WorkspaceFrame as="main" height="viewport" surface="plain" label="i18n:govoplan-mail.mailbox_workspace" className="mailbox-page" helpContextId="mail.mailbox" helpModuleId="mail" helpTopicId="mail.workflow.read-mailbox">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<WorkspaceActionBar
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={<>
<FormField label="i18n:govoplan-mail.mailbox_profile" className="mailbox-profile-field">
<select value={selectedProfileId} disabled={loadingProfiles || mailboxProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{mailboxProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_mailbox_profiles</option>}
{mailboxProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</FormField>
<Button type="button" onClick={() => setMailToolsOpen(true)} helpContextId="mail.mailbox.tools" helpModuleId="mail" helpTopicId="mail.workflow.read-mailbox">i18n:govoplan-mail.mailbox_tools</Button>
</>}
helpAction={<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />}
/>
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
<div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
@@ -497,37 +684,6 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
<div className="file-list-sticky">
<ActionToolbar justify="between" className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
<label className="mailbox-profile-field">
<span>Mailbox profile</span>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || mailboxProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{mailboxProfiles.length === 0 && <option value="">No mailbox profiles available</option>}
{mailboxProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</label>
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile ? transportLabel(selectedProfile) : "No mailbox profile selected"}</ToolbarGroup>
<ToolbarGroup align="end" className="mailbox-toolbar-actions">
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
<Activity size={16} aria-hidden="true" />
Bounce status
</Button>}
<Button onClick={() => void loadProfiles()} disabled={Boolean(profileReloadBlocker)} disabledReason={profileReloadBlocker} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.profiles.0c2a9300
</Button>
<Button onClick={() => void loadMailboxBootstrap(selectedProfileId, true)} disabled={Boolean(folderReloadBlocker)} disabledReason={folderReloadBlocker} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.folders.19adc47b
</Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true)} disabled={Boolean(messageReloadBlocker)} disabledReason={messageReloadBlocker} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.messages.f1702b46
</Button>
</ToolbarGroup>
</ActionToolbar>
{noMailboxProfiles &&
<ActionBlockerHint
className="mailbox-profile-blocker"
@@ -543,7 +699,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<nav className="file-breadcrumbs" aria-label="i18n:govoplan-mail.current_mailbox_folder.55e2aea5">
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "i18n:govoplan-mail.mail.92379cbb"}</span>
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolderGroup?.label ?? selectedFolder}</span></span>
</nav>
<div className="mailbox-filter-row">
@@ -667,7 +823,20 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</div>
}
</div>
</div>);
<Dialog open={mailToolsOpen} title="i18n:govoplan-mail.mailbox_tools" description={selectedProfile ? `${selectedProfile.name} · ${transportLabel(selectedProfile)}` : "i18n:govoplan-mail.no_mailbox_profiles"} onClose={() => setMailToolsOpen(false)}
footer={<Button type="button" onClick={() => setMailToolsOpen(false)}>i18n:govoplan-core.close.bbfa773e</Button>}>
<FormSection title="i18n:govoplan-mail.mailbox_refresh_tools" description="i18n:govoplan-mail.mailbox_refresh_tools_help">
<ActionToolbar surface="plain">
<Button type="button" onClick={() => { setMailToolsOpen(false); void loadProfiles(); }} disabledReason={profileReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_profiles</Button>
<Button type="button" onClick={() => { setMailToolsOpen(false); void refreshFolderCatalogue(); }} disabledReason={folderReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_folder_catalogue</Button>
<Button type="button" onClick={() => { setMailToolsOpen(false); void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true); }} disabledReason={messageReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_message_index</Button>
</ActionToolbar>
</FormSection>
<FormSection variant="separated" title="i18n:govoplan-mail.mailbox_related_tools">
<ActionToolbar surface="plain"><Button type="button" onClick={() => { setMailToolsOpen(false); navigate("/mail/bounces"); }} disabledReason={!hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) ? "i18n:govoplan-mail.mailbox_bounce_permission" : ""}><Activity size={16} aria-hidden="true" /> i18n:govoplan-mail.mailbox_bounce_status</Button></ActionToolbar>
</FormSection>
</Dialog>
</WorkspaceFrame>);
}
@@ -889,7 +1058,7 @@ function transportLabel(profile: MailServerProfile): string {
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
}
function preferredJmapServer(profile: MailServerProfile | null): MailServerProfile["servers"][number] | null {
function preferredJmapServer(profile: MailServerProfile | null): NonNullable<MailServerProfile["servers"]>[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;