email: compose + reply via Gmail SMTP (app password), with recipient autocomplete
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Loader2, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
export type ComposeDraft = { to?: string; cc?: string; subject?: string; body?: string; inReplyTo?: string };
|
||||
|
||||
// Shared open/close handle. Set a draft to open the composer; null closes it.
|
||||
export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE', null);
|
||||
|
||||
type Contact = { address: string; name: string };
|
||||
|
||||
// A recipient field with contact autocomplete on the last comma-separated segment.
|
||||
const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean }) => {
|
||||
const client = useClient();
|
||||
const [suggestions, setSuggestions] = useState<Contact[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const seg = value.split(',').pop()?.trim() ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (seg.length < 1) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
client.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {});
|
||||
}, 180);
|
||||
return () => clearTimeout(t);
|
||||
}, [seg]);
|
||||
|
||||
const pick = (address: string) => {
|
||||
const segs = value.split(',');
|
||||
segs[segs.length - 1] = ` ${address}`;
|
||||
onChange(segs.join(',').replace(/^\s+/, '') + ', ');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
onChange(ev.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent px-3 py-2 text-sm outline-none placeholder:opacity-40"
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-full z-10 mt-0.5 max-h-56 overflow-y-auto rounded-md border bg-background shadow-lg">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.address}
|
||||
onMouseDown={(ev) => {
|
||||
ev.preventDefault();
|
||||
pick(s.address);
|
||||
}}
|
||||
className="flex w-full flex-col items-start px-3 py-1.5 text-left text-sm hover:bg-accent cursor-pointer"
|
||||
>
|
||||
{s.name && <span className="font-medium">{s.name}</span>}
|
||||
<span className={s.name ? 'text-xs opacity-60' : ''}>{s.address}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComposeModal = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useComposer();
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const seeded = useRef<ComposeDraft | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft && draft !== seeded.current) {
|
||||
seeded.current = draft;
|
||||
setTo(draft.to ?? '');
|
||||
setCc(draft.cc ?? '');
|
||||
setShowCc(!!draft.cc);
|
||||
setSubject(draft.subject ?? '');
|
||||
setBody(draft.body ?? '');
|
||||
}
|
||||
if (!draft) seeded.current = null;
|
||||
}, [draft]);
|
||||
|
||||
const close = () => setDraft(null);
|
||||
|
||||
const send = async () => {
|
||||
if (!to.trim()) {
|
||||
toast.error('Add at least one recipient');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
await client.post('/email/send', { to, cc: cc || undefined, subject, body, inReplyTo: draft?.inReplyTo });
|
||||
toast.success('Email sent');
|
||||
// Gmail files the sent copy in Sent, so refresh the list to surface it.
|
||||
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
|
||||
close();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to send');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!draft) return null;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && close()}>
|
||||
<DialogContent className="flex h-[70vh] max-w-2xl flex-col gap-0 overflow-hidden p-0">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<DialogTitle className="text-sm font-semibold">{/^re:/i.test(subject) ? 'Reply' : 'New message'}</DialogTitle>
|
||||
<button onClick={close} className="opacity-50 hover:opacity-100 cursor-pointer">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center border-b">
|
||||
<span className="pl-4 text-xs opacity-50">To</span>
|
||||
<div className="flex-1">
|
||||
<RecipientInput value={to} onChange={setTo} placeholder="Recipients" autoFocus />
|
||||
</div>
|
||||
{!showCc && (
|
||||
<button onClick={() => setShowCc(true)} className="px-3 text-xs opacity-50 hover:opacity-100 cursor-pointer">
|
||||
Cc
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showCc && (
|
||||
<div className="flex items-center border-b">
|
||||
<span className="pl-4 text-xs opacity-50">Cc</span>
|
||||
<div className="flex-1">
|
||||
<RecipientInput value={cc} onChange={setCc} placeholder="Cc recipients" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
value={subject}
|
||||
onChange={(ev) => setSubject(ev.target.value)}
|
||||
placeholder="Subject"
|
||||
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
|
||||
/>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(ev) => setBody(ev.target.value)}
|
||||
placeholder="Write your message…"
|
||||
className="flex-1 resize-none bg-transparent px-4 py-3 text-sm outline-none placeholder:opacity-40"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t px-4 py-2.5">
|
||||
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={sending}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-4 py-1.5 text-sm font-medium text-white hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
|
||||
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
|
||||
// is a follow-up: store the Message-Id header on ingest.)
|
||||
export const replyDraft = (m: { from: string; subject: string; date: string; text?: string; snippet?: string }): ComposeDraft => {
|
||||
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
|
||||
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
|
||||
const original = (m.text || m.snippet || '').trim();
|
||||
const quoted = original
|
||||
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original.split('\n').map((l) => `> ${l}`).join('\n')}`
|
||||
: '';
|
||||
return { to: addr, subject, body: quoted };
|
||||
};
|
||||
Reference in New Issue
Block a user