Outgoing Email Account cache returns the site default instead of a more specific match
Description
EmailAccount.find_outgoing() resolves the outgoing account in order of specificity: an account with the
sender's address, then an account whose append_to matches the reference doctype, then the site default.
Its per-request cache (cache_email_account in frappe/email/doctype/email_account/email_account.py)
does not keep that order:
cached_accounts = getattr(frappe.local, cache_name)
match_by = [*list(kwargs.values()), "default"]
matched_accounts = list(filter(None, [cached_accounts.get(key) for key in match_by]))
if matched_accounts:
return matched_accounts[0]"default" is always a candidate key. As soon as one lookup in the request has fallen through to the
default account, "default" sits in the cache, and every later lookup returns it without running the
query — including a lookup that would have matched an account by address or by append_to.
Steps to reproduce
On a site with two outgoing accounts:
ERP—default_outgoing = 1,always_use_account_email_id_as_sender = 1Support—enable_outgoing = 1,enable_incoming = 1,append_to = "<some doctype>", not a default
In one request:
frappe.sendmail(recipients=["[email protected]"], subject="plain", message="plain") # caches "default"
frappe.sendmail(recipients=["[email protected]"], sender="[email protected]",
reference_doctype="<that doctype>", reference_name=name,
subject="should use Support", message="...")The second mail is queued with email_account = "ERP", and because that account rewrites the sender, its
From becomes the ERP address. Expected: the second mail uses Support, matched by address or by
append_to.
Impact
Any site that runs a helpdesk-style mailbox next to an ordinary default account can send a customer reply from the wrong mailbox: the customer sees the wrong sender, the reply goes to the wrong inbox and never threads back onto the document, and when the two mailboxes are on different providers the message fails SPF/DKIM/DMARC at the receiving side. The switch is silent — nothing is logged.
Suggested fix
Consider "default" only when the caller asked for no specific match:
keys = [v for v in kwargs.values() if v] or ["default"]
for key in keys:
if cached_accounts.get(key):
return cached_accounts[key]
matched_accounts = func(*args, **kwargs)
cached_accounts.update(matched_accounts or {})
return matched_accounts and next(iter(matched_accounts.values()))A lookup that falls through still caches the default under "default"; a later specific lookup re-runs the
query instead of inheriting it.
Version
Reproduced on Frappe v16.10.7 with Helpdesk v1.28.1 (MariaDB 11.8, Python 3.14). The code is identical on
version-16 and develop as of 2026-09-17.
Source: frappe/frappe