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(); }
});