Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 3cd8c45c42
commit 3d82d7b32d
40 changed files with 1877 additions and 520 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import {
Button,
DismissibleAlert,
@@ -19,14 +19,7 @@ import {
type MailMailboxMessageSummary,
type MailServerProfile
} from "../../api/mail";
type MailFolderNode = {
id: string;
label: string;
folderName: string | null;
flags: string[];
children: MailFolderNode[];
};
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
@@ -37,13 +30,21 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [messagePage, setMessagePage] = useState(1);
const [messagePageSize, setMessagePageSize] = useState(50);
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 selectedMessageKeyRef = useRef("");
const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0);
@@ -52,19 +53,23 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
const filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]);
const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize));
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : pendingMessageKey;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const folderEmptyText = noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.";
const messageEmptyText = !selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : "No messages in this folder.";
const previewEmptyText = loadingMessage ? "Loading message..." : "Select a message to inspect its content.";
const folderEmptyText = folderError || (noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.");
const messageEmptyText = messageError || (!selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : messages.length > 0 && filteredMessages.length === 0 ? "No messages match the current filter on this page." : "No messages in this folder.");
const previewEmptyText = detailError || (loadingMessage ? "Loading message..." : "Select a message to inspect its content.");
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => { selectedMessageKeyRef.current = selectedMessageKeyState; }, [selectedMessageKeyState]);
useEffect(() => { if (messagePage > messagePageCount) setMessagePage(messagePageCount); }, [messagePage, messagePageCount]);
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder); }, [foldersReady, selectedProfileId, selectedFolder]);
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize); }, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => {
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
@@ -90,6 +95,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
}
} catch (err) {
@@ -107,7 +113,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setLoadingFolders(true);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
setFolderError("");
setMessageError("");
setDetailError("");
setError("");
try {
const response = await listMailboxFolders(settings, profileId);
@@ -124,40 +136,51 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setFoldersLoadedForProfile(profileId);
} catch (err) {
if (requestId !== folderRequestRef.current) return;
setFolderError(errorText(err));
setError(errorText(err));
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
} finally {
if (requestId === folderRequestRef.current) setLoadingFolders(false);
}
}
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize) {
if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1;
const offset = (Math.max(1, page) - 1) * pageSize;
setLoadingMessages(true);
setMessageTotalCount(null);
setSelectedMessage(null);
setPendingMessageKey("");
setMessageError("");
setDetailError("");
setError("");
try {
const response = await listMailboxMessages(settings, profileId, folder, 50);
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset);
if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length;
setMessages(loaded);
setMessageTotalCount(response.total_count ?? loaded.length);
setSelectedMessage(null);
setMessageTotalCount(total);
setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
}
} catch (err) {
if (requestId !== messageListRequestRef.current) return;
setError(errorText(err));
const message = errorText(err);
setMessageError(message);
setError(message);
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
} finally {
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
@@ -168,18 +191,25 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (!selectedProfileId) return;
const folderName = message.folder || selectedFolder;
const requestId = ++messageDetailRequestRef.current;
setPendingMessageKey(mailboxMessageKey(folderName, message.uid));
const nextKey = mailboxMessageKey(folderName, message.uid);
setSelectedMessageKeyState(nextKey);
selectedMessageKeyRef.current = nextKey;
setPendingMessageKey(nextKey);
setSelectedMessage(null);
setDetailError("");
setLoadingMessage(true);
setError("");
try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
if (requestId !== messageDetailRequestRef.current) return;
setSelectedMessage(response.message);
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
setPendingMessageKey("");
} catch (err) {
if (requestId !== messageDetailRequestRef.current) return;
setError(errorText(err));
const messageText = errorText(err);
setDetailError(messageText);
setError(messageText);
setPendingMessageKey("");
} finally {
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
@@ -195,7 +225,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setExpandedFolders(new Set());
setLoadingFolders(false);
@@ -205,8 +238,16 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function openFolderNode(node: MailFolderNode) {
if (node.folderName) {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName);
else setSelectedFolder(node.folderName);
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);
else {
setSelectedFolder(node.folderName);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setDetailError("");
}
return;
}
toggleFolderNode(node);
@@ -221,6 +262,20 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
});
}
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 (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
@@ -242,12 +297,12 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
disabled={shellBusy}
renderNodeContent={(node) => {
const flagText = folderFlagLabel(node.flags);
const showCount = foldersReady && node.folderName === selectedFolder && messageTotalCount !== null;
const showCount = foldersReady && node.messageCount !== null;
return (
<span className="mailbox-tree-node-label">
<span className="mailbox-tree-node-main">
<span>{node.label}</span>
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>}
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
</span>
{flagText && <small>{flagText}</small>}
</span>
@@ -262,22 +317,22 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
<label className="mailbox-profile-field">
<span>IMAP profile</span>
<select value={selectedProfileId} disabled={shellBusy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>}
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
<div className="mailbox-toolbar-actions">
<Button onClick={() => void loadProfiles()} disabled={shellBusy} title="Reload IMAP profiles">
<Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="Reload IMAP profiles">
<RefreshCw size={16} aria-hidden="true" />
Profiles
</Button>
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || shellBusy} title="Refresh mailbox folders">
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || loadingFolders} title="Refresh mailbox folders">
<RefreshCw size={16} aria-hidden="true" />
Folders
</Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || shellBusy} title="Refresh messages in the current folder">
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="Refresh messages in the current folder">
<RefreshCw size={16} aria-hidden="true" />
Messages
</Button>
@@ -289,9 +344,18 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{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="Search messages" />
{messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="Clear message search"><X size={14} /></button>}
</label>
</div>
<div className="file-list-meta">
<span>{messageCountLabel}</span>
<span>{selectedFolder}</span>
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} on page</span>}
{shellBusy && <span>Working...</span>}
{loadingMessage && <span>Loading preview...</span>}
</div>
@@ -305,8 +369,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
<div className="file-list-drop-target mailbox-message-scroll">
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
{messages.length === 0 && <div className="file-list-empty">{messageEmptyText}</div>}
{messages.map((message) => {
{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;
@@ -343,6 +407,14 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
})}
</div>
</div>
<MailboxPagination
page={messagePage}
pageSize={messagePageSize}
totalRows={messageTotalCount ?? 0}
disabled={loadingMessages || !foldersReady}
onPageChange={changeMessagePage}
onPageSizeChange={changeMessagePageSize}
/>
</section>
<section className="mailbox-preview-panel" aria-label="Message preview">
@@ -354,6 +426,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{ label: "From", value: selectedMessage.from_header || "-" },
{ label: "To", value: selectedMessage.to_header || "-" },
{ label: "Cc", value: selectedMessage.cc_header || null },
{ label: "Bcc", value: selectedMessage.bcc_header || null },
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
] : []}
bodyText={selectedMessage?.body_text}
@@ -380,81 +453,49 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
);
}
function buildMailboxFolderTree(folders: MailImapFolderResponse[]): MailFolderNode[] {
const roots: MailFolderNode[] = [];
const byId = new Map<string, MailFolderNode>();
function ensureNode(parts: string[], index: number): MailFolderNode | null {
if (index < 0 || index >= parts.length) return null;
const id = parts.slice(0, index + 1).join("/");
const existing = byId.get(id);
if (existing) return existing;
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], children: [] };
byId.set(id, node);
if (index === 0) {
roots.push(node);
} else {
ensureNode(parts, index - 1)?.children.push(node);
}
return node;
}
for (const folder of folders) {
const parts = folderParts(folder.name);
const node = ensureNode(parts, parts.length - 1);
if (node) {
node.folderName = folder.name;
node.flags = folder.flags ?? [];
}
}
sortFolderNodes(roots);
return roots;
function MailboxPagination({ page, pageSize, totalRows, disabled, onPageChange, onPageSizeChange }: { page: number; pageSize: number; totalRows: number; disabled: boolean; onPageChange: (page: number) => void; onPageSizeChange: (pageSize: number) => void }) {
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
const last = Math.min(totalRows, page * pageSize);
const options = [25, 50, 100];
return (
<div className="data-grid-pagination mailbox-pagination" aria-label="Mailbox message pagination">
<div className="data-grid-pagination-summary">{first}-{last} of {totalRows}</div>
<label className="data-grid-page-size">
<span>Rows per page</span>
<select value={pageSize} disabled={disabled} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
{options.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<div className="data-grid-page-controls">
<button type="button" aria-label="First page" disabled={disabled || page <= 1} onClick={() => onPageChange(1)}><ChevronsLeft size={16} /></button>
<button type="button" aria-label="Previous page" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button>
<span>Page {page} of {pageCount}</span>
<button type="button" aria-label="Next page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(page + 1)}><ChevronRight size={16} /></button>
<button type="button" aria-label="Last page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight size={16} /></button>
</div>
</div>
);
}
function folderParts(folderName: string): string[] {
const normalized = folderName.trim();
if (!normalized) return [];
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
return [normalized];
}
function sortFolderNodes(nodes: MailFolderNode[]) {
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
nodes.forEach((node) => sortFolderNodes(node.children));
}
function folderSortKey(label: string): string {
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
}
function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
for (const node of nodes) {
if (node.folderName === folderName) return node.id;
const child = findFolderNodeId(node.children, folderName);
if (child) return child;
}
return null;
}
function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
for (const node of nodes) {
const path = [...parents, node.id];
if (node.folderName === folderName) return path;
const child = folderAncestorIds(node.children, folderName, path);
if (child.length > 0) return child;
}
return [];
}
function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`;
}
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 "No folder loaded";
if (loading) return "Loading messages";