2018年10月17日 星期三

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

本篇繼續未完的 Gmail 寄送郵件測試, 上一篇測試文使用 Python 內建的 smtplib 模組只能傳送 ASCII 編碼之純文字郵件, 無法傳送 HTML, 圖片, 影音等多媒體資訊, 參考 :

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

模組 smtplib 詳細文件參考 :

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

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

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

在上一篇文章中使用 smtplib 的 sendmail() 函數傳送郵件, 但那只適用於 ASCII 編碼之郵件內容, 例如英數字或符號等, 如果參數 msg 中含有中文字元, 則將出現 UnicodeEncodeError 錯誤訊息, 例如 :

TLS 版 :

>>> 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')
>>> smtp.sendmail("mygmail@gmail.com", "myhinetmail@ms5.hinet.net", "您好")
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
  File "C:\Python36\lib\smtplib.py", line 854, in sendmail
    msg = _fix_eols(msg).encode('ascii')
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

SSL 版 :

>>> import smtplib
>>> smtpssl=smtplib.SMTP_SSL("smtp.gmail.com", 465)
>>> 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')
>>> smtpssl.sendmail("mygmail@gmail.com", "myhinetmail@ms5.hinet.net", "您好")
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
  File "C:\Python36\lib\smtplib.py", line 854, in sendmail
    msg = _fix_eols(msg).encode('ascii')
UnicodeEncodeError: 'ascii' codec can't encode characters in position 38-39: ordinal not in range(128)

除了編碼問題外, 單單使用 smtplib 的 sendmail() 也無法傳送 HTML 或圖片, 聲音, 視訊等二進位檔案, 解決此問題的辦法是使用多用途郵件擴展協定 MIME (Multiplepurpose Internet Mail Extensions), 參考 :

多用途郵件擴展協定
MIME協議在郵件中的應用詳解

MIME 協定原本是為了解決舊的 Email 傳輸協定 RFC822 無法傳送多媒體格式資料的問題, 後來也被 HTTP 協定採用為標準的一部份. MIME 主要由 type (資料型態) 與 subtype (次型態) 構成, type 有四種 :

 MIME 資料型態 type 說明
 text 文字
 image 圖片
 audio 音訊
 video 視訊
 multipart 連結各部分資料為一整體資訊
 message 包裝 Email 訊息
 application 應用程式資料或二進位資料

常用的次型態 subtype 如下表所示 :

 MIME 資料次型態 subtype 說明
 application/octet-stream 二進位資料
 application/pdf Adobe pdf 文件
 application/msword Microsoft Word 文件
 text/plain 純文字
 text/html HTML 網頁
 image/gif GIF 圖片
 image/png PNG 圖片
 image/jpeg JPEG 圖片
 video/mp4 MP4 影片
 video/mpeg MPEG 影片
 video/ogg OGG 影片
 video/webm WEBM 影片
 multipart/x-www-form-urlencoded HTTP 以 POST 提交之表單
 multipart/form-data HTTP 以 POST 提交之表單有附檔上傳

Python 內建模組 email.mime.text 的 MIMEText 類別支援 MIME 協定, 可以處理非 ASCII 編碼文字郵件與二進位檔問題, 其介面如下 :

email.mime.text.MIMEText(text [, subtype [, charset]])

這些參數都是字串, 說明如下 :
  1. text : 要傳送的資料 (文字, 圖片, 視訊, 檔案等)
  2. subtype : 資料的次型態, 預設為 "plain" (純文字)
  3. charset : 資料編碼類型, 預設為 "us-ascii"
此處 subtype 只要傳入上表中斜線後面的部分即可, 例如預設之 "plain". 傳送含中文資訊時 charset 要用 "utf-8" (unicode 編碼).

使用 MIME 協定傳送多媒體郵件前須從 email.mime.text 模組匯入 MIMEText() 類別 :

>>> from email.mime.text import MIMEText 

然後呼叫 MIMEText() 建構子建立 MIME 物件 :

>>> mime=MIMEText("您好! 我是 Tony.", "plain", "utf-8") 
>>> type(mime) 
<class 'email.mime.text.MIMEText'> 

接著便可設定 MIME 物件之下列信件屬性 :
  1. Subject : 主旨
  2. From : 寄件人暱稱
  3. To : 收件人暱稱
  4. Cc : 副本收件者 Email 位址 (多個用逗號隔開)
注意, 這四個屬性值均為字串.

>>> mime["Subject"]="Gmail sent by Python scripts(MIME)"
>>> mime["From"]="Your best friend"
>>> mime["To"]="mailgroup"
>>> mime["Cc"]="myyahoomail@yahoo.com, mycompanymail@blablabla.com.tw"
>>> msg=mime.as_string()    #物件轉成字串
>>> type(msg)
<class 'str'>
>>> print(msg)
'Content-Type: text/plain; charset="utf-8"\nMIME-Version: 1.0\nContent-Transfer-Encoding: base64\nSubject: Gmail sent by Python scripts(MIME)\nFrom: Your best friend\nTo: mailgroup\nCc: myyahoomail@yahoo.com, mycompanymail@blablabla.com.tw\n\n5oKo5aW9ISDmiJHmmK8gVG9ueS4=\n'

可見轉成字串後多了 Content-Type, MIME-Version, 以及 Content-Transfer-Encoding 三個參數.

準備好 MIME 字串後便可連線 Gmaail 主機傳送信件了 :

>>> 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"]
>>> status=smtp.sendmail(from_addr, to_addr, msg)
>>> if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
郵件傳送成功!
>>> smtp.quit()
(221, b'2.0.0 closing connection x23-v6sm18066135pfh.56 - gsmtp')




完整程式如下 :


測試 1 : 用 MIME 傳送中文信件  

import smtplib
from email.mime.text import MIMEText

mime=MIMEText("您好! 我是 Tony.", "plain", "utf-8")
mime["Subject"]="Gmail sent by Python scripts(MIME)"
mime["From"]="Your best friend"
mime["To"]="mailgroup"
mime["Cc"]="myyahoomail@yahoo.com, mycompanymail@blablabla.com.tw"
msg=mime.as_string()  

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"]
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()

如果要傳送的內容是 HTML 的話, subtype 要設為 "html", 例如 :


測試 2 : 用 MIME 傳送 HTML 信件

import smtplib
from email.mime.text import MIMEText

html="""
<!doctype html>
<html>
<head>
  <meta charset='utf-8'>
  <title>HTML mail</title>
</head>
<body>
  <b>HTML 郵件測試</b>
</body>
</html>
"""
mime=MIMEText(html, "html", "utf-8")
mime["Subject"]="Gmail sent by Python scripts(MIME)"
mime["From"]="Your best friend"
mime["To"]="mailgroup"
mime["Cc"]="myyahoomail@yahoo.com, mycompanymail@blablabla.com.tw"
msg=mime.as_string()
print(msg)

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"]
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()




此程式中的 print(msg) 將轉成字串後的信件內容列印出來, 如下所示 :

>>> %Run test.py

Content-Type: text/html; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Subject: Gmail sent by Python scripts(MIME)
From: Your best friend
To: mailgroup
Cc: mycompanymail@blablabla.com.tw

CjwhZG9jdHlwZSBodG1sPgo8aHRtbD4KPGhlYWQ+CiAgPG1ldGEgY2hhcnNldD0ndXRmLTgnPgog
IDx0aXRsZT5IVE1MIG1haWw8L3RpdGxlPgo8L2hlYWQ+Cjxib2R5PgogIDxiPkhUTUwg6YO15Lu2
5ris6KmmPC9iPgo8L2JvZHk+CjwvaHRtbD4K

郵件傳送成功!

其中最底下的一堆怪碼就是以 unicode 編碼後的 html 郵件內容.

傳送文字檔附件須以 binary 方式讀取檔案後, 以 base64 次型態編碼成 ASCII 字元傳送, 關於 base64 參考 :

# https://zh.wikipedia.org/wiki/Base64

另外, MIME 物件須設定 Content-Type 屬性為 octet-stream, Content-Disposition 屬性設為 attachment 並指定檔案名稱, 例如 :


測試 3 : 以郵件傳送純文字附檔

import smtplib
from email.mime.text import MIMEText

with open("test.txt", "rb") as file:
    filecontent=file.read()

mime=MIMEText(filecontent, "base64", "utf-8")   
mime["Content-Type"]="application/octet-stream"   
mime["Content-Disposition"]='attachment; filename="test.txt"'
mime["Subject"]="Gmail sent by Python scripts(附檔測試)"
mime["From"]="Your best friend"
mime["To"]="mailgroup"
mime["Cc"]="mycompanymail@blablabla.com.tw"
msg=mime.as_string()
print(msg)

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"]
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()

此程式開啟所在目錄底下的文字檔 test.txt, 其內容如下 :

"您好! 這是附檔測試."

Hinet Webmail 收到的信件如下, 可見信件中有附件 test.txt 檔 :




注意, 在 MIME 物件的 Content-Disposition 中, 附檔的檔名必須用雙引號括起來, 不要用單引號, 整個屬性則用單引號, 如下所示 :

mime["Content-Disposition"]='attachment; filename="test.txt"'

如果附檔的檔名用單引號, 整個屬性就要用雙引號括起來 :

mime["Content-Disposition"]="attachment; filename='test.txt'"

但是這樣收到的附件檔名會變成 'test.txt', 即兩邊的單引號會變成檔名的一部分, 下載下來後還要變更檔案名稱很麻煩, 如下所示 :




在郵件中傳送圖檔須使用內建模組的 email.mime.image.MIMEImage 類別, 其介面如下 :

email.mime.image.MIMEImage(_imagedata[, _subtype[, _encoder[, **_params]]])

處理附帶圖檔之郵件要匯入如下模組與類別 :

import smtplib
from email.mime.text import MIMEText
from email.mime.image import MIMEImage

圖檔以 binary 方式讀取後傳給 MIMEImage 類別的建構子建立 mime 物件, 與傳送文字檔案一樣指定其 Content-Type 屬性為 "application/octet-stream", 於 Content-Disposition 屬性中指定附檔檔名即可, 例如 :


測試 4 : 以郵件傳送圖檔

import smtplib
from email.mime.text import MIMEText
from email.mime.image import MIMEImage

with open("桌面_海灣.jpg", "rb") as file:
    filecontent=file.read()

mime=MIMEImage(filecontent)
mime["Content-Type"]="application/octet-stream"
mime["Content-Disposition"]='attachment; filename="桌面_海灣.jpg"'
mime["Subject"]="Gmail sent by Python scripts(圖片附檔測試)"
mime["From"]="Your best friend"
mime["To"]="mailgroup"
mime["Cc"]="mycompanymail@blablabla.com.tw"
msg=mime.as_string()
print(msg)

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"]
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()

此例傳送程式目錄下的 "桌面_海灣.jpg" 圖檔, 用 Hinet webmail 收信如下 :




奇怪的是檔名變成 "HiNet-ATT001" 且沒有副檔名 jpg, 但下載添加副檔名 jpg 後開啟卻又是是預期之圖檔 :




改用 Outlook 開啟收到的 Email, 收到的附檔是副檔名為 .dat 的附檔 :




下載 .dat 附檔後更改副檔名為 .jpg 同樣是預期的圖檔. 可見不同的郵件伺服器對 "application/stream-octet" 次型態資料附檔有不同的處理方式.

不過既然是 jpg 檔, 次型態不是應該用 "image/jpeg" 嗎? 我將上面的範例如下 :


測試 5 : 以郵件傳送圖檔 (修正)

import smtplib
from email.mime.text import MIMEText
from email.mime.image import MIMEImage

with open("桌面_海灣.jpg", "rb") as file:
    filecontent=file.read()

mime=MIMEImage(filecontent, "image/jpeg")
mime["Content-Type"]="image/jpeg"
mime["Content-Disposition"]='attachment; filename="桌面_海灣.jpg"'
mime["Subject"]="Gmail sent by Python scripts(圖片附檔測試)"
mime["From"]="Your best friend"
mime["To"]="mailgroup"
#mime["Cc"]="mycompanymail@blablabla.com.tw"
msg=mime.as_string()
print(msg)

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"]
status=smtp.sendmail(from_addr, to_addr, msg)
if status=={}:
    print("郵件傳送成功!")
else:
    print("郵件傳送失敗!")
smtp.quit()

結果在 Hinet webmail 上收到的附檔依然是沒有副檔名的 HiNet-ATT001, 下載後添加副檔名 .jpg 也是正確之圖檔. 但是在 Outlook 上收信則明確帶了 .jpg 的副檔名 :




可見指定正確的 subtype 還是有作用的, 但 Hinet webmail 郵件主機無法帶上正確副檔名還是個問題.

參考 :

Python加密與解密
python字符串加密解密的三种方法分享(base64 win32com)

沒有留言 :