开源项目源码解读--sender,小巧易用的发送邮件模块

1. sender

sender是一个微型的邮件发送模块,作者自述是受到了Flask-Mail的启发,实质上是对python内置模块smtplib和email模块的封装,提供了更加容易使用的接口。学习此项目,我认为可以有如下收获:

  1. 更深入理解和掌握原生模块smtplib和email的使用
  2. 学习使用面向对象思想封装原生模块,提供更好的使用体验
  3. 了解如何制作单文件的安装包和制作兼容python2和3的安装包

项目地址: https://github.com/fengsp/sender

2. 制作安装包

2.1 兼容py2和py3的安装包

sender项目同时支持python2.7和python3,除了在源码里针对python2和3差异化的部分做兼容外,必须在安装包制作上做相应的配置,否则生成的安装包只能支持2或者3。

除了setup.py文件需要编写外,还需要编写setup.cfg,文件内容:

[wheel]
universal = 1

这表示生成的whl安装包将同时支持python2和python3,安装包的名称是:sender-0.3-py2.py3-none-any.whl,关键就在于安装包的名称里有py2和py3字样。如果只有py3,那么只能在python3环境里安装,如果是在python2环境里,使用pip指定安装版本为0.3,pip会提示找不到合适的版本。

整个项目所有的源码都在sender.py文件中,在setup.py文件中,通过配置py_modules来设置需要封入安装包的源码

py_modules= ['sender',],

2.2 自动化管理

实现自动化管理,需要编写Makefile文件,这是一个很古老的技术了,可以追溯到上世纪70年代,不过至今仍广泛使用,如果你对c和c++了解一点的话就能明白什么是宝刀不老。

sender项目里的Makefile内容如下

all: clean-pyc test

test:
	python test_sender.py

tox-test:
	tox

clean-pyc:
	find . -name '*.pyc' -exec rm -f {} +
	find . -name '*.pyo' -exec rm -f {} +
	find . -name '*~' -exec rm -f {} +

lines:
	find . -name "*.py"|xargs cat|wc -l

release:
	python setup.py register
	python setup.py sdist upload
	python setup.py bdist_wheel upload

使用时,可以指定需要执行的命令,比如

make lines      # 查看脚本的代码函数
make clean-pyc  # 去除pyc,pyo文件
make release    # 发布程序

应当说,这是一种比较好的实现python项目自动化管理的手段和方式,将常用的指令写在Makefile文件中,随时可以查看和执行。

3. 源码中类的介绍

该源码中,主要有3个大类,分别是Mail, Connection 和 Message, Message负责构建邮件内容,Connection负责建立与邮件服务器的连接,Mail负责提供给使用者调用接口。

3.1 一个简单的示例如

from sender import Mail

mail = Mail(host='smtp.163.com',
            username='xigongda200608',
            password='密码',
            port=25)

mail.send_message("Hello", fromaddr="xigongda200608@163.com",
                  to="to@163.com", body="Hello world!")

由于邮件的内容很简单,因此并没有使用Message来创建邮件内容对象,在send_message方法里,会自行创建

def send_message(self, *args, **kwargs):
    """Shortcut for send.
    """
    self.send(Message(*args, **kwargs))

在send方法里,会创建与邮件服务器之间的连接

    @property
    def connection(self):
        """Open one connection to the SMTP server.
        """
        return Connection(self)

    def send(self, message_or_messages):
        """Sends a single messsage or multiple messages.
        :param message_or_messages: one message instance or one iterable of
                                    message instances.
        """
        try:
            messages = iter(message_or_messages)
        except TypeError:
            messages = [message_or_messages]

        with self.connection as c:
            for message in messages:
                if self.fromaddr and not message.fromaddr:
                    message.fromaddr = self.fromaddr
                message.validate()
                c.send(message)

3.2 Connection 实现了上下文管理器

send方法可一次发送多封邮件,使用的是同一个连接,这里有一点值得大家来学习,Connection类实现了__enter__ 和 __exit__ 方法,实现这两个方法的类就是上下文管理器。你可以使用with语句进入上下文环境,退出时,上下文管理器自动的进行清理工作

def __enter__(self):
    if self.mail.use_ssl:
        server = smtplib.SMTP_SSL(self.mail.host, self.mail.port)
    else:
        server = smtplib.SMTP(self.mail.host, self.mail.port)

    # Set the debug output level
    if self.mail.debug_level is not None:
        server.set_debuglevel(int(self.mail.debug_level))

    if self.mail.use_tls:
        server.starttls()

    if self.mail.username and self.mail.password:
        server.login(self.mail.username, self.mail.password)

    self.server = server

    return self

def __exit__(self, exc_type, exc_value, exc_tb):
    self.server.quit()

__enter__方法执行时,创建SMTP对象sever, 随后进行登录,__exit__方法执行时,server.quit方法被执行,退出登录。

3.3 Message

3.3.1 MIMEText 与 MIMEMultipart

MIMEText 与 MIMEMultipart 是两种不同的MIME邮件体类型,MIMEText是纯文本,MIMEMultipart是超文本,如果你的邮件里只有文字,那么使用MIMEText即可,如果邮件里有附件,或者想要发送html作为邮件内容,则需要使用MIMEMultipart。

想要发送html邮件,那么需要提供html的源码,注意,一定是源码,而不是html文件。

源码里有一段

msg = MIMEMultipart()
alternative = MIMEMultipart('alternative')
alternative.attach(MIMEText(self.body, 'plain', self.charset))
alternative.attach(MIMEText(self.html, 'html', self.charset))
msg.attach(alternative)

本意是创建一个alternative类型的邮件体,支持纯文本与超文本共存,但我在实践中发现,html的内容可以正常显示,纯文本内容不能同时显示。

3.3.2 添加附件

from sender import Mail
from sender import Attachment

mail = Mail(host='smtp.163.com',
            username='xigongda200608',
            password='密码',
            port=25)

with open('pig.jpg', 'rb')as f:
    attachment = Attachment("pig.jpg", "image/jpeg", f.read())

mail.send_message("Hello", fromaddr="xigongda200608@163.com",
                  to="to@163.com", body="Hello world!",
                  html='<b>Hello</b>',
                  attachments=[attachment])

Message类中最重要的方法是as_string, 该方法首先要创建一个MIMEText或者一个MIMEMultipart对象msg,附件的对象需要通过使用attach方法添加到msg对象中。

添加附件,需要创建MIMEBase类型的对象,步骤是比较固定的

f = MIMEBase(*attachment.content_type.split('/'))   # 指定maintype 和subtype
f.set_payload(attachment.data)      # 设置载荷
encode_base64(f)                    # base64编码
if attachment.filename is None:
    filename = str(None)
else:
    filename = force_text(attachment.filename, self.charset)

f.add_header('Content-Disposition', attachment.disposition,
                     filename=filename)         # 设置header
for key, value in attachment.headers.items():
    f.add_header(key, value)
msg.attach(f)

源码里有一段使用ascii 进行编码的部分被我去掉了,如果不去掉,中文名称的附件会显示为乱码。

3.3.3 在html里添加图片

sender库并没有支持该功能,以下内容已经超出了源码的解读范围,权当是对技术问题的一个探讨。

在html添加图片,有两种方法,一种是把图片进行base64编码,放到html中,另一种是添加MIMEImage,先来看第一种

import base64
from sender import Mail
from sender import Attachment

mail = Mail(host='smtp.163.com',
            username='xigongda200608',
            password='密码',
            port=25)

with open('pig.jpg', 'rb')as f:
    base64_data = base64.b64encode(f.read())
    base64_data = base64_data.decode('utf-8')  # 转成字符串

html = '<html><body><img src="data:image/jpg;base64,%s" alt="image1"></body></html>' % base64_data
mail.send_message("Hello", fromaddr="xigongda200608@163.com",
                  to="to@163.com", body="Hello world!",
                  html=html)

第二种,需要添加MIMEImage对象,由于sender模块并不支持,我修改了源码
第一步,增加HtmlImage类

from email.mime.image import MIMEImage
class HtmlImage():
    def __init__(self, data, id):
        self.data = data
        self.id = id

第二步,Message初始化函数里增加htmlimages=None参数

    def __init__(self, subject=None, to=None, body=None, html=None,
                 fromaddr=None, cc=None, bcc=None, attachments=None,
                 reply_to=None, date=None, charset='utf-8',
                 extra_headers=None, mail_options=None, rcpt_options=None, htmlimages=None):
        self.htmlimages = htmlimages

第三步,修改as_string方法

if self.htmlimages:
    for htmlimage in self.htmlimages:
        image = MIMEImage(htmlimage.data)
        image.add_header('Content-ID', htmlimage.id)
        msg.attach(image)
        
return msg.as_string()

主程序

from sender import Mail
from sender import Attachment, HtmlImage

mail = Mail(host='smtp.163.com',
            username='xigongda200608',
            password='密码',
            port=25)

with open('pig.jpg', 'rb')as f:
    data = f.read()
    html_image = HtmlImage(data, 'insert_image_to_html')

html = '<html><body><img src="cid:insert_image_to_html" alt="image1"></body></html>'
mail.send_message("Hello", fromaddr="xigongda200608@163.com",
                  to="to@163.com", body="Hello world!",
                  html=html, htmlimages=[html_image])

4. 收获

  1. 创建Mail对象时,虽然提供了用户名和密码,但不会在这一步创建与邮件服务器的连接,而是等到具体发送邮件时才会创建连接,这种延迟创建连接的技术广泛使用,redis,mysql等数据库的python客户端模块都是如此。
  2. 掌握了制作单个python文件安装包和兼容py2和py3安装包的方法
  3. 学会了如何用Makefile实现python项目维护的自动化
  4. 接触到了上下文管理器的具体应用场景
  5. 掌握了如何发送有附件的邮件
  6. 掌握了如何发送html内容的邮件,以及如何在html添加图片

扫描关注, 与我技术互动

QQ交流群: 211426309

加入知识星球, 每天收获更多精彩内容

分享日常研究的python技术和遇到的问题及解决方案