Merge pull request #37760 from ShrihariMahabal/undo-email-send

feat: Allow undo email send for 10 seconds
This commit is contained in:
Ankush Menat 2026-03-10 13:06:52 +05:30 committed by GitHub
commit a655bbdfa6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 203 additions and 9 deletions

View file

@ -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

View file

@ -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 <test@example.com>",
"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 <test@example.com>",
"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")

View file

@ -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,
)

View file

@ -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: `<span>${__(
"Email Sent"
)}</span><span class="cursor-pointer ml-4" data-action="undo" style="font-weight: 500; text-decoration: underline;">${__(
"Undo"
)}</span>`,
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 {

View file

@ -2,7 +2,7 @@
position: fixed;
bottom: 0px;
right: 20px;
z-index: 1050;
z-index: 2000;
@include media-breakpoint-down(sm) {
right: 0;

View file

@ -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 = [