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:
parent
3b40368a24
commit
69ef3ccf72
@ -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`。
|
||||
|
||||
19
innovedus_cms/base/forms.py
Normal file
19
innovedus_cms/base/forms.py
Normal 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())
|
||||
@ -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 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。')),
|
||||
('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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。",
|
||||
)
|
||||
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)")
|
||||
|
||||
189
innovedus_cms/base/newsletter.py
Normal file
189
innovedus_cms/base/newsletter.py
Normal 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
|
||||
71
innovedus_cms/base/security.py
Normal file
71
innovedus_cms/base/security.py
Normal 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")
|
||||
9
innovedus_cms/base/templates/base/newsletter/status.html
Normal file
9
innovedus_cms/base/templates/base/newsletter/status.html
Normal 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 %}
|
||||
@ -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 %}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
60
innovedus_cms/mysite/templates/wagtailsettings/edit.html
Normal file
60
innovedus_cms/mysite/templates/wagtailsettings/edit.html
Normal 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 %}
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
31
innovedus_cms/smptrelay-ca.pem
Normal file
31
innovedus_cms/smptrelay-ca.pem
Normal file
@ -0,0 +1,31 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----
|
||||
Loading…
x
Reference in New Issue
Block a user