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:
2026-07-24 07:12:34 +00:00
co-authored by Claude Opus 4.8
parent 3b13b98532
commit ead8f53951
5 changed files with 275 additions and 3 deletions
@@ -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 };
};