campaign sending prototype

This commit is contained in:
2026-06-24 16:20:44 +02:00
parent 99fee44651
commit a9d16a2c89
12 changed files with 1478 additions and 41 deletions

View File

@@ -46,6 +46,52 @@ export type MailImapFolderListResponse = {
};
export type MailMailboxAttachment = {
filename?: string | null;
content_type: string;
size_bytes: number;
};
export type MailMailboxMessageSummary = {
uid: string;
folder: string;
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
date?: string | null;
message_id?: string | null;
flags?: string[];
size_bytes?: number | null;
body_preview?: string | null;
attachment_count?: number;
};
export type MailMailboxMessageDetail = MailMailboxMessageSummary & {
body_text?: string | null;
body_html?: string | null;
headers?: Record<string, string>;
attachments?: MailMailboxAttachment[];
};
export type MailMailboxMessageListResponse = {
profile_id: string;
folder: string;
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
messages: MailMailboxMessageSummary[];
};
export type MailMailboxMessageResponse = {
profile_id: string;
folder: string;
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: MailMailboxMessageDetail;
};
export type MailServerProfile = {
id: string;
tenant_id?: string | null;
@@ -179,6 +225,30 @@ export async function listMailProfileImapFolders(settings: ApiSettings, profileI
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
}
export async function listMailboxFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`);
}
export async function listMailboxMessages(
settings: ApiSettings,
profileId: string,
folder = "INBOX",
limit = 50
): Promise<MailMailboxMessageListResponse> {
const params = new URLSearchParams({ folder, limit: String(limit) });
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
}
export async function getMailboxMessage(
settings: ApiSettings,
profileId: string,
folder: string,
uid: string
): Promise<MailMailboxMessageResponse> {
const params = new URLSearchParams({ folder });
return apiFetch<MailMailboxMessageResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`);
}
export async function testSmtpSettings(
settings: ApiSettings,
payload: MailSmtpTestPayload

View File

@@ -1,13 +1,20 @@
import { useEffect, useMemo, useState } from "react";
import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
createMailServerProfile,
deactivateMailServerProfile,
getMailProfilePolicy,
listImapFolders,
listMailProfileImapFolders,
listMailServerProfiles,
mailProfilePatternKeys,
updateMailProfilePolicy,
testImapSettings,
testMailProfileImap,
testMailProfileSmtp,
testSmtpSettings,
updateMailServerProfile,
type MailCredentialPolicy,
type MailImapTestPayload,
@@ -28,7 +35,6 @@ import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog";
import DismissibleAlert from "../../components/DismissibleAlert";
import FormField from "../../components/FormField";
import ToggleSwitch from "../../components/ToggleSwitch";
export type MailProfileTargetOption = {
id: string;
@@ -290,7 +296,7 @@ export function MailProfileScopeManager({
closeDisabled={busy}
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "Saving…" : "Save profile"}</Button></>}
>
<ProfileForm draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
<ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
</Dialog>
<ConfirmDialog
@@ -492,6 +498,7 @@ export function MailProfilePolicyEditor({
}
function ProfileForm({
settings,
draft,
setDraft,
editing,
@@ -499,6 +506,7 @@ function ProfileForm({
canWrite,
canManageCredentials
}: {
settings: ApiSettings;
draft: ProfileDraft;
setDraft: (draft: ProfileDraft) => void;
editing: EditingProfile;
@@ -506,10 +514,101 @@ function ProfileForm({
canWrite: boolean;
canManageCredentials: boolean;
}) {
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials;
const existingHasImap = editing !== "new" && Boolean(editing?.imap);
const existingProfile = editing !== "new" ? editing : null;
const existingHasImap = Boolean(existingProfile?.imap);
const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled);
const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured);
const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured);
useEffect(() => {
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
setMailActionState(null);
}, [editing]);
function patchSmtpSettings(patch: Partial<MailServerSmtpSettings>) {
setDraft({
...draft,
smtpHost: patch.host !== undefined ? String(patch.host ?? "") : draft.smtpHost,
smtpPort: patch.port !== undefined ? String(patch.port ?? "") : draft.smtpPort,
smtpUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.smtpUsername,
smtpPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.smtpPassword,
smtpSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "starttls"), "starttls") : draft.smtpSecurity,
smtpTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.smtpTimeout
});
}
function patchImapSettings(patch: Partial<MailServerImapSettings>) {
setDraft({
...draft,
imapHost: patch.host !== undefined ? String(patch.host ?? "") : draft.imapHost,
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
imapUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.imapUsername,
imapPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.imapPassword,
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
});
}
async function runSmtpTest() {
setMailActionState("smtp");
setSmtpTestResult(null);
try {
setSmtpTestResult(useSavedSmtpTest && existingProfile
? await testMailProfileSmtp(settings, existingProfile.id)
: await testSmtpSettings(settings, smtpPayload(draft, false)));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: errorMessage(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runImapTest() {
if (!draft.imapEnabled) return;
setMailActionState("imap");
setImapTestResult(null);
try {
setImapTestResult(useSavedImapTest && existingProfile
? await testMailProfileImap(settings, existingProfile.id)
: await testImapSettings(settings, imapPayload(draft, false)));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: errorMessage(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runFolderLookup() {
if (!draft.imapEnabled) return;
setMailActionState("folders");
setFolderResult(null);
try {
setFolderResult(useSavedImapTest && existingProfile
? await listMailProfileImapFolders(settings, existingProfile.id)
: await listImapFolders(settings, imapPayload(draft, false)));
} catch (err) {
setFolderResult({ ok: false, message: errorMessage(err), folders: [] });
} finally {
setMailActionState(null);
}
}
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || disabled || !draft.imapEnabled) return;
setDraft({ ...draft, imapSentFolder: folder });
}
return (
<div className="mail-profile-form">
<div className="admin-form-grid two-columns">
@@ -519,29 +618,32 @@ function ProfileForm({
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
<h3>SMTP</h3>
<div className="admin-form-grid three-columns">
<FormField label="Host"><input value={draft.smtpHost} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpHost: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.smtpPort} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpPort: event.target.value })} /></FormField>
<FormField label="Security"><SecuritySelect value={draft.smtpSecurity} disabled={disabled} onChange={(smtpSecurity) => setDraft({ ...draft, smtpSecurity })} /></FormField>
<FormField label="Username"><input value={draft.smtpUsername} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpUsername: event.target.value })} /></FormField>
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.smtpPassword} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, smtpPassword: event.target.value })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.smtpTimeout} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpTimeout: event.target.value })} /></FormField>
</div>
<div className="mail-profile-imap-heading">
<h3>IMAP</h3>
<ToggleSwitch label="Enable IMAP" checked={draft.imapEnabled} disabled={imapToggleDisabled} onChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })} />
</div>
<div className="admin-form-grid three-columns">
<FormField label="Host"><input value={draft.imapHost} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapHost: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.imapPort} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPort: event.target.value })} /></FormField>
<FormField label="Security"><SecuritySelect value={draft.imapSecurity} disabled={disabled || !draft.imapEnabled} onChange={(imapSecurity) => setDraft({ ...draft, imapSecurity })} /></FormField>
<FormField label="Username"><input value={draft.imapUsername} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapUsername: event.target.value })} /></FormField>
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.imapPassword} disabled={credentialDisabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPassword: event.target.value })} /></FormField>
<FormField label="Sent folder"><input value={draft.imapSentFolder} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapSentFolder: event.target.value })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.imapTimeout} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapTimeout: event.target.value })} /></FormField>
</div>
<MailServerSettingsPanel
smtp={{ host: draft.smtpHost, port: draft.smtpPort, username: draft.smtpUsername, password: draft.smtpPassword, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
imap={{ enabled: draft.imapEnabled, host: draft.imapHost, port: draft.imapPort, username: draft.imapUsername, password: draft.imapPassword, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
onSmtpChange={patchSmtpSettings}
onImapChange={patchImapSettings}
onImapEnabledChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })}
smtpDisabled={disabled}
smtpCredentialDisabled={credentialDisabled}
imapToggleDisabled={imapToggleDisabled}
imapServerDisabled={disabled || !draft.imapEnabled}
imapCredentialDisabled={credentialDisabled || !draft.imapEnabled}
imapActionDisabled={disabled || !draft.imapEnabled}
smtpTitle="SMTP"
imapTitle="IMAP"
smtpTestLabel={useSavedSmtpTest ? "Test saved SMTP" : "Test SMTP"}
imapTestLabel={useSavedImapTest ? "Test saved IMAP" : "Test IMAP"}
busyAction={mailActionState}
onTestSmtp={() => void runSmtpTest()}
onTestImap={() => void runImapTest()}
onLookupFolders={() => void runFolderLookup()}
smtpTestResult={smtpTestResult}
imapTestResult={imapTestResult}
folderLookupResult={folderResult}
onUseDetectedFolder={useDetectedSentFolder}
useDetectedFolderDisabled={disabled || !draft.imapEnabled}
/>
</div>
);
}
@@ -586,10 +688,6 @@ function PatternTextarea({ label, value, disabled, onChange }: { label: string;
return <FormField label={label}><textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" /></FormField>;
}
function SecuritySelect({ value, disabled, onChange }: { value: MailSecurity; disabled: boolean; onChange: (value: MailSecurity) => void }) {
return <select value={value} disabled={disabled} onChange={(event) => onChange(readSecurity(event.target.value, value))}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
}
function TransportCell({ profile, protocol }: { profile: MailServerProfile; protocol: "smtp" | "imap" }) {
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
if (!transport) return <span className="muted">Not configured</span>;

View 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);
}

View File

@@ -5,3 +5,4 @@ export * from "./api/mail";
export { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
export type { MailProfileTargetOption } from "./features/mail/MailProfileManagement";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
export { default as MailboxPage } from "./features/mail/MailboxPage";

View File

@@ -1,10 +1,18 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const mailboxRead = ["mail:mailbox:read"];
export const mailModule: PlatformWebModule = {
id: "mail",
label: "Mail",
version: "1.0.0",
dependencies: ["access"]
dependencies: ["access"],
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
]
};
export default mailModule;

View File

@@ -193,3 +193,397 @@
flex-direction: column;
}
}
.mailbox-page {
display: grid;
gap: 18px;
}
.mailbox-toolbar,
.mailbox-message-toolbar {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
}
.mailbox-toolbar label {
display: grid;
gap: 5px;
min-width: min(340px, 100%);
}
.mailbox-toolbar label span,
.mailbox-preview-header span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.mailbox-toolbar-meta {
flex: 1;
min-width: 0;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-layout {
display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1.25fr) minmax(320px, .75fr);
gap: 18px;
align-items: start;
}
.mailbox-folder-list {
display: grid;
gap: 6px;
}
.mailbox-folder-list button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 8px;
align-items: center;
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
padding: 9px 10px;
text-align: left;
cursor: pointer;
}
.mailbox-folder-list button:hover,
.mailbox-folder-list button:focus-visible,
.mailbox-folder-list button.is-active {
border-color: var(--accent);
background: var(--surface-subtle);
}
.mailbox-folder-list small {
grid-column: 2;
min-width: 0;
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
}
.mailbox-message-table {
min-width: 0;
}
.mailbox-message-table .data-grid-cell.is-selected {
background: var(--surface-subtle);
}
.mailbox-subject-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-strong);
font: inherit;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.mailbox-subject-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-attachment-count {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mailbox-preview {
display: grid;
gap: 14px;
min-width: 0;
}
.mailbox-preview-header {
display: grid;
gap: 8px;
}
.mailbox-preview-header h3 {
margin: 0;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.mailbox-preview-header div {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 10px;
}
.mailbox-preview-header strong {
min-width: 0;
overflow-wrap: anywhere;
}
.mailbox-message-body {
max-height: 420px;
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
color: var(--text);
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mailbox-attachments {
display: grid;
gap: 8px;
}
.mailbox-attachments h4 {
margin: 0;
}
.mailbox-attachments div {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.mailbox-attachments small {
color: var(--muted);
}
.mailbox-headers dl {
display: grid;
gap: 7px;
max-height: 280px;
overflow: auto;
}
.mailbox-headers dl div {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 10px;
}
.mailbox-headers dt {
color: var(--muted);
font-weight: 700;
}
.mailbox-headers dd {
margin: 0;
overflow-wrap: anywhere;
}
@media (max-width: 1200px) {
.mailbox-layout {
grid-template-columns: 220px minmax(0, 1fr);
}
.mailbox-layout > .card:last-child {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.mailbox-toolbar,
.mailbox-message-toolbar {
align-items: stretch;
flex-direction: column;
}
.mailbox-layout {
grid-template-columns: 1fr;
}
}
/* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */
.mailbox-page.file-manager-fullscreen {
position: relative;
display: grid;
grid-template-rows: 1fr;
height: calc(100vh - 115px);
padding: 0;
overflow: hidden;
}
.mailbox-shell.file-manager-shell {
position: relative;
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr);
}
.mailbox-shell.is-loading .mailbox-preview-panel {
filter: blur(1.5px);
pointer-events: none;
user-select: none;
}
.mailbox-tree-empty {
padding: 8px 10px;
}
.mailbox-tree-node-label {
display: grid;
gap: 2px;
min-width: 0;
}
.mailbox-tree-node-label > span,
.mailbox-tree-node-label small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-tree-node-label small {
color: var(--muted);
font-size: 11px;
}
.mailbox-toolbar.file-manager-toolbar {
display: grid;
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
gap: 12px;
}
.mailbox-profile-field {
display: grid;
gap: 4px;
min-width: 0;
}
.mailbox-profile-field > span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.mailbox-toolbar-meta {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-breadcrumb-static {
cursor: default;
}
.mailbox-message-list-panel {
border-right: 1px solid var(--line);
}
.mailbox-message-table {
min-width: 660px;
}
.mailbox-message-head,
.mailbox-message-row {
grid-template-columns: minmax(260px, 1fr) 190px 130px;
}
.mailbox-message-head span {
justify-self: start;
}
.mailbox-message-row {
cursor: pointer;
}
.mailbox-message-row .file-list-name strong {
max-width: 100%;
}
.mailbox-message-date {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-message-tail {
justify-content: flex-end;
}
.mailbox-message-tail > span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mailbox-preview-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--panel);
}
.mailbox-preview-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 18px;
}
@media (max-width: 1250px) {
.mailbox-shell.file-manager-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
}
.mailbox-preview-panel {
grid-column: 1 / -1;
min-height: 320px;
border-top: 1px solid var(--line);
}
}
@media (max-width: 760px) {
.mailbox-toolbar.file-manager-toolbar {
grid-template-columns: 1fr;
}
.mailbox-message-head {
display: none;
}
.mailbox-message-table {
min-width: 0;
}
.mailbox-message-row {
grid-template-columns: 1fr;
gap: 8px;
}
.mailbox-message-tail {
justify-content: flex-start;
}
}