From ebd8a351acad4c84a683946889a96a354fced69b Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Thu, 24 Sep 2026 13:21:09 -0400 Subject: [PATCH 1/4] Support managed outgoing mail and website company sender --- README.md | 13 +++++ docker/scripts/odoo_website_bootstrap.py | 11 +++++ docker/scripts/run_odoo_startup.py | 19 +++++++- tests/test_odoo_startup.py | 60 ++++++++++++++++++++---- tests/test_odoo_website_bootstrap.py | 23 +++++++++ 5 files changed, 115 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d971393..6b9af6b 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,19 @@ Current runtime ownership is intentionally narrow and explicit: ## Runtime Contract Notes +- The startup wrapper maps explicitly supplied `ODOO_SMTP_SERVER`, + `ODOO_SMTP_PORT`, `ODOO_SMTP_USER`, `ODOO_SMTP_PASSWORD`, `ODOO_SMTP_SSL`, + `ODOO_EMAIL_FROM`, and `ODOO_FROM_FILTER` to Odoo's native mail options. + `ODOO_SMTP_SSL=True` selects STARTTLS; it is not implicit TLS on port 465. + Launchplane owns hosted values and secret bindings. Empty supplied values + clear inherited mail options; omitted values preserve the base config. + The generated config containing credentials is readable only by its owner. + Existing Odoo outgoing-server records take precedence over the config fallback. +- An optional `company_email` in Launchplane's website-bootstrap payload sets + the selected website company's sender address and verifies it was saved. + Omission preserves the existing company email. This fixes the company sender + used by native website contact forms without changing form submissions or + sending email during bootstrap. - The shared tenant compose database service stays pinned to `postgres:17` while existing tenant DB volumes still use the legacy `/var/lib/postgresql/data` layout. diff --git a/docker/scripts/odoo_website_bootstrap.py b/docker/scripts/odoo_website_bootstrap.py index 409968c..c1f44f7 100644 --- a/docker/scripts/odoo_website_bootstrap.py +++ b/docker/scripts/odoo_website_bootstrap.py @@ -465,6 +465,17 @@ def apply_website_bootstrap(env: Any, parsed_payload: dict[str, object] | None) create_values = _field_values(website_model, {"name": default_name}) website = website_model.create(create_values or {"name": default_name}) + company_email = str(website_payload.get("company_email") or "").strip() + if company_email: + _require_existing_fields(website, ("company_id",), label="website company") + company = website.company_id + if not company: + raise RuntimeError("Website bootstrap cannot set company email; the selected website has no company.") + _require_existing_fields(company, ("email",), label="company email") + company.sudo().write({"email": company_email}) + _assert_field_value(company, "email", company_email, label="company email") + print("website_bootstrap_company_email_matches=true") + website_values: dict[str, object] = {} website_name = str(website_payload.get("name") or "").strip() if website_name: diff --git a/docker/scripts/run_odoo_startup.py b/docker/scripts/run_odoo_startup.py index 7099d4f..cad3cab 100644 --- a/docker/scripts/run_odoo_startup.py +++ b/docker/scripts/run_odoo_startup.py @@ -35,6 +35,15 @@ ("ODOO_LIMIT_MEMORY_SOFT", "limit_memory_soft"), ("ODOO_LIMIT_MEMORY_HARD", "limit_memory_hard"), ) +MAIL_OPTION_MAP: tuple[tuple[str, str], ...] = ( + ("ODOO_SMTP_SERVER", "smtp_server"), + ("ODOO_SMTP_PORT", "smtp_port"), + ("ODOO_SMTP_USER", "smtp_user"), + ("ODOO_SMTP_PASSWORD", "smtp_password"), + ("ODOO_SMTP_SSL", "smtp_ssl"), + ("ODOO_EMAIL_FROM", "email_from"), + ("ODOO_FROM_FILTER", "from_filter"), +) UNSAFE_MASTER_PASSWORDS = {"admin"} LOCAL_INSTANCE_NAMES = {"", "local", "dev", "development"} @@ -181,6 +190,12 @@ def _write_runtime_config(settings: StartupSettings) -> None: if option_value: options[option_name] = option_value + # Explicit empty values clear inherited mail options. Preserve password bytes. + for env_name, option_name in MAIL_OPTION_MAP: + if env_name in os.environ: + value = os.environ[env_name] + options[option_name] = value if option_name == "smtp_password" else value.strip() + dev_mode_value = os.environ.get("ODOO_DEV_MODE", "").strip() if dev_mode_value: options["dev_mode"] = dev_mode_value @@ -191,7 +206,9 @@ def _write_runtime_config(settings: StartupSettings) -> None: config_directory = os.path.dirname(config_path) if config_directory: os.makedirs(config_directory, exist_ok=True) - with open(config_path, "w", encoding="utf-8") as config_file: + descriptor = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as config_file: + os.fchmod(config_file.fileno(), 0o600) config_parser.write(config_file) diff --git a/tests/test_odoo_startup.py b/tests/test_odoo_startup.py index 0e2190c..11be17b 100644 --- a/tests/test_odoo_startup.py +++ b/tests/test_odoo_startup.py @@ -3,11 +3,15 @@ import argparse import configparser import importlib.util +import io import os import sys import types import unittest +from contextlib import redirect_stdout +from dataclasses import replace from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import patch @@ -154,11 +158,11 @@ def test_public_runtime_config_pins_http_database_filter_to_configured_database( settings = self._settings(platform_instance="testing", admin_password="safe-admin-password") parser = configparser.ConfigParser(interpolation=None) - with patch("builtins.open", unittest.mock.mock_open()) as open_mock: - odoo_startup._write_runtime_config(settings) - - written_config = "".join(call.args[0] for call in open_mock().write.call_args_list) - parser.read_string(written_config) + with TemporaryDirectory() as directory: + settings = replace(settings, config_path=str(Path(directory) / "odoo.conf"), base_config_path="") + with patch.dict(os.environ, {}, clear=True): + odoo_startup._write_runtime_config(settings) + parser.read(settings.config_path) self.assertEqual(parser["options"]["db_name"], "opw") self.assertEqual(parser["options"]["dbfilter"], "^opw$") @@ -167,15 +171,51 @@ def test_local_runtime_config_does_not_pin_http_database_filter(self) -> None: settings = self._settings(platform_instance="local") parser = configparser.ConfigParser(interpolation=None) - with patch("builtins.open", unittest.mock.mock_open()) as open_mock: - odoo_startup._write_runtime_config(settings) - - written_config = "".join(call.args[0] for call in open_mock().write.call_args_list) - parser.read_string(written_config) + with TemporaryDirectory() as directory: + settings = replace(settings, config_path=str(Path(directory) / "odoo.conf"), base_config_path="") + with patch.dict(os.environ, {}, clear=True): + odoo_startup._write_runtime_config(settings) + parser.read(settings.config_path) self.assertEqual(parser["options"]["db_name"], "opw") self.assertNotIn("dbfilter", parser["options"]) + def test_managed_mail_options_replace_base_config_without_logging_password(self) -> None: + environment = { + "ODOO_SMTP_SERVER": "smtp.example.test", + "ODOO_SMTP_PORT": "587", + "ODOO_SMTP_USER": "mailbox@example.test", + "ODOO_SMTP_PASSWORD": "secret%with#punctuation", + "ODOO_SMTP_SSL": "True", + "ODOO_EMAIL_FROM": "support@example.test", + "ODOO_FROM_FILTER": "support@example.test", + } + output = io.StringIO() + with TemporaryDirectory() as directory: + base = Path(directory) / "base.conf" + target = Path(directory) / "runtime.conf" + base.write_text("[options]\nsmtp_server = old.example.test\nsmtp_password = old-secret\n", encoding="utf-8") + target.touch(mode=0o644) + target.chmod(0o644) + settings = replace(self._settings(), base_config_path=str(base), config_path=str(target)) + with patch.dict(os.environ, environment, clear=True), redirect_stdout(output): + odoo_startup._write_runtime_config(settings) + parser = configparser.ConfigParser(interpolation=None) + parser.read(target) + self.assertEqual(parser["options"]["smtp_server"], "smtp.example.test") + self.assertEqual(parser["options"].getint("smtp_port"), 587) + self.assertEqual(parser["options"]["smtp_user"], "mailbox@example.test") + self.assertEqual(parser["options"]["smtp_password"], environment["ODOO_SMTP_PASSWORD"]) + self.assertTrue(parser["options"].getboolean("smtp_ssl")) + self.assertEqual(parser["options"]["email_from"], "support@example.test") + self.assertEqual(parser["options"]["from_filter"], "support@example.test") + self.assertEqual(target.stat().st_mode & 0o777, 0o600) + with patch.dict(os.environ, {"ODOO_SMTP_PASSWORD": ""}, clear=True): + odoo_startup._write_runtime_config(settings) + parser.read(target) + self.assertEqual(parser["options"]["smtp_password"], "") + self.assertNotIn(environment["ODOO_SMTP_PASSWORD"], output.getvalue()) + def test_database_filter_escapes_database_name(self) -> None: pattern = odoo_startup._database_filter_pattern("tenant.prod") diff --git a/tests/test_odoo_website_bootstrap.py b/tests/test_odoo_website_bootstrap.py index 5aae695..16e8c03 100644 --- a/tests/test_odoo_website_bootstrap.py +++ b/tests/test_odoo_website_bootstrap.py @@ -143,6 +143,29 @@ def ref(self, xmlid: str, *unused_args: object, **unused_kwargs: object) -> Fake class WebsiteBootstrapHelperTests(unittest.TestCase): + def test_company_sender_is_set_on_selected_website_and_verified(self) -> None: + env = FakeEnv() + company = FakeRecord(fields=("email",), values={"email": False}) + env.website._fields.add("company_id") + env.website.company_id = company + payload = {"website_bootstrap": {"name": "Example", "company_email": "support@example.test"}} + with redirect_stdout(io.StringIO()): + website_bootstrap.apply_website_bootstrap(env, payload) + self.assertEqual(company.email, "support@example.test") + company.persist_writes = False + company.email = False + with self.assertRaisesRegex(RuntimeError, "failed to persist company email"): + website_bootstrap.apply_website_bootstrap(env, payload) + + def test_company_sender_request_fails_when_selected_website_has_no_company(self) -> None: + env = FakeEnv() + env.website._fields.add("company_id") + env.website.company_id = FakeRecord(truthy=False) + with self.assertRaisesRegex(RuntimeError, "selected website has no company"): + website_bootstrap.apply_website_bootstrap( + env, {"website_bootstrap": {"name": "Example", "company_email": "support@example.test"}} + ) + def test_required_instance_overrides_fail_without_payload(self) -> None: with patch.dict( os.environ, From 4b7b08b9f24cc65e03e742cd1e842016713ce6e6 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Thu, 24 Sep 2026 13:29:53 -0400 Subject: [PATCH 2/4] Keep restored databases from using SMTP fallback --- README.md | 3 +++ docker/scripts/run_odoo_data_workflows.py | 9 +++++++- tests/test_odoo_data_workflows.py | 28 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6b9af6b..94cde02 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,9 @@ Current runtime ownership is intentionally narrow and explicit: clear inherited mail options; omitted values preserve the base config. The generated config containing credentials is readable only by its owner. Existing Odoo outgoing-server records take precedence over the config fallback. + Restored databases that undergo sanitization get an active dummy outgoing + server, following Odoo's neutralization behavior, so even configured SMTP + fallback cannot send copied customer mail. Copied SMTP credentials are cleared. - An optional `company_email` in Launchplane's website-bootstrap payload sets the selected website company's sender address and verifies it was saved. Omission preserves the existing company email. This fixes the company sender diff --git a/docker/scripts/run_odoo_data_workflows.py b/docker/scripts/run_odoo_data_workflows.py index 9737ce3..5b1c9bb 100644 --- a/docker/scripts/run_odoo_data_workflows.py +++ b/docker/scripts/run_odoo_data_workflows.py @@ -908,7 +908,6 @@ def sanitize_database(self) -> None: disable_cron = self.local.disable_cron sql_calls: list[SqlCall] = [ - SqlCall("ir.mail_server", KeyValuePair("active", False)), SqlCall("ir.config_parameter", KeyValuePair("value", "False"), KeyValuePair("key", "mail.catchall.domain")), SqlCall("ir.config_parameter", KeyValuePair("value", "False"), KeyValuePair("key", "mail.catchall.alias")), SqlCall("ir.config_parameter", KeyValuePair("value", "False"), KeyValuePair("key", "mail.bounce.alias")), @@ -917,6 +916,14 @@ def sanitize_database(self) -> None: sql_calls.append(SqlCall("ir.cron", KeyValuePair("active", False))) _logger.info("Sanitizing database...") + # An active dummy server also blocks Odoo's config/CLI SMTP fallback. + # Match Odoo's neutralization behavior and remove copied credentials. + with self.connect_to_db().cursor() as cursor: + cursor.execute("UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL") + cursor.execute( + "INSERT INTO ir_mail_server (name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication) " + "VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login')" + ) # noinspection PyUnresolvedReferences # call_odoo_sql exists on this class; PyCharm false positive. call_odoo_sql = self.call_odoo_sql for sql_call in sql_calls: diff --git a/tests/test_odoo_data_workflows.py b/tests/test_odoo_data_workflows.py index c562d41..cff555a 100644 --- a/tests/test_odoo_data_workflows.py +++ b/tests/test_odoo_data_workflows.py @@ -2,10 +2,12 @@ import importlib.util import os +import sqlite3 import subprocess import sys import types import unittest +from contextlib import closing from pathlib import Path from unittest.mock import MagicMock, patch @@ -44,6 +46,32 @@ def _load_data_workflows_module() -> types.ModuleType: class OdooDataWorkflowShellEnvironmentTests(unittest.TestCase): + def test_sanitized_database_blocks_configured_smtp_fallback_and_removes_copied_credentials(self) -> None: + with closing(sqlite3.connect(":memory:")) as database: + database.execute( + "CREATE TABLE ir_mail_server (name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, " + "active BOOLEAN, smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT)" + ) + database.execute( + "INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', " + "'mailbox@example.test', 'copied-secret')" + ) + runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(), upstream=None, env_file=None) + runner.local.db_conn = types.SimpleNamespace(cursor=lambda: closing(database.cursor())) + with patch.object(runner, "call_odoo_sql", return_value=[]): + runner.sanitize_database() + runner.sanitize_database() + self.assertEqual( + database.execute("SELECT smtp_host, smtp_port FROM ir_mail_server WHERE active = true").fetchall(), + [("invalid", 1025)], + ) + self.assertEqual( + database.execute( + "SELECT count(*) FROM ir_mail_server WHERE smtp_user IS NOT NULL OR smtp_pass IS NOT NULL" + ).fetchone()[0], + 0, + ) + @staticmethod def _local_settings() -> object: return odoo_data_workflows.LocalServerSettings( From 3bf919c3d6eb50fb5c0761e9c598f8a94d0d22de Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Thu, 24 Sep 2026 13:43:35 -0400 Subject: [PATCH 3/4] Preserve SMTP configuration for fresh bootstrap --- README.md | 4 +++- docker/scripts/run_odoo_data_workflows.py | 13 ++++++------ tests/test_odoo_data_workflows.py | 26 ++++++++++++++++++++--- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 94cde02..ec2382d 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,9 @@ Current runtime ownership is intentionally narrow and explicit: Existing Odoo outgoing-server records take precedence over the config fallback. Restored databases that undergo sanitization get an active dummy outgoing server, following Odoo's neutralization behavior, so even configured SMTP - fallback cannot send copied customer mail. Copied SMTP credentials are cleared. + fallback cannot send copied customer mail. Copied SMTP usernames/passwords are + cleared. Fresh bootstrap does not insert that dummy server, so an empty new + database can use the operator's explicitly supplied mail configuration. - An optional `company_email` in Launchplane's website-bootstrap payload sets the selected website company's sender address and verifies it was saved. Omission preserves the existing company email. This fixes the company sender diff --git a/docker/scripts/run_odoo_data_workflows.py b/docker/scripts/run_odoo_data_workflows.py index 5b1c9bb..a8d92b4 100644 --- a/docker/scripts/run_odoo_data_workflows.py +++ b/docker/scripts/run_odoo_data_workflows.py @@ -904,7 +904,7 @@ def call_odoo_sql(self, sql_call: SqlCall, call_type: SqlCallType) -> list[tuple else: return [] - def sanitize_database(self) -> None: + def sanitize_database(self, *, block_smtp_fallback: bool = True) -> None: disable_cron = self.local.disable_cron sql_calls: list[SqlCall] = [ @@ -920,10 +920,11 @@ def sanitize_database(self) -> None: # Match Odoo's neutralization behavior and remove copied credentials. with self.connect_to_db().cursor() as cursor: cursor.execute("UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL") - cursor.execute( - "INSERT INTO ir_mail_server (name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication) " - "VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login')" - ) + if block_smtp_fallback: + cursor.execute( + "INSERT INTO ir_mail_server (name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication) " + "VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login')" + ) # noinspection PyUnresolvedReferences # call_odoo_sql exists on this class; PyCharm false positive. call_odoo_sql = self.call_odoo_sql for sql_call in sql_calls: @@ -1510,7 +1511,7 @@ def run_bootstrap(self, *, do_sanitize: bool) -> None: self.connect_to_db() if do_sanitize: - self.sanitize_database() + self.sanitize_database(block_smtp_fallback=False) self.local.db_conn.commit() else: _logger.info("Skipping sanitization per --no-sanitize flag.") diff --git a/tests/test_odoo_data_workflows.py b/tests/test_odoo_data_workflows.py index cff555a..af528e1 100644 --- a/tests/test_odoo_data_workflows.py +++ b/tests/test_odoo_data_workflows.py @@ -46,18 +46,38 @@ def _load_data_workflows_module() -> types.ModuleType: class OdooDataWorkflowShellEnvironmentTests(unittest.TestCase): - def test_sanitized_database_blocks_configured_smtp_fallback_and_removes_copied_credentials(self) -> None: + def test_bootstrap_allows_configured_mail_but_sanitized_restores_block_it(self) -> None: with closing(sqlite3.connect(":memory:")) as database: database.execute( "CREATE TABLE ir_mail_server (name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, " "active BOOLEAN, smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT)" ) + runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(), upstream=None, env_file=None) + runner.local.db_conn = types.SimpleNamespace(cursor=lambda: closing(database.cursor()), commit=database.commit) + with patch.multiple( + runner, + _resolve_filestore_owner=MagicMock(return_value=None), + database_exists=MagicMock(return_value=False), + _clean_filestore=MagicMock(), + normalize_filestore_permissions=MagicMock(), + create_database=MagicMock(), + _reset_db_connection=MagicMock(), + needs_base_install=MagicMock(return_value=False), + install_addons=MagicMock(), + update_addons=MagicMock(), + call_odoo_sql=MagicMock(return_value=[]), + assert_install_queue_is_resolvable=MagicMock(), + apply_environment_overrides=MagicMock(), + ensure_admin_user=MagicMock(), + assert_core_schema_healthy=MagicMock(), + ensure_gpt_users=MagicMock(), + ): + runner.run_bootstrap(do_sanitize=True) + self.assertEqual(database.execute("SELECT count(*) FROM ir_mail_server WHERE active = true").fetchone()[0], 0) database.execute( "INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', " "'mailbox@example.test', 'copied-secret')" ) - runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(), upstream=None, env_file=None) - runner.local.db_conn = types.SimpleNamespace(cursor=lambda: closing(database.cursor())) with patch.object(runner, "call_odoo_sql", return_value=[]): runner.sanitize_database() runner.sanitize_database() From 41ec129ee3f25b0cd2fdce3f1a220e2063168641 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Thu, 24 Sep 2026 15:29:07 -0400 Subject: [PATCH 4/4] Type the startup settings fixture as its actual dataclass --- tests/test_odoo_startup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_odoo_startup.py b/tests/test_odoo_startup.py index 11be17b..5d39a44 100644 --- a/tests/test_odoo_startup.py +++ b/tests/test_odoo_startup.py @@ -12,8 +12,12 @@ from dataclasses import replace from pathlib import Path from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING from unittest.mock import patch +if TYPE_CHECKING: + from docker.scripts.run_odoo_startup import StartupSettings + def _load_startup_module() -> types.ModuleType: module_path = Path(__file__).resolve().parents[1] / "docker" / "scripts" / "run_odoo_startup.py" @@ -47,7 +51,7 @@ def _settings( platform_instance: str = "local", master_password: str = "master-password", admin_password: str = "", - ) -> object: + ) -> StartupSettings: return odoo_startup.StartupSettings( config_path="/tmp/generated.conf", base_config_path="/tmp/base.conf",