Release v0.1.2
This commit is contained in:
@@ -14,10 +14,19 @@ export type MailSmtpTestPayload = {
|
||||
};
|
||||
|
||||
export type MailImapTestPayload = MailSmtpTestPayload & {
|
||||
enabled?: boolean;
|
||||
sent_folder?: string | null;
|
||||
};
|
||||
|
||||
export type MailTransportCredentialsPayload = {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type MailServerProfileCredentialsPayload = {
|
||||
smtp?: MailTransportCredentialsPayload;
|
||||
imap?: MailTransportCredentialsPayload;
|
||||
};
|
||||
|
||||
export type MailConnectionTestResponse = {
|
||||
ok: boolean;
|
||||
protocol: "smtp" | "imap";
|
||||
@@ -31,6 +40,8 @@ export type MailConnectionTestResponse = {
|
||||
export type MailImapFolderResponse = {
|
||||
name: string;
|
||||
flags?: string[];
|
||||
message_count?: number | null;
|
||||
unseen_count?: number | null;
|
||||
};
|
||||
|
||||
export type MailImapFolderListResponse = {
|
||||
@@ -59,6 +70,7 @@ export type MailMailboxMessageSummary = {
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
cc_header?: string | null;
|
||||
bcc_header?: string | null;
|
||||
date?: string | null;
|
||||
message_id?: string | null;
|
||||
flags?: string[];
|
||||
@@ -81,6 +93,8 @@ export type MailMailboxMessageListResponse = {
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string | null;
|
||||
total_count?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
messages: MailMailboxMessageSummary[];
|
||||
};
|
||||
|
||||
@@ -104,6 +118,7 @@ export type MailServerProfile = {
|
||||
is_active: boolean;
|
||||
smtp: MailSmtpTestPayload;
|
||||
imap?: MailImapTestPayload | null;
|
||||
credentials?: MailServerProfileCredentialsPayload | null;
|
||||
smtp_password_configured: boolean;
|
||||
imap_password_configured: boolean;
|
||||
created_at: string;
|
||||
@@ -123,9 +138,30 @@ export const mailProfilePatternKeys = [
|
||||
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
|
||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||
|
||||
export const mailProfilePolicyLimitKeys = [
|
||||
"allowed_profile_ids",
|
||||
"allow_user_profiles",
|
||||
"allow_group_profiles",
|
||||
"allow_campaign_profiles",
|
||||
"smtp_credentials.inherit",
|
||||
"imap_credentials.inherit",
|
||||
"whitelist.smtp_hosts",
|
||||
"whitelist.imap_hosts",
|
||||
"whitelist.envelope_senders",
|
||||
"whitelist.from_headers",
|
||||
"whitelist.recipient_domains",
|
||||
"blacklist.smtp_hosts",
|
||||
"blacklist.imap_hosts",
|
||||
"blacklist.envelope_senders",
|
||||
"blacklist.from_headers",
|
||||
"blacklist.recipient_domains"
|
||||
] as const;
|
||||
|
||||
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
|
||||
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
|
||||
|
||||
export type MailCredentialPolicy = {
|
||||
inherit?: boolean | null;
|
||||
allow_override?: boolean | null;
|
||||
};
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
@@ -137,6 +173,7 @@ export type MailProfilePolicy = {
|
||||
imap_credentials?: MailCredentialPolicy | null;
|
||||
whitelist?: MailProfilePatternRules | null;
|
||||
blacklist?: MailProfilePatternRules | null;
|
||||
allow_lower_level_limits?: MailProfilePolicyLimitPermissions | null;
|
||||
};
|
||||
|
||||
export type PolicySourceStep = {
|
||||
@@ -144,6 +181,7 @@ export type PolicySourceStep = {
|
||||
scope_id?: string | null;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
policy?: MailProfilePolicy | null;
|
||||
};
|
||||
|
||||
export type MailProfilePolicyResponse = {
|
||||
@@ -165,6 +203,7 @@ export type MailServerProfilePayload = {
|
||||
scope_id?: string | null;
|
||||
smtp: MailSmtpTestPayload;
|
||||
imap?: MailImapTestPayload | null;
|
||||
credentials?: MailServerProfileCredentialsPayload | null;
|
||||
};
|
||||
|
||||
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||
@@ -244,9 +283,10 @@ export async function listMailboxMessages(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
folder = "INBOX",
|
||||
limit = 50
|
||||
limit = 50,
|
||||
offset = 0
|
||||
): Promise<MailMailboxMessageListResponse> {
|
||||
const params = new URLSearchParams({ folder, limit: String(limit) });
|
||||
const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) });
|
||||
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default Button;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
export default Card;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
export default ConfirmDialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
export default Dialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
export default DismissibleAlert;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
export default FormField;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
export default LoadingFrame;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoadingIndicator } from "@govoplan/core-webui";
|
||||
export default LoadingIndicator;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
export default MetricCard;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
export default PageTitle;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
export default StatusBadge;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Stepper } from "@govoplan/core-webui";
|
||||
export default Stepper;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
export default ToggleSwitch;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { EmailAddressInput } from "@govoplan/core-webui";
|
||||
export default EmailAddressInput;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
export default FieldLabel;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { InlineHelp } from "@govoplan/core-webui";
|
||||
export default InlineHelp;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
120
webui/src/features/mail/mailPolicyValidation.ts
Normal file
120
webui/src/features/mail/mailPolicyValidation.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationInput = {
|
||||
smtpHost?: string | null;
|
||||
imapHost?: string | null;
|
||||
envelopeSender?: string | null;
|
||||
fromHeader?: string | null;
|
||||
recipientDomains?: Array<string | null | undefined> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationMessage = {
|
||||
key: MailProfilePatternKey;
|
||||
label: string;
|
||||
value: string;
|
||||
message: string;
|
||||
severity?: "warning" | "danger";
|
||||
};
|
||||
|
||||
const patternLabels: Record<MailProfilePatternKey, string> = {
|
||||
smtp_hosts: "SMTP host",
|
||||
imap_hosts: "IMAP host",
|
||||
envelope_senders: "Envelope sender",
|
||||
from_headers: "From header",
|
||||
recipient_domains: "Recipient domain"
|
||||
};
|
||||
|
||||
type ValueCheck = {
|
||||
key: MailProfilePatternKey;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function validateMailPolicy(
|
||||
policy: MailProfilePolicy | null | undefined,
|
||||
input: MailPolicyValidationInput,
|
||||
): MailPolicyValidationMessage[] {
|
||||
if (!policy) return [];
|
||||
|
||||
const checks: ValueCheck[] = [
|
||||
{ key: "smtp_hosts", value: input.smtpHost ?? "" },
|
||||
{ key: "imap_hosts", value: input.imapHost ?? "" },
|
||||
{ key: "envelope_senders", value: input.envelopeSender ?? "" },
|
||||
{ key: "from_headers", value: input.fromHeader ?? "" },
|
||||
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean)))
|
||||
.map((value) => ({ key: "recipient_domains" as const, value }))
|
||||
];
|
||||
|
||||
return checks.flatMap(({ key, value }) => {
|
||||
const result = mailPolicyValueAllowed(policy, key, value);
|
||||
if (result.allowed) return [];
|
||||
const label = patternLabels[key];
|
||||
return [{
|
||||
key,
|
||||
label,
|
||||
value: result.value,
|
||||
severity: "danger" as const,
|
||||
message: result.reason === "blacklist"
|
||||
? `${label} ${result.value} is denied by pattern ${result.pattern}.`
|
||||
: `${label} ${result.value} is outside the effective allow-list.`
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function mailPolicyValueAllowed(
|
||||
policy: MailProfilePolicy | null | undefined,
|
||||
key: MailProfilePatternKey,
|
||||
rawValue: string | null | undefined,
|
||||
): { allowed: true; value: string } | { allowed: false; value: string; reason: "blacklist" | "whitelist"; pattern?: string } {
|
||||
const value = normalizePolicyValue(key, rawValue);
|
||||
if (!value || !policy) return { allowed: true, value };
|
||||
|
||||
const deniedBy = patternsFor(policy.blacklist, key).find((pattern) => wildcardPatternMatches(pattern, value));
|
||||
if (deniedBy) return { allowed: false, value, reason: "blacklist", pattern: deniedBy };
|
||||
|
||||
const allowPatterns = meaningfulAllowPatterns(patternsFor(policy.whitelist, key));
|
||||
if (allowPatterns.length > 0 && !allowPatterns.some((pattern) => wildcardPatternMatches(pattern, value))) {
|
||||
return { allowed: false, value, reason: "whitelist" };
|
||||
}
|
||||
|
||||
return { allowed: true, value };
|
||||
}
|
||||
|
||||
export function wildcardPatternMatches(pattern: string, rawValue: string): boolean {
|
||||
const normalizedPattern = pattern.trim();
|
||||
const value = rawValue.trim();
|
||||
if (!normalizedPattern || !value) return false;
|
||||
if (normalizedPattern === "*") return true;
|
||||
const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = `^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`;
|
||||
return new RegExp(regex, "i").test(value);
|
||||
}
|
||||
|
||||
function patternsFor(
|
||||
rules: MailProfilePolicy["whitelist"] | MailProfilePolicy["blacklist"],
|
||||
key: MailProfilePatternKey,
|
||||
): string[] {
|
||||
return (rules?.[key] ?? []).map((pattern) => String(pattern).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function meaningfulAllowPatterns(patterns: string[]): string[] {
|
||||
return patterns.filter((pattern) => pattern !== "*");
|
||||
}
|
||||
|
||||
function normalizePolicyValue(key: MailProfilePatternKey, value: string | null | undefined): string {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
if (key === "recipient_domains") return normalizeDomain(trimmed);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeDomain(value: string | null | undefined): string {
|
||||
const trimmed = String(value ?? "").trim().toLowerCase();
|
||||
if (!trimmed) return "";
|
||||
const emailDomain = trimmed.includes("@") ? trimmed.split("@").pop() ?? "" : trimmed;
|
||||
return emailDomain.replace(/^@+/, "");
|
||||
}
|
||||
88
webui/src/features/mail/mailboxFolders.ts
Normal file
88
webui/src/features/mail/mailboxFolders.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type MailboxFolderInfo = {
|
||||
name: string;
|
||||
flags?: string[];
|
||||
message_count?: number | null;
|
||||
unseen_count?: number | null;
|
||||
};
|
||||
|
||||
export type MailFolderNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
folderName: string | null;
|
||||
flags: string[];
|
||||
messageCount: number | null;
|
||||
unseenCount: number | null;
|
||||
children: MailFolderNode[];
|
||||
};
|
||||
|
||||
export function buildMailboxFolderTree(folders: MailboxFolderInfo[]): 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: [], messageCount: null, unseenCount: null, 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 ?? [];
|
||||
node.messageCount = typeof folder.message_count === "number" ? folder.message_count : null;
|
||||
node.unseenCount = typeof folder.unseen_count === "number" ? folder.unseen_count : null;
|
||||
}
|
||||
}
|
||||
|
||||
sortFolderNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export 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];
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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 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()}`;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||
|
||||
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||
const mailboxRead = ["mail:mailbox:read"];
|
||||
@@ -12,7 +14,13 @@ export const mailModule: PlatformWebModule = {
|
||||
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||
routes: [
|
||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
|
||||
]
|
||||
],
|
||||
uiCapabilities: {
|
||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
|
||||
},
|
||||
runtimeUiCapabilities: {
|
||||
"mail.devMailbox": { enabled: true, label: "Development mock mailbox" } satisfies MailDevMailboxUiCapability
|
||||
}
|
||||
};
|
||||
|
||||
export default mailModule;
|
||||
|
||||
@@ -123,6 +123,42 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mail-policy-row {
|
||||
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr);
|
||||
}
|
||||
|
||||
.mail-policy-table.with-effective-column .mail-policy-row {
|
||||
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr) minmax(220px, 1fr);
|
||||
}
|
||||
|
||||
.mail-policy-table.with-allow-column .mail-policy-row {
|
||||
grid-template-columns: minmax(170px, .8fr) minmax(200px, .75fr) minmax(150px, .55fr);
|
||||
}
|
||||
|
||||
.mail-policy-table.with-effective-column.with-allow-column .mail-policy-row {
|
||||
grid-template-columns: minmax(170px, .8fr) minmax(200px, .75fr) minmax(210px, .95fr) minmax(150px, .55fr);
|
||||
}
|
||||
|
||||
.mail-policy-pattern-row {
|
||||
grid-template-columns: minmax(190px, .8fr) minmax(220px, 1fr) minmax(220px, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mail-policy-pattern-table.with-allow-column .mail-policy-pattern-row {
|
||||
grid-template-columns: minmax(170px, .7fr) minmax(200px, 1fr) minmax(200px, 1fr) minmax(165px, .6fr);
|
||||
}
|
||||
|
||||
.mail-policy-pattern-limits {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-policy-lower-cell .toggle-switch-row,
|
||||
.mail-policy-pattern-limits .toggle-switch-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mail-policy-pattern-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -179,10 +215,19 @@
|
||||
@media (max-width: 900px) {
|
||||
.admin-form-grid.three-columns,
|
||||
.mail-policy-pattern-grid,
|
||||
.mail-policy-effective-grid {
|
||||
.mail-policy-effective-grid,
|
||||
.mail-policy-row,
|
||||
.mail-policy-table.with-effective-column .mail-policy-row,
|
||||
.mail-policy-table.with-allow-column .mail-policy-row,
|
||||
.mail-policy-table.with-effective-column.with-allow-column .mail-policy-row,
|
||||
.mail-policy-pattern-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mail-policy-row-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.mail-credential-inheritance-row {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -539,6 +584,62 @@
|
||||
min-width: 660px;
|
||||
}
|
||||
|
||||
.mailbox-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.mailbox-search-field {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(430px, 100%);
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mailbox-search-field input {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.mailbox-search-field button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-xs, 5px);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mailbox-search-field button:hover,
|
||||
.mailbox-search-field button:focus-visible {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.mailbox-error-state {
|
||||
color: var(--danger-text, #8f2e2e);
|
||||
}
|
||||
|
||||
.mailbox-pagination {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mailbox-message-head,
|
||||
.mailbox-message-row {
|
||||
grid-template-columns: minmax(260px, 1fr) 190px 130px;
|
||||
|
||||
Reference in New Issue
Block a user