Python3批量发送邮件,支持smpt的ssl验证,支持163和outlook邮箱,可发送html格式和附件,使用asyncio和线程池实现并发并行

最近我们的服务需要批量发送邮件,先后试过了163邮箱和outlook企业邮箱,还是后者比较稳定。写完以后把代码整理成了一个脚本,如下所示,喜欢的客官可以拿去用了,有问题欢迎流言交流。

import io
import ssl
import uuid
import time
import json
import redis
import base64
import pickle
import django
import zipfile
import smtplib
import logging
import traceback
import mimetypes
from random import choice
from threading import Thread
from email.header import Header
from django.conf import settings
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from email.encoders import encode_base64
from email.headerregistry import Address
from email.mime.multipart import MIMEMultipart
from concurrent.futures import ThreadPoolExecutor
from django.template.base import Template, Context
from email.utils import formatdate, formataddr, make_msgid, parseaddr
from email.errors import InvalidHeaderDefect, NonASCIILocalPartDefect
from asyncio import run_coroutine_threadsafe, ensure_future, gather, get_event_loop


class Email:
    def __init__(self, title, message, receivers, sender_name, sender_host='', charset='utf-8', files=[]):
        '''
            sender_host是发件人邮箱地址
            填空值时收件人看到的是发件用户的邮箱账号
            填非空值时收件人看到的是填写的地址,以及由发件用户的邮箱账号代发的提示
        '''
        self.title = title
        self.message = message
        self.receivers = receivers
        self.sender_name = sender_name
        self.sender_host = sender_host
        self.charset = charset
        self.files = files


class UnknownSendError(smtplib.SMTPException):
    def __init__(self, recipients):
        self.recipients = recipients
        self.args = (recipients,)


class ConnectionPool:  # 连接邮箱服务器的连接池
    def __init__(self, host='', port='', send_email_user='', send_email_password='', max_connections=0, use_ssl=False, use_smpt_ssl=False, connection_lifetime=60, re_helo_time=10):
        self.host = host  # 邮箱服务器的地址
        self.port = port  # 邮箱服务器的端口号
        self.send_email_user = send_email_user  # 发件用户的SMPT服务账号(收件人看到的发件地址)
        self.send_email_password = send_email_password  # 发件用户的SMPT服务账号的密码,注意是发件邮箱配置的SMPT服务的密码,不是发件邮箱登陆密码
        self.max_connections = max_connections  # 一个IP地址能够同时建立的连接数(连接池的大小),163邮箱为10个,outlook邮箱为20个
        self.use_ssl = use_ssl  # smtp服务是否开启了ssl验证
        self.use_smpt_ssl = use_smpt_ssl  # smtp服务是否开启了ssl加密传输
        self.connection_lifetime = connection_lifetime  # 连接的存活时间,到达这个时间后就替换掉该连接,一般不用配置
        self.re_helo_time = re_helo_time  # 连接的心跳时间间隔,每隔一定时间和邮箱服务器helo一下保证服务器不断开连接,一般不用配置
        self.connections = {}
        self.context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
        self.running = True

    def __create(self):  # 创建一个与邮箱服务器的连接
        while self.running:
            try:
                smpt_connect = smtplib.SMTP_SSL if self.use_smpt_ssl else smtplib.SMTP
                connection = smpt_connect(timeout=30, host=self.host, port=self.port)
                if self.use_ssl:
                    connection.starttls(context=self.context)
                connection.login(self.send_email_user, self.send_email_password)
                key = uuid.uuid1().hex
                self.connections[key] = [connection, time.time() + len(self.connections.keys())]
                if not self.running:
                    self.replace(key, connection)
                break
            # 异常处理根据需要自定义
            except Exception as e:
                try:
                    connection.quit()
                except:
                    pass
                if e.args[0] == 421:  # (421, b'Too many connections')
                    sleep_time = choice(range(5, 11))
                elif e.args[0] == 554:  # (554, b'IP<*****> in blacklist')
                    sleep_time = 1800
                else:
                    sleep_time = choice(range(5, 11))
                time.sleep(sleep_time)

    def __add(self):  # 非阻塞创建连接
        thread = Thread(target=self.__create)
        thread.setDaemon(True)
        thread.start()

    def __keep(self):  # 保证连接池的连接可用,定时与邮箱服务器握手,关闭并移除过期的连接
        last_check_helo = time.time()
        while self.running:
            if time.time() - last_check_helo >= self.re_helo_time:
                re_helo = True
                last_check_helo = time.time()
            else:
                re_helo = False
            connections = dict(self.connections.items())
            for key, connection_info in connections.items():
                connection, connection_time = connection_info
                if time.time() - connection_time >= self.connection_lifetime:
                    self.replace(key, connection)
                elif re_helo:
                    try:
                        connection.helo()
                    except:
                        self.replace(key, connection)
            time.sleep(1)

    def start(self):  # 启动连接池
        self.running = True
        threads = [Thread(target=self.__create) for index in range(self.max_connections)]
        for thread in threads:
            thread.setDaemon(True)
            thread.start()
        thread = Thread(target=self.__keep)
        thread.setDaemon(True)
        thread.start()

    def close(self):  # 关闭连接池
        self.running = False
        connections = dict(self.connections.items())
        while connections:
            for key, connection_info in connections.items():
                connection, connection_time = connection_info
                self.replace(key, connection)
            connections = dict(self.connections.items())

    def replace(self, key, connection):  # 关闭并替换一个连接
        try:
            connection.quit()
        except:
            pass
        try:
            self.connections.pop(key, None)
        except:
            pass
        if self.running:
            self.__add()

    def get(self):  # 从连接池获取一个可用的连接
        time_now = time.time()
        while self.running:
            try:
                connections = dict(self.connections.items())
                key = choice(list(connections.keys()))
                connection, connection_time = connections[key]
            except (IndexError, KeyError):
                if time.time() - time_now > 3:
                    return None, None
                else:
                    time.sleep(0.1)
                    continue
            if time.time() - connection_time >= self.connection_lifetime:
                if time.time() - time_now > 3:
                    return None, None
                else:
                    self.replace(key, connection)
                    continue
            try:
                connection.helo()
                return key, connection
            except:
                self.replace(key, connection)


class EmailServer:  # 使用线程独立运行的邮件发送服务
    def __init__(self, send_step=1, emails_list_key='', redis=None, max_workers=10, connection_pool_kwargs={}):
        self.emails_list_key = emails_list_key  # 邮件队列的key
        self.send_step = send_step  # 发送并发量大小,163邮箱每批次只能发送11封邮件,outlook邮箱为20封
        self.max_workers = max_workers  # 线程池的大小,即并行发送邮件的数量
        self.connection_pool = ConnectionPool(**connection_pool_kwargs)
        self.io_loop = get_event_loop()
        self.logging = logging.getLogger()
        self.redis = redis
        self.running = True

    def split_addr(self, addr, encoding):
        if '@' in addr:
            localpart, domain = addr.split('@', 1)
            try:
                localpart.encode('ascii')
            except UnicodeEncodeError:
                localpart = Header(localpart, encoding).encode()
            domain = domain.encode('idna').decode('ascii')
        else:
            localpart = Header(addr, encoding).encode()
            domain = ''
        return (localpart, domain)

    def sanitize_address(self, addr, encoding):
        if not isinstance(addr, tuple):
            addr = parseaddr(addr)
        nm, addr = addr
        localpart, domain = None, None
        nm = Header(nm, encoding).encode()
        try:
            addr.encode('ascii')
        except UnicodeEncodeError:  # IDN or non-ascii in the local part
            localpart, domain = self.split_addr(addr, encoding)
        if localpart and domain:
            address = Address(nm, username=localpart, domain=domain)
            return str(address)
        try:
            address = Address(nm, addr_spec=addr)
        except (InvalidHeaderDefect, NonASCIILocalPartDefect):
            localpart, domain = self.split_addr(addr, encoding)
            address = Address(nm, username=localpart, domain=domain)
        return str(address)

    def format_email(self, host_user, email, charset='utf-8', use_localtime=True):
        # use_localtime 是否使用本地时间,True使用本地时间(东8区),False使用标准世界时间
        from_email = self.sanitize_address(host_user, charset)
        recipients = [self.sanitize_address(receive, charset) for receive in email.receivers]
        if not from_email or not recipients:
            return ('', [], '')

        subtype = 'html' if email.message.strip().endswith('</html>') else 'plain'
        text_msg = MIMEText(email.message, subtype, email.charset)
        msg = MIMEMultipart()
        msg.attach(text_msg)

        for the_file in email.files:
            content_type, encoding = mimetypes.guess_type(the_file['name'])
            if content_type is None or encoding is not None:
                content_type = 'application/octet-stream'
            maintype, subtype = content_type.split('/', 1)
            file_msg = MIMEBase(maintype, subtype)
            file_msg.set_payload(base64.b64decode(the_file['content']))  # 附件内容解码
            encode_base64(file_msg)  # 文件内容编码
            file_msg['Content-Type'] = content_type
            # 中文附件名指定编码
            file_msg.add_header('Content-Disposition', 'attachment', filename=('utf-8', '', the_file['name']))
            msg.attach(file_msg)

        msg['Subject'] = email.title
        email_sender_host = email.sender_host or host_user
        msg['From'] = formataddr([email.sender_name, email_sender_host])
        msg['To'] = ', '.join(map(str, email.receivers))
        msg['Date'] = formatdate(localtime=use_localtime)
        msg['Message-ID'] = make_msgid()
        return from_email, recipients, msg.as_bytes()

    async def send_one_email(self, email):  # 发送一封邮件
        error_str, no_connection_error, retry_num = '', False, self.connection_pool.max_connections * 2
        for index in range(retry_num):
            try:
                try:
                    key, connection = self.connection_pool.get()
                    if connection:
                        from_email, recipients, message = self.format_email(connection.user, Email(**email))
                        if not recipients:
                            return '邮件发送失败,请检查收件人信息是否正确'
                        if not from_email:
                            return '邮件发送失败,请检查发件人信息是否正确'
                        senderrs = connection.sendmail(from_email, recipients, message)
                        if senderrs:
                            raise UnknownSendError(senderrs)
                        return True
                    elif index == retry_num - 1:
                        no_connection_error = True
                except Exception as e:
                    error_str = traceback.format_exc()
                    raise e
            # 异常处理根据需要自定义
            except smtplib.SMTPRecipientsRefused as e:
                self.logging.error('%s\nEmail info: %s' % (error_str, email))
                return '邮件发送失败,请检查收件人邮箱是否正确'
            except (smtplib.SMTPSenderRefused, smtplib.SMTPDataError, AttributeError, ValueError) as e:
                self.logging.error('%s\nEmail info: %s' % (error_str, email))
                self.connection_pool.replace(key, connection)
            except (ssl.SSLError, smtplib.SMTPServerDisconnected) as e:
                self.logging.error('%s\nEmail info: %s' % (error_str, email))
                self.connection_pool.replace(key, connection)
            except Exception:
                self.logging.error('%s\nEmail info: %s' % (error_str, email))
                return '邮件发送失败,请稍后再试'
        if no_connection_error:
            if error_str:
                error_str = '邮件连接全部失效,请检查是否被邮箱服务器加入黑名单,最后的异常:\n' + error_str
            else:
                error_str = '邮件连接全部失效,请检查是否被邮箱服务器加入黑名单'
        self.logging.error('%s\nEmail info: %s' % (error_str, email))
        return '邮件发送失败,请稍后再试'

    async def send_some_emails(self, emails):  # 发送一个批次的邮件
        tasks = [ensure_future(self.send_one_email(email), loop=self.io_loop) for email in emails]
        results = await gather(*tasks, loop=self.io_loop, return_exceptions=True)
        return results

    async def send_all_emails(self, emails):  # 按照步长分批次发送所有邮件
        # 把邮件按照步长分成多个批次
        tasks = [ensure_future(self.send_some_emails(emails[index: index + self.send_step]), loop=self.io_loop) for index in range(0, len(emails), self.send_step)]
        the_results = await gather(*tasks, loop=self.io_loop, return_exceptions=True)
        results = []
        for result in the_results:
            results.extend(result)
        return results

    def do_send_emails(self, emails_info):  # 发送邮件并反馈结果给EmailSender
        emails_info = pickle.loads(emails_info)  # 邮件信息解码
        result = run_coroutine_threadsafe(self.send_all_emails(emails_info['emails']), self.io_loop).result()
        # 把发送结果通过redis反馈给EmailSender
        self.redis.set(emails_info['send_task_id'], json.dumps(result), 60)

    def run_send_email_server(self):  # 使用线程池发送邮件
        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            while self.running:  # 轮询redis,从队列中获取发送邮件的任务并插入到线程池执行
                emails_info = self.redis.lpop(self.emails_list_key)
                if emails_info:
                    executor.submit(self.do_send_emails, emails_info)
                else:
                    time.sleep(0.1)

    def start(self):  # 启动邮件发送服务
        # 启动连接池
        self.connection_pool.start()
        print('Connection pool started.')

        # 启动一个协程事件循环,不要使用run_until_complete
        thread = Thread(target=self.io_loop.run_forever)
        thread.setDaemon(True)
        thread.start()

        # 启动邮件发送任务接收器和发送器
        thread = Thread(target=self.run_send_email_server)
        thread.setDaemon(True)
        thread.start()

    def stop(self):  # 关闭邮件发送服务
        self.running = False
        self.connection_pool.close()
        # self.io_loop.close()  # 关闭事件循环后会造成其它使用同一事件循环的服务的异常,这里不关闭
        print('Connection pool closed.')


class EmailSender:  # 邮件发送者
    def __init__(self, emails_list_key='', redis=None):
        self.emails_list_key = emails_list_key
        self.redis = redis

    def send_emails(self, emails):
        # 向redis插入邮件发送任务数据并等待发送结果
        ok_redis_key = 'emails_ok:%s' % uuid.uuid1().hex
        # 如果附件内容没有进行base64编码,则使用pickle的dumps,否则可以使用json的dumps
        self.redis.rpush(self.emails_list_key, pickle.dumps({'send_task_id': ok_redis_key, 'emails': emails}))
        while True:
            result = self.redis.get(ok_redis_key)
            if result:
                self.redis.delete(ok_redis_key)
                return json.loads(result)
            else:
                time.sleep(0.1)

# 初始化django模板引擎
TEMPLATES = [{'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': ['.']}]
settings.configure(TEMPLATES=TEMPLATES)
django.setup()


def get_html_content(email_title='', email_charset='utf-8'):
    # 格式化邮件内容,以发送html格式的邮件为例
    content = '''
        <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
        <html xmlns="http://www.w3.org/1999/xhtml">
         <head>
          <meta http-equiv="Content-Type" content="text/html; charset={{ render_data.charset }}" />
          <title>{{ render_data.title }}</title>
          <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
        <style>
         table th, table td{
              line-height: 1.4em;
              font-size: 14px;
          }
        </style>
         </head>
          <body style="margin: 0; padding: 0;">
            {{ render_data.data }}
          </body>
        </html>
    '''
    template = Template(content)
    render_data = {'title': email_title, 'data': {'示例': '内容'}, 'charset': email_charset}
    return template.render(Context({'render_data': render_data}))


def get_emails(email_num=2, receivers=[]):
    email_charset = 'utf-8'
    emails = []
    for email_index in range(email_num):
        email_title = '这是一封测试邮件[%s]-[%.2f]' % (email_index, time.time())
        # 格式化邮件内容,以发送html格式的邮件为例,email_content也可以是普通字符串
        email_content = get_html_content(email_title, email_charset)
        sender_host = ''  # 填写则为代发模式

        # 生成附件压缩包
        zip_io = io.BytesIO()
        zip_file = zipfile.ZipFile(zip_io, 'w', zipfile.ZIP_DEFLATED)
        for index in range(10):
            file_content = io.BytesIO(('测试内容_%s' % index).encode()).getvalue()
            zip_file.writestr('test【%s】.txt' % index, file_content)
        zip_file.close()
        file_content = base64.b64encode(zip_io.getvalue())
        zip_io.close()

        emails.append({'title': email_title, 'message': email_content, 'receivers': receivers, 'sender_name': '旷古的寂寞', 'sender_host': sender_host, 'charset': email_charset, 'files': [{'content': file_content, 'name': '测试压缩文件.zip'}]})
    return emails


emails_list_key = 'emails_list'  # EmailSender与EmailServer使用redis交互的key,EmailSender和EmailServer必须使用同一个
redis_session = redis.Redis()
receivers = ['*****@163.com', '*****@qq.com']  # 邮件接收者,可以是一个也可以是多个
email_server = EmailServer(send_step=20, emails_list_key=emails_list_key, redis=redis_session, max_workers=400, connection_pool_kwargs={
    'host': 'smtp.163.com',  # 邮箱服务器的地址,163邮箱就是这个
    'port': 994,  # 邮箱服务器的端口号,163邮箱就是这个
    'send_email_user': '*****@163.com',  # 发件用户的SMPT服务账号(收件人看到的发件地址)
    'send_email_password': '*****',  # 发件用户的SMPT服务账号的密码,注意是发件邮箱配置的SMPT服务的密码,不是发件邮箱登陆密码
    'max_connections': 10,  # 一个IP地址能够同时建立的连接数(连接池的大小),163邮箱为10个,outlook邮箱为20个
    'use_ssl': False,  # smtp服务是否开启了ssl验证
    'use_smpt_ssl': True,  # smtp服务是否开启了ssl加密传输,163邮箱、QQ邮箱都是开启的
    # 'connection_lifetime': 60,  # 连接的存活时间,到达这个时间后就替换掉该连接,一般不用配置
    # 're_helo_time': 10  # 连接的心跳时间间隔,每隔一定时间和邮箱服务器helo一下保证服务器不断开连接,一般不用配置
})
email_sender = EmailSender(emails_list_key, redis_session)

if __name__ == '__main__':
    email_server.start()
    result = email_sender.send_emails(get_emails(email_num=2, receivers=receivers))
    print(result)
    email_server.stop()  # 关闭邮件发送服务以关闭连接池和其他线程

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章