From 9fb2d3dfd35e3506efb0d8e6930026e546c3eb0e Mon Sep 17 00:00:00 2001 From: Lukas Ramroth Date: Wed, 13 May 2026 11:42:00 +0200 Subject: [PATCH] fix(msg): RFC 2047 encode non-ASCII names in address headers str(NameEmail) emits the display name as raw UTF-8, so To/Cc/Bcc/ Reply-To headers built via ", ".join(str(r) for r in ...) carry raw non-ASCII bytes. On older Python versions the stdlib email package silently re-encoded these (issue #225); on Python 3.14+ it no longer does, and some SMTP servers (e.g. Office 365) strip the non-ASCII bytes from the recipient display name and reject the message with `550 5.2.254 InvalidRecipientsException`, eventually throttling the sender. Use email.utils.formataddr with charset="utf-8" so display names are RFC 2047 quoted-printable encoded. ASCII names round-trip unchanged. Also pass charset="utf-8" to the existing formataddr call in FastMail._sender so MAIL_FROM_NAME has the same protection. Fixes #316 Refs #225 --- fastapi_mail/fastmail.py | 2 +- fastapi_mail/msg.py | 14 ++++++++----- tests/test_message.py | 44 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/fastapi_mail/fastmail.py b/fastapi_mail/fastmail.py index ff66478..bb8c331 100755 --- a/fastapi_mail/fastmail.py +++ b/fastapi_mail/fastmail.py @@ -112,7 +112,7 @@ async def _template_message_builder( async def _sender(self, message: MessageSchema) -> Union[EmailStr, str]: sender = message.from_email or self.config.MAIL_FROM if (from_name := message.from_name or self.config.MAIL_FROM_NAME) is not None: - return formataddr((from_name, sender)) + return formataddr((from_name, sender), charset="utf-8") return sender async def send_message( diff --git a/fastapi_mail/msg.py b/fastapi_mail/msg.py index 27f2395..cf3c01b 100644 --- a/fastapi_mail/msg.py +++ b/fastapi_mail/msg.py @@ -5,7 +5,7 @@ from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from email.utils import formatdate, make_msgid +from email.utils import formataddr, formatdate, make_msgid from typing import Any, Union from .schemas import MessageType, MultipartSubtypeEnum @@ -13,6 +13,10 @@ PY3 = sys.version_info[0] == 3 +def _format_address(recipient: Any) -> str: + return formataddr((recipient.name, str(recipient.email)), charset="utf-8") + + class MailMsg: """ Mail message parameters @@ -144,21 +148,21 @@ async def _message(self, sender: str) -> Union[EmailMessage, Message]: self.message["Date"] = formatdate(time.time(), localtime=True) self.message["Message-ID"] = self.msgId - self.message["To"] = ", ".join(str(recipient) for recipient in self.recipients) + self.message["To"] = ", ".join(_format_address(r) for r in self.recipients) self.message["From"] = sender if self.subject: self.message["Subject"] = self.subject if self.cc: - self.message["Cc"] = ", ".join(str(recipient) for recipient in self.cc) + self.message["Cc"] = ", ".join(_format_address(r) for r in self.cc) if self.bcc: - self.message["Bcc"] = ", ".join(str(recipient) for recipient in self.bcc) + self.message["Bcc"] = ", ".join(_format_address(r) for r in self.bcc) if self.reply_to: self.message["Reply-To"] = ", ".join( - str(recipient) for recipient in self.reply_to + _format_address(r) for r in self.reply_to ) if self.attachments: diff --git a/tests/test_message.py b/tests/test_message.py index a71923b..9ff1996 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -307,3 +307,47 @@ async def test_name_email_message_headers(): assert msg_object["Cc"] == "CC User " assert msg_object["Bcc"] == "BCC User " assert msg_object["Reply-To"] == "Reply User " + + +@pytest.mark.asyncio +async def test_non_ascii_names_are_rfc2047_encoded(): + """Recipient display names with non-ASCII characters must be RFC 2047 + encoded so the resulting headers are 7-bit-clean. Without encoding, the + raw UTF-8 reaches the SMTP server, which on some providers (e.g. + Office 365) strips it and rejects the message with + `550 5.2.254 InvalidRecipientsException`.""" + from email.header import decode_header + from email.utils import getaddresses + + message = MessageSchema( + subject="test subject", + recipients=["Lukas Böhm "], + cc=["René Méndez "], + bcc=["Straße Müller "], + reply_to=["Renée Doe "], + body="test", + subtype=MessageType.plain, + ) + + msg = MailMsg(message) + msg_object = await msg._message("sender@example.com") + + def _decode(header_value): + name, addr = getaddresses([header_value])[0] + parts = decode_header(name) + return ( + "".join( + chunk.decode(charset or "ascii") if isinstance(chunk, bytes) else chunk + for chunk, charset in parts + ), + addr, + ) + + for header in ("To", "Cc", "Bcc", "Reply-To"): + raw = msg_object[header].encode("ascii") # must not raise + assert b"=?utf-8?" in raw, f"{header} is not RFC 2047 encoded: {raw!r}" + + assert _decode(msg_object["To"]) == ("Lukas Böhm", "lboehm@example.com") + assert _decode(msg_object["Cc"]) == ("René Méndez", "rmendez@example.com") + assert _decode(msg_object["Bcc"]) == ("Straße Müller", "strasse@example.com") + assert _decode(msg_object["Reply-To"]) == ("Renée Doe", "reply@example.com")