feat(mail): add governed JMAP mailbox sync and search
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-22 17:09:05 +02:00
parent fd808336bc
commit 1cbf4acaf4
29 changed files with 2481 additions and 183 deletions
+66 -24
View File
@@ -30,6 +30,7 @@ import {
type MailImapFolderResponse,
type MailMailboxMessageDetail,
type MailMailboxMessageSummary,
type MailMailboxProtocol,
type MailServerProfile } from
"../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
@@ -77,29 +78,30 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
const mailboxProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile)), [profiles]);
const selectedMailboxProtocol = mailboxProtocolForProfile(selectedProfile) ?? "imap";
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 noMailboxProfiles = !loadingProfiles && mailboxProfiles.length === 0;
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 folderEmptyText = folderError || (noMailboxProfiles ? "No IMAP- or JMAP-enabled Mail profiles are available." : 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");
const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094";
const profileReloadBlocker = loadingProfiles ? "Mail profiles are already loading." : "";
const folderReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing folders."
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing folders."
: loadingFolders || loadingMessages
? "Wait for the current mailbox refresh to finish."
: "";
const messageReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing messages."
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing messages."
: !selectedFolder || !foldersReady
? "Select a loaded mailbox folder before refreshing messages."
: loadingMessages
@@ -111,7 +113,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const request = parseMailboxLaunch(location.search);
launchRequestRef.current = request;
if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return;
const usable = profiles.filter((profile) => profile.is_active && profile.imap);
const usable = profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile));
const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId)
? request.profileId
: selectedProfileId || usable[0]?.id || "";
@@ -133,6 +135,15 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => {
if (selectedMailboxProtocol !== "jmap" || !selectedProfileId || !selectedFolder || !foldersReady) return;
const handle = window.setTimeout(() => {
setMessagePage(1);
mailboxPageCursorsRef.current = {};
void loadMessages(selectedProfileId, selectedFolder, 1, messagePageSize);
}, 250);
return () => window.clearTimeout(handle);
}, [messageQuery]);
useEffect(() => {
const request = launchRequestRef.current;
@@ -193,7 +204,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setError("");
try {
const loaded = await listMailServerProfiles(settings);
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
const usable = loaded.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile));
const requestedProfileId = parseMailboxLaunch(location.search).profileId;
setProfiles(loaded);
setSelectedProfileId((current) =>
@@ -224,7 +235,9 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
if (!profileId) return;
const profileInbox = profiles.find((profile) => profile.id === profileId)?.imap?.folder_mappings?.inbox || "";
const targetProfile = profiles.find((profile) => profile.id === profileId) ?? null;
const mailboxProtocol = mailboxProtocolForProfile(targetProfile) ?? "imap";
const profileInbox = mailboxProtocol === "imap" ? targetProfile?.imap?.folder_mappings?.inbox || "" : "";
const requestedLaunch = launchRequestRef.current;
const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId)
? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null)
@@ -250,7 +263,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setDetailError("");
setError("");
try {
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh);
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh, mailboxProtocol);
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
@@ -264,7 +277,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
: loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
}
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize, "");
mailboxPageCursorsRef.current = {
[`${cursorKey}:1`]: null,
[`${cursorKey}:2`]: response.messages.next_cursor ?? null
@@ -301,9 +314,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) {
if (!profileId || !folder) return;
const mailboxProtocol = mailboxProtocolForProfile(profiles.find((profile) => profile.id === profileId) ?? null) ?? "imap";
const requestId = ++messageListRequestRef.current;
const offset = (Math.max(1, page) - 1) * pageSize;
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
const cursorKey = mailboxCursorKey(profileId, folder, pageSize, mailboxProtocol === "jmap" ? messageQuery : "");
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
setLoadingMessages(true);
setMessageProvenance(null);
@@ -311,7 +325,17 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setDetailError("");
setError("");
try {
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor, refresh);
const response = await listMailboxMessages(
settings,
profileId,
folder,
pageSize,
offset,
cursor,
refresh,
mailboxProtocol,
mailboxProtocol === "jmap" ? messageQuery : null
);
if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length;
@@ -356,7 +380,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setLoadingMessage(true);
setError("");
try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid, selectedMailboxProtocol);
if (requestId !== messageDetailRequestRef.current) return;
setSelectedMessage(response.message);
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
@@ -475,13 +499,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<div className="file-list-sticky">
<ActionToolbar justify="between" className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
<label className="mailbox-profile-field">
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{imapProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_imap_profiles_available.d64589f8</option>}
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
<span>Mailbox profile</span>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || mailboxProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{mailboxProfiles.length === 0 && <option value="">No mailbox profiles available</option>}
{mailboxProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</label>
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</ToolbarGroup>
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile ? transportLabel(selectedProfile) : "No mailbox profile selected"}</ToolbarGroup>
<ToolbarGroup align="end" className="mailbox-toolbar-actions">
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
@@ -504,13 +528,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</ToolbarGroup>
</ActionToolbar>
{noImapProfiles &&
{noMailboxProfiles &&
<ActionBlockerHint
className="mailbox-profile-blocker"
reason={{
summary: "No IMAP-enabled Mail profile is available.",
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP server and credential.",
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP-enabled profile.",
summary: "No mailbox-enabled Mail profile is available.",
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP or JMAP server and credential.",
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP- or JMAP-enabled profile.",
actor: "Mail profile administrator",
target: "Settings or Administration > Mail profiles"
}}
@@ -792,8 +816,8 @@ function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`;
}
function mailboxCursorKey(profileId: string, folder: string, pageSize: number): string {
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
function mailboxCursorKey(profileId: string, folder: string, pageSize: number, query: string): string {
return `${profileId}::${folder || "INBOX"}::${pageSize}::${query.trim()}`;
}
function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance {
@@ -855,11 +879,29 @@ function displayFolderFlag(flag: string): string | null {
}
function transportLabel(profile: MailServerProfile): string {
const jmap = preferredJmapServer(profile);
if (jmap) {
const sessionUrl = "session_url" in jmap.config ? jmap.config.session_url : null;
return `JMAP · ${String(sessionUrl || "configured endpoint")}`;
}
const imap = profile.imap;
if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3";
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
}
function preferredJmapServer(profile: MailServerProfile | null): MailServerProfile["servers"][number] | null {
if (!profile) return null;
const servers = (profile.servers ?? []).filter((server) => server.protocol === "jmap" && server.is_active);
return servers.find((server) => server.is_default) ?? servers[0] ?? null;
}
function mailboxProtocolForProfile(profile: MailServerProfile | null): MailMailboxProtocol | null {
if (!profile) return null;
if (preferredJmapServer(profile)) return "jmap";
if (profile.imap || (profile.servers ?? []).some((server) => server.protocol === "imap" && server.is_active)) return "imap";
return null;
}
function formatBytes(value?: number | null): string {
if (!value) return "-";
if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });