Skip to content

Commit

Permalink
refactor: Mail Domain
Browse files Browse the repository at this point in the history
  • Loading branch information
s-aga-r committed Jan 5, 2025
1 parent 16c811a commit d8acee1
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 143 deletions.
1 change: 1 addition & 0 deletions mail/mail/doctype/dkim_key/dkim_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 1 addition & 29 deletions mail/mail/doctype/mail_domain/mail_domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
}
},

Expand Down Expand Up @@ -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();
}
},
});
},
});
52 changes: 21 additions & 31 deletions mail/mail/doctype/mail_domain/mail_domain.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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",
Expand Down
170 changes: 87 additions & 83 deletions mail/mail/doctype/mail_domain/mail_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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."""
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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

0 comments on commit d8acee1

Please sign in to comment.