Lin.Log|Studio
// 01 · wordpress-newsletter-three-architecture-migrations$ cat wordpress-newsletter-three-architecture-migrations.md

換過三次 終於把這封信好好寄出去

2026.05.07Tech Notes7 min read
換過三次 終於把這封信好好寄出去

技術主題:WordPress Newsletter / WP Cron / Brevo Transactional API
實作時間:2026/04 - 2026/05
換過方案:3 次
紀錄者:_YLin_Lai
參考資料: https://developers.brevo.com/, https://developer.wordpress.org/plugins/cron/

1.主題

本篇主題同詩意副標 — 換過三次,終於把這封信好好寄出去。我以為 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。

2.背景(需求簡介)

freelance 站台的 newsletter 不是「行銷工具」,它是訂閱者跟我之間最薄的一條線 — 不依賴 IG 演算法、不依賴 Google ranking、不依賴 LINE 群組是否被封。訂閱者主動填了 email,我發新文章他就會收到,這條線是我自己擁有的。

需求乾乾淨淨 4 步:

  • (1) 訪客在 sidebar 看到表單,填 email 訂閱
  • (2) 系統寄出 double opt-in 確認信
  • (3) 訂閱者點確認連結,正式進 mailing list
  • (4) 我在 wp-admin 發新文章,訂閱者自動收到通知信

看似 4 步,每一步都有自己的 hell。前後換 3 個方案、踩 8 個坑、修 1 個隱私 bug,才把整條 pipeline 跑順。

3.使用技術

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。

4.心得

這 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 都不影響訂閱關係。

累死了 還好現在自動跑了 不用我每次手動寄 ∠( ᐛ 」∠)_

linlog@studio $ exit

Got something you want to build together?

[Contact me]
email dannylaii@linlogstudio.com
hours Mon – Fri · After 7PM
base Taiwan · Remote-first
Connection to lin.log closed. © 2026 Lin.Log|Studio