From 69ef3ccf72209f942f90add2c173b17e2c11ea5b Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Tue, 17 Feb 2026 17:57:16 +0900 Subject: [PATCH] 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. --- docs/newsletter_integration_memo.md | 50 ++++ innovedus_cms/base/forms.py | 19 ++ ...stemsettings_newslettertemplatesettings.py | 62 ++++ innovedus_cms/base/models.py | 169 +++++++++++ innovedus_cms/base/newsletter.py | 189 ++++++++++++ innovedus_cms/base/security.py | 71 +++++ .../templates/base/newsletter/status.html | 9 + .../base/newsletter/unsubscribe.html | 22 ++ innovedus_cms/base/tests.py | 33 ++- innovedus_cms/base/views.py | 274 +++++++++++++++++- innovedus_cms/mysite/static/css/mysite.css | 70 +++++ .../mysite/templates/includes/footer.html | 9 + .../templates/wagtailsettings/edit.html | 60 ++++ innovedus_cms/mysite/urls.py | 5 + innovedus_cms/smptrelay-ca.pem | 31 ++ 15 files changed, 1070 insertions(+), 3 deletions(-) create mode 100644 innovedus_cms/base/forms.py create mode 100644 innovedus_cms/base/migrations/0005_newslettersystemsettings_newslettertemplatesettings.py create mode 100644 innovedus_cms/base/newsletter.py create mode 100644 innovedus_cms/base/security.py create mode 100644 innovedus_cms/base/templates/base/newsletter/status.html create mode 100644 innovedus_cms/base/templates/base/newsletter/unsubscribe.html create mode 100644 innovedus_cms/mysite/templates/wagtailsettings/edit.html create mode 100644 innovedus_cms/smptrelay-ca.pem diff --git a/docs/newsletter_integration_memo.md b/docs/newsletter_integration_memo.md index 25f899e..11d7199 100644 --- a/docs/newsletter_integration_memo.md +++ b/docs/newsletter_integration_memo.md @@ -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`。 diff --git a/innovedus_cms/base/forms.py b/innovedus_cms/base/forms.py new file mode 100644 index 0000000..cac2eea --- /dev/null +++ b/innovedus_cms/base/forms.py @@ -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()) diff --git a/innovedus_cms/base/migrations/0005_newslettersystemsettings_newslettertemplatesettings.py b/innovedus_cms/base/migrations/0005_newslettersystemsettings_newslettertemplatesettings.py new file mode 100644 index 0000000..b67933d --- /dev/null +++ b/innovedus_cms/base/migrations/0005_newslettersystemsettings_newslettertemplatesettings.py @@ -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="

您好,請點擊以下連結完成訂閱:

{{confirm_url}}

")), + ('subscribe_text_template', models.TextField(default='您好,請點擊以下連結完成訂閱:{{confirm_url}}')), + ('confirm_success_template', wagtail.fields.RichTextField(blank=True, default='

訂閱確認成功。

')), + ('confirm_failure_template', wagtail.fields.RichTextField(blank=True, default='

訂閱確認失敗,請稍後再試。

')), + ('unsubscribe_intro_template', wagtail.fields.RichTextField(blank=True, default='

確認要退訂電子報嗎?

')), + ('unsubscribe_success_template', wagtail.fields.RichTextField(blank=True, default='

已完成退訂。

')), + ('unsubscribe_failure_template', wagtail.fields.RichTextField(blank=True, default='

退訂失敗,請稍後再試。

')), + ], + options={ + 'verbose_name': 'Newsletter Template Settings', + }, + ), + ] diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py index 0824424..9198269 100644 --- a/innovedus_cms/base/models.py +++ b/innovedus_cms/base/models.py @@ -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=( + "

您好,請點擊以下連結完成訂閱:

" + "

{{confirm_url}}

" + ), + ) + subscribe_text_template = models.TextField( + default="您好,請點擊以下連結完成訂閱:{{confirm_url}}", + ) + confirm_success_template = RichTextField( + blank=True, + default="

訂閱確認成功。

", + ) + confirm_failure_template = RichTextField( + blank=True, + default="

訂閱確認失敗,請稍後再試。

", + ) + unsubscribe_intro_template = RichTextField( + blank=True, + default="

確認要退訂電子報嗎?

", + ) + unsubscribe_success_template = RichTextField( + blank=True, + default="

已完成退訂。

", + ) + unsubscribe_failure_template = RichTextField( + blank=True, + default="

退訂失敗,請稍後再試。

", + ) + + 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)") diff --git a/innovedus_cms/base/newsletter.py b/innovedus_cms/base/newsletter.py new file mode 100644 index 0000000..f1395bd --- /dev/null +++ b/innovedus_cms/base/newsletter.py @@ -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 diff --git a/innovedus_cms/base/security.py b/innovedus_cms/base/security.py new file mode 100644 index 0000000..8ed2c2d --- /dev/null +++ b/innovedus_cms/base/security.py @@ -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") diff --git a/innovedus_cms/base/templates/base/newsletter/status.html b/innovedus_cms/base/templates/base/newsletter/status.html new file mode 100644 index 0000000..9b3bf80 --- /dev/null +++ b/innovedus_cms/base/templates/base/newsletter/status.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block content %} +
+

{{ title }}

+ +

回到首頁

+
+{% endblock %} diff --git a/innovedus_cms/base/templates/base/newsletter/unsubscribe.html b/innovedus_cms/base/templates/base/newsletter/unsubscribe.html new file mode 100644 index 0000000..18c65a5 --- /dev/null +++ b/innovedus_cms/base/templates/base/newsletter/unsubscribe.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block content %} +
+

取消訂閱

+ + + {% if can_submit %} + + {% else %} +

退訂連結已失效或缺少必要參數。

+ {% endif %} + +

回到首頁

+
+{% endblock %} diff --git a/innovedus_cms/base/tests.py b/innovedus_cms/base/tests.py index 7ce503c..6f8ebab 100644 --- a/innovedus_cms/base/tests.py +++ b/innovedus_cms/base/tests.py @@ -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) diff --git a/innovedus_cms/base/views.py b/innovedus_cms/base/views.py index 91ea44a..c410ae8 100644 --- a/innovedus_cms/base/views.py +++ b/innovedus_cms/base/views.py @@ -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="

這是一封測試信,代表 SMTP 設定可正常寄送。

", + 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) diff --git a/innovedus_cms/mysite/static/css/mysite.css b/innovedus_cms/mysite/static/css/mysite.css index 57bc3db..ff7a0c6 100644 --- a/innovedus_cms/mysite/static/css/mysite.css +++ b/innovedus_cms/mysite/static/css/mysite.css @@ -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; } diff --git a/innovedus_cms/mysite/templates/includes/footer.html b/innovedus_cms/mysite/templates/includes/footer.html index b59cc80..94a6ef1 100644 --- a/innovedus_cms/mysite/templates/includes/footer.html +++ b/innovedus_cms/mysite/templates/includes/footer.html @@ -49,6 +49,15 @@ {% endif %} {% endwith %} + +
+ {% csrf_token %} + + +
diff --git a/innovedus_cms/mysite/templates/wagtailsettings/edit.html b/innovedus_cms/mysite/templates/wagtailsettings/edit.html new file mode 100644 index 0000000..460aa0b --- /dev/null +++ b/innovedus_cms/mysite/templates/wagtailsettings/edit.html @@ -0,0 +1,60 @@ +{% extends "wagtailadmin/generic/edit.html" %} +{% load i18n wagtailadmin_tags %} + +{% block before_form %} + {% if site_switcher %} +
+ + {{ site_switcher.site }} +
+ {% elif site_for_header %} +
+ {% trans "Site" %}: + {{ site_for_header.hostname }} + {% if site_for_header.is_default_site %}[{% trans "default" %}]{% endif %} +
+ {% endif %} +{% endblock %} + +{% block form_content %} + {% if panel %} + {{ panel.render_form_content }} + {% else %} + {{ block.super }} + {% endif %} + + {% if form.smtp_relay_host and form.smtp_password %} +
+
+

發送測試郵件(請先儲存設定後再發送測試郵件)

+
+
+
    +
  • + + +

    此欄位僅作本次測試,不會儲存。

    +
  • +
+ +
+
+ {% endif %} +{% endblock %} diff --git a/innovedus_cms/mysite/urls.py b/innovedus_cms/mysite/urls.py index b60d225..ba5b160 100644 --- a/innovedus_cms/mysite/urls.py +++ b/innovedus_cms/mysite/urls.py @@ -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 so Unicode tag slugs (e.g. 台北美食) still resolve path("tags//", 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"), ] diff --git a/innovedus_cms/smptrelay-ca.pem b/innovedus_cms/smptrelay-ca.pem new file mode 100644 index 0000000..b85c803 --- /dev/null +++ b/innovedus_cms/smptrelay-ca.pem @@ -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-----