generate and send mail with python: tutorial

aspineux aspineux at gmail.com
Thu Aug 11 11:07:09 EDT 2011


Hi I have written a tutorial about how to generate and send emails
with python.

You can find it here http://blog.magiksys.net/generate-and-send-mail-with-python-tutorial

And here is the content. Enjoy.

This article follows two other articles (1, 2) about how to parse
emails in Python.
These articles describe very well mail usages and rules and can be
helpful for non Python developer too.

The goal here is to generate a valid email including internationalized
content, attachments and even inline images, and send it to a SMTP
server.

The procedure can be achieved in 3 steps :

    compose the body of the email.
    compose the header of the email.
    send the email to a SMTP server

At the end you will find the sources.

The first rule to keep in mind all the time, is that emails are 7bits
only, they can contains us-ascii characters only ! A lot of RFCs
define rules to encode non us-ascii characters when they are required:
RFC2047 to encode headers, RFC2045 for encoding body using the MIME
format or RFC2231 for MIME parameters like the attachment filename.
The mail header

The header is composed of lines having a name and a value. RFC2047 let
you use quoted or binary encoding to encode non us-ascii characters in
it.
For example:

Subject: Courrier électronique en Français

become using the quoted format

Subject: =?iso-8859-1?q?Courrier_=E8lectronique_en_Fran=E7ais?=

or using the binary one.

Subject: =?iso-8859-1?b?Q291cnJpZXIg6GxlY3Ryb25pcXVlIGVuIEZyYW7nYWlz?=

Here the quoted format is readable and shorter. Python
email.header.Header object generates user friendly header by choosing
the quoted format when the encoding shares characters with us-ascii,
and the binary one for encoding like the Chinese big5 that requires
the encoding of all characters.

>>> str(Header(u'Courrier \xe8lectronique en Fran\xe7ais', 'iso-8859-1'))
'=?iso-8859-1?q?Courrier_=E8lectronique_en_Fran=E7ais?='

Header values have different types (text, date, address, ...) that all
require different encoding rules. For example, in an address like :

Sender: Alain Spineux <alain.spineux at gmail.com>

The email part <alain.spineux at gmail.com> has to be in us-ascii and
cannot be encoded. The name part cannot contains some special
characters without quoting : []\()<>@,:;".
We can easily understand why "<>" are in the list, others have all
their own story.

For example, the use of the "Dr." prefix requires to quote the name
because of the '.':

Sender: "Dr. Alain Spineux" <alain.spineux at gmail.com>

For a name with non us-ascii characters like (look at the "ï" in
Alaïn), the name must be encoded.

Sender: Dr. Alïan Spineux <alain.spineux at gmail.com>

must be written :

Sender: =?iso-8859-1?q?Dr=2E_Ala=EFn_Spineux?=
<alain.spineux at gmail.com>

Notice that the '.' in the prefix is replaced by "=2E", because Header
preventively encode all non alpha or digit characters to match the
most restrictive header rules.

The Python function email.utils.formataddr() quotes the special
characters but don't encode non us-ascii characters. On the other
hand, email.header.Header can encode non us-ascii characters but
ignore all specials rule about address encoding.
Let see how both work:

>>> email.Utils.formataddr(('Alain Spineux', 'alain.spineux at gmail.com'))
'Alain Spineux <alain.spineux at gmail.com>'

This is a valid header value for a To: field

>>> str(Header('Dr. Alain Spineux <alain.spineux at gmail.com>'))
'Dr. Alain Spineux <alain.spineux at gmail.com>'

Here the '.' should be escaped like these 13 characters: []\()<>@,:;".

>>> email.Utils.formataddr(('"Alain" Spineux', 'alain.spineux at gmail.com'))
'"\\"Alain\\" Spineux" <alain.spineux at gmail.com>'

Here '"' is escaped using '\', this is fine.

>>> email.Utils.formataddr((u'Ala\xefn Spineux', 'alain.spineux at gmail.com'))
u'Ala\xefn Spineux <alain.spineux at gmail.com>'

formataddr() don't handle non us-ascii string, this must be done by
Header object

>>> str(Header(email.Utils.formataddr((u'Ala\xefn Spineux', 'alain.spineux at gmail.com'))))
'=?utf-8?q?Ala=C3=AFn_Spineux_=3Calain=2Espineux=40gmail=2Ecom=3E?='

This is not valid because the address is also encoded and an old or
some recent MUA will't handle this. The good form here is :

=?utf-8?q?Ala=C3=AFn_Spineux?= <alain.spineux at gmail.com>'

Function format_addresses(addresses, header_name=None, charset=None)
handle carefully the encoding of the addresses.

>>> str(format_addresses([ (u'Ala\xefn Spineux', 'alain.spineux at gmail.com'), ('John', 'John at smith.com'), ], 'to', 'iso-8859-1'))
'=?iso-8859-1?q?Ala=EFn_Spineux?= <alain.spineux at gmail.com> ,\n John
<John at smith.com>'

Bytes and unicode string can be mixed. Addresses must always be us-
ascii. Byte string must be encoded using charset or be us-ascii.
Unicode strings that cannot be encoded using charset will be encoded
using utf-8 automatically by Header.

For dates, use the email.utils.formatdate() this way.

>>> email.utils.formatdate(time.time(), localtime=True)
'Wed, 10 Aug 2011 16:46:30 +0200'

The mail body

Depending of your need :

    text and/or html version of the message
    related inline images
    attached files

the structure of the MIME email may vary, but the general one is as
follow:

multipart/mixed
 |
 +-- multipart/related
 |    |
 |    +-- multipart/alternative
 |    |    |
 |    |    +-- text/plain
 |    |    +-- text/html
 |    |
 |    +-- image/gif
 |
 +-- application/msword

Un-needed parts will be removed by function gen_mail(text, html=None,
attachments=[], relateds=[]) regarding your parameters.

>>> print gen_mail(text=(u'Bonne journ\xe8e', 'iso-8859-1'), \
                   html=None, \
                   attachments=[ (u'Text attach
\xe8'.encode('iso-8859-1'), 'text', 'plain', 'filename.txt',
'iso-8859-1'), ] )
>From nobody Thu Aug 11 08:05:14 2011
Content-Type: multipart/mixed; boundary="===============0992984520=="
MIME-Version: 1.0

--===============0992984520==
Content-Type: text/plain; charset="iso-8859-1"
MIME-Version: 1.0
Content-Transfer-Encoding: quoted-printable

Bonne journ=E8e
--===============0992984520==
Content-Type: text/plain; charset="iso-8859-1"
MIME-Version: 1.0
Content-Transfer-Encoding: quoted-printable
Content-Disposition: attachment; filename="filename.txt"

Text attach=E8
--===============0992984520==--

text and html are tuple of the form (content, encoding).
Items of the attachments list can be tuples of the form (content,
maintype, subtype, filename, charset) or can be MIME object inheriting
from MIMEBase. If maintype is 'text', charset must match the encoding
of the content, or if content is unicode, charset will be used to
encode it. For other value of maintype, charset is not used.
relateds is similar to attachments but content is related to the
message in HTML to allow embedding of images or other contents.
filename is replaced by content_id that must match the cid: references
inside the HTML message.

Attachments can have non us-ascii filename, but this is badly
supported by some MUA and then discouraged. Anyway, if you want to use
non us-ascii filename, RFC2231 need the encoding but also the
language. Replace filename, by a tuple of the form (encoding,
language, filename), for example, use ('iso-8859-1', 'fr', u'r
\xe9pertoir.png'.encode('iso-8859-1')) instead of 'filename.txt'.

The attached source code provide more samples. Look carefully in it
how I encode or not the unicode string and content before to use them.
Send your email

The Python smtplib library handles SSL, STARTLS, and authentication,
all you need to connect to any SMTP server. The library also reports
all errors carefully.

The send_mail(smtp_host, sender, recipients, subject, msg,
default_charset, cc=[], bcc=[], smtp_port=25, smtp_mode='normal',
smtp_login=None, smtp_password=None, message_id_string=None) function
first fill in the header about sender and recipients using
format_addresses() function, and send the email to the SMTP using the
protocols and credentials you have choose with smtp_* variables.
sender is a tuples and recipients, cc, bcc are list of tuple of the
form [ (name, address), .. ] like expected by format_addresses().
default_charset will be used as default encoding when generating the
email (to encode unicode string), and as default encoding for byte
string.
subject is the subject of the email.
msg is a MIME object like returned by gen_mail().
smtp_mode can be 'normal', 'ssl' or 'tls'.

For example if you want to use your GMAIL account to send your emails,
use this setup:

smtp_host='smtp.gmail.com'
smtp_port=587
smtp_mode='tls'
smtp_login='your.address at gmail.com'
smtp_password='yourpassword'
sender=('Your Name', smtp_login)

Most of the time you will just need to specify smtp_host
Source

#!/bin/env/python
#
# sendmail.py
# (c) Alain Spineux <alain.spineux at gmail.com>
# http://blog.magiksys.net/sending-email-using-python-tutorial
# Released under GPL
#
import os, sys
import time
import base64
import smtplib
import email
import email.header
import email.utils
import email.mime
import email.mime.base
import email.mime.text
import email.mime.image
import email.mime.multipart

def format_addresses(addresses, header_name=None, charset=None):
    """This is an extension of email.utils.formataddr.
       Function expect a list of addresses [ ('name',
'name at domain'), ...].
       The len(header_name) is used to limit first line length.
       The function mix the use Header(), formataddr() and check for
'us-ascii'
       string to have valid and friendly 'address' header.
       If one 'name' is not unicode string, then it must encoded using
'charset',
       Header will use 'charset' to decode it.
       Unicode string will be encoded following the "Header" rules : (
       try first using ascii, then 'charset', then 'uft8')
       'name at address' is supposed to be pure us-ascii, it can be
unicode
       string or not (but cannot contains non us-ascii)

       In short Header() ignore syntax rules about 'address' field,
       and formataddr() ignore encoding of non us-ascci chars.
    """
    header=email.header.Header(charset=charset,
header_name=header_name)
    for i, (name, addr) in enumerate(addresses):
        if i!=0:
            # add separator between addresses
            header.append(',', charset='us-ascii')
        # check if address name is a unicode or byte string in "pure"
us-ascii
        try:
            if isinstance(name, unicode):
                # convert name in byte string
                name=name.encode('us-ascii')
            else:
                # check id byte string contains only us-ascii chars
                name.decode('us-ascii')
        except UnicodeError:
            # Header will use "RFC2047" to encode the address name
            # if name is byte string, charset will be used to decode
it first
            header.append(name)
            # here us-ascii must be used and not default 'charset'
            header.append('<%s>' % (addr,), charset='us-ascii')
        else:
            # name is a us-ascii byte string, i can use formataddr
            formated_addr=email.utils.formataddr((name, addr))
            # us-ascii must be used and not default 'charset'
            header.append(formated_addr, charset='us-ascii')

    return header


def gen_mail(text, html=None, attachments=[], relateds=[]):
    """generate the core of the email message.
    text=(encoded_content, encoding)
    html=(encoded_content, encoding)
    attachments=[(data, maintype, subtype, filename, charset), ..]
     if maintype is 'text', data lmust be encoded using charset
     filename can be us-ascii or (charset, lang, encoded_filename)
     where encoded_filename is encoded into charset, lang can be empty
but
     usually the language related to the charset.
    relateds=[(data, maintype, subtype, content_id, charset), ..]
     idem attachment above, but content_id is related to the "CID"
reference
     in the html version of the message.
    """

    main=text_part=html_part=None
    if text:
        content, charset=text
        main=text_part=email.mime.text.MIMEText(content, 'plain',
charset)

    if html:
        content, charset=html
        main=html_part=email.mime.text.MIMEText(content, 'html',
charset)

    if not text_part and not html_part:
        main=text_part=email.mime.text.MIMEText('', 'plain', 'us-
ascii')
    elif text_part and html_part:
        # need to create a multipart/alternative to include text and
html version
        main=email.mime.multipart.MIMEMultipart('alternative', None,
[text_part, html_part])

    if relateds:
        related=email.mime.multipart.MIMEMultipart('related')
        related.attach(main)
        for part in relateds:
            if not isinstance(part, email.mime.base.MIMEBase):
                data, maintype, subtype, content_id, charset=part
                if (maintype=='text'):
                    part=email.mime.text.MIMEText(data, subtype,
charset)
                else:
                    part=email.mime.base.MIMEBase(maintype, subtype)
                    part.set_payload(data)
                    email.Encoders.encode_base64(part)
                part.add_header('Content-ID', '<'+content_id+'>')
                part.add_header('Content-Disposition', 'inline')
            related.attach(part)
        main=related

    if attachments:
        mixed=email.mime.multipart.MIMEMultipart('mixed')
        mixed.attach(main)
        for part in attachments:
            if not isinstance(part, email.mime.base.MIMEBase):
                data, maintype, subtype, filename, charset=part
                if (maintype=='text'):
                    part=email.mime.text.MIMEText(data, subtype,
charset)
                else:
                    part=email.mime.base.MIMEBase(maintype, subtype)
                    part.set_payload(data)
                    email.Encoders.encode_base64(part)
                part.add_header('Content-Disposition', 'attachment',
filename=filename)
            mixed.attach(part)
        main=mixed

    return main

def send_mail(smtp_host, sender, recipients, subject, msg,
default_charset, cc=[], bcc=[], smtp_port=25, smtp_mode='normal',
smtp_login=None, smtp_password=None, message_id_string=None):
    """

    """

    mail_from=sender[1]
    rcpt_to=map(lambda x:x[1], recipients)
    rcpt_to.extend(map(lambda x:x[1], cc))
    rcpt_to.extend(map(lambda x:x[1], bcc))

    msg['From'] = format_addresses([ sender, ], header_name='from',
charset=default_charset)
    msg['To'] = format_addresses(recipients, header_name='to',
charset=default_charset)
    msg['Cc'] = format_addresses(cc, header_name='cc',
charset=default_charset)
    msg['Subject'] = email.header.Header(subject, default_charset)
    utc_from_epoch=time.time()
    msg['Date'] = email.utils.formatdate(utc_from_epoch,
localtime=True)
    msg['Messsage-Id'] =email.utils.make_msgid(message_id_string)

    # Send the message
    errmsg=''
    failed_addresses=[]
    try:
        if smtp_mode=='ssl':
            smtp=smtplib.SMTP_SSL(smtp_host, smtp_port)
        else:
            smtp=smtplib.SMTP(smtp_host, smtp_port)
            if smtp_mode=='tls':
                smtp.starttls()

        if smtp_login and smtp_password:
            # login and password must be encoded
            # because HMAC used in CRAM_MD5 require non unicode string
            smtp.login(smtp_login.encode('utf-8'),
smtp_password.encode('utf-8'))

        ret=smtp.sendmail(mail_from, rcpt_to, msg.as_string())
        smtp.quit()
    except (socket.error, ), e:
        errmsg='server %s:%s not responding: %s' % (smtp_host,
smtp_port, e)
    except smtplib.SMTPAuthenticationError, e:
        errmsg='authentication error: %s' % (e, )
    except smtplib.SMTPRecipientsRefused, e:
        # code, errmsg=e.recipients[recipient_addr]
        errmsg='recipients refused: '+', '.join(e.recipients.keys())
    except smtplib.SMTPSenderRefused, e:
        # e.sender, e.smtp_code, e.smtp_error
        errmsg='sender refused: %s' % (e.sender, )
    except smtplib.SMTPDataError, e:
        errmsg='SMTP protocol mismatch: %s' % (e, )
    except smtplib.SMTPHeloError, e:
        errmsg="server didn't reply properly to the HELO greeting: %s"
% (e, )
    except smtplib.SMTPException, e:
        errmsg='SMTP error: %s' % (e, )
    except Exception, e:
        errmsg=str(e)
    else:
        if ret:
            failed_addresses=ret.keys()
            errmsg='recipients refused: '+', '.join(failed_addresses)

    return msg, errmsg, failed_addresses

And how to use it :

smtp_host='max'
smtp_port=25

if False:
    smtp_host='smtp.gmail.com'
    smtp_port='587'
    smtp_login='your.addresse at gmail.com'
    smtp_passwd='your.password'

sender=(u'Ala\xefn Spineux', 'alain.spineux at gmail.com')
sender=(u'Alain Spineux', u'alain.spineux at gmail.com')

root_addr='root at max.asxnet.loc'
recipients=[ ('Alain Spineux', root_addr),
#             (u'Alain Spineux', root_addr),
#             ('Dr. Alain Sp<i>neux', root_addr),
#             (u'Dr. Alain Sp<i>neux', root_addr),
#             (u'Dr. Ala\xefn Spineux', root_addr),
#             (u'Dr. Ala\xefn Spineux', root_addr),
#             ('us_ascii_name_with_a_space_some_where_in_the_middle
to_allow_python_Header._split()_to_split_according_RFC2822',
root_addr),
#             (u'This-is-a-very-long-unicode-name-with-one-non-ascii-
char-\xf4-to-force-Header()-to-use-RFC2047-encoding-and-split-in-multi-
line', root_addr),
#
('this_line_is_too_long_and_dont_have_any_white_space_to_allow_Header._split()_to_split_according_RFC2822',
root_addr),
#             ('Alain Spineux', root_addr),
              ]

smile_png=base64.b64decode(
"""iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOBAMAAADtZjDiAAAAMFBMVEUQEAhaUjlaWlp7e3uMezGU
hDGcnJy1lCnGvVretTnn5+/3pSn33mP355T39+//
75SdwkyMAAAACXBIWXMAAA7EAAAOxAGVKw4b
AAAAB3RJTUUH2wcJDxEjgefAiQAAAAd0RVh0QXV0aG9yAKmuzEgAAAAMdEVYdERlc2NyaXB0aW9u
ABMJISMAAAAKdEVYdENvcHlyaWdodACsD8w6AAAADnRFWHRDcmVhdGlvbiB0aW1lADX3DwkAAAAJ
dEVYdFNvZnR3YXJlAF1w/
zoAAAALdEVYdERpc2NsYWltZXIAt8C0jwAAAAh0RVh0V2FybmluZwDA
G+aHAAAAB3RFWHRTb3VyY2UA9f+D6wAAAAh0RVh0Q29tbWVudAD2zJa/
AAAABnRFWHRUaXRsZQCo
7tInAAAAaElEQVR4nGNYsXv3zt27TzHcPup6XDBmDsOeBvYzLTynGfacuHfm/
x8gfS7tbtobEM3w
n2E9kP5n9N/oPZA+//7PP5D8GSCYA6RPzjlzEkSfmTlz
+xkgffbkzDlAuvsMWAHDmt0g0AUAmyNE
wLAIvcgAAAAASUVORK5CYII=
""")
angry_gif=base64.b64decode(
"""R0lGODlhDgAOALMAAAwMCYAAAACAAKaCIwAAgIAAgACAgPbTfoR/YP8AAAD/AAAA//
rMUf8A/wD/
//Tw5CH5BAAAAAAALAAAAAAOAA4AgwwMCYAAAACAAKaCIwAAgIAAgACAgPbTfoR/
YP8AAAD/AAAA
//rMUf8A/wD///Tw5AQ28B1Gqz3S6jop2sxnAYNGaghAHirQUZh6sEDGPQgy5/b9UI
+eZkAkghhG
ZPLIbMKcDMwLhIkAADs=
""")
pingu_png=base64.b64decode(
"""iVBORw0KGgoAAAANSUhEUgAAABoAAAATBAMAAAB8awA1AAAAMFBMVEUQGCE5OUJKa3tSUlJSrdZj
xu9rWjl7e4SljDGlnHutnFK9vbXGxsbWrTHW1tbv7+88a/
HUAAAACXBIWXMAAA7EAAAOxAGVKw4b
AAAAB3RJTUUH2wgJDw8mp5ycCAAAAAd0RVh0QXV0aG9yAKmuzEgAAAAMdEVYdERlc2NyaXB0aW9u
ABMJISMAAAAKdEVYdENvcHlyaWdodACsD8w6AAAADnRFWHRDcmVhdGlvbiB0aW1lADX3DwkAAAAJ
dEVYdFNvZnR3YXJlAF1w/
zoAAAALdEVYdERpc2NsYWltZXIAt8C0jwAAAAh0RVh0V2FybmluZwDA
G+aHAAAAB3RFWHRTb3VyY2UA9f+D6wAAAAh0RVh0Q29tbWVudAD2zJa/
AAAABnRFWHRUaXRsZQCo
7tInAAAA0klEQVR4nE2OsYrCUBBFJ42woOhuq43f4IfYmD5fsO2yWNhbCFZ21ovgFyQYG1FISCxt
Xi8k3KnFx8xOTON0Z86d4ZKKiNowM5QEUD6FU9uCSWwpYThrSQuj2fjLso2DqB9OBqqvJFiVllHa
usJedty1NFe2brRbs7ny5aIP8dSXukmyUABQ0CR9AU9d1IOO1EZgjg7n+XEBpOQP0E/
rUz2Rlw09
Amte7bdVSs/s7py5i1vFRsnFuW+gdysSu4vzv9vm57faJuY0ywFhAFmupO/zDzDcxlhVE/
gbAAAA
AElFTkSuQmCC
""")

text_utf8="""This is the the text part.
With a related picture: cid:smile.png
and related document: cid:related.txt

Bonne journ\xc3\xa9ee.
"""
utext=u"""This is the the text part.
With a related picture: cid:smile.png
and related document: cid:related.txt
Bonne journ\xe9e.
"""
data_text=u'Text en Fran\xe7ais'
related_text=u'Document relatif en Fran\xe7ais'

html="""<html><body>
This is the html part with a related picture: <img
src="cid:smile.png" />
and related document: <a href="cid:related.txt">here</a><br>
Bonne journée.
</body></html>
"""
relateds=[ (smile_png, 'image', 'png', 'smile.png', None),
           (related_text.encode('iso-8859-1'), 'text', 'plain',
'related.txt', 'iso-8859-1'),
          ]

pingu_att=email.mime.image.MIMEImage(pingu_png, 'png')
pingu_att.add_header('Content-Disposition', 'attachment',
filename=('iso-8859-1', 'fr', u'ping\xfc.png'.encode('iso-8859-1')))

pingu_att2=email.mime.image.MIMEImage(pingu_png, 'png')
pingu_att2.add_header('Content-Disposition', 'attachment',
filename='pingu.png')

attachments=[ (angry_gif, 'image', 'gif', ('iso-8859-1', 'fr',
u'\xe4ngry.gif'.encode('iso-8859-1')), None),
              (angry_gif, 'image', 'gif', 'angry.gif', None),

              (data_text.encode('iso-8859-1'), 'text', 'plain',
'document.txt', 'iso-8859-1'),
              pingu_att,
              pingu_att2]

mail=gen_mail((utext.encode('iso-8859-1'), 'iso-8859-1'), (html, 'us-
ascii'), attachments, relateds)

msg, errmsg, failed_addresses=send_mail(smtp_host, \
          sender, \
          recipients, \
          u'My Subject', \
          mail, \
          default_charset='iso-8859-1',
          cc=[('Gama', root_addr), ],
          bcc=[('Colombus', root_addr), ],
          smtp_port=smtp_port,
          )

print msg
print errmsg
print failed_addresses





More information about the Python-list mailing list