Email
index.jsx 468 lines
Files · 9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Page,
Header,
Button,
Input,
EmptyState,
Modal,
Toast,
useToast,
} from "../../shared/ui.jsx";
import { ConfirmDelete } from "../../shared/forms.jsx";
import { useStore, displayTime } from "../../shared/data.js";
import "./style.css";
import { createMailbox } from "./mailbox.js";
const L = window.lattice;
const mailbox = createMailbox(L);
const folders = ["inbox", "sent", "outbox", "drafts", "starred"];
const label = (value) => value[0].toUpperCase() + value.slice(1);
const emptyCompose = { to: "", subject: "", body: "", draftId: null };
function App() {
const [folder, setFolder] = useState("inbox"),
[search, setSearch] = useState(""),
[sort, setSort] = useState("newest"),
[selectedId, setSelectedId] = useState(null);
const [compose, setCompose] = useState(null),
[deleting, setDeleting] = useState(null),
[account, setAccount] = useState(null),
[mailError, setMailError] = useState(""),
[validation, setValidation] = useState("");
const { toast, notify, dismissToast } = useToast();
const store = useStore({
setup: mailbox.setup,
load: () =>
L.db.query("SELECT * FROM emails ORDER BY created_at DESC,id DESC"),
listeners: { "send-email": mailbox.send },
});
async function checkMail() {
await store.act(async () => {
try {
const result = await mailbox.sync();
setAccount(result.configured ? result.address : false);
setMailError("");
} catch (error) {
setMailError(String(error));
}
});
}
useEffect(() => {
if (!store.ready) return;
checkMail();
const timer = setInterval(checkMail, 30000);
window.addEventListener("lattice-email-account-changed", checkMail);
return () => {
clearInterval(timer);
window.removeEventListener("lattice-email-account-changed", checkMail);
};
}, [store.ready]);
const query = search.trim().toLowerCase();
const matchesFolder = (row) =>
folder === "starred" ? row.starred : row.folder === folder;
const filtered = store.rows
.filter(
(row) =>
matchesFolder(row) &&
["subject", "body", "from_addr", "to_addr"].some((key) =>
String(row[key] || "")
.toLowerCase()
.includes(query),
),
)
.sort((a, b) =>
sort === "subject"
? a.subject.localeCompare(b.subject)
: sort === "oldest"
? a.created_at.localeCompare(b.created_at) || a.id - b.id
: b.created_at.localeCompare(a.created_at) || b.id - a.id,
);
const selected = store.rows.find((row) => row.id === selectedId);
function openCompose(value = emptyCompose) {
setValidation("");
setCompose({
...value,
deliveryKey: value.deliveryKey || crypto.randomUUID(),
});
}
function reply(row) {
const address = row.folder === "sent" ? row.to_addr : row.from_addr;
const to = address?.match(/<([^>]+)>/)?.[1] || address || "";
openCompose({
to,
subject: /^re:/i.test(row.subject) ? row.subject : `Re: ${row.subject}`,
body: `\n\n--- Original message ---\n${row.body || ""}`,
draftId: null,
});
}
async function submit(event) {
event.preventDefault();
const to = compose.to.trim(),
subject = compose.subject.trim();
if (!to || !subject) {
setValidation("Enter a recipient and subject.");
return;
}
let result;
if (
await store.act(async () => {
result = await mailbox.send(
{ to, subject, body: compose.body },
{ hash: compose.deliveryKey },
);
if (compose.draftId)
await L.db.exec("DELETE FROM emails WHERE id=? AND folder='drafts'", [
compose.draftId,
]);
})
) {
setCompose(null);
setFolder(result.status === "sent" ? "sent" : "outbox");
setSelectedId(null);
if (result.status === "unconfigured") setAccount(false);
notify(
result.status === "sent"
? "Message sent via SMTP"
: "Draft saved in Outbox. Configure an account in host Settings to send it.",
);
}
}
async function saveDraft() {
if (
![compose.to, compose.subject, compose.body].some((text) => text.trim())
) {
setValidation("Write something before saving a draft.");
return;
}
const subject = compose.subject.trim() || "(No subject)";
if (
await store.act(async () => {
if (compose.draftId)
await L.db.exec(
"UPDATE emails SET to_addr=?,subject=?,body=? WHERE id=? AND folder='drafts'",
[compose.to, subject, compose.body, compose.draftId],
);
else
await L.db.exec(
"INSERT INTO emails (folder,to_addr,subject,body,is_read,created_at) VALUES ('drafts',?,?,?,1,?)",
[compose.to, subject, compose.body, new Date().toISOString()],
);
})
) {
setCompose(null);
setFolder("drafts");
setSelectedId(null);
notify("Draft saved");
}
}
return (
<Page className="email-page">
<Header
title="Email"
subtitle={
account
? `Mailbox · ${account}`
: "Send and receive mail for one account."
}
actions={
<>
<Button disabled={store.busy} onClick={checkMail}>
Check mail
</Button>
<Button variant="primary" onClick={() => openCompose()}>
Compose
</Button>
</>
}
/>
{account === false && (
<p className="ui-panel email-account-note" role="status">
No account configured. Set up this Email instance in host Settings.
Send attempts are saved as outbox drafts and sent automatically after
setup, when Email next checks the account.
</p>
)}
<div className="ui-toolbar" role="group" aria-label="Mail folders">
{folders.map((value) => (
<Button
key={value}
variant={folder === value ? "primary" : "ghost"}
aria-pressed={folder === value}
onClick={() => {
setFolder(value);
setSelectedId(null);
}}
>
{label(value)}{" "}
<span>
{
store.rows.filter((row) =>
value === "starred" ? row.starred : row.folder === value,
).length
}
</span>
</Button>
))}
</div>
<div className="ui-toolbar">
<Input
type="search"
aria-label="Search email"
placeholder="Search this folder"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
aria-label="Sort email"
value={sort}
onChange={(e) => setSort(e.target.value)}
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="subject">Subject A–Z</option>
</select>
</div>
{(store.error || mailError) && (
<p className="ui-error-text" role="alert">
{store.error || mailError}{" "}
<Button disabled={store.busy} onClick={checkMail}>
Retry
</Button>
</p>
)}
<div className="ui-panel email-split">
<section
className="email-list"
aria-label={`${label(folder)} messages`}
>
{!store.ready ? (
<EmptyState
title={store.error ? "Mail could not be loaded" : "Loading mail…"}
/>
) : !filtered.length ? (
<EmptyState
title={
query ? "No matching messages" : `No messages in ${folder}`
}
action={
query ? (
<Button onClick={() => setSearch("")}>Clear search</Button>
) : folder === "inbox" ? (
<Button disabled={store.busy} onClick={checkMail}>
Check mail
</Button>
) : null
}
/>
) : (
filtered.map((row) => (
<div
key={row.id}
className={`email-item ${selectedId === row.id ? "selected" : ""}`}
>
<button
className="email-open"
aria-pressed={selectedId === row.id}
onClick={() => {
setSelectedId(row.id);
if (!row.is_read)
store.act(() =>
L.db.exec("UPDATE emails SET is_read=1 WHERE id=?", [
row.id,
]),
);
}}
>
<span className="ui-muted">
{row.folder === "inbox"
? row.from_addr
: row.to_addr || "No recipient"}
{!row.is_read && (
<span
className="ui-badge ui-badge-accent"
style={{ marginLeft: 6 }}
>
Unread
</span>
)}
</span>
<strong>{row.subject}</strong>
<span className="ui-muted email-preview">{row.body}</span>
<small className="ui-muted">
{displayTime(row.created_at)}
</small>
</button>
<Button
variant="ghost"
aria-label={`${row.starred ? "Unstar" : "Star"} ${row.subject}`}
aria-pressed={!!row.starred}
disabled={store.busy}
onClick={() =>
store.act(() =>
mailbox.setStarred(row.id, !row.starred),
)
}
>
{row.starred ? "★" : "☆"}
</Button>
</div>
))
)}
</section>
<section className="email-detail" aria-label="Message detail">
{selected ? (
<>
<h2>{selected.subject}</h2>
<p className="ui-muted" style={{ marginTop: 10 }}>
{selected.folder === "inbox" ? "From" : "To"}:{" "}
{selected.folder === "inbox"
? selected.from_addr
: selected.to_addr}
</p>
<p className="ui-muted">{displayTime(selected.created_at)}</p>
<p className="email-body">{selected.body || "(No content)"}</p>
{selected.last_error && (
<p className="ui-error-text" role="status">
{selected.last_error}
</p>
)}
<div className="ui-actions">
{selected.folder === "outbox" ? (
<Button
disabled={store.busy}
variant="primary"
onClick={async () => {
let result;
if (
await store.act(async () => {
result = await mailbox.deliver(selected);
})
) {
setFolder(result.status === "sent" ? "sent" : "outbox");
notify(
result.status === "sent"
? "Message sent via SMTP"
: "Draft is waiting for an account in host Settings",
);
}
}}
>
Retry delivery
</Button>
) : selected.folder === "drafts" ? (
<Button
variant="primary"
onClick={() =>
openCompose({
to: selected.to_addr || "",
subject: selected.subject,
body: selected.body || "",
draftId: selected.id,
})
}
>
Edit draft
</Button>
) : (
<Button onClick={() => reply(selected)}>Reply</Button>
)}
<Button
disabled={store.busy}
onClick={() =>
store.act(() =>
L.db.exec("UPDATE emails SET is_read=? WHERE id=?", [
selected.is_read ? 0 : 1,
selected.id,
]),
)
}
>
{selected.is_read ? "Mark unread" : "Mark read"}
</Button>
<Button variant="ghost" onClick={() => setDeleting(selected)}>
Delete
</Button>
</div>
</>
) : (
<EmptyState title="Select a message" />
)}
</section>
</div>
{compose && (
<Modal
title={compose.draftId ? "Edit draft" : "Compose"}
onClose={store.busy ? undefined : () => setCompose(null)}
>
<form onSubmit={submit}>
<Input
label="To"
value={compose.to}
onChange={(e) => setCompose({ ...compose, to: e.target.value })}
placeholder="[address], or me"
autoFocus
/>
<Input
label="Subject"
value={compose.subject}
onChange={(e) =>
setCompose({ ...compose, subject: e.target.value })
}
/>
<Input
label="Message"
multiline
rows={9}
value={compose.body}
onChange={(e) => setCompose({ ...compose, body: e.target.value })}
/>
{(validation || store.error) && (
<p role="alert" className="ui-error-text">
{validation || store.error}
</p>
)}
<div className="ui-actions">
<Button type="submit" variant="primary" disabled={store.busy}>
Send message
</Button>
<Button onClick={saveDraft} disabled={store.busy}>
Save draft
</Button>
<Button
variant="ghost"
disabled={store.busy}
onClick={() => setCompose(null)}
>
Cancel
</Button>
</div>
</form>
</Modal>
)}
{deleting && (
<ConfirmDelete
title="Delete message?"
busy={store.busy}
error={store.error}
onClose={() => setDeleting(null)}
onConfirm={async () => {
if (
await store.act(() =>
L.db.exec("DELETE FROM emails WHERE id=?", [deleting.id]),
)
) {
if (selectedId === deleting.id) setSelectedId(null);
setDeleting(null);
notify("Message deleted");
}
}}
>
{deleting.subject} will be permanently deleted.
</ConfirmDelete>
)}
<Toast message={toast} onDismiss={dismissToast} />
</Page>
);
}
createRoot(document.getElementById("root")).render(<App />);