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) {
    // Replies go to the sender; sent mail uses the original recipient.
    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 />);