最近我们的服务需要批量发送邮件,先后试过了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, cc_receivers=None, sender_name='', sender_host='', charset='utf-8', files=None):
'''
sender_host是发件人邮箱地址
填空值时收件人看到的是发件用户的邮箱账号
填非空值时收件人看到的是填写的地址,以及由发件用户的邮箱账号代发的提示
'''
self.title = title
self.message = message
self.receivers = receivers
self.cc_receivers = cc_receivers or []
self.sender_name = sender_name
self.sender_host = sender_host
self.charset = charset
self.files = files or []
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, max_send_limit=11):
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
self.creater = 0
self.max_send_limit = max_send_limit
self.logging = logging.getLogger()
def __create(self): # 创建一个与邮箱服务器的连接
self.logging.info('create ...')
self.creater += 1
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
connection.sended = 0
connection.max_send_limit = self.max_send_limit
self.connections[key] = [connection, time.time() + len(self.connections.keys())]
if not self.running:
self.replace(key, connection, 'create when close')
break
# 异常处理根据需要自定义
except Exception as e:
try:
connection.quit()
except:
pass
if e.args[0] == 421: # (421, b'Too many connections')
self.logging.warning('[Connection Pool] %s' % str(e))
sleep_time = choice(range(5, 11))
elif e.args[0] == 554: # (554, b'IP<*****> in blacklist')
self.logging.warning('[Connection Pool] %s' % str(e))
sleep_time = 1800
elif '[Errno 104]' in str(e.args): # [Errno 104] Connection reset by peer
self.logging.warning('[Connection Pool] %s' % str(e))
sleep_time = 60
else:
self.logging.warning('[Connection Pool] %s' % str(e))
sleep_time = choice(range(5, 11))
time.sleep(sleep_time)
self.logging.info('create over ...')
self.creater -= 1
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 self.connection_lifetime > 0 and time.time() - connection_time >= self.connection_lifetime:
self.replace(key, connection, 'life over time')
elif connection.max_send_limit > 0 and connection.sended >= connection.max_send_limit:
self.replace(key, connection, 'send full limit')
elif re_helo:
try:
connection.helo()
except:
self.logging.debug('keep helo error: %s' % traceback.format_exc())
self.replace(key, connection, 'keep helo error')
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, 'close')
connections = dict(self.connections.items())
def replace(self, key, connection, replace_type=''): # 关闭并替换一个连接
self.logging.warning('[Connection Pool] Replace connection when %s.' % replace_type)
try:
connection.quit()
except:
pass
try:
self.connections.pop(key, None)
except:
pass
if self.running and self.creater < self.max_connections:
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:
self.logging.error('[Connection Pool] connections: %s' % self.connections)
return None, None
else:
time.sleep(0.1)
continue
if connection.max_send_limit > 0 and connection.sended >= connection.max_send_limit:
self.replace(key, connection, 'send full limit')
if time.time() - time_now > 3:
self.logging.error('%s connections: %s' % (self.log_tag, self.connections))
return None, None
else:
time.sleep(0.1)
continue
if self.connection_lifetime > 0 and time.time() - connection_time >= self.connection_lifetime:
self.replace(key, connection, 'get over time')
if time.time() - time_now > 3:
self.logging.error('[Connection Pool] connections: %s' % self.connections)
return None, None
else:
continue
try:
connection.helo()
return key, connection
except:
self.replace(key, connection, 'get helo error')
if time.time() - time_now > 3:
self.logging.error('[Connection Pool] connections: %s' % self.connections)
return None, None
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_pools = [ConnectionPool(**kwargs) for kwargs in 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 + email.cc_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['Cc'] = ', '.join(map(str, email.cc_receivers))
msg['Date'] = formatdate(localtime=use_localtime)
msg['Message-ID'] = make_msgid()
return from_email, recipients, msg.as_bytes()
def get_connection_pool(self, connection_pools):
temp_connection_pools = []
for index, connection_pool in enumerate(connection_pools):
if connection_pool.connections:
temp_connection_pools.append([index, connection_pool])
if temp_connection_pools:
return choice(temp_connection_pools)
async def send_one_email(self, email): # 发送一封邮件
connection_index, connection_pool = self.get_connection_pool(self.connection_pools)
error_str, no_connection_error, retry_num = '', False, connection_pool.max_connections * 2
if not connection_pool.connections:
self.logging.error('There is no connection in every connection_pools, please check !')
retry_num = 1
for index in range(retry_num):
try:
try:
self.logging.info('[TRACK] start send %s' % email)
key, connection = connection_pool.get()
self.logging.info('[TRACK] get connection ok %s' % email)
if connection:
from_email, recipients, message = self.format_email(connection.user, Email(**email))
self.logging.info('[TRACK] format ok %s' % email)
if not recipients:
return '邮件发送失败,请检查收件人信息是否正确'
if not from_email:
return '邮件发送失败,请检查发件人信息是否正确'
senderrs = connection.sendmail(from_email, recipients, message)
self.logging.info('[TRACK] send ok %s' % email)
if senderrs:
raise UnknownSendError(senderrs)
self.connection_pools[connection_index].connections[key][0].sended += 1
return True
elif index == retry_num - 1:
no_connection_error = True
self.logging.info('[TRACK] retry by no_connection_error %s' % email)
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))
connection_pool.replace(key, connection, 'send email error-1')
self.logging.info('[TRACK] replace ok and retry %s' % email)
except (ssl.SSLError, smtplib.SMTPServerDisconnected) as e:
self.logging.error('%s\nEmail info: %s' % (error_str, email))
connection_pool.replace(key, connection, 'send email error-2')
self.logging.info('[TRACK] replace ok and retry %s' % email)
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): # 启动邮件发送服务
# 启动连接池
for connection_pool in self.connection_pools:
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
for connection_pool in self.connection_pools:
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=[], cc_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, 'cc_receivers': cc_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'] # 邮件接收者,可以是一个也可以是多个
cc_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': 0, # 连接的存活时间,到达这个时间后就替换掉该连接,配置为0则不自动替换,一般不用配置
're_helo_time': 19, # 连接的心跳时间间隔,每隔一定时间和邮箱服务器helo一下保证服务器不断开连接,一般不用配置
'max_send_limit': 11 # 单个连接能发送的最大邮件数,为0时不限制
}])
email_sender = EmailSender(emails_list_key, redis_session)
if __name__ == '__main__':
email_server.start()
time.sleep(1)
result = email_sender.send_emails(get_emails(email_num=2, receivers=receivers, cc_receivers=cc_receivers))
print(result)
email_server.stop() # 关闭邮件发送服务以关闭连接池和其他线程