diff --git a/mail/api/auth.py b/mail/api/auth.py index fe297963..0930f9bf 100644 --- a/mail/api/auth.py +++ b/mail/api/auth.py @@ -1,7 +1,7 @@ import frappe from frappe import _ -from mail.utils.user import has_role, is_mailbox_owner +from mail.utils.user import has_role, is_mail_account_owner from mail.utils.validation import ( validate_mailbox_for_incoming, validate_mailbox_for_outgoing, @@ -37,7 +37,7 @@ def validate_mailbox(mailbox: str) -> None: user = frappe.session.user - if not is_mailbox_owner(mailbox, user): + if not is_mail_account_owner(mailbox, user): frappe.throw( _("Mailbox {0} is not associated with user {1}").format(frappe.bold(mailbox), frappe.bold(user)) ) diff --git a/mail/mail/doctype/incoming_mail/incoming_mail.py b/mail/mail/doctype/incoming_mail/incoming_mail.py index e913d563..e6404ed0 100644 --- a/mail/mail/doctype/incoming_mail/incoming_mail.py +++ b/mail/mail/doctype/incoming_mail/incoming_mail.py @@ -20,7 +20,7 @@ from mail.utils.cache import get_postmaster_for_domain from mail.utils.dt import add_or_update_tzinfo, parse_iso_datetime from mail.utils.email_parser import EmailParser, extract_ip_and_host -from mail.utils.user import get_user_mailboxes, is_mailbox_owner, is_system_manager +from mail.utils.user import get_user_mailboxes, is_mail_account_owner, is_system_manager if TYPE_CHECKING: from mail.mail.doctype.outgoing_mail.outgoing_mail import OutgoingMail @@ -221,7 +221,7 @@ def has_permission(doc: "Document", ptype: str, user: str) -> bool: return False user_is_system_manager = is_system_manager(user) - user_is_mailbox_owner = is_mailbox_owner(doc.receiver, user) + user_is_mailbox_owner = is_mail_account_owner(doc.receiver, user) if ptype in ["create", "submit"]: return user_is_system_manager diff --git a/mail/mail/doctype/mail_account/mail_account.json b/mail/mail/doctype/mail_account/mail_account.json index 2b6d650b..d51ccbd0 100644 --- a/mail/mail/doctype/mail_account/mail_account.json +++ b/mail/mail/doctype/mail_account/mail_account.json @@ -147,9 +147,19 @@ "group": "Reference", "link_doctype": "Mail Group Member", "link_fieldname": "member_name" + }, + { + "group": "Mail", + "link_doctype": "Incoming Mail", + "link_fieldname": "receiver" + }, + { + "group": "Mail", + "link_doctype": "Outgoing Mail", + "link_fieldname": "sender" } ], - "modified": "2025-01-06 18:20:48.241975", + "modified": "2025-01-07 17:19:31.813542", "modified_by": "Administrator", "module": "Mail", "name": "Mail Account", diff --git a/mail/mail/doctype/mail_domain/mail_domain.json b/mail/mail/doctype/mail_domain/mail_domain.json index 1a329128..9ffd9e28 100644 --- a/mail/mail/doctype/mail_domain/mail_domain.json +++ b/mail/mail/doctype/mail_domain/mail_domain.json @@ -13,7 +13,15 @@ "dkim_rsa_key_size", "newsletter_retention", "dns_records_section", - "dns_records" + "dns_records", + "agent_groups_section", + "include_agent_groups", + "column_break_vpyy", + "exclude_agent_groups", + "agents_section", + "include_agents", + "column_break_x6bd", + "exclude_agents" ], "fields": [ { @@ -86,6 +94,48 @@ "fieldtype": "Select", "label": "DKIM RSA Key Size", "options": "\n2048\n4096" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.include_agents || doc.exclude_agents", + "fieldname": "agents_section", + "fieldtype": "Section Break", + "label": "Agents" + }, + { + "fieldname": "include_agents", + "fieldtype": "Small Text", + "label": "Include Agents" + }, + { + "fieldname": "column_break_x6bd", + "fieldtype": "Column Break" + }, + { + "fieldname": "exclude_agents", + "fieldtype": "Small Text", + "label": "Exclude Agents" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.include_agent_groups || doc.exclude_agent_groups", + "fieldname": "agent_groups_section", + "fieldtype": "Section Break", + "label": "Agent Groups" + }, + { + "fieldname": "include_agent_groups", + "fieldtype": "Small Text", + "label": "Include Agent Groups" + }, + { + "fieldname": "column_break_vpyy", + "fieldtype": "Column Break" + }, + { + "fieldname": "exclude_agent_groups", + "fieldtype": "Small Text", + "label": "Exclude Agent Groups" } ], "index_web_pages_for_search": 1, @@ -126,7 +176,7 @@ "link_fieldname": "domain_name" } ], - "modified": "2025-01-06 15:35:35.811831", + "modified": "2025-01-07 17:15:28.361921", "modified_by": "Administrator", "module": "Mail", "name": "Mail Domain", diff --git a/mail/mail/doctype/mail_settings/mail_settings.json b/mail/mail/doctype/mail_settings/mail_settings.json index bfa49bfe..08d77a16 100644 --- a/mail/mail/doctype/mail_settings/mail_settings.json +++ b/mail/mail/doctype/mail_settings/mail_settings.json @@ -15,8 +15,17 @@ "default_newsletter_retention", "column_break_rwnw", "default_ttl", - "limits_section", + "limits_tab", + "max_recipients", + "max_headers", + "column_break_seja", + "max_message_size_mb", "max_newsletter_retention", + "section_break_m7bg", + "max_attachments", + "column_break_4lt9", + "max_attachment_size_mb", + "max_total_attachments_size_mb", "spamassassin_tab", "section_break_hgqa", "enable_spamd", @@ -193,11 +202,6 @@ "non_negative": 1, "reqd": 1 }, - { - "fieldname": "limits_section", - "fieldtype": "Section Break", - "label": "Limits" - }, { "default": "2048", "fieldname": "default_dkim_rsa_key_size", @@ -205,12 +209,77 @@ "label": "DKIM RSA Key Size", "options": "\n2048\n4096", "reqd": 1 + }, + { + "default": "25", + "fieldname": "max_recipients", + "fieldtype": "Int", + "label": "Maximum Number of Recipients", + "non_negative": 1, + "reqd": 1 + }, + { + "default": "15", + "fieldname": "max_message_size_mb", + "fieldtype": "Int", + "label": "Maximum Message Size (MB)", + "non_negative": 1, + "reqd": 1 + }, + { + "default": "10", + "fieldname": "max_headers", + "fieldtype": "Int", + "label": "Maximum Custom Headers", + "non_negative": 1, + "reqd": 1 + }, + { + "default": "10", + "fieldname": "max_attachment_size_mb", + "fieldtype": "Int", + "label": "Maximum Attachment Size (MB)", + "non_negative": 1, + "reqd": 1 + }, + { + "default": "10", + "fieldname": "max_total_attachments_size_mb", + "fieldtype": "Int", + "label": "Maximum Total Attachments Size (MB)", + "non_negative": 1, + "reqd": 1 + }, + { + "fieldname": "limits_tab", + "fieldtype": "Tab Break", + "label": "Limits" + }, + { + "fieldname": "column_break_seja", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_m7bg", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_4lt9", + "fieldtype": "Column Break" + }, + { + "default": "10", + "fieldname": "max_attachments", + "fieldtype": "Int", + "label": "Maximum Number of Attachments", + "non_negative": 1, + "reqd": 1 } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-01-05 12:06:02.282670", + "modified": "2025-01-07 15:14:39.981167", "modified_by": "Administrator", "module": "Mail", "name": "Mail Settings", diff --git a/mail/mail/doctype/mime_message/mime_message.json b/mail/mail/doctype/mime_message/mime_message.json index 8edcf8e7..08180e2f 100644 --- a/mail/mail/doctype/mime_message/mime_message.json +++ b/mail/mail/doctype/mime_message/mime_message.json @@ -34,7 +34,12 @@ { "group": "Reference", "link_doctype": "Outgoing Mail", - "link_fieldname": "message" + "link_fieldname": "_message" + }, + { + "group": "Reference", + "link_doctype": "Outgoing Mail", + "link_fieldname": "_raw_message" }, { "group": "Reference", @@ -42,7 +47,7 @@ "link_fieldname": "_message" } ], - "modified": "2025-01-07 12:51:54.162433", + "modified": "2025-01-07 17:20:05.618459", "modified_by": "Administrator", "module": "Mail", "name": "MIME Message", diff --git a/mail/mail/doctype/outgoing_mail/outgoing_mail.js b/mail/mail/doctype/outgoing_mail/outgoing_mail.js index 8731eb4d..ec5f42bf 100644 --- a/mail/mail/doctype/outgoing_mail/outgoing_mail.js +++ b/mail/mail/doctype/outgoing_mail/outgoing_mail.js @@ -27,11 +27,11 @@ frappe.ui.form.on("Outgoing Mail", { add_actions(frm) { if (frm.doc.docstatus === 1) { - if (frm.doc.status === "Pending") { + if (frm.doc.status === "In Progress") { frm.add_custom_button( __("Transfer Now"), () => { - frm.trigger("transfer_to_mail_server"); + frm.trigger("transfer_to_mail_agent"); }, __("Actions") ); @@ -43,14 +43,6 @@ frappe.ui.form.on("Outgoing Mail", { }, __("Actions") ); - } else if (["Queued", "Deferred"].includes(frm.doc.status)) { - frm.add_custom_button( - __("Fetch Delivery Status"), - () => { - frm.trigger("fetch_and_update_delivery_statuses"); - }, - __("Actions") - ); } else if (frm.doc.status === "Sent") { frm.add_custom_button( __("Reply"), @@ -89,10 +81,10 @@ frappe.ui.form.on("Outgoing Mail", { } }, - transfer_to_mail_server(frm) { + transfer_to_mail_agent(frm) { frappe.call({ doc: frm.doc, - method: "transfer_to_mail_server", + method: "transfer_to_mail_agent", freeze: true, freeze_message: __("Transferring..."), callback: (r) => { @@ -117,22 +109,6 @@ frappe.ui.form.on("Outgoing Mail", { }); }, - fetch_and_update_delivery_statuses(frm) { - frappe.call({ - method: "mail.tasks.enqueue_fetch_and_update_delivery_statuses", - freeze: true, - freeze_message: __("Creating Job..."), - callback: () => { - frappe.show_alert({ - message: __("{0} job has been created.", [ - __("Fetch Delivery Statuses").bold(), - ]), - indicator: "green", - }); - }, - }); - }, - reply(frm) { frappe.model.open_mapped_doc({ method: "mail.mail.doctype.outgoing_mail.outgoing_mail.reply_to_mail", diff --git a/mail/mail/doctype/outgoing_mail/outgoing_mail.json b/mail/mail/doctype/outgoing_mail/outgoing_mail.json index 1708d023..4d4e44f7 100644 --- a/mail/mail/doctype/outgoing_mail/outgoing_mail.json +++ b/mail/mail/doctype/outgoing_mail/outgoing_mail.json @@ -17,8 +17,8 @@ "section_break_atoq", "custom_headers", "section_break_ijwo", + "_raw_message", "body_html", - "raw_message", "body_plain", "section_break_ubzr", "error_log", @@ -30,11 +30,11 @@ "is_newsletter", "column_break_d6p9", "domain_name", - "token", + "queue_id", "section_break_quhp", - "message_id", "in_reply_to", "column_break_4zfa", + "_message", "message_size", "section_break_093p", "created_at", @@ -54,8 +54,14 @@ "open_count", "last_opened_at", "last_opened_from_ip", - "section_break_kops", - "message", + "agent_groups_section", + "include_agent_groups", + "column_break_qhyj", + "exclude_agent_groups", + "agents_section", + "include_agents", + "column_break_c90z", + "exclude_agents", "section_break_eh7n", "amended_from" ], @@ -66,7 +72,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Sender", - "options": "Mailbox", + "options": "Mail Account", "reqd": 1, "search_index": 1 }, @@ -106,7 +112,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "Draft\nPending\nQueuing\nFailed\nQueued\nBlocked\nDeferred\nBounced\nPartially Sent\nSent\nCancelled", + "options": "Draft\nIn Progress\nBlocked\nAccepted\nTransferring\nFailed\nTransferred\nDeferred\nBounced\nPartially Sent\nSent\nCancelled", "read_only": 1, "reqd": 1, "search_index": 1 @@ -131,12 +137,6 @@ "fieldname": "column_break_d6p9", "fieldtype": "Column Break" }, - { - "collapsible": 1, - "fieldname": "section_break_kops", - "fieldtype": "Section Break", - "label": "Message" - }, { "fieldname": "body_plain", "fieldtype": "Code", @@ -145,7 +145,7 @@ "read_only": 1 }, { - "depends_on": "eval: !doc.raw_message", + "depends_on": "eval: !doc._raw_message", "fieldname": "body_html", "fieldtype": "HTML Editor", "label": "Body HTML", @@ -179,17 +179,6 @@ "fieldname": "column_break_fvyv", "fieldtype": "Column Break" }, - { - "fieldname": "message_id", - "fieldtype": "Data", - "ignore_xss_filter": 1, - "in_standard_filter": 1, - "label": "Message ID", - "length": 255, - "no_copy": 1, - "read_only": 1, - "search_index": 1 - }, { "fieldname": "created_at", "fieldtype": "Datetime", @@ -294,13 +283,6 @@ "fieldtype": "Tab Break", "label": "More Info" }, - { - "fieldname": "message", - "fieldtype": "Code", - "label": "Message", - "no_copy": 1, - "read_only": 1 - }, { "depends_on": "eval: doc.sender", "fetch_from": "sender.reply_to", @@ -323,14 +305,6 @@ "reqd": 1, "search_index": 1 }, - { - "fieldname": "raw_message", - "fieldtype": "Code", - "hidden": 1, - "label": "Raw Message", - "no_copy": 1, - "read_only": 1 - }, { "collapsible": 1, "fieldname": "section_break_ubzr", @@ -438,14 +412,6 @@ "precision": "2", "read_only": 1 }, - { - "fieldname": "token", - "fieldtype": "Data", - "label": "Token", - "no_copy": 1, - "read_only": 1, - "unique": 1 - }, { "fieldname": "error_message", "fieldtype": "Code", @@ -479,12 +445,88 @@ "no_copy": 1, "read_only": 1, "search_index": 1 + }, + { + "fieldname": "_raw_message", + "fieldtype": "Link", + "hidden": 1, + "label": "Raw Message", + "no_copy": 1, + "options": "MIME Message", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "queue_id", + "fieldtype": "Data", + "label": "Queue ID", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "_message", + "fieldtype": "Link", + "label": "Message", + "no_copy": 1, + "options": "MIME Message", + "read_only": 1, + "search_index": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.include_agents || doc.exclude_agents", + "fieldname": "agents_section", + "fieldtype": "Section Break", + "label": "Agents" + }, + { + "fetch_from": "domain_name.include_agents", + "fetch_if_empty": 1, + "fieldname": "include_agents", + "fieldtype": "Small Text", + "label": "Include Agents" + }, + { + "fieldname": "column_break_c90z", + "fieldtype": "Column Break" + }, + { + "fetch_from": "domain_name.exclude_agents", + "fetch_if_empty": 1, + "fieldname": "exclude_agents", + "fieldtype": "Small Text", + "label": "Exclude Agents" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.include_agent_groups || doc.exclude_agent_groups", + "fieldname": "agent_groups_section", + "fieldtype": "Section Break", + "label": "Agent Groups" + }, + { + "fetch_from": "domain_name.include_agent_groups", + "fetch_if_empty": 1, + "fieldname": "include_agent_groups", + "fieldtype": "Small Text", + "label": "Include Agent Groups" + }, + { + "fieldname": "column_break_qhyj", + "fieldtype": "Column Break" + }, + { + "fetch_from": "domain_name.exclude_agent_groups", + "fetch_if_empty": 1, + "fieldname": "exclude_agent_groups", + "fieldtype": "Small Text", + "label": "Exclude Agent Groups" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-12-02 14:23:32.322784", + "modified": "2025-01-07 17:15:18.236411", "modified_by": "Administrator", "module": "Mail", "name": "Outgoing Mail", @@ -526,4 +568,4 @@ "states": [], "title_field": "subject", "track_changes": 1 -} +} \ No newline at end of file diff --git a/mail/mail/doctype/outgoing_mail/outgoing_mail.py b/mail/mail/doctype/outgoing_mail/outgoing_mail.py index d383884b..d8e6f8be 100644 --- a/mail/mail/doctype/outgoing_mail/outgoing_mail.py +++ b/mail/mail/doctype/outgoing_mail/outgoing_mail.py @@ -2,6 +2,7 @@ # For license information, please see license.txt import json +import random import time from email import message_from_string, policy from email.encoders import encode_base64 @@ -37,7 +38,13 @@ from uuid_utils import uuid7 from mail.mail.doctype.mail_contact.mail_contact import create_mail_contact +from mail.mail.doctype.mime_message.mime_message import ( + create_mime_message, + get_mime_message, + update_mime_message, +) from mail.mail_server import get_mail_server_outbound_api +from mail.smtp import SMTPContext from mail.utils import ( convert_html_to_text, get_in_reply_to, @@ -47,13 +54,35 @@ from mail.utils.dns import get_host_by_ip from mail.utils.dt import parsedate_to_datetime from mail.utils.email_parser import EmailParser -from mail.utils.user import get_user_mailboxes, is_mailbox_owner, is_system_manager +from mail.utils.user import get_user_mailboxes, is_mail_account_owner, is_system_manager from mail.utils.validation import validate_mailbox_for_outgoing MAX_FAILED_COUNT = 5 class OutgoingMail(Document): + @property + def raw_message(self) -> str: + return get_mime_message(self._raw_message) + + @raw_message.setter + def raw_message(self, value: str | bytes) -> None: + if self._raw_message: + update_mime_message(self._raw_message, value) + else: + self._raw_message = create_mime_message(value) + + @property + def message(self) -> str: + return get_mime_message(self._message) + + @message.setter + def message(self, value: str | bytes) -> None: + if self._message: + update_mime_message(self._message, value) + else: + self._message = create_mime_message(value) + def autoname(self) -> None: self.name = str(uuid7()) @@ -83,15 +112,15 @@ def validate(self) -> None: def on_submit(self) -> None: self.create_mail_contacts() - status = "Pending" + status = "In Progress" if self.via_api and not self.is_newsletter and self.submitted_after <= 5: - status = "Queuing" + status = "Transferring" self._db_set(status=status, notify_update=True) - if status == "Queuing": + if status == "Transferring": frappe.enqueue_doc( - "Outgoing Mail", self.name, "transfer_to_mail_server", enqueue_after_commit=True + "Outgoing Mail", self.name, "transfer_to_mail_agent", enqueue_after_commit=True ) def on_update_after_submit(self) -> None: @@ -125,9 +154,9 @@ def load_runtime(self) -> None: """Loads the runtime properties.""" self.runtime = frappe._dict() - self.runtime.mailbox = frappe.get_cached_doc("Mailbox", self.sender) - self.runtime.mail_domain = frappe.get_cached_doc("Mail Domain", self.domain_name) self.runtime.mail_settings = frappe.get_cached_doc("Mail Settings") + self.runtime.mail_account = frappe.get_cached_doc("Mail Account", self.sender) + self.runtime.mail_domain = frappe.get_cached_doc("Mail Domain", self.domain_name) def validate_domain(self) -> None: """Validates the domain.""" @@ -141,12 +170,11 @@ def validate_sender(self) -> None: """Validates the sender.""" user = frappe.session.user - if not is_mailbox_owner(self.sender, user) and not is_system_manager(user): - frappe.throw( - _("You are not allowed to send mail from mailbox {0}.").format(frappe.bold(self.sender)) - ) + if (self.runtime.mail_account.user != user) and not is_system_manager(user): + frappe.throw(_("You are not allowed to send from address {0}.").format(frappe.bold(self.sender))) - validate_mailbox_for_outgoing(self.sender) + if not self.runtime.mail_account.enabled: + frappe.throw(_("Mail Account {0} is disabled.").format(frappe.bold(self.sender))) def validate_in_reply_to(self) -> None: """Validates the In Reply To.""" @@ -215,31 +243,31 @@ def validate_recipients(self) -> None: def validate_custom_headers(self) -> None: """Validates the custom headers.""" - if self.custom_headers: - max_headers = self.runtime.mail_settings.max_headers - if len(self.custom_headers) > max_headers: - frappe.throw( - _("Custom Headers limit exceeded ({0}). Maximum {1} custom header(s) allowed.").format( - frappe.bold(len(self.custom_headers)), frappe.bold(max_headers) - ) + if not self.custom_headers: + return + + max_headers = self.runtime.mail_settings.max_headers + if len(self.custom_headers) > max_headers: + frappe.throw( + _("Custom Headers limit exceeded ({0}). Maximum {1} custom header(s) allowed.").format( + frappe.bold(len(self.custom_headers)), frappe.bold(max_headers) ) + ) - custom_headers = [] - for header in self.custom_headers: - if not header.key.upper().startswith("X-"): - header.key = f"X-{header.key}" + custom_headers = [] + for header in self.custom_headers: + if not header.key.upper().startswith("X-"): + header.key = f"X-{header.key}" - if header.key.upper().startswith("X-FM-"): - frappe.throw(_("Custom header {0} is not allowed.").format(frappe.bold(header.key))) + if header.key.upper().startswith("X-FM-"): + frappe.throw(_("Custom header {0} is not allowed.").format(frappe.bold(header.key))) - if header.key in custom_headers: - frappe.throw( - _("Row #{0}: Duplicate custom header {1}.").format( - header.idx, frappe.bold(header.key) - ) - ) - else: - custom_headers.append(header.key) + if header.key in custom_headers: + frappe.throw( + _("Row #{0}: Duplicate custom header {1}.").format(header.idx, frappe.bold(header.key)) + ) + else: + custom_headers.append(header.key) def load_attachments(self) -> None: """Loads the attachments.""" @@ -257,38 +285,40 @@ def load_attachments(self) -> None: def validate_attachments(self) -> None: """Validates the attachments.""" - if self.attachments: - max_attachments = self.runtime.mail_settings.outgoing_max_attachments - max_attachment_size = self.runtime.mail_settings.outgoing_max_attachment_size - max_attachments_size = self.runtime.mail_settings.outgoing_total_attachments_size + if not self.attachments: + return - if len(self.attachments) > max_attachments: - frappe.throw( - _("Attachment limit exceeded ({0}). Maximum {1} attachment(s) allowed.").format( - frappe.bold(len(self.attachments)), - frappe.bold(max_attachments), - ) + max_attachments = self.runtime.mail_settings.max_attachments + max_attachment_size = self.runtime.mail_settings.max_attachment_size_mb + max_attachments_size = self.runtime.mail_settings.max_total_attachments_size_mb + + if len(self.attachments) > max_attachments: + frappe.throw( + _("Attachment limit exceeded ({0}). Maximum {1} attachment(s) allowed.").format( + frappe.bold(len(self.attachments)), + frappe.bold(max_attachments), ) + ) - total_attachments_size = 0 - for attachment in self.attachments: - file_size = flt(attachment.file_size / 1024 / 1024, 3) - if file_size > max_attachment_size: - frappe.throw( - _("Attachment size limit exceeded ({0} MB). Maximum {1} MB allowed.").format( - frappe.bold(file_size), frappe.bold(max_attachment_size) - ) + total_attachments_size = 0 + for attachment in self.attachments: + file_size = flt(attachment.file_size / 1024 / 1024, 3) + if file_size > max_attachment_size: + frappe.throw( + _("Attachment size limit exceeded ({0} MB). Maximum {1} MB allowed.").format( + frappe.bold(file_size), frappe.bold(max_attachment_size) ) + ) - total_attachments_size += file_size + total_attachments_size += file_size - if total_attachments_size > max_attachments_size: - frappe.throw( - _("Attachments size limit exceeded ({0} MB). Maximum {1} MB allowed.").format( - frappe.bold(total_attachments_size), - frappe.bold(max_attachments_size), - ) + if total_attachments_size > max_attachments_size: + frappe.throw( + _("Attachments size limit exceeded ({0} MB). Maximum {1} MB allowed.").format( + frappe.bold(total_attachments_size), + frappe.bold(max_attachments_size), ) + ) def set_ip_address(self) -> None: """Sets the IP Address.""" @@ -326,15 +356,15 @@ def _get_message() -> MIMEMultipart | Message: frappe.throw(_("Future date is not allowed.")) if self.via_api: - if self.runtime.mailbox.override_display_name: - self.display_name = self.runtime.mailbox.display_name - if self.runtime.mailbox.override_reply_to: - if self.runtime.mailbox.reply_to: - parser.update_header("Reply-To", self.runtime.mailbox.reply_to) + if self.runtime.mail_account.override_display_name_api: + self.display_name = self.runtime.mail_account.display_name + if self.runtime.mail_account.override_reply_to_api: + if self.runtime.mail_account.reply_to: + parser.update_header("Reply-To", self.runtime.mail_account.reply_to) else: del parser["Reply-To"] - self.body_html = self.body_plain = self.raw_message = None + self.body_html = self.body_plain = None parser.update_header("From", formataddr((self.display_name, self.sender))) self.subject = parser.get_subject() self.reply_to = parser.get_reply_to() @@ -369,7 +399,7 @@ def _get_message() -> MIMEMultipart | Message: body_html = self._replace_image_url_with_content_id() body_plain = convert_html_to_text(body_html) - if self.runtime.mailbox.track_outgoing_mail: + if self.runtime.mail_account.track_outgoing_mail: self.tracking_id = uuid7().hex body_html = add_tracking_pixel(body_html, self.tracking_id) @@ -381,7 +411,7 @@ def _get_message() -> MIMEMultipart | Message: def _add_headers(message: MIMEMultipart | Message) -> None: """Adds the headers to the message.""" - received_header_value = f"from {get_host_by_ip(self.ip_address) or 'unknown-host'} ({self.ip_address}) by {frappe.local.site} (Frappe Mail) via API; {formatdate()}" + received_header_value = f"from {get_host_by_ip(self.ip_address) or 'unknown-host'} ({self.ip_address}) by {frappe.local.site} (Frappe Mail) via HTTP; {formatdate()}" received_header = ("Received", received_header_value) message._headers.insert(0, received_header) @@ -390,7 +420,7 @@ def _add_headers(message: MIMEMultipart | Message) -> None: message.add_header(header.key, header.value) del message["X-Priority"] - message["X-Priority"] = str(0 if self.is_newsletter else 1) + message["X-Priority"] = str(3 if self.is_newsletter else 2) if self.is_newsletter: del message["X-Newsletter"] @@ -430,34 +460,9 @@ def _add_attachments(message: MIMEMultipart | Message) -> None: message.attach(part) - def _add_dkim_signature(message: MIMEMultipart | Message) -> None: - """Adds the DKIM signature to the message.""" - - include_headers = [ - b"To", - b"Cc", - b"From", - b"Date", - b"Subject", - b"Reply-To", - b"Message-ID", - b"In-Reply-To", - ] - dkim_private_key = self.get_dkim_private_key() - dkim_signature = dkim_sign( - message=message.as_string().split("\n", 1)[-1].encode("utf-8"), - domain=self.domain_name.encode(), - selector=b"frappemail", - privkey=dkim_private_key.encode(), - include_headers=include_headers, - ) - dkim_header = dkim_signature.decode().replace("\n", "").replace("\r", "") - message["DKIM-Signature"] = dkim_header[len("DKIM-Signature: ") :] - message = _get_message() _add_headers(message) _add_attachments(message) - _add_dkim_signature(message) self.message = message.as_string() self.message_size = len(self.message) @@ -469,7 +474,7 @@ def validate_max_message_size(self) -> None: """Validates the maximum message size.""" message_size = flt(self.message_size / 1024 / 1024, 3) - max_message_size = self.runtime.mail_settings.max_message_size + max_message_size = self.runtime.mail_settings.max_message_size_mb if message_size > max_message_size: frappe.throw( @@ -481,14 +486,9 @@ def validate_max_message_size(self) -> None: def create_mail_contacts(self) -> None: """Creates the mail contacts.""" - if self.runtime.mailbox.create_mail_contact: - for recipient in self.recipients: - create_mail_contact(self.runtime.mailbox.user, recipient.email, recipient.display_name) - - def get_dkim_private_key(self) -> str: - """Returns the DKIM private key.""" - - return self.runtime.mail_domain.get_password("dkim_private_key") + if self.runtime.mail_account.create_mail_contact: + for rcpt in self.recipients: + create_mail_contact(self.runtime.mail_account.user, rcpt.email, rcpt.display_name) def _add_recipient(self, type: str, recipient: str | list[str] | None = None) -> None: """Adds the recipients.""" @@ -509,11 +509,11 @@ def _get_recipients(self, type: str | None = None, as_list: bool = False) -> str """Returns the recipients.""" recipients = [] - for recipient in self.recipients: - if type and recipient.type != type: + for rcpt in self.recipients: + if type and rcpt.type != type: continue - recipients.append(formataddr((recipient.display_name, recipient.email))) + recipients.append(formataddr((rcpt.display_name, rcpt.email))) return recipients if as_list else ", ".join(recipients) @@ -521,36 +521,38 @@ def _update_recipients(self, type: str, recipients: list[str] | None = None) -> """Updates the recipients by comparing new and old list.""" prev_recipients = self._get_recipients(type, as_list=True) - for d in recipients: - if d not in prev_recipients: - self._add_recipient(type, d) + for rcpt in recipients: + if rcpt not in prev_recipients: + self._add_recipient(type, rcpt) - for d in self.recipients[:]: - if d.type == type and d.email not in recipients: - self.recipients.remove(d) + for rcpt in self.recipients[:]: + if rcpt.type == type and rcpt.email not in recipients: + self.recipients.remove(rcpt) def _add_attachment(self, attachment: dict | list[dict]) -> None: """Adds the attachments.""" - if attachment: - attachments = [attachment] if isinstance(attachment, dict) else attachment - for a in attachments: - filename = a.get("filename") - content = a["content"] - - kwargs = { - "dt": self.doctype, - "dn": self.name, - "df": "file", - "fname": filename, - "content": content, - "is_private": 1, - "decode": True, - } - file = save_file(**kwargs) - - if filename and filename != file.file_name: - file.db_set("file_name", filename, update_modified=False) + if not attachment: + return + + attachments = [attachment] if isinstance(attachment, dict) else attachment + for a in attachments: + filename = a.get("filename") + content = a["content"] + + kwargs = { + "dt": self.doctype, + "dn": self.name, + "df": "file", + "fname": filename, + "content": content, + "is_private": 1, + "decode": True, + } + file = save_file(**kwargs) + + if filename and filename != file.file_name: + file.db_set("file_name", filename, update_modified=False) def _add_custom_headers(self, headers: dict) -> None: """Adds the custom headers.""" @@ -578,22 +580,24 @@ def _replace_image_url_with_content_id(self) -> str: def _get_attachment_content_id(self, file_url: str, set_as_inline: bool = False) -> str | None: """Returns the attachment content ID.""" - if file_url: - field = "file_url" - parsed_url = urlparse(file_url) - value = parsed_url.path + if not file_url: + return - if query_params := parse_qs(parsed_url.query): - if fid := query_params.get("fid", [None])[0]: - field = "name" - value = fid + field = "file_url" + parsed_url = urlparse(file_url) + value = parsed_url.path - for attachment in self.attachments: - if attachment[field] == value: - if set_as_inline: - attachment.type = "inline" + if query_params := parse_qs(parsed_url.query): + if fid := query_params.get("fid", [None])[0]: + field = "name" + value = fid - return attachment.name + for attachment in self.attachments: + if attachment[field] == value: + if set_as_inline: + attachment.type = "inline" + + return attachment.name def _correct_attachments_file_url(self) -> None: """Corrects the attachments file URL.""" @@ -617,8 +621,8 @@ def _get_attachment_file_url(self, src: str) -> str | None: def _update_delivery_status(self, data: dict, notify_update: bool = False) -> None: """Update Delivery Status.""" - if self.token != data["token"]: - msg = _("Invalid token ({0}) for outgoing mail ({1}).").format(data["token"], self.name) + if self.queue_id != data["queue_id"]: + msg = _("Invalid queue_id ({0}) for outgoing mail ({1}).").format(data["queue_id"], self.name) self.add_comment("Comment", msg) frappe.throw(msg) elif self.docstatus != 1: @@ -677,52 +681,73 @@ def retry_failed(self) -> None: """Retries the failed mail.""" if self.docstatus == 1 and self.status == "Failed" and self.failed_count < MAX_FAILED_COUNT: - self._db_set(status="Queuing", error_log=None, error_message=None, commit=True) - self.transfer_to_mail_server() + self._db_set(status="Transferring", error_log=None, error_message=None, commit=True) + self.transfer_to_mail_agent() @frappe.whitelist() - def transfer_to_mail_server(self) -> None: - """Transfers the email to the Mail Server.""" + def transfer_to_mail_agent(self) -> None: + """Transfers the email to the Mail Agent.""" if not frappe.flags.force_transfer: self.reload() - # Ensure the document is submitted and has "Queuing" or "Pending" status + # Ensure the document is submitted and has "Transferring" or "In Progress" status if not ( self.docstatus == 1 - and self.status in ["Queuing", "Pending"] + and self.status in ["Transferring", "In Progress"] and self.failed_count < MAX_FAILED_COUNT ): return + transfer_started_at = now() + self._db_set( + status="Transferring", + transfer_started_at=transfer_started_at, + transfer_started_after=time_diff_in_seconds(transfer_started_at, self.processed_at), + notify_update=False, + commit=True, + ) + try: - transfer_started_at = now() - transfer_started_after = time_diff_in_seconds(transfer_started_at, self.submitted_at) + # Remove duplicate recipients while preserving the order by using `dict.fromkeys()`. + # This avoids using a set, which could change the order of recipients. + recipients = list( + dict.fromkeys( + [rcpt.email for rcpt in self.recipients if rcpt.status not in ["Blocked", "Sent"]] + ) + ) + + if not recipients: + frappe.throw(_("All recipients are blocked or sent.")) - # Update X-Priority to 3 [highest] + agent_or_group = get_random_agent_or_agent_group( + self.include_agent_groups, self.exclude_agent_groups, self.include_agents, self.exclude_agents + ) + + if not self.runtime or not self.runtime.mail_account: + self.runtime.mail_account = frappe.get_cached_doc("Mail Account", self.sender) + + # Update X-Priority to 1 [highest] message = message_from_string(self.message) del message["X-Priority"] - message["X-Priority"] = "3" + message["X-Priority"] = "1" message = message.as_string() - # Remove duplicate recipients while preserving the order by using `dict.fromkeys()`. - # This avoids using a set, which could change the order of recipients. - recipients = list(dict.fromkeys([rcpt.email for rcpt in self.recipients])) + username = self.runtime.mail_account.email + password = self.runtime.mail_account.get_password("password") - outbound_api = get_mail_server_outbound_api() - token = outbound_api.send(self.name, recipients, message) + with SMTPContext(agent_or_group, 465, username, password, use_ssl=True) as server: + mail_options = [f"ENVID={self.name}"] + server.sendmail(self.sender, recipients, message, mail_options=mail_options) transfer_completed_at = now() transfer_completed_after = time_diff_in_seconds(transfer_completed_at, transfer_started_at) self._db_set( - token=token, - status="Queued", - transfer_started_at=transfer_started_at, - transfer_started_after=transfer_started_after, + status="Transferred", transfer_completed_at=transfer_completed_at, transfer_completed_after=transfer_completed_after, - commit=True, notify_update=True, + commit=True, ) except Exception: error_log = frappe.get_traceback(with_context=False) @@ -732,8 +757,8 @@ def transfer_to_mail_server(self) -> None: error_log=error_log, failed_count=failed_count, retry_after=get_retry_after(failed_count), - commit=True, notify_update=True, + commit=True, ) @@ -741,17 +766,7 @@ def transfer_to_mail_server(self) -> None: def get_default_sender() -> str | None: """Returns the default sender.""" - user = frappe.session.user - return frappe.db.get_value( - "Mailbox", - { - "user": user, - "enabled": 1, - "is_default": 1, - "outgoing": 1, - }, - "name", - ) + return frappe.db.get_value("Mail Account", {"user": frappe.session.user, "enabled": 1}, "name") @frappe.whitelist() @@ -803,6 +818,49 @@ def get_retry_after(failed_count: int) -> str: return add_to_date(now(), minutes=retry_after_minutes) +def get_random_agent_or_agent_group( + include_agent_groups: str | list[str] | None = None, + exclude_agent_groups: str | list[str] | None = None, + include_agents: str | list[str] | None = None, + exclude_agents: str | list[str] | None = None, + raise_if_not_found: bool = True, +) -> str: + """Returns a random agent or agent group based on the given criteria.""" + + selected_agent = None + selected_agent_group = None + agent_groups = set(frappe.db.get_all("Mail Agent Group", {"enabled": 1, "outbound": 1}, pluck="name")) + + if include_agents or exclude_agents: + agents = set(frappe.db.get_all("Mail Agent", {"enabled": 1, "enable_outbound": 1}, pluck="name")) + + for agent in include_agents.split("\n"): + if agent not in agents: + frappe.throw(_("Agent {0} does not exist or is not enabled for outbound.").format(agent)) + for agent in exclude_agents.split("\n"): + agents.remove(agent) + + selected_agent = random.choice(list(agents)) + + elif include_agent_groups or exclude_agent_groups: + for agent_group in include_agent_groups.split("\n"): + frappe.throw( + _("Agent Group {0} does not exist or is not enabled for outbound.").format(agent_group) + ) + for agent_group in exclude_agent_groups.split("\n"): + agent_groups.remove(agent_group) + + selected_agent_group = random.choice(list(agent_groups)) + + else: + selected_agent_group = random.choice(list(agent_groups)) + + if not selected_agent and not selected_agent_group and raise_if_not_found: + frappe.throw(_("No agent or agent group found.")) + + return selected_agent or selected_agent_group + + def create_outgoing_mail( sender: str, to: str | list[str], @@ -887,7 +945,7 @@ def has_permission(doc: "Document", ptype: str, user: str) -> bool: return False user_is_system_manager = is_system_manager(user) - user_is_mailbox_owner = is_mailbox_owner(doc.sender, user) + user_is_mailbox_owner = is_mail_account_owner(doc.sender, user) if ptype == "create": return True @@ -936,7 +994,7 @@ def transfer_emails_to_mail_server() -> None: (OM.docstatus == 1) & (OM.failed_count < MAX_FAILED_COUNT) & ((OM.retry_after.isnull()) | (OM.retry_after <= now_datetime())) - & (OM.status.isin(["Pending", "Failed"])) + & (OM.status.isin(["In Progress", "Failed"])) ) .groupby(OM.name) .orderby(OM.submitted_at) @@ -968,7 +1026,7 @@ def transfer_emails_to_mail_server() -> None: mail["name"], { "token": token, - "status": "Queued", + "status": "Transferred", "error_log": None, "error_message": None, "transfer_started_at": transfer_started_at, @@ -1013,7 +1071,7 @@ def fetch_and_update_delivery_statuses() -> None: max_failures = 3 total_failures = 0 ignore_mails = [] - statuses_to_update = ["Queued", "Deferred"] + statuses_to_update = ["Transferred", "Deferred"] while total_failures < max_failures: OM = frappe.qb.DocType("Outgoing Mail") diff --git a/mail/mail/doctype/outgoing_mail/outgoing_mail_list.js b/mail/mail/doctype/outgoing_mail/outgoing_mail_list.js index 15fd1231..4b77e93a 100644 --- a/mail/mail/doctype/outgoing_mail/outgoing_mail_list.js +++ b/mail/mail/doctype/outgoing_mail/outgoing_mail_list.js @@ -5,10 +5,10 @@ frappe.listview_settings["Outgoing Mail"] = { get_indicator: (doc) => { const status_colors = { Draft: "grey", - Pending: "yellow", - Queuing: "yellow", + "In Progress": "yellow", + Transferring: "yellow", Failed: "red", - Queued: "blue", + Transferred: "blue", Blocked: "red", Deferred: "orange", Bounced: "pink", diff --git a/mail/mail/report/mail_tracker/mail_tracker.js b/mail/mail/report/mail_tracker/mail_tracker.js index 7352ac67..1c07833d 100644 --- a/mail/mail/report/mail_tracker/mail_tracker.js +++ b/mail/mail/report/mail_tracker/mail_tracker.js @@ -35,9 +35,9 @@ frappe.query_reports["Mail Tracker"] = { get_data: (txt) => { return [ "", - "Pending", + "In Progress", "Failed", - "Queued", + "Transferred", "Blocked", "Deferred", "Bounced", diff --git a/mail/utils/query.py b/mail/utils/query.py index a6a637c7..f505a088 100644 --- a/mail/utils/query.py +++ b/mail/utils/query.py @@ -16,19 +16,18 @@ def get_sender( ) -> list: """Returns the sender.""" - MAILBOX = frappe.qb.DocType("Mailbox") - DOMAIN = frappe.qb.DocType("Mail Domain") + MAIL_ACCOUNT = frappe.qb.DocType("Mail Account") + MAIL_DOMAIN = frappe.qb.DocType("Mail Domain") query = ( - frappe.qb.from_(DOMAIN) - .left_join(MAILBOX) - .on(DOMAIN.name == MAILBOX.domain_name) - .select(MAILBOX.name) + frappe.qb.from_(MAIL_DOMAIN) + .left_join(MAIL_ACCOUNT) + .on(MAIL_DOMAIN.name == MAIL_ACCOUNT.domain_name) + .select(MAIL_ACCOUNT.name) .where( - (DOMAIN.enabled == 1) - & (DOMAIN.is_verified == 1) - & (MAILBOX.enabled == 1) - & (MAILBOX.outgoing == 1) - & (MAILBOX[searchfield].like(f"%{txt}%")) + (MAIL_DOMAIN.enabled == 1) + & (MAIL_DOMAIN.is_verified == 1) + & (MAIL_ACCOUNT.enabled == 1) + & (MAIL_ACCOUNT[searchfield].like(f"%{txt}%")) ) .offset(start) .limit(page_len) @@ -36,7 +35,7 @@ def get_sender( user = frappe.session.user if not is_system_manager(user): - query = query.where(MAILBOX.user == user) + query = query.where(MAIL_ACCOUNT.user == user) return query.run(as_dict=False) diff --git a/mail/utils/user.py b/mail/utils/user.py index 7c929ff7..34b16478 100644 --- a/mail/utils/user.py +++ b/mail/utils/user.py @@ -28,10 +28,10 @@ def get_user_mailboxes(user: str, type: Literal["Incoming", "Outgoing"] | None = @request_cache -def is_mailbox_owner(mailbox: str, user: str) -> bool: - """Returns True if the mailbox is associated with the user else False.""" +def is_mail_account_owner(account: str, user: str) -> bool: + """Returns True if the mail account is associated with the user else False.""" - return frappe.db.get_value("Mailbox", mailbox, "user") == user + return frappe.db.get_value("Mail Account", account, "user") == user @request_cache