diff --git a/src/routes/_authenticated/mail.tsx b/src/routes/_authenticated/mail.tsx new file mode 100644 index 0000000..81ee795 --- /dev/null +++ b/src/routes/_authenticated/mail.tsx @@ -0,0 +1,689 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +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 { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Mail, + Inbox, + Loader2, + Paperclip, + RefreshCw, + Reply, + Send, + Settings, + Trash2, + AlertTriangle, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + getMailSetup, + getAttachment, + getMessage, + listFolders, + listMessages, + myMailboxStatus, + removeUserMailbox, + saveMailSettings, + sendMail, + setUserMailbox, + type MailSettings, +} from "@/lib/mail.functions"; + +export const Route = createFileRoute("/_authenticated/mail")({ + head: () => ({ meta: [{ title: "Mail — School Portal" }] }), + component: MailPage, +}); + +const fmt = (iso: string | null) => + iso ? new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "—"; + +function MailPage() { + const { roles } = useAuth(); + const isAdmin = roles.includes("admin"); + const qc = useQueryClient(); + const [folder, setFolder] = useState("INBOX"); + const [openUid, setOpenUid] = useState(null); + const [composing, setComposing] = useState(null); + const [setupOpen, setSetupOpen] = useState(false); + + const status = useQuery({ + queryKey: ["mail-status"], + queryFn: () => myMailboxStatus({}), + }); + + const folders = useQuery({ + queryKey: ["mail-folders"], + enabled: !!status.data?.email, + queryFn: () => listFolders({}), + retry: false, + }); + + const messages = useQuery({ + queryKey: ["mail-messages", folder], + enabled: !!status.data?.email, + queryFn: () => listMessages({ data: { folder, limit: 40 } }), + retry: false, + }); + + if (status.isLoading) { + return ( +
+ Loading mail… +
+ ); + } + + // Nothing set up yet: tell the admin what to do, and tell everyone else who to ask. + if (!status.data?.email) { + return ( +
+

+ Mail +

+
+ {!status.data?.serverConfigured ? ( + <> +
The mail server isn't configured yet.
+

+ {isAdmin + ? "Enter your provider's IMAP and SMTP details, then give each staff member their mailbox login." + : "An administrator needs to set up the school mail server before you can use this page."} +

+ + ) : ( + <> +
You don't have a mailbox yet.
+

+ The mail server is configured, but no mailbox has been assigned to your account. + {isAdmin ? " Assign one below." : " Ask an administrator to set one up."} +

+ + )} + {isAdmin && ( + + )} +
+ {setupOpen && ( + { + setSetupOpen(false); + qc.invalidateQueries({ queryKey: ["mail-status"] }); + }} + /> + )} +
+ ); + } + + const err = (messages.error ?? folders.error) as Error | undefined; + + return ( +
+
+
+

+ Mail +

+

{status.data.email}

+
+
+ + + {isAdmin && ( + + )} +
+
+ + {err && ( +
+ +
+
Couldn't reach the mail server
+
{err.message}
+
+
+ )} + +
+
+ {(folders.data ?? [{ path: "INBOX", name: "Inbox", specialUse: null }]).map((f) => ( + + ))} +
+ +
+ {messages.isLoading ? ( +
+ Loading messages… +
+ ) : (messages.data?.messages ?? []).length === 0 ? ( +
+ No messages in this folder. +
+ ) : ( +
+ {(messages.data?.messages ?? []).map((m) => ( + + ))} +
+ )} + {messages.data ? ( +

+ Showing {messages.data.messages.length} of {messages.data.total} in{" "} + {messages.data.folder}. +

+ ) : null} +
+
+ + {openUid !== null && ( + { + setOpenUid(null); + qc.invalidateQueries({ queryKey: ["mail-messages", folder] }); + }} + onReply={(c) => { + setOpenUid(null); + setComposing(c); + }} + /> + )} + {composing && setComposing(null)} />} + {setupOpen && ( + { + setSetupOpen(false); + qc.invalidateQueries({ queryKey: ["mail-status"] }); + }} + /> + )} +
+ ); +} + +function MessageDialog({ + folder, + uid, + onClose, + onReply, +}: { + folder: string; + uid: number; + onClose: () => void; + onReply: (c: { + to: string; + subject: string; + inReplyTo: string | null; + references: string | null; + }) => void; +}) { + const { data, isLoading, error } = useQuery({ + queryKey: ["mail-message", folder, uid], + queryFn: () => getMessage({ data: { folder, uid } }), + retry: false, + }); + + const download = async (filename: string) => { + try { + const a = await getAttachment({ data: { folder, uid, filename } }); + const bytes = Uint8Array.from(atob(a.base64), (ch) => ch.charCodeAt(0)); + const url = URL.createObjectURL(new Blob([bytes], { type: a.contentType })); + const el = document.createElement("a"); + el.href = url; + el.download = a.filename; + el.click(); + URL.revokeObjectURL(url); + } catch (e) { + toast.error((e as Error).message); + } + }; + + return ( + !o && onClose()}> + + + {data?.subject ?? "Message"} + + {isLoading && ( +
+ Loading… +
+ )} + {error &&

{(error as Error).message}

} + {data && ( +
+
+
+ From: {data.from} +
+
+ To: {data.to} +
+ {data.cc && ( +
+ Cc: {data.cc} +
+ )} +
{fmt(data.date)}
+
+ + {data.attachments.length > 0 && ( +
+ {data.attachments.map((a) => ( + + ))} +
+ )} + + {/* Remote HTML is deliberately not rendered — an