Search code examples
pythonemailsmtplib

EmailMessage doesn't show correctly in the sent emails with python's email and smtplib packages


My emails can be correctly sent but don't show correctly in the receiver mails. It looks like this:

To: =?utf-8?b?..?= <....com> MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="===============5404281335870522242=="

--===============5404281335870522242== Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: base64

5bCK5pWs55qE5a2U6LaF5YW...

--===============5404281335870522242== Content-Type: image/png Content-Transfer-Encoding: base64 Content-Disposition: attachment; filename="user.png" MIME-Version: 1.0

iVBORw0KGgo...

The MIME string is directly shown except the Subject and the From line(It is shown after the To) as well as all bodies in plain text.

There's my code:

import smtplib
import ssl
import mimetypes
from pathlib import Path
from email.message import EmailMessage
from email.utils import formataddr
import time

class EmailSender:
    PORT = 465
    CONTEXT = ssl.create_default_context()

    def __init__(
        self,
        username,
        password,
        host,
    ):
        self.username = username
        self.password = password
        self.host = host
        self.mails = []

    def _add_name_header(self, name="", mail_addr=""):
        if name:
            return formataddr((name, mail_addr))
        else:
            return mail_addr

    def add_mail(
        self,
        from_email="",
        from_name="",
        to_email="",
        to_name="",
        subject="",
        message_txt="",
        files=None,
    ):
        msg = EmailMessage()
        msg["Subject"] = subject
        msg["From"] = self._add_name_header(from_name, from_email)
        msg["To"] = self._add_name_header(to_name, to_email)
        msg.set_content(message_txt)

        if not files is None:
            for file_obj in files:
                if file_obj.exists():
                    file = str(file_obj)
                    ctype, encoding = mimetypes.guess_type(file)
                    if ctype is None or encoding is not None:
                        # No guess could be made, or the file is encoded (compressed), so use a generic bag-of-bits type.
                        ctype = "application/octet-stream"
                    maintype, subtype = ctype.split("/", 1)
                    with file_obj.open("rb") as fp:
                        msg.add_attachment(
                            fp.read(),
                            maintype=maintype,
                            subtype=subtype,
                            filename=file_obj.name,
                        )

        self.mails.append(msg)

    def send(self, time_interval=1):
        with smtplib.SMTP_SSL(
            host=self.host, port=self.PORT, context=self.CONTEXT
        ) as server:
            try:
                server.login(user=self.username, password=self.password)
            except Exception as e:
                # Need process errors
                raise e
            for msg in self.mails:
                server.send_message(msg)
                time.sleep(time_interval)

And I just do:

sender = EmailSender(
        username, password, host="smtp.163.com"
)

files = list(Path("D:/").glob("*.pdf"))

sender.add_mail(
        from_email, from_name, to_email, to_name, subject, message_txt, files=None
)
sender.send(time_interval=10)

Solution

  • I'm the OP of the question. I just solved this problem by myself and I'd share the solution.

    TLNR: Non-Ascii chars are used in my mails so use msg = EmailMessage(EmailPolicy(utf8=True)) instead of msg = EmailMessage().

    I misunderstood these sentences in the doc of SMTP.send_message:

    If any of the addresses in from_addr and to_addrs contain non-ASCII characters and the server does not advertise SMTPUTF8 support, an SMTPNotSupported error is raised. Otherwise the Message is serialized with a clone of its policy with the utf8 attribute set to True, and SMTPUTF8 and BODY=8BITMIME are added to mail_options.

    Since I add a non-ASCII header to my address, I believe that smtplib will automatically use the utf8 policy for me. But in the file smtplib.py I saw this:

    if from_addr is None:
        # Some code
        from_addr = email.utils.getaddresses([from_addr])[0][1]
    if to_addrs is None:
        # Some code
        to_addrs = [a[1] for a in email.utils.getaddresses(addr_fields)]
    # Some code
    international = False
    try:
        "".join([from_addr, *to_addrs]).encode("ascii")
    except UnicodeEncodeError:
        # Some code
        international = True
    

    That is, the function only checks if the address parts have non-ASCII chars but not along with the header names.

    After that, the message is dealt with as pure ASCII content, that's maybe no problem, I have no idea why, but somohow, many extra /r chars are inserted before and after the To:xxx line, which makes the smtp server think this as a separator maybe? And finally caused the problem.