Python2无法发信问题解决

进入公司的Jenkins服务在部署任务时经常报错,每次检查发现都是后处理发邮件出问题了,遂排查一下

问题复现

报错时好时坏,有的时候可以发信,有的时候又不能发信,很是恼人

发信异常
发信又正常

故障排查

已知情况

  1. 目前有同事在用相同账号相同的邮件服务器地址相同的端口号,因此初步断定并非邮件服务器存在变更或配置错误导致
  2. 同事反馈会有时好时坏的情况,初步简单测试了一下,如果内容中包含测试/TEST等字样的邮件会被拦截,因为公司近期在推进邮件安全加固,这种问题出现情有可原,但是并非所有邮件均无法发送,如果不包含敏感字段的邮件是可以正常发送的。
  3. 与公司负责邮件服务器的相关同事确认,近期做过邮件服务器系统升级,但相关的配置及安全设置未修改

初步假设

smtplib(可能?)不适用于Exchange邮件服务器

猜测是否由于升级系统导致的邮件系统无法再使用smtplib发送邮件?

验证结果

我在别的项目中曾经使用过以下依赖实现,具体代码不贴了,太长了,确定成功发送并接收邮件

1
2
3
4
5
import smtplib
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
成功接收邮件

Python2无法通过

上面的问题排除了, 那么猜测是不是由于Python2导致的呢? 毕竟Python2都已经早早进入废弃状态了(在2022-01-01就废弃了)

Python2早已标记废弃

经一番搜索后得知, Python存在exchangelib1包, 可以直接与Exchange服务器进行交互, 通过官方文档中对于安装和连接的部分, 以及发送及其他操作相关的说明, 简单几行就可以实现发送邮件操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from exchangelib import Credentials, Account, Message, DELEGATE, Mailbox

EMAIL_ADDR = 'you_name@example.com'
EMAIL_PASS = 'YOUR_PASSWORD'

credentials = Credentials(EMAIL_ADDR, EMAIL_PASS)
account = Account(EMAIL_ADDR,
credentials=credentials,
autodiscover=True,
access_type=DELEGATE)

message = Message(
account=account,
subject='Daily motivation',
body='All bodies are beautiful',
to_recipients=[
Mailbox(email_address='anne@example.com'),
Mailbox(email_address='bob@example.com'),
],
# Simple strings work, too
cc_recipients=['carl@example.com', 'denice@example.com'],
bcc_recipients=[
Mailbox(email_address='erik@example.com'),
'felicity@example.com',
], # Or a mix of both
)
message.send()

验证结果

修改并使用上述代码尝试执行

可以发送邮件

可以看到, 可以发送邮件, 但是有警告抛出, 此时我的邮件正文和标题中均为纯英文, 尝试修改正文/标题, 使用中文, 同样地也可以发送邮件, 诶, 这就奇怪了? 登录服务器重新执行一下, 也不报错了, 邮件也能收到

服务器发送邮件又正常了
邮件可以正常收到

因此, 这个猜想的结论也是, 所以问题并非出在代码或者功能上

问题解决

结论

在问过一圈同事后, 发现不光我们受到了影响, 其他使用邮件系统的同事也或多或少的有影响, 最终确定问题出在Exchange服务器上, 以下内容由负责邮件服务器的同事提供的结论

由于实施了2次/周的更新策略, 部分更新(我猜也许是nightly更新?)会将已有配置重置(这很微软🤐), 因此导致邮件系统发送不了邮件

解决措施

整体流程变更

淦🤬这个导致很多Jenkins部署都会失败, 用户一顿抱怨, 总以为自己的代码部署有问题了, 需要跟用户说明情况, 无形中增加了很多无效沟通成本, 并且极大地增加了部署的失败率.

看了下正好Python2也废弃了, 索性改改逻辑, 保障原有功能参数不变的情况下, 功能解耦吧, 采用AWS Lambda实现发信功能.

flowchart TD

    a[Jenkins-CI/CD] --> |触发发信规则|b[执行Python2发信脚本] 
    b --> |转换请求|c[调用Lambda函数]
    c --> |通过Python3 exchangelib模块|d[发送邮件] 

参考上面的流程图, 将原来直接发送请求的需求直接转移到Lambda函数, 利用其支持高并发并且所有的发信请求和内容等都可以通过CloudWatch记录追溯等特性, 解耦原逻辑

实现Lambda函数

函数很简单, 用不了太多行就可以搞定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
发送邮件功能

- Author: Rex Zhou <879582094@qq.com>
- Created Time: 2023/3/17 15:07
- Copyright: Copyright © 2023 Rex Zhou. All rights reserved.
"""

__version__ = "0.0.3"

__author__ = "Rex Zhou"
__copyright__ = "Copyright © 2023 Rex Zhou. All rights reserved."
__credits__ = [__author__]
__license__ = "None"
__maintainer__ = __author__
__email__ = "879582094@qq.com"

import json
import logging
import os

from exchangelib import Credentials, Account, Message, DELEGATE, HTMLBody
from retry import retry

# 日志等级
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'info').upper()

# 发信邮件账号
EMAIL_ADDR = os.environ.get('EMAIL_ADDR', 'lambda@example.com')
# 发信邮件密码
EMAIL_PASS = os.environ.get('EMAIL_PASS', '')

# 重试机制
RETRIES = int(os.environ.get('RETRIES', 10))
DELAY = int(os.environ.get('DELAY', 2))
BACKOFF = int(os.environ.get('BACKOFF', 2))

logger = logging.getLogger()
logger.setLevel(LOG_LEVEL)


def _split_email_addresses(addresses: str, seps: str = ',;-') -> list:
"""
切分邮件地址方法

:param addresses: str 邮件地址字符串
:return: list 邮件地址列表
"""
result = set()
for sep in seps:
result |= set(addresses.split(sep))
result = [_ for _ in result if _]
if len(result) > 1:
result.remove(addresses)
return result


def _parse_event(event: dict) -> dict:
"""
解析event字典

发送邮件必须提供以下内容:

- subject: 标题
- body: 正文
- to: 收件人

以下为可选参数:

- cc: 抄送
- bcc: 密送
- html_body: HTML格式正文, 默认: False

:param event: dict 事件字典
:return: dict 发信字典
:raise: KeyError 无法获取全部 **必需** 字典键
"""
body = HTMLBody(event['body']) if event.get('html_body') else event['body']
logger.debug('正文解析结果:')
logger.debug(body)
kwargs = {'subject': event['subject'], 'body': body}
receivers = _split_email_addresses(event['to'])
if not receivers:
msg = '收件人解析结果为空!'
logger.critical(msg)
raise KeyError(msg)
kwargs['to_recipients'] = receivers
if _cc := _split_email_addresses(event.get('cc', None)):
kwargs['cc_recipients'] = _cc
if bcc := _split_email_addresses(event.get('bcc', None)):
kwargs['bcc_recipients'] = bcc
return kwargs


@retry(tries=RETRIES, delay=DELAY, backoff=BACKOFF)
def send(account: Account, kwargs: dict) -> None:
"""
尝试发信函数

:param account: Account 账号实例
:param kwargs: dict 发信请求字典
:return: None
"""
message = Message(account=account, **kwargs)
logger.info('发信请求实例化成功!')
message.send()


def lambda_handler(event, context):
"""
Lambda入口程序

1. 使用 :meth:`_parse_event` 解析请求字典作为Exchange发信参数, 事件字典对应关系:

- subject ➡️ [**必须**] 邮件主题
- body ➡️ [**必须**] 邮件正文
- to ➡️ [**必须**] 收件人, 多个以',;-'分割
- cc ➡️ [*可选*] 抄送, 多个以',;-'分割
- bcc ➡️ [*可选*] 密送, 多个以',;-'分割
- html_body ➡️ [*可选*] HTML格式正文, 默认为: False

#. 实例化 ``Credential`` 及 ``Account`` 作为发信凭证

#. 使用 :meth:`send` 发送邮件, 通过 :meth:`retry` 保证重试机制

.. seealso::

- `exchangelib 创建并连接到服务器`_
- `exchangelib 发送邮件`_

.. _`exchangelib 创建并连接到服务器`: https://ecederstrand.github.io/exchangelib/#setup-and-connecting
.. _`exchangelib 发送邮件`:
https://ecederstrand.github.io/exchangelib/#creating-updating-deleting-sending-moving-archiving-marking-as-junk

:param event: :class:`dict` 事件字典
:param context: :class:`str` 上下文
:return: :class:`None`
"""
logger.debug('事件及上下文信息如下:')
logger.debug(event)
logger.debug(context)
kwargs = _parse_event(event)
logger.info('解析请求事件成功!')
logger.info(json.dumps(kwargs, ensure_ascii=False, indent=2))
credentials = Credentials(EMAIL_ADDR, EMAIL_PASS)
account = Account(primary_smtp_address=EMAIL_ADDR,
credentials=credentials,
autodiscover=True,
access_type=DELEGATE)
logger.info('开始尝试执行发信操作...')
send(account, kwargs)
logger.info('邮件已发送!')

简单说明一下, 这个Lambda函数主要通过以下三步实现发送邮件的功能

  1. 解析重构发信请求信息
  2. 连接Exchange服务器
  3. 发送邮件(具有重试机制)

只需要给这个Lambda配置基础环境变量即可

Lambda环境变量
  • EMAIL_ADDR ➡️ [必填] 发信地址
  • EMAIL_PASS ➡️ [必填] 发信密码
  • LOG_LEVEL ➡️ [可选] 日志等级
  • RETRIES ➡️ [可选] 重试次数
  • DELAY ➡️ [可选] 重试延迟秒数
  • BACKOFF ➡️ [可选] 重试退避指数2

测试一下, 邮件可以正常发送

通过Lambda发送邮件正常

修改Jenkins发信脚本

对应Jenkins的Python2脚本应修改成类似如下代码的样式, 这里我就不贴源代码了(18年我师傅写的😁)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# !/usr/bin/env python
# -*- coding: utf8 -*-

import argparse
import base64
import json
import logging

import boto3

# 调用Lambda的ARN
LAMBDA_ARN = 'arn:aws-cn:lambda:cn-north-1:123456789012:function:send_email'
# 编码格式
CODING = 'utf-8'


def _parse_arguments(arg):
"""
解析参数方法

产出结果包含以下Key:

- subject ➡️ [**必须**] 邮件主题
- body ➡️ [**必须**] 邮件正文
- to ➡️ [**必须**] 收件人, 多个以',;-'分割
- cc ➡️ [*可选*] 抄送, 多个以',;-'分割
- bcc ➡️ [*可选*] 密送, 多个以',;-'分割
- html_body ➡️ [*可选*] HTML格式正文, 默认为: False

:param args: 需要解析参数
:return: dict 解析后的字典
"""
pass


def invoke_lambda(data):
"""
调用Lambda函数方法

:param data: dict 请求字典
:return: None
"""
# 不指定区域等信息将使用名为`default`的配置
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/session.html#boto3.session.Session
region = LAMBDA_ARN.split(':')[3]
logger.info("Lambda所在区域: %s" % region)
session = boto3.Session(region_name=region)
client = session.client('lambda')
response = client.invoke(FunctionName=LAMBDA_ARN,
InvocationType='RequestResponse',
LogType='Tail',
Payload=data)
logger.info('返回状态码: %s' % response['StatusCode'])
logger.info('返回错误信息: %s' % response.get('FunctionError'))
log = base64.b64decode(response['LogResult']).decode(CODING).encode(CODING)
logger.info('返回执行日志:')
for _ in log.split('\n'):
logger.info(_)
logger.info('返回具体结果: %s' % response['Payload'].read())

if __name__ == '__main__':
"""参数说明:
--param a: xxxx
--param b: xxxx
--param c: xxxx
...
--param x: xxxx
--debug: bool 是否启用DEBUG模式
"""
parser = argparse.ArgumentParser(description='Send email by jenkins.')
parser.add_argument('--a', type=str, default=None)
parser.add_argument('--b', type=str, default=None)
parser.add_argument('--c', type=str, default=None)
parser.add_argument('--x', type=str, default=None)
arg = parser.parse_args()
level = logging.DEBUG if arg.debug else logging.INFO
logging.basicConfig(level=level)
logger = logging.getLogger()
req = _parse_arguments(arg)
invoke_lambda(req)

验证

部署推送代码后, 一切正常! 🎉

Jenkins发邮件正常

  1. Pypi官方地址: exchangelib · PyPI↩︎

  2. 截断二进制指数避退算法, 详见维基百科Exponential backoff - Wikipedia↩︎