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>
This commit is contained in:
s-aga-r 2026-02-19 12:13:55 +05:30 committed by GitHub
parent 199f1f540f
commit 8bfb42deb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 149 additions and 6 deletions

View file

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

View file

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

View file

@ -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": []
}

View file

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

View file

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

View file

@ -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
frappe.patches.v16_0.fix_myanmar_language_name
execute:frappe.db.set_value("Email Account", {}, "add_reply_to_header", 1)