diff --git a/mail/mail/doctype/dkim_key/dkim_key.py b/mail/mail/doctype/dkim_key/dkim_key.py index 91177866..b6e11a9e 100644 --- a/mail/mail/doctype/dkim_key/dkim_key.py +++ b/mail/mail/doctype/dkim_key/dkim_key.py @@ -55,6 +55,7 @@ def create_or_update_dns_record(self) -> None: host=f"{self.domain_name.replace('.', '-')}._domainkey", type="TXT", value=f"v=DKIM1; k=rsa; p={self.public_key}", + ttl=300, category="Sending Record", attached_to_doctype=self.doctype, attached_to_docname=self.name, diff --git a/mail/mail/doctype/mail_domain/mail_domain.js b/mail/mail/doctype/mail_domain/mail_domain.js index 683368c2..b09bd42b 100644 --- a/mail/mail/doctype/mail_domain/mail_domain.js +++ b/mail/mail/doctype/mail_domain/mail_domain.js @@ -34,26 +34,13 @@ frappe.ui.form.on("Mail Domain", { () => { frappe.confirm( __( - "Are you sure you want to rotate the DKIM keys? This will generate new keys for email signing and may take up to 10 minutes to propagate across DNS servers. Emails sent during this period may be blocked due to a DKIM signature mismatch." + "Are you sure you want to rotate the DKIM keys? This will generate new keys for email signing and may take up to 10 minutes to propagate across DNS servers. Emails sent during this period may fail DKIM verification." ), () => frm.trigger("rotate_dkim_keys") ); }, __("Actions") ); - - frm.add_custom_button( - __("Rotate Access Token"), - () => { - frappe.confirm( - __( - "Are you sure you want to rotate the access token? This will replace the current token with a new one, potentially interrupting any active sessions using the old token." - ), - () => frm.trigger("rotate_access_token") - ); - }, - __("Actions") - ); } }, @@ -101,19 +88,4 @@ frappe.ui.form.on("Mail Domain", { }, }); }, - - rotate_access_token(frm) { - frappe.call({ - doc: frm.doc, - method: "rotate_access_token", - args: {}, - freeze: true, - freeze_message: __("Rotating Access Token..."), - callback: (r) => { - if (!r.exc) { - frm.refresh(); - } - }, - }); - }, }); diff --git a/mail/mail/doctype/mail_domain/mail_domain.json b/mail/mail/doctype/mail_domain/mail_domain.json index 82669510..60323e21 100644 --- a/mail/mail/doctype/mail_domain/mail_domain.json +++ b/mail/mail/doctype/mail_domain/mail_domain.json @@ -4,18 +4,16 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "section_break_ceuu", "domain_name", "enabled", "is_verified", + "is_subdomain", "column_break_lr3y", - "access_token", + "dkim_key_size", "newsletter_retention", "dns_records_section", - "dns_records", - "section_break_i51k", - "dkim_private_key", - "column_break_jqvh", - "dkim_public_key" + "dns_records" ], "fields": [ { @@ -71,36 +69,23 @@ "non_negative": 1 }, { - "fieldname": "section_break_i51k", - "fieldtype": "Section Break", - "hidden": 1, - "label": "DKIM Keys" + "fieldname": "dkim_key_size", + "fieldtype": "Select", + "label": "DKIM Key Size", + "options": "\n2048\n4096" }, { - "fieldname": "dkim_private_key", - "fieldtype": "Password", - "label": "Private Key", - "no_copy": 1, - "read_only": 1 - }, - { - "fieldname": "dkim_public_key", - "fieldtype": "Text", - "label": "Public Key", - "no_copy": 1, - "read_only": 1 + "fieldname": "section_break_ceuu", + "fieldtype": "Section Break" }, { - "fieldname": "access_token", - "fieldtype": "Password", - "hidden": 1, - "label": "Access Token", + "default": "0", + "depends_on": "eval: !doc.__islocal", + "fieldname": "is_subdomain", + "fieldtype": "Check", + "label": "Subdomain", "no_copy": 1, "read_only": 1 - }, - { - "fieldname": "column_break_jqvh", - "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, @@ -115,6 +100,11 @@ "link_doctype": "Mail Alias", "link_fieldname": "domain_name" }, + { + "group": "Reference", + "link_doctype": "DKIM Key", + "link_fieldname": "domain_name" + }, { "group": "Reference", "link_doctype": "Incoming Mail", @@ -126,7 +116,7 @@ "link_fieldname": "domain_name" } ], - "modified": "2024-11-16 15:34:25.472358", + "modified": "2025-01-05 10:38:03.188966", "modified_by": "Administrator", "module": "Mail", "name": "Mail Domain", diff --git a/mail/mail/doctype/mail_domain/mail_domain.py b/mail/mail/doctype/mail_domain/mail_domain.py index 8b759c88..dbe29485 100644 --- a/mail/mail/doctype/mail_domain/mail_domain.py +++ b/mail/mail/doctype/mail_domain/mail_domain.py @@ -4,9 +4,11 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import cint -from mail.mail.doctype.mailbox.mailbox import create_postmaster_mailbox -from mail.mail_server import get_mail_server_domain_api +from mail.mail.doctype.dkim_key.dkim_key import create_dkim_key +from mail.utils import get_dmarc_address +from mail.utils.dns import verify_dns_record class MailDomain(Document): @@ -15,20 +17,22 @@ def autoname(self) -> None: self.name = self.domain_name def validate(self) -> None: + self.validate_dkim_key_size() self.validate_newsletter_retention() + self.validate_is_verified() + self.validate_is_subdomain() - if self.is_new(): - self.validate_duplicate() - self.access_token = generate_access_token() - self.dkim_private_key, self.dkim_public_key = generate_dkim_keys() - self.add_or_update_domain_in_mail_server() + if self.is_new() or self.has_value_changed("dkim_key_size"): + create_dkim_key(self.domain_name, cint(self.dkim_key_size)) self.refresh_dns_records(do_not_save=True) - if not self.enabled: - self.is_verified = 0 + def validate_dkim_key_size(self) -> None: + """Validates the DKIM Key Size.""" - def after_insert(self) -> None: - create_postmaster_mailbox(self.domain_name) + if not self.dkim_key_size: + self.dkim_key_size = frappe.db.get_single_value( + "Mail Settings", "default_dkim_key_size", cache=True + ) def validate_newsletter_retention(self) -> None: """Validates the Newsletter Retention.""" @@ -51,22 +55,17 @@ def validate_newsletter_retention(self) -> None: "Mail Settings", "default_newsletter_retention", cache=True ) - def validate_duplicate(self) -> None: - """Validate if the Mail Domain already exists.""" + def validate_is_verified(self) -> None: + """Validates the Is Verified field.""" - if frappe.db.exists("Mail Domain", {"domain_name": self.domain_name}): - frappe.throw(_("Mail Domain {0} already exists.").format(frappe.bold(self.domain_name))) + if not self.enabled: + self.is_verified = 0 - def add_or_update_domain_in_mail_server(self) -> None: - """Adds or Updates the Domain in the Mail Server.""" + def validate_is_subdomain(self) -> None: + """Validates the Is Subdomain field.""" - domain_api = get_mail_server_domain_api() - domain_api.add_or_update_domain( - domain_name=self.domain_name, - access_token=self.access_token, - dkim_public_key=self.dkim_public_key, - mail_host=frappe.utils.get_url(), - ) + if len(self.domain_name.split(".")) > 2: + self.is_subdomain = 1 @frappe.whitelist() def refresh_dns_records(self, do_not_save: bool = False) -> None: @@ -75,10 +74,7 @@ def refresh_dns_records(self, do_not_save: bool = False) -> None: self.is_verified = 0 self.dns_records.clear() - domain_api = get_mail_server_domain_api() - dns_records = domain_api.get_dns_records(self.domain_name) - - for record in dns_records: + for record in get_dns_records(self.domain_name): self.append("dns_records", record) if not do_not_save: @@ -88,8 +84,14 @@ def refresh_dns_records(self, do_not_save: bool = False) -> None: def verify_dns_records(self, do_not_save: bool = False) -> None: """Verifies the DNS Records.""" - domain_api = get_mail_server_domain_api() - errors = domain_api.verify_dns_records(self.domain_name) + errors = [] + for record in self.dns_records: + if not verify_dns_record(record.host, record.type, record.value): + errors.append( + _("Row #{0}: Failed to verify {1} : {2}.").format( + record.idx, frappe.bold(record.type), frappe.bold(record.host) + ) + ) if not errors: self.is_verified = 1 @@ -105,65 +107,67 @@ def verify_dns_records(self, do_not_save: bool = False) -> None: def rotate_dkim_keys(self) -> None: """Rotates the DKIM Keys.""" - frappe.only_for(["System Manager", "Administrator"]) - self.dkim_private_key, self.dkim_public_key = generate_dkim_keys() - self.add_or_update_domain_in_mail_server() - self.save() + create_dkim_key(self.domain_name, cint(self.dkim_key_size)) frappe.msgprint(_("DKIM Keys rotated successfully."), indicator="green", alert=True) - @frappe.whitelist() - def rotate_access_token(self) -> None: - """Rotates the Access Token.""" - - frappe.only_for(["System Manager", "Administrator"]) - self.access_token = generate_access_token() - self.add_or_update_domain_in_mail_server() - self.save() - frappe.msgprint(_("Access Token rotated successfully."), indicator="green", alert=True) - - -def generate_access_token() -> str: - """Generates and returns the Access Token.""" - return frappe.generate_hash(length=32) +def get_dns_records(domain_name: str) -> list[dict]: + """Returns the DNS Records for the given domain.""" + records = [] + mail_settings = frappe.get_cached_doc("Mail Settings") -def generate_dkim_keys(key_size: int = 2048) -> tuple[str, str]: - """Generates and returns the DKIM Keys (Private and Public).""" - - def get_filtered_dkim_key(key_pem: str) -> str: - """Returns the filtered DKIM Key.""" + # SPF Record + records.append( + { + "category": "Sending Record", + "type": "TXT", + "host": domain_name, + "value": f"v=spf1 include:{mail_settings.spf_host}.{mail_settings.root_domain_name} ~all", + "ttl": mail_settings.default_ttl, + }, + ) - key_pem = "".join(key_pem.split()) - key_pem = ( - key_pem.replace("-----BEGINPUBLICKEY-----", "") - .replace("-----ENDPUBLICKEY-----", "") - .replace("-----BEGINRSAPRIVATEKEY-----", "") - .replace("----ENDRSAPRIVATEKEY-----", "") - ) + # DKIM Record + records.append( + { + "category": "Sending Record", + "type": "CNAME", + "host": f"frappemail._domainkey.{domain_name}", + "value": f"{domain_name.replace('.', '-')}._domainkey.{mail_settings.root_domain_name}.", + "ttl": mail_settings.default_ttl, + } + ) - return key_pem + # DMARC Record + dmarc_address = get_dmarc_address() + records.append( + { + "category": "Sending Record", + "type": "TXT", + "host": f"_dmarc.{domain_name}", + "value": f"v=DMARC1; p=reject; rua=mailto:{dmarc_address}; ruf=mailto:{dmarc_address}; fo=1; aspf=s; adkim=s; pct=100;", + "ttl": mail_settings.default_ttl, + } + ) - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric import rsa + # MX Record(s) + if inbound_agent_groups := frappe.db.get_all( + "Mail Agent Group", + filters={"enabled": 1, "inbound": 1}, + fields=["agent_group", "priority"], + order_by="priority asc", + ): + for group in inbound_agent_groups: + records.append( + { + "category": "Receiving Record", + "type": "MX", + "host": domain_name, + "value": f"{group.agent_group.split(':')[0]}.", + "priority": group.priority, + "ttl": mail_settings.default_ttl, + } + ) - private_key = rsa.generate_private_key( - public_exponent=65537, key_size=key_size, backend=default_backend() - ) - public_key = private_key.public_key() - - private_key_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ).decode() - public_key_pem = public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ).decode() - - private_key = private_key_pem - public_key = get_filtered_dkim_key(public_key_pem) - - return private_key, public_key + return records