From 8bfb42deb4c28cd7e43e7f5a1da70fcafcc6c4d4 Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Thu, 19 Feb 2026 12:13:55 +0530 Subject: [PATCH] feat: configurable `Reply-To` (#36774) * feat: configurable `Reply-To` * chore: set `add_reply_to_header` to `1` * fix: Resolve conflicts --------- Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- .../doctype/email_account/email_account.json | 47 +++++++++++++++++-- .../doctype/email_account/email_account.py | 12 +++++ .../doctype/reply_to_address/__init__.py | 0 .../reply_to_address/reply_to_address.json | 47 +++++++++++++++++++ .../reply_to_address/reply_to_address.py | 24 ++++++++++ frappe/email/email_body.py | 22 ++++++++- frappe/patches.txt | 3 +- 7 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 frappe/email/doctype/reply_to_address/__init__.py create mode 100644 frappe/email/doctype/reply_to_address/reply_to_address.json create mode 100644 frappe/email/doctype/reply_to_address/reply_to_address.py diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index f85615ebd2..042366b1cf 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -67,6 +67,11 @@ "send_unsubscribe_message", "add_x_original_from", "track_email_status", + "headers_section", + "column_break_mcbu", + "add_x_original_from", + "add_reply_to_header", + "reply_to_addresses", "outgoing_mail_settings", "use_tls", "use_ssl_for_outgoing", @@ -84,9 +89,11 @@ "set_footer", "footer", "brand_logo", + "section_break_jdoz", "uidvalidity", "uidnext", - "no_failed" + "no_failed", + "column_break_wojv" ], "fields": [ { @@ -161,7 +168,8 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Domain", - "options": "Email Domain" + "options": "Email Domain", + "search_index": 1 }, { "depends_on": "eval:!doc.domain", @@ -714,13 +722,46 @@ "fieldname": "add_x_original_from", "fieldtype": "Check", "label": "Add X-Original-From header" + }, + { + "default": "1", + "fieldname": "add_reply_to_header", + "fieldtype": "Check", + "label": "Add Reply-To header" + }, + { + "collapsible": 1, + "fieldname": "headers_section", + "fieldtype": "Section Break", + "label": "Headers" + }, + { + "fieldname": "section_break_jdoz", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_wojv", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_mcbu", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.add_reply_to_header", + "description": "Addresses added here will be used as the Reply-To header for outgoing emails sent from this account.", + "fieldname": "reply_to_addresses", + "fieldtype": "Table", + "ignore_xss_filter": 1, + "label": "Reply-To Addresses", + "options": "Reply To Address" } ], "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2026-02-04 15:50:27.898578", + "modified": "2026-02-06 11:39:39.412130", "modified_by": "Administrator", "module": "Email", "name": "Email Account", diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 37c84cf12a..14100f963f 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -58,8 +58,10 @@ class EmailAccount(Document): if TYPE_CHECKING: from frappe.email.doctype.imap_folder.imap_folder import IMAPFolder + from frappe.email.doctype.reply_to_address.reply_to_address import ReplyToAddress from frappe.types import DF + add_reply_to_header: DF.Check add_signature: DF.Check add_x_original_from: DF.Check always_bcc: DF.Data | None @@ -102,6 +104,7 @@ class EmailAccount(Document): no_smtp_authentication: DF.Check notify_if_unreplied: DF.Check password: DF.Password | None + reply_to_addresses: DF.Table[ReplyToAddress] send_notification_to: DF.SmallText | None send_unsubscribe_message: DF.Check sent_folder_name: DF.Data | None @@ -203,6 +206,9 @@ class EmailAccount(Document): if folder.append_to not in valid_doctypes: frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes))) + if self.enable_outgoing: + self.validate_reply_to_addresses() + @frappe.whitelist() def validate_frappe_mail_settings(self): if self.service == "Frappe Mail": @@ -217,6 +223,12 @@ class EmailAccount(Document): self.get_smtp_server().session del self._smtp_server_instance + def validate_reply_to_addresses(self) -> None: + for reply_to in self.reply_to_addresses: + if not reply_to.email: + frappe.throw(_("Reply To email is required")) + validate_email_address(reply_to.email, True) + def before_save(self): messages = [] as_list = 1 diff --git a/frappe/email/doctype/reply_to_address/__init__.py b/frappe/email/doctype/reply_to_address/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/email/doctype/reply_to_address/reply_to_address.json b/frappe/email/doctype/reply_to_address/reply_to_address.json new file mode 100644 index 0000000000..7a8d10d635 --- /dev/null +++ b/frappe/email/doctype/reply_to_address/reply_to_address.json @@ -0,0 +1,47 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-02-06 11:33:22.774848", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "_name", + "column_break_xtxq", + "email" + ], + "fields": [ + { + "fieldname": "_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Name" + }, + { + "fieldname": "column_break_xtxq", + "fieldtype": "Column Break" + }, + { + "fieldname": "email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Email", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-06 11:35:05.181524", + "modified_by": "Administrator", + "module": "Email", + "name": "Reply To Address", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/email/doctype/reply_to_address/reply_to_address.py b/frappe/email/doctype/reply_to_address/reply_to_address.py new file mode 100644 index 0000000000..4666057f9a --- /dev/null +++ b/frappe/email/doctype/reply_to_address/reply_to_address.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class ReplyToAddress(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + _name: DF.Data | None + email: DF.Data + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 5c1fe6320b..5dc3054843 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -9,6 +9,7 @@ import re from email import policy from email.header import Header from email.mime.multipart import MIMEMultipart +from email.utils import formataddr from typing import TYPE_CHECKING import frappe @@ -25,6 +26,7 @@ from frappe.utils import ( split_emails, strip, to_markdown, + validate_email_address, ) from frappe.utils.pdf import get_pdf @@ -268,13 +270,12 @@ class EMail: def validate(self): """validate the Email Addresses""" - from frappe.utils import validate_email_address if not self.sender: self.sender = self.email_account.default_sender validate_email_address(strip(self.sender), True) - self.reply_to = validate_email_address(strip(self.reply_to) or self.sender, True) + self.validate_reply_to() if self.email_account.add_x_original_from: self.set_header("X-Original-From", self.sender) @@ -289,6 +290,23 @@ class EMail: for e in self.recipients + (self.cc or []) + (self.bcc or []): validate_email_address(e, True) + def validate_reply_to(self) -> None: + if not self.email_account.add_reply_to_header: + self.reply_to = None + return + + if self.email_account.reply_to_addresses: + valid_addresses = [ + formataddr((reply_to._name, reply_to.email)) + for reply_to in self.email_account.reply_to_addresses + if reply_to.email and validate_email_address(reply_to.email, True) + ] + self.reply_to = ", ".join(valid_addresses) if valid_addresses else None + return + + fallback = strip(self.reply_to) or self.sender + self.reply_to = validate_email_address(fallback, True) + def replace_sender(self): if cint(self.email_account.always_use_account_email_id_as_sender): sender_name, _ = parse_addr(self.sender) diff --git a/frappe/patches.txt b/frappe/patches.txt index 0e550b0e42..c71b45b2cd 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -258,4 +258,5 @@ execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Productivity") frappe.patches.v16_0.unset_standard_field_for_auto_generated_icons execute:from frappe.email.doctype.notification.notification import install_notification_templates; install_notification_templates() execute:frappe.db.set_value("Email Account", {}, "add_x_original_from", 1) -frappe.patches.v16_0.fix_myanmar_language_name \ No newline at end of file +frappe.patches.v16_0.fix_myanmar_language_name +execute:frappe.db.set_value("Email Account", {}, "add_reply_to_header", 1)