Added IMAP email module

X-Lovable-Edit-ID: edt-b4ebfe33-7795-49c3-8f3c-cd565a4fb306
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 01:00:44 +00:00
co-authored by renee-png
9 changed files with 832 additions and 1 deletions
BIN
View File
Binary file not shown.
+3
View File
@@ -54,6 +54,7 @@
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"@types/file-saver": "^2.0.7",
"@types/mailparser": "3.4.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -61,10 +62,12 @@
"docx": "^9.6.1",
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"imapflow": "1.0.171",
"input-otp": "^1.4.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.575.0",
"mailparser": "3.7.1",
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.0",
+2 -1
View File
@@ -1,7 +1,7 @@
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard, Calendar as CalendarIcon, CheckSquare, MessageSquare, Activity, DollarSign, FolderOpen, Files as FilesIcon, Settings as SettingsIcon, ClipboardList, UserPlus, User as UserIcon } from "lucide-react";
import { Briefcase, Users, FileText, Receipt, ShieldCheck, LogOut, Scale, LayoutDashboard, Calendar as CalendarIcon, CheckSquare, MessageSquare, Activity, DollarSign, FolderOpen, Files as FilesIcon, Settings as SettingsIcon, ClipboardList, UserPlus, User as UserIcon, Inbox as InboxIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import { HeaderTimer } from "@/components/timer/header-timer";
@@ -24,6 +24,7 @@ const NAV: NavItem[] = [
{ to: "/cases", label: "Cases", icon: Briefcase },
{ to: "/tasks", label: "Tasks", icon: CheckSquare },
{ to: "/messages", label: "Messages", icon: MessageSquare },
{ to: "/inbox", label: "Inbox", icon: InboxIcon },
{ to: "/status", label: "Status Updates", icon: Activity },
{ to: "/collections", label: "Collections", icon: DollarSign },
{ to: "/documents", label: "Documents", icon: FolderOpen },
+63
View File
@@ -18,6 +18,7 @@ import { Route as StatusIndexRouteImport } from './routes/status.index'
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
import { Route as MessagesIndexRouteImport } from './routes/messages.index'
import { Route as InvoicesIndexRouteImport } from './routes/invoices.index'
import { Route as InboxIndexRouteImport } from './routes/inbox.index'
import { Route as FormsIndexRouteImport } from './routes/forms.index'
import { Route as FilesIndexRouteImport } from './routes/files.index'
import { Route as DocumentsIndexRouteImport } from './routes/documents.index'
@@ -30,8 +31,10 @@ import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflo
import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow'
import { Route as SettingsSmtpRouteImport } from './routes/settings.smtp'
import { Route as SettingsProfileRouteImport } from './routes/settings.profile'
import { Route as SettingsImapRouteImport } from './routes/settings.imap'
import { Route as SettingsFeesRouteImport } from './routes/settings.fees'
import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId'
import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap'
import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId'
import { Route as CollectionsCollectionIdRouteImport } from './routes/collections.$collectionId'
import { Route as ClientsClientIdRouteImport } from './routes/clients.$clientId'
@@ -88,6 +91,11 @@ const InvoicesIndexRoute = InvoicesIndexRouteImport.update({
path: '/invoices/',
getParentRoute: () => rootRouteImport,
} as any)
const InboxIndexRoute = InboxIndexRouteImport.update({
id: '/inbox/',
path: '/inbox/',
getParentRoute: () => rootRouteImport,
} as any)
const FormsIndexRoute = FormsIndexRouteImport.update({
id: '/forms/',
path: '/forms/',
@@ -148,6 +156,11 @@ const SettingsProfileRoute = SettingsProfileRouteImport.update({
path: '/profile',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsImapRoute = SettingsImapRouteImport.update({
id: '/imap',
path: '/imap',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsFeesRoute = SettingsFeesRouteImport.update({
id: '/fees',
path: '/fees',
@@ -158,6 +171,11 @@ const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({
path: '/invoices/$invoiceId',
getParentRoute: () => rootRouteImport,
} as any)
const HooksPollImapRoute = HooksPollImapRouteImport.update({
id: '/hooks/poll-imap',
path: '/hooks/poll-imap',
getParentRoute: () => rootRouteImport,
} as any)
const ContactsContactIdRoute = ContactsContactIdRouteImport.update({
id: '/contacts/$contactId',
path: '/contacts/$contactId',
@@ -221,8 +239,10 @@ export interface FileRoutesByFullPath {
'/clients/$clientId': typeof ClientsClientIdRoute
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/imap': typeof SettingsImapRoute
'/settings/profile': typeof SettingsProfileRoute
'/settings/smtp': typeof SettingsSmtpRoute
'/settings/workflow': typeof SettingsWorkflowRoute
@@ -235,6 +255,7 @@ export interface FileRoutesByFullPath {
'/documents/': typeof DocumentsIndexRoute
'/files/': typeof FilesIndexRoute
'/forms/': typeof FormsIndexRoute
'/inbox/': typeof InboxIndexRoute
'/invoices/': typeof InvoicesIndexRoute
'/messages/': typeof MessagesIndexRoute
'/settings/': typeof SettingsIndexRoute
@@ -255,8 +276,10 @@ export interface FileRoutesByTo {
'/clients/$clientId': typeof ClientsClientIdRoute
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/imap': typeof SettingsImapRoute
'/settings/profile': typeof SettingsProfileRoute
'/settings/smtp': typeof SettingsSmtpRoute
'/settings/workflow': typeof SettingsWorkflowRoute
@@ -269,6 +292,7 @@ export interface FileRoutesByTo {
'/documents': typeof DocumentsIndexRoute
'/files': typeof FilesIndexRoute
'/forms': typeof FormsIndexRoute
'/inbox': typeof InboxIndexRoute
'/invoices': typeof InvoicesIndexRoute
'/messages': typeof MessagesIndexRoute
'/settings': typeof SettingsIndexRoute
@@ -291,8 +315,10 @@ export interface FileRoutesById {
'/clients/$clientId': typeof ClientsClientIdRoute
'/collections/$collectionId': typeof CollectionsCollectionIdRoute
'/contacts/$contactId': typeof ContactsContactIdRoute
'/hooks/poll-imap': typeof HooksPollImapRoute
'/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute
'/settings/fees': typeof SettingsFeesRoute
'/settings/imap': typeof SettingsImapRoute
'/settings/profile': typeof SettingsProfileRoute
'/settings/smtp': typeof SettingsSmtpRoute
'/settings/workflow': typeof SettingsWorkflowRoute
@@ -305,6 +331,7 @@ export interface FileRoutesById {
'/documents/': typeof DocumentsIndexRoute
'/files/': typeof FilesIndexRoute
'/forms/': typeof FormsIndexRoute
'/inbox/': typeof InboxIndexRoute
'/invoices/': typeof InvoicesIndexRoute
'/messages/': typeof MessagesIndexRoute
'/settings/': typeof SettingsIndexRoute
@@ -328,8 +355,10 @@ export interface FileRouteTypes {
| '/clients/$clientId'
| '/collections/$collectionId'
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/imap'
| '/settings/profile'
| '/settings/smtp'
| '/settings/workflow'
@@ -342,6 +371,7 @@ export interface FileRouteTypes {
| '/documents/'
| '/files/'
| '/forms/'
| '/inbox/'
| '/invoices/'
| '/messages/'
| '/settings/'
@@ -362,8 +392,10 @@ export interface FileRouteTypes {
| '/clients/$clientId'
| '/collections/$collectionId'
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/imap'
| '/settings/profile'
| '/settings/smtp'
| '/settings/workflow'
@@ -376,6 +408,7 @@ export interface FileRouteTypes {
| '/documents'
| '/files'
| '/forms'
| '/inbox'
| '/invoices'
| '/messages'
| '/settings'
@@ -397,8 +430,10 @@ export interface FileRouteTypes {
| '/clients/$clientId'
| '/collections/$collectionId'
| '/contacts/$contactId'
| '/hooks/poll-imap'
| '/invoices/$invoiceId'
| '/settings/fees'
| '/settings/imap'
| '/settings/profile'
| '/settings/smtp'
| '/settings/workflow'
@@ -411,6 +446,7 @@ export interface FileRouteTypes {
| '/documents/'
| '/files/'
| '/forms/'
| '/inbox/'
| '/invoices/'
| '/messages/'
| '/settings/'
@@ -433,6 +469,7 @@ export interface RootRouteChildren {
ClientsClientIdRoute: typeof ClientsClientIdRoute
CollectionsCollectionIdRoute: typeof CollectionsCollectionIdRoute
ContactsContactIdRoute: typeof ContactsContactIdRoute
HooksPollImapRoute: typeof HooksPollImapRoute
InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute
CalendarIndexRoute: typeof CalendarIndexRoute
CasesIndexRoute: typeof CasesIndexRoute
@@ -442,6 +479,7 @@ export interface RootRouteChildren {
DocumentsIndexRoute: typeof DocumentsIndexRoute
FilesIndexRoute: typeof FilesIndexRoute
FormsIndexRoute: typeof FormsIndexRoute
InboxIndexRoute: typeof InboxIndexRoute
InvoicesIndexRoute: typeof InvoicesIndexRoute
MessagesIndexRoute: typeof MessagesIndexRoute
StatusIndexRoute: typeof StatusIndexRoute
@@ -517,6 +555,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InvoicesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/inbox/': {
id: '/inbox/'
path: '/inbox'
fullPath: '/inbox/'
preLoaderRoute: typeof InboxIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/forms/': {
id: '/forms/'
path: '/forms'
@@ -601,6 +646,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsProfileRouteImport
parentRoute: typeof SettingsRoute
}
'/settings/imap': {
id: '/settings/imap'
path: '/imap'
fullPath: '/settings/imap'
preLoaderRoute: typeof SettingsImapRouteImport
parentRoute: typeof SettingsRoute
}
'/settings/fees': {
id: '/settings/fees'
path: '/fees'
@@ -615,6 +667,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InvoicesInvoiceIdRouteImport
parentRoute: typeof rootRouteImport
}
'/hooks/poll-imap': {
id: '/hooks/poll-imap'
path: '/hooks/poll-imap'
fullPath: '/hooks/poll-imap'
preLoaderRoute: typeof HooksPollImapRouteImport
parentRoute: typeof rootRouteImport
}
'/contacts/$contactId': {
id: '/contacts/$contactId'
path: '/contacts/$contactId'
@@ -690,6 +749,7 @@ declare module '@tanstack/react-router' {
interface SettingsRouteChildren {
SettingsFeesRoute: typeof SettingsFeesRoute
SettingsImapRoute: typeof SettingsImapRoute
SettingsProfileRoute: typeof SettingsProfileRoute
SettingsSmtpRoute: typeof SettingsSmtpRoute
SettingsWorkflowRoute: typeof SettingsWorkflowRoute
@@ -699,6 +759,7 @@ interface SettingsRouteChildren {
const SettingsRouteChildren: SettingsRouteChildren = {
SettingsFeesRoute: SettingsFeesRoute,
SettingsImapRoute: SettingsImapRoute,
SettingsProfileRoute: SettingsProfileRoute,
SettingsSmtpRoute: SettingsSmtpRoute,
SettingsWorkflowRoute: SettingsWorkflowRoute,
@@ -721,6 +782,7 @@ const rootRouteChildren: RootRouteChildren = {
ClientsClientIdRoute: ClientsClientIdRoute,
CollectionsCollectionIdRoute: CollectionsCollectionIdRoute,
ContactsContactIdRoute: ContactsContactIdRoute,
HooksPollImapRoute: HooksPollImapRoute,
InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute,
CalendarIndexRoute: CalendarIndexRoute,
CasesIndexRoute: CasesIndexRoute,
@@ -730,6 +792,7 @@ const rootRouteChildren: RootRouteChildren = {
DocumentsIndexRoute: DocumentsIndexRoute,
FilesIndexRoute: FilesIndexRoute,
FormsIndexRoute: FormsIndexRoute,
InboxIndexRoute: InboxIndexRoute,
InvoicesIndexRoute: InvoicesIndexRoute,
MessagesIndexRoute: MessagesIndexRoute,
StatusIndexRoute: StatusIndexRoute,
+157
View File
@@ -0,0 +1,157 @@
// Server route that connects to the configured IMAP mailbox, fetches new
// messages since the last polled UID, and stores them in incoming_emails.
// Triggered by pg_cron and by the "Poll now" button in settings.
import { createFileRoute } from "@tanstack/react-router";
import { supabaseAdmin } from "@/integrations/supabase/client.server";
import { ImapFlow } from "imapflow";
import { simpleParser } from "mailparser";
const MAX_MESSAGES_PER_RUN = 50;
export const Route = createFileRoute("/hooks/poll-imap")({
server: {
handlers: {
POST: async () => {
try {
const { data: settings, error: setErr } = await supabaseAdmin
.from("imap_settings")
.select("*")
.eq("enabled", true)
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (setErr) return json({ error: setErr.message }, 500);
if (!settings) return json({ error: "No IMAP settings configured" }, 400);
const password = process.env.IMAP_PASSWORD;
if (!password) {
return json({ error: "IMAP_PASSWORD secret is not set" }, 400);
}
const client = new ImapFlow({
host: settings.host,
port: settings.port,
secure: !!settings.secure,
auth: { user: settings.username, pass: password },
logger: false,
});
let imported = 0;
let highestUid = settings.last_uid ?? 0;
let errorMessage: string | null = null;
try {
await client.connect();
const lock = await client.getMailboxLock(settings.folder ?? "INBOX");
try {
const sinceUid = (settings.last_uid ?? 0) + 1;
const range = `${sinceUid}:*`;
for await (const msg of client.fetch(
range,
{ uid: true, source: true, envelope: true, size: true },
{ uid: true },
)) {
if (imported >= MAX_MESSAGES_PER_RUN) break;
if (msg.uid <= (settings.last_uid ?? 0)) continue;
let parsed;
try {
parsed = await simpleParser(msg.source as Buffer);
} catch (e) {
console.error("Failed to parse message uid", msg.uid, e);
continue;
}
const fromAddr = parsed.from?.value?.[0]?.address ?? null;
const fromName = parsed.from?.value?.[0]?.name ?? null;
const toAddrs =
Array.isArray(parsed.to)
? parsed.to.flatMap((a: any) => a.value.map((v: any) => v.address).filter(Boolean))
: (parsed.to?.value?.map((v: any) => v.address).filter(Boolean) ?? []);
const ccAddrs =
Array.isArray(parsed.cc)
? parsed.cc.flatMap((a: any) => a.value.map((v: any) => v.address).filter(Boolean))
: (parsed.cc?.value?.map((v: any) => v.address).filter(Boolean) ?? []);
const text = parsed.text ?? null;
const snippet = text
? text.replace(/\s+/g, " ").trim().slice(0, 280)
: null;
const attachments = parsed.attachments ?? [];
const insertRow = {
message_id: parsed.messageId ?? null,
imap_uid: msg.uid,
received_at: (parsed.date ?? new Date()).toISOString(),
from_address: fromAddr,
from_name: fromName,
to_addresses: toAddrs,
cc_addresses: ccAddrs,
subject: parsed.subject ?? null,
body_text: text,
body_html: parsed.html || null,
snippet,
has_attachments: attachments.length > 0,
attachment_count: attachments.length,
raw_size_bytes: msg.size ?? null,
};
// Upsert by message_id to avoid duplicates if mailbox is re-polled
const { error: insErr } = await supabaseAdmin
.from("incoming_emails")
.upsert(insertRow, { onConflict: "message_id", ignoreDuplicates: true });
if (insErr && !insErr.message.includes("duplicate")) {
console.error("Insert failed for uid", msg.uid, insErr.message);
} else {
imported++;
}
if (msg.uid > highestUid) highestUid = msg.uid;
}
} finally {
lock.release();
}
await client.logout();
} catch (e) {
errorMessage = e instanceof Error ? e.message : String(e);
console.error("IMAP poll error:", errorMessage);
try {
await client.close();
} catch (_) {
/* ignore */
}
}
await supabaseAdmin
.from("imap_settings")
.update({
last_uid: highestUid,
last_polled_at: new Date().toISOString(),
last_error: errorMessage,
})
.eq("id", settings.id);
if (errorMessage) {
return json({ ok: false, imported, error: errorMessage }, 500);
}
return json({ ok: true, imported, last_uid: highestUid });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.error("poll-imap fatal", message);
return json({ error: message }, 500);
}
},
},
},
});
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
+299
View File
@@ -0,0 +1,299 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { ProtectedLayout } from "@/components/protected-layout";
import { PageContainer, PageHeader } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Loader2, Inbox as InboxIcon, RefreshCcw, Search, Mail, MailOpen, Archive, ArchiveRestore, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/lib/auth";
export const Route = createFileRoute("/inbox/")({
component: InboxPage,
});
interface IncomingEmail {
id: string;
received_at: string;
from_address: string | null;
from_name: string | null;
to_addresses: string[];
subject: string | null;
snippet: string | null;
body_text: string | null;
body_html: string | null;
is_read: boolean;
is_archived: boolean;
has_attachments: boolean;
attachment_count: number;
}
function InboxPage() {
const { isAdmin } = useAuth();
const [emails, setEmails] = useState<IncomingEmail[]>([]);
const [loading, setLoading] = useState(true);
const [polling, setPolling] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const load = async () => {
setLoading(true);
let query = supabase
.from("incoming_emails")
.select("id, received_at, from_address, from_name, to_addresses, subject, snippet, body_text, body_html, is_read, is_archived, has_attachments, attachment_count")
.order("received_at", { ascending: false })
.limit(200);
query = showArchived ? query.eq("is_archived", true) : query.eq("is_archived", false);
const { data, error } = await query;
if (error) {
toast.error("Failed to load inbox", { description: error.message });
} else {
setEmails((data as IncomingEmail[]) ?? []);
}
setLoading(false);
};
useEffect(() => {
load();
}, [showArchived]);
const pollNow = async () => {
setPolling(true);
try {
const res = await fetch("/hooks/poll-imap", { method: "POST" });
const body = await res.json();
if (!res.ok || body.error) {
toast.error("Poll failed", { description: body.error });
} else {
toast.success(`Imported ${body.imported ?? 0} new email(s)`);
load();
}
} catch (e) {
toast.error("Poll failed", {
description: e instanceof Error ? e.message : String(e),
});
} finally {
setPolling(false);
}
};
const markRead = async (id: string, isRead: boolean) => {
await supabase.from("incoming_emails").update({ is_read: isRead }).eq("id", id);
setEmails((prev) =>
prev.map((e) => (e.id === id ? { ...e, is_read: isRead } : e)),
);
};
const toggleArchive = async (id: string, archive: boolean) => {
await supabase.from("incoming_emails").update({ is_archived: archive }).eq("id", id);
setEmails((prev) => prev.filter((e) => e.id !== id));
if (selectedId === id) setSelectedId(null);
toast.success(archive ? "Archived" : "Restored");
};
const deleteEmail = async (id: string) => {
if (!confirm("Delete this email permanently?")) return;
const { error } = await supabase.from("incoming_emails").delete().eq("id", id);
if (error) {
toast.error("Delete failed", { description: error.message });
return;
}
setEmails((prev) => prev.filter((e) => e.id !== id));
if (selectedId === id) setSelectedId(null);
};
const filtered = emails.filter((e) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
return (
(e.subject ?? "").toLowerCase().includes(q) ||
(e.from_address ?? "").toLowerCase().includes(q) ||
(e.from_name ?? "").toLowerCase().includes(q) ||
(e.snippet ?? "").toLowerCase().includes(q)
);
});
const selected = emails.find((e) => e.id === selectedId) ?? null;
const unreadCount = emails.filter((e) => !e.is_read).length;
return (
<ProtectedLayout>
<PageContainer>
<PageHeader
title="Inbox"
description={`Incoming emails received via IMAP. ${unreadCount} unread.`}
actions={
<Button onClick={pollNow} disabled={polling} variant="outline" size="sm">
{polling ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4 mr-2" />
)}
Check for new mail
</Button>
}
/>
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by subject, sender..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant={showArchived ? "default" : "outline"}
size="sm"
onClick={() => {
setShowArchived((v) => !v);
setSelectedId(null);
}}
>
{showArchived ? "Showing archived" : "Show archived"}
</Button>
</div>
{loading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 text-center text-sm text-muted-foreground">
<InboxIcon className="h-8 w-8 mx-auto mb-3 opacity-50" />
{showArchived ? "No archived emails." : "No emails yet. "}
{!showArchived && (
<>Configure IMAP in <a className="underline" href="/settings/imap">Settings → Email (IMAP)</a> and click "Check for new mail".</>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<Card className="border-border/60 overflow-hidden">
<div className="divide-y max-h-[70vh] overflow-y-auto">
{filtered.map((email) => (
<button
key={email.id}
type="button"
onClick={() => {
setSelectedId(email.id);
if (!email.is_read) markRead(email.id, true);
}}
className={`w-full text-left px-3 py-3 hover:bg-muted/50 transition-colors ${
selectedId === email.id ? "bg-muted" : ""
} ${!email.is_read ? "font-medium" : ""}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm">
{email.from_name || email.from_address || "Unknown sender"}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{new Date(email.received_at).toLocaleDateString()}
</span>
</div>
<div className="text-sm truncate mt-0.5">
{email.subject || "(no subject)"}
</div>
<div className="text-xs text-muted-foreground truncate mt-0.5">
{email.snippet || "(empty body)"}
</div>
<div className="flex items-center gap-1.5 mt-1">
{!email.is_read && (
<Badge variant="default" className="h-4 text-[9px] px-1.5">NEW</Badge>
)}
{email.has_attachments && (
<Badge variant="secondary" className="h-4 text-[9px] px-1.5">
📎 {email.attachment_count}
</Badge>
)}
</div>
</button>
))}
</div>
</Card>
<Card className="border-border/60">
{selected ? (
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="font-serif text-lg leading-tight">
{selected.subject || "(no subject)"}
</h2>
<div className="text-sm text-muted-foreground mt-1">
From: <span className="text-foreground">
{selected.from_name
? `${selected.from_name} <${selected.from_address}>`
: selected.from_address}
</span>
</div>
<div className="text-xs text-muted-foreground">
To: {selected.to_addresses.join(", ")}
</div>
<div className="text-xs text-muted-foreground">
{new Date(selected.received_at).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
onClick={() => markRead(selected.id, !selected.is_read)}
title={selected.is_read ? "Mark unread" : "Mark read"}
>
{selected.is_read ? <Mail className="h-4 w-4" /> : <MailOpen className="h-4 w-4" />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => toggleArchive(selected.id, !selected.is_archived)}
title={selected.is_archived ? "Unarchive" : "Archive"}
>
{selected.is_archived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
</Button>
{isAdmin && (
<Button
size="sm"
variant="ghost"
onClick={() => deleteEmail(selected.id)}
title="Delete"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
<div className="border-t pt-3">
{selected.body_html ? (
<iframe
title="Email body"
srcDoc={selected.body_html}
sandbox=""
className="w-full min-h-[400px] border rounded bg-white"
/>
) : (
<pre className="text-sm whitespace-pre-wrap font-sans">
{selected.body_text || "(empty)"}
</pre>
)}
</div>
</CardContent>
) : (
<CardContent className="py-16 text-center text-sm text-muted-foreground">
Select an email to read.
</CardContent>
)}
</Card>
</div>
)}
</PageContainer>
</ProtectedLayout>
);
}
+281
View File
@@ -0,0 +1,281 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Loader2, Inbox, KeyRound, RefreshCcw } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/imap")({
component: ImapSettingsPage,
});
const EMPTY = {
host: "",
port: "993",
secure: true,
username: "",
folder: "INBOX",
enabled: true,
notes: "",
};
function ImapSettingsPage() {
const { user, isAdmin } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [polling, setPolling] = useState(false);
const [recordId, setRecordId] = useState<string | null>(null);
const [form, setForm] = useState({ ...EMPTY });
const [meta, setMeta] = useState<{
last_polled_at: string | null;
last_uid: number | null;
last_error: string | null;
}>({ last_polled_at: null, last_uid: null, last_error: null });
const load = async () => {
setLoading(true);
const { data } = await supabase
.from("imap_settings")
.select("*")
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle();
if (data) {
setRecordId(data.id);
setForm({
host: data.host ?? "",
port: String(data.port ?? 993),
secure: !!data.secure,
username: data.username ?? "",
folder: data.folder ?? "INBOX",
enabled: data.enabled ?? true,
notes: data.notes ?? "",
});
setMeta({
last_polled_at: data.last_polled_at,
last_uid: data.last_uid,
last_error: data.last_error,
});
}
setLoading(false);
};
useEffect(() => {
if (isAdmin) load();
else setLoading(false);
}, [isAdmin]);
const update = (k: keyof typeof EMPTY, v: string | boolean) =>
setForm((f) => ({ ...f, [k]: v }));
const save = async () => {
if (!form.host || !form.username) {
toast.error("Host and username are required");
return;
}
setSaving(true);
const payload = {
host: form.host.trim(),
port: parseInt(form.port, 10) || 993,
secure: form.secure,
username: form.username.trim(),
folder: form.folder.trim() || "INBOX",
enabled: form.enabled,
notes: form.notes || null,
updated_by: user?.id,
};
const { error } = recordId
? await supabase.from("imap_settings").update(payload).eq("id", recordId)
: await supabase.from("imap_settings").insert(payload);
setSaving(false);
if (error) {
toast.error("Could not save", { description: error.message });
return;
}
toast.success("IMAP settings saved");
load();
};
const pollNow = async () => {
setPolling(true);
try {
const res = await fetch("/hooks/poll-imap", { method: "POST" });
const body = await res.json();
if (!res.ok || body.error) {
toast.error("Poll failed", { description: body.error });
} else {
toast.success(`Imported ${body.imported ?? 0} new email(s)`);
}
} catch (e) {
toast.error("Poll failed", {
description: e instanceof Error ? e.message : String(e),
});
} finally {
setPolling(false);
load();
}
};
if (!isAdmin) {
return (
<div className="text-sm text-muted-foreground py-8">
Only administrators can manage IMAP settings.
</div>
);
}
if (loading) {
return (
<div className="flex justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 max-w-3xl">
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base flex items-center gap-2">
<Inbox className="h-4 w-4 text-muted-foreground" /> Incoming mail (IMAP)
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Field label="IMAP host *">
<Input
value={form.host}
onChange={(e) => update("host", e.target.value)}
placeholder="imap.gmail.com"
/>
</Field>
<Field label="Port *">
<Input
type="number"
value={form.port}
onChange={(e) => update("port", e.target.value)}
placeholder="993"
/>
</Field>
<Field label="Username *">
<Input
value={form.username}
onChange={(e) => update("username", e.target.value)}
placeholder="inbox@yourfirm.com"
/>
</Field>
<Field label="Folder">
<Input
value={form.folder}
onChange={(e) => update("folder", e.target.value)}
placeholder="INBOX"
/>
</Field>
<div className="space-y-1.5 sm:col-span-2">
<Label className="text-xs">Password</Label>
<div className="flex items-center gap-2 h-9 px-3 rounded-md border bg-muted/30 text-xs text-muted-foreground">
<KeyRound className="h-3.5 w-3.5" />
Stored securely as the <code>IMAP_PASSWORD</code> secret. For Gmail/Workspace, use an App Password.
</div>
</div>
<div className="sm:col-span-2 flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm font-medium">Use TLS / SSL</div>
<div className="text-xs text-muted-foreground">
Required for port 993. Off for 143 (STARTTLS).
</div>
</div>
<Switch
checked={form.secure}
onCheckedChange={(v) => update("secure", v)}
/>
</div>
<Field label="Notes" className="sm:col-span-2">
<Textarea
rows={2}
value={form.notes}
onChange={(e) => update("notes", e.target.value)}
/>
</Field>
<div className="sm:col-span-2 flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm font-medium">Polling enabled</div>
<div className="text-xs text-muted-foreground">
When on, the system fetches new mail automatically every 5 minutes.
</div>
</div>
<Switch
checked={form.enabled}
onCheckedChange={(v) => update("enabled", v)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-between items-center gap-3">
<Button
variant="outline"
onClick={pollNow}
disabled={polling || !recordId}
>
{polling ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4 mr-2" />
)}
Poll now
</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save IMAP settings
</Button>
</div>
{recordId && (
<Card className="border-border/60">
<CardHeader>
<CardTitle className="font-serif text-base">Last poll</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-1">
<div>
<span className="text-muted-foreground">Last polled at:</span>{" "}
{meta.last_polled_at
? new Date(meta.last_polled_at).toLocaleString()
: "never"}
</div>
<div>
<span className="text-muted-foreground">Last UID seen:</span>{" "}
{meta.last_uid ?? "—"}
</div>
{meta.last_error && (
<div className="text-destructive">
<span className="font-medium">Last error:</span> {meta.last_error}
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}
function Field({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={`space-y-1.5 ${className ?? ""}`}>
<Label className="text-xs">{label}</Label>
{children}
</div>
);
}
+1
View File
@@ -16,6 +16,7 @@ const TABS = [
{ to: "/settings/workflow", label: "Collections workflow" },
{ to: "/settings/workflows", label: "Task workflows" },
{ to: "/settings/smtp", label: "Email (SMTP)" },
{ to: "/settings/imap", label: "Email (IMAP)" },
];
function SettingsLayout() {
+26
View File
@@ -0,0 +1,26 @@
declare module "imapflow" {
// Minimal types covering only what we use server-side.
export class ImapFlow {
constructor(opts: {
host: string;
port: number;
secure: boolean;
auth: { user: string; pass: string };
logger?: any;
});
connect(): Promise<void>;
logout(): Promise<void>;
close(): Promise<void>;
getMailboxLock(folder: string): Promise<{ release: () => void }>;
fetch(
range: string,
query: Record<string, unknown>,
options?: Record<string, unknown>,
): AsyncIterable<{
uid: number;
source: Buffer;
size?: number;
envelope?: any;
}>;
}
}