Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions mail_bulk_send/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2026 Quartile Limited
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from . import wizard
16 changes: 16 additions & 0 deletions mail_bulk_send/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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,
}
25 changes: 25 additions & 0 deletions mail_bulk_send/readme/CONFIGURE.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions mail_bulk_send/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- [Quartile](https://www.quartile.co):
- Toshikimi Shigenobu
9 changes: 9 additions & 0 deletions mail_bulk_send/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions mail_bulk_send/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions mail_bulk_send/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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
97 changes: 97 additions & 0 deletions mail_bulk_send/tests/test_mail_bulk_send_wizard.py
Original file line number Diff line number Diff line change
@@ -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": "<p>Hello</p>",
}
)
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": "<p>Hi</p>",
}
)
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")
31 changes: 31 additions & 0 deletions mail_bulk_send/views/mail_bulk_send_wizard_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="mail_bulk_send_wizard_view_form" model="ir.ui.view">
<field name="name">mail.bulk.send.wizard.form</field>
<field name="model">mail.bulk.send.wizard</field>
<field name="arch" type="xml">
<form string="Send Emails in Bulk">
<field name="active_model" invisible="1" />
<field name="template_locked" invisible="1" />
<field name="active_ids_json" invisible="1" />
<group>
<field
name="template_id"
domain="[('model', '=', active_model)]"
attrs="{'readonly': [('template_locked', '=', True)]}"
/>
<field name="record_count" string="Records to Send" />
</group>
<footer>
<button
name="action_send"
type="object"
string="Send Emails"
class="btn-primary"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
</odoo>
4 changes: 4 additions & 0 deletions mail_bulk_send/wizard/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2026 Quartile Limited
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from . import mail_bulk_send_wizard
57 changes: 57 additions & 0 deletions mail_bulk_send/wizard/mail_bulk_send_wizard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright 2026 Quartile Limited
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import json

from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError


class MailBulkSendWizard(models.TransientModel):
_name = "mail.bulk.send.wizard"
_description = "Bulk Email Send Wizard"

template_id = fields.Many2one(
"mail.template",
string="Email Template",
required=True,
)
# When pre-set via context (default_template_id), lock the field.
template_locked = fields.Boolean()
active_model = fields.Char(readonly=True)
# Store active_ids as a JSON list since the target model is unknown at
# definition time and cannot be expressed as a Many2many field.
active_ids_json = fields.Char()
record_count = fields.Integer(
string="Records",
compute="_compute_record_count",
)

@api.depends("active_ids_json")
def _compute_record_count(self):
for wizard in self:
wizard.record_count = len(json.loads(wizard.active_ids_json or "[]"))

@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
ctx = self.env.context
res["active_ids_json"] = json.dumps(ctx.get("active_ids", []))
res["active_model"] = ctx.get("active_model", "")
if ctx.get("default_template_id"):
res["template_locked"] = True
return res

def action_send(self):
if self.template_id.model != self.active_model:
raise ValidationError(
_("Template model '%s' does not match the selected records model '%s'.")
% (self.template_id.model, self.active_model)
)
active_ids = json.loads(self.active_ids_json or "[]")
records = self.env[self.active_model].browse(active_ids).exists()
sent = 0
for record in records:
self.template_id.send_mail(record.id, force_send=False)
sent += 1
return {"type": "ir.actions.act_window_close"}
1 change: 1 addition & 0 deletions setup/mail_bulk_send/odoo/addons/mail_bulk_send
6 changes: 6 additions & 0 deletions setup/mail_bulk_send/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
Loading