Remove legacy newsletter template settings

This commit is contained in:
warrenchen 2026-06-30 16:35:53 +09:00
parent ed70c742df
commit a6249e8854
16 changed files with 376 additions and 494 deletions

View File

@ -1,6 +1,4 @@
from django import forms
from django.conf import settings
from django.utils.safestring import mark_safe
from wagtail.admin.rich_text import DraftailRichTextArea
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
@ -33,45 +31,18 @@ class ContactForm(forms.ModelForm):
}
class GrapesJSEditorWidget(forms.Textarea):
class NewsletterHtmlEditorWidget(forms.Textarea):
class Media:
js = (
"js/newsletter_template_editor.js",
)
js = ("js/newsletter_template_editor.js",)
css = {
"all": (
"vendor/grapesjs/grapes.min.css",
"css/newsletter_template_editor.css",
)
"all": ("css/newsletter_template_editor.css",)
}
def render(self, name, value, attrs=None, renderer=None):
attrs = attrs or {}
attrs["data-newsletter-html-input"] = "1"
attrs["data-newsletter-media-url"] = (settings.MEDIA_URL or "").strip()
attrs["hidden"] = "hidden"
textarea = super().render(name, value, attrs, renderer)
editor_shell = """
<div class="newsletter-editor-shell">
<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>
<div class="newsletter-editor-toolbar">
<button type="button" class="button button-small button-secondary" data-newsletter-preview>
Preview with sample content
</button>
</div>
<div class="newsletter-preview-shell" data-newsletter-preview-shell hidden>
<div class="newsletter-preview-shell__head">
<strong>Preview</strong>
<button type="button" class="button button-small button-secondary" data-newsletter-preview-close>
Close
</button>
</div>
<iframe class="newsletter-preview-frame" data-newsletter-preview-frame title="Newsletter preview"></iframe>
</div>
<div id="newsletter-grapesjs-editor" class="newsletter-grapesjs-editor"></div>
</div>
"""
return mark_safe(f"{editor_shell}{textarea}")
attrs.setdefault("spellcheck", "false")
return super().render(name, value, attrs, renderer)
class NewsletterCampaignEditorWidget(DraftailRichTextArea):
@ -86,12 +57,11 @@ class NewsletterCampaignEditorWidget(DraftailRichTextArea):
class NewsletterTemplateAdminForm(forms.ModelForm):
class Meta:
model = NewsletterTemplate
fields = ["name", "subject", "template_json", "template_html", "template_text"]
fields = ["name", "subject", "template_html", "template_text"]
widgets = {
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
"template_html": GrapesJSEditorWidget(
"template_html": NewsletterHtmlEditorWidget(
attrs={
"rows": 18,
"rows": 28,
"placeholder": "Use {{email_body}} as the content placeholder.",
}
),

View File

@ -0,0 +1,89 @@
from django.db import migrations, models
DEFAULT_WEEKLY_ITEM_TEMPLATE = '''<tr>
<td height="40" style="height: 40px;">
&nbsp;
</td>
</tr>
<tr>
<td width="100%" style="width:100%">
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
<p style="font-weight: 400; font-size: 24px; line-height: 29px; text-align: justify; color: #0E1B42;">
{{article_title}}
</p>
</a>
</td>
</tr>
<tr>
<td height="20" style="height: 20px;">
&nbsp;
</td>
</tr>
<tr>
<td>
<table
role="presentation"
width="100%"
height="333"
cellspacing="0"
cellpadding="0"
border="0"
style="width:100%; height:333px; border-collapse:collapse; border-spacing:0; margin:0; padding:0;"
>
<tbody>
<tr>
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;">&nbsp;</td>
<td width="596" style="width: 596px; padding:0; margin:0; font-size:0; line-height:0;">
<a href="{{article_url}}" target="_blank" rel="newsletter">
<img src="{{article_cover_url}}" alt="{{article_title}}" style="width: 596px; height: 333px;">
</a>
</td>
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;">&nbsp;</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td height="40" style="height: 40px;">
&nbsp;
</td>
</tr>
<tr>
<td width="100%" style="width:100%">
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
<p style="font-weight: 400; font-size: 20px; line-height: 143%; text-align: justify; color: #0E1B42;">
{{article_intro}}
</p>
</a>
</td>
</tr>
<tr>
<td height="40" style="height: 39px; border-bottom: 1pt solid #0E1B42;">
&nbsp;
</td>
</tr>'''
class Migration(migrations.Migration):
dependencies = [
("base", "0010_newslettercampaign_campaign_type_and_weekly_fields"),
]
operations = [
migrations.RemoveField(
model_name="newslettertemplate",
name="template_json",
),
migrations.AlterField(
model_name="newslettercampaign",
name="item_template_html",
field=models.TextField(
blank=True,
default=DEFAULT_WEEKLY_ITEM_TEMPLATE,
verbose_name="Weekly Item HTML Template",
),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.2.15 on 2026-06-30 07:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('base', '0011_remove_newslettertemplate_template_json'),
]
operations = [
migrations.DeleteModel(
name='NewsletterTemplateSettings',
),
]

View File

@ -527,22 +527,69 @@ class NewsletterCampaign(models.Model):
)
item_template_html = models.TextField(
blank=True,
default=(
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;">'
"<tr>"
'<td style="padding:0;">'
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;">'
'<img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;">'
"</a>"
'<h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;">'
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a>'
"</h3>"
'<p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p>'
'<p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p>'
"</td>"
"</tr>"
"</table>"
),
default='''<tr>
<td height="40" style="height: 40px;">
&nbsp;
</td>
</tr>
<tr>
<td width="100%" style="width:100%">
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
<p style="font-weight: 400; font-size: 24px; line-height: 29px; text-align: justify; color: #0E1B42;">
{{article_title}}
</p>
</a>
</td>
</tr>
<tr>
<td height="20" style="height: 20px;">
&nbsp;
</td>
</tr>
<tr>
<td>
<table
role="presentation"
width="100%"
height="333"
cellspacing="0"
cellpadding="0"
border="0"
style="width:100%; height:333px; border-collapse:collapse; border-spacing:0; margin:0; padding:0;"
>
<tbody>
<tr>
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;">&nbsp;</td>
<td width="596" style="width: 596px; padding:0; margin:0; font-size:0; line-height:0;">
<a href="{{article_url}}" target="_blank" rel="newsletter">
<img src="{{article_cover_url}}" alt="{{article_title}}" style="width: 596px; height: 333px;">
</a>
</td>
<td width="96" style="width: 96px; padding:0; margin:0; font-size:0; line-height:0;">&nbsp;</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td height="40" style="height: 40px;">
&nbsp;
</td>
</tr>
<tr>
<td width="100%" style="width:100%">
<a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
<p style="font-weight: 400; font-size: 20px; line-height: 143%; text-align: justify; color: #0E1B42;">
{{article_intro}}
</p>
</a>
</td>
</tr>
<tr>
<td height="40" style="height: 39px; border-bottom: 1pt solid #0E1B42;">
&nbsp;
</td>
</tr>''',
verbose_name=_("Weekly Item HTML Template"),
)
content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config"))
@ -589,7 +636,6 @@ class NewsletterCampaign(models.Model):
class NewsletterTemplate(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject"))
template_json = models.JSONField(default=dict, blank=True, verbose_name=_("Template JSON"))
template_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML"))
template_text = models.TextField(blank=True, default="", verbose_name=_("Template Text"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
@ -598,7 +644,6 @@ class NewsletterTemplate(models.Model):
panels = [
FieldPanel("name"),
FieldPanel("subject"),
FieldPanel("template_json"),
FieldPanel("template_html"),
FieldPanel("template_text"),
]
@ -650,74 +695,6 @@ class NewsletterDispatchRecord(models.Model):
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
class NewsletterTemplateSettings(BaseGenericSetting):
subscribe_subject_template = models.CharField(
max_length=255,
default="請確認您的電子報訂閱",
verbose_name=_("Subscribe Subject Template"),
)
subscribe_html_template = models.TextField(
default=(
"<p>您好,請點擊以下連結完成訂閱:</p>"
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
),
verbose_name=_("Subscribe HTML Template"),
)
subscribe_text_template = models.TextField(
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
verbose_name=_("Subscribe Text Template"),
)
confirm_success_template = RichTextField(
blank=True,
default="<p>訂閱確認成功。</p>",
verbose_name=_("Confirm Success Template"),
)
confirm_failure_template = RichTextField(
blank=True,
default="<p>訂閱確認失敗,請稍後再試。</p>",
verbose_name=_("Confirm Failure Template"),
)
unsubscribe_intro_template = RichTextField(
blank=True,
default="<p>確認要退訂電子報嗎?</p>",
verbose_name=_("Unsubscribe Intro Template"),
)
unsubscribe_success_template = RichTextField(
blank=True,
default="<p>已完成退訂。</p>",
verbose_name=_("Unsubscribe Success Template"),
)
unsubscribe_failure_template = RichTextField(
blank=True,
default="<p>退訂失敗,請稍後再試。</p>",
verbose_name=_("Unsubscribe Failure Template"),
)
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=_("Identifier key, e.g. home / category"), verbose_name=_("Key"))

View File

@ -224,9 +224,8 @@ def _absolutize_links(html: str, site_base_url: str) -> str:
return pattern.sub(_replace, html)
def _strip_grapesjs_svg_placeholder_images(html: str) -> str:
# GrapesJS uses inline SVG placeholder data URIs when image src is missing.
# These should not be sent as real email images.
def _strip_inline_svg_placeholder_images(html: str) -> str:
# Inline SVG data URI placeholders should not be sent as real email images.
return re.sub(
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>",
"",
@ -250,7 +249,7 @@ def render_newsletter_html_for_send_job(template: str, site_base_url: str = "")
rendered = expand_db_html(rendered)
except Exception:
pass
rendered = _strip_grapesjs_svg_placeholder_images(rendered)
rendered = _strip_inline_svg_placeholder_images(rendered)
return _absolutize_links(rendered, site_base_url)
@ -305,7 +304,7 @@ def build_weekly_news_body_html(*, article_page_ids: list[int], item_template_ht
cover_url = ""
if page.cover_image_id and page.cover_image:
try:
cover_url = page.cover_image.get_rendition("max-640x360").url
cover_url = page.cover_image.get_rendition("fill-596x333").url
except Exception:
cover_url = page.cover_image.file.url
if site_base_url and cover_url.startswith("/"):

View File

@ -3,22 +3,16 @@
}
.newsletter-editor-toolbar {
margin: 10px 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 10px 0;
}
.newsletter-editor-status {
margin: 8px 0 10px;
color: #666;
}
.newsletter-grapesjs-editor {
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
background: #fff;
color: #8a5a00;
}
.newsletter-preview-shell {
@ -39,11 +33,19 @@
.newsletter-preview-frame {
display: block;
width: 100%;
min-height: 420px;
min-height: 560px;
border: 0;
background: #fff;
}
textarea[data-newsletter-html-input="1"] {
display: none;
box-sizing: border-box;
width: 100%;
min-height: 620px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px;
line-height: 1.5;
tab-size: 2;
white-space: pre;
}

View File

Before

Width:  |  Height:  |  Size: 922 B

After

Width:  |  Height:  |  Size: 922 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 841 B

View File

@ -1,194 +1,66 @@
(function () {
function loadScript(src) {
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
async function ensureGrapesJS() {
if (window.grapesjs) {
return true;
}
try {
await loadScript('/static/vendor/grapesjs/grapes.min.js');
} catch (e) {
return false;
}
try {
await loadScript('/static/vendor/grapesjs-preset-newsletter/index.js');
} catch (e) {
// Plugin is optional; core editor still works.
}
return !!window.grapesjs;
}
async function ensureWagtailImageChooser() {
if (window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS) {
return true;
}
try {
if (!window.ModalWorkflow) {
await loadScript('/static/wagtailadmin/js/modal-workflow.js');
}
if (!window.CHOOSER_MODAL_ONLOAD_HANDLERS) {
await loadScript('/static/wagtailadmin/js/chooser-modal.js');
}
await loadScript('/static/wagtailimages/js/image-chooser-modal.js');
} catch (e) {
return false;
}
return !!(window.ModalWorkflow && window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS);
}
function getImageDataSrc(imageData, mediaBaseUrl) {
if (!imageData) {
return '';
}
var sourceUrl =
(imageData.url || '') ||
(imageData.image && imageData.image.url) ||
(imageData.preview && imageData.preview.url) ||
'';
if (!sourceUrl) {
return '';
}
if (!mediaBaseUrl) {
return sourceUrl;
}
try {
var parsed = new URL(sourceUrl, window.location.origin);
var path = parsed.pathname || '';
var idx = path.indexOf('/images/');
if (idx < 0) {
return sourceUrl;
}
var key = path.slice(idx + 1);
var base = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
return new URL(key, base).toString();
} catch (e) {
if (sourceUrl.indexOf('/images/') >= 0) {
var relative = sourceUrl.split('/images/')[1];
if (relative) {
var baseUrl = mediaBaseUrl.endsWith('/') ? mediaBaseUrl : mediaBaseUrl + '/';
return baseUrl + 'images/' + relative.split('?')[0];
}
}
return sourceUrl;
}
}
function openWagtailImageChooser(mediaBaseUrl) {
return new Promise(function (resolve, reject) {
function onChoose(imageData) {
var src = getImageDataSrc(imageData, mediaBaseUrl);
if (!src) {
reject(new Error('No image URL returned by chooser'));
return;
}
resolve({
src: src,
title: imageData.title || imageData.name || 'Wagtail image',
alt: imageData.alt || imageData.default_alt_text || '',
});
}
if (window.ModalWorkflow) {
new window.ModalWorkflow({
url: '/admin/images/chooser/',
onload: window.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS || {},
responses: {
chosen: function (data) {
// Wagtail image chooser commonly returns { step: "chosen", result: {...} }.
onChoose(data && data.result ? data.result : data);
},
imageChosen: function (data) {
onChoose(data && data.result ? data.result : data);
},
},
});
return;
}
reject(new Error('No compatible Wagtail chooser API found'));
});
}
function parseJSON(value, fallback) {
try {
return JSON.parse(value);
} catch (e) {
return fallback;
}
}
function getHtmlWithCss(editor) {
var html = '';
var css = '';
try {
html = editor.getHtml() || '';
css = editor.getCss() || '';
} catch (e) {
html = '';
css = '';
}
if (!html.trim()) {
try {
var wrapper = editor && editor.getWrapper ? editor.getWrapper() : null;
if (wrapper && typeof wrapper.toHTML === 'function') {
html = wrapper.toHTML() || '';
}
} catch (e) {
html = html || '';
}
}
if (!css.trim()) {
return html;
}
return '<style>' + css + '</style>\n' + html;
}
function normalizeBodyPlaceholder(html) {
var source = html || '';
source = source.replace(/\{\{html_body\}\}/g, '{{email_body}}');
if (source.indexOf('{{email_body}}') === -1) {
return source;
}
// Wrap plain placeholder text so it's selectable/movable in GrapesJS.
source = source.replace(
/\{\{email_body\}\}/g,
'<div data-newsletter-body-slot="1">{{email_body}}</div>'
);
return source;
}
function renderPreviewHtml(html) {
function renderPreviewHtml(source) {
var sampleBody =
'<h2 style="margin:0 0 12px;">本期電子報標題</h2>' +
'<p style="margin:0 0 10px;">這是預覽用的假內容,實際寄送時會替換成真實內容。</p>' +
'<p style="margin:0 0 10px;"><a href="https://example.com" target="_blank">瞭解更多</a></p>';
return (html || '')
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">' +
'<tr><td style="padding:24px;font-family:Arial,sans-serif;color:#0E1B42;">' +
'<h2 style="margin:0 0 12px;font-size:24px;line-height:1.3;">本期電子報標題</h2>' +
'<p style="margin:0 0 12px;font-size:16px;line-height:1.7;">這是預覽用的假內容,實際寄送時會替換成文章列表。</p>' +
'<p style="margin:0;font-size:16px;line-height:1.7;"><a href="https://example.com" target="_blank">瞭解更多</a></p>' +
'</td></tr></table>';
var html = source || '';
var hasBodySlot = /\{\{(?:email_body|html_body|text_body)\}\}/.test(html);
return {
html: html
.replace(/\{\{email_body\}\}/g, sampleBody)
.replace(/\{\{html_body\}\}/g, sampleBody)
.replace(/\{\{token\}\}/g, 'sample-token-123')
.replace(/\{\{email\}\}/g, 'demo@example.com')
.replace(/\{\{list_id\}\}/g, 'sample-list')
.replace(/\{\{tenant_id\}\}/g, 'sample-tenant')
.replace(/\{\{confirm_url\}\}/g, 'https://example.com/newsletter/confirm?token=sample-token-123')
.replace(/\{\{unsubscribe_url\}\}/g, 'https://example.com/newsletter/unsubscribe?token=sample-token-123');
.replace(/\{\{text_body\}\}/g, sampleBody),
hasBodySlot: hasBodySlot,
};
}
function openPreviewInFrame(html, frame, shell) {
function insertAtCursor(textarea, text) {
if (!textarea) {
return;
}
var start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : textarea.value.length;
var end = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : textarea.value.length;
var before = textarea.value.slice(0, start);
var after = textarea.value.slice(end);
textarea.value = before + text + after;
var nextPos = start + text.length;
textarea.focus();
textarea.setSelectionRange(nextPos, nextPos);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function collectWarnings(html, hasBodySlot) {
var warnings = [];
if (!hasBodySlot) {
warnings.push('Missing {{email_body}} placeholder.');
}
if (/<p\b[^>]*>\s*<(?:ul|ol|table)\b/i.test(html || '')) {
warnings.push('Avoid wrapping ul, ol, or table directly inside p tags.');
}
if (/\b(?:src|href)=["']\/(?!\/)/i.test(html || '')) {
warnings.push('Relative src/href detected; email templates should use absolute URLs.');
}
return warnings;
}
function setStatus(statusNode, messages) {
if (!statusNode) {
return;
}
if (!messages || !messages.length) {
statusNode.hidden = true;
statusNode.textContent = '';
return;
}
statusNode.hidden = false;
statusNode.textContent = messages.join(' ');
}
function openPreviewInFrame(frame, shell, html) {
if (!frame || !shell) {
return false;
}
@ -198,175 +70,80 @@
return false;
}
doc.open();
doc.write(
'<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body>' +
renderPreviewHtml(html) +
'</body></html>'
);
doc.write('<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body style="margin:0;">' + html + '</body></html>');
doc.close();
return true;
}
function buildPreviewSource(editor, htmlInput) {
var source = '';
var origin = '';
source = getHtmlWithCss(editor) || '';
if (source.trim()) {
origin = 'editor-export';
return { source: source, origin: origin };
function createEditorShell() {
var shell = document.createElement('div');
shell.className = 'newsletter-editor-shell';
shell.innerHTML =
'<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>' +
'<div class="newsletter-editor-toolbar">' +
'<button type="button" class="button button-small button-secondary" data-newsletter-insert="{{email_body}}">Insert {{email_body}}</button>' +
'<button type="button" class="button button-small button-secondary" data-newsletter-preview>Preview with sample content</button>' +
'</div>' +
'<div class="newsletter-preview-shell" data-newsletter-preview-shell hidden>' +
'<div class="newsletter-preview-shell__head">' +
'<strong>Preview</strong>' +
'<button type="button" class="button button-small button-secondary" data-newsletter-preview-close>Close</button>' +
'</div>' +
'<iframe class="newsletter-preview-frame" data-newsletter-preview-frame title="Newsletter preview"></iframe>' +
'</div>';
return shell;
}
source = htmlInput && htmlInput.value ? htmlInput.value : '';
if (source.trim()) {
origin = 'html-input';
return { source: source, origin: origin };
}
source =
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation">' +
'<tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>';
origin = 'default-template';
return { source: source, origin: origin };
}
function bootEditor() {
var htmlInput = document.querySelector('[data-newsletter-html-input="1"]');
var jsonInput = document.querySelector('[data-newsletter-json-input="1"]');
var container = document.getElementById('newsletter-grapesjs-editor');
var statusNode = document.querySelector('[data-newsletter-editor-status]');
var previewButton = document.querySelector('[data-newsletter-preview]');
var previewShell = document.querySelector('[data-newsletter-preview-shell]');
var previewFrame = document.querySelector('[data-newsletter-preview-frame]');
var previewCloseButton = document.querySelector('[data-newsletter-preview-close]');
var mediaBaseUrl = (htmlInput.dataset.newsletterMediaUrl || '').trim();
if (!htmlInput || !jsonInput || !container) {
function bindEditor(textarea) {
if (!textarea || textarea.dataset.newsletterEditorBound === '1') {
return;
}
textarea.dataset.newsletterEditorBound = '1';
(async function () {
var ok = await ensureGrapesJS();
if (!ok) {
if (statusNode) {
statusNode.textContent = 'GrapesJS static files not found';
}
return;
}
var shell = createEditorShell();
textarea.parentNode.insertBefore(shell, textarea);
var plugins = [];
if (window['grapesjs-preset-newsletter']) {
plugins.push('grapesjs-preset-newsletter');
}
var statusNode = shell.querySelector('[data-newsletter-editor-status]');
var previewButton = shell.querySelector('[data-newsletter-preview]');
var previewShell = shell.querySelector('[data-newsletter-preview-shell]');
var previewFrame = shell.querySelector('[data-newsletter-preview-frame]');
var previewCloseButton = shell.querySelector('[data-newsletter-preview-close]');
var insertButtons = shell.querySelectorAll('[data-newsletter-insert]');
var editor = window.grapesjs.init({
container: '#newsletter-grapesjs-editor',
height: '620px',
storageManager: false,
fromElement: false,
plugins: plugins,
assetManager: {
custom: {
open: function (props) {
openWagtailImageChooser(mediaBaseUrl)
.then(function (assetData) {
var selected = editor.getSelected();
if (selected && selected.is && selected.is('image')) {
selected.addAttributes({
src: assetData.src,
alt: assetData.alt,
});
props.close();
return;
}
var asset = editor.AssetManager.add({
type: 'image',
src: assetData.src,
name: assetData.title,
});
if (props && typeof props.select === 'function') {
props.select(asset, true);
}
if (props && typeof props.close === 'function') {
props.close();
}
})
.catch(function (err) {
if (statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Image chooser error: ' + err.message;
}
if (props && typeof props.close === 'function') {
props.close();
}
});
},
close: function () {},
},
},
});
var chooserReady = await ensureWagtailImageChooser();
if (!chooserReady && statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Wagtail image chooser not available';
}
var savedProject = parseJSON(jsonInput.value || '{}', {});
if (savedProject && Object.keys(savedProject).length > 0) {
try {
editor.loadProjectData(savedProject);
} catch (e) {
if ((htmlInput.value || '').trim()) {
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
}
}
} else if ((htmlInput.value || '').trim()) {
editor.setComponents(normalizeBodyPlaceholder(htmlInput.value));
} else {
editor.setComponents(
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td><div data-newsletter-body-slot="1">{{email_body}}</div></td></tr></table>'
);
}
var form = htmlInput.closest('form');
if (form) {
form.addEventListener('submit', function () {
try {
var projectData = editor.getProjectData();
jsonInput.value = JSON.stringify(projectData);
} catch (e) {
jsonInput.value = '{}';
}
htmlInput.value = getHtmlWithCss(editor);
for (var i = 0; i < insertButtons.length; i++) {
insertButtons[i].addEventListener('click', function () {
insertAtCursor(textarea, this.getAttribute('data-newsletter-insert') || '');
});
}
if (previewButton) {
previewButton.addEventListener('click', function () {
var previewPayload = buildPreviewSource(editor, htmlInput);
var html = previewPayload.source;
if (!openPreviewInFrame(html, previewFrame, previewShell) && statusNode) {
statusNode.hidden = false;
statusNode.textContent = 'Unable to render in-page preview';
var preview = renderPreviewHtml(textarea.value || '');
var warnings = collectWarnings(textarea.value || '', preview.hasBodySlot);
setStatus(statusNode, warnings);
if (!openPreviewInFrame(previewFrame, previewShell, preview.html)) {
setStatus(statusNode, ['Unable to render in-page preview.'].concat(warnings));
}
});
}
if (previewCloseButton && previewShell) {
previewCloseButton.addEventListener('click', function () {
previewShell.hidden = true;
});
}
if (statusNode) {
statusNode.hidden = true;
}
})();
function boot() {
var textareas = document.querySelectorAll('textarea[data-newsletter-html-input="1"]');
for (var i = 0; i < textareas.length; i++) {
bindEditor(textareas[i]);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootEditor);
document.addEventListener('DOMContentLoaded', boot);
} else {
bootEditor();
boot();
}
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,14 +1,19 @@
from datetime import date
from types import SimpleNamespace
from unittest.mock import Mock, patch
from django.test import TestCase
from django.urls import reverse
from .newsletter import (
extract_token,
generate_one_click_token,
build_weekly_news_body_html,
render_placeholders,
render_newsletter_html,
verify_one_click_token,
)
from .models import ContactFormSubmission
from .models import ContactFormSubmission, SystemNotificationMailSettings
from .security import decrypt_text, encrypt_text
@ -77,6 +82,65 @@ 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_newsletter_subscribe_uses_system_notification_template(self):
notification_settings = SystemNotificationMailSettings.load()
notification_settings.subscribe_subject_template = "Confirm {{email}}"
notification_settings.subscribe_html_template = "<table><tr><td>New {{confirm_url}}</td></tr></table>"
notification_settings.subscribe_text_template = "Text {{confirm_url}}"
notification_settings.save()
with (
patch("base.views.MemberCenterClient") as client_cls,
patch("base.views.send_subscribe_email") as send_email,
):
client_cls.return_value.subscribe.return_value = SimpleNamespace(
ok=True,
data={"token": "abc123"},
)
response = self.client.post(
reverse("newsletter_subscribe"),
data={"email": "demo@example.com"},
)
self.assertEqual(response.status_code, 200)
send_email.assert_called_once()
kwargs = send_email.call_args.kwargs
self.assertEqual(kwargs["subject"], "Confirm demo@example.com")
self.assertIn("<table><tr><td>New http://testserver/newsletter/confirm/", kwargs["html_body"])
def test_weekly_news_body_uses_fixed_size_cover_rendition(self):
image = Mock()
image.get_rendition.return_value = SimpleNamespace(url="/media/images/article.fill-596x333.jpg")
image.file.url = "/media/images/article-original.jpg"
article = SimpleNamespace(
id=12,
title="Weekly title",
intro="Weekly intro",
date=date(2026, 6, 30),
url="/news/weekly-title/",
cover_image_id=34,
cover_image=image,
)
item_template_html = (
'<a href="{{article_url}}">{{article_title}}</a>'
'<img src="{{article_cover_url}}" alt="{{article_title}}">'
"<p>{{article_intro}}</p>"
)
with patch("home.models.ArticlePage") as article_page:
article_page.objects.live.return_value.filter.return_value = [article]
rendered = build_weekly_news_body_html(
article_page_ids=[article.id],
item_template_html=item_template_html,
site_base_url="https://debutai.net",
)
image.get_rendition.assert_called_once_with("fill-596x333")
self.assertIn('href="https://debutai.net/news/weekly-title/"', rendered)
self.assertIn('src="https://debutai.net/media/images/article.fill-596x333.jpg"', rendered)
self.assertIn("Weekly intro", rendered)
def test_contact_form_submit_saves_submission(self):
response = self.client.post(
reverse("contact_form_submit"),

View File

@ -21,7 +21,6 @@ from .models import (
NewsletterCampaign,
NewsletterSystemSettings,
NewsletterTemplate,
NewsletterTemplateSettings,
OneClickUnsubscribeAudit,
SystemNotificationMailSettings,
)
@ -72,7 +71,7 @@ def media_proxy(request, key: str):
def _load_settings(request_or_site=None):
return (
NewsletterSystemSettings.load(request_or_site=request_or_site),
NewsletterTemplateSettings.load(request_or_site=request_or_site),
SystemNotificationMailSettings.load(request_or_site=request_or_site),
MailSmtpSettings.load(request_or_site=request_or_site),
)

View File

@ -13,9 +13,9 @@ from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
from .forms import (
GrapesJSEditorWidget,
NewsletterCampaignAdminForm,
NewsletterCampaignEditorWidget,
NewsletterHtmlEditorWidget,
NewsletterTemplateAdminForm,
)
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
@ -224,15 +224,11 @@ class NewsletterTemplateViewSet(SnippetViewSet):
panels = [
FieldPanel("name"),
FieldPanel("subject"),
FieldPanel(
"template_json",
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
),
FieldPanel(
"template_html",
widget=GrapesJSEditorWidget(
widget=NewsletterHtmlEditorWidget(
attrs={
"rows": 18,
"rows": 28,
"placeholder": "Use {{email_body}} as the content placeholder.",
}
),