diff --git a/requirements.txt b/requirements.txt index c87117ed..81425a21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ cryptography feedparser fsspec +idna manifestoo_core openpyxl requests diff --git a/res_partner_autolink_by_domain/README.rst b/res_partner_autolink_by_domain/README.rst new file mode 100644 index 00000000..3fc4cfc9 --- /dev/null +++ b/res_partner_autolink_by_domain/README.rst @@ -0,0 +1,19 @@ +Partner Autolink by Email Domain +================================ + +When a contact is created from an incoming email, this module files it under the +contact that owns the sender's email domain. A mail from ``test@example.com`` +creates ``test@example.com`` as a child of the ``Example`` contact, so it picks up +the right commercial partner instead of sitting loose at the top level. + +Usage +----- + +List the domains a customer is known by on their company contact, under the +**Email Domains** tab. A domain may only be listed against one contact — a domain +resolving to two contacts identifies neither. + +Shared domains must never identify a customer. 200 webmail providers, ISPs, +disposable address services and common typos are shipped pre-banned, under +**Contacts > Configuration > Banned Autolink Email Domains**. Archive an entry +rather than deleting it, or the next upgrade of this module restores it. diff --git a/res_partner_autolink_by_domain/__init__.py b/res_partner_autolink_by_domain/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/res_partner_autolink_by_domain/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/res_partner_autolink_by_domain/__manifest__.py b/res_partner_autolink_by_domain/__manifest__.py new file mode 100644 index 00000000..24b438ff --- /dev/null +++ b/res_partner_autolink_by_domain/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "Partner Autolink by Email Domain", + "summary": "File contacts emailing in under the contact owning their domain", + "author": "Glo Networks", + "website": "https://github.com/GlodoUK/odoo-addons", + "category": "Uncategorized", + "version": "19.0.1.0.0", + "depends": ["contacts"], + "external_dependencies": {"python": ["idna"]}, + "data": [ + "security/ir.model.access.csv", + "data/res.partner.email.domain.ban.csv", + "views/res_partner_email_domain_ban.xml", + "views/res_partner.xml", + ], + "license": "LGPL-3", +} diff --git a/res_partner_autolink_by_domain/data/res.partner.email.domain.ban.csv b/res_partner_autolink_by_domain/data/res.partner.email.domain.ban.csv new file mode 100644 index 00000000..d4328a88 --- /dev/null +++ b/res_partner_autolink_by_domain/data/res.partner.email.domain.ban.csv @@ -0,0 +1,201 @@ +id,name +ban_gmail_com,gmail.com +ban_googlemail_com,googlemail.com +ban_hotmail_com,hotmail.com +ban_hotmail_co_uk,hotmail.co.uk +ban_hotmail_fr,hotmail.fr +ban_hotmail_de,hotmail.de +ban_hotmail_es,hotmail.es +ban_hotmail_it,hotmail.it +ban_hotmail_be,hotmail.be +ban_hotmail_ca,hotmail.ca +ban_outlook_com,outlook.com +ban_outlook_fr,outlook.fr +ban_outlook_de,outlook.de +ban_outlook_es,outlook.es +ban_outlook_be,outlook.be +ban_outlook_jp,outlook.jp +ban_live_com,live.com +ban_live_co_uk,live.co.uk +ban_live_fr,live.fr +ban_live_nl,live.nl +ban_live_be,live.be +ban_live_de,live.de +ban_live_it,live.it +ban_live_ca,live.ca +ban_live_in,live.in +ban_live_cn,live.cn +ban_msn_com,msn.com +ban_windowslive_com,windowslive.com +ban_yahoo_com,yahoo.com +ban_yahoo_co_uk,yahoo.co.uk +ban_yahoo_fr,yahoo.fr +ban_yahoo_de,yahoo.de +ban_yahoo_es,yahoo.es +ban_yahoo_it,yahoo.it +ban_yahoo_ca,yahoo.ca +ban_yahoo_co_in,yahoo.co.in +ban_yahoo_in,yahoo.in +ban_yahoo_co_jp,yahoo.co.jp +ban_yahoo_com_au,yahoo.com.au +ban_yahoo_com_br,yahoo.com.br +ban_yahoo_com_mx,yahoo.com.mx +ban_yahoo_com_hk,yahoo.com.hk +ban_yahoo_com_sg,yahoo.com.sg +ban_ymail_com,ymail.com +ban_rocketmail_com,rocketmail.com +ban_aol_com,aol.com +ban_aim_com,aim.com +ban_icloud_com,icloud.com +ban_me_com,me.com +ban_mac_com,mac.com +ban_proton_me,proton.me +ban_protonmail_com,protonmail.com +ban_protonmail_ch,protonmail.ch +ban_pm_me,pm.me +ban_tutanota_com,tutanota.com +ban_fastmail_com,fastmail.com +ban_hushmail_com,hushmail.com +ban_zoho_com,zoho.com +ban_gmx_com,gmx.com +ban_gmx_de,gmx.de +ban_gmx_net,gmx.net +ban_gmx_at,gmx.at +ban_gmx_ch,gmx.ch +ban_gmx_fr,gmx.fr +ban_web_de,web.de +ban_mail_com,mail.com +ban_email_com,email.com +ban_inbox_com,inbox.com +ban_lycos_com,lycos.com +ban_excite_com,excite.com +ban_usa_com,usa.com +ban_consultant_com,consultant.com +ban_engineer_com,engineer.com +ban_yandex_ru,yandex.ru +ban_yandex_com,yandex.com +ban_ya_ru,ya.ru +ban_mail_ru,mail.ru +ban_inbox_ru,inbox.ru +ban_list_ru,list.ru +ban_bk_ru,bk.ru +ban_rambler_ru,rambler.ru +ban_ukr_net,ukr.net +ban_qq_com,qq.com +ban_vip_qq_com,vip.qq.com +ban_foxmail_com,foxmail.com +ban_163_com,163.com +ban_126_com,126.com +ban_139_com,139.com +ban_21cn_com,21cn.com +ban_sina_com,sina.com +ban_sina_cn,sina.cn +ban_sohu_com,sohu.com +ban_yeah_net,yeah.net +ban_aliyun_com,aliyun.com +ban_naver_com,naver.com +ban_nate_com,nate.com +ban_hanmail_net,hanmail.net +ban_daum_net,daum.net +ban_rediffmail_com,rediffmail.com +ban_in_com,in.com +ban_abv_bg,abv.bg +ban_seznam_cz,seznam.cz +ban_wp_pl,wp.pl +ban_o2_pl,o2.pl +ban_op_pl,op.pl +ban_onet_pl,onet.pl +ban_interia_pl,interia.pl +ban_freemail_hu,freemail.hu +ban_sapo_pt,sapo.pt +ban_btinternet_com,btinternet.com +ban_sky_com,sky.com +ban_virginmedia_com,virginmedia.com +ban_talktalk_net,talktalk.net +ban_blueyonder_co_uk,blueyonder.co.uk +ban_ntlworld_com,ntlworld.com +ban_plus_com,plus.com +ban_eircom_net,eircom.net +ban_free_fr,free.fr +ban_orange_fr,orange.fr +ban_wanadoo_fr,wanadoo.fr +ban_sfr_fr,sfr.fr +ban_laposte_net,laposte.net +ban_neuf_fr,neuf.fr +ban_bbox_fr,bbox.fr +ban_aliceadsl_fr,aliceadsl.fr +ban_cegetel_net,cegetel.net +ban_club_internet_fr,club-internet.fr +ban_netcourrier_com,netcourrier.com +ban_voila_fr,voila.fr +ban_skynet_be,skynet.be +ban_telenet_be,telenet.be +ban_scarlet_be,scarlet.be +ban_ziggo_nl,ziggo.nl +ban_home_nl,home.nl +ban_xs4all_nl,xs4all.nl +ban_libero_it,libero.it +ban_virgilio_it,virgilio.it +ban_tin_it,tin.it +ban_alice_it,alice.it +ban_tiscali_it,tiscali.it +ban_t_online_de,t-online.de +ban_freenet_de,freenet.de +ban_arcor_de,arcor.de +ban_online_de,online.de +ban_bluewin_ch,bluewin.ch +ban_comcast_net,comcast.net +ban_verizon_net,verizon.net +ban_att_net,att.net +ban_sbcglobal_net,sbcglobal.net +ban_cox_net,cox.net +ban_bellsouth_net,bellsouth.net +ban_prodigy_net_mx,prodigy.net.mx +ban_shaw_ca,shaw.ca +ban_rogers_com,rogers.com +ban_videotron_ca,videotron.ca +ban_optusnet_com_au,optusnet.com.au +ban_bigpond_com,bigpond.com +ban_iinet_net_au,iinet.net.au +ban_terra_com_br,terra.com.br +ban_uol_com_br,uol.com.br +ban_bol_com_br,bol.com.br +ban_ig_com_br,ig.com.br +ban_mailinator_com,mailinator.com +ban_yopmail_com,yopmail.com +ban_maildrop_cc,maildrop.cc +ban_dispostable_com,dispostable.com +ban_grr_la,grr.la +ban_guerrillamail_com,guerrillamail.com +ban_sharklasers_com,sharklasers.com +ban_10minutemail_com,10minutemail.com +ban_temp_mail_org,temp-mail.org +ban_throwawaymail_com,throwawaymail.com +ban_trashmail_com,trashmail.com +ban_mailcatch_com,mailcatch.com +ban_getnada_com,getnada.com +ban_moakt_com,moakt.com +ban_emailondeck_com,emailondeck.com +ban_fakeinbox_com,fakeinbox.com +ban_dayrep_com,dayrep.com +ban_teleworm_us,teleworm.us +ban_mvrht_com,mvrht.com +ban_nwytg_net,nwytg.net +ban_trbvm_com,trbvm.com +ban_gmai_com,gmai.com +ban_gmial_com,gmial.com +ban_gmil_com,gmil.com +ban_gmal_com,gmal.com +ban_gmaill_com,gmaill.com +ban_gmail_co,gmail.co +ban_gmail_con,gmail.con +ban_gmail_om,gmail.om +ban_gmail_fr,gmail.fr +ban_hotmai_com,hotmai.com +ban_hotmial_com,hotmial.com +ban_yaho_com,yaho.com +ban_yahooo_com,yahooo.com +ban_outlok_com,outlok.com +ban_example_com,example.com +ban_example_org,example.org +ban_example_net,example.net diff --git a/res_partner_autolink_by_domain/models/__init__.py b/res_partner_autolink_by_domain/models/__init__.py new file mode 100644 index 00000000..7c8339c4 --- /dev/null +++ b/res_partner_autolink_by_domain/models/__init__.py @@ -0,0 +1,3 @@ +from . import res_partner_email_domain +from . import res_partner_email_domain_ban +from . import res_partner diff --git a/res_partner_autolink_by_domain/models/res_partner.py b/res_partner_autolink_by_domain/models/res_partner.py new file mode 100644 index 00000000..b6b9692b --- /dev/null +++ b/res_partner_autolink_by_domain/models/res_partner.py @@ -0,0 +1,96 @@ +from odoo import api, fields, models +from odoo.tools.mail import parse_contact_from_email + +from ..tools import normalize_domain + + +class ResPartner(models.Model): + _inherit = "res.partner" + + email_domain_ids = fields.One2many( + "res.partner.email.domain", + "partner_id", + string="Email Domains", + help="Domains this contact is known by. A contact emailing in from one " + "of them is filed under this contact. A domain may only be listed " + "against one contact.", + ) + + @api.model + def _get_partner_per_email_domain(self, emails): + """Map each email to the partner that claims its domain. + + Keys are derived exactly as '_find_or_create_from_emails' derives the + keys of its 'additional_values', so that the two line up. + + :return: {email key: partner id}, omitting emails whose domain is + unclaimed, banned or not a domain at all. + :rtype: dict + """ + domain_per_key = {} + for email in emails: + key = parse_contact_from_email(email)[1] + domain = normalize_domain(key.rpartition("@")[2]) if key else False + if domain: + domain_per_key[key] = domain + if not domain_per_key: + return {} + + domains = set(domain_per_key.values()) + # Bans are applied here and not only when a domain is assigned: a domain + # banned after the fact then stays inert, rather than mis-filing every + # sender who happens to share it. + banned = set( + self.env["res.partner.email.domain.ban"] + .sudo() + .search([("domain_normalized", "in", list(domains))]) + .mapped("domain_normalized") + ) + domains -= banned + if not domains: + return {} + + partner_per_domain = { + record.domain_normalized: record.partner_id.id + for record in self.env["res.partner.email.domain"] + .sudo() + .search([("domain_normalized", "in", list(domains))]) + } + return { + key: partner_per_domain[domain] + for key, domain in domain_per_key.items() + if domain in partner_per_domain + } + + @api.model + def _find_or_create_from_emails(self, emails, additional_values=None, **kwargs): + """Nest partners created from an email under the owner of its domain. + + This is the single funnel every mail-created partner passes through, so + overriding it covers the mail gateway, Discuss recipients and mail + template recipients alike. 'additional_values' only reaches partners + that are actually created, so existing contacts are never re-parented. + """ + partner_per_key = self._get_partner_per_email_domain(emails) + if partner_per_key: + additional_values = { + key: dict(values) for key, values in (additional_values or {}).items() + } + parents = self.browse(set(partner_per_key.values())).sudo() + company_per_parent = {parent.id: parent.company_id.id for parent in parents} + for key, partner_id in partner_per_key.items(): + values = additional_values.setdefault(key, {}) + # An explicit parent from the calling record wins: crm passes the + # lead's commercial partner, which beats a guess from the domain. + if values.get("parent_id"): + continue + # Do not file a contact belonging to one company under another + # company's partner. + company_id = values.get("company_id") + parent_company_id = company_per_parent.get(partner_id) + if company_id and parent_company_id and company_id != parent_company_id: + continue + values["parent_id"] = partner_id + return super()._find_or_create_from_emails( + emails, additional_values=additional_values, **kwargs + ) diff --git a/res_partner_autolink_by_domain/models/res_partner_email_domain.py b/res_partner_autolink_by_domain/models/res_partner_email_domain.py new file mode 100644 index 00000000..01b9a471 --- /dev/null +++ b/res_partner_autolink_by_domain/models/res_partner_email_domain.py @@ -0,0 +1,74 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from ..tools import normalize_domain + + +class ResPartnerEmailDomain(models.Model): + _name = "res.partner.email.domain" + _description = "Partner Email Domain" + _order = "name" + + name = fields.Char( + string="Domain", + required=True, + help="Bare domain name, e.g. example.com. Do not include an '@', a " + "local part or a URL.", + ) + # Not required=True: Odoo leaves stored computed fields out of the INSERT and + # fills them in a later recompute pass, so a NOT NULL column would reject + # every create, valid or not. + domain_normalized = fields.Char( + string="Normalized Domain", + compute="_compute_domain_normalized", + store=True, + index=True, + help="Lowercased and IDNA encoded form of the domain. This is what an " + "incoming email address is matched against.", + ) + partner_id = fields.Many2one( + "res.partner", + string="Contact", + required=True, + index=True, + ondelete="cascade", + ) + + # Deliberately unique across every partner rather than per partner: a domain + # that resolves to two contacts cannot identify either of them. + _unique_domain_normalized = models.Constraint( + "UNIQUE (domain_normalized)", + "This domain is already assigned to another contact.", + ) + + @api.depends("name") + def _compute_domain_normalized(self): + for record in self: + record.domain_normalized = normalize_domain(record.name) + + @api.constrains("domain_normalized") + def _check_domain_normalized(self): + invalid = self.filtered(lambda record: not record.domain_normalized) + if invalid: + raise ValidationError( + self.env._( + "%(domains)s: not a valid domain. Give a bare domain such " + "as example.com, without an '@', a local part or a URL.", + domains=", ".join(invalid.mapped("name")), + ) + ) + banned = ( + self.env["res.partner.email.domain.ban"] + .sudo() + .search([("domain_normalized", "in", self.mapped("domain_normalized"))]) + .mapped("domain_normalized") + ) + if banned: + raise ValidationError( + self.env._( + "%(domains)s: banned domains, shared by senders who have " + "nothing to do with each other, so they cannot identify a " + "single contact.", + domains=", ".join(banned), + ) + ) diff --git a/res_partner_autolink_by_domain/models/res_partner_email_domain_ban.py b/res_partner_autolink_by_domain/models/res_partner_email_domain_ban.py new file mode 100644 index 00000000..7d51db3a --- /dev/null +++ b/res_partner_autolink_by_domain/models/res_partner_email_domain_ban.py @@ -0,0 +1,55 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from ..tools import normalize_domain + + +class ResPartnerEmailDomainBan(models.Model): + _name = "res.partner.email.domain.ban" + _description = "Banned Email Domain" + _order = "name" + + name = fields.Char( + string="Domain", + required=True, + help="Bare domain name, e.g. gmail.com. Do not include an '@', a " + "local part or a URL.", + ) + # Not required=True: Odoo leaves stored computed fields out of the INSERT and + # fills them in a later recompute pass, so a NOT NULL column would reject + # every create, valid or not. + domain_normalized = fields.Char( + string="Normalized Domain", + compute="_compute_domain_normalized", + store=True, + index=True, + help="Lowercased and IDNA encoded form of the domain. This is what an " + "incoming email address is matched against.", + ) + active = fields.Boolean( + default=True, + help="Archive rather than delete an entry you do not want: deleted " + "entries shipped with the module come back on the next upgrade.", + ) + + _unique_domain_normalized = models.Constraint( + "UNIQUE (domain_normalized)", + "This domain is already banned.", + ) + + @api.depends("name") + def _compute_domain_normalized(self): + for record in self: + record.domain_normalized = normalize_domain(record.name) + + @api.constrains("domain_normalized") + def _check_domain_normalized(self): + invalid = self.filtered(lambda record: not record.domain_normalized) + if invalid: + raise ValidationError( + self.env._( + "%(domains)s: not a valid domain. Give a bare domain such " + "as gmail.com, without an '@', a local part or a URL.", + domains=", ".join(invalid.mapped("name")), + ) + ) diff --git a/res_partner_autolink_by_domain/pyproject.toml b/res_partner_autolink_by_domain/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/res_partner_autolink_by_domain/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/res_partner_autolink_by_domain/security/ir.model.access.csv b/res_partner_autolink_by_domain/security/ir.model.access.csv new file mode 100644 index 00000000..d61e216c --- /dev/null +++ b/res_partner_autolink_by_domain/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_res_partner_email_domain_user,access_res_partner_email_domain_user,model_res_partner_email_domain,base.group_user,1,0,0,0 +access_res_partner_email_domain_manager,access_res_partner_email_domain_manager,model_res_partner_email_domain,base.group_partner_manager,1,1,1,1 +access_res_partner_email_domain_ban_user,access_res_partner_email_domain_ban_user,model_res_partner_email_domain_ban,base.group_user,1,0,0,0 +access_res_partner_email_domain_ban_system,access_res_partner_email_domain_ban_system,model_res_partner_email_domain_ban,base.group_system,1,1,1,1 diff --git a/res_partner_autolink_by_domain/tests/__init__.py b/res_partner_autolink_by_domain/tests/__init__.py new file mode 100644 index 00000000..190147fe --- /dev/null +++ b/res_partner_autolink_by_domain/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_res_partner_email_domain +from . import test_autolink +from . import test_gateway diff --git a/res_partner_autolink_by_domain/tests/fake_models.py b/res_partner_autolink_by_domain/tests/fake_models.py new file mode 100644 index 00000000..2cf9c886 --- /dev/null +++ b/res_partner_autolink_by_domain/tests/fake_models.py @@ -0,0 +1,32 @@ +from odoo import api, fields, models + + +class AutolinkTestRecord(models.Model): + """Minimal mail.thread record that creates a contact for its sender. + + This mirrors what a real application does with incoming mail — see + 'project.task.message_new', "Auto create partner if not existent when the + task is created from email" — which is the case this module exists for. Most + applications do not: 'crm.lead.message_new' only reuses an author resolved + with no_create=True, so it never reaches this module. + + Registered by the gateway test only, never by the module itself. + """ + + _name = "autolink.test.record" + _description = "Autolink Test Record" + _inherit = ["mail.thread"] + + name = fields.Char() + partner_id = fields.Many2one("res.partner", string="Contact") + + @api.model + def message_new(self, msg_dict, custom_values=None): + custom_values = dict(custom_values or {}) + if not msg_dict.get("author_id") and msg_dict.get("email_from"): + author = self.env["mail.thread"]._partner_find_from_emails_single( + [msg_dict["email_from"]], no_create=False + ) + msg_dict["author_id"] = author.id + custom_values.setdefault("partner_id", msg_dict.get("author_id")) + return super().message_new(msg_dict, custom_values=custom_values) diff --git a/res_partner_autolink_by_domain/tests/test_autolink.py b/res_partner_autolink_by_domain/tests/test_autolink.py new file mode 100644 index 00000000..7b945917 --- /dev/null +++ b/res_partner_autolink_by_domain/tests/test_autolink.py @@ -0,0 +1,109 @@ +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +# post_install: these exercise the mail funnel and multi-company behaviour of +# whatever else is installed, which is not all in place during at_install. +@tagged("-at_install", "post_install") +class TestEmailDomainAutolink(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.acme = cls.env["res.partner"].create( + { + "name": "Acme", + "is_company": True, + "email_domain_ids": [(0, 0, {"name": "acme.test"})], + } + ) + cls.Partner = cls.env["res.partner"] + + def _find_or_create(self, emails, **kwargs): + return self.Partner._find_or_create_from_emails(emails, **kwargs) + + def test_new_partner_nested_under_domain_owner(self): + (partner,) = self._find_or_create(["bob@acme.test"]) + self.assertEqual(partner.parent_id, self.acme) + self.assertEqual(partner.email, "bob@acme.test") + self.assertEqual(partner.commercial_partner_id, self.acme) + + def test_name_and_email_forms_still_match(self): + (partner,) = self._find_or_create(['"Bob" ']) + self.assertEqual(partner.parent_id, self.acme) + self.assertEqual(partner.name, "Bob") + + def test_reached_through_the_mail_funnel(self): + """The gateway goes through mail.thread, not res.partner directly.""" + partner = self.env["mail.thread"]._partner_find_from_emails_single( + ["bob@acme.test"] + ) + self.assertEqual(partner.parent_id, self.acme) + + def test_existing_partner_not_reparented(self): + existing = self.env["res.partner"].create( + {"name": "Bob", "email": "bob@acme.test"} + ) + (partner,) = self._find_or_create(["bob@acme.test"]) + self.assertEqual(partner, existing) + self.assertFalse(partner.parent_id) + + def test_unclaimed_domain_gets_no_parent(self): + (partner,) = self._find_or_create(["bob@unknown.test"]) + self.assertFalse(partner.parent_id) + + def test_banned_domain_gets_no_parent(self): + (partner,) = self._find_or_create(["bob@gmail.com"]) + self.assertFalse(partner.parent_id) + + def test_domain_banned_after_assignment_is_inert(self): + self.env["res.partner.email.domain.ban"].create({"name": "acme.test"}) + (partner,) = self._find_or_create(["bob@acme.test"]) + self.assertFalse(partner.parent_id) + + def test_subdomain_does_not_match(self): + """Matching is exact: a subdomain has to be listed in its own right.""" + (partner,) = self._find_or_create(["bob@mail.acme.test"]) + self.assertFalse(partner.parent_id) + + def test_explicit_parent_wins(self): + other = self.env["res.partner"].create({"name": "Other", "is_company": True}) + (partner,) = self._find_or_create( + ["bob@acme.test"], + additional_values={"bob@acme.test": {"parent_id": other.id}}, + ) + self.assertEqual(partner.parent_id, other) + + def test_company_mismatch_skipped(self): + other_company = self.env["res.company"].create({"name": "Other Co"}) + self.acme.company_id = self.env.company + (partner,) = self._find_or_create( + ["bob@acme.test"], + additional_values={"bob@acme.test": {"company_id": other_company.id}}, + ) + self.assertFalse(partner.parent_id) + + def test_company_agnostic_owner_still_matches(self): + self.assertFalse(self.acme.company_id) + (partner,) = self._find_or_create( + ["bob@acme.test"], + additional_values={"bob@acme.test": {"company_id": self.env.company.id}}, + ) + self.assertEqual(partner.parent_id, self.acme) + + def test_no_create_creates_nothing(self): + before = self.env["res.partner"].search_count([]) + # A list is returned, holding a falsy value where nothing matched. + self.assertFalse(any(self._find_or_create(["bob@acme.test"], no_create=True))) + self.assertEqual(self.env["res.partner"].search_count([]), before) + + def test_batch_of_mixed_emails(self): + partners = self._find_or_create( + ["bob@acme.test", "eve@gmail.com", "sue@unknown.test"] + ) + self.assertEqual(partners[0].parent_id, self.acme) + self.assertFalse(partners[1].parent_id) + self.assertFalse(partners[2].parent_id) + + def test_invalid_email_is_untouched(self): + (partner,) = self._find_or_create(["not-an-email"]) + self.assertFalse(partner.parent_id) diff --git a/res_partner_autolink_by_domain/tests/test_gateway.py b/res_partner_autolink_by_domain/tests/test_gateway.py new file mode 100644 index 00000000..d78478b3 --- /dev/null +++ b/res_partner_autolink_by_domain/tests/test_gateway.py @@ -0,0 +1,112 @@ +from odoo.orm.model_classes import add_to_registry +from odoo.tests import tagged +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +@tagged("-at_install", "post_install") +class TestGateway(TransactionCase): + """Feed a raw MIME message to the gateway and check what comes out. + + A fake mail.thread model receives the mail so that the test depends on no + application beyond this module's own dependencies. See ./fake_models.py. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + from .fake_models import AutolinkTestRecord + + add_to_registry(cls.registry, AutolinkTestRecord) + cls.registry._setup_models__(cls.env.cr, [AutolinkTestRecord._name]) + cls.registry.init_models( + cls.env.cr, [AutolinkTestRecord._name], {"models_to_check": True} + ) + cls.addClassCleanup(cls.registry.__delitem__, AutolinkTestRecord._name) + cls.Record = cls.env[AutolinkTestRecord._name] + + cls.alias_domain = cls.env["mail.alias.domain"].search([], limit=1) or cls.env[ + "mail.alias.domain" + ].create({"name": "autolink.test"}) + cls.alias = cls.env["mail.alias"].create( + { + "alias_name": "autolink-test", + "alias_domain_id": cls.alias_domain.id, + "alias_model_id": cls.env["ir.model"]._get_id(AutolinkTestRecord._name), + } + ) + cls.alias_email = f"{cls.alias.alias_name}@{cls.alias_domain.name}" + + cls.acme = cls.env["res.partner"].create( + { + "name": "Acme", + "is_company": True, + "email_domain_ids": [(0, 0, {"name": "acme.test"})], + } + ) + + def _mime(self, email_from, subject, message_id): + return ( + "\n".join( + [ + "Content-Type: text/plain; charset=utf-8", + "MIME-Version: 1.0", + "Date: Mon, 3 Aug 2026 10:00:00 +0000", + f"From: {email_from}", + f"To: {self.alias_email}", + f"Subject: {subject}", + f"Message-Id: <{message_id}@acme.test>", + ] + ) + + "\n\nMy printer is on fire.\n" + ) + + def _process(self, email_from, subject, message_id): + record_id = self.env["mail.thread"].message_process( + False, self._mime(email_from, subject, message_id) + ) + return self.Record.browse(record_id) + + def _partner(self, email): + return self.env["res.partner"].search([("email_normalized", "=", email)]) + + @mute_logger("odoo.addons.mail.models.mail_thread") + def test_sender_nested_under_domain_owner(self): + record = self._process('"Bob Smith" ', "Printer down", "e2e-1") + self.assertEqual(record.name, "Printer down") + + bob = self._partner("bob@acme.test") + self.assertEqual(len(bob), 1, "the sender should have been created once") + self.assertEqual(bob.name, "Bob Smith") + self.assertEqual(bob.parent_id, self.acme) + self.assertEqual(bob.commercial_partner_id, self.acme) + self.assertEqual(record.partner_id, bob) + + @mute_logger("odoo.addons.mail.models.mail_thread") + def test_second_sender_same_domain_nests_alongside(self): + self._process("bob@acme.test", "Printer down", "e2e-2a") + self._process("sue@acme.test", "Printer down too", "e2e-2b") + + self.assertEqual( + set(self.acme.child_ids.mapped("email")), + {"bob@acme.test", "sue@acme.test"}, + ) + + @mute_logger("odoo.addons.mail.models.mail_thread") + def test_banned_sender_domain_gets_no_parent(self): + self._process("eve@gmail.com", "Printer fine", "e2e-3") + + eve = self._partner("eve@gmail.com") + self.assertEqual(len(eve), 1, "the sender is still created, just not nested") + self.assertFalse(eve.parent_id) + + @mute_logger("odoo.addons.mail.models.mail_thread") + def test_unclaimed_sender_domain_gets_no_parent(self): + self._process("dan@unknown.test", "Printer ok", "e2e-4") + self.assertFalse(self._partner("dan@unknown.test").parent_id) + + @mute_logger("odoo.addons.mail.models.mail_thread") + def test_alias_never_becomes_a_contact(self): + self._process("bob@acme.test", "Printer down", "e2e-5") + self.assertFalse(self._partner(self.alias_email)) diff --git a/res_partner_autolink_by_domain/tests/test_res_partner_email_domain.py b/res_partner_autolink_by_domain/tests/test_res_partner_email_domain.py new file mode 100644 index 00000000..00777f14 --- /dev/null +++ b/res_partner_autolink_by_domain/tests/test_res_partner_email_domain.py @@ -0,0 +1,129 @@ +from psycopg2 import IntegrityError + +from odoo.exceptions import ValidationError +from odoo.tests.common import BaseCase, TransactionCase +from odoo.tools import mute_logger + +from odoo.addons.res_partner_autolink_by_domain.tools import normalize_domain + +NOT_BARE_DOMAINS = [ + "@acme.test", + "bob@acme.test", + "Bob ", + "acme", + "acme.", + ".acme.test", + "acme..test", + "https://acme.test", + "acme.test/contact", + "acme.test:25", + "*.acme.test", + "acme test", + "192.168.0.1", + "[192.168.0.1]", + "-acme.test", + "acme-.test", + "_acme.test", + "acme.t", + "acme.123", + "a" * 64 + ".test", +] + + +class TestNormalizeDomain(BaseCase): + def test_canonical_form(self): + for given, expected in [ + ("acme.test", "acme.test"), + ("ACME.TEST", "acme.test"), + (" acme.test ", "acme.test"), + ("acme.test.", "acme.test"), + ("sub.acme.test", "sub.acme.test"), + ("acme-corp.test", "acme-corp.test"), + ("acme.co.uk", "acme.co.uk"), + ("a" * 63 + ".test", "a" * 63 + ".test"), + # Incoming mail gives us punycode, so that is what we store. + ("äcme.test", "xn--cme-pla.test"), + ("ÄCME.TEST", "xn--cme-pla.test"), + ("xn--cme-pla.test", "xn--cme-pla.test"), + ]: + with self.subTest(given=given): + self.assertEqual(normalize_domain(given), expected) + + def test_rejects_anything_but_a_bare_domain(self): + for given in NOT_BARE_DOMAINS + ["", False, None, 123]: + with self.subTest(given=given): + self.assertFalse(normalize_domain(given)) + + +class TestResPartnerEmailDomain(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + {"name": "Acme", "is_company": True} + ) + cls.other_partner = cls.env["res.partner"].create( + {"name": "Acme Subsidiary", "is_company": True} + ) + cls.Domain = cls.env["res.partner.email.domain"] + + def _create(self, name, partner=None): + return self.Domain.create( + {"partner_id": (partner or self.partner).id, "name": name} + ) + + def test_name_kept_raw_and_normalized_stored(self): + domain = self._create(" ACME.TEST. ") + self.assertEqual(domain.name, " ACME.TEST. ") + self.assertEqual(domain.domain_normalized, "acme.test") + + def test_normalized_recomputed_on_write(self): + domain = self._create("acme.test") + domain.name = "OTHER.TEST" + self.assertEqual(domain.domain_normalized, "other.test") + + def test_reject_not_a_bare_domain(self): + for given in NOT_BARE_DOMAINS: + with self.subTest(given=given), self.assertRaises(ValidationError): + self._create(given) + + def test_reject_not_a_bare_domain_on_write(self): + domain = self._create("acme.test") + with self.assertRaises(ValidationError): + domain.name = "bob@acme.test" + + def test_unique_across_partners(self): + self._create("acme.test") + with mute_logger("odoo.sql_db"), self.assertRaises(IntegrityError): + self._create("acme.test", partner=self.other_partner) + self.env.flush_all() + + def test_unique_ignores_the_form_it_was_typed_in(self): + """The constraint is on the normalized column, so casing cannot slip a + duplicate past it.""" + self._create("acme.test") + with mute_logger("odoo.sql_db"), self.assertRaises(IntegrityError): + self._create(" ACME.Test. ", partner=self.other_partner) + self.env.flush_all() + + def test_reject_banned_domain(self): + self.env["res.partner.email.domain.ban"].create({"name": "shared.test"}) + with self.assertRaises(ValidationError): + self._create("shared.test") + + def test_reject_seeded_banned_domain(self): + with self.assertRaises(ValidationError): + self._create("GMAIL.COM") + + def test_archived_ban_does_not_block(self): + ban = self.env["res.partner.email.domain.ban"].create({"name": "shared.test"}) + ban.active = False + self.assertTrue(self._create("shared.test")) + + def test_ban_shares_validation(self): + with self.assertRaises(ValidationError): + self.env["res.partner.email.domain.ban"].create({"name": "bob@acme.test"}) + + def test_ban_normalized_stored(self): + ban = self.env["res.partner.email.domain.ban"].create({"name": "SHARED.TEST"}) + self.assertEqual(ban.domain_normalized, "shared.test") diff --git a/res_partner_autolink_by_domain/tools.py b/res_partner_autolink_by_domain/tools.py new file mode 100644 index 00000000..0064edaf --- /dev/null +++ b/res_partner_autolink_by_domain/tools.py @@ -0,0 +1,42 @@ +import re + +import idna + +# RFC 1035, minus the trailing dot we strip during normalisation. +_DOMAIN_MAX_LENGTH = 253 +# idna is happy with an all-numeric TLD, which no customer can send mail from, +# and which is what would let an IP address through. +_TLD_RE = re.compile(r"^[a-z]{2,63}$") + + +def normalize_domain(domain): + """Return ``domain`` in the canonical form used for storage and matching. + + Lowercased, IDNA encoded, stripped of surrounding whitespace and of the + optional trailing dot. + + Returns False for anything that is not a bare domain, so a falsy result + doubles as the validation failure: an '@', a local part, a URL, a bare + hostname with no dot and an IP address are all rejected. + """ + if not domain or not isinstance(domain, str): + return False + + try: + # idna enforces the IDNA2008 + UTS-46 label rules for us: it rejects + # '@', '_', spaces, hyphens at either end of a label and over-long + # labels, lowercases, and encodes unicode to the punycode form that + # incoming mail gives us. + domain = idna.encode(domain.strip().rstrip("."), uts46=True).decode("ascii") + except idna.IDNAError: + return False + + labels = domain.split(".") + if ( + len(domain) > _DOMAIN_MAX_LENGTH + or len(labels) < 2 + or not _TLD_RE.match(labels[-1]) + ): + return False + + return domain diff --git a/res_partner_autolink_by_domain/views/res_partner.xml b/res_partner_autolink_by_domain/views/res_partner.xml new file mode 100644 index 00000000..038afce2 --- /dev/null +++ b/res_partner_autolink_by_domain/views/res_partner.xml @@ -0,0 +1,24 @@ + + + + res.partner.form.email.domain + res.partner + + + + + + + + + + + + + + + diff --git a/res_partner_autolink_by_domain/views/res_partner_email_domain_ban.xml b/res_partner_autolink_by_domain/views/res_partner_email_domain_ban.xml new file mode 100644 index 00000000..03606493 --- /dev/null +++ b/res_partner_autolink_by_domain/views/res_partner_email_domain_ban.xml @@ -0,0 +1,45 @@ + + + + res.partner.email.domain.ban.list + res.partner.email.domain.ban + + + + + + + + + res.partner.email.domain.ban.search + res.partner.email.domain.ban + + + + + + + + + Banned Email Domains + res.partner.email.domain.ban + list + +

Ban a shared email domain

+

Domains listed here are never used to identify a contact, because + they are shared by senders who have nothing to do with each + other: webmail providers, ISPs and disposable address services.

+
+
+ +