From 7bdc89732fcd2eebad84db2b811605585fdd557c Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Wed, 25 Mar 2026 15:54:03 +0100 Subject: [PATCH 1/4] [IMP] mail_environment: uninstall hook --- mail_environment/__init__.py | 1 + mail_environment/__manifest__.py | 1 + mail_environment/hooks.py | 41 ++++ .../tests/test_mail_environment.py | 207 ++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 mail_environment/hooks.py diff --git a/mail_environment/__init__.py b/mail_environment/__init__.py index 0650744f6..071962a35 100644 --- a/mail_environment/__init__.py +++ b/mail_environment/__init__.py @@ -1 +1,2 @@ from . import models +from .hooks import uninstall_hook diff --git a/mail_environment/__manifest__.py b/mail_environment/__manifest__.py index 7bd19de9c..14a0f7075 100644 --- a/mail_environment/__manifest__.py +++ b/mail_environment/__manifest__.py @@ -10,4 +10,5 @@ "license": "AGPL-3", "website": "https://github.com/OCA/server-env", "depends": ["mail", "server_environment"], + "uninstall_hook": "uninstall_hook", } diff --git a/mail_environment/hooks.py b/mail_environment/hooks.py new file mode 100644 index 000000000..cadc8cb4c --- /dev/null +++ b/mail_environment/hooks.py @@ -0,0 +1,41 @@ +def uninstall_hook(env): + """Restore database columns that server.env.mixin dropped for mail models. + + When mail_environment is uninstalled, ``ir.mail_server`` and + ``fetchmail.server`` would be left without the columns that the ORM + dropped when this addon was first installed. This hook recreates those + columns and repopulates them with the current effective values so the + database remains usable after removal. + """ + mixin = env["server.env.mixin"] + mixin.restore_env_managed_columns( + "ir.mail_server", + [ + "smtp_host", + "smtp_port", + "smtp_user", + "smtp_pass", + "smtp_encryption", + "smtp_authentication", + ], + field_defaults={ + "smtp_authentication": "login", # Fallback for required field + }, + ) + mixin.restore_env_managed_columns( + "fetchmail.server", + [ + "server", + "port", + "server_type", + "user", + "password", + "is_ssl", + "attach", + "original", + ], + field_defaults={ + "server": "localhost", # Fallback for required field + "server_type": "imap", # Fallback for required field + }, + ) diff --git a/mail_environment/tests/test_mail_environment.py b/mail_environment/tests/test_mail_environment.py index 44c396b2c..de335d028 100644 --- a/mail_environment/tests/test_mail_environment.py +++ b/mail_environment/tests/test_mail_environment.py @@ -2,6 +2,8 @@ # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html) +from odoo.tools import sql + from odoo.addons.server_environment.tests.common import ServerEnvironmentCase fetchmail_config = """ @@ -75,3 +77,208 @@ def test_fetchmail_search_is_ssl(self): fetchmail2, self.env["fetchmail.server"].search([("is_ssl", "!=", True)]), ) + + +_outgoing_config = """ +[outgoing_mail.test_outgoing] +smtp_host = smtp.example.com +smtp_port = 587 +smtp_encryption = starttls +smtp_authentication = login +smtp_user = testuser +smtp_pass = testpass +""" + +_incoming_config = """ +[incoming_mail.test_incoming] +server = imap.example.com +port = 993 +server_type = imap +is_ssl = 1 +user = imap_user +password = imap_pass +attach = 1 +original = 0 +""" + + +class TestRestoreEnvManagedColumns(ServerEnvironmentCase): + """Test column restoration performed by the uninstall hook.""" + + def _drop_columns(self, model_name, field_names): + """Drop columns created by restore_env_managed_columns (test cleanup).""" + model = self.env[model_name] + cr = self.env.cr + for field_name in field_names: + if sql.column_exists(cr, model._table, field_name): + cr.execute( # noqa: S608 + f'ALTER TABLE {model._table} DROP COLUMN "{field_name}"' + ) + + def test_restore_ir_mail_server_columns(self): + """Outgoing mail columns are recreated and populated with config values.""" + field_names = [ + "smtp_host", + "smtp_port", + "smtp_encryption", + "smtp_authentication", + "smtp_user", + "smtp_pass", + ] + server = self.env["ir.mail_server"].create({"name": "test_outgoing"}) + try: + with self.load_config(public=_outgoing_config): + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", field_names + ) + table = self.env["ir.mail_server"]._table + for field_name in field_names: + self.assertTrue( + sql.column_exists(self.env.cr, table, field_name), + f"Column {field_name} was not created", + ) + self.env.cr.execute( + "SELECT smtp_host, smtp_port, smtp_encryption," + " smtp_authentication, smtp_user, smtp_pass" + " FROM ir_mail_server WHERE id = %s", + [server.id], + ) + row = self.env.cr.dictfetchone() + self.assertEqual(row["smtp_host"], "smtp.example.com") + self.assertEqual(row["smtp_port"], 587) + self.assertEqual(row["smtp_encryption"], "starttls") + self.assertEqual(row["smtp_authentication"], "login") + self.assertEqual(row["smtp_user"], "testuser") + self.assertEqual(row["smtp_pass"], "testpass") + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_ir_mail_server_columns_with_default(self): + """Columns are populated with default values when no config is loaded.""" + field_names = ["smtp_host", "smtp_port"] + # Write via the inverse to set the x_smtp_host_env_default sparse field. + server = self.env["ir.mail_server"].create( + {"name": "test_default_outgoing", "smtp_host": "default.example.com"} + ) + try: + # No config loaded — values come from x_smtp_host_env_default. + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", field_names + ) + table = self.env["ir.mail_server"]._table + self.assertTrue(sql.column_exists(self.env.cr, table, "smtp_host")) + self.env.cr.execute( + "SELECT smtp_host FROM ir_mail_server WHERE id = %s", + [server.id], + ) + self.assertEqual(self.env.cr.fetchone()[0], "default.example.com") + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_fetchmail_server_columns(self): + """Incoming mail columns are recreated and populated with config values.""" + field_names = [ + "server", + "port", + "server_type", + "is_ssl", + "user", + "password", + "attach", + "original", + ] + fetchmail = self.env["fetchmail.server"].create({"name": "test_incoming"}) + try: + with self.load_config(public=_incoming_config): + self.env["server.env.mixin"].restore_env_managed_columns( + "fetchmail.server", field_names + ) + table = self.env["fetchmail.server"]._table + for field_name in field_names: + self.assertTrue( + sql.column_exists(self.env.cr, table, field_name), + f"Column {field_name} was not created", + ) + self.env.cr.execute( + 'SELECT server, port, server_type, is_ssl, "user",' + " password, attach, original" + " FROM fetchmail_server WHERE id = %s", + [fetchmail.id], + ) + row = self.env.cr.dictfetchone() + self.assertEqual(row["server"], "imap.example.com") + self.assertEqual(row["port"], 993) + self.assertEqual(row["server_type"], "imap") + self.assertTrue(row["is_ssl"]) + self.assertEqual(row["user"], "imap_user") + self.assertEqual(row["password"], "imap_pass") + self.assertTrue(row["attach"]) + self.assertFalse(row["original"]) + finally: + self._drop_columns("fetchmail.server", field_names) + + def test_restore_env_managed_columns_idempotent(self): + """Calling restore_env_managed_columns twice is safe and idempotent.""" + field_names = ["smtp_host", "smtp_port"] + self.env["ir.mail_server"].create({"name": "test_idempotent"}) + try: + with self.load_config(public=_outgoing_config): + mixin = self.env["server.env.mixin"] + mixin.restore_env_managed_columns("ir.mail_server", field_names) + # Second call must not raise or corrupt data. + mixin.restore_env_managed_columns("ir.mail_server", field_names) + table = self.env["ir.mail_server"]._table + for field_name in field_names: + self.assertTrue(sql.column_exists(self.env.cr, table, field_name)) + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_env_managed_columns_with_fallback_defaults(self): + """Required fields can be restored with fallback values via field_defaults.""" + + field_names = ["smtp_authentication"] + server = self.env["ir.mail_server"].create({"name": "test_fallback"}) + try: + # smtp_authentication is required but we provide no config or default. + # Without field_defaults, this would raise UserError. + # With field_defaults, it should succeed. + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", + field_names, + field_defaults={"smtp_authentication": "login"}, + ) + table = self.env["ir.mail_server"]._table + column_exists = sql.column_exists(self.env.cr, table, "smtp_authentication") + self.assertTrue(column_exists) + self.env.cr.execute( + "SELECT smtp_authentication FROM ir_mail_server WHERE id = %s", + [server.id], + ) + self.assertEqual(self.env.cr.fetchone()[0], "login") + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_env_managed_columns_required_field_uses_model_default(self): + """Required fields can be restored from the model default when no fallback.""" + + field_names = ["smtp_authentication"] + server = self.env["ir.mail_server"].create({"name": "test_no_fallback"}) + try: + # On supported Odoo versions smtp_authentication is required and has + # a model default ('login'). Restoring without field_defaults should + # therefore succeed by using that effective value. + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", field_names + ) + self.env.cr.execute( + "SELECT smtp_authentication FROM ir_mail_server WHERE id = %s", + [server.id], + ) + self.assertEqual(self.env.cr.fetchone()[0], "login") + finally: + # Manually drop in case the column was created. + model = self.env["ir.mail_server"] + if sql.column_exists(self.env.cr, model._table, "smtp_authentication"): + self.env.cr.execute( # noqa: S608 + f'ALTER TABLE {model._table} DROP COLUMN "smtp_authentication"' + ) From 6b543ea806d2c141668abafccebdab9e08bc6f56 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Wed, 1 Apr 2026 13:41:59 +0200 Subject: [PATCH 2/4] [DON'T MERGE] test-requirements.txt --- test-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test-requirements.txt b/test-requirements.txt index 4ad8e0ece..f432fe7f2 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,2 @@ odoo-test-helper +odoo-addon-server_environment @ git+https://github.com/OCA/server-env@refs/pull/261/head#subdirectory=server_environment From fd46bb7007d9c65255002435089aaadc2490dd35 Mon Sep 17 00:00:00 2001 From: Tomasz Walter Date: Thu, 4 Jun 2026 13:54:16 +0200 Subject: [PATCH 3/4] fixup! [IMP] mail_environment: uninstall hook --- mail_environment/hooks.py | 11 ++--- .../tests/test_mail_environment.py | 49 ++++++++++++++----- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/mail_environment/hooks.py b/mail_environment/hooks.py index cadc8cb4c..c1236356b 100644 --- a/mail_environment/hooks.py +++ b/mail_environment/hooks.py @@ -6,6 +6,10 @@ def uninstall_hook(env): dropped when this addon was first installed. This hook recreates those columns and repopulates them with the current effective values so the database remains usable after removal. + + After uninstalling this module, an Odoo server restart is required. + Field definitions referencing the compute methods of the mixin persist in memory + until the Python process restarts. """ mixin = env["server.env.mixin"] mixin.restore_env_managed_columns( @@ -18,9 +22,6 @@ def uninstall_hook(env): "smtp_encryption", "smtp_authentication", ], - field_defaults={ - "smtp_authentication": "login", # Fallback for required field - }, ) mixin.restore_env_managed_columns( "fetchmail.server", @@ -34,8 +35,4 @@ def uninstall_hook(env): "attach", "original", ], - field_defaults={ - "server": "localhost", # Fallback for required field - "server_type": "imap", # Fallback for required field - }, ) diff --git a/mail_environment/tests/test_mail_environment.py b/mail_environment/tests/test_mail_environment.py index de335d028..ea0f4eaed 100644 --- a/mail_environment/tests/test_mail_environment.py +++ b/mail_environment/tests/test_mail_environment.py @@ -234,39 +234,62 @@ def test_restore_env_managed_columns_idempotent(self): self._drop_columns("ir.mail_server", field_names) def test_restore_env_managed_columns_with_fallback_defaults(self): - """Required fields can be restored with fallback values via field_defaults.""" + """Fields with no value can be restored with fallback values + via field_defaults.""" - field_names = ["smtp_authentication"] + field_names = ["smtp_host"] server = self.env["ir.mail_server"].create({"name": "test_fallback"}) try: - # smtp_authentication is required but we provide no config or default. - # Without field_defaults, this would raise UserError. - # With field_defaults, it should succeed. + # smtp_host has no config, no ORM default, and no env_default. + # With field_defaults, it should be populated with the fallback value. self.env["server.env.mixin"].restore_env_managed_columns( "ir.mail_server", field_names, - field_defaults={"smtp_authentication": "login"}, + field_defaults={"smtp_host": "fallback.example.com"}, ) table = self.env["ir.mail_server"]._table - column_exists = sql.column_exists(self.env.cr, table, "smtp_authentication") + column_exists = sql.column_exists(self.env.cr, table, "smtp_host") self.assertTrue(column_exists) self.env.cr.execute( - "SELECT smtp_authentication FROM ir_mail_server WHERE id = %s", + "SELECT smtp_host FROM ir_mail_server WHERE id = %s", [server.id], ) - self.assertEqual(self.env.cr.fetchone()[0], "login") + self.assertEqual(self.env.cr.fetchone()[0], "fallback.example.com") + finally: + self._drop_columns("ir.mail_server", field_names) + + def test_restore_env_managed_columns_no_fallback(self): + """Fields with no value and no fallback are set to NULL.""" + + field_names = ["smtp_host"] + server = self.env["ir.mail_server"].create({"name": "test_no_value"}) + try: + # smtp_host has no config, no default, and no field_defaults. + # The column should be created and set to NULL. + self.env["server.env.mixin"].restore_env_managed_columns( + "ir.mail_server", field_names + ) + table = self.env["ir.mail_server"]._table + column_exists = sql.column_exists(self.env.cr, table, "smtp_host") + self.assertTrue(column_exists) + self.env.cr.execute( + "SELECT smtp_host FROM ir_mail_server WHERE id = %s", + [server.id], + ) + self.assertIsNone(self.env.cr.fetchone()[0]) finally: self._drop_columns("ir.mail_server", field_names) def test_restore_env_managed_columns_required_field_uses_model_default(self): - """Required fields can be restored from the model default when no fallback.""" + """Fields with no value can be restored from the model default (if available) + when no fallback is provided.""" field_names = ["smtp_authentication"] server = self.env["ir.mail_server"].create({"name": "test_no_fallback"}) try: - # On supported Odoo versions smtp_authentication is required and has - # a model default ('login'). Restoring without field_defaults should - # therefore succeed by using that effective value. + # On supported Odoo versions smtp_authentication has a model default + # ('login'). Restoring without field_defaults should therefore succeed by + # using that effective value. self.env["server.env.mixin"].restore_env_managed_columns( "ir.mail_server", field_names ) From c857ef3ea1892e69f014e3e50bc669d44f780fb6 Mon Sep 17 00:00:00 2001 From: Tomasz Walter Date: Thu, 4 Jun 2026 14:35:43 +0200 Subject: [PATCH 4/4] Pre-commit auto fixes --- mail_environment/README.rst | 22 +++++------- .../static/description/index.html | 36 ++++++++----------- 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/mail_environment/README.rst b/mail_environment/README.rst index ed0aa41a8..d9afe0be5 100644 --- a/mail_environment/README.rst +++ b/mail_environment/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ========================================== Mail configuration with server_environment ========================================== @@ -17,7 +13,7 @@ Mail configuration with server_environment .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--env-lightgray.png?logo=github @@ -103,8 +99,8 @@ file. Known issues / Roadmap ====================== -- Due to the special nature of this addon, you cannot test it on the OCA - runbot. +- Due to the special nature of this addon, you cannot test it on the + OCA runbot. Bug Tracker =========== @@ -127,12 +123,12 @@ Authors Contributors ------------ -- Nicolas Bessi -- Yannick Vaucher -- Guewen Baconnier -- Joël Grand-Guillaume -- Holger Brunn -- Alexandre Fayolle +- Nicolas Bessi +- Yannick Vaucher +- Guewen Baconnier +- Joël Grand-Guillaume +- Holger Brunn +- Alexandre Fayolle Maintainers ----------- diff --git a/mail_environment/static/description/index.html b/mail_environment/static/description/index.html index be52611f6..d2571219a 100644 --- a/mail_environment/static/description/index.html +++ b/mail_environment/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Mail configuration with server_environment -
+
+

Mail configuration with server_environment

- - -Odoo Community Association - -
-

Mail configuration with server_environment

-

Beta License: AGPL-3 OCA/server-env Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/server-env Translate me on Weblate Try me on Runboat

This module allows to configure the incoming and outgoing mail servers using the server_environment mechanism: you can then have different mail servers for the production and the test environment.

@@ -395,12 +390,12 @@

Mail configuration with server_environment

-

Installation

+

Installation

To install this module, you need to have the server_environment module installed and properly configured.

-

Configuration

+

Configuration

With this module installed, the incoming and outgoing mail servers are configured in the server_environment_files module (which is a module you should provide, see the documentation of server_environment for more @@ -440,20 +435,20 @@

Configuration

mail server with the field name set to “odoo_pop_mail1”.

-

Usage

+

Usage

Once configured, Odoo will read the mail servers values from the configuration file related to each environment defined in the main Odoo file.

-

Known issues / Roadmap

+

Known issues / Roadmap

    -
  • Due to the special nature of this addon, you cannot test it on the OCA -runbot.
  • +
  • Due to the special nature of this addon, you cannot test it on the +OCA runbot.
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -461,15 +456,15 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -493,6 +488,5 @@

Maintainers

-