Integrate campaign delivery with hierarchical mail profiles
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import type {
|
||||
ApiSettings,
|
||||
MailConnectionTestResponse,
|
||||
MailCredentialEnvelope,
|
||||
MailImapFolderListResponse,
|
||||
MailServerProfile,
|
||||
MockMailboxMessageResponse
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPost } from "./client";
|
||||
import { apiFetch, apiGetList, apiPath, apiPost } from "./client";
|
||||
|
||||
const profileActionEndpoints = {
|
||||
smtp: "test-smtp",
|
||||
@@ -16,11 +17,21 @@ const profileActionEndpoints = {
|
||||
function runProfileAction<TResponse>(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
action: keyof typeof profileActionEndpoints
|
||||
action: keyof typeof profileActionEndpoints,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<TResponse> {
|
||||
return apiPost<TResponse>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
|
||||
apiPath(
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`,
|
||||
{
|
||||
server_id: serverId,
|
||||
credential_id: credentialId,
|
||||
campaign_id: campaignId
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,16 +42,58 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
|
||||
});
|
||||
}
|
||||
|
||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
|
||||
export function createCampaignMailCredential(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
campaignId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
server_ids: string[];
|
||||
}
|
||||
): Promise<MailCredentialEnvelope> {
|
||||
return apiFetch<MailCredentialEnvelope>(
|
||||
settings,
|
||||
apiPath(
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/campaign-credentials`,
|
||||
{ campaign_id: campaignId }
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
|
||||
export async function testMailProfileSmtp(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
|
||||
export async function testMailProfileImap(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailImapFolderListResponse> {
|
||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
|
||||
}
|
||||
|
||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||
|
||||
@@ -2,21 +2,26 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
MailServerFolderLookupResultView,
|
||||
MetricCard,
|
||||
PageTitle,
|
||||
PasswordField,
|
||||
ToggleSwitch,
|
||||
usePlatformModuleInstalled,
|
||||
usePlatformUiCapability,
|
||||
type MailCredentialEnvelope,
|
||||
type MailProfilesUiCapability,
|
||||
type MailServerConnectionTestResult,
|
||||
type MailServerEndpoint,
|
||||
type MailServerFolderLookupResult
|
||||
} from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
createCampaignMailCredential,
|
||||
listMailProfileImapFolders,
|
||||
listMailServerProfiles,
|
||||
testMailProfileImap,
|
||||
@@ -39,6 +44,14 @@ type MailSettingsPageProps = {
|
||||
view?: MailSettingsView;
|
||||
};
|
||||
|
||||
type CampaignCredentialDraft = {
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
useSmtp: boolean;
|
||||
useImap: boolean;
|
||||
};
|
||||
|
||||
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
|
||||
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
@@ -53,6 +66,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
|
||||
const [credentialDialogOpen, setCredentialDialogOpen] = useState(false);
|
||||
const [credentialSaving, setCredentialSaving] = useState(false);
|
||||
const [credentialDraft, setCredentialDraft] = useState<CampaignCredentialDraft>({
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
useSmtp: true,
|
||||
useImap: false
|
||||
});
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
@@ -78,10 +100,28 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const server = asRecord(displayDraft.server);
|
||||
const selectedProfileId = getText(server, "mail_profile_id");
|
||||
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const smtpServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "smtp" && item.is_active);
|
||||
const imapServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "imap" && item.is_active);
|
||||
const selectedSmtpServer = smtpServers.find((item) => item.id === getText(server, "smtp_server_id"))
|
||||
?? smtpServers.find((item) => item.is_default)
|
||||
?? smtpServers[0]
|
||||
?? null;
|
||||
const selectedImapServer = imapServers.find((item) => item.id === getText(server, "imap_server_id"))
|
||||
?? imapServers.find((item) => item.is_default)
|
||||
?? imapServers[0]
|
||||
?? null;
|
||||
const selectedSmtpCredential = selectedSmtpServer?.credentials.find((item) => item.id === getText(server, "smtp_credential_id"))
|
||||
?? selectedSmtpServer?.credentials.find((item) => item.is_default)
|
||||
?? selectedSmtpServer?.credentials[0]
|
||||
?? null;
|
||||
const selectedImapCredential = selectedImapServer?.credentials.find((item) => item.id === getText(server, "imap_credential_id"))
|
||||
?? selectedImapServer?.credentials.find((item) => item.is_default)
|
||||
?? selectedImapServer?.credentials[0]
|
||||
?? null;
|
||||
const delivery = asRecord(displayDraft.delivery);
|
||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
||||
const selectedProfileHasImap = Boolean(selectedImapServer);
|
||||
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
|
||||
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
|
||||
|
||||
@@ -117,22 +157,140 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
function selectMailProfile(profileId: string) {
|
||||
if (!mailModuleInstalled || locked) return;
|
||||
patch(["server"], profileId ? { mail_profile_id: profileId } : {});
|
||||
const profile = mailProfiles.find((item) => item.id === profileId);
|
||||
const smtpServer = profile?.servers?.find((item) => item.protocol === "smtp" && item.is_active && item.is_default)
|
||||
?? profile?.servers?.find((item) => item.protocol === "smtp" && item.is_active);
|
||||
const imapServer = profile?.servers?.find((item) => item.protocol === "imap" && item.is_active && item.is_default)
|
||||
?? profile?.servers?.find((item) => item.protocol === "imap" && item.is_active);
|
||||
const smtpCredential = smtpServer?.credentials.find((item) => item.is_active && item.is_default)
|
||||
?? smtpServer?.credentials.find((item) => item.is_active);
|
||||
const imapCredential = imapServer?.credentials.find((item) => item.is_active && item.is_default)
|
||||
?? imapServer?.credentials.find((item) => item.is_active);
|
||||
patch(["server"], profile ? mailReference(
|
||||
profile.id,
|
||||
smtpServer?.id,
|
||||
smtpCredential?.id,
|
||||
imapServer?.id,
|
||||
imapCredential?.id
|
||||
) : {});
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
setFolderResult(null);
|
||||
setProfileError("");
|
||||
}
|
||||
|
||||
function selectServer(protocol: "smtp" | "imap", serverId: string) {
|
||||
if (!selectedProfile || locked) return;
|
||||
const availableServers = protocol === "smtp" ? smtpServers : imapServers;
|
||||
const selected = availableServers.find((item) => item.id === serverId) ?? null;
|
||||
const credential = selected?.credentials.find((item) => item.is_active && item.is_default)
|
||||
?? selected?.credentials.find((item) => item.is_active)
|
||||
?? null;
|
||||
patch(["server"], mailReference(
|
||||
selectedProfile.id,
|
||||
protocol === "smtp" ? selected?.id : selectedSmtpServer?.id,
|
||||
protocol === "smtp" ? credential?.id : selectedSmtpCredential?.id,
|
||||
protocol === "imap" ? selected?.id : selectedImapServer?.id,
|
||||
protocol === "imap" ? credential?.id : selectedImapCredential?.id
|
||||
));
|
||||
if (protocol === "smtp") setSmtpTestResult(null);
|
||||
else {
|
||||
setImapTestResult(null);
|
||||
setFolderResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
function selectCredential(protocol: "smtp" | "imap", credentialId: string) {
|
||||
if (!selectedProfile || locked) return;
|
||||
patch(["server"], mailReference(
|
||||
selectedProfile.id,
|
||||
selectedSmtpServer?.id,
|
||||
protocol === "smtp" ? credentialId : selectedSmtpCredential?.id,
|
||||
selectedImapServer?.id,
|
||||
protocol === "imap" ? credentialId : selectedImapCredential?.id
|
||||
));
|
||||
if (protocol === "smtp") setSmtpTestResult(null);
|
||||
else {
|
||||
setImapTestResult(null);
|
||||
setFolderResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
function openCredentialDialog() {
|
||||
setCredentialDraft({
|
||||
name: `${data.campaign?.name || "Campaign"} credential`,
|
||||
username: "",
|
||||
password: "",
|
||||
useSmtp: Boolean(selectedSmtpServer),
|
||||
useImap: Boolean(selectedImapServer)
|
||||
});
|
||||
setCredentialDialogOpen(true);
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
async function saveCampaignCredential() {
|
||||
if (!selectedProfile || credentialSaving) return;
|
||||
const serverIds = [
|
||||
credentialDraft.useSmtp ? selectedSmtpServer?.id : null,
|
||||
credentialDraft.useImap ? selectedImapServer?.id : null
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (
|
||||
!credentialDraft.name.trim() ||
|
||||
!credentialDraft.username.trim() ||
|
||||
!credentialDraft.password ||
|
||||
serverIds.length === 0
|
||||
) return;
|
||||
setCredentialSaving(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
const credential = await createCampaignMailCredential(
|
||||
settings,
|
||||
selectedProfile.id,
|
||||
campaignId,
|
||||
{
|
||||
name: credentialDraft.name.trim(),
|
||||
username: credentialDraft.username.trim(),
|
||||
password: credentialDraft.password,
|
||||
server_ids: serverIds
|
||||
}
|
||||
);
|
||||
patch(["server"], mailReference(
|
||||
selectedProfile.id,
|
||||
selectedSmtpServer?.id,
|
||||
credentialDraft.useSmtp ? credential.id : selectedSmtpCredential?.id,
|
||||
selectedImapServer?.id,
|
||||
credentialDraft.useImap ? credential.id : selectedImapCredential?.id
|
||||
));
|
||||
setCredentialDialogOpen(false);
|
||||
await refreshMailProfiles();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCredentialSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runProfileTest(protocol: "smtp" | "imap") {
|
||||
if (!selectedProfileId || locked) return;
|
||||
setMailActionState(protocol);
|
||||
setLocalError("");
|
||||
try {
|
||||
if (protocol === "smtp") {
|
||||
setSmtpTestResult(await testMailProfileSmtp(settings, selectedProfileId));
|
||||
setSmtpTestResult(await testMailProfileSmtp(
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedSmtpServer?.id,
|
||||
selectedSmtpCredential?.id,
|
||||
campaignId
|
||||
));
|
||||
} else {
|
||||
setImapTestResult(await testMailProfileImap(settings, selectedProfileId));
|
||||
setImapTestResult(await testMailProfileImap(
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedImapServer?.id,
|
||||
selectedImapCredential?.id,
|
||||
campaignId
|
||||
));
|
||||
}
|
||||
} catch (err) {
|
||||
const result = { ok: false, protocol, message: err instanceof Error ? err.message : String(err), details: {} };
|
||||
@@ -148,7 +306,13 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
setMailActionState("folders");
|
||||
setLocalError("");
|
||||
try {
|
||||
setFolderResult(await listMailProfileImapFolders(settings, selectedProfileId));
|
||||
setFolderResult(await listMailProfileImapFolders(
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedImapServer?.id,
|
||||
selectedImapCredential?.id,
|
||||
campaignId
|
||||
));
|
||||
} catch (err) {
|
||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||
} finally {
|
||||
@@ -204,8 +368,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
{!isPolicyView && mailModuleInstalled && <Card
|
||||
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
||||
actions={<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>}>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809</p>
|
||||
actions={<div className="button-row compact-actions">
|
||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
|
||||
<Button variant="primary" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
|
||||
</div>}>
|
||||
<p className="muted small-note">The campaign stores stable references to the envelope, selected servers, and credentials.</p>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||
@@ -213,16 +380,44 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="SMTP server">
|
||||
<select value={selectedSmtpServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("smtp", event.target.value)}>
|
||||
<option value="">No SMTP server</option>
|
||||
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="SMTP credential">
|
||||
<select value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
|
||||
<option value="">No credential</option>
|
||||
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="IMAP server">
|
||||
<select value={selectedImapServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("imap", event.target.value)}>
|
||||
<option value="">No IMAP server</option>
|
||||
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="IMAP credential">
|
||||
<select value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
|
||||
<option value="">No credential</option>
|
||||
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
||||
{selectedProfile && <div className="metric-grid inside">
|
||||
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
||||
<MetricCard label="i18n:govoplan-campaign.scope.4651a34e" value={profileScopeLabel(selectedProfile)} />
|
||||
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value="i18n:govoplan-campaign.configured.668c5fff" tone="good" />
|
||||
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedProfile.imap ? "i18n:govoplan-campaign.configured.668c5fff" : "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedProfile.imap ? "good" : "neutral"} />
|
||||
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value={selectedSmtpServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedSmtpServer ? "good" : "neutral"} />
|
||||
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedImapServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedImapServer ? "good" : "neutral"} />
|
||||
</div>}
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedProfile || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
|
||||
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedSmtpServer || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
|
||||
<Button onClick={() => void runProfileTest("imap")} disabled={!selectedProfileHasImap || locked || Boolean(mailActionState)}>{mailActionState === "imap" ? "i18n:govoplan-campaign.testing_imap.13d255cf" : "i18n:govoplan-campaign.test_profile_imap.e1cec0e0"}</Button>
|
||||
</div>
|
||||
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
|
||||
@@ -230,6 +425,57 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
||||
</Card>}
|
||||
|
||||
<Dialog
|
||||
open={credentialDialogOpen}
|
||||
title="Add campaign credential"
|
||||
onClose={() => setCredentialDialogOpen(false)}
|
||||
closeDisabled={credentialSaving}
|
||||
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setCredentialDialogOpen(false)} disabled={credentialSaving}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveCampaignCredential()}
|
||||
disabled={
|
||||
credentialSaving ||
|
||||
!credentialDraft.name.trim() ||
|
||||
!credentialDraft.username.trim() ||
|
||||
!credentialDraft.password ||
|
||||
!credentialDraft.useSmtp && !credentialDraft.useImap
|
||||
}
|
||||
>
|
||||
{credentialSaving ? "Saving" : "Save credential"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="adaptive-config-form">
|
||||
<section className="adaptive-config-section">
|
||||
<header>
|
||||
<h3>Campaign login</h3>
|
||||
<p>This credential belongs to this campaign and can use only the selected mail servers.</p>
|
||||
</header>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Name">
|
||||
<input value={credentialDraft.name} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} autoFocus />
|
||||
</FormField>
|
||||
<FormField label="Username">
|
||||
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} autoComplete="new-password" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="form-grid two">
|
||||
{selectedSmtpServer && <ToggleSwitch checked={credentialDraft.useSmtp} disabled={credentialSaving} onChange={(useSmtp) => setCredentialDraft({ ...credentialDraft, useSmtp })} label={`Use for SMTP: ${selectedSmtpServer.name}`} />}
|
||||
{selectedImapServer && <ToggleSwitch checked={credentialDraft.useImap} disabled={credentialSaving} onChange={(useImap) => setCredentialDraft({ ...credentialDraft, useImap })} label={`Use for IMAP: ${selectedImapServer.name}`} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
@@ -266,3 +512,28 @@ function profileScopeLabel(profile: MailServerProfile): string {
|
||||
if (profile.scope_type === "group") return "i18n:govoplan-campaign.group.171a0606";
|
||||
return "i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505";
|
||||
}
|
||||
|
||||
function mailReference(
|
||||
profileId: string,
|
||||
smtpServerId?: string | null,
|
||||
smtpCredentialId?: string | null,
|
||||
imapServerId?: string | null,
|
||||
imapCredentialId?: string | null
|
||||
): Record<string, string> {
|
||||
const value: Record<string, string> = { mail_profile_id: profileId };
|
||||
if (smtpServerId) value.smtp_server_id = smtpServerId;
|
||||
if (smtpServerId && smtpCredentialId) value.smtp_credential_id = smtpCredentialId;
|
||||
if (imapServerId) value.imap_server_id = imapServerId;
|
||||
if (imapServerId && imapCredentialId) value.imap_credential_id = imapCredentialId;
|
||||
return value;
|
||||
}
|
||||
|
||||
function serverEndpointLabel(config: MailServerEndpoint["config"]): string {
|
||||
const host = typeof config.host === "string" && config.host ? config.host : "No host";
|
||||
return config.port ? `${host}:${config.port}` : host;
|
||||
}
|
||||
|
||||
function credentialLabel(credential: MailCredentialEnvelope): string {
|
||||
const username = credential.public_data?.username;
|
||||
return username ? `${credential.name} (${String(username)})` : credential.name;
|
||||
}
|
||||
|
||||
@@ -232,27 +232,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
|
||||
updateEntry(index, (entry) => entryWithAddressList(entry, key, addresses));
|
||||
}
|
||||
|
||||
function updateEntryAddressGroups(index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) {
|
||||
function saveEntryAddresses(
|
||||
index: number,
|
||||
values: HeaderAddressValues,
|
||||
merges: EntryAddressMergeValues
|
||||
) {
|
||||
updateEntry(index, (entry) => {
|
||||
let nextEntry = entry;
|
||||
for (const group of groups) {
|
||||
const currentAddresses = getEntryAddresses(nextEntry, group.key);
|
||||
nextEntry = entryWithAddressList(nextEntry, group.key, dedupeAddresses([...currentAddresses, ...group.addresses]));
|
||||
for (const column of recipientAddressOverlayColumns) {
|
||||
if (!(column.key in values)) continue;
|
||||
nextEntry = entryWithAddressList(nextEntry, column.key, values[column.key] ?? []);
|
||||
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
|
||||
nextEntry = { ...nextEntry, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
|
||||
delete nextEntry[column.mergeKey.replace("merge_", "combine_")];
|
||||
}
|
||||
return nextEntry;
|
||||
});
|
||||
}
|
||||
|
||||
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) {
|
||||
updateEntry(index, (entry) => {
|
||||
const next = { ...entry, [mergeKey]: checked };
|
||||
delete next[mergeKey.replace("merge_", "combine_")];
|
||||
return next;
|
||||
});
|
||||
setRecipientAddressEditorIndex(null);
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: unknown) {
|
||||
@@ -305,8 +301,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
setAddressSourceImportOpen(false);
|
||||
}
|
||||
|
||||
function updateHeaderAddressList(key: AddressFieldKey, addresses: MailboxAddress[]) {
|
||||
patch(["recipients", key], key === "from" ? addresses.slice(0, 1) : addresses);
|
||||
function saveHeaderAddresses(values: HeaderAddressValues) {
|
||||
const nextRecipients = { ...recipientsSection };
|
||||
for (const [key, addresses] of Object.entries(values) as Array<[AddressFieldKey, MailboxAddress[]]>) {
|
||||
nextRecipients[key] = key === "from" ? addresses.slice(0, 1) : addresses;
|
||||
}
|
||||
patch(["recipients"], nextRecipients);
|
||||
setHeaderAddressEditor(null);
|
||||
}
|
||||
|
||||
async function copyHeaderAddresses(columns: EntryAddressColumn[]) {
|
||||
@@ -508,9 +509,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
locked={locked}
|
||||
recipientsSection={recipientsSection}
|
||||
entryDefaults={entryDefaults}
|
||||
updateEntryAddressList={updateEntryAddressList}
|
||||
updateEntryAddressGroups={updateEntryAddressGroups}
|
||||
updateEntryMerge={updateEntryMerge}
|
||||
onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
|
||||
onClose={() => setRecipientAddressEditorIndex(null)} />
|
||||
|
||||
}
|
||||
@@ -520,7 +519,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
columns={headerAddressEditor.columns}
|
||||
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
|
||||
locked={locked}
|
||||
onAddressesChange={updateHeaderAddressList}
|
||||
onSave={saveHeaderAddresses}
|
||||
onClose={() => setHeaderAddressEditor(null)} />
|
||||
|
||||
}
|
||||
@@ -534,13 +533,12 @@ type RecipientAddressEditorDialogProps = {
|
||||
locked: boolean;
|
||||
recipientsSection: Record<string, unknown>;
|
||||
entryDefaults: Record<string, unknown>;
|
||||
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
||||
updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void;
|
||||
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
|
||||
onSave: (values: HeaderAddressValues, merges: EntryAddressMergeValues) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
|
||||
type EntryAddressMergeValues = Partial<Record<NonNullable<EntryAddressColumn["mergeKey"]>, boolean>>;
|
||||
|
||||
type AddressHeaderControlProps = {
|
||||
columns: EntryAddressColumn[];
|
||||
@@ -596,26 +594,33 @@ type HeaderAddressEditorDialogProps = {
|
||||
columns: EntryAddressColumn[];
|
||||
values: HeaderAddressValues;
|
||||
locked: boolean;
|
||||
onAddressesChange: (key: AddressFieldKey, addresses: MailboxAddress[]) => void;
|
||||
onSave: (values: HeaderAddressValues) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HeaderAddressEditorDialog({ title, columns, values, locked, onAddressesChange, onClose }: HeaderAddressEditorDialogProps) {
|
||||
function HeaderAddressEditorDialog({ title, columns, values, locked, onSave, onClose }: HeaderAddressEditorDialogProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const columnKeys = new Set(columns.map((column) => column.key));
|
||||
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() => cloneAddressValues(columns, values));
|
||||
const [validationError, setValidationError] = useState("");
|
||||
|
||||
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
|
||||
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key));
|
||||
if (groups.length === 0) return false;
|
||||
const nextValues: HeaderAddressValues = { ...values };
|
||||
for (const group of groups) {
|
||||
nextValues[group.key] = dedupeAddresses([...(nextValues[group.key] ?? []), ...group.addresses]);
|
||||
}
|
||||
for (const group of groups) {
|
||||
onAddressesChange(group.key, nextValues[group.key] ?? []);
|
||||
}
|
||||
setDraftValues((current) => mergeAddressGroups(current, groups));
|
||||
setValidationError("");
|
||||
return true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const prepared = prepareAddressValues(columns, draftValues, translateText);
|
||||
if (prepared.error) {
|
||||
setValidationError(prepared.error);
|
||||
return;
|
||||
}
|
||||
onSave(prepared.values);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@@ -623,17 +628,24 @@ function HeaderAddressEditorDialog({ title, columns, values, locked, onAddresses
|
||||
className="recipient-address-editor-modal"
|
||||
bodyClassName="recipient-address-editor-body"
|
||||
onClose={onClose}
|
||||
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
footer={<>
|
||||
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</>}>
|
||||
|
||||
<div className="recipient-address-editor">
|
||||
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
|
||||
{columns.map((column) =>
|
||||
<RecipientAddressCategoryEditor
|
||||
key={column.key}
|
||||
column={column}
|
||||
addresses={values[column.key] ?? []}
|
||||
addresses={draftValues[column.key] ?? []}
|
||||
merge={false}
|
||||
locked={locked}
|
||||
onAddressesChange={(addresses) => onAddressesChange(column.key, addresses)}
|
||||
onAddressesChange={(addresses) => {
|
||||
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
|
||||
setValidationError("");
|
||||
}}
|
||||
onPasteAddresses={applyPastedAddresses} />
|
||||
|
||||
)}
|
||||
@@ -647,21 +659,41 @@ function RecipientAddressEditorDialog({
|
||||
locked,
|
||||
recipientsSection,
|
||||
entryDefaults,
|
||||
updateEntryAddressList,
|
||||
updateEntryAddressGroups,
|
||||
updateEntryMerge,
|
||||
onSave,
|
||||
onClose
|
||||
}: RecipientAddressEditorDialogProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key));
|
||||
const availableKeys = new Set(availableColumns.map((column) => column.key));
|
||||
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() =>
|
||||
Object.fromEntries(availableColumns.map((column) => [column.key, getEntryAddresses(entry, column.key).map(cloneMailboxAddress)]))
|
||||
);
|
||||
const [draftMerges, setDraftMerges] = useState<EntryAddressMergeValues>(() =>
|
||||
Object.fromEntries(
|
||||
availableColumns
|
||||
.filter((column) => column.mergeKey)
|
||||
.map((column) => [column.mergeKey!, getEntryMerge(entry, entryDefaults, column.mergeKey!)])
|
||||
)
|
||||
);
|
||||
const [validationError, setValidationError] = useState("");
|
||||
|
||||
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
|
||||
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key));
|
||||
if (groups.length === 0) return false;
|
||||
updateEntryAddressGroups(index, groups);
|
||||
setDraftValues((current) => mergeAddressGroups(current, groups));
|
||||
setValidationError("");
|
||||
return true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const prepared = prepareAddressValues(availableColumns, draftValues, translateText);
|
||||
if (prepared.error) {
|
||||
setValidationError(prepared.error);
|
||||
return;
|
||||
}
|
||||
onSave(prepared.values, draftMerges);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@@ -669,9 +701,13 @@ function RecipientAddressEditorDialog({
|
||||
className="recipient-address-editor-modal"
|
||||
bodyClassName="recipient-address-editor-body"
|
||||
onClose={onClose}
|
||||
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
footer={<>
|
||||
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</>}>
|
||||
|
||||
<div className="recipient-address-editor">
|
||||
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
|
||||
{availableColumns.length === 0 &&
|
||||
<p className="muted">No individual recipient address fields are enabled.</p>
|
||||
}
|
||||
@@ -679,11 +715,16 @@ function RecipientAddressEditorDialog({
|
||||
<RecipientAddressCategoryEditor
|
||||
key={column.key}
|
||||
column={column}
|
||||
addresses={getEntryAddresses(entry, column.key)}
|
||||
merge={column.mergeKey ? getEntryMerge(entry, entryDefaults, column.mergeKey) : false}
|
||||
addresses={draftValues[column.key] ?? []}
|
||||
merge={column.mergeKey ? Boolean(draftMerges[column.mergeKey]) : false}
|
||||
locked={locked}
|
||||
onAddressesChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
|
||||
onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined}
|
||||
onAddressesChange={(addresses) => {
|
||||
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
|
||||
setValidationError("");
|
||||
}}
|
||||
onMergeChange={column.mergeKey ? (merge) => {
|
||||
setDraftMerges((current) => ({ ...current, [column.mergeKey!]: merge }));
|
||||
} : undefined}
|
||||
onPasteAddresses={applyPastedAddresses} />
|
||||
|
||||
)}
|
||||
@@ -716,7 +757,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
||||
|
||||
function commitAddresses(nextAddresses: MailboxAddress[]) {
|
||||
setDraftAddresses(nextAddresses);
|
||||
onAddressesChange(nextAddresses.filter((address) => String(address.email ?? "").trim() || String(address.name ?? "").trim()));
|
||||
onAddressesChange(nextAddresses);
|
||||
}
|
||||
|
||||
function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) {
|
||||
@@ -731,7 +772,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
||||
{ name: "", email: "" },
|
||||
...draftAddresses.slice(insertIndex)];
|
||||
|
||||
setDraftAddresses(nextAddresses);
|
||||
commitAddresses(nextAddresses);
|
||||
}
|
||||
|
||||
function removeAddress(addressIndex: number) {
|
||||
@@ -2147,9 +2188,59 @@ function getAddressColumn(key: AddressFieldKey): EntryAddressColumn {
|
||||
return column;
|
||||
}
|
||||
|
||||
function cloneMailboxAddress(address: MailboxAddress): MailboxAddress {
|
||||
return { name: address.name ?? "", email: address.email ?? "" };
|
||||
}
|
||||
|
||||
function cloneAddressValues(columns: EntryAddressColumn[], values: HeaderAddressValues): HeaderAddressValues {
|
||||
return Object.fromEntries(
|
||||
columns.map((column) => [column.key, (values[column.key] ?? []).map(cloneMailboxAddress)])
|
||||
) as HeaderAddressValues;
|
||||
}
|
||||
|
||||
function mergeAddressGroups(
|
||||
values: HeaderAddressValues,
|
||||
groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>
|
||||
): HeaderAddressValues {
|
||||
const nextValues = cloneAddressValues(recipientAddressOverlayColumns, values);
|
||||
for (const group of groups) {
|
||||
nextValues[group.key] = dedupeAddresses([
|
||||
...(nextValues[group.key] ?? []),
|
||||
...group.addresses.map(cloneMailboxAddress)
|
||||
]);
|
||||
}
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function prepareAddressValues(
|
||||
columns: EntryAddressColumn[],
|
||||
values: HeaderAddressValues,
|
||||
translateText: (value: string) => string
|
||||
): {values: HeaderAddressValues;error: string;} {
|
||||
const prepared: HeaderAddressValues = {};
|
||||
for (const column of columns) {
|
||||
const addresses: MailboxAddress[] = [];
|
||||
for (const address of values[column.key] ?? []) {
|
||||
const name = String(address.name ?? "").trim();
|
||||
const email = String(address.email ?? "").trim();
|
||||
if (!name && !email) continue;
|
||||
if (!email) {
|
||||
return {
|
||||
values: prepared,
|
||||
error: `${translateText(column.label)}: enter an email address or remove the incomplete row.`
|
||||
};
|
||||
}
|
||||
addresses.push({ name, email });
|
||||
}
|
||||
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses) : addresses.slice(0, 1);
|
||||
}
|
||||
return { values: prepared, error: "" };
|
||||
}
|
||||
|
||||
function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] {
|
||||
if (column.allowMultiple) return addresses;
|
||||
return addresses.length > 0 ? addresses.slice(0, 1) : [{ name: "", email: "" }];
|
||||
const rows = addresses.map(cloneMailboxAddress);
|
||||
if (column.allowMultiple) return rows;
|
||||
return rows.length > 0 ? rows.slice(0, 1) : [{ name: "", email: "" }];
|
||||
}
|
||||
|
||||
function formatMailboxAddress(address: MailboxAddress): string {
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const server = isRecord(value.server) ? value.server : {};
|
||||
const rawProfileId = server.mail_profile_id;
|
||||
const profileId = typeof rawProfileId === "string" ? rawProfileId.trim() : "";
|
||||
const profileId = textId(server.mail_profile_id);
|
||||
const smtpServerId = textId(server.smtp_server_id);
|
||||
const smtpCredentialId = textId(server.smtp_credential_id);
|
||||
const imapServerId = textId(server.imap_server_id);
|
||||
const imapCredentialId = textId(server.imap_credential_id);
|
||||
const reference: Record<string, string> = {};
|
||||
if (profileId) reference.mail_profile_id = profileId;
|
||||
if (profileId && smtpServerId) reference.smtp_server_id = smtpServerId;
|
||||
if (profileId && smtpServerId && smtpCredentialId) reference.smtp_credential_id = smtpCredentialId;
|
||||
if (profileId && imapServerId) reference.imap_server_id = imapServerId;
|
||||
if (profileId && imapServerId && imapCredentialId) reference.imap_credential_id = imapCredentialId;
|
||||
return {
|
||||
...value,
|
||||
server: profileId ? { mail_profile_id: profileId } : {}
|
||||
server: reference
|
||||
};
|
||||
}
|
||||
|
||||
function textId(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -1003,12 +1003,13 @@
|
||||
|
||||
.recipient-address-empty-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(150px, .7fr) minmax(220px, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recipient-address-empty {
|
||||
grid-column: 1 / 3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1082,6 +1083,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.recipient-address-empty {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.recipient-address-category-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
@@ -1218,8 +1223,8 @@
|
||||
|
||||
/* Shared message preview overlay --------------------------------------- */
|
||||
.message-preview-modal {
|
||||
width: min(920px, 100%);
|
||||
height: min(780px, calc(100dvh - 48px));
|
||||
width: min(1104px, calc(100vw - 48px));
|
||||
height: min(936px, calc(100dvh - 48px));
|
||||
max-height: calc(100dvh - 48px);
|
||||
}
|
||||
|
||||
@@ -1237,15 +1242,16 @@
|
||||
.message-preview-modal .modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-preview-content {
|
||||
display: contents;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-preview-modal .modal-footer {
|
||||
@@ -1257,10 +1263,24 @@
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.message-preview-modal .message-display-panel {
|
||||
flex: 1 1 auto;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-preview-modal .message-display-body-section {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-preview-modal .message-display-body,
|
||||
.message-preview-modal .message-display-html-frame {
|
||||
height: clamp(280px, 45vh, 520px);
|
||||
max-height: clamp(280px, 45vh, 520px);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.message-preview-meta {
|
||||
@@ -1272,6 +1292,17 @@
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.message-preview-modal .message-display-attachments {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-preview-modal .message-display-attachments-scroll {
|
||||
max-height: 116px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.attachment-chip-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1303,6 +1334,9 @@
|
||||
}
|
||||
|
||||
.message-preview-raw {
|
||||
flex: 0 0 auto;
|
||||
max-height: 132px;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--line-subtle);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user