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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.21",
"version": "0.1.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.21",
"version": "0.1.22",
"devDependencies": {
"typescript": "^5.7.2"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.21",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "src/index.ts",
+60 -9
View File
@@ -164,6 +164,20 @@ export type MailMailboxMessageResponse = {
message: MailMailboxMessageDetail;
};
export type MailMailboxProtocol = "imap" | "jmap";
export type MailMailboxChangesResponse = {
profile_id: string;
protocol: "jmap";
account_id: string;
old_state: string;
new_state: string;
has_more_changes: boolean;
created: string[];
updated: string[];
destroyed: string[];
};
export type MailSettingsDeltaResponse = {
profiles: MailServerProfile[];
policy?: MailProfilePolicyResponse | null;
@@ -296,7 +310,7 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
export type MailServerEndpointPayload = {
protocol: "smtp" | "imap" | "pop3";
protocol: "smtp" | "imap" | "jmap" | "pop3";
name: string;
config: Record<string, unknown>;
inherit_to_lower_scopes?: boolean | null;
@@ -574,6 +588,23 @@ export async function testMailProfileImap(
);
}
export async function testMailProfileJmap(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(
settings,
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-jmap`, {
server_id: serverId,
credential_id: credentialId,
campaign_id: campaignId
})
);
}
export async function testMailProfilePop3(
settings: ApiSettings,
profileId: string,
@@ -648,10 +679,11 @@ export async function listMailProfileImapFolders(
);
}
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false, protocol: MailMailboxProtocol = "imap"): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, {
include_status: includeStatus ? true : undefined,
refresh: refresh ? true : undefined
refresh: refresh ? true : undefined,
protocol
}));
}
@@ -661,13 +693,15 @@ export async function bootstrapMailbox(
folder = "INBOX",
limit = 50,
offset = 0,
refresh = false
refresh = false,
protocol: MailMailboxProtocol = "imap"
): Promise<MailMailboxBootstrapResponse> {
return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, {
folder,
limit,
offset,
refresh: refresh ? true : undefined
refresh: refresh ? true : undefined,
protocol
}));
}
@@ -678,14 +712,18 @@ export async function listMailboxMessages(
limit = 50,
offset = 0,
cursor?: string | null,
refresh = false
refresh = false,
protocol: MailMailboxProtocol = "imap",
query?: string | null
): Promise<MailMailboxMessageListResponse> {
return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
folder,
limit,
offset,
cursor,
refresh: refresh ? true : undefined
refresh: refresh ? true : undefined,
protocol,
q: query || undefined
}));
}
@@ -693,9 +731,22 @@ export async function getMailboxMessage(
settings: ApiSettings,
profileId: string,
folder: string,
uid: string
uid: string,
protocol: MailMailboxProtocol = "imap"
): Promise<MailMailboxMessageResponse> {
return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder }));
return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder, protocol }));
}
export async function getMailboxChanges(
settings: ApiSettings,
profileId: string,
sinceState: string,
maxChanges = 500
): Promise<MailMailboxChangesResponse> {
return apiFetch<MailMailboxChangesResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/changes`, {
since_state: sinceState,
max_changes: maxChanges
}));
}
export async function testSmtpSettings(
+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);
}
+10 -2
View File
@@ -16,6 +16,7 @@ import {
bootstrapMailbox,
listMailServerProfiles,
lookupMailAddresses,
type MailMailboxProtocol,
type MailMailboxMessageSummary
} from "../../api/mail";
import { mailLookupSuggestions, mailtoHref } from "./mailAddressIntegration";
@@ -53,9 +54,10 @@ export default function MailQuickAccess({
const lookupRequestRef = useRef(0);
const load = useCallback(async (): Promise<MailQuickAccessData> => {
const profiles = await listMailServerProfiles(settings);
const profile = profiles.find((item) => item.is_active && item.imap);
const profile = profiles.find((item) => item.is_active && quickAccessMailboxProtocol(item));
if (!profile) return { messages: [], available: false };
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false);
const protocol = quickAccessMailboxProtocol(profile) ?? "imap";
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false, protocol);
return {
profileName: profile.name,
profileId: profile.id,
@@ -188,6 +190,12 @@ export default function MailQuickAccess({
);
}
function quickAccessMailboxProtocol(profile: { imap?: unknown; servers?: Array<{ protocol: string; is_active: boolean; is_default?: boolean }> }): MailMailboxProtocol | null {
if (profile.servers?.some((server) => server.protocol === "jmap" && server.is_active)) return "jmap";
if (profile.imap || profile.servers?.some((server) => server.protocol === "imap" && server.is_active)) return "imap";
return null;
}
function formatMessageDate(value?: string | null): string | undefined {
if (!value) return undefined;
+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 });
@@ -1,4 +1,4 @@
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "jmap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePolicy = {
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
@@ -8,6 +8,7 @@ export type MailProfilePolicy = {
export type MailPolicyValidationInput = {
smtpHost?: string | null;
imapHost?: string | null;
jmapHost?: string | null;
envelopeSender?: string | null;
fromHeader?: string | null;
recipientDomains?: Array<string | null | undefined> | null;
@@ -24,6 +25,7 @@ export type MailPolicyValidationMessage = {
const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "i18n:govoplan-mail.smtp_host.2d4a434b",
imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751",
jmap_hosts: "JMAP host",
envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0",
from_headers: "i18n:govoplan-mail.from_header.bb78e85d",
recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf"
@@ -43,6 +45,7 @@ input: MailPolicyValidationInput)
const checks: ValueCheck[] = [
{ key: "smtp_hosts", value: input.smtpHost ?? "" },
{ key: "imap_hosts", value: input.imapHost ?? "" },
{ key: "jmap_hosts", value: input.jmapHost ?? "" },
{ key: "envelope_senders", value: input.envelopeSender ?? "" },
{ key: "from_headers", value: input.fromHeader ?? "" },
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))).
@@ -1,5 +1,5 @@
export type MailProfileProtocol = "smtp" | "imap";
export type MailProfileEditSection = MailProfileProtocol;
export type MailProfileProtocol = "smtp" | "imap" | "jmap";
export type MailProfileEditSection = "smtp" | "imap";
export type MailProfilePanelMode = "all" | "server" | "credentials";
export type MailProfileCreateStage =
| "profile"
@@ -54,9 +54,11 @@ export type MailProfileTargetedUpdateParts = {
profile: Record<string, unknown>;
smtp: Record<string, unknown>;
imap: Record<string, unknown> | null;
jmap?: Record<string, unknown> | null;
credentials: {
smtp: MailProfileTransportCredentialsLike;
imap: MailProfileTransportCredentialsLike;
jmap?: MailProfileTransportCredentialsLike;
};
clearImap: boolean;
};
@@ -72,7 +74,7 @@ export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike)
}
export function mailProfileEditTargetInitialSection(target: MailProfileEditTarget): MailProfileEditSection {
if (target.kind === "server" || target.kind === "credentials") return target.protocol;
if (target.kind === "server" || target.kind === "credentials") return target.protocol === "jmap" ? "imap" : target.protocol;
return "smtp";
}
@@ -84,7 +86,7 @@ export function mailProfileEditTargetPanelMode(target: MailProfileEditTarget): M
}
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] {
if (target.kind === "server" || target.kind === "credentials") return [target.protocol];
if (target.kind === "server" || target.kind === "credentials") return target.protocol === "jmap" ? [] : [target.protocol];
if (target.kind === "create") return ["smtp", "imap"];
return [];
}
@@ -94,7 +96,7 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT
}
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
return target.kind !== "profile";
return target.kind !== "profile" && !((target.kind === "server" || target.kind === "credentials") && target.protocol === "jmap");
}
export function mailProfileCreateStagePanel(
@@ -152,12 +154,16 @@ export function mailProfileTargetedUpdatePayload(
if (target.kind === "profile") return parts.profile;
if (target.kind === "server") {
if (target.protocol === "smtp") return { smtp: parts.smtp };
if (target.protocol === "jmap") return { jmap: parts.jmap ?? null };
return parts.imap === null
? { imap: null, clear_imap: parts.clearImap }
: { imap: parts.imap };
}
if (target.kind === "credentials") {
return { credentials: { [target.protocol]: parts.credentials[target.protocol] } };
const credentials = target.protocol === "jmap"
? parts.credentials.jmap ?? {}
: parts.credentials[target.protocol];
return { credentials: { [target.protocol]: credentials } };
}
throw new Error("Create is not an update target");
}
@@ -21,11 +21,13 @@ assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp12.example.org"), f
const policy = {
whitelist: {
smtp_hosts: ["smtp.allowed.test"],
jmap_hosts: ["jmap.allowed.test"],
from_headers: ["*@allowed.test"],
recipient_domains: ["allowed.test"]
},
blacklist: {
smtp_hosts: ["smtp.blocked.test"],
jmap_hosts: ["jmap.blocked.test"],
envelope_senders: ["blocked@*"]
}
};
@@ -33,14 +35,17 @@ const policy = {
assertDeepEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.allowed.test"), { allowed: true, value: "smtp.allowed.test" });
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.blocked.test").allowed, false, "blacklist wins for SMTP host");
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.other.test").allowed, false, "whitelist blocks unknown SMTP host");
assertEqual(mailPolicyValueAllowed(policy, "jmap_hosts", "jmap.blocked.test").allowed, false, "JMAP hostname deny policy is independent");
const messages = validateMailPolicy(policy, {
smtpHost: "smtp.other.test",
jmapHost: "jmap.blocked.test",
envelopeSender: "blocked@allowed.test",
fromHeader: "sender@other.test",
recipientDomains: ["allowed.test", "denied.test", "user@denied.test"]
});
assert(messages.some((item) => item.key === "smtp_hosts" && item.value === "smtp.other.test"));
assert(messages.some((item) => item.key === "jmap_hosts" && item.value === "jmap.blocked.test"));
assert(messages.some((item) => item.key === "envelope_senders" && item.value === "blocked@allowed.test"));
assert(messages.some((item) => item.key === "from_headers" && item.value === "sender@other.test"));
assertEqual(messages.filter((item) => item.key === "recipient_domains" && item.value === "denied.test").length, 1, "recipient domains are normalized and de-duplicated");
+16 -1
View File
@@ -47,6 +47,9 @@ assertEqual(mailProfileEditTargetPanelMode({ kind: "credentials", protocol: "ima
assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "create" }), ["smtp", "imap"]);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["imap"]);
assertEqual(mailProfileEditTargetInitialSection({ kind: "server", protocol: "jmap" }), "imap");
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "server", protocol: "jmap" }), []);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "credentials", protocol: "jmap" }), false);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
@@ -118,9 +121,11 @@ const updateParts = {
profile: { name: "Renamed" },
smtp: { host: "smtp.example.org" },
imap: { host: "imap.example.org" },
jmap: { session_url: "https://jmap.example.org/.well-known/jmap" },
credentials: {
smtp: { username: "smtp-user", password: "smtp-secret" },
imap: { username: "imap-user", password: "imap-secret" }
imap: { username: "imap-user", password: "imap-secret" },
jmap: { password: "jmap-token" }
},
clearImap: false
};
@@ -139,6 +144,16 @@ assertDeepEqual(
{ credentials: { imap: { username: "imap-user", password: "imap-secret" } } },
"credential edits send only the selected protocol"
);
assertDeepEqual(
mailProfileTargetedUpdatePayload({ kind: "server", protocol: "jmap" }, updateParts),
{ jmap: { session_url: "https://jmap.example.org/.well-known/jmap" } },
"JMAP server edits remain isolated from legacy profile transports"
);
assertDeepEqual(
mailProfileTargetedUpdatePayload({ kind: "credentials", protocol: "jmap" }, updateParts),
{ credentials: { jmap: { password: "jmap-token" } } },
"JMAP credential edits retain only the selected credential"
);
assertEqual(
mailProfileCreateCredentialsPayload({ username: null }, { password: "" }),
undefined,