diff --git a/mail_post_defer/README.rst b/mail_post_defer/README.rst new file mode 100644 index 000000000..38929e877 --- /dev/null +++ b/mail_post_defer/README.rst @@ -0,0 +1,35 @@ +**This file is going to be generated by oca-gen-addon-readme.** + +*Manual changes will be overwritten.* + +Please provide content in the ``readme`` directory: + +* **DESCRIPTION.rst** (required) +* INSTALL.rst (optional) +* CONFIGURE.rst (optional) +* **USAGE.rst** (optional, highly recommended) +* DEVELOP.rst (optional) +* ROADMAP.rst (optional) +* HISTORY.rst (optional, recommended) +* **CONTRIBUTORS.rst** (optional, highly recommended) +* CREDITS.rst (optional) + +Content of this README will also be drawn from the addon manifest, +from keys such as name, authors, maintainers, development_status, +and license. + +A good, one sentence summary in the manifest is also highly recommended. + + +Automatic changelog generation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +`HISTORY.rst` can be auto generated using `towncrier `_. + +Just put towncrier compatible changelog fragments into `readme/newsfragments` +and the changelog file will be automatically generated and updated when a new fragment is added. + +Please refer to `towncrier` documentation to know more. + +NOTE: the changelog will be automatically generated when using `/ocabot merge $option`. +If you need to run it manually, refer to `OCA/maintainer-tools README `_. diff --git a/mail_post_defer/__init__.py b/mail_post_defer/__init__.py new file mode 100644 index 000000000..cc6b6354a --- /dev/null +++ b/mail_post_defer/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import post_init_hook diff --git a/mail_post_defer/__manifest__.py b/mail_post_defer/__manifest__.py new file mode 100644 index 000000000..9a6ecf264 --- /dev/null +++ b/mail_post_defer/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2022-2023 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +{ + "name": "Deferred Message Posting", + "summary": "Faster and cancellable outgoing messages", + "version": "15.0.1.0.0", + "development_status": "Alpha", + "category": "Productivity/Discuss", + "website": "https://github.com/OCA/social", + "author": "Moduon, Odoo Community Association (OCA)", + "maintainers": ["Yajo"], + "license": "LGPL-3", + "depends": [ + "mail", + ], + "post_init_hook": "post_init_hook", + "assets": { + "web.assets_backend": [ + "mail_post_defer/static/src/**/*.js", + ], + "web.assets_qweb": [ + "mail_post_defer/static/src/**/*.xml", + ], + }, +} diff --git a/mail_post_defer/hooks.py b/mail_post_defer/hooks.py new file mode 100644 index 000000000..5b51bc922 --- /dev/null +++ b/mail_post_defer/hooks.py @@ -0,0 +1,23 @@ +# Copyright 2022-2023 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def post_init_hook(cr, registry): + """Increase cadence of mail queue cron.""" + env = api.Environment(cr, SUPERUSER_ID, {}) + try: + cron = env.ref("mail.ir_cron_mail_scheduler_action") + except ValueError: + _logger.warning( + "Couldn't find the standard mail scheduler cron. " + "Maybe no mails will be ever sent!" + ) + else: + _logger.info("Setting mail queue cron cadence to 1 minute") + cron.interval_number = 1 + cron.interval_type = "minutes" diff --git a/mail_post_defer/models/__init__.py b/mail_post_defer/models/__init__.py new file mode 100644 index 000000000..eccc2881b --- /dev/null +++ b/mail_post_defer/models/__init__.py @@ -0,0 +1,2 @@ +from . import mail_message +from . import mail_thread diff --git a/mail_post_defer/models/mail_message.py b/mail_post_defer/models/mail_message.py new file mode 100644 index 000000000..2da8085df --- /dev/null +++ b/mail_post_defer/models/mail_message.py @@ -0,0 +1,18 @@ +# Copyright 2022-2023 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class MailMessage(models.Model): + _inherit = "mail.message" + + def _cleanup_side_records(self): + """Delete pending outgoing mails.""" + self.mail_ids.filtered(lambda mail: mail.state == "outgoing").unlink() + return super()._cleanup_side_records() + + def _update_content(self, body, attachment_ids): + """Let checker know about empty body.""" + _self = self.with_context(deleting=body == "") + return super(MailMessage, _self)._update_content(body, attachment_ids) diff --git a/mail_post_defer/models/mail_thread.py b/mail_post_defer/models/mail_thread.py new file mode 100644 index 000000000..cb213a7b8 --- /dev/null +++ b/mail_post_defer/models/mail_thread.py @@ -0,0 +1,43 @@ +# Copyright 2022-2023 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from datetime import timedelta + +from odoo import fields, models + + +class MailThread(models.AbstractModel): + _inherit = "mail.thread" + + def message_post(self, **kwargs): + """Post messages using queue by default.""" + _self = self + force_send = self.env.context.get("mail_notify_force_send") or kwargs.get( + "force_send", False + ) + kwargs.setdefault("force_send", force_send) + if not force_send: + # If deferring message, give the user some minimal time to revert it + _self = self.with_context(mail_defer_seconds=30) + return super(MailThread, _self).message_post(**kwargs) + + def _notify_by_email_add_values(self, base_mail_values): + """Defer emails by default.""" + result = super()._notify_by_email_add_values(base_mail_values) + defer_seconds = self.env.context.get("mail_defer_seconds") + if defer_seconds: + result.setdefault( + "scheduled_date", + fields.Datetime.now() + timedelta(seconds=defer_seconds), + ) + return result + + def _check_can_update_message_content(self, message): + """Allow deleting unsent mails.""" + if ( + self.env.context.get("deleting") + and set(message.notification_ids.mapped("notification_status")) == {"ready"} + and set(message.mail_ids.mapped("state")) == {"outgoing"} + ): + return + return super()._check_can_update_message_content(message) diff --git a/mail_post_defer/readme/CONFIGURE.rst b/mail_post_defer/readme/CONFIGURE.rst new file mode 100644 index 000000000..c70ccb340 --- /dev/null +++ b/mail_post_defer/readme/CONFIGURE.rst @@ -0,0 +1,15 @@ +You need to do nothing. The module is configured appropriately out of the box. + +The mail queue processing is made by a cron job. This is normal Odoo behavior, +not specific to this module. However, since you will start using that queue for +every message posted by any user in any thread, this module configures that job +to execute every minute by default. + +You can still change that cadence after installing the module (although it is +not recommended). To do so: + +#. Log in with an administrator user. +#. Activate developer mode. +#. Go to *Settings > Technical > Automation > Scheduled Actions*. +#. Edit the action named "Mail: Email Queue Manager". +#. Lower down the frequency in the field *Execute Every*. Recommended: 1 minute. diff --git a/mail_post_defer/readme/CONTRIBUTORS.rst b/mail_post_defer/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..c5eed53c6 --- /dev/null +++ b/mail_post_defer/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Jairo Llopis (https://www.moduon.team/) diff --git a/mail_post_defer/readme/DESCRIPTION.rst b/mail_post_defer/readme/DESCRIPTION.rst new file mode 100644 index 000000000..69740ce49 --- /dev/null +++ b/mail_post_defer/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +This module enhances mail threads by using the mail queue by default. + +Without this module, Odoo attempts to notify recipients of your message immediately. +If your mail server is slow or you have many followers, this can mean a lot of time. +Install this module and make Odoo more snappy! + +All emails will be kept in the outgoing queue by at least 30 seconds, +giving you some time to re-think what you wrote. During that time, +you can still delete the message and start again. diff --git a/mail_post_defer/readme/ROADMAP.rst b/mail_post_defer/readme/ROADMAP.rst new file mode 100644 index 000000000..e02d4e0d8 --- /dev/null +++ b/mail_post_defer/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +* Add minimal deferring time configuration if it ever becomes necessary. See + https://github.com/OCA/social/pull/1001#issuecomment-1461581573 for the + rationale behind current hardcoded value of 30 seconds. diff --git a/mail_post_defer/readme/USAGE.rst b/mail_post_defer/readme/USAGE.rst new file mode 100644 index 000000000..95b7a47af --- /dev/null +++ b/mail_post_defer/readme/USAGE.rst @@ -0,0 +1,15 @@ +To use this module, you need to: + +#. Go to the form view of any record that has a mail thread. It can be a partner, for example. +#. Post a message. + +The mail is now in the outgoing mail queue. It will be there for at least 30 +seconds. It will be really sent the next time the "Mail: Email Queue Manager" +cron job is executed. + +While the message has not been yet sent: + +#. Hover over the little envelope. You will see a paper airplane icon, + indicating it is still outgoing. +#. Hover over the message and click on the little trash icon to delete it. + Mails will not be sent. diff --git a/mail_post_defer/static/description/icon.png b/mail_post_defer/static/description/icon.png new file mode 100644 index 000000000..94a2bcd5e Binary files /dev/null and b/mail_post_defer/static/description/icon.png differ diff --git a/mail_post_defer/static/src/js/message.esm.js b/mail_post_defer/static/src/js/message.esm.js new file mode 100644 index 000000000..7079cb8c6 --- /dev/null +++ b/mail_post_defer/static/src/js/message.esm.js @@ -0,0 +1,48 @@ +/** @odoo-module **/ +import { + registerFieldPatchModel, + registerInstancePatchModel, +} from "@mail/model/model_core"; +import {attr} from "@mail/model/model_field"; + +registerInstancePatchModel("mail.message", "mail_post_defer.message", { + xmlDependencies: ["/mail_post_defer/static/src/xml/message.xml"], + + /** + * Allow deleting deferred messages + * + * @param {Boolean} editing Set `true` to know if you can edit the message + * @returns {Boolean} + */ + _computeCanBeDeleted(editing) { + return ( + this._super() || + (!editing && + this.notifications.filter( + (current) => current.notification_status !== "ready" + ).length === 0) + ); + }, + + /** + * Allow editing messages. + * + * Upstream Odoo allows editing any message that can be deleted. We do the + * same here. However, if the message is a public message that is deferred, + * it can be edited but not deleted. + * + * @returns {Boolean} + */ + _computeCanBeEdited() { + return this._computeCanBeDeleted(true); + }, +}); + +registerFieldPatchModel("mail.message", "mail_post_defer.message", { + /** + * Whether this message can be edited. + */ + canBeEdited: attr({ + compute: "_computeCanBeEdited", + }), +}); diff --git a/mail_post_defer/static/src/xml/message.xml b/mail_post_defer/static/src/xml/message.xml new file mode 100644 index 000000000..0af2c23cc --- /dev/null +++ b/mail_post_defer/static/src/xml/message.xml @@ -0,0 +1,13 @@ + + + diff --git a/mail_post_defer/tests/__init__.py b/mail_post_defer/tests/__init__.py new file mode 100644 index 000000000..f8340d864 --- /dev/null +++ b/mail_post_defer/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_install +from . import test_mail diff --git a/mail_post_defer/tests/test_install.py b/mail_post_defer/tests/test_install.py new file mode 100644 index 000000000..c83fd2ddb --- /dev/null +++ b/mail_post_defer/tests/test_install.py @@ -0,0 +1,12 @@ +# Copyright 2022-2023 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo.tests.common import TransactionCase + + +class InstallationCase(TransactionCase): + def test_cron_cadence(self): + """Test that the post_init_hook was properly executed.""" + cron = self.env.ref("mail.ir_cron_mail_scheduler_action") + cadence = cron.interval_number, cron.interval_type + self.assertEqual(cadence, (1, "minutes")) diff --git a/mail_post_defer/tests/test_mail.py b/mail_post_defer/tests/test_mail.py new file mode 100644 index 000000000..a33251841 --- /dev/null +++ b/mail_post_defer/tests/test_mail.py @@ -0,0 +1,159 @@ +# Copyright 2022-2023 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +import freezegun + +from odoo.exceptions import UserError + +from odoo.addons.mail.tests.common import MailCommon + + +@freezegun.freeze_time("2023-01-02 10:00:00") +class MessagePostCase(MailCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_portal_user() + # Notify employee by email + cls.user_employee.notification_type = "email" + + def test_standard(self): + """A normal call just uses the queue by default.""" + with self.mock_mail_gateway(): + self.partner_portal.message_post( + body="test body", + subject="test subject", + message_type="comment", + partner_ids=self.partner_employee.ids, + ) + self.assertMailMail( + self.partner_employee, + "outgoing", + author=self.env.user.partner_id, + content="test body", + fields_values={"scheduled_date": "2023-01-02 10:00:30"}, + ) + + def test_forced_arg(self): + """A forced send via method argument is sent directly.""" + with self.mock_mail_gateway(): + self.partner_portal.message_post( + body="test body", + subject="test subject", + message_type="comment", + partner_ids=self.partner_employee.ids, + force_send=True, + ) + self.assertMailMail( + self.partner_employee, + "sent", + author=self.env.user.partner_id, + content="test body", + fields_values={"scheduled_date": False}, + ) + + def test_forced_context(self): + """A forced send via context is sent directly.""" + with self.mock_mail_gateway(): + self.partner_portal.with_context(mail_notify_force_send=True).message_post( + body="test body", + subject="test subject", + message_type="comment", + partner_ids=self.partner_employee.ids, + ) + self.assertMailMail( + self.partner_employee, + "sent", + author=self.env.user.partner_id, + content="test body", + fields_values={"scheduled_date": False}, + ) + + def test_no_msg_edit(self): + """Cannot update messages. + + This is normal upstream Odoo behavior. It is not a feature of this + module, but it is important to make sure this protection is still + respected, because we disable it for queued message deletion. + + A non-malicious end user won't get to this code because the edit button + is hidden. Still, the server-side protection is important. + + If, at some point, this module is improved to support this use case, + then this test should change; and that would be a good thing probably. + """ + with self.mock_mail_gateway(): + msg = self.partner_portal.message_post( + body="test body", + subject="test subject", + message_type="comment", + partner_ids=self.partner_employee.ids, + subtype_xmlid="mail.mt_comment", + ) + # Emulate user clicking on edit button and going through the + # `/mail/message/update_content` controller + with self.assertRaises(UserError): + msg._update_content("new body", []) + self.assertMailMail( + self.partner_employee, + "outgoing", + author=self.env.user.partner_id, + content="test body", + fields_values={"scheduled_date": "2023-01-02 10:00:30"}, + ) + + def test_queued_msg_delete(self): + """A user can delete a message before it's sent.""" + with self.mock_mail_gateway(): + msg = self.partner_portal.message_post( + body="test body", + subject="test subject", + message_type="comment", + partner_ids=self.partner_employee.ids, + subtype_xmlid="mail.mt_comment", + ) + # Emulate user clicking on delete button and going through the + # `/mail/message/update_content` controller + msg._update_content("", []) + self.assertNoMail( + self.partner_employee, + author=self.env.user.partner_id, + ) + # One minute later, the cron has no mails to send + with freezegun.freeze_time("2023-01-02 10:01:00"): + self.env["mail.mail"].process_email_queue() + self.assertNoMail( + self.partner_employee, + author=self.env.user.partner_id, + ) + + def test_no_sent_msg_delete(self): + """A user cannot delete a message after it's sent. + + Usually, the trash button will be hidden in UI if the message is sent. + However, the server-side protection is still important, because there + can be a race condition when the mail is sent in the background but + the user didn't refresh the view. + """ + with self.mock_mail_gateway(): + msg = self.partner_portal.message_post( + body="test body", + subject="test subject", + message_type="comment", + partner_ids=self.partner_employee.ids, + subtype_xmlid="mail.mt_comment", + ) + # One minute later, the cron sends the mail + with freezegun.freeze_time("2023-01-02 10:01:00"): + self.env["mail.mail"].process_email_queue() + self.assertMailMail( + self.partner_employee, + "sent", + author=self.env.user.partner_id, + content="test body", + fields_values={"scheduled_date": "2023-01-02 10:00:30"}, + ) + # Emulate user clicking on delete button and going through the + # `/mail/message/update_content` controller + with self.assertRaises(UserError): + msg._update_content("", []) diff --git a/setup/mail_post_defer/odoo/addons/mail_post_defer b/setup/mail_post_defer/odoo/addons/mail_post_defer new file mode 120000 index 000000000..68bdda7fc --- /dev/null +++ b/setup/mail_post_defer/odoo/addons/mail_post_defer @@ -0,0 +1 @@ +../../../../mail_post_defer \ No newline at end of file diff --git a/setup/mail_post_defer/setup.py b/setup/mail_post_defer/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/mail_post_defer/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)