Skip to content

To: / Cc: / Bcc: headers emit raw UTF-8 instead of RFC 2047 (5.2.254 InvalidRecipientsException with Office 365 on Python 3.14) #316

@lukas-r

Description

@lukas-r

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öhmBhm) 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öhmBohm in the inbox preview), but bypasses the rejection.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions