diff --git a/mail_server/api/auth.py b/mail_server/api/auth.py index f1f631a..33886ff 100644 --- a/mail_server/api/auth.py +++ b/mail_server/api/auth.py @@ -1,13 +1,9 @@ import frappe from frappe import _ -from mail_server.utils.user import has_role, is_system_manager - @frappe.whitelist(methods=["POST"]) def validate() -> None: """Validate the user is a domain owner or system manager.""" - user = frappe.session.user - if not has_role(user, "Domain Owner") and not is_system_manager(user): - frappe.throw(_("Not permitted"), frappe.PermissionError) + frappe.only_for(["Domain Owner", "System Manager", "Administrator"]) diff --git a/mail_server/api/inbound.py b/mail_server/api/inbound.py index 5e68525..f8be135 100644 --- a/mail_server/api/inbound.py +++ b/mail_server/api/inbound.py @@ -21,8 +21,6 @@ ) -from email.utils import formataddr - from mail_server.utils import convert_to_utc diff --git a/mail_server/api/outbound.py b/mail_server/api/outbound.py index f2870d0..60c80ba 100644 --- a/mail_server/api/outbound.py +++ b/mail_server/api/outbound.py @@ -8,6 +8,8 @@ @frappe.whitelist(methods=["POST"]) def send() -> str: + """Sends the outgoing mail.""" + data = json.loads(frappe.request.data.decode()) log = create_outgoing_mail_log(data["outgoing_mail"], data["recipients"], data["message"]) return log.name diff --git a/mail_server/mail_server/doctype/dns_record/dns_record.py b/mail_server/mail_server/doctype/dns_record/dns_record.py index 84da979..a14cc51 100644 --- a/mail_server/mail_server/doctype/dns_record/dns_record.py +++ b/mail_server/mail_server/doctype/dns_record/dns_record.py @@ -7,7 +7,8 @@ from frappe.utils import cint, now from mail_server.mail_server.doctype.dns_record.dns_provider import DNSProvider -from mail_server.utils import enqueue_job +from mail_server.utils import enqueue_job, verify_dns_record +from mail_server.utils.cache import get_root_domain_name class DNSRecord(Document): @@ -92,16 +93,12 @@ def delete_record_from_dns_provider(self) -> None: def get_fqdn(self) -> str: """Returns the Fully Qualified Domain Name""" - from mail_server.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_server.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): @@ -163,7 +160,7 @@ def create_or_update_dns_record( dns_record.ttl = ttl dns_record.priority = priority dns_record.category = category - dns_record.insert(ignore_permissions=True) + dns_record.save(ignore_permissions=True) return dns_record @@ -177,12 +174,5 @@ def verify_all_dns_records() -> None: dns_record.verify_dns_record(save=True) -@frappe.whitelist() -def enqueue_verify_all_dns_records() -> None: - "Called by the scheduler to enqueue the `verify_all_dns_records` job." - - enqueue_job(verify_all_dns_records, queue="long") - - def after_doctype_insert() -> None: frappe.db.add_unique("DNS Record", ["host", "type"]) diff --git a/mail_server/mail_server/doctype/dns_record/dns_record_list.js b/mail_server/mail_server/doctype/dns_record/dns_record_list.js index ce8e427..360945c 100644 --- a/mail_server/mail_server/doctype/dns_record/dns_record_list.js +++ b/mail_server/mail_server/doctype/dns_record/dns_record_list.js @@ -11,7 +11,7 @@ frappe.listview_settings["DNS Record"] = { function verify_all_dns_records(listview) { frappe.call({ - method: "mail_server.mail_server.doctype.dns_record.dns_record.enqueue_verify_all_dns_records", + method: "mail_server.tasks.enqueue_verify_all_dns_records", freeze: true, freeze_message: __("Creating Job..."), callback: () => { diff --git a/mail_server/mail_server/doctype/incoming_mail_log/incoming_mail_log.py b/mail_server/mail_server/doctype/incoming_mail_log/incoming_mail_log.py index 283ae28..a865c3a 100644 --- a/mail_server/mail_server/doctype/incoming_mail_log/incoming_mail_log.py +++ b/mail_server/mail_server/doctype/incoming_mail_log/incoming_mail_log.py @@ -9,6 +9,12 @@ from frappe.utils import cint, now, time_diff_in_seconds, validate_email_address from uuid_utils import uuid7 +from mail_server.mail_server.doctype.spam_check_log.spam_check_log import create_spam_check_log +from mail_server.rabbitmq import INCOMING_MAIL_QUEUE, rabbitmq_context +from mail_server.utils import parse_iso_datetime +from mail_server.utils.email_parser import EmailParser, extract_ip_and_host +from mail_server.utils.validation import is_domain_registry_exists + class IncomingMailLog(Document): def autoname(self) -> None: @@ -43,11 +49,6 @@ def enqueue_process_message(self) -> None: def process_message(self) -> None: """Process the email message and update the log.""" - from mail_server.mail_server.doctype.spam_check_log.spam_check_log import create_spam_check_log - from mail_server.utils import parse_iso_datetime - from mail_server.utils.email_parser import EmailParser, extract_ip_and_host - from mail_server.utils.validation import is_domain_registry_exists - parser = EmailParser(self.message) self.display_name, self.sender = parser.get_sender() self.receiver = parser.get_header("Delivered-To") @@ -122,8 +123,6 @@ def create_incoming_mail_log(agent: str, message: str) -> "IncomingMailLog": def fetch_emails_from_queue() -> None: """Fetch emails from queue and create Incoming Mail Log.""" - from mail_server.rabbitmq import INCOMING_MAIL_QUEUE, rabbitmq_context - max_failures = 3 total_failures = 0 diff --git a/mail_server/mail_server/doctype/mail_agent_group/mail_agent_group.json b/mail_server/mail_server/doctype/mail_agent_group/mail_agent_group.json index c327b3d..083afed 100644 --- a/mail_server/mail_server/doctype/mail_agent_group/mail_agent_group.json +++ b/mail_server/mail_server/doctype/mail_agent_group/mail_agent_group.json @@ -80,8 +80,14 @@ } ], "index_web_pages_for_search": 1, - "links": [], - "modified": "2024-10-21 10:09:50.083574", + "links": [ + { + "group": "Reference", + "link_doctype": "Mail Agent", + "link_fieldname": "agent_group" + } + ], + "modified": "2024-10-30 16:39:27.695709", "modified_by": "Administrator", "module": "Mail Server", "name": "Mail Agent Group", diff --git a/mail_server/mail_server/doctype/mail_domain_registry/mail_domain_registry.js b/mail_server/mail_server/doctype/mail_domain_registry/mail_domain_registry.js index 955ab92..1d31646 100644 --- a/mail_server/mail_server/doctype/mail_domain_registry/mail_domain_registry.js +++ b/mail_server/mail_server/doctype/mail_domain_registry/mail_domain_registry.js @@ -8,7 +8,7 @@ frappe.ui.form.on("Mail Domain Registry", { set_queries(frm) { frm.set_query("domain_owner", () => ({ - query: "mail_server.mail_server.doctype.mail_domain_registry.mail_domain_registry.get_users_with_domain_owner_role", + query: "mail_server.utils.query.get_users_with_domain_owner_role", filters: { enabled: 1, role: "Domain Owner", diff --git a/mail_server/mail_server/doctype/mail_domain_registry/mail_domain_registry.py b/mail_server/mail_server/doctype/mail_domain_registry/mail_domain_registry.py index 7341fdb..0824f4f 100644 --- a/mail_server/mail_server/doctype/mail_domain_registry/mail_domain_registry.py +++ b/mail_server/mail_server/doctype/mail_domain_registry/mail_domain_registry.py @@ -13,6 +13,7 @@ from mail_server.mail_server.doctype.mail_server_settings.mail_server_settings import ( validate_mail_server_settings, ) +from mail_server.utils import verify_dns_record from mail_server.utils.cache import delete_cache from mail_server.utils.user import has_role, is_system_manager @@ -143,8 +144,6 @@ def get_dns_records(self) -> list[dict]: def verify_dns_records(self) -> None: """Verifies DNS Records""" - from mail_server.utils import verify_dns_record - errors = [] for record in self.get_dns_records(): if not verify_dns_record(record["host"], record["type"], record["value"]): @@ -183,34 +182,6 @@ def _db_set( self.notify_update() -@frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def get_users_with_domain_owner_role( - doctype: str | None = None, - txt: str | None = None, - searchfield: str | None = None, - start: int = 0, - page_len: int = 20, - filters: dict | None = None, -) -> list: - """Returns a list of User(s) who have Domain Owner role.""" - - USER = frappe.qb.DocType("User") - HAS_ROLE = frappe.qb.DocType("Has Role") - return ( - frappe.qb.from_(USER) - .left_join(HAS_ROLE) - .on(USER.name == HAS_ROLE.parent) - .select(USER.name) - .where( - (USER.enabled == 1) - & (USER.name.like(f"%{txt}%")) - & (HAS_ROLE.role == "Domain Owner") - & (HAS_ROLE.parenttype == "User") - ) - ).run(as_dict=False) - - def get_permission_query_condition(user: str | None = None) -> str: if not user: user = frappe.session.user diff --git a/mail_server/mail_server/doctype/mail_server_settings/mail_server_settings.py b/mail_server/mail_server/doctype/mail_server_settings/mail_server_settings.py index 1156a0d..9b58e95 100644 --- a/mail_server/mail_server/doctype/mail_server_settings/mail_server_settings.py +++ b/mail_server/mail_server/doctype/mail_server_settings/mail_server_settings.py @@ -1,11 +1,14 @@ # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +import socket + import frappe from frappe import _ from frappe.model.document import Document from frappe.utils import cint +from mail_server.rabbitmq import rabbitmq_context from mail_server.utils.cache import delete_cache from mail_server.utils.validation import is_valid_host @@ -49,6 +52,8 @@ def validate_spf_host(self) -> None: if not self.has_value_changed("spf_host"): return + from mail_server.mail_server.doctype.mail_agent.mail_agent import create_or_update_spf_dns_record + self.spf_host = self.spf_host.lower() if not is_valid_host(self.spf_host): msg = _( @@ -63,8 +68,6 @@ def validate_spf_host(self) -> None: ): frappe.delete_doc("DNS Record", spf_dns_record, ignore_permissions=True) - from mail_server.mail_server.doctype.mail_agent.mail_agent import create_or_update_spf_dns_record - create_or_update_spf_dns_record(self.spf_host) def validate_default_dkim_key_size(self) -> None: @@ -83,10 +86,6 @@ def validate_rmq_host(self) -> None: def test_rabbitmq_connection(self) -> None: """Tests the connection to the RabbitMQ server.""" - import socket - - from mail_server.rabbitmq import rabbitmq_context - try: with rabbitmq_context(): frappe.msgprint(_("Connection Successful"), alert=True, indicator="green") diff --git a/mail_server/mail_server/doctype/mail_server_sync_history/mail_server_sync_history.py b/mail_server/mail_server/doctype/mail_server_sync_history/mail_server_sync_history.py index 8220b13..c3b1a3b 100644 --- a/mail_server/mail_server/doctype/mail_server_sync_history/mail_server_sync_history.py +++ b/mail_server/mail_server/doctype/mail_server_sync_history/mail_server_sync_history.py @@ -67,7 +67,7 @@ def get_mail_server_sync_history(source: str, user: str, domain_name: str) -> "M return create_mail_server_sync_history(source, user, domain_name, commit=True) -def on_doctype_update(): +def on_doctype_update() -> None: frappe.db.add_unique( "Mail Server Sync History", ["source", "user", "domain_name"], diff --git a/mail_server/mail_server/doctype/outgoing_mail_log/outgoing_mail_log.py b/mail_server/mail_server/doctype/outgoing_mail_log/outgoing_mail_log.py index b8a575a..fd9a0a9 100644 --- a/mail_server/mail_server/doctype/outgoing_mail_log/outgoing_mail_log.py +++ b/mail_server/mail_server/doctype/outgoing_mail_log/outgoing_mail_log.py @@ -10,12 +10,16 @@ import requests from frappe import _ from frappe.model.document import Document +from frappe.query_builder.functions import GroupConcat from frappe.utils import cint, now, time_diff_in_seconds +from pypika import Order from uuid_utils import uuid7 from mail_server.mail_server.doctype.spam_check_log.spam_check_log import create_spam_check_log +from mail_server.rabbitmq import OUTGOING_MAIL_QUEUE, OUTGOING_MAIL_STATUS_QUEUE, rabbitmq_context from mail_server.utils import convert_to_utc, parse_iso_datetime -from mail_server.utils.cache import get_user_owned_domains +from mail_server.utils.cache import get_root_domain_name, get_user_owned_domains +from mail_server.utils.email_parser import EmailParser from mail_server.utils.user import is_system_manager @@ -47,8 +51,6 @@ def set_ip_address(self) -> None: def validate_message(self) -> None: """Validate message and extract domain name.""" - from mail_server.utils.email_parser import EmailParser - parser = EmailParser(self.message) parser.update_header("X-FM-OML", self.name) self.priority = cint(parser.get_header("X-Priority")) @@ -260,8 +262,6 @@ def push_to_queue(self) -> None: if not (self.status == "Accepted" and self.failed_count < 3): return - from mail_server.rabbitmq import OUTGOING_MAIL_QUEUE, rabbitmq_context - transfer_started_at = now() transfer_started_after = time_diff_in_seconds(transfer_started_at, self.processed_at) self._db_set( @@ -329,12 +329,6 @@ def is_spam_detection_enabled_for_outbound() -> bool: def push_emails_to_queue() -> None: """Pushes emails to the queue for sending.""" - from frappe.query_builder.functions import GroupConcat - from pypika import Order - - from mail_server.rabbitmq import OUTGOING_MAIL_QUEUE, rabbitmq_context - from mail_server.utils.cache import get_root_domain_name - batch_size = 1000 max_failures = 3 total_failures = 0 @@ -518,8 +512,6 @@ def delivered(data: dict) -> None: except Exception: frappe.log_error(title="Update Delivery Status - Delivered", message=frappe.get_traceback()) - from mail_server.rabbitmq import OUTGOING_MAIL_STATUS_QUEUE, rabbitmq_context - max_failures = 3 total_failures = 0 diff --git a/mail_server/mail_server/doctype/spam_check_log/spam_check_log.py b/mail_server/mail_server/doctype/spam_check_log/spam_check_log.py index 1f69b5f..5051f59 100644 --- a/mail_server/mail_server/doctype/spam_check_log/spam_check_log.py +++ b/mail_server/mail_server/doctype/spam_check_log/spam_check_log.py @@ -19,7 +19,7 @@ class SpamCheckLog(Document): @staticmethod - def clear_old_logs(days=7): + def clear_old_logs(days=7) -> None: log = frappe.qb.DocType("Spam Check Log") frappe.db.delete(log, filters=(log.creation < (Now() - Interval(days=days)))) diff --git a/mail_server/tasks.py b/mail_server/tasks.py index e021bf7..bd61bb6 100644 --- a/mail_server/tasks.py +++ b/mail_server/tasks.py @@ -1,13 +1,17 @@ import frappe +from mail_server.mail_server.doctype.dns_record.dns_record import verify_all_dns_records +from mail_server.mail_server.doctype.incoming_mail_log.incoming_mail_log import fetch_emails_from_queue +from mail_server.mail_server.doctype.outgoing_mail_log.outgoing_mail_log import ( + fetch_and_update_delivery_statuses, + push_emails_to_queue, +) from mail_server.utils import enqueue_job def enqueue_push_emails_to_queue() -> None: "Called by the scheduler to enqueue the `push_emails_to_queue` job." - from mail_server.mail_server.doctype.outgoing_mail_log.outgoing_mail_log import push_emails_to_queue - frappe.session.user = "Administrator" enqueue_job(push_emails_to_queue, queue="long") @@ -16,10 +20,6 @@ def enqueue_push_emails_to_queue() -> None: def enqueue_fetch_and_update_delivery_statuses() -> None: "Called by the scheduler to enqueue the `fetch_and_update_delivery_statuses` job." - from mail_server.mail_server.doctype.outgoing_mail_log.outgoing_mail_log import ( - fetch_and_update_delivery_statuses, - ) - frappe.session.user = "Administrator" enqueue_job(fetch_and_update_delivery_statuses, queue="long") @@ -28,7 +28,12 @@ def enqueue_fetch_and_update_delivery_statuses() -> None: def enqueue_fetch_emails_from_queue() -> None: "Called by the scheduler to enqueue the `fetch_emails_from_queue` job." - from mail_server.mail_server.doctype.incoming_mail_log.incoming_mail_log import fetch_emails_from_queue - frappe.session.user = "Administrator" enqueue_job(fetch_emails_from_queue, queue="long") + + +@frappe.whitelist() +def enqueue_verify_all_dns_records() -> None: + "Called by the scheduler to enqueue the `verify_all_dns_records` job." + + enqueue_job(verify_all_dns_records, queue="long") diff --git a/mail_server/utils/__init__.py b/mail_server/utils/__init__.py index 10ddeba..3bcd2d5 100644 --- a/mail_server/utils/__init__.py +++ b/mail_server/utils/__init__.py @@ -1,11 +1,14 @@ +import socket from collections.abc import Callable from datetime import datetime +from email.utils import parsedate_to_datetime as parsedate import dns.resolver import frappe import pytz from frappe import _ -from frappe.utils import get_datetime, get_system_timezone +from frappe.utils import get_datetime, get_datetime_str, get_system_timezone +from frappe.utils.background_jobs import get_jobs def get_dns_record(fqdn: str, type: str = "A", raise_exception: bool = False) -> dns.resolver.Answer | None: @@ -57,8 +60,6 @@ def verify_dns_record(fqdn: str, type: str, expected_value: str, debug: bool = F def get_host_by_ip(ip_address: str, raise_exception: bool = False) -> str | None: """Returns host for the given IP address.""" - import socket - err_msg = None try: @@ -73,8 +74,6 @@ def get_host_by_ip(ip_address: str, raise_exception: bool = False) -> str | None def enqueue_job(method: str | Callable, **kwargs) -> None: """Enqueues a background job.""" - from frappe.utils.background_jobs import get_jobs - site = frappe.local.site jobs = get_jobs(site=site) if not jobs or method not in jobs[site]: @@ -95,8 +94,6 @@ def convert_to_utc(date_time: datetime | str, from_timezone: str | None = None) def parsedate_to_datetime(date_header: str, to_timezone: str | None = None) -> "datetime": """Returns datetime object from parsed date header.""" - from email.utils import parsedate_to_datetime as parsedate - dt = parsedate(date_header) if not dt: frappe.throw(_("Invalid date format: {0}").format(date_header)) @@ -109,11 +106,23 @@ def parse_iso_datetime( ) -> str | datetime: """Converts ISO datetime string to datetime object in given timezone.""" - from frappe.utils import get_datetime_str - if not to_timezone: to_timezone = get_system_timezone() dt = datetime.fromisoformat(datetime_str.replace("Z", "+00:00")).astimezone(pytz.timezone(to_timezone)) return get_datetime_str(dt) if as_str else dt + + +def add_or_update_tzinfo(date_time: datetime | str, timezone: str | None = None) -> str: + """Adds or updates timezone to the datetime.""" + + date_time = get_datetime(date_time) + target_tz = pytz.timezone(timezone or get_system_timezone()) + + if date_time.tzinfo is None: + date_time = target_tz.localize(date_time) + else: + date_time = date_time.astimezone(target_tz) + + return str(date_time) diff --git a/mail_server/utils/email_parser.py b/mail_server/utils/email_parser.py index edacd1f..1759317 100644 --- a/mail_server/utils/email_parser.py +++ b/mail_server/utils/email_parser.py @@ -1,6 +1,14 @@ +import re +from email import message_from_string, policy +from email.header import decode_header, make_header from email.utils import parseaddr from typing import TYPE_CHECKING +from frappe.utils import cint, get_datetime_str +from frappe.utils.file_manager import save_file + +from mail_server.utils import parsedate_to_datetime + if TYPE_CHECKING: from email.message import Message @@ -14,8 +22,6 @@ def __init__(self, message: str) -> None: def get_parsed_message(message: str) -> "Message": """Returns parsed email message object from string.""" - from email import message_from_string - return message_from_string(message) def get_message_id(self) -> str | None: @@ -33,8 +39,6 @@ def get_in_reply_to(self) -> str | None: def get_subject(self) -> str | None: """Returns the decoded subject of the email.""" - from email.header import decode_header, make_header - if subject := self.message["Subject"]: decoded_subject = str(make_header(decode_header(subject))) return remove_whitespace_characters(decoded_subject) @@ -68,16 +72,11 @@ def update_header(self, header: str, value: str) -> None: def get_date(self) -> str | None: """Returns the date of the email.""" - from frappe.utils import get_datetime_str - - from mail_server.utils import parsedate_to_datetime - if date_header := self.message.get("Date"): return get_datetime_str(parsedate_to_datetime(date_header)) def get_size(self) -> int: """Returns the size of the email.""" - from email import policy return len(self.message.as_string(policy=policy.default).encode("utf-8")) @@ -102,11 +101,6 @@ def get_recipients(self, types: str | list | None = None) -> list[dict]: def save_attachments(self, doctype: str, docname: str, is_private: bool = True) -> None: """Saves the attachments of the email.""" - import re - - from frappe.utils import cint - from frappe.utils.file_manager import save_file - def save_attachment( filename: str, content: bytes, doctype: str, docname: str, is_private: bool ) -> dict: @@ -214,8 +208,6 @@ def extract_ip_and_host(header: str | None = None) -> tuple[str | None, str | No if not header: return None, None - import re - ip_pattern = re.compile(r"\[(?P[\d\.]+|[a-fA-F0-9:]+)") host_pattern = re.compile(r"from\s+(?P[^\s]+)") @@ -234,8 +226,6 @@ def extract_spam_score(header: str | None = None) -> float: if not header: return 0.0 - import re - spam_score_pattern = re.compile(r"score=(-?\d+\.?\d*)") if match := spam_score_pattern.search(header): return float(match.group(1)) diff --git a/mail_server/utils/query.py b/mail_server/utils/query.py new file mode 100644 index 0000000..0a03bef --- /dev/null +++ b/mail_server/utils/query.py @@ -0,0 +1,29 @@ +import frappe + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_users_with_domain_owner_role( + doctype: str | None = None, + txt: str | None = None, + searchfield: str | None = None, + start: int = 0, + page_len: int = 20, + filters: dict | None = None, +) -> list: + """Returns a list of User(s) who have Domain Owner role.""" + + USER = frappe.qb.DocType("User") + HAS_ROLE = frappe.qb.DocType("Has Role") + return ( + frappe.qb.from_(USER) + .left_join(HAS_ROLE) + .on(USER.name == HAS_ROLE.parent) + .select(USER.name) + .where( + (USER.enabled == 1) + & (USER.name.like(f"%{txt}%")) + & (HAS_ROLE.role == "Domain Owner") + & (HAS_ROLE.parenttype == "User") + ) + ).run(as_dict=False) diff --git a/mail_server/utils/validation.py b/mail_server/utils/validation.py index 2dbfc47..f91b064 100644 --- a/mail_server/utils/validation.py +++ b/mail_server/utils/validation.py @@ -1,4 +1,6 @@ +import ipaddress import re +import socket import frappe from frappe import _ @@ -13,6 +15,33 @@ def is_valid_host(host: str) -> bool: return bool(re.compile(r"^[a-zA-Z0-9_-]+$").match(host)) +def is_valid_ip(ip: str, category: str | None = None) -> bool: + """Returns True if the IP is valid else False.""" + + try: + ip_obj = ipaddress.ip_address(ip) + + if category: + if category == "private": + return ip_obj.is_private + elif category == "public": + return not ip_obj.is_private + + return True + except ValueError: + return False + + +def is_port_open(fqdn: str, port: int) -> bool: + """Returns True if the port is open else False.""" + + try: + with socket.create_connection((fqdn, port), timeout=10): + return True + except (TimeoutError, OSError): + return False + + def is_domain_registry_exists( domain_name: str, exclude_disabled: bool = True, raise_exception: bool = False ) -> bool: diff --git a/pyproject.toml b/pyproject.toml index a77fa42..1f606bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" dynamic = ["version"] dependencies = [ # "frappe~=15.0.0" # Installed and managed by bench. + "uuid-utils~=0.6.1", "dnspython~=2.4.2", "pika~=1.3.2", ]