Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
1090 lines
54 KiB
TypeScript
1090 lines
54 KiB
TypeScript
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<MailServerProfile[]>([]);
|
|
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[]>([]);
|
|
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
|
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
|
|
const [messagePage, setMessagePage] = useState(1);
|
|
const [messagePageSize, setMessagePageSize] = useState(10);
|
|
const [messageQuery, setMessageQuery] = useState("");
|
|
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(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<Record<string, string | null>>({});
|
|
const skipNextMessageLoadRef = useRef(false);
|
|
const launchRequestRef = useRef<MailboxLaunch | null>(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 (
|
|
<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
|
|
title="i18n:govoplan-mail.mailbox_workspace"
|
|
titleHelp={<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />}
|
|
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>
|
|
</>}
|
|
/>
|
|
|
|
<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>
|
|
<div className="file-tree-list">
|
|
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>}
|
|
<ExplorerTree
|
|
nodes={folderTree}
|
|
getNodeId={(node) => node.id}
|
|
getNodeLabel={(node) => node.label}
|
|
getNodeChildren={(node) => node.children}
|
|
activeId={selectedFolderNodeId}
|
|
expandedIds={expandedFolders}
|
|
onOpen={openFolderNode}
|
|
onToggle={toggleFolderNode}
|
|
disabled={shellBusy}
|
|
renderNodeContent={(node) => {
|
|
const flagText = folderFlagLabel(node.flags);
|
|
const showCount = foldersReady && node.messageCount !== null;
|
|
return (
|
|
<span className="mailbox-tree-node-label">
|
|
<span className="mailbox-tree-node-main">
|
|
<span>{node.label}</span>
|
|
{showCount && <CountBadge tone="neutral" size="compact" className="mailbox-folder-count">{node.messageCount}</CountBadge>}
|
|
</span>
|
|
{flagText && <small>{flagText}</small>}
|
|
</span>);
|
|
|
|
}} />
|
|
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
|
<div className="file-list-sticky">
|
|
{noMailboxProfiles &&
|
|
<ActionBlockerHint
|
|
className="mailbox-profile-blocker"
|
|
reason={{
|
|
summary: "No mailbox-enabled Mail profile is available.",
|
|
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP or JMAP server and credential.",
|
|
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP- or JMAP-enabled profile.",
|
|
actor: "Mail profile administrator",
|
|
target: "Settings or Administration > Mail profiles"
|
|
}}
|
|
documentation={MAILBOX_DOCUMENTATION} />
|
|
}
|
|
|
|
<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">{selectedFolderGroup?.label ?? selectedFolder}</span></span>
|
|
</nav>
|
|
|
|
<div className="mailbox-filter-row">
|
|
<label className="mailbox-search-field">
|
|
<Search size={15} aria-hidden="true" />
|
|
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="i18n:govoplan-mail.search_messages.abea65ae" />
|
|
{messageQuery && <IconButton label="i18n:govoplan-mail.clear_message_search.cc9f2800" icon={<X />} variant="ghost" onClick={() => setMessageQuery("")} />}
|
|
</label>
|
|
</div>
|
|
|
|
<div className="file-list-meta">
|
|
<span>{messageCountLabel}</span>
|
|
<span>{selectedFolder}</span>
|
|
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
|
|
{syncState &&
|
|
<span className={`mailbox-sync-provenance is-${syncState}`} aria-live="polite">
|
|
<Database size={13} aria-hidden="true" />
|
|
{syncState === "refreshing" ? "i18n:govoplan-mail.cached_index_refreshing.75f18a6c" : syncState === "cached" ? "i18n:govoplan-mail.cached_mailbox_index.16fe75d1" : "i18n:govoplan-mail.live_provider_response.39c46538"}
|
|
{messageProvenance?.indexedAt && <span> · {formatDateTime(messageProvenance.indexedAt, { fallback: "-" })}</span>}
|
|
</span>
|
|
}
|
|
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
|
|
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
|
|
</div>
|
|
|
|
<div className="file-list-table-head mailbox-message-head" role="row">
|
|
<span>i18n:govoplan-mail.subject.8d183dbd</span>
|
|
<span>i18n:govoplan-mail.date.eb9a4bc1</span>
|
|
<span>i18n:govoplan-mail.size.b7152342</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="file-list-drop-target mailbox-message-scroll">
|
|
<div className="file-list-table mailbox-message-table" role="table" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
|
{filteredMessages.length === 0 && <div className={`file-list-empty${messageError ? " mailbox-error-state" : ""}`}>{messageEmptyText}</div>}
|
|
{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 (
|
|
<div
|
|
key={message.uid}
|
|
className={`file-list-row file-row mailbox-message-row ${read ? "is-read" : "is-unread"} ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
|
role="row"
|
|
tabIndex={0}
|
|
onClick={() => void openMessage(message)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
void openMessage(message);
|
|
}
|
|
}}>
|
|
|
|
<div className="file-list-name-cell">
|
|
<div className="file-list-name">
|
|
{read ? <MailOpen className="file-row-icon" size={20} aria-hidden="true" /> : <Mail className="file-row-icon" size={20} aria-hidden="true" />}
|
|
<span>
|
|
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
|
|
<small>{message.from_header || "-"}</small>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
|
|
<span className="file-row-tail mailbox-message-tail">
|
|
<span className="mailbox-read-state" title={read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}>
|
|
<span className="visually-hidden">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
|
<span aria-hidden="true">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
|
</span>
|
|
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
|
|
<span>{formatBytes(message.size_bytes)}</span>
|
|
</span>
|
|
</div>);
|
|
|
|
})}
|
|
</div>
|
|
</div>
|
|
<DataGridPaginationBar
|
|
page={messagePage}
|
|
pageSize={messagePageSize}
|
|
totalRows={messageTotalCount ?? 0}
|
|
className="mailbox-pagination"
|
|
ariaLabel="i18n:govoplan-mail.mailbox_message_pagination.965407bf"
|
|
disabled={loadingMessages || !foldersReady}
|
|
onPageChange={changeMessagePage}
|
|
onPageSizeChange={changeMessagePageSize} />
|
|
|
|
</section>
|
|
|
|
<section className="mailbox-preview-panel" aria-label="i18n:govoplan-mail.message_preview.58de1450">
|
|
<div className="file-tree-heading">i18n:govoplan-mail.preview.f1fbb2b4</div>
|
|
<div className="mailbox-preview-scroll">
|
|
<MessageDisplayPanel
|
|
title={selectedMessage?.subject}
|
|
fields={selectedMessage ? [
|
|
{ label: "i18n:govoplan-mail.from.3f66052a", value: selectedMessage.from_header || "-" },
|
|
{ label: "i18n:govoplan-mail.to.ae79ea1e", value: selectedMessage.to_header || "-" },
|
|
{ label: "i18n:govoplan-mail.cc.1fd6a880", value: selectedMessage.cc_header || null },
|
|
{ label: "i18n:govoplan-mail.bcc.8431acad", value: selectedMessage.bcc_header || null },
|
|
{ label: "i18n:govoplan-mail.date.eb9a4bc1", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }] :
|
|
[]}
|
|
bodyText={selectedMessage?.body_text}
|
|
bodyHtml={selectedMessage?.body_html}
|
|
bodyPreview={selectedMessage?.body_preview}
|
|
headers={selectedMessage?.headers}
|
|
attachments={selectedMessage?.attachments?.map((attachment) => ({
|
|
filename: attachment.filename,
|
|
contentType: attachment.content_type,
|
|
sizeBytes: attachment.size_bytes
|
|
}))}
|
|
emptyText={previewEmptyText} />
|
|
|
|
<MailboxContactActions settings={settings} message={selectedMessage} />
|
|
|
|
</div>
|
|
</section>
|
|
|
|
{shellBusy &&
|
|
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
|
|
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{i18nMessage(loadingLabel)}</span></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>);
|
|
|
|
}
|
|
|
|
function MailboxContactActions({
|
|
settings,
|
|
message
|
|
}: {
|
|
settings: ApiSettings;
|
|
message: MailMailboxMessageDetail | null;
|
|
}) {
|
|
const [available, setAvailable] = useState(false);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [targets, setTargets] = useState<MailAddressWriteTarget[]>([]);
|
|
const [selectedTargetId, setSelectedTargetId] = useState("");
|
|
const [creatingEmail, setCreatingEmail] = useState("");
|
|
const [addedEmails, setAddedEmails] = useState<Set<string>>(() => 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 (
|
|
<section className="mailbox-contact-actions" aria-label="i18n:govoplan-mail.address_book_actions">
|
|
<h4>i18n:govoplan-mail.address_book_actions</h4>
|
|
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
|
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
|
|
{writableTargets.length > 0 ? (
|
|
<label className="mailbox-contact-target">
|
|
<span>i18n:govoplan-mail.save_contacts_to</span>
|
|
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
|
{writableTargets.map((target) => (
|
|
<option key={target.address_book_id} value={target.address_book_id}>
|
|
{target.address_book_label || target.address_book_id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : (
|
|
<p className="form-help">i18n:govoplan-mail.no_writable_address_book</p>
|
|
)}
|
|
<div className="mailbox-contact-candidates">
|
|
{addresses.map((address) => {
|
|
const added = addedEmails.has(address.email);
|
|
return (
|
|
<Button
|
|
key={address.email}
|
|
className="compact"
|
|
disabled={!selectedTargetId || Boolean(creatingEmail) || added}
|
|
disabledReason={!selectedTargetId ? blockedTargets[0]?.message || "i18n:govoplan-mail.no_writable_address_book" : undefined}
|
|
onClick={() => void addContact(address)}
|
|
>
|
|
{added ? <Check size={15} aria-hidden="true" /> : <UserPlus size={15} aria-hidden="true" />}
|
|
{added ? "i18n:govoplan-mail.contact_added_short" : i18nMessage("i18n:govoplan-mail.add_value_to_contacts", { value0: address.name || address.email })}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
{blockedTargets.length > 0 ? (
|
|
<details className="mailbox-contact-policy">
|
|
<summary>i18n:govoplan-mail.unavailable_address_books</summary>
|
|
<ul>
|
|
{blockedTargets.map((target) => (
|
|
<li key={target.address_book_id}>
|
|
<strong>{target.address_book_label || target.address_book_id}</strong>: {target.message}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function uniqueMailboxAddresses<T extends { email: string }>(addresses: T[]): T[] {
|
|
const seen = new Set<string>();
|
|
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<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;
|
|
}
|
|
|
|
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);
|
|
}
|