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()  # 關閉郵件發送服務以關閉連接池和其他線程

 

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