本文將使用Python從零實現一個簡易的郵件客戶端,通過本文你將對SMTP協議有更深入的了解,同時掌握使用Python實現標准協議的經驗。
我們將開發一個簡單的郵件客戶端,將郵件發送給任意收件人。我們的客戶端將需要連接到郵件服務器(QQ郵件服務器),使用SMTP協議與郵件服務器進行對話,並向郵件服務器發送電子郵件。
Python提供了一個名為smtplib
的模塊,它內置了使用SMTP協議發送郵件的方法。但是我們不會使用此模塊,因為它隱藏了SMTP和套接字編程的細節,我們將完全從零開始實現自己的郵件客戶端。
1.基本郵件客戶端
我們先來了解SMTP客戶和SMTP服務器之間交換報文時在客戶端需要發送哪些命令,服務器又是如何對每個命令作出回答,其中每個回答含有一個響應碼和英文解釋。
命令 | 含義 | 響應碼及其英文解釋 |
---|---|---|
HELO <domain><CRLF> | HELLO的縮寫,客戶端為標識自己的身份而發送的命令,通常帶域名 | 220 <domain> Service ready |
MAIL FROM: <reverse-path><CRLF> | 標識郵件的發件人,<reverse-path>為發送者的地址,此命令告訴接收方一個新郵件發送的開始,並對所有的狀態和緩沖區進行初始化 | 250 Requested mail action okay, completed |
RCPT TO: <forward-path><CRLF> | 標識郵件的收件人,<forward-path>為收件人的地址 | 250 Requested mail action okay, completed |
DATA<CRLF> | 標識郵件數據傳輸的開始,<CRLF>.<CRLF>標識數據的結尾,客戶端發送的、用於啟動郵件內容傳輸的命令 | 354 Start mail input; end with <CRLF>.<CRLF> |
QUIT<CRLF> | 表示會話的終止 | 221 <domain> Service closing transmission channel |
注意:<CRLF>中的CR和LF分別表示回車和換行。SMTP響應碼的每一個數字都是有特定含義的,如第一位數字為2時表示命令成功,為5時表示失敗,為3時表示沒有完成。
以上命令是正常完成一次郵件傳輸的必不可少的命令,我們將在后面的代碼中用到它們,更多的命令這里不做更多的介紹。下面我們來看代碼實現:
from socket import *
from base64 import b64encode
global clientSocket
def init():
global clientSocket
mail_server = 'smtp.qq.com'
clientSocket = socket(AF_INET, SOCK_STREAM)
while True:
clientSocket.connect((mail_server, 25))
recv = clientSocket.recv(1024).decode()
print(recv)
if recv[:3] == '220':
print('成功與郵件服務器建立TCP連接!')
break
def command_send(command, success_code, data_type='str'):
if data_type == 'str':
command = command.encode()
while True:
print(command)
clientSocket.send(command)
recv = clientSocket.recv(1024).decode()
print(recv)
if recv[:3] == success_code:
break
else:
print('Failed')
if __name__ == "__main__":
init()
heloCommand = 'HELO Alice\r\n'
command_send(heloCommand, '250')
authLoginCommand = 'AUTH LOGIN\r\n'
command_send(authLoginCommand, '334')
user = b64encode('你的QQ郵箱賬戶'.encode()) + b'\r\n'
command_send(user, '334', 'bytes')
pwd = b64encode('你的QQ郵箱授權碼'.encode()) + b'\r\n'
command_send(pwd, '235', 'bytes')
mailFromCommand = 'MAIL FROM: <發送方郵箱地址>\r\n'
command_send(mailFromCommand, '250')
reptToCommand = 'RCPT TO: <接收方郵件地址>\r\n'
command_send(reptToCommand, '250')
dataCommand = 'DATA\r\n'
command_send(dataCommand, '354')
msg = "FROM: 發送方郵箱地址\r\nTO: 接收方郵件地址\r\nSubject: Say Hello\r\n\r\nHello World!"
clientSocket.send(msg.encode())
endmsg = '\r\n.\r\n'
command_send(endmsg, '250')
quitCommand = 'QUIT\r\n'
command_send(quitCommand, '221')
發送命令和接收響應是每一步都需要做的事情,因此我們其封裝到command_send
函數中以降低代碼冗余,事實上每一個步驟都有出錯的可能,而我們處理差錯的方法也很簡單:一直循環直到成功為止。
通過觀察代碼不難發現AUTH LOGIN
命令是我們沒介紹的,這是因為我們所采用的QQ郵件服務器要求進行安全認證。最初的SMTP協議並不包含安全認證,而ESMTP通過增加命令EHLO和AUTH在安全性方面擴展了SMTP。如今的SMTP服務器,無論是公網的還是內網的,大多都要求安全認證。
通過向服務器發送EHLO
命令(格式為EHLO <domain><CRLF>
),客戶端可以了解到服務器是否支持擴展簡單郵件傳輸協議(ESMTP)。我們向QQ郵件服務器發送該命令收到的響應如下:
250-newxmesmtplogicsvrsza5.qq.com
250-PIPELINING
250-SIZE 73400320
250-STARTTLS
250-AUTH LOGIN PLAIN XOAUTH XOAUTH2
250-AUTH=LOGIN
250-MAILCOMPRESS
250 8BITMIME
上述響應中的AUTH LOGIN PLAIN XOAUTH XOAUTH2
說明了SMTP服務器支持的驗證方式,這里我們采用的是LOGIN
方式,具體步驟如下:
- 客戶端發送
AUTH LOGIN
命令,指示服務器進行身份認證; - 客戶端收到
334 VXNlcm5hbWU6
響應后發送BASE64編碼的賬戶名; - 客戶端收到
334 UGFzc3dvcmQ6
響應后發送BASE64編碼的密碼; - 客戶端收到
235 Authentication successful
響應后表明身份驗證成功;
注意:對於QQ郵件服務器而言,步驟3中輸入的不是賬戶的登錄密碼而是授權碼,在QQ郵箱中開通POP3/SMTP服務需要生成授權碼,具體方法此處不介紹。
2.添加安全套接字層
SSL(Secure Sockets Layer)
即安全套接層,是由Netscape公司於1990年開發,用於保障World Wide Web(WWW)
通訊的安全。其主要任務是提供私密性,信息完整性和身份認證。
SSL是一個不依賴於平台和運用程序的協議,位於TCP/IP
協議與各種應用層協議之間,為數據通信提高安全支持。HTTP是第一個使用SSL保障安全的應用層協議,我們常見的HTTPS的全稱就是HTTP over SSL
。
注意:HTTPS默認工作在443端口,而HTTP默認工作在80端口。
與HTTPS類似,諸如SMTP、POP3、IMAP等郵件協議也能支持SSL,基於SSL安全協議的SMTP協議被稱為SMTPS(SMTP over SSL)
。在本例中,為了添加安全套接層,我們需要對init()
函數進行修改,具體代碼如下:
import ssl
def init():
global clientSocket
mail_server = 'smtp.qq.com'
clientSocket = socket(AF_INET, SOCK_STREAM)
context = ssl.create_default_context()
while True:
clientSocket.connect((mail_server, 465))
clientSocket = context.wrap_socket(sock=clientSocket, server_hostname=mail_server)
recv = clientSocketSSL.recv(1024).decode()
print(recv)
if recv[:3] == '220':
print('成功與郵件服務器建立TCP連接!')
break
context
是SSL創建的默認上下文對象,對象中保存了我們對證書的認證與加密算法選擇的偏好設置。通過調用上下文對象的wrap_socket()
方法,表示由OpenSSL庫負責控制我們的TCP鏈接,然后與通信對方交換必要的握手信息,並建立加密鏈接,最終返回一個SSLSocket
對象,該對象負責進行所有的后續通信。
在與郵件服務器建立連接的過程中,我們連接的端口號是465,而不是25。這是因為465號端口是為SMTPS協議服務開放的,而25號端口是為SMTP協議服務開放的。
3.發送圖像信息
到目前為止,盡管我們的SMTP郵件客戶端可以正常工作,但是只能在電子郵件的正文中發送文本消息。本節我們將修改客戶端代碼,使其可以發送包含文本和圖像的電子郵件。
其實要實現我們的功能很簡單,只需要對郵件的格式動動手腳就可以了。下面簡要說明一下郵件的格式:
郵件是由郵件頭和郵件體構成的,郵件體又可能由文本、超文本和附件等多個部分構成,當在同一郵件體內有多個不同的數據集合時,我們必須在郵件頭中通過multipart
參數值顯式地指出這一點。郵件體的不同子部分之間是通過邊界boundary
封裝的,每一部分都會由邊界開始,然后包含着郵件子體的頭信息(header),空行,然后是郵件正文。需要指出的是,最后一個子部分的后面必須跟一個結尾邊界。
關於boundary
的使用方法,我們可以在Content-type
字段的后面是把boundary
的值包含在引號之中。也可以沒有引號,但有引號是最保險的。當有一些非法字符出現在boundary
值中時,如果不加引號可能會引起錯誤。在使用邊界封裝郵件時,其使用方法是在值的前面加兩個-
。其中,對於最后一個部分的結尾邊界,還需要在值的后面再加兩個-
。
對於客戶端代碼,其余部分不變,我們只需要修改郵件部分。接下來我們看下修改后的郵件部分:
if __name__ == "__main__":
...省略...
html_data = b64encode(b'<img src="cid:image1">')
with open("test.jpg", "rb") as f:
image_data = b64encode(f.read())
msg = "FROM: 發送方郵箱地址\r\nTO: 接收方郵箱地址\r\nSubject: Transmit Image by E-mail\r\nMIME-Version: " \
"1.0\r\nContent-Type:multipart/related; boundary='12345678'\r\n\r\n--12345678\r\nContent-Type: text/html; " \
"charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n".encode()
msg += html_data
msg += "\r\n\r\n--12345678\r\nContent-Type: image/jpeg; name='test.jpg'\r\nContent-Transfer-Encoding: " \
"base64\r\nContent-ID: image1\r\n\r\n".encode()
msg += image_data
msg += "\r\n\r\n--12345678--\r\n".encode()
clientSocketSSL.send(msg)
...省略...
可以看到,修改后的郵件由兩部分組成,一部分是HTML數據,另一部分則是圖片數據。此處我們通過HTML將圖片嵌入到了郵件正文中,具體方法是在HTML中通過引用src="cid:0"
就可以把附件作為圖片嵌入了。如果有多個圖片,則依次給它們編號,然后引用不同的cid:x
即可,例如這里我們將test.jpg
編號為image1
。
郵件的最終效果如下圖所示: