From 11d1699f9c986d623c48ced307c54e2db8eb5566 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Thu, 9 Mar 2023 09:12:14 +0000 Subject: [PATCH 1/6] [ADD] mail_post_defer: deferred message posting with queue - Faster because the email sending doesn't block the UI. - Safer because users can undo mails while they're still not sent. @moduon MT-1579 MT-2480 --- mail_post_defer/README.rst | 35 ++++ mail_post_defer/__init__.py | 2 + mail_post_defer/__manifest__.py | 25 +++ mail_post_defer/hooks.py | 23 +++ mail_post_defer/models/__init__.py | 2 + mail_post_defer/models/mail_message.py | 18 +++ mail_post_defer/models/mail_thread.py | 43 +++++ mail_post_defer/readme/CONFIGURE.rst | 15 ++ mail_post_defer/readme/CONTRIBUTORS.rst | 1 + mail_post_defer/readme/DESCRIPTION.rst | 9 ++ mail_post_defer/readme/ROADMAP.rst | 3 + mail_post_defer/readme/USAGE.rst | 15 ++ mail_post_defer/static/description/icon.png | Bin 0 -> 26216 bytes mail_post_defer/static/src/js/message.esm.js | 48 ++++++ mail_post_defer/static/src/xml/message.xml | 13 ++ mail_post_defer/tests/__init__.py | 2 + mail_post_defer/tests/test_install.py | 12 ++ mail_post_defer/tests/test_mail.py | 159 +++++++++++++++++++ 18 files changed, 425 insertions(+) create mode 100644 mail_post_defer/README.rst create mode 100644 mail_post_defer/__init__.py create mode 100644 mail_post_defer/__manifest__.py create mode 100644 mail_post_defer/hooks.py create mode 100644 mail_post_defer/models/__init__.py create mode 100644 mail_post_defer/models/mail_message.py create mode 100644 mail_post_defer/models/mail_thread.py create mode 100644 mail_post_defer/readme/CONFIGURE.rst create mode 100644 mail_post_defer/readme/CONTRIBUTORS.rst create mode 100644 mail_post_defer/readme/DESCRIPTION.rst create mode 100644 mail_post_defer/readme/ROADMAP.rst create mode 100644 mail_post_defer/readme/USAGE.rst create mode 100644 mail_post_defer/static/description/icon.png create mode 100644 mail_post_defer/static/src/js/message.esm.js create mode 100644 mail_post_defer/static/src/xml/message.xml create mode 100644 mail_post_defer/tests/__init__.py create mode 100644 mail_post_defer/tests/test_install.py create mode 100644 mail_post_defer/tests/test_mail.py 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 0000000000000000000000000000000000000000..94a2bcd5ef63360fa7ec85c91cefff693bb18382 GIT binary patch literal 26216 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 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("", []) From aa156ece1b62862454f83f18c3c6c4002f85eccd Mon Sep 17 00:00:00 2001 From: oca-ci Date: Thu, 9 Mar 2023 11:46:11 +0000 Subject: [PATCH 2/6] [UPD] Update mail_post_defer.pot --- mail_post_defer/i18n/mail_post_defer.pot | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 mail_post_defer/i18n/mail_post_defer.pot diff --git a/mail_post_defer/i18n/mail_post_defer.pot b/mail_post_defer/i18n/mail_post_defer.pot new file mode 100644 index 000000000..528b87631 --- /dev/null +++ b/mail_post_defer/i18n/mail_post_defer.pot @@ -0,0 +1,31 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_post_defer +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_thread +msgid "Email Thread" +msgstr "" + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_message +msgid "Message" +msgstr "" + +#. module: mail_post_defer +#. openerp-web +#: code:addons/mail_post_defer/static/src/xml/message.xml:0 +#, python-format +msgid "messageActionList.message.canBeEdited" +msgstr "" From 0461d35972d80996a6813891ec1574ce1aed3921 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 9 Mar 2023 11:51:50 +0000 Subject: [PATCH 3/6] [UPD] README.rst --- mail_post_defer/README.rst | 152 +++++- mail_post_defer/static/description/index.html | 479 ++++++++++++++++++ 2 files changed, 607 insertions(+), 24 deletions(-) create mode 100644 mail_post_defer/static/description/index.html diff --git a/mail_post_defer/README.rst b/mail_post_defer/README.rst index 38929e877..e9488133a 100644 --- a/mail_post_defer/README.rst +++ b/mail_post_defer/README.rst @@ -1,35 +1,139 @@ -**This file is going to be generated by oca-gen-addon-readme.** +======================== +Deferred Message Posting +======================== -*Manual changes will be overwritten.* +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -Please provide content in the ``readme`` directory: +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/15.0/mail_post_defer + :alt: OCA/social +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/social-15-0/social-15-0-mail_post_defer + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/205/15.0 + :alt: Try me on Runbot -* **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) +|badge1| |badge2| |badge3| |badge4| |badge5| -Content of this README will also be drawn from the addon manifest, -from keys such as name, authors, maintainers, development_status, -and license. +This module enhances mail threads by using the mail queue by default. -A good, one sentence summary in the manifest is also highly recommended. +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. -Automatic changelog generation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ -`HISTORY.rst` can be auto generated using `towncrier `_. +**Table of contents** -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. +.. contents:: + :local: -Please refer to `towncrier` documentation to know more. +Configuration +============= -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 `_. +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. + +Usage +===== + +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. + +Known issues / Roadmap +====================== + +* 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. + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Moduon + +Contributors +~~~~~~~~~~~~ + +* Jairo Llopis (https://www.moduon.team/) + +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. + +.. |maintainer-Yajo| image:: https://github.com/Yajo.png?size=40px + :target: https://github.com/Yajo + :alt: Yajo + +Current `maintainer `__: + +|maintainer-Yajo| + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_post_defer/static/description/index.html b/mail_post_defer/static/description/index.html new file mode 100644 index 000000000..184437546 --- /dev/null +++ b/mail_post_defer/static/description/index.html @@ -0,0 +1,479 @@ + + + + + + +Deferred Message Posting + + + +
+

Deferred Message Posting

+ + +

Alpha License: LGPL-3 OCA/social Translate me on Weblate Try me on Runbot

+

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.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+

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:

+
    +
  1. Log in with an administrator user.
  2. +
  3. Activate developer mode.
  4. +
  5. Go to Settings > Technical > Automation > Scheduled Actions.
  6. +
  7. Edit the action named “Mail: Email Queue Manager”.
  8. +
  9. Lower down the frequency in the field Execute Every. Recommended: 1 minute.
  10. +
+
+
+

Usage

+

To use this module, you need to:

+
    +
  1. Go to the form view of any record that has a mail thread. It can be a partner, for example.
  2. +
  3. Post a message.
  4. +
+

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:

+
    +
  1. Hover over the little envelope. You will see a paper airplane icon, +indicating it is still outgoing.
  2. +
  3. Hover over the message and click on the little trash icon to delete it. +Mails will not be sent.
  4. +
+
+
+

Known issues / Roadmap

+ +
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Moduon
  • +
+
+
+

Contributors

+ +
+
+

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.

+

Current maintainer:

+

Yajo

+

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

+

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

+
+
+
+ + From 0851cb764ef9c96654fb2c91a49d731f27625574 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Thu, 24 Aug 2023 12:26:20 +0100 Subject: [PATCH 4/6] [IMP] mail_post_defer: black, isort, prettier --- setup/mail_post_defer/odoo/addons/mail_post_defer | 1 + setup/mail_post_defer/setup.py | 6 ++++++ 2 files changed, 7 insertions(+) create mode 120000 setup/mail_post_defer/odoo/addons/mail_post_defer create mode 100644 setup/mail_post_defer/setup.py 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, +) From 09f45faafdad8543157da4d44d0aaeb6b2f9a3b8 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Thu, 31 Aug 2023 11:54:06 +0100 Subject: [PATCH 5/6] [MIG] mail_post_defer: migrate to 16.0 Rely on the new `mail.message.schedule` model and follow the rest of the refactor from https://github.com/odoo/odoo/pull/95623. @moduon MT-3088 --- mail_post_defer/__manifest__.py | 2 +- mail_post_defer/models/mail_message.py | 8 ++-- mail_post_defer/models/mail_thread.py | 33 +++++++++++---- mail_post_defer/tests/test_mail.py | 57 ++++++++++++++++++-------- 4 files changed, 69 insertions(+), 31 deletions(-) diff --git a/mail_post_defer/__manifest__.py b/mail_post_defer/__manifest__.py index 9a6ecf264..dbb6c2842 100644 --- a/mail_post_defer/__manifest__.py +++ b/mail_post_defer/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Deferred Message Posting", "summary": "Faster and cancellable outgoing messages", - "version": "15.0.1.0.0", + "version": "16.0.1.0.0", "development_status": "Alpha", "category": "Productivity/Discuss", "website": "https://github.com/OCA/social", diff --git a/mail_post_defer/models/mail_message.py b/mail_post_defer/models/mail_message.py index 2da8085df..7f59f4f5c 100644 --- a/mail_post_defer/models/mail_message.py +++ b/mail_post_defer/models/mail_message.py @@ -10,9 +10,7 @@ class MailMessage(models.Model): def _cleanup_side_records(self): """Delete pending outgoing mails.""" self.mail_ids.filtered(lambda mail: mail.state == "outgoing").unlink() + self.env["mail.message.schedule"].search( + [("mail_message_id", "in", self.ids)] + ).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 index cb213a7b8..feea4a834 100644 --- a/mail_post_defer/models/mail_thread.py +++ b/mail_post_defer/models/mail_thread.py @@ -21,23 +21,42 @@ class MailThread(models.AbstractModel): _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): + def _notify_thread(self, message, msg_vals=False, **kwargs): """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( + kwargs.setdefault( "scheduled_date", fields.Datetime.now() + timedelta(seconds=defer_seconds), ) - return result + return super()._notify_thread(message, msg_vals=msg_vals, **kwargs) def _check_can_update_message_content(self, message): - """Allow deleting unsent mails.""" + """Allow deleting unsent mails. + + When a message is scheduled, notifications and mails will still not + exist. Another possibility is that they exist but are not sent yet. In + those cases, we are still on time to update it. Once they are sent, + it's too late. + """ if ( self.env.context.get("deleting") - and set(message.notification_ids.mapped("notification_status")) == {"ready"} - and set(message.mail_ids.mapped("state")) == {"outgoing"} + and ( + not message.notification_ids + or set(message.notification_ids.mapped("notification_status")) + == {"ready"} + ) + and ( + not message.mail_ids + or set(message.mail_ids.mapped("state")) == {"outgoing"} + ) ): return return super()._check_can_update_message_content(message) + + def _message_update_content(self, message, body, *args, **kwargs): + """Let checker know about empty body.""" + _self = self.with_context(deleting=body == "") + return super(MailThread, _self)._message_update_content( + message, body, *args, **kwargs + ) diff --git a/mail_post_defer/tests/test_mail.py b/mail_post_defer/tests/test_mail.py index a33251841..edff11cb1 100644 --- a/mail_post_defer/tests/test_mail.py +++ b/mail_post_defer/tests/test_mail.py @@ -20,19 +20,20 @@ class MessagePostCase(MailCommon): def test_standard(self): """A normal call just uses the queue by default.""" with self.mock_mail_gateway(): - self.partner_portal.message_post( + msg = 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"}, + schedules = self.env["mail.message.schedule"].search( + [ + ("mail_message_id", "=", msg.id), + ("scheduled_datetime", "=", "2023-01-02 10:00:30"), + ] ) + self.assertEqual(len(schedules), 1) + self.assertNoMail(self.partner_employee) def test_forced_arg(self): """A forced send via method argument is sent directly.""" @@ -91,16 +92,26 @@ class MessagePostCase(MailCommon): subtype_xmlid="mail.mt_comment", ) # Emulate user clicking on edit button and going through the - # `/mail/message/update_content` controller + # `/mail/message/update_content` controller. It should fail. 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"}, + self.partner_portal._message_update_content(msg, "new body") + schedules = self.env["mail.message.schedule"].search( + [ + ("mail_message_id", "=", msg.id), + ("scheduled_datetime", "=", "2023-01-02 10:00:30"), + ] ) + self.assertEqual(len(schedules), 1) + self.assertNoMail(self.partner_employee) + # After a minute, the mail is created + with freezegun.freeze_time("2023-01-02 10:01:00"): + self.env["mail.message.schedule"]._send_notifications_cron() + self.assertMailMail( + self.partner_employee, + "outgoing", + author=self.env.user.partner_id, + content="test body", + ) def test_queued_msg_delete(self): """A user can delete a message before it's sent.""" @@ -112,15 +123,25 @@ class MessagePostCase(MailCommon): partner_ids=self.partner_employee.ids, subtype_xmlid="mail.mt_comment", ) + schedules = self.env["mail.message.schedule"].search( + [ + ("mail_message_id", "=", msg.id), + ("scheduled_datetime", "=", "2023-01-02 10:00:30"), + ] + ) + self.assertEqual(len(schedules), 1) # Emulate user clicking on delete button and going through the # `/mail/message/update_content` controller - msg._update_content("", []) + self.partner_portal._message_update_content(msg, "", []) + self.env.flush_all() + self.assertFalse(schedules.exists()) 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.message.schedule"]._send_notifications_cron() self.env["mail.mail"].process_email_queue() self.assertNoMail( self.partner_employee, @@ -145,15 +166,15 @@ class MessagePostCase(MailCommon): ) # One minute later, the cron sends the mail with freezegun.freeze_time("2023-01-02 10:01:00"): + self.env["mail.message.schedule"]._send_notifications_cron() 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("", []) + self.partner_portal._message_update_content(msg, "", []) From 3d0ce338d0f8393679cd2db94a282113fda0cd59 Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Fri, 1 Sep 2023 11:42:50 +0100 Subject: [PATCH 6/6] [MIG+IMP] mail_post_defer: migrate to 16.0, allow editing messages Rely on the new `mail.message.schedule` model and follow the rest of the refactor from https://github.com/odoo/odoo/pull/95623. FWIW this cron isn't strictly needed to run every minute, as it runs at the specific time automatically. Follow refactors from https://github.com/odoo/odoo/pull/95500 and https://github.com/odoo/odoo/pull/79259 for the client. Improve the module by allowing to edit messages, instead of just deleting them. This was simpler than splitting the edit and delete checks in the client-side. @moduon MT-3088 --- mail_post_defer/__manifest__.py | 6 +- mail_post_defer/models/mail_thread.py | 60 ++++++++------ mail_post_defer/static/src/js/message.esm.js | 87 ++++++++++---------- mail_post_defer/static/src/xml/message.xml | 13 --- mail_post_defer/tests/test_mail.py | 32 +++---- 5 files changed, 98 insertions(+), 100 deletions(-) delete mode 100644 mail_post_defer/static/src/xml/message.xml diff --git a/mail_post_defer/__manifest__.py b/mail_post_defer/__manifest__.py index dbb6c2842..976821623 100644 --- a/mail_post_defer/__manifest__.py +++ b/mail_post_defer/__manifest__.py @@ -15,11 +15,11 @@ ], "post_init_hook": "post_init_hook", "assets": { + # This could go in mail.assets_messaging, but that's included in + # mail.assets_discuss_public and we don't want the public to be able to + # edit their messages; only the backend. "web.assets_backend": [ "mail_post_defer/static/src/**/*.js", ], - "web.assets_qweb": [ - "mail_post_defer/static/src/**/*.xml", - ], }, } diff --git a/mail_post_defer/models/mail_thread.py b/mail_post_defer/models/mail_thread.py index feea4a834..ef2ed02d4 100644 --- a/mail_post_defer/models/mail_thread.py +++ b/mail_post_defer/models/mail_thread.py @@ -3,7 +3,8 @@ from datetime import timedelta -from odoo import fields, models +from odoo import _, fields, models +from odoo.exceptions import UserError class MailThread(models.AbstractModel): @@ -31,32 +32,39 @@ class MailThread(models.AbstractModel): ) return super()._notify_thread(message, msg_vals=msg_vals, **kwargs) - def _check_can_update_message_content(self, message): - """Allow deleting unsent mails. + def _check_can_update_message_content(self, messages): + """Allow updating unsent messages. - When a message is scheduled, notifications and mails will still not - exist. Another possibility is that they exist but are not sent yet. In - those cases, we are still on time to update it. Once they are sent, - it's too late. + Upstream Odoo only allows updating notes. We want to be able to update + any message that is not sent yet. When a message is scheduled, + notifications and mails will still not exist. Another possibility is + that they exist but are not sent yet. In those cases, we are still on + time to update it. """ - if ( - self.env.context.get("deleting") - and ( - not message.notification_ids - or set(message.notification_ids.mapped("notification_status")) - == {"ready"} - ) - and ( - not message.mail_ids - or set(message.mail_ids.mapped("state")) == {"outgoing"} - ) - ): - return - return super()._check_can_update_message_content(message) + try: + # If upstream allows editing, we are done + return super()._check_can_update_message_content(messages) + except UserError: + # Repeat upstream checks that are still valid for us + if messages.tracking_value_ids: + raise + if any(message.message_type != "comment" for message in messages): + raise + # Check that no notification or mail has been sent yet + if any( + ntf.notification_status == "sent" for ntf in messages.notification_ids + ): + raise UserError( + _("Cannot modify message; notifications were already sent.") + ) from None + if any(mail.state in {"sent", "received"} for mail in messages.mail_ids): + raise UserError( + _("Cannot modify message; notifications were already sent.") + ) from None - def _message_update_content(self, message, body, *args, **kwargs): - """Let checker know about empty body.""" - _self = self.with_context(deleting=body == "") - return super(MailThread, _self)._message_update_content( - message, body, *args, **kwargs + def _message_update_content(self, *args, **kwargs): + """Defer messages by extra 30 seconds after updates.""" + kwargs.setdefault( + "scheduled_date", fields.Datetime.now() + timedelta(seconds=30) ) + return super()._message_update_content(*args, **kwargs) diff --git a/mail_post_defer/static/src/js/message.esm.js b/mail_post_defer/static/src/js/message.esm.js index 7079cb8c6..2bad491a0 100644 --- a/mail_post_defer/static/src/js/message.esm.js +++ b/mail_post_defer/static/src/js/message.esm.js @@ -1,48 +1,51 @@ /** @odoo-module **/ -import { - registerFieldPatchModel, - registerInstancePatchModel, -} from "@mail/model/model_core"; -import {attr} from "@mail/model/model_field"; +import {registerPatch} from "@mail/model/model_core"; -registerInstancePatchModel("mail.message", "mail_post_defer.message", { - xmlDependencies: ["/mail_post_defer/static/src/xml/message.xml"], +// Ensure that the model definition is loaded before the patch +import "@mail/models/message"; - /** - * 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) - ); - }, +registerPatch({ + name: "Message", - /** - * 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); + fields: { + canBeDeleted: { + /** + * Whether this message can be updated. + * + * Despite the field name, this method is used upstream to determine + * whether a message can be edited or deleted. + * + * Upstream Odoo allows updating notes. We want to allow updating any + * user message that is not yet sent. If there's a race condition, + * anyways the server will repeat these checks. + * + * @returns {Boolean} + * Whether this message can be updated. + * @override + */ + compute() { + // If upstream allows editing, we are done + if (this._super()) { + return true; + } + // Repeat upstream checks that are still valid for us + if (this.trackingValues.length > 0) { + return false; + } + if (this.message_type !== "comment") { + return false; + } + if (this.originThread.model === "mail.channel") { + return true; + } + // Check that no notification has been sent yet + if ( + this.notifications.some((ntf) => ntf.notification_status === "sent") + ) { + return false; + } + return 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 deleted file mode 100644 index 0af2c23cc..000000000 --- a/mail_post_defer/static/src/xml/message.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/mail_post_defer/tests/test_mail.py b/mail_post_defer/tests/test_mail.py index edff11cb1..a65310a52 100644 --- a/mail_post_defer/tests/test_mail.py +++ b/mail_post_defer/tests/test_mail.py @@ -70,18 +70,11 @@ class MessagePostCase(MailCommon): fields_values={"scheduled_date": False}, ) - def test_no_msg_edit(self): - """Cannot update messages. + def test_msg_edit(self): + """Can 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. + Upstream Odoo allows only updating notes, regardless of their sent + status. We allow updating any message that is not sent yet. """ with self.mock_mail_gateway(): msg = self.partner_portal.message_post( @@ -91,10 +84,6 @@ class MessagePostCase(MailCommon): 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. It should fail. - with self.assertRaises(UserError): - self.partner_portal._message_update_content(msg, "new body") schedules = self.env["mail.message.schedule"].search( [ ("mail_message_id", "=", msg.id), @@ -103,6 +92,17 @@ class MessagePostCase(MailCommon): ) self.assertEqual(len(schedules), 1) self.assertNoMail(self.partner_employee) + # After 15 seconds, the user updates the message + with freezegun.freeze_time("2023-01-02 10:00:15"): + self.partner_portal._message_update_content(msg, "new body") + schedules = self.env["mail.message.schedule"].search( + [ + ("mail_message_id", "=", msg.id), + ("scheduled_datetime", "=", "2023-01-02 10:00:45"), + ] + ) + self.assertEqual(len(schedules), 1) + self.assertNoMail(self.partner_employee) # After a minute, the mail is created with freezegun.freeze_time("2023-01-02 10:01:00"): self.env["mail.message.schedule"]._send_notifications_cron() @@ -110,7 +110,7 @@ class MessagePostCase(MailCommon): self.partner_employee, "outgoing", author=self.env.user.partner_id, - content="test body", + content="new body", ) def test_queued_msg_delete(self):