diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 62e7873c79..733a5d73dc 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -18,7 +18,10 @@ from frappe.utils import ( get_imaginary_pixel_response, get_string_between, list_to_str, + now_datetime, + parse_addr, split_emails, + time_diff_in_seconds, validate_email_address, ) @@ -328,3 +331,63 @@ def update_communication_as_read(name): name, {"read_by_recipient": 1, "delivery_status": "Read", "read_by_recipient_on": get_datetime()}, ) + + +@frappe.whitelist() +def undo_email_send(communication_name: str): + communication = frappe.get_doc("Communication", communication_name) + + if communication.owner != frappe.session.user: + frappe.throw(_("You are not authorized to undo this email")) + + if communication.sent_or_received != "Sent" or communication.communication_medium != "Email": + frappe.throw(_("Failed to delete communication")) + + time_elapsed_in_seconds = time_diff_in_seconds(now_datetime(), communication.creation) + if time_elapsed_in_seconds > 10: + frappe.msgprint( + _("Email undo window is over. Cannot undo email."), alert=True, indicator="red", raise_exception=1 + ) + + email_queue_records = frappe.get_all( + "Email Queue", filters={"communication": communication_name}, fields=["name", "status"] + ) + + for queue in email_queue_records: + if queue.status != "Not Sent": + frappe.msgprint( + _("It is too late to undo this email. It is already being sent."), + alert=True, + indicator="red", + raise_exception=1, + ) + + for queue in email_queue_records: + frappe.delete_doc("Email Queue", queue.name, ignore_permissions=True) + + communication_data = { + "subject": communication.subject, + "content": communication.content, + "recipients": communication.recipients, + "cc": communication.cc, + "bcc": communication.bcc, + "doc": {"doctype": communication.reference_doctype, "name": communication.reference_name}, + "sender": communication.sender, + "send_read_receipt": communication.read_receipt, + } + + linked_files = frappe.get_all( + "File", + filters={"attached_to_doctype": "Communication", "attached_to_name": communication_name}, + pluck="name", + ) + + if linked_files: + for file_name in linked_files: + frappe.db.set_value("File", file_name, {"attached_to_doctype": None, "attached_to_name": None}) + + communication_data["attachments"] = linked_files + + communication.delete(ignore_permissions=True) + + return communication_data diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index 48a6e9b636..b7ad8e2c19 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -1,12 +1,14 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +from datetime import timedelta from typing import TYPE_CHECKING import frappe from frappe.core.doctype.communication.communication import Communication, get_emails, parse_email -from frappe.core.doctype.communication.email import add_attachments, make +from frappe.core.doctype.communication.email import add_attachments, make, undo_email_send from frappe.email.doctype.email_queue.email_queue import EmailQueue from frappe.tests import IntegrationTestCase +from frappe.utils import add_to_date, now_datetime if TYPE_CHECKING: from frappe.contacts.doctype.contact.contact import Contact @@ -438,6 +440,79 @@ class TestCommunicationEmailMixin(IntegrationTestCase): self.assertEqual(attached_file.file_name, file_name) self.assertEqual(attached_file.get_content(), file_content) + def test_undo_email_send(self): + """Undo should delete Communication and Email Queue, and return original data.""" + comm = self.new_communication(recipients=["to@test.com"]) + comm.sent_or_received = "Sent" + comm.save(ignore_permissions=True) + + eq = frappe.get_doc( + { + "doctype": "Email Queue", + "sender": "Test ", + "message": "Test message", + "status": "Not Sent", + "priority": 1, + "communication": comm.name, + "recipients": [{"recipient": "to@test.com", "status": "Not Sent"}], + } + ).insert(ignore_permissions=True) + + result = undo_email_send(comm.name) + + self.assertFalse(frappe.db.exists("Communication", comm.name)) + self.assertFalse(frappe.db.exists("Email Queue", eq.name)) + self.assertFalse(frappe.db.exists("Email Queue Recipient", {"parent": eq.name})) + self.assertEqual(result["subject"], comm.subject) + self.assertEqual(result["recipients"], comm.recipients) + + def test_undo_email_send_fails_for_different_user(self): + """Undo should fail if the current user is not the owner.""" + comm = self.new_communication(recipients=["to@test.com"]) + comm.sent_or_received = "Sent" + comm.save(ignore_permissions=True) + frappe.db.set_value("Communication", comm.name, "owner", "other@test.com") + + with self.assertRaises(frappe.exceptions.ValidationError): + undo_email_send(comm.name) + + self.assertTrue(frappe.db.exists("Communication", comm.name)) + + def test_undo_email_send_fails_after_time_window(self): + """Undo should fail if the 10-second window has passed.""" + comm = self.new_communication(recipients=["to@test.com"]) + comm.sent_or_received = "Sent" + comm.save(ignore_permissions=True) + + with self.freeze_time(add_to_date(now_datetime(), seconds=12)): + with self.assertRaises(frappe.exceptions.ValidationError): + undo_email_send(comm.name) + + self.assertTrue(frappe.db.exists("Communication", comm.name)) + + def test_undo_email_send_fails_if_already_sent(self): + """Undo should fail if Email Queue status is not 'Not Sent'.""" + comm = self.new_communication(recipients=["to@test.com"]) + comm.sent_or_received = "Sent" + comm.save(ignore_permissions=True) + + frappe.get_doc( + { + "doctype": "Email Queue", + "sender": "Test ", + "message": "Test message", + "status": "Sent", + "priority": 1, + "communication": comm.name, + "recipients": [{"recipient": "to@test.com", "status": "Sent"}], + } + ).insert(ignore_permissions=True) + + with self.assertRaises(frappe.exceptions.ValidationError): + undo_email_send(comm.name) + + self.assertTrue(frappe.db.exists("Communication", comm.name)) + def create_email_account() -> "EmailAccount": frappe.delete_doc_if_exists("Email Account", "_Test Comm Account 1") diff --git a/frappe/email/queue.py b/frappe/email/queue.py index 6e47541385..e0aa112e60 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -5,7 +5,7 @@ from datetime import timedelta import frappe from frappe import _, msgprint from frappe.utils import cint, cstr, get_url, now_datetime -from frappe.utils.data import getdate +from frappe.utils.data import add_to_date, getdate from frappe.utils.verified_command import get_signed_params, verify_request # After this percent of failures in every batch, entire batch is aborted. @@ -163,19 +163,21 @@ def flush(): def get_queue(): batch_size = cint(frappe.conf.email_queue_batch_size) or 500 + undo_window = add_to_date(now_datetime(), seconds=-10) return frappe.db.sql( - f"""select + """select name, sender from `tabEmail Queue` where (status='Not Sent' or status='Partially Sent') and - (send_after is null or send_after < %(now)s) + (send_after is null or send_after < %(now)s) and + (creation < %(undo_window)s) order by priority desc, retry asc, creation asc - limit {batch_size}""", - {"now": now_datetime()}, + limit %(batch_size)s""", + {"now": now_datetime(), "undo_window": undo_window, "batch_size": batch_size}, as_dict=True, ) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 528b5f81a3..8424473c30 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -896,6 +896,8 @@ frappe.views.CommunicationComposer = class { if (!r.exc) { frappe.utils.play_sound("email"); + const communication_name = r.message["name"]; + if (r.message["emails_not_sent_to"]) { frappe.msgprint( __("Email not sent to {0} (unsubscribed / disabled)", [ @@ -910,6 +912,54 @@ frappe.views.CommunicationComposer = class { me.frm.reload_doc(); } + let undo_alert = frappe.show_alert( + { + message: `${__( + "Email Sent" + )}${__( + "Undo" + )}`, + indicator: "green", + }, + 10, + { + undo: () => { + if (undo_alert) { + undo_alert.find(".close").click(); + } + frappe + .xcall( + "frappe.core.doctype.communication.email.undo_email_send", + { communication_name: communication_name } + ) + .then((d) => { + if (me.frm) { + me.frm.reload_doc(); + } + + // Reopen the composer with the recovered data + new frappe.views.CommunicationComposer({ + doc: d.doc, + subject: d.subject, + recipients: d.recipients, + cc: d.cc, + bcc: d.bcc, + message: d.content, + sender: d.sender, + read_receipt: d.send_read_receipt, + attachments: d.attachments, + frm: me.frm, + }); + + frappe.show_alert({ + message: __("Email sending undone"), + indicator: "blue", + }); + }); + }, + } + ); + // try the success callback if it exists if (me.success) { try { diff --git a/frappe/public/scss/desk/toast.scss b/frappe/public/scss/desk/toast.scss index 695c7118b7..36b067a5d3 100644 --- a/frappe/public/scss/desk/toast.scss +++ b/frappe/public/scss/desk/toast.scss @@ -2,7 +2,7 @@ position: fixed; bottom: 0px; right: 20px; - z-index: 1050; + z-index: 2000; @include media-breakpoint-down(sm) { right: 0; diff --git a/frappe/tests/test_email.py b/frappe/tests/test_email.py index 2e3932c7e3..4a2ec50c30 100644 --- a/frappe/tests/test_email.py +++ b/frappe/tests/test_email.py @@ -57,16 +57,20 @@ class TestEmail(IntegrationTestCase): def test_send_after(self): self.test_email_queue(send_after=1) from frappe.email.queue import flush + from frappe.utils import add_to_date, now_datetime - flush() + with self.freeze_time(add_to_date(now_datetime(), seconds=12)): + flush() email_queue = frappe.db.sql("""select name from `tabEmail Queue` where status='Sent'""", as_dict=1) self.assertEqual(len(email_queue), 0) def test_flush(self): self.test_email_queue() from frappe.email.queue import flush + from frappe.utils import add_to_date, now_datetime - flush() + with self.freeze_time(add_to_date(now_datetime(), seconds=12)): + flush() email_queue = frappe.db.sql("""select name from `tabEmail Queue` where status='Sent'""", as_dict=1) self.assertEqual(len(email_queue), 1) queue_recipients = [