當你發郵件時,你的電腦都幹了什麼?藉助Python探索SMTP協議
Oct. 30th, 2018

你是否需要每天使用電子郵件服務?
電子郵件(email)是網際網路上歷史悠久又常用的訊息收發形式。對於大多數辦公室一族,每天到班上的第一件事恐怕就是要查一下新的郵件。雖然即時通訊工具在飛速佔領著通訊市場,但是在商業或者學術圈裡,email依然佔據著主流地位。
一般收發email,要麼使用電子郵件管理工具(如Outlook, Mac的Mail等),要麼登入網頁版的email服務網頁(gmail, 126等等)。作為一個好奇心很強的程式員,我一直很想知道當自己編輯了一封郵件,點擊發送的時候,自己的電腦/手機都在我背後幹了什麼?收發email的伺服器是如何工作的?藉助Python的TCP介面和smtplib,我們可以很容易就可以寫一個“email伺服器“。這裡email伺服器加了引號,是因為它只是假裝自己是一個email伺服器(見文尾的討論)。但是,這個假伺服器其實已經具備了真伺服器的所有邏輯功能。如果你希望,你完全可以把它做成一個可以正常工作的email伺服器!(文尾也會大體講解如何去做,但是需要你購買一個域名並向DNS伺服器進行相應的註冊。)

當你點選“傳送”之後,你的郵箱做了那些操作?今天的實驗帶你親自看一看。
引言:網際網路協議和SMTP協議
在之前的一篇文章(點選這裡檢視)中,筆者討論了網際網路協議的幾個層,並且構建實驗探討了提供網頁服務的HTTP伺服器如何工作。如果你也喜歡探索原理,並且還沒有做過這個實驗,那麼強烈建議你點開連線,跟著文章中設計的實驗探索一下HTTP協議。這裡,我們會做一個類似的實驗來窺探email收發使用的SMTP協議。
簡單概括原理:我們的網際網路分為四個層,每一層的正常工作建立在下面層的基礎上。工作在最上層的“應用層”,有提供網頁服務的HTTP協議,提供郵件收發的SMTP協議,提供檔案傳輸的FTP協議等等。這些協議想要正常工作,都要基於下面“傳輸層”的支援。傳輸層比較常用的是TCP協議。今天的實驗裡,我們將在SMTP層和TCP層兩個層面上觀察SMTP協議,並且在TCP層上構造一個簡單的,需要手動控制的“SMTP伺服器”。

這張圖詮釋了SMTP連線。當我們說SMTP通訊時,它其實是一種虛擬的,抽象的說法。真正建立連線的,是在下面的層上
實驗0(準備工作):檢視email伺服器地址
在真正開始實驗之前,我們先看一下如何從一個email地址出發,查詢它對應的SMTP伺服器域名。比如 [email protected]
,我們知道它的email域名是 126.com
。但是我們需要知道, 126.com
背後的SMTP伺服器地址是多少。為了便於區分,通常管 126.com
叫做 email域名 ,而其背後的SMTP伺服器地址,叫做 mx域名 。(mx是mail exchange的縮寫。)
這裡我們使用工具 nslookup
查詢mx域名。無論你使用的是Windows系統,還是Mac OS,還是Linux, nslookup
都已經存在於你的電腦裡了。使用它的步驟如下:
- 開啟命令列。Windows系統:開啟“開始”選單,輸入"cmd",搜尋到“命令提示行”工具。開啟後介面如下。

windows中的命令提示行
Mac OS在應用程式中找到"Terminal"。Linux我就不說命令列在哪裡了。
- 在命令列中輸入
nslookup
按回車,進入nslookup
工具中。輸入set q=mx
,指定查詢mx域名。輸入126.com
,按下回車,你就會得到查詢結果。

nslookup查詢126.com的mx域名
圖中可以看到, 126.com
email域名背後有4個mx伺服器。後面的討論中,使用任何一個mx伺服器(比如“126mx01.mxmail.netease.com”)都可以。友情提示:複製mx域名時,注意不要把最後的句號複製上了。
實驗1(應用層):使用Python傳送email
Python提供了一個很強大的包進行email的相關操作: smtplib 。在這一節,我們通過使用這個包,熟悉一下smtp協議工作的基本步驟,下一節,我們再深入到smtp協議的底層。
如果你想知道如何 姿勢正確 地傳送郵件,你可以參見這一篇文章。這裡說的“姿勢正確”,意思是你將會使用 smtplib
登入到你使用的郵箱伺服器(比如你註冊了新浪的郵箱,你就可以使用 smtplib
的 login
函式登入到新浪郵箱伺服器上),然後再對你的目標收件人(比如126的email郵箱)發郵件。只要正確設定,這樣發郵件一定不會有問題,因為有你的郵件服務提供商(這個例子裡是新浪)給你撐腰,對方的郵箱(這裡是126郵箱)不敢拒絕你的郵件。
但是,這裡筆者只想討論smtp協議的結構。嚴格來說 login
並算不上smtp協議的要求(至少並不是基本要求)。很多接收者的郵箱,並不需要發件人有一個具體的email地址,只需要收件人的email地址明確,郵件內容格式正確就可以了。是的, 你不需要自己有一個email地址才可以給別人發郵件!
當然,很多時候這樣的郵件會被對方SMTP伺服器拒收。即時接收了,也有可能因為來源不明而被放到垃圾郵件裡。所以,並不建議讀者用這種收發日常郵件。但是為了弄懂SMTP的協議,這樣做一兩次還是值得的。
話不多說,先上一個完整的郵件傳送的截圖。注意,我作為發件人,並沒有登入任何自己的郵箱。另外,注意變數 s_body
的格式。大部分郵件伺服器對這個格式很看重。不符合這個格式的郵件經常會被拒絕。最後,注意在 server.connect
那一行執行之後,後面手速一定要快。隔一會再執行下一行的話,對方伺服器通常會斷開。

Python中使用smtplib傳送郵件。要先把收件人地址、發件人地址和郵件內容都實現編輯好,存在變數中。避免連線到伺服器之後,由於超時沒有響應而導致伺服器斷開連線
大概來看一下程式碼裡面都幹了什麼。
- 建立了一個叫
server
的SMTP物件。在呼叫connect
方法之前,事先準備好了收件人、發件人和郵件內容的字串s_from
,s_to
和s_body
。因為伺服器允許的連線時間有限。特別注意s_body
字串的格式。 -
server.connect(...)
連線126的mx伺服器。這裡用到的是上一節中nslookup
查詢到的資訊。 -
server.sendmail(...)
傳送郵件。返回{}
,就說明發送成功了。
So far so good。 但是,作為一個好奇寶寶,你肯定要問: server.sendmail
到底幹了什麼?使用 server.sendmail
發郵件,跟開啟outlook寫好了郵件點發送,似乎並沒有太大提高。為了探尋後面發生了什麼,我們就要祭出TCP socket了。
實驗2 (傳輸層):使用TCP socket假裝自己是個SMTP伺服器
怎麼才能搞清楚 server.sendmail
揹著你跟伺服器幹了什麼事情呢?好吧,換個問題:假設你懷疑你物件在網上見了漂亮妹子/帥氣男生就勾搭,怎麼才能抓住他/她的把柄呢?一個方法就是,自己註冊個上網賬號,把自己偽裝成漂亮妹子/帥氣男生,跟他/她聊。
我們知道,像 requests
一樣, smtplib
要想進行SMTP通訊,一定會使用下面傳輸層的TCP協議,跟對方的SMTP伺服器建立TCP連線。所以,我們就像上一篇文章那樣,準備一個TCP連線,把對方發過來的資料都顯示到螢幕上,具體哪些訊息,什麼格式,就一目瞭然了。
跟http請求不同的是, server.sendmail
不是一個 單次 的請求/響應,而是要求雙方使用協議規定的格式 反覆提問回答幾次 才可以完成郵件傳送。因此,我們的伺服器裡也要仔細設定響應的內容,確保返回的東西符合格式,使得對話能繼續進行。(也就是說,想假裝自己是SMTP伺服器,比假裝自己是HTTP伺服器要難一點,穿幫的可能性也更大一點。)為了增強體驗感,我們在每次收到資訊時,讓我們手動填寫返回內容。
我們先來看程式碼。
""" 這是一個虛擬伺服器。當任何程式簡介到它時,它先發送一條歡迎資訊WELCOME_MSG, 然後等待對方傳送資訊。對方每傳送一條資訊,它就會把資訊顯示到螢幕上,然後提示 我們輸入應答內容。緊接著,它會把我們輸入的內容後面加上換行符\r\n,傳送回去。 """ SERVER_IP = "localhost" SERVER_PORT = 25#預設的SMTP之一 MAX_LENGTH = 1023#規定每條資訊長度上限。 WELCOME_MSG = "220 Virtual Server At Your Service!\r\n" #歡迎資訊 socket_list = [] import socket def close_sockets(): #再程式出現異常退出時關閉所有埠,避免端口占用 for sock in socket_list: sock.close() def main(): sock_listen = socket.socket(socket.AF_INET, socket.SOCK_STREAM) socket_list.append(sock_listen) sock_listen.bind((SERVER_IP, SERVER_PORT)) sock_listen.listen(1) conn, addr = sock_listen.accept() socket_list.append(conn) # Once connection is built: send welcome message and print connection print("Connection established. From: " + str(addr)) if WELCOME_MSG is not None and WELCOME_MSG != '': # 如果WELCOME_MSG為''或者None, # 則不傳送歡迎資訊,連結建立後 # 直接進入接收資訊狀態 conn.send(WELCOME_MSG.encode()) print("歡迎資訊發出!") while True: print("等待對方應答... 資訊長度上限: " + str(MAX_LENGTH)) data = conn.recv(MAX_LENGTH) print("收到資訊:\n", data) if len(data) == 0: break reply_msg = input("您的回覆: ") reply_msg += '\r\n' conn.send(reply_msg.encode()) print("回覆訊息發出!") if __name__ == "__main__": try: main() except Exception as err: print(str(err)) close_sockets() exit()
這段程式碼比較直接。程式碼裡的註釋或者 print
的提示字都描述著每一部分程式碼的功能。
有了這個人工伺服器,我們就可以拿它接收 smtplib
發來的請求了。前面的演示用了Windows和Mac OS的電腦。為了不偏心,這裡就用Linux的電腦做演示了。(我才不會告訴你,其實是因為Windows電腦老婆在用,而Mac電腦落在辦公室裡了T_T)
- 伺服器開啟。注意由於使用了25埠(SMTP協議的預設埠之一),為系統預留埠,因此程式需要管理員許可權。第一幅圖中,第一次嘗試由於沒有用管理員許可權
sudo
而被拒絕執行了。加了sudo
程式得以啟動,並開啟埠,等待連線。

開啟伺服器。第一次由於沒有使用管理員許可權而被拒絕。第二次成功開啟,進入等待連線狀態
- 使用Python3的
smtplib
連線SMTP伺服器。這一步跟上面實驗是一樣的。注意server.connect
連線的是'localhost'
。此時,右邊圖中伺服器也顯示收到了連線,併發送了歡迎訊息'220 Virtual Server At Your Service!' 這時從smtplib
接收到的資訊來看,它識別了這種 狀態碼<空格>回覆資訊 的格式,返回了一個二元素的陣列。

python的smtp連線,伺服器傳送來歡迎資訊
接下來,我們就重複上面實驗中的做法,定義 s_from
, s_to
和 s_msg
,然後交給 server.sendmail
函式來以郵件形式傳送出去。見下圖。

使用smtplib的sendmail傳送郵件
- 接下來的圖 很重要! 它顯示了sendmail函式執行後,伺服器上收到的 一連串資訊 。

sendmail函式給伺服器傳送的一系列SMTP訊息。從`helo`開始
注意,這裡為了把換行符也都顯示出來,特意沒有將Bytes型字串解碼成普通的Python3字串(即,沒有呼叫 decode
方法)。所以圖裡每條資訊前面都有個 b
。
這段對話是這樣的:
sendmail: helo [自己地址]
我: 250 Nice to meet you # 注意格式是“狀態程式碼<空格>響應資訊”。
sendmail: mail FROM:<發件人地址>
我: 250 Ok
sendmail: rcpt TO: <收件人地址>
我: 250 Ok
sendmail: data # 這個 data
單詞是告訴對方,注意,我後面要開始傳送正文了!
我: 354 Go ahead # 這裡狀態碼也不再是250,而是354,表示“我等你發信息”
sendmail: <郵件正文> # 注意:這段文字最後的 \r\n.\r\n
是SMTP協議定義的data結束符號。
我: 250 Received
到這裡, sendmail
函式就完成了它的任務,發出一封郵件。其實, sendmail
正常工作,分析的是每一個請求對方發來的狀態碼(250, 354這些)。比如 sendmail
傳送 data
字元的時候,如果你還是回覆250而不是354的話, sendmail
會認為你這個伺服器有問題,就不再理你了。與之相比,後面的響應資訊的具體內容,SMTP協議是沒有具體要求的。所以才會有五花八門的回覆。比如,gmail的伺服器響應 helo
的內容是"at your service",而我這裡寫的是"Nice to meet you"。
一切傳送完畢後, sendmail
返回了熟悉的 {}
,即空字典,表示資訊傳送成功了。後面我又呼叫了 server.quit()
結束對話。從下圖可以看到,這個函式在斷開連線之前,先給伺服器傳送了 quit
資訊。我響應了 221 bye
之後,它才關閉了TCP連線。

sendmail返回`{}`之後,呼叫quit函式傳送“結束通訊”的訊息
總結
這一篇實驗有點長。在準備階段(實驗0),我們瞭解瞭如何使用 nslookup
查詢一個郵件域名對應的MX伺服器地址。實驗1中,我們使用Python的 smtplib
包傳送郵件,觀察了在應用層(SMTP層)上的情況,並且掌握了 smtplib
的使用方法。實驗2中,我們下潛到TCP層,開啟了一個簡單的人工SMTP伺服器,接收 smtplib
發來的郵件傳送請求。看到了 helo
, mail FROM
, rcpt TO
, data
, quit
這些標準的SMTP請求報文,也瞭解了伺服器響應資訊的"狀態程式碼<空格>響應訊息"格式。順便說一下,其實 smtplib
裡也是提供了 server.helo
, server.mail
, server.rcpt
, server.data
這些函式的。有興趣的讀者可以自己嘗試一下。
通過親手進行這個實驗,相信讀者會對SMTP協議有一個更直觀的瞭解,以後再發郵件的時候,腦子裡會不會自動浮現出你的郵件管理程式在背後傳送的這一連串請求呢?
感謝您的支援,如果您有任何疑問,或者建議,或者還想看什麼簡單的探索計算機的小實驗,歡迎給我留言!