diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx
index 0c8f9cb..29ee5a2 100644
--- a/webui/src/features/mail/MailProfileManagement.tsx
+++ b/webui/src/features/mail/MailProfileManagement.tsx
@@ -42,6 +42,8 @@ import {
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
mailProfileEditTargetVisibleSections,
+ mailProfileCreateCredentialsPayload,
+ mailProfileTargetedUpdatePayload,
type MailProfileEditTarget,
type MailProfileProtocol
} from "./mailProfileEditorModel";
@@ -278,7 +280,7 @@ export function MailProfileScopeManager({
const created = await createMailServerProfile(settings, payload);
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_created.2a088d8d", { value0: created.name }));
} else {
- const payload = updateProfilePayload(draft, editing);
+ const payload = updateProfilePayload(draft, editing, editingTarget);
const updated = await updateMailServerProfile(settings, editing.id, payload);
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_updated.fdbad0ea", { value0: updated.name }));
}
@@ -369,13 +371,16 @@ export function MailProfileScopeManager({
}
if (row.kind === "credential") {
return , disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile, { kind: "credentials", protocol: row.protocol })
+ id: "edit-credentials", label: i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() }), icon: , disabled: !canWriteProfiles || !canManageCredentials || busy, onClick: () => openEdit(row.profile, { kind: "credentials", protocol: row.protocol })
}]} />;
}
+ const deactivationDeletesCredentials = Boolean(
+ row.profile.smtp_password_configured || row.profile.imap_password_configured
+ );
return , disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) },
- { id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: , variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy, onClick: () => setDeactivating(row.profile) }
+ { id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: , variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy || (deactivationDeletesCredentials && !canManageCredentials), onClick: () => setDeactivating(row.profile) }
]} />;
}
@@ -1092,6 +1097,7 @@ function profileToDraft(profile: MailServerProfile): ProfileDraft {
}
function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scopeId: string | null): MailServerProfilePayload {
+ const credentials = profileCreateCredentialsPayload(draft);
return {
name: draft.name.trim(),
slug: mailTextOrNull(draft.slug),
@@ -1101,21 +1107,27 @@ function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope,
scope_id: scopeId,
smtp: smtpServerPayload(draft),
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
- credentials: profileCredentialsPayload(draft, false)
+ ...(credentials ? { credentials } : {})
};
}
-function updateProfilePayload(draft: ProfileDraft, profile: MailServerProfile): MailServerProfileUpdatePayload {
- return {
- name: draft.name.trim(),
- slug: mailTextOrNull(draft.slug),
- description: draft.description.trim(),
- is_active: draft.isActive,
+function updateProfilePayload(
+ draft: ProfileDraft,
+ profile: MailServerProfile,
+ target: MailProfileEditTarget
+): MailServerProfileUpdatePayload {
+ return mailProfileTargetedUpdatePayload(target, {
+ profile: {
+ name: draft.name.trim(),
+ slug: mailTextOrNull(draft.slug),
+ description: draft.description.trim(),
+ is_active: draft.isActive
+ },
smtp: smtpServerPayload(draft),
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
credentials: profileCredentialsPayload(draft, true),
- clear_imap: !hasDraftImapSettings(draft) && Boolean(profile.imap)
- };
+ clearImap: !hasDraftImapSettings(draft) && Boolean(profile.imap)
+ }) as MailServerProfileUpdatePayload;
}
function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
@@ -1139,6 +1151,13 @@ function profileCredentialsPayload(draft: ProfileDraft, preserveBlankPassword: b
};
}
+function profileCreateCredentialsPayload(draft: ProfileDraft) {
+ return mailProfileCreateCredentialsPayload(
+ mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, true),
+ mailTransportCredentialsPayload(draft.imapUsername, draft.imapPassword, true)
+ );
+}
+
function rawSmtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload {
return { ...smtpServerPayload(draft), ...mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword) };
}
diff --git a/webui/src/features/mail/mailProfileEditorModel.ts b/webui/src/features/mail/mailProfileEditorModel.ts
index e6480f8..5d2a9ed 100644
--- a/webui/src/features/mail/mailProfileEditorModel.ts
+++ b/webui/src/features/mail/mailProfileEditorModel.ts
@@ -23,6 +23,22 @@ export type MailProfileChildDescriptor = {
protocol: MailProfileProtocol;
};
+export type MailProfileTransportCredentialsLike = {
+ username?: string | null;
+ password?: string | null;
+};
+
+export type MailProfileTargetedUpdateParts = {
+ profile: Record;
+ smtp: Record;
+ imap: Record | null;
+ credentials: {
+ smtp: MailProfileTransportCredentialsLike;
+ imap: MailProfileTransportCredentialsLike;
+ };
+ clearImap: boolean;
+};
+
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
const children: MailProfileChildDescriptor[] = [
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
@@ -58,3 +74,42 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
return target.kind !== "profile";
}
+
+/**
+ * Keep the PATCH authority surface equal to the focused editor. Profile and
+ * server edits must not accidentally replay credential fields merely because
+ * the form draft contains their displayed values.
+ */
+export function mailProfileTargetedUpdatePayload(
+ target: MailProfileEditTarget,
+ parts: MailProfileTargetedUpdateParts
+): Record {
+ if (target.kind === "profile") return parts.profile;
+ if (target.kind === "server") {
+ if (target.protocol === "smtp") return { smtp: parts.smtp };
+ 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] } };
+ }
+ throw new Error("Create is not an update target");
+}
+
+/** Omit blank create credentials so profile-write remains independent. */
+export function mailProfileCreateCredentialsPayload(
+ smtp: MailProfileTransportCredentialsLike,
+ imap: MailProfileTransportCredentialsLike
+): {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} | undefined {
+ const populated = (value: MailProfileTransportCredentialsLike): MailProfileTransportCredentialsLike =>
+ Object.fromEntries(
+ Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== "")
+ );
+ const smtpCredentials = populated(smtp);
+ const imapCredentials = populated(imap);
+ const result: {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} = {};
+ if (Object.keys(smtpCredentials).length > 0) result.smtp = smtpCredentials;
+ if (Object.keys(imapCredentials).length > 0) result.imap = imapCredentials;
+ return Object.keys(result).length > 0 ? result : undefined;
+}
diff --git a/webui/tests/mail-profile-editor-model.test.ts b/webui/tests/mail-profile-editor-model.test.ts
index 0c80970..5517633 100644
--- a/webui/tests/mail-profile-editor-model.test.ts
+++ b/webui/tests/mail-profile-editor-model.test.ts
@@ -18,7 +18,9 @@ import {
mailProfileEditTargetPanelMode,
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
- mailProfileEditTargetVisibleSections
+ mailProfileEditTargetVisibleSections,
+ mailProfileCreateCredentialsPayload,
+ mailProfileTargetedUpdatePayload
} from "../src/features/mail/mailProfileEditorModel";
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
@@ -46,3 +48,42 @@ assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
+
+const updateParts = {
+ profile: { name: "Renamed" },
+ smtp: { host: "smtp.example.org" },
+ imap: { host: "imap.example.org" },
+ credentials: {
+ smtp: { username: "smtp-user", password: "smtp-secret" },
+ imap: { username: "imap-user", password: "imap-secret" }
+ },
+ clearImap: false
+};
+assertDeepEqual(
+ mailProfileTargetedUpdatePayload({ kind: "profile" }, updateParts),
+ { name: "Renamed" },
+ "profile edits do not replay transport or credential fields"
+);
+assertDeepEqual(
+ mailProfileTargetedUpdatePayload({ kind: "server", protocol: "smtp" }, updateParts),
+ { smtp: { host: "smtp.example.org" } },
+ "server edits do not replay credential fields"
+);
+assertDeepEqual(
+ mailProfileTargetedUpdatePayload({ kind: "credentials", protocol: "imap" }, updateParts),
+ { credentials: { imap: { username: "imap-user", password: "imap-secret" } } },
+ "credential edits send only the selected protocol"
+);
+assertEqual(
+ mailProfileCreateCredentialsPayload({ username: null }, { password: "" }),
+ undefined,
+ "credential-free creates omit the credential object"
+);
+assertDeepEqual(
+ mailProfileCreateCredentialsPayload(
+ { username: "smtp-user", password: null },
+ { username: null, password: "imap-secret" }
+ ),
+ { smtp: { username: "smtp-user" }, imap: { password: "imap-secret" } },
+ "create payloads retain only explicitly populated credential fields"
+);