diff --git a/docs/MAIL_HANDBOOK.md b/docs/MAIL_HANDBOOK.md index f6b3953..279b95f 100644 --- a/docs/MAIL_HANDBOOK.md +++ b/docs/MAIL_HANDBOOK.md @@ -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 diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index d8532c7..c8986c8 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -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.", diff --git a/webui/package.json b/webui/package.json index 9824343..aa6f0a9 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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" diff --git a/webui/scripts/test-mailbox-icon-button-structure.mjs b/webui/scripts/test-mailbox-icon-button-structure.mjs index 8e51db8..02cc2ae 100644 --- a/webui/scripts/test-mailbox-icon-button-structure.mjs +++ b/webui/scripts/test-mailbox-icon-button-structure.mjs @@ -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" +); diff --git a/webui/src/features/mail/MailboxPage.tsx b/webui/src/features/mail/MailboxPage.tsx index 4dbe450..9d461af 100644 --- a/webui/src/features/mail/MailboxPage.tsx +++ b/webui/src/features/mail/MailboxPage.tsx @@ -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>(() => new Set()); const [messages, setMessages] = useState([]); const [messageTotalCount, setMessageTotalCount] = useState(null); + const [messageProvenance, setMessageProvenance] = useState(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; {messageCountLabel} {selectedFolder} {messageQuery && {filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4} + {syncState && + + + } {shellBusy && i18n:govoplan-mail.working.049ac820} {loadingMessage && {i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}} @@ -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 (
void openMessage(message)} @@ -510,7 +529,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
-
{formatDateTime(message.date, { fallback: "-" })} + + {read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"} + + {message.attachment_count ? : null} {formatBytes(message.size_bytes)} @@ -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; diff --git a/webui/src/features/mail/mailboxDisplay.ts b/webui/src/features/mail/mailboxDisplay.ts new file mode 100644 index 0000000..b209b20 --- /dev/null +++ b/webui/src/features/mail/mailboxDisplay.ts @@ -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"; +} diff --git a/webui/src/styles/mail-profiles.css b/webui/src/styles/mail-profiles.css index 5e0eeee..aafd04c 100644 --- a/webui/src/styles/mail-profiles.css +++ b/webui/src/styles/mail-profiles.css @@ -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; diff --git a/webui/tests/mailbox-display.test.ts b/webui/tests/mailbox-display.test.ts new file mode 100644 index 0000000..d192a92 --- /dev/null +++ b/webui/tests/mailbox-display.test.ts @@ -0,0 +1,19 @@ +function assert(condition: unknown, message = "assertion failed"): void { + if (!condition) throw new Error(message); +} + +function assertEqual(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); diff --git a/webui/tsconfig.mail-tests.json b/webui/tsconfig.mail-tests.json index 7ad40d7..b16222f 100644 --- a/webui/tsconfig.mail-tests.json +++ b/webui/tsconfig.mail-tests.json @@ -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"