Node.js定時郵件的那些事兒
近開發一個專案,需要在Node.js程式裡實現定期給管理員發郵件的功能。
筆者平時只會在Web介面收發郵件。對郵件的原理完全不懂(可能大學教過,然而全忘了),直到要解決這個問題。請教了幾個業務的同事,得到的答覆是:“你需要搭一個SMTP服務,還要裝一個mail agent,巴拉巴拉……” 你們在說什麼,我瞎了聽不見……
聽起來很複雜,有沒有開箱即用的服務啊?一打聽還真有。同事告知我司有提供Exchange服務。筆者的內心獨白:“Exchange啊,我見過,跟outlook什麼關係?”。好在最後還是在同事的幫助下,冰雪聰明的筆者實現了這個功能。踩了一些坑,記錄一下,順便複習一下基礎知識。
名詞解釋
以上提到的那些名詞我一個也沒聽懂,直到做完這個功能。先給大家解釋一下:
-
SMTP。簡單郵件傳輸協議。實際常用於在不同的郵件伺服器之間傳輸郵件。
-
IMAP/POP。兩者都是用於在本地查收郵件的協議。POP需要將郵件下載到本地儲存。IMAP是POP的增強版,更偏雲端一些,郵件儲存在伺服器,可以多裝置訪問。
-
Email Agent。在郵件各個傳輸鏈路上的具體程式。可以理解成協議的實現者。
下面是一個傳統的郵件從傳送到接收的過程:
(圖片來源:維基百科 )
第一個教我的同事實際上是讓我去安裝圖中各個小矩形裡的具體程式(比如MUA,MSA,MTA,MRA),它們都是email agent。可以不恰當地將它們比喻成郵局的各個部門。
從圖中可以看到SMTP主要用於傳送郵件,而IMAP和POP主要用於本地獲取檢視郵件。
好,科普結束。以上內容跟本文主題無直接關係,逃……
下面是本文的主角:
Exchange Web Service
先解釋一下Exchange服務,它是微軟開發的一個郵件和日曆服務,執行在Windows Server作業系統上。它不同於SMTP和IMAP/POP等,並不是一個簡單的協議,而是微軟自己實現的一套服務。
而Exchange Web Service(簡稱EWS),是應用跟Exchange伺服器通訊的一種方式。簡單地說,當你使用Exchange提供的郵件服務時,可以使用EWS傳送或者接收郵件等。不過微軟已經在2018年7月宣佈停止在產品和功能上更新EWS,它推薦使用Microsoft Graph來訪問郵箱服務。這不重要,因為已經安裝的EWS不受影響,可以繼續使用。
看看Exchange的架構(示例為Exchange on-premise版本,除此之外它還有online版)。
(圖片來源:EWS應用和Exchange的架構)
回顧一下開頭提到的場景,結合實際情況(即公司已有Exchange服務),要解決發郵件的問題只需關注圖中的1、2、3。
可以把Node.js程式看做圖中的EWS應用,它需要呼叫EWS的API跟Exchange伺服器通訊,從而實現發郵件的功能。
我們實現一個EWS傳送郵件的程式需要實現兩點:
-
鑑權。校驗身份。
-
傳送。將郵件內容發出去。
下面看看具體實現。
一個基於EWS的Node.js發郵件程式
微軟官網提供了一套EWS Managed API,用於呼叫EWS的介面。但是很遺憾,它不支援Node.js。不過github上有Node.js版本:ews-javascript-api,可以直接從npm上安裝。筆者最終沒有用它,因為只是一個小小的發郵件功能,不必用這麼全的第三方庫。(其實是懶得看文件了)。
推薦EWS的同事使用了一個叫exchange-web-service的庫,非常簡單。不過筆者在使用的時候踩到了坑,後來看了一遍該庫的程式碼,改進了一版程式碼,最終解決了問題,順便加深了對EWS的理解。
先說說坑:
發郵件程式被設計成了一個介面(ThinkJS程式裡的一個action)。這個介面需要先查資料庫,按條件篩選出收件人。然後呼叫exchange-web-service庫的方法,將郵件傳送給篩選出來的收件人。虛擬碼如下:
import ews from 'exchange-web-service';
// 配置郵箱帳號
ews.config('mail_account', 'mail_password', "https://mail_domain/Ews/Exchange.asmx", "mail_domain");
async checkNotifyUsersAction() {
// 只允許命令列執行
if (!this.isCli) { return this.fail("only allow invoked in cli mode"); }
// 篩選收件人
const recipients = await this.modelInstance.getRecipients();
const title = '標題';
const msg = `<![CDATA[ 你好 ]]>`;
// 發件
ews.sendMail(email, title, msg);
}
注意,這裡ews.sendMail是沒有被“await”的,因為這個庫不支援promise,那麼它能不能將郵件傳送成功呢?當然不能。並且在除錯的時候發現,先呼叫ews.sendMail,再執行篩選收件人的操作,就能收到郵件。為什麼放在最後不行?
經過分析,發現ews.sendMail本身是非同步操作,而篩選收件人的await能夠hold住整個action的執行,為ews.sendMail的非同步操作爭取了時間,所以能傳送成功。如果將sendMail放到最後執行,程序結束了,傳送郵件的操作就終止了。
解決辦法很簡單,將這個庫的介面都改為async/await方式。並將原先的呼叫方法改為await ews.sendMail(email, title, msg);即可。
根據這個庫梳理了呼叫EWS的流程。
Node.js呼叫EWS的原理
再解釋兩個名詞:
-
SOAP。是一種訊息格式(本質是XML)。用特定的結構和標籤約定了訊息的格式,比如<soap:Envelope>、<soap:Header>、<soap: Body>。EWS使用SOAP來傳遞訊息和指令。
-
NTML。一種認證方式。用於鑑別訪問者具有系統訪問許可權。EWS不止有一種認證方式,NTLM只是其中一種。
下面動手實現呼叫EWS的Node.js程式。
首先,構造SOAP格式的郵件內容。一個傳送郵件的SOAP如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Header>
<t:RequestServerVersion Version="Exchange2010" />
</soap:Header>
<soap:Body>
<m:CreateItem MessageDisposition="SendAndSaveCopy">
<m:SavedItemFolderId>
<t:DistinguishedFolderId Id="sentitems" />
</m:SavedItemFolderId>
<m:Items>
<t:Message>
<t:Subject>測試</t:Subject>
<t:Body BodyType="HTML">你好</t:Body>
<t:ToRecipients>
<t:Mailbox>
<t:EmailAddress>[email protected]</t:EmailAddress>
</t:Mailbox>
</t:ToRecipients>
</t:Message>
</m:Items>
</m:CreateItem>
</soap:Body>
</soap:Envelope>
當然,EWS提供的遠不止發郵件這麼簡單的功能。更多操作請參考官方文件。
構造完郵件內容之後,需要藉助一個npm包:httpntlm。它實現了鑑權併發送郵件內容的功能。呼叫方法如下所示:
httpntlm.post({
username: 'xxx',
password: 'xxx',
domain: 'xxx',
url: 'xxx',
content: '' // SOAP郵件內容
})
ThinkJS實現定時任務
發郵件的功能完成了,需要實現定時功能。定時功能當然要藉助crontab啦。ThinkJS只需要幾行配置就能搞定crontab,不用開發者多操心。參考文件:https://thinkjs.org/zh-cn/doc/3.0/crontab.html
問題來了,如何保證郵件不重複傳送(即定時任務不重複執行)。
首先,config/crontab.js(ts)要配置type: 'one'。這樣能保證在開多個worker的時候只有一個worker會執行定時任務。
其次,如果專案部署在多臺機器,要保證只有一個機器能執行定時任務,這個可以通過環境變數來實現,比如當process.env.CRONTAB為1的時候才開啟。可以在將程式碼部署到線上的時候匹配特定的機器名,並在這臺機器的部署命令中設定引數CRONTAB=1。
config/crontab.js程式碼如下:
module.exports = [{
immediate: false,
cron: '0 14 * * 4,5',
handle: 'api/crontab/xxx',
type: 'one',
enable: process.env.CRONTAB == '1'
}];
總結
本文介紹了郵件的基本原理和流程,EWS的用法,以及ThinkJS開發定時任務的注意事項。在開發過程中,順帶理解了SOAP、NTLM等協議。郵件功能還有其他開源的解決辦法,比如基於SMTP等協議的開源專案nodemailer。希望本文能給大家帶來啟發,祝寫碼開心~
參考
-
阮一峰:如何驗證 Email 地址:SMTP 協議入門教程
-
JavaMail學習筆記(一)、理解郵件傳輸協議(SMTP、POP3、IMAP、MIME)
-
Microsoft NTLM文件