diff --git a/control_plane/contracts/odoo_instance_override_record.py b/control_plane/contracts/odoo_instance_override_record.py index b660cc5fc..fb3f77727 100644 --- a/control_plane/contracts/odoo_instance_override_record.py +++ b/control_plane/contracts/odoo_instance_override_record.py @@ -29,6 +29,11 @@ def validate_odoo_website_bootstrap_contract( # Payload model construction strips text; this helper only enforces # write-path shape so persisted record reads remain repairable. _validate_local_route_path(payload.homepage_url, label="homepage_url") + if ( + payload.company_email + and re.fullmatch(r"[^\s@<>,;\"()]+@[^\s@<>,;\"()]+", payload.company_email) is None + ): + raise ValueError("Odoo website bootstrap company_email must be one email address") if payload.primary_page_xmlid and _ODOO_XMLID_RE.fullmatch(payload.primary_page_xmlid) is None: raise ValueError("Odoo website bootstrap primary_page_xmlid must be a dotted XML ID") route_urls = [route.url for route in payload.routes] @@ -145,6 +150,7 @@ class OdooWebsiteBootstrapPayload(BaseModel): logo_path: str = "" logo_alt: str = "" canonical_url: str = "" + company_email: str = "" pages_source: dict[str, object] = Field(default_factory=dict) routes_source: dict[str, object] = Field(default_factory=dict) routes: tuple[OdooWebsiteBootstrapRoute, ...] = () @@ -158,6 +164,7 @@ class OdooWebsiteBootstrapPayload(BaseModel): "logo_path", "logo_alt", "canonical_url", + "company_email", mode="after", ) @classmethod diff --git a/control_plane/dokploy/post_deploy.py b/control_plane/dokploy/post_deploy.py index c895d2a29..d48c3f279 100644 --- a/control_plane/dokploy/post_deploy.py +++ b/control_plane/dokploy/post_deploy.py @@ -9,6 +9,7 @@ import click +from control_plane.contracts.odoo_post_deploy_payload import OdooPostDeployPayload from control_plane.contracts.odoo_prod_retained_volume_backup_import import ( ODOO_PROD_RETAINED_VOLUME_BACKUP_IMPORT_FAILURE_STAGE_BY_CODE, OdooProdRetainedVolumeBackupImportInspectionEvidence, @@ -89,6 +90,7 @@ "website_bootstrap_primary_page_xmlid_found", "website_bootstrap_homepage_matches_page", "website_bootstrap_logo_present", + "website_bootstrap_company_email_matches", "website_bootstrap_applied", } ) @@ -339,6 +341,20 @@ def run_compose_post_deploy_update( else: desired_env_map.pop("ODOO_ADDONS_PATH", None) resolved_workflow_environment_overrides = dict(workflow_environment_overrides or {}) + require_company_email = False + encoded_payload = resolved_workflow_environment_overrides.get( + ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY, "" + ) + if encoded_payload: + try: + override_payload = OdooPostDeployPayload.model_validate_json( + base64.b64decode(encoded_payload, validate=True) + ) + except ValueError as error: + raise click.ClickException("Odoo post-deploy override payload is invalid.") from error + require_company_email = bool( + override_payload.website_bootstrap and override_payload.website_bootstrap.company_email + ) resolved_required_workflow_environment_keys = tuple(required_workflow_environment_keys) runtime_override_target_environment = { key: value @@ -558,7 +574,7 @@ def run_compose_post_deploy_update( ) if api.deployment_key(completed_schedule_deployment) != completed_schedule_deployment_key: completed_schedule_deployment = None - return _read_odoo_post_deploy_log_markers( + evidence = _read_odoo_post_deploy_log_markers( host=host, token=token, schedule_id=schedule_id, @@ -566,6 +582,11 @@ def run_compose_post_deploy_update( deployment_id=completed_schedule_deployment_key, deployment=completed_schedule_deployment, ) + if require_company_email and evidence.get("website_bootstrap_company_email_matches") != "true": + raise click.ClickException( + "Odoo post-deploy did not prove the requested website company sender was saved." + ) + return evidence def run_compose_odoo_stable_bootstrap( diff --git a/docs/records.md b/docs/records.md index 318d05939..53d278551 100644 --- a/docs/records.md +++ b/docs/records.md @@ -1976,6 +1976,17 @@ run` is the foreground loop intended for an external process supervisor, and including site identity, canonical URL, logo path, source metadata, and route definitions. Product repos remain the source of that intent; Launchplane persists the typed payload and renders it during Odoo post-deploy. +- Optional `website_bootstrap.company_email` is one sender address for the + selected website's company. The runtime bootstrap saves and reads it back; + omission preserves the existing company email. Native website contact forms + use this company field when constructing the sender. SMTP transport and its + credential remain separate managed runtime configuration. Deploy an Odoo + artifact whose devkit supports the field. Post-deploy requires the + `website_bootstrap_company_email_matches` readback marker whenever a sender + is requested; an older runtime that ignores the field fails this check. + Deploy the supporting Launchplane version before storing the new field; + rolling back to an older service requires removing the field from the record + through the supported record workflow first. - New website-bootstrap writes through the service route enforce the devkit-safe contract: homepage and route URLs are local Odoo route paths, `primary_page_xmlid` is a dotted XML ID, and at most one route can be marked diff --git a/frontend/generated/openapi-canonical.json b/frontend/generated/openapi-canonical.json index 1f41285bf..fe9f35fb1 100644 --- a/frontend/generated/openapi-canonical.json +++ b/frontend/generated/openapi-canonical.json @@ -19205,6 +19205,11 @@ "title": "Canonical Url", "type": "string" }, + "company_email": { + "default": "", + "title": "Company Email", + "type": "string" + }, "default_lang": { "default": "", "title": "Default Lang", @@ -59987,6 +59992,11 @@ "title": "Canonical Url", "type": "string" }, + "company_email": { + "default": "", + "title": "Company Email", + "type": "string" + }, "default_lang": { "default": "", "title": "Default Lang", diff --git a/frontend/generated/openapi-ui.json b/frontend/generated/openapi-ui.json index 8a23bab1b..e09391244 100644 --- a/frontend/generated/openapi-ui.json +++ b/frontend/generated/openapi-ui.json @@ -14421,6 +14421,11 @@ "title": "Canonical Url", "type": "string" }, + "company_email": { + "default": "", + "title": "Company Email", + "type": "string" + }, "default_lang": { "default": "", "title": "Default Lang", @@ -14476,6 +14481,7 @@ }, "required": [ "canonical_url", + "company_email", "default_lang", "homepage_url", "logo_alt", diff --git a/frontend/src/generated/openapi.ts/types.gen.ts b/frontend/src/generated/openapi.ts/types.gen.ts index 22697ddbf..603271ad5 100644 --- a/frontend/src/generated/openapi.ts/types.gen.ts +++ b/frontend/src/generated/openapi.ts/types.gen.ts @@ -1443,6 +1443,7 @@ export type OdooOverrideValue = { export type OdooWebsiteBootstrapPayload = { canonical_url: string; + company_email: string; default_lang: string; homepage_url: string; logo_alt: string; diff --git a/tests/test_dokploy.py b/tests/test_dokploy.py index 69c74d8b6..945564a30 100644 --- a/tests/test_dokploy.py +++ b/tests/test_dokploy.py @@ -12,7 +12,7 @@ from pathlib import Path from tempfile import TemporaryDirectory from typing import cast -from unittest.mock import patch +from unittest.mock import Mock, patch from urllib.error import HTTPError import click @@ -3108,6 +3108,68 @@ def capture_schedule_payload(**_kwargs: object) -> dict[str, str]: }, ) + def test_post_deploy_refuses_unproved_company_sender(self) -> None: + payload = base64.b64encode( + json.dumps( + { + "context": "example", + "instance": "testing", + "website_bootstrap": { + "name": "Example", + "company_email": "support@example.test", + }, + } + ).encode() + ).decode() + target = control_plane_dokploy.DokployTargetDefinition( + context="example", instance="testing", target_id="compose-example" + ) + for marker in (None, "false", "true"): + logs = [] if marker is None else [f"website_bootstrap_company_email_matches={marker}"] + with ( + self.subTest(marker=marker), + patch.multiple( + dokploy_api, + fetch_dokploy_target_payload=Mock( + return_value={ + "env": f"ODOO_DB_NAME=example\n{ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY}={payload}\n", + "appName": "example-app", + "serverId": "server-example", + } + ), + find_matching_dokploy_schedule=Mock(return_value=None), + upsert_dokploy_schedule=Mock(return_value={"scheduleId": "schedule-example"}), + latest_deployment_for_schedule=Mock( + side_effect=[ + {"id": "before"}, + {"id": "after", "logs": logs}, + ] + ), + wait_for_dokploy_schedule_deployment=Mock( + return_value="deployment=after status=done" + ), + fetch_dokploy_deployment_logs=Mock(return_value=logs), + dokploy_request=Mock(return_value={"ok": True}), + ), + ): + + def deploy() -> dict[str, str]: + return control_plane_dokploy.run_compose_post_deploy_update( + host="https://dokploy.example.test", + token="test-token", + target_definition=target, + env_file=None, + workflow_environment_overrides={ + ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY: payload + }, + ) + + if marker == "true": + self.assertEqual(deploy()["website_bootstrap_company_email_matches"], "true") + else: + with self.assertRaisesRegex(click.ClickException, "company sender"): + deploy() + def test_run_compose_post_deploy_update_reads_inline_schedule_log_markers( self, ) -> None: diff --git a/tests/test_odoo_instance_overrides.py b/tests/test_odoo_instance_overrides.py index 20d796e2a..b7e302f1b 100644 --- a/tests/test_odoo_instance_overrides.py +++ b/tests/test_odoo_instance_overrides.py @@ -65,6 +65,17 @@ def _assert_direct_db_mutation_rejected(test_case: unittest.TestCase, result: Re class OdooInstanceOverrideTests(unittest.TestCase): + def test_website_bootstrap_rejects_invalid_company_sender_on_write(self) -> None: + for email in ( + "not-an-address", + "Name ", + "sender@example.test\nBcc: other@example.test", + ): + with self.subTest(email=email), self.assertRaisesRegex(ValueError, "company_email"): + validate_odoo_website_bootstrap_contract( + OdooWebsiteBootstrapPayload(name="Example", company_email=email) + ) + def test_website_bootstrap_payload_accepts_devkit_route_shape(self) -> None: payload = validate_odoo_website_bootstrap_contract( OdooWebsiteBootstrapPayload( @@ -358,6 +369,7 @@ def test_build_post_deploy_environment_sets_website_bootstrap_required_flag(self tenant="cm", name="Cell Mechanic", canonical_url="https://cm-testing.example.com", + company_email="support@example.test", ), updated_at="2026-06-13T18:00:00Z", ) @@ -370,6 +382,9 @@ def test_build_post_deploy_environment_sets_website_bootstrap_required_flag(self ).decode("utf-8") ) self.assertIn("website_bootstrap", decoded_payload) + self.assertEqual( + decoded_payload["website_bootstrap"]["company_email"], "support@example.test" + ) self.assertEqual( environment.inline_environment[LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY], "true", @@ -407,6 +422,7 @@ def test_preview_website_bootstrap_environment_inherits_only_deploy_bootstrap(se tenant="cm", name="Cell Mechanic", canonical_url="https://cm-testing.example.com", + company_email="support@example.test", ), updated_at="2026-07-31T18:00:00Z", ) @@ -426,6 +442,9 @@ def test_preview_website_bootstrap_environment_inherits_only_deploy_bootstrap(se self.assertEqual(decoded_payload["config_parameters"], []) self.assertEqual(decoded_payload["addon_settings"], []) self.assertEqual(decoded_payload["website_bootstrap"]["name"], "Cell Mechanic") + self.assertEqual( + decoded_payload["website_bootstrap"]["company_email"], "support@example.test" + ) self.assertEqual( decoded_payload["website_bootstrap"]["canonical_url"], "https://pr-70.cm-preview.example.test",