Skip to content
Open
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
125 changes: 85 additions & 40 deletions components/email/email-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1000,22 +1019,80 @@ export function EmailComposer({
}
};

const handleTemplateSelect = useCallback((template: EmailTemplate, filledValues: Record<string, string>) => {
const handleImageUpload = useCallback(async (
file: File,
): Promise<{ src: string; cid: string } | null> => {
if (!client) return null;
try {
const readAsDataUrl = new Promise<string | null>((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<string, string>) => {
const filledSubject = Object.keys(filledValues).length > 0
? substitutePlaceholders(template.subject, filledValues)
: template.subject;
const filledBody = Object.keys(filledValues).length > 0
? substitutePlaceholders(template.body, filledValues)
: template.body;

// In plain text mode, use template body as-is; otherwise convert to HTML
const bodyContent = plainTextMode
? filledBody
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
// 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
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`);

// 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(`<body>${bodyContent}</body>`, '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));
}
Expand All @@ -1028,15 +1105,15 @@ export function EmailComposer({
setShowBcc(true);
}
} else {
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
setBody((prev) => finalBody + (plainTextMode ? '\n' : '') + prev);
}

if (template.identityId) {
setSelectedIdentityId(template.identityId);
}

setShowTemplatePicker(false);
}, [mode, plainTextMode]);
}, [mode, plainTextMode, client, handleImageUpload]);

useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -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<string | null>((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<HTMLInputElement>) => {
if (!event.target.files) return;
Expand Down
65 changes: 55 additions & 10 deletions components/templates/template-form.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<Editor | null>(null);
const [toRecipients, setToRecipients] = useState(
template?.defaultRecipients?.to?.join(', ') || initialData?.to?.join(', ') || ''
);
Expand Down Expand Up @@ -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 }
Expand All @@ -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<InlineImageUpload | null> => {
if (file.size > MAX_TEMPLATE_IMAGE_BYTES) {
toast.error(tSettings('image_too_large'));
return null;
}
const dataUrl = await new Promise<string | null>((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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
Expand Down Expand Up @@ -183,13 +225,16 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
)}
</div>
</div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={tSettings('body_placeholder')}
rows={6}
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
/>
<div className="mt-1">
<RichTextEditor
content={body}
onChange={setBody}
onImageUpload={handleImageUpload}
onEditorReady={(ed) => { editorRef.current = ed; }}
placeholder={tSettings('body_placeholder')}
className="min-h-[180px]"
/>
</div>
</div>

<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
Expand Down
2 changes: 2 additions & 0 deletions lib/template-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export interface EmailTemplate {
name: string;
subject: string;
body: string;
/** True when `body` is HTML (rich editor). Absent/false = legacy plain text. */
bodyIsHtml?: boolean;
category: string;
defaultRecipients?: {
to?: string[];
Expand Down
3 changes: 2 additions & 1 deletion locales/cs/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,7 +1820,8 @@
"validation": {
"empty": "Název šablony je vyžadován",
"too_long": "Název šablony může mít maximálně 200 znaků"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/da/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1823,7 +1823,8 @@
"validation": {
"empty": "Skabelonnavn er påkrævet",
"too_long": "Skabelonnavn skal være 200 tegn eller mindre"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,7 +1820,8 @@
"validation": {
"empty": "Vorlagenname ist erforderlich",
"too_long": "Vorlagenname darf maximal 200 Zeichen haben"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1827,7 +1827,8 @@
"validation": {
"empty": "Template name is required",
"too_long": "Template name must be 200 characters or less"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,7 +1820,8 @@
"validation": {
"empty": "El nombre de la plantilla es obligatorio",
"too_long": "El nombre de la plantilla no debe superar los 200 caracteres"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,7 +1820,8 @@
"validation": {
"empty": "Le nom du modèle est requis",
"too_long": "Le nom du modèle ne doit pas dépasser 200 caractères"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/hu/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1823,7 +1823,8 @@
"validation": {
"empty": "A sablon neve kötelező",
"too_long": "A sablon neve legfeljebb 200 karakter lehet"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/it/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,7 +1820,8 @@
"validation": {
"empty": "Il nome del modello è obbligatorio",
"too_long": "Il nome del modello non deve superare i 200 caratteri"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,7 +1820,8 @@
"validation": {
"empty": "テンプレート名は必須です",
"too_long": "テンプレート名は200文字以内にしてください"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
3 changes: 2 additions & 1 deletion locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,7 +1820,8 @@
"validation": {
"empty": "템플릿 이름이 필요해요",
"too_long": "템플릿 이름은 200자 이하여야 해요"
}
},
"image_too_large": "Image too large (max 1 MB)"
},
"files": {
"display": {
Expand Down
Loading