From 4ba5801bca30fd3fd5c5d6d94f9419e09dbc71c7 Mon Sep 17 00:00:00 2001 From: Anjeel Haria Date: Mon, 3 Mar 2025 18:33:52 +0530 Subject: [PATCH 1/3] Added modules multi_alias_domain_mail and helpdesk_mgmt_multi_alias_domain_mail --- helpdesk_mgmt_email/__manifest__.py | 4 +- helpdesk_mgmt_email/data/automated_action.xml | 16 - helpdesk_mgmt_email/data/mail_template.xml | 215 +----- helpdesk_mgmt_email/i18n/nl.po | 399 ---------- helpdesk_mgmt_email/models/__init__.py | 2 + helpdesk_mgmt_email/models/helpdesk_ticket.py | 2 + .../models/helpdesk_ticket_team.py | 9 + helpdesk_mgmt_email/models/res_company.py | 12 + .../models/res_config_settings.py | 12 + .../views/res_config_settings_view.xml | 23 + .../__init__.py | 0 .../__manifest__.py | 16 + .../views/helpdesk_ticket_team_view.xml | 17 + multi_alias_domain_mail/__init__.py | 7 + multi_alias_domain_mail/__manifest__.py | 22 + multi_alias_domain_mail/models/__init__.py | 8 + multi_alias_domain_mail/models/mail_alias.py | 273 +++++++ .../models/mail_alias_domain.py | 200 +++++ multi_alias_domain_mail/models/mail_mail.py | 291 ++++++++ .../models/mail_message.py | 7 + multi_alias_domain_mail/models/mail_thread.py | 692 ++++++++++++++++++ multi_alias_domain_mail/models/models.py | 147 ++++ multi_alias_domain_mail/models/res_company.py | 43 ++ .../models/res_config_settings.py | 12 + .../security/ir.model.access.csv | 3 + .../views/mail_alias_domain_views.xml | 85 +++ .../views/mail_alias_views.xml | 39 + .../views/res_company_views.xml | 16 + .../views/res_config_settings_view.xml | 26 + multi_alias_domain_mail/wizard/__init__.py | 1 + .../wizard/mail_compose_message.py | 135 ++++ .../wizard/mail_compose_message_view.xml | 15 + 32 files changed, 2145 insertions(+), 604 deletions(-) delete mode 100644 helpdesk_mgmt_email/data/automated_action.xml delete mode 100644 helpdesk_mgmt_email/i18n/nl.po create mode 100644 helpdesk_mgmt_email/models/res_company.py create mode 100644 helpdesk_mgmt_email/models/res_config_settings.py create mode 100644 helpdesk_mgmt_email/views/res_config_settings_view.xml create mode 100644 helpdesk_mgmt_multi_alias_domain_mail/__init__.py create mode 100644 helpdesk_mgmt_multi_alias_domain_mail/__manifest__.py create mode 100644 helpdesk_mgmt_multi_alias_domain_mail/views/helpdesk_ticket_team_view.xml create mode 100644 multi_alias_domain_mail/__init__.py create mode 100644 multi_alias_domain_mail/__manifest__.py create mode 100644 multi_alias_domain_mail/models/__init__.py create mode 100644 multi_alias_domain_mail/models/mail_alias.py create mode 100644 multi_alias_domain_mail/models/mail_alias_domain.py create mode 100644 multi_alias_domain_mail/models/mail_mail.py create mode 100644 multi_alias_domain_mail/models/mail_message.py create mode 100644 multi_alias_domain_mail/models/mail_thread.py create mode 100644 multi_alias_domain_mail/models/models.py create mode 100644 multi_alias_domain_mail/models/res_company.py create mode 100644 multi_alias_domain_mail/models/res_config_settings.py create mode 100644 multi_alias_domain_mail/security/ir.model.access.csv create mode 100644 multi_alias_domain_mail/views/mail_alias_domain_views.xml create mode 100644 multi_alias_domain_mail/views/mail_alias_views.xml create mode 100644 multi_alias_domain_mail/views/res_company_views.xml create mode 100644 multi_alias_domain_mail/views/res_config_settings_view.xml create mode 100644 multi_alias_domain_mail/wizard/__init__.py create mode 100644 multi_alias_domain_mail/wizard/mail_compose_message.py create mode 100644 multi_alias_domain_mail/wizard/mail_compose_message_view.xml diff --git a/helpdesk_mgmt_email/__manifest__.py b/helpdesk_mgmt_email/__manifest__.py index 2260b674..d7203f2d 100644 --- a/helpdesk_mgmt_email/__manifest__.py +++ b/helpdesk_mgmt_email/__manifest__.py @@ -8,12 +8,12 @@ "category": "After-Sales", "author": "Onestein BV", "website": "https://www.onestein.eu", - "depends": ["helpdesk_mgmt", "base_automation", "mail_layout_force"], + "depends": ["helpdesk_mgmt"], "data": [ "data/mail_template.xml", - "data/automated_action.xml", "views/helpdesk_ticket_team_view.xml", "views/helpdesk_ticket_view.xml", + "views/res_config_settings_view.xml", ], "installable": True, } diff --git a/helpdesk_mgmt_email/data/automated_action.xml b/helpdesk_mgmt_email/data/automated_action.xml deleted file mode 100644 index d70767a1..00000000 --- a/helpdesk_mgmt_email/data/automated_action.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - Email on helpdesk ticket creation - - mail_post - on_create - comment - - - - - diff --git a/helpdesk_mgmt_email/data/mail_template.xml b/helpdesk_mgmt_email/data/mail_template.xml index 32554d25..2874dbd5 100644 --- a/helpdesk_mgmt_email/data/mail_template.xml +++ b/helpdesk_mgmt_email/data/mail_template.xml @@ -2,211 +2,52 @@ Support Ticket Number - - + {{ object.team_id.email or object.company_id.partner_id.email or ''}} {{not object.partner_id and object.partner_email or ''}} + >{{not object.partner_id and object.partner_email or ''}} + {{object.partner_id.id}} {{ object.team_id.email or object.company_id.partner_id.email or ''}} {{ object.partner_id.lang or ctx.get("lang") or object.company_id.partner_id.lang or object.env.user.lang }} + name="lang" + >{{ object.partner_id.lang or ctx.get("lang") or object.company_id.partner_id.lang or object.env.user.lang }} + Ticket {{object.number or 'n/a' }}: {{object.name or '' }} -
Dear -
-
+
+

+ Dear + +
+

- Thank you for your report. Your report has been registered under ticket and is being processed. + Thank you for your report. Your report has been registered under ticket + + and is being processed.

If there are any questions or uncertainties, please let us know.

Kind regards,
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - Support - - - -
- -   - -
-
-   -
-
T:   -
A:   - - - | - - - - -
W:   - -
-
-
-

- This e-mail is intended for the addressee’s eyes only. If you are not the intended recipient, you are hereby kindly requested to inform the sender of this. In view of the electronic nature of this communication, is neither liable for the proper and complete transmission of the information contained therein nor for any delay in its receipt. For information about - - ,direct your browser to - -

-
- - - - - Support - - - {{ object.team_id.email or object.company_id.partner_id.email or ''}} - {{not object.partner_id and object.partner_email or ''}} - {{object.partner_id.id}} - {{ object.team_id.email or object.company_id.partner_id.email or ''}} - {{ object.partner_id.lang or ctx.get("lang") or object.company_id.partner_id.lang or object.env.user.lang }} - Ticket {{object.number or 'n/a' }}: {{object.name or '' }} - -
Dear -
-
- Kind regards,
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - Support - - - -
- -   - -
-
-   -
-
T:   -
A:   - - - | - - - - -
W:   - -
-
-

- This e-mail is intended for the addressee’s eyes only. If you are not the intended recipient, you are hereby kindly requested to inform the sender of this. In view of the electronic nature of this communication, is neither liable for the proper and complete transmission of the information contained therein nor for any delay in its receipt. For information about + This e-mail is intended for the addressee’s eyes only. If you are not the intended recipient, + you are hereby + kindly requested to inform the sender of this. In view of the electronic nature of this + communication, + + is neither liable for the proper and complete transmission of the information contained therein + nor for any + delay in its receipt. For information about + - ,direct your browser to + ,direct your browser to +

-
+

+
diff --git a/helpdesk_mgmt_email/i18n/nl.po b/helpdesk_mgmt_email/i18n/nl.po deleted file mode 100644 index 29a90135..00000000 --- a/helpdesk_mgmt_email/i18n/nl.po +++ /dev/null @@ -1,399 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * helpdesk_mgmt_email -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-14 07:50+0000\n" -"PO-Revision-Date: 2024-03-14 07:50+0000\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: helpdesk_mgmt_email -#: model:mail.template,body_html:helpdesk_mgmt_email.helpdesk_ticket_draft_email_template -msgid "" -"
Dear \n" -"
\n" -"
\n" -" Kind regards,\n" -"
\n" -"
\n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -"
\n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" Support\n" -" \n" -" \n" -" \n" -"
\n" -" \n" -"  \n" -" \n" -"
\n" -"
\n" -"  \n" -"
\n" -"
T:  \n" -"
A:  \n" -" \n" -" \n" -" |\n" -" \n" -" \n" -" \n" -" \n" -"
W:  \n" -" \n" -"
\n" -"
\n" -"
\n" -"

\n" -" This e-mail is intended for the addressee’s eyes only. If you are not the intended recipient, you are hereby kindly requested to inform the sender of this. In view of the electronic nature of this communication, is neither liable for the proper and complete transmission of the information contained therein nor for any delay in its receipt. For information about \n" -" \n" -" ,direct your browser to \n" -" \n" -"

\n" -"
\n" -" " -msgstr "" -"
Beste \n" -"
\n" -"
\n" -" Met vriendelijke groet,\n" -"
\n" -"
\n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -"
\n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" Support\n" -" \n" -" \n" -" \n" -"
\n" -" \n" -"  \n" -" \n" -"
\n" -"
\n" -"  \n" -"
\n" -"
T:  \n" -"
A:  \n" -" \n" -" \n" -" |\n" -" \n" -" \n" -" \n" -" \n" -"
W:  \n" -" \n" -"
\n" -"
\n" -"
\n" -"

\n" -" Dit e-mailbericht is uitsluitend bestemd voor de geadresseerde. Als dit bericht niet voor u bestemd is, wordt u vriendelijk verzocht dit aan de afzender te melden. staat door de elektronische verzending van dit bericht niet in voor de juiste en volledige overbrenging van de inhoud, noch voor tijdige ontvangst daarvan. Voor informatie over \n" -" \n" -" ,raadpleegt u \n" -" \n" -"

\n" -"
\n" -" " - -#. module: helpdesk_mgmt_email -#: model:mail.template,body_html:helpdesk_mgmt_email.helpdesk_ticket_created_email_template -msgid "" -"
Dear \n" -"
\n" -"
\n" -"

\n" -" Thank you for your report. Your report has been registered under ticket and is being processed.\n" -"

\n" -"

If there are any questions or uncertainties, please let us know.

\n" -" Kind regards,\n" -"
\n" -"
\n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -"
\n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" Support\n" -" \n" -" \n" -" \n" -"
\n" -" \n" -"  \n" -" \n" -"
\n" -"
\n" -"  \n" -"
\n" -"
T:  \n" -"
A:  \n" -" \n" -" \n" -" |\n" -" \n" -" \n" -" \n" -" \n" -"
W:  \n" -" \n" -"
\n" -"
\n" -"
\n" -"

\n" -" This e-mail is intended for the addressee’s eyes only. If you are not the intended recipient, you are hereby kindly requested to inform the sender of this. In view of the electronic nature of this communication, is neither liable for the proper and complete transmission of the information contained therein nor for any delay in its receipt. For information about \n" -" \n" -" ,direct your browser to \n" -" \n" -"

\n" -"
\n" -" " -msgstr "" -"
Beste \n" -"
\n" -"
\n" -"

\n" -" Bedankt voor je melding. Je melding is geregistreerd onder ticket wordt in behandeling genomen.\n" -"

\n" -"

Als er vragen of onduidelijkheden zijn, laat het ons dan gerust weten.

\n" -" Met vriendelijke groet,\n" -"
\n" -"
\n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -"
\n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" \n" -" Support\n" -" \n" -" \n" -" \n" -"
\n" -" \n" -"  \n" -" \n" -"
\n" -"
\n" -"  \n" -"
\n" -"
T:  \n" -"
A:  \n" -" \n" -" \n" -" |\n" -" \n" -" \n" -" \n" -" \n" -"
W:  \n" -" \n" -"
\n" -"
\n" -"
\n" -"

\n" -" Dit e-mailbericht is uitsluitend bestemd voor de geadresseerde. Als dit bericht niet voor u bestemd is, wordt u vriendelijk verzocht dit aan de afzender te melden. staat door de elektronische verzending van dit bericht niet in voor de juiste en volledige overbrenging van de inhoud, noch voor tijdige ontvangst daarvan. Voor informatie over \n" -" \n" -" ,raadpleegt u \n" -" \n" -"

\n" -"
\n" -" " - -#. module: helpdesk_mgmt_email -#. odoo-python -#: code:addons/helpdesk_mgmt_email/models/helpdesk_ticket.py:0 -#, python-format -msgid "Compose Email" -msgstr "Email opstellen" - -#. module: helpdesk_mgmt_email -#: model:ir.model.fields,field_description:helpdesk_mgmt_email.field_helpdesk_ticket_team__email -msgid "Email" -msgstr "" - -#. module: helpdesk_mgmt_email -#: model:ir.actions.server,name:helpdesk_mgmt_email.automated_action_on_ticket_creation_ir_actions_server -msgid "Email on helpdesk ticket creation" -msgstr "E-mail over het aanmaken van helpdesktickets" - -#. module: helpdesk_mgmt_email -#: model:ir.model,name:helpdesk_mgmt_email.model_helpdesk_ticket -msgid "Helpdesk Ticket" -msgstr "" - -#. module: helpdesk_mgmt_email -#: model:ir.model,name:helpdesk_mgmt_email.model_helpdesk_ticket_team -msgid "Helpdesk Ticket Team" -msgstr "" - -#. module: helpdesk_mgmt_email -#: model_terms:ir.ui.view,arch_db:helpdesk_mgmt_email.ticket_view_form -msgid "Send Email" -msgstr "E-mail verzenden" - -#. module: helpdesk_mgmt_email -#: model:mail.template,name:helpdesk_mgmt_email.helpdesk_ticket_draft_email_template -msgid "Support" -msgstr "Ondersteunings" - -#. module: helpdesk_mgmt_email -#: model:mail.template,name:helpdesk_mgmt_email.helpdesk_ticket_created_email_template -msgid "Support Ticket Number" -msgstr "Ondersteunings Ticket Nummer" - -#. module: helpdesk_mgmt_email -#: model:ir.model.fields,help:helpdesk_mgmt_email.field_helpdesk_ticket_team__email -msgid "This would be used when sending out emails to contacts" -msgstr "Dit zou worden gebruikt bij het verzenden van e-mails naar contacten" - -#. module: helpdesk_mgmt_email -#: model:mail.template,subject:helpdesk_mgmt_email.helpdesk_ticket_created_email_template -#: model:mail.template,subject:helpdesk_mgmt_email.helpdesk_ticket_draft_email_template -msgid "Ticket {{object.number or 'n/a' }}: {{object.name or '' }}" -msgstr "" \ No newline at end of file diff --git a/helpdesk_mgmt_email/models/__init__.py b/helpdesk_mgmt_email/models/__init__.py index c005bbd5..973f68ac 100644 --- a/helpdesk_mgmt_email/models/__init__.py +++ b/helpdesk_mgmt_email/models/__init__.py @@ -1,2 +1,4 @@ from . import helpdesk_ticket from . import helpdesk_ticket_team +from . import res_company +from . import res_config_settings diff --git a/helpdesk_mgmt_email/models/helpdesk_ticket.py b/helpdesk_mgmt_email/models/helpdesk_ticket.py index ff9813c7..3c5d8de5 100644 --- a/helpdesk_mgmt_email/models/helpdesk_ticket.py +++ b/helpdesk_mgmt_email/models/helpdesk_ticket.py @@ -27,3 +27,5 @@ def action_ticket_send(self): "target": "new", "context": ctx, } + + diff --git a/helpdesk_mgmt_email/models/helpdesk_ticket_team.py b/helpdesk_mgmt_email/models/helpdesk_ticket_team.py index aac98e5d..896a651e 100644 --- a/helpdesk_mgmt_email/models/helpdesk_ticket_team.py +++ b/helpdesk_mgmt_email/models/helpdesk_ticket_team.py @@ -7,3 +7,12 @@ class HelpdeskTeam(models.Model): email = fields.Char( "Team Email", help="This would be used when sending out emails to contacts" ) + + def _notify_get_reply_to(self, default=None): + """ Override to set reply to email address to that of the helpdesk team if configured likewise""" + team_email_to_be_used_recs = self.filtered(lambda rec: rec.email and rec.company_id and rec.company_id.helpdesk_mgmt_use_team_email_as_reply_to) + res = {team.id: team.email for team in team_email_to_be_used_recs} + leftover = self - team_email_to_be_used_recs + if leftover: + res.update(super(HelpdeskTeam, leftover)._notify_get_reply_to(default=default)) + return res \ No newline at end of file diff --git a/helpdesk_mgmt_email/models/res_company.py b/helpdesk_mgmt_email/models/res_company.py new file mode 100644 index 00000000..9acfa840 --- /dev/null +++ b/helpdesk_mgmt_email/models/res_company.py @@ -0,0 +1,12 @@ +# Copyright 2022 Tecnativa - Víctor Martínez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class Company(models.Model): + _inherit = "res.company" + + helpdesk_mgmt_use_team_email_as_reply_to = fields.Boolean( + string="Use Helpdesk team's email as the reply to address in mail communications through chatter" + ) diff --git a/helpdesk_mgmt_email/models/res_config_settings.py b/helpdesk_mgmt_email/models/res_config_settings.py new file mode 100644 index 00000000..20978c5b --- /dev/null +++ b/helpdesk_mgmt_email/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Copyright 2022 Tecnativa - Víctor Martínez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + helpdesk_mgmt_use_team_email_as_reply_to = fields.Boolean( + related="company_id.helpdesk_mgmt_use_team_email_as_reply_to", + readonly=False, + ) diff --git a/helpdesk_mgmt_email/views/res_config_settings_view.xml b/helpdesk_mgmt_email/views/res_config_settings_view.xml new file mode 100644 index 00000000..a5b9f166 --- /dev/null +++ b/helpdesk_mgmt_email/views/res_config_settings_view.xml @@ -0,0 +1,23 @@ + + + + res.config.settings + + +
+

Email

+
+
+
+ +
+
+
+
+
+
+
+
+
diff --git a/helpdesk_mgmt_multi_alias_domain_mail/__init__.py b/helpdesk_mgmt_multi_alias_domain_mail/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/helpdesk_mgmt_multi_alias_domain_mail/__manifest__.py b/helpdesk_mgmt_multi_alias_domain_mail/__manifest__.py new file mode 100644 index 00000000..315fa79e --- /dev/null +++ b/helpdesk_mgmt_multi_alias_domain_mail/__manifest__.py @@ -0,0 +1,16 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Helpdesk Management Multi Alias Domain", + "summary": "Allows to add multiple domains for helpdesk team aliases", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "category": "After-Sales", + "author": "Onestein BV", + "website": "https://www.onestein.eu", + "depends": ["helpdesk_mgmt","multi_alias_domain_mail"], + "data": [ + "views/helpdesk_ticket_team_view.xml", + ], + "installable": True, +} diff --git a/helpdesk_mgmt_multi_alias_domain_mail/views/helpdesk_ticket_team_view.xml b/helpdesk_mgmt_multi_alias_domain_mail/views/helpdesk_ticket_team_view.xml new file mode 100644 index 00000000..82806aae --- /dev/null +++ b/helpdesk_mgmt_multi_alias_domain_mail/views/helpdesk_ticket_team_view.xml @@ -0,0 +1,17 @@ + + + + view.helpdesk_team.form + helpdesk.ticket.team + + + + + + + {'invisible': [('alias_domain_id', '!=', False)]} + + + + diff --git a/multi_alias_domain_mail/__init__.py b/multi_alias_domain_mail/__init__.py new file mode 100644 index 00000000..5ea07663 --- /dev/null +++ b/multi_alias_domain_mail/__init__.py @@ -0,0 +1,7 @@ +from . import models +from . import wizard +from odoo import SUPERUSER_ID, api + +def _mail_post_init(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + env['mail.alias.domain']._migrate_icp_to_domain() diff --git a/multi_alias_domain_mail/__manifest__.py b/multi_alias_domain_mail/__manifest__.py new file mode 100644 index 00000000..a0d96569 --- /dev/null +++ b/multi_alias_domain_mail/__manifest__.py @@ -0,0 +1,22 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Multi Alias Domain", + "summary": "Allows to add multiple domains for aliases", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "category": "After-Sales", + "author": "Onestein BV", + "website": "https://www.onestein.eu", + "depends": ["mail"], + "data": [ + "security/ir.model.access.csv", + "views/mail_alias_domain_views.xml", + "views/mail_alias_views.xml", + "views/res_company_views.xml", + "views/res_config_settings_view.xml", + "wizard/mail_compose_message_view.xml", + ], + "installable": True, + "post_init_hook": "_mail_post_init", +} diff --git a/multi_alias_domain_mail/models/__init__.py b/multi_alias_domain_mail/models/__init__.py new file mode 100644 index 00000000..11d6db45 --- /dev/null +++ b/multi_alias_domain_mail/models/__init__.py @@ -0,0 +1,8 @@ +from . import mail_alias +from . import mail_alias_domain +from . import mail_mail +from . import mail_message +from . import mail_thread +from . import models +from . import res_company +from . import res_config_settings diff --git a/multi_alias_domain_mail/models/mail_alias.py b/multi_alias_domain_mail/models/mail_alias.py new file mode 100644 index 00000000..9a2d061d --- /dev/null +++ b/multi_alias_domain_mail/models/mail_alias.py @@ -0,0 +1,273 @@ +import re +from collections import defaultdict + +from odoo import api, exceptions, fields, models, _ +from odoo.addons.mail.models.mail_alias import dot_atom_text +from odoo.exceptions import ValidationError, UserError +from odoo.osv import expression +from odoo.tools import is_html_empty, remove_accents + + + +class MailAlias(models.Model): + _inherit = 'mail.alias' + + alias_domain_id = fields.Many2one( + 'mail.alias.domain', string='Alias Domain', ondelete='restrict', + default=lambda self: self.env.company.alias_domain_id) + alias_domain = fields.Char('Alias domain name', related='alias_domain_id.name') + alias_full_name = fields.Char('Alias Email', compute='_compute_alias_full_name', store=True, index='btree_not_null') + display_name = fields.Char(string='Display Name', compute='_compute_display_name') + + def init(self): + """Make sure there aren't multiple records for the same name and alias + domain. Not in _sql_constraint because COALESCE is not supported for + PostgreSQL constraint. """ + self.env.cr.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS mail_alias_name_domain_unique + ON mail_alias (alias_name, COALESCE(alias_domain_id, 0)) + """) + + @api.constrains('alias_domain_id', 'alias_force_thread_id', 'alias_parent_model_id', + 'alias_parent_thread_id', 'alias_model_id') + def _check_alias_domain_id_mc(self): + """ Check for invalid alias domains based on company configuration. + When having a parent record and/or updating an existing record alias + domain should match the one used on the related record. """ + + # in sudo, to be able to read alias_parent_model_id (ir.model) + tocheck = self.sudo().filtered(lambda domain: domain.alias_domain_id.company_ids) + if not tocheck: + return + + # helpers to find owner / target models + def _owner_model(alias): + return alias.alias_parent_model_id.model + + def _owner_env(alias): + return self.env[_owner_model(alias)] + + def _target_model(alias): + return alias.alias_model_id.model + + def _target_env(alias): + return self.env[_target_model(alias)] + + # fetch impacted records, classify by model + recs_by_model = defaultdict(list) + for alias in tocheck: + # owner record (like 'project.project' for aliases creating new 'project.task') + if alias.alias_parent_model_id and alias.alias_parent_thread_id: + if _owner_env(alias)._mail_get_company_field(): + recs_by_model[_owner_model(alias)].append(alias.alias_parent_thread_id) + # target record (like 'mail.group' updating a given group) + if alias.alias_model_id and alias.alias_force_thread_id: + if _target_env(alias)._mail_get_company_field(): + recs_by_model[_target_model(alias)].append(alias.alias_force_thread_id) + + # helpers to fetch owner / target with prefetching + def _fetch_owner(alias): + if alias.alias_parent_thread_id in recs_by_model[alias.alias_parent_model_id.model]: + return _owner_env(alias).with_prefetch( + recs_by_model[_owner_model(alias)] + ).browse(alias.alias_parent_thread_id) + return None + + def _fetch_target(alias): + if alias.alias_force_thread_id in recs_by_model[alias.alias_model_id.model]: + return _target_env(alias).with_prefetch( + recs_by_model[_target_model(alias)] + ).browse(alias.alias_force_thread_id) + return None + + # check company domains are compatible + for alias in tocheck: + if owner := _fetch_owner(alias): + company = owner[owner._mail_get_company_field()] + if company and company.alias_domain_id != alias.alias_domain_id and alias.alias_domain_id.company_ids: + raise ValidationError(_( + "We could not create alias %(alias_name)s because domain " + "%(alias_domain_name)s belongs to company %(alias_company_names)s " + "while the owner document belongs to company %(company_name)s.", + alias_company_names=','.join(alias.alias_domain_id.company_ids.mapped('name')), + alias_domain_name=alias.alias_domain_id.name, + alias_name=alias.display_name, + company_name=company.name, + )) + if target := _fetch_target(alias): + company = target[target._mail_get_company_field()] + if company and company.alias_domain_id != alias.alias_domain_id and alias.alias_domain_id.company_ids: + raise ValidationError(_( + "We could not create alias %(alias_name)s because domain " + "%(alias_domain_name)s belongs to company %(alias_company_names)s " + "while the target document belongs to company %(company_name)s.", + alias_company_names=','.join(alias.alias_domain_id.company_ids.mapped('name')), + alias_domain_name=alias.alias_domain_id.name, + alias_name=alias.display_name, + company_name=company.name, + )) + + @api.depends('alias_domain_id', 'alias_domain_id.name') + def _compute_alias_domain(self): + for rec in self: + rec.alias_domain = rec.alias_domain_id.name + + @api.depends('alias_domain_id.name', 'alias_name') + def _compute_alias_full_name(self): + """ A bit like display_name, but without the 'inactive alias' UI display. + Moreover it is stored, allowing to search on it. """ + for record in self: + if record.alias_domain_id and record.alias_name: + record.alias_full_name = f"{record.alias_name}@{record.alias_domain_id.name}" + elif record.alias_name: + record.alias_full_name = record.alias_name + else: + record.alias_full_name = False + + @api.depends('alias_domain', 'alias_name') + def _compute_display_name(self): + """ Return the mail alias display alias_name, including the catchall + domain if found otherwise "Inactive Alias". e.g.`jobs@mail.odoo.com` + or `jobs` or 'Inactive Alias' """ + for record in self: + if record.alias_name and record.alias_domain: + record.display_name = f"{record.alias_name}@{record.alias_domain}" + elif record.alias_name: + record.display_name = record.alias_name + else: + record.display_name = _("Inactive Alias") + + @api.model_create_multi + def create(self, vals_list): + """ Creates mail.alias records according to the values provided in + ``vals`` but sanitize 'alias_name' by replacing certain unsafe + characters; set default alias domain if not given. + + :raise UserError: if given (alias_name, alias_domain_id) already exists + or if there are duplicates in given vals_list; + """ + alias_names, alias_domains = [], [] + for vals in vals_list: + vals['alias_name'] = self._sanitize_alias_name(vals.get('alias_name')) + alias_names.append(vals['alias_name']) + vals['alias_domain_id'] = vals.get('alias_domain_id', self.env.company.alias_domain_id.id) + alias_domains.append(self.env['mail.alias.domain'].browse(vals['alias_domain_id'])) + + self._check_unique(alias_names, alias_domains) + return super().create(vals_list) + + def write(self, vals): + """ Raise UserError with a meaningful message instead of letting the + uniqueness constraint raise an SQL error. To check uniqueness we have + to rebuild pairs of names / domains to validate, taking into account + that a void alias_domain_id is acceptable (but also raises for + uniqueness). + """ + alias_names, alias_domains = [], [] + if 'alias_name' in vals: + vals['alias_name'] = self._sanitize_alias_name(vals['alias_name']) + if vals.get('alias_name') and self.ids: + alias_names = [vals['alias_name']] * len(self) + elif 'alias_name' not in vals and 'alias_domain_id' in vals: + # avoid checking when writing the same value + if [vals['alias_domain_id']] != self.alias_domain_id.ids: + alias_names = self.filtered('alias_name').mapped('alias_name') + + if alias_names: + tocheck_records = self if vals.get('alias_name') else self.filtered('alias_name') + if 'alias_domain_id' in vals: + alias_domains = [self.env['mail.alias.domain'].browse(vals['alias_domain_id'])] * len(tocheck_records) + else: + alias_domains = [record.alias_domain_id for record in tocheck_records] + self._check_unique(alias_names, alias_domains) + + return super().write(vals) + + def _check_unique(self, alias_names, alias_domains): + """ Check unicity constraint won't be raised, otherwise raise a UserError + with a complete error message. Also check unicity against alias config + parameters. + + :param list alias_names: a list of names (considered as sanitized + and ready to be sent to DB); + :param list alias_domains: list of alias_domain records under which + the check is performed, as uniqueness is performed for given pair + (name, alias_domain); + """ + if len(alias_names) != len(alias_domains): + msg = (f"Invalid call to '_check_unique': names and domains should make coherent lists, " + f"received {', '.join(alias_names)} and {', '.join(alias_domains.mapped('name'))}") + raise ValueError(msg) + + # reorder per alias domain, keep only not void alias names (void domain also checks uniqueness) + domain_to_names = defaultdict(list) + for alias_name, alias_domain in zip(alias_names, alias_domains): + if alias_name and alias_name in domain_to_names[alias_domain]: + raise UserError( + _('Email aliases %(alias_name)s cannot be used on several records at the same time. Please update records one by one.', + alias_name=alias_name) + ) + if alias_name: + domain_to_names[alias_domain].append(alias_name) + + # matches existing alias + domain = expression.OR([ + ['&', ('alias_name', 'in', alias_names), ('alias_domain_id', '=', alias_domain.id)] + for alias_domain, alias_names in domain_to_names.items() + ]) + if domain and self: + domain = expression.AND([domain, [('id', 'not in', self.ids)]]) + existing = self.search(domain, limit=1) if domain else self.env['mail.alias'] + if not existing: + return + if existing.alias_parent_model_id and existing.alias_parent_thread_id: + parent_name = self.env[existing.alias_parent_model_id.model].sudo().browse( + existing.alias_parent_thread_id).display_name + msg_begin = _( + 'Alias %(matching_name)s (%(current_id)s) is already linked with %(alias_model_name)s (%(matching_id)s) and used by the %(parent_name)s %(parent_model_name)s.', + alias_model_name=existing.alias_model_id.name, + current_id=self.ids if self else _('your alias'), + matching_id=existing.id, + matching_name=existing.display_name, + parent_name=parent_name, + parent_model_name=existing.alias_parent_model_id.name + ) + else: + msg_begin = _( + 'Alias %(matching_name)s (%(current_id)s) is already linked with %(alias_model_name)s (%(matching_id)s).', + alias_model_name=existing.alias_model_id.name, + current_id=self.ids if self else _('new'), + matching_id=existing.id, + matching_name=existing.display_name, + ) + msg_end = _('Choose another value or change it on the other document.') + raise UserError(f'{msg_begin} {msg_end}') + + @api.model + def _sanitize_alias_name(self, name, is_email=False): + """ Cleans and sanitizes the alias name. In some cases we want the alias + to be a complete email instead of just a left-part (when sanitizing + default.from for example). In that case we extract the right part and + put it back after sanitizing the left part. + + :param str name: the alias name to sanitize; + :param bool is_email: whether to keep a right part, otherwise only + left part is kept; + + :return str: sanitized alias name + """ + sanitized_name = name.strip() if name else '' + if is_email: + right_part = sanitized_name.lower().partition('@')[2] + else: + right_part = False + if sanitized_name: + sanitized_name = remove_accents(sanitized_name).lower().split('@')[0] + # cannot start and end with dot + sanitized_name = re.sub(r'^\.+|\.+$|\.+(?=\.)', '', sanitized_name) + # subset of allowed characters + sanitized_name = re.sub(r'[^\w!#$%&\'*+\-/=?^_`{|}~.]+', '-', sanitized_name) + sanitized_name = sanitized_name.encode('ascii', errors='replace').decode() + if not sanitized_name.strip(): + return False + return f'{sanitized_name}@{right_part}' if is_email and right_part else sanitized_name diff --git a/multi_alias_domain_mail/models/mail_alias_domain.py b/multi_alias_domain_mail/models/mail_alias_domain.py new file mode 100644 index 00000000..6576318f --- /dev/null +++ b/multi_alias_domain_mail/models/mail_alias_domain.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, exceptions, fields, models, _ +from odoo.addons.mail.models.mail_alias import dot_atom_text + + +class AliasDomain(models.Model): + """ Model alias domains, now company-specific. Alias domains are email + domains used to receive emails through catchall and bounce aliases, as + well as using mail.alias records to redirect email replies. + + This replaces ``mail.alias.domain`` configuration parameter use until v16. + """ + _name = 'mail.alias.domain' + _description = "Email Domain" + _order = 'sequence ASC, id ASC' + + name = fields.Char( + 'Name', required=True, + help="Email domain e.g. 'example.com' in 'odoo@example.com'") + company_ids = fields.One2many( + 'res.company', 'alias_domain_id', string='Companies', + help="Companies using this domain as default for sending mails") + sequence = fields.Integer(default=10) + bounce_alias = fields.Char( + 'Bounce Alias', default='bounce', required=True, + help="Local-part of email used for Return-Path used when emails bounce e.g. " + "'bounce' in 'bounce@example.com'") + bounce_email = fields.Char('Bounce Email', compute='_compute_bounce_email') + catchall_alias = fields.Char( + 'Catchall Alias', default='catchall', required=True, + help="Local-part of email used for Reply-To to catch answers e.g. " + "'catchall' in 'catchall@example.com'") + catchall_email = fields.Char('Catchall Email', compute='_compute_catchall_email') + default_from = fields.Char( + 'Default From Alias', default='notifications', + help="Default from when it does not match outgoing server filters. Can be either " + "a local-part e.g. 'notifications' either a complete email address e.g. " + "'notifications@example.com' to override all outgoing emails.") + default_from_email = fields.Char('Default From', compute='_compute_default_from_email') + + _sql_constraints = [ + ( + 'bounce_email_uniques', + 'UNIQUE(bounce_alias, name)', + 'Bounce emails should be unique' + ), + ( + 'catchall_email_uniques', + 'UNIQUE(catchall_alias, name)', + 'Catchall emails should be unique' + ), + ] + + @api.depends('bounce_alias', 'name') + def _compute_bounce_email(self): + self.bounce_email = '' + for domain in self.filtered('bounce_alias'): + domain.bounce_email = f'{domain.bounce_alias}@{domain.name}' + + @api.depends('catchall_alias', 'name') + def _compute_catchall_email(self): + self.catchall_email = '' + for domain in self.filtered('catchall_alias'): + domain.catchall_email = f'{domain.catchall_alias}@{domain.name}' + + @api.depends('default_from', 'name') + def _compute_default_from_email(self): + """ Default from may be a valid complete email and not only a left-part + like bounce or catchall aliases. Adding domain name should therefore + be done only if necessary. """ + self.default_from_email = '' + for domain in self.filtered('default_from'): + if "@" in domain.default_from: + domain.default_from_email = domain.default_from + else: + domain.default_from_email = f'{domain.default_from}@{domain.name}' + + @api.constrains('bounce_alias', 'catchall_alias') + def _check_bounce_catchall_uniqueness(self): + names = self.filtered('bounce_alias').mapped('bounce_alias') + self.filtered('catchall_alias').mapped('catchall_alias') + if not names: + return + + similar_domains = self.env['mail.alias.domain'].search([('name', 'in', self.mapped('name'))]) + for tocheck in self: + if any(similar.bounce_alias == tocheck.bounce_alias + for similar in similar_domains if similar != tocheck and similar.name == tocheck.name): + raise exceptions.ValidationError( + _('Bounce alias %(bounce)s is already used for another domain with same name. ' + 'Use another bounce or simply use the other alias domain.', + bounce=tocheck.bounce_email) + ) + if any(similar.catchall_alias == tocheck.catchall_alias + for similar in similar_domains if similar != tocheck and similar.name == tocheck.name): + raise exceptions.ValidationError( + _('Catchall alias %(catchall)s is already used for another domain with same name. ' + 'Use another catchall or simply use the other alias domain.', + catchall=tocheck.catchall_email) + ) + + # search on left-part only to speedup, then filter on right part + potential_aliases = self.env['mail.alias'].search([ + ('alias_name', 'in', list(set(names))), + ('alias_domain_id', '!=', False) + ]) + existing = next( + (alias for alias in potential_aliases + if alias.display_name in (self.mapped('bounce_email') + self.mapped('catchall_email'))), + self.env['mail.alias'] + ) + if existing: + document_name = False + # If owner or target: display document name also in the warning + if existing.alias_parent_model_id and existing.alias_parent_thread_id: + document_name = self.env[existing.alias_parent_model_id.model].sudo().browse(existing.alias_parent_thread_id).display_name + elif existing.alias_model_id and existing.alias_force_thread_id: + document_name = self.env[existing.alias_model_id.model].sudo().browse(existing.alias_force_thread_id).display_name + if document_name: + raise exceptions.ValidationError( + _("Bounce/Catchall '%(matching_alias_name)s' is already used by %(document_name)s. Choose another alias or change it on the other document.", + matching_alias_name=existing.display_name, + document_name=document_name) + ) + raise exceptions.ValidationError( + _("Bounce/Catchall '%(matching_alias_name)s' is already used. Choose another alias or change it on the linked model.", + matching_alias_name=existing.display_name) + ) + + @api.constrains('name') + def _check_name(self): + """ Should match a sanitized version of itself, otherwise raise to warn + user (do not dynamically change it, would be confusing). """ + for domain in self: + if not dot_atom_text.match(domain.name): + raise exceptions.ValidationError( + _("You cannot use anything else than unaccented latin characters in the domain name %(domain_name)s.", + domain_name=domain.name) + ) + + @api.model_create_multi + def create(self, vals_list): + """ Sanitize bounce_alias / catchall_alias / default_from """ + for vals in vals_list: + self._sanitize_configuration(vals) + + alias_domains = super().create(vals_list) + + # alias domain init: populate companies and aliases at first creation + if alias_domains and self.search_count([]) == len(alias_domains): + self.env['res.company'].search( + [('alias_domain_id', '=', False)] + ).alias_domain_id = alias_domains[0].id + self.env['mail.alias'].sudo().search( + [('alias_domain_id', '=', False)] + ).alias_domain_id = alias_domains[0].id + + return alias_domains + + def write(self, vals): + """ Sanitize bounce_alias / catchall_alias / default_from """ + self._sanitize_configuration(vals) + return super().write(vals) + + @api.model + def _sanitize_configuration(self, config_values): + """ Tool sanitizing configuration values for domains """ + if config_values.get('bounce_alias'): + config_values['bounce_alias'] = self.env['mail.alias']._sanitize_alias_name(config_values['bounce_alias']) + if config_values.get('catchall_alias'): + config_values['catchall_alias'] = self.env['mail.alias']._sanitize_alias_name(config_values['catchall_alias']) + if config_values.get('default_from'): + config_values['default_from'] = self.env['mail.alias']._sanitize_alias_name( + config_values['default_from'], is_email=True + ) + return config_values + + @api.model + def _migrate_icp_to_domain(self): + """ Compatibility layer helping going from pre-v17 ICP to alias + domains. Mainly used when base mail configuration is done with 'base' + module only and 'mail' is installed afterwards: configuration should + not be lost (odoo.sh use case). """ + Icp = self.env['ir.config_parameter'].sudo() + alias_domain = Icp.get_param('mail.catchall.domain') + if alias_domain: + existing = self.search([('name', '=', alias_domain)]) + if existing: + return existing + bounce_alias = Icp.get_param('mail.bounce.alias') + catchall_alias = Icp.get_param('mail.catchall.alias') + default_from = Icp.get_param('mail.default.from') + return self.create({ + 'bounce_alias': bounce_alias or 'bounce', + 'catchall_alias': catchall_alias or 'catchall', + 'default_from': default_from or 'notifications', + 'name': alias_domain, + }) + return self.browse() diff --git a/multi_alias_domain_mail/models/mail_mail.py b/multi_alias_domain_mail/models/mail_mail.py new file mode 100644 index 00000000..8ad0a778 --- /dev/null +++ b/multi_alias_domain_mail/models/mail_mail.py @@ -0,0 +1,291 @@ +import ast +import base64 +import psycopg2 +import smtplib +import re + +from collections import defaultdict +from odoo import api, models, tools, _ +from odoo.addons.base.models.ir_mail_server import MailDeliveryException + +import logging + +_logger = logging.getLogger(__name__) + +from odoo.addons.mail.models.mail_mail import MailMail + + +def _split_by_mail_configuration(cls): + """Group the based on their "email_from", their "alias domain" + and their "mail_server_id". + + The will have the "same sending configuration" if they have the same + mail server, alias domain and mail from. For performance purpose, we can use an SMTP + session in batch and therefore we need to group them by the parameter that will + influence the mail server used. + + The same "sending configuration" may repeat in order to limit batch size + according to the `mail.session.batch.size` system parameter. + + Return iterators over + mail_server_id, email_from, Records.ids + """ + mail_values = cls.read(['id', 'email_from', 'mail_server_id', 'record_alias_domain_id']) + + # First group the per mail_server_id, per alias_domain (if no server) and per email_from + group_per_email_from = defaultdict(list) + for values in mail_values: + mail_server_id = values['mail_server_id'][0] if values['mail_server_id'] else False + alias_domain_id = values['record_alias_domain_id'][0] if values['record_alias_domain_id'] else False + key = (mail_server_id, alias_domain_id, values['email_from']) + group_per_email_from[key].append(values['id']) + + # Then find the mail server for each email_from and group the + # per mail_server_id and smtp_from + mail_servers = cls.env['ir.mail_server'].sudo().search([], order='sequence, id') + group_per_smtp_from = defaultdict(list) + for (mail_server_id, alias_domain_id, email_from), mail_ids in group_per_email_from.items(): + if not mail_server_id: + mail_server = cls.env['ir.mail_server'] + if alias_domain_id: + alias_domain = cls.env['mail.alias.domain'].sudo().browse(alias_domain_id) + mail_server = mail_server.with_context( + domain_notifications_email=alias_domain.default_from_email, + domain_bounce_address=alias_domain.bounce_email, + ) + mail_server, smtp_from = mail_server._find_mail_server(email_from, mail_servers) + mail_server_id = mail_server.id if mail_server else False + else: + smtp_from = email_from + + group_per_smtp_from[(mail_server_id, alias_domain_id, smtp_from)].extend(mail_ids) + + batch_size = int(cls.env['ir.config_parameter'].sudo().get_param('mail.session.batch.size')) or 1000 + for (mail_server_id, alias_domain_id, smtp_from), record_ids in group_per_smtp_from.items(): + for batch_ids in tools.split_every(batch_size, record_ids): + yield mail_server_id, alias_domain_id, smtp_from, batch_ids + +def send(cls, auto_commit=False, raise_exception=False): + """ Sends the selected emails immediately, ignoring their current + state (mails that have already been sent should not be passed + unless they should actually be re-sent). + Emails successfully delivered are marked as 'sent', and those + that fail to be deliver are marked as 'exception', and the + corresponding error mail is output in the server logs. + + :param bool auto_commit: whether to force a commit of the mail status + after sending each mail (meant only for scheduler processing); + should never be True during normal transactions (default: False) + :param bool raise_exception: whether to raise an exception if the + email sending process has failed + :return: True + """ + for mail_server_id, alias_domain_id, smtp_from, batch_ids in cls._split_by_mail_configuration(): + smtp_session = None + try: + smtp_session = cls.env['ir.mail_server'].connect(mail_server_id=mail_server_id, smtp_from=smtp_from) + except Exception as exc: + if raise_exception: + # To be consistent and backward compatible with mail_mail.send() raised + # exceptions, it is encapsulated into an Odoo MailDeliveryException + raise MailDeliveryException(_('Unable to connect to SMTP Server'), exc) + else: + batch = cls.browse(batch_ids) + batch.write({'state': 'exception', 'failure_reason': exc}) + batch._postprocess_sent_message(success_pids=[], failure_type="mail_smtp") + else: + cls.browse(batch_ids).with_context(alias_domain_id=alias_domain_id)._send( + auto_commit=auto_commit, + raise_exception=raise_exception, + smtp_session=smtp_session + ) + _logger.info( + 'Sent batch %s emails via mail server ID #%s', + len(batch_ids), mail_server_id) + finally: + if smtp_session: + smtp_session.quit() + +def _send(cls, auto_commit=False, raise_exception=False, smtp_session=None): + IrMailServer = cls.env['ir.mail_server'] + IrAttachment = cls.env['ir.attachment'] + alias_domain_id = cls._context.get("alias_domain_id", False) + alias_domain = cls.env['mail.alias.domain'].sudo().browse(alias_domain_id) if alias_domain_id else False + for mail_id in cls.ids: + success_pids = [] + failure_type = None + processing_pid = None + mail = None + try: + mail = cls.browse(mail_id) + if mail.state != 'outgoing': + continue + + # remove attachments if user send the link with the access_token + body = mail.body_html or '' + attachments = mail.attachment_ids + for link in re.findall(r'/web/(?:content|image)/([0-9]+)', body): + attachments = attachments - IrAttachment.browse(int(link)) + + # load attachment binary data with a separate read(), as prefetching all + # `datas` (binary field) could bloat the browse cache, triggerring + # soft/hard mem limits with temporary data. + attachments = [(a['name'], base64.b64decode(a['datas']), a['mimetype']) + for a in attachments.sudo().read(['name', 'datas', 'mimetype']) if a['datas'] is not False] + + # specific behavior to customize the send email for notified partners + email_list = [] + if mail.email_to: + email_list.append(mail._send_prepare_values()) + for partner in mail.recipient_ids: + values = mail._send_prepare_values(partner=partner) + values['partner_id'] = partner + email_list.append(values) + + # headers + headers = {'X-Odoo-Message-Id': mail.message_id} + headers['Return-Path'] = alias_domain and alias_domain.bounce_email or cls.env.company.bounce_email + if mail.headers: + try: + headers.update(ast.literal_eval(mail.headers)) + except Exception: + pass + + # Writing on the mail object may fail (e.g. lock on user) which + # would trigger a rollback *after* actually sending the email. + # To avoid sending twice the same email, provoke the failure earlier + mail.write({ + 'state': 'exception', + 'failure_reason': _('Error without exception. Probably due to sending an email without computed recipients.'), + }) + # Update notification in a transient exception state to avoid concurrent + # update in case an email bounces while sending all emails related to current + # mail record. + notifs = cls.env['mail.notification'].search([ + ('notification_type', '=', 'email'), + ('mail_mail_id', 'in', mail.ids), + ('notification_status', 'not in', ('sent', 'canceled')) + ]) + if notifs: + notif_msg = _('Error without exception. Probably due to concurrent access update of notification records. Please see with an administrator.') + notifs.sudo().write({ + 'notification_status': 'exception', + 'failure_type': 'unknown', + 'failure_reason': notif_msg, + }) + # `test_mail_bounce_during_send`, force immediate update to obtain the lock. + # see rev. 56596e5240ef920df14d99087451ce6f06ac6d36 + notifs.flush_recordset(['notification_status', 'failure_type', 'failure_reason']) + + # protect against ill-formatted email_from when formataddr was used on an already formatted email + emails_from = tools.email_split_and_format_normalize(mail.email_from) + email_from = emails_from[0] if emails_from else mail.email_from + + # build an RFC2822 email.message.Message object and send it without queuing + res = None + # TDE note: could be great to pre-detect missing to/cc and skip sending it + # to go directly to failed state update + for email in email_list: + + if alias_domain_id: + alias_domain = cls.env['mail.alias.domain'].sudo().browse(alias_domain_id) + SendIrMailServer = IrMailServer.with_context( + domain_notifications_email=alias_domain.default_from_email, + domain_bounce_address=(email.get('headers') and email.get('headers').get('Return-Path')) or alias_domain.bounce_email, + ) + else: + SendIrMailServer = IrMailServer + # give indication to 'send_mail' about emails already considered + # as being valid + email_to_normalized = email.pop('email_to_normalized', []) + # support headers specific to the specific outgoing email + if email.get('headers'): + email_headers = headers.copy() + try: + email_headers.update(email.get('headers')) + except Exception: # noqa: BLE001 + pass + else: + email_headers = headers + + msg = SendIrMailServer.build_email( + email_from=email_from, + email_to=email.get('email_to'), + subject=mail.subject, + body=email.get('body'), + body_alternative=email.get('body_alternative'), + email_cc=tools.email_split_and_format_normalize(mail.email_cc), + reply_to=mail.reply_to, + attachments=attachments, + message_id=mail.message_id, + references=mail.references, + object_id=mail.res_id and ('%s-%s' % (mail.res_id, mail.model)), + subtype='html', + subtype_alternative='plain', + headers=email_headers) + processing_pid = email.pop("partner_id", None) + try: + # 'send_validated_to' restricts emails found by 'extract_rfc2822_addresses' + res = SendIrMailServer.with_context(send_validated_to=email_to_normalized).send_email( + msg, mail_server_id=mail.mail_server_id.id, smtp_session=smtp_session) + if processing_pid: + success_pids.append(processing_pid) + processing_pid = None + except AssertionError as error: + if str(error) == IrMailServer.NO_VALID_RECIPIENT: + # if we have a list of void emails for email_list -> email missing, otherwise generic email failure + if not email.get('email_to') and failure_type != "mail_email_invalid": + failure_type = "mail_email_missing" + else: + failure_type = "mail_email_invalid" + # No valid recipient found for this particular + # mail item -> ignore error to avoid blocking + # delivery to next recipients, if any. If this is + # the only recipient, the mail will show as failed. + _logger.info("Ignoring invalid recipients for mail.mail %s: %s", + mail.message_id, email.get('email_to')) + else: + raise + if res: # mail has been sent at least once, no major exception occurred + mail.write({'state': 'sent', 'message_id': res, 'failure_reason': False}) + _logger.info('Mail with ID %r and Message-Id %r successfully sent', mail.id, mail.message_id) + # /!\ can't use mail.state here, as mail.refresh() will cause an error + # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1 + mail._postprocess_sent_message(success_pids=success_pids, failure_type=failure_type) + except MemoryError: + # prevent catching transient MemoryErrors, bubble up to notify user or abort cron job + # instead of marking the mail as failed + _logger.exception( + 'MemoryError while processing mail with ID %r and Msg-Id %r. Consider raising the --limit-memory-hard startup option', + mail.id, mail.message_id) + # mail status will stay on ongoing since transaction will be rollback + raise + except (psycopg2.Error, smtplib.SMTPServerDisconnected): + # If an error with the database or SMTP session occurs, chances are that the cursor + # or SMTP session are unusable, causing further errors when trying to save the state. + _logger.exception( + 'Exception while processing mail with ID %r and Msg-Id %r.', + mail.id, mail.message_id) + raise + except Exception as e: + failure_reason = tools.ustr(e) + _logger.exception('failed sending mail (id: %s) due to %s', mail.id, failure_reason) + mail.write({'state': 'exception', 'failure_reason': failure_reason}) + mail._postprocess_sent_message(success_pids=success_pids, failure_reason=failure_reason, failure_type='unknown') + if raise_exception: + if isinstance(e, (AssertionError, UnicodeEncodeError)): + if isinstance(e, UnicodeEncodeError): + value = "Invalid text: %s" % e.object + else: + value = '. '.join(e.args) + raise MailDeliveryException(value) + raise + + if auto_commit is True: + cls._cr.commit() + return True + +MailMail._send = _send +MailMail.send = send +MailMail._split_by_mail_configuration = _split_by_mail_configuration + diff --git a/multi_alias_domain_mail/models/mail_message.py b/multi_alias_domain_mail/models/mail_message.py new file mode 100644 index 00000000..d041a43c --- /dev/null +++ b/multi_alias_domain_mail/models/mail_message.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class MailMessage(models.Model): + _inherit = "mail.message" + + record_alias_domain_id = fields.Many2one('mail.alias.domain', 'Alias Domain', ondelete='set null') diff --git a/multi_alias_domain_mail/models/mail_thread.py b/multi_alias_domain_mail/models/mail_thread.py new file mode 100644 index 00000000..b9faac5b --- /dev/null +++ b/multi_alias_domain_mail/models/mail_thread.py @@ -0,0 +1,692 @@ +import ast +import base64 +import psycopg2 +import smtplib +import re +from markupsafe import Markup, escape +from collections import defaultdict +from email.message import EmailMessage +from odoo import api, models, tools, _ +from odoo.addons.mail.models.mail_thread import MailThread as BaseMailThread +from odoo.tools import is_html_empty + +import logging + +_logger = logging.getLogger(__name__) + + +@api.model +def get_empty_list_help(self, help): + """ Override of BaseModel.get_empty_list_help() to generate an help message + that adds alias information. """ + model = self._context.get('empty_list_help_model') + res_id = self._context.get('empty_list_help_id') + document_name = self._context.get('empty_list_help_document_name', _('document')) + nothing_here = is_html_empty(help) + alias = None + + # specific res_id -> find its alias (i.e. section_id specified) + if model and res_id: + record = self.env[model].sudo().browse(res_id) + # check that the alias effectively creates new records + if ('alias_id' in record and record.alias_id and + record.alias_id.alias_name and record.alias_id.alias_domain and + record.alias_id.alias_model_id.model == self._name and + record.alias_id.alias_force_thread_id == 0): + alias = record.alias_id + # no res_id or res_id not linked to an alias -> generic help message, take a generic alias of the model + if not alias and model and self.env.company.alias_domain_id: + aliases = self.env['mail.alias'].search([ + ("alias_domain_id", "=", self.env.company.alias_domain_id.id), + ("alias_parent_model_id.model", "=", model), + ("alias_name", "!=", False), + ('alias_force_thread_id', '=', False), + ('alias_parent_thread_id', '=', False)], order='id ASC') + if aliases and len(aliases) == 1: + alias = aliases[0] + + if alias: + email_link = "%(email)s" % {'email': alias.display_name} + if nothing_here: + return "

%(dyn_help)s

" % { + 'dyn_help': _("Add a new %(document)s or send an email to %(email_link)s", + document=document_name, + email_link=email_link, + ) + } + # do not add alias two times if it was added previously + if "oe_view_nocontent_alias" not in help: + return "%(static_help)s

%(dyn_help)s

" % { + 'static_help': help, + 'dyn_help': _("Create new %(document)s by sending an email to %(email_link)s", + document=document_name, + email_link=email_link, + ) + } + + if nothing_here: + return "

%(dyn_help)s

" % { + 'dyn_help': _("Create new %(document)s", document=document_name), + } + + return help + +def _routing_create_bounce_email(self, email_from, body_html, message, **mail_values): + bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from + bounce_mail_values = { + 'author_id': False, + 'body_html': body_html, + 'subject': 'Re: %s' % message.get('subject'), + 'email_to': bounce_to, + 'auto_delete': True, + } + email_from = False + if bounce_from := self.env.company.bounce_email: + email_from = tools.formataddr(('MAILER-DAEMON', bounce_from)) + if not email_from: + catchall_aliases = self.env['mail.alias.domain'].search([]).mapped('catchall_email') + if not any(catchall_email in message['To'] for catchall_email in catchall_aliases): + email_from = tools.decode_message_header(message, 'To') + if not email_from: + email_from = tools.formataddr(('MAILER-DAEMON', self.env.user.email_normalized)) + + bounce_mail_values['email_from'] = email_from + bounce_mail_values.update(mail_values) + self.env['mail.mail'].sudo().create(bounce_mail_values).send() + + +def _mail_find_user_for_gateway(self, email_value, alias=None): + """ Utility method to find user from email address that can create documents + in the target model. Purpose is to link document creation to users whenever + possible, for example when creating document through mailgateway. + + Heuristic + + * alias owner record: fetch in its followers for user with matching email; + * find any user with matching emails; + * try alias owner as fallback; + + Note that standard search order is applied. + + :param str email_value: will be sanitized and parsed to find email; + :param mail.alias alias: optional alias. Used to fetch owner followers + or fallback user (alias owner); + + :return res.user user: user matching email or void recordset if none found + """ + # find normalized emails and exclude aliases (to avoid subscribing alias emails to records) + normalized_email = tools.email_normalize(email_value) + if not normalized_email: + return self.env['res.users'] + + if self.env['mail.alias'].sudo().search_count([('alias_full_name', '=', email_value)]): + return self.env['res.users'] + + if alias and alias.alias_parent_model_id and alias.alias_parent_thread_id: + followers = self.env['mail.followers'].search([ + ('res_model', '=', alias.alias_parent_model_id.sudo().model), + ('res_id', '=', alias.alias_parent_thread_id)] + ).mapped('partner_id') + else: + followers = self.env['res.partner'] + + follower_users = self.env['res.users'].search([ + ('partner_id', 'in', followers.ids), ('email_normalized', '=', normalized_email) + ], limit=1) if followers else self.env['res.users'] + matching_user = follower_users[0] if follower_users else self.env['res.users'] + if matching_user: + return matching_user + + if not matching_user: + std_users = self.env['res.users'].sudo().search([('email_normalized', '=', normalized_email)], limit=1) + matching_user = std_users[0] if std_users else self.env['res.users'] + + if not matching_user and alias and alias.alias_user_id: + matching_user = alias and alias.alias_user_id + if matching_user: + return matching_user + + return matching_user + +@api.model +def _mail_find_partner_from_emails(self, emails, records=None, force_create=False, extra_domain=False): + """ Utility method to find partners from email addresses. If no partner is + found, create new partners if force_create is enabled. Search heuristics + + * 0: clean incoming email list to use only normalized emails. Exclude + those used in aliases to avoid setting partner emails to emails + used as aliases; + * 1: check in records (record set) followers if records is mail.thread + enabled and if check_followers parameter is enabled; + * 2: search for partners with user; + * 3: search for partners; + + :param records: record set on which to check followers; + :param list emails: list of email addresses for finding partner; + :param boolean force_create: create a new partner if not found + + :return list partners: a list of partner records ordered as given emails. + If no partner has been found and/or created for a given emails its + matching partner is an empty record. + """ + if records and isinstance(records, self.pool['mail.thread']): + followers = records.mapped('message_partner_ids') + else: + followers = self.env['res.partner'] + + # first, build a normalized email list and remove those linked to aliases + # to avoid adding aliases as partners. In case of multi-email input, use + # the first found valid one to be tolerant against multi emails encoding + normalized_emails = [email_normalized + for email_normalized in (tools.email_normalize(contact, strict=False) for contact in emails) + if email_normalized + ] + matching_aliases = self.env['mail.alias'].sudo().search([('alias_full_name', 'in', normalized_emails)]) + if matching_aliases: + normalized_emails = [email for email in normalized_emails if + email not in matching_aliases.mapped('alias_full_name')] + + done_partners = [follower for follower in followers if follower.email_normalized in normalized_emails] + remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]] + + user_partners = self._mail_search_on_user(remaining, extra_domain=extra_domain) + done_partners += [user_partner for user_partner in user_partners] + remaining = [email for email in normalized_emails if email not in [partner.email_normalized for partner in done_partners]] + + partners = self._mail_search_on_partner(remaining, extra_domain=extra_domain) + done_partners += [partner for partner in partners] + # prioritize current user if exists in list, and partners with matching company ids + if company_fname := records and records._mail_get_company_field(): + def sort_key(p): + return ( + self.env.user.partner_id == p, # prioritize user + p.company_id in records[company_fname], # then partner associated w/ records + not p.company_id, # else pick partner w/out company_id + ) + else: + def sort_key(p): + return (self.env.user.partner_id == p, not p.company_id) + # prioritize current user if exists in list, and partners with matching company ids + done_partners.sort(key=sort_key, reverse=True) # reverse because False < True + + # iterate and keep ordering + partners = [] + for contact in emails: + normalized_email = tools.email_normalize(contact, strict=False) + partner = next((partner for partner in done_partners if partner.email_normalized == normalized_email), self.env['res.partner']) + if not partner and force_create and normalized_email in normalized_emails: + partner = self.env['res.partner'].browse(self.env['res.partner'].name_create(contact)[0]) + partners.append(partner) + return partners + +@api.returns('mail.message', lambda value: value.id) +def message_post(self, *, + body='', subject=None, message_type='notification', + email_from=None, author_id=None, parent_id=False, + subtype_xmlid=None, subtype_id=False, partner_ids=None, + attachments=None, attachment_ids=None, + **kwargs): + """ Post a new message in an existing thread, returning the new mail.message. + + :param str body: body of the message, usually raw HTML that will + be sanitized + :param str subject: subject of the message + :param str message_type: see mail_message.message_type field. Can be anything but + user_notification, reserved for message_notify + :param str email_from: from address of the author. See ``_message_compute_author`` + that uses it to make email_from / author_id coherent; + :param int author_id: optional ID of partner record being the author. See + ``_message_compute_author`` that uses it to make email_from / author_id coherent; + :param int parent_id: handle thread formation + :param int subtype_id: subtype_id of the message, used mainly for followers + notification mechanism; + :param list(int) partner_ids: partner_ids to notify in addition to partners + computed based on subtype / followers matching; + :param list(tuple(str,str), tuple(str,str, dict)) attachments : list of attachment + tuples in the form ``(name,content)`` or ``(name,content, info)`` where content + is NOT base64 encoded; + :param list attachment_ids: list of existing attachments to link to this message + -Should only be set by chatter + -Attachment object attached to mail.compose.message(0) will be attached + to the related document. + + Extra keyword arguments will be used either + * as default column values for the new mail.message record if they match + mail.message fields; + * propagated to notification methods; + + :return record: newly create mail.message + """ + self.ensure_one() # should always be posted on a record, use message_notify if no record + # split message additional values from notify additional values + msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields) + notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs) + + # preliminary value safety check + partner_ids = set(partner_ids or []) + if self._name == 'mail.thread' or not self.id or message_type == 'user_notification': + raise ValueError(_('Posting a message should be done on a business document. Use message_notify to send a notification to an user.')) + if 'channel_ids' in kwargs: + raise ValueError(_("Posting a message with channels as listeners is not supported since Odoo 14.3+. Please update code accordingly.")) + if 'model' in msg_kwargs or 'res_id' in msg_kwargs: + raise ValueError(_("message_post does not support model and res_id parameters anymore. Please call message_post on record.")) + if 'subtype' in kwargs: + raise ValueError(_("message_post does not support subtype parameter anymore. Please give a valid subtype_id or subtype_xmlid value instead.")) + if any(not isinstance(pc_id, int) for pc_id in partner_ids): + raise ValueError(_('message_post partner_ids and must be integer list, not commands.')) + + self = self._fallback_lang() # add lang to context immediately since it will be useful in various flows latter. + + # Find the message's author + guest = self.env['mail.guest']._get_guest_from_context() + if self.env.user._is_public() and guest: + author_guest_id = guest.id + author_id, email_from = False, False + else: + author_guest_id = False + author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=True) + + if subtype_xmlid: + subtype_id = self.env['ir.model.data']._xmlid_to_res_id(subtype_xmlid) + if not subtype_id: + subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') + + # automatically subscribe recipients if asked to + if self._context.get('mail_post_autofollow') and partner_ids: + self.message_subscribe(partner_ids=list(partner_ids)) + + msg_values = dict(msg_kwargs) + if 'email_add_signature' not in msg_values: + msg_values['email_add_signature'] = True + if not msg_values.get('record_name'): + # use sudo as record access is not always granted (notably when replying + # a notification) -> final check is done at message creation level + msg_values['record_name'] = self.sudo().display_name + if 'record_alias_domain_id' not in msg_values: + msg_values['record_alias_domain_id'] = self.sudo()._mail_get_alias_domains(default_company=self.env.company)[ + self.id].id + msg_values.update({ + 'author_id': author_id, + 'author_guest_id': author_guest_id, + 'email_from': email_from, + 'model': self._name, + 'res_id': self.id, + # content + 'body': body, + 'subject': subject or False, + 'message_type': message_type, + 'parent_id': self._message_compute_parent_id(parent_id), + 'subtype_id': subtype_id, + # recipients + 'partner_ids': partner_ids, + }) + + attachments = attachments or [] + attachment_ids = attachment_ids or [] + attachement_values = self._message_post_process_attachments(attachments, attachment_ids, msg_values) + msg_values.update(attachement_values) # attachement_ids, [body] + + new_message = self._message_create(msg_values) + + # Set main attachment field if necessary. Call as sudo as people may post + # without read access on the document, notably when replying on a + # notification, which makes attachments check crash. + self.sudo()._message_set_main_attachment_id(msg_values['attachment_ids']) + + if msg_values['author_id'] and msg_values['message_type'] != 'notification' and not self._context.get('mail_create_nosubscribe'): + if self.env['res.partner'].browse(msg_values['author_id']).active: # we dont want to add odoobot/inactive as a follower + self._message_subscribe(partner_ids=[msg_values['author_id']]) + + self._message_post_after_hook(new_message, msg_values) + self._notify_thread(new_message, msg_values, **notif_kwargs) + return new_message + +def message_notify(self, *, + partner_ids=False, parent_id=False, model=False, res_id=False, + author_id=None, email_from=None, body='', subject=False, **kwargs): + """ Shortcut allowing to notify partners of messages that shouldn't be + displayed on a document. It pushes notifications on inbox or by email depending + on the user configuration, like other notifications. """ + if self: + self.ensure_one() + # split message additional values from notify additional values + msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields) + notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs) + + author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=True) + + if not partner_ids: + _logger.warning('Message notify called without recipient_ids, skipping') + return self.env['mail.message'] + + # allow to link a notification to a document that does not inherit from + # MailThread by supporting model / res_id + if not (model and res_id): # both value should be set or none should be set (record) + model = False + res_id = False + + msg_values = { + 'parent_id': parent_id, + 'model': self._name if self else model, + 'res_id': self.id if self else res_id, + 'message_type': 'user_notification', + 'subject': subject, + 'body': body, + 'author_id': author_id, + 'email_from': email_from, + 'partner_ids': partner_ids, + 'is_internal': True, + 'record_name': False, + 'message_id': tools.generate_tracking_message_id('message-notify'), + } + msg_values.update(msg_kwargs) + # add default-like values afterwards, to avoid useless queries + if 'subtype_id' not in msg_values: + msg_values['subtype_id'] = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') + if 'reply_to' not in msg_values: + msg_values['reply_to'] = self._notify_get_reply_to(default=email_from)[self.id if self else False] + if 'email_add_signature' not in msg_values: + msg_values['email_add_signature'] = True + if self: + if 'record_alias_domain_id' not in msg_values: + msg_values['record_alias_domain_id'] = self._mail_get_alias_domains(default_company=self.env.company)[ + self.id].id + new_message = self._message_create(msg_values) + self._notify_thread(new_message, msg_values, **notif_kwargs) + return new_message + +def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, message_type='notification'): + """ Shortcut allowing to post notes on a batch of documents. It achieve the + same purpose as _message_log, done in batch to speedup quick note log. + + :param bodies: dict {record_id: body} + """ + author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=False) + + base_message_values = { + 'subject': subject, + 'author_id': author_id, + 'email_from': email_from, + 'message_type': message_type, + 'model': self._name, + 'record_alias_domain_id': False, + 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + 'is_internal': True, + 'record_name': False, + 'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from)[False], + 'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify + 'email_add_signature': False, + } + values_list = [dict(base_message_values, + res_id=record.id, + body=bodies.get(record.id, '')) + for record in self] + return self.sudo()._message_create(values_list) + +@api.model +def message_route(self, message, message_dict, model=None, thread_id=None, custom_values=None): + """ Attempt to figure out the correct target model, thread_id, + custom_values and user_id to use for an incoming message. + Multiple values may be returned, if a message had multiple + recipients matching existing mail.aliases, for example. + + The following heuristics are used, in this order: + + * if the message replies to an existing thread by having a Message-Id + that matches an existing mail_message.message_id, we take the original + message model/thread_id pair and ignore custom_value as no creation will + take place; + * look for a mail.alias entry matching the message recipients and use the + corresponding model, thread_id, custom_values and user_id. This could + lead to a thread update or creation depending on the alias; + * fallback on provided ``model``, ``thread_id`` and ``custom_values``; + * raise an exception as no route has been found + + :param string message: an email.message instance + :param dict message_dict: dictionary holding parsed message variables + :param string model: the fallback model to use if the message does not match + any of the currently configured mail aliases (may be None if a matching + alias is supposed to be present) + :type dict custom_values: optional dictionary of default field values + to pass to ``message_new`` if a new record needs to be created. + Ignored if the thread record already exists, and also if a matching + mail.alias was found (aliases define their own defaults) + :param int thread_id: optional ID of the record/thread from ``model`` to + which this mail should be attached. Only used if the message does not + reply to an existing thread and does not match any mail alias. + :return: list of routes [(model, thread_id, custom_values, user_id, alias)] + + :raises: ValueError, TypeError + """ + if not isinstance(message, EmailMessage): + raise TypeError('message must be an email.message.EmailMessage at this point') + catchall_domains_allowed = list(filter(None, (self.env["ir.config_parameter"].sudo().get_param( + "mail.catchall.domain.allowed") or '').split(','))) + if catchall_domains_allowed: + catchall_domains_allowed += self.env['mail.alias.domain'].search([]).mapped('name') + + def _filter_excluded_local_part(email): + left, _at, domain = email.partition('@') + if not domain: + return False + if catchall_domains_allowed and domain not in catchall_domains_allowed: + return False + return left + fallback_model = model + # 0. Handle bounce: verify whether this is a bounced email and use it to collect bounce data and update notifications for customers + # Bounce alias: if any To contains bounce_alias@domain + # Bounce message (not alias) + # See http://datatracker.ietf.org/doc/rfc3462/?include_text=1 + # As all MTA does not respect this RFC (googlemail is one of them), + # we also need to verify if the message come from "mailer-daemon" + # If not a bounce: reset bounce information + bounce_aliases = self.env['mail.alias.domain'].search([]).mapped('bounce_email') + email_to_list = [ + tools.email_normalize(e) or e + for e in (tools.email_split(message_dict['to']) or ['']) + ] + if bounce_aliases and any(email in bounce_aliases for email in email_to_list): + self._routing_handle_bounce(message, message_dict) + return [] + email_from = message_dict['email_from'] + email_from_localpart = (tools.email_split(email_from) or [''])[0].split('@', 1)[0].lower() + + # detection based on email_from + if email_from_localpart == 'mailer-daemon': + self._routing_handle_bounce(message, message_dict) + return [] + + # detection based on content type + content_type = message.get_content_type() + if content_type == 'multipart/report' or 'report-type=delivery-status' in content_type: + self._routing_handle_bounce(message, message_dict) + return [] + self._routing_reset_bounce(message, message_dict) + # get email.message.Message variables for future processing + message_id = message_dict['message_id'] + + # compute references to find if message is a reply to an existing thread + thread_references = message_dict['references'] or message_dict['in_reply_to'] + msg_references = [r.strip() for r in tools.unfold_references(thread_references) if 'reply_to' not in r] + mail_messages = self.env['mail.message'].sudo().search([('message_id', 'in', msg_references)], limit=1, order='id desc, message_id') + is_a_reply = bool(mail_messages) + reply_model, reply_thread_id = mail_messages.model, mail_messages.res_id + + # author and recipients + email_to_list = [e.lower() for e in (tools.email_split(message_dict['to']) or [''])] + email_to_localparts = list(filter(None, (_filter_excluded_local_part(email_to) for email_to in email_to_list))) + # Delivered-To is a safe bet in most modern MTAs, but we have to fallback on To + Cc values + # for all the odd MTAs out there, as there is no standard header for the envelope's `rcpt_to` value. + rcpt_tos_list = [e.lower() for e in (tools.email_split(message_dict['recipients']) or [''])] + rcpt_tos_localparts = list(filter(None, (_filter_excluded_local_part(email_to) for email_to in rcpt_tos_list))) + rcpt_tos_valid_list = list(rcpt_tos_list) + + + + # 1. Handle reply + # if destination = alias with different model -> consider it is a forward and not a reply + # if destination = alias with same model -> check contact settings as they still apply + if reply_model and reply_thread_id: + reply_model_id = self.env['ir.model']._get_id(reply_model) + other_model_aliases = self.env['mail.alias'].search([ + '&', + ('alias_model_id', '!=', reply_model_id), + '|', + ('alias_full_name', 'in', email_to_list), + '&', ('alias_name', 'in', email_to_localparts), ('alias_incoming_local', '=', True), + ]) + if other_model_aliases: + is_a_reply, reply_model, reply_thread_id = False, False, False + rcpt_tos_valid_list = [ + to + for to in rcpt_tos_valid_list + if ( + to in other_model_aliases.mapped('alias_full_name') + or to.split('@', 1)[0] in other_model_aliases.filtered('alias_incoming_local').mapped( + 'alias_name') + ) + ] + rcpt_tos_valid_localparts = list( + filter(None, (_filter_excluded_local_part(email_to) for email_to in rcpt_tos_valid_list))) + + if is_a_reply and reply_model: + reply_model_id = self.env['ir.model']._get_id(reply_model) + dest_aliases = self.env['mail.alias'].search([ + '&', + ('alias_model_id', '=', reply_model_id), + '|', + ('alias_full_name', 'in', rcpt_tos_list), + '&', ('alias_name', 'in', rcpt_tos_localparts), ('alias_incoming_local', '=', True), + ], limit=1) + + user_id = self._mail_find_user_for_gateway(email_from, alias=dest_aliases).id or self._uid + route = self._routing_check_route( + message, message_dict, + (reply_model, reply_thread_id, custom_values, user_id, dest_aliases), + raise_exception=False) + if route: + _logger.info( + 'Routing mail from %s to %s with Message-Id %s: direct reply to msg: model: %s, thread_id: %s, custom_values: %s, uid: %s', + email_from, message_dict['to'], message_id, reply_model, reply_thread_id, custom_values, self._uid) + return [route] + elif route is False: + return [] + + # 2. Handle new incoming email by checking aliases and applying their settings + catchall_aliases = self.env['mail.alias.domain'].search([]).mapped('catchall_email') + self = self.with_context(mail_catchall_aliases=catchall_aliases) + if rcpt_tos_list: + # no route found for a matching reference (or reply), so parent is invalid + message_dict.pop('parent_id', None) + # check it does not directly contact catchall + if self._detect_write_to_catchall(message_dict): + _logger.info('Routing mail from %s to %s with Message-Id %s: direct write to catchall, bounce', email_from, message_dict['to'], message_id) + body = self.env['ir.qweb']._render('mail.mail_bounce_catchall', { + 'message': message, + }) + self._routing_create_bounce_email( + email_from, body, message, + # add a reference with a tag, to be able to ignore response to this email + references=f'{message_id} {tools.generate_tracking_message_id("loop-detection-bounce-email")}', + reply_to=self.env.company.email) + return [] + + dest_aliases = self.env['mail.alias'].search([ + '|', + ('alias_full_name', 'in', rcpt_tos_valid_list), + '&', ('alias_name', 'in', rcpt_tos_valid_localparts), ('alias_incoming_local', '=', True), + ]) + if dest_aliases: + routes = [] + for alias in dest_aliases: + user_id = self._mail_find_user_for_gateway(email_from, alias=alias).id or self._uid + route = (alias.sudo().alias_model_id.model, alias.alias_force_thread_id, ast.literal_eval(alias.alias_defaults), user_id, alias) + route = self._routing_check_route(message, message_dict, route, raise_exception=True) + if route: + _logger.info( + 'Routing mail from %s to %s with Message-Id %s: direct alias match: %r', + email_from, message_dict['to'], message_id, route) + routes.append(route) + return routes + + # 3. Fallback to the provided parameters, if they work + if fallback_model: + # no route found for a matching reference (or reply), so parent is invalid + message_dict.pop('parent_id', None) + user_id = self._mail_find_user_for_gateway(email_from).id or self._uid + route = self._routing_check_route( + message, message_dict, + (fallback_model, thread_id, custom_values, user_id, None), + raise_exception=True) + if route: + _logger.info( + 'Routing mail from %s to %s with Message-Id %s: fallback to model:%s, thread_id:%s, custom_values:%s, uid:%s', + email_from, message_dict['to'], message_id, fallback_model, thread_id, custom_values, user_id) + return [route] + + # 4. Recipients contain catchall and unroutable emails -> bounce + if rcpt_tos_list and self.with_context(mail_catchall_write_any_to=True)._detect_write_to_catchall(message_dict): + _logger.info( + 'Routing mail from %s to %s with Message-Id %s: write to catchall + other unroutable emails, bounce', + email_from, message_dict['to'], message_id + ) + body = self.env['ir.qweb']._render('mail.mail_bounce_catchall', { + 'message': message, + }) + self._routing_create_bounce_email( + email_from, body, message, + # add a reference with a tag, to be able to ignore response to this email + references=f'{message_id} {tools.generate_tracking_message_id("loop-detection-bounce-email")}', + reply_to=self.env.company.email) + return [] + + # ValueError if no routes found and if no bounce occurred + raise ValueError( + 'No possible route found for incoming message from %s to %s (Message-Id %s:). ' + 'Create an appropriate mail.alias or force the destination model.' % + (email_from, message_dict['to'], message_id) + ) + + +BaseMailThread.get_empty_list_help = get_empty_list_help +BaseMailThread._routing_create_bounce_email = _routing_create_bounce_email +BaseMailThread._mail_find_user_for_gateway = _mail_find_user_for_gateway +BaseMailThread._mail_find_partner_from_emails = _mail_find_partner_from_emails +BaseMailThread.message_post = message_post +BaseMailThread.message_notify = message_notify +BaseMailThread.message_notify = message_notify + +class MailThread(models.AbstractModel): + _inherit = 'mail.alias' + + + def _notify_by_email_get_base_mail_values(self, message, additional_values=None): + base_mail_values = super()._notify_by_email_get_base_mail_values(message=message,additional_values=additional_values) + if 'headers' in base_mail_values: + message_sudo = message.sudo() + if message_sudo.record_alias_domain_id.bounce_email: + base_mail_values['headers']['Return-Path'] = message_sudo.record_alias_domain_id.bounce_email + return base_mail_values + + @api.model + def _detect_write_to_catchall(self, msg_dict): + """Return True if directly contacts catchall.""" + # Note: tweaked in stable to avoid doing two times same search due to bugfix + # (see odoo/odoo#161782), to clean when reaching master + if self.env.context.get("mail_catchall_aliases"): + catchall_aliases = self.env.context["mail_catchall_aliases"] + else: + catchall_aliases = self.env['mail.alias.domain'].search([]).mapped('catchall_email') + + email_to_list = [ + tools.email_normalize(e) or e + for e in (tools.email_split(msg_dict['to']) or ['']) + ] + # check it does not directly contact catchall; either (legacy) strict aka + # all TOs belong are catchall, either (optional) any catchall in all TOs + if self.env.context.get("mail_catchall_write_any_to"): + return catchall_aliases and any(email_to in catchall_aliases for email_to in email_to_list) + return ( + catchall_aliases and email_to_list and + all(email_to in catchall_aliases for email_to in email_to_list) + ) \ No newline at end of file diff --git a/multi_alias_domain_mail/models/models.py b/multi_alias_domain_mail/models/models.py new file mode 100644 index 00000000..ee862abf --- /dev/null +++ b/multi_alias_domain_mail/models/models.py @@ -0,0 +1,147 @@ +from collections import defaultdict +from odoo import api, models, tools, _ + +import logging + +_logger = logging.getLogger(__name__) + +from odoo.addons.mail.models.models import BaseModel + + +def _notify_get_reply_to(cls, default=None): + """ Override to look for aliases based on alias domains + """ + _records = cls + model = _records._name if _records and _records._name != 'mail.thread' else False + res_ids = _records.ids if _records and model else [] + _res_ids = res_ids or [False] # always have a default value located in False + + result = dict.fromkeys(_res_ids, False) + result_email = dict() + doc_names = dict() + + # group ids per company + if res_ids: + company_to_res_ids = defaultdict(list) + record_ids_to_company = _records._mail_get_companies(default=cls.env.company) + for record_id, company in record_ids_to_company.items(): + company_to_res_ids[company].append(record_id) + else: + company_to_res_ids = {cls.env.company: _res_ids} + record_ids_to_company = {_res_id: cls.env.company for _res_id in _res_ids} + + + if model and res_ids: + if not doc_names: + doc_names = dict((rec.id, rec.display_name) for rec in _records) + mail_aliases = cls.env['mail.alias'].sudo().search([ + ('alias_domain_id', '!=', False), + ('alias_parent_model_id.model', '=', model), + ('alias_parent_thread_id', 'in', res_ids), + ('alias_name', '!=', False) + ]) + # take only first found alias for each thread_id, to match order (1 found -> limit=1 for each res_id) + for alias in mail_aliases: + result_email.setdefault(alias.alias_parent_thread_id, alias.alias_full_name) + + # left ids: use catchall + left_ids = set(_res_ids) - set(result_email) + if left_ids: + for company, record_ids in company_to_res_ids.items(): + # left ids: use catchall defined on company alias domain + if company.catchall_email: + left_ids = set(record_ids) - set(result_email) + if left_ids: + result_email.update({rec_id: company.catchall_email for rec_id in left_ids}) + + reply_to_formatted = dict.fromkeys(_res_ids, default) + for res_id, record_reply_to in result_email.items(): + result[res_id] = cls._notify_get_reply_to_formatted_email( + record_reply_to, doc_names.get(res_id) or '', company=record_ids_to_company[res_id], + ) + return result + +def _notify_get_reply_to_formatted_email(cls, record_email, record_name, company=False): + """ + :param company: if given, setup the company used to + complete name in formataddr. Otherwise fallback on 'company_id' + of self or environment company; + """ + length_limit = 68 # 78 - len('Reply-To: '), 78 per RFC + # address itself is too long : return only email and log warning + if len(record_email) >= length_limit: + _logger.warning('Notification email address for reply-to is longer than 68 characters. ' + 'This might create non-compliant folding in the email header in certain DKIM ' + 'verification tech stacks. It is advised to shorten it if possible. ' + 'Record name (if set): %s ' + 'Reply-To: %s ', record_name, record_email) + return record_email + + if not company: + if len(cls) == 1: + company = cls.sudo()._mail_get_companies(default=cls.env.company) + else: + company = cls.env.company + + # try company.name + record_name, or record_name alone (or company.name alone) + name = f"{company.name} {record_name}" if record_name else company.name + + formatted_email = tools.formataddr((name, record_email)) + if len(formatted_email) > length_limit: + formatted_email = tools.formataddr((record_name or company.name, record_email)) + if len(formatted_email) > length_limit: + formatted_email = record_email + return formatted_email + + +BaseModel._notify_get_reply_to = _notify_get_reply_to +BaseModel._notify_get_reply_to_formatted_email = _notify_get_reply_to_formatted_email + + +class Base(models.AbstractModel): + _inherit = 'base' + + def _mail_get_alias_domains(self, default_company=False): + """ Return alias domain linked to each record in self. It is based + on the company (record's company, environment company) and fallback + on the first found alias domain if configuration is not correct. + + :param default_company: default company in case records + have no company (or no company field); defaults to env.company; + + :return: for each record ID in self, found + """ + record_companies = self._mail_get_companies(default=(default_company or self.env.company)) + + # prepare default alias domain, fetch only if necessary + default_domain = (default_company or self.env.company).alias_domain_id + all_companies = self.env['res.company'].browse({comp.id for comp in record_companies.values()}) + # early optimization: search only if necessary + if not default_domain and any(not comp.alias_domain_id for comp in all_companies): + default_domain = self.env['mail.alias.domain'].search([], limit=1) + + return { + record.id: ( + record_companies[record.id].alias_domain_id or default_domain + ) + for record in self + } + + @api.model + def _mail_get_company_field(self): + return 'company_id' if 'company_id' in self else False + + def _mail_get_companies(self, default=False): + """ Return company linked to each record in self. + + :param default: default value if no company field is found + or if it holds a void value. Defaults to a void recordset; + + :return: for each record ID in self, found + """ + default_company = default or self.env['res.company'] + company_fname = self._mail_get_company_field() + return { + record.id: (record[company_fname] or default_company) if company_fname else default_company + for record in self + } diff --git a/multi_alias_domain_mail/models/res_company.py b/multi_alias_domain_mail/models/res_company.py new file mode 100644 index 00000000..7958e3b9 --- /dev/null +++ b/multi_alias_domain_mail/models/res_company.py @@ -0,0 +1,43 @@ +# Copyright 2022 Tecnativa - Víctor Martínez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models, api, tools + + +class Company(models.Model): + _inherit = "res.company" + + def _default_alias_domain_id(self): + return self.env['mail.alias.domain'].search([], limit=1) + + alias_domain_id = fields.Many2one( + 'mail.alias.domain', string='Email Domain', + default=lambda self: self._default_alias_domain_id()) + alias_domain_name = fields.Char('Alias Domain Name', related='alias_domain_id.name', readonly=True, store=True) + default_from_email = fields.Char( + string="Default From", related="alias_domain_id.default_from_email", + readonly=True) + bounce_email = fields.Char(string="Bounce Email", compute="_compute_bounce") + bounce_formatted = fields.Char(string="Bounce", compute="_compute_bounce") + catchall_email = fields.Char(string="Catchall Email", compute="_compute_catchall") + catchall_formatted = fields.Char(string="Catchall", compute="_compute_catchall") + + @api.depends('alias_domain_id', 'name') + def _compute_bounce(self): + self.bounce_email = '' + self.bounce_formatted = '' + + for company in self.filtered('alias_domain_id'): + bounce_email = company.alias_domain_id.bounce_email + company.bounce_email = bounce_email + company.bounce_formatted = tools.formataddr((company.name, bounce_email)) + + @api.depends('alias_domain_id', 'name') + def _compute_catchall(self): + self.catchall_email = '' + self.catchall_formatted = '' + + for company in self.filtered('alias_domain_id'): + catchall_email = company.alias_domain_id.catchall_email + company.catchall_email = catchall_email + company.catchall_formatted = tools.formataddr((company.name, catchall_email)) diff --git a/multi_alias_domain_mail/models/res_config_settings.py b/multi_alias_domain_mail/models/res_config_settings.py new file mode 100644 index 00000000..be58c2ca --- /dev/null +++ b/multi_alias_domain_mail/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Copyright 2022 Tecnativa - Víctor Martínez +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + alias_domain_id = fields.Many2one( + 'mail.alias.domain', 'Alias Domain', + readonly=False, related='company_id.alias_domain_id', + help="If you have setup a catch-all email domain redirected to the Odoo server, enter the domain name here.") diff --git a/multi_alias_domain_mail/security/ir.model.access.csv b/multi_alias_domain_mail/security/ir.model.access.csv new file mode 100644 index 00000000..6a2b3a08 --- /dev/null +++ b/multi_alias_domain_mail/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mail_alias_domain_user,mail.alias.domain.user,model_mail_alias_domain,base.group_user,1,0,0,0 +access_mail_alias_domain_system,mail.alias.domain.system,model_mail_alias_domain,base.group_erp_manager,1,1,1,1 diff --git a/multi_alias_domain_mail/views/mail_alias_domain_views.xml b/multi_alias_domain_mail/views/mail_alias_domain_views.xml new file mode 100644 index 00000000..3586e93b --- /dev/null +++ b/multi_alias_domain_mail/views/mail_alias_domain_views.xml @@ -0,0 +1,85 @@ + + + + + mail.alias.domain.view.form + mail.alias.domain + +
+ + + + + + + + + + + +
+
+
+ + + mail.alias.domain.view.tree + mail.alias.domain + + + + + + + + + + + + + + mail.alias.domain.view.search + mail.alias.domain + + + + + + + + + + + + + + + Alias Domains + mail.alias.domain + tree,form + + + + +
diff --git a/multi_alias_domain_mail/views/mail_alias_views.xml b/multi_alias_domain_mail/views/mail_alias_views.xml new file mode 100644 index 00000000..17aae0d9 --- /dev/null +++ b/multi_alias_domain_mail/views/mail_alias_views.xml @@ -0,0 +1,39 @@ + + + + + mail.alias.view.form + mail.alias + + + + + + + + + + mail.alias.tree + mail.alias + + + + + + + + + + mail.alias.search + mail.alias + + + + + + + + + + diff --git a/multi_alias_domain_mail/views/res_company_views.xml b/multi_alias_domain_mail/views/res_company_views.xml new file mode 100644 index 00000000..5aecec8c --- /dev/null +++ b/multi_alias_domain_mail/views/res_company_views.xml @@ -0,0 +1,16 @@ + + + + res.company.view.form.inherit.mail + res.company + + + + + + + + + + + diff --git a/multi_alias_domain_mail/views/res_config_settings_view.xml b/multi_alias_domain_mail/views/res_config_settings_view.xml new file mode 100644 index 00000000..e71cd9b9 --- /dev/null +++ b/multi_alias_domain_mail/views/res_config_settings_view.xml @@ -0,0 +1,26 @@ + + + + res.config.settings + + + + + +
+
+
+ Use different domains for your mail aliases +
+
+ @ + +
+
+
+
+ +
+
+
diff --git a/multi_alias_domain_mail/wizard/__init__.py b/multi_alias_domain_mail/wizard/__init__.py new file mode 100644 index 00000000..257ac0f8 --- /dev/null +++ b/multi_alias_domain_mail/wizard/__init__.py @@ -0,0 +1 @@ +from . import mail_compose_message \ No newline at end of file diff --git a/multi_alias_domain_mail/wizard/mail_compose_message.py b/multi_alias_domain_mail/wizard/mail_compose_message.py new file mode 100644 index 00000000..a08a236f --- /dev/null +++ b/multi_alias_domain_mail/wizard/mail_compose_message.py @@ -0,0 +1,135 @@ +import ast +import base64 +import re + +from odoo import _, api, fields, models, tools, Command +from odoo.exceptions import ValidationError +from odoo.osv import expression + +def parse_res_ids(res_ids): + """ Returns the already valid list/tuple of int or returns the literal eval + of the string as a list/tuple of int. Void strings / missing values are + evaluated as an empty list. + + :param str|tuple|list res_ids: a list of ids, tuple or list; + + :raise: ValidationError if the provided res_ids is an incorrect type or + invalid format; + + :return list: list of ids + """ + if tools.is_list_of(res_ids, int) or not res_ids: + return res_ids + error_msg = _("Invalid res_ids %(res_ids_str)s (type %(res_ids_type)s)", + res_ids_str=res_ids, + res_ids_type=type(res_ids)) + try: + res_ids = ast.literal_eval(res_ids) + except Exception as e: + raise ValidationError(error_msg) from e + + if not tools.is_list_of(res_ids, int): + raise ValidationError(error_msg) + + return res_ids + +class MailComposer(models.TransientModel): + _inherit = 'mail.compose.message' + + composition_batch = fields.Boolean( + 'Batch composition', compute='_compute_composition_batch') # more than 1 record (raw source) + res_ids = fields.Text('Related Document IDs', compute='_compute_res_ids', readonly=False, store=True) + record_alias_domain_id = fields.Many2one( + 'mail.alias.domain', 'Alias Domain', + compute='_compute_record_environment', readonly=False, store=True) # useful only in monorecord comment mode + + @api.constrains('res_ids') + def _check_res_ids(self): + """ Check res_ids is a valid list of integers (or Falsy). """ + for composer in self: + composer._evaluate_res_ids() + + @api.depends('res_ids') + def _compute_composition_batch(self): + """ Determine if batch mode is activated: + + * using res_domain: always batch (even if result is singleton at a + given time, it is user and time dependent, hence batch); + * res_ids: if more than one item in the list (void and singleton are + not batch); + """ + for composer in self: + res_ids = composer._evaluate_res_ids() + composer.composition_batch = len(res_ids) > 1 if res_ids else False + + @api.depends('composition_mode', 'parent_id') + def _compute_res_ids(self): + """ Computation may come from parent in comment mode, if set. It takes + the parent message's res_id. Otherwise the composer uses the 'active_ids' + context key, unless it is too big to be stored in database. Indeed + when invoked for big mailings, 'active_ids' may be a very big list. + Support of 'active_ids' when sending is granted in order to not always + rely on 'res_ids' field. When 'active_ids' is not present, fallback + on 'active_id'. """ + for composer in self.filtered(lambda composer: not composer.res_ids): + if composer.parent_id and composer.composition_mode == 'comment': + composer.res_ids = f"{[composer.parent_id.res_id]}" + else: + active_res_ids = parse_res_ids(self.env.context.get('active_ids')) + # beware, field is limited in storage, usage of active_ids in context still required + if active_res_ids and len(active_res_ids) <= self._batch_size: + composer.res_ids = f"{self.env.context['active_ids']}" + elif not active_res_ids and self.env.context.get('active_id'): + composer.res_ids = f"{[self.env.context['active_id']]}" + + @api.depends('composition_mode', 'model', 'res_ids') + def _compute_record_environment(self): + """ In monorecord mode, fetch record company and the linked alias domain, + easing future processing notably at post and notification sending time. + + In batch mode it makes no sense to compute a single company, it will be + dynamically generated. """ + toreset = self.filtered( + lambda comp: comp.record_alias_domain_id and comp.composition_batch + ) + if toreset: + toreset.record_alias_domain_id = False + + toupdate = self.filtered( + lambda comp: not comp.composition_batch + ) + for composer in toupdate: + res_ids = composer._evaluate_res_ids() + if composer.model in self.env and len(res_ids) == 1: + record = self.env[composer.model].browse(res_ids) + composer.record_alias_domain_id = record._mail_get_alias_domains( + default_company=self.env.company + )[record.id] + + def get_mail_values(self, res_ids): + mail_values = super().get_mail_values(res_ids=res_ids) + mass_mail_mode = self.composition_mode == 'mass_mail' + for res_id in res_ids: + if mass_mail_mode: + mail_values[res_id].update(record_alias_domain_id=self.record_alias_domain_id.id) + return mail_values + + def _evaluate_res_ids(self): + """ Parse composer res_ids, which can be: an already valid list or + tuple (generally in code), a list or tuple as a string (coming from + actions). Void strings / missing values are evaluated as an empty list. + + Note that 'active_ids' context key is supported at this point as mailing + on big ID list would create issues if stored in database. + + Another context key 'composer_force_res_ids' is temporarily supported + to ease support of accounting wizard, while waiting to implement a + proper solution to language management. + + :return: a list of IDs (empty list in case of falsy strings)""" + self.ensure_one() + return parse_res_ids( + self.env.context.get('composer_force_res_ids') or + self.res_ids or + self.env.context.get('active_ids') + ) or [] \ No newline at end of file diff --git a/multi_alias_domain_mail/wizard/mail_compose_message_view.xml b/multi_alias_domain_mail/wizard/mail_compose_message_view.xml new file mode 100644 index 00000000..2aa8e6be --- /dev/null +++ b/multi_alias_domain_mail/wizard/mail_compose_message_view.xml @@ -0,0 +1,15 @@ + + + + + mail.compose.message.form + mail.compose.message + + + + + + + + + \ No newline at end of file From 86b37bd71cbe0fb7ac116ca64a447ff9ed033fc0 Mon Sep 17 00:00:00 2001 From: Anjeel Haria Date: Thu, 20 Mar 2025 14:26:44 +0530 Subject: [PATCH 2/3] [UPD] multi_alias_domain_mail : Add constraint for mail_alias domain --- multi_alias_domain_mail/models/mail_alias.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/multi_alias_domain_mail/models/mail_alias.py b/multi_alias_domain_mail/models/mail_alias.py index 9a2d061d..a51cc144 100644 --- a/multi_alias_domain_mail/models/mail_alias.py +++ b/multi_alias_domain_mail/models/mail_alias.py @@ -28,6 +28,19 @@ def init(self): ON mail_alias (alias_name, COALESCE(alias_domain_id, 0)) """) + @api.constrains('alias_name', 'alias_domain_id') + def _check_alias_domain_clash(self): + """ Within a given alias domain, aliases should not conflict with bounce + or catchall email addresses, as emails should be unique for the gateway. """ + failing = self.filtered(lambda alias: alias.alias_name and alias.alias_name in [ + alias.alias_domain_id.bounce_alias, alias.alias_domain_id.catchall_alias + ]) + if failing: + raise ValidationError( + _('Aliases %(alias_names)s is already used as bounce or catchall address. Please choose another alias.', + alias_names=', '.join(failing.mapped('display_name'))) + ) + @api.constrains('alias_domain_id', 'alias_force_thread_id', 'alias_parent_model_id', 'alias_parent_thread_id', 'alias_model_id') def _check_alias_domain_id_mc(self): From e2f8b1e07f460a70aa91683382aada4cc8d0fe38 Mon Sep 17 00:00:00 2001 From: Anjeel Haria Date: Fri, 21 Mar 2025 16:35:41 +0530 Subject: [PATCH 3/3] [UPD] multi_alias_domain_mail : Fix --- multi_alias_domain_mail/models/models.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/multi_alias_domain_mail/models/models.py b/multi_alias_domain_mail/models/models.py index ee862abf..701a7b3e 100644 --- a/multi_alias_domain_mail/models/models.py +++ b/multi_alias_domain_mail/models/models.py @@ -16,7 +16,6 @@ def _notify_get_reply_to(cls, default=None): res_ids = _records.ids if _records and model else [] _res_ids = res_ids or [False] # always have a default value located in False - result = dict.fromkeys(_res_ids, False) result_email = dict() doc_names = dict() @@ -56,10 +55,10 @@ def _notify_get_reply_to(cls, default=None): reply_to_formatted = dict.fromkeys(_res_ids, default) for res_id, record_reply_to in result_email.items(): - result[res_id] = cls._notify_get_reply_to_formatted_email( + reply_to_formatted[res_id] = cls._notify_get_reply_to_formatted_email( record_reply_to, doc_names.get(res_id) or '', company=record_ids_to_company[res_id], ) - return result + return reply_to_formatted def _notify_get_reply_to_formatted_email(cls, record_email, record_name, company=False): """