Email

mailbox.js 151 lines

Files · 9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151// The host owns accounts, credentials, identity and protocol connections.
// This model owns local messages and publishes their workflow events.
import ddl from "./schema.js";
export function createMailbox(L) {
  const setup = () =>
    L.db.exec(ddl.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS"));
  async function row(id) {
    return (await L.db.query("SELECT * FROM emails WHERE id=?", [id]))[0];
  }
  async function publish(message) {
    if (message.event_emitted) return;
    const sent = message.folder === "sent";
    await L.events.emit(sent ? "email-sent" : "email-received", {
      id: message.id,
      ...(sent
        ? { to: message.to_addr, sent_at: message.created_at }
        : { from: message.from_addr, received_at: message.created_at }),
      subject: message.subject,
      body: message.body,
    });
    await L.db.exec("UPDATE emails SET event_emitted=1 WHERE id=?", [
      message.id,
    ]);
  }
  async function deliver(message) {
    if (message.folder === "sent") {
      await publish(message);
      return { status: "sent" };
    }
    let result;
    // Disable automatic retries before SMTP starts: a lost SMTP response can
    // mean the server accepted the mail. Only unconfigured drafts auto-send.
    await L.db.exec("UPDATE emails SET auto_send=0,last_error=? WHERE id=?", [
      "Delivery in progress; if interrupted, check Sent before retrying.",
      message.id,
    ]);
    try {
      result = await L.email.send({
        to: message.to_addr,
        ...(message.cc ? { cc: message.cc } : {}),
        subject: message.subject,
        body: message.body,
      });
    } catch (error) {
      await L.db.exec("UPDATE emails SET last_error=? WHERE id=?", [
        String(error),
        message.id,
      ]);
      throw error;
    }
    if (result.status === "unconfigured") {
      await L.db.exec("UPDATE emails SET auto_send=1,last_error=? WHERE id=?", [
        "Draft waiting for an account in host Settings. It will send automatically after setup.",
        message.id,
      ]);
      return result;
    }
    if (result.status !== "sent")
      throw Error("The host did not confirm SMTP delivery.");
    await L.db.exec(
      "UPDATE emails SET folder='sent',from_addr=?,to_addr=?,created_at=?,last_error=NULL WHERE id=?",
      [result.from, result.to, result.sent_at, message.id],
    );
    await publish(await row(message.id));
    return result;
  }
  async function send(data, envelope) {
    if (!data?.to?.trim() || !data?.subject?.trim())
      throw Error("Enter a recipient and subject.");
    const key = envelope?.hash || null;
    let message = key
      ? (await L.db.query("SELECT * FROM emails WHERE source_key=?", [key]))[0]
      : null;
    if (!message) {
      await L.db.exec(
        "INSERT INTO emails (folder,to_addr,cc,subject,body,is_read,created_at,source_key) VALUES ('outbox',?,?,?,?,1,?,?)",
        [
          data.to.trim(),
          data.cc || "",
          data.subject,
          data.body || "",
          new Date().toISOString(),
          key,
        ],
      );
      message = (
        await L.db.query("SELECT * FROM emails WHERE rowid=last_insert_rowid()")
      )[0];
    } else if (message.folder === "outbox") {
      await L.db.exec(
        "UPDATE emails SET to_addr=?,cc=?,subject=?,body=? WHERE id=?",
        [
          data.to.trim(),
          data.cc || "",
          data.subject,
          data.body || "",
          message.id,
        ],
      );
      message = await row(message.id);
    }
    return deliver(message);
  }
  async function sync() {
    // Confirmed SMTP delivery can publish its event even if IMAP is offline.
    const unpublished = await L.db.query(
      "SELECT * FROM emails WHERE folder IN ('sent','inbox') AND event_emitted=0 ORDER BY id",
    );
    for (const message of unpublished) await publish(message);
    // SMTP works independently of IMAP availability. Unconfigured calls do
    // not connect; the host simply leaves these as waiting drafts.
    const waiting = await L.db.query(
      "SELECT * FROM emails WHERE folder='outbox' AND auto_send=1 ORDER BY id",
    );
    for (const message of waiting) await deliver(message);
    const since = await L.kv.get("imap_cursor");
    const result = await L.email.fetch(since ? { since } : {});
    if (!result.configured) return result;
    for (const incoming of result.messages) {
      await L.db.exec(
        "INSERT OR IGNORE INTO emails (folder,from_addr,to_addr,subject,body,created_at,remote_key) VALUES ('inbox',?,?,?,?,?,?)",
        [
          incoming.from,
          result.address,
          incoming.subject,
          incoming.body,
          incoming.received_at,
          incoming.key,
        ],
      );
      const message = (
        await L.db.query("SELECT * FROM emails WHERE remote_key=?", [
          incoming.key,
        ])
      )[0];
      await publish(message);
    }
    await L.kv.set("imap_cursor", result.since);
    return result;
  }
  async function setStarred(id, starred) {
    const [message] = await L.db.query(
      "UPDATE emails SET starred=? WHERE id=? AND COALESCE(starred,0)<>? RETURNING *",
      [starred ? 1 : 0, id, starred ? 1 : 0]);
    if (message && starred) await L.events.emit("email-starred", {
      id: message.id, from: message.from_addr || "", subject: message.subject,
      body: message.body, received_at: message.created_at, folder: message.folder,
    });
  }
  return { setup, send, sync, deliver, setStarred };
}