自建 SMTP 轉 HTTP Email 橋接器:用 Node.js 突破雲端商封鎖 SMTP 的完整實作
自建 SMTP 轉 HTTP Email 橋接器:用 Node.js 突破雲端商封鎖 SMTP 的完整實作 給遇到 Linode、GCP、AWS Lightsail 等雲端商封鎖對外 SMTP port 的工程師。用 Node.js 在內網架一層 SMTP 轉 HTTP 的橋接服務,讓 Ghost 等只支援 SMTP 的應用透過 REST Email API 順利發信。
自建 SMTP 轉 HTTP Email 橋接器:用 Node.js 突破雲端商封鎖 SMTP 的完整實作
適用技術棧 :Node.js 18+、smtp-server、mailparser,部署環境支援 TCP 內網(Zeabur、Fly.io、Railway、Render)
前陣子幫一個跑在 Linode 的 Ghost 部落格搬家,碰到一個老問題:Linode 預設封鎖對外 SMTP port (25 / 465 / 587),所以 Ghost 沒辦法直接連 Mailgun 或 Resend 那種 SMTP-only 服務發信。Ghost 又只支援 SMTP transport,不能直接呼叫 REST API。最後發現用一層約 150 行的 Node.js 橋接服務就能解決,SMTP 進、HTTP 出,跑在內網不受 SMTP port 封鎖影響。這篇整理了完整的實作邏輯,可以直接拿去用,或對應改成你的 Email API 提供商。
1. 這個系統做什麼
概覽
一個輕量 SMTP 伺服器,接收標準 SMTP 連線(HELO / AUTH / MAIL FROM / RCPT TO / DATA),把郵件內容解析後,轉成 HTTP POST 呼叫第三方 Email REST API 發送。對 SMTP 客戶端來說它就是個普通 SMTP server,對 Email API 來說它就是個普通 HTTP client。
解決什麼問題
雲端供應商封鎖對外 SMTP port(Linode、GCP、AWS Lightsail 預設都封)
應用程式(Ghost、Gitea、Outline、自架 WordPress 等)只支援 SMTP,不支援直接呼叫 REST Email API
想用 Resend、Postmark、SendGrid、Zeabur Email、AWS SES 等 REST-based email service,又不想改原本應用的 SMTP 整合
功能邊界(Scope)
包含 :SMTP server (PLAIN / LOGIN auth)、郵件解析(headers / body / attachments)、HTTP POST 到任意 REST Email API、HTTP 錯誤碼對應到 SMTP 回應碼、雙模式 API key(環境變數 / SMTP password 直傳)
不包含 :DKIM 簽章(由 Email API 提供商處理)、寄信記錄持久化(無 DB)、Webhook 接收、寄信佇列(fire-and-forget)
2. 整體架構與資料流
資料流
SMTP Client SMTP Bridge Email REST API
(Ghost, etc.) TCP (Node.js) HTTPS (Resend / Zeabur /
| :2525 SES / Postmark...)
| HELO / AUTH / MAIL / | |
| RCPT / DATA | |
|-------------------------->| |
| | parse RFC 5322 message |
| | build JSON payload |
| |------------------------->|
| | |
| | { id, status: pending } |
| |<-------------------------|
| SMTP 250 OK / 4xx / 5xx | |
|<--------------------------| |
來源檔案參考(僅供對照,非必要)
以下路徑僅標記程式碼出處,閱讀此 PRD 不需要存取這些檔案。
GEEKAZ Design engineering from Taipei. Building courses, tools, and resources for designers who code.
server.js入口 / 核心邏輯 啟動 SMTP server、定義 onAuth 與 onData Dockerfile部署 Node 20-alpine 基底,EXPOSE 2525 package.json依賴宣告 smtp-server、mailparser,require Node 18+
外部依賴 依賴 類型 用途 smtp-server ^3.13.6npm SMTP 協定處理(接受連線、解析指令、auth) mailparser ^3.7.2npm RFC 5322 訊息解析(含 attachments) Native fetch Node 18+ runtime HTTP client(不需要 axios / node-fetch)
環境變數 / 設定 # Email API key (optional — see auth modes in Step 2)
EMAIL_API_KEY=
# SMTP listening port (default 2525)
PORT=2525
# Email REST API endpoint
EMAIL_API_URL=https://api.example.com/v1/emails
3. 資料結構與型別定義
入口:mailparser 解析後的訊息 interface ParsedMail {
from?: {
text: string // RFC 5322 form: "Name <email>"
value: Array<{ address: string; name?: string }>
}
to?: AddressObject | AddressObject[]
cc?: AddressObject | AddressObject[]
bcc?: AddressObject | AddressObject[]
replyTo?: AddressObject
subject?: string
html?: string
text?: string
attachments?: Array<{
filename?: string
contentType?: string
content: Buffer // raw bytes, need base64 encoding
}>
}
interface AddressObject {
text: string
value: Array<{ address: string; name?: string }>
}
出口:HTTP payload 給 Email API interface EmailApiPayload {
from: string // "Name <email>" or plain email
to: string[]
cc?: string[]
bcc?: string[]
reply_to?: string[]
subject: string // typically max 998 chars
html?: string // typically max 5 MB
text?: string // html or text required
attachments?: Array<{
filename: string
content: string // base64-encoded
content_type: string
}>
}
Email API 回應(成功) interface EmailApiResponse {
id: string
message_id?: string
status: 'pending' | 'queued' | 'sent'
}
4. 一步步實作 坦白說整個橋接器的關鍵就是把 SMTP 跟 HTTP 兩個世界的「成功 / 失敗」語意正確對應起來。這部分如果做不對,客戶端要嘛狂重試要嘛永遠不重試,反而比直接拒絕還慘。建議照順序走一遍。
Step 1:啟動 SMTP server 目的 :在指定 port 接收 SMTP 連線,禁用 STARTTLS(因為跑在內網不需要加密)。
const { SMTPServer } = require("smtp-server")
const PORT = parseInt(process.env.PORT || "2525", 10)
const server = new SMTPServer({
secure: false, // No TLS, internal network only
disabledCommands: ["STARTTLS"], // Prevent client from upgrading
authMethods: ["PLAIN", "LOGIN"],
onAuth, // defined in Step 2
onData, // defined in Step 3-4
authOptional: false, // Always require auth
size: 15 * 1024 * 1024, // 15 MB max message size
banner: "SMTP Bridge",
logger: false, // disable smtp-server's own logs
})
server.on("error", (err) => {
console.error("[SERVER ERROR]", err.message)
})
server.listen(PORT, "0.0.0.0", () => {
console.log(`[SMTP Bridge] Listening on port ${PORT}`)
})
一定要 disabledCommands: ["STARTTLS"],否則 nodemailer 客戶端會嘗試升級 TLS,但內網沒憑證會失敗
客戶端那邊要對應設定 secure: false 和 ignoreTLS: true
size 是單一訊息上限,超過會在 SMTP 層被拒絕
Step 2:雙模式 Auth 目的 :讓客戶端可以用兩種方式提供 Email API key,環境變數模式適合單服務(簡單),passthrough 模式適合 stateless / 多租戶(彈性)。
const API_KEY = process.env.EMAIL_API_KEY || ""
function onAuth(auth, _session, callback) {
// Mode 1: Env var configured → accept any SMTP credentials
if (API_KEY) {
return callback(null, { user: auth.username || "anonymous" })
}
// Mode 2: No env var → SMTP password IS the API key
if (!auth.password) {
return callback(new Error(
"API key required: set EMAIL_API_KEY env var, or pass it as SMTP auth password"
))
}
callback(null, { user: auth.username })
}
nodemailer 規定 auth.user 和 auth.pass 不能空字串,所以即使 env var 模式下,客戶端還是要填佔位字串(例如 "anonymous" / "x")
session.auth.password 會在 onData 那邊被取出當 API key 用(passthrough 模式)
Step 3:解析 SMTP 訊息並建 HTTP payload 目的 :把 RFC 5322 格式的訊息轉成 Email API 預期的 JSON 結構,順便做 fail-fast 限制驗證。
const { simpleParser } = require("mailparser")
async function onData(stream, session, callback) {
try {
const parsed = await simpleParser(stream)
const apiKey = API_KEY || session.auth?.password
if (!apiKey) {
return callback(new Error("No API key available"))
}
const toAddresses = extractAddresses(parsed.to)
const ccAddresses = extractAddresses(parsed.cc)
const bccAddresses = extractAddresses(parsed.bcc)
const replyTo = extractAddresses(parsed.replyTo)
if (toAddresses.length === 0) {
return callback(new Error("No recipients found"))
}
// Adjust limits based on your Email API's spec
const totalRecipients =
toAddresses.length + ccAddresses.length + bccAddresses.length
if (totalRecipients > 50) {
return callback(new Error(
`Too many recipients: ${totalRecipients} (limit: 50)`
))
}
const fromAddress =
parsed.from?.text ||
session.envelope.mailFrom?.address ||
""
if (!fromAddress) {
return callback(new Error("No sender address found"))
}
const payload = {
from: fromAddress,
to: toAddresses,
subject: (parsed.subject || "(no subject)").slice(0, 998),
}
if (parsed.html) payload.html = parsed.html
if (parsed.text) payload.text = parsed.text
if (!payload.html && !payload.text) {
payload.text = " " // some APIs require non-empty body
}
if (ccAddresses.length > 0) payload.cc = ccAddresses
if (bccAddresses.length > 0) payload.bcc = bccAddresses
if (replyTo.length > 0) payload.reply_to = replyTo
if (parsed.attachments && parsed.attachments.length > 0) {
if (parsed.attachments.length > 10) {
return callback(new Error(
`Too many attachments: ${parsed.attachments.length} (limit: 10)`
))
}
payload.attachments = parsed.attachments.map((att) => ({
filename: att.filename || "attachment",
content: att.content.toString("base64"),
content_type: att.contentType || "application/octet-stream",
}))
}
// ... continue to Step 4 (HTTP forward)
} catch (err) {
console.error("[ERROR] Failed to process email:", err.message)
callback(err)
}
}
// Extract email addresses from various mailparser address shapes
function extractAddresses(addressObj) {
if (!addressObj) return []
const list = Array.isArray(addressObj) ? addressObj : [addressObj]
return list
.flatMap((a) => {
if (a.value) return a.value.map((v) => v.address)
if (a.text) return [a.text]
return []
})
.filter(Boolean)
}
mailparser 的 address 物件有兩種型態:單一 { text, value } 或 Array<{ text, value }>,要統一處理(這就是 extractAddresses 把參數先 normalize 成 array 的原因)
parsed.from.text 包含 display name ('Name' <email>),但有些 API 只接受純 email。如果你的 API 不支援 RFC 5322 format,改用 parsed.from.value[0].address
Subject 截斷到 998 字(RFC 5322 line length 限制),否則 API 端會回 400
以上的 limit 數字(50 收件人、10 附件)是常見值,請依你選的 Email API 文件調整
Step 4:HTTP forward 與錯誤碼對應 目的 :呼叫 Email API,把 HTTP 結果正確轉成 SMTP 回應碼。這是整個橋接器最關鍵的部分。
const EMAIL_API_URL = process.env.EMAIL_API_URL ||
"https://api.example.com/v1/emails"
// (continue from Step 3, inside onData try block)
const response = await fetch(EMAIL_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
})
// Read as text first, parse later — protects against non-JSON error pages (502 HTML)
const rawBody = await response.text()
let result = {}
try {
result = rawBody ? JSON.parse(rawBody) : {}
} catch {
result = { raw: rawBody }
}
if (!response.ok) {
console.error(
`[ERROR] API ${response.status}:`,
rawBody || "(empty body)"
)
const err = new Error(
`API error (${response.status}): ${result.message || result.error || rawBody || "unknown"}`
)
// Critical: map HTTP status → SMTP response code
if (response.status === 401 || response.status === 403) {
err.responseCode = 535 // Auth failure (permanent)
} else if (response.status === 429 || response.status >= 500) {
err.responseCode = 451 // Temporary, client should retry
} else if (response.status === 400) {
err.responseCode = 554 // Bad payload, do not retry
}
return callback(err)
}
console.log(
`[OK] sent: ${toAddresses.join(", ")} | id=${result.id || "unknown"} | status=${result.status || "unknown"}`
)
callback()
不要直接 await response.json() 。如果 API 在錯誤情況回傳非 JSON(例如 reverse proxy 的 502 HTML 錯誤頁),response.json() 會拋 SyntaxError,蓋掉真正的 HTTP 錯誤訊息。先 response.text(),再 try parse 才能保留原始錯誤
SMTP 回應碼對應原則:
4xx 開頭 = 暫時性錯誤,nodemailer 會重試
5xx 開頭 = 永久性錯誤,nodemailer 不應重試
535 = 認證失敗(特殊類別,會被歸類為 auth error)
如果不設 err.responseCode,smtp-server 預設會用 421 或 500,對精準的重試行為不利
Step 5:部署 Dockerfile FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.js ./
EXPOSE 2525
CMD ["node", "server.js"]
Node 20+ 才有原生 fetch,不用裝 axios 或 node-fetch
alpine base image 最後映像約 50 MB,足夠這個用途
EXPOSE 2525 給平台(Zeabur、Fly、Railway)看到該開哪個 port
部署平台的 port type 一定要是 TCP 不是 HTTP,因為 SMTP 是 TCP 協定,HTTP proxy 看到 HELO 這種指令會直接拒絕
5. 快速開始
前提條件
實作步驟
安裝依賴
npm install smtp-server mailparser
設定環境變數
EMAIL_API_KEY=your_api_key_here
PORT=2525
EMAIL_API_URL=https://api.example.com/v1/emails
複製 Step 1-4 的程式碼到 server.js
本地測試
node server.js
# 用 swaks 測一封:
swaks --to [email protected] --from [email protected] \
--server localhost:2525 \
--auth-user x --auth-password "$EMAIL_API_KEY" \
--auth PLAIN
部署到生產環境
Docker:docker build -t smtp-bridge . && docker run -p 2525:2525 smtp-bridge
PaaS:直接 push GitHub repo,平台會偵測 Dockerfile 自動建置
port type 設成 TCP (不是 HTTP)
連接你的應用
把應用的 SMTP 設定改成:
host: <bridge-internal-hostname>
port: 2525
secure: false
ignoreTLS: true
auth.user: anonymous (任意值,nodemailer 規定不能空)
auth.pass: <your_api_key 或佔位字串>
Ghost on Zeabur 完整流程
假設你是用 Zeabur 上的 Ghost 模板部署的(這是最自然的搭配,畢竟 bridge 本身也部署在同一個 Project 內),Ghost 的 mail 設定有兩種改法。推薦用「設定檔編輯器」 ,可以保留完整的 config.production.json 結構、之後 debug 也方便看。
方式 A:Zeabur 設定檔編輯器(推薦)
到 Zeabur Dashboard → Ghost 服務 → 設定 分頁
找到「設定檔 」區塊 → 點「開啟設定檔編輯器 」
新增一個檔案,path 設為 /var/lib/ghost/config.production.json
內容貼上:
{
"url": "https://your-blog-domain.com",
"server": {
"port": 2368,
"host": "::"
},
"mail": {
"transport": "SMTP",
"from": "'Your Name' <[email protected] >",
"options": {
"host": "smtp-bridge.zeabur.internal",
"port": 2525,
"secure": false,
"ignoreTLS": true,
"auth": {
"user": "anonymous",
"pass": "anonymous"
}
}
},
"logging": {
"transports": ["file", "stdout"]
},
"process": "systemd",
"paths": {
"contentPath": "/var/lib/ghost/content"
}
}
儲存後 Zeabur 會自動重啟 Ghost service,新設定立即生效
如果你是從 Mailgun / SendGrid 等舊 SMTP 服務遷移 ,原本 options 區塊裡如果有 "service": "Mailgun"(或 "SendGrid" / "Gmail")這行,必須整行拿掉 。這是 nodemailer 的 shortcut,會強制覆蓋你的 host / port / secure 設定,留著的話 bridge host 改不過去。新貼上去的 config 已經沒有這行,照貼即可。
auth.pass 為什麼填 "anonymous" 就好?因為 bridge 已經有 EMAIL_API_KEY 環境變數,bridge 端不檢查 SMTP 密碼。但 nodemailer 規定 auth.pass 不能空字串,所以填個非空字串騙過 nodemailer 即可。如果你的 bridge 沒設 EMAIL_API_KEY(passthrough 模式),這裡才要填真正的 API key。
方式 B:Zeabur 環境變數(替代方案)
Ghost 支援用 __(double underscore)把巢狀 config 攤平成環境變數。到 Zeabur Dashboard → Ghost 服務 → 環境變數 分頁加:
mail__transport=SMTP
mail__from='Your Name' <[email protected] >
mail__options__host=smtp-bridge.zeabur.internal
mail__options__port=2525
mail__options__secure=false
mail__options__ignoreTLS=true
mail__options__auth__user=anonymous
mail__options__auth__pass=anonymous
設定完 Zeabur 會自動重啟。不要設 mail__options__service — 沒這個變數就等於不存在,是我們要的結果。
方式 A 跟 B 二選一,不要兩個都用(環境變數會覆蓋設定檔的同名欄位,兩邊不一致會除錯到瘋掉)。
這個流程只針對 Zeabur 。如果你的 Ghost 部署在其他地方(VPS、Ghost-CLI、Ghost(Pro)),改 SMTP 設定的方式不同,請參考 Ghost 官方文件。但 SMTP 欄位本身(host / port / secure / auth)是一樣的,只是修改入口不同而已。
驗證
6. 不同技術棧的調整方式 這個橋接器最大的優勢就是 95% 的程式碼跟 Email API 提供商無關,只有 payload 欄位名稱和 endpoint URL 要對應你選的服務調整。
高度通用的部分(直接用)
SMTP server 設定(onAuth、disabledCommands、authMethods)
Address extraction 邏輯
Attachment base64 編碼
HTTP error → SMTP response code 對應
mailparser 整合
雙模式 auth 設計
需要客製化的部分 部分 原實作 新專案需要調整的地方 Endpoint URL https://api.example.com/v1/emails換成你的 API endpoint 欄位命名風格 snake_case (reply_to、content_type) 對照 API 文件,可能是 camelCase 或 PascalCase Auth header Authorization: Bearer <key>各家通常都是 Bearer,但 Mailgun 用 Basic auth 限制驗證 50 收件人、10 附件、5MB 內文、998 字 subject 對照 API 文件調整驗證上限 Response 解析 result.id、result.status改 log 取的欄位名稱 Attachment 結構 { filename, content, content_type }各家 schema 不同
技術棧替換建議
如果用 Resend :欄位是 camelCase (replyTo、contentType),attachment 可以用 path (URL) 或 content (base64),文件 https://resend.com/docs/api-reference/emails/send-email
如果用 AWS SES (v2 API) :用 Destination.ToAddresses、Content.Simple.Subject.Data 這種巢狀結構,差異很大,建議直接用 @aws-sdk/client-sesv2 而不是自己組 JSON
如果用 Postmark :用 From、To、HtmlBody、TextBody(PascalCase),auth header 改成 X-Postmark-Server-Token
如果用 SendGrid :要包成 { personalizations: [{ to: [...] }], from: { email }, content: [{ type, value }] },結構完全不同,建議直接用 @sendgrid/mail SDK
如果用 Mailgun(HTTP API 不是 SMTP) :API 是 form-encoded 不是 JSON,要用 multipart/form-data,attachment 是 file part 不是 base64
結語 這套橋接器最棒的地方是它本身完全 stateless,沒有資料庫、沒有 queue、沒有狀態,重啟、scale-out、換機房都不用擔心。整體跑下來大概 100ms 內可以從接收 SMTP 到呼叫完 API,對 transactional email(登入信、密碼重設)來說延遲完全可以接受。如果你也卡在「應用只支援 SMTP,但雲端商封鎖 SMTP port」的問題上,這個方案應該能省掉你不少摸索的時間。
© 2026 GEEKAZ. All Rights Reserved.