Integrate campaign delivery with hierarchical mail profiles
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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