diff --git a/mail/mail/doctype/dkim_key/dkim_key.json b/mail/mail/doctype/dkim_key/dkim_key.json index 97a1673c..e849cd30 100644 --- a/mail/mail/doctype/dkim_key/dkim_key.json +++ b/mail/mail/doctype/dkim_key/dkim_key.json @@ -86,8 +86,14 @@ ], "in_create": 1, "index_web_pages_for_search": 1, - "links": [], - "modified": "2024-09-04 20:16:14.814445", + "links": [ + { + "group": "General", + "link_doctype": "DNS Record", + "link_fieldname": "attached_to_docname" + } + ], + "modified": "2024-10-08 12:36:24.443179", "modified_by": "Administrator", "module": "Mail", "name": "DKIM Key", diff --git a/mail/mail/doctype/dkim_key/dkim_key.py b/mail/mail/doctype/dkim_key/dkim_key.py index a9515063..ad9ba9eb 100644 --- a/mail/mail/doctype/dkim_key/dkim_key.py +++ b/mail/mail/doctype/dkim_key/dkim_key.py @@ -6,6 +6,7 @@ from frappe import _, generate_hash from frappe.model.document import Document from frappe.utils.caching import request_cache +from mail.mail.doctype.dns_record.dns_record import create_or_update_dns_record class DKIMKey(Document): @@ -18,6 +19,7 @@ def validate(self) -> None: self.generate_dkim_keys() def after_insert(self) -> None: + self.create_or_update_dns_record() self.disable_existing_dkim_keys() def on_trash(self) -> None: @@ -46,6 +48,18 @@ def generate_dkim_keys(self) -> None: self.private_key, self.public_key = generate_dkim_keys(cint(self.key_size)) + def create_or_update_dns_record(self) -> None: + """Creates or Updates the DNS Record.""" + + create_or_update_dns_record( + host=f"{self.name}._domainkey", + type="TXT", + value=f"v=DKIM1; k=rsa; p={self.public_key}", + category="Sending Record", + attached_to_doctype=self.doctype, + attached_to_docname=self.name, + ) + def disable_existing_dkim_keys(self) -> None: """Disables the existing DKIM Keys.""" @@ -60,19 +74,6 @@ def disable_existing_dkim_keys(self) -> None: ) ).run() - def get_dkim_record(self) -> dict: - """Returns the DKIM Record.""" - - from mail.utils.cache import get_root_domain_name - - return { - "category": "Sending Record", - "type": "TXT", - "host": f"{self.name}._domainkey.{get_root_domain_name()}", - "value": f"v=DKIM1; k=rsa; p={self.public_key}", - "ttl": frappe.db.get_single_value("Mail Settings", "default_ttl", cache=True), - } - def create_dkim_key(domain_name: str, key_size: int | None = None) -> "DKIMKey": """Creates a DKIM Key document.""" diff --git a/mail/mail/doctype/dns_record/__init__.py b/mail/mail/doctype/dns_record/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mail/mail/doctype/dns_record/dns_record.js b/mail/mail/doctype/dns_record/dns_record.js new file mode 100644 index 00000000..7566cf51 --- /dev/null +++ b/mail/mail/doctype/dns_record/dns_record.js @@ -0,0 +1,30 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +frappe.ui.form.on("DNS Record", { + refresh(frm) { + frm.trigger("add_actions"); + }, + add_actions(frm) { + if (!frm.doc.__islocal) { + frm.add_custom_button(__("Verify DNS Record"), () => { + frm.trigger("verify_dns_record"); + }, __("Actions")); + } + }, + verify_dns_record(frm) { + frappe.call({ + doc: frm.doc, + method: "verify_dns_record", + args: { + save: true, + }, + freeze: true, + freeze_message: __("Verifying DNS Record..."), + callback: (r) => { + if (!r.exc) { + frm.refresh(); + } + } + }); + }, +}); \ No newline at end of file diff --git a/mail/mail/doctype/dns_record/dns_record.json b/mail/mail/doctype/dns_record/dns_record.json new file mode 100644 index 00000000..20f633b3 --- /dev/null +++ b/mail/mail/doctype/dns_record/dns_record.json @@ -0,0 +1,162 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2024-10-08 12:25:53.593747", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "section_break_lxj3", + "is_verified", + "column_break_ihb5", + "last_checked_at", + "section_break_bazr", + "attached_to_doctype", + "column_break_topm", + "attached_to_docname", + "section_break_gd3y", + "category", + "host", + "type", + "priority", + "ttl", + "column_break_hszw", + "value" + ], + "fields": [ + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Type", + "options": "\nA\nAAAA\nCNAME\nMX\nTXT", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "host", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Host", + "length": 255, + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "value", + "fieldtype": "Text", + "ignore_xss_filter": 1, + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Value", + "reqd": 1 + }, + { + "depends_on": "eval: doc.type == \"MX\"", + "fieldname": "priority", + "fieldtype": "Int", + "in_standard_filter": 1, + "label": "Priority", + "no_copy": 1, + "non_negative": 1 + }, + { + "fieldname": "ttl", + "fieldtype": "Int", + "in_standard_filter": 1, + "label": "TTL", + "non_negative": 1 + }, + { + "fieldname": "category", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Category", + "options": "\nSending Record\nReceiving Record\nTracking Record\nServer Record", + "reqd": 1 + }, + { + "default": "0", + "depends_on": "eval: !doc.__islocal", + "fieldname": "is_verified", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Verified", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "section_break_lxj3", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_ihb5", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_bazr", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_hszw", + "fieldtype": "Column Break" + }, + { + "fieldname": "attached_to_doctype", + "fieldtype": "Select", + "label": "Attached To DocType", + "options": "\nDKIM Key", + "set_only_once": 1 + }, + { + "fieldname": "column_break_topm", + "fieldtype": "Column Break" + }, + { + "fieldname": "attached_to_docname", + "fieldtype": "Dynamic Link", + "label": "Attached To DocName", + "options": "attached_to_doctype", + "set_only_once": 1 + }, + { + "fieldname": "section_break_gd3y", + "fieldtype": "Section Break" + }, + { + "fieldname": "last_checked_at", + "fieldtype": "Datetime", + "in_standard_filter": 1, + "label": "Last Checked At", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-10-08 12:25:53.593747", + "modified_by": "Administrator", + "module": "Mail", + "name": "DNS Record", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/mail/mail/doctype/dns_record/dns_record.py b/mail/mail/doctype/dns_record/dns_record.py new file mode 100644 index 00000000..c2c76261 --- /dev/null +++ b/mail/mail/doctype/dns_record/dns_record.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class DNSRecord(Document): + pass diff --git a/mail/mail/doctype/dns_record/test_dns_record.py b/mail/mail/doctype/dns_record/test_dns_record.py new file mode 100644 index 00000000..5c0c5904 --- /dev/null +++ b/mail/mail/doctype/dns_record/test_dns_record.py @@ -0,0 +1,78 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.utils import now +from frappe.model.document import Document + + +class DNSRecord(Document): + def validate(self): + if self.is_new(): + self.validate_ttl() + + def validate_ttl(self) -> None: + """Validates the TTL value""" + if not self.ttl: + self.ttl = frappe.db.get_single_value("Mail Settings", "default_ttl", cache=True) + + def get_fqdn(self) -> str: + """Returns the Fully Qualified Domain Name""" + + from mail.utils.cache import get_root_domain_name + + return f"{self.host}.{get_root_domain_name()}" + + @frappe.whitelist() + def verify_dns_record(self, save: bool = False) -> None: + """Verifies the DNS Record""" + + from mail.utils import verify_dns_record + + self.is_verified = 0 + self.last_checked_at = now() + if verify_dns_record(self.get_fqdn(), self.type, self.value): + self.is_verified = 1 + frappe.msgprint( + _("Verified {0}:{1} record.").format( + frappe.bold(self.get_fqdn()), frappe.bold(self.type) + ), + indicator="green", + alert=True, + ) + if save: + self.save() + + +def create_or_update_dns_record( + host: str, + type: str, + value: str, + ttl: int | None = None, + priority: int | None = None, + category: str | None = None, + attached_to_doctype: str | None = None, + attached_to_docname: str | None = None, +) -> "DNSRecord": + """Creates or updates a DNS Record""" + + if dns_record := frappe.db.exists("DNS Record", {"host": host, "type": type}): + dns_record = frappe.get_doc("DNS Record", dns_record) + else: + dns_record = frappe.new_doc("DNS Record") + dns_record.host = host + dns_record.type = type + dns_record.attached_to_doctype = attached_to_doctype + dns_record.attached_to_docname = attached_to_docname + dns_record.value = value + dns_record.ttl = ttl + dns_record.priority = priority + dns_record.category = category + dns_record.save() + + return dns_record + + +def after_doctype_insert(): + frappe.db.add_unique("DNS Record", ["host", "type"]) diff --git a/mail/mail/doctype/mail_domain/mail_domain.py b/mail/mail/doctype/mail_domain/mail_domain.py index 89dec692..eba299a1 100644 --- a/mail/mail/doctype/mail_domain/mail_domain.py +++ b/mail/mail/doctype/mail_domain/mail_domain.py @@ -4,8 +4,6 @@ import frappe from frappe import _ from frappe.utils import cint -from typing import TYPE_CHECKING -from mail.utils import get_dns_record from frappe.model.document import Document from mail.utils.user import has_role, is_system_manager from mail.mail.doctype.dkim_key.dkim_key import create_dkim_key @@ -15,9 +13,6 @@ create_postmaster_mailbox, ) -if TYPE_CHECKING: - from mail.mail.doctype.dns_record.dns_record import DNSRecord - class MailDomain(Document): def autoname(self) -> None: @@ -173,10 +168,12 @@ def get_receiving_records(self, ttl: str) -> list[dict]: def verify_dns_records(self, save: bool = False) -> None: """Verifies the DNS Records.""" + from mail.utils import verify_dns_record + self.is_verified = 1 for record in self.dns_records: - if verify_dns_record(record): + if verify_dns_record(record.host, record.type, record.value): record.is_verified = 1 frappe.msgprint( _("Row #{0}: Verified {1}:{2} record.").format( @@ -200,29 +197,6 @@ def verify_dns_records(self, save: bool = False) -> None: self.save() -def verify_dns_record(record: "DNSRecord", debug: bool = False) -> bool: - """Verifies the DNS Record.""" - - if result := get_dns_record(record.host, record.type): - for data in result: - if data: - if record.type == "MX": - data = data.exchange - - data = data.to_text().replace('"', "") - - if record.type == "TXT" and "._domainkey." in record.host: - data = data.replace(" ", "") - - if data == record.value: - return True - - if debug: - frappe.msgprint(f"Expected: {record.value} Got: {data}") - - return False - - def has_permission(doc: "Document", ptype: str, user: str) -> bool: if doc.doctype != "Mail Domain": return False diff --git a/mail/patches/v1_0/set_default_ttl.py b/mail/patches/v1_0/set_default_ttl.py index d7d84aab..52e82b93 100644 --- a/mail/patches/v1_0/set_default_ttl.py +++ b/mail/patches/v1_0/set_default_ttl.py @@ -2,4 +2,4 @@ def execute(): - frappe.db.set_single_value("Mail Settings", "default_ttl", "3600") \ No newline at end of file + frappe.db.set_single_value("Mail Settings", "default_ttl", "3600") diff --git a/mail/utils/__init__.py b/mail/utils/__init__.py index 86d99bc6..6d59e270 100644 --- a/mail/utils/__init__.py +++ b/mail/utils/__init__.py @@ -34,6 +34,26 @@ def get_dns_record( frappe.throw(err_msg) +def verify_dns_record( + fqdn: str, type: str, expected_value: str, debug: bool = False +) -> bool: + """Verifies the DNS Record.""" + + if result := get_dns_record(fqdn, type): + for data in result: + if data: + if type == "MX": + data = data.exchange + data = data.to_text().replace('"', "") + if type == "TXT" and "._domainkey." in fqdn: + data = data.replace(" ", "") + if data == expected_value: + return True + if debug: + frappe.msgprint(f"Expected: {expected_value} Got: {data}") + return False + + def get_host_by_ip(ip_address: str, raise_exception: bool = False) -> str | None: """Returns host for the given IP address."""