
本篇主題同詩意副標 — 換過三次,終於把這封信好好寄出去。我以為 newsletter 很簡單,裝個 plugin、開個表單、收件人訂閱、我發新文章自動寄通知,4 步驟,有什麼難的。結果第一個 plugin 直接被 IIS 拒絕、第二個 plugin 預設值是空的還搭配隱性 list_id bug、第三個方案我自己刻 hook 才差點洩漏所有訂閱者的 email。這篇是我從 MailPoet → Email Subscribers → Brevo Native 的全紀錄,以及最後 transition_post_status hook + WP Cron + Brevo Transactional API 自架架構的完整 code。
freelance 站台的 newsletter 不是「行銷工具」,它是訂閱者跟我之間最薄的一條線 — 不依賴 IG 演算法、不依賴 Google ranking、不依賴 LINE 群組是否被封。訂閱者主動填了 email,我發新文章他就會收到,這條線是我自己擁有的。
需求乾乾淨淨 4 步:
看似 4 步,每一步都有自己的 hell。前後換 3 個方案、踩 8 個坑、修 1 個隱私 bug,才把整條 pipeline 跑順。
MailPoet:WordPress 老牌 newsletter plugin,2014 起,LAMP 環境的標準選擇。第一個出局者。
Email Subscribers (Icegram):輕量替代,WP 標準 admin UI,可串各種 SMTP 後端。第二個出局者。
Brevo Native form:Brevo 自己 dashboard 建的 subscription form,產出一段 raw HTML,直接貼進 WP 即可,訂閱資料 POST 到 sibforms.com,完全不經過 WP DB。
WP transition_post_status hook:每當文章從 draft 轉成 publish,WP 會 fire 這個 hook。我用它偵測「新文章發布」事件。
wp_schedule_single_event:WP 內建的 cron deferred 觸發,可以排程「N 分鐘後跑某個 callback」。我用它把寄信任務從 publish 動作中解耦,publish 動作 1 秒內結束、寄信任務 60 秒後在背景跑。
Brevo Transactional API:POST https://api.brevo.com/v3/smtp/email,可以寄一封信給一個收件人。我用它做「一個訂閱者一封信」的 fan-out。
這 16 小時拆 3 條故事講。
第一條:MailPoet — 第一眼就掛了
MailPoet 是 newsletter 圈的 default 答案,我點裝、啟用,wp-admin 跳出一個紅色警告:
MailPoet plugin cannot run under Microsoft's Internet Information Services (IIS).
Please use Apache or nginx web server.
⋯⋯⋯⋯下一個。
(這也是為什麼我前面文章 1 一直強調 IIS 是冷門山頭 — 你會在某些 plugin 上遇到「我們不支援」的硬牆。)
第二條:Email Subscribers — 預設值是空的,list_id 是錯的
第二輪我裝 Email Subscribers (Icegram)。它輕量、不依賴特定 server、UI 在 wp-admin 內。我設定「double opt-in」、設定 confirmation email 模板、設定 SMTP 後端用 Brevo relay,看起來都對。
第一個訂閱者進來,我用自己的測試帳號試 — 收到了一封 confirmation 信。但內容是:
主旨:(空)
內文:(空)
點擊此連結確認:[CONFIRM_LINK]
主旨空、內文空、連結是個沒被替換的 mustache token。訂閱者收到這封廢信會直接刪除或標 spam。
我去 plugin 的 confirmation template 設定看,發現 — 預設值是空的。Plugin 出廠就是空的,要管理員自己填。Email Subscribers 的設計者顯然認為「管理員一定會填」,但對於從 marketplace 一鍵裝起來、想開箱即用的人,這個假設不成立。
Plugin 的預設值
從來不是給你用的
是給開發者測試用的
填完模板後,confirmation 信終於正常。但接下來踩到第二個坑 — list_id 配錯。
我在 plugin 設定看到 Brevo List ID: 1,以為 1 是預設清單。實際上 — Brevo dashboard 我的清單 id 是 2(Main)跟 3(identified_contacts),根本沒有 id=1。確認的訂閱者進了 plugin 自己 WP DB 的「list 1」,但這個 list 在 Brevo 那端不存在,寄新文章通知時 Brevo API 回 404。
修法是去 plugin 設定改成 list_id=2,把已經卡在 plugin DB 的訂閱者重新 sync 過去。但這時候我已經對 Email Subscribers 失去信心 — 它的設計太多隱性假設,每一個都會在某個邊角咬你一口。
第三條:Brevo Native + WP Cron + 隱私 bug
第三輪我決定全部自己刻。
訂閱表單交給 Brevo Native — 在 Brevo dashboard 建一個 subscription form,UI 拖拉幾下生成一段 raw HTML embed,直接貼進 WordPress 的 sidebar widget。訪客填表單時,POST 直接打到 sibforms.com,Brevo 處理 confirmation 寄信、確認、加入 list,我這邊不用碰任何 WP DB。Confirmation 信模板我重寫了一份米白 + 咖啡色的 HTML,符合站台調性。
新文章推播這部分由我自己寫:
add_action( 'transition_post_status', 'studio_morcept_on_publish', 10, 3 );
function studio_morcept_on_publish( $new_status, $old_status, $post ) {
if ( $new_status !== 'publish' || $old_status === 'publish' ) {
return;
}
if ( $post->post_type !== 'post' ) {
return;
}
wp_schedule_single_event( time() + 60, 'studio_morcept_send_post_notification', [ $post->ID ] );
}
transition_post_status 在每次 post status 變動時 fire,我只接「new = publish & old != publish」的情況(避免重複觸發),然後 schedule 一個 60 秒後執行的 cron event。為什麼是 60 秒 — 是給我自己一個「寫錯標題快撤回」的緩衝。
但接下來踩到全程最大的坑 — 寄信時的隱私 bug。
我第一版實作很直觀:從 Brevo 撈 list 2 的所有 contacts,丟進 Brevo Transactional API 的 to 陣列,一次寄一個 batch:
// ⚠️ 錯誤示範
$payload = [
'sender' => [...],
'to' => $all_subscribers, // [{email:'a@x.com'}, {email:'b@x.com'}, ...]
'subject' => $post_title,
...
];
按下 publish,等 60 秒,信寄出去了。我打開測試帳號收件匣,看到信 — 然後我看到收件人欄位 ──
To: a@x.com, b@x.com, c@x.com, d@x.com, ...
所有訂閱者的 email 在 To 欄位互相暴露了。這是 GDPR 級別的隱私 bug,等於我把所有訂閱者的 email 列表發給了所有訂閱者。
我立刻把那篇文章撤下、發道歉信、把錯誤架構砍掉重寫。
新版改成 for-loop 一個收件人一封信,中間 sleep 150ms 避開 Brevo rate limit:
foreach ( $subscribers as $sub ) {
$payload = [
'sender' => [...],
'to' => [ ['email' => $sub['email'], 'name' => $sub['name']] ],
'subject' => $post_title,
'htmlContent' => $rendered_html,
];
wp_remote_post( 'https://api.brevo.com/v3/smtp/email', [
'headers' => [ 'api-key' => $brevo_key, 'content-type' => 'application/json' ],
'body' => wp_json_encode( $payload ),
'timeout' => 10,
] );
usleep( 150000 ); // 150ms
}
慢、累、但每封信只有一個收件人,沒有人能看到其他人的 email。
隱私不是 feature
它是預設值
寫錯一行 你害的不是自己
最後一個小坑 — confirmation 連結點下去 404。我在我的 Brevo email template 裡寫:
<a href="[DOUBLEOPTIN]">確認訂閱</a>
點下去,瀏覽器跳轉到 http://[DOUBLEOPTIN],parse error。Brevo 的 token 不是中括號 [XXX] 格式(那是某些舊系統的慣例),是 mustache {{ doubleoptin }} 格式,而且小寫。改成:
<a href="{{ doubleoptin }}">確認訂閱</a>
之後一切正常。
文件要看到尾
不要讀到 50% 就以為你會了
「換過三次 終於把這封信好好寄出去」這句副標其實有點謙虛 — 三次方案、八個坑、一個差點害死我的隱私 bug,我才把 4 步驟 newsletter pipeline 跑順。每一篇新文章發布時,我按一下 publish,60 秒後 60 個訂閱者各自收到一封獨立的信,沒人看到誰、沒人被遺漏、沒人被寄重複。
對工程師讀者 — transition_post_status + wp_schedule_single_event + wp_remote_post 這三個原語組起來,可以做出比 99% newsletter plugin 都更可控的架構。完整 hook code 在文末彩蛋。對客戶讀者 — 你想要 newsletter 但不想自己 hand 這些坑,找我。
[訪客]
↓ 填 sidebar 訂閱表單
[Brevo Native form (sibforms.com)]
↓ Brevo 寄 confirmation 信
[訂閱者收到我寫的咖啡風 HTML 模板]
↓ 點 {{ doubleoptin }} 連結
[/subscribed/ 落地頁]
↓ Brevo 自動把 contact 加入 list 2 (Main)
─────────────────────
[我在 wp-admin 點 Publish]
↓ transition_post_status hook fire
[wp_schedule_single_event +60s]
↓ 60 秒後 WP cron 觸發
[studio_morcept_send_post_notification(post_id)]
↓ 從 Brevo API 撈 list 2 全部 contacts
[for each subscriber]
↓ POST /v3/smtp/email (一人一封)
↓ usleep(150ms) 避 rate limit
[每位訂閱者各自收到一封獨立信]
整條 pipeline 沒有任何 plugin DB cache,訂閱者資料只存在 Brevo 那一份。換 plugin / 重灌 WP 都不影響訂閱關係。
累死了 還好現在自動跑了 不用我每次手動寄 ∠( ᐛ 」∠)_