feat(newsletter): Implement one-click unsubscribe functionality and update related settings

This commit is contained in:
Warren Chen 2026-02-26 18:32:15 +09:00
parent 6ea501dc62
commit 9b3673831a
6 changed files with 41 additions and 99 deletions

View File

@ -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. 下一階段演進備忘(先記錄,待試用回饋後再排)
說明:

View File

@ -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:

View File

@ -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

View File

@ -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):

View File

@ -57,37 +57,4 @@
</div>
</section>
{% endif %}
{% if form.one_click_endpoint_path %}
<section class="w-panel w-mt-8">
<div class="w-panel__header">
<h2 class="w-panel__heading">List-Unsubscribe One-Click 預覽</h2>
</div>
<div class="w-panel__content">
<ul class="fields">
<li>
<label class="w-block w-font-semibold w-mb-2" for="preview_subscriber_id">Subscriber ID</label>
<input class="w-input" type="text" id="preview_subscriber_id" name="subscriber_id" value="preview-subscriber">
</li>
<li>
<label class="w-block w-font-semibold w-mb-2" for="preview_list_id">List ID</label>
<input class="w-input" type="text" id="preview_list_id" name="list_id" value="{{ form.member_center_list_id.value|default:'' }}">
</li>
<li>
<label class="w-block w-font-semibold w-mb-2" for="preview_campaign_id">Campaign ID</label>
<input class="w-input" type="text" id="preview_campaign_id" name="campaign_id" value="preview-campaign">
</li>
</ul>
<button
type="submit"
class="button button-secondary"
formaction="{% url 'newsletter_one_click_preview' %}"
formmethod="post"
formnovalidate
>
產生 One-Click 連結與 Header
</button>
</div>
</section>
{% endif %}
{% endblock %}

View File

@ -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/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
]