From dd21724f7631f625dc85e1b3cb2c9a9efd00d16d Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Thu, 24 Sep 2026 13:22:58 -0400 Subject: [PATCH 1/2] Carry website company sender through Odoo bootstrap records --- .../odoo_instance_override_record.py | 7 +++++++ control_plane/dokploy/post_deploy.py | 1 + docs/records.md | 8 ++++++++ frontend/generated/openapi-canonical.json | 10 ++++++++++ frontend/generated/openapi-ui.json | 6 ++++++ .../src/generated/openapi.ts/types.gen.ts | 1 + tests/test_odoo_instance_overrides.py | 19 +++++++++++++++++++ 7 files changed, 52 insertions(+) diff --git a/control_plane/contracts/odoo_instance_override_record.py b/control_plane/contracts/odoo_instance_override_record.py index b660cc5fc..9a62b7c1a 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..0ef06889d 100644 --- a/control_plane/dokploy/post_deploy.py +++ b/control_plane/dokploy/post_deploy.py @@ -89,6 +89,7 @@ "website_bootstrap_primary_page_xmlid_found", "website_bootstrap_homepage_matches_page", "website_bootstrap_logo_present", + "website_bootstrap_company_email_matches", "website_bootstrap_applied", } ) diff --git a/docs/records.md b/docs/records.md index 318d05939..ac81d3745 100644 --- a/docs/records.md +++ b/docs/records.md @@ -1976,6 +1976,14 @@ 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 and verify the + `website_bootstrap_company_email_matches` readback marker before treating the + sender as configured; old runtime artifacts do not consume this new field. - 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_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", From fe90079ee1731e1ffa3ed79d43faf7efd5a0606b Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Thu, 24 Sep 2026 13:30:03 -0400 Subject: [PATCH 2/2] Require runtime evidence for configured company senders --- .../odoo_instance_override_record.py | 2 +- control_plane/dokploy/post_deploy.py | 22 ++++++- docs/records.md | 9 ++- tests/test_dokploy.py | 64 ++++++++++++++++++- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/control_plane/contracts/odoo_instance_override_record.py b/control_plane/contracts/odoo_instance_override_record.py index 9a62b7c1a..fb3f77727 100644 --- a/control_plane/contracts/odoo_instance_override_record.py +++ b/control_plane/contracts/odoo_instance_override_record.py @@ -31,7 +31,7 @@ def validate_odoo_website_bootstrap_contract( _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 + 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: diff --git a/control_plane/dokploy/post_deploy.py b/control_plane/dokploy/post_deploy.py index 0ef06889d..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, @@ -340,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 @@ -559,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, @@ -567,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 ac81d3745..53d278551 100644 --- a/docs/records.md +++ b/docs/records.md @@ -1981,9 +1981,12 @@ run` is the foreground loop intended for an external process supervisor, and 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 and verify the - `website_bootstrap_company_email_matches` readback marker before treating the - sender as configured; old runtime artifacts do not consume this new field. + 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/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: