feat: add email templates with placeholder variables and composer integration

- Reusable email templates with local storage persistence
- Dynamic placeholder variables ({{recipientName}}, {{date}}, etc.) with auto-fill
- Template manager modal with category filtering and search
- Template picker integrated in composer toolbar (Ctrl+Shift+T)
- Settings tab for template management
- 48 unit tests for template utilities
- i18n support for all 8 languages
This commit is contained in:
Matthieu MALVACHE
2026-02-17 01:35:26 +01:00
committed by Matthieu MALVACHE
parent b65b2f26df
commit 2636a88820
23 changed files with 2422 additions and 36 deletions
+115 -2
View File
@@ -1,15 +1,21 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders } from "@/lib/template-utils";
import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
interface EmailComposerProps {
onSend?: (data: {
@@ -107,9 +113,18 @@ export function EmailComposer({
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>('');
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
onEscape: () => setShowSaveAsTemplate(false),
restoreFocus: true,
});
const { client, identities, primaryIdentity } = useAuthStore();
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addTemplate = useTemplateStore((s) => s.addTemplate);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -174,6 +189,52 @@ export function EmailComposer({
}
};
const handleTemplateSelect = useCallback((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;
if (mode === 'compose') {
setSubject(filledSubject);
setBody(filledBody);
if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.join(', '));
}
if (template.defaultRecipients?.cc?.length) {
setCc(template.defaultRecipients.cc.join(', '));
setShowCc(true);
}
if (template.defaultRecipients?.bcc?.length) {
setBcc(template.defaultRecipients.bcc.join(', '));
setShowBcc(true);
}
} else {
setBody((prev) => filledBody + prev);
}
if (template.identityId) {
setSelectedIdentityId(template.identityId);
}
setShowTemplatePicker(false);
}, [mode]);
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
setShowTemplatePicker(true);
}
};
window.addEventListener('keydown', handleTemplateKey);
return () => window.removeEventListener('keydown', handleTemplateKey);
}, []);
// Handle file selection
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!client || !event.target.files) return;
@@ -663,8 +724,25 @@ export function EmailComposer({
{t('discard')}
</button>
{/* Right side - Attach and Send */}
{/* Right side - Template, Save as Template, Attach and Send */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowTemplatePicker(true)}
title={t('use_template')}
>
<FileText className="w-4 h-4 mr-2" />
{t('use_template')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
title={t('save_as_template')}
>
<BookmarkPlus className="w-4 h-4" />
</Button>
<input
ref={fileInputRef}
type="file"
@@ -688,6 +766,41 @@ export function EmailComposer({
</div>
</div>
</div>
{showTemplatePicker && (
<TemplatePicker
isOpen={showTemplatePicker}
onClose={() => setShowTemplatePicker(false)}
onSelect={handleTemplateSelect}
/>
)}
{showSaveAsTemplate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={saveTemplateModalRef}
role="dialog"
aria-modal="true"
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-lg p-6 animate-in zoom-in-95 duration-200"
>
<h3 className="text-lg font-semibold text-foreground mb-4">{t('save_as_template')}</h3>
<TemplateForm
initialData={{
subject,
body,
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
}}
onSave={(data) => {
addTemplate(data);
setShowSaveAsTemplate(false);
}}
onCancel={() => setShowSaveAsTemplate(false)}
/>
</div>
</div>
)}
</div>
);
}