feat: Implement contact form submission feature with SMTP settings

- Added ContactFormSubmission model to store contact form submissions.
- Created ContactForm for handling form submissions.
- Implemented admin interface for managing contact form submissions.
- Developed views and JavaScript for handling contact form submission via AJAX.
- Added SMTP settings model for email configuration.
- Created notification email templates for contact form submissions.
- Updated frontend to include contact form modal and associated styles.
- Added tests for contact form submission and validation.
This commit is contained in:
Warren Chen 2026-03-10 17:00:42 +09:00
parent 4679cc70ef
commit 7a632c5ebd
14 changed files with 918 additions and 65 deletions

View File

@ -1,3 +1,20 @@
from django.contrib import admin
from .models import ContactFormSubmission
# Register your models here.
@admin.register(ContactFormSubmission)
class ContactFormSubmissionAdmin(admin.ModelAdmin):
list_display = ("created_at", "category", "name", "email", "contact")
list_filter = ("category", "created_at")
search_fields = ("name", "email", "contact", "message", "source_page")
readonly_fields = (
"name",
"email",
"contact",
"category",
"message",
"source_page",
"ip_address",
"user_agent",
"created_at",
)

View File

@ -1,4 +1,5 @@
from django import forms
from .models import ContactFormSubmission
class NewsletterSubscribeForm(forms.Form):
@ -17,3 +18,12 @@ class NewsletterUnsubscribeForm(forms.Form):
),
)
token = forms.CharField(max_length=1024, widget=forms.HiddenInput())
class ContactForm(forms.ModelForm):
class Meta:
model = ContactFormSubmission
fields = ["name", "contact", "email", "category", "message", "source_page"]
widgets = {
"source_page": forms.HiddenInput(),
}

View File

@ -0,0 +1,117 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("base", "0006_oneclickunsubscribeaudit_and_more"),
]
operations = [
migrations.CreateModel(
name="MailSmtpSettings",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("smtp_relay_host", models.CharField(blank=True, max_length=255)),
("smtp_relay_port", models.PositiveIntegerField(default=587)),
("smtp_use_tls", models.BooleanField(default=True)),
(
"smtp_use_ssl",
models.BooleanField(default=False, help_text="465 常用 SSLImplicit TLS587 常用 STARTTLSTLS"),
),
("smtp_timeout_seconds", models.PositiveIntegerField(default=15)),
("smtp_username", models.CharField(blank=True, max_length=255)),
("smtp_password", models.TextField(blank=True)),
],
options={
"verbose_name": "SMTP Settings",
},
),
migrations.CreateModel(
name="SystemNotificationMailSettings",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("contact_form_from_name", models.CharField(blank=True, max_length=255)),
("contact_form_from_email", models.EmailField(blank=True, max_length=254)),
("contact_form_reply_to_email", models.EmailField(blank=True, max_length=254)),
("contact_form_to_emails", models.TextField(blank=True, help_text="可填多個收件人,以逗號或換行分隔。")),
("contact_form_subject_prefix", models.CharField(blank=True, default="[Contact Us]", max_length=255)),
("contact_form_user_subject_template", models.CharField(blank=True, default="已收到您的聯絡表單", max_length=255)),
(
"contact_form_user_text_template",
models.TextField(
blank=True,
default="您好 {{name}}\n\n我們已收到您的來信,以下為存檔資訊:\nEmail: {{email}}\n聯絡方式: {{contact}}\n問題類別: {{category}}\n\n留言內容:\n{{message}}\n",
),
),
(
"contact_form_user_html_template",
models.TextField(
blank=True,
default="<p>您好 {{name}}</p><p>我們已收到您的來信,以下為存檔資訊:</p><ul><li>Email: {{email}}</li><li>聯絡方式: {{contact}}</li><li>問題類別: {{category}}</li></ul><p>留言內容:</p><p>{{message}}</p>",
),
),
("default_charset", models.CharField(default="utf-8", max_length=50)),
],
options={
"verbose_name": "System Notification Mail Settings",
},
),
migrations.CreateModel(
name="ContactFormSubmission",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=100)),
("email", models.EmailField(blank=True, max_length=254)),
("contact", models.CharField(max_length=255)),
(
"category",
models.CharField(
choices=[
("collaboration", "合作邀約"),
("website_issue", "網站問題回報"),
("career", "求職專區"),
("other", "其他"),
],
max_length=32,
),
),
("message", models.TextField()),
("source_page", models.CharField(blank=True, max_length=512)),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("user_agent", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["-created_at"],
},
),
migrations.RemoveField(
model_name="newslettersystemsettings",
name="smtp_password",
),
migrations.RemoveField(
model_name="newslettersystemsettings",
name="smtp_relay_host",
),
migrations.RemoveField(
model_name="newslettersystemsettings",
name="smtp_relay_port",
),
migrations.RemoveField(
model_name="newslettersystemsettings",
name="smtp_timeout_seconds",
),
migrations.RemoveField(
model_name="newslettersystemsettings",
name="smtp_use_ssl",
),
migrations.RemoveField(
model_name="newslettersystemsettings",
name="smtp_use_tls",
),
migrations.RemoveField(
model_name="newslettersystemsettings",
name="smtp_username",
),
]

View File

@ -29,6 +29,8 @@ from wagtail.fields import StreamField
from wagtail import blocks
from .security import encrypt_text
@register_setting
class HeaderSettings(BaseGenericSetting):
logo_light = models.ForeignKey(
@ -115,6 +117,53 @@ class SocialMediaSettings(BaseGenericSetting):
panels = [FieldPanel("links")]
@register_setting
class MailSmtpSettings(BaseGenericSetting):
smtp_relay_host = models.CharField(max_length=255, blank=True)
smtp_relay_port = models.PositiveIntegerField(default=587)
smtp_use_tls = models.BooleanField(default=True)
smtp_use_ssl = models.BooleanField(
default=False,
help_text="465 常用 SSLImplicit TLS587 常用 STARTTLSTLS",
)
smtp_timeout_seconds = models.PositiveIntegerField(default=15)
smtp_username = models.CharField(max_length=255, blank=True)
smtp_password = models.TextField(blank=True)
panels = [
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),
),
],
heading="SMTP Settings",
),
]
class Meta:
verbose_name = "SMTP 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 NewsletterSystemSettings(BaseGenericSetting):
member_center_base_url = models.URLField(blank=True)
@ -176,16 +225,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300)
send_engine_retry_max_attempts = models.PositiveIntegerField(default=3)
smtp_relay_host = models.CharField(max_length=255, blank=True)
smtp_relay_port = models.PositiveIntegerField(default=587)
smtp_use_tls = models.BooleanField(default=True)
smtp_use_ssl = models.BooleanField(
default=False,
help_text="465 常用 SSLImplicit TLS587 常用 STARTTLSTLS",
)
smtp_timeout_seconds = models.PositiveIntegerField(default=15)
smtp_username = models.CharField(max_length=255, blank=True)
smtp_password = models.TextField(blank=True)
sender_name = models.CharField(max_length=255, blank=True)
sender_email = models.EmailField(blank=True)
reply_to_email = models.EmailField(blank=True)
@ -252,22 +291,12 @@ class NewsletterSystemSettings(BaseGenericSetting):
),
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",
heading="Newsletter Mail",
),
]
@ -275,15 +304,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
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)
if self.pk and not self.member_center_oauth_client_secret:
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
@ -293,6 +313,73 @@ class NewsletterSystemSettings(BaseGenericSetting):
super().save(*args, **kwargs)
@register_setting
class SystemNotificationMailSettings(BaseGenericSetting):
contact_form_from_name = models.CharField(max_length=255, blank=True)
contact_form_from_email = models.EmailField(blank=True)
contact_form_reply_to_email = models.EmailField(blank=True)
contact_form_to_emails = models.TextField(
blank=True,
help_text="可填多個收件人,以逗號或換行分隔。",
)
contact_form_subject_prefix = models.CharField(max_length=255, blank=True, default="[Contact Us]")
contact_form_user_subject_template = models.CharField(
max_length=255,
blank=True,
default="已收到您的聯絡表單",
)
contact_form_user_text_template = models.TextField(
blank=True,
default=(
"您好 {{name}}\n\n"
"我們已收到您的來信,以下為存檔資訊:\n"
"Email: {{email}}\n"
"聯絡方式: {{contact}}\n"
"問題類別: {{category}}\n\n"
"留言內容:\n{{message}}\n"
),
)
contact_form_user_html_template = models.TextField(
blank=True,
default=(
"<p>您好 {{name}}</p>"
"<p>我們已收到您的來信,以下為存檔資訊:</p>"
"<ul>"
"<li>Email: {{email}}</li>"
"<li>聯絡方式: {{contact}}</li>"
"<li>問題類別: {{category}}</li>"
"</ul>"
"<p>留言內容:</p>"
"<p>{{message}}</p>"
),
)
default_charset = models.CharField(max_length=50, default="utf-8")
panels = [
MultiFieldPanel(
[
FieldPanel("contact_form_from_name"),
FieldPanel("contact_form_from_email"),
FieldPanel("contact_form_reply_to_email"),
FieldPanel("contact_form_to_emails"),
FieldPanel("contact_form_subject_prefix"),
FieldPanel("contact_form_user_subject_template"),
FieldPanel("contact_form_user_text_template"),
FieldPanel("contact_form_user_html_template"),
FieldPanel("default_charset"),
],
heading="Contact Us Notification Mail",
),
]
class Meta:
verbose_name = "System Notification Mail Settings"
def contact_form_recipient_list(self) -> list[str]:
raw = (self.contact_form_to_emails or "").replace("\n", ",")
return [item.strip() for item in raw.split(",") if item.strip()]
class OneClickUnsubscribeAudit(models.Model):
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
subscriber_id = models.CharField(max_length=128, blank=True)
@ -516,3 +603,32 @@ class FooterText(
class Meta(TranslatableMixin.Meta):
verbose_name_plural = "Footer Text"
class ContactFormSubmission(models.Model):
CATEGORY_COLLABORATION = "collaboration"
CATEGORY_WEBSITE_ISSUE = "website_issue"
CATEGORY_CAREER = "career"
CATEGORY_OTHER = "other"
CATEGORY_CHOICES = [
(CATEGORY_COLLABORATION, "合作邀約"),
(CATEGORY_WEBSITE_ISSUE, "網站問題回報"),
(CATEGORY_CAREER, "求職專區"),
(CATEGORY_OTHER, "其他"),
]
name = models.CharField(max_length=100)
email = models.EmailField(blank=True)
contact = models.CharField(max_length=255)
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES)
message = models.TextField()
source_page = models.CharField(max_length=512, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.get_category_display()} - {self.name}"

View File

@ -15,7 +15,7 @@ from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from wagtail.rich_text import expand_db_html
from .models import NewsletterSystemSettings
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
from .security import decrypt_text
logger = logging.getLogger(__name__)
@ -444,35 +444,47 @@ class SendEngineClient:
return APIResult(ok=False, status=0, data={}, error=str(exc))
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:
def _build_smtp_connection(smtp_config: MailSmtpSettings):
if not smtp_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:
if smtp_config.smtp_use_tls and smtp_config.smtp_use_ssl:
raise ValueError("SMTP TLS and SSL cannot both be enabled.")
password = ""
encrypted_password = (config.smtp_password or "").strip()
encrypted_password = (smtp_config.smtp_password or "").strip()
if encrypted_password:
try:
password = decrypt_text(encrypted_password)
except Exception:
password = ""
connection = get_connection(
return 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,
host=smtp_config.smtp_relay_host,
port=smtp_config.smtp_relay_port,
username=smtp_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)),
use_tls=bool(smtp_config.smtp_use_tls),
use_ssl=bool(smtp_config.smtp_use_ssl),
timeout=max(1, int(smtp_config.smtp_timeout_seconds or 15)),
fail_silently=False,
)
def send_subscribe_email(
*,
to_email: str,
subject: str,
text_body: str,
html_body: str,
config: NewsletterSystemSettings,
smtp_config: MailSmtpSettings | None = None,
) -> int:
smtp_config = smtp_config or MailSmtpSettings.load()
from_email = build_from_email(config.sender_name, config.sender_email)
reply_to = [config.reply_to_email] if config.reply_to_email else None
connection = _build_smtp_connection(smtp_config)
message = EmailMultiAlternatives(
subject=subject,
body=text_body,
@ -490,12 +502,88 @@ def send_subscribe_email(*, to_email: str, subject: str, text_body: str, html_bo
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)),
smtp_config.smtp_relay_host,
smtp_config.smtp_relay_port,
bool(smtp_config.smtp_use_tls),
bool(smtp_config.smtp_use_ssl),
max(1, int(smtp_config.smtp_timeout_seconds or 15)),
from_email or settings.DEFAULT_FROM_EMAIL,
to_email,
)
return sent_count
def send_contact_notification_email(
*,
subject: str,
text_body: str,
html_body: str,
notification_config: SystemNotificationMailSettings,
smtp_config: MailSmtpSettings | None = None,
) -> int:
recipients = notification_config.contact_form_recipient_list()
if not recipients:
return 0
smtp_config = smtp_config or MailSmtpSettings.load()
from_email = build_from_email(
notification_config.contact_form_from_name,
notification_config.contact_form_from_email,
)
reply_to = (
[notification_config.contact_form_reply_to_email]
if notification_config.contact_form_reply_to_email
else None
)
connection = _build_smtp_connection(smtp_config)
message = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
to=recipients,
reply_to=reply_to,
connection=connection,
)
if html_body:
message.attach_alternative(html_body, "text/html")
charset = (notification_config.default_charset or "utf-8").strip() or "utf-8"
message.encoding = charset
return message.send(fail_silently=False)
def send_contact_user_email(
*,
to_email: str,
subject: str,
text_body: str,
html_body: str,
notification_config: SystemNotificationMailSettings,
smtp_config: MailSmtpSettings | None = None,
) -> int:
if not (to_email or "").strip():
return 0
smtp_config = smtp_config or MailSmtpSettings.load()
from_email = build_from_email(
notification_config.contact_form_from_name,
notification_config.contact_form_from_email,
)
reply_to = (
[notification_config.contact_form_reply_to_email]
if notification_config.contact_form_reply_to_email
else None
)
connection = _build_smtp_connection(smtp_config)
message = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
to=[to_email.strip()],
reply_to=reply_to,
connection=connection,
)
if html_body:
message.attach_alternative(html_body, "text/html")
charset = (notification_config.default_charset or "utf-8").strip() or "utf-8"
message.encoding = charset
return message.send(fail_silently=False)

View File

@ -1,4 +1,5 @@
from django.test import TestCase
from django.urls import reverse
from .newsletter import (
extract_token,
@ -7,6 +8,7 @@ from .newsletter import (
render_newsletter_html,
verify_one_click_token,
)
from .models import ContactFormSubmission
from .security import decrypt_text, encrypt_text
@ -74,3 +76,39 @@ class NewsletterTemplateTests(TestCase):
)
self.assertIn('href="https://news.example.com/a"', rendered)
self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)
def test_contact_form_submit_saves_submission(self):
response = self.client.post(
reverse("contact_form_submit"),
data={
"name": "Tester",
"contact": "tester@example.com",
"email": "tester@example.com",
"category": "other",
"message": "hello",
"source_page": "/",
},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(ContactFormSubmission.objects.count(), 1)
submission = ContactFormSubmission.objects.first()
self.assertEqual(submission.name, "Tester")
self.assertEqual(submission.email, "tester@example.com")
self.assertEqual(submission.category, "other")
def test_contact_form_submit_rejects_invalid_email(self):
response = self.client.post(
reverse("contact_form_submit"),
data={
"name": "Tester",
"contact": "123",
"email": "not-an-email",
"category": "other",
"message": "hello",
"source_page": "/",
},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(ContactFormSubmission.objects.count(), 0)

View File

@ -1,3 +1,4 @@
import logging
from urllib.parse import urlencode
import hashlib
import json
@ -9,27 +10,38 @@ from django.core.validators import validate_email
from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from .forms import NewsletterSubscribeForm, NewsletterUnsubscribeForm
from .forms import ContactForm, NewsletterSubscribeForm, NewsletterUnsubscribeForm
from .models import (
MailSmtpSettings,
NewsletterCampaign,
NewsletterSystemSettings,
NewsletterTemplateSettings,
OneClickUnsubscribeAudit,
SystemNotificationMailSettings,
)
from .newsletter import (
MemberCenterClient,
build_from_email,
extract_token,
render_placeholders,
send_contact_notification_email,
send_contact_user_email,
send_subscribe_email,
)
from .newsletter_scheduler import dispatch_campaign
logger = logging.getLogger(__name__)
def _load_settings():
return NewsletterSystemSettings.load(), NewsletterTemplateSettings.load()
def _load_settings(request_or_site=None):
return (
NewsletterSystemSettings.load(request_or_site=request_or_site),
NewsletterTemplateSettings.load(request_or_site=request_or_site),
MailSmtpSettings.load(request_or_site=request_or_site),
)
def _build_context(*, title: str, message: str, success: bool):
@ -40,6 +52,112 @@ def _build_context(*, title: str, message: str, success: bool):
}
def _render_contact_template(template: str, values: dict[str, str]) -> str:
rendered = template or ""
for key, value in values.items():
rendered = rendered.replace(f"{{{{{key}}}}}", value)
return rendered
@require_POST
def contact_form_submit(request):
form = ContactForm(request.POST)
if not form.is_valid():
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JsonResponse({"success": False, "errors": form.errors}, status=400)
messages.error(request, "表單欄位未填完整,請確認後再送出。")
return redirect(request.META.get("HTTP_REFERER") or "/")
submission = form.save(commit=False)
submission.ip_address = request.META.get("REMOTE_ADDR") or ""
submission.user_agent = request.META.get("HTTP_USER_AGENT", "")
submission.save()
notification_settings = SystemNotificationMailSettings.load(request_or_site=request)
smtp_settings = MailSmtpSettings.load(request_or_site=request)
subject_prefix = (notification_settings.contact_form_subject_prefix or "").strip()
subject = f"{subject_prefix} {submission.get_category_display()}".strip()
escaped_message_html = escape(submission.message).replace("\n", "<br>")
text_body = (
f"Name: {submission.name}\n"
f"Email: {submission.email}\n"
f"Contact: {submission.contact}\n"
f"Category: {submission.get_category_display()}\n"
f"Source Page: {submission.source_page}\n\n"
f"Message:\n{submission.message}\n"
)
html_body = (
"<p><strong>Name:</strong> "
f"{escape(submission.name)}</p>"
"<p><strong>Email:</strong> "
f"{escape(submission.email)}</p>"
"<p><strong>Contact:</strong> "
f"{escape(submission.contact)}</p>"
"<p><strong>Category:</strong> "
f"{escape(submission.get_category_display())}</p>"
"<p><strong>Source Page:</strong> "
f"{escape(submission.source_page or '')}</p>"
"<p><strong>Message:</strong></p>"
f"<p>{escaped_message_html}</p>"
)
try:
send_contact_notification_email(
subject=subject,
text_body=text_body,
html_body=html_body,
notification_config=notification_settings,
smtp_config=smtp_settings,
)
except Exception as exc:
logger.warning("contact form admin notification email failed: %s", exc)
if submission.email:
values_text = {
"name": submission.name,
"email": submission.email,
"contact": submission.contact,
"category": submission.get_category_display(),
"message": submission.message,
"source_page": submission.source_page or "",
}
values_html = {
"name": escape(submission.name),
"email": escape(submission.email),
"contact": escape(submission.contact),
"category": escape(submission.get_category_display()),
"message": escape(submission.message).replace("\n", "<br>"),
"source_page": escape(submission.source_page or ""),
}
user_subject = _render_contact_template(
notification_settings.contact_form_user_subject_template,
values_text,
)
user_text = _render_contact_template(
notification_settings.contact_form_user_text_template,
values_text,
)
user_html = _render_contact_template(
notification_settings.contact_form_user_html_template,
values_html,
)
try:
send_contact_user_email(
to_email=submission.email,
subject=user_subject,
text_body=user_text,
html_body=user_html,
notification_config=notification_settings,
smtp_config=smtp_settings,
)
except Exception as exc:
logger.warning("contact form user copy email failed: %s", exc)
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return JsonResponse({"success": True})
messages.success(request, "感謝您的來信,我們已收到您的表單。")
return redirect(request.META.get("HTTP_REFERER") or "/")
def _extract_one_click_token(request):
token = (request.GET.get("token") or "").strip()
if token:
@ -61,7 +179,7 @@ def _extract_one_click_token(request):
def _handle_one_click_unsubscribe(*, request, token):
system_settings, _ = _load_settings()
system_settings, _, _ = _load_settings(request)
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() if token else ""
@ -115,7 +233,7 @@ def _handle_one_click_unsubscribe(*, request, token):
@require_http_methods(["POST"])
def newsletter_subscribe(request):
form = NewsletterSubscribeForm(request.POST)
system_settings, template_settings = _load_settings()
system_settings, template_settings, smtp_settings = _load_settings(request)
if not form.is_valid():
return render(
@ -174,6 +292,7 @@ def newsletter_subscribe(request):
text_body=render_placeholders(template_settings.subscribe_text_template, values),
html_body=render_placeholders(template_settings.subscribe_html_template, values),
config=system_settings,
smtp_config=smtp_settings,
)
except Exception:
return render(
@ -202,7 +321,7 @@ def newsletter_subscribe(request):
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()
system_settings, template_settings, _ = _load_settings(request)
if not token:
return render(
@ -242,7 +361,7 @@ def newsletter_confirm(request):
@require_http_methods(["GET", "POST"])
def newsletter_unsubscribe(request):
system_settings, template_settings = _load_settings()
system_settings, template_settings, _ = _load_settings(request)
client = MemberCenterClient(system_settings)
if request.method == "GET":
@ -379,6 +498,7 @@ def newsletter_smtp_test(request):
return redirect(redirect_to)
settings_obj = NewsletterSystemSettings.load(request_or_site=request)
smtp_settings = MailSmtpSettings.load(request_or_site=request)
try:
sent_count = send_subscribe_email(
to_email=to_email,
@ -386,6 +506,7 @@ def newsletter_smtp_test(request):
text_body="這是一封測試信,代表 SMTP 設定可正常寄送。",
html_body="<p>這是一封測試信,代表 SMTP 設定可正常寄送。</p>",
config=settings_obj,
smtp_config=smtp_settings,
)
except Exception as exc:
messages.error(request, f"測試信寄送失敗:{exc}")

View File

@ -0,0 +1,198 @@
.contact-form-modal[hidden] {
display: none;
}
.contact-form-modal {
position: fixed;
inset: 0;
z-index: 2000;
overflow-y: auto;
padding: 24px;
}
.contact-form-modal__backdrop {
position: absolute;
inset: 0;
background: #0e1b42cc;
}
.contact-form-modal__dialog {
position: relative;
z-index: 1;
width: min(680px, calc(100vw - 48px));
max-height: calc(100dvh - 48px);
margin: 0 auto;
padding: 20px 28px 24px;
background: #0e1b42e6;
color: #ffffff;
overflow-y: auto;
}
.contact-form-modal__close {
position: absolute;
top: 8px;
right: 12px;
border: 0;
background: transparent;
color: #ffffff;
font-size: 26px;
cursor: pointer;
}
.contact-form-modal__title {
margin: 0;
text-align: center;
font-size: 32px;
font-weight: 700;
line-height: 1.2;
}
.contact-form-modal__subtitle {
margin: 12px 0 18px;
text-align: center;
font-size: 20px;
font-weight: 700;
line-height: 1.35;
}
.contact-form {
max-width: 415px;
margin: 0 auto;
}
.contact-form,
.contact-form * {
box-sizing: border-box;
}
.contact-form__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.contact-form__field {
margin-bottom: 14px;
min-width: 0;
}
.contact-form__field label {
display: block;
margin-bottom: 8px;
text-align: center;
font-size: 14px;
font-weight: 500;
}
.contact-form__field input,
.contact-form__field select,
.contact-form__field textarea {
display: block;
width: 100%;
max-width: 100%;
border: 0;
background: #ffffff4d;
color: #0e1b42;
font-size: 14px;
padding: 8px 12px;
}
.contact-form__field input:focus,
.contact-form__field select:focus,
.contact-form__field textarea:focus {
background: #ffffffcc;
color: #0e1b42;
}
.contact-form__field input::placeholder {
color: #ffffffcc;
}
.contact-form__field select {
height: 42px;
}
.contact-form__field textarea {
min-height: 180px;
resize: vertical;
}
.contact-form__actions {
margin-top: 10px;
text-align: center;
}
.contact-form__actions button {
min-width: 196px;
height: 38px;
border: 0;
background: #ffffff;
color: #0e1b42;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.contact-form__result {
margin: 10px 0 0;
min-height: 20px;
text-align: center;
font-size: 14px;
}
@media (max-width: 767px) {
.contact-form-modal__dialog {
width: calc(90vw - 24px);
max-height: calc(100dvh - 24px);
margin: 0 auto;
padding: 16px 12px 18px;
}
.contact-form-modal__title {
font-size: 24px;
}
.contact-form-modal__subtitle {
font-size: 16px;
margin: 10px 0 14px;
}
.contact-form__row {
grid-template-columns: 1fr;
gap: 8px;
}
.contact-form__field {
margin-bottom: 10px;
}
.contact-form__field label {
font-size: 13px;
margin-bottom: 6px;
}
.contact-form__field input,
.contact-form__field select,
.contact-form__field textarea {
font-size: 13px;
padding: 8px 10px;
}
.contact-form__field select {
height: 38px;
}
.contact-form__field textarea {
min-height: 120px;
}
.contact-form__actions button {
min-width: 160px;
height: 34px;
font-size: 13px;
}
.contact-form__result {
font-size: 12px;
}
}

View File

@ -0,0 +1,70 @@
(function () {
const modal = document.querySelector("[data-contact-form]");
if (!modal) return;
const form = modal.querySelector("[data-contact-form-body]");
const result = modal.querySelector("[data-contact-result]");
const categoryInput = modal.querySelector("#contact-form-category");
const closeTargets = modal.querySelectorAll("[data-contact-close]");
const triggers = document.querySelectorAll("[data-contact-trigger]");
let lastActive = null;
const setOpen = (open) => {
modal.hidden = !open;
document.body.style.overflow = open ? "hidden" : "";
if (!open && lastActive) {
lastActive.focus();
}
};
triggers.forEach((el) => {
el.addEventListener("click", (event) => {
event.preventDefault();
lastActive = el;
const preset = el.getAttribute("data-contact-category");
if (preset && categoryInput) {
categoryInput.value = preset;
}
if (result) result.textContent = "";
setOpen(true);
});
});
closeTargets.forEach((el) => {
el.addEventListener("click", () => setOpen(false));
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !modal.hidden) {
setOpen(false);
}
});
if (form) {
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
try {
const response = await fetch(form.action, {
method: "POST",
body: data,
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
const payload = await response.json();
if (!response.ok || !payload.success) {
if (result) result.textContent = "送出失敗,請確認欄位後再試一次。";
return;
}
if (result) result.textContent = "已送出,我們會盡快與您聯繫。";
form.reset();
} catch (_) {
if (result) result.textContent = "送出失敗,請稍後再試。";
}
});
}
})();

View File

@ -3,27 +3,49 @@
if (!root) return;
const toggle = root.querySelector(".subscribe-fab__toggle");
const input = root.querySelector(".subscribe-fab__input");
const triggers = document.querySelectorAll("[data-subscribe-trigger]");
if (!toggle) return;
const setOpen = (open) => {
const focusInput = () => {
if (!input) return;
requestAnimationFrame(function () {
input.focus();
});
};
const setOpen = (open, shouldFocusInput) => {
root.classList.toggle("is-open", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
if (open && shouldFocusInput) {
focusInput();
}
};
toggle.addEventListener("click", function () {
const isOpen = root.classList.contains("is-open");
setOpen(!isOpen);
setOpen(!isOpen, !isOpen);
});
triggers.forEach(function (trigger) {
trigger.addEventListener("click", function (event) {
event.preventDefault();
setOpen(true, true);
});
});
document.addEventListener("click", function (event) {
if (event.target.closest("[data-subscribe-trigger]")) {
return;
}
if (!root.contains(event.target)) {
setOpen(false);
setOpen(false, false);
}
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
setOpen(false);
setOpen(false, false);
}
});
})();

View File

@ -36,6 +36,7 @@
<link rel="stylesheet" type="text/css" href="{% static 'css/mysite.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/footer.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/subscribe_fab.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/contact_form.css' %}">
{% block extra_css %}
{# Override this in templates to add extra stylesheets #}
@ -54,11 +55,13 @@
</main>
{% include "includes/footer.html" %}
{% include "includes/contact_form.html" %}
{% include "includes/subscribe_fab.html" %}
{# Global javascript #}
<script type="text/javascript" src="{% static 'js/mysite.js' %}"></script>
<script type="text/javascript" src="{% static 'js/header.js' %}"></script>
<script type="text/javascript" src="{% static 'js/contact_form.js' %}"></script>
<script type="text/javascript" src="{% static 'js/subscribe_fab.js' %}"></script>
{# Instagram embed script to render IG oEmbeds #}
<script async src="https://www.instagram.com/embed.js"></script>

View File

@ -0,0 +1,52 @@
<div class="contact-form-modal" data-contact-form hidden>
<div class="contact-form-modal__backdrop" data-contact-close></div>
<div class="contact-form-modal__dialog" role="dialog" aria-modal="true" aria-label="聯絡表單">
<button class="contact-form-modal__close" type="button" aria-label="關閉" data-contact-close>×</button>
<h2 class="contact-form-modal__title">聯絡我們</h2>
<p class="contact-form-modal__subtitle">有任何問題或想要與我們交流的地方,都歡迎留言聯絡</p>
<form class="contact-form" method="post" action="{% url 'contact_form_submit' %}" data-contact-form-body>
{% csrf_token %}
<input type="hidden" name="source_page" value="{{ request.get_full_path|default:'/' }}" />
<div class="contact-form__row">
<div class="contact-form__field">
<label for="contact-form-name">該如何稱呼您</label>
<input id="contact-form-name" type="text" name="name" required placeholder="請留下您的姓名" />
</div>
<div class="contact-form__field">
<label for="contact-form-contact">聯絡方式</label>
<input id="contact-form-contact" type="text" name="contact" required placeholder="+886..." />
</div>
</div>
<div class="contact-form__field">
<label for="contact-form-email">Email</label>
<input id="contact-form-email" type="email" name="email" placeholder="name@example.com" />
</div>
<div class="contact-form__field">
<label for="contact-form-category">問題類別</label>
<select id="contact-form-category" name="category" required>
<option value="" selected disabled>請選擇問題類別</option>
<option value="collaboration">合作邀約</option>
<option value="website_issue">網站問題回報</option>
<option value="career">求職專區</option>
<option value="other">其他</option>
</select>
</div>
<div class="contact-form__field">
<label for="contact-form-message">留言內容</label>
<textarea id="contact-form-message" name="message" required></textarea>
</div>
<div class="contact-form__actions">
<button type="submit">送出</button>
</div>
<p class="contact-form__result" data-contact-result aria-live="polite"></p>
</form>
</div>
</div>

View File

@ -40,9 +40,9 @@
<!-- <div class="footer-col footer-col--right"> -->
<div class="footer-fixed-links">
<ul>
<li><a href="{% url 'newsletter_subscribe' %}">訂閱電子報</a></li>
<li><a href="#">合作提案</a></li>
<li><a href="#">聯絡我們</a></li>
<li><a href="#" data-subscribe-trigger>訂閱電子報</a></li>
<li><a href="#" data-contact-trigger data-contact-category="collaboration">合作提案</a></li>
<li><a href="#" data-contact-trigger data-contact-category="other">聯絡我們</a></li>
</ul>
</div>

View File

@ -18,6 +18,7 @@ urlpatterns = [
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("contact-form/submit/", base_views.contact_form_submit, name="contact_form_submit"),
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),