Integrate campaign delivery with hierarchical mail profiles
This commit is contained in:
@@ -4,7 +4,15 @@ import copy
|
|||||||
from typing import Any
|
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_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"})
|
||||||
CAMPAIGN_OPT_IN_KEYS = frozenset(
|
CAMPAIGN_OPT_IN_KEYS = frozenset(
|
||||||
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
|
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
|
||||||
@@ -24,8 +32,8 @@ class CampaignMailProfileBoundaryError(ValueError):
|
|||||||
"""Raised when campaign JSON owns mail transport configuration.
|
"""Raised when campaign JSON owns mail transport configuration.
|
||||||
|
|
||||||
SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON
|
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
|
may select Mail-owned profile, server, and credential identifiers, but it
|
||||||
profile's transport configuration.
|
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
|
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, ...]:
|
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
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||||
if not isinstance(server, dict):
|
if not isinstance(server, dict):
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS]
|
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS]
|
||||||
if "mail_profile_id" in server:
|
for key in CAMPAIGN_MAIL_SERVER_KEYS:
|
||||||
profile_id = server["mail_profile_id"]
|
if key not in server:
|
||||||
if not isinstance(profile_id, str) or not profile_id.strip():
|
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")
|
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)
|
return tuple(violations)
|
||||||
|
|
||||||
|
|
||||||
@@ -240,9 +285,9 @@ def assert_campaign_uses_mail_profile_reference(
|
|||||||
if violations:
|
if violations:
|
||||||
fields = ", ".join(violations)
|
fields = ", ".join(violations)
|
||||||
raise CampaignMailProfileBoundaryError(
|
raise CampaignMailProfileBoundaryError(
|
||||||
"Campaign JSON may only reference a Mail-module profile through "
|
"Campaign JSON may only reference Mail-owned profiles, servers, and credentials; "
|
||||||
f"server.mail_profile_id; remove campaign-local SMTP/IMAP settings ({fields}), "
|
f"remove campaign-local SMTP/IMAP settings or invalid references ({fields}), "
|
||||||
"select an authorized Mail profile, and save a new campaign version."
|
"select authorized Mail resources, and save a new campaign version."
|
||||||
)
|
)
|
||||||
if require_profile and campaign_mail_profile_id(raw_json) is None:
|
if require_profile and campaign_mail_profile_id(raw_json) is None:
|
||||||
raise CampaignMailProfileBoundaryError(
|
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]:
|
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
|
||||||
"""Return the complete public/persisted Campaign-to-Mail contract."""
|
"""Return the complete public/persisted Campaign-to-Mail contract."""
|
||||||
|
|
||||||
profile_id = campaign_mail_profile_id(raw_json)
|
return {
|
||||||
return {"mail_profile_id": profile_id} if profile_id else {}
|
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):
|
class ServerConfig(StrictModel):
|
||||||
mail_profile_id: str | None = None
|
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)
|
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 (
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
assert_campaign_uses_mail_profile_reference,
|
assert_campaign_uses_mail_profile_reference,
|
||||||
campaign_mail_profile_id,
|
campaign_mail_profile_id,
|
||||||
|
campaign_mail_resource_ids,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
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.builder import build_campaign_messages
|
||||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
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.campaign.models import CampaignConfig, SendStatus
|
||||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
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)
|
materialized = copy.deepcopy(raw_json)
|
||||||
profile_id = campaign_mail_profile_id(raw_json)
|
profile_id = campaign_mail_profile_id(raw_json)
|
||||||
if profile_id:
|
if profile_id:
|
||||||
|
references = campaign_mail_resource_ids(raw_json)
|
||||||
summary = mail_integration().campaign_profile_delivery_summary(
|
summary = mail_integration().campaign_profile_delivery_summary(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -80,6 +82,10 @@ def load_campaign_config_from_json(
|
|||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
owner_user_id=owner_user_id,
|
owner_user_id=owner_user_id,
|
||||||
owner_group_id=owner_group_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"] = {
|
materialized.setdefault("server", {})["profile_capabilities"] = {
|
||||||
"smtp_available": bool(summary.get("smtp_available")),
|
"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 {})
|
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||||
if profile_id is None:
|
if profile_id is None:
|
||||||
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages")
|
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages")
|
||||||
revisions = profile_transport_revisions(session, version)
|
profile_summary = profile_delivery_summary(session, version)
|
||||||
if not revisions["smtp"]:
|
if not profile_summary.get("smtp_transport_revision"):
|
||||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
|
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
|
||||||
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
||||||
version,
|
version,
|
||||||
mail_profile_id=profile_id,
|
mail_profile_id=profile_id,
|
||||||
smtp_transport_revision=revisions["smtp"],
|
smtp_server_id=profile_summary.get("smtp_server_id"),
|
||||||
imap_transport_revision=revisions["imap"],
|
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,
|
delivery=managed_config.delivery,
|
||||||
jobs=[job for job, _message in job_build_pairs],
|
jobs=[job for job, _message in job_build_pairs],
|
||||||
build_summary=report_json,
|
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.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
||||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
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_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
||||||
from govoplan_core.security.time import utc_now
|
from govoplan_core.security.time import utc_now
|
||||||
from govoplan_campaign.backend.persistence.versions import (
|
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 = 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)
|
next_server.pop("profile_id", None)
|
||||||
raw_json["server"] = next_server
|
raw_json["server"] = next_server
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,27 @@
|
|||||||
"mail_profile_id": {
|
"mail_profile_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"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
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
|||||||
CampaignMailProfileBoundaryError,
|
CampaignMailProfileBoundaryError,
|
||||||
assert_campaign_uses_mail_profile_reference,
|
assert_campaign_uses_mail_profile_reference,
|
||||||
campaign_mail_profile_id,
|
campaign_mail_profile_id,
|
||||||
|
campaign_mail_resource_ids,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
||||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
||||||
|
|
||||||
SNAPSHOT_VERSION = "5"
|
SNAPSHOT_VERSION = "6"
|
||||||
|
|
||||||
|
|
||||||
class ExecutionSnapshotError(RuntimeError):
|
class ExecutionSnapshotError(RuntimeError):
|
||||||
@@ -41,6 +42,10 @@ class ExecutionSnapshot(BaseModel):
|
|||||||
campaign_version_id: str
|
campaign_version_id: str
|
||||||
campaign_json_sha256: str
|
campaign_json_sha256: str
|
||||||
mail_profile_id: 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
|
created_at: str
|
||||||
build_token: str | None = None
|
build_token: str | None = None
|
||||||
built_at: 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)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||||
|
references = campaign_mail_resource_ids(raw_json)
|
||||||
try:
|
try:
|
||||||
return mail.campaign_profile_delivery_summary(
|
return mail.campaign_profile_delivery_summary(
|
||||||
session,
|
session,
|
||||||
tenant_id=campaign.tenant_id,
|
tenant_id=campaign.tenant_id,
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
profile_id=profile_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:
|
except MailProfileError as exc:
|
||||||
raise ExecutionSnapshotError(str(exc)) from 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. "
|
"The campaign's Mail profile reference differs from the built execution snapshot. "
|
||||||
"Revalidate and rebuild the campaign before delivery."
|
"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:
|
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
|
||||||
@@ -163,6 +186,10 @@ def create_execution_snapshot(
|
|||||||
smtp_transport_revision: str,
|
smtp_transport_revision: str,
|
||||||
imap_transport_revision: str | None,
|
imap_transport_revision: str | None,
|
||||||
delivery: DeliveryConfig,
|
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] = (),
|
jobs: Iterable[CampaignJob] = (),
|
||||||
build_summary: dict[str, Any] | None = None,
|
build_summary: dict[str, Any] | None = None,
|
||||||
) -> tuple[dict[str, Any], str]:
|
) -> tuple[dict[str, Any], str]:
|
||||||
@@ -176,6 +203,10 @@ def create_execution_snapshot(
|
|||||||
campaign_version_id=version.id,
|
campaign_version_id=version.id,
|
||||||
campaign_json_sha256=_sha256(raw_json),
|
campaign_json_sha256=_sha256(raw_json),
|
||||||
mail_profile_id=mail_profile_id,
|
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,
|
build_token=str(summary.get("build_token") or "") or None,
|
||||||
built_at=str(summary.get("built_at") or "") or None,
|
built_at=str(summary.get("built_at") or "") or None,
|
||||||
job_count=len(job_list),
|
job_count=len(job_list),
|
||||||
@@ -316,14 +347,18 @@ def ensure_execution_snapshot(
|
|||||||
)
|
)
|
||||||
if not jobs:
|
if not jobs:
|
||||||
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
||||||
revisions = profile_transport_revisions(session, version)
|
summary = profile_delivery_summary(session, version)
|
||||||
if not revisions["smtp"]:
|
if not summary.get("smtp_transport_revision"):
|
||||||
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
|
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
|
||||||
payload, digest = create_execution_snapshot(
|
payload, digest = create_execution_snapshot(
|
||||||
version,
|
version,
|
||||||
mail_profile_id=profile_id,
|
mail_profile_id=profile_id,
|
||||||
smtp_transport_revision=revisions["smtp"],
|
smtp_server_id=summary.get("smtp_server_id"),
|
||||||
imap_transport_revision=revisions["imap"],
|
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,
|
delivery=config.delivery,
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
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,
|
envelope_recipients=context.envelope_recipients,
|
||||||
from_header=_from_header_from_job(job),
|
from_header=_from_header_from_job(job),
|
||||||
expected_smtp_transport_revision=context.snapshot.smtp_transport_revision or "",
|
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:
|
if result.accepted_count <= 0:
|
||||||
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
|
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,
|
folder=None if folder == "auto" else folder,
|
||||||
expected_smtp_transport_revision=snapshot.smtp_transport_revision or "",
|
expected_smtp_transport_revision=snapshot.smtp_transport_revision or "",
|
||||||
expected_imap_transport_revision=snapshot.imap_transport_revision,
|
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:
|
except (MailProfileError, ImapConfigurationError, ImapAppendError) as exc:
|
||||||
outcome_unknown = bool(getattr(exc, "outcome_unknown", False))
|
outcome_unknown = bool(getattr(exc, "outcome_unknown", False))
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
snapshot = SimpleNamespace(
|
snapshot = SimpleNamespace(
|
||||||
mail_profile_id="profile-1",
|
mail_profile_id="profile-1",
|
||||||
|
smtp_server_id="smtp-server-1",
|
||||||
|
smtp_credential_id="smtp-credential-1",
|
||||||
smtp_transport_revision="frozen",
|
smtp_transport_revision="frozen",
|
||||||
delivery=SimpleNamespace(
|
delivery=SimpleNamespace(
|
||||||
rate_limit=SimpleNamespace(messages_per_minute=60),
|
rate_limit=SimpleNamespace(messages_per_minute=60),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
ApiSettings,
|
ApiSettings,
|
||||||
MailConnectionTestResponse,
|
MailConnectionTestResponse,
|
||||||
|
MailCredentialEnvelope,
|
||||||
MailImapFolderListResponse,
|
MailImapFolderListResponse,
|
||||||
MailServerProfile,
|
MailServerProfile,
|
||||||
MockMailboxMessageResponse
|
MockMailboxMessageResponse
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { apiFetch, apiGetList, apiPost } from "./client";
|
import { apiFetch, apiGetList, apiPath, apiPost } from "./client";
|
||||||
|
|
||||||
const profileActionEndpoints = {
|
const profileActionEndpoints = {
|
||||||
smtp: "test-smtp",
|
smtp: "test-smtp",
|
||||||
@@ -16,11 +17,21 @@ const profileActionEndpoints = {
|
|||||||
function runProfileAction<TResponse>(
|
function runProfileAction<TResponse>(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
action: keyof typeof profileActionEndpoints
|
action: keyof typeof profileActionEndpoints,
|
||||||
|
serverId?: string | null,
|
||||||
|
credentialId?: string | null,
|
||||||
|
campaignId?: string | null
|
||||||
): Promise<TResponse> {
|
): Promise<TResponse> {
|
||||||
return apiPost<TResponse>(
|
return apiPost<TResponse>(
|
||||||
settings,
|
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> {
|
export function createCampaignMailCredential(
|
||||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
|
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> {
|
export async function testMailProfileSmtp(
|
||||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
|
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> {
|
export async function testMailProfileImap(
|
||||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
|
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> {
|
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||||
|
|||||||
@@ -2,21 +2,26 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FormField,
|
FormField,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
MailServerFolderLookupResultView,
|
MailServerFolderLookupResultView,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
|
PasswordField,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
usePlatformModuleInstalled,
|
usePlatformModuleInstalled,
|
||||||
usePlatformUiCapability,
|
usePlatformUiCapability,
|
||||||
|
type MailCredentialEnvelope,
|
||||||
type MailProfilesUiCapability,
|
type MailProfilesUiCapability,
|
||||||
type MailServerConnectionTestResult,
|
type MailServerConnectionTestResult,
|
||||||
|
type MailServerEndpoint,
|
||||||
type MailServerFolderLookupResult
|
type MailServerFolderLookupResult
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import {
|
import {
|
||||||
|
createCampaignMailCredential,
|
||||||
listMailProfileImapFolders,
|
listMailProfileImapFolders,
|
||||||
listMailServerProfiles,
|
listMailServerProfiles,
|
||||||
testMailProfileImap,
|
testMailProfileImap,
|
||||||
@@ -39,6 +44,14 @@ type MailSettingsPageProps = {
|
|||||||
view?: MailSettingsView;
|
view?: MailSettingsView;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CampaignCredentialDraft = {
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
useSmtp: boolean;
|
||||||
|
useImap: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
|
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
|
||||||
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
||||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
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 [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | 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 version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
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 server = asRecord(displayDraft.server);
|
||||||
const selectedProfileId = getText(server, "mail_profile_id");
|
const selectedProfileId = getText(server, "mail_profile_id");
|
||||||
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
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 delivery = asRecord(displayDraft.delivery);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||||
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
const selectedProfileHasImap = Boolean(selectedImapServer);
|
||||||
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
|
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
|
||||||
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
|
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) {
|
function selectMailProfile(profileId: string) {
|
||||||
if (!mailModuleInstalled || locked) return;
|
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);
|
setSmtpTestResult(null);
|
||||||
setImapTestResult(null);
|
setImapTestResult(null);
|
||||||
setFolderResult(null);
|
setFolderResult(null);
|
||||||
setProfileError("");
|
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") {
|
async function runProfileTest(protocol: "smtp" | "imap") {
|
||||||
if (!selectedProfileId || locked) return;
|
if (!selectedProfileId || locked) return;
|
||||||
setMailActionState(protocol);
|
setMailActionState(protocol);
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
try {
|
try {
|
||||||
if (protocol === "smtp") {
|
if (protocol === "smtp") {
|
||||||
setSmtpTestResult(await testMailProfileSmtp(settings, selectedProfileId));
|
setSmtpTestResult(await testMailProfileSmtp(
|
||||||
|
settings,
|
||||||
|
selectedProfileId,
|
||||||
|
selectedSmtpServer?.id,
|
||||||
|
selectedSmtpCredential?.id,
|
||||||
|
campaignId
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
setImapTestResult(await testMailProfileImap(settings, selectedProfileId));
|
setImapTestResult(await testMailProfileImap(
|
||||||
|
settings,
|
||||||
|
selectedProfileId,
|
||||||
|
selectedImapServer?.id,
|
||||||
|
selectedImapCredential?.id,
|
||||||
|
campaignId
|
||||||
|
));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const result = { ok: false, protocol, message: err instanceof Error ? err.message : String(err), details: {} };
|
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");
|
setMailActionState("folders");
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
try {
|
try {
|
||||||
setFolderResult(await listMailProfileImapFolders(settings, selectedProfileId));
|
setFolderResult(await listMailProfileImapFolders(
|
||||||
|
settings,
|
||||||
|
selectedProfileId,
|
||||||
|
selectedImapServer?.id,
|
||||||
|
selectedImapCredential?.id,
|
||||||
|
campaignId
|
||||||
|
));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -204,8 +368,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
|
|
||||||
{!isPolicyView && mailModuleInstalled && <Card
|
{!isPolicyView && mailModuleInstalled && <Card
|
||||||
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
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>}>
|
actions={<div className="button-row compact-actions">
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809</p>
|
<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">
|
<div className="form-grid compact responsive-form-grid">
|
||||||
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
<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>)}
|
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</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>
|
</div>
|
||||||
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
||||||
{selectedProfile && <div className="metric-grid inside">
|
{selectedProfile && <div className="metric-grid inside">
|
||||||
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
<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.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.smtp.efff9cca" value={selectedSmtpServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedSmtpServer ? "good" : "neutral"} />
|
||||||
<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.imap.271f9ef2" value={selectedImapServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedImapServer ? "good" : "neutral"} />
|
||||||
</div>}
|
</div>}
|
||||||
<div className="button-row compact-actions">
|
<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>
|
<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>
|
</div>
|
||||||
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
|
{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>}
|
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
||||||
</Card>}
|
</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>
|
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<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">
|
<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";
|
if (profile.scope_type === "group") return "i18n:govoplan-campaign.group.171a0606";
|
||||||
return "i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505";
|
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);
|
replaceInlineEntries(nextEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
|
function saveEntryAddresses(
|
||||||
updateEntry(index, (entry) => entryWithAddressList(entry, key, addresses));
|
index: number,
|
||||||
}
|
values: HeaderAddressValues,
|
||||||
|
merges: EntryAddressMergeValues
|
||||||
function updateEntryAddressGroups(index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) {
|
) {
|
||||||
updateEntry(index, (entry) => {
|
updateEntry(index, (entry) => {
|
||||||
let nextEntry = entry;
|
let nextEntry = entry;
|
||||||
for (const group of groups) {
|
for (const column of recipientAddressOverlayColumns) {
|
||||||
const currentAddresses = getEntryAddresses(nextEntry, group.key);
|
if (!(column.key in values)) continue;
|
||||||
nextEntry = entryWithAddressList(nextEntry, group.key, dedupeAddresses([...currentAddresses, ...group.addresses]));
|
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;
|
return nextEntry;
|
||||||
});
|
});
|
||||||
}
|
setRecipientAddressEditorIndex(null);
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEntryField(index: number, field: string, value: unknown) {
|
function updateEntryField(index: number, field: string, value: unknown) {
|
||||||
@@ -305,8 +301,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|||||||
setAddressSourceImportOpen(false);
|
setAddressSourceImportOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHeaderAddressList(key: AddressFieldKey, addresses: MailboxAddress[]) {
|
function saveHeaderAddresses(values: HeaderAddressValues) {
|
||||||
patch(["recipients", key], key === "from" ? addresses.slice(0, 1) : addresses);
|
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[]) {
|
async function copyHeaderAddresses(columns: EntryAddressColumn[]) {
|
||||||
@@ -508,9 +509,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|||||||
locked={locked}
|
locked={locked}
|
||||||
recipientsSection={recipientsSection}
|
recipientsSection={recipientsSection}
|
||||||
entryDefaults={entryDefaults}
|
entryDefaults={entryDefaults}
|
||||||
updateEntryAddressList={updateEntryAddressList}
|
onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
|
||||||
updateEntryAddressGroups={updateEntryAddressGroups}
|
|
||||||
updateEntryMerge={updateEntryMerge}
|
|
||||||
onClose={() => setRecipientAddressEditorIndex(null)} />
|
onClose={() => setRecipientAddressEditorIndex(null)} />
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -520,7 +519,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|||||||
columns={headerAddressEditor.columns}
|
columns={headerAddressEditor.columns}
|
||||||
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
|
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
onAddressesChange={updateHeaderAddressList}
|
onSave={saveHeaderAddresses}
|
||||||
onClose={() => setHeaderAddressEditor(null)} />
|
onClose={() => setHeaderAddressEditor(null)} />
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -534,13 +533,12 @@ type RecipientAddressEditorDialogProps = {
|
|||||||
locked: boolean;
|
locked: boolean;
|
||||||
recipientsSection: Record<string, unknown>;
|
recipientsSection: Record<string, unknown>;
|
||||||
entryDefaults: Record<string, unknown>;
|
entryDefaults: Record<string, unknown>;
|
||||||
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
onSave: (values: HeaderAddressValues, merges: EntryAddressMergeValues) => void;
|
||||||
updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void;
|
|
||||||
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
|
type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
|
||||||
|
type EntryAddressMergeValues = Partial<Record<NonNullable<EntryAddressColumn["mergeKey"]>, boolean>>;
|
||||||
|
|
||||||
type AddressHeaderControlProps = {
|
type AddressHeaderControlProps = {
|
||||||
columns: EntryAddressColumn[];
|
columns: EntryAddressColumn[];
|
||||||
@@ -596,26 +594,33 @@ type HeaderAddressEditorDialogProps = {
|
|||||||
columns: EntryAddressColumn[];
|
columns: EntryAddressColumn[];
|
||||||
values: HeaderAddressValues;
|
values: HeaderAddressValues;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
onAddressesChange: (key: AddressFieldKey, addresses: MailboxAddress[]) => void;
|
onSave: (values: HeaderAddressValues) => void;
|
||||||
onClose: () => 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 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 {
|
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
|
||||||
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key));
|
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key));
|
||||||
if (groups.length === 0) return false;
|
if (groups.length === 0) return false;
|
||||||
const nextValues: HeaderAddressValues = { ...values };
|
setDraftValues((current) => mergeAddressGroups(current, groups));
|
||||||
for (const group of groups) {
|
setValidationError("");
|
||||||
nextValues[group.key] = dedupeAddresses([...(nextValues[group.key] ?? []), ...group.addresses]);
|
|
||||||
}
|
|
||||||
for (const group of groups) {
|
|
||||||
onAddressesChange(group.key, nextValues[group.key] ?? []);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const prepared = prepareAddressValues(columns, draftValues, translateText);
|
||||||
|
if (prepared.error) {
|
||||||
|
setValidationError(prepared.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(prepared.values);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
@@ -623,17 +628,24 @@ function HeaderAddressEditorDialog({ title, columns, values, locked, onAddresses
|
|||||||
className="recipient-address-editor-modal"
|
className="recipient-address-editor-modal"
|
||||||
bodyClassName="recipient-address-editor-body"
|
bodyClassName="recipient-address-editor-body"
|
||||||
onClose={onClose}
|
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">
|
<div className="recipient-address-editor">
|
||||||
|
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
|
||||||
{columns.map((column) =>
|
{columns.map((column) =>
|
||||||
<RecipientAddressCategoryEditor
|
<RecipientAddressCategoryEditor
|
||||||
key={column.key}
|
key={column.key}
|
||||||
column={column}
|
column={column}
|
||||||
addresses={values[column.key] ?? []}
|
addresses={draftValues[column.key] ?? []}
|
||||||
merge={false}
|
merge={false}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
onAddressesChange={(addresses) => onAddressesChange(column.key, addresses)}
|
onAddressesChange={(addresses) => {
|
||||||
|
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
|
||||||
|
setValidationError("");
|
||||||
|
}}
|
||||||
onPasteAddresses={applyPastedAddresses} />
|
onPasteAddresses={applyPastedAddresses} />
|
||||||
|
|
||||||
)}
|
)}
|
||||||
@@ -647,21 +659,41 @@ function RecipientAddressEditorDialog({
|
|||||||
locked,
|
locked,
|
||||||
recipientsSection,
|
recipientsSection,
|
||||||
entryDefaults,
|
entryDefaults,
|
||||||
updateEntryAddressList,
|
onSave,
|
||||||
updateEntryAddressGroups,
|
|
||||||
updateEntryMerge,
|
|
||||||
onClose
|
onClose
|
||||||
}: RecipientAddressEditorDialogProps) {
|
}: RecipientAddressEditorDialogProps) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key));
|
const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key));
|
||||||
const availableKeys = new Set(availableColumns.map((column) => 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 {
|
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
|
||||||
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key));
|
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key));
|
||||||
if (groups.length === 0) return false;
|
if (groups.length === 0) return false;
|
||||||
updateEntryAddressGroups(index, groups);
|
setDraftValues((current) => mergeAddressGroups(current, groups));
|
||||||
|
setValidationError("");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const prepared = prepareAddressValues(availableColumns, draftValues, translateText);
|
||||||
|
if (prepared.error) {
|
||||||
|
setValidationError(prepared.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(prepared.values, draftMerges);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
@@ -669,9 +701,13 @@ function RecipientAddressEditorDialog({
|
|||||||
className="recipient-address-editor-modal"
|
className="recipient-address-editor-modal"
|
||||||
bodyClassName="recipient-address-editor-body"
|
bodyClassName="recipient-address-editor-body"
|
||||||
onClose={onClose}
|
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">
|
<div className="recipient-address-editor">
|
||||||
|
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
|
||||||
{availableColumns.length === 0 &&
|
{availableColumns.length === 0 &&
|
||||||
<p className="muted">No individual recipient address fields are enabled.</p>
|
<p className="muted">No individual recipient address fields are enabled.</p>
|
||||||
}
|
}
|
||||||
@@ -679,11 +715,16 @@ function RecipientAddressEditorDialog({
|
|||||||
<RecipientAddressCategoryEditor
|
<RecipientAddressCategoryEditor
|
||||||
key={column.key}
|
key={column.key}
|
||||||
column={column}
|
column={column}
|
||||||
addresses={getEntryAddresses(entry, column.key)}
|
addresses={draftValues[column.key] ?? []}
|
||||||
merge={column.mergeKey ? getEntryMerge(entry, entryDefaults, column.mergeKey) : false}
|
merge={column.mergeKey ? Boolean(draftMerges[column.mergeKey]) : false}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
onAddressesChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
|
onAddressesChange={(addresses) => {
|
||||||
onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined}
|
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
|
||||||
|
setValidationError("");
|
||||||
|
}}
|
||||||
|
onMergeChange={column.mergeKey ? (merge) => {
|
||||||
|
setDraftMerges((current) => ({ ...current, [column.mergeKey!]: merge }));
|
||||||
|
} : undefined}
|
||||||
onPasteAddresses={applyPastedAddresses} />
|
onPasteAddresses={applyPastedAddresses} />
|
||||||
|
|
||||||
)}
|
)}
|
||||||
@@ -716,7 +757,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
|||||||
|
|
||||||
function commitAddresses(nextAddresses: MailboxAddress[]) {
|
function commitAddresses(nextAddresses: MailboxAddress[]) {
|
||||||
setDraftAddresses(nextAddresses);
|
setDraftAddresses(nextAddresses);
|
||||||
onAddressesChange(nextAddresses.filter((address) => String(address.email ?? "").trim() || String(address.name ?? "").trim()));
|
onAddressesChange(nextAddresses);
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) {
|
function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) {
|
||||||
@@ -731,7 +772,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
|||||||
{ name: "", email: "" },
|
{ name: "", email: "" },
|
||||||
...draftAddresses.slice(insertIndex)];
|
...draftAddresses.slice(insertIndex)];
|
||||||
|
|
||||||
setDraftAddresses(nextAddresses);
|
commitAddresses(nextAddresses);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAddress(addressIndex: number) {
|
function removeAddress(addressIndex: number) {
|
||||||
@@ -2147,9 +2188,59 @@ function getAddressColumn(key: AddressFieldKey): EntryAddressColumn {
|
|||||||
return column;
|
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[] {
|
function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] {
|
||||||
if (column.allowMultiple) return addresses;
|
const rows = addresses.map(cloneMailboxAddress);
|
||||||
return addresses.length > 0 ? addresses.slice(0, 1) : [{ name: "", email: "" }];
|
if (column.allowMultiple) return rows;
|
||||||
|
return rows.length > 0 ? rows.slice(0, 1) : [{ name: "", email: "" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMailboxAddress(address: MailboxAddress): string {
|
function formatMailboxAddress(address: MailboxAddress): string {
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
|
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
const server = isRecord(value.server) ? value.server : {};
|
const server = isRecord(value.server) ? value.server : {};
|
||||||
const rawProfileId = server.mail_profile_id;
|
const profileId = textId(server.mail_profile_id);
|
||||||
const profileId = typeof rawProfileId === "string" ? rawProfileId.trim() : "";
|
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 {
|
return {
|
||||||
...value,
|
...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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1003,12 +1003,13 @@
|
|||||||
|
|
||||||
.recipient-address-empty-row {
|
.recipient-address-empty-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(150px, .7fr) minmax(220px, 1fr) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipient-address-empty {
|
.recipient-address-empty {
|
||||||
|
grid-column: 1 / 3;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,6 +1083,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipient-address-empty {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.recipient-address-category-header {
|
.recipient-address-category-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1218,8 +1223,8 @@
|
|||||||
|
|
||||||
/* Shared message preview overlay --------------------------------------- */
|
/* Shared message preview overlay --------------------------------------- */
|
||||||
.message-preview-modal {
|
.message-preview-modal {
|
||||||
width: min(920px, 100%);
|
width: min(1104px, calc(100vw - 48px));
|
||||||
height: min(780px, calc(100dvh - 48px));
|
height: min(936px, calc(100dvh - 48px));
|
||||||
max-height: calc(100dvh - 48px);
|
max-height: calc(100dvh - 48px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,15 +1242,16 @@
|
|||||||
.message-preview-modal .modal-body {
|
.message-preview-modal .modal-body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
overflow: hidden;
|
||||||
align-content: start;
|
|
||||||
gap: 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-preview-content {
|
.message-preview-content {
|
||||||
display: contents;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-preview-modal .modal-footer {
|
.message-preview-modal .modal-footer {
|
||||||
@@ -1257,10 +1263,24 @@
|
|||||||
margin-right: auto;
|
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-body,
|
||||||
.message-preview-modal .message-display-html-frame {
|
.message-preview-modal .message-display-html-frame {
|
||||||
height: clamp(280px, 45vh, 520px);
|
height: 100%;
|
||||||
max-height: clamp(280px, 45vh, 520px);
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-preview-meta {
|
.message-preview-meta {
|
||||||
@@ -1272,6 +1292,17 @@
|
|||||||
margin: 0 0 0.5rem;
|
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 {
|
.attachment-chip-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1303,6 +1334,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-preview-raw {
|
.message-preview-raw {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: 132px;
|
||||||
|
overflow: auto;
|
||||||
border-top: 1px solid var(--line-subtle);
|
border-top: 1px solid var(--line-subtle);
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user