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 串接。
|
3. 再完成退訂頁與退訂 API 串接。
|
||||||
4. 建立電子報 app 與 HTML 編輯器資料模型。
|
4. 建立電子報 app 與 HTML 編輯器資料模型。
|
||||||
5. 最後接排程任務與 Send Engine 發信。
|
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 django.db import models
|
||||||
from wagtail.admin.panels import (
|
from wagtail.admin.panels import (
|
||||||
FieldPanel,
|
FieldPanel,
|
||||||
@ -27,6 +28,7 @@ from wagtail.snippets.models import register_snippet
|
|||||||
from wagtail.fields import StreamField
|
from wagtail.fields import StreamField
|
||||||
from wagtail import blocks
|
from wagtail import blocks
|
||||||
|
|
||||||
|
from .security import encrypt_text
|
||||||
@register_setting
|
@register_setting
|
||||||
class HeaderSettings(BaseGenericSetting):
|
class HeaderSettings(BaseGenericSetting):
|
||||||
logo_light = models.ForeignKey(
|
logo_light = models.ForeignKey(
|
||||||
@ -112,6 +114,173 @@ class SocialMediaSettings(BaseGenericSetting):
|
|||||||
|
|
||||||
panels = [FieldPanel("links")]
|
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
|
@register_snippet
|
||||||
class BannerSnippet(models.Model):
|
class BannerSnippet(models.Model):
|
||||||
key = models.CharField(max_length=50, blank=True, help_text="識別用 key(例如 home / category)")
|
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
|
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;
|
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 {
|
footer .footer-links {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
@ -393,6 +434,31 @@ footer .footer-sections {
|
|||||||
color: #0e1b42;
|
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) {
|
@media (max-width: 1023px) {
|
||||||
.site-container {
|
.site-container {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
@ -419,6 +485,10 @@ footer .footer-sections {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.newsletter-subscribe-controls {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
footer .footer-sections {
|
footer .footer-sections {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,6 +49,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% 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>
|
||||||
|
|
||||||
<div class="footer-divider" aria-hidden="true"></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 search import views as search_views
|
||||||
from home import views as home_views
|
from home import views as home_views
|
||||||
|
from base import views as base_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("django-admin/", admin.site.urls),
|
path("django-admin/", admin.site.urls),
|
||||||
@ -16,6 +17,10 @@ urlpatterns = [
|
|||||||
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
|
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
|
||||||
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
|
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
|
||||||
path("search/", search_views.search, name="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