From 9b3673831a552814526949d5a63239b869913125 Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Thu, 26 Feb 2026 18:32:15 +0900 Subject: [PATCH] feat(newsletter): Implement one-click unsubscribe functionality and update related settings --- docs/newsletter_integration_memo.md | 16 +++++ innovedus_cms/base/models.py | 18 ++--- innovedus_cms/base/newsletter_scheduler.py | 7 +- innovedus_cms/base/views.py | 65 +++---------------- .../templates/wagtailsettings/edit.html | 33 ---------- innovedus_cms/mysite/urls.py | 1 - 6 files changed, 41 insertions(+), 99 deletions(-) diff --git a/docs/newsletter_integration_memo.md b/docs/newsletter_integration_memo.md index 793cd36..82b9197 100644 --- a/docs/newsletter_integration_memo.md +++ b/docs/newsletter_integration_memo.md @@ -343,6 +343,22 @@ Send Engine 最終態(terminal): - 目前 dispatch 紀錄改為「每次送 job / 重試一次一筆」,不再是逐收件者一筆。 - 若要做投遞到單一收件者的最終監控(delivery/bounce/complaint),仍建議接 Send Engine webhook 或事件回寫機制處理。 +### 9.10 One-Click Token 模式(目前採用) + +目前採用:`MemberCenter token relay` + +流程: +1. CMS 發送 payload 時同時提供: + - `template.list_unsubscribe_url_template`(給 Send Engine 產生 `List-Unsubscribe` header) + 信內可點連結由 `body_html/body_text` 直接使用 `{{unsubscribe_token}}` 組 URL。 + 例如:`https://{cms}/u/unsubscribe?token={{unsubscribe_token}}` +2. Send Engine 於發送時向 Member Center 取得每位收件者的退訂 token,並替換 `{{unsubscribe_token}}`。 +3. 使用者點擊信內退訂(或 mailbox one-click)後,進入 CMS `/u/unsubscribe`。 +4. CMS 不自行驗簽 token,直接以 S2S 呼叫 Member Center `unsubscribe(token)` 完成退訂。 + +備註: +- 舊的 CMS 自簽 HMAC one-click token 保留工具函式作為備援,但主流程已切到 relay 模式。 + ## 10. 下一階段演進備忘(先記錄,待試用回饋後再排) 說明: diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py index 2843a64..f40ce50 100644 --- a/innovedus_cms/base/models.py +++ b/innovedus_cms/base/models.py @@ -241,6 +241,15 @@ class NewsletterSystemSettings(BaseGenericSetting): ], heading="Send Engine", ), + MultiFieldPanel( + [ + FieldPanel("one_click_endpoint_path"), + FieldPanel("one_click_token_secret"), + FieldPanel("one_click_token_ttl_seconds"), + FieldPanel("site_base_url"), + ], + heading="List-Unsubscribe One-Click", + ), MultiFieldPanel( [ FieldPanel("smtp_relay_host"), @@ -260,15 +269,6 @@ class NewsletterSystemSettings(BaseGenericSetting): ], heading="SMTP / Mail", ), - MultiFieldPanel( - [ - FieldPanel("one_click_endpoint_path"), - FieldPanel("one_click_token_secret"), - FieldPanel("one_click_token_ttl_seconds"), - FieldPanel("site_base_url"), - ], - heading="List-Unsubscribe One-Click", - ), ] class Meta: diff --git a/innovedus_cms/base/newsletter_scheduler.py b/innovedus_cms/base/newsletter_scheduler.py index e78598e..be0a706 100644 --- a/innovedus_cms/base/newsletter_scheduler.py +++ b/innovedus_cms/base/newsletter_scheduler.py @@ -39,8 +39,13 @@ def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: Newsl if body_text: payload["body_text"] = body_text if site_base_url: + one_click_path = (settings_obj.one_click_endpoint_path or "/u/unsubscribe").strip() + if not one_click_path.startswith("/"): + one_click_path = f"/{one_click_path}" + unsubscribe_url_template = f"{site_base_url}{one_click_path}?token={{{{unsubscribe_token}}}}" payload["template"] = { - "unsubscribe_url": f"{site_base_url}/newsletter/unsubscribe?email={{{{email}}}}", + # Send Engine uses this key to emit List-Unsubscribe headers. + "list_unsubscribe_url_template": unsubscribe_url_template, } return payload diff --git a/innovedus_cms/base/views.py b/innovedus_cms/base/views.py index 00bb7b3..ba1bc72 100644 --- a/innovedus_cms/base/views.py +++ b/innovedus_cms/base/views.py @@ -11,8 +11,6 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_http_methods, require_POST -from wagtail.models import Site - from .forms import NewsletterSubscribeForm, NewsletterUnsubscribeForm from .models import ( NewsletterCampaign, @@ -23,14 +21,9 @@ from .models import ( from .newsletter import ( MemberCenterClient, build_from_email, - build_list_unsubscribe_headers, - build_one_click_unsubscribe_url, extract_token, - generate_one_click_token, render_placeholders, - resolve_one_click_secret, send_subscribe_email, - verify_one_click_token, ) from .newsletter_scheduler import dispatch_campaign @@ -69,14 +62,10 @@ def _extract_one_click_token(request): def _handle_one_click_unsubscribe(*, request, token): system_settings, _ = _load_settings() - secret = resolve_one_click_secret(system_settings) - payload, token_error = verify_one_click_token(token, secret) token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() if token else "" - if token_error == "expired": - return False, 410, "退訂連結已過期。" - if token_error: + if not token: return False, 400, "退訂連結無效。" existing = OneClickUnsubscribeAudit.objects.filter( @@ -86,13 +75,7 @@ def _handle_one_click_unsubscribe(*, request, token): if existing: return True, 200, "您已完成退訂。" - request_payload = { - "subscriber_id": payload["subscriber_id"], - "list_id": payload["list_id"], - "source": "one_click", - "campaign_id": payload.get("campaign_id", ""), - } - result = MemberCenterClient(system_settings).one_click_unsubscribe(request_payload) + result = MemberCenterClient(system_settings).unsubscribe({"token": token}) response_data = result.data if isinstance(result.data, dict) else {} if result.ok: @@ -102,10 +85,10 @@ def _handle_one_click_unsubscribe(*, request, token): OneClickUnsubscribeAudit.objects.update_or_create( token_hash=token_hash, defaults={ - "subscriber_id": str(payload.get("subscriber_id", "")), - "list_id": str(payload.get("list_id", "")), - "site_id": str(payload.get("site_id", "")), - "campaign_id": str(payload.get("campaign_id", "")), + "subscriber_id": "", + "list_id": "", + "site_id": "", + "campaign_id": "", "status": "already_unsubscribed" if already else "success", "response_status": result.status, "response_payload": response_data, @@ -116,10 +99,10 @@ def _handle_one_click_unsubscribe(*, request, token): OneClickUnsubscribeAudit.objects.update_or_create( token_hash=token_hash, defaults={ - "subscriber_id": str(payload.get("subscriber_id", "")), - "list_id": str(payload.get("list_id", "")), - "site_id": str(payload.get("site_id", "")), - "campaign_id": str(payload.get("campaign_id", "")), + "subscriber_id": "", + "list_id": "", + "site_id": "", + "campaign_id": "", "status": "failed", "response_status": result.status, "response_payload": response_data, @@ -357,34 +340,6 @@ def one_click_unsubscribe(request): ) -@staff_member_required -@require_POST -def newsletter_one_click_preview(request): - settings_obj = NewsletterSystemSettings.load(request_or_site=request) - site = Site.find_for_request(request) - site_base_url = f"https://{site.hostname}" if site else request.build_absolute_uri("/").rstrip("/") - secret = resolve_one_click_secret(settings_obj) - token = generate_one_click_token( - subscriber_id=request.POST.get("subscriber_id", "preview-subscriber"), - list_id=request.POST.get("list_id", settings_obj.member_center_list_id or "preview-list"), - site_id=request.POST.get("site_id", str(site.id) if site else "preview-site"), - campaign_id=request.POST.get("campaign_id", "preview-campaign"), - secret=secret, - ttl_seconds=settings_obj.one_click_token_ttl_seconds, - ) - one_click_url = build_one_click_unsubscribe_url( - site_base_url=site_base_url, - endpoint_path=settings_obj.one_click_endpoint_path or "/u/unsubscribe", - token=token, - ) - headers = build_list_unsubscribe_headers(one_click_url=one_click_url) - messages.success( - request, - f"One-click URL: {one_click_url} | Headers: {headers}", - ) - return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home")) - - @staff_member_required @require_GET def newsletter_campaign_send_now(request, campaign_id: int): diff --git a/innovedus_cms/mysite/templates/wagtailsettings/edit.html b/innovedus_cms/mysite/templates/wagtailsettings/edit.html index 19347fc..460aa0b 100644 --- a/innovedus_cms/mysite/templates/wagtailsettings/edit.html +++ b/innovedus_cms/mysite/templates/wagtailsettings/edit.html @@ -57,37 +57,4 @@ {% endif %} - - {% if form.one_click_endpoint_path %} -
-
-

List-Unsubscribe One-Click 預覽

-
-
-
    -
  • - - -
  • -
  • - - -
  • -
  • - - -
  • -
- -
-
- {% endif %} {% endblock %} diff --git a/innovedus_cms/mysite/urls.py b/innovedus_cms/mysite/urls.py index 33a3a14..65c2cc2 100644 --- a/innovedus_cms/mysite/urls.py +++ b/innovedus_cms/mysite/urls.py @@ -22,7 +22,6 @@ urlpatterns = [ path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"), path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"), path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"), - path("newsletter/one-click-preview/", base_views.newsletter_one_click_preview, name="newsletter_one_click_preview"), path("newsletter/campaigns//send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"), ]