2025年7月21日 星期一

Python 學習筆記 : 用 Google SMTP 伺服器傳送 Email

這兩天在 Mapleboard 上安裝 postfix 與 mailutils 工具包成功地從命令列透過 Google SMTP 郵件伺服器傳送 Email, 其實也可以用 Python 的 smtplib 套件來做, 我之前在鄉下那台樹莓派 Pi 3 主機也是用這方法將光世代網路的外網 IP 寄到我的 Hinet 信箱以便能遠端連線, 參考 :


今天我將其中 Python 程式 reportip3.py 的寄信功能寫成一個 send_mail() 函式方便應用程式呼叫.


1. 傳送 ASCII 信件 : 

如果信件內容是英數字等 ASCII 編碼的字元, 可用下列函式 send_mail() 來傳送 : 

# smtp_mail_test_1.py
import smtplib

def send_mail(account, password, subject, from_addr, to_addr, cc_addr=None, body=''):
    if cc_addr is None:
        cc_addr=[]  # 預設空串列
    smtp=smtplib.SMTP('smtp.gmail.com', 587)
    smtp.ehlo()
    smtp.starttls()  # 啟動 TLS 安全傳輸
    smtp.login(account, password)  # 登入 Google SMTP 伺服器
    '''content=(
        f"Subject:{subject}\n"
        f"From:{from_addr}\n"
        f"To:{', '.join(to_addr)}\n"
        f"Cc:{', '.join(cc_addr)}\n"
        f"{body}"
        )'''
    content=(
        "Subject:{subject}\n"
        "From:{from_addr}\n"
        "To:{to_addr}\n"
        "Cc:{cc_addr}\n"
        "{body}"
        ).format(
            subject=subject,
            from_addr=from_addr,
            to_addr=', '.join(to_addr),
            cc_addr=', '.join(cc_addr),
            body=body
            )    
    all_recipients=to_addr + cc_addr  # 合併收件人和副本收件人
    status=smtp.sendmail(from_addr, all_recipients, content)
    if status == {}:
        print('郵件傳送成功!')
    else:
        print('郵件傳送失敗!')
    smtp.quit()

account='mygmail@gmail.com'     # Gmail 帳號
password='azfkqbjbftodjucd'         # Google 應用程式密碼 (這是樣本)
subject=''                   # 主旨
from_addr='mygmail@gmail.com'   # 寄件人
to_addr=['myhinet@ms5.hinet.net']  # 收件人
cc_addr=['tony@xxx.com.tw']      # 副本收件人(無設為 None)
body='您好, 這是測試信'                # 信件內容
send_mail(account, password, subject, from_addr, to_addr, cc_addr, body)

此處因為我的 Pi 3 安裝的 Python 是 3.5 版不支援 f 字串, 因此改用 format() 來塞變數到字串中. 如果是 Python 3.6+ 版就可以使用 f 字串了. 此程式是從我的 Gmail 信箱傳送 Email 到我的 Hinet 與公司信箱 (副本), 結果兩個信箱都有成功地收到郵件 : 




注意, 程式中的 password 並不是 Google 密碼, 而是啟用 Google 二階段驗證之後, 到下列網址產生的 Google 應用程式密碼 : 


在 "應用程式名稱" 框內輸入可辨識的名稱, 按右下角的 "建立" 鈕即可 : 





將產生的密碼複製下來, 去除中間的分隔用的空白字元後即可使用, 但最好先儲存到文字檔中備查, 因為它只顯示一次, 若沒記下來, 下次要用時須重新建立. 


2. 傳送非 ASCII 信件 : 

不過上面的函式只能用來傳送 ASCII 字元, 如果主旨與內容含有非 ASCII 字元例如中文, 執行時會出現 "UnicodeEncodeError: 'ascii' codec can't encode characters" 的錯誤訊息. 

解決辦法是用 Python 內建的 email 模組中的 email.mime.text.MIMEText 類別來處理 Unicode, 此外, 為了能寄出帶有 PDF與圖檔等附件的信件, 還需要 email.mime.multipart.MIMEMultipart 來打包郵件內容, 完整程式碼如下 :

# smtp_mail_test_2.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

def send_mail(account, password, subject, from_addr, to_addr, cc_addr=None, body=''):
    if cc_addr is None:
        cc_addr=[]  # 預設空串列
    # 登入 Google SMTP 伺服器
    smtp=smtplib.SMTP('smtp.gmail.com', 587)
    smtp.ehlo()
    smtp.starttls()  # 啟動 TLS 安全傳輸
    smtp.login(account, password)  # 登入 Google SMTP 伺服器
    # 建立郵件內容
    content=MIMEMultipart()
    content['Subject']=subject
    content['From']=from_addr
    content['To']=', '.join(to_addr)
    content['Cc']=', '.join(cc_addr)
    # 添加郵件正文
    content.attach(MIMEText(body, 'plain', 'utf-8'))
    # 合併收件人和副本收件人
    all_recipients=to_addr + cc_addr
    status=smtp.sendmail(from_addr, all_recipients, content.as_string())
    if status == {}:
        print('郵件傳送成功!')
    else:
        print('郵件傳送失敗!')
    smtp.quit()

account='mygmail@gmail.com'     # Gmail 帳號
password='azfkqbjbftodjucd'         # Google 應用程式密碼 (這是樣本)
subject='郵件測試'                   # 主旨
from_addr='mygmail@gmail.com'   # 寄件人
to_addr=['myhinet@ms5.hinet.net']  # 收件人
cc_addr=['tony@xxx.com.tw']      # 副本收件人(無設為 None)
body='您好, 這是測試信'                # 信件內容
send_mail(account, password, subject, from_addr, to_addr, cc_addr, body)

結果如下 :




如果只是傳送純文字的中文 Email, 那麼這個程式就堪用了. 但是如果傳送 HTML 格式的內容, 例如將 body 改為如下粗體 :

body='<b>您好, 這是測試信</b>' 

則它會以 HTML 碼純文字傳送, 結果如下 :




3. 傳送網頁格式信件 :    

如果郵件內容是 HTML 網頁碼, 則在呼叫建構式 MIMEText() 時要傳入 mode 參數為 'html' :

MIMEText(body, 'html', 'utf-8')

修改 send_mail() 函式添加一個 mode 參數來控制內容模式為 'plain' 或 'html' :

# smtp_mail_test_3.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

def send_mail(account, password, subject, from_addr, to_addr, cc_addr=None, body='', mode='html'):
    if cc_addr is None:
        cc_addr=[]  # 預設空串列
    # 登入 Google SMTP 伺服器
    smtp=smtplib.SMTP('smtp.gmail.com', 587)
    smtp.ehlo()
    smtp.starttls()  # 啟動 TLS 安全傳輸
    smtp.login(account, password)  # 登入 Google SMTP 伺服器
    # 建立郵件內容
    content=MIMEMultipart()
    content['Subject']=subject
    content['From']=from_addr
    content['To']=', '.join(to_addr)
    content['Cc']=', '.join(cc_addr)
    # 添加郵件正文
    if mode == 'html':
        content.attach(MIMEText(body, 'html', 'utf-8'))
    else:
        content.attach(MIMEText(body, 'plain', 'utf-8'))
    # 合併收件人和副本收件人
    all_recipients=to_addr + cc_addr
    status=smtp.sendmail(from_addr, all_recipients, content.as_string())
    if status == {}:
        print('郵件傳送成功!')
    else:
        print('郵件傳送失敗!')
    smtp.quit()

# 使用範例
account='mygmail@gmail.com'     # Gmail 帳號
password='azfkqbjbftodjucd'         # Google 應用程式密碼 (這是樣本)
subject='郵件測試'                         # 主旨
from_addr='mygmail@gmail.com'   # 寄件人
to_addr=['myhinet@ms5.hinet.net']  # 收件人
cc_addr=['tony@xxx.com.tw']          # 副本收件人(無設為 None)
body='<b><i>您好, 這是測試信<i><b>'   # 信件內容
mode='html'                        # 內容模式 'plain'/'html'
send_mail(account, password, subject, from_addr, to_addr, cc_addr, body, mode)

此處信件內容為粗體斜體的中文字, 結果如下 :




如果將 mode 改成純文字 mode='html' 就沒有網頁效果了 : 




4. 傳送 Text+HTML雙模格式信件 :   

上面的 send_mail() 函式呼叫時要用 mode 參數決定內容格式為純文字 (plain) 或網頁 (html) 格式, 純文字格式可以在所有郵件客戶端顯示; 網頁格式較美觀, 但有些郵件軟體不支援, 這時可能會直接顯示原始 HTML 碼, 空白或純文字亂碼等. 

解決辦法是採用 multipart/alternative 內容格式, 也就是同時傳送純文字內容 text_body 與網頁格式內容 html_body, 當收信端收到這種 multipart/alternative 格式信件時, 若支援 HTML 就會顯示網頁格式之內容 html_body; 反之則顯示純文字格式內容 text_body. 

修改 send_mail() 函式加入 text_body 與 html_body 參數並取消 mode 參數 :

# smtp_mail_test_4.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

def send_mail(account, password, subject, from_addr, to_addr, cc_addr=None, text_body='', html_body=''):
    if cc_addr is None:
        cc_addr=[]  # 預設空串列
    # 登入 Google SMTP 伺服器
    smtp=smtplib.SMTP('smtp.gmail.com', 587)
    smtp.ehlo()
    smtp.starttls()  # 啟動 TLS 安全傳輸
    smtp.login(account, password)  # 登入 Google SMTP 伺服器
    # 建立郵件內容
    content=MIMEMultipart()
    content['Subject']=subject
    content['From']=from_addr
    content['To']=', '.join(to_addr)
    content['Cc']=', '.join(cc_addr)
    # 添加郵件正文 (雙模)
    content.attach(MIMEText(text_body, 'plain', 'utf-8'))  # 純文字
    if html_body:
        content.attach(MIMEText(html_body, 'html', 'utf-8'))  # 網頁格式
    # 合併收件人和副本收件人
    all_recipients=to_addr + cc_addr
    status=smtp.sendmail(from_addr, all_recipients, content.as_string())
    if status == {}:
        print('郵件傳送成功!')
    else:
        print('郵件傳送失敗!')
    smtp.quit()

# 使用範例
account='mygmail@gmail.com'     # Gmail 帳號
password='azfkqbjbftodjucd'         # Google 應用程式密碼 (這是樣本)
subject='郵件測試'                         # 主旨
from_addr='mygmail@gmail.com'   # 寄件人
to_addr=['myhinet@ms5.hinet.net']  # 收件人
cc_addr=['tony@xxx.com.tw']          # 副本收件人(無設為 None)
text_body='您好, 這是測試信'         # 純文字信件內容
html_body='<b><i>您好, 這是測試信<i><b>'   # 網頁格式信件內容
send_mail(account, password, subject, from_addr, to_addr, cc_addr, text_body, html_body)

結果如下 :




5. 傳送有附檔之信件 :    

傳送附檔需要 open() 函式以二進位模式讀取附檔, 用 email.mime.application.MIMEApplication 類別傳給 MIMEApplication() 後加入信件本體中, 修改 send_mail() 函式添加 attachments 參數如下 : 

# smtp_mail_test_5.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication   
import os   

def send_mail(account, password, subject, from_addr, to_addr, cc_addr=None, text_body='', html_body=None, attachments=None):
    if cc_addr is None:
        cc_addr=[]  # 預設空串列
    if attachments is None:  # 可變資料不宜做參數
        attachments=[]       # 應付迴圈需求
    # 登入 Google SMTP 伺服器
    smtp=smtplib.SMTP('smtp.gmail.com', 587)
    smtp.ehlo()
    smtp.starttls()  # 啟動 TLS 安全傳輸
    smtp.login(account, password)  # 登入 Google SMTP 伺服器
    # 建立郵件內容
    content=MIMEMultipart()
    content['Subject']=subject
    content['From']=from_addr
    content['To']=', '.join(to_addr)
    content['Cc']=', '.join(cc_addr)
    # 添加郵件正文 (雙模)
    if not text_body and not html_body:  # text_body 與 html_body 均未傳
        text_body='(No message content)'   # 預設內容
    content.attach(MIMEText(text_body, 'plain', 'utf-8'))  # 純文字
    if html_body:
        content.attach(MIMEText(html_body, 'html', 'utf-8'))  # 網頁格式
    # 加入附件
    for filepath in attachments:
        if os.path.isfile(filepath):
            filename=os.path.basename(filepath)
            with open(filepath, 'rb') as f:
                part=MIMEApplication(f.read(), Name=filename)  
                part.add_header('Content-Disposition', 'attachment', filename=filename)
                content.attach(part)
        else:
            print(f'找不到附件:{filepath}')                
    # 合併收件人和副本收件人
    all_recipients=to_addr + cc_addr
    status=smtp.sendmail(from_addr, all_recipients, content.as_string())
    if status == {}:
        print('郵件傳送成功!')
    else:
        print('郵件傳送失敗!')
    smtp.quit()

# 使用範例
account='mygmail@gmail.com'     # Gmail 帳號
password='azfkqbjbftodjucd'         # Google 應用程式密碼 (這是樣本)
subject='郵件測試'                         # 主旨
from_addr='mygmail@gmail.com'   # 寄件人
to_addr=['myhinet@ms5.hinet.net']  # 收件人
cc_addr=['tony@xxx.com.tw']          # 副本收件人(無設為 None)
text_body='您好, 這是測試信'         # 純文字信件內容
html_body='<b><i>您好, 這是測試信<i><b>'   # 網頁格式信件內容
attachments=['cat1.jpg', 'cat2.jpg']  # 附檔
send_mail(account, password, subject, from_addr, to_addr, cc_addr, text_body, html_body, attachments)

藍色字的是為了傳送附檔所增加的部分, 結果如下 :




6. 處理主旨的 Unicode 與 Text+HTML 雙模問題 :    

在上面的測試中主旨含有中文 Unicode 都能順利傳送且在收信端正常顯示中文, 其實這些信件並不符合 RFC 標準格式 (因為 RFC 標頭只允許 ASCII 字元), 但因為目前多數的郵件客戶端會試圖去猜測編碼方式, 通常會自動用 UTF-8 來解釋非 ASCII 字元, 所以大都能正常顯示信件內容. 其實比較保險, 符合 RFC 規範的做法是用 email.header.Header 類別來處理主旨的 Unicode 問題, 只要將主旨內容傳給 Header() 建構式即可. 

其次, 我將上面的函式提交給 AI 檢查, 它建議雙模應該採取兩層結構, 把 text/plain 和 text/html 包在 MIMEMultipart('alternative') 中, 再讓它作為內容的一部分附加到 MIMEMultipart('mixed') 外層, 讓彼此在 multipart/alternative 中互為備案 (fallback 機制), 這樣才算是符合 RFC 標準, 否則部分收信端可能只會讀第一個文字段落而忽略 HTML 部分. 

# smtp_mail_test_6.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.header import Header  
import os

def send_mail(account, password, subject, from_addr, to_addr, cc_addr=None, text_body='', html_body=None, attachments=None):
    if cc_addr is None:
        cc_addr=[]  # 預設空串列
    if attachments is None:  # 可變資料不宜做參數
        attachments=[]       # 應付迴圈需求
    # 登入 Google SMTP 伺服器
    smtp=smtplib.SMTP('smtp.gmail.com', 587)
    smtp.ehlo()
    smtp.starttls()  # 啟動 TLS 安全傳輸
    smtp.login(account, password)  # 登入 Google SMTP 伺服器
    # 建立郵件內容
    content=MIMEMultipart('mixed')  # 外層 mixed
    content['Subject']=Header(subject, 'utf-8')   # 處理主旨 Unicode
    content['From']=from_addr
    content['To']=', '.join(to_addr)
    content['Cc']=', '.join(cc_addr)
    # 添加郵件正文 (雙模)    
    alt_part=MIMEMultipart('alternative')  # 內層:alternative(純文字 + HTML)
    alt_part.attach(MIMEText(text_body or '(No message content)', 'plain', 'utf-8'))
    if html_body:
        alt_part.attach(MIMEText(html_body, 'html', 'utf-8'))
    content.attach(alt_part)  # 將內層加入外層
    # 加入附件
    for filepath in attachments:
        if os.path.isfile(filepath):
            filename=os.path.basename(filepath)
            with open(filepath, 'rb') as f:
                part=MIMEApplication(f.read(), Name=filename)
                part.add_header('Content-Disposition', 'attachment', filename=filename)
                content.attach(part)
        else:
            print(f'找不到附件:{filepath}')                
    # 合併收件人和副本收件人
    all_recipients=to_addr + cc_addr
    status=smtp.sendmail(from_addr, all_recipients, content.as_string())
    if status == {}:
        print('郵件傳送成功!')
    else:
        print('郵件傳送失敗!')
    smtp.quit()

# 使用範例
account='mygmail@gmail.com'     # Gmail 帳號
password='azfkqbjbftodjucd'         # Google 應用程式密碼 (這是樣本)
subject='郵件測試'                         # 主旨
from_addr='mygmail@gmail.com'   # 寄件人
to_addr=['myhinet@ms5.hinet.net']  # 收件人
cc_addr=['tony@xxx.com.tw']          # 副本收件人(無設為 None)
text_body='您好, 這是測試信👋'       # 純文字信件內容
html_body='<b><i>您好, 這是測試信<i><b>🌈'   # 網頁格式信件內容
attachments=['cat1.jpg']  # 附檔
send_mail(account, password, subject, from_addr, to_addr, cc_addr, text_body, html_body, attachments)

黃底色為新增或修改的部分, 因為已經處理了 Unicode, 所以不論中文或 emoji 圖示均可傳送, 結果如下 :




7. 在 HTML 內容中內嵌圖片 :    

上面範例是透過附件檔案方式傳送圖片, 使用夾帶附件的方式可用來傳送任何檔案, 但如果想看到附件內容須點擊開啟附件檔. 如果想在信件內容中直接顯示圖片, 則必須以 HTML 格式用 img 標籤內嵌圖片來傳送信件, 且須使用 email.mime.image,MIMEImage 類別來包裝 open() 讀取的圖片 byte 資料後加入信件標頭中, 將 send_mail() 函式改寫如下 :

# smtp_mail_test_7.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.header import Header
from email.mime.image import MIMEImage
import os

def send_mail(account,
              password,
              subject,
              from_addr,
              to_addr,
              cc_addr=None,
              text_body='',
              html_body=None,
              attachments=None,
              inline_images=None):
    if cc_addr is None:
        cc_addr=[]  # 預設空串列
    if attachments is None:  # 可變資料不宜做參數
        attachments=[]       # 應付迴圈需求
    if inline_images is None:  # 無內嵌圖片
        inline_images={}        
    # 登入 Google SMTP 伺服器
    smtp=smtplib.SMTP('smtp.gmail.com', 587)
    smtp.ehlo()
    smtp.starttls()  # 啟動 TLS 安全傳輸
    smtp.login(account, password)  # 登入 Google SMTP 伺服器
    # 建立郵件內容
    content=MIMEMultipart('mixed')  # 外層 mixed
    content['Subject']=Header(subject, 'utf-8')  # 處理主旨 Unicode
    content['From']=from_addr
    content['To']=', '.join(to_addr)
    content['Cc']=', '.join(cc_addr)
    # 添加郵件正文 (雙模)    
    alt_part=MIMEMultipart('alternative')  # 內層:alternative(純文字 + HTML)
    alt_part.attach(MIMEText(text_body or '(No message content)', 'plain', 'utf-8'))
    # 若含有 inline 圖片使用 multipart/related 包住 HTML
    if html_body:
        related_part=MIMEMultipart('related')
        related_part.attach(MIMEText(html_body, 'html', 'utf-8'))
        for cid, img_path in inline_images.items():
            if os.path.isfile(img_path):
                with open(img_path, 'rb') as f:
                    img=MIMEImage(f.read())
                    img.add_header('Content-ID', f'<{cid}>')
                    img.add_header('Content-Disposition', 'inline', filename=os.path.basename(img_path))
                    related_part.attach(img)
            else:
                print(f'找不到內嵌圖片:{img_path}')
        alt_part.attach(related_part)  # 將 related 加入 alt
    content.attach(alt_part)  # 將 alt 加入信件內容
    # 加入附件
    for filepath in attachments:
        if os.path.isfile(filepath):
            filename=os.path.basename(filepath)
            with open(filepath, 'rb') as f:
                part=MIMEApplication(f.read(), Name=filename)
                part.add_header('Content-Disposition', 'attachment', filename=filename)
                content.attach(part)
        else:
            print(f'找不到附件:{filepath}')                
    # 合併收件人和副本收件人
    all_recipients=to_addr + cc_addr
    status=smtp.sendmail(from_addr, all_recipients, content.as_string())
    if status == {}:
        print('郵件傳送成功!')
    else:
        print('郵件傳送失敗!')
    smtp.quit()

# 使用範例
account='mygmail@gmail.com'     # Gmail 帳號
password='azfkqbjbftodjucd'         # Google 應用程式密碼 (這是樣本)
subject='郵件測試'                         # 主旨
from_addr='mygmail@gmail.com'   # 寄件人
to_addr=['myhinet@ms5.hinet.net']  # 收件人
cc_addr=['tony@xxx.com.tw']          # 副本收件人(無設為 None)
text_body='您好, 這是測試信👋'       # 純文字信件內容
html_body='''
<html>
  <body>
    <h3>您好!</h3>
    <p>這是內嵌圖片的測試:</p>
    <img src="cid:cat1" width="300">
  </body>
</html>
'''
attachments=None  # 無附檔
inline_images={"cat1": "cat1.jpg"}  # 內嵌圖片 (鍵與 img 之 src 對應)
send_mail(
    account,
    password,
    subject,
    from_addr,
    to_addr, 
    cc_addr,
    text_body,
    html_body,
    attachments,
    inline_images
    )

此處藍色與黃色為改寫或增加的部分, 結果如下 :




可見圖片是直接內嵌在信件內容中.

最後這一版 send_mail() 就是集大成的版本了 (Unicode+純文字網頁雙模+可傳附件檔+可內嵌圖片), 我們可以同時夾帶附件檔與內嵌圖片. 但如果信件內容只是英數字而已的話, 其實用最上面那個範例中的簡易版 send_mail() 即可. 

沒有留言 :