Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,16 @@ COPY pkg/ pkg/
# Build the application with CGO disabled (pure Go)
ENV CGO_ENABLED=0
ENV GOOS=linux
RUN go build -ldflags="-s -w" -o /tmp/server ./cmd/api

# BUILD_TAGS is empty by default, which is what every published image must use: the
# resulting binary trusts only the production licence signing key (pubkey_prod.go).
# Passing --build-arg BUILD_TAGS=licdev for a local build switches it to trust the dev
# signing key instead (pubkey_dev.go), whose private half is committed under
# pkg/license/testdata/ for exactly that purpose. A licdev image must never be pushed
# to a registry or run anywhere but a developer's own machine: the dev key is public,
# so a licdev binary accepts a licence key anyone can mint.
ARG BUILD_TAGS=""
RUN go build -tags "${BUILD_TAGS}" -ldflags="-s -w" -o /tmp/server ./cmd/api

# Stage 4: Create the runtime container (Alpine for smaller image)
FROM alpine:3.24
Expand Down
2 changes: 2 additions & 0 deletions console/src/components/email_builder/EmailBuilder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const props = (
onCompile,
testData: undefined,
onTestDataChange: vi.fn(),
plainText: '',
onPlainTextChange: vi.fn(),
onSaveBlock: vi.fn(),
forcedViewMode
})
Expand Down
21 changes: 19 additions & 2 deletions console/src/components/email_builder/EmailBuilder.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useMemo, useState, useEffect } from 'react'
import { useLingui } from '@lingui/react/macro'
import { Button, Space, Segmented, Spin } from 'antd'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faRedoAlt, faUndoAlt } from '@fortawesome/free-solid-svg-icons'
Expand All @@ -16,6 +17,7 @@ import type {
MJMLComponentType
} from './types'
import { EmailBlockClass } from './EmailBlockClass'
import PlainTextEditorPanel from './PlainTextEditorPanel'

interface EmailBuilderProps {
tree: EmailBlock
Expand All @@ -26,6 +28,8 @@ interface EmailBuilderProps {
) => Promise<{ errors?: Array<Record<string, unknown>>; html: string; mjml: string }>
testData?: Record<string, unknown>
onTestDataChange: (testData: Record<string, unknown>) => void
plainText: string
onPlainTextChange: (text: string) => void
toolbarActions?: React.ReactNode
savedBlocks?: SavedBlock[]
onSaveBlock: (block: EmailBlock, operation: SaveOperation, nameOrId: string) => void
Expand All @@ -48,6 +52,8 @@ const EmailBuilderContent: React.FC<EmailBuilderProps> = ({
onCompile,
testData,
onTestDataChange,
plainText,
onPlainTextChange,
toolbarActions,
savedBlocks,
onSaveBlock,
Expand All @@ -63,6 +69,8 @@ const EmailBuilderContent: React.FC<EmailBuilderProps> = ({
hiddenBlocks,
height
}) => {
const { t } = useLingui()

// State for current selection, UI, and history
const [state, setState] = useState<
EmailBuilderState & { history: EmailBlock[]; historyIndex: number }
Expand Down Expand Up @@ -103,7 +111,7 @@ const EmailBuilderContent: React.FC<EmailBuilderProps> = ({
})

// Local state for view mode and compilation results
const [viewMode, setViewMode] = useState<'edit' | 'preview'>('edit')
const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'plaintext'>('edit')

// Use forced view mode when provided (for tour), otherwise use local state
const effectiveViewMode = forcedViewMode || viewMode
Expand Down Expand Up @@ -206,7 +214,7 @@ const EmailBuilderContent: React.FC<EmailBuilderProps> = ({
}

const handleModeChange = async (value: string | number) => {
const mode = value as 'edit' | 'preview'
const mode = value as 'edit' | 'preview' | 'plaintext'
// Only update local state if not being forced by tour
if (!forcedViewMode) {
setViewMode(mode)
Expand Down Expand Up @@ -840,6 +848,10 @@ const EmailBuilderContent: React.FC<EmailBuilderProps> = ({
{
label: 'Preview',
value: 'preview'
},
{
label: t`Plain text`,
value: 'plaintext'
}
]}
/>
Expand All @@ -863,6 +875,11 @@ const EmailBuilderContent: React.FC<EmailBuilderProps> = ({
{effectiveViewMode === 'preview' && !compilationResults && (
<Spin size="large" className="!m-16" />
)}
{effectiveViewMode === 'plaintext' && (
<div className="flex-1 min-h-0 p-4">
<PlainTextEditorPanel value={plainText} onChange={onPlainTextChange} />
</div>
)}
{/* Three Column Layout */}
{effectiveViewMode === 'edit' && (
<>
Expand Down
48 changes: 47 additions & 1 deletion console/src/components/email_builder/MjmlCodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import Editor, { type OnMount, type BeforeMount } from '@monaco-editor/react'
import type { editor as MonacoEditor } from 'monaco-editor'
import { useLingui } from '@lingui/react/macro'
import type { MjmlCompileError } from '../../services/api/template'
import PlainTextEditorPanel from './PlainTextEditorPanel'
import { readTextFile, downloadTextFile } from '../../lib/textFile'

interface MjmlCodeEditorProps {
mjmlSource: string
Expand All @@ -26,6 +28,8 @@ interface MjmlCodeEditorProps {
}>
testData?: Record<string, unknown>
onTestDataChange: (testData: Record<string, unknown>) => void
plainText: string
onPlainTextChange: (text: string) => void
height?: string | number
templateId?: string
}
Expand Down Expand Up @@ -223,6 +227,8 @@ const MjmlCodeEditor = forwardRef<MjmlCodeEditorRef, MjmlCodeEditorProps>(({
onCompile,
testData,
onTestDataChange,
plainText,
onPlainTextChange,
height = 'calc(100vh - 200px)',
templateId
}, ref) => {
Expand Down Expand Up @@ -584,6 +590,32 @@ const MjmlCodeEditor = forwardRef<MjmlCodeEditorRef, MjmlCodeEditorProps>(({
URL.revokeObjectURL(url)
}, [mjmlSource])

const handleImportPlainText = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.txt,text/plain'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
if (file.size > 1024 * 1024) {
message.error(t`File is too large. Maximum size is 1MB.`)
return
}
try {
const content = await readTextFile(file)
onPlainTextChange(content)
message.success(t`Plain text file imported`)
} catch {
message.error(t`Failed to read the file`)
}
}
input.click()
}, [onPlainTextChange, message, t])

const handleExportPlainText = useCallback(() => {
downloadTextFile(plainText, 'template.txt')
}, [plainText])

const handleExportHtml = useCallback(async () => {
if (!htmlOutput) {
message.warning(t`Compile the template first`)
Expand Down Expand Up @@ -634,6 +666,11 @@ const MjmlCodeEditor = forwardRef<MjmlCodeEditorRef, MjmlCodeEditorProps>(({
key: 'import-mjml',
label: t`Import MJML`,
onClick: handleImportMjml
},
{
key: 'import-plaintext',
label: t`Import Plain Text`,
onClick: handleImportPlainText
}
]
},
Expand All @@ -651,6 +688,11 @@ const MjmlCodeEditor = forwardRef<MjmlCodeEditorRef, MjmlCodeEditorProps>(({
key: 'export-html',
label: t`Export HTML`,
onClick: handleExportHtml
},
{
key: 'export-plaintext',
label: t`Export Plain Text`,
onClick: handleExportPlainText
}
]
}
Expand Down Expand Up @@ -688,7 +730,8 @@ const MjmlCodeEditor = forwardRef<MjmlCodeEditorRef, MjmlCodeEditorProps>(({
)
},
{ key: 'testdata', label: t`Test Data` },
{ key: 'html', label: t`Generated HTML` }
{ key: 'html', label: t`Generated HTML` },
{ key: 'plaintext', label: t`Plain text` }
]}
tabBarExtraContent={
<Space size="small">
Expand Down Expand Up @@ -848,6 +891,9 @@ const MjmlCodeEditor = forwardRef<MjmlCodeEditorRef, MjmlCodeEditorProps>(({
}}
/>
)}
{activeTab === 'plaintext' && (
<PlainTextEditorPanel value={plainText} onChange={onPlainTextChange} />
)}
</div>
</Splitter.Panel>
<Splitter.Panel>
Expand Down
65 changes: 65 additions & 0 deletions console/src/components/email_builder/PlainTextEditorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useLingui } from '@lingui/react/macro'
import Editor from '@monaco-editor/react'

interface PlainTextEditorPanelProps {
value: string
onChange: (value: string) => void
height?: string | number
}

/**
* The plain-text (text/plain) alternative editor, shared between MjmlCodeEditor's
* "Plain text" tab and EmailBuilder's "Plain text" view mode so both editing surfaces —
* and every language's nested editor Drawer that reuses them — get the same experience.
*/
const PlainTextEditorPanel: React.FC<PlainTextEditorPanelProps> = ({
value,
onChange,
height = '100%'
}) => {
const { t } = useLingui()

return (
<div style={{ height, position: 'relative' }}>
{!value && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 40,
pointerEvents: 'none',
zIndex: 1
}}
>
<p style={{ color: '#999', textAlign: 'center', maxWidth: 480, margin: 0 }}>
{t`Sent as the text/plain part alongside the HTML body — improves deliverability and accessibility. Supports the same templating variables as the subject. Leave blank to send HTML only.`}
</p>
</div>
)}
<Editor
height="100%"
language="plaintext"
theme="vs"
value={value}
onChange={(v) => onChange(v || '')}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
scrollbar: {
vertical: 'visible',
horizontal: 'visible'
}
}}
/>
</div>
)
}

export default PlainTextEditorPanel
27 changes: 27 additions & 0 deletions console/src/components/templates/CreateTemplateDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ export function CreateTemplateDrawer({
if (fromTemplate?.email?.mjml_source) return fromTemplate.email.mjml_source
return STARTER_TEMPLATE
})
const [plainText, setPlainText] = useState<string>(() => {
return template?.email?.text || fromTemplate?.email?.text || ''
})
const [translationsState, setTranslationsState] = useState<Record<string, TranslationEditorState>>({})

const translationLanguages = (workspace.settings.languages || []).filter(
Expand Down Expand Up @@ -371,6 +374,7 @@ export function CreateTemplateDrawer({
enabled: true,
subject: trans.email?.subject || '',
subjectPreview: trans.email?.subject_preview || '',
text: trans.email?.text || undefined,
visualEditorTree: trans.email?.visual_editor_tree
? (typeof trans.email.visual_editor_tree === 'object'
? (JSON.parse(JSON.stringify(trans.email.visual_editor_tree)) as EmailBlock)
Expand All @@ -388,6 +392,7 @@ export function CreateTemplateDrawer({
if (latest.email?.editor_mode === 'code' && latest.email?.mjml_source) {
setMjmlSource(latest.email.mjml_source)
}
setPlainText(latest.email?.text || '')
form.setFieldsValue({
name: latest.name,
id: latest.id || kebabCase(latest.name),
Expand Down Expand Up @@ -453,6 +458,7 @@ export function CreateTemplateDrawer({
if (template.email?.editor_mode === 'code' && template.email?.mjml_source) {
setMjmlSource(template.email.mjml_source)
}
setPlainText(template.email?.text || '')
form.setFieldsValue({
name: template.name,
id: template.id || kebabCase(template.name),
Expand All @@ -479,6 +485,7 @@ export function CreateTemplateDrawer({
if (fromTemplate.email?.editor_mode === 'code' && fromTemplate.email?.mjml_source) {
setMjmlSource(fromTemplate.email.mjml_source)
}
setPlainText(fromTemplate.email?.text || '')
// Append "copy" as suffix instead of "Copy of" prefix
form.setFieldsValue({
name: `${fromTemplate.name} copy`,
Expand Down Expand Up @@ -692,6 +699,7 @@ export function CreateTemplateDrawer({
values.email.editor_mode = 'visual'
values.email.visual_editor_tree = visualEditorTree
}
values.email.text = plainText

// Validate and build translations from state
if (showTranslationsTab) {
Expand All @@ -714,6 +722,9 @@ export function CreateTemplateDrawer({
subject: state.subject,
subject_preview: state.subjectPreview || ''
}
if (state.text) {
emailTranslation.text = state.text
}
if (editorMode === 'code') {
emailTranslation.editor_mode = 'code'
emailTranslation.mjml_source = state.mjmlSource || ''
Expand Down Expand Up @@ -1000,6 +1011,11 @@ export function CreateTemplateDrawer({
dirtyRef.current = true
setMjmlSource(source)
}}
plainText={plainText}
onPlainTextChange={(text) => {
dirtyRef.current = true
setPlainText(text)
}}
onCompile={async (
mjml: string,
codeTestData?: Record<string, unknown>
Expand Down Expand Up @@ -1058,6 +1074,11 @@ export function CreateTemplateDrawer({
dirtyRef.current = true
setVisualEditorTree(tree)
}}
plainText={plainText}
onPlainTextChange={(text) => {
dirtyRef.current = true
setPlainText(text)
}}
onCompile={async (
tree: EmailBlock,
builderTestData?: Record<string, unknown>
Expand Down Expand Up @@ -1134,6 +1155,11 @@ export function CreateTemplateDrawer({
// onTestDataImport={handleTestDataImport}
tree={visualEditorTree}
testData={testData}
plainText={plainText}
onPlainTextChange={(text) => {
dirtyRef.current = true
setPlainText(text)
}}
workspaceId={workspace.id}
templateName={template?.name}
/>
Expand All @@ -1158,6 +1184,7 @@ export function CreateTemplateDrawer({
}}
defaultSubject={emailSubject}
defaultSubjectPreview={emailPreview}
defaultText={plainText}
defaultVisualEditorTree={visualEditorTree}
defaultMjmlSource={mjmlSource}
testData={form.getFieldValue('test_data')}
Expand Down
Loading