Skip to content

Commit 9707613

Browse files
authored
feat: add email template management functionality (#1573)
1 parent f664c53 commit 9707613

File tree

13 files changed

+1748
-2
lines changed

13 files changed

+1748
-2
lines changed

apps/mail/components/create/email-composer.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import pluralize from 'pluralize';
4747
import { toast } from 'sonner';
4848
import { z } from 'zod';
4949
const shortcodeRegex = /:([a-zA-Z0-9_+-]+):/g;
50+
import { TemplateButton } from './template-button';
5051

5152
type ThreadContent = {
5253
from: string;
@@ -1322,6 +1323,15 @@ export function EmailComposer({
13221323
<Plus className="h-3 w-3 fill-[#9A9A9A]" />
13231324
<span className="hidden px-0.5 text-sm md:block">Add</span>
13241325
</Button>
1326+
<TemplateButton
1327+
editor={editor}
1328+
subject={subjectInput}
1329+
setSubject={(value) => setValue('subject', value)}
1330+
to={toEmails}
1331+
cc={ccEmails ?? []}
1332+
bcc={bccEmails ?? []}
1333+
setRecipients={(field, val) => setValue(field, val)}
1334+
/>
13251335
<Input
13261336
type="file"
13271337
id="attachment-input"
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { useTemplates } from '@/hooks/use-templates';
2+
import { useTRPC } from '@/providers/query-provider';
3+
import { Editor } from '@tiptap/react';
4+
import { Button } from '@/components/ui/button';
5+
import {
6+
DropdownMenu,
7+
DropdownMenuContent,
8+
DropdownMenuItem,
9+
DropdownMenuSub,
10+
DropdownMenuSubContent,
11+
DropdownMenuSubTrigger,
12+
DropdownMenuTrigger,
13+
} from '@/components/ui/dropdown-menu';
14+
import { toast } from 'sonner';
15+
import { useMutation, useQueryClient } from '@tanstack/react-query';
16+
import { FileText, Save, Trash2 } from 'lucide-react';
17+
import React, { useState, useMemo, useDeferredValue, useCallback, startTransition } from 'react';
18+
import {
19+
Dialog,
20+
DialogContent,
21+
DialogFooter,
22+
DialogHeader,
23+
DialogTitle,
24+
} from '@/components/ui/dialog';
25+
import { Input } from '@/components/ui/input';
26+
import { TRPCClientError } from '@trpc/client';
27+
28+
type RecipientField = 'to' | 'cc' | 'bcc';
29+
30+
type Template = {
31+
id: string;
32+
name: string;
33+
subject?: string | null;
34+
body?: string | null;
35+
to?: string[] | null;
36+
cc?: string[] | null;
37+
bcc?: string[] | null;
38+
};
39+
40+
type TemplatesQueryData = {
41+
templates: Template[];
42+
} | undefined;
43+
44+
interface TemplateButtonProps {
45+
editor: Editor | null;
46+
subject: string;
47+
setSubject: (value: string) => void;
48+
to: string[];
49+
cc: string[];
50+
bcc: string[];
51+
setRecipients: (field: RecipientField, value: string[]) => void;
52+
}
53+
54+
const TemplateButtonComponent: React.FC<TemplateButtonProps> = ({
55+
editor,
56+
subject,
57+
setSubject,
58+
to,
59+
cc,
60+
bcc,
61+
setRecipients,
62+
}) => {
63+
const trpc = useTRPC();
64+
const queryClient = useQueryClient();
65+
const { data } = useTemplates();
66+
67+
const templates: Template[] = data?.templates ?? [];
68+
69+
const [menuOpen, setMenuOpen] = useState(false);
70+
const [isSaving, setIsSaving] = useState(false);
71+
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
72+
const [templateName, setTemplateName] = useState('');
73+
const [search, setSearch] = useState('');
74+
75+
const deferredSearch = useDeferredValue(search);
76+
77+
const filteredTemplates = useMemo(() => {
78+
if (!deferredSearch.trim()) return templates;
79+
return templates.filter((t) =>
80+
t.name.toLowerCase().includes(deferredSearch.toLowerCase()),
81+
);
82+
}, [deferredSearch, templates]);
83+
84+
const { mutateAsync: createTemplate } = useMutation(trpc.templates.create.mutationOptions());
85+
const { mutateAsync: deleteTemplateMutation } = useMutation(
86+
trpc.templates.delete.mutationOptions(),
87+
);
88+
89+
const handleSaveTemplate = async () => {
90+
if (!editor) return;
91+
if (!templateName.trim()) {
92+
toast.error('Please provide a name');
93+
return;
94+
}
95+
96+
setIsSaving(true);
97+
try {
98+
const newTemplate = await createTemplate({
99+
name: templateName.trim(),
100+
subject: subject || '',
101+
body: editor.getHTML(),
102+
to: to.length ? to : undefined,
103+
cc: cc.length ? cc : undefined,
104+
bcc: bcc.length ? bcc : undefined,
105+
});
106+
queryClient.setQueryData(trpc.templates.list.queryKey(), (old: TemplatesQueryData) => {
107+
if (!old?.templates) return old;
108+
return {
109+
templates: [newTemplate.template, ...old.templates],
110+
};
111+
});
112+
toast.success('Template saved');
113+
setTemplateName('');
114+
setSaveDialogOpen(false);
115+
} catch (error) {
116+
if (error instanceof TRPCClientError) {
117+
toast.error(error.message);
118+
} else {
119+
toast.error('Failed to save template');
120+
}
121+
} finally {
122+
setIsSaving(false);
123+
}
124+
};
125+
126+
const handleApplyTemplate = useCallback((template: Template) => {
127+
if (!editor) return;
128+
startTransition(() => {
129+
if (template.subject) setSubject(template.subject);
130+
if (template.body) editor.commands.setContent(template.body, false);
131+
if (template.to) setRecipients('to', template.to);
132+
if (template.cc) setRecipients('cc', template.cc);
133+
if (template.bcc) setRecipients('bcc', template.bcc);
134+
});
135+
}, [editor, setSubject, setRecipients]);
136+
137+
const handleDeleteTemplate = useCallback(
138+
async (templateId: string) => {
139+
try {
140+
await deleteTemplateMutation({ id: templateId });
141+
await queryClient.invalidateQueries({
142+
queryKey: trpc.templates.list.queryKey(),
143+
});
144+
toast.success('Template deleted');
145+
} catch (err) {
146+
if (err instanceof TRPCClientError) {
147+
toast.error(err.message);
148+
} else {
149+
toast.error('Failed to delete template');
150+
}
151+
}
152+
},
153+
[deleteTemplateMutation, queryClient, trpc.templates.list],
154+
);
155+
156+
return (
157+
<>
158+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
159+
<DropdownMenuTrigger asChild>
160+
<Button size={'xs'} variant={'secondary'} disabled={isSaving}>
161+
Templates
162+
</Button>
163+
</DropdownMenuTrigger>
164+
<DropdownMenuContent className="z-[99999] w-60" align="start" sideOffset={6}>
165+
<DropdownMenuItem
166+
onSelect={() => {
167+
setMenuOpen(false);
168+
setSaveDialogOpen(true);
169+
}}
170+
disabled={isSaving}
171+
>
172+
<Save className="mr-2 h-3.5 w-3.5" /> Save current as template
173+
</DropdownMenuItem>
174+
{templates.length > 0 ? (
175+
<DropdownMenuSub>
176+
<DropdownMenuSubTrigger>
177+
<FileText className="mr-2 h-3.5 w-3.5" /> Use template
178+
</DropdownMenuSubTrigger>
179+
<DropdownMenuSubContent className="z-[99999] w-60">
180+
<div className="p-2 border-b border-border sticky top-0 bg-background">
181+
<Input
182+
placeholder="Search..."
183+
value={search}
184+
onChange={(e) => setSearch(e.target.value)}
185+
className="h-8 text-sm"
186+
autoFocus
187+
/>
188+
</div>
189+
<div className="max-h-30 overflow-y-auto">
190+
{filteredTemplates.map((t: Template) => (
191+
<DropdownMenuItem
192+
key={t.id}
193+
className="flex items-center justify-between gap-2"
194+
onClick={() => handleApplyTemplate(t)}
195+
>
196+
<span className="flex-1 truncate text-left">{t.name}</span>
197+
<button
198+
className="p-0.5 text-muted-foreground hover:text-destructive"
199+
onClick={(e) => {
200+
e.stopPropagation();
201+
setMenuOpen(false);
202+
toast(`Delete template "${t.name}"?`, {
203+
duration: 10000,
204+
action: {
205+
label: 'Delete',
206+
onClick: () => handleDeleteTemplate(t.id),
207+
},
208+
className: 'pointer-events-auto',
209+
style: {
210+
pointerEvents: 'auto',
211+
},
212+
});
213+
}}
214+
>
215+
<Trash2 className="h-3.5 w-3.5" />
216+
</button>
217+
</DropdownMenuItem>
218+
))}
219+
{filteredTemplates.length === 0 && (
220+
<div className="p-2 text-xs text-muted-foreground">No templates</div>
221+
)}
222+
</div>
223+
</DropdownMenuSubContent>
224+
</DropdownMenuSub>
225+
) : null}
226+
</DropdownMenuContent>
227+
</DropdownMenu>
228+
229+
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
230+
<DialogContent showOverlay>
231+
<DialogHeader>
232+
<DialogTitle>Save as Template</DialogTitle>
233+
</DialogHeader>
234+
<div className="py-4 space-y-2">
235+
<Input
236+
placeholder="Template name"
237+
value={templateName}
238+
onChange={(e) => setTemplateName(e.target.value)}
239+
autoFocus
240+
/>
241+
</div>
242+
<DialogFooter className="flex justify-end gap-2">
243+
<Button
244+
variant="ghost"
245+
size="sm"
246+
onClick={() => setSaveDialogOpen(false)}
247+
>
248+
Cancel
249+
</Button>
250+
<Button size="sm" onClick={handleSaveTemplate} disabled={isSaving}>
251+
Save
252+
</Button>
253+
</DialogFooter>
254+
</DialogContent>
255+
</Dialog>
256+
</>
257+
);
258+
};
259+
260+
export const TemplateButton = React.memo(TemplateButtonComponent);

apps/mail/components/ui/toast.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const Toaster = () => {
2424
description: '!text-black dark:!text-white text-xs',
2525
toast: 'p-1',
2626
actionButton:
27-
'inline-flex h-7 items-center justify-center gap-1 overflow-hidden !rounded-md border px-1.5 dark:border-none !bg-[#E0E0E0] dark:!bg-[#424242]',
27+
'inline-flex h-7 items-center justify-center gap-1 overflow-hidden !rounded-md border px-1.5 dark:border-none !bg-[#E0E0E0] dark:!bg-[#424242] pointer-events-auto cursor-pointer',
2828
cancelButton:
2929
'inline-flex h-7 items-center justify-center gap-1 overflow-hidden !rounded-md border px-1.5 dark:border-none !bg-[#E0E0E0] dark:!bg-[#424242]',
3030
closeButton:

apps/mail/hooks/use-templates.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { useTRPC } from '@/providers/query-provider';
2+
import { useQuery } from '@tanstack/react-query';
3+
4+
export const useTemplates = () => {
5+
const trpc = useTRPC();
6+
return useQuery(
7+
trpc.templates.list.queryOptions(void 0, {
8+
staleTime: 1000 * 60 * 5,
9+
}),
10+
);
11+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
CREATE TABLE "mail0_email_template" (
2+
"id" text PRIMARY KEY NOT NULL,
3+
"user_id" text NOT NULL,
4+
"name" text NOT NULL,
5+
"subject" text,
6+
"body" text,
7+
"to" jsonb,
8+
"cc" jsonb,
9+
"bcc" jsonb,
10+
"created_at" timestamp DEFAULT now() NOT NULL,
11+
"updated_at" timestamp DEFAULT now() NOT NULL
12+
);
13+
--> statement-breakpoint
14+
ALTER TABLE "mail0_email_template" ADD CONSTRAINT "mail0_email_template_user_id_mail0_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."mail0_user"("id") ON DELETE cascade ON UPDATE no action;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CREATE INDEX "idx_mail0_email_template_user_id" ON "mail0_email_template" USING btree ("user_id");--> statement-breakpoint
2+
ALTER TABLE "mail0_email_template" ADD CONSTRAINT "mail0_email_template_user_id_name_unique" UNIQUE("user_id","name");

0 commit comments

Comments
 (0)