feat(newsletter): Implement one-click unsubscribe functionality and update related settings
This commit is contained in:
parent
6ea501dc62
commit
9b3673831a
@ -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. 下一階段演進備忘(先記錄,待試用回饋後再排)
|
||||
|
||||
說明:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user