To: / Cc: / Bcc: / Reply-To: headers emit raw UTF-8 instead of RFC 2047 (5.2.254 InvalidRecipientsException with Office 365 on Python 3.14)
Summary
In fastapi_mail/msg.py, all recipient headers are set via str(NameEmail):
self.message["To"] = ", ".join(str(recipient) for recipient in self.recipients)
self.message["From"] = sender
# (same pattern for Cc, Bcc, Reply-To below)
For a recipient with a non-ASCII display name (e.g. Lukas Böhm <user@example.com>), str(NameEmail("Lukas Böhm", "user@example.com")) returns the raw UTF-8 string 'Lukas Böhm <user@example.com>' — not an RFC 2047 quoted-printable encoded header ('=?utf-8?q?Lukas_B=C3=B6hm?= <user@example.com>').
On Python 3.12 this silently fails for some users with 'ascii' codec can't encode characters (already reported in #225). On Python 3.14, the stdlib email package is stricter and lets the raw bytes through to the SMTP layer; Office 365's submission server then strips the non-ASCII bytes (Böhm → Bhm) and rejects the message with:
550 5.2.254 InvalidRecipientsException; Sender throttled due to continuous invalid recipients errors.
STOREDRV.Submission.Exception:InvalidRecipientsException; Failed to process message …
Recipient 'Lukas Bhm <user@example.com>' is not resolved.
After enough rejections, the sender mailbox lands in O365's "Sender throttled" state — even subsequent ASCII-only sends fail until the throttle decays.
This is related to #225 but with a different downstream symptom: instead of a Python-level UnicodeEncodeError, the message is accepted locally and rejected by the receiving SMTP server.
Reproducer
from pydantic import NameEmail
ne = NameEmail(name="Lukas Böhm", email="user@example.com")
print(repr(str(ne)))
# 'Lukas Böhm <user@example.com>' ← raw UTF-8 — what fastapi-mail puts into the To: header
from email.utils import formataddr
print(repr(formataddr((ne.name, ne.email), charset="utf-8")))
# '=?utf-8?q?Lukas_B=C3=B6hm?= <user@example.com>' ← what RFC 2047 requires
Environment
fastapi-mail==1.6.2 (and current master — verified the same code at msg.py line where self.message["To"] = ", ".join(str(r) for r in self.recipients))
aiosmtplib (latest)
pydantic v2
- Python 3.14 on Linux
- SMTP relay: Office 365 (
smtp.office365.com:587, STARTTLS)
- Receivers: ASCII recipients work; non-ASCII display names trigger
550 5.2.254
Proposed fix
Use email.utils.formataddr with explicit charset='utf-8' when building the address-list headers. Sketch:
from email.utils import formataddr # add to imports
def _format_recipient(r):
# `r` is a pydantic NameEmail; if no name was provided, `.name` is "" or equals `.email`.
name = r.name if r.name and r.name != r.email else ""
return formataddr((name, str(r.email)), charset="utf-8")
# in MailMsg._message(...)
self.message["To"] = ", ".join(_format_recipient(r) for r in self.recipients)
if self.cc:
self.message["Cc"] = ", ".join(_format_recipient(r) for r in self.cc)
if self.bcc:
self.message["Bcc"] = ", ".join(_format_recipient(r) for r in self.bcc)
if self.reply_to:
self.message["Reply-To"] = ", ".join(_format_recipient(r) for r in self.reply_to)
formataddr(..., charset='utf-8') returns plain ASCII when the name is already ASCII (no overhead, no behaviour change for existing users) and emits RFC 2047 quoted-printable when the name has non-ASCII characters.
Same treatment is needed for MAIL_FROM_NAME when constructing self.message["From"] via sender — see FastMail.__sender() which already uses formataddr but without charset='utf-8', so it has the same latent bug.
Happy to send a PR with the patch + a unit test that asserts Lukas Böhm round-trips through MailMsg as a header that header.encode("ascii") accepts. Let me know.
Workaround for downstream users
Until this is fixed upstream, downstream consumers can ASCII-fold display names before passing to NameEmail:
import unicodedata
def _ascii_safe(name: str) -> str:
folded = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
return " ".join(folded.split())
recipients = [NameEmail(name=_ascii_safe(full_name), email=addr) for ...]
— lossy (Böhm → Bohm in the inbox preview), but bypasses the rejection.
To:/Cc:/Bcc:/Reply-To:headers emit raw UTF-8 instead of RFC 2047 (5.2.254 InvalidRecipientsException with Office 365 on Python 3.14)Summary
In
fastapi_mail/msg.py, all recipient headers are set viastr(NameEmail):For a recipient with a non-ASCII display name (e.g.
Lukas Böhm <user@example.com>),str(NameEmail("Lukas Böhm", "user@example.com"))returns the raw UTF-8 string'Lukas Böhm <user@example.com>'— not an RFC 2047 quoted-printable encoded header ('=?utf-8?q?Lukas_B=C3=B6hm?= <user@example.com>').On Python 3.12 this silently fails for some users with
'ascii' codec can't encode characters(already reported in #225). On Python 3.14, the stdlibemailpackage is stricter and lets the raw bytes through to the SMTP layer; Office 365's submission server then strips the non-ASCII bytes (Böhm→Bhm) and rejects the message with:After enough rejections, the sender mailbox lands in O365's "Sender throttled" state — even subsequent ASCII-only sends fail until the throttle decays.
This is related to #225 but with a different downstream symptom: instead of a Python-level
UnicodeEncodeError, the message is accepted locally and rejected by the receiving SMTP server.Reproducer
Environment
fastapi-mail==1.6.2(and currentmaster— verified the same code atmsg.pyline whereself.message["To"] = ", ".join(str(r) for r in self.recipients))aiosmtplib(latest)pydanticv2smtp.office365.com:587, STARTTLS)550 5.2.254Proposed fix
Use
email.utils.formataddrwith explicitcharset='utf-8'when building the address-list headers. Sketch:formataddr(..., charset='utf-8')returns plain ASCII when the name is already ASCII (no overhead, no behaviour change for existing users) and emits RFC 2047 quoted-printable when the name has non-ASCII characters.Same treatment is needed for
MAIL_FROM_NAMEwhen constructingself.message["From"]viasender— seeFastMail.__sender()which already usesformataddrbut withoutcharset='utf-8', so it has the same latent bug.Happy to send a PR with the patch + a unit test that asserts
Lukas Böhmround-trips throughMailMsgas a header thatheader.encode("ascii")accepts. Let me know.Workaround for downstream users
Until this is fixed upstream, downstream consumers can ASCII-fold display names before passing to
NameEmail:— lossy (
Böhm→Bohmin the inbox preview), but bypasses the rejection.