diff --git a/mail_bulk_send/__init__.py b/mail_bulk_send/__init__.py new file mode 100644 index 0000000..60381a5 --- /dev/null +++ b/mail_bulk_send/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import wizard diff --git a/mail_bulk_send/__manifest__.py b/mail_bulk_send/__manifest__.py new file mode 100644 index 0000000..c34ea25 --- /dev/null +++ b/mail_bulk_send/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Mail Bulk Send", + "category": "Email", + "version": "15.0.1.0.0", + "author": "Quartile Limited", + "website": "https://github.com/OCA/social", + "license": "AGPL-3", + "depends": ["mail"], + "data": [ + "security/ir.model.access.csv", + "views/mail_bulk_send_wizard_views.xml", + ], + "installable": True, +} diff --git a/mail_bulk_send/readme/CONFIGURE.md b/mail_bulk_send/readme/CONFIGURE.md new file mode 100644 index 0000000..44a64a6 --- /dev/null +++ b/mail_bulk_send/readme/CONFIGURE.md @@ -0,0 +1,25 @@ +Create an `ir.actions.server` record for each model you want to support. +Set **Action To Do** to *Execute Python Code* and enter the following, +replacing the template reference with the one you want to use: + +```python +action = { + "type": "ir.actions.act_window", + "res_model": "mail.bulk.send.wizard", + "view_mode": "form", + "target": "new", + "context": { + **env.context, + "default_template_id": env.ref("module_name.template_xml_id").id, + }, +} +``` + +- `**env.context` passes the selected record IDs and model to the wizard + automatically. +- `default_template_id` pre-selects the template and locks the field so + users cannot change it. Omit this key to let users choose the template + themselves. + +Set **Binding Model** to the target model and **Binding View Types** to +`list` so the action appears in the **Action** menu of the list view. diff --git a/mail_bulk_send/readme/CONTRIBUTORS.md b/mail_bulk_send/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..eddbbf3 --- /dev/null +++ b/mail_bulk_send/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Toshikimi Shigenobu diff --git a/mail_bulk_send/readme/DESCRIPTION.md b/mail_bulk_send/readme/DESCRIPTION.md new file mode 100644 index 0000000..8e6ed46 --- /dev/null +++ b/mail_bulk_send/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +This module provides a generic wizard to send emails in bulk using a mail +template. + +- Select multiple records from any list view. +- Choose a mail template (filtered to the current model). +- Queue emails for all selected records in one operation. + +The wizard can be triggered from any model by creating an +`ir.actions.server` record that opens it with the appropriate context. diff --git a/mail_bulk_send/security/ir.model.access.csv b/mail_bulk_send/security/ir.model.access.csv new file mode 100644 index 0000000..a204e35 --- /dev/null +++ b/mail_bulk_send/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mail_bulk_send_wizard,mail.bulk.send.wizard,model_mail_bulk_send_wizard,base.group_user,1,1,1,1 diff --git a/mail_bulk_send/tests/__init__.py b/mail_bulk_send/tests/__init__.py new file mode 100644 index 0000000..7cb6c68 --- /dev/null +++ b/mail_bulk_send/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_mail_bulk_send_wizard diff --git a/mail_bulk_send/tests/test_mail_bulk_send_wizard.py b/mail_bulk_send/tests/test_mail_bulk_send_wizard.py new file mode 100644 index 0000000..1a98de1 --- /dev/null +++ b/mail_bulk_send/tests/test_mail_bulk_send_wizard.py @@ -0,0 +1,97 @@ +# Copyright 2026 Quartile Limited +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +from unittest.mock import patch + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestMailBulkSendWizard(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.template = cls.env["mail.template"].create( + { + "name": "Test Bulk Send Template", + "model_id": cls.env["ir.model"]._get("res.partner").id, + "subject": "Test Subject", + "body_html": "
Hello
", + } + ) + cls.partner1 = cls.env["res.partner"].create( + {"name": "Bulk Partner 1", "email": "p1@example.com"} + ) + cls.partner2 = cls.env["res.partner"].create( + {"name": "Bulk Partner 2", "email": "p2@example.com"} + ) + + def _make_wizard(self, partner_ids, template=None, active_model="res.partner"): + template = template or self.template + ctx = { + "active_ids": partner_ids, + "active_model": active_model, + "default_template_id": template.id, + } + return ( + self.env["mail.bulk.send.wizard"] + .with_context(**ctx) + .create({"template_id": template.id}) + ) + + def test_default_get_populates_active_ids_json(self): + ctx = { + "active_ids": [self.partner1.id, self.partner2.id], + "active_model": "res.partner", + } + defaults = ( + self.env["mail.bulk.send.wizard"] + .with_context(**ctx) + .default_get(["active_ids_json", "active_model"]) + ) + self.assertEqual( + json.loads(defaults["active_ids_json"]), + [self.partner1.id, self.partner2.id], + ) + self.assertEqual(defaults["active_model"], "res.partner") + + def test_action_send_success_closes_wizard(self): + wizard = self._make_wizard([self.partner1.id, self.partner2.id]) + with patch.object( + type(self.env["mail.template"]), "send_mail", return_value=1 + ) as mock_send: + result = wizard.action_send() + self.assertEqual(mock_send.call_count, 2) + self.assertEqual(result["type"], "ir.actions.act_window_close") + + def test_action_send_template_model_mismatch_raises_validation_error(self): + other_template = self.env["mail.template"].create( + { + "name": "Users Template", + "model_id": self.env["ir.model"]._get("res.users").id, + "subject": "Hi", + "body_html": "Hi
", + } + ) + wizard = self._make_wizard( + [self.partner1.id], + template=other_template, + active_model="res.partner", + ) + with self.assertRaises(ValidationError): + wizard.action_send() + + def test_action_send_skips_deleted_records(self): + partner3 = self.env["res.partner"].create( + {"name": "To Delete", "email": "del@example.com"} + ) + deleted_id = partner3.id + partner3.unlink() + wizard = self._make_wizard([self.partner1.id, deleted_id]) + with patch.object( + type(self.env["mail.template"]), "send_mail", return_value=1 + ) as mock_send: + result = wizard.action_send() + self.assertEqual(mock_send.call_count, 1) + self.assertEqual(result["type"], "ir.actions.act_window_close") diff --git a/mail_bulk_send/views/mail_bulk_send_wizard_views.xml b/mail_bulk_send/views/mail_bulk_send_wizard_views.xml new file mode 100644 index 0000000..d10f9b4 --- /dev/null +++ b/mail_bulk_send/views/mail_bulk_send_wizard_views.xml @@ -0,0 +1,31 @@ + +