diff --git a/mail/api/mail.py b/mail/api/mail.py index f4246a7a..56107854 100644 --- a/mail/api/mail.py +++ b/mail/api/mail.py @@ -6,8 +6,8 @@ from frappe.translate import get_all_translations from frappe.utils import is_html -from mail.utils.cache import get_user_default_mailbox -from mail.utils.user import get_user_mailboxes, has_role, is_system_manager +from mail.utils.cache import get_user_default_mail_account +from mail.utils.user import get_user_email_addresses, has_role, is_system_manager def check_app_permission() -> bool: @@ -63,7 +63,7 @@ def get_translations() -> dict: def get_incoming_mails(start: int = 0) -> list: """Returns incoming mails for the current user.""" - mailboxes = get_user_mailboxes(frappe.session.user, "Incoming") + mailboxes = get_user_email_addresses(frappe.session.user, "Incoming") mails = frappe.get_all( "Incoming Mail", @@ -105,7 +105,7 @@ def get_draft_mails(start: int = 0) -> list: def get_outgoing_mails(status: str, start: int = 0) -> list: """Returns outgoing mails for the current user.""" - mailboxes = get_user_mailboxes(frappe.session.user, "Outgoing") + mailboxes = get_user_email_addresses(frappe.session.user, "Outgoing") if status == "Draft": docstatus = 0 @@ -388,7 +388,7 @@ def get_mail_contacts(txt=None) -> list: def get_default_outgoing() -> str | None: """Returns default outgoing mailbox.""" - return get_user_default_mailbox(frappe.session.user) + return get_user_default_mail_account(frappe.session.user) @frappe.whitelist() diff --git a/mail/mail/doctype/incoming_mail/incoming_mail.py b/mail/mail/doctype/incoming_mail/incoming_mail.py index e6404ed0..0c4f9bc1 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_mail_account_owner, is_system_manager +from mail.utils.user import get_user_email_addresses, is_mail_account_owner, is_system_manager if TYPE_CHECKING: from mail.mail.doctype.outgoing_mail.outgoing_mail import OutgoingMail @@ -238,7 +238,7 @@ def get_permission_query_condition(user: str | None = None) -> str: if is_system_manager(user): return "" - if mailboxes := ", ".join(repr(m) for m in get_user_mailboxes(user)): + if mailboxes := ", ".join(repr(m) for m in get_user_email_addresses(user)): return f"(`tabIncoming Mail`.`receiver` IN ({mailboxes})) AND (`tabIncoming Mail`.`docstatus` = 1)" else: return "1=0" diff --git a/mail/mail/doctype/mail_recipient/mail_recipient.json b/mail/mail/doctype/mail_recipient/mail_recipient.json index bfc66260..6e44ed2f 100644 --- a/mail/mail/doctype/mail_recipient/mail_recipient.json +++ b/mail/mail/doctype/mail_recipient/mail_recipient.json @@ -8,6 +8,7 @@ "field_order": [ "type", "email", + "check_deliverability", "display_name", "column_break_iqoo", "status", @@ -98,12 +99,18 @@ "label": "Error Message", "no_copy": 1, "read_only": 1 + }, + { + "depends_on": "eval: doc.parenttype == \"Outgoing Mail\"", + "fieldname": "check_deliverability", + "fieldtype": "Button", + "label": "Check Deliverability" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-12-03 18:07:48.333983", + "modified": "2025-01-09 17:19:51.731199", "modified_by": "Administrator", "module": "Mail", "name": "Mail Recipient", diff --git a/mail/mail/doctype/outgoing_mail/outgoing_mail.js b/mail/mail/doctype/outgoing_mail/outgoing_mail.js index ec5f42bf..242f1d66 100644 --- a/mail/mail/doctype/outgoing_mail/outgoing_mail.js +++ b/mail/mail/doctype/outgoing_mail/outgoing_mail.js @@ -26,39 +26,74 @@ frappe.ui.form.on("Outgoing Mail", { }, add_actions(frm) { - if (frm.doc.docstatus === 1) { - if (frm.doc.status === "In Progress") { - frm.add_custom_button( - __("Transfer Now"), - () => { - frm.trigger("transfer_to_mail_agent"); - }, - __("Actions") - ); - } else if (frm.doc.status === "Failed" && frm.doc.failed_count < 5) { - frm.add_custom_button( - __("Retry"), - () => { - frm.trigger("retry_failed"); - }, - __("Actions") - ); - } else if (frm.doc.status === "Sent") { - frm.add_custom_button( - __("Reply"), - () => { - frm.trigger("reply"); - }, - __("Actions") - ); - frm.add_custom_button( - __("Reply All"), - () => { - frm.trigger("reply_all"); - }, - __("Actions") - ); - } + if (frm.doc.docstatus !== 1) return; + + if (["In Progress", "Blocked"].includes(frm.doc.status)) { + if (!frappe.user_roles.includes("System Manager")) return; + + frm.add_custom_button( + __("Force Accept"), + () => { + frm.trigger("force_accept"); + }, + __("Actions") + ); + } else if (frm.doc.status === "Accepted") { + frm.add_custom_button( + __("Transfer to Agent"), + () => { + frm.trigger("transfer_to_mail_agent"); + }, + __("Actions") + ); + } else if (frm.doc.status === "Failed" && frm.doc.failed_count < 5) { + frm.add_custom_button( + __("Retry"), + () => { + frm.trigger("retry_failed"); + }, + __("Actions") + ); + } else if (frm.doc.status === "Transferring") { + if (!frappe.user_roles.includes("System Manager")) return; + + frm.add_custom_button( + __("Force Transfer to Agent"), + () => { + frappe.confirm( + __( + "Are you sure you want to force transfer this email to the agent? It may cause duplicate emails to be sent." + ), + () => frm.trigger("force_transfer_to_mail_agent") + ); + }, + __("Actions") + ); + } else if (frm.doc.status === "Sent") { + frm.add_custom_button( + __("Reply"), + () => { + frm.trigger("reply"); + }, + __("Actions") + ); + frm.add_custom_button( + __("Reply All"), + () => { + frm.trigger("reply_all"); + }, + __("Actions") + ); + } else if (frm.doc.status === "Bounced") { + if (!frappe.user_roles.includes("System Manager")) return; + + frm.add_custom_button( + __("Retry"), + () => { + frm.trigger("retry_bounced"); + }, + __("Actions") + ); } }, @@ -81,12 +116,12 @@ frappe.ui.form.on("Outgoing Mail", { } }, - transfer_to_mail_agent(frm) { + force_accept(frm) { frappe.call({ doc: frm.doc, - method: "transfer_to_mail_agent", + method: "force_accept", freeze: true, - freeze_message: __("Transferring..."), + freeze_message: __("Force Accepting..."), callback: (r) => { if (!r.exc) { frm.refresh(); @@ -109,6 +144,34 @@ frappe.ui.form.on("Outgoing Mail", { }); }, + transfer_to_mail_agent(frm) { + frappe.call({ + doc: frm.doc, + method: "transfer_to_mail_agent", + freeze: true, + freeze_message: __("Transferring..."), + callback: (r) => { + if (!r.exc) { + frm.refresh(); + } + }, + }); + }, + + force_transfer_to_mail_agent(frm) { + frappe.call({ + doc: frm.doc, + method: "force_transfer_to_mail_agent", + freeze: true, + freeze_message: __("Force Transferring..."), + callback: (r) => { + if (!r.exc) { + frm.refresh(); + } + }, + }); + }, + reply(frm) { frappe.model.open_mapped_doc({ method: "mail.mail.doctype.outgoing_mail.outgoing_mail.reply_to_mail", @@ -128,4 +191,53 @@ frappe.ui.form.on("Outgoing Mail", { }, }); }, + + retry_bounced(frm) { + frappe.call({ + doc: frm.doc, + method: "retry_bounced", + freeze: true, + freeze_message: __("Retrying..."), + callback: (r) => { + if (!r.exc) { + frm.refresh(); + } + }, + }); + }, +}); + +frappe.ui.form.on("Mail Recipient", { + check_deliverability(frm, cdt, cdn) { + let recipient = locals[cdt][cdn]; + let email = recipient.email; + + if (!email) return; + + frappe.call({ + method: "mail.utils.check_deliverability", + args: { + email: email, + }, + freeze: true, + freeze_message: __("Checking email deliverability..."), + callback: (r) => { + if (!r.exc) { + if (r.message) { + frappe.show_alert({ + message: __("The email address {0} is deliverable.", [email.bold()]), + indicator: "green", + }); + } else { + frappe.show_alert({ + message: __("The email address {0} appears to be invalid.", [ + email.bold(), + ]), + indicator: "red", + }); + } + } + }, + }); + }, }); diff --git a/mail/mail/doctype/outgoing_mail/outgoing_mail.json b/mail/mail/doctype/outgoing_mail/outgoing_mail.json index aaff51e1..73717718 100644 --- a/mail/mail/doctype/outgoing_mail/outgoing_mail.json +++ b/mail/mail/doctype/outgoing_mail/outgoing_mail.json @@ -25,24 +25,32 @@ "error_message", "more_info_tab", "status", + "priority", "ip_address", + "spam_score", + "spam_check_log", "via_api", + "is_spam", "is_newsletter", "column_break_d6p9", "domain_name", + "agent", "queue_id", "section_break_quhp", - "_message", + "message_id", "in_reply_to", "column_break_4zfa", + "_message", "message_size", "section_break_093p", "created_at", "submitted_at", + "processed_at", "transfer_started_at", "transfer_completed_at", "column_break_fvyv", "submitted_after", + "processed_after", "transfer_started_after", "transfer_completed_after", "section_break_ptje", @@ -482,8 +490,6 @@ "label": "Agents" }, { - "fetch_from": "domain_name.include_agents", - "fetch_if_empty": 1, "fieldname": "include_agents", "fieldtype": "Small Text", "label": "Include Agents" @@ -493,8 +499,6 @@ "fieldtype": "Column Break" }, { - "fetch_from": "domain_name.exclude_agents", - "fetch_if_empty": 1, "fieldname": "exclude_agents", "fieldtype": "Small Text", "label": "Exclude Agents" @@ -507,8 +511,6 @@ "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" @@ -518,8 +520,6 @@ "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" @@ -531,12 +531,85 @@ { "fieldname": "column_break_sjvs", "fieldtype": "Column Break" + }, + { + "fieldname": "priority", + "fieldtype": "Int", + "label": "Priority", + "no_copy": 1, + "non_negative": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "spam_score", + "fieldtype": "Float", + "label": "Spam Score", + "no_copy": 1, + "precision": "1", + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval: doc.is_spam", + "fieldname": "is_spam", + "fieldtype": "Check", + "hidden": 1, + "in_list_view": 1, + "label": "Is Spam", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "agent", + "fieldtype": "Link", + "label": "Agent", + "no_copy": 1, + "options": "Mail Agent", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "message_id", + "fieldtype": "Data", + "ignore_xss_filter": 1, + "in_list_view": 1, + "label": "Message ID", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "spam_check_log", + "fieldtype": "Link", + "label": "Spam Check Log", + "no_copy": 1, + "options": "Spam Check Log", + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "processed_at", + "fieldtype": "Datetime", + "label": "Processed At", + "no_copy": 1, + "read_only": 1 + }, + { + "depends_on": "eval: doc.processed_at", + "description": "Processed At - Submitted At", + "fieldname": "processed_after", + "fieldtype": "Float", + "label": "Processed After (Seconds)", + "no_copy": 1, + "non_negative": 1, + "precision": "2", + "read_only": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-01-07 17:48:20.923806", + "modified": "2025-01-09 15:15:15.113111", "modified_by": "Administrator", "module": "Mail", "name": "Outgoing Mail", diff --git a/mail/mail/doctype/outgoing_mail/outgoing_mail.py b/mail/mail/doctype/outgoing_mail/outgoing_mail.py index 9890b43a..5d1923d5 100644 --- a/mail/mail/doctype/outgoing_mail/outgoing_mail.py +++ b/mail/mail/doctype/outgoing_mail/outgoing_mail.py @@ -3,7 +3,6 @@ import json import random -import time from email import message_from_string, policy from email.encoders import encode_base64 from email.message import Message @@ -18,44 +17,43 @@ from urllib.parse import parse_qs, urlparse import frappe -from dkim import sign as dkim_sign from frappe import _ from frappe.model.document import Document from frappe.query_builder import Interval -from frappe.query_builder.functions import GroupConcat, IfNull, Now +from frappe.query_builder.functions import Now from frappe.utils import ( add_to_date, + cint, convert_utc_to_system_timezone, flt, get_datetime, get_datetime_str, now, - now_datetime, time_diff_in_seconds, validate_email_address, ) from frappe.utils.file_manager import save_file from uuid_utils import uuid7 +from mail.mail.doctype.bounce_log.bounce_log import is_email_blocked 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.mail.doctype.spam_check_log.spam_check_log import create_spam_check_log from mail.smtp import SMTPContext from mail.utils import ( convert_html_to_text, get_in_reply_to, get_in_reply_to_mail, ) -from mail.utils.cache import get_user_default_mailbox +from mail.utils.cache import get_user_default_mail_account 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_mail_account_owner, is_system_manager -from mail.utils.validation import validate_mailbox_for_outgoing +from mail.utils.user import get_user_email_addresses, is_mail_account_owner, is_system_manager MAX_FAILED_COUNT = 5 @@ -91,14 +89,19 @@ def autoname(self) -> None: def validate(self) -> None: self.validate_amended_doc() self.set_folder() + self.set_priority() self.load_runtime() - self.validate_domain() + self.validate_domain_name() self.validate_sender() self.validate_in_reply_to() self.validate_recipients() self.validate_custom_headers() self.load_attachments() self.validate_attachments() + self.validate_include_agent_groups() + self.validate_exclude_agent_groups() + self.validate_include_agents() + self.validate_exclude_agents() if self.get("_action") == "submit": self.set_ip_address() @@ -114,9 +117,7 @@ def validate(self) -> None: def on_submit(self) -> None: self.create_mail_contacts() self._db_set(status="In Progress", notify_update=True) - - if not self.is_newsletter: - self.transfer_to_mail_agent() + self.enqueue_process_for_delivery() def on_update_after_submit(self) -> None: self.set_folder() @@ -145,6 +146,11 @@ def set_folder(self) -> None: else: self.folder = folder + def set_priority(self) -> None: + """Sets the priority.""" + + self.priority = 3 if self.is_newsletter else 2 + def load_runtime(self) -> None: """Loads the runtime properties.""" @@ -153,13 +159,13 @@ def load_runtime(self) -> None: 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.""" + def validate_domain_name(self) -> None: + """Validates the domain name.""" if not self.runtime.mail_domain.enabled: - frappe.throw(_("Domain {0} is disabled.").format(frappe.bold(self.domain_name))) + frappe.throw(_("Mail Domain {0} is disabled.").format(frappe.bold(self.domain_name))) if not self.runtime.mail_domain.is_verified: - frappe.throw(_("Domain {0} is not verified.").format(frappe.bold(self.domain_name))) + frappe.throw(_("Mail Domain {0} is not verified.").format(frappe.bold(self.domain_name))) def validate_sender(self) -> None: """Validates the sender.""" @@ -315,6 +321,26 @@ def validate_attachments(self) -> None: ) ) + def validate_include_agent_groups(self) -> None: + """Validate include agent groups and set it to the value from the domain.""" + + self.include_agent_groups = self.include_agent_groups or self.runtime.mail_domain.include_agent_groups + + def validate_exclude_agent_groups(self) -> None: + """Validate exclude agent groups and set it to the value from the domain.""" + + self.exclude_agent_groups = self.exclude_agent_groups or self.runtime.mail_domain.exclude_agent_groups + + def validate_include_agents(self) -> None: + """Validate include agents and set it to the value from the domain.""" + + self.include_agents = self.include_agents or self.runtime.mail_domain.include_agents + + def validate_exclude_agents(self) -> None: + """Validate exclude agents and set it to the value from the domain.""" + + self.exclude_agents = self.exclude_agents or self.runtime.mail_domain.exclude_agents + def set_ip_address(self) -> None: """Sets the IP Address.""" @@ -415,7 +441,7 @@ def _add_headers(message: MIMEMultipart | Message) -> None: message.add_header(header.key, header.value) del message["X-Priority"] - message["X-Priority"] = str(3 if self.is_newsletter else 2) + message["X-Priority"] = str(self.priority) if self.is_newsletter: del message["X-Newsletter"] @@ -671,12 +697,196 @@ def _db_set( if notify_update: self.notify_update() + def update_status(self, status: str | None = None, db_set: bool = False) -> None: + """Updates the status of the email based on the status of the recipients.""" + + if not status: + recipient_statuses = [r.status for r in self.recipients] + total_statuses = len(recipient_statuses) + status_counts = { + k: recipient_statuses.count(k) for k in ["", "Blocked", "Deferred", "Bounced", "Sent"] + } + + if status_counts[""] == total_statuses: # All recipients are in pending state (no status) + return + + if status_counts["Blocked"] == total_statuses: # All recipients are blocked + status = "Blocked" + elif status_counts["Deferred"] > 0: # Any recipient is deferred + status = "Deferred" + elif status_counts["Sent"] == total_statuses: # All recipients are sent + status = "Sent" + elif status_counts["Sent"] > 0: # Any recipient is sent + status = "Partially Sent" + elif ( + status_counts["Bounced"] > 0 + ): # All recipients are bounced or some are blocked and some are bounced + status = "Bounced" + + if status: + self.status = status + + if db_set: + self._db_set(status=status) + + def enqueue_process_for_delivery(self) -> None: + """Enqueue the job to process the email for delivery.""" + + # Emails with priority 1 are considered high-priority and should be enqueued at the front. + # Note: Existing jobs with priority 1 in the queue may lead to concurrent processing, + # which is acceptable (for now) as multiple workers can handle jobs in parallel. + at_front = self.priority == 1 + + frappe.enqueue_doc( + self.doctype, + self.name, + "process_for_delivery", + queue="short", + enqueue_after_commit=True, + at_front=at_front, + ) + + def process_for_delivery(self) -> None: + """Process the email for delivery.""" + + # Reload the doc to ensure it reflects the latest status. + # This handles cases where the email's status might have been manually updated (e.g., Accepted) after the job was created. + self.reload() + if self.status != "In Progress": + return + + kwargs = self._prepare_delivery_args() + self._db_set(notify_update=True, **kwargs) + + if self.status == "Blocked": + self._sync_with_frontend(self.status) + elif self.status == "Accepted": + frappe.flags.force_transfer = True + self.transfer_to_mail_agent() + + def _prepare_delivery_args(self) -> dict: + """Prepare arguments for delivery processing.""" + + kwargs = {"status": "Accepted"} + + for rcpt in self.recipients: + if is_email_blocked(rcpt.email): + rcpt.status = "Blocked" + rcpt.error_message = _( + "Delivery to this recipient was blocked because their email address is on our blocklist. This action was taken after repeated delivery failures to this address. To protect your sender reputation and prevent further issues, this email was not sent to the blocked recipient." + ) + rcpt.db_update() + + self.update_status() + if self.status == "Blocked": + kwargs.update( + { + "status": "Blocked", + "error_message": _( + "Delivery of this email was blocked because all recipients are on our blocklist. Repeated delivery failures to these addresses have led to their blocking. To protect your sender reputation and avoid further issues, this email was not sent. Please review the recipient list or contact support for assistance." + ), + } + ) + + if kwargs["status"] == "Accepted" and is_spam_detection_enabled_for_outbound(): + kwargs.update(self._check_for_spam()) + + kwargs["processed_at"] = now() + kwargs["processed_after"] = time_diff_in_seconds(kwargs["processed_at"], self.submitted_at) + + return kwargs + + def _check_for_spam(self) -> dict: + """Check the message for spam and update the status if necessary.""" + + log = create_spam_check_log(self.message) + mail_settings = frappe.get_cached_doc("Mail Settings") + is_spam = log.spam_score > mail_settings.spamd_outbound_threshold + short_error_message = None + kwargs = { + "spam_score": log.spam_score, + "spam_check_log": log.name, + "is_spam": cint(is_spam), + } + + if is_spam and mail_settings.spamd_outbound_block: + short_error_message = _("Spam score exceeded the permitted threshold.") + kwargs.update( + { + "status": "Blocked", + "error_message": _( + "This email has been blocked because our system flagged it as spam. The spam score exceeded the permitted threshold. To resolve this, review your email content, remove any potentially suspicious links or attachments, and try sending it again. If the issue persists, please contact our support team for assistance." + ), + } + ) + + if kwargs.get("status") == "Blocked": + for rcpt in self.recipients: + rcpt.status = "Blocked" + rcpt.error_message = short_error_message + rcpt.db_update() + + return kwargs + + def _accept(self) -> None: + """Accept the email and set status to `Accepted`.""" + + processed_at = now() + processed_after = time_diff_in_seconds(processed_at, self.submitted_at) + self._db_set( + status="Accepted", + error_message=None, + processed_at=processed_at, + processed_after=processed_after, + notify_update=True, + ) + + @frappe.whitelist() + def force_accept(self) -> None: + """Forces accept the email.""" + + frappe.only_for("System Manager") + + if self.status in ["In Progress", "Blocked"]: + for rcpt in self.recipients: + if rcpt.status == "Blocked": + rcpt.status = "" + rcpt.error_message = None + rcpt.db_update() + + self._accept() + self.add_comment( + "Comment", _("Mail accepted by System Manager {0}.").format(frappe.bold(frappe.session.user)) + ) + + if self.priority == 1: + frappe.flags.force_transfer = True + self.transfer_to_mail_agent() + @frappe.whitelist() 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="In Progress", error_log=None, error_message=None) + self._db_set(status="Accepted", error_log=None, error_message=None, commit=True) + self.transfer_to_mail_agent() + + @frappe.whitelist() + def force_transfer_to_mail_agent(self) -> None: + """Forces push the email to the agent for sending.""" + + frappe.only_for("System Manager") + if self.status in ["Transferring"]: + frappe.flags.force_transfer = True + self.push_to_queue() + + @frappe.whitelist() + def retry_bounced(self) -> None: + """Retries bounced email.""" + + frappe.only_for("System Manager") + if self.status == "Bounced": + self._db_set(status="Accepted", error_log=None, error_message=None, commit=True) self.transfer_to_mail_agent() @frappe.whitelist() @@ -686,11 +896,9 @@ def transfer_to_mail_agent(self) -> None: if not frappe.flags.force_transfer: self.reload() - # Ensure the document is submitted and has "In Progress" status + # Ensure the document is submitted and has "Accepted" status if not ( - self.docstatus == 1 - and self.status in ["In Progress"] - and self.failed_count < MAX_FAILED_COUNT + self.docstatus == 1 and self.status in ["Accepted"] and self.failed_count < MAX_FAILED_COUNT ): return @@ -759,7 +967,9 @@ def transfer_to_mail_agent(self) -> None: def get_default_sender() -> str | None: """Returns the default sender.""" - return frappe.db.get_value("Mail Account", {"user": frappe.session.user, "enabled": 1}, "name") + return frappe.db.get_value( + "Mail Account", {"user": frappe.session.user, "enabled": 1, "is_default": 1}, "name" + ) @frappe.whitelist() @@ -804,6 +1014,13 @@ def add_tracking_pixel(body_html: str, tracking_id: str) -> str: return body_html +def is_spam_detection_enabled_for_outbound() -> bool: + """Returns True if spam detection is enabled for outbound emails else False.""" + + mail_settings = frappe.get_cached_doc("Mail Settings") + return mail_settings.enable_spamd and mail_settings.enable_spamd_for_outbound + + def get_retry_after(failed_count: int) -> str: """Returns the retry after datetime.""" @@ -820,36 +1037,54 @@ def get_random_agent_or_agent_group( ) -> str: """Returns a random agent or agent group based on the given criteria.""" + def normalize_input(value: str | list[str] | None) -> list[str]: + """Normalize input to a list of strings.""" + + if isinstance(value, str): + return value.split("\n") + return value or [] + + include_agent_groups = normalize_input(include_agent_groups) + exclude_agent_groups = normalize_input(exclude_agent_groups) + include_agents = normalize_input(include_agents) + exclude_agents = normalize_input(exclude_agents) + 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")) + if include_agents: + if invalid_agents := [agent for agent in include_agents if agent not in agents]: + frappe.throw( + _("The following agents do not exist or are not enabled for outbound: {0}").format( + ", ".join(invalid_agents) + ) + ) + agents.intersection_update(include_agents) - 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) + if exclude_agents: + agents.difference_update(exclude_agents) selected_agent = random.choice(list(agents)) + else: + if include_agent_groups: + if invalid_groups := [group for group in include_agent_groups if group not in agent_groups]: + frappe.throw( + _("The following agent groups do not exist or are not enabled for outbound: {0}").format( + ", ".join(invalid_groups) + ) + ) + agent_groups.intersection_update(include_agent_groups) - 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)) + if exclude_agent_groups: + agent_groups.difference_update(exclude_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.")) + frappe.throw(_("No agent or agent group found based on the given criteria.")) return selected_agent or selected_agent_group @@ -893,8 +1128,8 @@ def create_outgoing_mail( if via_api and not is_newsletter: user = frappe.session.user - if sender not in get_user_mailboxes(user, "Outgoing"): - doc.sender = get_user_default_mailbox(user) + if sender not in get_user_email_addresses(user, "Mail Account"): + doc.sender = get_user_default_mail_account(user) if not do_not_save: doc.save() @@ -905,6 +1140,25 @@ def create_outgoing_mail( return doc +def transfer_stuck_emails_to_agent() -> None: + """Transfers the stuck emails to the agent.""" + + mails = frappe.db.get_all( + "Outgoing Mail", + { + "status": ["in", ["Failed", "Transferring"]], + "submitted_at": ["<=", add_to_date(now(), minutes=-60)], + }, + pluck="name", + ) + for mail in mails: + doc = frappe.get_doc("Outgoing Mail", mail) + if doc.status == "Failed": + doc.retry_failed() + else: + doc.force_transfer_to_mail_agent() + + def delete_newsletters() -> None: """Called by the scheduler to delete the newsletters based on the retention.""" @@ -955,7 +1209,7 @@ def get_permission_query_condition(user: str | None = None) -> str: if is_system_manager(user): return "" - if mailboxes := ", ".join(repr(m) for m in get_user_mailboxes(user)): - return f"(`tabOutgoing Mail`.`sender` IN ({mailboxes})) AND (`tabOutgoing Mail`.`docstatus` != 2)" + if accounts := ", ".join(repr(m) for m in get_user_email_addresses(user, "Mail Account")): + return f"(`tabOutgoing Mail`.`sender` IN ({accounts})) AND (`tabOutgoing Mail`.`docstatus` != 2)" else: return "1=0" diff --git a/mail/mail/doctype/outgoing_mail/outgoing_mail_list.js b/mail/mail/doctype/outgoing_mail/outgoing_mail_list.js index 4b77e93a..e83cf40d 100644 --- a/mail/mail/doctype/outgoing_mail/outgoing_mail_list.js +++ b/mail/mail/doctype/outgoing_mail/outgoing_mail_list.js @@ -6,10 +6,11 @@ frappe.listview_settings["Outgoing Mail"] = { const status_colors = { Draft: "grey", "In Progress": "yellow", + Blocked: "red", + Accepted: "blue", Transferring: "yellow", Failed: "red", Transferred: "blue", - Blocked: "red", Deferred: "orange", Bounced: "pink", "Partially Sent": "purple", diff --git a/mail/mail/doctype/spam_check_log/spam_check_log.json b/mail/mail/doctype/spam_check_log/spam_check_log.json index 0784e785..9e54a0ba 100644 --- a/mail/mail/doctype/spam_check_log/spam_check_log.json +++ b/mail/mail/doctype/spam_check_log/spam_check_log.json @@ -136,8 +136,14 @@ ], "in_create": 1, "index_web_pages_for_search": 1, - "links": [], - "modified": "2025-01-07 12:55:07.324604", + "links": [ + { + "group": "Mail", + "link_doctype": "Outgoing Mail", + "link_fieldname": "spam_check_log" + } + ], + "modified": "2025-01-09 15:16:10.523012", "modified_by": "Administrator", "module": "Mail", "name": "Spam Check Log", diff --git a/mail/mail/report/outbound_delay/outbound_delay.py b/mail/mail/report/outbound_delay/outbound_delay.py index db774fb6..1da41cb9 100644 --- a/mail/mail/report/outbound_delay/outbound_delay.py +++ b/mail/mail/report/outbound_delay/outbound_delay.py @@ -8,7 +8,7 @@ from frappe.query_builder.functions import Date, IfNull from frappe.utils import flt -from mail.utils.user import get_user_mailboxes, has_role, is_system_manager +from mail.utils.user import get_user_email_addresses, has_role, is_system_manager def execute(filters: dict | None = None) -> tuple: @@ -207,10 +207,10 @@ def get_data(filters: dict | None = None) -> list[dict]: user = frappe.session.user if not is_system_manager(user): conditions = [] - mailboxes = get_user_mailboxes(user) + accounts = get_user_email_addresses(user, "Mail Account") - if has_role(user, "Mailbox User") and mailboxes: - conditions.append(OM.sender.isin(mailboxes)) + if has_role(user, "Mailbox User") and accounts: + conditions.append(OM.sender.isin(accounts)) if not conditions: return [] diff --git a/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.py b/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.py index d9de2af3..a83d216d 100644 --- a/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.py +++ b/mail/mail/report/outgoing_mail_summary/outgoing_mail_summary.py @@ -9,7 +9,7 @@ from frappe.query_builder import Criterion, Order from frappe.query_builder.functions import Date, IfNull -from mail.utils.user import get_user_mailboxes, has_role, is_system_manager +from mail.utils.user import get_user_email_addresses, has_role, is_system_manager def execute(filters: dict | None = None) -> tuple: @@ -203,10 +203,10 @@ def get_data(filters: dict | None = None) -> list[dict]: user = frappe.session.user if not is_system_manager(user): conditions = [] - mailboxes = get_user_mailboxes(user) + accounts = get_user_email_addresses(user, "Mail Account") - if has_role(user, "Mailbox User") and mailboxes: - conditions.append(OM.sender.isin(mailboxes)) + if has_role(user, "Mailbox User") and accounts: + conditions.append(OM.sender.isin(accounts)) if not conditions: return [] diff --git a/mail/utils/cache.py b/mail/utils/cache.py index ad887429..1dae9c8c 100644 --- a/mail/utils/cache.py +++ b/mail/utils/cache.py @@ -60,7 +60,40 @@ def generator() -> list: .where((MAIL_DOMAIN.enabled == 1) & (MAIL_DOMAIN.domain_owner == user)) ).run(pluck="name") - return frappe.cache(f"user|{user}", "owned_domains", generator) + return frappe.cache.hget(f"user|{user}", "owned_domains", generator) + + +def get_user_mail_accounts(user: str) -> list: + def generator() -> list: + MAIL_ACCOUNT = frappe.qb.DocType("Mail Account") + return ( + frappe.qb.from_(MAIL_ACCOUNT) + .select("name") + .where((MAIL_ACCOUNT.enabled == 1) & (MAIL_ACCOUNT.user == user)) + ).run(pluck="name") + + return frappe.cache.hget(f"user|{user}", "mail_accounts", generator) + + +def get_user_mail_aliases(user: str) -> list: + def generator() -> list: + accounts = get_user_mail_accounts(user) + + if not accounts: + return [] + + MAIL_ALIAS = frappe.qb.DocType("Mail Alias") + return ( + frappe.qb.from_(MAIL_ALIAS) + .select("name") + .where( + (MAIL_ALIAS.enabled == 1) + & (MAIL_ALIAS.alias_for_type == "Mail Account") + & (MAIL_ALIAS.alias_for_name.isin(accounts)) + ) + ).run(pluck="name") + + return frappe.cache.hget(f"user|{user}", "mail_aliases", generator) def get_user_incoming_mailboxes(user: str) -> list: @@ -74,7 +107,7 @@ def generator() -> list: .where((MAILBOX.user == user) & (MAILBOX.enabled == 1) & (MAILBOX.incoming == 1)) ).run(pluck="name") - return frappe.cache(f"user|{user}", "incoming_mailboxes", generator) + return frappe.cache.hget(f"user|{user}", "incoming_mailboxes", generator) def get_user_outgoing_mailboxes(user: str) -> list: @@ -88,16 +121,16 @@ def generator() -> list: .where((MAILBOX.user == user) & (MAILBOX.enabled == 1) & (MAILBOX.outgoing == 1)) ).run(pluck="name") - return frappe.cache(f"user|{user}", "outgoing_mailboxes", generator) + return frappe.cache.hget(f"user|{user}", "outgoing_mailboxes", generator) -def get_user_default_mailbox(user: str) -> str | None: - """Returns the default mailbox of the user.""" +def get_user_default_mail_account(user: str) -> str | None: + """Returns the default mail account of the user.""" def generator() -> str | None: - return frappe.db.get_value("Mailbox", {"user": user, "is_default": 1}, "name") + return frappe.db.get_value("Mail Account", {"user": user, "enabled": 1, "is_default": 1}, "name") - return frappe.cache(f"user|{user}", "default_mailbox", generator) + return frappe.cache.hget(f"user|{user}", "default_mail_account", generator) def get_blacklist_for_ip_group(ip_group: str) -> list: diff --git a/mail/utils/query.py b/mail/utils/query.py index f505a088..c744cb4e 100644 --- a/mail/utils/query.py +++ b/mail/utils/query.py @@ -1,7 +1,7 @@ import frappe from frappe.query_builder import Criterion, Order -from mail.utils.user import get_user_mailboxes, has_role, is_system_manager +from mail.utils.user import get_user_email_addresses, has_role, is_system_manager @frappe.whitelist() @@ -66,10 +66,10 @@ def get_outgoing_mails( if not is_system_manager(user): conditions = [] - mailboxes = get_user_mailboxes(user) + accounts = get_user_email_addresses(user, "Mail Account") - if has_role(user, "Mailbox User") and mailboxes: - conditions.append(OM.sender.isin(mailboxes)) + if has_role(user, "Mailbox User") and accounts: + conditions.append(OM.sender.isin(accounts)) if not conditions: return [] diff --git a/mail/utils/user.py b/mail/utils/user.py index 34b16478..18a7b1ae 100644 --- a/mail/utils/user.py +++ b/mail/utils/user.py @@ -3,7 +3,7 @@ import frappe from frappe.utils.caching import request_cache -from mail.utils.cache import get_user_incoming_mailboxes, get_user_outgoing_mailboxes +from mail.utils.cache import get_user_mail_accounts, get_user_mail_aliases @request_cache @@ -13,18 +13,16 @@ def is_system_manager(user: str) -> bool: return user == "Administrator" or has_role(user, "System Manager") -def get_user_mailboxes(user: str, type: Literal["Incoming", "Outgoing"] | None = None) -> list: - """Returns the list of mailboxes associated with the user.""" +def get_user_email_addresses(user: str, type: Literal["Mail Account", "Mail Alias"] | None = None) -> list: + """Returns the list of email addresses associated with the user.""" - if type and type in ["Incoming", "Outgoing"]: - if type == "Incoming": - return get_user_incoming_mailboxes(user) - else: - return get_user_outgoing_mailboxes(user) + if type: + if type == "Mail Account": + return get_user_mail_accounts(user) + elif type == "Mail Alias": + return get_user_mail_aliases(user) - unique_mailboxes = set(get_user_incoming_mailboxes(user)) | set(get_user_outgoing_mailboxes(user)) - - return list(unique_mailboxes) + return get_user_mail_accounts(user) + get_user_mail_aliases(user) @request_cache