feat(newsletter): Implement newsletter subscription and unsubscription features

- Added models for NewsletterSystemSettings and NewsletterTemplateSettings to manage configuration.
- Created forms for subscribing and unsubscribing from the newsletter.
- Developed views for handling subscription, confirmation, and unsubscription processes.
- Integrated Member Center API for managing newsletter subscriptions.
- Implemented email sending functionality with SMTP settings.
- Added templates for displaying subscription status and unsubscription confirmation.
- Enhanced CSS for newsletter forms and status messages.
- Included tests for newsletter functionality and security measures for sensitive data.
This commit is contained in:
Warren Chen 2026-02-17 17:57:16 +09:00
parent 3b40368a24
commit 69ef3ccf72
15 changed files with 1070 additions and 3 deletions

View File

@ -154,3 +154,53 @@
3. 再完成退訂頁與退訂 API 串接。
4. 建立電子報 app 與 HTML 編輯器資料模型。
5. 最後接排程任務與 Send Engine 發信。
## 8. Member Center API 實際規格(以 member_center 程式碼 / OpenAPI 為準)
資料來源2026-02-17 比對):
- `../member_center/src/MemberCenter.Api/Controllers/NewsletterController.cs`
- `../member_center/docs/openapi.yaml`
### 8.1 Base URL 與路徑
- OpenAPI `servers.url``/api`,部署時實際呼叫通常為:
- `https://{member-center}/api/newsletter/...`
- 若部署已在 gateway 做 path rewrite也可為
- `https://{member-center}/newsletter/...`
- 租戶端必須以實際部署路徑設定 `member_center_base_url`
### 8.2 Endpoint / Method / Request
1. 訂閱
- `POST /newsletter/subscribe`
- JSON body必要
- `list_id`Guid
- `email`string
- JSON body可選
- `preferences`object
- `source`string
- 回傳包含 `confirm_token`
2. 訂閱確認(雙重驗證)
- `GET /newsletter/confirm?token=...`
- 注意:**confirm 是 GET不是 POST**
3. 單一名單退訂
- `POST /newsletter/unsubscribe`
- JSON body必要
- `token`string
4. 申請退訂 token
- `POST /newsletter/unsubscribe-token`
- JSON body必要
- `list_id`Guid
- `email`string
- 回傳:
- `unsubscribe_token`string
### 8.3 與租戶端目前實作對齊結果
- `subscribe`:已使用 `POST`
- `confirm`:已改為 `GET + query token`(修正 HTTP 405 問題)。
- `unsubscribe`:已使用 `POST`,且 body 僅送 `token`
- `unsubscribe-token`:已使用 `POST`,且 body 送 `list_id + email`

View File

@ -0,0 +1,19 @@
from django import forms
class NewsletterSubscribeForm(forms.Form):
email = forms.EmailField(max_length=254)
class NewsletterUnsubscribeForm(forms.Form):
email = forms.EmailField(
max_length=254,
required=False,
widget=forms.EmailInput(
attrs={
"readonly": "readonly",
"aria-readonly": "true",
}
),
)
token = forms.CharField(max_length=1024, widget=forms.HiddenInput())

View File

@ -0,0 +1,62 @@
# Generated by Django 5.2.7 on 2026-02-12 07:23
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('base', '0004_remove_headersettings_logo_headersettings_logo_dark_and_more'),
]
operations = [
migrations.CreateModel(
name='NewsletterSystemSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('member_center_base_url', models.URLField(blank=True)),
('member_center_subscribe_path', models.CharField(blank=True, default='/newsletter/subscribe', max_length=255)),
('member_center_confirm_path', models.CharField(blank=True, default='/newsletter/confirm', max_length=255)),
('member_center_unsubscribe_token_path', models.CharField(blank=True, default='/newsletter/unsubscribe-token', max_length=255)),
('member_center_unsubscribe_path', models.CharField(blank=True, default='/newsletter/unsubscribe', max_length=255)),
('member_center_tenant_id', models.CharField(blank=True, max_length=128)),
('member_center_list_id', models.CharField(blank=True, max_length=128)),
('member_center_timeout_seconds', models.PositiveIntegerField(default=10)),
('send_engine_base_url', models.URLField(blank=True)),
('send_engine_oauth_scope', models.CharField(blank=True, max_length=255)),
('send_engine_timeout_seconds', models.PositiveIntegerField(default=10)),
('smtp_relay_host', models.CharField(blank=True, max_length=255)),
('smtp_relay_port', models.PositiveIntegerField(default=587)),
('smtp_use_tls', models.BooleanField(default=True)),
('smtp_use_ssl', models.BooleanField(default=False, help_text='465 常用 SSLImplicit TLS587 常用 STARTTLSTLS')),
('smtp_timeout_seconds', models.PositiveIntegerField(default=15)),
('smtp_username', models.CharField(blank=True, max_length=255)),
('smtp_password', models.TextField(blank=True)),
('sender_name', models.CharField(blank=True, max_length=255)),
('sender_email', models.EmailField(blank=True, max_length=254)),
('reply_to_email', models.EmailField(blank=True, max_length=254)),
('default_charset', models.CharField(default='utf-8', max_length=50)),
],
options={
'verbose_name': 'Newsletter System Settings',
},
),
migrations.CreateModel(
name='NewsletterTemplateSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subscribe_subject_template', models.CharField(default='請確認您的電子報訂閱', max_length=255)),
('subscribe_html_template', models.TextField(default="<p>您好,請點擊以下連結完成訂閱:</p><p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>")),
('subscribe_text_template', models.TextField(default='您好,請點擊以下連結完成訂閱:{{confirm_url}}')),
('confirm_success_template', wagtail.fields.RichTextField(blank=True, default='<p>訂閱確認成功。</p>')),
('confirm_failure_template', wagtail.fields.RichTextField(blank=True, default='<p>訂閱確認失敗,請稍後再試。</p>')),
('unsubscribe_intro_template', wagtail.fields.RichTextField(blank=True, default='<p>確認要退訂電子報嗎?</p>')),
('unsubscribe_success_template', wagtail.fields.RichTextField(blank=True, default='<p>已完成退訂。</p>')),
('unsubscribe_failure_template', wagtail.fields.RichTextField(blank=True, default='<p>退訂失敗,請稍後再試。</p>')),
],
options={
'verbose_name': 'Newsletter Template Settings',
},
),
]

View File

@ -1,3 +1,4 @@
from django import forms as django_forms
from django.db import models
from wagtail.admin.panels import (
FieldPanel,
@ -27,6 +28,7 @@ from wagtail.snippets.models import register_snippet
from wagtail.fields import StreamField
from wagtail import blocks
from .security import encrypt_text
@register_setting
class HeaderSettings(BaseGenericSetting):
logo_light = models.ForeignKey(
@ -112,6 +114,173 @@ class SocialMediaSettings(BaseGenericSetting):
panels = [FieldPanel("links")]
@register_setting
class NewsletterSystemSettings(BaseGenericSetting):
member_center_base_url = models.URLField(blank=True)
member_center_subscribe_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/subscribe",
)
member_center_confirm_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/confirm",
)
member_center_unsubscribe_token_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/unsubscribe-token",
)
member_center_unsubscribe_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/unsubscribe",
)
member_center_tenant_id = models.CharField(max_length=128, blank=True)
member_center_list_id = models.CharField(max_length=128, blank=True)
member_center_timeout_seconds = models.PositiveIntegerField(default=10)
send_engine_base_url = models.URLField(blank=True)
send_engine_oauth_scope = models.CharField(max_length=255, blank=True)
send_engine_timeout_seconds = models.PositiveIntegerField(default=10)
smtp_relay_host = models.CharField(max_length=255, blank=True)
smtp_relay_port = models.PositiveIntegerField(default=587)
smtp_use_tls = models.BooleanField(default=True)
smtp_use_ssl = models.BooleanField(
default=False,
help_text="465 常用 SSLImplicit TLS587 常用 STARTTLSTLS",
)
smtp_timeout_seconds = models.PositiveIntegerField(default=15)
smtp_username = models.CharField(max_length=255, blank=True)
smtp_password = models.TextField(blank=True)
sender_name = models.CharField(max_length=255, blank=True)
sender_email = models.EmailField(blank=True)
reply_to_email = models.EmailField(blank=True)
default_charset = models.CharField(max_length=50, default="utf-8")
panels = [
MultiFieldPanel(
[
FieldPanel("member_center_base_url"),
FieldPanel("member_center_subscribe_path"),
FieldPanel("member_center_confirm_path"),
FieldPanel("member_center_unsubscribe_token_path"),
FieldPanel("member_center_unsubscribe_path"),
FieldPanel("member_center_tenant_id"),
FieldPanel("member_center_list_id"),
FieldPanel("member_center_timeout_seconds"),
],
heading="Member Center",
),
MultiFieldPanel(
[
FieldPanel("send_engine_base_url"),
FieldPanel("send_engine_oauth_scope"),
FieldPanel("send_engine_timeout_seconds"),
],
heading="Send Engine",
),
MultiFieldPanel(
[
FieldPanel("smtp_relay_host"),
FieldPanel("smtp_relay_port"),
FieldPanel("smtp_use_tls"),
FieldPanel("smtp_use_ssl"),
FieldPanel("smtp_timeout_seconds"),
FieldPanel("smtp_username"),
FieldPanel(
"smtp_password",
widget=django_forms.PasswordInput(render_value=False),
),
FieldPanel("sender_name"),
FieldPanel("sender_email"),
FieldPanel("reply_to_email"),
FieldPanel("default_charset"),
],
heading="SMTP / Mail",
),
]
class Meta:
verbose_name = "Newsletter System Settings"
def save(self, *args, **kwargs):
if self.smtp_use_tls and self.smtp_use_ssl:
raise ValueError("smtp_use_tls and smtp_use_ssl cannot both be True.")
if self.pk and not self.smtp_password:
previous = type(self).objects.filter(pk=self.pk).only("smtp_password").first()
self.smtp_password = previous.smtp_password if previous else ""
elif self.smtp_password:
self.smtp_password = encrypt_text(self.smtp_password)
super().save(*args, **kwargs)
@register_setting
class NewsletterTemplateSettings(BaseGenericSetting):
subscribe_subject_template = models.CharField(
max_length=255,
default="請確認您的電子報訂閱",
)
subscribe_html_template = models.TextField(
default=(
"<p>您好,請點擊以下連結完成訂閱:</p>"
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
),
)
subscribe_text_template = models.TextField(
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
)
confirm_success_template = RichTextField(
blank=True,
default="<p>訂閱確認成功。</p>",
)
confirm_failure_template = RichTextField(
blank=True,
default="<p>訂閱確認失敗,請稍後再試。</p>",
)
unsubscribe_intro_template = RichTextField(
blank=True,
default="<p>確認要退訂電子報嗎?</p>",
)
unsubscribe_success_template = RichTextField(
blank=True,
default="<p>已完成退訂。</p>",
)
unsubscribe_failure_template = RichTextField(
blank=True,
default="<p>退訂失敗,請稍後再試。</p>",
)
panels = [
MultiFieldPanel(
[
FieldPanel("subscribe_subject_template"),
FieldPanel("subscribe_html_template"),
FieldPanel("subscribe_text_template"),
],
heading="Subscribe Confirmation Email",
),
MultiFieldPanel(
[
FieldPanel("confirm_success_template"),
FieldPanel("confirm_failure_template"),
FieldPanel("unsubscribe_intro_template"),
FieldPanel("unsubscribe_success_template"),
FieldPanel("unsubscribe_failure_template"),
],
heading="Page Templates",
),
]
class Meta:
verbose_name = "Newsletter Template Settings"
@register_snippet
class BannerSnippet(models.Model):
key = models.CharField(max_length=50, blank=True, help_text="識別用 key例如 home / category")

View File

@ -0,0 +1,189 @@
import json
import logging
from dataclasses import dataclass
from email.utils import formataddr
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urljoin
from urllib.request import Request, urlopen
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from .models import NewsletterSystemSettings
from .security import decrypt_text
logger = logging.getLogger(__name__)
PLACEHOLDER_KEYS = (
"token",
"email",
"list_id",
"tenant_id",
"confirm_url",
"unsubscribe_url",
)
@dataclass
class APIResult:
ok: bool
status: int
data: dict
error: str = ""
class MemberCenterClient:
def __init__(self, config: NewsletterSystemSettings):
self.config = config
def subscribe(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_subscribe_path, payload)
def confirm(self, token: str) -> APIResult:
return self._get(self.config.member_center_confirm_path, {"token": token})
def request_unsubscribe_token(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_unsubscribe_token_path, payload)
def unsubscribe(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_unsubscribe_path, payload)
def _get(self, path: str, query: dict) -> APIResult:
base_url = (self.config.member_center_base_url or "").strip()
if not base_url:
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
if query:
endpoint = f"{endpoint}?{urlencode(query)}"
request = Request(
endpoint,
headers={"Accept": "application/json"},
method="GET",
)
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
return self._send(request, timeout=timeout)
def _post(self, path: str, payload: dict) -> APIResult:
base_url = (self.config.member_center_base_url or "").strip()
if not base_url:
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
body = json.dumps(payload).encode("utf-8")
request = Request(
endpoint,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
return self._send(request, timeout=timeout)
def _send(self, request: Request, timeout: int) -> APIResult:
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw) if raw else {}
status = getattr(response, "status", 200)
return APIResult(ok=200 <= status < 300, status=status, data=data)
except HTTPError as exc:
raw = exc.read().decode("utf-8") if exc.fp else ""
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {}
return APIResult(ok=False, status=exc.code, data=data, error=str(exc))
except (URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
return APIResult(ok=False, status=0, data={}, error=str(exc))
def render_placeholders(template: str, values: dict) -> str:
rendered = template or ""
for key in PLACEHOLDER_KEYS:
rendered = rendered.replace(f"{{{{{key}}}}}", str(values.get(key, "")))
return rendered
def extract_token(payload: dict) -> str:
if not payload:
return ""
for key in ("token", "confirm_token", "unsubscribe_token"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
data = payload.get("data")
if isinstance(data, dict):
for key in ("token", "confirm_token", "unsubscribe_token"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def build_from_email(sender_name: str, sender_email: str) -> str:
if sender_name and sender_email:
return formataddr((sender_name, sender_email))
return sender_email
def send_subscribe_email(*, to_email: str, subject: str, text_body: str, html_body: str, config: NewsletterSystemSettings) -> int:
from_email = build_from_email(config.sender_name, config.sender_email)
reply_to = [config.reply_to_email] if config.reply_to_email else None
if not config.smtp_relay_host:
raise ValueError("SMTP relay host is empty. Please save SMTP settings first.")
if config.smtp_use_tls and config.smtp_use_ssl:
raise ValueError("SMTP TLS and SSL cannot both be enabled.")
password = ""
encrypted_password = (config.smtp_password or "").strip()
if encrypted_password:
try:
password = decrypt_text(encrypted_password)
except Exception:
password = ""
connection = get_connection(
backend="django.core.mail.backends.smtp.EmailBackend",
host=config.smtp_relay_host,
port=config.smtp_relay_port,
username=config.smtp_username or None,
password=password or None,
use_tls=bool(config.smtp_use_tls),
use_ssl=bool(config.smtp_use_ssl),
timeout=max(1, int(config.smtp_timeout_seconds or 15)),
fail_silently=False,
)
message = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
to=[to_email],
reply_to=reply_to,
connection=connection,
)
if html_body:
message.attach_alternative(html_body, "text/html")
charset = (config.default_charset or "utf-8").strip() or "utf-8"
message.encoding = charset
sent_count = message.send(fail_silently=False)
logger.info(
"newsletter email send result sent_count=%s smtp_host=%s smtp_port=%s smtp_tls=%s smtp_ssl=%s smtp_timeout=%s from_email=%s to_email=%s",
sent_count,
config.smtp_relay_host,
config.smtp_relay_port,
bool(config.smtp_use_tls),
bool(config.smtp_use_ssl),
max(1, int(config.smtp_timeout_seconds or 15)),
from_email or settings.DEFAULT_FROM_EMAIL,
to_email,
)
return sent_count

View File

@ -0,0 +1,71 @@
import base64
import hmac
import os
from hashlib import sha256
from django.conf import settings
from django.utils.crypto import pbkdf2
_VERSION = b"v1"
_SALT_SIZE = 16
_TAG_SIZE = 32
_PREFIX = "enc1:"
def _derive_key(salt: bytes) -> bytes:
return pbkdf2(settings.SECRET_KEY, salt, 260000, digest=sha256)
def _keystream(key: bytes, length: int) -> bytes:
out = bytearray()
counter = 0
while len(out) < length:
block = sha256(key + counter.to_bytes(4, "big")).digest()
out.extend(block)
counter += 1
return bytes(out[:length])
def encrypt_text(plaintext: str) -> str:
if not plaintext:
return ""
raw = plaintext.encode("utf-8")
salt = os.urandom(_SALT_SIZE)
key = _derive_key(salt)
stream = _keystream(key, len(raw))
ciphertext = bytes(a ^ b for a, b in zip(raw, stream))
tag = hmac.new(key, ciphertext, sha256).digest()
payload = _VERSION + salt + tag + ciphertext
return _PREFIX + base64.urlsafe_b64encode(payload).decode("ascii")
def decrypt_text(token: str) -> str:
if not token:
return ""
if not token.startswith(_PREFIX):
return token
payload = base64.urlsafe_b64decode(token[len(_PREFIX) :].encode("ascii"))
version = payload[:2]
if version != _VERSION:
raise ValueError("Unsupported encrypted payload version")
salt_start = 2
salt_end = salt_start + _SALT_SIZE
salt = payload[salt_start:salt_end]
tag_start = salt_end
tag_end = tag_start + _TAG_SIZE
tag = payload[tag_start:tag_end]
ciphertext = payload[tag_end:]
key = _derive_key(salt)
expected = hmac.new(key, ciphertext, sha256).digest()
if not hmac.compare_digest(tag, expected):
raise ValueError("Encrypted payload validation failed")
stream = _keystream(key, len(ciphertext))
raw = bytes(a ^ b for a, b in zip(ciphertext, stream))
return raw.decode("utf-8")

View File

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block content %}
<section class="newsletter-status{% if success %} is-success{% else %} is-failure{% endif %}">
<h1>{{ title }}</h1>
<div class="newsletter-status-message">{{ message|safe }}</div>
<p><a href="/">回到首頁</a></p>
</section>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<section class="newsletter-status{% if can_submit %} is-warning{% else %} is-failure{% endif %}">
<h1>取消訂閱</h1>
<div class="newsletter-status-message">{{ intro_message|safe }}</div>
{% if can_submit %}
<form method="post" action="{% url 'newsletter_unsubscribe' %}" class="newsletter-unsubscribe-form">
{% csrf_token %}
<label for="{{ form.email.id_for_label }}">退訂 Email</label>
{{ form.email }}
{{ form.token }}
<button type="submit">確認退訂</button>
</form>
{% else %}
<p>退訂連結已失效或缺少必要參數。</p>
{% endif %}
<p><a href="/">回到首頁</a></p>
</section>
{% endblock %}

View File

@ -1,3 +1,34 @@
from django.test import TestCase
# Create your tests here.
from .newsletter import extract_token, render_placeholders
from .security import decrypt_text, encrypt_text
class NewsletterTemplateTests(TestCase):
def test_render_placeholders_replaces_known_keys(self):
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
rendered = render_placeholders(
template,
{
"confirm_url": "https://example.com/confirm?token=abc",
"email": "demo@example.com",
"token": "abc",
},
)
self.assertEqual(
rendered,
"confirm=https://example.com/confirm?token=abc email=demo@example.com token=abc",
)
def test_extract_token_supports_top_level_and_nested_data(self):
self.assertEqual(extract_token({"token": "t1"}), "t1")
self.assertEqual(extract_token({"data": {"unsubscribe_token": "t2"}}), "t2")
self.assertEqual(extract_token({}), "")
def test_encrypt_decrypt_roundtrip(self):
secret = "mailrelay-secret"
encrypted = encrypt_text(secret)
self.assertTrue(encrypted.startswith("enc1:"))
self.assertNotEqual(encrypted, secret)
self.assertEqual(decrypt_text(encrypted), secret)

View File

@ -1,3 +1,273 @@
from django.shortcuts import render
from urllib.parse import urlencode
# Create your views here.
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseNotAllowed
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from .forms import NewsletterSubscribeForm, NewsletterUnsubscribeForm
from .models import NewsletterSystemSettings, NewsletterTemplateSettings
from .newsletter import (
MemberCenterClient,
build_from_email,
extract_token,
render_placeholders,
send_subscribe_email,
)
def _load_settings():
return NewsletterSystemSettings.load(), NewsletterTemplateSettings.load()
def _build_context(*, title: str, message: str, success: bool):
return {
"title": title,
"message": message,
"success": success,
}
@require_http_methods(["POST"])
def newsletter_subscribe(request):
form = NewsletterSubscribeForm(request.POST)
system_settings, template_settings = _load_settings()
if not form.is_valid():
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱失敗",
message="請輸入正確 email。",
success=False,
),
status=400,
)
email = form.cleaned_data["email"].strip().lower()
client = MemberCenterClient(system_settings)
subscribe_payload = {
"email": email,
"list_id": system_settings.member_center_list_id,
"source": "wagtail",
}
subscribe_result = client.subscribe(subscribe_payload)
if not subscribe_result.ok:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱失敗",
message="訂閱服務暫時無法使用,請稍後再試。",
success=False,
),
status=502,
)
token = extract_token(subscribe_result.data)
confirm_url = request.build_absolute_uri(reverse("newsletter_confirm"))
unsubscribe_url = request.build_absolute_uri(reverse("newsletter_unsubscribe"))
if token:
query = urlencode({"token": token, "email": email})
confirm_url = f"{confirm_url}?{query}"
unsubscribe_url = f"{unsubscribe_url}?{query}"
values = {
"token": token,
"email": email,
"list_id": system_settings.member_center_list_id,
"tenant_id": system_settings.member_center_tenant_id,
"confirm_url": confirm_url,
"unsubscribe_url": unsubscribe_url,
}
try:
send_subscribe_email(
to_email=email,
subject=render_placeholders(template_settings.subscribe_subject_template, values),
text_body=render_placeholders(template_settings.subscribe_text_template, values),
html_body=render_placeholders(template_settings.subscribe_html_template, values),
config=system_settings,
)
except Exception:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱失敗",
message="確認信寄送失敗,請稍後再試。",
success=False,
),
status=502,
)
return render(
request,
"base/newsletter/status.html",
_build_context(
title="請前往信箱確認",
message="我們已寄出確認信,請點擊信中的連結完成訂閱。",
success=True,
),
)
@require_GET
def newsletter_confirm(request):
token = (request.GET.get("token") or "").strip()
email = (request.GET.get("email") or "").strip().lower()
system_settings, template_settings = _load_settings()
if not token:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱確認失敗",
message=template_settings.confirm_failure_template,
success=False,
),
status=400,
)
result = MemberCenterClient(system_settings).confirm(token)
if result.ok:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱確認成功",
message=template_settings.confirm_success_template,
success=True,
),
)
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱確認失敗",
message=template_settings.confirm_failure_template,
success=False,
),
status=400,
)
@require_http_methods(["GET", "POST"])
def newsletter_unsubscribe(request):
system_settings, template_settings = _load_settings()
client = MemberCenterClient(system_settings)
if request.method == "GET":
token = (request.GET.get("token") or "").strip()
email = (request.GET.get("email") or "").strip().lower()
if not token and email:
token_result = client.request_unsubscribe_token(
{
"email": email,
"list_id": system_settings.member_center_list_id,
}
)
if token_result.ok:
token = extract_token(token_result.data)
form = NewsletterUnsubscribeForm(initial={"email": email, "token": token})
can_submit = bool(token)
return render(
request,
"base/newsletter/unsubscribe.html",
{
"form": form,
"can_submit": can_submit,
"intro_message": template_settings.unsubscribe_intro_template,
},
status=200 if can_submit else 400,
)
if request.method != "POST":
return HttpResponseNotAllowed(["GET", "POST"])
form = NewsletterUnsubscribeForm(request.POST)
if not form.is_valid():
return render(
request,
"base/newsletter/status.html",
_build_context(
title="退訂失敗",
message=template_settings.unsubscribe_failure_template,
success=False,
),
status=400,
)
payload = {
"token": form.cleaned_data["token"],
}
result = client.unsubscribe(payload)
if result.ok:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="退訂成功",
message=template_settings.unsubscribe_success_template,
success=True,
),
)
return render(
request,
"base/newsletter/status.html",
_build_context(
title="退訂失敗",
message=template_settings.unsubscribe_failure_template,
success=False,
),
status=400,
)
@staff_member_required
@require_POST
def newsletter_smtp_test(request):
to_email = (request.POST.get("smtp_test_email") or "").strip().lower()
redirect_to = request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home")
if not to_email:
messages.error(request, "請輸入測試收件 Email。")
return redirect(redirect_to)
try:
validate_email(to_email)
except ValidationError:
messages.error(request, "測試收件 Email 格式不正確。")
return redirect(redirect_to)
settings_obj = NewsletterSystemSettings.load(request_or_site=request)
try:
sent_count = send_subscribe_email(
to_email=to_email,
subject="[SMTP Test] Newsletter SMTP 設定測試",
text_body="這是一封測試信,代表 SMTP 設定可正常寄送。",
html_body="<p>這是一封測試信,代表 SMTP 設定可正常寄送。</p>",
config=settings_obj,
)
except Exception as exc:
messages.error(request, f"測試信寄送失敗:{exc}")
return redirect(redirect_to)
from_email = build_from_email(settings_obj.sender_name, settings_obj.sender_email)
messages.success(
request,
f"SMTP 已接受請求sent_count={sent_count}from={from_email or 'settings.DEFAULT_FROM_EMAIL'}to={to_email}",
)
return redirect(redirect_to)

View File

@ -342,6 +342,47 @@ footer .copyright p {
fill: #0e1b42;
}
.newsletter-subscribe-form {
margin-top: 16px;
}
.newsletter-subscribe-form label {
display: block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
}
.newsletter-subscribe-controls {
display: flex;
align-items: center;
gap: 8px;
}
.newsletter-subscribe-controls input[type="email"] {
width: 180px;
max-width: 100%;
padding: 8px;
border: 1px solid #ffffff66;
border-radius: 4px;
background: #ffffff;
color: #0e1b42;
}
.newsletter-subscribe-controls button {
border: 1px solid #ffffff;
border-radius: 4px;
background: transparent;
color: #ffffff;
padding: 8px 12px;
cursor: pointer;
}
.newsletter-subscribe-controls button:hover {
background: #ffffff;
color: #0e1b42;
}
footer .footer-links {
padding: 0 16px;
}
@ -393,6 +434,31 @@ footer .footer-sections {
color: #0e1b42;
}
.newsletter-status {
max-width: 640px;
margin: 48px auto;
padding: 24px;
border: 1px solid #0e1b4233;
border-radius: 8px;
}
.newsletter-status h1 {
margin-top: 0;
}
.newsletter-status-message {
margin-bottom: 16px;
}
.newsletter-unsubscribe-form button {
border: 0;
border-radius: 4px;
background: #0e1b42;
color: #ffffff;
padding: 10px 16px;
cursor: pointer;
}
@media (max-width: 1023px) {
.site-container {
max-width: 640px;
@ -419,6 +485,10 @@ footer .footer-sections {
justify-content: center;
}
.newsletter-subscribe-controls {
justify-content: center;
}
footer .footer-sections {
justify-content: center;
}

View File

@ -49,6 +49,15 @@
</div>
{% endif %}
{% endwith %}
<form class="newsletter-subscribe-form" method="post" action="{% url 'newsletter_subscribe' %}">
{% csrf_token %}
<label for="newsletter-email">訂閱電子報</label>
<div class="newsletter-subscribe-controls">
<input id="newsletter-email" type="email" name="email" required placeholder="輸入 Email">
<button type="submit">送出</button>
</div>
</form>
</div>
<div class="footer-divider" aria-hidden="true"></div>

View File

@ -0,0 +1,60 @@
{% extends "wagtailadmin/generic/edit.html" %}
{% load i18n wagtailadmin_tags %}
{% block before_form %}
{% if site_switcher %}
<form class="w-mb-8" method="get" id="settings-site-switch" novalidate>
<label for="{{ site_switcher.site.id_for_label }}">
{% trans "Site" %}:
</label>
{{ site_switcher.site }}
</form>
{% elif site_for_header %}
<div class="w-mb-8">
{% trans "Site" %}:
{{ site_for_header.hostname }}
{% if site_for_header.is_default_site %}[{% trans "default" %}]{% endif %}
</div>
{% endif %}
{% endblock %}
{% block form_content %}
{% if panel %}
{{ panel.render_form_content }}
{% else %}
{{ block.super }}
{% endif %}
{% if form.smtp_relay_host and form.smtp_password %}
<section class="w-panel w-mt-8">
<div class="w-panel__header">
<h2 class="w-panel__heading">發送測試郵件(請先儲存設定後再發送測試郵件)</h2>
</div>
<div class="w-panel__content">
<ul class="fields">
<li>
<label class="w-block w-font-semibold w-mb-2" for="smtp_test_email">發送郵件到</label>
<input
class="w-input"
type="email"
id="smtp_test_email"
name="smtp_test_email"
value="{{ form.data.smtp_test_email|default:'' }}"
placeholder="name@example.com"
>
<p class="help">此欄位僅作本次測試,不會儲存。</p>
</li>
</ul>
<button
type="submit"
class="button button-secondary"
formaction="{% url 'newsletter_smtp_test' %}"
formmethod="post"
formnovalidate
>
發送測試郵件
</button>
</div>
</section>
{% endif %}
{% endblock %}

View File

@ -8,6 +8,7 @@ from wagtail.documents import urls as wagtaildocs_urls
from search import views as search_views
from home import views as home_views
from base import views as base_views
urlpatterns = [
path("django-admin/", admin.site.urls),
@ -16,6 +17,10 @@ urlpatterns = [
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
path("search/", search_views.search, name="search"),
path("newsletter/subscribe/", base_views.newsletter_subscribe, name="newsletter_subscribe"),
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
]

View File

@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----