feat(mail): add governed JMAP mailbox sync and search
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user