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 import forms
from django.conf import settings
from django.utils.safestring import mark_safe
from wagtail.admin.rich_text import DraftailRichTextArea from wagtail.admin.rich_text import DraftailRichTextArea
from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate from .models import ContactFormSubmission, NewsletterCampaign, NewsletterTemplate
@ -33,45 +31,18 @@ class ContactForm(forms.ModelForm):
} }
class GrapesJSEditorWidget(forms.Textarea): class NewsletterHtmlEditorWidget(forms.Textarea):
class Media: class Media:
js = ( js = ("js/newsletter_template_editor.js",)
"js/newsletter_template_editor.js",
)
css = { css = {
"all": ( "all": ("css/newsletter_template_editor.css",)
"vendor/grapesjs/grapes.min.css",
"css/newsletter_template_editor.css",
)
} }
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
attrs = attrs or {} attrs = attrs or {}
attrs["data-newsletter-html-input"] = "1" attrs["data-newsletter-html-input"] = "1"
attrs["data-newsletter-media-url"] = (settings.MEDIA_URL or "").strip() attrs.setdefault("spellcheck", "false")
attrs["hidden"] = "hidden" return super().render(name, value, attrs, renderer)
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}")
class NewsletterCampaignEditorWidget(DraftailRichTextArea): class NewsletterCampaignEditorWidget(DraftailRichTextArea):
@ -86,12 +57,11 @@ class NewsletterCampaignEditorWidget(DraftailRichTextArea):
class NewsletterTemplateAdminForm(forms.ModelForm): class NewsletterTemplateAdminForm(forms.ModelForm):
class Meta: class Meta:
model = NewsletterTemplate model = NewsletterTemplate
fields = ["name", "subject", "template_json", "template_html", "template_text"] fields = ["name", "subject", "template_html", "template_text"]
widgets = { widgets = {
"template_json": forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}), "template_html": NewsletterHtmlEditorWidget(
"template_html": GrapesJSEditorWidget(
attrs={ attrs={
"rows": 18, "rows": 28,
"placeholder": "Use {{email_body}} as the content placeholder.", "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( item_template_html = models.TextField(
blank=True, blank=True,
default=( default='''<tr>
'<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 0 16px;">' <td height="40" style="height: 40px;">
"<tr>" &nbsp;
'<td style="padding:0;">' </td>
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:inherit;">' </tr>
'<img src="{{article_cover_url}}" alt="{{article_title}}" style="display:block;width:100%;max-width:640px;height:auto;border:0;">' <tr>
"</a>" <td width="100%" style="width:100%">
'<h3 style="margin:12px 0 8px;font-size:20px;line-height:1.4;">' <a href="{{article_url}}" target="_blank" rel="newsletter" style="text-decoration: none;">
'<a href="{{article_url}}" target="_blank" style="text-decoration:none;color:#111;">{{article_title}}</a>' <p style="font-weight: 400; font-size: 24px; line-height: 29px; text-align: justify; color: #0E1B42;">
"</h3>" {{article_title}}
'<p style="margin:0 0 8px;color:#555;font-size:14px;">{{article_date}}</p>' </p>
'<p style="margin:0;color:#222;font-size:16px;line-height:1.6;">{{article_intro}}</p>' </a>
"</td>" </td>
"</tr>" </tr>
"</table>" <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"), verbose_name=_("Weekly Item HTML Template"),
) )
content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config")) content_config = models.JSONField(default=dict, blank=True, verbose_name=_("Content Config"))
@ -589,7 +636,6 @@ class NewsletterCampaign(models.Model):
class NewsletterTemplate(models.Model): class NewsletterTemplate(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name")) name = models.CharField(max_length=255, verbose_name=_("Name"))
subject = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Subject")) 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_html = models.TextField(blank=True, default="", verbose_name=_("Template HTML"))
template_text = models.TextField(blank=True, default="", verbose_name=_("Template Text")) template_text = models.TextField(blank=True, default="", verbose_name=_("Template Text"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
@ -598,7 +644,6 @@ class NewsletterTemplate(models.Model):
panels = [ panels = [
FieldPanel("name"), FieldPanel("name"),
FieldPanel("subject"), FieldPanel("subject"),
FieldPanel("template_json"),
FieldPanel("template_html"), FieldPanel("template_html"),
FieldPanel("template_text"), FieldPanel("template_text"),
] ]
@ -650,74 +695,6 @@ class NewsletterDispatchRecord(models.Model):
return f"{self.campaign_id}:{self.email or self.subscriber_id}" 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 @register_snippet
class BannerSnippet(models.Model): class BannerSnippet(models.Model):
key = models.CharField(max_length=50, blank=True, help_text=_("Identifier key, e.g. home / category"), verbose_name=_("Key")) 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) return pattern.sub(_replace, html)
def _strip_grapesjs_svg_placeholder_images(html: str) -> str: def _strip_inline_svg_placeholder_images(html: str) -> str:
# GrapesJS uses inline SVG placeholder data URIs when image src is missing. # Inline SVG data URI placeholders should not be sent as real email images.
# These should not be sent as real email images.
return re.sub( return re.sub(
r"<img\b[^>]*\bsrc=['\"]data:image/svg\+xml;base64,[^'\"]*['\"][^>]*>", 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) rendered = expand_db_html(rendered)
except Exception: except Exception:
pass pass
rendered = _strip_grapesjs_svg_placeholder_images(rendered) rendered = _strip_inline_svg_placeholder_images(rendered)
return _absolutize_links(rendered, site_base_url) 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 = "" cover_url = ""
if page.cover_image_id and page.cover_image: if page.cover_image_id and page.cover_image:
try: try:
cover_url = page.cover_image.get_rendition("max-640x360").url cover_url = page.cover_image.get_rendition("fill-596x333").url
except Exception: except Exception:
cover_url = page.cover_image.file.url cover_url = page.cover_image.file.url
if site_base_url and cover_url.startswith("/"): if site_base_url and cover_url.startswith("/"):

View File

@ -3,22 +3,16 @@
} }
.newsletter-editor-toolbar { .newsletter-editor-toolbar {
margin: 10px 0;
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
margin: 10px 0;
} }
.newsletter-editor-status { .newsletter-editor-status {
margin: 8px 0 10px; margin: 8px 0 10px;
color: #666; color: #8a5a00;
}
.newsletter-grapesjs-editor {
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
background: #fff;
} }
.newsletter-preview-shell { .newsletter-preview-shell {
@ -39,11 +33,19 @@
.newsletter-preview-frame { .newsletter-preview-frame {
display: block; display: block;
width: 100%; width: 100%;
min-height: 420px; min-height: 560px;
border: 0; border: 0;
background: #fff; background: #fff;
} }
textarea[data-newsletter-html-input="1"] { 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 () {
function loadScript(src) { function renderPreviewHtml(source) {
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) {
var sampleBody = var sampleBody =
'<h2 style="margin:0 0 12px;">本期電子報標題</h2>' + '<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">' +
'<p style="margin:0 0 10px;">這是預覽用的假內容,實際寄送時會替換成真實內容。</p>' + '<tr><td style="padding:24px;font-family:Arial,sans-serif;color:#0E1B42;">' +
'<p style="margin:0 0 10px;"><a href="https://example.com" target="_blank">瞭解更多</a></p>'; '<h2 style="margin:0 0 12px;font-size:24px;line-height:1.3;">本期電子報標題</h2>' +
return (html || '') '<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(/\{\{email_body\}\}/g, sampleBody)
.replace(/\{\{html_body\}\}/g, sampleBody) .replace(/\{\{html_body\}\}/g, sampleBody)
.replace(/\{\{token\}\}/g, 'sample-token-123') .replace(/\{\{text_body\}\}/g, sampleBody),
.replace(/\{\{email\}\}/g, 'demo@example.com') hasBodySlot: hasBodySlot,
.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');
} }
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) { if (!frame || !shell) {
return false; return false;
} }
@ -198,175 +70,80 @@
return false; return false;
} }
doc.open(); doc.open();
doc.write( doc.write('<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body style="margin:0;">' + html + '</body></html>');
'<!doctype html><html><head><meta charset="utf-8"><title>Newsletter Preview</title></head><body>' +
renderPreviewHtml(html) +
'</body></html>'
);
doc.close(); doc.close();
return true; return true;
} }
function buildPreviewSource(editor, htmlInput) { function createEditorShell() {
var source = ''; var shell = document.createElement('div');
var origin = ''; shell.className = 'newsletter-editor-shell';
shell.innerHTML =
source = getHtmlWithCss(editor) || ''; '<div class="newsletter-editor-status" data-newsletter-editor-status hidden></div>' +
if (source.trim()) { '<div class="newsletter-editor-toolbar">' +
origin = 'editor-export'; '<button type="button" class="button button-small button-secondary" data-newsletter-insert="{{email_body}}">Insert {{email_body}}</button>' +
return { source: source, origin: origin }; '<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 : ''; function bindEditor(textarea) {
if (source.trim()) { if (!textarea || textarea.dataset.newsletterEditorBound === '1') {
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) {
return; return;
} }
textarea.dataset.newsletterEditorBound = '1';
(async function () { var shell = createEditorShell();
var ok = await ensureGrapesJS(); textarea.parentNode.insertBefore(shell, textarea);
if (!ok) {
if (statusNode) {
statusNode.textContent = 'GrapesJS static files not found';
}
return;
}
var plugins = []; var statusNode = shell.querySelector('[data-newsletter-editor-status]');
if (window['grapesjs-preset-newsletter']) { var previewButton = shell.querySelector('[data-newsletter-preview]');
plugins.push('grapesjs-preset-newsletter'); 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({ for (var i = 0; i < insertButtons.length; i++) {
container: '#newsletter-grapesjs-editor', insertButtons[i].addEventListener('click', function () {
height: '620px', insertAtCursor(textarea, this.getAttribute('data-newsletter-insert') || '');
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);
}); });
} }
if (previewButton) { if (previewButton) {
previewButton.addEventListener('click', function () { previewButton.addEventListener('click', function () {
var previewPayload = buildPreviewSource(editor, htmlInput); var preview = renderPreviewHtml(textarea.value || '');
var html = previewPayload.source; var warnings = collectWarnings(textarea.value || '', preview.hasBodySlot);
if (!openPreviewInFrame(html, previewFrame, previewShell) && statusNode) { setStatus(statusNode, warnings);
statusNode.hidden = false; if (!openPreviewInFrame(previewFrame, previewShell, preview.html)) {
statusNode.textContent = 'Unable to render in-page preview'; setStatus(statusNode, ['Unable to render in-page preview.'].concat(warnings));
} }
}); });
} }
if (previewCloseButton && previewShell) { if (previewCloseButton && previewShell) {
previewCloseButton.addEventListener('click', function () { previewCloseButton.addEventListener('click', function () {
previewShell.hidden = true; 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') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootEditor); document.addEventListener('DOMContentLoaded', boot);
} else { } 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.test import TestCase
from django.urls import reverse from django.urls import reverse
from .newsletter import ( from .newsletter import (
extract_token, extract_token,
generate_one_click_token, generate_one_click_token,
build_weekly_news_body_html,
render_placeholders, render_placeholders,
render_newsletter_html, render_newsletter_html,
verify_one_click_token, verify_one_click_token,
) )
from .models import ContactFormSubmission from .models import ContactFormSubmission, SystemNotificationMailSettings
from .security import decrypt_text, encrypt_text 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('href="https://news.example.com/a"', rendered)
self.assertIn('src="https://news.example.com/media/x.jpg"', 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): def test_contact_form_submit_saves_submission(self):
response = self.client.post( response = self.client.post(
reverse("contact_form_submit"), reverse("contact_form_submit"),

View File

@ -21,7 +21,6 @@ from .models import (
NewsletterCampaign, NewsletterCampaign,
NewsletterSystemSettings, NewsletterSystemSettings,
NewsletterTemplate, NewsletterTemplate,
NewsletterTemplateSettings,
OneClickUnsubscribeAudit, OneClickUnsubscribeAudit,
SystemNotificationMailSettings, SystemNotificationMailSettings,
) )
@ -72,7 +71,7 @@ def media_proxy(request, key: str):
def _load_settings(request_or_site=None): def _load_settings(request_or_site=None):
return ( return (
NewsletterSystemSettings.load(request_or_site=request_or_site), 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), 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 wagtail.snippets.views.snippets import CreateView, SnippetViewSet, SnippetViewSetGroup
from .forms import ( from .forms import (
GrapesJSEditorWidget,
NewsletterCampaignAdminForm, NewsletterCampaignAdminForm,
NewsletterCampaignEditorWidget, NewsletterCampaignEditorWidget,
NewsletterHtmlEditorWidget,
NewsletterTemplateAdminForm, NewsletterTemplateAdminForm,
) )
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings, NewsletterTemplate
@ -224,15 +224,11 @@ class NewsletterTemplateViewSet(SnippetViewSet):
panels = [ panels = [
FieldPanel("name"), FieldPanel("name"),
FieldPanel("subject"), FieldPanel("subject"),
FieldPanel(
"template_json",
widget=forms.HiddenInput(attrs={"data-newsletter-json-input": "1"}),
),
FieldPanel( FieldPanel(
"template_html", "template_html",
widget=GrapesJSEditorWidget( widget=NewsletterHtmlEditorWidget(
attrs={ attrs={
"rows": 18, "rows": 28,
"placeholder": "Use {{email_body}} as the content placeholder.", "placeholder": "Use {{email_body}} as the content placeholder.",
} }
), ),