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 %}
-
-
-
-
-
-
-
- {% 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"),
]