feat(mail): complete mailbox display states
This commit is contained in:
+11
-1
@@ -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
|
||||
folders, page through messages, and inspect a bounded full message. Folder
|
||||
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
|
||||
not mutate read/unread, delete, move, or reply state. Message responses are
|
||||
|
||||
@@ -790,8 +790,8 @@ manifest = ModuleManifest(
|
||||
DocumentationTopic(
|
||||
id="mail.workflow.read-mailbox",
|
||||
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.",
|
||||
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.",
|
||||
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. 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",
|
||||
documentation_types=("user",),
|
||||
audience=("mail_user",),
|
||||
@@ -818,8 +818,9 @@ manifest = ModuleManifest(
|
||||
],
|
||||
"steps": [
|
||||
"Open Mail and choose an authorized IMAP-enabled profile.",
|
||||
"Select a folder and page through its bounded message index.",
|
||||
"Open only the message needed for the task and close it when finished.",
|
||||
"Select a folder, review the live/cached synchronization label, and page or filter its bounded message index.",
|
||||
"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.",
|
||||
"verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.",
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -23,3 +23,15 @@ assert(
|
||||
!styles.includes(".mailbox-search-field button"),
|
||||
"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"
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type MailServerProfile } from
|
||||
"../../api/mail";
|
||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
||||
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
|
||||
|
||||
const MAILBOX_DOCUMENTATION = {
|
||||
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 [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
||||
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
|
||||
const [messagePage, setMessagePage] = useState(1);
|
||||
const [messagePageSize, setMessagePageSize] = useState(10);
|
||||
const [messageQuery, setMessageQuery] = useState("");
|
||||
@@ -77,6 +79,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
|
||||
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
|
||||
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 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");
|
||||
@@ -162,6 +165,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
@@ -188,6 +192,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setLoadingMessages(true);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
@@ -224,6 +229,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile(profileId);
|
||||
setMessages(loadedMessages);
|
||||
setMessageTotalCount(total);
|
||||
setMessageProvenance(mailboxProvenance(response.messages));
|
||||
} catch (err) {
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
const message = errorText(err);
|
||||
@@ -235,6 +241,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
selectedMessageKeyRef.current = "";
|
||||
@@ -252,6 +259,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
|
||||
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
|
||||
setLoadingMessages(true);
|
||||
setMessageProvenance(null);
|
||||
setMessageError("");
|
||||
setDetailError("");
|
||||
setError("");
|
||||
@@ -264,6 +272,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
|
||||
setMessages(loaded);
|
||||
setMessageTotalCount(total);
|
||||
setMessageProvenance(mailboxProvenance(response));
|
||||
setFolderMessageCount(folder, total);
|
||||
const rememberedKey = selectedMessageKeyRef.current;
|
||||
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);
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
@@ -324,6 +334,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
@@ -476,6 +487,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<span>{messageCountLabel}</span>
|
||||
<span>{selectedFolder}</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>}
|
||||
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
|
||||
</div>
|
||||
@@ -494,10 +512,11 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
||||
const selected = key === selectedMessageKey;
|
||||
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
||||
const read = isMailboxMessageRead(message.flags);
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
tabIndex={0}
|
||||
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">
|
||||
<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>
|
||||
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
|
||||
<small>{message.from_header || "-"}</small>
|
||||
@@ -519,6 +538,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
</div>
|
||||
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
|
||||
<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}
|
||||
<span>{formatBytes(message.size_bytes)}</span>
|
||||
</span>
|
||||
@@ -584,6 +607,14 @@ function mailboxCursorKey(profileId: string, folder: string, pageSize: number):
|
||||
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[] {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return messages;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -560,6 +560,14 @@
|
||||
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 {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -583,6 +591,27 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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);
|
||||
@@ -17,9 +17,11 @@
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/mailbox-display.test.ts",
|
||||
"tests/mailbox-folders.test.ts",
|
||||
"tests/mail-profile-editor-model.test.ts",
|
||||
"tests/mail-policy-validation.test.ts",
|
||||
"src/features/mail/mailboxDisplay.ts",
|
||||
"src/features/mail/mailboxFolders.ts",
|
||||
"src/features/mail/mailProfileEditorModel.ts",
|
||||
"src/features/mail/mailPolicyValidation.ts"
|
||||
|
||||
Reference in New Issue
Block a user