feat(mail): complete mailbox display states

This commit is contained in:
2026-08-19 23:03:46 +02:00
parent e0e00d7000
commit 9ac8847559
9 changed files with 130 additions and 9 deletions
+11 -1
View File
@@ -185,7 +185,17 @@ ordinary consumers to bypass reusable profiles.
The current mailbox UI and API are read-only. An authorized user can list IMAP The current mailbox UI and API are read-only. An authorized user can list IMAP
folders, page through messages, and inspect a bounded full message. Folder folders, page through messages, and inspect a bounded full message. Folder
names are parsed and quoted defensively; Sent-folder discovery uses provider names are parsed and quoted defensively; Sent-folder discovery uses provider
flags and common names. flags and common names. The list exposes the provider's `Seen` flag as a
read/unread indicator without changing it. It also labels whether the current
page came directly from the provider, from the bounded mailbox index, or from
an index while a refresh is in progress, including the index timestamp when
available.
Message HTML is displayed only in the shared sandboxed message component.
Remote URLs and active markup are removed, embedded `data:`/`cid:` image
references remain isolated, and plain text is always available when supplied.
Attachments and provider/content failures remain explicit rather than being
silently interpreted as an empty message.
Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must
not mutate read/unread, delete, move, or reply state. Message responses are not mutate read/unread, delete, move, or reply state. Message responses are
+5 -4
View File
@@ -790,8 +790,8 @@ manifest = ModuleManifest(
DocumentationTopic( DocumentationTopic(
id="mail.workflow.read-mailbox", id="mail.workflow.read-mailbox",
title="Read a permitted mailbox without changing it", title="Read a permitted mailbox without changing it",
summary="Choose an IMAP-enabled profile, browse folders, and inspect bounded message content through the read-only mailbox surface.", summary="Choose an IMAP-enabled profile, browse synchronized folders, and inspect bounded message content through the read-only mailbox surface.",
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.", body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Lists expose provider read/unread flags and live/cache/refresh provenance without mutating them. Message HTML is isolated and sanitized, while attachments, inline references, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
layer="configured", layer="configured",
documentation_types=("user",), documentation_types=("user",),
audience=("mail_user",), audience=("mail_user",),
@@ -818,8 +818,9 @@ manifest = ModuleManifest(
], ],
"steps": [ "steps": [
"Open Mail and choose an authorized IMAP-enabled profile.", "Open Mail and choose an authorized IMAP-enabled profile.",
"Select a folder and page through its bounded message index.", "Select a folder, review the live/cached synchronization label, and page or filter its bounded message index.",
"Open only the message needed for the task and close it when finished.", "Use the provider-derived read/unread indicator, then open only the message needed for the task.",
"Switch between safe plain-text and isolated HTML views as needed, and review attachment or unavailable-content details before closing the preview.",
], ],
"outcome": "The required message was inspected without changing provider mailbox state.", "outcome": "The required message was inspected without changing provider mailbox state.",
"verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.", "verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.",
+1 -1
View File
@@ -26,7 +26,7 @@
} }
}, },
"scripts": { "scripts": {
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs" "test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
@@ -23,3 +23,15 @@ assert(
!styles.includes(".mailbox-search-field button"), !styles.includes(".mailbox-search-field button"),
"Mail must not redefine the central icon-button appearance" "Mail must not redefine the central icon-button appearance"
); );
assert(
source.includes("isMailboxMessageRead(message.flags)"),
"mailbox rows must present provider-derived read/unread state"
);
assert(
source.includes("mailboxSyncState(messageProvenance)"),
"mailbox lists must present synchronization provenance"
);
assert(
styles.includes(".mailbox-message-row.is-unread") && styles.includes(".mailbox-sync-provenance"),
"read state and synchronization provenance must retain focused responsive styling"
);
+34 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react"; import { Activity, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { ToolbarGroup, ActionToolbar, import { ToolbarGroup, ActionToolbar,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
@@ -29,6 +29,7 @@ import {
type MailServerProfile } from type MailServerProfile } from
"../../api/mail"; "../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders"; import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
const MAILBOX_DOCUMENTATION = { const MAILBOX_DOCUMENTATION = {
topicId: "mail.workflow.read-mailbox", topicId: "mail.workflow.read-mailbox",
@@ -45,6 +46,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]); const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null); const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
const [messagePage, setMessagePage] = useState(1); const [messagePage, setMessagePage] = useState(1);
const [messagePageSize, setMessagePageSize] = useState(10); const [messagePageSize, setMessagePageSize] = useState(10);
const [messageQuery, setMessageQuery] = useState(""); const [messageQuery, setMessageQuery] = useState("");
@@ -77,6 +79,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId; const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : ""); const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady); const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const syncState = mailboxSyncState(messageProvenance);
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26"); const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d"); const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342"); const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
@@ -162,6 +165,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
@@ -188,6 +192,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setLoadingMessages(true); setLoadingMessages(true);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessageTotalCount(null); setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1); setMessagePage(1);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
@@ -224,6 +229,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFoldersLoadedForProfile(profileId); setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages); setMessages(loadedMessages);
setMessageTotalCount(total); setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(response.messages));
} catch (err) { } catch (err) {
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return; if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
const message = errorText(err); const message = errorText(err);
@@ -235,6 +241,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
selectedMessageKeyRef.current = ""; selectedMessageKeyRef.current = "";
@@ -252,6 +259,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const cursorKey = mailboxCursorKey(profileId, folder, pageSize); const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null; const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
setLoadingMessages(true); setLoadingMessages(true);
setMessageProvenance(null);
setMessageError(""); setMessageError("");
setDetailError(""); setDetailError("");
setError(""); setError("");
@@ -264,6 +272,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null; mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
setMessages(loaded); setMessages(loaded);
setMessageTotalCount(total); setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(response));
setFolderMessageCount(folder, total); setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current; const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) { if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
@@ -278,6 +287,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setError(message); setError(message);
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
@@ -324,6 +334,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1); setMessagePage(1);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
@@ -476,6 +487,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<span>{messageCountLabel}</span> <span>{messageCountLabel}</span>
<span>{selectedFolder}</span> <span>{selectedFolder}</span>
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>} {messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
{syncState &&
<span className={`mailbox-sync-provenance is-${syncState}`} aria-live="polite">
<Database size={13} aria-hidden="true" />
{syncState === "refreshing" ? "i18n:govoplan-mail.cached_index_refreshing.75f18a6c" : syncState === "cached" ? "i18n:govoplan-mail.cached_mailbox_index.16fe75d1" : "i18n:govoplan-mail.live_provider_response.39c46538"}
{messageProvenance?.indexedAt && <span> · {formatDateTime(messageProvenance.indexedAt, { fallback: "-" })}</span>}
</span>
}
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>} {shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>} {loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
</div> </div>
@@ -494,10 +512,11 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid); const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
const selected = key === selectedMessageKey; const selected = key === selectedMessageKey;
const loadingSelected = loadingMessage && key === pendingMessageKey; const loadingSelected = loadingMessage && key === pendingMessageKey;
const read = isMailboxMessageRead(message.flags);
return ( return (
<div <div
key={message.uid} key={message.uid}
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`} className={`file-list-row file-row mailbox-message-row ${read ? "is-read" : "is-unread"} ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
role="row" role="row"
tabIndex={0} tabIndex={0}
onClick={() => void openMessage(message)} onClick={() => void openMessage(message)}
@@ -510,7 +529,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<div className="file-list-name-cell"> <div className="file-list-name-cell">
<div className="file-list-name"> <div className="file-list-name">
<Mail className="file-row-icon" size={20} aria-hidden="true" /> {read ? <MailOpen className="file-row-icon" size={20} aria-hidden="true" /> : <Mail className="file-row-icon" size={20} aria-hidden="true" />}
<span> <span>
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong> <strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
<small>{message.from_header || "-"}</small> <small>{message.from_header || "-"}</small>
@@ -519,6 +538,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</div> </div>
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span> <span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
<span className="file-row-tail mailbox-message-tail"> <span className="file-row-tail mailbox-message-tail">
<span className="mailbox-read-state" title={read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}>
<span className="visually-hidden">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
<span aria-hidden="true">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
</span>
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null} {message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
<span>{formatBytes(message.size_bytes)}</span> <span>{formatBytes(message.size_bytes)}</span>
</span> </span>
@@ -584,6 +607,14 @@ function mailboxCursorKey(profileId: string, folder: string, pageSize: number):
return `${profileId}::${folder || "INBOX"}::${pageSize}`; return `${profileId}::${folder || "INBOX"}::${pageSize}`;
} }
function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance {
return {
fromCache: response.from_cache === true,
refreshing: response.refreshing === true,
indexedAt: response.indexed_at ?? null
};
}
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] { function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
const normalized = query.trim().toLocaleLowerCase(); const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return messages; if (!normalized) return messages;
+17
View File
@@ -0,0 +1,17 @@
export type MailboxSyncProvenance = {
fromCache: boolean;
refreshing: boolean;
indexedAt: string | null;
};
export type MailboxSyncState = "live" | "cached" | "refreshing";
export function isMailboxMessageRead(flags: readonly string[] | null | undefined): boolean {
return (flags ?? []).some((flag) => flag.trim().replace(/^\\+/, "").toLocaleLowerCase() === "seen");
}
export function mailboxSyncState(provenance: MailboxSyncProvenance | null): MailboxSyncState | null {
if (!provenance) return null;
if (provenance.refreshing) return "refreshing";
return provenance.fromCache ? "cached" : "live";
}
+29
View File
@@ -560,6 +560,14 @@
opacity: .72; opacity: .72;
} }
.mailbox-message-row.is-read .file-list-name strong {
font-weight: 600;
}
.mailbox-message-row.is-unread .file-list-name strong {
font-weight: 850;
}
.mailbox-message-row .file-list-name strong { .mailbox-message-row .file-list-name strong {
max-width: 100%; max-width: 100%;
} }
@@ -583,6 +591,27 @@
gap: 4px; gap: 4px;
} }
.mailbox-read-state,
.mailbox-sync-provenance {
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.mailbox-message-row.is-unread .mailbox-read-state {
color: var(--accent-text);
}
.mailbox-sync-provenance {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mailbox-sync-provenance.is-refreshing {
color: var(--warning-text);
}
.mailbox-preview-panel { .mailbox-preview-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+19
View File
@@ -0,0 +1,19 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
import { isMailboxMessageRead, mailboxSyncState } from "../src/features/mail/mailboxDisplay";
assert(isMailboxMessageRead(["\\Seen"]), "standard IMAP Seen flag marks a message as read");
assert(isMailboxMessageRead(["answered", "SEEN"]), "flag matching is case-insensitive and accepts normalized flags");
assert(!isMailboxMessageRead(["\\Answered", "\\Flagged"]), "messages without Seen remain unread");
assert(!isMailboxMessageRead(undefined), "missing flags fail safely to unread");
assertEqual(mailboxSyncState({ fromCache: false, refreshing: false, indexedAt: null }), "live");
assertEqual(mailboxSyncState({ fromCache: true, refreshing: false, indexedAt: "2026-08-19T09:00:00Z" }), "cached");
assertEqual(mailboxSyncState({ fromCache: true, refreshing: true, indexedAt: "2026-08-19T09:00:00Z" }), "refreshing");
assertEqual(mailboxSyncState(null), null);
+2
View File
@@ -17,9 +17,11 @@
"rootDir": "." "rootDir": "."
}, },
"include": [ "include": [
"tests/mailbox-display.test.ts",
"tests/mailbox-folders.test.ts", "tests/mailbox-folders.test.ts",
"tests/mail-profile-editor-model.test.ts", "tests/mail-profile-editor-model.test.ts",
"tests/mail-policy-validation.test.ts", "tests/mail-policy-validation.test.ts",
"src/features/mail/mailboxDisplay.ts",
"src/features/mail/mailboxFolders.ts", "src/features/mail/mailboxFolders.ts",
"src/features/mail/mailProfileEditorModel.ts", "src/features/mail/mailProfileEditorModel.ts",
"src/features/mail/mailPolicyValidation.ts" "src/features/mail/mailPolicyValidation.ts"