From 2a091854f4f8f4e640c6240a2a90e047b33c9b36 Mon Sep 17 00:00:00 2001 From: prayag Date: Tue, 8 Sep 2026 12:33:46 +0530 Subject: [PATCH] [ADD] mail_server_connection_alert --- mail_server_connection_alert/README.rst | 85 ++++ mail_server_connection_alert/__init__.py | 4 + mail_server_connection_alert/__manifest__.py | 18 + .../data/mail_alerts_channel.xml | 7 + .../data/test_connection.xml | 11 + .../models/__init__.py | 4 + .../models/ir_mail_server.py | 162 +++++++ mail_server_connection_alert/pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 2 + .../readme/DESCRIPTION.md | 3 + .../static/description/index.html | 432 ++++++++++++++++++ .../tests/__init__.py | 4 + .../tests/test_ir_mail_server.py | 274 +++++++++++ 13 files changed, 1009 insertions(+) create mode 100644 mail_server_connection_alert/README.rst create mode 100644 mail_server_connection_alert/__init__.py create mode 100644 mail_server_connection_alert/__manifest__.py create mode 100644 mail_server_connection_alert/data/mail_alerts_channel.xml create mode 100644 mail_server_connection_alert/data/test_connection.xml create mode 100644 mail_server_connection_alert/models/__init__.py create mode 100644 mail_server_connection_alert/models/ir_mail_server.py create mode 100644 mail_server_connection_alert/pyproject.toml create mode 100644 mail_server_connection_alert/readme/CONTRIBUTORS.md create mode 100644 mail_server_connection_alert/readme/DESCRIPTION.md create mode 100644 mail_server_connection_alert/static/description/index.html create mode 100644 mail_server_connection_alert/tests/__init__.py create mode 100644 mail_server_connection_alert/tests/test_ir_mail_server.py diff --git a/mail_server_connection_alert/README.rst b/mail_server_connection_alert/README.rst new file mode 100644 index 000000000..e67b04769 --- /dev/null +++ b/mail_server_connection_alert/README.rst @@ -0,0 +1,85 @@ +=================================== +Mail Server Connection Issue Alerts +=================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ee631b7d5ac8dd0a9c3f35d82823af1fc5cdb922db07cc826a767336b443348c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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%2Fmail-lightgray.png?logo=github + :target: https://github.com/OCA/mail/tree/18.0/mail_server_connection_alert + :alt: OCA/mail +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/mail-18-0/mail-18-0-mail_server_connection_alert + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/mail&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module periodically checks the connection status of incoming and +outgoing mail servers to detect failures early. It displays prominent +notifications to administrators whenever a mail server configuration +stops working. + +It uses the **``web_notify``** module (from +`OCA/web `__) to display sticky +notifications. It also creates a chatter channel for the same. + +**Table of contents** + +.. contents:: + :local: + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* CIT-Services + +Contributors +------------ + +- `CIT-Services `__ + + - Prayag + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/mail `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_server_connection_alert/__init__.py b/mail_server_connection_alert/__init__.py new file mode 100644 index 000000000..91e47f425 --- /dev/null +++ b/mail_server_connection_alert/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/mail_server_connection_alert/__manifest__.py b/mail_server_connection_alert/__manifest__.py new file mode 100644 index 000000000..86ced267d --- /dev/null +++ b/mail_server_connection_alert/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Mail Server Connection Issue Alerts", + "summary": "Incoming & Outgoing Mail Status Warning", + "version": "18.0.1.0.0", + "category": "Tools", + "website": "https://github.com/OCA/mail", + "author": "CIT-Services, Odoo Community Association (OCA)", + "license": "AGPL-3", + "installable": True, + "depends": ["base", "mail", "web_notify"], + "data": [ + "data/test_connection.xml", + "data/mail_alerts_channel.xml", + ], +} diff --git a/mail_server_connection_alert/data/mail_alerts_channel.xml b/mail_server_connection_alert/data/mail_alerts_channel.xml new file mode 100644 index 000000000..ff8429590 --- /dev/null +++ b/mail_server_connection_alert/data/mail_alerts_channel.xml @@ -0,0 +1,7 @@ + + + + ⚠️ System Mail Alerts + group + + diff --git a/mail_server_connection_alert/data/test_connection.xml b/mail_server_connection_alert/data/test_connection.xml new file mode 100644 index 000000000..7739d144d --- /dev/null +++ b/mail_server_connection_alert/data/test_connection.xml @@ -0,0 +1,11 @@ + + + + Test Connection + + code + model._process_reminders() + 1 + hours + + diff --git a/mail_server_connection_alert/models/__init__.py b/mail_server_connection_alert/models/__init__.py new file mode 100644 index 000000000..3d17e29aa --- /dev/null +++ b/mail_server_connection_alert/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import ir_mail_server diff --git a/mail_server_connection_alert/models/ir_mail_server.py b/mail_server_connection_alert/models/ir_mail_server.py new file mode 100644 index 000000000..509475ae0 --- /dev/null +++ b/mail_server_connection_alert/models/ir_mail_server.py @@ -0,0 +1,162 @@ +# Copyright 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from markupsafe import Markup + +from odoo import Command, api, models + +_logger = logging.getLogger(__name__) + + +class IrMailServer(models.Model): + _inherit = "ir.mail_server" + + def _format_failure_item(self, label, message, url=False): + if url: + bus_item = Markup( + f'[{label}] {message}' + ) + list_item = Markup(f'
  • {message}
  • ') + else: + bus_item = f"[{label}] {message}" + list_item = Markup(f"
  • {message}
  • ") + return bus_item, list_item + + @api.model + def _process_reminders(self): + failures = { + "outgoing": {"web_notify_data": [], "channel_data": []}, + "incoming": {"web_notify_data": [], "channel_data": []}, + } + + base_url = self.get_base_url() + + active_outgoing = self.search([("active", "=", True)]) + if not active_outgoing: + bus_item, list_item = self._format_failure_item( + "Outgoing", + "No active outgoing mail servers found in system settings.", + ) + failures["outgoing"]["web_notify_data"].append(bus_item) + failures["outgoing"]["channel_data"].append(list_item) + else: + for server in active_outgoing: + try: + server.test_smtp_connection() + except Exception as e: + _logger.warning( + "Outgoing mail server %s (ID: %s) connection failed: %s", + server.name, + server.id, + e, + ) + bus_item, list_item = self._format_failure_item( + "Outgoing", + f"{server.name} (ID: {server.id}) is not working properly.", + url=f"{base_url}/odoo/{server._name}/{server.id}", + ) + failures["outgoing"]["web_notify_data"].append(bus_item) + failures["outgoing"]["channel_data"].append(list_item) + + incoming_servers = self.env["fetchmail.server"].search( + [ + ("active", "=", True), + ("server_type", "!=", "local"), + ("state", "=", "done"), + ] + ) + for inc_server in incoming_servers: + connection = None + try: + connection = inc_server.connect() + except Exception as e: + _logger.warning( + "Incoming mail server %s (ID: %s) connection failed: %s", + inc_server.name, + inc_server.id, + e, + ) + bus_item, list_item = self._format_failure_item( + "Incoming", + f"{inc_server.name} (ID: {inc_server.id}) is not working properly.", + url=f"{base_url}/odoo/{inc_server._name}/{inc_server.id}", + ) + failures["incoming"]["web_notify_data"].append(bus_item) + failures["incoming"]["channel_data"].append(list_item) + finally: + try: + if connection: + connection_type = inc_server.server_type + if connection_type == "imap": + connection.close() + elif connection_type == "pop": + connection.quit() + except Exception as e: + _logger.debug("Failed to close incoming mail connection: %s", e) + + if ( + failures["outgoing"]["web_notify_data"] + or failures["incoming"]["web_notify_data"] + ): + self._notify_system_admins(failures) + + def _notify_system_admins(self, failures): + admin_group = self.env.ref("base.group_system") + admin_users = admin_group.users + if not admin_users: + return + admin_partners = admin_users.mapped("partner_id") + odoobot_partner = self.env.ref("base.partner_root") + all_members = admin_partners | odoobot_partner + + outgoing = failures.get("outgoing", {}) + incoming = failures.get("incoming", {}) + + if outgoing.get("web_notify_data"): + admin_users.sudo().notify_danger( + title="⚠️ Outgoing Mail System Warning", + message=Markup("
    ").join(outgoing.get("web_notify_data")), + sticky=True, + html=True, + ) + if incoming.get("web_notify_data"): + admin_users.sudo().notify_danger( + title="⚠️ Incoming Mail System Warning", + message=Markup("
    ").join(incoming.get("web_notify_data")), + sticky=True, + html=True, + ) + + channel = self.env.ref( + "mail_server_connection_alert.channel_system_mail_alerts" + ).sudo() + + channel.channel_partner_ids = [Command.link(pid) for pid in all_members.ids] + + html_sections = [] + if outgoing.get("channel_data"): + html_sections.append( + f"

    Outgoing Mail Server(s):

      " + f"{''.join(outgoing.get('channel_data'))}
    " + ) + if incoming.get("channel_data"): + prefix = "
    " if html_sections else "" + html_sections.append( + f"{prefix}

    Incoming Mail Server(s):

      " + f"{''.join(incoming.get('channel_data'))}
    " + ) + + formatted_body = Markup( + "

    ⚠️ Mail System Warning


    " + "".join(html_sections) + ) + + channel.sudo().message_post( + body=formatted_body, + author_id=odoobot_partner.id, + message_type="comment", + subtype_xmlid="mail.mt_comment", + silent=True, + ) diff --git a/mail_server_connection_alert/pyproject.toml b/mail_server_connection_alert/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/mail_server_connection_alert/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/mail_server_connection_alert/readme/CONTRIBUTORS.md b/mail_server_connection_alert/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..4b54f31c5 --- /dev/null +++ b/mail_server_connection_alert/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [CIT-Services](cit-services.eu) + - Prayag \<\> diff --git a/mail_server_connection_alert/readme/DESCRIPTION.md b/mail_server_connection_alert/readme/DESCRIPTION.md new file mode 100644 index 000000000..5329135d0 --- /dev/null +++ b/mail_server_connection_alert/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module periodically checks the connection status of incoming and outgoing mail servers to detect failures early. It displays prominent notifications to administrators whenever a mail server configuration stops working. + +It uses the **`web_notify`** module (from [OCA/web](https://github.com/OCA/web)) to display sticky notifications. It also creates a chatter channel for the same. diff --git a/mail_server_connection_alert/static/description/index.html b/mail_server_connection_alert/static/description/index.html new file mode 100644 index 000000000..3245ce04c --- /dev/null +++ b/mail_server_connection_alert/static/description/index.html @@ -0,0 +1,432 @@ + + + + + +Mail Server Connection Issue Alerts + + + +
    +

    Mail Server Connection Issue Alerts

    + + +

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

    +

    This module periodically checks the connection status of incoming and +outgoing mail servers to detect failures early. It displays prominent +notifications to administrators whenever a mail server configuration +stops working.

    +

    It uses the ``web_notify`` module (from +OCA/web) to display sticky +notifications. It also creates a chatter channel for the same.

    +

    Table of contents

    + +
    +

    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 +feedback.

    +

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

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • CIT-Services
    • +
    +
    + +
    +

    Maintainers

    +

    This module is maintained by the OCA.

    + +Odoo Community Association + +

    OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

    +

    This module is part of the OCA/mail project on GitHub.

    +

    You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

    +
    +
    +
    + + diff --git a/mail_server_connection_alert/tests/__init__.py b/mail_server_connection_alert/tests/__init__.py new file mode 100644 index 000000000..df315c524 --- /dev/null +++ b/mail_server_connection_alert/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_ir_mail_server diff --git a/mail_server_connection_alert/tests/test_ir_mail_server.py b/mail_server_connection_alert/tests/test_ir_mail_server.py new file mode 100644 index 000000000..e5a5c2fed --- /dev/null +++ b/mail_server_connection_alert/tests/test_ir_mail_server.py @@ -0,0 +1,274 @@ +# Copyright 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import MagicMock, PropertyMock, patch + +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestIrMailServerReminders(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.admin_group = cls.env.ref("base.group_system") + cls.admin_user = cls.env["res.users"].create( + { + "name": "Mail Test Admin", + "login": "mail_test_admin", + "email": "admin@example.com", + "groups_id": [(6, 0, [cls.admin_group.id])], + } + ) + + cls.outgoing_server = cls.env["ir.mail_server"].create( + { + "name": "Test Outgoing Server", + "smtp_host": "smtp.test.com", + "smtp_port": 25, + "active": True, + } + ) + + cls.incoming_server = cls.env["fetchmail.server"].create( + { + "name": "Test Incoming Server", + "server_type": "imap", + "server": "imap.test.com", + "active": True, + "state": "done", + } + ) + + @mute_logger("odoo.addons.mail_server_connection_alert.models.ir_mail_server") + def test_process_reminders_detects_failures_and_notifies(self): + mail_server_cls = type(self.env["ir.mail_server"]) + fetchmail_cls = type(self.env["fetchmail.server"]) + bus_cls = type(self.env["bus.bus"]) + + with ( + patch.object( + mail_server_cls, + "test_smtp_connection", + side_effect=Exception("SMTP Auth Failed"), + ), + patch.object( + fetchmail_cls, "connect", side_effect=Exception("IMAP Timeout") + ), + patch.object(bus_cls, "_sendone") as mock_sendone, + ): + self.env["ir.mail_server"]._process_reminders() + + self.assertTrue(mock_sendone.called) + sent_types = [call[0][1] for call in mock_sendone.call_args_list] + self.assertIn("web_notify", sent_types) + + web_notify_payloads = [ + call[0][2] + for call in mock_sendone.call_args_list + if call[0][1] == "web_notify" + ] + self.assertTrue(web_notify_payloads) + self.assertTrue(all(p.get("type") == "danger" for p in web_notify_payloads)) + self.assertTrue(all(p.get("html") is True for p in web_notify_payloads)) + self.assertTrue(all(p.get("sticky") is True for p in web_notify_payloads)) + + channel = self.env["discuss.channel"].search( + [("name", "=", "⚠️ System Mail Alerts")], limit=1 + ) + self.assertTrue(channel, "Alert channel should be created.") + self.assertIn( + self.admin_user.partner_id, + channel.channel_partner_ids, + "Admin partner must be in the channel.", + ) + + latest_message = channel.message_ids.sorted("id", reverse=True)[:1] + self.assertTrue(latest_message, "A warning message should be posted.") + self.assertIn("Test Outgoing Server", latest_message.body) + self.assertIn("Test Incoming Server", latest_message.body) + + @mute_logger("odoo.addons.mail_server_connection_alert.models.ir_mail_server") + def test_process_reminders_only_incoming_fails(self): + mail_server_cls = type(self.env["ir.mail_server"]) + fetchmail_cls = type(self.env["fetchmail.server"]) + bus_cls = type(self.env["bus.bus"]) + + with ( + patch.object(mail_server_cls, "test_smtp_connection", return_value=True), + patch.object( + fetchmail_cls, "connect", side_effect=Exception("IMAP Failure") + ), + patch.object(bus_cls, "_sendone") as mock_sendone, + ): + self.env["ir.mail_server"]._process_reminders() + + self.assertTrue(mock_sendone.called) + channel = self.env.ref( + "mail_server_connection_alert.channel_system_mail_alerts" + ) + latest_message = channel.message_ids.sorted("id", reverse=True)[:1] + self.assertTrue(latest_message) + self.assertIn("Test Incoming Server", latest_message.body) + self.assertNotIn("Outgoing Mail Server(s)", latest_message.body) + + @mute_logger("odoo.addons.mail_server_connection_alert.models.ir_mail_server") + def test_process_reminders_no_active_outgoing_server(self): + self.env["ir.mail_server"].search([("active", "=", True)]).write( + {"active": False} + ) + fetchmail_cls = type(self.env["fetchmail.server"]) + bus_cls = type(self.env["bus.bus"]) + + with ( + patch.object(fetchmail_cls, "connect", return_value=MagicMock()), + patch.object(bus_cls, "_sendone") as mock_sendone, + ): + self.env["ir.mail_server"]._process_reminders() + + self.assertTrue(mock_sendone.called) + web_notify_payloads = [ + call[0][2] + for call in mock_sendone.call_args_list + if call[0][1] == "web_notify" + ] + self.assertTrue(web_notify_payloads) + self.assertTrue( + any( + "No active outgoing mail servers found" in p.get("message", "") + for p in web_notify_payloads + ) + ) + + channel = self.env.ref( + "mail_server_connection_alert.channel_system_mail_alerts" + ) + latest_message = channel.message_ids.sorted("id", reverse=True)[:1] + self.assertTrue(latest_message) + self.assertIn( + "No active outgoing mail servers found in system settings.", + latest_message.body, + ) + + def test_process_reminders_success_no_alerts(self): + mail_server_cls = type(self.env["ir.mail_server"]) + fetchmail_cls = type(self.env["fetchmail.server"]) + bus_cls = type(self.env["bus.bus"]) + + with ( + patch.object(mail_server_cls, "test_smtp_connection", return_value=True), + patch.object(fetchmail_cls, "connect", return_value=MagicMock()), + patch.object(bus_cls, "_sendone") as mock_sendone, + ): + self.env["ir.mail_server"]._process_reminders() + + mock_sendone.assert_not_called() + + def test_incoming_mail_connection_cleanup_imap(self): + fetchmail_cls = type(self.env["fetchmail.server"]) + mock_connection = MagicMock() + + with ( + patch.object( + type(self.env["ir.mail_server"]), + "test_smtp_connection", + return_value=True, + ), + patch.object(fetchmail_cls, "search", return_value=self.incoming_server), + patch.object(fetchmail_cls, "connect", return_value=mock_connection), + ): + self.env["ir.mail_server"]._process_reminders() + + mock_connection.close.assert_called_once() + mock_connection.quit.assert_not_called() + + def test_incoming_mail_connection_cleanup_pop(self): + fetchmail_cls = type(self.env["fetchmail.server"]) + pop_server = self.env["fetchmail.server"].create( + { + "name": "Test POP Server", + "server_type": "pop", + "server": "pop.test.com", + "active": True, + "state": "done", + } + ) + mock_connection = MagicMock() + + with ( + patch.object( + type(self.env["ir.mail_server"]), + "test_smtp_connection", + return_value=True, + ), + patch.object(fetchmail_cls, "search", return_value=pop_server), + patch.object(fetchmail_cls, "connect", return_value=mock_connection), + ): + self.env["ir.mail_server"]._process_reminders() + + mock_connection.quit.assert_called_once() + mock_connection.close.assert_not_called() + + @mute_logger("odoo.addons.mail_server_connection_alert.models.ir_mail_server") + def test_incoming_mail_connection_cleanup_error_handled(self): + fetchmail_cls = type(self.env["fetchmail.server"]) + mock_connection = MagicMock() + mock_connection.close.side_effect = Exception("Socket teardown failed") + + with ( + patch.object( + type(self.env["ir.mail_server"]), + "test_smtp_connection", + return_value=True, + ), + patch.object(fetchmail_cls, "search", return_value=self.incoming_server), + patch.object(fetchmail_cls, "connect", return_value=mock_connection), + ): + self.env["ir.mail_server"]._process_reminders() + mock_connection.close.assert_called_once() + + def test_incoming_mail_connection_cleanup_other_type(self): + fetchmail_cls = type(self.env["fetchmail.server"]) + mock_connection = MagicMock() + + with ( + patch.object( + type(self.env["ir.mail_server"]), + "test_smtp_connection", + return_value=True, + ), + patch.object(fetchmail_cls, "search", return_value=self.incoming_server), + patch.object(fetchmail_cls, "connect", return_value=mock_connection), + patch.object( + fetchmail_cls, + "server_type", + new_callable=PropertyMock, + return_value="other", + ), + ): + self.env["ir.mail_server"]._process_reminders() + + mock_connection.close.assert_not_called() + mock_connection.quit.assert_not_called() + + def test_notify_system_admins_no_admin_users(self): + failures = { + "outgoing": { + "web_notify_data": ["Test"], + "channel_data": ["
  • Test
  • "], + }, + "incoming": {"web_notify_data": [], "channel_data": []}, + } + with ( + patch.object( + type(self.env.ref("base.group_system")), + "users", + new_callable=PropertyMock, + return_value=self.env["res.users"], + ), + patch.object( + type(self.env["discuss.channel"]), "message_post" + ) as mock_post, + ): + self.env["ir.mail_server"]._notify_system_admins(failures) + mock_post.assert_not_called()