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
+173 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailJmapTransportSettings, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
@@ -19,6 +19,7 @@ import {
updateMailProfilePolicy,
testImapSettings,
testMailProfileImap,
testMailProfileJmap,
testMailProfileSmtp,
testSmtpSettings,
unlinkMailServerCredential,
@@ -106,6 +107,15 @@ type ProfileDraft = {
imapSentFolder: string;
imapFolderMappings: MailImapFolderMappings;
imapTimeout: string;
jmapSessionUrl: string;
jmapAccountId: string;
jmapAuthScheme: "bearer" | "basic";
jmapAllowedApiOrigins: string;
jmapTimeout: string;
jmapMaxResponseBytes: string;
jmapMaxBodyValueBytes: string;
jmapUsername: string;
jmapToken: string;
};
type MailProfileScopeManagerProps = {
@@ -163,6 +173,7 @@ const MAIL_PROFILE_WORKFLOW_DOCUMENTATION = {
const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "i18n:govoplan-mail.smtp_hostnames.36eb51d8",
imap_hosts: "i18n:govoplan-mail.imap_hostnames.ac9c1d78",
jmap_hosts: "JMAP hostnames",
envelope_senders: "i18n:govoplan-mail.envelope_senders.269065cd",
from_headers: "i18n:govoplan-mail.from_headers.b3ea473b",
recipient_domains: "i18n:govoplan-mail.recipient_domains.cb9b7b44"
@@ -402,7 +413,11 @@ export function MailProfileScopeManager({
} else if (editingTarget.kind === "server") {
const serverPayload = {
name: draft.serverName.trim(),
config: editingTarget.protocol === "smtp" ? smtpServerPayload(draft) : imapServerPayload(draft),
config: editingTarget.protocol === "smtp"
? smtpServerPayload(draft)
: editingTarget.protocol === "imap"
? imapServerPayload(draft)
: jmapServerPayload(draft),
inherit_to_lower_scopes: draft.serverInheritToLowerScopes,
is_default: draft.serverIsDefault,
is_active: draft.serverIsActive
@@ -526,7 +541,7 @@ export function MailProfileScopeManager({
header: "i18n:govoplan-mail.transport.c10d76c9",
width: "minmax(220px, 1fr)",
render: (row) => row.kind === "profile" ?
<span>{transportLabel(row.profile.smtp)}{row.profile.imap ? i18nMessage("i18n:govoplan-mail.value.48afe802", { value0: transportLabel(row.profile.imap) }) : ""}</span> :
<span>{profileTransportLabels(row.profile).join(" · ")}</span> :
row.kind === "server" ?
<TransportCell server={row.server} /> :
<span>{String(row.credential.public_data?.username || "No username")}</span>
@@ -660,6 +675,7 @@ export function MailProfileScopeManager({
{ id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openEdit(row.profile) },
{ id: "add-smtp", label: "Add SMTP server", icon: <Plus size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "smtp") },
{ id: "add-imap", label: "Add IMAP server", icon: <Plus size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "imap") },
{ id: "add-jmap", label: "Add JMAP server", icon: <Plus size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "jmap") },
{ id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: <Trash2 size={16} />, variant: "danger", disabled: Boolean(deactivationBlocker), disabledReason: deactivationBlocker, onClick: () => setDeactivating(row.profile) }
]} />;
@@ -1166,8 +1182,9 @@ function ProfileForm({
const { translateText } = usePlatformLanguage();
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [jmapTestResult, setJmapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapFolderResult, setImapFolderResult] = useState<MailImapFolderListResponse | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "jmap" | "folders" | null>(null);
const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials;
@@ -1205,6 +1222,13 @@ function ProfileForm({
&& selectedServerId
&& !draft.imapPassword
);
const useSavedJmapTest = Boolean(
existingProfile
&& (editTarget.kind === "server" || editTarget.kind === "credentials")
&& editTarget.protocol === "jmap"
&& selectedServerId
&& !draft.jmapToken
);
const creatingCredential = editTarget.kind === "credentials" && !editTarget.credentialId;
const creatingNewCredential = creatingCredential && !reuseCredentialId;
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
@@ -1249,8 +1273,9 @@ function ProfileForm({
);
const policyMessages = useMemo(() => validateMailPolicy(effectivePolicy, {
smtpHost: draft.smtpHost,
imapHost: draft.imapHost
}), [draft.imapHost, draft.smtpHost, effectivePolicy]);
imapHost: draft.imapHost,
jmapHost: jmapHostname(draft.jmapSessionUrl)
}), [draft.imapHost, draft.jmapSessionUrl, draft.smtpHost, effectivePolicy]);
const profileFormMutationBlocker = mailProfileMutationDisabledReason(canWrite, busy);
const smtpTestBlocker = profileFormMutationBlocker
|| (!draft.smtpHost.trim() ? "Enter an SMTP server hostname before testing the connection." : "");
@@ -1260,6 +1285,7 @@ function ProfileForm({
useEffect(() => {
setSmtpTestResult(null);
setImapTestResult(null);
setJmapTestResult(null);
setImapFolderResult(null);
setMailActionState(null);
}, [editing, editTarget]);
@@ -1346,6 +1372,24 @@ function ProfileForm({
}
}
async function runJmapTest() {
if (!existingProfile || !selectedServerId) return;
setMailActionState("jmap");
setJmapTestResult(null);
try {
setJmapTestResult(await testMailProfileJmap(
settings,
existingProfile.id,
selectedServerId,
selectedCredentialId
));
} catch (err) {
setJmapTestResult({ ok: false, protocol: "jmap", message: errorMessage(err), details: {} });
} finally {
setMailActionState(null);
}
}
return (
<div className="mail-profile-form">
{!canManageCredentials && (
@@ -1359,7 +1403,7 @@ function ProfileForm({
details: "Profile metadata and server configuration are separate from encrypted credential authority.",
requiredAction: "Ask a Mail credential administrator to create, replace, or link the required credential.",
actor: "Mail credential administrator",
target: "The credential row beneath the relevant SMTP or IMAP server"
target: "The credential row beneath the relevant SMTP, IMAP, or JMAP server"
}}
documentation={MAIL_PROFILE_DOCUMENTATION} />
}
@@ -1411,6 +1455,43 @@ function ProfileForm({
</FormGrid>
}
{editTarget.kind === "server" && editTarget.protocol === "jmap" &&
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
<FormField label="JMAP Session URL" help="Use the authenticated RFC 8620 Session resource URL; credentials must not be embedded in the URL." documentation={MAIL_PROFILE_DOCUMENTATION}>
<input value={draft.jmapSessionUrl} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapSessionUrl: event.target.value })} placeholder="https://mail.example.org/.well-known/jmap" />
</FormField>
<FormField label="Account id" help="Leave blank to use the primary JMAP Mail account. Configure it when the credential exposes multiple mail accounts.">
<input value={draft.jmapAccountId} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapAccountId: event.target.value })} />
</FormField>
<FormField label="Authentication scheme">
<select value={draft.jmapAuthScheme} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapAuthScheme: event.target.value as "bearer" | "basic" })}>
<option value="bearer">Bearer access token</option>
<option value="basic">Basic username and password</option>
</select>
</FormField>
<FormField label="Allowed API origins" help="Optional comma-separated origins for an apiUrl hosted on a different origin than the Session resource. Cross-origin endpoints fail closed unless listed.">
<textarea rows={2} value={draft.jmapAllowedApiOrigins} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapAllowedApiOrigins: event.target.value })} placeholder="https://api.example.org" />
</FormField>
<FormField label="Timeout (seconds)">
<input type="number" min="1" max="120" value={draft.jmapTimeout} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapTimeout: event.target.value })} />
</FormField>
<FormField label="Maximum response bytes">
<input type="number" min="65536" max="26214400" value={draft.jmapMaxResponseBytes} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapMaxResponseBytes: event.target.value })} />
</FormField>
<FormField label="Maximum body value bytes">
<input type="number" min="1024" max="5242880" value={draft.jmapMaxBodyValueBytes} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapMaxBodyValueBytes: event.target.value })} />
</FormField>
{editTarget.serverId &&
<div className="mail-server-field-span">
<Button onClick={() => void runJmapTest()} disabled={disabled || mailActionState !== null || (!useSavedJmapTest && !selectedCredentialId)} disabledReason={!useSavedJmapTest && !selectedCredentialId ? "Save or select a JMAP credential before testing." : undefined}>
{mailActionState === "jmap" ? "Testing JMAP…" : "Test saved JMAP"}
</Button>
<MailServerActionResult result={jmapTestResult} />
</div>
}
</FormGrid>
}
{editTarget.kind === "credentials" &&
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
{creatingCredential &&
@@ -1469,6 +1550,18 @@ function ProfileForm({
onChange={(checked) => setDraft({ ...draft, credentialInheritToLowerScopes: checked })}
label="Visible to lower scopes" />
</div>
{editTarget.protocol === "jmap" &&
<>
{draft.jmapAuthScheme === "basic" &&
<FormField label="JMAP username">
<input value={draft.jmapUsername} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, jmapUsername: event.target.value })} autoComplete="username" />
</FormField>
}
<FormField label={draft.jmapAuthScheme === "bearer" ? "JMAP access token" : "JMAP password"} help={selectedCredential?.secret_configured ? "A secret is saved. Leave blank to retain it." : "The secret is encrypted in the governed credential envelope."}>
<input type="password" value={draft.jmapToken} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, jmapToken: event.target.value })} autoComplete="new-password" />
</FormField>
</>
}
</>
}
{reuseCredentialId &&
@@ -1574,6 +1667,7 @@ function MailCredentialTreeCell({ credential }: {credential: MailCredentialEnvel
}
function ProfileTransportSummary({ profile }: {profile: MailServerProfile;}) {
const jmapServer = preferredProfileServer(profile, "jmap");
return (
<div className="mail-profile-transport-summary">
<div>
@@ -1586,6 +1680,11 @@ function ProfileTransportSummary({ profile }: {profile: MailServerProfile;}) {
<strong>{transportLabel(profile.imap)}</strong>
<small>{profile.imap ? mailCredentialConfigured(profile, "imap") ? "i18n:govoplan-mail.password_saved.f6fab237" : "i18n:govoplan-mail.no_saved_password.32ce2b16" : "i18n:govoplan-mail.not_configured.811931bb"}</small>
</div>
<div>
<span>JMAP server</span>
<strong>{transportLabel(jmapServer?.config)}</strong>
<small>{jmapServer ? jmapServer.credentials.some((credential) => credential.secret_configured) ? "Credential saved" : "No saved credential" : "i18n:govoplan-mail.not_configured.811931bb"}</small>
</div>
</div>);
}
@@ -1607,6 +1706,19 @@ function mailCredentialConfigured(profile: MailServerProfile, protocol: "smtp" |
return protocol === "smtp" ? profile.smtp_password_configured : profile.imap_password_configured;
}
function preferredProfileServer(profile: MailServerProfile, protocol: MailProfileProtocol): MailServerEndpoint | null {
const servers = (profile.servers ?? []).filter((server) => server.protocol === protocol && server.is_active);
return servers.find((server) => server.is_default) ?? servers[0] ?? null;
}
function profileTransportLabels(profile: MailServerProfile): string[] {
const labels = [`SMTP · ${transportLabel(profile.smtp)}`];
if (profile.imap) labels.push(`IMAP · ${transportLabel(profile.imap)}`);
const jmap = preferredProfileServer(profile, "jmap");
if (jmap) labels.push(`JMAP · ${transportLabel(jmap.config)}`);
return labels;
}
function credentialAvailabilityLabel(credential: MailCredentialEnvelope): string {
const modules = credential.allowed_modules.length > 0 ? credential.allowed_modules.join(", ") : "all modules";
const servers = credential.allowed_server_refs.length > 0
@@ -1669,7 +1781,16 @@ function emptyProfileDraft(): ProfileDraft {
imapPassword: "",
imapSentFolder: "auto",
imapFolderMappings: {},
imapTimeout: "30"
imapTimeout: "30",
jmapSessionUrl: "",
jmapAccountId: "",
jmapAuthScheme: "bearer",
jmapAllowedApiOrigins: "",
jmapTimeout: "20",
jmapMaxResponseBytes: String(5 * 1024 * 1024),
jmapMaxBodyValueBytes: String(1024 * 1024),
jmapUsername: "",
jmapToken: ""
};
}
@@ -1688,6 +1809,7 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
: undefined;
const smtpConfig = targetServer?.protocol === "smtp" ? targetServer.config : profile.smtp;
const imapConfig = targetServer?.protocol === "imap" ? targetServer.config : profile.imap;
const jmapConfig = targetServer?.protocol === "jmap" ? targetServer.config as MailJmapTransportSettings : undefined;
const targetUsername = stringValue(targetCredential?.public_data?.username);
return {
name: profile.name,
@@ -1725,7 +1847,16 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
...(imapConfig?.folder_mappings ?? {}),
...(!imapConfig?.folder_mappings?.sent && imapConfig?.sent_folder && imapConfig.sent_folder !== "auto" ? { sent: imapConfig.sent_folder } : {})
}),
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30)
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30),
jmapSessionUrl: stringValue(jmapConfig?.session_url),
jmapAccountId: stringValue(jmapConfig?.account_id),
jmapAuthScheme: jmapConfig?.auth_scheme === "basic" ? "basic" : "bearer",
jmapAllowedApiOrigins: (jmapConfig?.allowed_api_origins ?? []).join(", "),
jmapTimeout: stringValue(jmapConfig?.timeout_seconds ?? 20),
jmapMaxResponseBytes: stringValue(jmapConfig?.max_response_bytes ?? 5 * 1024 * 1024),
jmapMaxBodyValueBytes: stringValue(jmapConfig?.max_body_value_bytes ?? 1024 * 1024),
jmapUsername: targetServer?.protocol === "jmap" && targetCredential ? targetUsername : "",
jmapToken: ""
};
}
@@ -1758,8 +1889,8 @@ function updateProfilePayload(
}
function credentialPayload(draft: ProfileDraft, protocol: MailProfileProtocol, preserveBlankPassword: boolean) {
const username = protocol === "smtp" ? draft.smtpUsername : draft.imapUsername;
const password = protocol === "smtp" ? draft.smtpPassword : draft.imapPassword;
const username = protocol === "smtp" ? draft.smtpUsername : protocol === "imap" ? draft.imapUsername : draft.jmapUsername;
const password = protocol === "smtp" ? draft.smtpPassword : protocol === "imap" ? draft.imapPassword : draft.jmapToken;
const allowedModules = commaValues(draft.credentialAllowedModules);
const allowedServerRefs = commaValues(draft.credentialAllowedServerRefs);
const payload: {
@@ -1775,7 +1906,7 @@ function credentialPayload(draft: ProfileDraft, protocol: MailProfileProtocol, p
} = {
name: draft.credentialName.trim(),
description: mailTextOrNull(draft.credentialDescription),
credential_kind: "username_password",
credential_kind: protocol === "jmap" && draft.jmapAuthScheme === "bearer" ? "bearer_token" : "username_password",
username: mailTextOrNull(username),
inherit_to_lower_scopes: draft.credentialInheritToLowerScopes,
allowed_modules: allowedModules.length > 0 ? allowedModules : ["mail"],
@@ -1796,13 +1927,13 @@ function profileEditorCanSave(
if (editing === "new") return Boolean(draft.name.trim() && draft.smtpHost.trim());
if (target.kind === "profile") return Boolean(draft.name.trim());
if (target.kind === "server") {
const host = target.protocol === "smtp" ? draft.smtpHost : draft.imapHost;
const host = target.protocol === "smtp" ? draft.smtpHost : target.protocol === "imap" ? draft.imapHost : draft.jmapSessionUrl;
return Boolean(draft.serverName.trim() && host.trim());
}
if (target.kind === "credentials") {
if (!target.serverId) return false;
if (!target.credentialId && reuseCredentialId) return true;
const password = target.protocol === "smtp" ? draft.smtpPassword : draft.imapPassword;
const password = target.protocol === "smtp" ? draft.smtpPassword : target.protocol === "imap" ? draft.imapPassword : draft.jmapToken;
return Boolean(draft.credentialName.trim() && (target.credentialId || password));
}
return false;
@@ -1906,6 +2037,18 @@ function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
);
}
function jmapServerPayload(draft: ProfileDraft): MailJmapTransportSettings {
return {
session_url: draft.jmapSessionUrl.trim(),
account_id: mailTextOrNull(draft.jmapAccountId),
auth_scheme: draft.jmapAuthScheme,
timeout_seconds: boundedInteger(draft.jmapTimeout, 20, 1, 120),
max_response_bytes: boundedInteger(draft.jmapMaxResponseBytes, 5 * 1024 * 1024, 64 * 1024, 25 * 1024 * 1024),
max_body_value_bytes: boundedInteger(draft.jmapMaxBodyValueBytes, 1024 * 1024, 1024, 5 * 1024 * 1024),
allowed_api_origins: commaValues(draft.jmapAllowedApiOrigins)
};
}
function profileCredentialsPayload(draft: ProfileDraft, preserveBlankPassword: boolean) {
return {
smtp: mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword),
@@ -2171,8 +2314,9 @@ function mailPolicySourcePath(scopeType: MailProfileScope): string[] {
return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.owner_policy.1e8df143", "i18n:govoplan-mail.campaign.69390e16"];
}
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | null | undefined): string {
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | MailJmapTransportSettings | null | undefined): string {
if (!transport) return "i18n:govoplan-mail.not_configured.811931bb";
if ("session_url" in transport) return transport.session_url || "No Session URL";
const host = transport.host || "i18n:govoplan-mail.no_host.4c710d7d";
const port = transport.port ? `:${transport.port}` : "";
return `${host}${port}`;
@@ -2204,6 +2348,20 @@ function commaValues(value: string): string[] {
)];
}
function boundedInteger(value: string, fallback: number, minimum: number, maximum: number): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(maximum, Math.max(minimum, parsed));
}
function jmapHostname(value: string): string {
try {
return new URL(value).hostname;
} catch {
return "";
}
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}