diff --git a/src/chat/__tests__/email-login.test.ts b/src/chat/__tests__/email-login.test.ts index f3b343a..ad56a76 100644 --- a/src/chat/__tests__/email-login.test.ts +++ b/src/chat/__tests__/email-login.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { revokeAllSessions } from "../../ui/session.ts"; -import { clearRateLimits, handleEmailLogin, sanitizeLocalPart } from "../email-login.ts"; +import { clearRateLimits, handleEmailLogin, sanitizeLocalPart, sendLoginEmail } from "../email-login.ts"; import { escapeHtml } from "../util/escape.ts"; const originalEnv = { ...process.env }; @@ -31,7 +31,7 @@ function makeRequest(body: Record, ip = "127.0.0.1"): Request { describe("handleEmailLogin", () => { test("returns 200 with neutral response for valid email", async () => { const req = makeRequest({ email: "owner@example.com" }); - const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent"); + const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev"); expect(res.status).toBe(200); const data = (await res.json()) as { ok: boolean }; expect(data.ok).toBe(true); @@ -39,7 +39,7 @@ describe("handleEmailLogin", () => { test("returns 200 with neutral response for invalid email", async () => { const req = makeRequest({ email: "wrong@example.com" }); - const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent"); + const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev"); expect(res.status).toBe(200); const data = (await res.json()) as { ok: boolean }; expect(data.ok).toBe(true); @@ -48,7 +48,7 @@ describe("handleEmailLogin", () => { test("returns 200 with neutral response when OWNER_EMAIL is unset", async () => { process.env.OWNER_EMAIL = undefined; const req = makeRequest({ email: "someone@example.com" }); - const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent"); + const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev"); expect(res.status).toBe(200); const data = (await res.json()) as { ok: boolean }; expect(data.ok).toBe(true); @@ -56,7 +56,7 @@ describe("handleEmailLogin", () => { test("returns 200 for missing email field", async () => { const req = makeRequest({}); - const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent"); + const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev"); expect(res.status).toBe(200); }); @@ -69,31 +69,31 @@ describe("handleEmailLogin", () => { }, body: "not json", }); - const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent"); + const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev"); expect(res.status).toBe(200); }); test("rate limits to 1 per 60 seconds per IP", async () => { // First request succeeds (triggers rate limit regardless of match) const req1 = makeRequest({ email: "owner@example.com" }, "10.0.0.1"); - const res1 = await handleEmailLogin(req1, "http://localhost:6666", "test-agent"); + const res1 = await handleEmailLogin(req1, "http://localhost:6666", "test-agent", "test.dev"); expect(res1.status).toBe(200); // Second request from same IP within 60 seconds const req2 = makeRequest({ email: "owner@example.com" }, "10.0.0.1"); - const res2 = await handleEmailLogin(req2, "http://localhost:6666", "test-agent"); + const res2 = await handleEmailLogin(req2, "http://localhost:6666", "test-agent", "test.dev"); expect(res2.status).toBe(200); // Still returns ok (neutral), but it was rate-limited internally // Different IP is not rate-limited const req3 = makeRequest({ email: "owner@example.com" }, "10.0.0.2"); - const res3 = await handleEmailLogin(req3, "http://localhost:6666", "test-agent"); + const res3 = await handleEmailLogin(req3, "http://localhost:6666", "test-agent", "test.dev"); expect(res3.status).toBe(200); }); test("normalizes email comparison to lowercase", async () => { const req = makeRequest({ email: "OWNER@Example.COM" }); - const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent"); + const res = await handleEmailLogin(req, "http://localhost:6666", "test-agent", "test.dev"); expect(res.status).toBe(200); const data = (await res.json()) as { ok: boolean }; expect(data.ok).toBe(true); @@ -152,3 +152,38 @@ describe("escapeHtml", () => { expect(escapeHtml("my-agent")).toBe("my-agent"); }); }); + +describe("sendLoginEmail", () => { + test("throws when Resend API returns an error response", async () => { + mock.module("resend", () => ({ + Resend: class { + emails = { + send: async () => ({ error: { message: "domain not verified" } }), + }; + }, + })); + process.env.RESEND_API_KEY = "re_test_key"; + + await expect(sendLoginEmail("owner@example.com", "http://localhost/magic", "Phantom", "test.dev")).rejects.toThrow( + "Resend API error: domain not verified", + ); + }); + + test("uses domain parameter for the sender domain", async () => { + let capturedFrom = ""; + mock.module("resend", () => ({ + Resend: class { + emails = { + send: async (opts: { from: string }) => { + capturedFrom = opts.from; + return { data: { id: "ok" }, error: null }; + }, + }; + }, + })); + process.env.RESEND_API_KEY = "re_test_key"; + + await sendLoginEmail("owner@example.com", "http://localhost/magic", "Phantom", "mycompany.com"); + expect(capturedFrom).toContain("mycompany.com"); + }); +}); diff --git a/src/chat/email-login.ts b/src/chat/email-login.ts index 1761b42..5bc813e 100644 --- a/src/chat/email-login.ts +++ b/src/chat/email-login.ts @@ -34,7 +34,12 @@ export function clearRateLimits(): void { rateLimitMap.clear(); } -export async function handleEmailLogin(req: Request, publicUrl: string, agentName: string): Promise { +export async function handleEmailLogin( + req: Request, + publicUrl: string, + agentName: string, + domain: string, +): Promise { const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; // Rate limit check @@ -75,7 +80,7 @@ export async function handleEmailLogin(req: Request, publicUrl: string, agentNam const { magicToken } = createSession(); const magicUrl = `${publicUrl}/ui/login?magic=${encodeURIComponent(magicToken)}&redirect=%2Fchat`; - await sendLoginEmail(ownerEmail, magicUrl, agentName); + await sendLoginEmail(ownerEmail, magicUrl, agentName, domain); // Evict expired entries to prevent unbounded map growth if (rateLimitMap.size > 1000) { @@ -88,7 +93,12 @@ export async function handleEmailLogin(req: Request, publicUrl: string, agentNam return Response.json({ ok: true }); } -export async function sendLoginEmail(email: string, magicLink: string, agentName: string): Promise { +export async function sendLoginEmail( + email: string, + magicLink: string, + agentName: string, + domain: string, +): Promise { const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { console.log(`[email-login] No RESEND_API_KEY set. Magic link for ${email}:`); @@ -99,7 +109,6 @@ export async function sendLoginEmail(email: string, magicLink: string, agentName try { const { Resend } = await import("resend"); const resend = new Resend(apiKey); - const domain = process.env.PHANTOM_EMAIL_DOMAIN ?? "ghostwright.dev"; const safeDisplayName = sanitizeHeader(agentName); const safeLocalPart = sanitizeLocalPart(agentName); const safeHtmlName = escapeHtml(agentName); @@ -112,7 +121,7 @@ export async function sendLoginEmail(email: string, magicLink: string, agentName

If you did not request this, you can ignore this email.

`.trim(); - await resend.emails.send({ + const { error } = await resend.emails.send({ from, to: [email], subject: `${safeDisplayName} - Login link`, @@ -120,6 +129,10 @@ export async function sendLoginEmail(email: string, magicLink: string, agentName text: `Sign in to ${safeDisplayName}: ${magicLink}\n\nThis link expires in 10 minutes and can be used once.`, }); + if (error) { + throw new Error(`Resend API error: ${error.message}`); + } + console.log(`[email-login] Sent login email to ${email}`); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/chat/first-run.ts b/src/chat/first-run.ts index 16c75de..0599a9e 100644 --- a/src/chat/first-run.ts +++ b/src/chat/first-run.ts @@ -24,7 +24,10 @@ function ensureFirstRunRow(db: Database): void { db.run("INSERT OR IGNORE INTO first_run_state (id) VALUES (1)"); } -export async function handleFirstRun(db: Database, config: { name: string; public_url?: string }): Promise { +export async function handleFirstRun( + db: Database, + config: { name: string; public_url?: string; domain?: string }, +): Promise { const ownerEmail = process.env.OWNER_EMAIL; if (!ownerEmail) { console.warn("[first-run] No OWNER_EMAIL set and no Slack configured."); @@ -53,7 +56,7 @@ export async function handleFirstRun(db: Database, config: { name: string; publi db.run("UPDATE first_run_state SET bootstrap_magic_hash = ? WHERE id = 1", [hash]); try { - await sendLoginEmail(ownerEmail, magicUrl, config.name); + await sendLoginEmail(ownerEmail, magicUrl, config.name, config.domain ?? "ghostwright.dev"); db.run("UPDATE first_run_state SET email_sent_at = datetime('now') WHERE id = 1"); console.log(`[first-run] Login email sent to ${ownerEmail}`); } catch (err: unknown) { diff --git a/src/core/server.ts b/src/core/server.ts index c99d474..bf813b8 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -176,7 +176,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp if (url.pathname === "/login/email" && req.method === "POST") { const publicUrl = config.public_url ?? `http://localhost:${config.port}`; - return handleEmailLogin(req, publicUrl, config.name); + return handleEmailLogin(req, publicUrl, config.name, config.domain ?? "ghostwright.dev"); } // Public PWA/SW-scoped mirror of the operator avatar. Service