Skip to content

Commit 146afd9

Browse files
committed
ses + raw mjml + filemanager
1 parent 0ff888a commit 146afd9

File tree

11 files changed

+421
-19
lines changed

11 files changed

+421
-19
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [26.10] - 2026-01-23
6+
7+
- **File Manager**: Added warning modal when selecting images larger than 200KB from email editor to prevent slow email loading times
8+
- **SES Email**: Fixed incorrect quoted-printable encoding in raw emails causing Gmail to break rendering for broadcasts with List-Unsubscribe headers or attachments (#230)
9+
- **Email Builder**: Fixed Raw HTML block content not appearing in preview or sent emails (#229)
10+
511
## [26.9] - 2026-01-21
612

713
- **Contacts**: Fixed CSV import not trimming non-breaking spaces (NBSP) from email addresses and string fields (#223)

config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
"github.com/spf13/viper"
1515
)
1616

17-
const VERSION = "26.9"
17+
const VERSION = "26.10"
1818

1919
type Config struct {
2020
Server ServerConfig

console/package-lock.json

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

console/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
"html2canvas": "^1.4.1",
7171
"is-hotkey": "^0.2.0",
7272
"liquidjs": "^10.24.0",
73-
"lodash": "^4.17.21",
73+
"lodash": "^4.17.23",
7474
"lodash.throttle": "^4.1.1",
7575
"lowlight": "^3.3.0",
7676
"lucide-react": "^0.487.0",

console/src/components/email_builder/EmailBlockClass.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -468,12 +468,14 @@ export class EmailBlockClass {
468468
block.children = []
469469
}
470470

471-
// Special handling for mj-text blocks to ensure EditorJS content
471+
// Special handling for blocks that support content
472472
if (
473473
type === 'mj-text' ||
474474
type === 'mj-button' ||
475475
type === 'mj-title' ||
476-
type === 'mj-preview'
476+
type === 'mj-preview' ||
477+
type === 'mj-raw' ||
478+
type === 'mj-style'
477479
) {
478480
// For mj-text blocks, ensure content is wrapped in <p> tags (Tiptap always wraps in <p>)
479481
if (type === 'mj-text') {
@@ -486,8 +488,9 @@ export class EmailBlockClass {
486488
block.content = '<p></p>'
487489
}
488490
} else {
489-
// For other content-supporting blocks, use content as-is
490-
block.content = content
491+
// For other content-supporting blocks, use content as-is or initialize to empty string
492+
// This ensures the content field exists on the block for mj-raw and mj-style blocks
493+
block.content = content ?? ''
491494
}
492495
}
493496

console/src/components/file_manager/context.tsx

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface SelectFileButtonProps {
1818
type?: 'primary' | 'default' | 'dashed' | 'link' | 'text'
1919
ghost?: boolean
2020
style?: React.CSSProperties
21+
maxFileSizeWarning?: number // Threshold in bytes, default 200KB (204800)
2122
}
2223

2324
interface FileManagerProviderProps {
@@ -44,7 +45,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
4445
onSelect: (url: string) => void
4546
acceptFileType?: string
4647
acceptItem?: (item: StorageObject) => boolean
48+
maxFileSizeWarning?: number
4749
} | null>(null)
50+
const [warningModalVisible, setWarningModalVisible] = useState(false)
51+
const [pendingFile, setPendingFile] = useState<StorageObject | null>(null)
4852

4953
// Close file manager modal
5054
const closeModal = () => {
@@ -57,13 +61,40 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
5761
if (currentOptions?.onSelect && items.length > 0) {
5862
const selectedFile = items[0]
5963
if (selectedFile.file_info?.url) {
64+
const maxSize = currentOptions.maxFileSizeWarning ?? 204800 // Default 200KB
65+
66+
// Check if file exceeds size threshold
67+
if (selectedFile.file_info.size > maxSize) {
68+
setPendingFile(selectedFile)
69+
setWarningModalVisible(true)
70+
return
71+
}
72+
73+
// File is under threshold, proceed normally
6074
currentOptions.onSelect(selectedFile.file_info.url)
6175
message.success(`Selected: ${selectedFile.name}`)
6276
closeModal()
6377
}
6478
}
6579
}
6680

81+
// Handle confirm large file selection
82+
const handleConfirmLargeFile = () => {
83+
if (pendingFile && currentOptions?.onSelect) {
84+
currentOptions.onSelect(pendingFile.file_info.url)
85+
message.success(`Selected: ${pendingFile.name}`)
86+
}
87+
setWarningModalVisible(false)
88+
setPendingFile(null)
89+
closeModal()
90+
}
91+
92+
// Handle cancel large file selection
93+
const handleCancelLargeFile = () => {
94+
setWarningModalVisible(false)
95+
setPendingFile(null)
96+
}
97+
6798
// Handle file manager errors
6899
const handleFileManagerError = (error: Error) => {
69100
console.error('File manager error:', error)
@@ -81,13 +112,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
81112
block = false,
82113
type = 'primary',
83114
ghost = false,
84-
style
115+
style,
116+
maxFileSizeWarning
85117
}) => {
86118
const handleOpenFileManager = () => {
87119
setCurrentOptions({
88120
onSelect,
89121
acceptFileType,
90-
acceptItem
122+
acceptItem,
123+
maxFileSizeWarning
91124
})
92125
setIsModalVisible(true)
93126
}
@@ -146,6 +179,32 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
146179
/>
147180
)}
148181
</Modal>
182+
183+
{/* Large File Warning Modal */}
184+
<Modal
185+
title="Large File Warning"
186+
open={warningModalVisible}
187+
onCancel={handleCancelLargeFile}
188+
footer={[
189+
<Button key="cancel" onClick={handleCancelLargeFile}>
190+
Cancel
191+
</Button>,
192+
<Button key="confirm" type="primary" onClick={handleConfirmLargeFile}>
193+
Use Anyway
194+
</Button>
195+
]}
196+
zIndex={1400}
197+
>
198+
<p>
199+
The selected file <strong>{pendingFile?.name}</strong> is{' '}
200+
<strong>{pendingFile?.file_info?.size_human}</strong>.
201+
</p>
202+
<p>
203+
Large images can significantly slow down email loading times for recipients,
204+
especially on mobile devices or slow connections.
205+
</p>
206+
<p>Are you sure you want to use this file?</p>
207+
</Modal>
149208
</FileManagerContext.Provider>
150209
)
151210
}

internal/domain/template_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1744,3 +1744,149 @@ func TestEmailTemplate_UnmarshalJSON_Minimal_ExistingFile(t *testing.T) {
17441744
t.Fatalf("unexpected error: %v", err)
17451745
}
17461746
}
1747+
1748+
// TestEmailTemplate_MjRawContent_JSONRoundTrip tests that mj-raw block content is preserved
1749+
// through JSON serialization/deserialization (GitHub issue #229)
1750+
func TestEmailTemplate_MjRawContent_JSONRoundTrip(t *testing.T) {
1751+
// JSON that represents a template with mj-raw content
1752+
jsonData := []byte(`{
1753+
"sender_id": "test-sender",
1754+
"subject": "Test Subject",
1755+
"compiled_preview": "<html>test</html>",
1756+
"visual_editor_tree": {
1757+
"id": "mjml-1",
1758+
"type": "mjml",
1759+
"children": [
1760+
{
1761+
"id": "body-1",
1762+
"type": "mj-body",
1763+
"children": [
1764+
{
1765+
"id": "section-1",
1766+
"type": "mj-section",
1767+
"children": [
1768+
{
1769+
"id": "column-1",
1770+
"type": "mj-column",
1771+
"children": [
1772+
{
1773+
"id": "raw-1",
1774+
"type": "mj-raw",
1775+
"content": "<table><tr><td>Cell 1</td><td>Cell 2</td></tr></table>",
1776+
"attributes": {}
1777+
}
1778+
]
1779+
}
1780+
]
1781+
}
1782+
]
1783+
}
1784+
]
1785+
}
1786+
}`)
1787+
1788+
// Unmarshal the JSON
1789+
var emailTemplate EmailTemplate
1790+
err := emailTemplate.UnmarshalJSON(jsonData)
1791+
assert.NoError(t, err, "Failed to unmarshal EmailTemplate")
1792+
1793+
// Verify the visual_editor_tree was unmarshaled correctly
1794+
assert.NotNil(t, emailTemplate.VisualEditorTree, "VisualEditorTree should not be nil")
1795+
assert.Equal(t, notifuse_mjml.MJMLComponentMjml, emailTemplate.VisualEditorTree.GetType())
1796+
1797+
// Find the mj-raw block and verify content
1798+
var rawBlock notifuse_mjml.EmailBlock
1799+
bodyBlock := emailTemplate.VisualEditorTree.GetChildren()[0]
1800+
sectionBlock := bodyBlock.GetChildren()[0]
1801+
columnBlock := sectionBlock.GetChildren()[0]
1802+
rawBlock = columnBlock.GetChildren()[0]
1803+
1804+
assert.Equal(t, notifuse_mjml.MJMLComponentMjRaw, rawBlock.GetType(), "Expected mj-raw block")
1805+
content := rawBlock.GetContent()
1806+
assert.NotNil(t, content, "mj-raw content should not be nil")
1807+
assert.Equal(t, "<table><tr><td>Cell 1</td><td>Cell 2</td></tr></table>", *content)
1808+
1809+
// Marshal back to JSON
1810+
marshaledJSON, err := emailTemplate.MarshalJSON()
1811+
assert.NoError(t, err, "Failed to marshal EmailTemplate")
1812+
1813+
// Verify the content is preserved in the marshaled JSON
1814+
assert.Contains(t, string(marshaledJSON), "Cell 1", "Marshaled JSON should contain mj-raw content")
1815+
assert.Contains(t, string(marshaledJSON), "Cell 2", "Marshaled JSON should contain mj-raw content")
1816+
1817+
// Unmarshal again to verify round-trip
1818+
var emailTemplate2 EmailTemplate
1819+
err = emailTemplate2.UnmarshalJSON(marshaledJSON)
1820+
assert.NoError(t, err, "Failed to unmarshal EmailTemplate after round-trip")
1821+
1822+
// Find the mj-raw block again and verify content
1823+
var rawBlock2 notifuse_mjml.EmailBlock
1824+
bodyBlock2 := emailTemplate2.VisualEditorTree.GetChildren()[0]
1825+
sectionBlock2 := bodyBlock2.GetChildren()[0]
1826+
columnBlock2 := sectionBlock2.GetChildren()[0]
1827+
rawBlock2 = columnBlock2.GetChildren()[0]
1828+
1829+
content2 := rawBlock2.GetContent()
1830+
assert.NotNil(t, content2, "mj-raw content should not be nil after round-trip")
1831+
assert.Equal(t, "<table><tr><td>Cell 1</td><td>Cell 2</td></tr></table>", *content2)
1832+
}
1833+
1834+
// TestEmailTemplate_MjRawContent_Value_Scan tests that mj-raw content is preserved
1835+
// when using database Value() and Scan() methods (simulating database save/load)
1836+
func TestEmailTemplate_MjRawContent_Value_Scan(t *testing.T) {
1837+
// Create an EmailTemplate with mj-raw content
1838+
rawContent := "<table><tr><td>Cell 1</td><td>Cell 2</td></tr></table>"
1839+
1840+
rawBase := notifuse_mjml.NewBaseBlock("raw-1", notifuse_mjml.MJMLComponentMjRaw)
1841+
rawBase.Content = &rawContent
1842+
rawBlock := &notifuse_mjml.MJRawBlock{BaseBlock: rawBase}
1843+
1844+
columnBlock := &notifuse_mjml.MJColumnBlock{BaseBlock: notifuse_mjml.NewBaseBlock("column-1", notifuse_mjml.MJMLComponentMjColumn)}
1845+
columnBlock.Children = []notifuse_mjml.EmailBlock{rawBlock}
1846+
1847+
sectionBlock := &notifuse_mjml.MJSectionBlock{BaseBlock: notifuse_mjml.NewBaseBlock("section-1", notifuse_mjml.MJMLComponentMjSection)}
1848+
sectionBlock.Children = []notifuse_mjml.EmailBlock{columnBlock}
1849+
1850+
bodyBlock := &notifuse_mjml.MJBodyBlock{BaseBlock: notifuse_mjml.NewBaseBlock("body-1", notifuse_mjml.MJMLComponentMjBody)}
1851+
bodyBlock.Children = []notifuse_mjml.EmailBlock{sectionBlock}
1852+
1853+
mjmlBlock := &notifuse_mjml.MJMLBlock{BaseBlock: notifuse_mjml.NewBaseBlock("mjml-1", notifuse_mjml.MJMLComponentMjml)}
1854+
mjmlBlock.Children = []notifuse_mjml.EmailBlock{bodyBlock}
1855+
1856+
emailTemplate := &EmailTemplate{
1857+
SenderID: "test-sender",
1858+
Subject: "Test Subject",
1859+
CompiledPreview: "<html>test</html>",
1860+
VisualEditorTree: mjmlBlock,
1861+
}
1862+
1863+
// Test Value() - simulates database save
1864+
value, err := emailTemplate.Value()
1865+
assert.NoError(t, err, "Value() should not return error")
1866+
assert.NotNil(t, value, "Value() should return data")
1867+
1868+
// Verify the value contains the content
1869+
valueBytes, ok := value.([]byte)
1870+
assert.True(t, ok, "Value() should return []byte")
1871+
assert.Contains(t, string(valueBytes), "Cell 1", "Value() should contain mj-raw content")
1872+
1873+
// Test Scan() - simulates database load
1874+
var emailTemplate2 EmailTemplate
1875+
err = emailTemplate2.Scan(valueBytes)
1876+
assert.NoError(t, err, "Scan() should not return error")
1877+
1878+
// Verify the visual_editor_tree was scanned correctly
1879+
assert.NotNil(t, emailTemplate2.VisualEditorTree, "VisualEditorTree should not be nil after Scan")
1880+
1881+
// Find the mj-raw block and verify content
1882+
var rawBlock2 notifuse_mjml.EmailBlock
1883+
bodyBlock2 := emailTemplate2.VisualEditorTree.GetChildren()[0]
1884+
sectionBlock2 := bodyBlock2.GetChildren()[0]
1885+
columnBlock2 := sectionBlock2.GetChildren()[0]
1886+
rawBlock2 = columnBlock2.GetChildren()[0]
1887+
1888+
assert.Equal(t, notifuse_mjml.MJMLComponentMjRaw, rawBlock2.GetType(), "Expected mj-raw block after Scan")
1889+
content2 := rawBlock2.GetContent()
1890+
assert.NotNil(t, content2, "mj-raw content should not be nil after Scan")
1891+
assert.Equal(t, rawContent, *content2, "mj-raw content should be preserved after Value/Scan round-trip")
1892+
}

internal/domain/workspace.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"encoding/json"
99
"errors"
1010
"fmt"
11+
"strings"
1112
"time"
1213

1314
"github.com/Notifuse/notifuse/pkg/crypto"
@@ -360,6 +361,18 @@ func (ws *WorkspaceSettings) Validate(passphrase string) error {
360361
return fmt.Errorf("invalid cover URL: %s", ws.CoverURL)
361362
}
362363

364+
// Validate custom endpoint URL if provided
365+
if ws.CustomEndpointURL != nil && *ws.CustomEndpointURL != "" {
366+
customURL := *ws.CustomEndpointURL
367+
if !govalidator.IsURL(customURL) {
368+
return fmt.Errorf("invalid custom endpoint URL: %s", customURL)
369+
}
370+
// Ensure it uses http or https scheme
371+
if !strings.HasPrefix(customURL, "http://") && !strings.HasPrefix(customURL, "https://") {
372+
return fmt.Errorf("custom endpoint URL must use http or https scheme: %s", customURL)
373+
}
374+
}
375+
363376
// FileManager is completely optional, but if any fields are set, validate them
364377
if err := ws.FileManager.Validate(passphrase); err != nil {
365378
return fmt.Errorf("invalid file manager settings: %w", err)

0 commit comments

Comments
 (0)