2018年10月17日 星期三

Python 學習筆記 : 以 Gmail 寄送郵件的方法 (一)

去年為了能從外網連線位於鄉下的 Raspberry Pi 3 主機, 必須知道目前的 ADSL 外網 IP, 因此參考網路文件做法改寫 Python 程式, 透過我的 Hinet 郵件信箱傳送最新的外網 IP, 目前為止運作正常, 參考:

從外網以 SSH 存取樹莓派的方法

最近在 "Python:網路爬蟲與資料分析入門實戰" 看到作者介紹如何用 Python內建的 smtplib 與 email.mime.text  模組透過 Gmail 傳送郵件, 想起之前玩 GAE 時也有用過類似功能, 不過使用的是 GAE 所提供的 mail API, 不是 smtplib, 參考 :

利用 GAE 與 Gmail 傳送電子郵件

Python 內建的 smtplib 詳細文件參考 :

https://docs.python.org/3/library/smtplib.html

本篇測試主要參考了下列書籍 :

# Python 入門邁向高手之路-王者歸來 (深石, 洪錦魁) 第 26 章
Python:網路爬蟲與資料分析入門實戰 (博碩, 林俊瑋) 第 8 章

在 Internet 上傳送郵件使用的是 SMTP 協定 (Simple Mail Transfer Protocol), 不過 SMTP 本身並沒有內建保密功能, 為了通訊的保密性 (security) 與完整性 (Integrity), 後來又制定了 SSL (Secure Socket Layer) 與 TLS (Transport Layer Seccurity) 保密協定, 其中 SSL 是比較舊的安全協定, 最新版是 3.0, 而 TLS 則是 SSL 的改良版, 可說是 SSL v3.1 版. 在協定堆疊中 SSL/TLS 位置介於 SMTP (應用層) 協議與 TCP 傳輸層之間, 兩者結構都是由上層 Hanshake Layer (握手層) 與下層 Record Layer (紀錄層) 兩個子層組成, SMTP 要傳送的資料透過 SSL/TLS 加密後傳送較為安全, 參考  :

簡單郵件傳輸協定
傳輸層安全性協定
# SSL/TLS and SMTP
簡述SSL與TLS之間的關係
# 淺談HTTPS協議和SSL、TLS之間的區別與關係

以下是用 smtplib 透過 Gmail 傳送郵件的測試紀錄, 

1. 匯入 smtplib 模組 : 

Python 內建 smtplib 模組可用來傳送郵件, 使用前需先匯入 smtplib :

>>> import smtplib 

然後呼叫 SMTP_SSL() 或 SMTP() 方法與郵件伺服器建立 SMTP 安全連線, 如果郵件伺服器使用的是舊版的 SSL 傳輸安全憑證就呼叫 SMTP_SSL(); 若使用新版的 TLS 安全憑證就呼叫 SMTP().

2. 建立 SMTP 安全連線 :

Gmail 郵件主機同時支援 SSL 與 TLS 安全憑證, SSL 主機埠號為 465; 而 TLS 主機埠號為 587 :

 Gmail 安全憑證 建立連線呼叫方法
 SSL smtplib.SMTP_SSL("smtp.gmail.com", 465)
 TLS smtplib.SMTP("smtp.gmail.com", 587)

例如 :

>>> smtpssl=smtplib.SMTP_SSL("smtp.gmail.com", 465)  #SSL
>>> smtp=smtplib.SMTP("smtp.gmail.com", 587)     #TSL


3. 呼叫 ehlo() 向郵件主機註冊身份 : 

建立連線後必須呼叫 ehlo() 方法向郵件主機註冊身份 (Identification), 成功的話郵件主機會回應包含代碼 250 的 tuple, 注意, 這是必要的過程, 否則後面將無法登入主機.

SSL 主機回應如下 :

>>> import smtplib 
>>> smtpssl=smtplib.SMTP_SSL("smtp.gmail.com", 465) 
>>> type(smtpssl)
<class 'smtplib.SMTP_SSL'> 
>>> smtpssl.ehlo()
(250, b'smtp.gmail.com at your service, [111.254.51.104]\nSIZE 35882577\n8BITMIME\nAUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH\nENHANCEDSTATUSCODES\nPIPELINING\nCHUNKING\nSMTPUTF8')

TSL 主機回應如下 :

>>> import smtplib 
>>> smtp=smtplib.SMTP("smtp.gmail.com", 587) 
>>> type(smtp) 
<class 'smtplib.SMTP'> 
>>> smtp.ehlo()   
(250, b'smtp.gmail.com at your service, [111.254.51.104]\nSIZE 35882577\n8BITMIME\nSTARTTLS\nENHANCEDSTATUSCODES\nPIPELINING\nCHUNKING\nSMTPUTF8')


4. 呼叫 login() 登入郵件主機 :

用 chlo() 與郵件主機打完招呼後即可呼叫 login() 登入 Gmail 郵件主機, 但是不論用 SSL 或 TLS 連線都出現 "smtplib.SMTPNotSupportedError" 錯誤 :

>>> smtp.login("myaccount@gmail.com", "mypassword") 
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
  File "C:\Python36\lib\smtplib.py", line 696, in login
    "SMTP AUTH extension not supported by server.")
smtplib.SMTPNotSupportedError: SMTP AUTH extension not supported by server.

>>> smtpssl.login("myaccount@gmail.com", "mypassword")
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
  File "C:\Python36\lib\smtplib.py", line 729, in login
    raise last_exception
  File "C:\Python36\lib\smtplib.py", line 720, in login
    initial_response_ok=initial_response_ok)
  File "C:\Python36\lib\smtplib.py", line 641, in auth
    raise SMTPAuthenticationError(code, resp)
smtplib.SMTPAuthenticationError: (534, b'5.7.9 Application-specific password required. Learn more at\n5.7.9  https://support.google.com/mail/?p=InvalidSecondFactor f83-v6sm10706249pfa.109 - gsmtp')

這是因為我的 Gmail 帳號有開啟兩段式驗證, 所以不能直接使用 Gmail 密碼 (程式沒辦法收簡訊啊!) , 必須進入 Google 帳號管理為應用程式產生特別的密碼才行. 書上說可以在 Google 帳號管理中啟用低安全性應用程式的存取權限應該是針對未開啟兩段式驗證的用戶, 兩段式用戶必須參考下列程序為應用程式產生專用密碼, 參考 :

https://myaccount.google.com/lesssecureapps




首先登入 Google 帳戶管理 :




點選 "登入和安全性" 底下的 "登入 Google" :




輸入密碼登入 Google (可能需收簡訊做兩段式驗證) :




登入成功後將 "登入和安全性" 網頁往下拉到 "應用程式密碼" 項下, 選取應用程式名稱為最底下的 "其他 (自訂名稱)" :




輸入一個自訂名稱例如 Python scripts, 按 "產生" :




在彈出頁面中顯示了一組應用程式專用密碼, 須立即複製到記事本中儲存, 因為只要關閉此頁面便無法查出此密碼, 只能刪除再重新產生 :





經過這樣產生的專用密碼才能讓我們的 Python 應用程式避開兩段式驗證, 順利以 SSL/TLS 認證方式登入 Gmail 郵件主機:

>>> smtpssl.login("mygmail@gmail.com", "yguxhsurqwpseksw")
(235, b'2.7.0 Accepted')

收到 235 回應表示已經成功登入 Gmail 郵件主機了.

若使用 TLS 認證, 則須先呼叫 starttls() 起動 TLS 加密模式後再呼叫 login() :

>>> smtp.starttls() 
(220, b'2.0.0 Ready to start TLS')
>>> smtp.login("mygmail@gmail.com", "yguxhsurqwpseksw")
(235, b'2.7.0 Accepted')

呼叫 starttls() 若收到 220 回應, 表示 Gmail 郵件主機已準備好對要傳送之郵件進行 TLS 加密, 這時再呼叫 login() 就可收到 235 登入成功回應. 注意, 一定要先呼叫 starttls() 才能呼叫 login(), 否則會出現如下錯誤訊息 :

>>> smtp.login("mygmail@gmail.com", "yguxhsurqwpseksw")
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
  File "C:\Python36\lib\smtplib.py", line 696, in login
    "SMTP AUTH extension not supported by server.")
smtplib.SMTPNotSupportedError: SMTP AUTH extension not supported by server.

如果採用 SSL 認證, 在呼叫 SMTP_SSL() 時即已與郵件主機協議好加密方式了, 因此不需要一個 startssl() 函數.


5. 呼叫 sendmail() 傳送郵件 

登入 Gmail 主機成功後即可呼叫 sendmail() 傳送以 ASCII 編碼之郵件 (不能傳送中文等非 ASCII 文字), 此函數介面如下 :

sendmail(from_addr, to_addr, msg, mail_options=(), rcpt_options=())

前面三個參數 from (寄件者), to (收件者), 與 msg (訊息) 就是傳送信件的主要部分 :
  1. from_addr : 寄件者郵件位址 (字串)
  2. to_addr : 收信者郵件位址 (字串串列)
  3. msg : 信件內容 (字串)
其中收信者 to_addr 是字串 list, 亦即可同時傳送給多個收信者, 只有一個收信者時也可以傳入字串. 第三參數 msg 字串須以 "Subjects:" 開頭, 它後面跟的字串就會被當作信件主旨, 直到出現 "\n" 為止, 第一個跳行字元 "\n" 後面的就是信件主體 (body), 如果沒有照此格式, 則整個 msg 字串會被當作內容, 傳送出去的信件將無主旨 (Subject).

使用 TLS 驗證 :

>>> smtp.sendmail("mygmail@gmail.com", "myhinetmail@msa.hinet.net", "Subject:Gmail sent by Python scripts\nHello World!")
{}

使用 SSL 驗證 :

>>> smtpssl.sendmail("mygmail@gmail.com", "myhinetmail@msa.hinet.net", "Subject:Gmail sent by Python scripts\nHello World!")
{}

郵件傳送成功的話傳回值為一個空的 dict 物件 {}, 傳送失敗會將錯誤訊息以 key:value 鍵值對傳回. 此處我是從 Gmail 傳送信件到 Hinet 郵件信箱, 檢查確實有收到 Python script 傳送的信件 :




6. 關閉郵件主機連線 :

傳送完成呼叫 quit() 或 close() 關閉連線 :

>>> smtp.quit()       #TSL 版
(451, b'4.4.2 Timeout - closing connection. s23-v6sm10897937pgg.67 - gsmtp')

>>> smtpssl.quit()   #SSL 版
(221, b'2.0.0 closing connection v189-v6sm10893679pfb.54 - gsmtp')

完整的程式如下 :


測試 1 : 使用 TLS 驗證傳送 ASCII 編碼之郵件  

#Send mail by Gmail with TLS
import smtplib
smtp=smtplib.SMTP("smtp.gmail.com", 587)
smtp.ehlo()
smtp.starttls()
smtp.login("mygmail@gmail.com", "yguxhsurqwpseksw")
from_addr="mygmail@gmail.com"
to_addr="myhinetmail@msa.hinet.net"
msg="Subject:Gmail sent by Python scripts\nHello World!"
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()


測試 2 : 使用 SSL 驗證傳送 ASCII 編碼之郵件

#Send mail by Gmail with SSL
import smtplib
smtpssl=smtplib.SMTP_SSL("smtp.gmail.com", 465)
smtpssl.ehlo()
smtpssl.login("mygmail@gmail.com", "yguxhsurqwpseksw")
from_addr="mygmail@gmail.com"
to_addr="myhinetmail@msa.hinet.net"
msg="Subject:Gmail sent by Python scripts\nHello World!"
status=smtpssl.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtpssl.quit()


函數 sendmail() 的第二參數 to_addr 如果傳入一個由收信者 Email 組成之串列即可同時將郵件傳給多個收信者, 例如 :


測試 3 : 傳送郵件給多個收信者

#Send mail by Gmail with TLS
import smtplib
smtp=smtplib.SMTP("smtp.gmail.com", 587)
smtp.ehlo()
smtp.starttls()
smtp.login("mygmail@gmail.com", "yguxhsurqwpseksw")
from_addr="mygmail@gmail.com"
to_addr=["myhinetmail@msa.hinet.net", "myyahoomail@yahoo.com"]
msg="Subject:Gmail sent by Python scripts\nHello World!"
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()

用 Outlook 收信會發現, 不管是單一收信者或多個收信者, 在收信者那欄顯示的是 "Undisclosed-recipient" :




如果要在收信者欄顯示收信者 Email 或名稱, 須在 msg 參數中加入 "To :" 字串, 例如 :


測試 4 : 用 "To :" 設定收信者或群組名稱

#Send mail by Gmail with TLS
import smtplib
smtp=smtplib.SMTP("smtp.gmail.com", 587)
smtp.ehlo()
smtp.starttls()
smtp.login("mygmail@gmail.com", "yguxhsurqwpseksw")
from_addr="mygmail@gmail.com"
to_addr=["myhinetmail@msa.hinet.net", "myyahoomail@yahoo.com"]
msg="Subject:Gmail sent by Python scripts\nTo:mailgroup\nHello World!"
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()




可見加上 "To:" 之後收件者欄就不會空白或顯示 "Undisclosed-recipients" 了.

使用 smtplib 傳送郵件時對方收到的寄件人欄預設是 from_addr 參數之值, 但可以在 msg 參數中用 "From:" 字串在預設值 (Email) 前面添加暱稱以利辨認, 例如 :


測試 5 : 用 "From :" 修改寄件人預設值

#Send mail by Gmail with TLS
import smtplib
smtp=smtplib.SMTP("smtp.gmail.com", 587)
smtp.ehlo()
smtp.starttls()
smtp.login("mygmail@gmail.com", "yguxhsurqwpseksw")
from_addr="mygmail@gmail.com"
to_addr=["myhinetmail@msa.hinet.net", "myyahoomail@yahoo.com"]
msg="Subject:Gmail sent by Python scripts\n\
From:Your best friend\nTo:mailgroup\nHello World!"
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()

此程式中 msg 參數多了 "From:" 字串, 後面帶的 "Your best friend" 即為暱稱字串. 在 Hinet Webmail 收信結果如下 :




在 Outlook 收信結果如下 :




可見來自 from_addr 參數的 Email 還在, 只是前面會冠上 msg 參數中 "From:" 字串後面的暱稱字串.

參數 msg 還可用 "Cc:" 字串設定副本收件者, 可用逗號 "," 號隔開多個副本收件者 Email, 例如 ;

 測試 6 : 用 "Cc :" 副本收件人

#Send mail by Gmail with TLS
import smtplib
smtp=smtplib.SMTP("smtp.gmail.com", 587)
smtp.ehlo()
smtp.starttls()
smtp.login("mygmail@gmail.com", "yguxhsurqwpseksw")
from_addr="mygmail@gmail.com"
to_addr=["myhinetmail@msa.hinet.net"]
msg="Subject:Gmail sent by Python scripts\n\
From:Your best friend\n\
To:mailgroup\n\
Cc:myyahoomail@yahoo.com, mygmail2@gmail.com\n\
Hello World!"
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()




此程式內的 msg 參數添加了以 "Cc:" 開頭的子字串, 並以逗號分隔兩個副本收件人, 在 Hinet Webmail 可看到出現包含兩個收件人的副本欄位.

如果只是傳送英文郵件的話, 單單使用 smtplib 即可透過 Gmail 傳送郵件, 但若要傳遞含有中文訊息或圖片, 影音等附檔的郵件, 便需要 email.mime.text 模組提供之 MIME 協定功能.

參考 :

RFC822:电子邮件的基本框架

3 則留言 :

cheming 提到...

厲害厲害

Unknown 提到...

感謝你!!我是在CALIBRE軟體遇到錯誤,同個方法可解!

John 提到...

Helpful. Thanks for the sharing!