Python has some built-in modules for sending emails. Typically we would use smtplib and email modules. email module can build email structure and layout, while smtplib can use and call smtp servers to send emails.

email module has many usefull classes:

  1. MIMEMultipart: content-type header identifier, marks the position and function of the current paragraph in an email, results in something like this Content-Type: multipart/related; boundary="===============4225807650688820451==".
  2. MIMEText: render data into messages, can be plain or html and will become something like this Content-Type: text/html; charset="us-ascii" in a message.
  3. MIMEImage: can be used to render image attachments.
  4. MIMEBase: can be used to send any type of file attachments including images, file need to be encoded in base64.

Few issus I found while testing against different mailboxes:

  1. Same layout may not work against all mailboxes, e.g: outlook doesn’t support multiple layers, if you put alternitive in mixed content layers, it will be dropped by Exchange server, but same email can work against Gmail.
  2. Gmail and Exchange Outlook treat attachment differently. e.g: Gmail deals with MIMEImage image attachment perfectly, you put it inline as attach and it will be displayed properly on Gmail; But this doesn’t work with Exchange, the attached image will be filtered and lost. So a better approach I can think of to maxmaize its compatibility is to send image twice, one uses MIMEImage and another one uses MIMEBase.

Example code as following, this is a Plain-text and HTML and attachments type of email, Gmail can disply it perfectly, but some mailbox may not support recursive layout, you may only use 1 layer in such a case:

import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email import encoders

email = os.environ["ADMIN_EMAIL"]
password = os.environ["ADMIN_PASSWORD"]
send_to_email = os.environ["USER_EMAIL"]
subject = 'VPN Account Information'
qrcode = '/tmp/qrcode.png'
file_location = './vpn_card.html'
with open('./vpn_card.html', 'r') as f:
  file = f.read()
# Replace the target string
file = file.replace('USERNAME', os.environ["USERNAME"])
file = file.replace('PASSWORD', os.environ["PASSWORD"])
# Write the file out again
with open('./vpn_card.html', 'w') as f:
  f.write(file)

with open("./vpn_card.html", "r") as f:
  messageHTML = f.read()
messagePlain = 'Your account {} has been created, passowrd is {}, please scan the attached QRCode for VPN access'.format(os.environ["USERNAME"], os.environ["PASSWORD"])

# add first content-type 'mixed'
mail = MIMEMultipart('mixed')
mail['From'] = email
mail['To'] = send_to_email
mail['Subject'] = subject

# add second layer 'related'
related = MIMEMultipart('related')

# add third layer 'alternative', receiver will choose txt or html version which ever it supports
alternative = MIMEMultipart('alternative')
alternative.attach(MIMEText(messagePlain, 'plain'))
alternative.attach(MIMEText(messageHTML, 'html'))

#read any attachment file
filename = os.path.basename(file_location)
attachment = open(file_location, "rb")
part = MIMEBase('application', 'octet-stream')
part.set_payload((attachment).read())
encoders.encode_base64(part)
part.add_header('Content-Disposition', "attachment; filename= %s" % filename)

# read image attachment
fp = open(qrcode, 'rb')
msgImage = MIMEImage(fp.read())
fp.close()
msgImage.add_header('Content-ID', '<qrcode.png>')

alternative.attach(msgImage)
related.attach(alternative)
related.attach(part)

mail.attach(related)
print (mail)

server = smtplib.SMTP(os.environ["MAIL_SERVER"], 25)
server.connect(os.environ["MAIL_SERVER"],465)
server.starttls()
server.login(email, password)
text = mail.as_string()
server.sendmail(email, send_to_email, text)
server.quit()

The output email would be like this:

Content-Type: multipart/mixed; boundary="===============1156468728444507130=="
MIME-Version: 1.0
From: [email protected]
To: [email protected]
Subject: VPN Account Information

--===============1156468728444507130==
Content-Type: multipart/related; boundary="===============1669362824009595437=="
MIME-Version: 1.0

--===============1669362824009595437==
Content-Type: multipart/alternative; boundary="===============1125631072229806177=="
MIME-Version: 1.0

--===============1125631072229806177==
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit

Your account username has been created, passowrd is password, please scan the attached QRCode for VPN access
--===============1125631072229806177==
Content-Type: text/html; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit

<!DOCTYPE html>
       <!-- version1.2-->
<html>
<title>CODE1</title>

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<body style="width:350px;height:900px;border:5px solid rgb(11,75,119);">

    <!-- CODE Lower Body -->
    <div style="height:150px;font-family:Arial black;color:rgb(11,75,119)">
        <div>
            <br>
            <center>
                <h1>COMPANY NAME</h1>
                    Account Details</p>
        </div>
    </div>
    <div style="height:550px">
        <div>
            <div style="font-family:Arial Black;font-size:25px">
                <label>&nbsp;Name</label>
                                <div>
                        <div style="font-family:Courier Bold;font-size:25px">
                                <!-- username VALUE BELOW -->
                                <center><label><br>username</label></p>
                </div>
            <div style="font-family:Arial black;font-size:25px">
                <Label>&nbsp;Password</Label>
                            <div>
                        <div style="font-family:Courier Bold;font-size:25px">
                                 <!-- password VALUE BELOW -->
                                <center><label><br>password</label></p>
                </div>
            <div style="font-family:Arial black">
                <label>&nbsp;QR code</label>
                <div>
                          <!-- QR CODE PATH -->
                    <center><img src="cid:qrcode.png" width="300" height="300" style="border:10px solid black"></center>
                </div>
                </form>
            </div>
        </div>
    </div>

    <!-- Footer -->
    <footer style="font-family:Courier;color:rgb(11,75,119)">
        <div style="font-family:Calibri;font-size:15px">
        <br>&nbsp;<i>1. Use QRCode in attachment if not displayed properly<i>
                <div>
    </footer>

</body>

</html>

--===============1125631072229806177==
Content-Type: image/png
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-ID: <qrcode.png>

iVBORw0KGgoAAAANSUhEUgAAAcIAAAHCAQAAAABUY/ToAAADnElEQVR4nO2cTYrjSBCFvxgJvJSg
D1BHkW7QRxr6ZtJRfIACaVmQInqRkfpxmR7osSmXeLEwtqyPlCDIfPEiJXP+LsZ//hIEkSJFihQp
UqTI1yMtosasXcx6lnwAZjOzFqyfy1n9F1+tyJckO3d3n8CHucZ/teBD42491aq6K3d39yP5FVcr
8iXJucwv3VR5/oDFGFtwnxbzATCz+nFjijwHWd/8NpqEQZWsu9Y4c2uPHlPk6cnFoEkAlZu9Jdwn
sP6ZY4o8Adm4+5C/Vk53rfGBxeiuF2dsQxS5e3rcmCJPRY4W5RfdNZY3W+W0/TsBbLXaY8YUeRIy
J8y+4dHETONji/n4lnBI+PGs73afIp9NWk84QIxWA82H0U0Acw3kP1an6CFjijwLSfZ8hiaRp6Ao
6yun8wTdFE5RTFBNKsT3uk+RzyMJ27BJZE2dkybnUMmcbgL3klzKIZHH8DXyxLMdG6higgoXu/LI
JuWQyF3sPEZn/hGyeezBad4Nmq3XsZhDqq0b/t+YIs9F7vVQFkD5QHiM0UOjWEOdSw+JvI3SRq3y
apX1c8jpaqeHQh6htUzkXTJ70jQflv3E0S5FZ8+HjlpUbY8YU+S5SOsB69d8CXW9mIVZXXkYRyzy
xNxiUOcPaCJxfOzrpNpe5CFCAXXDgo/9xQ3AxzePjWg0Ewbkn4RJ9FVXK/IVyajtu6yH0n6H0G6P
NRBVvvwhkZ9i63WULllJlaEpEnsAIs0m+UMib6P0XD3sxXxsbd4DJaVQv0zk3djWstgwRJjVN436
PA8NEIucckhkiTIPbWInfTo2UK37h9aF73vdp8jnkaseKts+hvJIx2HiSUftrRwSuUXpbsxt3i5t
3UQp8FvMc0U//3DGn++H1tn3uk+RzyPXvj3sC/xpPyPt6n2tZSI/hx+jlPVF+wyQU2rbGqscEnmH
3N77kaO71rhfazYlnZ/w6K4X9ctE3iU3T/pXS7RgaRLWz5dIn/XBs0eNKfIk5FpvcXzlR3W7RX+H
yB8S+SfSh+bDypuILiGnxzZ2nm1W9mtcrcgXJSuPpGkS5cHW1Sma1hbsi1ytyC8ny1rm0dzYe9LE
T/ftgY9J/TKRt3EQO2tXdSqPJ24PCK2Vv/SQyEOY//c5d0PvOBcpUqRIkSJFnoT8DZ5FE2pnDE8G
AAAAAElFTkSuQmCC

--===============1125631072229806177==--

--===============1669362824009595437==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename= vpn_card.html

PCFET0NUWVBFIGh0bWw+CiAgICAgICA8IS0tIHZlcnNpb24xLjItLT4gCjxodG1sPgo8dGl0bGU+
Q09ERTE8L3RpdGxlPgoKPG1ldGEgY2hhcnNldD0iVVRGLTgiPgo8bWV0YSBuYW1lPSJ2aWV3cG9y
dCIgY29udGVudD0id2lkdGg9ZGV2aWNlLXdpZHRoLCBpbml0aWFsLXNjYWxlPTEiPgoKPGJvZHkg
c3R5bGU9IndpZHRoOjM1MHB4O2hlaWdodDo5MDBweDtib3JkZXI6NXB4IHNvbGlkIHJnYigxMSw3
NSwxMTkpOyI+CgogICAgPCEtLSBDT0RFIExvd2VyIEJvZHkgLS0+CiAgICA8ZGl2IHN0eWxlPSJo
ZWlnaHQ6MTUwcHg7Zm9udC1mYW1pbHk6QXJpYWwgYmxhY2s7Y29sb3I6cmdiKDExLDc1LDExOSki
PgogICAgICAgIDxkaXY+CiAgICAgICAgICAgIDxicj4KICAgICAgICAgICAgPGNlbnRlcj4KICAg
ICAgICAgICAgICAgIDxoMT5CRUxMIE1PQklMSVRZPC9oMT4KICAgICAgICAgICAgICAgICAgICBU
RExBQiBBY2NvdW50IERldGFpbHM8L3A+CiAgICAgICAgPC9kaXY+CiAgICA8L2Rpdj4KICAgIDxk
aXYgc3R5bGU9ImhlaWdodDo1NTBweCI+CiAgICAgICAgPGRpdj4KICAgICAgICAgICAgPGRpdiBz
dHlsZT0iZm9udC1mYW1pbHk6QXJpYWwgQmxhY2s7Zm9udC1zaXplOjI1cHgiPgogICAgICAgICAg
IHRlc3QgVkFMVUUgQkVMT1cgLS0+ICAgICAKCQkJCTxjZW50ZXI+PGxhYmVsPjxicj50ZXN0PC9s
YWJlbD48L3A+CiAgICAgICAgICAgICAgICA8L2Rpdj4KICAgICAgICAgICAgPGRpdiBzdHlsZT0i
Zm9udC1mYW1pbHk6QXJpYWwgYmxhY2s7Zm9udC1zaXplOjI1cHgiPgogICAgICAgICAgICAgICAg
PExhYmVsPiZuYnNwO1Bhc3N3b3JkPC9MYWJlbD4KCQkJICAgIDxkaXY+CgkJCTxkaXYgc3R5bGU9
ImZvbnQtZmFtaWx5OkNvdXJpZXIgQm9sZDtmb250LXNpemU6MjVweCI+CgkJCSAgICAgICAgIDwh
LS0gMTIzc3F3ZXJ0IFZBTFVFIEJFTE9XIC0tPgoJCQkJPGNlbnRlcj48bGFiZWw+PGJyPjEyM3Nx
d2VydDwvbGFiZWw+PC9wPgogICAgICAgICAgICAgICAgPC9kaXY+CiAgICAgICAgICAgIDxkaXYg
c3R5bGU9ImZvbnQtZmFtaWx5OkFyaWFsIGJsYWNrIj4KICAgICAgICAgICAgICAgIDxsYWJlbD4m
bmJzcDtRUiBjb2RlPC9sYWJlbD4KICAgICAgICAgICAgICAgIDxkaXY+CiAgICAgICAgICAgICAg
ICAgICAgICAgICAgPCEtLSBRUiBDT0RFIFBBVEggLS0+CiAgICAgICAgICAgICAgICAgICAgPGNl
bnRlcj48aW1nIHNyYz0iY2lkOnFyY29kZS5wbmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIiBz
dHlsZT0iYm9yZGVyOjEwcHggc29saWQgYmxhY2siPjwvY2VudGVyPgogICAgICAgICAgICAgICAg
PC9kaXY+CiAgICAgICAgICAgICAgICA8L2Zvcm0+CiAgICAgICAgICAgIDwvZGl2PgogICAgICAg
IDwvZGl2PgogICAgPC9kaXY+CgogICAgPCEtLSBGb290ZXIgLS0+CiAgICA8Zm9vdGVyIHN0eWxl
PSJmb250LWZhbWlseTpDb3VyaWVyO2NvbG9yOnJnYigxMSw3NSwxMTkpIj4KCTxkaXYgc3R5bGU9
ImZvbnQtZmFtaWx5OkNhbGlicmk7Zm9udC1zaXplOjE1cHgiPgogICAgICAgIDxicj4mbmJzcDs8
aT4xLiBVc2UgUVJDb2RlIGluIGF0dGFjaG1lbnQgaWYgbm90IGRpc3BsYXllZCBwcm9wZXJseTxp
PgogICAgICAgIDxicj4mbmJzcDs8aT4yLiBQbGVhc2UgdXNlIHRkbGFiIGRvbWFpbjxpPgogICAg
ICAgIDxicj4mbmJzcDs8aT4zLiBEb2N1bWVudCBHZW5lcmF0ZWQgYnkgdGRsYWIuY2E8aT4KCQk8
ZGl2PgogICAgPC9mb290ZXI+Cgo8L2JvZHk+Cgo8L2h0bWw+Cg==

--===============1669362824009595437==--

--===============1156468728444507130==--

A snippset of code that works on both Exchange and Gmail, it has only 1 layer and sends images twice:

import os
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.image import MIMEImage
from email.mime.base import MIMEBase
from email import encoders

email = os.environ["ADMIN_EMAIL"]
password = os.environ["ADMIN_PASSWORD"]
send_to_email = os.environ["USER_EMAIL"]
subject = 'VPN Account Information'
file_location = '/tmp/qrcode.png'
with open('./vpn_card.html', 'r') as f:
  file = f.read()
# Replace the target string
file = file.replace('USERNAME', os.environ["USERNAME"])
file = file.replace('PASSWORD', os.environ["PASSWORD"])
# Write the file out again
with open('./vpn_card.html', 'w') as f:
  f.write(file)

messageHTML = open("./vpn_card.html", "r").read()
messagePlain = 'Your account has been created along with your VPN 2nd-factor QRCode'

msg = MIMEMultipart('alternative')
msg['From'] = email
msg['To'] = send_to_email
msg['Subject'] = subject
#attachment file
filename = os.path.basename(file_location)
attachment = open(file_location, "rb")
part = MIMEBase('application', 'octet-stream')
part.set_payload((attachment).read())
encoders.encode_base64(part)
part.add_header('Content-Disposition', "attachment; filename= %s" % filename)

# read image attachment
fp = open(file_location, 'rb')
msgImage = MIMEImage(fp.read())
fp.close()
msgImage.add_header('Content-ID', '<qrcode.png>')

msg.attach(MIMEText(messagePlain, 'plain'))
msg.attach(MIMEText(messageHTML, 'html'))
msg.attach(msgImage)
msg.attach(part)
print (msg)

server = smtplib.SMTP(os.environ["MAIL_SERVER"], 25)
server.connect(os.environ["MAIL_SERVER"],465)
server.starttls()
server.login(email, password)
text = msg.as_string()
server.sendmail(email, send_to_email, text)
server.quit()

Reference: Email layout explaination