Email

mailbox.test.js 238 lines

Files · 9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238import { expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import Ajv from "ajv";
import { readFileSync } from "node:fs";
import ddl from "./schema.js";
import { createMailbox } from "./mailbox.js";

async function fixture() {
  const db = new Database(":memory:");
  const kv = new Map(),
    events = [],
    calls = [];
  const state = { configured: true, failure: false, emitFailure: false };
  const incoming = {
    key: "account:42:7",
    from: "Alice Example <[address]>",
    subject: "Hello",
    body: "A real message",
    received_at: "2026-10-15T12:00:00Z",
  };
  const L = {
    db: {
      exec: async (sql, params = []) => db.run(sql, params),
      query: async (sql, params = []) => db.query(sql).all(...params),
    },
    kv: {
      get: async (key) => kv.get(key),
      set: async (key, value) => kv.set(key, value),
    },
    events: {
      emit: async (name, data) => {
        if (state.emitFailure) throw Error("Event unavailable");
        events.push({ name, data });
      },
    },
    email: {
      send: async (mail) => {
        calls.push(mail);
        if (state.failure) throw Error("SMTP connection or delivery failed");
        if (!state.configured) return { status: "unconfigured" };
        return {
          status: "sent",
          to:
            mail.to.trim().toLowerCase() === "me"
              ? "[address]"
              : mail.to,
          from: "[address]",
          sent_at: "2026-10-15T13:00:00Z",
        };
      },
      fetch: async () =>
        state.configured
          ? {
              configured: true,
              address: "[address]",
              messages: [incoming],
              since: incoming.key,
            }
          : { configured: false },
    },
  };
  const model = createMailbox(L);
  await model.setup();
  return { db, kv, events, calls, state, L, model };
}

test("manual send and workflow listener send wait for SMTP and resolve me on the host", async () => {
  const f = await fixture();
  try {
    await f.model.send(
      { to: "me", subject: "Reminder" },
      { hash: "workflow-delivery" },
    );
    const rows = f.db.query("SELECT * FROM emails").all();
    expect(rows).toHaveLength(1);
    expect(rows[0].folder).toBe("sent");
    expect(rows[0].to_addr).toBe("[address]");
    expect(f.events).toEqual([
      {
        name: "email-sent",
        data: {
          id: 1,
          to: "[address]",
          subject: "Reminder",
          body: "",
          sent_at: "2026-10-15T13:00:00Z",
        },
      },
    ]);
    await f.model.send(
      { to: "me", subject: "Reminder" },
      { hash: "workflow-delivery" },
    );
    expect(f.calls).toHaveLength(1);
    expect(f.events).toHaveLength(1);
  } finally {
    f.db.close();
  }
});

test("failed SMTP keeps an outbox draft, emits nothing, and does not automatically retry", async () => {
  const f = await fixture();
  try {
    f.state.failure = true;
    await expect(
      f.model.send({
        to: "[address]",
        subject: "Hello",
        body: "Text",
      }),
    ).rejects.toThrow("SMTP");
    expect(f.events).toHaveLength(0);
    const draft = f.db.query("SELECT * FROM emails").get();
    expect(draft.folder).toBe("outbox");
    expect(draft.auto_send).toBe(0);
    expect(draft.last_error).toContain("SMTP");
    await f.model.sync();
    expect(f.calls).toHaveLength(1);
  } finally {
    f.db.close();
  }
});

test("unconfigured messages are waiting drafts and send after account setup", async () => {
  const f = await fixture();
  try {
    f.state.configured = false;
    expect((await f.model.send({ to: "me", subject: "Waiting" })).status).toBe(
      "unconfigured",
    );
    expect(f.events).toHaveLength(0);
    const draft = f.db.query("SELECT * FROM emails").get();
    expect(draft.folder).toBe("outbox");
    expect(draft.auto_send).toBe(1);
    f.state.configured = true;
    await f.model.sync();
    expect(
      f.db.query("SELECT * FROM emails WHERE id=?").get(draft.id).folder,
    ).toBe("sent");
    expect(f.events.filter((e) => e.name === "email-sent")).toHaveLength(1);
  } finally {
    f.db.close();
  }
});

test("IMAP inbox persists raw From and emits once across repeated fetches", async () => {
  const f = await fixture();
  try {
    await f.model.sync();
    await f.model.sync();
    const rows = f.db.query("SELECT * FROM emails").all();
    expect(rows).toHaveLength(1);
    expect(rows[0].from_addr).toBe("Alice Example <[address]>");
    expect(f.events).toEqual([
      {
        name: "email-received",
        data: {
          id: 1,
          from: "Alice Example <[address]>",
          subject: "Hello",
          body: "A real message",
          received_at: "2026-10-15T12:00:00Z",
        },
      },
    ]);
    expect(f.kv.get("imap_cursor")).toBe("account:42:7");
  } finally {
    f.db.close();
  }
});

test("event retry after SMTP success never resends the message", async () => {
  const f = await fixture();
  try {
    f.state.emitFailure = true;
    await expect(
      f.model.send({ to: "me", subject: "Only once" }, { hash: "event-1" }),
    ).rejects.toThrow("Event unavailable");
    expect(f.db.query("SELECT folder FROM emails").get().folder).toBe("sent");
    f.state.emitFailure = false;
    await f.model.send({ to: "me", subject: "Only once" }, { hash: "event-1" });
    expect(f.calls).toHaveLength(1);
    expect(f.events).toHaveLength(1);
  } finally {
    f.db.close();
  }
});

test("correcting a failed compose retries the same outbox draft with edited fields", async () => {
  const f = await fixture();
  try {
    f.state.failure = true;
    await expect(
      f.model.send(
        { to: "[address]", subject: "First" },
        { hash: "compose-1" },
      ),
    ).rejects.toThrow("SMTP");
    f.state.failure = false;
    await f.model.send(
      { to: "[address]", subject: "Corrected", body: "New text" },
      { hash: "compose-1" },
    );
    const rows = f.db.query("SELECT * FROM emails").all();
    expect(rows).toHaveLength(1);
    expect(rows[0].folder).toBe("sent");
    expect(f.calls[1].to).toBe("[address]");
    expect(f.events[0].data.subject).toBe("Corrected");
  } finally {
    f.db.close();
  }
});

test("starring persists before publishing, includes original message fields, and emits only on transitions", async () => {
  const f = await fixture();
  try {
    expect(ddl).toBe(readFileSync(new URL("./schema.sql", import.meta.url), "utf8"));
    const manifest = JSON.parse(readFileSync(new URL("./manifest.json", import.meta.url), "utf8"));
    const validate = new Ajv().compile(manifest.requested_permissions.events["email-starred"].schema);
    await f.model.sync();
    await Promise.all([f.model.setStarred(1, true), f.model.setStarred(1, true)]);
    let starred = f.events.filter(e => e.name === "email-starred");
    expect(starred).toHaveLength(1);
    expect(validate(starred[0].data)).toBe(true);
    expect(starred[0].data).toEqual({ id: 1, from: "Alice Example <[address]>",
      subject: "Hello", body: "A real message", received_at: "2026-10-15T12:00:00Z", folder: "inbox" });
    expect(f.db.query("SELECT starred FROM emails WHERE id=1").get().starred).toBe(1);
    await f.model.setStarred(1, false);
    await f.model.setStarred(999, true);
    expect(f.events.filter(e => e.name === "email-starred")).toHaveLength(1);
    await f.model.setStarred(1, true);
    expect(f.events.filter(e => e.name === "email-starred")).toHaveLength(2);
    await f.model.send({ to: "me", subject: "Sent message" });
    await f.model.setStarred(2, true);
    expect(f.events.at(-1).data.folder).toBe("sent");
    expect(validate(f.events.at(-1).data)).toBe(true);
  } finally { f.db.close(); }
});