Release govoplan-mail v0.1.27: stabilize credentials, folder encoding and transport progress
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user