From 89ae14c032a4043496b3d9c6373ddc278fa65501 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 28 Jul 2026 19:33:12 +0200 Subject: [PATCH] Integrate campaign delivery with hierarchical mail profiles --- .../backend/campaign/mail_profile_boundary.py | 72 ++++- .../backend/campaign/models.py | 4 + .../backend/persistence/campaigns.py | 20 +- src/govoplan_campaign/backend/router.py | 8 +- .../backend/schema/campaign.schema.json | 22 +- .../backend/sending/execution.py | 45 ++- src/govoplan_campaign/backend/sending/jobs.py | 6 + tests/test_sending_jobs.py | 2 + webui/src/api/mail.ts | 71 ++++- .../features/campaigns/MailSettingsPage.tsx | 291 +++++++++++++++++- .../features/campaigns/RecipientDataPage.tsx | 191 +++++++++--- .../campaigns/utils/mailProfileReference.ts | 19 +- webui/src/styles/campaign-workspace.css | 56 +++- 13 files changed, 699 insertions(+), 108 deletions(-) diff --git a/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py b/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py index 95acacf..2067b49 100644 --- a/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py +++ b/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py @@ -4,7 +4,15 @@ import copy from typing import Any -CAMPAIGN_MAIL_SERVER_KEYS = frozenset({"mail_profile_id"}) +CAMPAIGN_MAIL_SERVER_KEYS = frozenset( + { + "mail_profile_id", + "smtp_server_id", + "smtp_credential_id", + "imap_server_id", + "imap_credential_id", + } +) CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"}) CAMPAIGN_OPT_IN_KEYS = frozenset( {"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"} @@ -24,8 +32,8 @@ class CampaignMailProfileBoundaryError(ValueError): """Raised when campaign JSON owns mail transport configuration. SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON - may select one Mail-owned profile, but it must never copy or override that - profile's transport configuration. + may select Mail-owned profile, server, and credential identifiers, but it + must never copy or override transport configuration. """ @@ -218,16 +226,53 @@ def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None: return normalized or None +def campaign_mail_resource_ids( + raw_json: dict[str, Any] | None, +) -> dict[str, str | None]: + server = raw_json.get("server") if isinstance(raw_json, dict) else None + if not isinstance(server, dict): + return { + "mail_profile_id": None, + "smtp_server_id": None, + "smtp_credential_id": None, + "imap_server_id": None, + "imap_credential_id": None, + } + return { + key: ( + value.strip() + if isinstance((value := server.get(key)), str) and value.strip() + else None + ) + for key in CAMPAIGN_MAIL_SERVER_KEYS + } + + def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]: server = raw_json.get("server") if isinstance(raw_json, dict) else None if not isinstance(server, dict): return () violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS] - if "mail_profile_id" in server: - profile_id = server["mail_profile_id"] - if not isinstance(profile_id, str) or not profile_id.strip(): - violations.append("/server/mail_profile_id") + for key in CAMPAIGN_MAIL_SERVER_KEYS: + if key not in server: + continue + value = server[key] + if not isinstance(value, str) or not value.strip(): + violations.append(f"/server/{key}") + references = campaign_mail_resource_ids(raw_json) + if references["mail_profile_id"] is None and any( + references[key] + for key in references + if key != "mail_profile_id" + ): + violations.append("/server/mail_profile_id") + for protocol in ("smtp", "imap"): + if ( + references[f"{protocol}_credential_id"] + and not references[f"{protocol}_server_id"] + ): + violations.append(f"/server/{protocol}_server_id") return tuple(violations) @@ -240,9 +285,9 @@ def assert_campaign_uses_mail_profile_reference( if violations: fields = ", ".join(violations) raise CampaignMailProfileBoundaryError( - "Campaign JSON may only reference a Mail-module profile through " - f"server.mail_profile_id; remove campaign-local SMTP/IMAP settings ({fields}), " - "select an authorized Mail profile, and save a new campaign version." + "Campaign JSON may only reference Mail-owned profiles, servers, and credentials; " + f"remove campaign-local SMTP/IMAP settings or invalid references ({fields}), " + "select authorized Mail resources, and save a new campaign version." ) if require_profile and campaign_mail_profile_id(raw_json) is None: raise CampaignMailProfileBoundaryError( @@ -254,5 +299,8 @@ def assert_campaign_uses_mail_profile_reference( def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]: """Return the complete public/persisted Campaign-to-Mail contract.""" - profile_id = campaign_mail_profile_id(raw_json) - return {"mail_profile_id": profile_id} if profile_id else {} + return { + key: value + for key, value in campaign_mail_resource_ids(raw_json).items() + if value + } diff --git a/src/govoplan_campaign/backend/campaign/models.py b/src/govoplan_campaign/backend/campaign/models.py index eda6d9d..5b1c8ae 100644 --- a/src/govoplan_campaign/backend/campaign/models.py +++ b/src/govoplan_campaign/backend/campaign/models.py @@ -114,6 +114,10 @@ class MailProfileCapabilities(StrictModel): class ServerConfig(StrictModel): mail_profile_id: str | None = None + smtp_server_id: str | None = None + smtp_credential_id: str | None = None + imap_server_id: str | None = None + imap_credential_id: str | None = None profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities) diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py index 608a144..20968ba 100644 --- a/src/govoplan_campaign/backend/persistence/campaigns.py +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -30,11 +30,12 @@ from govoplan_campaign.backend.campaign.loader import load_campaign_json, valida from govoplan_campaign.backend.campaign.mail_profile_boundary import ( assert_campaign_uses_mail_profile_reference, campaign_mail_profile_id, + campaign_mail_resource_ids, ) from govoplan_campaign.backend.campaign.validation import validate_campaign_config from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.models import MessageDraft -from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions +from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_delivery_summary from govoplan_campaign.backend.campaign.models import CampaignConfig, SendStatus from govoplan_campaign.backend.integrations import files_integration, mail_integration from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths @@ -73,6 +74,7 @@ def load_campaign_config_from_json( materialized = copy.deepcopy(raw_json) profile_id = campaign_mail_profile_id(raw_json) if profile_id: + references = campaign_mail_resource_ids(raw_json) summary = mail_integration().campaign_profile_delivery_summary( session, tenant_id=tenant_id, @@ -80,6 +82,10 @@ def load_campaign_config_from_json( profile_id=profile_id, owner_user_id=owner_user_id, owner_group_id=owner_group_id, + smtp_server_id=references["smtp_server_id"], + smtp_credential_id=references["smtp_credential_id"], + imap_server_id=references["imap_server_id"], + imap_credential_id=references["imap_credential_id"], ) materialized.setdefault("server", {})["profile_capabilities"] = { "smtp_available": bool(summary.get("smtp_available")), @@ -513,14 +519,18 @@ def build_campaign_version( profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {}) if profile_id is None: raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages") - revisions = profile_transport_revisions(session, version) - if not revisions["smtp"]: + profile_summary = profile_delivery_summary(session, version) + if not profile_summary.get("smtp_transport_revision"): raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision") execution_snapshot, execution_snapshot_hash = create_execution_snapshot( version, mail_profile_id=profile_id, - smtp_transport_revision=revisions["smtp"], - imap_transport_revision=revisions["imap"], + smtp_server_id=profile_summary.get("smtp_server_id"), + smtp_credential_id=profile_summary.get("smtp_credential_id"), + imap_server_id=profile_summary.get("imap_server_id"), + imap_credential_id=profile_summary.get("imap_credential_id"), + smtp_transport_revision=profile_summary["smtp_transport_revision"], + imap_transport_revision=profile_summary.get("imap_transport_revision"), delivery=managed_config.delivery, jobs=[job for job, _message in job_build_pairs], build_summary=report_json, diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 73b31ee..92f78a0 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -136,7 +136,10 @@ from govoplan_campaign.backend.integrations import ( ) from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths from govoplan_campaign.backend.campaign.loader import load_campaign_json -from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id +from govoplan_campaign.backend.campaign.mail_profile_boundary import ( + CAMPAIGN_MAIL_SERVER_KEYS, + campaign_mail_profile_id, +) from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments from govoplan_core.security.time import utc_now from govoplan_campaign.backend.persistence.versions import ( @@ -581,7 +584,8 @@ def _clear_current_version_mail_profile_for_owner_transfer(session: Session, cam ) next_server = dict(server) - next_server.pop("mail_profile_id", None) + for key in CAMPAIGN_MAIL_SERVER_KEYS: + next_server.pop(key, None) next_server.pop("profile_id", None) raw_json["server"] = next_server diff --git a/src/govoplan_campaign/backend/schema/campaign.schema.json b/src/govoplan_campaign/backend/schema/campaign.schema.json index aa83a2c..c251a06 100644 --- a/src/govoplan_campaign/backend/schema/campaign.schema.json +++ b/src/govoplan_campaign/backend/schema/campaign.schema.json @@ -92,7 +92,27 @@ "mail_profile_id": { "type": "string", "minLength": 1, - "description": "Stable reference to an authorized profile owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials." + "description": "Stable reference to an authorized server envelope owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials." + }, + "smtp_server_id": { + "type": "string", + "minLength": 1, + "description": "Optional explicit Mail-owned SMTP server selection." + }, + "smtp_credential_id": { + "type": "string", + "minLength": 1, + "description": "Optional explicit core credential envelope bound to the selected SMTP server." + }, + "imap_server_id": { + "type": "string", + "minLength": 1, + "description": "Optional explicit Mail-owned IMAP server selection." + }, + "imap_credential_id": { + "type": "string", + "minLength": 1, + "description": "Optional explicit core credential envelope bound to the selected IMAP server." } }, "additionalProperties": false diff --git a/src/govoplan_campaign/backend/sending/execution.py b/src/govoplan_campaign/backend/sending/execution.py index 6e605f6..7e1472c 100644 --- a/src/govoplan_campaign/backend/sending/execution.py +++ b/src/govoplan_campaign/backend/sending/execution.py @@ -14,11 +14,12 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import ( CampaignMailProfileBoundaryError, assert_campaign_uses_mail_profile_reference, campaign_mail_profile_id, + campaign_mail_resource_ids, ) from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths -SNAPSHOT_VERSION = "5" +SNAPSHOT_VERSION = "6" class ExecutionSnapshotError(RuntimeError): @@ -41,6 +42,10 @@ class ExecutionSnapshot(BaseModel): campaign_version_id: str campaign_json_sha256: str mail_profile_id: str + smtp_server_id: str | None = None + smtp_credential_id: str | None = None + imap_server_id: str | None = None + imap_credential_id: str | None = None created_at: str build_token: str | None = None built_at: str | None = None @@ -75,12 +80,17 @@ def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict campaign = session.get(Campaign, version.campaign_id) if campaign is None: raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution") + references = campaign_mail_resource_ids(raw_json) try: return mail.campaign_profile_delivery_summary( session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, + smtp_server_id=references["smtp_server_id"], + smtp_credential_id=references["smtp_credential_id"], + imap_server_id=references["imap_server_id"], + imap_credential_id=references["imap_credential_id"], ) except MailProfileError as exc: raise ExecutionSnapshotError(str(exc)) from exc @@ -102,6 +112,19 @@ def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: "The campaign's Mail profile reference differs from the built execution snapshot. " "Revalidate and rebuild the campaign before delivery." ) + references = campaign_mail_resource_ids(raw_json) + for key in ( + "smtp_server_id", + "smtp_credential_id", + "imap_server_id", + "imap_credential_id", + ): + configured = references[key] + if configured and configured != getattr(snapshot, key): + raise ExecutionSnapshotError( + "The campaign's Mail server or credential selection differs from the built " + "execution snapshot. Revalidate and rebuild before delivery." + ) def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None: @@ -163,6 +186,10 @@ def create_execution_snapshot( smtp_transport_revision: str, imap_transport_revision: str | None, delivery: DeliveryConfig, + smtp_server_id: str | None = None, + smtp_credential_id: str | None = None, + imap_server_id: str | None = None, + imap_credential_id: str | None = None, jobs: Iterable[CampaignJob] = (), build_summary: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], str]: @@ -176,6 +203,10 @@ def create_execution_snapshot( campaign_version_id=version.id, campaign_json_sha256=_sha256(raw_json), mail_profile_id=mail_profile_id, + smtp_server_id=smtp_server_id, + smtp_credential_id=smtp_credential_id, + imap_server_id=imap_server_id, + imap_credential_id=imap_credential_id, build_token=str(summary.get("build_token") or "") or None, built_at=str(summary.get("built_at") or "") or None, job_count=len(job_list), @@ -316,14 +347,18 @@ def ensure_execution_snapshot( ) if not jobs: raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery") - revisions = profile_transport_revisions(session, version) - if not revisions["smtp"]: + summary = profile_delivery_summary(session, version) + if not summary.get("smtp_transport_revision"): raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision") payload, digest = create_execution_snapshot( version, mail_profile_id=profile_id, - smtp_transport_revision=revisions["smtp"], - imap_transport_revision=revisions["imap"], + smtp_server_id=summary.get("smtp_server_id"), + smtp_credential_id=summary.get("smtp_credential_id"), + imap_server_id=summary.get("imap_server_id"), + imap_credential_id=summary.get("imap_credential_id"), + smtp_transport_revision=summary["smtp_transport_revision"], + imap_transport_revision=summary.get("imap_transport_revision"), delivery=config.delivery, jobs=jobs, build_summary=version.build_summary if isinstance(version.build_summary, dict) else {}, diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py index 20e4f8b..ccfe5a7 100644 --- a/src/govoplan_campaign/backend/sending/jobs.py +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -1904,6 +1904,8 @@ def _send_claimed_campaign_job( envelope_recipients=context.envelope_recipients, from_header=_from_header_from_job(job), expected_smtp_transport_revision=context.snapshot.smtp_transport_revision or "", + smtp_server_id=context.snapshot.smtp_server_id, + smtp_credential_id=context.snapshot.smtp_credential_id, ) if result.accepted_count <= 0: raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False) @@ -2412,6 +2414,10 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) folder=None if folder == "auto" else folder, expected_smtp_transport_revision=snapshot.smtp_transport_revision or "", expected_imap_transport_revision=snapshot.imap_transport_revision, + smtp_server_id=snapshot.smtp_server_id, + smtp_credential_id=snapshot.smtp_credential_id, + imap_server_id=snapshot.imap_server_id, + imap_credential_id=snapshot.imap_credential_id, ) except (MailProfileError, ImapConfigurationError, ImapAppendError) as exc: outcome_unknown = bool(getattr(exc, "outcome_unknown", False)) diff --git a/tests/test_sending_jobs.py b/tests/test_sending_jobs.py index a51a8b0..aaf9f80 100644 --- a/tests/test_sending_jobs.py +++ b/tests/test_sending_jobs.py @@ -141,6 +141,8 @@ class CampaignQueueSelectionTests(unittest.TestCase): ) snapshot = SimpleNamespace( mail_profile_id="profile-1", + smtp_server_id="smtp-server-1", + smtp_credential_id="smtp-credential-1", smtp_transport_revision="frozen", delivery=SimpleNamespace( rate_limit=SimpleNamespace(messages_per_minute=60), diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 2d4277d..e5be6dd 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -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( settings: ApiSettings, profileId: string, - action: keyof typeof profileActionEndpoints + action: keyof typeof profileActionEndpoints, + serverId?: string | null, + credentialId?: string | null, + campaignId?: string | null ): Promise { return apiPost( 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 { - return runProfileAction(settings, profileId, "smtp"); +export function createCampaignMailCredential( + settings: ApiSettings, + profileId: string, + campaignId: string, + payload: { + name: string; + username: string; + password: string; + server_ids: string[]; + } +): Promise { + return apiFetch( + 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 { - return runProfileAction(settings, profileId, "imap"); +export async function testMailProfileSmtp( + settings: ApiSettings, + profileId: string, + serverId?: string | null, + credentialId?: string | null, + campaignId?: string | null +): Promise { + return runProfileAction(settings, profileId, "smtp", serverId, credentialId, campaignId); } -export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise { - return runProfileAction(settings, profileId, "folders"); +export async function testMailProfileImap( + settings: ApiSettings, + profileId: string, + serverId?: string | null, + credentialId?: string | null, + campaignId?: string | null +): Promise { + return runProfileAction(settings, profileId, "imap", serverId, credentialId, campaignId); +} + +export async function listMailProfileImapFolders( + settings: ApiSettings, + profileId: string, + serverId?: string | null, + credentialId?: string | null, + campaignId?: string | null +): Promise { + return runProfileAction(settings, profileId, "folders", serverId, credentialId, campaignId); } export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise { diff --git a/webui/src/features/campaigns/MailSettingsPage.tsx b/webui/src/features/campaigns/MailSettingsPage.tsx index a2aaa49..25d1951 100644 --- a/webui/src/features/campaigns/MailSettingsPage.tsx +++ b/webui/src/features/campaigns/MailSettingsPage.tsx @@ -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("mail.profiles"); @@ -53,6 +66,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); const [folderResult, setFolderResult] = useState(null); + const [credentialDialogOpen, setCredentialDialogOpen] = useState(false); + const [credentialSaving, setCredentialSaving] = useState(false); + const [credentialDraft, setCredentialDraft] = useState({ + 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 && void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}}> -

i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809

+ actions={
+ + +
}> +

The campaign stores stable references to the envelope, selected servers, and credentials.

+ + + + + + + + + + + +
{selectedProfileUnavailable && i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26} {selectedProfile &&
- - + +
}
- +
{smtpTestResult && {smtpTestResult.message}} @@ -230,6 +425,57 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting {profileError && {profileError}}
} + setCredentialDialogOpen(false)} + closeDisabled={credentialSaving} + className="admin-dialog admin-dialog-wide adaptive-config-dialog" + footerClassName="button-row compact-actions" + footer={ + <> + + + + } + > +
+
+
+

Campaign login

+

This credential belongs to this campaign and can use only the selected mail servers.

+
+
+ + setCredentialDraft({ ...credentialDraft, name: event.target.value })} autoFocus /> + + + setCredentialDraft({ ...credentialDraft, username: event.target.value })} /> + + + setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} autoComplete="new-password" /> + +
+
+ {selectedSmtpServer && setCredentialDraft({ ...credentialDraft, useSmtp })} label={`Use for SMTP: ${selectedSmtpServer.name}`} />} + {selectedImapServer && setCredentialDraft({ ...credentialDraft, useImap })} label={`Use for IMAP: ${selectedImapServer.name}`} />} +
+
+
+
+ {!isPolicyView &&
@@ -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 { + const value: Record = { 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; +} diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx index 5e32e38..c930ba6 100644 --- a/webui/src/features/campaigns/RecipientDataPage.tsx +++ b/webui/src/features/campaigns/RecipientDataPage.tsx @@ -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, 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; entryDefaults: Record; - updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void; - updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void; - updateEntryMerge: (index: number, mergeKey: NonNullable, checked: boolean) => void; + onSave: (values: HeaderAddressValues, merges: EntryAddressMergeValues) => void; onClose: () => void; }; type HeaderAddressValues = Partial>; +type EntryAddressMergeValues = Partial, 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(() => 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 ( i18n:govoplan-campaign.close.bbfa773e}> + footer={<> + + + }>
+ {validationError && {validationError}} {columns.map((column) => 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(() => + Object.fromEntries(availableColumns.map((column) => [column.key, getEntryAddresses(entry, column.key).map(cloneMailboxAddress)])) + ); + const [draftMerges, setDraftMerges] = useState(() => + 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 ( i18n:govoplan-campaign.close.bbfa773e}> + footer={<> + + + }>
+ {validationError && {validationError}} {availableColumns.length === 0 &&

No individual recipient address fields are enabled.

} @@ -679,11 +715,16 @@ function RecipientAddressEditorDialog({ 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) { @@ -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 { diff --git a/webui/src/features/campaigns/utils/mailProfileReference.ts b/webui/src/features/campaigns/utils/mailProfileReference.ts index 3ae198c..949164d 100644 --- a/webui/src/features/campaigns/utils/mailProfileReference.ts +++ b/webui/src/features/campaigns/utils/mailProfileReference.ts @@ -1,13 +1,26 @@ export function campaignMailProfileReferenceOnly(value: Record): Record { 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 = {}; + 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 { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } diff --git a/webui/src/styles/campaign-workspace.css b/webui/src/styles/campaign-workspace.css index c8c8906..e562cf3 100644 --- a/webui/src/styles/campaign-workspace.css +++ b/webui/src/styles/campaign-workspace.css @@ -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; }