#6 Python 应用: 简易 SMTP 服务器

2018-05-07

Python2

python2 -m smtpd -h
# An RFC 2821 smtp proxy.
# Usage: /usr/lib/python2.7/smtpd.py [options] [localhost:localport [remotehost:remoteport]]
#     --nosetuid, -n
#     --version, -V
#     --class classname, -c classname
#     --debug, -d
#     --help, -h

Python3

python -m smtpd -h
# An RFC 5321 smtp proxy with optional RFC 1870 and RFC 6531 extensions.
# Usage: /usr/lib/python3.9/smtpd.py [options] [localhost:localport [remotehost:remoteport]]
#     --nosetuid, -n  默认会设置用户为 nobody,如果不是 root 会因权限不足失败
#     --version, -V
#     --class classname, -c classname 默认: PureProxy
#     --size limit, -s limit 消息大小限制(RFC 1870 SIZE extension),默认是 33554432 字节,即 32MB
#     --smtputf8, -u 启用 SMTPUTF8 扩展(RFC 6531)
#     --debug, -d
#     --help, -h

# 如果不指定主机,就使用 localhost
# 如果主机是 localhost,端口使用 8025
# 如果是其他主机,端口使用 25
python3 -m smtpd -n

# 默认的 PureProxy 会给转信出去,正常情况会被服务器拒绝
python3 -m smtpd -n -c smtpd.DebuggingServer

Python 3.9 的 PureProxy 有 BUG,会报 process_message() got an unexpected keyword argument 'mail_options'

自定义黑洞服务器

blackhole.py

import smtpd
import time
class BlackHoleServer(smtpd.SMTPServer):
    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
        print('%s %s %s -> %s' % (time.strftime('%Y-%m-%d %H:%M:%S'), peer, mailfrom, rcpttos))

setup.py

import setuptools
setuptools.setup(name="blackhole", py_modules=["blackhole"])

附件下载:blackhole.zip

python setup.py install --user
python -m smtpd -n -c blackhole.BlackHoleServer

测试

import smtplib
smtp = smtplib.SMTP('localhost', 8025)
from_addr = 'admin@markjour.com'
to_addr = 'you@markjour.com'
smtp.sendmail(from_addr, to_addr,
    f"""From: {from_addr}\nTo: {to_addr}\nSubject: just4fun\n\nhello, world!""")

#5 邮件格式 (RFC 822)

2018-05-05

相关的文章:

邮件是由纯文本组成,其详细的格式有很多 RFC 规范需要遵守。我这里只能对我所了解的,也是基础的 —— 或者说最核心的 —— 格式做一个说明。

最核心的部分是 1982 年的 RFC 822 (STANDARD FOR THE FORMAT OF ARPA INTERNET TEXT MESSAGES),之后又做过一些更新, 比如 RFC 2822RFC 5322 (Internet Message Format) 和一堆补丁更新。本文要讲的基本格式,从开始到现在并没有什么明显变化。

  1. 邮件是一种纯文本格式,最开始只包含 ASCII 字符,后来引入了 MIME 之后,可以制定别的编码,比如 UTF-8 等。

  2. 换行符是 \r\n,也就是 CR + LF

  3. 整体来说,一封邮件由邮件头(Headers)和邮件体(Payload)组成。

  4. 邮件头包含若干个头字段

  5. 邮件头和邮件体之间用一个空行隔开

  6. RFC2882 和 RFC5322 都规定了电子邮件每一行的长度,排除行末 CRLF,不可以超过 998 个字符,建议不超过 78 个字符。

There are two limits that this specification places on the number of
characters in a line.  Each line of characters MUST be no more than
998 characters, and SHOULD be no more than 78 characters, excluding
the CRLF.

如果太长,应该拆分成多行,下一行行首加上至少一个空格或者制表符,表示是上一行的延续。

邮件示例

From: Bob <bob@markjour.com>
To: Mark <mark@markjour.com>
Subject: Hello

Hello, Bob,

Would you like to join me for dinner?

--
Mark

RFC 822 中关于字符的定义

                                            ; (  Octal, Decimal.)
# 字符
CHAR        =  <any ASCII character>        ; (  0-177,  0.-127.)
# 字母
ALPHA       =  <any ASCII alphabetic character>
                                            ; (101-132, 65.- 90.)
                                            ; (141-172, 97.-122.)
# 数字
DIGIT       =  <any ASCII decimal digit>    ; ( 60- 71, 48.- 57.)
# 控制字符
CTL         =  <any ASCII control           ; (  0- 37,  0.- 31.)
                character and DEL>          ; (    177,     127.)
# 回车
CR          =  <ASCII CR, carriage return>  ; (     15,      13.)
# 换行
LF          =  <ASCII LF, linefeed>         ; (     12,      10.)
# 空格
SPACE       =  <ASCII SP, space>            ; (     40,      32.)
# 制表符
HTAB        =  <ASCII HT, horizontal-tab>   ; (     11,       9.)
# 引号
<">         =  <ASCII quote mark>           ; (     42,      34.)
# 回车换行
CRLF        =  CR LF
# 空白
LWSP-char   =  SPACE / HTAB                 ; semantics = SPACE
# 连贯空白, 折行空白
linear-white-space =  1*([CRLF] LWSP-char)  ; semantics = SPACE
                                            ; CRLF => folding
# 特殊字符
specials    =  "(" / ")" / "<" / ">" / "@"  ; Must be in quoted-
            /  "," / ";" / ":" / "\" / <">  ;  string, to use
            /  "." / "[" / "]"              ;  within a word.
# 分隔符
delimiters  =  specials / linear-white-space / comment
# 文本
text        =  <any CHAR, including bare    ; => atoms, specials,
                CR & bare LF, but NOT       ;  comments and
                including CRLF>             ;  quoted-strings are
                                            ;  NOT recognized.
# 原子字符
atom        =  1*<any CHAR except specials, SPACE and CTLs>
quoted-string = <"> *(qtext/quoted-pair) <">; Regular qtext or
                                            ;   quoted chars.
qtext       =  <any CHAR excepting <">,     ; => may be folded
                "\" & CR, and including
                linear-white-space>
domain-literal =  "[" *(dtext / quoted-pair) "]"
dtext       =  <any CHAR excluding "[",     ; => may be folded
                "]", "\" & CR, & including
                linear-white-space>
# 注释
comment     =  "(" *(ctext / quoted-pair / comment) ")"
ctext       =  <any CHAR excluding "(",     ; => may be folded
                ")", "\" & CR, & including
                linear-white-space>
quoted-pair =  "\" CHAR                     ; may quote any char
phrase      =  1*word                       ; Sequence of words
word        =  atom / quoted-string

对应上 ASCII:

  0 -  31 控制字符, 其中包括常用的:
          - HT ( 9)  水平制表符
          - LF (10) 换行
          - CR (13) 回车
       32 空格
 33 -  47 符号 !"#$%&'()*+,-./
 48 -  57 数字
 58 -  64 符号 :;<=>?@
 65 -  90 大写字母
 91 -  96 符号 [\]^_`
 97 - 122 小写字母
123 - 126 符号 {|}~
      127 控制字符(DEL)

CHAR 0-127
CTL  0-37 + 127

符号中:
()<>[]@,;:\".
    13 个被视作特殊字符,需要转义
!#$%&'*+-/=?^_`
    15 个就是普通符号

atom = 数字 + 字母 + 普通符号

邮件头格式

field       =  field-name ":" [ field-body ] CRLF
     field-name  =  1*<any CHAR, excluding CTLs, SPACE, and ":">
     field-body  =  field-body-contents
                    [CRLF LWSP-char field-body]
     field-body-contents =
                   <the ASCII characters making up the field-body, as
                    defined in the following sections, and consisting
                    of combinations of atom, quoted-string, and
                    specials tokens, or else consisting of texts>

字段名称允许使用的字符范围非常宽泛,而且大小写不敏感,

但是一般实践中:

  • 由字母和中划线组成
  • 首字母大写,或全部大写

常用字段

  • Received MTA 轨迹(传输过程中的相关信息)
  • Date 发信时间,格式:Fri, 21 Nov 1997 09:55:06 -0600
  • Sender Mail From 地址
  • From 发件人
  • Subject 邮件标题
  • To 收件人
  • Cc 抄送
  • Bcc 密送
  • Reply-To 回复地址
  • Message-ID 邮件标识
  • References 回复邮件标识,逗号隔开
  • In-Reply-To 回复邮件标识(会话发起的第一封)
  • Return-Path 发信任地址(2020/07/31,邮件的 Return-Path 头是什么
  • Comments 说明
  • Keywords 关键字

一般采用 X- 开头的字段名称表示自定义字段,或者叫拓展字段:

常见的拓展字段:

  • X-Mailer

#4 SMTP 拓展

2018-04-24

RFC#821 定义的 SMTP 协议非常简单(简陋)。
1993 年,RFC#1425 SMTP Service Extensions 定义了 SMTP 协议的拓展框架。
这个向前兼容的安全拓展框架是通过 EHLO 命令来实现。

#3 邮件发送中会遇到的各种地址

2018-04-16

注意:这边不是讨论 邮箱地址的格式

格式

  • 邮箱地址
  • "名称" <邮箱地址>

含义

  • SMTP 会话(投递)
  • Mail From 真实投递的发信人
  • Rcpt To 真实投递的收信人
  • 邮件内容(显示)
  • From 发信人
    • 如果和 Mail From 地址不同,可能会显示:由 xxx 代发
  • To 收信人
  • Cc 抄送人
  • Bcc 密送人
  • Rely-To 回复地址
    • 客户端点击回复的时候用的
    • 如果没有这个字段,就会回复 From 地址
  • Sender 发信人
  • Return-Path / Reverse-Path / Envelope-From
    • 作用是在邮件投递出现问题的时候,邮件服务将邮件退回这个地址
    • 如果我们看到这几个名字
    • 可能是发信人自己在邮件中声明
    • 可能是收信方收到邮件之后添加的,单独字段,或放在 Received 头中

关于抄送和密送

碳式复写纸 carbon paper
副本,抄送 carbon copy => CC
密送 blind carbon copy => BCC

按照设计,密送地址不希望被其他收信人、抄送人察觉,只是密送地址才知道自己是密送。

CC, BCC in SMTP

SMTP 服务器不处理 CC、BCC,SMTP 客户端应该自行处理
TO 地址 + CC 地址 + BCC 地址一起放到 SMTP 会话的 RCPT TO 字段

所以,按照我的理解,邮件客户端:

在一次 SMTP 会话中,如果有 3 个 TO/CC 地址,2 个 BCC 地址,应该对那 3 个地址批量发送,然后对那 2 个 BCC 地址分别加上 BCC 头,分别发送。
更稳妥一点:如果是批量发送邮件,不要放 BCC 到邮件头!!!显示一个 密送:xxx 也没啥意义。

  • PS:MSN(Outlook),网易邮箱发出去的邮件,不会加 BCC 头
    甚至网易可能在显示邮件原文的时候会移除 BCC 头(给网易邮箱发的 BCC 头都不见了)
  • PS:Gmail,QQ 邮箱发出去的邮件,密送人会看到 BCC 头
from_addr = "from@markjour.com"
to_addrs = ["to@markjour.com"]
cc_addrs = ["cc1@markjour.com", "cc2@markjour.com"]
bcc_addrs = ["bcc@markjour.com"]

msg = f"""
From: {from_addr}
To: {", ".join(to_addrs)}
Cc: {", ".join(cc_addrs)}

Hello World
""".strip()

send_to = to_addrs + cc_addrs + bcc_addrs

server = smtplib.SMTP('smtp.126.com')
server.set_debuglevel(1)
server.login(api_user, api_key)
server.sendmail(from_addr, send_to, msg)
server.quit()

#2 邮件中的时间格式

2018-04-10

比如:

Sun, 20 Jun 2018 00:47:04 -0700 (PDT)
Thu, 10 Jun 2021 16:10:03 -0700 (PDT)
Thu, 10 Jun 2021 08:06:31 -0700 (PDT)

定义

定义在 RFC 822 的 5. DATE AND TIME SPECIFICATION

date-time   =  [ day "," ] date time        ; dd mm yy hh:mm:ss zzz
day         =  "Mon"  / "Tue" /  "Wed"  / "Thu" /  "Fri"  / "Sat" /  "Sun"
date        =  1*2DIGIT month 2DIGIT        ; day month year e.g. 20 Jun 82
month       =  "Jan"  /  "Feb" /  "Mar"  /  "Apr" /  "May"  /  "Jun" /  "Jul"  /  "Aug" /  "Sep"  /  "Oct" /  "Nov"  /  "Dec"
time        =  hour zone                    ; ANSI and Military
hour        =  2DIGIT ":" 2DIGIT [":" 2DIGIT]
                                            ; 00:00:00 - 23:59:59
zone        =  "UT"  / "GMT"                ; Universal Time
                                            ; North American : UT
            /  "EST" / "EDT"                ;  Eastern:  - 5/ - 4
            /  "CST" / "CDT"                ;  Central:  - 6/ - 5
            /  "MST" / "MDT"                ;  Mountain: - 7/ - 6
            /  "PST" / "PDT"                ;  Pacific:  - 8/ - 7
            /  1ALPHA                       ; Military: Z = UT;
                                            ;  A:-1; (J not used)
                                            ;  M:-12; N:+1; Y:+12
            / ( ("+" / "-") 4DIGIT )        ; Local differential
                                            ;  hours+min. (HHMM)

总结就是:

[day-of-week,] day month year hour:minute[:second] timezone
  1. 周几和秒是可选的,据我观察,没有邮件省略这两部分
  2. 周几和月份采用三字母英文缩写(首字母大写)
  3. 年份是 2 位数字,后来的规范更新中建议采用 4 位数字。出于兼容性考虑,一般都保留了对 RFC 822 两位数字年份的支持。
  4. 时区除了数字之外,可以使用 UTGMTESTEDTCSTCDTMSTMDTPSTPDT
    还有 25 个字母(J 没有使用),Z 表示 UTC/GMT 时间,A - M 表示 -1 ~ -12 时区,N - Y 表示 1 到 12 时区。
  1 2 3 4 5 6 7 8 9 10 11 12
West A B C D E F G H I K L M
Eest N O P Q R S T U V W X Y

Python

生成符合要求的时间字符串比较简单:

import time
time.strftime('%a, %d %b %Y %H:%M:%S %z')
# 'Tue, 10 Apr 2018 09:10:05 +0800'

但是由于这个灵活度比较大,解析起来最好借助专业的库(email.utils)来做这个事。

import time
import datetime
import email.utils
import pytz

# 解析 ############################################

date_str = 'Sun, 20 Jun 2018 00:47:04 -0700 (PDT)'
email.utils.parsedate_to_datetime(date_str)
# datetime.datetime(2018, 6, 20, 0, 47, 4, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)))
email.utils.parsedate_tz(date_str)
(2018, 6, 20, 0, 47, 4, 0, 1, -1, -25200)

# 生成 ############################################

# email.utils.formatdate(timeval=None, localtime=False, usegmt=False)
email.utils.formatdate()
# 'Tue, 10 Apr 2018 09:10:41 -0000'

# email.utils.format_datetime(dt, usegmt=False)
dt = datetime.datetime.now()
email.utils.format_datetime(dt)
# 'Tue, 10 Apr 2018 09:16:43 -0000'

tz = pytz.timezone('Asia/Shanghai') # <DstTzInfo 'Asia/Shanghai' LMT+8:06:00 STD>
dt = datetime.datetime(2018, 4, 10, 9, 10, 0, tzinfo=tz)
# datetime.datetime(2018, 4, 10, 9, 10, tzinfo=<DstTzInfo 'Asia/Shanghai' LMT+8:06:00 STD>)
email.utils.format_datetime(dt)
# 'Tue, 10 Apr 2018 09:10:00 +0806'

#1 邮箱地址的格式

2018-04-07

规范

规范定义比较复杂,甚至支持注释。

我简化一下(去掉注释,去掉双引号,去掉 [IPv4] / [IPv6] / 主机名 做域):

  1. 格式:域内部分@域
  2. 域内部分:

  3. 长度不超过 64

  4. 大小写字母 + 数字(62)
  5. ASCII 标点符号(19)

    !#$%&'*+-/=?^_`{|}~
    
  6. 可以加入点号(.)隔开,不放首尾,不连续出现

  7. 域名

  8. 每一级域名 1 - 63 个字符,总长度不超过 253
    这个限制和 DNS 报文设计有关
    国际化域名转换成 Punycode 之后也必须遵守这个约定

  9. 允许包含数字、字母(大小写不敏感)和短横线(-
  10. 短横线不能出现在首尾位置

实践

实际上的邮件地址会更加简单:

  1. 长度限制
  2. QQ 邮箱 3 - 18
  3. 网易邮箱 6 - 18
  4. Gmail 6 - 30
  5. 新浪邮箱 4 - 16
  6. 字符限制:字母数字 + .-_
  7. 一般大小写不敏感
  8. 连字符(.-_)不可连续出现
    1. 网易免费邮箱只支持下划线,网易 VIP 邮箱支持点和下划线
    2. Gmail 只支持点和加号
      1. 在实际投递中,点和加号会被忽略
      2. 点可以用作单词风格
      3. 加号通常用做来信归类,比如注册淘宝时 +taobao,订阅开发者头条时 +toutiao,相关邮件就方便搜索归类。
  9. 部分邮箱不支持全数字(别有用途,或是避免 QQ 号冲突,或是避免手机号冲突)
  10. 开头结尾字符限制:
    1. 字母数字开头 + 字母数字结尾
    2. 字母开头 / 字母数字结尾

正则表达式

以规范为准,参考真实场景下的实践:

域内部分:

/[a-z0-9]+([.-_#][a-z0-9]+)+/;

域名部分:

/[a-z0-9]+(-[a-z0-9]+)?(\.[a-z0-9]+(-[a-z0-9]+)?)+/;

汇总在一起就是:

/^[a-z0-9]+([.-_#][a-z0-9]+)+@[a-z0-9]+(-[a-z0-9]+)?(\.[a-z0-9]+(-[a-z0-9]+)?)+$/;

参考资料与拓展阅读