From b4afe973b7f206b8b28725526d84a04a24d15bd0 Mon Sep 17 00:00:00 2001 From: Aungkokolin1997 Date: Fri, 9 Jan 2026 10:02:18 +0000 Subject: [PATCH] [ADD] mail_post_defer --- mail_post_defer/README.rst | 164 ++++++ mail_post_defer/__init__.py | 2 + mail_post_defer/__manifest__.py | 17 + mail_post_defer/hooks.py | 28 + mail_post_defer/i18n/es.po | 39 ++ mail_post_defer/i18n/it.po | 40 ++ mail_post_defer/i18n/mail_post_defer.pot | 36 ++ mail_post_defer/models/__init__.py | 2 + .../models/mail_message_schedule.py | 26 + mail_post_defer/models/mail_thread.py | 70 +++ mail_post_defer/pyproject.toml | 3 + mail_post_defer/readme/CONFIGURE.md | 21 + mail_post_defer/readme/CONTRIBUTORS.md | 3 + mail_post_defer/readme/DESCRIPTION.md | 10 + mail_post_defer/readme/ROADMAP.md | 3 + mail_post_defer/readme/USAGE.md | 15 + mail_post_defer/static/description/icon.png | Bin 0 -> 26216 bytes mail_post_defer/static/description/index.html | 509 ++++++++++++++++++ mail_post_defer/tests/__init__.py | 2 + mail_post_defer/tests/test_install.py | 12 + mail_post_defer/tests/test_mail.py | 309 +++++++++++ 21 files changed, 1311 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/i18n/es.po create mode 100644 mail_post_defer/i18n/it.po create mode 100644 mail_post_defer/i18n/mail_post_defer.pot create mode 100644 mail_post_defer/models/__init__.py create mode 100644 mail_post_defer/models/mail_message_schedule.py create mode 100644 mail_post_defer/models/mail_thread.py create mode 100644 mail_post_defer/pyproject.toml create mode 100644 mail_post_defer/readme/CONFIGURE.md create mode 100644 mail_post_defer/readme/CONTRIBUTORS.md create mode 100644 mail_post_defer/readme/DESCRIPTION.md create mode 100644 mail_post_defer/readme/ROADMAP.md create mode 100644 mail_post_defer/readme/USAGE.md create mode 100644 mail_post_defer/static/description/icon.png create mode 100644 mail_post_defer/static/description/index.html 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 0000000..29fabb6 --- /dev/null +++ b/mail_post_defer/README.rst @@ -0,0 +1,164 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================== +Deferred Message Posting +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5d35c257858b754315a0d2e0b11be43a2d0337857cbdc635da5dfa83d48e2477 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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%2Fmail-lightgray.png?logo=github + :target: https://github.com/OCA/mail/tree/19.0/mail_post_defer + :alt: OCA/mail +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/mail-19-0/mail-19-0-mail_post_defer + :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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +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** + +.. contents:: + :local: + +Configuration +============= + +You usually don't need to do anything. The module is configured +appropriately out of the box. Just make sure the following scheduled +actions are active: + +- Mail: Email Queue Manager (mail.ir_cron_mail_scheduler_action) +- Notification: Notify scheduled messages + (mail.ir_cron_send_scheduled_message) + +The mail queue processing and scheduled messages notifications are +handled by cron jobs. 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, both jobs are configured 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. Activate developer mode. +3. Go to *Settings > Technical > Automation > Scheduled Actions*. +4. Find the cron(s) you want to adjust: + + - Mail: Email Queue Manager - handles outgoing emails. + - Notification: Notify scheduled messages - handles notifications. + +5. Lower down the frequency in the field *Execute Every*. Recommended: 1 + minute. + +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. 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: + +1. Click the little envelope. You will see a paper airplane icon, + indicating it is still outgoing. +2. Hover over the message and click on *⠇ > 🗑️ Delete*. 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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Moduon +* OERP Canada + +Contributors +------------ + +- Jairo Llopis (https://www.moduon.team/) +- `OERP Canada `__ + + - Yash Pathak + +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/mail `_ 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/__init__.py b/mail_post_defer/__init__.py new file mode 100644 index 0000000..cc6b635 --- /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 0000000..0c67bf1 --- /dev/null +++ b/mail_post_defer/__manifest__.py @@ -0,0 +1,17 @@ +# 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": "19.0.1.0.0", + "development_status": "Alpha", + "category": "Productivity/Discuss", + "website": "https://github.com/OCA/mail", + "author": "Moduon, OERP Canada, Odoo Community Association (OCA)", + "maintainers": ["Yajo"], + "license": "LGPL-3", + "depends": [ + "mail", + ], + "post_init_hook": "post_init_hook", +} diff --git a/mail_post_defer/hooks.py b/mail_post_defer/hooks.py new file mode 100644 index 0000000..bb9f035 --- /dev/null +++ b/mail_post_defer/hooks.py @@ -0,0 +1,28 @@ +# Copyright 2022-2023 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +import logging + +_logger = logging.getLogger(__name__) + + +def post_init_hook(env): + """Increase cadence of mail queue and notification crons.""" + crons = { + "mail.ir_cron_mail_scheduler_action": "Mail: Email Queue Manager", + "mail.ir_cron_send_scheduled_message": "Notification: Notify scheduled messages", # noqa: E501 + } + + for cron_ref, description in crons.items(): + try: + cron = env.ref(cron_ref) + except ValueError: + _logger.warning( + "Couldn't find the %s cron (%s). " + "Maybe no mails/notification will be ever sent!.", + description, + cron_ref, + ) + else: + _logger.info("Setting %s cron cadence to 1 minute", description) + cron.interval_number = 1 + cron.interval_type = "minutes" diff --git a/mail_post_defer/i18n/es.po b/mail_post_defer/i18n/es.po new file mode 100644 index 0000000..7b7e5b9 --- /dev/null +++ b/mail_post_defer/i18n/es.po @@ -0,0 +1,39 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_post_defer +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-01-09 20:34+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: mail_post_defer +#. odoo-python +#: code:addons/mail_post_defer/models/mail_thread.py:0 +#, python-format +msgid "Cannot modify message; notifications were already sent." +msgstr "No se puede modificar el mensaje; ya se han enviado notificaciones." + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_thread +msgid "Email Thread" +msgstr "Hilo de Correo Electrónico" + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_message +msgid "Message" +msgstr "Mensaje" + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_message_schedule +msgid "Scheduled Messages" +msgstr "" diff --git a/mail_post_defer/i18n/it.po b/mail_post_defer/i18n/it.po new file mode 100644 index 0000000..fbc405f --- /dev/null +++ b/mail_post_defer/i18n/it.po @@ -0,0 +1,40 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_post_defer +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-05-27 12:55+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: mail_post_defer +#. odoo-python +#: code:addons/mail_post_defer/models/mail_thread.py:0 +#, python-format +msgid "Cannot modify message; notifications were already sent." +msgstr "" +"Non si può modificare il messaggio; le notifiche sono già state spedite." + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_thread +msgid "Email Thread" +msgstr "Discussione e-mail" + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_message +msgid "Message" +msgstr "Messaggio" + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_message_schedule +msgid "Scheduled Messages" +msgstr "Messaggi schedulati" 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 0000000..9933b7c --- /dev/null +++ b/mail_post_defer/i18n/mail_post_defer.pot @@ -0,0 +1,36 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_post_defer +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.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.fields,field_description:mail_post_defer.field_mail_message_schedule__display_name +#: model:ir.model.fields,field_description:mail_post_defer.field_mail_thread__display_name +msgid "Display Name" +msgstr "" + +#. 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.fields,field_description:mail_post_defer.field_mail_message_schedule__id +#: model:ir.model.fields,field_description:mail_post_defer.field_mail_thread__id +msgid "ID" +msgstr "" + +#. module: mail_post_defer +#: model:ir.model,name:mail_post_defer.model_mail_message_schedule +msgid "Scheduled Messages" +msgstr "" diff --git a/mail_post_defer/models/__init__.py b/mail_post_defer/models/__init__.py new file mode 100644 index 0000000..140386f --- /dev/null +++ b/mail_post_defer/models/__init__.py @@ -0,0 +1,2 @@ +from . import mail_message_schedule +from . import mail_thread diff --git a/mail_post_defer/models/mail_message_schedule.py b/mail_post_defer/models/mail_message_schedule.py new file mode 100644 index 0000000..0cfb98a --- /dev/null +++ b/mail_post_defer/models/mail_message_schedule.py @@ -0,0 +1,26 @@ +# Copyright 2024 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import models + + +class MailMessageSchedule(models.Model): + _inherit = "mail.message.schedule" + + def _send_notifications(self, default_notify_kwargs=None): + """Avoid deferring notifications when they should be sent.""" + _self = self.with_context(mail_defer_seconds=0) + return super(MailMessageSchedule, _self)._send_notifications( + default_notify_kwargs=default_notify_kwargs + ) + + def _group_by_model(self): + """Make sure only mail.thread children are grouped with model.""" + result = super()._group_by_model() + for model, records in result.copy().items(): + # Move records without mail.thread mixin to a False key + if model and not hasattr(self.env.get(model), "_notify_thread"): + result.pop(model) + result.setdefault(False, self.browse()) + result[False] += records + return result diff --git a/mail_post_defer/models/mail_thread.py b/mail_post_defer/models/mail_thread.py new file mode 100644 index 0000000..40ba0da --- /dev/null +++ b/mail_post_defer/models/mail_thread.py @@ -0,0 +1,70 @@ +# Copyright 2022-2024 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 _notify_thread(self, message, msg_vals=False, **kwargs): + """Defer emails by default.""" + # Don't defer automatically if forcing send + _self = self + if "mail_defer_seconds" not in _self.env.context: + 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) + # Apply deferring + defer_seconds = _self.env.context.get("mail_defer_seconds") + if defer_seconds: + kwargs.setdefault( + "scheduled_date", + fields.Datetime.now() + timedelta(seconds=defer_seconds), + ) + return super(MailThread, _self)._notify_thread( + message, msg_vals=msg_vals, **kwargs + ) + + def _message_update_content(self, message, /, *, body, **kwargs): + # If anything already went out, fall back to the standard behavior + if any(ntf.notification_status == "sent" for ntf in message.notification_ids): + return super()._message_update_content(message, body=body, **kwargs) + scheduled_date = fields.Datetime.now() + timedelta(seconds=30) + Schedule = self.env["mail.message.schedule"].sudo() + sched = Schedule.search([("mail_message_id", "=", message.id)], limit=1) + if sched: + sched.scheduled_datetime = scheduled_date + # In case emails are already created but not sent yet, cancel queued mails and + # delete the notification (so nothing goes out with old content) + else: + message.mail_ids.filtered( + lambda x: x.state in {"outgoing", "exception", "draft"} + ).write({"state": "cancel"}) + message.notification_ids.filtered( + lambda x: x.notification_status + in {"ready", "exception", "canceled", "bounce"} + ).unlink() + Schedule.create( + {"mail_message_id": message.id, "scheduled_datetime": scheduled_date} + ) + kw = dict(kwargs) + # Drop empty [] for partner_ids, or else Odoo will try to send to nobody + if not kw.get("partner_ids"): + kw.pop("partner_ids", None) + res = super()._message_update_content(message, body=body, **kw) + # Delete empty pending outgoing mails + if empty_messages := message.sudo()._filter_empty(): + empty_messages.mail_ids.filtered( + lambda mail: mail.state == "outgoing" + ).unlink() + empty_messages.env["mail.message.schedule"].search( + [("mail_message_id", "in", empty_messages.ids)] + ).unlink() + return res diff --git a/mail_post_defer/pyproject.toml b/mail_post_defer/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/mail_post_defer/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/mail_post_defer/readme/CONFIGURE.md b/mail_post_defer/readme/CONFIGURE.md new file mode 100644 index 0000000..11c8ff0 --- /dev/null +++ b/mail_post_defer/readme/CONFIGURE.md @@ -0,0 +1,21 @@ +You usually don't need to do anything. The module is configured appropriately out of the box. +Just make sure the following scheduled actions are active: + +- Mail: Email Queue Manager (mail.ir_cron_mail_scheduler_action) +- Notification: Notify scheduled messages (mail.ir_cron_send_scheduled_message) + +The mail queue processing and scheduled messages notifications are handled by cron jobs. +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, +both jobs are configured 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. Activate developer mode. +3. Go to *Settings \> Technical \> Automation \> Scheduled Actions*. +4. Find the cron(s) you want to adjust: + - Mail: Email Queue Manager - handles outgoing emails. + - Notification: Notify scheduled messages - handles notifications. +5. Lower down the frequency in the field *Execute Every*. Recommended: 1 minute. diff --git a/mail_post_defer/readme/CONTRIBUTORS.md b/mail_post_defer/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..d36af61 --- /dev/null +++ b/mail_post_defer/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Jairo Llopis () +- [OERP Canada](https://www.oerp.ca) + - Yash Pathak diff --git a/mail_post_defer/readme/DESCRIPTION.md b/mail_post_defer/readme/DESCRIPTION.md new file mode 100644 index 0000000..51bd185 --- /dev/null +++ b/mail_post_defer/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +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.md b/mail_post_defer/readme/ROADMAP.md new file mode 100644 index 0000000..ad91258 --- /dev/null +++ b/mail_post_defer/readme/ROADMAP.md @@ -0,0 +1,3 @@ +- Add minimal deferring time configuration if it ever becomes necessary. + See + for the rationale behind current hardcoded value of 30 seconds. diff --git a/mail_post_defer/readme/USAGE.md b/mail_post_defer/readme/USAGE.md new file mode 100644 index 0000000..30610e0 --- /dev/null +++ b/mail_post_defer/readme/USAGE.md @@ -0,0 +1,15 @@ +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. 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: + +1. Click the little envelope. You will see a paper airplane icon, + indicating it is still outgoing. +2. Hover over the message and click on *⠇ \> 🗑️ Delete*. 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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 + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Deferred Message Posting

+ +

Alpha License: LGPL-3 OCA/mail Translate me on Weblate Try me on Runboat

+

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 usually don’t need to do anything. The module is configured +appropriately out of the box. Just make sure the following scheduled +actions are active:

+
    +
  • Mail: Email Queue Manager (mail.ir_cron_mail_scheduler_action)
  • +
  • Notification: Notify scheduled messages +(mail.ir_cron_send_scheduled_message)
  • +
+

The mail queue processing and scheduled messages notifications are +handled by cron jobs. 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, both jobs are configured 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. Find the cron(s) you want to adjust:
      +
    • Mail: Email Queue Manager - handles outgoing emails.
    • +
    • Notification: Notify scheduled messages - handles notifications.
    • +
    +
  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. Click the little envelope. You will see a paper airplane icon, +indicating it is still outgoing.
  2. +
  3. Hover over the message and click on ⠇ > 🗑️ Delete. 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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Moduon
  • +
  • OERP Canada
  • +
+
+
+

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/mail 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/tests/__init__.py b/mail_post_defer/tests/__init__.py new file mode 100644 index 0000000..f8340d8 --- /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 0000000..c83fd2d --- /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 0000000..7122b3a --- /dev/null +++ b/mail_post_defer/tests/test_mail.py @@ -0,0 +1,309 @@ +# Copyright 2022-2023 Moduon Team S.L. +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from datetime import datetime + +import freezegun +from lxml import html + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.mail.tests.common import MailCommon + + +class MailPostDeferCommon(MailCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.classPatch(cls.cr, "now", datetime.now) + cls._create_portal_user() + # Notify employee by email + cls.user_employee.notification_type = "email" + + +@freezegun.freeze_time("2023-01-02 10:00:00") +class MessagePostCase(MailPostDeferCommon): + def test_standard(self): + """A normal call just uses the queue by default.""" + 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, + ) + 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.""" + 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_msg_edit(self): + """Can update messages. + + 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( + body="test body", + subject="test subject", + message_type="comment", + 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) + 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, body="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() + self.assertMailMail( + self.partner_employee, + "outgoing", + author=self.env.user.partner_id, + content="new body", + ) + + 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", + ) + 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 + self.partner_portal._message_update_content(msg, body="", partner_ids=[]) + 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, + 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", + ) + # We add tracking value to raise the userError in _message_update_content + field = self.env["ir.model.fields"]._get("mail.message", "body") + msg.tracking_value_ids = [ + ( + 0, + 0, + { + "field_id": field.id, + "old_value_char": "test body", + "new_value_char": "test body edited", + }, + ) + ] + # 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", + ) + # Emulate user clicking on delete button and going through the + # `/mail/message/update_content` controller + with self.assertRaises(UserError): + self.partner_portal._message_update_content( + msg, body="", partner_ids=[] + ) + + def test_model_without_threading(self): + """When models don't inherit from mail.thread, they still work.""" + self.partner_portal.email = "portal@example.com" + with self.mock_mail_gateway(): + self.env["mail.thread"].message_notify( + author_id=self.partner_employee.id, + body="test body", + model="res.country", + partner_ids=(self.partner_employee | self.partner_portal).ids, + res_id=self.ref("base.es"), + notify_author_mention=False, + ) + self.assertNoMail(self.partner_employee | self.partner_portal) + # 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_portal, + "sent", + author=self.partner_employee, + content="test body", + ) + # res.partner does not send mail because res.country does not inherit + # from mail.thread + self.assertNoMail(self.partner_employee) + # Safety belt to avoid false positives in this test + self.assertFalse(hasattr(self.env["res.country"], "_notify_thread")) + self.assertTrue(hasattr(self.env["res.partner"], "_notify_thread")) + + def test_button_access(self): + """A button is added to the email to access the record.""" + customer = self.env["res.partner"].create( + {"name": "Customer", "email": "customer@example.com"} + ) + with self.mock_mail_gateway(): + customer.message_post( + body="test body", + subject="test subject", + message_type="comment", + partner_ids=(self.partner_employee | customer).ids, + ) + self.assertNoMail(self.partner_employee | customer) + # After a minute, mails are sent + 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() + # Employee has a button that grants them access + customer_link = customer._notify_get_action_link("view") + employee_mail = self.assertSentEmail( + self.env.user.partner_id, + self.partner_employee, + body_content="test body", + ) + self.assertEqual( + html.fromstring(employee_mail["body"]) + .xpath(f"//a[contains(@href, '{customer_link}')]")[0] + .text_content() + .strip(), + "View Contact", + ) + # Customer got the mail, but doesn't have access + customer_mail = self.assertSentEmail( + self.env.user.partner_id, + customer, + body_content="test body", + ) + self.assertFalse( + html.fromstring(customer_mail["body"]).xpath( + f"//a[contains(@href, '{customer_link}')]" + ) + ) + + +@tagged("-at_install", "post_install") +@freezegun.freeze_time("2023-01-02 10:00:00") +class AutomaticNotificationCase(MailPostDeferCommon): + """Check that automatic notifications are queued too. + + This is a separate case because some notifications require a + completely-loaded registry, so this case needs to run in post-install mode. + """ + + def test_assignation_mail(self): + """When assigning a record to a user, a notification is scheduled.""" + with self.mock_mail_gateway(): + self.partner_portal.user_id = self.user_employee.id + self.partner_portal.flush_recordset() + self.assertNoMail(self.partner_employee) + schedules = self.env["mail.message.schedule"].search( + [ + ("mail_message_id.res_id", "=", self.partner_portal.id), + ("mail_message_id.model", "=", "res.partner"), + ("scheduled_datetime", "=", "2023-01-02 10:00:30"), + ] + ) + self.assertEqual(len(schedules), 1) + # After a minute, the mail is sent + 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="You have been assigned to the Contact Chell Gladys.", + )