Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions helpdesk/overrides/email_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,41 @@
from frappe.email.receive import InboundMail


def auto_generated_reason(msg) -> str | None:
"""Why this mail is machine-generated (bounce or autoresponder), else None.

One such mail can start three different loops: opening a ticket acks
raised_by straight back to the address that just bounced
(HD Ticket.after_insert), threading onto a portal ticket makes frappe CC the
parent doc's owner -- the same dead address -- on every inbound mail
(mail_cc), and an account with enable_auto_reply answers mailer-daemon.

X-Auto-Generated only catches helpdesk's own acks, which stamp it. Real
bounces announce themselves with RFC 3834 Auto-Submitted, the RFC 3464
report type, or the RFC 5321 null return-path.
"""
if msg.get("X-Auto-Generated"):
return "X-Auto-Generated"

# RFC 3834: "no" is the only human-sent value, and it may carry parameters
auto_submitted = (msg.get("Auto-Submitted") or "no").split(";")[0].strip().lower()
if auto_submitted != "no":
return f"Auto-Submitted: {auto_submitted}"

# RFC 3464 delivery status notification -- survives a stripped Return-Path
if (
msg.get_content_type() == "multipart/report"
and msg.get_param("report-type") == "delivery-status"
):
return "delivery status notification"

# bounces MUST carry a null envelope sender (RFC 5321 §6.1)
if (msg.get("Return-Path") or "").strip() == "<>":
return "null return-path"

return None


class CustomInboundMail(InboundMail):
"""
Extend InboundMail with robust thread stitching for forwarded emails.
Expand Down Expand Up @@ -68,15 +103,19 @@ def process_mail(messages, append_to=None):
message.decode("utf-8", errors="replace")
)

# Important: If the email is auto-generated, we do not create a ticket
if _msg.get("X-Auto-Generated"):
continue

uid = (
messages["uid_list"][index]
if messages.get("uid_list")
else None
)

# Important: auto-generated mail must never reach a ticket, it
# starts a mail loop. The fetch already marked it seen, so park
# it in Unhandled Email instead of dropping it without a trace.
if reason := auto_generated_reason(_msg):
self.handle_bad_emails(uid, message, reason)
continue

seen_status = messages.get("seen_status", {}).get(uid)
if self.email_sync_option != "UNSEEN" or seen_status != "SEEN":
_inbound_mail = CustomInboundMail(
Expand Down
98 changes: 98 additions & 0 deletions helpdesk/overrides/test_email_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import unittest
from email import message_from_string

from helpdesk.overrides.email_account import auto_generated_reason

# Real Gmail DSN headers -- the shape that caused the 95-mail loop on ticket 74703
GMAIL_BOUNCE = """\
Return-Path: <>
From: Mail Delivery Subsystem <mailer-daemon@googlemail.com>
To: support@frappe.io
Subject: Delivery Status Notification (Failure)
Auto-Submitted: auto-replied
Content-Type: multipart/report; report-type=delivery-status; boundary="b"

--b
Content-Type: text/plain

The email account that you tried to reach does not exist.
--b--
"""

OOO_REPLY = """\
Return-Path: <someone@example.com>
From: Someone <someone@example.com>
To: support@frappe.io
Subject: Out of office
Auto-Submitted: auto-replied

Back on Monday.
"""

# same DSN reaching us over POP3/Frappe Mail, where no MTA added a Return-Path
BARE_DSN = """\
From: Mail Delivery Subsystem <mailer-daemon@example.com>
To: support@frappe.io
Subject: Undelivered Mail Returned to Sender
Content-Type: multipart/report; report-type=delivery-status; boundary="b"

--b
Content-Type: text/plain

Recipient address rejected: User unknown.
--b--
"""

REAL_CUSTOMER_REPLY = """\
Return-Path: <omkar@batt.science>
From: Omkar <omkar@batt.science>
To: support@frappe.io
Subject: Re: URGENT : Tickets raised outside working hours

Any update on this?
"""


class TestAutoGeneratedReason(unittest.TestCase):
def test_gmail_bounce_is_dropped(self):
self.assertTrue(auto_generated_reason(message_from_string(GMAIL_BOUNCE)))

def test_out_of_office_is_dropped(self):
self.assertTrue(auto_generated_reason(message_from_string(OOO_REPLY)))

def test_dsn_without_return_path_is_dropped(self):
# only the RFC 3464 report type is left to go on
self.assertEqual(
auto_generated_reason(message_from_string(BARE_DSN)),
"delivery status notification",
)

def test_legacy_x_auto_generated_still_dropped(self):
msg = message_from_string(REAL_CUSTOMER_REPLY)
msg["X-Auto-Generated"] = "auto-replied"
self.assertTrue(auto_generated_reason(msg))

def test_real_reply_is_kept(self):
self.assertIsNone(
auto_generated_reason(message_from_string(REAL_CUSTOMER_REPLY))
)

def test_missing_headers_do_not_look_like_a_bounce(self):
# no Return-Path / Auto-Submitted at all -- must not be mistaken for a DSN
msg = message_from_string("From: a@b.com\nTo: support@frappe.io\n\nhi")
self.assertIsNone(auto_generated_reason(msg))

def test_auto_submitted_no_is_kept(self):
msg = message_from_string(REAL_CUSTOMER_REPLY)
msg["Auto-Submitted"] = "no"
self.assertIsNone(auto_generated_reason(msg))

def test_auto_submitted_no_with_parameters_is_kept(self):
# RFC 3834 allows parameters after the value -- still human-sent
msg = message_from_string(REAL_CUSTOMER_REPLY)
msg["Auto-Submitted"] = "no; owner=someone@example.com"
self.assertIsNone(auto_generated_reason(msg))


if __name__ == "__main__":
unittest.main()
Loading