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 };
};
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router';
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Search, Send, ShieldAlert, Trash2, X } from 'lucide-react';
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Search, Send, ShieldAlert, SquarePen, Trash2, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
import type { EmailSummary } from 'types';
import { useComposer } from './Compose';
type EmailAccountRow = {
id: number;
@@ -38,6 +39,7 @@ const formatDate = (iso: string) => {
export const EmailList = () => {
const client = useClient();
const queryClient = useQueryClient();
const [, openCompose] = useComposer();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
const [page, setPage] = useState(1);
@@ -246,6 +248,13 @@ export const EmailList = () => {
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : null}
<button
onClick={() => openCompose({})}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Compose"
>
<SquarePen className="h-3.5 w-3.5" />
</button>
{totalPages > 1 && (
<div className="ml-auto flex items-center gap-2">
<button
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Mail } from 'lucide-react';
import { Mail, Reply } from 'lucide-react';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
import type { EmailMessage } from 'types';
import { useComposer, replyDraft } from './Compose';
type OpenAttachment = {
filePath: string;
@@ -44,6 +45,7 @@ const HtmlBody = ({ html }: { html: string }) => {
export const EmailReader = () => {
const client = useClient();
const queryClient = useQueryClient();
const [, openCompose] = useComposer();
const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(null);
@@ -89,7 +91,16 @@ export const EmailReader = () => {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex flex-col gap-1 border-b px-4 py-3">
<h2 className="text-lg font-semibold">{message.subject}</h2>
<div className="flex items-start justify-between gap-3">
<h2 className="text-lg font-semibold">{message.subject}</h2>
<button
onClick={() => openCompose(replyDraft(message))}
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
title="Reply"
>
<Reply className="h-3.5 w-3.5" /> Reply
</button>
</div>
<div className="flex flex-col gap-0.5 text-sm opacity-70">
<div>
<span className="font-medium">From:</span> {message.from}
@@ -8,6 +8,7 @@ import { useGlobal } from 'hooks/useGlobal';
import { defaultLayout } from './defaultLayout';
import { EmailList } from './EmailList';
import { EmailReader } from './EmailReader';
import { ComposeModal } from './Compose';
const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite email database available via the "email_db" tool — use it for all email queries (search, count, stats, aggregations, deletions) unless the user explicitly asks you to use Gmail. Do not use the Gmail integration for questions about existing emails.`;
@@ -53,6 +54,7 @@ export const EmailScreen = () => {
mobilePanelId={mobilePanelId}
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/>
<ComposeModal />
</div>
);
};
+56
View File
@@ -1,8 +1,11 @@
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import nodemailer from 'nodemailer';
import type { EmailMessage } from 'types';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { DATA_PATH } from '@@/data-path';
import { getEmailAccounts } from 'officerdb';
import { openEmailDb, rowToSummary, getSyncMeta, searchEmails } from './email-db';
import { accountsRouter } from './accounts';
@@ -10,6 +13,59 @@ export const emailRouter = createRouter();
emailRouter.route('/accounts', accountsRouter);
// ── Sending (SMTP) — sends as the connected account using its app password ──
async function getSmtpTransport(userId: number): Promise<{ transport: ReturnType<typeof nodemailer.createTransport>; from: string }> {
const accounts = await getEmailAccounts(userId);
const acct = accounts.find((a) => a.enabled) ?? accounts[0];
if (!acct) throw errors.BAD_REQUEST('No email account configured');
const pass = (acct.credentials as Record<string, unknown> | null)?.password as string | undefined;
if (!pass) throw errors.BAD_REQUEST('This account has no SMTP password — sending needs an app-password account');
// Gmail: smtp.gmail.com:465 (SSL). Derive from the IMAP host for other providers.
const host = acct.provider === 'gmail' ? 'smtp.gmail.com' : acct.imapHost.replace(/^imap\./, 'smtp.');
const transport = nodemailer.createTransport({ host, port: 465, secure: true, auth: { user: acct.email, pass } });
return { transport, from: acct.email };
}
// POST /send — compose/reply. Gmail auto-files the sent copy in "Sent", so IMAP sync picks it up.
emailRouter.post('/send', async (ctx) => {
const user = ctx.get('user');
const b = await ctx.req.json<{ to: string; cc?: string; bcc?: string; subject?: string; body?: string; inReplyTo?: string }>();
if (!b.to?.trim()) throw errors.BAD_REQUEST('At least one recipient is required');
const { transport, from } = await getSmtpTransport(user.id);
await transport.sendMail({
from,
to: b.to,
cc: b.cc?.trim() || undefined,
bcc: b.bcc?.trim() || undefined,
subject: b.subject?.trim() || '(no subject)',
text: b.body ?? '',
...(b.inReplyTo ? { headers: { 'In-Reply-To': b.inReplyTo, References: b.inReplyTo } } : {}),
});
return ctx.json({ ok: true });
});
// GET /contacts?q= — address autocomplete from people you've received mail from, ranked by frequency.
emailRouter.get('/contacts', (ctx) => {
const email = ctx.get('user').email;
const like = `%${(ctx.req.query('q') ?? '').trim().toLowerCase()}%`;
const db = openEmailDb(email);
try {
const rows = db
.query(
`SELECT lower(from_address) AS address, from_name AS name, count(*) AS c
FROM emails
WHERE from_address IS NOT NULL AND from_address != '' AND deleted = 0
AND (lower(from_address) LIKE ? OR lower(from_name) LIKE ?)
GROUP BY lower(from_address)
ORDER BY c DESC LIMIT 10`,
)
.all(like, like) as Array<{ address: string; name: string | null }>;
return ctx.json(rows.map((r) => ({ address: r.address, name: r.name || '' })));
} finally {
db.close();
}
});
// ── Real-time: per-user SSE stream of email events (fed by the IMAP IDLE watcher) ──
const emailSseClients = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>();