campaign sending prototype
This commit is contained in:
390
webui/src/features/mail/MailboxPage.tsx
Normal file
390
webui/src/features/mail/MailboxPage.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
LoadingIndicator,
|
||||
MessageDisplayPanel,
|
||||
formatDateTime,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
getMailboxMessage,
|
||||
listMailboxFolders,
|
||||
listMailboxMessages,
|
||||
listMailServerProfiles,
|
||||
type MailImapFolderResponse,
|
||||
type MailMailboxMessageDetail,
|
||||
type MailMailboxMessageSummary,
|
||||
type MailServerProfile
|
||||
} from "../../api/mail";
|
||||
|
||||
type MailFolderNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
folderName: string | null;
|
||||
flags: string[];
|
||||
children: MailFolderNode[];
|
||||
};
|
||||
|
||||
export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState("INBOX");
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
|
||||
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 selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
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 busy = loadingProfiles || loadingFolders || loadingMessages || loadingMessage;
|
||||
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
|
||||
|
||||
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
|
||||
useEffect(() => { if (selectedProfileId && selectedFolder) void loadMessages(selectedProfileId, selectedFolder); }, [selectedProfileId, selectedFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
|
||||
if (ancestorIds.length === 0) return;
|
||||
setExpandedFolders((current) => {
|
||||
const next = new Set(current);
|
||||
ancestorIds.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
}, [folderTree, selectedFolder]);
|
||||
|
||||
async function loadProfiles() {
|
||||
setLoadingProfiles(true);
|
||||
setError("");
|
||||
try {
|
||||
const loaded = await listMailServerProfiles(settings);
|
||||
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
|
||||
setProfiles(loaded);
|
||||
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
||||
if (usable.length === 0) {
|
||||
setFolders([]);
|
||||
setMessages([]);
|
||||
setSelectedMessage(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoadingProfiles(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFolders(profileId = selectedProfileId) {
|
||||
if (!profileId) return;
|
||||
setLoadingFolders(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listMailboxFolders(settings, profileId);
|
||||
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded.");
|
||||
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
|
||||
setFolders(loaded);
|
||||
setExpandedFolders(new Set());
|
||||
setSelectedFolder((current) => {
|
||||
if (current && loaded.some((folder) => folder.name === current)) return current;
|
||||
if (loaded.some((folder) => folder.name === "INBOX")) return "INBOX";
|
||||
return response.detected_sent_folder || loaded[0]?.name || "INBOX";
|
||||
});
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setFolders([]);
|
||||
setMessages([]);
|
||||
setSelectedMessage(null);
|
||||
} finally {
|
||||
setLoadingFolders(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
|
||||
if (!profileId || !folder) return;
|
||||
setLoadingMessages(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listMailboxMessages(settings, profileId, folder, 50);
|
||||
setMessages(response.messages ?? []);
|
||||
setSelectedMessage(null);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setMessages([]);
|
||||
setSelectedMessage(null);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openMessage(message: MailMailboxMessageSummary) {
|
||||
if (!selectedProfileId || loadingMessage) return;
|
||||
setLoadingMessage(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await getMailboxMessage(settings, selectedProfileId, selectedFolder, message.uid);
|
||||
setSelectedMessage(response.message);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoadingMessage(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectProfile(profileId: string) {
|
||||
setSelectedProfileId(profileId);
|
||||
setFolders([]);
|
||||
setMessages([]);
|
||||
setSelectedMessage(null);
|
||||
setExpandedFolders(new Set());
|
||||
}
|
||||
|
||||
function openFolderNode(node: MailFolderNode) {
|
||||
if (node.folderName) {
|
||||
setSelectedFolder(node.folderName);
|
||||
return;
|
||||
}
|
||||
toggleFolderNode(node);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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>}
|
||||
|
||||
<div className={`file-manager-shell mailbox-shell ${busy ? "is-loading" : ""}`}>
|
||||
<aside className="file-tree-panel" aria-label="Mailbox folders">
|
||||
<div className="file-tree-heading">Folders</div>
|
||||
<div className="file-tree-list">
|
||||
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">No folders available.</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={busy}
|
||||
renderNodeContent={(node) => (
|
||||
<span className="mailbox-tree-node-label">
|
||||
<span>{node.label}</span>
|
||||
{node.flags.length > 0 && <small>{node.flags.join(" ")}</small>}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="file-list-panel mailbox-message-list-panel" aria-label="Mailbox messages">
|
||||
<div className="file-list-sticky">
|
||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
|
||||
<label className="mailbox-profile-field">
|
||||
<span>Profile</span>
|
||||
<select value={selectedProfileId} disabled={busy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||
{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>
|
||||
<Button onClick={() => void loadProfiles()} disabled={busy}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
|
||||
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "Mail"}</span>
|
||||
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
|
||||
</nav>
|
||||
|
||||
<div className="file-list-meta">
|
||||
<span>{messages.length} message{messages.length === 1 ? "" : "s"}</span>
|
||||
<span>{selectedFolder}</span>
|
||||
{busy && <span>Working...</span>}
|
||||
</div>
|
||||
|
||||
<div className="file-list-table-head mailbox-message-head" role="row">
|
||||
<span>Subject</span>
|
||||
<span>Date</span>
|
||||
<span>Size</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">No messages in this folder.</div>}
|
||||
{messages.map((message) => {
|
||||
const selected = message.uid === selectedMessage?.uid;
|
||||
return (
|
||||
<div
|
||||
key={message.uid}
|
||||
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""}`}
|
||||
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">
|
||||
<Mail className="file-row-icon" size={20} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{message.subject || "(no subject)"}</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">
|
||||
{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>
|
||||
</section>
|
||||
|
||||
<section className="mailbox-preview-panel" aria-label="Message preview">
|
||||
<div className="file-tree-heading">Preview</div>
|
||||
<div className="mailbox-preview-scroll">
|
||||
<MessageDisplayPanel
|
||||
title={selectedMessage?.subject}
|
||||
fields={selectedMessage ? [
|
||||
{ label: "From", value: selectedMessage.from_header || "-" },
|
||||
{ label: "To", value: selectedMessage.to_header || "-" },
|
||||
{ label: "Cc", value: selectedMessage.cc_header || null },
|
||||
{ label: "Date", 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="Select a message to inspect its content."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{busy && (
|
||||
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
|
||||
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 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 transportLabel(profile: MailServerProfile): string {
|
||||
const imap = profile.imap;
|
||||
if (!imap?.host) return "IMAP not configured";
|
||||
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
if (!value) return "-";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
Reference in New Issue
Block a user