diff --git a/README.md b/README.md index 88483848..b61e5246 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,14 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server* - Configurable notification sound and enable/disable toggles - Keyboard shortcuts: m/w/d/a (views), t (today), n (new event), arrows (navigate) +### Email Templates +- Reusable email templates with category organization (General, Business, Personal, Support, Follow-up) +- Dynamic placeholder variables (`{{recipientName}}`, `{{date}}`, etc.) with auto-fill from composer context +- Template picker in compose toolbar with search and category filter +- Custom placeholder prompt when inserting templates +- Template manager for creating, editing, duplicating, and deleting templates +- Settings tab for template management + ### Email Filters - Server-side email filtering with JMAP Sieve Scripts (RFC 9661) - Visual rule builder with conditions (From, To, Subject, Size, Body, etc.) and actions (Move, Forward, Mark read, Star, Discard, Reject, etc.) @@ -208,6 +216,7 @@ docker run -p 3000:3000 -e JMAP_SERVER_URL=https://mail.example.com jmap-webmail | `u` | Mark as unread | | `/` | Focus search | | `x` | Expand/collapse thread | +| `Ctrl+Shift+T` | Insert template | | `?` | Show shortcuts help | ## Screenshots diff --git a/ROADMAP.md b/ROADMAP.md index 4bf7bc71..596d1f8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,6 +162,17 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Push notification handling for SieveScript state changes - [x] i18n support (all 8 languages) +### Email Templates +- [x] Reusable email templates with local storage persistence +- [x] Category organization (General, Business, Personal, Support, Follow-up, custom) +- [x] Dynamic placeholder variables with auto-fill from composer context +- [x] Template manager modal (create, edit, duplicate, delete) +- [x] Template picker in composer toolbar with search and category filter +- [x] Custom placeholder prompt on template insertion +- [x] Settings tab for template management +- [x] Keyboard shortcut (Ctrl+Shift+T to insert template) +- [x] i18n support (all 8 languages) + ### Email Display - [x] Proper email layout without horizontal scroll or clipping - [x] Blocked image container collapsing (no empty spaces in newsletters) @@ -183,6 +194,7 @@ This document tracks the development status and planned features for JMAP Webmai - [x] Unit tests for calendar notification store (8 tests) - [x] Unit tests for calendar invitation parsing (25 tests) - [x] Unit tests for calendar participants (26 tests) +- [x] Unit tests for template utilities (48 tests) - [x] XSS attack vector testing - [x] Playwright E2E framework setup @@ -197,7 +209,6 @@ This document tracks the development status and planned features for JMAP Webmai ### Advanced Features - [ ] Free/busy queries (Principal/getAvailability) - [ ] Calendar sharing UI (JMAP Sharing RFC 9670) -- [ ] Email templates - [ ] Email encryption (PGP/GPG) - [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default) diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 933dda25..5ebc80e1 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -12,11 +12,12 @@ import { IdentitySettings } from '@/components/settings/identity-settings'; import { VacationSettings } from '@/components/settings/vacation-settings'; import { CalendarSettings } from '@/components/settings/calendar-settings'; import { FilterSettings } from '@/components/settings/filter-settings'; +import { TemplateSettings } from '@/components/settings/template-settings'; import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { useAuthStore } from '@/stores/auth-store'; import { cn } from '@/lib/utils'; -type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'advanced'; +type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'advanced'; export default function SettingsPage() { const router = useRouter(); @@ -36,6 +37,7 @@ export default function SettingsPage() { ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []), ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []), + { id: 'templates', label: t('tabs.templates') }, { id: 'advanced', label: t('tabs.advanced') }, ]; @@ -97,6 +99,7 @@ export default function SettingsPage() { {activeTab === 'vacation' && } {activeTab === 'calendar' && } {activeTab === 'filters' && } + {activeTab === 'templates' && } {activeTab === 'advanced' && } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 623717d8..8a840412 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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(null); const [selectedIdentityId, setSelectedIdentityId] = useState(null); const [subAddressTag, setSubAddressTag] = useState(''); + 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>([]); 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) => { + 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) => { if (!client || !event.target.files) return; @@ -663,8 +724,25 @@ export function EmailComposer({ {t('discard')} - {/* Right side - Attach and Send */} + {/* Right side - Template, Save as Template, Attach and Send */}
+ +
+ + {showTemplatePicker && ( + setShowTemplatePicker(false)} + onSelect={handleTemplateSelect} + /> + )} + + {showSaveAsTemplate && ( +
+
+

{t('save_as_template')}

+ 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)} + /> +
+
+ )} ); } diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index fabd287d..649cccf9 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -135,6 +135,22 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod ))} + + {/* Composer Section */} +
+

+ {t("shortcuts.sections.composer")} +

+
+ {KEYBOARD_SHORTCUTS.composer.map((shortcut) => ( + + ))} +
+
{/* Footer tip */} diff --git a/components/settings/template-settings.tsx b/components/settings/template-settings.tsx new file mode 100644 index 00000000..f4127d19 --- /dev/null +++ b/components/settings/template-settings.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState, useRef } from 'react'; +import { useTranslations } from 'next-intl'; +import { SettingsSection } from './settings-section'; +import { Button } from '@/components/ui/button'; +import { TemplateManagerModal } from '@/components/templates/template-manager-modal'; +import { useTemplateStore } from '@/stores/template-store'; +import { toast } from '@/stores/toast-store'; +import { debug } from '@/lib/debug'; +import { + FileText, + Download, + Upload, +} from 'lucide-react'; + +const MAX_IMPORT_FILE_SIZE = 1 * 1024 * 1024; + +export function TemplateSettings() { + const t = useTranslations('settings.templates'); + const tNotif = useTranslations('notifications'); + + const { templates, exportAllTemplates, importTemplates: storeImport } = useTemplateStore(); + + const [showManager, setShowManager] = useState(false); + const fileInputRef = useRef(null); + + const handleExport = () => { + const json = exportAllTemplates(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'email-templates.json'; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast.success(tNotif('templates_exported')); + }; + + const resetFileInput = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleImport = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_IMPORT_FILE_SIZE) { + toast.error(tNotif('templates_import_errors')); + resetFileInput(); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + const result = storeImport(content); + + if (result.errors.length > 0) { + toast.error(tNotif('templates_import_errors')); + } else if (result.count > 0) { + toast.success(tNotif('templates_imported', { count: result.count })); + } else { + toast.error(tNotif('templates_import_empty')); + } + + resetFileInput(); + }; + reader.onerror = () => { + debug.error('FileReader error during template import:', reader.error); + toast.error(tNotif('templates_import_errors')); + resetFileInput(); + }; + reader.readAsText(file); + }; + + return ( +
+ +
+
+ + + {t('count', { count: templates.length })} + +
+ +
+
+ + +
+ +
+ + +
+
+
+ + {showManager && ( + setShowManager(false)} + /> + )} +
+ ); +} diff --git a/components/templates/placeholder-fill-modal.tsx b/components/templates/placeholder-fill-modal.tsx new file mode 100644 index 00000000..ca4cf40c --- /dev/null +++ b/components/templates/placeholder-fill-modal.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { substitutePlaceholders, isBuiltInPlaceholder } from '@/lib/template-utils'; +import { useFocusTrap } from '@/hooks/use-focus-trap'; +import type { EmailTemplate } from '@/lib/template-types'; + +interface PlaceholderFillModalProps { + template: EmailTemplate; + placeholders: string[]; + autoFilled: Record; + onConfirm: (values: Record) => void; + onSkip: () => void; + onClose: () => void; +} + +export function PlaceholderFillModal({ + template, + placeholders, + autoFilled, + onConfirm, + onSkip, + onClose, +}: PlaceholderFillModalProps) { + const t = useTranslations('templates'); + + const [values, setValues] = useState>(() => { + const initial: Record = {}; + for (const p of placeholders) { + initial[p] = autoFilled[p] || ''; + } + return initial; + }); + + const modalRef = useFocusTrap({ + isActive: true, + onEscape: onClose, + restoreFocus: true, + }); + + const preview = useMemo(() => { + return substitutePlaceholders(template.body, values); + }, [template.body, values]); + + return ( +
+
+
+

+ {t('fill_placeholders')} +

+ +
+ +
+ {placeholders.map((p) => ( +
+ + setValues((prev) => ({ ...prev, [p]: e.target.value }))} + placeholder={t('enter_value')} + className="mt-1" + /> +
+ ))} + + {preview && ( +
+

{t('preview')}

+
+ {preview} +
+
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/components/templates/template-form.tsx b/components/templates/template-form.tsx new file mode 100644 index 00000000..1a01338a --- /dev/null +++ b/components/templates/template-form.tsx @@ -0,0 +1,295 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Star, Plus } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { validateTemplateName } from '@/lib/template-utils'; +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'; + + +interface TemplateFormProps { + template?: EmailTemplate; + initialData?: { + subject?: string; + body?: string; + to?: string[]; + cc?: string[]; + bcc?: string[]; + }; + onSave: (data: Omit) => void; + onCancel: () => void; +} + +export function TemplateForm({ template, initialData, onSave, onCancel }: TemplateFormProps) { + const t = useTranslations('templates'); + const tSettings = useTranslations('settings.templates'); + const tComposer = useTranslations('email_composer'); + + const { identities } = useAuthStore(); + const templates = useTemplateStore((s) => s.templates); + + 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 || ''); + const [toRecipients, setToRecipients] = useState( + template?.defaultRecipients?.to?.join(', ') || initialData?.to?.join(', ') || '' + ); + const [ccRecipients, setCcRecipients] = useState( + template?.defaultRecipients?.cc?.join(', ') || initialData?.cc?.join(', ') || '' + ); + const [bccRecipients, setBccRecipients] = useState( + template?.defaultRecipients?.bcc?.join(', ') || initialData?.bcc?.join(', ') || '' + ); + const [identityId, setIdentityId] = useState(template?.identityId || ''); + const [isFavorite, setIsFavorite] = useState(template?.isFavorite || false); + const [nameError, setNameError] = useState(null); + const [showPlaceholderMenu, setShowPlaceholderMenu] = useState<'subject' | 'body' | null>(null); + + const existingCategories = useMemo(() => { + const cats = new Set(templates.map((t) => t.category).filter(Boolean)); + return Array.from(cats).sort(); + }, [templates]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const error = validateTemplateName(name); + if (error) { + setNameError(error); + return; + } + + const parseRecipients = (val: string) => + val.split(',').map((s) => s.trim()).filter(Boolean); + + const to = parseRecipients(toRecipients); + const cc = parseRecipients(ccRecipients); + const bcc = parseRecipients(bccRecipients); + + onSave({ + name: name.trim(), + subject, + body, + 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 } + : undefined, + identityId: identityId || undefined, + isFavorite, + }); + }; + + const insertPlaceholder = (placeholder: string, field: 'subject' | 'body') => { + const tag = `{{${placeholder}}}`; + if (field === 'subject') { + setSubject((prev) => prev + tag); + } else { + setBody((prev) => prev + tag); + } + setShowPlaceholderMenu(null); + }; + + return ( +
+
+ + { setName(e.target.value); setNameError(null); }} + placeholder={tSettings('name_placeholder')} + className={cn('mt-1', nameError && 'border-red-500')} + autoFocus + /> + {nameError && ( +

+ {tSettings(`validation.${nameError}`)} +

+ )} +
+ +
+ + setCategory(e.target.value)} + placeholder={tSettings('category_placeholder')} + className="mt-1" + list="template-categories" + /> + {existingCategories.length > 0 && ( + + {existingCategories.map((cat) => ( + + )} +
+ +
+
+ +
+ + {showPlaceholderMenu === 'subject' && ( + insertPlaceholder(p, 'subject')} + onClose={() => setShowPlaceholderMenu(null)} + /> + )} +
+
+ setSubject(e.target.value)} + placeholder={tSettings('subject_placeholder')} + className="mt-1" + /> +
+ +
+
+ +
+ + {showPlaceholderMenu === 'body' && ( + insertPlaceholder(p, 'body')} + onClose={() => setShowPlaceholderMenu(null)} + /> + )} +
+
+