Compare commits
3 Commits
57fe6c6006
...
fa9119bea7
| Author | SHA1 | Date | |
|---|---|---|---|
| fa9119bea7 | |||
| 70ca772138 | |||
| 2eae5c4df6 |
@@ -84,7 +84,15 @@
|
|||||||
"provides_interfaces": [
|
"provides_interfaces": [
|
||||||
{
|
{
|
||||||
"name": "mail.campaign_delivery",
|
"name": "mail.campaign_delivery",
|
||||||
"version": "1.4.0"
|
"version": "0.2.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requires_interfaces": [
|
||||||
|
{
|
||||||
|
"name": "campaigns.access",
|
||||||
|
"version_min": "0.1.0",
|
||||||
|
"version_max_exclusive": "0.2.0",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
|
|||||||
@@ -100,6 +100,37 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
||||||
|
|
||||||
|
def _create_test_mail_profile(
|
||||||
|
self,
|
||||||
|
headers: dict[str, str],
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
include_imap: bool = False,
|
||||||
|
) -> str:
|
||||||
|
credentials = {
|
||||||
|
"smtp": {"username": "sender@example.org", "password": "test-secret"},
|
||||||
|
}
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"name": name,
|
||||||
|
"smtp": {
|
||||||
|
"host": "mock.smtp",
|
||||||
|
"port": 2525,
|
||||||
|
"security": "starttls",
|
||||||
|
},
|
||||||
|
"credentials": credentials,
|
||||||
|
}
|
||||||
|
if include_imap:
|
||||||
|
payload["imap"] = {
|
||||||
|
"host": "mock.imap",
|
||||||
|
"port": 993,
|
||||||
|
"security": "tls",
|
||||||
|
"sent_folder": "Sent",
|
||||||
|
}
|
||||||
|
credentials["imap"] = {"username": "sender@example.org", "password": "test-secret"}
|
||||||
|
response = self.client.post("/api/v1/mail/profiles", headers=headers, json=payload)
|
||||||
|
self.assertEqual(response.status_code, 201, response.text)
|
||||||
|
return str(response.json()["id"])
|
||||||
|
|
||||||
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
|
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
payload = {
|
payload = {
|
||||||
@@ -224,6 +255,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
external_id: str,
|
external_id: str,
|
||||||
recipient_count: int = 1,
|
recipient_count: int = 1,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name=f"{external_id} delivery")
|
||||||
entries = [
|
entries = [
|
||||||
{
|
{
|
||||||
"id": f"recipient-{index + 1}",
|
"id": f"recipient-{index + 1}",
|
||||||
@@ -237,15 +269,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"campaign": {"id": external_id, "name": external_id, "mode": "test"},
|
"campaign": {"id": external_id, "name": external_id, "mode": "test"},
|
||||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "mock.smtp",
|
|
||||||
"port": 2525,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -740,10 +764,40 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
assert version is not None
|
assert version is not None
|
||||||
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
|
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
|
||||||
snapshot = version.execution_snapshot or {}
|
snapshot = version.execution_snapshot or {}
|
||||||
self.assertIsNone(snapshot.get("smtp", {}).get("password"))
|
self.assertEqual(snapshot["snapshot_version"], "5")
|
||||||
self.assertEqual(snapshot.get("smtp", {}).get("host"), "mock.smtp")
|
self.assertEqual(snapshot["mail_profile_id"], profile_id)
|
||||||
|
self.assertNotIn("smtp", snapshot)
|
||||||
|
self.assertNotIn("imap", snapshot)
|
||||||
|
self.assertTrue(snapshot["smtp_transport_revision"])
|
||||||
|
self.assertTrue(snapshot["imap_transport_revision"])
|
||||||
|
|
||||||
def test_mail_profile_can_inherit_server_without_credentials(self) -> None:
|
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
|
||||||
|
self.assertEqual(deleted.status_code, 200, deleted.text)
|
||||||
|
self.assertFalse(deleted.json()["is_active"])
|
||||||
|
self.assertFalse(deleted.json()["smtp_password_configured"])
|
||||||
|
self.assertFalse(deleted.json()["imap_password_configured"])
|
||||||
|
|
||||||
|
from govoplan_audit.backend.db.models import AuditLog
|
||||||
|
|
||||||
|
with SessionLocal() as session:
|
||||||
|
profile = session.get(MailServerProfile, profile_id)
|
||||||
|
self.assertIsNotNone(profile)
|
||||||
|
assert profile is not None
|
||||||
|
self.assertIsNone(profile.smtp_password_encrypted)
|
||||||
|
self.assertIsNone(profile.imap_password_encrypted)
|
||||||
|
audit = (
|
||||||
|
session.query(AuditLog)
|
||||||
|
.filter(
|
||||||
|
AuditLog.action == "mail.profile_credentials_deleted",
|
||||||
|
AuditLog.object_id == profile_id,
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
self.assertEqual(audit.details["deleted_protocols"], ["smtp", "imap"])
|
||||||
|
self.assertEqual(audit.details["deletion_reason"], "profile_deactivated")
|
||||||
|
self.assertNotIn("secret", repr(audit.details))
|
||||||
|
|
||||||
|
def test_campaign_runtime_does_not_receive_mail_owned_profile_credentials(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
created_profile = self.client.post(
|
created_profile = self.client.post(
|
||||||
"/api/v1/mail/profiles",
|
"/api/v1/mail/profiles",
|
||||||
@@ -775,15 +829,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"},
|
"campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"},
|
||||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": profile_id},
|
||||||
"mail_profile_id": profile_id,
|
|
||||||
"inherit_smtp_credentials": False,
|
|
||||||
"inherit_imap_credentials": False,
|
|
||||||
"credentials": {
|
|
||||||
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
|
||||||
"imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||||
@@ -810,31 +856,43 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(built.status_code, 200, built.text)
|
self.assertEqual(built.status_code, 200, built.text)
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import CampaignVersion
|
from govoplan_campaign.backend.db.models import CampaignVersion
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, runtime_imap_config, runtime_smtp_config
|
from govoplan_campaign.backend.integrations import mail_integration
|
||||||
|
from govoplan_mail.backend.db.models import MailServerProfile
|
||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
version = session.get(CampaignVersion, version_id)
|
version = session.get(CampaignVersion, version_id)
|
||||||
self.assertIsNotNone(version)
|
self.assertIsNotNone(version)
|
||||||
assert version is not None
|
assert version is not None
|
||||||
snapshot_payload = version.execution_snapshot or {}
|
snapshot_payload = version.execution_snapshot or {}
|
||||||
self.assertEqual(snapshot_payload.get("smtp", {}).get("host"), "mock.smtp")
|
self.assertEqual(snapshot_payload["snapshot_version"], "5")
|
||||||
self.assertEqual(snapshot_payload.get("smtp", {}).get("username"), "campaign-smtp@example.org")
|
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
|
||||||
self.assertIsNone(snapshot_payload.get("smtp", {}).get("password"))
|
self.assertNotIn("smtp", snapshot_payload)
|
||||||
self.assertEqual(snapshot_payload.get("imap", {}).get("host"), "mock.imap")
|
self.assertNotIn("imap", snapshot_payload)
|
||||||
self.assertEqual(snapshot_payload.get("imap", {}).get("username"), "campaign-imap@example.org")
|
self.assertTrue(snapshot_payload["smtp_transport_revision"])
|
||||||
self.assertIsNone(snapshot_payload.get("imap", {}).get("password"))
|
self.assertTrue(snapshot_payload["imap_transport_revision"])
|
||||||
|
|
||||||
snapshot = ExecutionSnapshot.model_validate(snapshot_payload)
|
profile = session.get(MailServerProfile, profile_id)
|
||||||
smtp_runtime = runtime_smtp_config(session, version, snapshot)
|
self.assertIsNotNone(profile)
|
||||||
imap_runtime = runtime_imap_config(session, version, snapshot)
|
assert profile is not None
|
||||||
self.assertEqual(smtp_runtime.host, "mock.smtp")
|
integration = mail_integration()
|
||||||
self.assertEqual(smtp_runtime.username, "campaign-smtp@example.org")
|
summary = integration.campaign_profile_delivery_summary(
|
||||||
self.assertEqual(smtp_runtime.password, "campaign-smtp-secret")
|
session,
|
||||||
self.assertIsNotNone(imap_runtime)
|
tenant_id=profile.tenant_id,
|
||||||
assert imap_runtime is not None
|
campaign_id=version.campaign_id,
|
||||||
self.assertEqual(imap_runtime.host, "mock.imap")
|
profile_id=profile_id,
|
||||||
self.assertEqual(imap_runtime.username, "campaign-imap@example.org")
|
)
|
||||||
self.assertEqual(imap_runtime.password, "campaign-imap-secret")
|
self.assertTrue(summary["smtp_available"])
|
||||||
|
self.assertTrue(summary["imap_available"])
|
||||||
|
self.assertNotIn("mock.smtp", repr(summary))
|
||||||
|
self.assertNotIn("profile-smtp", repr(summary))
|
||||||
|
self.assertNotIn("secret", repr(summary))
|
||||||
|
for name in (
|
||||||
|
"smtp_config_from_profile",
|
||||||
|
"imap_config_from_profile",
|
||||||
|
"send_email_bytes",
|
||||||
|
"send_email_message",
|
||||||
|
):
|
||||||
|
self.assertFalse(hasattr(integration, name), name)
|
||||||
|
|
||||||
def test_health_schema_and_dev_mailbox_gates(self) -> None:
|
def test_health_schema_and_dev_mailbox_gates(self) -> None:
|
||||||
public_health = self.client.get("/health")
|
public_health = self.client.get("/health")
|
||||||
@@ -2885,7 +2943,10 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertFalse(delta_payload["full"])
|
self.assertFalse(delta_payload["full"])
|
||||||
self.assertEqual([item["id"] for item in delta_payload["jobs"]], [job_id])
|
self.assertEqual([item["id"] for item in delta_payload["jobs"]], [job_id])
|
||||||
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
|
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
|
||||||
self.assertEqual(delta_payload["jobs"][0]["last_error"], "SMTP outcome needs reconciliation")
|
self.assertEqual(
|
||||||
|
delta_payload["jobs"][0]["last_error"],
|
||||||
|
"SMTP delivery outcome requires operator reconciliation.",
|
||||||
|
)
|
||||||
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
|
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
|
||||||
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
|
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
|
||||||
|
|
||||||
@@ -3095,28 +3156,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_campaign_create_validate_build_and_mock_send(self) -> None:
|
def test_campaign_create_validate_build_and_mock_send(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="API smoke delivery", include_imap=True)
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"},
|
"campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"},
|
||||||
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "smtp.example.invalid",
|
|
||||||
"port": 587,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
},
|
|
||||||
"imap": {
|
|
||||||
"enabled": True,
|
|
||||||
"host": "imap.example.invalid",
|
|
||||||
"port": 993,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "tls",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -3188,20 +3234,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None:
|
def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Managed attachments delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"},
|
"campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"},
|
||||||
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "smtp.example.invalid",
|
|
||||||
"port": 587,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -3401,20 +3440,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
|
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Unlinked attachments delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
|
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
|
||||||
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "smtp.example.invalid",
|
|
||||||
"port": 587,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -3522,20 +3554,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
|
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Frozen attachment delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "managed-send-freeze", "name": "Managed send freeze", "mode": "test"},
|
"campaign": {"id": "managed-send-freeze", "name": "Managed send freeze", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "mock.smtp",
|
|
||||||
"port": 2525,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -3689,11 +3714,12 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
|
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Bulk review delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
|
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
|
||||||
"fields": [], "global_values": {},
|
"fields": [], "global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "Warning test", "text": "Body"},
|
"template": {"subject": "Warning test", "text": "Body"},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
@@ -3741,12 +3767,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
|
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Inactive recipients delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
|
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "Hello", "text": "Active recipient only"},
|
"template": {"subject": "Hello", "text": "Active recipient only"},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
@@ -3801,12 +3828,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_recipient_address_fields_and_merge_modes(self) -> None:
|
def test_recipient_address_fields_and_merge_modes(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Recipient merge delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"},
|
"campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": [{"email": "global-from@example.org", "type": "to"}],
|
"from": [{"email": "global-from@example.org", "type": "to"}],
|
||||||
"allow_individual_from": True,
|
"allow_individual_from": True,
|
||||||
@@ -3867,12 +3895,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_multiple_from_addresses_are_rejected(self) -> None:
|
def test_multiple_from_addresses_are_rejected(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Multiple sender validation delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"},
|
"campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": [
|
"from": [
|
||||||
{"email": "first@example.org", "type": "to"},
|
{"email": "first@example.org", "type": "to"},
|
||||||
@@ -3892,12 +3921,13 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_duplicate_zip_archive_filenames_block_validation(self) -> None:
|
def test_duplicate_zip_archive_filenames_block_validation(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Duplicate ZIP validation delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"},
|
"campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
"template": {"subject": "ZIP validation", "text": "Body"},
|
"template": {"subject": "ZIP validation", "text": "Body"},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
@@ -3937,6 +3967,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None:
|
def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None:
|
||||||
headers, login = self._login()
|
headers, login = self._login()
|
||||||
user_id = login["user"]["id"]
|
user_id = login["user"]["id"]
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="ZIP attachment delivery")
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"},
|
"campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"},
|
||||||
@@ -3946,7 +3977,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
{"name": "global_zip_password", "type": "password", "required": True},
|
{"name": "global_zip_password", "type": "password", "required": True},
|
||||||
],
|
],
|
||||||
"global_values": {"global_zip_password": "campaign-secret"},
|
"global_values": {"global_zip_password": "campaign-secret"},
|
||||||
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -4133,14 +4164,15 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_execution_snapshot_freezes_delivery_configuration_and_job_manifest(self) -> None:
|
def test_execution_snapshot_detects_profile_drift_and_freezes_job_manifest(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
campaign_id, version_id = self._create_built_delivery_campaign(
|
campaign_id, version_id = self._create_built_delivery_campaign(
|
||||||
headers,
|
headers,
|
||||||
external_id="snapshot-freeze",
|
external_id="snapshot-freeze",
|
||||||
)
|
)
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion
|
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
|
||||||
|
from govoplan_mail.backend.db.models import MailServerProfile
|
||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
version = session.get(CampaignVersion, version_id)
|
version = session.get(CampaignVersion, version_id)
|
||||||
@@ -4148,23 +4180,24 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
assert version is not None
|
assert version is not None
|
||||||
snapshot = version.execution_snapshot
|
snapshot = version.execution_snapshot
|
||||||
self.assertIsInstance(snapshot, dict)
|
self.assertIsInstance(snapshot, dict)
|
||||||
self.assertEqual(snapshot["snapshot_version"], "3")
|
self.assertEqual(snapshot["snapshot_version"], "5")
|
||||||
self.assertEqual(snapshot["job_count"], 1)
|
self.assertEqual(snapshot["job_count"], 1)
|
||||||
self.assertEqual(snapshot["queueable_job_count"], 1)
|
self.assertEqual(snapshot["queueable_job_count"], 1)
|
||||||
self.assertTrue(snapshot["job_manifest_sha256"])
|
self.assertTrue(snapshot["job_manifest_sha256"])
|
||||||
self.assertTrue(snapshot["smtp_config_fingerprint"])
|
self.assertTrue(snapshot["smtp_transport_revision"])
|
||||||
self.assertIsNone(snapshot["smtp"].get("password"))
|
self.assertNotIn("smtp", snapshot)
|
||||||
|
self.assertNotIn("imap", snapshot)
|
||||||
self.assertTrue(version.execution_snapshot_hash)
|
self.assertTrue(version.execution_snapshot_hash)
|
||||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||||
self.assertTrue(job.eml_sha256)
|
self.assertTrue(job.eml_sha256)
|
||||||
self.assertTrue(job.message_id_header)
|
self.assertTrue(job.message_id_header)
|
||||||
|
|
||||||
# Simulate accidental/config-drift mutation after the build. Sending
|
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
|
||||||
# must still use the immutable mock SMTP snapshot, not raw_json.
|
self.assertIsNotNone(profile)
|
||||||
mutated = json.loads(json.dumps(version.raw_json))
|
assert profile is not None
|
||||||
mutated["server"]["smtp"]["host"] = "smtp.example.invalid"
|
profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"}
|
||||||
version.raw_json = mutated
|
profile.smtp_transport_revision = "manually-rotated-transport-revision"
|
||||||
session.add(version)
|
session.add(profile)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
sent = self.client.post(
|
sent = self.client.post(
|
||||||
@@ -4179,12 +4212,17 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(sent.status_code, 200, sent.text)
|
self.assertEqual(sent.status_code, 200, sent.text)
|
||||||
self.assertEqual(sent.json()["result"]["sent_count"], 1, sent.text)
|
self.assertEqual(sent.json()["result"]["sent_count"], 0)
|
||||||
self.assertEqual(sent.json()["result"]["outcome_unknown_count"], 0, sent.text)
|
self.assertEqual(sent.json()["result"]["failed_count"], 1)
|
||||||
|
self.assertIn("changed after this campaign was built", sent.json()["result"]["results"][0]["message"])
|
||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||||
self.assertEqual(job.send_status, "smtp_accepted")
|
self.assertEqual(job.send_status, "failed_permanent")
|
||||||
|
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
|
||||||
|
self.assertIn("changed after this campaign was built", attempt.error_message)
|
||||||
|
self.assertNotIn("smtp.example.invalid", attempt.error_message)
|
||||||
|
self.assertIsNone(attempt.smtp_response)
|
||||||
|
|
||||||
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
@@ -4256,21 +4294,14 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
|
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
from govoplan_mail.backend.dev.mock_mailbox import set_failures
|
from govoplan_mail.backend.dev.mock_mailbox import set_failures
|
||||||
|
mail_profile_id = self._create_test_mail_profile(headers, name="Partial refusal delivery")
|
||||||
|
|
||||||
campaign_json = {
|
campaign_json = {
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"},
|
"campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"smtp": {
|
|
||||||
"host": "mock.smtp",
|
|
||||||
"port": 2525,
|
|
||||||
"username": "sender@example.org",
|
|
||||||
"password": "test-secret",
|
|
||||||
"security": "starttls",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": {
|
"recipients": {
|
||||||
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
||||||
"allow_individual_to": True,
|
"allow_individual_to": True,
|
||||||
@@ -5761,6 +5792,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_campaign_acl_separates_capability_from_object_access(self) -> None:
|
def test_campaign_acl_separates_capability_from_object_access(self) -> None:
|
||||||
owner_headers, _ = self._login()
|
owner_headers, _ = self._login()
|
||||||
|
mail_profile_id = self._create_test_mail_profile(owner_headers, name="ACL campaign delivery")
|
||||||
access_role = self._create_role(
|
access_role = self._create_role(
|
||||||
owner_headers,
|
owner_headers,
|
||||||
slug="campaign-collaborator",
|
slug="campaign-collaborator",
|
||||||
@@ -5786,7 +5818,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"},
|
"campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"},
|
||||||
"fields": [],
|
"fields": [],
|
||||||
"global_values": {},
|
"global_values": {},
|
||||||
"server": {"smtp": {"host": "mock.smtp", "port": 2525, "security": "starttls"}},
|
"server": {"mail_profile_id": mail_profile_id},
|
||||||
"recipients": {"from": {"email": "sender@example.org", "type": "to"}},
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}},
|
||||||
"template": {"subject": "ACL", "text": "ACL"},
|
"template": {"subject": "ACL", "text": "ACL"},
|
||||||
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
||||||
@@ -5984,7 +6016,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
json={"campaign_json": inline_json},
|
json={"campaign_json": inline_json},
|
||||||
)
|
)
|
||||||
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
|
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
|
||||||
self.assertIn("Campaign-local inline mail settings", blocked_inline.json()["detail"])
|
self.assertIn("may only reference", blocked_inline.json()["detail"])
|
||||||
|
|
||||||
reusable_json = detail.json()["raw_json"]
|
reusable_json = detail.json()["raw_json"]
|
||||||
reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
|
reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
|
||||||
@@ -6105,7 +6137,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||||
self.assertEqual(blocked.status_code, 422, blocked.text)
|
self.assertEqual(blocked.status_code, 422, blocked.text)
|
||||||
self.assertIn("locked", blocked.json()["detail"])
|
self.assertIn("may only reference", blocked.json()["detail"])
|
||||||
|
|
||||||
campaign_json["server"] = {"mail_profile_id": profile_id}
|
campaign_json["server"] = {"mail_profile_id": profile_id}
|
||||||
allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
||||||
|
|||||||
@@ -3130,18 +3130,30 @@ finally:
|
|||||||
"version_max_exclusive": "0.2.0",
|
"version_max_exclusive": "0.2.0",
|
||||||
}, modules["files"]["requires_interfaces"])
|
}, modules["files"]["requires_interfaces"])
|
||||||
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
|
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
|
||||||
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
|
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
|
||||||
|
self.assertIn({
|
||||||
|
"name": "campaigns.access",
|
||||||
|
"optional": True,
|
||||||
|
"version_min": "0.1.0",
|
||||||
|
"version_max_exclusive": "0.2.0",
|
||||||
|
}, modules["mail"]["requires_interfaces"])
|
||||||
self.assertIn({
|
self.assertIn({
|
||||||
"name": "files.campaign_attachments",
|
"name": "files.campaign_attachments",
|
||||||
"optional": True,
|
"optional": True,
|
||||||
"version_min": "0.1.0",
|
"version_min": "0.1.0",
|
||||||
"version_max_exclusive": "0.2.0",
|
"version_max_exclusive": "0.2.0",
|
||||||
}, modules["campaigns"]["requires_interfaces"])
|
}, modules["campaigns"]["requires_interfaces"])
|
||||||
|
self.assertIn({
|
||||||
|
"name": "mail.campaign_delivery",
|
||||||
|
"optional": True,
|
||||||
|
"version_min": "0.2.0",
|
||||||
|
"version_max_exclusive": "0.3.0",
|
||||||
|
}, modules["campaigns"]["requires_interfaces"])
|
||||||
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
|
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
|
||||||
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
||||||
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
||||||
self.assertEqual("0.1.8", modules["files"]["version"])
|
self.assertEqual("0.1.9", modules["files"]["version"])
|
||||||
self.assertIn("@v0.1.8", modules["files"]["python_ref"])
|
self.assertIn("@v0.1.9", modules["files"]["python_ref"])
|
||||||
|
|
||||||
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
|
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ export type MessageDisplayAttachment = {
|
|||||||
|
|
||||||
type MessageDisplayBodyMode = "text" | "html";
|
type MessageDisplayBodyMode = "text" | "html";
|
||||||
|
|
||||||
|
const MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY = [
|
||||||
|
"default-src 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
"connect-src 'none'",
|
||||||
|
"font-src 'none'",
|
||||||
|
"form-action 'none'",
|
||||||
|
"frame-src 'none'",
|
||||||
|
"img-src data: cid:",
|
||||||
|
"manifest-src 'none'",
|
||||||
|
"media-src 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"prefetch-src 'none'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'unsafe-inline'",
|
||||||
|
"worker-src 'none'"
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
const URL_ATTRIBUTE_PATTERN = /(\s)(src|href|xlink:href|poster|background|action|formaction|cite|longdesc|ping|data)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
|
||||||
|
const SOURCE_SET_ATTRIBUTE_PATTERN = /\s(?:srcset|imagesrcset)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
||||||
|
const EVENT_HANDLER_ATTRIBUTE_PATTERN = /\son[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
||||||
|
const CSS_IMPORT_PATTERN = /@import\s+(?:url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)|"[^"]*"|'[^']*')[^;]*;?/gi;
|
||||||
|
const CSS_URL_PATTERN = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
|
||||||
|
const UNSAFE_DOCUMENT_ELEMENT_PATTERN = /<\s*(?:base|link|meta)\b[^>]*>/gi;
|
||||||
|
const SCRIPT_BLOCK_PATTERN = /<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi;
|
||||||
|
const SCRIPT_TAG_PATTERN = /<\s*\/?\s*script\b[^>]*>/gi;
|
||||||
|
|
||||||
type MessageDisplayPanelProps = {
|
type MessageDisplayPanelProps = {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
fields?: MessageDisplayField[];
|
fields?: MessageDisplayField[];
|
||||||
@@ -71,6 +97,7 @@ export default function MessageDisplayPanel({
|
|||||||
const showBodySwitch = hasHtml && hasText;
|
const showBodySwitch = hasHtml && hasText;
|
||||||
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
const htmlBodyFallback = translateText("i18n:govoplan-core.p_no_html_body_content_p.c305bfc6");
|
||||||
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
const textBodyFallback = translateText("i18n:govoplan-core.no_readable_body_content.37643a01");
|
||||||
|
const safeHtmlDocument = buildSafeMessageHtmlDocument(bodyHtml || htmlBodyFallback);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-display-panel">
|
<div className="message-display-panel">
|
||||||
@@ -104,7 +131,12 @@ export default function MessageDisplayPanel({
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{activeBodyMode === "html" ?
|
{activeBodyMode === "html" ?
|
||||||
<iframe className="message-display-html-frame" title="i18n:govoplan-core.rendered_html_message_body.8638b634" sandbox="" srcDoc={bodyHtml || htmlBodyFallback} /> :
|
<iframe
|
||||||
|
className="message-display-html-frame"
|
||||||
|
title="i18n:govoplan-core.rendered_html_message_body.8638b634"
|
||||||
|
sandbox=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
srcDoc={safeHtmlDocument} /> :
|
||||||
|
|
||||||
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
<pre className="message-display-body">{textBody || textBodyFallback}</pre>
|
||||||
}
|
}
|
||||||
@@ -278,3 +310,67 @@ function formatBytes(value?: number | null): string {
|
|||||||
function stripHtml(value: string): string {
|
function stripHtml(value: string): string {
|
||||||
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
return value.replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the isolated document used for untrusted email HTML previews.
|
||||||
|
*
|
||||||
|
* Attribute neutralization keeps common clients from even attempting remote
|
||||||
|
* resource resolution. The document-local CSP is the authoritative backstop
|
||||||
|
* for malformed markup and parser edge cases: no network-capable resource is
|
||||||
|
* permitted, while inline presentation plus embedded CID/raster data images
|
||||||
|
* remain usable inside the already scriptless sandbox.
|
||||||
|
*/
|
||||||
|
export function buildSafeMessageHtmlDocument(value: string): string {
|
||||||
|
const neutralized = neutralizeRemoteMessageContent(value);
|
||||||
|
return [
|
||||||
|
"<!doctype html>",
|
||||||
|
"<html>",
|
||||||
|
"<head>",
|
||||||
|
'<meta charset="utf-8">',
|
||||||
|
`<meta http-equiv="Content-Security-Policy" content="${MESSAGE_PREVIEW_CONTENT_SECURITY_POLICY}">`,
|
||||||
|
'<meta name="referrer" content="no-referrer">',
|
||||||
|
"</head>",
|
||||||
|
`<body>${neutralized}</body>`,
|
||||||
|
"</html>"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function neutralizeRemoteMessageContent(value: string): string {
|
||||||
|
return value.
|
||||||
|
replace(SCRIPT_BLOCK_PATTERN, "").
|
||||||
|
replace(SCRIPT_TAG_PATTERN, "").
|
||||||
|
replace(UNSAFE_DOCUMENT_ELEMENT_PATTERN, "").
|
||||||
|
replace(EVENT_HANDLER_ATTRIBUTE_PATTERN, "").
|
||||||
|
replace(SOURCE_SET_ATTRIBUTE_PATTERN, "").
|
||||||
|
replace(URL_ATTRIBUTE_PATTERN, (_match, spacing: string, attributeName: string, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
|
||||||
|
const url = doubleQuoted ?? singleQuoted ?? unquoted ?? "";
|
||||||
|
if (!messagePreviewUrlAllowed(attributeName, url)) return "";
|
||||||
|
return `${spacing}${attributeName.toLowerCase()}="${escapeHtmlAttribute(url.trim())}"`;
|
||||||
|
}).
|
||||||
|
replace(CSS_IMPORT_PATTERN, "").
|
||||||
|
replace(CSS_URL_PATTERN, (_match, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => {
|
||||||
|
const url = (doubleQuoted ?? singleQuoted ?? unquoted ?? "").trim();
|
||||||
|
return isSafeEmbeddedMessageResource(url) ? `url("${escapeCssString(url)}")` : "none";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function messagePreviewUrlAllowed(attributeName: string, value: string): boolean {
|
||||||
|
const attribute = attributeName.toLowerCase();
|
||||||
|
if (["action", "formaction", "ping"].includes(attribute)) return false;
|
||||||
|
const url = value.trim();
|
||||||
|
if ((attribute === "href" || attribute === "xlink:href") && /^#[^\s]*$/.test(url)) return true;
|
||||||
|
return isSafeEmbeddedMessageResource(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeEmbeddedMessageResource(value: string): boolean {
|
||||||
|
if (/^cid:[^\s"'<>]+$/i.test(value)) return true;
|
||||||
|
return /^data:image\/(?:png|gif|jpe?g|webp);base64,[a-z0-9+/=\s]+$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCssString(value: string): string {
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n\f]/g, "");
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function assertDeepEqual(actual: unknown, expected: unknown, message = "values s
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import CredentialPanel from "../src/components/CredentialPanel";
|
import CredentialPanel from "../src/components/CredentialPanel";
|
||||||
import PasswordField from "../src/components/PasswordField";
|
import PasswordField from "../src/components/PasswordField";
|
||||||
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
|
import MessageDisplayPanel, { buildSafeMessageHtmlDocument } from "../src/components/MessageDisplayPanel";
|
||||||
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
import MailServerSettingsPanel, { MailServerFolderLookupResultView, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
||||||
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
||||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
@@ -245,3 +245,44 @@ assert(messageDisplay.includes("agenda.pdf"), "direct attachment is rendered");
|
|||||||
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
assert(messageDisplay.includes("recipient-files.zip"), "ZIP archive attachment group is rendered");
|
||||||
assert(messageDisplay.includes("i18n:govoplan-core.password_protected.09d9e174"), "ZIP protection badge is rendered");
|
assert(messageDisplay.includes("i18n:govoplan-core.password_protected.09d9e174"), "ZIP protection badge is rendered");
|
||||||
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
assert(messageDisplay.includes("zip_password"), "ZIP protection note is rendered");
|
||||||
|
|
||||||
|
const safeMessageDocument = buildSafeMessageHtmlDocument(`
|
||||||
|
<base href="https://tracking.example/">
|
||||||
|
<meta http-equiv="refresh" content="0;url=https://tracking.example/refresh">
|
||||||
|
<link rel="stylesheet" href="https://tracking.example/mail.css">
|
||||||
|
<style>
|
||||||
|
@import "https://tracking.example/import.css";
|
||||||
|
.remote { background-image: url(https://tracking.example/background.png); }
|
||||||
|
.embedded { background-image: url('cid:background@example.org'); }
|
||||||
|
</style>
|
||||||
|
<a href="https://tracking.example/click" ping="https://tracking.example/ping">Remote link</a>
|
||||||
|
<a href="#local-section">Local link</a>
|
||||||
|
<img src="https://tracking.example/pixel.gif" srcset="https://tracking.example/retina.gif 2x" onerror="fetch('https://tracking.example/error')">
|
||||||
|
<img src="cid:logo@example.org">
|
||||||
|
<img src="data:image/png;base64,iVBORw0KGgo=">
|
||||||
|
<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">
|
||||||
|
<script>fetch("https://tracking.example/script")</script>
|
||||||
|
`);
|
||||||
|
assert(safeMessageDocument.startsWith("<!doctype html><html><head>"), "message HTML is wrapped in an isolated document");
|
||||||
|
assert(safeMessageDocument.includes("default-src 'none'"), "message iframe CSP denies resources by default");
|
||||||
|
assert(safeMessageDocument.includes("img-src data: cid:"), "message iframe CSP permits only embedded image sources");
|
||||||
|
assert(safeMessageDocument.includes("connect-src 'none'"), "message iframe CSP blocks active network connections");
|
||||||
|
assert(safeMessageDocument.includes("form-action 'none'"), "message iframe CSP blocks form submissions");
|
||||||
|
assert(safeMessageDocument.includes('src="cid:logo@example.org"'), "CID image references remain available");
|
||||||
|
assert(safeMessageDocument.includes('src="data:image/png;base64,iVBORw0KGgo="'), "conservative raster data images remain available");
|
||||||
|
assert(safeMessageDocument.includes('href="#local-section"'), "same-document fragment links remain available");
|
||||||
|
assert(!safeMessageDocument.includes("tracking.example"), "remote message URLs are neutralized before preview");
|
||||||
|
assert(!safeMessageDocument.includes("data:image/svg+xml"), "active SVG data resources are rejected");
|
||||||
|
assert(!safeMessageDocument.includes("onerror="), "event-handler attributes are removed");
|
||||||
|
assert(!safeMessageDocument.includes("<script"), "script elements are removed");
|
||||||
|
|
||||||
|
const privateMessageDisplay = renderToStaticMarkup(
|
||||||
|
<MessageDisplayPanel
|
||||||
|
title="Untrusted message"
|
||||||
|
bodyHtml='<img src="https://tracking.example/pixel.gif"><img src="cid:logo@example.org">'
|
||||||
|
preferredBodyMode="html"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assert(privateMessageDisplay.includes('sandbox=""'), "message HTML remains inside a scriptless iframe sandbox");
|
||||||
|
assert(privateMessageDisplay.includes('referrerPolicy="no-referrer"'), "message iframe suppresses referrer data");
|
||||||
|
assert(!privateMessageDisplay.includes("tracking.example"), "rendered iframe source contains no remote tracking URL");
|
||||||
|
|||||||
Reference in New Issue
Block a user