diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9d612259..8c009060 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -65,6 +65,25 @@ function htmlToPlainText(html: string): string { return htmlToPlainTextShared(html, { paragraphSpacing: true }); } +// Decode a base64/percent-encoded data: image URI into a File so it can be +// uploaded as a real inline attachment (template images are stored as data: +// URIs; without this they would ship as raw data: URIs and get stripped by +// Gmail/Outlook). +function dataUriToFile(uri: string, name: string): File | null { + const m = uri.match(/^data:([^;,]+)(;base64)?,([\s\S]*)$/); + if (!m) return null; + const mime = m[1] || 'application/octet-stream'; + try { + const bytes = m[2] + ? Uint8Array.from(atob(m[3]), (c) => c.charCodeAt(0)) + : new TextEncoder().encode(decodeURIComponent(m[3])); + const ext = (mime.split('/')[1] || 'png').split('+')[0]; + return new File([bytes], name.includes('.') ? name : `${name}.${ext}`, { type: mime }); + } catch { + return null; + } +} + /** * Build a floating drag image showing the recipient's address, mirroring the * email-list drag preview so dragging a chip feels consistent. The element is @@ -1000,7 +1019,40 @@ export function EmailComposer({ } }; - const handleTemplateSelect = useCallback((template: EmailTemplate, filledValues: Record) => { + const handleImageUpload = useCallback(async ( + file: File, + ): Promise<{ src: string; cid: string } | null> => { + if (!client) return null; + try { + const readAsDataUrl = new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => resolve((e.target?.result as string) ?? null); + reader.onerror = () => resolve(null); + reader.readAsDataURL(file); + }); + const [{ blobId }, dataUrl] = await Promise.all([ + client.uploadBlob(file), + readAsDataUrl, + ]); + if (!dataUrl) throw new Error('Failed to read image as data URL'); + const cid = `${generateUUID()}@webmail`; + inlineImagesRef.current.push({ + cid, + blobId, + type: file.type || 'application/octet-stream', + name: file.name, + size: file.size, + dataUrl, + }); + return { src: dataUrl, cid }; + } catch (error) { + debug.error(`Failed to upload inline image ${file.name}:`, error); + toast.error(t('upload_failed', { filename: file.name })); + return null; + } + }, [client, t]); + + const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record) => { const filledSubject = Object.keys(filledValues).length > 0 ? substitutePlaceholders(template.subject, filledValues) : template.subject; @@ -1008,14 +1060,39 @@ export function EmailComposer({ ? substitutePlaceholders(template.body, filledValues) : template.body; - // In plain text mode, use template body as-is; otherwise convert to HTML - const bodyContent = plainTextMode - ? filledBody - : `

${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

`; + // HTML templates (rich editor) carry markup; legacy templates are plain text. + // Plain-text compose mode flattens HTML; rich mode escapes/wraps plain bodies. + const bodyContent = template.bodyIsHtml + ? (plainTextMode ? htmlToPlainText(filledBody) : sanitizeEmailHtml(filledBody)) + : (plainTextMode + ? filledBody + : `

${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

`); + + // Register the template's inline data: images as real blob+cid attachments + // (via the same path as dropped images) and tag them data-cid, so the send + // path rewrites them to cid: refs. Otherwise they ship as raw data: URIs + // and are stripped by Gmail/Outlook. + let finalBody = bodyContent; + if (template.bodyIsHtml && !plainTextMode && client && bodyContent.includes('data:image/')) { + const doc = new DOMParser().parseFromString(`${bodyContent}`, 'text/html'); + let changed = false; + for (const img of Array.from(doc.querySelectorAll('img'))) { + const src = img.getAttribute('src') || ''; + if (!src.startsWith('data:image/') || img.getAttribute('data-cid')) continue; + const file = dataUriToFile(src, img.getAttribute('alt') || 'image'); + if (!file) continue; + const uploaded = await handleImageUpload(file); + if (uploaded?.cid) { + img.setAttribute('data-cid', uploaded.cid); + changed = true; + } + } + if (changed) finalBody = doc.body.innerHTML; + } if (mode === 'compose') { setSubject(filledSubject); - setBody(bodyContent); + setBody(finalBody); if (template.defaultRecipients?.to?.length) { setTo(template.defaultRecipients.to.map(parseRecipient)); } @@ -1028,7 +1105,7 @@ export function EmailComposer({ setShowBcc(true); } } else { - setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev); + setBody((prev) => finalBody + (plainTextMode ? '\n' : '') + prev); } if (template.identityId) { @@ -1036,7 +1113,7 @@ export function EmailComposer({ } setShowTemplatePicker(false); - }, [mode, plainTextMode]); + }, [mode, plainTextMode, client, handleImageUpload]); useEffect(() => { const handleTemplateKey = (e: KeyboardEvent) => { @@ -1119,38 +1196,6 @@ export function EmailComposer({ } }, [client, t]); - const handleImageUpload = useCallback(async ( - file: File, - ): Promise<{ src: string; cid: string } | null> => { - if (!client) return null; - try { - const readAsDataUrl = new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = (e) => resolve((e.target?.result as string) ?? null); - reader.onerror = () => resolve(null); - reader.readAsDataURL(file); - }); - const [{ blobId }, dataUrl] = await Promise.all([ - client.uploadBlob(file), - readAsDataUrl, - ]); - if (!dataUrl) throw new Error('Failed to read image as data URL'); - const cid = `${generateUUID()}@webmail`; - inlineImagesRef.current.push({ - cid, - blobId, - type: file.type || 'application/octet-stream', - name: file.name, - size: file.size, - dataUrl, - }); - return { src: dataUrl, cid }; - } catch (error) { - debug.error(`Failed to upload inline image ${file.name}:`, error); - toast.error(t('upload_failed', { filename: file.name })); - return null; - } - }, [client, t]); const handleFileSelect = async (event: React.ChangeEvent) => { if (!event.target.files) return; diff --git a/components/templates/template-form.tsx b/components/templates/template-form.tsx index 355331a5..770c98ee 100644 --- a/components/templates/template-form.tsx +++ b/components/templates/template-form.tsx @@ -1,16 +1,25 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef, useCallback } from 'react'; import { useTranslations } from 'next-intl'; +import type { Editor } from '@tiptap/react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { RichTextEditor, type InlineImageUpload } from '@/components/email/rich-text-editor'; import { Star, Plus } from 'lucide-react'; import { cn } from '@/lib/utils'; import { validateTemplateName } from '@/lib/template-utils'; +import { sanitizeEmailHtml, plainTextToSafeHtml } from '@/lib/email-sanitization'; import { BUILT_IN_PLACEHOLDERS } from '@/lib/template-types'; import type { EmailTemplate } from '@/lib/template-types'; import { useTemplateStore } from '@/stores/template-store'; import { useAuthStore } from '@/stores/auth-store'; +import { toast } from '@/stores/toast-store'; + +// Inline template images are stored as base64 data URIs inside the template +// (localStorage-backed). Cap each image so a couple of logos don't blow the +// ~5 MB localStorage budget. base64 inflates bytes ~33%. +const MAX_TEMPLATE_IMAGE_BYTES = 1024 * 1024; // 1 MB interface TemplateFormProps { @@ -37,7 +46,14 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa const [name, setName] = useState(template?.name || ''); const [category, setCategory] = useState(template?.category || ''); const [subject, setSubject] = useState(template?.subject || initialData?.subject || ''); - const [body, setBody] = useState(template?.body || initialData?.body || ''); + // Existing HTML templates load as-is; legacy plain-text bodies are converted + // to safe HTML so they render in the rich editor. initialData (e.g. "save as + // template" from the composer) is already HTML. + const initialBodyHtml = template + ? (template.bodyIsHtml ? template.body : plainTextToSafeHtml(template.body || '')) + : (initialData?.body || ''); + const [body, setBody] = useState(initialBodyHtml); + const editorRef = useRef(null); const [toRecipients, setToRecipients] = useState( template?.defaultRecipients?.to?.join(', ') || initialData?.to?.join(', ') || '' ); @@ -75,7 +91,8 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa onSave({ name: name.trim(), subject, - body, + body: sanitizeEmailHtml(body), + bodyIsHtml: true, category: category.trim(), defaultRecipients: to.length || cc.length || bcc.length ? { to: to.length ? to : undefined, cc: cc.length ? cc : undefined, bcc: bcc.length ? bcc : undefined } @@ -89,12 +106,37 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa const tag = `{{${placeholder}}}`; if (field === 'subject') { setSubject((prev) => prev + tag); + } else if (editorRef.current) { + editorRef.current.chain().focus().insertContent(tag).run(); } else { setBody((prev) => prev + tag); } setShowPlaceholderMenu(null); }; + const handleImageUpload = useCallback( + async (file: File): Promise => { + if (file.size > MAX_TEMPLATE_IMAGE_BYTES) { + toast.error(tSettings('image_too_large')); + return null; + } + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => resolve((e.target?.result as string) ?? null); + reader.onerror = () => resolve(null); + reader.readAsDataURL(file); + }); + if (!dataUrl) { + toast.error(tComposer('upload_failed', { filename: file.name })); + return null; + } + // No cid: templates have no send context. The data URI is the image; + // the compose/send path handles inlining when the template is used. + return { src: dataUrl }; + }, + [tSettings, tComposer], + ); + return (
@@ -183,13 +225,16 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa )}
-