MoeMail

開發者 Email 測試完全指南

端到端測試真實郵件流程——驗證碼、密碼重設、OTP 與免密登入連結——只需一個臨時信箱和一個 API。

Email 是大多數測試套件乾脆放棄的那一步。在它之前的一切都在你掌控之中——填表單、按按鈕、斷言轉址——然後整個流程徹底離開你的應用程式,穿過一段你並不擁有的基礎設施,落到一個測試看不見的地方。於是套件把寄信呼叫打成 stub,斷言應用程式「嘗試過」了,就當作覆蓋到了。

這樣漏掉的,正好是真的會出事的那部分:信到底有沒有寄出去、模板渲染對不對,以及裡面那串驗證碼跟後端願意接受的是不是同一串。

這份指南就是把它認真測起來的地圖。

先分清一件事,它能直接刷掉一半的工具

在挑任何工具之前,先弄清楚你要解的是兩個問題中的哪一個。這個領域的工具分得很乾淨,但從它們的行銷頁上通常看不出來。

真實進站收件匣給你的是一個可收信的地址,能收下網路上任何地方寄來的信,再用 API 讀取或由 webhook 推送。只有這一類,才能測試一封不是你自己程式寄出的信——註冊確認信、密碼重設信、第三方服務寄來的 2FA 驗證碼。

SMTP 捕捉器則是一台假的 SMTP 伺服器,你把應用程式指向它。它攔下的是你的應用程式寄出去的信,並在一個介面裡呈現。用在本機開發循環、用來斷言自家的外寄模板,都非常好用。但它收不到外部來信——因為網路上根本沒有路由到得了它。

Mailpit、MailHog、MailCatcher、MailDev,以及 Mailtrap 的 Email Testing 沙箱,全都屬於後者。它們會和前者並排出現在每一份「email 測試工具」清單上,卻沒有任何地方提醒你它們回答的是另一個問題。拿其中一個去做另一類的工作,是這個領域最常見的錯誤,而且通常是整合到第三天才發現。

多數團隊最後兩種都要:開發循環用捕捉器,端對端 CI 用真實收件匣。

核心模式:一個測試一個收件匣

至於「收信」這一半,整門功夫可以收斂成一條規則:替每個測試開一個全新的收件匣,而不是每個套件一個。

整個套件共用一個信箱,等於把你正想拿掉的不穩定又請了回來:只要兩個測試並行跑,其中一個就會讀到另一個的信,於是你得到一個只在並行下才重現的失敗。一個單獨跑會過、放進套件就掛的測試,除錯成本比它覆蓋的功能還高。

依序有四個環節:

  1. 一個本次測試專屬的地址;
  2. 觸發寄信的瀏覽器或 API 流程;
  3. 讀取抵達的訊息;
  4. 取出驗證碼或連結,然後斷言。

第 1 步和第 3 步才是工具選擇真正發揮作用的地方,其餘都是一般的測試程式碼。

能跑起來的最小實作

兩個端點就做完全部的事——建立地址,然後輪詢到有信落地為止:

const API = 'https://moemail.app/api'
const H = { 'X-API-Key': process.env.MAIL_KEY, 'Content-Type': 'application/json' }

// expiryTime 單位是毫秒。不傳 name 就會隨機產生本地部分,
// 這正是你要的——並行測試永遠不會撞在一起。
export async function createInbox() {
  const res = await fetch(`${API}/emails/generate`, {
    method: 'POST',
    headers: H,
    body: JSON.stringify({ expiryTime: 3_600_000, domain: 'moemail.app' }),
  })
  if (!res.ok) throw new Error(`create inbox failed: ${res.status}`)
  return res.json() // { id, email }
}

export async function waitForMessage(inboxId, timeoutMs = 30_000) {
  const deadline = Date.now() + timeoutMs
  while (Date.now() < deadline) {
    const res = await fetch(`${API}/emails/${inboxId}`, { headers: H })
    const { messages } = await res.json()
    if (messages?.length) return messages[0]
    await new Promise(r => setTimeout(r, 1500))
  }
  throw new Error(`no email arrived within ${timeoutMs}ms`)
}

回傳的訊息長成 { id, from_address, to_address, subject, content, html, received_at }——content 是純文字內文,html 則是 HTML 部分。

輪詢要用截止時間來框住,而不是重試次數。只要有人調了間隔,固定次數就會悄悄變成另一個逾時值。

另外,對「取出」這件事本身也要斷言:

const code = msg.content.match(/\b\d{6}\b/)?.[0]
expect(code, `no 6-digit code in: ${msg.subject}`).toBeDefined()

正則沒對上時,match(...)[0] 會丟 Cannot read properties of null,於是堆疊指向你的正則,而不是真正的問題——信裡寫的跟你預期的不一樣。把主旨放進失敗訊息裡,能把十分鐘的困惑變成瞄一眼報告。

輪詢還是 webhook

輪詢比較單純,測試只有幾個時完全夠用。Webhook 拿掉的是延遲和白跑的請求;當套件大到「1.5 秒間隔 × 一百個測試」變成實際的牆鐘時間時,它就開始重要了。

不管哪一種,逾時都要設得緊,但要誠實。測試環境的交易型郵件應該幾秒內就到,30 秒已經留足餘裕。如果某個套件非要 90 秒,那是在掩蓋一個投遞問題,而不是修好它。

別忘了讀 HTML 部分

有個坑值得先避開:如果你的模板只把驗證連結放在 HTML 內文裡,那讀 content 會什麼都找不到。純文字備援跟 HTML 脫節的情況,比多數人以為的常見得多,而使用者實際點的是 HTML。content 裡找不到連結時就去讀 msg.html,並且優先測試使用者真正收到的那一版。

怎麼選工具

如果你在評估付費服務,真正該比的不是功能條數,而是三件事:它收不收得到真實進站郵件、免費額度實際允許你做什麼,以及能不能自架來擺脫按收件匣計費的配額。

有兩篇專門寫了這個,也直說了在哪些情況下商業產品才是划算的選擇:Mailosaur 替代方案照上面那條「進站 / 捕捉」的分界把整個領域梳理了一遍;MailSlurp 替代方案則算了一筆帳——當正確做法就是每個測試建一個收件匣時,按收件匣計費代表什麼。

MoeMail 的定位

MoeMail 是真實進站收件匣:可收信的地址、帶金鑰驗證的 OpenAPI,以及 webhook。它開源,也可以自架在 Cloudflare 上,這樣就能用自訂網域,也不受外部配額限制。

坦白講講它不做的事:沒有簡訊/手機 OTP 測試,沒有跨郵件客戶端的渲染預覽,開箱即用的框架整合也遠少於付費產品——HTTP 呼叫要自己接,也就是上面那二十行。如果你的測試需要在同一個工具裡涵蓋簡訊,那就去買 Mailosaur;那是一個真實的缺口,不是小毛病。

從個人資料頁拿一把 API 金鑰,讀一下 OpenAPI 文件,或者直接建立一個信箱看看資料長什麼樣。

底下各篇指南,會把這套模式落到具體的框架、流程、語言與 CI 環境裡。